feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,816 @@
<script setup lang="ts">
import { PlugIcon, SpinnerIcon, TrashIcon } from '@modrinth/assets'
import {
Combobox,
defineMessages,
injectNotificationManager,
LOCALES,
NewButton as Button,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { type AIProviderDefinition, getAICatalog, getAIState, sharedAIState } from '@/helpers/ai'
import {
clearTranslationCache,
getGoogleIpPoolSize,
getTranslationErrorKind,
getTranslationSettings,
testTranslationProvider,
type TranslationProvider,
type TranslationSettings as TranslationSettingsState,
type TranslationStyle,
updateTranslationSettings,
} from '@/helpers/translation'
import AIIcon from './AIIcon.vue'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const settings = ref<TranslationSettingsState>({
provider: 'google',
target_language: '',
mode: 'bilingual',
auto_translate: false,
style: 'weakened',
ai_provider_id: '',
ai_model_id: '',
ai_system_prompt: '',
deepl_api_endpoint: 'https://api-free.deepl.com/v2/translate',
deepl_api_key: null,
})
// Debug logging helper
function debugLog(area: string, message: string, data?: unknown) {
const timestamp = new Date().toISOString()
const prefix = `[Translation Debug ${timestamp}] [${area}]`
if (data !== undefined) {
console.log(prefix, message, data)
} else {
console.log(prefix, message)
}
}
const aiCatalog = ref<AIProviderDefinition[]>([])
const loading = ref(true)
const cacheStatus = ref('')
const testing = ref(false)
const testStatus = ref('')
const googleIpPoolSize = ref(0)
let saveTimer: ReturnType<typeof setTimeout> | undefined
let poolTimer: ReturnType<typeof setInterval> | undefined
const messages = defineMessages({
title: { id: 'app.translation-settings.title', defaultMessage: 'Translation' },
description: {
id: 'app.translation-settings.description',
defaultMessage:
'Translate Modrinth project titles, summaries, and descriptions while browsing content.',
},
provider: { id: 'app.translation-settings.provider', defaultMessage: 'Translation service' },
google: {
id: 'app.translation-settings.provider.google',
defaultMessage: 'Google Translate (free)',
},
deepl: {
id: 'app.translation-settings.provider.deepl',
defaultMessage: 'DeepL',
},
deeplApiEndpoint: {
id: 'app.translation-settings.deepl-api-endpoint',
defaultMessage: 'API endpoint',
},
deeplApiEndpointPlaceholder: {
id: 'app.translation-settings.deepl-api-endpoint-placeholder',
defaultMessage: 'https://api-free.deepl.com/v2/translate',
},
deeplApiKey: {
id: 'app.translation-settings.deepl-api-key',
defaultMessage: 'API key',
},
deeplApiKeyPlaceholder: {
id: 'app.translation-settings.deepl-api-key-placeholder',
defaultMessage: 'Enter your DeepL API key',
},
deeplApiKeyHint: {
id: 'app.translation-settings.deepl-api-key-hint',
defaultMessage: 'Get a free key at deepl.com/pro-api',
},
googleIpPool: {
id: 'app.translation-settings.google-ip-pool',
defaultMessage: 'IP pool {count}',
},
ai: { id: 'app.translation-settings.provider.ai', defaultMessage: 'AI model' },
aiProvider: { id: 'app.translation-settings.ai-provider', defaultMessage: 'AI provider' },
aiModel: { id: 'app.translation-settings.ai-model', defaultMessage: 'Text model' },
targetLanguage: {
id: 'app.translation-settings.target-language',
defaultMessage: 'Target language',
},
followApp: {
id: 'app.translation-settings.target-language.follow-app',
defaultMessage: 'Follow launcher language',
},
displayMode: {
id: 'app.translation-settings.display-mode',
defaultMessage: 'Display mode',
},
bilingual: {
id: 'app.translation-settings.display-mode.bilingual',
defaultMessage: 'Original and translation',
},
translationOnly: {
id: 'app.translation-settings.display-mode.translation-only',
defaultMessage: 'Translation only',
},
autoTranslate: {
id: 'app.translation-settings.auto-translate',
defaultMessage: 'Translate project pages automatically',
},
autoTranslateDescription: {
id: 'app.translation-settings.auto-translate-description',
defaultMessage: 'Start translating as soon as a Modrinth project page is opened.',
},
style: { id: 'app.translation-settings.style', defaultMessage: 'Translation style' },
styleDefault: { id: 'app.translation-settings.style.default', defaultMessage: 'Default' },
styleBlur: { id: 'app.translation-settings.style.blur', defaultMessage: 'Blur' },
styleBlockquote: {
id: 'app.translation-settings.style.blockquote',
defaultMessage: 'Block quote',
},
styleWeakened: { id: 'app.translation-settings.style.weakened', defaultMessage: 'Muted' },
styleDashedLine: {
id: 'app.translation-settings.style.dashed-line',
defaultMessage: 'Dashed underline',
},
styleBorder: { id: 'app.translation-settings.style.border', defaultMessage: 'Border' },
styleTextColor: {
id: 'app.translation-settings.style.text-color',
defaultMessage: 'Text color',
},
styleBackground: {
id: 'app.translation-settings.style.background',
defaultMessage: 'Background',
},
stylePreview: { id: 'app.translation-settings.style.preview', defaultMessage: 'Preview' },
stylePreviewOriginalText: {
id: 'app.translation-settings.style.preview-original-text',
defaultMessage: 'Explore high-quality Minecraft content on Modrinth.',
},
stylePreviewText: {
id: 'app.translation-settings.style.preview-text',
defaultMessage: 'Discover high-quality Minecraft content on Modrinth.',
},
systemPrompt: {
id: 'app.translation-settings.system-prompt',
defaultMessage: 'Translation instructions',
},
systemPromptDescription: {
id: 'app.translation-settings.system-prompt-description',
defaultMessage:
'Optional feature-specific instructions. The launcher always appends its structured translation contract.',
},
test: { id: 'app.translation-settings.test', defaultMessage: 'Test service' },
testing: { id: 'app.translation-settings.testing', defaultMessage: 'Testing…' },
testSuccess: {
id: 'app.translation-settings.test-success',
defaultMessage: 'Connection succeeded: {translation}',
},
cache: { id: 'app.translation-settings.cache', defaultMessage: 'Translation cache' },
cacheDescription: {
id: 'app.translation-settings.cache-description',
defaultMessage: 'Successful translations are cached for seven days to reduce requests.',
},
clearCache: {
id: 'app.translation-settings.clear-cache',
defaultMessage: 'Clear translation cache',
},
cacheCleared: {
id: 'app.translation-settings.cache-cleared',
defaultMessage: 'Translation cache cleared.',
},
operationFailed: {
id: 'app.translation-settings.operation-failed',
defaultMessage: 'The translation operation failed. Check the configuration and try again.',
},
rateLimited: {
id: 'app.translation.error.rate-limited',
defaultMessage: 'The translation service is temporarily rate limited. Please try again later.',
},
authenticationFailed: {
id: 'app.translation.error.authentication',
defaultMessage: 'The translation service could not authenticate. Please try again later.',
},
contentTooLong: {
id: 'app.translation.error.content-too-long',
defaultMessage: 'This content is too long for the selected translation service.',
},
networkFailed: {
id: 'app.translation.error.network',
defaultMessage: 'The translation service could not be reached. Check your network or proxy.',
},
})
const configuredAIProviders = computed(() =>
(sharedAIState.value?.providers ?? []).filter(
(provider) => provider.enabled && provider.models.some((model) => model.enabled),
),
)
const aiAvailable = computed(
() => !!sharedAIState.value?.settings.enabled && configuredAIProviders.value.length > 0,
)
const modes = ['bilingual', 'translation-only'] as const
const styles: TranslationStyle[] = [
'default',
'blur',
'blockquote',
'weakened',
'dashed-line',
'border',
'text-color',
'background',
]
const languages = ['follow-app', ...LOCALES.map((locale) => locale.code)]
const targetLanguage = computed({
get: () => settings.value.target_language || 'follow-app',
set: (value: string) => {
settings.value.target_language = value === 'follow-app' ? '' : value
},
})
function providerName(provider: TranslationProvider) {
return formatMessage(
{ google: messages.google, deepl: messages.deepl, ai: messages.ai }[provider],
)
}
function languageName(code: string) {
if (code === 'follow-app') return formatMessage(messages.followApp)
const locale = LOCALES.find((item) => item.code === code)
return locale ? `${locale.name}${formatMessage(locale.translatedName)}` : code
}
function styleName(style: TranslationStyle) {
return formatMessage(
{
default: messages.styleDefault,
blur: messages.styleBlur,
blockquote: messages.styleBlockquote,
weakened: messages.styleWeakened,
'dashed-line': messages.styleDashedLine,
border: messages.styleBorder,
'text-color': messages.styleTextColor,
background: messages.styleBackground,
}[style],
)
}
const translationProviders = computed<TranslationProvider[]>(() => [
'google',
...(aiAvailable.value ? (['ai'] as const) : []),
'deepl',
])
const providerOptions = computed(() =>
translationProviders.value.map((provider) => ({
value: provider,
label: providerName(provider),
})),
)
const languageOptions = computed(() =>
languages.map((language) => ({ value: language, label: languageName(language) })),
)
const modeOptions = computed(() =>
modes.map((mode) => ({
value: mode,
label: formatMessage(mode === 'bilingual' ? messages.bilingual : messages.translationOnly),
})),
)
const styleOptions = computed(() =>
styles.map((style) => ({ value: style, label: styleName(style) })),
)
const aiProviderOptions = computed(() =>
configuredAIProviders.value.map((provider) => ({
value: provider.provider_id,
label:
provider.custom_name ||
aiCatalog.value.find((definition) => definition.id === provider.provider_id)?.name ||
provider.provider_id,
})),
)
const selectedAIProvider = computed({
get: () => settings.value.ai_provider_id,
set: (providerId: string) => {
settings.value.ai_provider_id = providerId
settings.value.ai_model_id =
configuredAIProviders.value
.find((provider) => provider.provider_id === providerId)
?.models.find((model) => model.enabled)?.id ?? ''
},
})
const aiModelOptions = computed(() =>
(
configuredAIProviders.value.find(
(provider) => provider.provider_id === settings.value.ai_provider_id,
)?.models ?? []
)
.filter((model) => model.enabled)
.map((model) => ({ value: model.id, label: model.name || model.id })),
)
const stylePreviewClass = computed(() => `translation-style-preview-${settings.value.style}`)
watch(
[aiAvailable, configuredAIProviders],
() => {
if (!aiAvailable.value) {
if (settings.value.provider === 'ai') settings.value.provider = 'google'
return
}
if (
!configuredAIProviders.value.some(
(provider) => provider.provider_id === settings.value.ai_provider_id,
)
) {
selectedAIProvider.value = configuredAIProviders.value[0]?.provider_id ?? ''
}
if (!aiModelOptions.value.some((model) => model.value === settings.value.ai_model_id)) {
settings.value.ai_model_id = aiModelOptions.value[0]?.value ?? ''
}
},
{ immediate: true, deep: true },
)
function reportOperationError(error?: unknown, context?: string) {
const errorKind = error ? getTranslationErrorKind(error) : 'provider'
const errorMessage = error instanceof Error ? error.message : String(error)
debugLog('Error', `Operation failed${context ? ` (${context})` : ''}`, {
kind: errorKind,
message: errorMessage,
provider: settings.value.provider,
deeplApiKeySet: !!settings.value.deepl_api_key?.trim(),
deeplEndpoint: settings.value.deepl_api_endpoint,
})
// Don't show error notifications for DeepL when API key is not configured
// This prevents spam when user is still configuring
if (
settings.value.provider === 'deepl' &&
!settings.value.deepl_api_key?.trim() &&
errorMessage.includes('DeepL API key is not configured')
) {
debugLog('Error', 'Suppressing DeepL API key not configured error - user is still configuring')
return
}
const message = error
? {
'rate-limited': messages.rateLimited,
authentication: messages.authenticationFailed,
'content-too-long': messages.contentTooLong,
network: messages.networkFailed,
provider: messages.operationFailed,
}[errorKind]
: messages.operationFailed
// Surface the underlying provider error (e.g. DeepL HTTP status, quota
// or endpoint mistakes) instead of a generic message, so users can fix
// the configuration themselves.
const displayMessage =
errorKind === 'provider' && errorMessage.includes('DeepL API error')
? errorMessage
: formatMessage(message)
handleError(new Error(displayMessage))
}
watch(
settings,
(newSettings) => {
if (loading.value) {
debugLog('Watch', 'Skipping save - still loading')
return
}
// Deep watchers receive the same object for old and new values when a
// nested field is mutated in place. Always schedule a save for a loaded
// settings change instead of comparing those references.
debugLog('Watch', 'Settings changed', {
provider: newSettings.provider,
mode: newSettings.mode,
style: newSettings.style,
autoTranslate: newSettings.auto_translate,
})
clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
saveTimer = undefined
debugLog('Save', 'Saving settings to backend', {
provider: newSettings.provider,
deeplApiKeySet: !!newSettings.deepl_api_key?.trim(),
deeplEndpoint: newSettings.deepl_api_endpoint,
aiProviderId: newSettings.ai_provider_id,
aiModelId: newSettings.ai_model_id,
})
// Only save settings, don't show error notifications
// Errors during save should be silent - only test button shows errors
void updateTranslationSettings({ ...settings.value })
.then(() => {
debugLog('Save', 'Settings saved successfully')
})
.catch((error) => {
// Log error but don't show notification to user
// Only the "Test" button should show errors
const errorMessage = error instanceof Error ? error.message : String(error)
debugLog('Save', 'Settings save failed (silent)', {
error: errorMessage,
provider: newSettings.provider,
})
})
}, 300)
},
{ deep: true },
)
async function refreshGoogleIpPool() {
try {
googleIpPoolSize.value = await getGoogleIpPoolSize()
} catch (error) {
reportOperationError(error)
}
}
watch(
() => settings.value.provider,
(provider) => {
clearInterval(poolTimer)
if (provider !== 'google') return
void refreshGoogleIpPool()
poolTimer = setInterval(() => void refreshGoogleIpPool(), 5000)
},
{ immediate: true },
)
onUnmounted(() => {
clearInterval(poolTimer)
if (loading.value || !saveTimer) return
clearTimeout(saveTimer)
saveTimer = undefined
void updateTranslationSettings({ ...settings.value }).catch(reportOperationError)
})
onMounted(async () => {
debugLog('Init', 'Loading translation settings...')
try {
const [loadedSettings, , loadedCatalog] = await Promise.all([
getTranslationSettings(),
getAIState(),
getAICatalog(),
])
debugLog('Init', 'Settings loaded from backend', {
provider: loadedSettings.provider,
deeplApiKeySet: !!loadedSettings.deepl_api_key?.trim(),
deeplEndpoint: loadedSettings.deepl_api_endpoint,
aiProviderId: loadedSettings.ai_provider_id,
aiModelId: loadedSettings.ai_model_id,
targetLanguage: loadedSettings.target_language,
mode: loadedSettings.mode,
autoTranslate: loadedSettings.auto_translate,
})
settings.value = loadedSettings
aiCatalog.value = loadedCatalog
} catch (error) {
debugLog('Init', 'Failed to load settings', error)
reportOperationError(error, 'load-settings')
} finally {
loading.value = false
debugLog('Init', 'Loading complete')
}
})
async function testProvider() {
debugLog('Test', 'Starting provider test', {
provider: settings.value.provider,
deeplApiKeySet: !!settings.value.deepl_api_key?.trim(),
deeplEndpoint: settings.value.deepl_api_endpoint,
aiProviderId: settings.value.ai_provider_id,
aiModelId: settings.value.ai_model_id,
})
testing.value = true
testStatus.value = ''
// Validate DeepL configuration before testing
if (settings.value.provider === 'deepl') {
if (!settings.value.deepl_api_key?.trim()) {
debugLog('Test', 'DeepL API key is not configured')
reportOperationError(
new Error('DeepL API key is not configured. Please enter your API key first.'),
'deepl-validation',
)
testing.value = false
return
}
if (!settings.value.deepl_api_endpoint?.trim()) {
debugLog('Test', 'DeepL API endpoint is not configured, using default')
settings.value.deepl_api_endpoint = 'https://api-free.deepl.com/v2/translate'
}
}
try {
debugLog('Test', 'Saving settings before test...')
await updateTranslationSettings({ ...settings.value })
debugLog('Test', 'Settings saved, now testing provider...')
const result = await testTranslationProvider(settings.value.provider)
debugLog('Test', 'Test succeeded', { result })
testStatus.value = formatMessage(messages.testSuccess, { translation: result })
} catch (error) {
debugLog('Test', 'Test failed', error)
reportOperationError(error, 'test-provider')
} finally {
testing.value = false
debugLog('Test', 'Test complete')
}
}
async function clearCache() {
try {
await clearTranslationCache()
cacheStatus.value = formatMessage(messages.cacheCleared)
} catch (error) {
reportOperationError(error)
}
}
</script>
<template>
<div v-if="loading" class="flex min-h-48 items-center justify-center">
<SpinnerIcon class="size-6 animate-spin text-secondary" />
</div>
<div v-else class="flex flex-col gap-6">
<SettingsSection>
<template #header>
<h2
id="settings-target-translation-service"
tabindex="-1"
class="m-0 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.title) }}
</h2>
<p class="m-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.description) }}
</p>
</template>
<template #extra>
<div class="flex flex-wrap items-center justify-end gap-2">
<span v-if="testStatus" class="text-sm text-secondary">{{ testStatus }}</span>
<Button type="base" :disabled="testing" @click="testProvider">
<PlugIcon />{{ formatMessage(testing ? messages.testing : messages.test) }}
</Button>
</div>
</template>
<SettingsRow>
<template #label>{{ formatMessage(messages.provider) }}</template>
<template #description>
<span v-if="settings.provider === 'google'">
{{ formatMessage(messages.googleIpPool, { count: googleIpPoolSize }) }}
</span>
</template>
<template #control>
<div class="w-full">
<Combobox v-model="settings.provider" :options="providerOptions" />
</div>
</template>
</SettingsRow>
<SettingsRow v-if="settings.provider === 'deepl'" stacked>
<template #label>{{ formatMessage(messages.deeplApiEndpoint) }}</template>
<template #control>
<StyledInput
v-model="settings.deepl_api_endpoint"
:placeholder="formatMessage(messages.deeplApiEndpointPlaceholder)"
wrapper-class="w-full"
/>
</template>
</SettingsRow>
<SettingsRow v-if="settings.provider === 'deepl'" stacked>
<template #label>{{ formatMessage(messages.deeplApiKey) }}</template>
<template #description>{{ formatMessage(messages.deeplApiKeyHint) }}</template>
<template #control>
<StyledInput
v-model="settings.deepl_api_key"
type="password"
:placeholder="formatMessage(messages.deeplApiKeyPlaceholder)"
wrapper-class="w-full"
/>
</template>
</SettingsRow>
<SettingsRow v-if="settings.provider === 'ai' && aiAvailable">
<template #label>{{ formatMessage(messages.aiProvider) }}</template>
<template #control>
<div class="w-full">
<Combobox v-model="selectedAIProvider" :options="aiProviderOptions">
<template #selected="{ label }">
<span class="inline-flex min-w-0 items-center gap-2">
<AIIcon kind="provider-avatar" :value="selectedAIProvider" :size="20" />
<span class="truncate">{{ label }}</span>
</span>
</template>
<template #option="{ item, isSelected }">
<div class="flex min-w-0 items-center gap-2.5">
<AIIcon kind="provider-avatar" :value="String(item.value)" :size="22" />
<span
class="truncate font-semibold leading-tight"
:class="isSelected ? 'text-brand' : 'text-primary'"
>
{{ item.label }}
</span>
</div>
</template>
</Combobox>
</div>
</template>
</SettingsRow>
<SettingsRow v-if="settings.provider === 'ai' && aiAvailable">
<template #label>{{ formatMessage(messages.aiModel) }}</template>
<template #control>
<div
class="translation-model-combobox relative w-full"
:class="{ 'has-model-icon': settings.ai_model_id }"
>
<AIIcon
v-if="settings.ai_model_id"
class="pointer-events-none absolute left-3 top-1/2 z-[2] -translate-y-1/2"
kind="model"
:value="settings.ai_model_id"
:size="20"
/>
<Combobox v-model="settings.ai_model_id" :options="aiModelOptions" searchable>
<template #option="{ item, isSelected }">
<div class="flex min-w-0 items-center gap-2.5">
<AIIcon kind="model" :value="String(item.value)" :size="22" />
<span
class="truncate font-semibold leading-tight"
:class="isSelected ? 'text-brand' : 'text-primary'"
>
{{ item.label }}
</span>
</div>
</template>
</Combobox>
</div>
</template>
</SettingsRow>
<SettingsRow v-if="settings.provider === 'ai' && aiAvailable" stacked>
<template #label>{{ formatMessage(messages.systemPrompt) }}</template>
<template #description>{{ formatMessage(messages.systemPromptDescription) }}</template>
<template #control>
<StyledInput
v-model="settings.ai_system_prompt"
multiline
:rows="3"
resize="vertical"
wrapper-class="w-full"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection>
<SettingsRow>
<template #label>{{ formatMessage(messages.targetLanguage) }}</template>
<template #control>
<div class="w-full">
<Combobox v-model="targetLanguage" :options="languageOptions" searchable />
</div>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.displayMode) }}</template>
<template #control>
<div class="w-full"><Combobox v-model="settings.mode" :options="modeOptions" /></div>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.style) }}</template>
<template #control>
<div class="w-full"><Combobox v-model="settings.style" :options="styleOptions" /></div>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>{{ formatMessage(messages.stylePreview) }}</template>
<template #control>
<div class="translation-style-preview-container">
<p v-if="settings.mode === 'bilingual'" class="translation-style-preview-original m-0">
{{ formatMessage(messages.stylePreviewOriginalText) }}
</p>
<p class="translation-style-preview m-0" :class="stylePreviewClass">
{{ formatMessage(messages.stylePreviewText) }}
</p>
</div>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-translation-auto-translate" tabindex="-1">
{{ formatMessage(messages.autoTranslate) }}
</span>
</template>
<template #description>{{ formatMessage(messages.autoTranslateDescription) }}</template>
<template #control
><Toggle id="translation-auto" v-model="settings.auto_translate"
/></template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-translation-cache" tabindex="-1">
{{ formatMessage(messages.cache) }}
</span>
</template>
<template #description>{{ formatMessage(messages.cacheDescription) }}</template>
<template #control>
<div class="flex flex-wrap items-center justify-end gap-2">
<span v-if="cacheStatus" class="text-sm text-secondary">{{ cacheStatus }}</span>
<Button type="base" @click="clearCache">
<TrashIcon />{{ formatMessage(messages.clearCache) }}
</Button>
</div>
</template>
</SettingsRow>
</SettingsSection>
</div>
</template>
<style scoped>
.translation-model-combobox.has-model-icon :deep(input) {
padding-left: 2.75rem !important;
}
.translation-style-preview-container {
display: flex;
width: 100%;
min-height: 6.5rem;
flex-direction: column;
box-sizing: border-box;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--color-divider);
border-radius: var(--radius-lg);
}
.translation-style-preview-original,
.translation-style-preview {
font-weight: 400;
}
.translation-style-preview-original,
.translation-style-preview-default {
color: var(--color-text-primary);
}
.translation-style-preview-weakened {
color: var(--color-secondary) !important;
}
.translation-style-preview-blur {
filter: blur(4px);
opacity: 0.75;
transition:
filter 0.1s ease-in-out,
opacity 0.1s ease-in-out;
}
.translation-style-preview-blur:hover {
filter: blur(0);
opacity: 1;
}
.translation-style-preview-blockquote {
padding: 4px 0 4px 8px;
border-left: 4px solid var(--color-brand);
}
.translation-style-preview-dashed-line {
text-decoration: underline dashed var(--color-brand) !important;
text-underline-offset: 5px;
}
.translation-style-preview-border {
padding: 2px 4px;
border: 1px solid var(--color-brand);
border-radius: 4px;
}
.translation-style-preview-text-color {
color: oklch(0.693 0.17 162.48) !important;
}
.translation-style-preview-background {
padding: 2px 4px;
border-radius: 4px;
background-color: color-mix(in srgb, var(--color-brand) 15%, transparent);
}
</style>