@acms-subir/core 0.2.0 → 0.3.1
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/CHANGELOG.md +87 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -369
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/live-video.md +91 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +189 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +578 -49
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +418 -46
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +119 -58
- package/src/components/LiveCard.astro +174 -0
- package/src/components/LiveManager.tsx +910 -0
- package/src/components/NavManager.tsx +42 -43
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +58 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/WidgetRenderer.astro +3 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +605 -223
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/slash.tsx +7 -1
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +352 -59
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +56 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +110 -12
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +60 -6
- package/src/lib/dynamicBlocks.ts +40 -8
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/live/client.ts +88 -0
- package/src/lib/live/crypto.ts +28 -0
- package/src/lib/live/migration.ts +15 -0
- package/src/lib/live/providers.ts +152 -0
- package/src/lib/live/service.ts +170 -0
- package/src/lib/live/signals.ts +89 -0
- package/src/lib/live/store.ts +49 -0
- package/src/lib/live/types.ts +52 -0
- package/src/lib/live/view.ts +78 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +221 -0
- package/src/lib/repo/sql.ts +907 -68
- package/src/lib/repo/types.ts +125 -5
- package/src/lib/scheduler.ts +27 -0
- package/src/lib/schema.ts +316 -1
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +183 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhook-sql.ts +68 -0
- package/src/lib/webhooks.ts +147 -0
- package/src/lib/widgets.ts +1 -0
- package/src/middleware.ts +45 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +14 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +4 -1
- package/src/pages/admin/c/[collection]/index.astro +127 -92
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +4 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +10 -3
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +232 -85
- package/src/pages/admin/integrations.astro +237 -0
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/live.astro +12 -0
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +4 -1
- package/src/pages/admin/pages/index.astro +41 -48
- package/src/pages/admin/settings.astro +418 -367
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +66 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
- package/src/pages/api/live/settings.ts +45 -0
- package/src/pages/api/live/status.ts +7 -0
- package/src/pages/api/live/thumb.ts +48 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/api/webhooks/docs.ts +61 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +137 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +12 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +29 -3
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +27 -6
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +924 -105
- package/src/styles/cards.css +92 -74
- package/src/styles/editor.css +492 -114
- package/src/styles/email-settings.css +117 -0
- package/src/styles/live-admin.css +126 -0
- package/src/styles/live.css +199 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -281
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +81 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +55 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +27 -37
- package/src/themes/editorial/theme.json +25 -3
- package/src/themes/field/theme.json +27 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +28 -10
- package/src/virtual.d.ts +14 -0
- package/src/worker.ts +5 -1
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
// AI & agent settings: two self-contained cards.
|
|
4
|
+
//
|
|
5
|
+
// 1. AI provider — OpenAI or a custom OpenAI-compatible API. The API
|
|
6
|
+
// key is write-only: the server never returns it, the field starts empty,
|
|
7
|
+
// and nothing is kept in browser storage. A blank key keeps the stored one
|
|
8
|
+
// only while provider + URL stay the same — a new destination needs the key
|
|
9
|
+
// typed again so a secret is never silently sent somewhere new.
|
|
10
|
+
// 2. Agent access — a separate hashed bearer key for /api/agent. The plain
|
|
11
|
+
// token comes back exactly once (after generating) and lives only in state
|
|
12
|
+
// until dismissed.
|
|
13
|
+
//
|
|
14
|
+
// Styles are scoped under .ais so settings.astro's own cards are untouched.
|
|
15
|
+
|
|
16
|
+
type Provider = 'openai' | 'custom';
|
|
17
|
+
|
|
18
|
+
interface PublicSettings {
|
|
19
|
+
ai: { enabled: boolean; provider: Provider; baseUrl: string; model: string; hasApiKey: boolean };
|
|
20
|
+
agent: { enabled: boolean; hasKey: boolean; keyPrefix: string; allowPublish: boolean };
|
|
21
|
+
agentUrl: string;
|
|
22
|
+
docsUrl: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const OPENAI_URL = 'https://api.openai.com/v1';
|
|
26
|
+
|
|
27
|
+
const PROVIDERS: { id: Provider; name: string }[] = [
|
|
28
|
+
{ id: 'openai', name: 'OpenAI' },
|
|
29
|
+
{ id: 'custom', name: 'Custom' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const URL_PLACEHOLDER: Record<Provider, string> = {
|
|
33
|
+
openai: OPENAI_URL,
|
|
34
|
+
custom: 'https://api.example.com/v1',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
async function api<T>(url: string, body?: unknown): Promise<T> {
|
|
38
|
+
const res = await fetch(url, {
|
|
39
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
40
|
+
credentials: 'same-origin',
|
|
41
|
+
headers: body === undefined || body === null ? undefined : { 'content-type': 'application/json' },
|
|
42
|
+
body: body === undefined || body === null ? undefined : JSON.stringify(body),
|
|
43
|
+
});
|
|
44
|
+
const data = await res.json().catch(() => null);
|
|
45
|
+
if (!res.ok) throw new Error(data?.error || data?.message || `${res.status} ${res.statusText || 'Request failed'}`);
|
|
46
|
+
if (!data) throw new Error('The server sent an empty response.');
|
|
47
|
+
return data as T;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
|
51
|
+
|
|
52
|
+
// Match server normalization: trim trailing slashes, preserve the API path.
|
|
53
|
+
const sameUrl = (a: string, b: string) => {
|
|
54
|
+
const n = (s: string) => { try { const u = new URL(s.trim()); return u.origin + u.pathname.replace(/\/+$/, '') + u.search + u.hash; } catch { return s.trim(); } };
|
|
55
|
+
return n(a) === n(b);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// only link to same-site paths or http(s) URLs from the server response
|
|
59
|
+
const safeHref = (u: string) => (u.startsWith('/') && !u.startsWith('//')) || /^https?:\/\//i.test(u) ? u : '';
|
|
60
|
+
|
|
61
|
+
type Busy = 'save' | 'test' | 'generate' | 'revoke' | 'saveAgent';
|
|
62
|
+
|
|
63
|
+
export default function AiSettings() {
|
|
64
|
+
const uid = useId();
|
|
65
|
+
const [data, setData] = useState<PublicSettings | null>(null);
|
|
66
|
+
const [loadError, setLoadError] = useState('');
|
|
67
|
+
|
|
68
|
+
// provider form
|
|
69
|
+
const [enabled, setEnabled] = useState(false);
|
|
70
|
+
const [provider, setProvider] = useState<Provider>('openai');
|
|
71
|
+
const [baseUrl, setBaseUrl] = useState('');
|
|
72
|
+
const [model, setModel] = useState('');
|
|
73
|
+
const [apiKey, setApiKey] = useState('');
|
|
74
|
+
const [clearApiKey, setClearApiKey] = useState(false);
|
|
75
|
+
const [models, setModels] = useState<string[]>([]);
|
|
76
|
+
|
|
77
|
+
// agent form
|
|
78
|
+
const [agentEnabled, setAgentEnabled] = useState(false);
|
|
79
|
+
const [allowPublish, setAllowPublish] = useState(false);
|
|
80
|
+
const [token, setToken] = useState('');
|
|
81
|
+
const [copied, setCopied] = useState('');
|
|
82
|
+
|
|
83
|
+
// one pending action at a time per card; the ref blocks double-clicks that
|
|
84
|
+
// land before React re-renders the disabled button
|
|
85
|
+
const [busy, setBusy] = useState<Busy | null>(null);
|
|
86
|
+
const busyRef = useRef<Busy | null>(null);
|
|
87
|
+
const [saveMsg, setSaveMsg] = useState({ ok: '', err: '' });
|
|
88
|
+
const [testMsg, setTestMsg] = useState({ ok: '', err: '' });
|
|
89
|
+
const [agentMsg, setAgentMsg] = useState({ ok: '', err: '' });
|
|
90
|
+
const tokenRef = useRef<HTMLInputElement>(null);
|
|
91
|
+
|
|
92
|
+
function adopt(s: PublicSettings, part: 'all' | 'ai' | 'agent' = 'all') {
|
|
93
|
+
setData(s);
|
|
94
|
+
if (part !== 'agent') {
|
|
95
|
+
setEnabled(s.ai.enabled);
|
|
96
|
+
setProvider(s.ai.provider);
|
|
97
|
+
setBaseUrl(s.ai.provider === 'openai' ? OPENAI_URL : s.ai.baseUrl);
|
|
98
|
+
setModel(s.ai.model);
|
|
99
|
+
setApiKey('');
|
|
100
|
+
setClearApiKey(false);
|
|
101
|
+
}
|
|
102
|
+
if (part !== 'ai') {
|
|
103
|
+
setAgentEnabled(s.agent.enabled);
|
|
104
|
+
setAllowPublish(s.agent.allowPublish);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function load() {
|
|
109
|
+
setLoadError('');
|
|
110
|
+
try {
|
|
111
|
+
adopt(await api<PublicSettings>('/api/ai/settings'));
|
|
112
|
+
} catch (e) {
|
|
113
|
+
setLoadError(errText(e));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
useEffect(() => { void load(); }, []);
|
|
118
|
+
|
|
119
|
+
async function run<T>(kind: Busy, fn: () => Promise<T>): Promise<T | undefined> {
|
|
120
|
+
if (busyRef.current) return undefined;
|
|
121
|
+
busyRef.current = kind;
|
|
122
|
+
setBusy(kind);
|
|
123
|
+
try {
|
|
124
|
+
return await fn();
|
|
125
|
+
} finally {
|
|
126
|
+
busyRef.current = null;
|
|
127
|
+
setBusy(null);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!data) {
|
|
132
|
+
return (
|
|
133
|
+
<div className="ais">
|
|
134
|
+
<style>{CSS}</style>
|
|
135
|
+
{loadError ? (
|
|
136
|
+
<div className="flash error" role="alert">
|
|
137
|
+
Couldn’t load AI settings: {loadError}{' '}
|
|
138
|
+
<button type="button" className="btn sm" onClick={() => void load()}>Retry</button>
|
|
139
|
+
</div>
|
|
140
|
+
) : (
|
|
141
|
+
<p className="hint" role="status">Loading AI settings…</p>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const effectiveUrl = provider === 'openai' ? OPENAI_URL : baseUrl.trim();
|
|
148
|
+
const savedUrl = data.ai.provider === 'openai' ? OPENAI_URL : data.ai.baseUrl;
|
|
149
|
+
const destinationChanged = provider !== data.ai.provider || !sameUrl(effectiveUrl, savedUrl);
|
|
150
|
+
const keyWillDrop = data.ai.hasApiKey && destinationChanged && !apiKey.trim() && !clearApiKey;
|
|
151
|
+
const formDirty =
|
|
152
|
+
enabled !== data.ai.enabled || destinationChanged || model.trim() !== data.ai.model || !!apiKey || clearApiKey;
|
|
153
|
+
const agentDirty = agentEnabled !== data.agent.enabled || allowPublish !== data.agent.allowPublish;
|
|
154
|
+
const isCustom = provider !== 'openai';
|
|
155
|
+
|
|
156
|
+
async function save() {
|
|
157
|
+
setSaveMsg({ ok: '', err: '' });
|
|
158
|
+
if (isCustom && !effectiveUrl) return setSaveMsg({ ok: '', err: 'Enter the API base URL first.' });
|
|
159
|
+
if (isCustom && !/^https?:\/\//i.test(effectiveUrl)) {
|
|
160
|
+
return setSaveMsg({ ok: '', err: 'The API URL must start with http:// or https://' });
|
|
161
|
+
}
|
|
162
|
+
if (enabled && !model.trim()) return setSaveMsg({ ok: '', err: 'Choose a model before turning AI on.' });
|
|
163
|
+
await run('save', async () => {
|
|
164
|
+
try {
|
|
165
|
+
const next = await api<PublicSettings>('/api/ai/settings', {
|
|
166
|
+
action: 'save',
|
|
167
|
+
enabled,
|
|
168
|
+
provider,
|
|
169
|
+
baseUrl: effectiveUrl,
|
|
170
|
+
model: model.trim(),
|
|
171
|
+
apiKey: clearApiKey ? '' : apiKey.trim(),
|
|
172
|
+
clearApiKey,
|
|
173
|
+
});
|
|
174
|
+
adopt(next, 'ai');
|
|
175
|
+
setSaveMsg({
|
|
176
|
+
ok: next.ai.hasApiKey ? 'AI settings saved.' : 'AI settings saved — no API key is stored for this destination.',
|
|
177
|
+
err: '',
|
|
178
|
+
});
|
|
179
|
+
} catch (e) {
|
|
180
|
+
setSaveMsg({ ok: '', err: errText(e) });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function test() {
|
|
186
|
+
setTestMsg({ ok: '', err: '' });
|
|
187
|
+
await run('test', async () => {
|
|
188
|
+
try {
|
|
189
|
+
const r = await api<{ models: string[]; baseUrl: string }>('/api/ai/test', null);
|
|
190
|
+
const list = Array.isArray(r.models) ? r.models.filter((m) => typeof m === 'string') : [];
|
|
191
|
+
setModels(list);
|
|
192
|
+
setTestMsg({
|
|
193
|
+
ok: list.length
|
|
194
|
+
? `Reached ${r.baseUrl} — ${list.length} model${list.length === 1 ? '' : 's'} available. Pick one from the Model field.`
|
|
195
|
+
: `Reached ${r.baseUrl}, but it listed no models. You can still type a model ID.`,
|
|
196
|
+
err: '',
|
|
197
|
+
});
|
|
198
|
+
} catch (e) {
|
|
199
|
+
setModels([]);
|
|
200
|
+
setTestMsg({ ok: '', err: errText(e) });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function generateKey() {
|
|
206
|
+
if (data!.agent.hasKey && !confirm('Generate a new agent key? The current key stops working immediately.')) return;
|
|
207
|
+
setAgentMsg({ ok: '', err: '' });
|
|
208
|
+
setCopied('');
|
|
209
|
+
await run('generate', async () => {
|
|
210
|
+
try {
|
|
211
|
+
const r = await api<PublicSettings & { token: string }>('/api/ai/settings', { action: 'generateAgentKey' });
|
|
212
|
+
const { token: t, ...rest } = r;
|
|
213
|
+
adopt(rest, 'agent');
|
|
214
|
+
setToken(t);
|
|
215
|
+
setAgentMsg({ ok: 'New agent key created. Copy it now — it won’t be shown again.', err: '' });
|
|
216
|
+
requestAnimationFrame(() => tokenRef.current?.select());
|
|
217
|
+
} catch (e) {
|
|
218
|
+
setAgentMsg({ ok: '', err: errText(e) });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function revokeKey() {
|
|
224
|
+
if (!confirm('Revoke the agent key? Anything using it gets 401 errors straight away.')) return;
|
|
225
|
+
setAgentMsg({ ok: '', err: '' });
|
|
226
|
+
await run('revoke', async () => {
|
|
227
|
+
try {
|
|
228
|
+
adopt(await api<PublicSettings>('/api/ai/settings', { action: 'revokeAgentKey' }), 'agent');
|
|
229
|
+
setToken('');
|
|
230
|
+
setAgentMsg({ ok: 'Agent key revoked. Agent access is off.', err: '' });
|
|
231
|
+
} catch (e) {
|
|
232
|
+
setAgentMsg({ ok: '', err: errText(e) });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function saveAgent() {
|
|
238
|
+
setAgentMsg({ ok: '', err: '' });
|
|
239
|
+
await run('saveAgent', async () => {
|
|
240
|
+
try {
|
|
241
|
+
adopt(await api<PublicSettings>('/api/ai/settings', { action: 'saveAgent', enabled: agentEnabled, allowPublish }), 'agent');
|
|
242
|
+
setAgentMsg({ ok: 'Agent access saved.', err: '' });
|
|
243
|
+
} catch (e) {
|
|
244
|
+
setAgentMsg({ ok: '', err: errText(e) });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function copy(text: string, what: string) {
|
|
250
|
+
try {
|
|
251
|
+
await navigator.clipboard.writeText(text);
|
|
252
|
+
setCopied(`${what} copied.`);
|
|
253
|
+
} catch {
|
|
254
|
+
setCopied('Copy failed. Select the text and copy it by hand.');
|
|
255
|
+
}
|
|
256
|
+
setTimeout(() => setCopied(''), 2500);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const agentUrl = data.agentUrl;
|
|
260
|
+
const docsHref = safeHref(data.docsUrl);
|
|
261
|
+
const curlDiscover = `curl "${agentUrl}" \\\n -H "Authorization: Bearer $AGENT_KEY"`;
|
|
262
|
+
const curlCall =
|
|
263
|
+
`curl -X POST "${agentUrl}" \\\n -H "Authorization: Bearer $AGENT_KEY" \\\n -H "Content-Type: application/json" \\\n` +
|
|
264
|
+
` -d '{"tool":"list_content","arguments":{"kind":"posts"}}'`;
|
|
265
|
+
const id = (s: string) => `${uid}-${s}`;
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<div className="ais">
|
|
269
|
+
<style>{CSS}</style>
|
|
270
|
+
|
|
271
|
+
{/* ---- provider ---- */}
|
|
272
|
+
<section className="ais-card" aria-labelledby={id('ai-h')}>
|
|
273
|
+
<header className="ais-head">
|
|
274
|
+
<div className="ais-grow">
|
|
275
|
+
<h2 id={id('ai-h')}>Document cleanup</h2>
|
|
276
|
+
<p>Clean uploaded documents into posts or pages. Preserve the original language unless translation is explicitly requested. Review before creating a draft.</p>
|
|
277
|
+
</div>
|
|
278
|
+
<span className={`pill ${data.ai.enabled ? 'published' : 'archived'}`}>{data.ai.enabled ? 'On' : 'Off'}</span>
|
|
279
|
+
</header>
|
|
280
|
+
|
|
281
|
+
<label className="check-row">
|
|
282
|
+
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
|
283
|
+
<span>Turn on the document cleaner</span>
|
|
284
|
+
</label>
|
|
285
|
+
|
|
286
|
+
<fieldset className="ais-providers">
|
|
287
|
+
<legend>Provider</legend>
|
|
288
|
+
{PROVIDERS.map((p) => (
|
|
289
|
+
<label key={p.id} className={`ais-provider ${provider === p.id ? 'on' : ''}`}>
|
|
290
|
+
<input
|
|
291
|
+
type="radio"
|
|
292
|
+
name={id('provider')}
|
|
293
|
+
value={p.id}
|
|
294
|
+
checked={provider === p.id}
|
|
295
|
+
onChange={() => {
|
|
296
|
+
setProvider(p.id);
|
|
297
|
+
setApiKey('');
|
|
298
|
+
setClearApiKey(false);
|
|
299
|
+
setModel(p.id === data.ai.provider ? data.ai.model : '');
|
|
300
|
+
setBaseUrl(p.id === 'openai' ? OPENAI_URL : p.id === data.ai.provider ? data.ai.baseUrl : '');
|
|
301
|
+
setModels([]);
|
|
302
|
+
setTestMsg({ ok: '', err: '' });
|
|
303
|
+
}}
|
|
304
|
+
/>
|
|
305
|
+
<span>
|
|
306
|
+
<b>{p.name}</b>
|
|
307
|
+
</span>
|
|
308
|
+
</label>
|
|
309
|
+
))}
|
|
310
|
+
</fieldset>
|
|
311
|
+
|
|
312
|
+
<div className="field-row">
|
|
313
|
+
<label className="field">
|
|
314
|
+
<span>API base URL</span>
|
|
315
|
+
<input
|
|
316
|
+
type="url"
|
|
317
|
+
className="mono"
|
|
318
|
+
inputMode="url"
|
|
319
|
+
value={effectiveUrl === OPENAI_URL && !isCustom ? OPENAI_URL : baseUrl}
|
|
320
|
+
readOnly={!isCustom}
|
|
321
|
+
aria-readonly={!isCustom}
|
|
322
|
+
placeholder={URL_PLACEHOLDER[provider]}
|
|
323
|
+
onChange={(e) => setBaseUrl(e.target.value)}
|
|
324
|
+
aria-describedby={id('url-hint')}
|
|
325
|
+
/>
|
|
326
|
+
<small className="hint" id={id('url-hint')}>
|
|
327
|
+
{isCustom ? <>Use your provider’s OpenAI-compatible base URL. Include <code>/v1</code> if your provider requires it.</> : 'Fixed for OpenAI.'}
|
|
328
|
+
</small>
|
|
329
|
+
</label>
|
|
330
|
+
<label className="field">
|
|
331
|
+
<span>Model</span>
|
|
332
|
+
<input
|
|
333
|
+
type="text"
|
|
334
|
+
className="mono"
|
|
335
|
+
list={id('models')}
|
|
336
|
+
value={model}
|
|
337
|
+
placeholder="Select a discovered model or enter its ID"
|
|
338
|
+
autoComplete="off"
|
|
339
|
+
spellCheck={false}
|
|
340
|
+
onChange={(e) => setModel(e.target.value)}
|
|
341
|
+
aria-describedby={id('model-hint')}
|
|
342
|
+
/>
|
|
343
|
+
<datalist id={id('models')}>
|
|
344
|
+
{models.map((m) => <option key={m} value={m} />)}
|
|
345
|
+
</datalist>
|
|
346
|
+
<small className="hint" id={id('model-hint')}>
|
|
347
|
+
{models.length ? `${models.length} suggestions from the last test. Use a model your provider supports.` : 'Type any model ID, or test the connection to get suggestions.'}
|
|
348
|
+
</small>
|
|
349
|
+
</label>
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
<label className="field">
|
|
353
|
+
<span>{isCustom ? 'API key' : 'OpenAI API key'}</span>
|
|
354
|
+
<input
|
|
355
|
+
type="password"
|
|
356
|
+
className="mono"
|
|
357
|
+
value={apiKey}
|
|
358
|
+
disabled={clearApiKey}
|
|
359
|
+
autoComplete="off"
|
|
360
|
+
spellCheck={false}
|
|
361
|
+
data-1p-ignore
|
|
362
|
+
data-lpignore="true"
|
|
363
|
+
placeholder={data.ai.hasApiKey && !destinationChanged ? 'Saved key kept — type to replace' : isCustom ? 'API key from your provider' : 'sk-…'}
|
|
364
|
+
onChange={(e) => setApiKey(e.target.value)}
|
|
365
|
+
aria-describedby={id('key-hint')}
|
|
366
|
+
/>
|
|
367
|
+
<small className="hint" id={id('key-hint')}>
|
|
368
|
+
{isCustom ? (
|
|
369
|
+
<>Use your provider’s API key. Leave empty if your endpoint does not require one. </>
|
|
370
|
+
) : (
|
|
371
|
+
<>Create one at platform.openai.com. </>
|
|
372
|
+
)}
|
|
373
|
+
The key is stored on the server and never shown again. Leave blank to keep the saved key.
|
|
374
|
+
</small>
|
|
375
|
+
</label>
|
|
376
|
+
|
|
377
|
+
{keyWillDrop && (
|
|
378
|
+
<div className="notice warn ais-notice" role="note">
|
|
379
|
+
You changed the provider or URL. The saved key is only kept for the address it was entered for — type the key again, or save without one.
|
|
380
|
+
</div>
|
|
381
|
+
)}
|
|
382
|
+
|
|
383
|
+
{data.ai.hasApiKey && (
|
|
384
|
+
<label className="check-row">
|
|
385
|
+
<input
|
|
386
|
+
type="checkbox"
|
|
387
|
+
checked={clearApiKey}
|
|
388
|
+
onChange={(e) => { setClearApiKey(e.target.checked); if (e.target.checked) setApiKey(''); }}
|
|
389
|
+
/>
|
|
390
|
+
<span>Remove the saved API key</span>
|
|
391
|
+
</label>
|
|
392
|
+
)}
|
|
393
|
+
|
|
394
|
+
<div className="ais-actions">
|
|
395
|
+
<button type="button" className="btn primary" onClick={() => void save()} disabled={busy !== null || !formDirty}>
|
|
396
|
+
{busy === 'save' ? 'Saving…' : 'Save'}
|
|
397
|
+
</button>
|
|
398
|
+
<button type="button" className="btn" onClick={() => void test()} disabled={busy !== null} aria-describedby={id('test-hint')}>
|
|
399
|
+
{busy === 'test' ? 'Testing…' : 'Test saved connection'}
|
|
400
|
+
</button>
|
|
401
|
+
<span className="hint" id={id('test-hint')}>
|
|
402
|
+
{formDirty ? 'Test uses the saved settings. Save first to test your changes.' : 'Works even while AI is off.'}
|
|
403
|
+
</span>
|
|
404
|
+
</div>
|
|
405
|
+
<div className="ais-status" aria-live="polite">
|
|
406
|
+
{saveMsg.ok && <p className="flash">{saveMsg.ok}</p>}
|
|
407
|
+
{testMsg.ok && <p className="flash">{testMsg.ok}</p>}
|
|
408
|
+
</div>
|
|
409
|
+
<div className="ais-status" role="alert">
|
|
410
|
+
{saveMsg.err && <p className="flash error">Save failed: {saveMsg.err}</p>}
|
|
411
|
+
{testMsg.err && <p className="flash error">Connection test failed: {testMsg.err}</p>}
|
|
412
|
+
</div>
|
|
413
|
+
</section>
|
|
414
|
+
|
|
415
|
+
{/* ---- agent ---- */}
|
|
416
|
+
<section className="ais-card" aria-labelledby={id('ag-h')}>
|
|
417
|
+
<header className="ais-head">
|
|
418
|
+
<div className="ais-grow">
|
|
419
|
+
<h2 id={id('ag-h')}>Agent access</h2>
|
|
420
|
+
<p>Let an AI agent (Claude, Codex, a script) work with your content through <code>/api/agent</code>, using its own key.</p>
|
|
421
|
+
</div>
|
|
422
|
+
<span className={`pill ${data.agent.enabled && data.agent.hasKey ? 'published' : 'archived'}`}>
|
|
423
|
+
{data.agent.enabled && data.agent.hasKey ? 'On' : 'Off'}
|
|
424
|
+
</span>
|
|
425
|
+
</header>
|
|
426
|
+
|
|
427
|
+
<ul className="ais-perms" aria-label="What the agent key can do">
|
|
428
|
+
<li className="yes">Read all posts and pages, including unpublished drafts</li>
|
|
429
|
+
<li className="yes">Create drafts and edit drafts of posts and pages</li>
|
|
430
|
+
<li className={allowPublish ? 'yes' : 'no'}>Publish — {allowPublish ? 'allowed' : 'off unless you turn it on below'}</li>
|
|
431
|
+
<li className="no">Never delete anything or change settings</li>
|
|
432
|
+
</ul>
|
|
433
|
+
|
|
434
|
+
{token && (
|
|
435
|
+
<div className="ais-token" role="group" aria-labelledby={id('tok-l')}>
|
|
436
|
+
<label className="field" htmlFor={id('tok')}>
|
|
437
|
+
<span id={id('tok-l')}>Your new agent key. It’s shown only once.</span>
|
|
438
|
+
</label>
|
|
439
|
+
<div className="ais-row">
|
|
440
|
+
<input
|
|
441
|
+
ref={tokenRef}
|
|
442
|
+
id={id('tok')}
|
|
443
|
+
type="text"
|
|
444
|
+
className="mono"
|
|
445
|
+
readOnly
|
|
446
|
+
value={token}
|
|
447
|
+
autoComplete="off"
|
|
448
|
+
spellCheck={false}
|
|
449
|
+
onFocus={(e) => e.target.select()}
|
|
450
|
+
/>
|
|
451
|
+
<button type="button" className="btn primary" onClick={() => void copy(token, 'Key')}>Copy</button>
|
|
452
|
+
<button type="button" className="btn ghost" onClick={() => { setToken(''); setCopied(''); }}>I’ve saved it</button>
|
|
453
|
+
</div>
|
|
454
|
+
<p className="hint">Only a hash is stored. If you lose this key, generate a new one.</p>
|
|
455
|
+
</div>
|
|
456
|
+
)}
|
|
457
|
+
|
|
458
|
+
<div className="ais-keyrow">
|
|
459
|
+
{data.agent.hasKey ? (
|
|
460
|
+
<code className="ais-key" aria-label="Current agent key (masked)">{data.agent.keyPrefix}{'•'.repeat(12)}</code>
|
|
461
|
+
) : (
|
|
462
|
+
<span className="hint">No agent key yet, so agents can’t connect.</span>
|
|
463
|
+
)}
|
|
464
|
+
<button type="button" className={`btn ${data.agent.hasKey ? '' : 'primary'}`} onClick={() => void generateKey()} disabled={busy !== null}>
|
|
465
|
+
{busy === 'generate' ? 'Generating…' : data.agent.hasKey ? 'Rotate key' : 'Generate key'}
|
|
466
|
+
</button>
|
|
467
|
+
{data.agent.hasKey && (
|
|
468
|
+
<button type="button" className="btn danger" onClick={() => void revokeKey()} disabled={busy !== null}>
|
|
469
|
+
{busy === 'revoke' ? 'Revoking…' : 'Revoke'}
|
|
470
|
+
</button>
|
|
471
|
+
)}
|
|
472
|
+
</div>
|
|
473
|
+
<p className="hint">Generating a key turns agent access on. Rotating revokes the old key immediately.</p>
|
|
474
|
+
|
|
475
|
+
<div className="ais-sub">
|
|
476
|
+
<label className="check-row">
|
|
477
|
+
<input
|
|
478
|
+
type="checkbox"
|
|
479
|
+
checked={agentEnabled}
|
|
480
|
+
disabled={!data.agent.hasKey}
|
|
481
|
+
onChange={(e) => setAgentEnabled(e.target.checked)}
|
|
482
|
+
/>
|
|
483
|
+
<span>Agent access on{!data.agent.hasKey && <span className="hint"> (generate a key first)</span>}</span>
|
|
484
|
+
</label>
|
|
485
|
+
<label className="check-row">
|
|
486
|
+
<input type="checkbox" checked={allowPublish} onChange={(e) => setAllowPublish(e.target.checked)} />
|
|
487
|
+
<span>Let the agent publish <span className="hint">(off by default, so drafts wait for you to review)</span></span>
|
|
488
|
+
</label>
|
|
489
|
+
<div className="ais-actions">
|
|
490
|
+
<button type="button" className="btn primary" onClick={() => void saveAgent()} disabled={busy !== null || !agentDirty}>
|
|
491
|
+
{busy === 'saveAgent' ? 'Saving…' : 'Save agent access'}
|
|
492
|
+
</button>
|
|
493
|
+
</div>
|
|
494
|
+
</div>
|
|
495
|
+
<div className="ais-status" aria-live="polite">
|
|
496
|
+
{agentMsg.ok && <p className="flash">{agentMsg.ok}</p>}
|
|
497
|
+
{copied && <p className="hint">{copied}</p>}
|
|
498
|
+
</div>
|
|
499
|
+
<div className="ais-status" role="alert">
|
|
500
|
+
{agentMsg.err && <p className="flash error">{agentMsg.err}</p>}
|
|
501
|
+
</div>
|
|
502
|
+
|
|
503
|
+
<div className="ais-sub">
|
|
504
|
+
<h3>Connect an agent</h3>
|
|
505
|
+
<p className="hint">For MCP clients, use <code>{new URL('/api/mcp', agentUrl).href}</code> with Streamable HTTP transport and the same bearer key. <button type="button" className="btn sm" onClick={() => void copy(new URL('/api/mcp', agentUrl).href, 'MCP URL')}>Copy MCP URL</button></p>
|
|
506
|
+
<div className="ais-row">
|
|
507
|
+
<code className="ais-key" aria-label="Agent API URL">{agentUrl}</code>
|
|
508
|
+
<button type="button" className="btn" onClick={() => void copy(agentUrl, 'URL')}>Copy URL</button>
|
|
509
|
+
{docsHref && <a className="btn ghost" href={docsHref} target="_blank" rel="noopener">API docs ↗</a>}
|
|
510
|
+
</div>
|
|
511
|
+
<p className="hint">
|
|
512
|
+
Send the key as <code>Authorization: Bearer …</code>. <code>GET</code> lists the tools and their schemas; <code>POST</code> calls one.
|
|
513
|
+
The examples read the key from an <code>AGENT_KEY</code> environment variable. Paste your key there, never into shared scripts.
|
|
514
|
+
</p>
|
|
515
|
+
<pre className="ais-code" aria-label="Discover tools with curl"><code>{curlDiscover}</code></pre>
|
|
516
|
+
<pre className="ais-code" aria-label="Call a tool with curl"><code>{curlCall}</code></pre>
|
|
517
|
+
<button type="button" className="btn sm" onClick={() => void copy(curlCall, 'Example')}>Copy example</button>
|
|
518
|
+
</div>
|
|
519
|
+
</section>
|
|
520
|
+
</div>
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const CSS = `
|
|
525
|
+
.ais { display: flex; flex-direction: column; gap: 12px; }
|
|
526
|
+
.ais code { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; }
|
|
527
|
+
.ais-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-lg); padding: 16px 18px; }
|
|
528
|
+
.ais-head { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 14px; }
|
|
529
|
+
.ais-grow { flex: 1; min-width: 0; }
|
|
530
|
+
.ais-head h2 { font-size: 14.5px; margin: 0; letter-spacing: -0.01em; }
|
|
531
|
+
.ais-head p { margin: 2px 0 0; color: var(--muted); font-size: 13px; }
|
|
532
|
+
.ais .hint { margin: 0; }
|
|
533
|
+
.ais .flash { margin: 10px 0 0; }
|
|
534
|
+
.ais-providers { border: 0; padding: 0; margin: 4px 0 14px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
|
535
|
+
.ais-providers legend { font-size: 12px; color: var(--muted); font-weight: 500; padding: 0; margin-bottom: 5px; }
|
|
536
|
+
.ais-provider { display: flex; gap: 9px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--line-strong); border-radius: var(--r-md); cursor: pointer; background: var(--panel); }
|
|
537
|
+
.ais-provider:hover { background: var(--hover); }
|
|
538
|
+
.ais-provider.on { background: var(--sel); border-color: var(--line-strong); }
|
|
539
|
+
.ais-provider:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); }
|
|
540
|
+
.ais-provider input { accent-color: var(--accent); margin: 3px 0 0; flex: 0 0 auto; }
|
|
541
|
+
.ais-provider b { display: block; font-size: 13px; }
|
|
542
|
+
.ais input[readonly] { background: var(--panel-2); color: var(--muted); }
|
|
543
|
+
.ais input:disabled { opacity: 0.55; }
|
|
544
|
+
.ais-notice { margin: 0 0 12px; font-size: 13px; }
|
|
545
|
+
.ais-note { background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r-md); padding: 10px 13px; margin: 0 0 12px; font-size: 13px; }
|
|
546
|
+
.ais-note p { margin: 3px 0 0; color: var(--text-2); }
|
|
547
|
+
.ais-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
|
|
548
|
+
.ais-status:empty { display: none; }
|
|
549
|
+
.ais-perms { list-style: none; margin: 0 0 14px; padding: 10px 13px; background: var(--panel-2); border-radius: var(--r-md); display: grid; gap: 4px; }
|
|
550
|
+
.ais-perms li { display: flex; gap: 8px; align-items: baseline; font-size: 13px; color: var(--text-2); }
|
|
551
|
+
.ais-perms li::before { flex: 0 0 auto; font-weight: 700; width: 12px; }
|
|
552
|
+
.ais-perms li.yes::before { content: '✓'; color: var(--green); }
|
|
553
|
+
.ais-perms li.no::before { content: '✕'; color: var(--muted); }
|
|
554
|
+
.ais-token { background: var(--accent-soft); border-radius: var(--r-md); padding: 12px 14px; margin-bottom: 12px; }
|
|
555
|
+
.ais-token .field { margin-bottom: 6px; }
|
|
556
|
+
.ais-token .field > span { color: var(--accent-deep); font-weight: 600; }
|
|
557
|
+
.ais-token .hint { margin-top: 6px; }
|
|
558
|
+
.ais-row, .ais-keyrow { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
559
|
+
.ais-row input { flex: 1; min-width: 200px; }
|
|
560
|
+
.ais-keyrow { margin-bottom: 6px; }
|
|
561
|
+
.ais-keyrow .hint { flex: 1; }
|
|
562
|
+
.ais-key { flex: 1; min-width: 200px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 7px; padding: 7px 10px; color: var(--muted); overflow-wrap: anywhere; }
|
|
563
|
+
.ais-sub { border-top: 1px solid var(--line); margin-top: 14px; padding-top: 12px; }
|
|
564
|
+
.ais-sub h3 { font-size: 13px; margin: 0 0 8px; }
|
|
565
|
+
.ais-sub > .hint { margin: 8px 0; }
|
|
566
|
+
.ais-code { background: var(--code-bg); color: var(--code-text); border-radius: var(--r-md); padding: 10px 12px; margin: 0 0 8px; overflow-x: auto; font-size: 12px; line-height: 1.55; }
|
|
567
|
+
.ais-code code { background: none; color: inherit; }
|
|
568
|
+
@media (max-width: 900px) {
|
|
569
|
+
.ais-providers { grid-template-columns: 1fr; }
|
|
570
|
+
}
|
|
571
|
+
@media (max-width: 767px) {
|
|
572
|
+
.ais-card { padding: 14px; }
|
|
573
|
+
.ais-actions .btn { flex: 1; justify-content: center; }
|
|
574
|
+
}
|
|
575
|
+
`;
|