Revamp automation page: 2-col grid, duplicate, search/filter, templates, grouping

- Switch user automations to 2-column grid layout (matches system automations)
- Add duplicate button on non-system cards with POST /api/automations/<id>/duplicate
- Add search/filter bar (text search + trigger/action dropdowns) shown at 6+ automations
- Add Inspiration section with 8 starter templates that pre-fill the builder
- Add folder-style automation grouping with group_name DB column, dropdown
  popover for assignment, collapsible group sections, and builder group input
pull/253/head
Broque Thomas 2 months ago
parent 4a308483ab
commit 4c6e2fe1ec

@ -445,6 +445,7 @@ class MusicDatabase:
self._add_automation_notify_columns(cursor)
self._add_automation_system_column(cursor)
self._add_automation_then_actions_column(cursor)
self._add_automation_group_name_column(cursor)
# Library issues — user-reported problems with tracks/albums/artists
cursor.execute("""
@ -520,6 +521,17 @@ class MusicDatabase:
except Exception as e:
logger.error(f"Error adding automation system column: {e}")
def _add_automation_group_name_column(self, cursor):
"""Add group_name column to automations table for folder-style grouping."""
try:
cursor.execute("PRAGMA table_info(automations)")
cols = [c[1] for c in cursor.fetchall()]
if 'group_name' not in cols:
cursor.execute("ALTER TABLE automations ADD COLUMN group_name TEXT DEFAULT NULL")
logger.info("Added group_name column to automations table")
except Exception as e:
logger.error(f"Error adding automation group_name column: {e}")
def _add_automation_then_actions_column(self, cursor):
"""Add then_actions column to automations table and migrate existing notify data."""
try:
@ -8322,15 +8334,15 @@ class MusicDatabase:
def create_automation(self, name: str, trigger_type: str, trigger_config: str,
action_type: str, action_config: str, profile_id: int = 1,
notify_type: str = None, notify_config: str = '{}',
then_actions: str = '[]'):
then_actions: str = '[]', group_name: str = None):
"""Create a new automation. Returns the new automation ID or None."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO automations (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions))
INSERT INTO automations (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name))
conn.commit()
return cursor.lastrowid
except Exception as e:
@ -8377,7 +8389,7 @@ class MusicDatabase:
def update_automation(self, automation_id: int, **kwargs) -> bool:
"""Update automation fields."""
allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result', 'is_system', 'then_actions'}
allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result', 'is_system', 'then_actions', 'group_name'}
updates = {k: v for k, v in kwargs.items() if k in allowed}
if not updates:
return False

@ -4790,8 +4790,9 @@ def create_automation():
if cycle:
return jsonify({"error": f"Signal cycle detected: {''.join(cycle)}. This would cause an infinite loop."}), 400
group_name = data.get('group_name') or None
db = get_database()
auto_id = db.create_automation(name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions_json)
auto_id = db.create_automation(name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions_json, group_name)
if auto_id is None:
return jsonify({"error": "Failed to create automation"}), 500
@ -4864,6 +4865,8 @@ def update_automation_endpoint(automation_id):
update_fields['notify_type'] = data['notify_type'] or None
if 'notify_config' in data and 'then_actions' not in data:
update_fields['notify_config'] = json.dumps(data['notify_config'])
if 'group_name' in data:
update_fields['group_name'] = data['group_name'] or None
if not update_fields:
return jsonify({"error": "No fields to update"}), 400
@ -4927,6 +4930,38 @@ def delete_automation_endpoint(automation_id):
logger.error(f"Error deleting automation: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/automations/<int:automation_id>/duplicate', methods=['POST'])
def duplicate_automation_endpoint(automation_id):
"""Duplicate an automation. System automations cannot be duplicated."""
try:
db = get_database()
auto = db.get_automation(automation_id)
if not auto:
return jsonify({"error": "Automation not found"}), 404
if auto.get('is_system'):
return jsonify({"error": "System automations cannot be duplicated"}), 403
profile_id = session.get('profile_id', 1)
new_id = db.create_automation(
name=f"{auto['name']} (Copy)",
trigger_type=auto['trigger_type'],
trigger_config=auto.get('trigger_config', '{}'),
action_type=auto['action_type'],
action_config=auto.get('action_config', '{}'),
profile_id=profile_id,
notify_type=auto.get('notify_type'),
notify_config=auto.get('notify_config', '{}'),
then_actions=auto.get('then_actions', '[]'),
group_name=auto.get('group_name'),
)
if new_id is None:
return jsonify({"error": "Failed to duplicate automation"}), 500
if automation_engine:
automation_engine.schedule_automation(new_id)
return jsonify({"success": True, "id": new_id})
except Exception as e:
logger.error(f"Error duplicating automation: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/automations/<int:automation_id>/toggle', methods=['POST'])
def toggle_automation_endpoint(automation_id):
"""Toggle an automation's enabled state."""

@ -2339,6 +2339,12 @@
</div>
</div>
<div class="automations-stats" id="automations-stats"></div>
<div class="auto-filter-bar" id="auto-filter-bar" style="display:none;">
<input type="text" class="auto-filter-search" id="auto-filter-search" placeholder="Search automations...">
<select class="auto-filter-select" id="auto-filter-trigger"><option value="">All Triggers</option></select>
<select class="auto-filter-select" id="auto-filter-action"><option value="">All Actions</option></select>
<span class="auto-filter-count" id="auto-filter-count"></span>
</div>
<div class="automations-list" id="automations-list"></div>
<div class="automations-empty" id="automations-empty" style="display:none;">
<div class="automations-empty-icon">&#9889;</div>
@ -2354,6 +2360,8 @@
<div class="builder-header">
<button class="builder-back-btn" onclick="hideAutomationBuilder()" title="Back to list">&#8592;</button>
<input type="text" id="builder-name" class="builder-name-input" placeholder="Automation Name">
<input type="text" id="builder-group-name" class="builder-group-input" placeholder="Group (optional)" list="builder-group-list">
<datalist id="builder-group-list"></datalist>
<div class="builder-header-actions">
<button class="btn-cancel" onclick="hideAutomationBuilder()">Cancel</button>
<button class="btn-save" onclick="saveAutomation()">Save</button>

@ -55043,6 +55043,26 @@ const _autoIcons = {
full_cleanup: '\uD83E\uDDF9',
};
// --- Inspiration Templates ---
const AUTO_TEMPLATES = [
{ icon: '\uD83D\uDD01', name: 'Playlist Auto-Sync Pipeline', desc: 'Refresh a mirrored playlist, discover new tracks, and sync to your server automatically.',
category: 'Sync', when: { type: 'schedule', config: { interval: 6, unit: 'hours' } }, do: { type: 'refresh_mirrored', config: {} }, then: [] },
{ icon: '\uD83D\uDD14', name: 'New Release Monitor', desc: 'Scan your watchlist for new releases every 12 hours.',
category: 'Monitor', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [] },
{ icon: '\uD83C\uDF19', name: 'Nightly Wishlist Processor', desc: 'Process your wishlist at 3 AM every night while you sleep.',
category: 'Sync', when: { type: 'daily_time', config: { time: '03:00' } }, do: { type: 'process_wishlist', config: {} }, then: [] },
{ icon: '\uD83D\uDD0D', name: 'Discovery Pipeline', desc: 'Auto-discover tracks when a new playlist is mirrored.',
category: 'Sync', when: { type: 'mirrored_playlist_created', config: {} }, do: { type: 'discover_playlist', config: {} }, then: [] },
{ icon: '\uD83D\uDCBE', name: 'Weekly Library Backup', desc: 'Back up your database every Sunday at 4 AM.',
category: 'Maintenance', when: { type: 'weekly_time', config: { days: ['sunday'], time: '04:00' } }, do: { type: 'backup_database', config: {} }, then: [] },
{ icon: '\uD83E\uDDF9', name: 'Post-Batch Cleanup', desc: 'Run a full cleanup after any batch download completes.',
category: 'Maintenance', when: { type: 'batch_complete', config: {} }, do: { type: 'full_cleanup', config: {} }, then: [] },
{ icon: '\u274C', name: 'Download Failure Alert', desc: 'Get notified via Discord when a download fails.',
category: 'Monitor', when: { type: 'download_failed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }] },
{ icon: '\uD83E\uDDF9', name: 'Full Library Maintenance', desc: 'Run full cleanup every Saturday at 5 AM — dedup, quarantine, wishlist tidy.',
category: 'Maintenance', when: { type: 'weekly_time', config: { days: ['saturday'], time: '05:00' } }, do: { type: 'full_cleanup', config: {} }, then: [] },
];
// --- Load & Render List ---
function _buildAutomationSection(id, label, automations, useGrid) {
@ -55097,8 +55117,21 @@ async function loadAutomations() {
if (systemAutos.length) {
list.appendChild(_buildAutomationSection('auto-section-system', 'System', systemAutos, true));
}
if (userAutos.length) {
list.appendChild(_buildAutomationSection('auto-section-custom', 'My Automations', userAutos, false));
// Inspiration / Templates section
list.appendChild(_buildTemplatesSection());
// User automations — split by group
const groups = [...new Set(userAutos.filter(a => a.group_name).map(a => a.group_name))].sort();
const ungrouped = userAutos.filter(a => !a.group_name);
groups.forEach(g => {
const groupAutos = userAutos.filter(a => a.group_name === g);
if (groupAutos.length) {
list.appendChild(_buildAutomationSection('auto-section-group-' + g.replace(/\W+/g, '_'), '\uD83D\uDCC1 ' + g, groupAutos, true));
}
});
if (ungrouped.length) {
list.appendChild(_buildAutomationSection('auto-section-custom', 'My Automations', ungrouped, true));
}
// Stats summary bar
@ -55113,6 +55146,9 @@ async function loadAutomations() {
<span class="auto-stat"><strong>${custom}</strong> Custom</span>
`;
}
// Filter bar — show when 6+ automations
_initAutoFilterBar(automations);
// Catch up on current automation progress
try {
const progRes = await fetch('/api/automations/progress');
@ -55125,10 +55161,210 @@ async function loadAutomations() {
}
}
// --- Templates Section ---
function _buildTemplatesSection() {
const section = document.createElement('div');
section.className = 'automations-section';
section.id = 'auto-section-templates';
const collapsed = localStorage.getItem('auto_section_auto-section-templates') === '1';
if (collapsed) section.classList.add('collapsed');
const header = document.createElement('div');
header.className = 'automations-section-header';
header.innerHTML = `
<span class="section-chevron">&#9660;</span>
<span class="section-label">Inspiration</span>
<span class="section-count">${AUTO_TEMPLATES.length}</span>
<span class="section-line"></span>
`;
header.onclick = () => {
section.classList.toggle('collapsed');
localStorage.setItem('auto_section_auto-section-templates', section.classList.contains('collapsed') ? '1' : '0');
};
const body = document.createElement('div');
body.className = 'automations-section-body';
const grid = document.createElement('div');
grid.className = 'automations-grid';
AUTO_TEMPLATES.forEach((t, i) => {
const card = document.createElement('div');
card.className = 'auto-template-card';
const trigLabel = _autoFormatTrigger(t.when.type, t.when.config);
const actLabel = _autoFormatAction(t.do.type);
card.innerHTML = `
<div class="auto-template-icon">${t.icon}</div>
<div class="auto-template-info">
<div class="auto-template-name">${_esc(t.name)}</div>
<div class="auto-template-desc">${_esc(t.desc)}</div>
<div class="auto-template-flow">
<span class="flow-trigger">${_esc(trigLabel)}</span>
<span class="flow-arrow">&rarr;</span>
<span class="flow-action">${_esc(actLabel)}</span>
${t.then.length ? t.then.map(th => `<span class="flow-arrow">&rarr;</span><span class="flow-notify">${_esc(_autoFormatNotify(th.type))}</span>`).join('') : ''}
</div>
</div>
<button class="auto-template-use" onclick="event.stopPropagation(); useTemplate(${i})">Use</button>
`;
card.onclick = () => useTemplate(i);
grid.appendChild(card);
});
body.appendChild(grid);
section.appendChild(header);
section.appendChild(body);
return section;
}
async function useTemplate(index) {
const t = AUTO_TEMPLATES[index];
if (!t) return;
await showAutomationBuilder();
document.getElementById('builder-name').value = t.name;
_autoBuilder.when = { type: t.when.type, config: JSON.parse(JSON.stringify(t.when.config)) };
_autoBuilder.do = { type: t.do.type, config: JSON.parse(JSON.stringify(t.do.config)) };
_autoBuilder.then = t.then.map(th => ({ type: th.type, config: JSON.parse(JSON.stringify(th.config)) }));
_renderBuilderSidebar();
_renderBuilderCanvas();
}
// --- Filter Bar ---
function _initAutoFilterBar(automations) {
const bar = document.getElementById('auto-filter-bar');
if (!bar) return;
if (automations.length < 7) { bar.style.display = 'none'; return; }
bar.style.display = '';
// Populate trigger dropdown
const trigSel = document.getElementById('auto-filter-trigger');
const actSel = document.getElementById('auto-filter-action');
const trigTypes = [...new Set(automations.map(a => a.trigger_type))].sort();
const actTypes = [...new Set(automations.map(a => a.action_type))].sort();
const prevTrig = trigSel.value;
const prevAct = actSel.value;
trigSel.innerHTML = '<option value="">All Triggers</option>' + trigTypes.map(t =>
`<option value="${_escAttr(t)}">${_esc(_autoFormatTrigger(t, {}))}</option>`).join('');
actSel.innerHTML = '<option value="">All Actions</option>' + actTypes.map(t =>
`<option value="${_escAttr(t)}">${_esc(_autoFormatAction(t))}</option>`).join('');
trigSel.value = prevTrig;
actSel.value = prevAct;
// Bind events (use a flag to avoid double-binding)
if (!bar.dataset.bound) {
bar.dataset.bound = '1';
document.getElementById('auto-filter-search').addEventListener('input', _filterAutomations);
trigSel.addEventListener('change', _filterAutomations);
actSel.addEventListener('change', _filterAutomations);
}
_filterAutomations();
}
function _filterAutomations() {
const q = (document.getElementById('auto-filter-search').value || '').toLowerCase().trim();
const trigFilter = document.getElementById('auto-filter-trigger').value;
const actFilter = document.getElementById('auto-filter-action').value;
const cards = document.querySelectorAll('#automations-list .automation-card');
let visible = 0;
cards.forEach(card => {
const name = (card.querySelector('.automation-name')?.textContent || '').toLowerCase();
const trig = card.querySelector('.flow-trigger')?.textContent || '';
const act = card.querySelector('.flow-action')?.textContent || '';
// Match search text against name, trigger label, action label
const matchQ = !q || name.includes(q) || trig.toLowerCase().includes(q) || act.toLowerCase().includes(q);
// Match trigger/action type filters using data attributes
const matchTrig = !trigFilter || card.dataset.triggerType === trigFilter;
const matchAct = !actFilter || card.dataset.actionType === actFilter;
const show = matchQ && matchTrig && matchAct;
card.style.display = show ? '' : 'none';
if (show) visible++;
});
const countEl = document.getElementById('auto-filter-count');
if (countEl) {
countEl.textContent = (q || trigFilter || actFilter) ? `${visible} of ${cards.length}` : '';
}
}
// --- Group Dropdown ---
let _activeGroupDropdown = null;
function _showGroupDropdown(event, autoId, currentGroup) {
// Close any existing dropdown
_closeGroupDropdown();
const btn = event.currentTarget;
const card = btn.closest('.automation-card');
if (!card) return;
// Collect all existing group names from visible cards
const allGroups = new Set();
document.querySelectorAll('#automations-list .automation-card .automation-group-btn[data-group]').forEach(b => {
const g = b.dataset.group;
if (g) allGroups.add(g);
});
const dropdown = document.createElement('div');
dropdown.className = 'auto-group-dropdown';
let html = '';
if (currentGroup) {
html += `<div class="auto-group-option ungroup" onclick="_assignGroup(${autoId}, null)">Remove from group</div>`;
html += '<div class="auto-group-divider"></div>';
}
allGroups.forEach(g => {
const isActive = g === currentGroup;
html += `<div class="auto-group-option${isActive ? ' active' : ''}" onclick="_assignGroup(${autoId}, '${_escAttr(g)}')">${_esc(g)}</div>`;
});
if (allGroups.size) html += '<div class="auto-group-divider"></div>';
html += `<input class="auto-group-input" placeholder="New group name..." onkeydown="if(event.key==='Enter'){_assignGroup(${autoId}, this.value.trim()); event.preventDefault();}">`;
dropdown.innerHTML = html;
// Position dropdown on document.body to avoid overflow:hidden clipping
const rect = btn.getBoundingClientRect();
dropdown.style.position = 'fixed';
dropdown.style.top = (rect.bottom + 4) + 'px';
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
dropdown.style.left = 'auto';
document.body.appendChild(dropdown);
_activeGroupDropdown = dropdown;
// Focus the input
setTimeout(() => dropdown.querySelector('.auto-group-input')?.focus(), 50);
// Close on outside click
const handler = (e) => {
if (!dropdown.contains(e.target) && e.target !== btn) {
_closeGroupDropdown();
document.removeEventListener('click', handler, true);
}
};
setTimeout(() => document.addEventListener('click', handler, true), 10);
}
function _closeGroupDropdown() {
if (_activeGroupDropdown) {
_activeGroupDropdown.remove();
_activeGroupDropdown = null;
}
}
async function _assignGroup(autoId, groupName) {
_closeGroupDropdown();
try {
const res = await fetch('/api/automations/' + autoId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group_name: groupName || null })
});
const data = await res.json();
if (data.error) throw new Error(data.error);
showToast(groupName ? `Moved to "${groupName}"` : 'Removed from group', 'success');
await loadAutomations();
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
function renderAutomationCard(a) {
const card = document.createElement('div');
card.className = 'automation-card' + (a.enabled ? '' : ' disabled') + (a.is_system ? ' system' : '');
card.dataset.id = a.id;
card.dataset.triggerType = a.trigger_type || '';
card.dataset.actionType = a.action_type || '';
const tIcon = _autoIcons[a.trigger_type] || '\u2699\uFE0F';
const aIcon = _autoIcons[a.action_type] || '\u2699\uFE0F';
const tl = tIcon + ' ' + _autoFormatTrigger(a.trigger_type, a.trigger_config);
@ -55143,6 +55379,10 @@ function renderAutomationCard(a) {
if (a.run_count) metaParts.push('<span class="auto-runs-link" onclick="event.stopPropagation(); showAutomationHistory(' + a.id + ', \'' + _escAttr(a.name) + '\', \'' + _escAttr(a.action_type || '') + '\')" title="View run history">Runs: ' + a.run_count + '</span>');
if (a.last_error) metaParts.push('Error: ' + _esc(a.last_error));
const dupeBtn = a.is_system ? '' :
`<button class="automation-dupe-btn" title="Duplicate" onclick="event.stopPropagation(); duplicateAutomation(${a.id})">&#128203;</button>`;
const groupBtn = a.is_system ? '' :
`<button class="automation-group-btn${a.group_name ? ' grouped' : ''}" data-group="${_escAttr(a.group_name || '')}" title="${a.group_name ? 'Group: ' + _escAttr(a.group_name) : 'Assign group'}" onclick="event.stopPropagation(); _showGroupDropdown(event, ${a.id}, ${a.group_name ? "'" + _escAttr(a.group_name) + "'" : 'null'})">&#128193;</button>`;
const deleteBtn = a.is_system ? '' :
`<button class="automation-delete-btn" title="Delete" onclick="event.stopPropagation(); deleteAutomation(${a.id}, '${_escAttr(a.name)}')">&#128465;</button>`;
@ -55166,6 +55406,8 @@ function renderAutomationCard(a) {
<span class="toggle-slider"></span>
</label>
<button class="automation-edit-btn" title="Edit" onclick="event.stopPropagation(); showAutomationBuilder(${a.id})">&#9881;</button>
${dupeBtn}
${groupBtn}
${deleteBtn}
</div>
`;
@ -55261,6 +55503,16 @@ async function deleteAutomation(id, name) {
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
async function duplicateAutomation(id) {
try {
const res = await fetch('/api/automations/' + id + '/duplicate', { method: 'POST' });
const data = await res.json();
if (data.error) throw new Error(data.error);
showToast('Automation duplicated', 'success');
await loadAutomations();
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
async function toggleAutomation(id) {
try {
const res = await fetch('/api/automations/' + id + '/toggle', { method: 'POST' });
@ -55562,11 +55814,15 @@ async function saveAutomation() {
const delayVal = delayEl ? parseInt(delayEl.value) : 0;
if (delayVal > 0) actionConfig.delay = delayVal;
const groupInput = document.getElementById('builder-group-name');
const groupName = groupInput ? groupInput.value.trim() : '';
const body = {
name,
trigger_type: _autoBuilder.when.type, trigger_config: triggerConfig,
action_type: _autoBuilder.do.type, action_config: actionConfig,
then_actions: thenActions,
group_name: groupName || null,
};
try {
@ -55599,6 +55855,16 @@ async function showAutomationBuilder(editId) {
_autoSpotifyAuthenticated = false;
_autoBuilder = { editId: editId || null, when: null, do: null, then: [], isSystem: false };
// Populate group datalist from existing automations
try {
const allRes = await fetch('/api/automations');
const allAutos = await allRes.json();
const groupSet = new Set();
if (Array.isArray(allAutos)) allAutos.forEach(a => { if (a.group_name) groupSet.add(a.group_name); });
const datalist = document.getElementById('builder-group-list');
if (datalist) datalist.innerHTML = [...groupSet].sort().map(g => `<option value="${_escAttr(g)}">`).join('');
} catch (e) {}
// If editing, load automation data
if (editId) {
try {
@ -55606,6 +55872,8 @@ async function showAutomationBuilder(editId) {
const a = await res.json();
if (a.error) throw new Error(a.error);
document.getElementById('builder-name').value = a.name || '';
const groupInput = document.getElementById('builder-group-name');
if (groupInput) groupInput.value = a.group_name || '';
_autoBuilder.when = { type: a.trigger_type, config: a.trigger_config || {} };
_autoBuilder.do = { type: a.action_type, config: a.action_config || {} };
// Load then_actions array
@ -55620,10 +55888,14 @@ async function showAutomationBuilder(editId) {
} catch (err) { showToast('Failed to load automation', 'error'); return; }
} else {
document.getElementById('builder-name').value = '';
const groupInput = document.getElementById('builder-group-name');
if (groupInput) groupInput.value = '';
}
// System automations: lock the name field
// System automations: lock the name field and hide group
document.getElementById('builder-name').readOnly = _autoBuilder.isSystem;
const groupEl = document.getElementById('builder-group-name');
if (groupEl) groupEl.style.display = _autoBuilder.isSystem ? 'none' : '';
_renderBuilderSidebar();
_renderBuilderCanvas();

@ -34141,7 +34141,7 @@ body.downloads-disabled [onclick*="DownloadMissing"]:not([onclick*="close"]) {
.automation-meta { font-size: 10px; color: rgba(255,255,255,0.35); line-height: 1.3; }
.automation-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.automation-run-btn, .automation-edit-btn, .automation-delete-btn {
.automation-run-btn, .automation-edit-btn, .automation-delete-btn, .automation-dupe-btn, .automation-group-btn {
background: transparent; border: 1px solid rgba(255,255,255,0.08);
border-radius: 6px; width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center;
@ -34219,6 +34219,93 @@ body.downloads-disabled [onclick*="DownloadMissing"]:not([onclick*="close"]) {
50% { opacity: 0.5; }
}
/* Duplicate & Group button hovers */
.automation-dupe-btn:hover { background: rgba(var(--accent-rgb),0.2); border-color: rgba(var(--accent-rgb),0.4); color: rgb(var(--accent-light-rgb)); }
.automation-group-btn:hover { background: rgba(250,204,21,0.15); border-color: rgba(250,204,21,0.35); color: #facc15; }
.automation-group-btn.grouped { color: #facc15; }
/* --- Filter Bar --- */
.auto-filter-bar {
display: flex; align-items: center; gap: 10px; padding: 10px 14px; margin-bottom: 12px;
background: rgba(22, 22, 22, 0.8); border: 1px solid rgba(255,255,255,0.07);
border-radius: 12px; flex-wrap: wrap;
}
.auto-filter-search {
flex: 1; min-width: 180px; max-width: 320px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px; padding: 7px 12px; color: #fff; font-size: 12px; outline: none;
transition: border-color 0.2s ease;
}
.auto-filter-search:focus { border-color: rgba(var(--accent-rgb),0.5); background: rgba(var(--accent-rgb),0.04); }
.auto-filter-search::placeholder { color: rgba(255,255,255,0.25); }
.auto-filter-select {
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px; padding: 7px 10px; color: rgba(255,255,255,0.7); font-size: 11px;
outline: none; cursor: pointer; transition: border-color 0.2s ease;
-webkit-appearance: none; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='rgba(255,255,255,0.3)'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 8px center; padding-right: 24px;
}
.auto-filter-select:focus { border-color: rgba(var(--accent-rgb),0.5); }
.auto-filter-select option { background: #1a1a1a; color: #fff; }
.auto-filter-count {
font-size: 11px; color: rgba(255,255,255,0.35); white-space: nowrap; margin-left: auto;
}
/* --- Inspiration / Templates Section --- */
.auto-template-card {
display: flex; align-items: center; gap: 12px;
padding: 12px 14px; border-radius: 12px;
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06);
cursor: pointer; transition: all 0.25s ease; position: relative;
}
.auto-template-card:hover {
background: rgba(var(--accent-rgb),0.06); border-color: rgba(var(--accent-rgb),0.25);
transform: translateY(-1px); box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.auto-template-icon {
font-size: 22px; width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
background: rgba(255,255,255,0.04); border-radius: 10px;
}
.auto-template-info { flex: 1; min-width: 0; }
.auto-template-name { font-size: 13px; font-weight: 600; color: #fff; margin-bottom: 3px; }
.auto-template-desc { font-size: 11px; color: rgba(255,255,255,0.4); line-height: 1.4; margin-bottom: 6px; }
.auto-template-flow { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
.auto-template-use {
flex-shrink: 0; padding: 5px 14px; border-radius: 16px; border: none;
background: rgba(var(--accent-rgb),0.15); color: rgb(var(--accent-light-rgb));
font-size: 11px; font-weight: 600; cursor: pointer; transition: all 0.2s ease;
}
.auto-template-use:hover { background: rgba(var(--accent-rgb),0.3); transform: translateY(-1px); }
/* --- Group Dropdown Popover --- */
.auto-group-dropdown {
z-index: 9999;
background: rgba(22,22,22,0.97); border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px; padding: 6px; min-width: 180px; max-width: 240px;
box-shadow: 0 8px 24px rgba(0,0,0,0.5); backdrop-filter: blur(12px);
margin-top: 4px;
}
.auto-group-option {
padding: 7px 10px; border-radius: 6px; font-size: 12px; color: rgba(255,255,255,0.7);
cursor: pointer; transition: background 0.15s ease; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.auto-group-option:hover { background: rgba(255,255,255,0.08); color: #fff; }
.auto-group-option.active { color: rgb(var(--accent-light-rgb)); background: rgba(var(--accent-rgb),0.12); }
.auto-group-option.ungroup { color: rgba(255,255,255,0.4); font-style: italic; }
.auto-group-divider { height: 1px; background: rgba(255,255,255,0.08); margin: 4px 0; }
.auto-group-input {
width: 100%; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px; padding: 6px 8px; color: #fff; font-size: 11px; outline: none;
margin-top: 2px;
}
.auto-group-input::placeholder { color: rgba(255,255,255,0.25); }
.auto-group-input:focus { border-color: rgba(var(--accent-rgb),0.5); }
/* Group section label styling */
.automations-section .section-group-icon { margin-right: 4px; font-size: 12px; }
/* --- Empty State --- */
.automations-empty {
text-align: center; padding: 80px 24px;
@ -34258,6 +34345,13 @@ body.downloads-disabled [onclick*="DownloadMissing"]:not([onclick*="close"]) {
}
.builder-name-input:focus { border-color: rgba(var(--accent-rgb),0.5); }
.builder-name-input::placeholder { color: rgba(255,255,255,0.3); }
.builder-group-input {
width: 160px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px; padding: 7px 10px; color: rgba(255,255,255,0.7); font-size: 12px;
outline: none; transition: border-color 0.2s ease;
}
.builder-group-input:focus { border-color: rgba(var(--accent-rgb),0.5); }
.builder-group-input::placeholder { color: rgba(255,255,255,0.25); }
.builder-header-actions { display: flex; gap: 8px; }
.builder-header-actions .btn-cancel {
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);

Loading…
Cancel
Save