ui: settings body — providers (APInex) config, local clientStorage persistence, backup/import
This commit is contained in:
1 parent
379b3d3370
commit
1468570d0f
2 files changed
+227
-1
No files matched your search
@@ -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
@@ -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 & 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 />
|
||||
|
||||
Reference in new issue
Block a user