From 1468570d0f362d8ee7910c3b519e13080147c819 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 19 Sep 2026 15:31:16 +0200 Subject: [PATCH] =?UTF-8?q?ui:=20settings=20body=20=E2=80=94=20providers?= =?UTF-8?q?=20(APInex)=20config,=20local=20clientStorage=20persistence,=20?= =?UTF-8?q?backup/import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/settings.ts | 76 ++++++++++++++++++++++++ src/ui.tsx | 152 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 src/settings.ts diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..4dd2e74 --- /dev/null +++ b/src/settings.ts @@ -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 { + 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 { + 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 +} diff --git a/src/ui.tsx b/src/ui.tsx index 4e3ab2d..894ef8e 100644 --- a/src/ui.tsx +++ b/src/ui.tsx @@ -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
(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(null) + + const active = settings.providers.find(function (p) { return p.id === settings.activeProviderId }) || null + + function patchActive (patch: Partial) { + 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 ( +
+
+ Providers +
+ + + +
+
+ + {active != null && ( +
+ {active.name} · {active.type} + + + Show key + + +
+ )} + +
+ + {savedNote && Saved} +
+ + + +
+ Backup & restore +
+ + + +
+
+
+ ) +} + function Plugin () { const [currentWindowSize, setCurrentWindowSize] = useState(MIN_WINDOW_SIZE) + const [settings, setSettings] = useState(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 () { {/* Body — semantic
, stretches via flex child of the column */}
- {/* empty placeholder for now */} + {loaded + ? + : Loading…}
{/* Footer — status bar */}