Compare commits

...

1 Commits

Author SHA1 Message Date
99dbbb1019 Add header quick links with CRUD management and icon enhancements
- Add LinkConfig model and links field to settings
- Add CRUD API endpoints for links (list/create/update/delete)
- Add Links management tab in WebUI with add/edit/delete dialogs
- Add live icon preview in Link and Script dialog forms
- Show MDI icons inline in Quick Actions cards, Scripts table, Links table
- Add broadcast_links_changed WebSocket event for live updates
- Add EN/RU translations for all links management strings

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-27 14:42:18 +03:00
11 changed files with 886 additions and 5 deletions

View File

@@ -39,6 +39,14 @@ class ScriptConfig(BaseModel):
shell: bool = Field(default=True, description="Run command in shell")
class LinkConfig(BaseModel):
"""Configuration for a header quick link."""
url: str = Field(..., description="URL to open")
icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')")
label: str = Field(default="", description="Tooltip text")
class Settings(BaseSettings):
"""Application settings loaded from environment or config file."""
@@ -97,6 +105,12 @@ class Settings(BaseSettings):
description='Thumbnail size: "small" (150x150), "medium" (300x300), or "both"',
)
# Header quick links
links: dict[str, LinkConfig] = Field(
default_factory=dict,
description="Quick links displayed as icons in the header",
)
@classmethod
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
"""Load settings from a YAML configuration file."""

View File

@@ -8,7 +8,7 @@ from typing import Optional
import yaml
from .config import CallbackConfig, MediaFolderConfig, ScriptConfig, settings
from .config import CallbackConfig, LinkConfig, MediaFolderConfig, ScriptConfig, settings
logger = logging.getLogger(__name__)
@@ -387,6 +387,70 @@ class ConfigManager:
logger.info(f"Media folder '{folder_id}' deleted from config")
def add_link(self, name: str, config: LinkConfig) -> None:
"""Add a new link to config."""
with self._lock:
if not self._config_path.exists():
data = {}
else:
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if "links" in data and name in data["links"]:
raise ValueError(f"Link '{name}' already exists")
if "links" not in data:
data["links"] = {}
data["links"][name] = config.model_dump(exclude_none=True)
self._config_path.parent.mkdir(parents=True, exist_ok=True)
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
settings.links[name] = config
logger.info(f"Link '{name}' added to config")
def update_link(self, name: str, config: LinkConfig) -> None:
"""Update an existing link."""
with self._lock:
if not self._config_path.exists():
raise ValueError(f"Config file not found: {self._config_path}")
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if "links" not in data or name not in data["links"]:
raise ValueError(f"Link '{name}' does not exist")
data["links"][name] = config.model_dump(exclude_none=True)
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
settings.links[name] = config
logger.info(f"Link '{name}' updated in config")
def delete_link(self, name: str) -> None:
"""Delete a link from config."""
with self._lock:
if not self._config_path.exists():
raise ValueError(f"Config file not found: {self._config_path}")
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if "links" not in data or name not in data["links"]:
raise ValueError(f"Link '{name}' does not exist")
del data["links"][name]
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
if name in settings.links:
del settings.links[name]
logger.info(f"Link '{name}' deleted from config")
# Global config manager instance
config_manager = ConfigManager()

View File

@@ -16,7 +16,7 @@ from fastapi.staticfiles import StaticFiles
from . import __version__
from .auth import get_token_label, token_label_var
from .config import settings, generate_default_config, get_config_dir
from .routes import audio_router, browser_router, callbacks_router, display_router, health_router, media_router, scripts_router
from .routes import audio_router, browser_router, callbacks_router, display_router, health_router, links_router, media_router, scripts_router
from .services import get_media_controller
from .services.websocket_manager import ws_manager
@@ -118,6 +118,7 @@ def create_app() -> FastAPI:
app.include_router(callbacks_router)
app.include_router(display_router)
app.include_router(health_router)
app.include_router(links_router)
app.include_router(media_router)
app.include_router(scripts_router)

View File

@@ -5,7 +5,8 @@ from .browser import router as browser_router
from .callbacks import router as callbacks_router
from .display import router as display_router
from .health import router as health_router
from .links import router as links_router
from .media import router as media_router
from .scripts import router as scripts_router
__all__ = ["audio_router", "browser_router", "callbacks_router", "display_router", "health_router", "media_router", "scripts_router"]
__all__ = ["audio_router", "browser_router", "callbacks_router", "display_router", "health_router", "links_router", "media_router", "scripts_router"]

View File

@@ -0,0 +1,185 @@
"""Header quick links management API endpoints."""
import logging
import re
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..auth import verify_token
from ..config import LinkConfig, settings
from ..config_manager import config_manager
from ..services.websocket_manager import ws_manager
router = APIRouter(prefix="/api/links", tags=["links"])
logger = logging.getLogger(__name__)
class LinkInfo(BaseModel):
"""Information about a configured link."""
name: str
url: str
icon: str
label: str
class LinkCreateRequest(BaseModel):
"""Request model for creating or updating a link."""
url: str = Field(..., description="URL to open", min_length=1)
icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')")
label: str = Field(default="", description="Tooltip text")
def _validate_link_name(name: str) -> None:
"""Validate link name.
Args:
name: Link name to validate.
Raises:
HTTPException: If name is invalid.
"""
if not re.match(r'^[a-zA-Z0-9_]+$', name):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Link name must contain only letters, numbers, and underscores",
)
if len(name) > 64:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Link name must be 64 characters or less",
)
@router.get("/list")
async def list_links(_: str = Depends(verify_token)) -> list[LinkInfo]:
"""List all configured links.
Returns:
List of configured links.
"""
return [
LinkInfo(
name=name,
url=config.url,
icon=config.icon,
label=config.label,
)
for name, config in settings.links.items()
]
@router.post("/create/{link_name}")
async def create_link(
link_name: str,
request: LinkCreateRequest,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Create a new link.
Args:
link_name: Link name (alphanumeric and underscores only).
request: Link configuration.
Returns:
Success response with link name.
"""
_validate_link_name(link_name)
if link_name in settings.links:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Link '{link_name}' already exists. Use PUT /api/links/update/{link_name} to update it.",
)
link_config = LinkConfig(**request.model_dump())
try:
config_manager.add_link(link_name, link_config)
except Exception as e:
logger.error(f"Failed to add link '{link_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add link: {str(e)}",
)
await ws_manager.broadcast_links_changed()
logger.info(f"Link '{link_name}' created successfully")
return {"success": True, "link": link_name}
@router.put("/update/{link_name}")
async def update_link(
link_name: str,
request: LinkCreateRequest,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Update an existing link.
Args:
link_name: Link name.
request: Updated link configuration.
Returns:
Success response with link name.
"""
_validate_link_name(link_name)
if link_name not in settings.links:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Link '{link_name}' not found. Use POST /api/links/create/{link_name} to create it.",
)
link_config = LinkConfig(**request.model_dump())
try:
config_manager.update_link(link_name, link_config)
except Exception as e:
logger.error(f"Failed to update link '{link_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update link: {str(e)}",
)
await ws_manager.broadcast_links_changed()
logger.info(f"Link '{link_name}' updated successfully")
return {"success": True, "link": link_name}
@router.delete("/delete/{link_name}")
async def delete_link(
link_name: str,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Delete a link.
Args:
link_name: Link name.
Returns:
Success response with link name.
"""
_validate_link_name(link_name)
if link_name not in settings.links:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Link '{link_name}' not found",
)
try:
config_manager.delete_link(link_name)
except Exception as e:
logger.error(f"Failed to delete link '{link_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete link: {str(e)}",
)
await ws_manager.broadcast_links_changed()
logger.info(f"Link '{link_name}' deleted successfully")
return {"success": True, "link": link_name}

View File

@@ -77,6 +77,12 @@ class ConnectionManager:
await self.broadcast(message)
logger.info("Broadcast sent: scripts_changed")
async def broadcast_links_changed(self) -> None:
"""Notify all connected clients that links have changed."""
message = {"type": "links_changed", "data": {}}
await self.broadcast(message)
logger.info("Broadcast sent: links_changed")
def status_changed(
self, old: dict[str, Any] | None, new: dict[str, Any]
) -> bool:

View File

@@ -205,6 +205,66 @@ h1 {
fill: currentColor;
}
/* Header Quick Links */
.header-links {
display: flex;
align-items: center;
gap: 0.125rem;
}
.header-link {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--text-primary);
opacity: 0.7;
transition: opacity 0.2s;
text-decoration: none;
}
.header-link:hover {
opacity: 1;
}
.header-link svg {
width: 16px;
height: 16px;
}
/* Icon Input with Preview */
.icon-input-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
}
.icon-input-wrapper input {
flex: 1;
}
.icon-preview {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
min-width: 36px;
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-primary);
}
.icon-preview svg {
width: 20px;
height: 20px;
}
.icon-preview:empty {
display: none;
}
/* Accent Color Picker */
.accent-picker {
position: relative;
@@ -872,6 +932,38 @@ button:disabled {
pointer-events: none;
}
.script-btn .script-icon {
color: var(--accent);
line-height: 0;
}
.script-btn .script-icon svg {
width: 24px;
height: 24px;
}
.script-btn:hover:not(:disabled) .script-icon {
color: #fff;
}
/* Inline icon in table name cells */
.name-with-icon {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.table-icon {
display: inline-flex;
color: var(--text-secondary);
line-height: 0;
}
.table-icon svg {
width: 16px;
height: 16px;
}
/* Script Management Styles */
.script-management {
background: var(--bg-secondary);

View File

@@ -66,6 +66,7 @@
<span class="version-label" id="version-label"></span>
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<div id="headerLinks" class="header-links"></div>
<div class="accent-picker">
<button class="accent-picker-btn" onclick="toggleAccentPicker()" title="Accent color">
<span class="accent-dot" id="accentDot"></span>
@@ -121,6 +122,10 @@
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
<span data-i18n="tab.callbacks">Callbacks</span>
</button>
<button class="tab-btn" data-tab="links" onclick="switchTab('links')" role="tab" aria-selected="false" aria-controls="panel-links" tabindex="-1">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
<span data-i18n="tab.links">Links</span>
</button>
</div>
<div class="player-container" data-tab-content="player" role="tabpanel" id="panel-player">
@@ -329,6 +334,36 @@
</div>
</div>
<!-- Links Management Section -->
<div class="script-management" data-tab-content="links" role="tabpanel" id="panel-links">
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 1rem;" data-i18n="links.description">
Quick links displayed as icons in the header bar. Click an icon to open the URL in a new tab.
</p>
<table class="scripts-table">
<thead>
<tr>
<th data-i18n="links.table.name">Name</th>
<th data-i18n="links.table.url">URL</th>
<th data-i18n="links.table.label">Label</th>
<th data-i18n="links.table.actions">Actions</th>
</tr>
</thead>
<tbody id="linksTableBody">
<tr>
<td colspan="4" class="empty-state">
<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><path d="M26 20a10 10 0 010 14l-6 6a10 10 0 01-14-14l6-6a10 10 0 0114 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M38 44a10 10 0 010-14l6-6a10 10 0 0114 14l-6 6a10 10 0 01-14 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M24 40l16-16" stroke="currentColor" stroke-width="2"/></svg>
<p data-i18n="links.empty">No links configured. Click "Add" to create one.</p>
</div>
</td>
</tr>
</tbody>
</table>
<div class="add-card" onclick="showAddLinkDialog()">
<span class="add-card-icon">+</span>
</div>
</div>
<!-- Display Control Section -->
<div class="display-container" data-tab-content="display" role="tabpanel" id="panel-display">
<div class="display-monitors" id="displayMonitors">
@@ -373,7 +408,10 @@
<label>
<span data-i18n="scripts.field.icon">Icon (MDI)</span>
<input type="text" id="scriptIcon" data-i18n-placeholder="scripts.placeholder.icon" placeholder="e.g., mdi:power">
<div class="icon-input-wrapper">
<input type="text" id="scriptIcon" data-i18n-placeholder="scripts.placeholder.icon" placeholder="e.g., mdi:power">
<div class="icon-preview" id="scriptIconPreview"></div>
</div>
</label>
<label>
@@ -437,6 +475,47 @@
</form>
</dialog>
<!-- Add/Edit Link Dialog -->
<dialog id="linkDialog">
<div class="dialog-header">
<h3 id="linkDialogTitle" data-i18n="links.dialog.add">Add Link</h3>
</div>
<form id="linkForm" onsubmit="saveLink(event)">
<div class="dialog-body">
<input type="hidden" id="linkOriginalName">
<input type="hidden" id="linkIsEdit">
<label>
<span data-i18n="links.field.name">Link Name *</span>
<input type="text" id="linkName" required pattern="[a-zA-Z0-9_]+"
data-i18n-title="links.placeholder.name" title="Only letters, numbers, and underscores allowed" maxlength="64">
</label>
<label>
<span data-i18n="links.field.url">URL *</span>
<input type="url" id="linkUrl" required data-i18n-placeholder="links.placeholder.url" placeholder="https://example.com">
</label>
<label>
<span data-i18n="links.field.icon">Icon (MDI)</span>
<div class="icon-input-wrapper">
<input type="text" id="linkIcon" data-i18n-placeholder="links.placeholder.icon" placeholder="mdi:link">
<div class="icon-preview" id="linkIconPreview"></div>
</div>
</label>
<label>
<span data-i18n="links.field.label">Label</span>
<input type="text" id="linkLabel" data-i18n-placeholder="links.placeholder.label" placeholder="Tooltip text">
</label>
</div>
<div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeLinkDialog()" data-i18n="links.button.cancel">Cancel</button>
<button type="submit" class="btn-primary" data-i18n="links.button.save">Save</button>
</div>
</form>
</dialog>
<!-- Execution Result Dialog -->
<dialog id="executionDialog">
<div class="dialog-header">

View File

@@ -405,6 +405,7 @@
if (token) {
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
}
}
@@ -525,6 +526,7 @@
loadScripts();
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
} else {
showAuthForm();
}
@@ -661,12 +663,43 @@
else if (action === 'delete') deleteCallbackConfirm(name);
});
// Link dialog backdrop click to close
const linkDialog = document.getElementById('linkDialog');
linkDialog.addEventListener('click', (e) => {
if (e.target === linkDialog) {
closeLinkDialog();
}
});
// Delegated click handlers for link table actions (XSS-safe)
document.getElementById('linksTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const name = btn.dataset.linkName;
if (action === 'edit') showEditLinkDialog(name);
else if (action === 'delete') deleteLinkConfirm(name);
});
// Track link form dirty state
const linkForm = document.getElementById('linkForm');
linkForm.addEventListener('input', () => {
linkFormDirty = true;
});
linkForm.addEventListener('change', () => {
linkFormDirty = true;
});
// Initialize browser toolbar and load folders
initBrowserToolbar();
if (token) {
loadMediaFolders();
}
// Icon preview for script and link dialogs
setupIconPreview('scriptIcon', 'scriptIconPreview');
setupIconPreview('linkIcon', 'linkIconPreview');
// Cleanup blob URLs on page unload
window.addEventListener('beforeunload', () => {
thumbnailCache.forEach(url => URL.revokeObjectURL(url));
@@ -790,6 +823,8 @@
loadScripts();
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
loadHeaderLinks();
};
ws.onmessage = (event) => {
@@ -801,6 +836,10 @@
console.log('Scripts changed, reloading...');
loadScripts(); // Reload Quick Actions
loadScriptsTable(); // Reload Script Management table
} else if (msg.type === 'links_changed') {
console.log('Links changed, reloading...');
loadHeaderLinks();
loadLinksTable();
} else if (msg.type === 'error') {
console.error('WebSocket error:', msg.message);
}
@@ -1174,6 +1213,13 @@
button.className = 'script-btn';
button.onclick = () => executeScript(script.name, button);
if (script.icon) {
const iconEl = document.createElement('div');
iconEl.className = 'script-icon';
iconEl.setAttribute('data-mdi-icon', script.icon);
button.appendChild(iconEl);
}
const label = document.createElement('div');
label.className = 'script-label';
label.textContent = script.label || script.name;
@@ -1197,6 +1243,9 @@
addCard.onclick = () => showAddScriptDialog();
addCard.innerHTML = '<span class="add-card-icon">+</span>';
grid.appendChild(addCard);
// Resolve MDI icons
resolveMdiIcons(grid);
}
async function executeScript(scriptName, buttonElement) {
@@ -1311,7 +1360,7 @@
tbody.innerHTML = scriptsList.map(script => `
<tr>
<td><code>${escapeHtml(script.name)}</code></td>
<td><span class="name-with-icon">${script.icon ? `<span class="table-icon" data-mdi-icon="${escapeHtml(script.icon)}"></span>` : ''}<code>${escapeHtml(script.name)}</code></span></td>
<td>${escapeHtml(script.label || script.name)}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
title="${escapeHtml(script.command || 'N/A')}">${escapeHtml(script.command || 'N/A')}</td>
@@ -1331,6 +1380,7 @@
</td>
</tr>
`).join('');
resolveMdiIcons(tbody);
} catch (error) {
console.error('Error loading scripts:', error);
tbody.innerHTML = '<tr><td colspan="5" class="empty-state" style="color: var(--error);">Failed to load scripts</td></tr>';
@@ -1353,6 +1403,7 @@
document.getElementById('scriptOriginalName').value = '';
document.getElementById('scriptIsEdit').value = 'false';
document.getElementById('scriptName').disabled = false;
document.getElementById('scriptIconPreview').innerHTML = '';
title.textContent = t('scripts.dialog.add');
// Reset dirty state
@@ -1396,6 +1447,14 @@
document.getElementById('scriptIcon').value = script.icon || '';
document.getElementById('scriptTimeout').value = script.timeout || 30;
// Update icon preview
const preview = document.getElementById('scriptIconPreview');
if (script.icon) {
fetchMdiIcon(script.icon).then(svg => { preview.innerHTML = svg; });
} else {
preview.innerHTML = '';
}
title.textContent = t('scripts.dialog.edit');
// Reset dirty state
@@ -2928,3 +2987,321 @@ async function toggleDisplayPower(monitorId, monitorName) {
}
}
// ============================================================
// Header Quick Links
// ============================================================
const mdiIconCache = {};
async function fetchMdiIcon(iconName) {
// Parse "mdi:icon-name" → "icon-name"
const name = iconName.replace(/^mdi:/, '');
if (mdiIconCache[name]) return mdiIconCache[name];
try {
const response = await fetch(`https://api.iconify.design/mdi/${name}.svg?width=16&height=16`);
if (response.ok) {
const svg = await response.text();
mdiIconCache[name] = svg;
return svg;
}
} catch (e) {
console.warn('Failed to fetch MDI icon:', name, e);
}
// Fallback: generic link icon
return '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
}
// Resolve all data-mdi-icon placeholders in a container
async function resolveMdiIcons(container) {
const els = container.querySelectorAll('[data-mdi-icon]');
await Promise.all(Array.from(els).map(async (el) => {
const icon = el.dataset.mdiIcon;
if (icon) {
el.innerHTML = await fetchMdiIcon(icon);
}
}));
}
// Debounced icon preview updater
function setupIconPreview(inputId, previewId) {
const input = document.getElementById(inputId);
const preview = document.getElementById(previewId);
if (!input || !preview) return;
let debounceTimer = null;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
const value = input.value.trim();
if (!value) {
preview.innerHTML = '';
return;
}
debounceTimer = setTimeout(async () => {
const svg = await fetchMdiIcon(value);
// Re-check value hasn't changed during fetch
if (input.value.trim() === value) {
preview.innerHTML = svg;
}
}, 400);
});
}
async function loadHeaderLinks() {
const token = localStorage.getItem('media_server_token');
if (!token) return;
const container = document.getElementById('headerLinks');
if (!container) return;
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) return;
const links = await response.json();
container.innerHTML = '';
for (const link of links) {
const a = document.createElement('a');
a.href = link.url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.className = 'header-link';
a.title = link.label || link.url;
const iconSvg = await fetchMdiIcon(link.icon || 'mdi:link');
a.innerHTML = iconSvg;
container.appendChild(a);
}
} catch (e) {
console.warn('Failed to load header links:', e);
}
}
// ============================================================
// Links Management
// ============================================================
let _loadLinksPromise = null;
let linkFormDirty = false;
async function loadLinksTable() {
if (_loadLinksPromise) return _loadLinksPromise;
_loadLinksPromise = _loadLinksTableImpl();
_loadLinksPromise.finally(() => { _loadLinksPromise = null; });
return _loadLinksPromise;
}
async function _loadLinksTableImpl() {
const token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('linksTableBody');
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch links');
}
const linksList = await response.json();
if (linksList.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><path d="M26 20a10 10 0 010 14l-6 6a10 10 0 01-14-14l6-6a10 10 0 0114 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M38 44a10 10 0 010-14l6-6a10 10 0 0114 14l-6 6a10 10 0 01-14 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M24 40l16-16" stroke="currentColor" stroke-width="2"/></svg><p>' + t('links.empty') + '</p></div></td></tr>';
return;
}
tbody.innerHTML = linksList.map(link => `
<tr>
<td><span class="name-with-icon"><span class="table-icon" data-mdi-icon="${escapeHtml(link.icon || 'mdi:link')}"></span><code>${escapeHtml(link.name)}</code></span></td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
title="${escapeHtml(link.url)}">${escapeHtml(link.url)}</td>
<td>${escapeHtml(link.label || '')}</td>
<td>
<div class="action-buttons">
<button class="action-btn" data-action="edit" data-link-name="${escapeHtml(link.name)}" title="${t('links.button.edit')}">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="action-btn delete" data-action="delete" data-link-name="${escapeHtml(link.name)}" title="${t('links.button.delete')}">
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>
</td>
</tr>
`).join('');
resolveMdiIcons(tbody);
} catch (error) {
console.error('Error loading links:', error);
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load links</td></tr>';
}
}
function showAddLinkDialog() {
const dialog = document.getElementById('linkDialog');
const form = document.getElementById('linkForm');
const title = document.getElementById('linkDialogTitle');
form.reset();
document.getElementById('linkOriginalName').value = '';
document.getElementById('linkIsEdit').value = 'false';
document.getElementById('linkName').disabled = false;
document.getElementById('linkIconPreview').innerHTML = '';
title.textContent = t('links.dialog.add');
linkFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
}
async function showEditLinkDialog(linkName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('linkDialog');
const title = document.getElementById('linkDialogTitle');
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch link details');
}
const linksList = await response.json();
const link = linksList.find(l => l.name === linkName);
if (!link) {
showToast(t('links.msg.not_found'), 'error');
return;
}
document.getElementById('linkOriginalName').value = linkName;
document.getElementById('linkIsEdit').value = 'true';
document.getElementById('linkName').value = linkName;
document.getElementById('linkName').disabled = true;
document.getElementById('linkUrl').value = link.url;
document.getElementById('linkIcon').value = link.icon || '';
document.getElementById('linkLabel').value = link.label || '';
// Update icon preview
const preview = document.getElementById('linkIconPreview');
if (link.icon) {
fetchMdiIcon(link.icon).then(svg => { preview.innerHTML = svg; });
} else {
preview.innerHTML = '';
}
title.textContent = t('links.dialog.edit');
linkFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
} catch (error) {
console.error('Error loading link for edit:', error);
showToast(t('links.msg.load_failed'), 'error');
}
}
async function closeLinkDialog() {
if (linkFormDirty) {
if (!await showConfirm(t('links.confirm.unsaved'))) {
return;
}
}
const dialog = document.getElementById('linkDialog');
linkFormDirty = false;
dialog.close();
document.body.classList.remove('dialog-open');
}
async function saveLink(event) {
event.preventDefault();
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
const token = localStorage.getItem('media_server_token');
const isEdit = document.getElementById('linkIsEdit').value === 'true';
const linkName = isEdit ?
document.getElementById('linkOriginalName').value :
document.getElementById('linkName').value;
const data = {
url: document.getElementById('linkUrl').value,
icon: document.getElementById('linkIcon').value || 'mdi:link',
label: document.getElementById('linkLabel').value || ''
};
const endpoint = isEdit ?
`/api/links/update/${linkName}` :
`/api/links/create/${linkName}`;
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch(endpoint, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
showToast(t(isEdit ? 'links.msg.updated' : 'links.msg.created'), 'success');
linkFormDirty = false;
closeLinkDialog();
} else {
showToast(result.detail || t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error');
}
} catch (error) {
console.error('Error saving link:', error);
showToast(t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
async function deleteLinkConfirm(linkName) {
if (!await showConfirm(t('links.confirm.delete').replace('{name}', linkName))) {
return;
}
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/links/delete/${linkName}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (response.ok && result.success) {
showToast(t('links.msg.deleted'), 'success');
} else {
showToast(result.detail || t('links.msg.delete_failed'), 'error');
}
} catch (error) {
console.error('Error deleting link:', error);
showToast(t('links.msg.delete_failed'), 'error');
}
}

View File

@@ -119,6 +119,7 @@
"tab.quick_actions": "Actions",
"tab.scripts": "Scripts",
"tab.callbacks": "Callbacks",
"tab.links": "Links",
"tab.display": "Display",
"display.loading": "Loading monitors...",
"display.error": "Failed to load monitors",
@@ -166,6 +167,36 @@
"connection.reconnect": "Reconnect",
"dialog.cancel": "Cancel",
"dialog.confirm": "Confirm",
"links.description": "Quick links displayed as icons in the header bar. Click an icon to open the URL in a new tab.",
"links.empty": "No links configured. Click 'Add' to create one.",
"links.table.name": "Name",
"links.table.url": "URL",
"links.table.label": "Label",
"links.table.actions": "Actions",
"links.dialog.add": "Add Link",
"links.dialog.edit": "Edit Link",
"links.field.name": "Link Name *",
"links.field.url": "URL *",
"links.field.icon": "Icon (MDI)",
"links.field.label": "Label",
"links.placeholder.name": "Only letters, numbers, and underscores allowed",
"links.placeholder.url": "https://example.com",
"links.placeholder.icon": "mdi:link",
"links.placeholder.label": "Tooltip text",
"links.button.cancel": "Cancel",
"links.button.save": "Save",
"links.button.edit": "Edit",
"links.button.delete": "Delete",
"links.msg.created": "Link created successfully",
"links.msg.updated": "Link updated successfully",
"links.msg.create_failed": "Failed to create link",
"links.msg.update_failed": "Failed to update link",
"links.msg.deleted": "Link deleted successfully",
"links.msg.delete_failed": "Failed to delete link",
"links.msg.not_found": "Link not found",
"links.msg.load_failed": "Failed to load link details",
"links.confirm.delete": "Are you sure you want to delete the link \"{name}\"?",
"links.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
"footer.created_by": "Created by",
"footer.source_code": "Source Code"
}

View File

@@ -119,6 +119,7 @@
"tab.quick_actions": "Действия",
"tab.scripts": "Скрипты",
"tab.callbacks": "Колбэки",
"tab.links": "Ссылки",
"tab.display": "Дисплей",
"display.loading": "Загрузка мониторов...",
"display.error": "Не удалось загрузить мониторы",
@@ -166,6 +167,36 @@
"connection.reconnect": "Переподключиться",
"dialog.cancel": "Отмена",
"dialog.confirm": "Подтвердить",
"links.description": "Быстрые ссылки, отображаемые в виде иконок в шапке. Нажмите на иконку, чтобы открыть URL в новой вкладке.",
"links.empty": "Ссылки не настроены. Нажмите 'Добавить' для создания.",
"links.table.name": "Имя",
"links.table.url": "URL",
"links.table.label": "Метка",
"links.table.actions": "Действия",
"links.dialog.add": "Добавить Ссылку",
"links.dialog.edit": "Редактировать Ссылку",
"links.field.name": "Имя Ссылки *",
"links.field.url": "URL *",
"links.field.icon": "Иконка (MDI)",
"links.field.label": "Метка",
"links.placeholder.name": "Только буквы, цифры и подчеркивания",
"links.placeholder.url": "https://example.com",
"links.placeholder.icon": "mdi:link",
"links.placeholder.label": "Текст подсказки",
"links.button.cancel": "Отмена",
"links.button.save": "Сохранить",
"links.button.edit": "Редактировать",
"links.button.delete": "Удалить",
"links.msg.created": "Ссылка создана успешно",
"links.msg.updated": "Ссылка обновлена успешно",
"links.msg.create_failed": "Не удалось создать ссылку",
"links.msg.update_failed": "Не удалось обновить ссылку",
"links.msg.deleted": "Ссылка удалена успешно",
"links.msg.delete_failed": "Не удалось удалить ссылку",
"links.msg.not_found": "Ссылка не найдена",
"links.msg.load_failed": "Не удалось загрузить данные ссылки",
"links.confirm.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
"footer.created_by": "Создано",
"footer.source_code": "Исходный код"
}