ui: settings body — providers (APInex) config, local clientStorage persistence, backup/import

This commit is contained in:
Hermes Agent committed 2026-09-19 15:31:16 +02:00
1 parent 379b3d3370
commit 1468570d0f
2 files changed
+227 -1

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
// Local settings model + persistence (figma.clientStorage).
//
// No server auth yet — everything lives on the Figma file locally. Backup/restore
// is just a JSON export/import of this same document. (Server auth comes later.)
export type ProviderType = 'apinex'
export interface ProviderConfig {
id: string
name: string
type: ProviderType
baseUrl: string
apiKey: string
defaultModel: string
}
export interface Settings {
version: 1
auth: { mode: 'local' } // later: { mode: 'server', ... }
providers: ProviderConfig[]
activeProviderId: string | null
}
export const APINEX_BASE_URL = 'https://api.apinex.bond/v1'
const SETTINGS_KEY = 'settings'
export function defaultSettings (): Settings {
return {
version: 1,
auth: { mode: 'local' },
providers: [],
activeProviderId: null
}
}
function genId (): string {
return 'p_' + Math.random().toString(36).slice(2, 10)
}
export function createApinexProvider (): ProviderConfig {
return {
id: genId(),
name: 'APInex',
type: 'apinex',
baseUrl: APINEX_BASE_URL,
apiKey: '',
defaultModel: ''
}
}
export async function loadSettings (): Promise<Settings> {
try {
const stored = await figma.clientStorage.getAsync(SETTINGS_KEY)
if (stored == null || stored.version !== 1) return defaultSettings()
return stored as Settings
} catch (e) {
return defaultSettings()
}
}
export async function saveSettings (settings: Settings): Promise<void> {
try {
await figma.clientStorage.setAsync(SETTINGS_KEY, settings)
} catch (e) { /* storage unavailable — ignore */ }
}
export function settingsToJSON (settings: Settings): string {
return JSON.stringify(settings, null, 2)
}
export function settingsFromJSON (text: string): Settings {
const parsed = JSON.parse(text)
if (parsed == null || parsed.version !== 1) throw new Error('Unsupported settings version')
if (!Array.isArray(parsed.providers)) throw new Error('Invalid settings: providers must be an array')
return parsed as Settings
}
+151 -1
View File
@@ -5,6 +5,10 @@ import {
Muted,
VerticalSpace,
Stack,
Button,
Textbox,
Dropdown,
Checkbox,
Modal,
IconButton,
IconExpand24,
@@ -17,6 +21,7 @@ import { emit, on } from '@create-figma-plugin/utilities'
import { h, Fragment } from 'preact'
import { useState, useEffect, useRef } from 'preact/hooks'
import MenuItem from './MenuItem'
import { type Settings, type ProviderConfig, defaultSettings, loadSettings, saveSettings, createApinexProvider, settingsToJSON, settingsFromJSON, APINEX_BASE_URL } from './settings'
const MIN_WINDOW_SIZE = { width: 320, height: 200 }
// No static max: "maximize" targets the whole viewport (maxViewport from main),
@@ -26,6 +31,16 @@ const MIN_WINDOW_SIZE = { width: 320, height: 200 }
// Full plugin version shown in the footer status bar. Keep in sync with package.json "version".
const PLUGIN_VERSION = '0.1.0'
const fieldStyle = {
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-4)'
}
const labelStyle = {
fontSize: '11px',
color: 'var(--figma-color-text-secondary)'
}
// Full-bleed separator: Container has no horizontal padding, so the <hr> (flex item,
// stretched to full width) spans the whole window edge-to-edge.
function Separator () {
@@ -40,8 +55,141 @@ function Separator () {
)
}
function SettingsPanel (props: { settings: Settings, setSettings: (s: Settings) => void }) {
const { settings, setSettings } = props
const [showKey, setShowKey] = useState(false)
const [savedNote, setSavedNote] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const active = settings.providers.find(function (p) { return p.id === settings.activeProviderId }) || null
function patchActive (patch: Partial<ProviderConfig>) {
if (active == null) return
setSettings({
...settings,
providers: settings.providers.map(function (p) {
return p.id === active.id ? { ...p, ...patch } : p
})
})
}
function addProvider () {
const p = createApinexProvider()
setSettings({ ...settings, providers: [...settings.providers, p], activeProviderId: p.id })
}
function deleteActive () {
if (active == null) return
const providers = settings.providers.filter(function (p) { return p.id !== active.id })
const activeProviderId = providers.length > 0 ? providers[0].id : null
setSettings({ ...settings, providers, activeProviderId })
}
function save () {
saveSettings(settings).then(function () {
setSavedNote(true)
setTimeout(function () { setSavedNote(false) }, 1500)
})
}
function exportSettings () {
const json = settingsToJSON(settings)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'figma-ai-plugin-settings.json'
a.click()
URL.revokeObjectURL(url)
}
function onImportFile (e: Event) {
const input = e.target as HTMLInputElement
const file = input.files != null ? input.files[0] : null
if (file == null) return
const reader = new FileReader()
reader.onload = function () {
try {
const s = settingsFromJSON(String(reader.result))
setSettings(s)
saveSettings(s)
} catch (err) {
// TODO: surface parse error to the user
}
}
reader.readAsText(file)
input.value = ''
}
const providerOptions = settings.providers.map(function (p) {
return { value: p.id, text: p.name }
})
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-16)' }}>
<section>
<Text style={{ fontWeight: 600, fontSize: '12px' }}>Providers</Text>
<div style={{ display: 'flex', gap: 'var(--space-8)', marginTop: 'var(--space-8)' }}>
<Dropdown
placeholder="Select provider"
value={settings.activeProviderId}
onValueChange={function (id: string) { setSettings({ ...settings, activeProviderId: id }) }}
options={providerOptions}
/>
<Button onClick={addProvider}>Add APInex</Button>
<Button danger onClick={deleteActive} disabled={active == null}>Delete</Button>
</div>
</section>
{active != null && (
<section style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-8)' }}>
<Text style={{ fontWeight: 600, fontSize: '12px' }}>{active.name} · {active.type}</Text>
<label style={fieldStyle}>
<span style={labelStyle}>Name</span>
<Textbox value={active.name} placeholder="My APInex" onValueInput={function (v: string) { patchActive({ name: v }) }} />
</label>
<label style={fieldStyle}>
<span style={labelStyle}>API key</span>
<Textbox password={!showKey} value={active.apiKey} placeholder="sk-apx…" onValueInput={function (v: string) { patchActive({ apiKey: v }) }} />
</label>
<Checkbox value={showKey} onValueChange={setShowKey}>Show key</Checkbox>
<label style={fieldStyle}>
<span style={labelStyle}>Base URL</span>
<Textbox value={active.baseUrl} placeholder={APINEX_BASE_URL} onValueInput={function (v: string) { patchActive({ baseUrl: v }) }} />
</label>
<label style={fieldStyle}>
<span style={labelStyle}>Default model</span>
<Textbox value={active.defaultModel} placeholder="gpt-5-6-terra" onValueInput={function (v: string) { patchActive({ defaultModel: v }) }} />
</label>
</section>
)}
<div style={{ display: 'flex', gap: 'var(--space-8)', alignItems: 'center' }}>
<Button onClick={save}>Save</Button>
{savedNote && <Muted>Saved</Muted>}
</div>
<Separator />
<section style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-8)' }}>
<Text style={{ fontWeight: 600, fontSize: '12px' }}>Backup &amp; restore</Text>
<div style={{ display: 'flex', gap: 'var(--space-8)' }}>
<Button onClick={exportSettings}>Export settings</Button>
<Button onClick={function () { if (fileInputRef.current != null) fileInputRef.current.click() }}>Import settings</Button>
<input ref={fileInputRef} type="file" accept="application/json" style={{ display: 'none' }} onChange={onImportFile} />
</div>
</section>
</div>
)
}
function Plugin () {
const [currentWindowSize, setCurrentWindowSize] = useState(MIN_WINDOW_SIZE)
const [settings, setSettings] = useState<Settings>(defaultSettings())
const [loaded, setLoaded] = useState(false)
useEffect(function () {
loadSettings().then(function (s) { setSettings(s); setLoaded(true) })
}, [])
const resizeRef = useRef({ active: false, dir: '', startX: 0, startY: 0, startW: 0, startH: 0, fixedAxis: 'none' as 'none' | 'x' | 'y' })
// Current dock position (for "window is edge-docked" detection in UI). Center and
// corners are NOT written here — they are not considered docked (window just lies there).
@@ -358,7 +506,9 @@ function Plugin () {
<Separator />
{/* Body — semantic <main>, stretches via flex child of the column */}
<main style={{ flex: '1 1 auto', overflow: 'auto', padding: 'var(--space-8)' }}>
{/* empty placeholder for now */}
{loaded
? <SettingsPanel settings={settings} setSettings={setSettings} />
: <Muted>Loading…</Muted>}
</main>
{/* Footer — status bar */}
<Separator />