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
This commit is contained in:
parent
4a308483ab
commit
4c6e2fe1ec
5 changed files with 431 additions and 10 deletions
|
|
@ -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">⚡</div>
|
||||
|
|
@ -2354,6 +2360,8 @@
|
|||
<div class="builder-header">
|
||||
<button class="builder-back-btn" onclick="hideAutomationBuilder()" title="Back to list">←</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">▼</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">→</span>
|
||||
<span class="flow-action">${_esc(actLabel)}</span>
|
||||
${t.then.length ? t.then.map(th => `<span class="flow-arrow">→</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})">📋</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'})">📁</button>`;
|
||||
const deleteBtn = a.is_system ? '' :
|
||||
`<button class="automation-delete-btn" title="Delete" onclick="event.stopPropagation(); deleteAutomation(${a.id}, '${_escAttr(a.name)}')">🗑</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})">⚙</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…
Reference in a new issue