feat(prefs): display.currency preference with live exchange rate conversion
- Backend: validate display.currency against 10 supported ISO 4217 codes (USD, GBP, EUR, CAD, AUD, JPY, CHF, MXN, BRL, INR); return 400 on unsupported code with a clear message listing accepted values - Frontend: useCurrency composable fetches rates from open.er-api.com with 1-hour module-level cache and in-flight deduplication; falls back to USD display on network failure - Preferences store: adds display.currency with localStorage fallback for anonymous users and localStorage-to-DB migration for newly logged-in users - ListingCard: price and market price now convert from USD using live rates, showing USD synchronously while rates load then updating reactively - Settings UI: currency selector dropdown in Appearance section using theme-aware CSS classes; available to all users (anon via localStorage, logged-in via DB preference) - Tests: 6 Python tests for the PATCH /api/preferences currency endpoint (including ordering-safe fixture using patch.object on _LOCAL_SNIPE_DB); 14 Vitest tests for convertFromUSD, formatPrice, and formatPriceUSD
This commit is contained in:
parent
d5912080fb
commit
dca3c3f50b
6 changed files with 435 additions and 11 deletions
76
tests/test_preferences_currency.py
Normal file
76
tests/test_preferences_currency.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Tests for PATCH /api/preferences display.currency validation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
"""TestClient with a patched local DB path.
|
||||
|
||||
api.cloud_session._LOCAL_SNIPE_DB is set at module import time, so we
|
||||
cannot rely on setting SNIPE_DB before import when other tests have already
|
||||
triggered the module load. Patch the module-level variable directly so
|
||||
the session dependency points at our fresh tmp DB for the duration of this
|
||||
fixture.
|
||||
"""
|
||||
db_path = tmp_path / "snipe.db"
|
||||
# Ensure the DB is initialised so the Store can create its tables.
|
||||
import api.cloud_session as _cs
|
||||
from circuitforge_core.db import get_connection, run_migrations
|
||||
conn = get_connection(db_path)
|
||||
run_migrations(conn, Path("app/db/migrations"))
|
||||
conn.close()
|
||||
|
||||
from api.main import app
|
||||
with patch.object(_cs, "_LOCAL_SNIPE_DB", db_path):
|
||||
yield TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_set_display_currency_valid(client):
|
||||
"""Accepted ISO 4217 codes are stored and returned."""
|
||||
for code in ("USD", "GBP", "EUR", "CAD", "AUD", "JPY", "CHF", "MXN", "BRL", "INR"):
|
||||
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": code})
|
||||
assert resp.status_code == 200, f"Expected 200 for {code}, got {resp.status_code}: {resp.text}"
|
||||
data = resp.json()
|
||||
assert data.get("display", {}).get("currency") == code
|
||||
|
||||
|
||||
def test_set_display_currency_normalises_lowercase(client):
|
||||
"""Lowercase code is accepted and normalised to uppercase."""
|
||||
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": "eur"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["display"]["currency"] == "EUR"
|
||||
|
||||
|
||||
def test_set_display_currency_unsupported_returns_400(client):
|
||||
"""Unsupported currency code returns 400 with a clear message."""
|
||||
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": "XYZ"})
|
||||
assert resp.status_code == 400
|
||||
detail = resp.json().get("detail", "")
|
||||
assert "XYZ" in detail
|
||||
assert "Supported" in detail or "supported" in detail
|
||||
|
||||
|
||||
def test_set_display_currency_empty_string_returns_400(client):
|
||||
"""Empty string is not a valid currency code."""
|
||||
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": ""})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_set_display_currency_none_returns_400(client):
|
||||
"""None is not a valid currency code."""
|
||||
resp = client.patch("/api/preferences", json={"path": "display.currency", "value": None})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_other_preference_paths_unaffected(client):
|
||||
"""Unrelated preference paths still work normally after currency validation added."""
|
||||
resp = client.patch("/api/preferences", json={"path": "affiliate.opt_out", "value": True})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get("affiliate", {}).get("opt_out") is True
|
||||
140
web/src/__tests__/useCurrency.test.ts
Normal file
140
web/src/__tests__/useCurrency.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Reset module-level cache and fetch mock between tests
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
// Reset module-level cache so each test starts clean
|
||||
const mod = await import('../composables/useCurrency')
|
||||
mod._resetCacheForTest()
|
||||
})
|
||||
|
||||
const MOCK_RATES: Record<string, number> = {
|
||||
USD: 1,
|
||||
GBP: 0.79,
|
||||
EUR: 0.92,
|
||||
JPY: 151.5,
|
||||
CAD: 1.36,
|
||||
}
|
||||
|
||||
function mockFetchSuccess(rates = MOCK_RATES) {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ rates }),
|
||||
}))
|
||||
}
|
||||
|
||||
function mockFetchFailure() {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')))
|
||||
}
|
||||
|
||||
describe('convertFromUSD', () => {
|
||||
it('returns the same amount for USD (no conversion)', async () => {
|
||||
mockFetchSuccess()
|
||||
const { convertFromUSD } = await import('../composables/useCurrency')
|
||||
const result = await convertFromUSD(100, 'USD')
|
||||
expect(result).toBe(100)
|
||||
// fetch should not be called for USD passthrough
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('converts USD to GBP using fetched rates', async () => {
|
||||
mockFetchSuccess()
|
||||
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
const result = await convertFromUSD(100, 'GBP')
|
||||
expect(result).toBeCloseTo(79, 1)
|
||||
})
|
||||
|
||||
it('converts USD to JPY using fetched rates', async () => {
|
||||
mockFetchSuccess()
|
||||
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
const result = await convertFromUSD(10, 'JPY')
|
||||
expect(result).toBeCloseTo(1515, 1)
|
||||
})
|
||||
|
||||
it('returns the original amount when rates are unavailable (network failure)', async () => {
|
||||
mockFetchFailure()
|
||||
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
const result = await convertFromUSD(100, 'EUR')
|
||||
expect(result).toBe(100)
|
||||
})
|
||||
|
||||
it('returns the original amount when the currency code is unknown', async () => {
|
||||
mockFetchSuccess({ USD: 1, EUR: 0.92 }) // no XYZ rate
|
||||
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
const result = await convertFromUSD(50, 'XYZ')
|
||||
expect(result).toBe(50)
|
||||
})
|
||||
|
||||
it('only calls fetch once when called concurrently (deduplication)', async () => {
|
||||
mockFetchSuccess()
|
||||
const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
await Promise.all([
|
||||
convertFromUSD(100, 'GBP'),
|
||||
convertFromUSD(200, 'EUR'),
|
||||
convertFromUSD(50, 'CAD'),
|
||||
])
|
||||
expect((fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatPrice', () => {
|
||||
it('formats USD amount with dollar sign', async () => {
|
||||
mockFetchSuccess()
|
||||
const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
const result = await formatPrice(99.99, 'USD')
|
||||
expect(result).toMatch(/^\$99\.99$|^\$100$/) // Intl rounding may vary
|
||||
expect(result).toContain('$')
|
||||
})
|
||||
|
||||
it('formats GBP amount with correct symbol', async () => {
|
||||
mockFetchSuccess()
|
||||
const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
const result = await formatPrice(100, 'GBP')
|
||||
// GBP 79 — expect pound sign or "GBP" prefix
|
||||
expect(result).toMatch(/[£]|GBP/)
|
||||
})
|
||||
|
||||
it('formats JPY without decimal places (Intl rounds to zero decimals)', async () => {
|
||||
mockFetchSuccess()
|
||||
const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
const result = await formatPrice(10, 'JPY')
|
||||
// 10 * 151.5 = 1515 JPY — no decimal places for JPY
|
||||
expect(result).toMatch(/¥1,515|JPY.*1,515|¥1515/)
|
||||
})
|
||||
|
||||
it('falls back gracefully on network failure, showing USD', async () => {
|
||||
mockFetchFailure()
|
||||
const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency')
|
||||
_resetCacheForTest()
|
||||
// With failed rates, conversion returns original amount and uses Intl with target currency
|
||||
// This may throw if Intl doesn't know EUR — but the function should not throw
|
||||
const result = await formatPrice(50, 'EUR')
|
||||
expect(typeof result).toBe('string')
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatPriceUSD', () => {
|
||||
it('formats a USD amount synchronously', async () => {
|
||||
const { formatPriceUSD } = await import('../composables/useCurrency')
|
||||
const result = formatPriceUSD(1234.5)
|
||||
// Intl output varies by runtime locale data; check structure not exact string
|
||||
expect(result).toContain('$')
|
||||
expect(result).toContain('1,234')
|
||||
})
|
||||
|
||||
it('formats zero as a USD string', async () => {
|
||||
const { formatPriceUSD } = await import('../composables/useCurrency')
|
||||
const result = formatPriceUSD(0)
|
||||
expect(result).toContain('$')
|
||||
expect(result).toMatch(/\$0/)
|
||||
})
|
||||
})
|
||||
|
|
@ -189,15 +189,18 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { Listing, TrustScore, Seller } from '../stores/search'
|
||||
import { useSearchStore } from '../stores/search'
|
||||
import { useBlocklistStore } from '../stores/blocklist'
|
||||
import TrustFeedbackButtons from './TrustFeedbackButtons.vue'
|
||||
import { useTrustSignalPref } from '../composables/useTrustSignalPref'
|
||||
import { formatPrice, formatPriceUSD } from '../composables/useCurrency'
|
||||
import { usePreferencesStore } from '../stores/preferences'
|
||||
|
||||
const { enabled: trustSignalEnabled } = useTrustSignalPref()
|
||||
const prefsStore = usePreferencesStore()
|
||||
|
||||
const props = defineProps<{
|
||||
listing: Listing
|
||||
|
|
@ -379,15 +382,26 @@ const isSteal = computed(() => {
|
|||
return props.listing.price < props.marketPrice * 0.8
|
||||
})
|
||||
|
||||
const formattedPrice = computed(() => {
|
||||
const sym = props.listing.currency === 'USD' ? '$' : props.listing.currency + ' '
|
||||
return `${sym}${props.listing.price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
|
||||
})
|
||||
// Async price display — show USD synchronously while rates load, then update
|
||||
const formattedPrice = ref(formatPriceUSD(props.listing.price))
|
||||
const formattedMarket = ref(props.marketPrice ? formatPriceUSD(props.marketPrice) : '')
|
||||
|
||||
const formattedMarket = computed(() => {
|
||||
if (!props.marketPrice) return ''
|
||||
return `$${props.marketPrice.toLocaleString('en-US', { maximumFractionDigits: 0 })}`
|
||||
})
|
||||
async function _updatePrices() {
|
||||
const currency = prefsStore.displayCurrency
|
||||
formattedPrice.value = await formatPrice(props.listing.price, currency)
|
||||
if (props.marketPrice) {
|
||||
formattedMarket.value = await formatPrice(props.marketPrice, currency)
|
||||
} else {
|
||||
formattedMarket.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Update when the listing, marketPrice, or display currency changes
|
||||
watch(
|
||||
[() => props.listing.price, () => props.marketPrice, () => prefsStore.displayCurrency],
|
||||
() => { _updatePrices() },
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
102
web/src/composables/useCurrency.ts
Normal file
102
web/src/composables/useCurrency.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* useCurrency — live exchange rate conversion from USD to a target display currency.
|
||||
*
|
||||
* Rates are fetched lazily on first use from open.er-api.com (free, no key required).
|
||||
* A module-level cache with a 1-hour TTL prevents redundant network calls.
|
||||
* On fetch failure the composable falls back silently to USD display.
|
||||
*/
|
||||
|
||||
const ER_API_URL = 'https://open.er-api.com/v6/latest/USD'
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour
|
||||
|
||||
interface RateCache {
|
||||
rates: Record<string, number>
|
||||
fetchedAt: number
|
||||
}
|
||||
|
||||
// Module-level cache shared across all composable instances
|
||||
let _cache: RateCache | null = null
|
||||
let _inflight: Promise<Record<string, number>> | null = null
|
||||
|
||||
async function _fetchRates(): Promise<Record<string, number>> {
|
||||
const now = Date.now()
|
||||
|
||||
if (_cache && now - _cache.fetchedAt < CACHE_TTL_MS) {
|
||||
return _cache.rates
|
||||
}
|
||||
|
||||
// Deduplicate concurrent calls — reuse the same in-flight fetch
|
||||
if (_inflight) {
|
||||
return _inflight
|
||||
}
|
||||
|
||||
_inflight = (async () => {
|
||||
try {
|
||||
const res = await fetch(ER_API_URL)
|
||||
if (!res.ok) throw new Error(`ER-API responded ${res.status}`)
|
||||
const data = await res.json()
|
||||
const rates: Record<string, number> = data.rates ?? {}
|
||||
_cache = { rates, fetchedAt: Date.now() }
|
||||
return rates
|
||||
} catch {
|
||||
// Return cached stale data if available, otherwise empty object (USD passthrough)
|
||||
return _cache?.rates ?? {}
|
||||
} finally {
|
||||
_inflight = null
|
||||
}
|
||||
})()
|
||||
|
||||
return _inflight
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an amount in USD to the target currency using the latest exchange rates.
|
||||
* Returns the original amount unchanged if rates are unavailable or the currency is USD.
|
||||
*/
|
||||
export async function convertFromUSD(amountUSD: number, targetCurrency: string): Promise<number> {
|
||||
if (targetCurrency === 'USD') return amountUSD
|
||||
const rates = await _fetchRates()
|
||||
const rate = rates[targetCurrency]
|
||||
if (!rate) return amountUSD
|
||||
return amountUSD * rate
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a USD amount as a localized string in the target currency.
|
||||
* Fetches exchange rates lazily. Falls back to USD display if rates are unavailable.
|
||||
*
|
||||
* Returns a plain USD string synchronously on first call while rates load;
|
||||
* callers should use a ref that updates once the promise resolves.
|
||||
*/
|
||||
export async function formatPrice(amountUSD: number, currency: string): Promise<string> {
|
||||
const converted = await convertFromUSD(amountUSD, currency)
|
||||
try {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(converted)
|
||||
} catch {
|
||||
// Fallback if Intl doesn't know the currency code
|
||||
return `${currency} ${converted.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous USD-only formatter for use before rates have loaded.
|
||||
*/
|
||||
export function formatPriceUSD(amountUSD: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amountUSD)
|
||||
}
|
||||
|
||||
// Exported for testing — allows resetting module-level cache between test cases
|
||||
export function _resetCacheForTest(): void {
|
||||
_cache = null
|
||||
_inflight = null
|
||||
}
|
||||
|
|
@ -12,8 +12,14 @@ export interface UserPreferences {
|
|||
community?: {
|
||||
blocklist_share?: boolean
|
||||
}
|
||||
display?: {
|
||||
currency?: string
|
||||
}
|
||||
}
|
||||
|
||||
const CURRENCY_LS_KEY = 'snipe:currency'
|
||||
const DEFAULT_CURRENCY = 'USD'
|
||||
|
||||
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
|
||||
|
||||
export const usePreferencesStore = defineStore('preferences', () => {
|
||||
|
|
@ -26,14 +32,34 @@ export const usePreferencesStore = defineStore('preferences', () => {
|
|||
const affiliateByokId = computed(() => prefs.value.affiliate?.byok_ids?.ebay ?? '')
|
||||
const communityBlocklistShare = computed(() => prefs.value.community?.blocklist_share ?? false)
|
||||
|
||||
// displayCurrency: DB preference for logged-in users, localStorage for anon users
|
||||
const displayCurrency = computed((): string => {
|
||||
return prefs.value.display?.currency ?? DEFAULT_CURRENCY
|
||||
})
|
||||
|
||||
async function load() {
|
||||
if (!session.isLoggedIn) return
|
||||
if (!session.isLoggedIn) {
|
||||
// Anonymous user: read currency from localStorage
|
||||
const stored = localStorage.getItem(CURRENCY_LS_KEY)
|
||||
if (stored) {
|
||||
prefs.value = { ...prefs.value, display: { ...prefs.value.display, currency: stored } }
|
||||
}
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/preferences`)
|
||||
if (res.ok) {
|
||||
prefs.value = await res.json()
|
||||
const data: UserPreferences = await res.json()
|
||||
// Migration: if logged in but no DB preference, fall back to localStorage value
|
||||
if (!data.display?.currency) {
|
||||
const lsVal = localStorage.getItem(CURRENCY_LS_KEY)
|
||||
if (lsVal) {
|
||||
data.display = { ...data.display, currency: lsVal }
|
||||
}
|
||||
}
|
||||
prefs.value = data
|
||||
}
|
||||
} catch {
|
||||
// Non-cloud deploy or network error — preferences unavailable
|
||||
|
|
@ -75,6 +101,18 @@ export const usePreferencesStore = defineStore('preferences', () => {
|
|||
await setPref('community.blocklist_share', value)
|
||||
}
|
||||
|
||||
async function setDisplayCurrency(code: string) {
|
||||
const upper = code.toUpperCase()
|
||||
// Optimistic local update so the UI reacts immediately
|
||||
prefs.value = { ...prefs.value, display: { ...prefs.value.display, currency: upper } }
|
||||
if (session.isLoggedIn) {
|
||||
await setPref('display.currency', upper)
|
||||
} else {
|
||||
// Anonymous user: persist to localStorage only
|
||||
localStorage.setItem(CURRENCY_LS_KEY, upper)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
prefs,
|
||||
loading,
|
||||
|
|
@ -82,9 +120,11 @@ export const usePreferencesStore = defineStore('preferences', () => {
|
|||
affiliateOptOut,
|
||||
affiliateByokId,
|
||||
communityBlocklistShare,
|
||||
displayCurrency,
|
||||
load,
|
||||
setAffiliateOptOut,
|
||||
setAffiliateByokId,
|
||||
setCommunityBlocklistShare,
|
||||
setDisplayCurrency,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -69,6 +69,28 @@
|
|||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display currency -->
|
||||
<div class="settings-toggle">
|
||||
<div class="settings-toggle-text">
|
||||
<span class="settings-toggle-label">Display currency</span>
|
||||
<span class="settings-toggle-desc">
|
||||
Listing prices are converted from USD using live exchange rates.
|
||||
Rates update hourly.
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
id="display-currency"
|
||||
class="settings-select"
|
||||
:value="prefs.displayCurrency"
|
||||
aria-label="Select display currency"
|
||||
@change="prefs.setDisplayCurrency(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="opt in currencyOptions" :key="opt.code" :value="opt.code">
|
||||
{{ opt.code }} — {{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Affiliate Links — only shown to signed-in cloud users -->
|
||||
|
|
@ -166,6 +188,18 @@ const themeOptions: { value: 'system' | 'dark' | 'light'; label: string }[] = [
|
|||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
]
|
||||
const currencyOptions: { code: string; label: string }[] = [
|
||||
{ code: 'USD', label: 'US Dollar' },
|
||||
{ code: 'EUR', label: 'Euro' },
|
||||
{ code: 'GBP', label: 'British Pound' },
|
||||
{ code: 'CAD', label: 'Canadian Dollar' },
|
||||
{ code: 'AUD', label: 'Australian Dollar' },
|
||||
{ code: 'JPY', label: 'Japanese Yen' },
|
||||
{ code: 'CHF', label: 'Swiss Franc' },
|
||||
{ code: 'MXN', label: 'Mexican Peso' },
|
||||
{ code: 'BRL', label: 'Brazilian Real' },
|
||||
{ code: 'INR', label: 'Indian Rupee' },
|
||||
]
|
||||
const session = useSessionStore()
|
||||
const prefs = usePreferencesStore()
|
||||
const { autoRun: llmAutoRun, setAutoRun: setLLMAutoRun } = useLLMQueryBuilder()
|
||||
|
|
@ -346,6 +380,24 @@ function saveByokId() {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-select {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.settings-select:focus {
|
||||
border-color: var(--app-primary);
|
||||
}
|
||||
|
||||
.theme-btn-group {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
|
|
|
|||
Loading…
Reference in a new issue