Files
Transcriptarr/frontend/src/views/RulesView.vue
Dasemu 4efdce8983
Some checks failed
Build_Subgen_Dockerfile_CPU / docker (push) Failing after 27s
Build_Subgen_Dockerfile_GPU / docker (push) Has been cancelled
feat(frontend): add Vue 3 web application
- Add Vue 3 + TypeScript + Pinia setup
- Add 6 complete views: Dashboard, Queue, Scanner, Rules, Workers, Settings
- Add Pinia stores for state management
- Add API service with Axios client
- Add dark theme with Tdarr-inspired styling
- Add setup wizard component
- Add path browser for filesystem navigation
2026-01-16 16:59:15 +01:00

453 lines
12 KiB
Vue

<template>
<div class="rules-view">
<div class="page-header">
<h1 class="page-title">Scan Rules</h1>
<button @click="showCreateModal = true" class="btn btn-primary">Create Rule</button>
</div>
<div v-if="loading" class="spinner"></div>
<div v-else-if="rules.length === 0" class="empty-state">
<p>No scan rules configured yet. Create your first rule to start automatic scanning.</p>
</div>
<div v-else class="rules-grid">
<div v-for="rule in rules" :key="rule.id" class="rule-card">
<div class="rule-header">
<h3 class="rule-name">{{ rule.name }}</h3>
<div class="rule-actions">
<button
@click="toggleRule(rule)"
:class="['btn-toggle', rule.enabled ? 'enabled' : 'disabled']"
:title="rule.enabled ? 'Disable' : 'Enable'"
>
{{ rule.enabled ? '✓' : '✕' }}
</button>
<button @click="editRule(rule)" class="btn-edit" title="Edit"></button>
<button @click="deleteRule(rule.id)" class="btn-delete" title="Delete">🗑</button>
</div>
</div>
<div class="rule-body">
<div class="rule-detail">
<span class="detail-label">Priority:</span>
<span class="detail-value">{{ rule.priority }}</span>
</div>
<div class="rule-detail">
<span class="detail-label">Audio:</span>
<span class="detail-value">{{ rule.conditions?.audio_language_is || 'Any' }}</span>
</div>
<div class="rule-detail">
<span class="detail-label">Action:</span>
<span class="detail-value">{{ rule.action?.action_type }} {{ rule.action?.target_language }}</span>
</div>
<div v-if="rule.conditions?.missing_external_subtitle_lang" class="rule-detail">
<span class="detail-label">Check missing:</span>
<span class="detail-value">{{ rule.conditions.missing_external_subtitle_lang }}</span>
</div>
</div>
</div>
</div>
<!-- Create/Edit Rule Modal -->
<div v-if="showCreateModal || editingRule" class="modal-overlay" @click="closeModal">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h2>{{ editingRule ? 'Edit Rule' : 'Create Rule' }}</h2>
<button @click="closeModal" class="btn-close"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Rule Name</label>
<input v-model="formData.name" type="text" class="form-input" placeholder="e.g., Japanese anime to Spanish" />
</div>
<div class="form-group">
<label>Priority (higher = first)</label>
<input v-model.number="formData.priority" type="number" class="form-input" />
</div>
<div class="form-group">
<label>Audio Language (empty = any)</label>
<input v-model="formData.audio_language_is" type="text" class="form-input" placeholder="ja, en, es..." />
</div>
<div class="form-group">
<label>Action Type</label>
<select v-model="formData.action_type" class="form-select" @change="onActionTypeChange">
<option value="transcribe">Transcribe (audio English)</option>
<option value="translate">Translate (audio English target language)</option>
</select>
</div>
<div class="form-group">
<label>
Target Language
<span v-if="formData.action_type === 'transcribe'" class="setting-description">
(Fixed: en - transcribe mode only creates English subtitles)
</span>
</label>
<input
v-if="formData.action_type === 'translate'"
v-model="formData.target_language"
type="text"
class="form-input"
placeholder="es, fr, de, it..."
required
/>
<input
v-else
value="en"
type="text"
class="form-input"
disabled
readonly
/>
</div>
<div class="form-group">
<label>Check Missing Subtitle</label>
<input v-model="formData.missing_external_subtitle_lang" type="text" class="form-input" placeholder="es, en..." />
</div>
<div class="form-group">
<label class="checkbox-label">
<input v-model="formData.enabled" type="checkbox" />
<span>Enabled</span>
</label>
</div>
</div>
<div class="modal-footer">
<button @click="saveRule" class="btn btn-primary">{{ editingRule ? 'Update' : 'Create' }}</button>
<button @click="closeModal" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import api from '@/services/api'
interface Rule {
id: number
name: string
enabled: boolean
priority: number
conditions: {
audio_language_is?: string | null
audio_language_not?: string | null
audio_track_count_min?: number | null
has_embedded_subtitle_lang?: string | null
missing_embedded_subtitle_lang?: string | null
missing_external_subtitle_lang?: string | null
file_extension?: string | null
}
action: {
action_type: string
target_language: string
quality_preset?: string
job_priority?: number
}
created_at?: string
updated_at?: string
}
const rules = ref<Rule[]>([])
const loading = ref(true)
const showCreateModal = ref(false)
const editingRule = ref<Rule | null>(null)
const formData = ref({
name: '',
priority: 10,
audio_language_is: '',
target_language: 'en', // Default to 'en' for transcribe mode
action_type: 'transcribe',
missing_external_subtitle_lang: '',
enabled: true
})
async function loadRules() {
loading.value = true
try {
const response = await api.get('/scan-rules')
rules.value = response.data || []
} catch (error: any) {
console.error('Failed to load rules:', error)
rules.value = []
} finally {
loading.value = false
}
}
async function toggleRule(rule: Rule) {
try {
await api.post(`/scan-rules/${rule.id}/toggle`)
await loadRules()
} catch (error: any) {
alert('Failed to toggle rule: ' + (error.response?.data?.detail || error.message))
}
}
function onActionTypeChange() {
// When switching to transcribe mode, force target language to 'en'
if (formData.value.action_type === 'transcribe') {
formData.value.target_language = 'en'
}
}
function editRule(rule: Rule) {
editingRule.value = rule
formData.value = {
name: rule.name,
priority: rule.priority,
audio_language_is: rule.conditions?.audio_language_is || '',
target_language: rule.action?.target_language || 'en',
action_type: rule.action?.action_type || 'transcribe',
missing_external_subtitle_lang: rule.conditions?.missing_external_subtitle_lang || '',
enabled: rule.enabled
}
}
async function saveRule() {
try {
// Force target_language to 'en' if action_type is 'transcribe'
const targetLanguage = formData.value.action_type === 'transcribe'
? 'en'
: formData.value.target_language
const payload = {
name: formData.value.name,
enabled: formData.value.enabled,
priority: formData.value.priority,
conditions: {
audio_language_is: formData.value.audio_language_is || null,
missing_external_subtitle_lang: formData.value.missing_external_subtitle_lang || null
},
action: {
action_type: formData.value.action_type,
target_language: targetLanguage,
quality_preset: 'fast',
job_priority: 0
}
}
if (editingRule.value) {
await api.put(`/scan-rules/${editingRule.value.id}`, payload)
} else {
await api.post('/scan-rules', payload)
}
closeModal()
await loadRules()
} catch (error: any) {
alert('Failed to save rule: ' + (error.response?.data?.detail || error.message))
}
}
async function deleteRule(id: number) {
if (!confirm('Delete this rule?')) return
try {
await api.delete(`/scan-rules/${id}`)
await loadRules()
} catch (error: any) {
alert('Failed to delete rule: ' + (error.response?.data?.detail || error.message))
}
}
function closeModal() {
showCreateModal.value = false
editingRule.value = null
formData.value = {
name: '',
priority: 10,
audio_language_is: '',
target_language: 'en', // Default to 'en' for transcribe mode
action_type: 'transcribe',
missing_external_subtitle_lang: '',
enabled: true
}
}
onMounted(() => {
loadRules()
})
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
}
.page-title {
font-size: 2rem;
color: var(--text-primary);
margin: 0;
}
.rules-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: var(--spacing-lg);
}
.rule-card {
background-color: var(--secondary-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--spacing-lg);
}
.rule-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
}
.rule-name {
margin: 0;
font-size: 1.125rem;
color: var(--text-primary);
}
.rule-actions {
display: flex;
gap: var(--spacing-xs);
}
.btn-toggle, .btn-edit, .btn-delete {
padding: 4px 8px;
border: 1px solid var(--border-color);
background-color: var(--tertiary-bg);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
}
.btn-toggle.enabled {
background-color: var(--success-color);
color: white;
}
.btn-toggle.disabled {
background-color: var(--text-muted);
color: white;
}
.rule-body {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.rule-detail {
display: flex;
justify-content: space-between;
}
.detail-label {
font-weight: 600;
color: var(--text-secondary);
}
.detail-value {
color: var(--text-primary);
font-family: monospace;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background-color: var(--secondary-bg);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
color: var(--text-primary);
}
.btn-close {
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-muted);
cursor: pointer;
}
.modal-body {
padding: var(--spacing-lg);
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-group label {
display: block;
margin-bottom: var(--spacing-xs);
font-weight: 600;
color: var(--text-secondary);
}
.form-input, .form-select {
width: 100%;
padding: var(--spacing-sm);
background-color: var(--tertiary-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--spacing-sm);
cursor: pointer;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-md);
padding: var(--spacing-lg);
border-top: 1px solid var(--border-color);
}
.empty-state {
text-align: center;
padding: var(--spacing-xl);
color: var(--text-muted);
background-color: var(--secondary-bg);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
}
</style>