feat: add privacy mode, thinking levels, PWA support, and i18n
- Add privacy mode to hide IP and user preferences from AI requests - Add model thinking levels (low/medium/high) for context analysis depth - Add PWA support with service worker, manifest, and app icons - Add SettingsPanel for user preferences (theme, background, language) - Add i18n translations for en/zh/ja/ko/de/fr - Add Pinia store for centralized settings management - Update backend to support user preferences and thinking levels - Update config to use absolute API URLs
This commit is contained in:
@@ -6,8 +6,8 @@
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn"
|
||||
aria-label="导入 Markdown 文件"
|
||||
title="导入 Markdown"
|
||||
:aria-label="t('importMd')"
|
||||
:title="t('importMd')"
|
||||
@click="triggerUpload"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -15,15 +15,15 @@
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
<span class="btn-tooltip">导入 Markdown</span>
|
||||
<span class="btn-tooltip">{{ t('importMd') }}</span>
|
||||
</button>
|
||||
<input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md" style="display:none">
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn"
|
||||
aria-label="导出 Markdown 文件"
|
||||
title="导出 Markdown"
|
||||
:aria-label="t('exportMd')"
|
||||
:title="t('exportMd')"
|
||||
@click="exportMarkdown"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -31,15 +31,15 @@
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
<span class="btn-tooltip">导出 Markdown</span>
|
||||
<span class="btn-tooltip">{{ t('exportMd') }}</span>
|
||||
</button>
|
||||
|
||||
<div class="image-btn-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn"
|
||||
aria-label="上传图片"
|
||||
title="上传图片"
|
||||
:aria-label="t('uploadImg')"
|
||||
:title="t('uploadImg')"
|
||||
@click="toggleImageDropdown"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -47,11 +47,11 @@
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
<span class="btn-tooltip">上传图片</span>
|
||||
<span class="btn-tooltip">{{ t('uploadImg') }}</span>
|
||||
</button>
|
||||
<div v-if="showImageDropdown" class="image-dropdown">
|
||||
<button type="button" @click="triggerImageUpload">上传本地图片</button>
|
||||
<button type="button" @click="showUrlDialog = true; showImageDropdown = false">通过 URL 插入</button>
|
||||
<button type="button" @click="triggerImageUpload">{{ t('uploadImg') }}</button>
|
||||
<button type="button" @click="showUrlDialog = true; showImageDropdown = false">{{ t('insertUrl') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" ref="imageInputRef" @change="handleImageUpload" accept="image/*" style="display:none">
|
||||
@@ -84,16 +84,16 @@
|
||||
|
||||
<div v-if="showUrlDialog" class="url-dialog-overlay" @click.self="showUrlDialog = false">
|
||||
<div class="url-dialog">
|
||||
<h3>通过 URL 插入图片</h3>
|
||||
<h3>{{ t('insertUrl') }}</h3>
|
||||
<input
|
||||
v-model="imageUrl"
|
||||
type="url"
|
||||
placeholder="请输入图片 URL"
|
||||
placeholder="https://..."
|
||||
@keyup.enter="insertImageFromUrl"
|
||||
/>
|
||||
<div class="url-dialog-buttons">
|
||||
<button type="button" class="dialog-btn primary" @click="insertImageFromUrl">插入</button>
|
||||
<button type="button" class="dialog-btn" @click="showUrlDialog = false; imageUrl = ''">取消</button>
|
||||
<button type="button" class="dialog-btn primary" @click="insertImageFromUrl">{{ t('insert') }}</button>
|
||||
<button type="button" class="dialog-btn" @click="showUrlDialog = false; imageUrl = ''">{{ t('cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,17 +101,20 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
|
||||
import { replaceAll } from '@milkdown/kit/utils'
|
||||
import { Crepe } from '@milkdown/crepe'
|
||||
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
|
||||
import { Selection } from '@milkdown/prose/state'
|
||||
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
|
||||
import { fetchSuggestion } from '../utils/api.js'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { OCR_URL } from '../utils/config.js'
|
||||
import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.js'
|
||||
|
||||
const emit = defineEmits(['update:markdown'])
|
||||
const settings = useSettingsStore()
|
||||
const t = (key) => settings.t[key]
|
||||
|
||||
const root = ref(null)
|
||||
const fileInputRef = ref(null)
|
||||
@@ -124,8 +127,8 @@ const imageUrl = ref('')
|
||||
const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
|
||||
const sizeInKB = computed(() => Math.floor(contentSize.value / 1024))
|
||||
const aiButtonLabel = computed(() => {
|
||||
if (isOverLimit.value) return '文档过大,AI已禁用'
|
||||
return aiEnabled.value ? '禁用 AI' : '启用 AI'
|
||||
if (isOverLimit.value) return t('docTooLarge')
|
||||
return aiEnabled.value ? t('disableAI') : t('enableAI')
|
||||
})
|
||||
|
||||
let crepe = null
|
||||
@@ -302,10 +305,24 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const settings = useSettingsStore()
|
||||
|
||||
crepe.editor.config((ctx) => {
|
||||
ctx.set(copilotConfigCtx.key, {
|
||||
fetchSuggestion,
|
||||
debounceMs: 1000
|
||||
debounceMs: settings.debounceMs
|
||||
})
|
||||
})
|
||||
|
||||
// Watch for debounce changes
|
||||
watch(() => settings.debounceMs, (newVal) => {
|
||||
if (!crepe) return
|
||||
crepe.editor.action((ctx) => {
|
||||
const config = ctx.get(copilotConfigCtx.key)
|
||||
ctx.set(copilotConfigCtx.key, {
|
||||
...config,
|
||||
debounceMs: newVal
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -691,7 +708,7 @@ onUnmounted(() => {
|
||||
.milkdown-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--crepe-color-background);
|
||||
background-color: transparent !important;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
490
src/components/SettingsPanel.vue
Normal file
490
src/components/SettingsPanel.vue
Normal file
@@ -0,0 +1,490 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
|
||||
const store = useSettingsStore()
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const togglePanel = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
const closePanel = () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
// Theme Handling
|
||||
watch(() => store.theme, (newVal) => {
|
||||
if (newVal === 'system') {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
setTheme(isDark ? 'dark' : 'light')
|
||||
} else {
|
||||
setTheme(newVal)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Background Image Handling
|
||||
const handleImageUpload = (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
store.backgroundImage = e.target.result
|
||||
store.backgroundType = 'image'
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to translate
|
||||
const t = (key) => store.t[key]
|
||||
|
||||
// Background Style for App (This will be used in App.vue, but we preview it here or just logical check)
|
||||
// UI Helpers
|
||||
const tabs = ['General', 'Model', 'Appearance', 'About']
|
||||
const currentTab = ref('General')
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Trigger Button -->
|
||||
<button
|
||||
class="settings-trigger"
|
||||
@click="togglePanel"
|
||||
:title="t('settings')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Overlay (Mobile) -->
|
||||
<div
|
||||
class="settings-overlay"
|
||||
:class="{ 'is-open': isOpen }"
|
||||
@click="closePanel"
|
||||
></div>
|
||||
|
||||
<!-- Panel -->
|
||||
<div class="settings-panel" :class="{ 'is-open': isOpen }">
|
||||
<div class="panel-header">
|
||||
<h2>{{ t('settings') }}</h2>
|
||||
<button class="close-btn" @click="closePanel" :title="t('close')">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
<!-- Appearance Section -->
|
||||
<section class="settings-section">
|
||||
<h3>{{ t('appearance') }}</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t('theme') }}</label>
|
||||
<div class="segment-control">
|
||||
<button :class="{ active: store.theme === 'light' }" @click="store.theme = 'light'">{{ t('light') }}</button>
|
||||
<button :class="{ active: store.theme === 'dark' }" @click="store.theme = 'dark'">{{ t('dark') }}</button>
|
||||
<button :class="{ active: store.theme === 'system' }" @click="store.theme = 'system'">{{ t('system') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t('background') }}</label>
|
||||
<select v-model="store.backgroundType" class="select-input">
|
||||
<option value="default">{{ t('default') }}</option>
|
||||
<option value="warm">{{ t('warm') }}</option>
|
||||
<option value="reading">{{ t('reading') }}</option>
|
||||
<option value="image">{{ t('image') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="store.backgroundType === 'image'" class="form-group">
|
||||
<label>{{ t('image') }}</label>
|
||||
<input type="file" accept="image/*" @change="handleImageUpload" class="file-input" />
|
||||
|
||||
<label class="sub-label">{{ t('opacity') }}: {{ Math.round(store.backgroundOpacity * 100) }}%</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="0.5"
|
||||
step="0.05"
|
||||
v-model.number="store.backgroundOpacity"
|
||||
class="range-slider"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Model Section -->
|
||||
<section class="settings-section">
|
||||
<h3>{{ t('modelIntelligence') }}</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t('thinkingLevel') }}</label>
|
||||
<div class="segment-control">
|
||||
<button :class="{ active: store.modelThinking === 'low' }" @click="store.modelThinking = 'low'">{{ t('low') }}</button>
|
||||
<button :class="{ active: store.modelThinking === 'medium' }" @click="store.modelThinking = 'medium'">{{ t('medium') }}</button>
|
||||
<button :class="{ active: store.modelThinking === 'high' }" @click="store.modelThinking = 'high'">{{ t('high') }}</button>
|
||||
</div>
|
||||
<p class="help-text">{{ store.modelThinking === 'low' ? t('lowDesc') : store.modelThinking === 'medium' ? t('mediumDesc') : t('highDesc') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t('debounceTime') }}: {{ store.debounceMs }}ms</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1000"
|
||||
max="5000"
|
||||
step="500"
|
||||
v-model.number="store.debounceMs"
|
||||
class="range-slider"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Privacy & Preferences -->
|
||||
<section class="settings-section">
|
||||
<h3>{{ t('privacyPreferences') }}</h3>
|
||||
|
||||
<div class="form-group toggle-group">
|
||||
<label>{{ t('privacyMode') }}</label>
|
||||
<button
|
||||
class="toggle-switch"
|
||||
:class="{ active: store.privacyMode }"
|
||||
@click="store.privacyMode = !store.privacyMode"
|
||||
>
|
||||
<span class="toggle-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="help-text">{{ t('privacyDesc') }}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t('language') }}</label>
|
||||
<select v-model="store.language" class="select-input" :disabled="store.privacyMode">
|
||||
<option value="auto">{{ t('auto') }}</option>
|
||||
<option value="zh">Chinese</option>
|
||||
<option value="en">English</option>
|
||||
<option value="ja">Japanese</option>
|
||||
<option value="ko">Korean</option>
|
||||
<option value="de">German</option>
|
||||
<option value="fr">French</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t('currency') }}</label>
|
||||
<select v-model="store.currency" class="select-input" :disabled="store.privacyMode">
|
||||
<option value="auto">{{ t('auto') }}</option>
|
||||
<option value="CNY">CNY (¥)</option>
|
||||
<option value="USD">USD ($)</option>
|
||||
<option value="EUR">EUR (€)</option>
|
||||
<option value="JPY">JPY (¥)</option>
|
||||
<option value="KRW">KRW (₩)</option>
|
||||
<option value="GBP">GBP (£)</option>
|
||||
<option value="AUD">AUD ($)</option>
|
||||
<option value="CAD">CAD ($)</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- About -->
|
||||
<section class="settings-section">
|
||||
<h3>{{ t('about') }}</h3>
|
||||
<div class="about-card">
|
||||
<h4>llm-in-text</h4>
|
||||
<p>A smart Markdown editor with local LLM intelligence.</p>
|
||||
<p class="version">v0.1.0-beta</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-trigger {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--btn-bg);
|
||||
color: var(--btn-fg);
|
||||
border: 1px solid var(--panel-border);
|
||||
box-shadow: var(--panel-shadow);
|
||||
cursor: pointer;
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.settings-trigger:hover {
|
||||
background: var(--btn-hover-bg);
|
||||
color: var(--btn-hover-fg);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 350px;
|
||||
background: var(--panel-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-right: 1px solid var(--panel-border);
|
||||
box-shadow: var(--panel-shadow);
|
||||
z-index: 10000;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-panel.is-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.settings-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.2);
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.settings-overlay.is-open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Mobile Fullscreen */
|
||||
@media (max-width: 640px) {
|
||||
.settings-panel {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted-text);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--app-text);
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-text);
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: var(--app-text);
|
||||
}
|
||||
|
||||
.sub-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted-text);
|
||||
margin-top: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted-text);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* Segment Control */
|
||||
.segment-control {
|
||||
display: flex;
|
||||
background: var(--ghost-code-bg);
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.segment-control button {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
color: var(--muted-text);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.segment-control button.active {
|
||||
background: var(--app-bg);
|
||||
color: var(--app-text);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Range Slider */
|
||||
.range-slider {
|
||||
width: 100%;
|
||||
-webkit-appearance: none;
|
||||
height: 4px;
|
||||
background: var(--panel-border);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.range-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--focus-ring);
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.range-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.select-input, .file-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--panel-border);
|
||||
background: var(--app-bg);
|
||||
color: var(--app-text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.select-input:focus {
|
||||
border-color: var(--focus-ring);
|
||||
}
|
||||
|
||||
.color-picker-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toggle-group label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: var(--panel-border);
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.toggle-switch.active {
|
||||
background: var(--focus-ring);
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.toggle-switch.active .toggle-thumb {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* About Card */
|
||||
.about-card {
|
||||
background: var(--ghost-code-bg);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.about-card h4 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.about-card p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.about-card .version {
|
||||
margin-top: 8px;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user