Files
MoviePilot-Frontend/src/views/system/WordsView.vue
2026-08-07 07:01:41 +08:00

1379 lines
38 KiB
Vue

<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify'
import { configureAceEditorPadding } from '@/utils/aceEditor'
import type { Ace } from 'ace-builds'
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
const { t } = useI18n()
const $toast = useToast()
const { global: globalTheme } = useTheme()
const WORDS_LINE_NUMBERS_STORAGE_KEY = 'MP_WORDS_SHOW_LINE_NUMBERS'
const WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY = 'MP_WORDS_SYNTAX_HIGHLIGHTING'
type TextSectionKey = 'identifiers' | 'releaseGroups' | 'customization' | 'excludeWords'
type WordSectionKey = TextSectionKey | 'episodeRules'
interface EpisodeFormatRule {
_localId: string
name: string
enabled: boolean
order: number
pattern: string
min_file_size_mb: number
}
interface WordSectionDefinition {
color: string
description: string
icon: string
key: WordSectionKey
shortTitle: string
title: string
}
interface TextSectionSetting {
endpoint: string
failedMessage: string
successMessage: string
}
const customIdentifiers = ref('')
const customReleaseGroups = ref('')
const customization = ref('')
const transferExcludeWords = ref('')
const episodeFormatRules = ref<EpisodeFormatRule[]>([])
const activeSection = ref<WordSectionKey>('identifiers')
const expandedHelp = ref<string | null>(null)
const saving = ref(false)
const showLineNumbers = ref(localStorage.getItem(WORDS_LINE_NUMBERS_STORAGE_KEY) === 'true')
const showSyntaxHighlighting = ref(localStorage.getItem(WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY) === 'true')
interface WordListModeConfig {
path: 'ace/mode/word_list'
syntax: boolean
}
const aceEditor = shallowRef<Ace.Editor | null>(null)
function onAceInit(editor: Ace.Editor) {
aceEditor.value = editor
configureAceEditorPadding(editor)
applyWordListSyntax()
}
function applyWordListSyntax() {
if (!aceEditor.value) return
const mode: WordListModeConfig = {
path: 'ace/mode/word_list',
syntax: showSyntaxHighlighting.value,
}
aceEditor.value.session.setMode(mode as unknown as Ace.SyntaxMode)
}
const textEditorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
const textEditorOptions = computed(() => ({
fontSize: 13.6,
highlightActiveLine: false,
scrollPastEnd: 0,
showFoldWidgets: false,
showGutter: showLineNumbers.value,
showLineNumbers: showLineNumbers.value,
showPrintMargin: false,
tabSize: 2,
}))
watch(showLineNumbers, value => {
localStorage.setItem(WORDS_LINE_NUMBERS_STORAGE_KEY, String(value))
})
watch(showSyntaxHighlighting, value => {
localStorage.setItem(WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY, String(value))
})
watch(showSyntaxHighlighting, applyWordListSyntax)
const savedTextValues = reactive<Record<TextSectionKey, string>>({
identifiers: '',
releaseGroups: '',
customization: '',
excludeWords: '',
})
const savedEpisodeRules = ref('[]')
const textSectionModels: Record<TextSectionKey, typeof customIdentifiers> = {
identifiers: customIdentifiers,
releaseGroups: customReleaseGroups,
customization,
excludeWords: transferExcludeWords,
}
const textSectionSettings = computed<Record<TextSectionKey, TextSectionSetting>>(() => ({
identifiers: {
endpoint: 'system/setting/CustomIdentifiers',
failedMessage: t('setting.words.identifierSaveFailed'),
successMessage: t('setting.words.identifierSaveSuccess'),
},
releaseGroups: {
endpoint: 'system/setting/CustomReleaseGroups',
failedMessage: t('setting.words.releaseGroupSaveFailed'),
successMessage: t('setting.words.releaseGroupSaveSuccess'),
},
customization: {
endpoint: 'system/setting/Customization',
failedMessage: t('setting.words.customizationSaveFailed'),
successMessage: t('setting.words.customizationSaveSuccess'),
},
excludeWords: {
endpoint: 'system/setting/TransferExcludeWords',
failedMessage: t('setting.words.excludeWordsSaveFailed'),
successMessage: t('setting.words.excludeWordsSaveSuccess'),
},
}))
const wordSections = computed<WordSectionDefinition[]>(() => [
{
color: 'primary',
description: t('setting.words.identifiersDesc'),
icon: 'mdi-tag-outline',
key: 'identifiers',
shortTitle: t('setting.words.identifiersShort'),
title: t('setting.words.customIdentifiers'),
},
{
color: 'secondary',
description: t('setting.words.releaseGroupsDesc'),
icon: 'mdi-account-group-outline',
key: 'releaseGroups',
shortTitle: t('setting.words.releaseGroupsShort'),
title: t('setting.words.customReleaseGroups'),
},
{
color: 'info',
description: t('setting.words.customizationDesc'),
icon: 'mdi-code-braces',
key: 'customization',
shortTitle: t('setting.words.customizationShort'),
title: t('setting.words.customization'),
},
{
color: 'success',
description: t('setting.words.excludeWordsDesc'),
icon: 'mdi-shield-off-outline',
key: 'excludeWords',
shortTitle: t('setting.words.excludeWordsShort'),
title: t('setting.words.transferExcludeWords'),
},
{
color: 'primary',
description: t('setting.words.episodeFormatRuleDesc'),
icon: 'mdi-format-list-numbered',
key: 'episodeRules',
shortTitle: t('setting.words.episodeFormatRuleShort'),
title: t('setting.words.episodeFormatRule'),
},
])
const activeSectionDefinition = computed(
() => wordSections.value.find(section => section.key === activeSection.value) ?? wordSections.value[0],
)
const isTextSection = computed(() => activeSection.value !== 'episodeRules')
const activeTextValue = computed({
get: () => (isTextSection.value ? textSectionModels[activeSection.value as TextSectionKey].value : ''),
set: value => {
if (isTextSection.value) textSectionModels[activeSection.value as TextSectionKey].value = value
},
})
const activeTextPlaceholder = computed(() => {
switch (activeSection.value) {
case 'identifiers':
return t('setting.words.identifiersPlaceholder')
case 'releaseGroups':
return t('setting.words.releaseGroupsPlaceholder')
case 'customization':
return t('setting.words.customizationPlaceholder')
case 'excludeWords':
return t('setting.words.excludeWordsPlaceholder')
default:
return ''
}
})
const activeTextHint = computed(() => {
switch (activeSection.value) {
case 'identifiers':
return t('setting.words.identifiersHint')
case 'releaseGroups':
return t('setting.words.releaseGroupsHint')
case 'customization':
return t('setting.words.customizationHint')
case 'excludeWords':
return t('setting.words.excludeWordsHint')
default:
return ''
}
})
const activeGuideTitle = computed(() =>
activeSection.value === 'identifiers'
? t('setting.words.formatTitle')
: activeSection.value === 'episodeRules'
? t('setting.words.episodeFormatRuleGuideTitle')
: t('setting.words.guideTitle'),
)
const activeGuideContent = computed(() => {
if (activeSection.value === 'identifiers') return t('setting.words.formatContent')
if (activeSection.value === 'episodeRules') return t('setting.words.episodeFormatRuleGuideContent')
return activeTextHint.value
})
// 仅在提示内容能补充行内说明时展示折叠面板,避免捷径弹窗内重复出现相同文案。
const shouldShowGuidePanel = computed(
() => activeSection.value === 'identifiers' || activeSection.value === 'episodeRules',
)
/** 生成仅供前端拖拽列表使用的稳定规则标识。 */
function createEpisodeRuleLocalId() {
return `episode-rule-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
/** 将后端规则或空白模板转换为带本地标识的可编辑规则。 */
function createEpisodeRule(rule?: Partial<Omit<EpisodeFormatRule, '_localId'>>): EpisodeFormatRule {
return {
_localId: createEpisodeRuleLocalId(),
name: rule?.name ?? '',
enabled: rule?.enabled ?? true,
order: rule?.order ?? episodeFormatRules.value.length + 1,
pattern: rule?.pattern ?? '',
min_file_size_mb: rule?.min_file_size_mb ?? 500,
}
}
/** 规范化后端返回的集数定位规则列表。 */
function normalizeEpisodeFormatRules(
rules: Array<Partial<Omit<EpisodeFormatRule, '_localId'>> & { _localId?: string }> = [],
) {
return rules.map(rule => createEpisodeRule(rule))
}
/** 构建后端保存集数定位规则所需的有序载荷。 */
function buildEpisodeFormatRulePayload() {
return episodeFormatRules.value.map((rule, index) => ({
name: rule.name,
enabled: rule.enabled,
order: index + 1,
pattern: rule.pattern,
min_file_size_mb: Number(rule.min_file_size_mb) || 0,
}))
}
/** 将集数定位规则序列化,用于判断当前内容是否有未保存修改。 */
function serializeEpisodeFormatRules() {
return JSON.stringify(buildEpisodeFormatRulePayload())
}
/** 统计多行词表中非空配置的数量。 */
function countConfiguredLines(value: string) {
return value.split('\n').filter(line => line.trim().length > 0).length
}
/** 返回指定词表分类当前配置条目数。 */
function getSectionCount(section: WordSectionKey) {
return section === 'episodeRules'
? episodeFormatRules.value.length
: countConfiguredLines(textSectionModels[section].value)
}
/** 判断指定词表分类是否存在未保存修改。 */
function isSectionDirty(section: WordSectionKey) {
return section === 'episodeRules'
? serializeEpisodeFormatRules() !== savedEpisodeRules.value
: textSectionModels[section].value !== savedTextValues[section]
}
const activeSectionDirty = computed(() => isSectionDirty(activeSection.value))
const activeSectionCount = computed(() => getSectionCount(activeSection.value))
const totalConfiguredEntries = computed(() =>
wordSections.value.reduce((total, section) => total + getSectionCount(section.key), 0),
)
/** 切换当前正在编辑的词表分类,并收起帮助内容。 */
function selectSection(section: WordSectionKey) {
activeSection.value = section
expandedHelp.value = null
}
/** 新增一条空白集数定位规则并滚动到规则编辑分类。 */
function addEpisodeRule() {
episodeFormatRules.value.push(createEpisodeRule())
activeSection.value = 'episodeRules'
}
/** 删除指定位置的集数定位规则。 */
function deleteEpisodeRule(index: number) {
episodeFormatRules.value.splice(index, 1)
}
/** 查询一个多行词表配置,并同步其已保存快照。 */
async function queryTextSection(section: TextSectionKey) {
try {
const result: { [key: string]: any } = await api.get(textSectionSettings.value[section].endpoint)
const value = Array.isArray(result?.data?.value) ? result.data.value.join('\n') : ''
textSectionModels[section].value = value
savedTextValues[section] = value
} catch (error) {
console.log(error)
}
}
/** 保存一个多行词表配置,并在成功后更新已保存快照。 */
async function saveTextSection(section: TextSectionKey) {
const setting = textSectionSettings.value[section]
try {
const value = textSectionModels[section].value
const result: { [key: string]: any } = await api.post(setting.endpoint, value.split('\n'))
if (result.success) {
savedTextValues[section] = value
$toast.success(setting.successMessage)
return true
}
$toast.error(setting.failedMessage)
} catch (error) {
console.log(error)
$toast.error(setting.failedMessage)
}
return false
}
/** 查询集数定位规则,并同步其已保存快照。 */
async function queryEpisodeFormatRules() {
try {
const result: { [key: string]: any } = await api.get('system/setting/public/EpisodeFormatRuleTable')
episodeFormatRules.value = normalizeEpisodeFormatRules(result?.data?.value ?? [])
savedEpisodeRules.value = serializeEpisodeFormatRules()
} catch (error) {
console.log(error)
}
}
/** 校验并保存集数定位规则。 */
async function saveEpisodeFormatRules() {
for (const rule of episodeFormatRules.value) {
if (!rule.name || !rule.pattern) {
$toast.error(t('setting.words.episodeFormatRuleEmptyError'))
return false
}
}
try {
const payload = buildEpisodeFormatRulePayload()
const result: { [key: string]: any } = await api.post('system/setting/EpisodeFormatRuleTable', payload)
if (result.success) {
episodeFormatRules.value.forEach((rule, index) => {
rule.order = payload[index].order
rule.min_file_size_mb = payload[index].min_file_size_mb
})
savedEpisodeRules.value = serializeEpisodeFormatRules()
$toast.success(t('setting.words.episodeFormatRuleSaveSuccess'))
return true
}
$toast.error(result.message || t('setting.words.episodeFormatRuleSaveFailed'))
} catch (error) {
console.log(error)
$toast.error(t('setting.words.episodeFormatRuleSaveFailed'))
}
return false
}
/** 保存当前正在编辑的词表分类。 */
async function saveActiveSection() {
if (saving.value) return
saving.value = true
try {
if (activeSection.value === 'episodeRules') await saveEpisodeFormatRules()
else await saveTextSection(activeSection.value)
} finally {
saving.value = false
}
}
/** 将当前分类恢复为最近一次成功加载或保存的内容。 */
function resetActiveSection() {
if (activeSection.value === 'episodeRules') {
const savedRules = JSON.parse(savedEpisodeRules.value) as Array<Omit<EpisodeFormatRule, '_localId'>>
episodeFormatRules.value = normalizeEpisodeFormatRules(savedRules)
return
}
textSectionModels[activeSection.value].value = savedTextValues[activeSection.value]
}
/** 拖拽调整规则顺序后沿用原有行为立即保存。 */
async function onEpisodeRuleDragEnd() {
if (saving.value) return
saving.value = true
try {
await saveEpisodeFormatRules()
} finally {
saving.value = false
}
}
onMounted(() => {
Promise.all([
queryTextSection('identifiers'),
queryTextSection('releaseGroups'),
queryTextSection('customization'),
queryTextSection('excludeWords'),
queryEpisodeFormatRules(),
])
})
</script>
<template>
<div class="words-view">
<header class="words-summary d-none d-md-flex">
<span>{{ t('setting.words.summary', { sections: wordSections.length, entries: totalConfiguredEntries }) }}</span>
</header>
<div class="words-workspace">
<nav class="words-sidebar d-none d-md-flex" :aria-label="t('setting.words.sectionListLabel')">
<button
v-for="section in wordSections"
:key="section.key"
type="button"
class="words-sidebar-item"
:class="{ 'words-sidebar-item--active': activeSection === section.key }"
:aria-current="activeSection === section.key ? 'page' : undefined"
@click="selectSection(section.key)"
>
<VAvatar :color="section.color" variant="tonal" rounded="lg" size="44" class="words-section-icon">
<VIcon :icon="section.icon" size="24" />
</VAvatar>
<span class="words-sidebar-copy">
<strong>{{ section.title }}</strong>
<small>{{ section.description }}</small>
</span>
<span class="words-sidebar-state">
<small>{{ t('setting.words.entryCount', { count: getSectionCount(section.key) }) }}</small>
<VIcon
:icon="isSectionDirty(section.key) ? 'mdi-circle-medium' : 'mdi-check'"
:color="isSectionDirty(section.key) ? 'warning' : 'success'"
size="18"
/>
</span>
</button>
<div class="words-sidebar-hint">
<VIcon icon="mdi-information-outline" size="18" />
<span>{{ t('setting.words.switchHint') }}</span>
</div>
</nav>
<nav class="words-mobile-tabs d-md-none" :aria-label="t('setting.words.sectionListLabel')">
<button
v-for="section in wordSections"
:key="section.key"
type="button"
class="words-mobile-tab"
:class="{ 'words-mobile-tab--active': activeSection === section.key }"
:aria-current="activeSection === section.key ? 'page' : undefined"
@click="selectSection(section.key)"
>
<span class="words-mobile-tab-icon">
<VIcon :icon="section.icon" size="23" />
<span v-if="isSectionDirty(section.key)" class="words-dirty-dot" />
</span>
<span>{{ section.shortTitle }}</span>
</button>
</nav>
<section class="words-editor">
<header class="words-editor-header">
<div class="words-editor-heading">
<h2>{{ activeSectionDefinition.title }}</h2>
<p>{{ activeSectionDefinition.description }}</p>
</div>
<div class="words-save-state" :class="{ 'words-save-state--dirty': activeSectionDirty }">
<VIcon :icon="activeSectionDirty ? 'mdi-circle-medium' : 'mdi-check-circle-outline'" size="18" />
<span>{{ activeSectionDirty ? t('setting.words.unsaved') : t('setting.words.saved') }}</span>
</div>
</header>
<div class="words-editor-scroll">
<template v-if="isTextSection">
<div class="words-field-meta">
<strong>{{ t('setting.words.listLabel') }}</strong>
<div class="words-field-actions">
<span>{{ t('setting.words.entryCount', { count: activeSectionCount }) }}</span>
<VSwitch
v-if="activeSection === 'identifiers'"
v-model="showLineNumbers"
class="words-editor-switch"
color="primary"
density="compact"
hide-details
:label="t('setting.words.lineNumbers')"
:mobile-layout="false"
/>
<VSwitch
v-if="activeSection === 'identifiers'"
v-model="showSyntaxHighlighting"
class="words-editor-switch"
color="primary"
density="compact"
hide-details
:label="t('setting.words.syntaxHighlighting')"
:mobile-layout="false"
/>
</div>
</div>
<VAceEditor
v-if="activeSection === 'identifiers'"
v-model:value="activeTextValue"
lang="word_list"
:theme="textEditorTheme"
:options="textEditorOptions"
:placeholder="activeTextPlaceholder"
:print-margin="false"
wrap
class="words-text-editor"
@init="onAceInit"
/>
<VTextarea
v-else
v-model="activeTextValue"
class="words-textarea"
:placeholder="activeTextPlaceholder"
variant="outlined"
rows="11"
hide-details
no-resize
spellcheck="false"
/>
<div class="words-inline-hint">
<VIcon icon="mdi-information-outline" size="17" />
<span>{{ activeTextHint }}</span>
</div>
</template>
<template v-else>
<div class="words-rule-toolbar">
<span>{{ t('setting.words.ruleCount', { count: activeSectionCount }) }}</span>
<VBtn variant="outlined" prepend-icon="mdi-plus" @click="addEpisodeRule">
{{ t('setting.words.episodeFormatRuleAdd') }}
</VBtn>
</div>
<Draggable
v-model="episodeFormatRules"
handle=".episode-rule-drag"
item-key="_localId"
tag="div"
:component-data="{ class: 'episode-rule-list' }"
@end="onEpisodeRuleDragEnd"
>
<template #item="{ element, index }">
<article class="episode-rule-card">
<div class="episode-rule-card-header">
<IconBtn
icon="mdi-drag-vertical"
variant="text"
class="episode-rule-drag cursor-move"
:aria-label="t('setting.words.dragToSort')"
/>
<VSwitch
v-model="element.enabled"
color="primary"
density="compact"
hide-details
:label="t('common.enable')"
class="episode-rule-enabled"
/>
</div>
<VTextField
v-model="element.name"
:label="t('setting.words.episodeFormatRuleName')"
hide-details="auto"
density="comfortable"
required
class="episode-rule-name"
/>
<VTextField
v-model="element.pattern"
:label="t('setting.words.episodeFormatRulePattern')"
hide-details="auto"
density="comfortable"
required
class="episode-rule-pattern"
/>
<VTextField
v-model.number="element.min_file_size_mb"
:label="t('setting.words.episodeFormatRuleMinSize')"
type="number"
min="0"
hide-details="auto"
density="comfortable"
required
class="episode-rule-size"
/>
<IconBtn
variant="text"
color="error"
class="episode-rule-delete"
:aria-label="t('common.delete')"
@click.stop="deleteEpisodeRule(index)"
>
<VIcon icon="mdi-delete-outline" />
<VTooltip activator="parent" location="top">{{ t('common.delete') }}</VTooltip>
</IconBtn>
</article>
</template>
</Draggable>
<div v-if="episodeFormatRules.length === 0" class="words-empty-state">
<VIcon icon="mdi-format-list-numbered" size="36" />
<span>{{ t('setting.words.noRules') }}</span>
</div>
</template>
<VExpansionPanels
v-if="shouldShowGuidePanel"
v-model="expandedHelp"
class="words-help-panels"
variant="accordion"
>
<VExpansionPanel value="guide" elevation="0">
<VExpansionPanelTitle>
<template #default>
<span class="words-help-title">
<VIcon icon="mdi-information-outline" color="primary" size="21" />
{{ activeGuideTitle }}
</span>
</template>
</VExpansionPanelTitle>
<VExpansionPanelText>
<div class="words-help-content" v-html="activeGuideContent.split('\n').join('<br>')" />
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
</div>
<footer class="words-editor-footer">
<div class="words-footer-state" :class="{ 'words-footer-state--dirty': activeSectionDirty }">
<VIcon :icon="activeSectionDirty ? 'mdi-circle-medium' : 'mdi-check-circle-outline'" size="18" />
<span>{{ activeSectionDirty ? t('setting.words.unsaved') : t('setting.words.saved') }}</span>
</div>
<div class="words-footer-actions">
<VBtn
variant="text"
:disabled="!activeSectionDirty || saving"
prepend-icon="mdi-restore"
@click="resetActiveSection"
>
{{ t('common.reset') }}
</VBtn>
<VBtn color="primary" :loading="saving" prepend-icon="mdi-content-save" @click="saveActiveSection">
{{ t('setting.words.saveChanges') }}
</VBtn>
</div>
</footer>
</section>
</div>
</div>
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.words-view {
--words-surface-background: var(--app-grouped-list-background);
--words-surface-filter: var(--app-grouped-list-backdrop-filter);
--words-surface-border: var(--app-grouped-list-border);
--words-surface-radius: var(--app-grouped-list-radius);
--words-separator-color: var(--app-grouped-list-separator-color);
--words-hover-background: var(--app-grouped-list-hover-background);
--words-active-background: var(--app-grouped-list-active-background);
display: flex;
min-block-size: 0;
flex-direction: column;
background: transparent;
}
.words-summary {
align-items: center;
min-block-size: 3rem;
padding: 0.75rem 1.25rem;
border-block-end: 1px solid var(--words-separator-color);
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.8125rem;
}
.words-workspace {
display: grid;
overflow: hidden;
min-block-size: 32rem;
block-size: min(40rem, calc(100dvh - 10rem));
border: var(--words-surface-border);
border-radius: var(--words-surface-radius);
backdrop-filter: var(--words-surface-filter);
background: var(--words-surface-background);
grid-template-columns: 19rem minmax(0, 1fr);
}
.words-sidebar {
min-block-size: 0;
border-inline-end: 1px solid var(--words-separator-color);
flex-direction: column;
}
.words-sidebar-item {
display: grid;
min-block-size: 6.25rem;
align-items: center;
border: 0;
border-block-end: 1px solid var(--words-separator-color);
background: transparent;
color: inherit;
cursor: pointer;
gap: 0.75rem;
grid-template-columns: max-content minmax(0, 1fr) max-content;
padding: 0.875rem 1rem;
text-align: start;
transition: background-color 0.18s ease;
}
.words-sidebar-item:hover,
.words-sidebar-item:focus-visible {
background: var(--words-hover-background);
outline: none;
}
.words-sidebar-item--active {
position: relative;
background: var(--words-active-background);
color: rgb(var(--v-theme-primary));
}
.words-sidebar-item--active::before {
position: absolute;
background: rgb(var(--v-theme-primary));
content: '';
inline-size: 3px;
inset-block: 0;
inset-inline-start: 0;
}
.words-section-icon {
flex: 0 0 auto;
}
.words-sidebar-copy {
display: flex;
min-inline-size: 0;
flex-direction: column;
gap: 0.25rem;
}
.words-sidebar-copy strong {
overflow: hidden;
color: rgb(var(--v-theme-on-surface));
font-size: 0.9375rem;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.words-sidebar-copy small {
display: -webkit-box;
overflow: hidden;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
line-height: 1.45;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.words-sidebar-state {
display: flex;
align-items: flex-end;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
flex-direction: column;
gap: 0.35rem;
}
.words-sidebar-state small {
font-size: 0.72rem;
white-space: nowrap;
}
.words-sidebar-hint {
display: flex;
align-items: center;
gap: 0.5rem;
margin-block-start: auto;
padding: 1rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
}
.words-editor {
display: flex;
overflow: hidden;
min-block-size: 0;
flex: 1 1 auto;
flex-direction: column;
}
.words-editor-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.35rem 1.5rem 1rem;
}
.words-editor-heading {
min-inline-size: 0;
}
.words-editor-heading h2 {
margin: 0;
color: rgb(var(--v-theme-on-surface));
font-size: 1.25rem;
font-weight: 650;
line-height: 1.35;
}
.words-editor-heading p {
margin: 0.35rem 0 0;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.8125rem;
line-height: 1.55;
}
.words-save-state,
.words-footer-state {
display: flex;
flex: 0 0 auto;
align-items: center;
color: rgb(var(--v-theme-success));
font-size: 0.78rem;
gap: 0.3rem;
white-space: nowrap;
}
.words-save-state--dirty,
.words-footer-state--dirty {
color: rgb(var(--v-theme-warning));
}
.words-editor-scroll {
overflow: auto;
min-block-size: 0;
flex: 1 1 auto;
padding: 0 1.5rem 1.25rem;
}
.words-field-meta,
.words-rule-toolbar {
display: flex;
min-block-size: 2.5rem;
align-items: center;
justify-content: space-between;
gap: 1rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.78rem;
}
.words-field-meta strong {
color: rgb(var(--v-theme-on-surface));
font-size: 0.85rem;
font-weight: 600;
}
.words-field-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.8rem;
}
.words-editor-switch {
flex: 0 0 auto;
}
.words-editor-switch :deep(.v-label) {
font-size: 0.78rem;
}
.words-textarea :deep(textarea) {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
font-size: 0.85rem;
line-height: 1.65;
}
.words-textarea :deep(.v-field) {
background: transparent;
}
.words-text-editor {
--words-token-block: #af00db;
--words-token-replaced: #001080;
--words-token-replacement: #a31515;
--words-token-parameter-syntax: #795e26;
--words-token-parameter-key: #0451a5;
--words-token-parameter-value: #098658;
--words-token-front: #267f99;
--words-token-back: #795e26;
--words-token-offset: #098658;
--words-token-comment: #008000;
--words-token-operator: #000;
overflow: hidden;
block-size: 18.8rem;
border: 1px solid rgba(var(--v-theme-on-surface), var(--v-border-opacity));
border-radius: var(--app-surface-radius);
contain: paint;
overscroll-behavior: contain;
transform: translateZ(0);
}
.words-text-editor.ace-github-dark {
--words-token-block: #c586c0;
--words-token-replaced: #9cdcfe;
--words-token-replacement: #ce9178;
--words-token-parameter-syntax: #dcdcaa;
--words-token-parameter-key: #9cdcfe;
--words-token-parameter-value: #b5cea8;
--words-token-front: #4ec9b0;
--words-token-back: #dcdcaa;
--words-token-offset: #b5cea8;
--words-token-comment: #6a9955;
--words-token-operator: #d4d4d4;
}
.words-text-editor :deep(.ace_scroller),
.words-text-editor :deep(.ace_content),
.words-text-editor :deep(.ace_text-layer) {
transform: translateZ(0);
will-change: transform;
}
.words-text-editor :deep(.ace_comment) {
color: var(--words-token-comment) !important;
font-style: normal;
}
.words-text-editor :deep(.ace_gutter-layer) {
text-align: start;
}
.words-text-editor :deep(.ace_gutter-cell) {
padding-inline: 0.35rem 0.25rem;
}
.words-text-editor :deep(.ace_word_list_block) {
color: var(--words-token-block);
}
.words-text-editor :deep(.ace_word_list_replaced) {
color: var(--words-token-replaced);
}
.words-text-editor :deep(.ace_word_list_replacement) {
color: var(--words-token-replacement);
}
.words-text-editor :deep(.ace_word_list_parameter_syntax) {
color: var(--words-token-parameter-syntax);
}
.words-text-editor :deep(.ace_word_list_parameter_key) {
color: var(--words-token-parameter-key);
}
.words-text-editor :deep(.ace_word_list_parameter_value) {
color: var(--words-token-parameter-value);
}
.words-text-editor :deep(.ace_invalid.ace_word-list) {
color: #f44336 !important;
background-color: transparent !important;
text-decoration: underline wavy #f44336 !important;
text-underline-offset: 0.12em;
}
.words-text-editor :deep(.ace_word_list_front) {
color: var(--words-token-front);
}
.words-text-editor :deep(.ace_word_list_back) {
color: var(--words-token-back);
}
.words-text-editor :deep(.ace_word_list_offset) {
color: var(--words-token-offset);
}
.words-text-editor :deep(.ace_keyword.ace_operator.ace_word-list) {
color: var(--words-token-operator);
}
.words-inline-hint {
display: flex;
align-items: flex-start;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
gap: 0.4rem;
line-height: 1.5;
margin-block-start: 0.65rem;
}
.words-help-panels {
min-inline-size: 0;
margin-block-start: 1rem;
}
.words-help-panels :deep(.v-expansion-panel) {
overflow: hidden;
border: var(--words-surface-border);
border-radius: var(--app-surface-radius) !important;
background: var(--words-hover-background);
}
.words-help-panels :deep(.v-expansion-panel-title) {
min-block-size: 3.25rem;
}
.words-help-title {
display: flex;
align-items: center;
color: rgb(var(--v-theme-primary));
font-weight: 600;
gap: 0.55rem;
}
.words-help-content {
min-inline-size: 0;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.78rem;
line-height: 1.7;
overflow-wrap: anywhere;
white-space: normal;
word-break: break-word;
}
.words-editor-footer {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
min-block-size: 4.5rem;
border-block-start: 1px solid var(--words-separator-color);
backdrop-filter: var(--words-surface-filter);
background: var(--words-surface-background);
gap: 1rem;
padding: 0.75rem 1.5rem;
}
.words-footer-state {
display: none;
}
.words-footer-actions {
display: flex;
align-items: center;
gap: 0.65rem;
margin-inline-start: auto;
}
.episode-rule-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.episode-rule-card {
display: grid;
align-items: start;
border: 1px solid var(--words-separator-color);
border-radius: var(--app-surface-radius);
background: transparent;
gap: 0.75rem;
grid-template-areas:
'toolbar name size delete'
'pattern pattern pattern pattern';
grid-template-columns: max-content minmax(10rem, 1fr) minmax(9rem, 11rem) max-content;
padding: 0.85rem;
}
.episode-rule-card-header {
display: flex;
min-block-size: 3.5rem;
align-items: center;
gap: 0.3rem;
grid-area: toolbar;
}
.episode-rule-enabled {
flex: 0 0 auto;
}
.episode-rule-enabled :deep(.v-label) {
display: none;
}
.episode-rule-name {
grid-area: name;
}
.episode-rule-pattern {
grid-area: pattern;
}
.episode-rule-size {
grid-area: size;
}
.episode-rule-delete {
align-self: center;
grid-area: delete;
justify-self: end;
}
.words-empty-state {
display: flex;
min-block-size: 10rem;
align-items: center;
justify-content: center;
border: 1px dashed var(--words-separator-color);
border-radius: var(--app-surface-radius);
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
flex-direction: column;
gap: 0.75rem;
}
@media (width <= 959.98px) {
.words-view {
flex: 1 1 auto;
block-size: 100%;
min-block-size: 0;
}
.words-workspace {
display: flex;
overflow: hidden;
min-block-size: 0;
block-size: 100%;
border: 0;
border-radius: 0;
flex-direction: column;
}
.words-mobile-tabs {
display: flex;
overflow-x: auto;
flex: 0 0 auto;
border-block-end: 1px solid var(--words-separator-color);
background: var(--words-surface-background);
scrollbar-width: none;
}
.words-mobile-tabs::-webkit-scrollbar {
display: none;
}
.words-mobile-tab {
position: relative;
display: flex;
min-inline-size: 5.2rem;
min-block-size: 5.4rem;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
cursor: pointer;
flex: 1 0 auto;
flex-direction: column;
font-size: 0.78rem;
gap: 0.45rem;
padding: 0.75rem 0.6rem 0.6rem;
}
.words-mobile-tab::after {
position: absolute;
background: transparent;
block-size: 3px;
content: '';
inset-block-end: 0;
inset-inline: 0.65rem;
}
.words-mobile-tab--active {
color: rgb(var(--v-theme-primary));
}
.words-mobile-tab--active::after {
background: rgb(var(--v-theme-primary));
}
.words-mobile-tab-icon {
position: relative;
display: inline-flex;
}
.words-dirty-dot {
position: absolute;
border: 2px solid var(--words-surface-background);
border-radius: 50%;
background: rgb(var(--v-theme-warning));
block-size: 0.55rem;
inline-size: 0.55rem;
inset-block-start: -0.18rem;
inset-inline-end: -0.3rem;
}
.words-editor-header {
padding: 1.4rem 1.25rem 0.85rem;
}
.words-editor-heading h2 {
font-size: 1.45rem;
}
.words-editor-heading p {
font-size: 0.85rem;
}
.words-save-state {
display: none;
}
.words-editor-scroll {
padding: 0 1.25rem 1.25rem;
}
.words-field-meta {
align-items: flex-start;
flex-wrap: wrap;
gap: 0.75rem;
min-block-size: 2.75rem;
}
.words-field-meta strong {
min-inline-size: 0;
}
.words-field-actions {
gap: 0.5rem;
inline-size: 100%;
}
.words-textarea :deep(.v-field__input) {
min-block-size: 18rem;
}
.words-text-editor {
block-size: 18rem;
}
.words-rule-toolbar {
align-items: stretch;
flex-direction: column;
margin-block-end: 1rem;
}
.words-rule-toolbar .v-btn {
inline-size: 100%;
}
.episode-rule-card {
grid-template-areas:
'toolbar delete'
'name name'
'pattern pattern'
'size size';
grid-template-columns: minmax(0, 1fr) max-content;
gap: 0.75rem;
padding: 0.95rem;
}
.episode-rule-card-header {
justify-content: flex-start;
}
.episode-rule-delete {
margin-inline-start: 0;
}
.episode-rule-enabled :deep(.v-label) {
display: inline-flex;
}
.words-editor-footer {
position: relative;
min-block-size: calc(5.25rem + env(safe-area-inset-bottom));
padding: 0.75rem 1.25rem calc(0.75rem + env(safe-area-inset-bottom));
}
.words-footer-state {
display: flex;
font-size: 0.8rem;
}
.words-footer-actions {
gap: 0.35rem;
}
.words-footer-actions .v-btn {
min-inline-size: 0;
}
}
@media (width <= 420px) {
.words-mobile-tab {
min-inline-size: 4.85rem;
}
.words-editor-header {
padding-inline: 1rem;
}
.words-editor-scroll {
padding-inline: 1rem;
}
.words-editor-footer {
padding-inline: 1rem;
}
}
</style>