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>
This commit is contained in:
@@ -39,6 +39,14 @@ class ScriptConfig(BaseModel):
|
|||||||
shell: bool = Field(default=True, description="Run command in shell")
|
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):
|
class Settings(BaseSettings):
|
||||||
"""Application settings loaded from environment or config file."""
|
"""Application settings loaded from environment or config file."""
|
||||||
|
|
||||||
@@ -97,6 +105,12 @@ class Settings(BaseSettings):
|
|||||||
description='Thumbnail size: "small" (150x150), "medium" (300x300), or "both"',
|
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
|
@classmethod
|
||||||
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
|
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
|
||||||
"""Load settings from a YAML configuration file."""
|
"""Load settings from a YAML configuration file."""
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Optional
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from .config import CallbackConfig, MediaFolderConfig, ScriptConfig, settings
|
from .config import CallbackConfig, LinkConfig, MediaFolderConfig, ScriptConfig, settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -387,6 +387,70 @@ class ConfigManager:
|
|||||||
|
|
||||||
logger.info(f"Media folder '{folder_id}' deleted from config")
|
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
|
# Global config manager instance
|
||||||
config_manager = ConfigManager()
|
config_manager = ConfigManager()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from . import __version__
|
from . import __version__
|
||||||
from .auth import get_token_label, token_label_var
|
from .auth import get_token_label, token_label_var
|
||||||
from .config import settings, generate_default_config, get_config_dir
|
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 import get_media_controller
|
||||||
from .services.websocket_manager import ws_manager
|
from .services.websocket_manager import ws_manager
|
||||||
|
|
||||||
@@ -118,6 +118,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(callbacks_router)
|
app.include_router(callbacks_router)
|
||||||
app.include_router(display_router)
|
app.include_router(display_router)
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
|
app.include_router(links_router)
|
||||||
app.include_router(media_router)
|
app.include_router(media_router)
|
||||||
app.include_router(scripts_router)
|
app.include_router(scripts_router)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ from .browser import router as browser_router
|
|||||||
from .callbacks import router as callbacks_router
|
from .callbacks import router as callbacks_router
|
||||||
from .display import router as display_router
|
from .display import router as display_router
|
||||||
from .health import router as health_router
|
from .health import router as health_router
|
||||||
|
from .links import router as links_router
|
||||||
from .media import router as media_router
|
from .media import router as media_router
|
||||||
from .scripts import router as scripts_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"]
|
||||||
|
|||||||
185
media_server/routes/links.py
Normal file
185
media_server/routes/links.py
Normal 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}
|
||||||
@@ -77,6 +77,12 @@ class ConnectionManager:
|
|||||||
await self.broadcast(message)
|
await self.broadcast(message)
|
||||||
logger.info("Broadcast sent: scripts_changed")
|
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(
|
def status_changed(
|
||||||
self, old: dict[str, Any] | None, new: dict[str, Any]
|
self, old: dict[str, Any] | None, new: dict[str, Any]
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
@@ -205,6 +205,66 @@ h1 {
|
|||||||
fill: currentColor;
|
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 Color Picker */
|
||||||
.accent-picker {
|
.accent-picker {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -872,6 +932,38 @@ button:disabled {
|
|||||||
pointer-events: none;
|
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 Styles */
|
||||||
.script-management {
|
.script-management {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
<span class="version-label" id="version-label"></span>
|
<span class="version-label" id="version-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<div id="headerLinks" class="header-links"></div>
|
||||||
<div class="accent-picker">
|
<div class="accent-picker">
|
||||||
<button class="accent-picker-btn" onclick="toggleAccentPicker()" title="Accent color">
|
<button class="accent-picker-btn" onclick="toggleAccentPicker()" title="Accent color">
|
||||||
<span class="accent-dot" id="accentDot"></span>
|
<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>
|
<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>
|
<span data-i18n="tab.callbacks">Callbacks</span>
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div class="player-container" data-tab-content="player" role="tabpanel" id="panel-player">
|
<div class="player-container" data-tab-content="player" role="tabpanel" id="panel-player">
|
||||||
@@ -329,6 +334,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Display Control Section -->
|
||||||
<div class="display-container" data-tab-content="display" role="tabpanel" id="panel-display">
|
<div class="display-container" data-tab-content="display" role="tabpanel" id="panel-display">
|
||||||
<div class="display-monitors" id="displayMonitors">
|
<div class="display-monitors" id="displayMonitors">
|
||||||
@@ -373,7 +408,10 @@
|
|||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span data-i18n="scripts.field.icon">Icon (MDI)</span>
|
<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>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
@@ -437,6 +475,47 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</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 -->
|
<!-- Execution Result Dialog -->
|
||||||
<dialog id="executionDialog">
|
<dialog id="executionDialog">
|
||||||
<div class="dialog-header">
|
<div class="dialog-header">
|
||||||
|
|||||||
@@ -405,6 +405,7 @@
|
|||||||
if (token) {
|
if (token) {
|
||||||
loadScriptsTable();
|
loadScriptsTable();
|
||||||
loadCallbacksTable();
|
loadCallbacksTable();
|
||||||
|
loadLinksTable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +526,7 @@
|
|||||||
loadScripts();
|
loadScripts();
|
||||||
loadScriptsTable();
|
loadScriptsTable();
|
||||||
loadCallbacksTable();
|
loadCallbacksTable();
|
||||||
|
loadLinksTable();
|
||||||
} else {
|
} else {
|
||||||
showAuthForm();
|
showAuthForm();
|
||||||
}
|
}
|
||||||
@@ -661,12 +663,43 @@
|
|||||||
else if (action === 'delete') deleteCallbackConfirm(name);
|
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
|
// Initialize browser toolbar and load folders
|
||||||
initBrowserToolbar();
|
initBrowserToolbar();
|
||||||
if (token) {
|
if (token) {
|
||||||
loadMediaFolders();
|
loadMediaFolders();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Icon preview for script and link dialogs
|
||||||
|
setupIconPreview('scriptIcon', 'scriptIconPreview');
|
||||||
|
setupIconPreview('linkIcon', 'linkIconPreview');
|
||||||
|
|
||||||
// Cleanup blob URLs on page unload
|
// Cleanup blob URLs on page unload
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
thumbnailCache.forEach(url => URL.revokeObjectURL(url));
|
thumbnailCache.forEach(url => URL.revokeObjectURL(url));
|
||||||
@@ -790,6 +823,8 @@
|
|||||||
loadScripts();
|
loadScripts();
|
||||||
loadScriptsTable();
|
loadScriptsTable();
|
||||||
loadCallbacksTable();
|
loadCallbacksTable();
|
||||||
|
loadLinksTable();
|
||||||
|
loadHeaderLinks();
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
@@ -801,6 +836,10 @@
|
|||||||
console.log('Scripts changed, reloading...');
|
console.log('Scripts changed, reloading...');
|
||||||
loadScripts(); // Reload Quick Actions
|
loadScripts(); // Reload Quick Actions
|
||||||
loadScriptsTable(); // Reload Script Management table
|
loadScriptsTable(); // Reload Script Management table
|
||||||
|
} else if (msg.type === 'links_changed') {
|
||||||
|
console.log('Links changed, reloading...');
|
||||||
|
loadHeaderLinks();
|
||||||
|
loadLinksTable();
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
console.error('WebSocket error:', msg.message);
|
console.error('WebSocket error:', msg.message);
|
||||||
}
|
}
|
||||||
@@ -1174,6 +1213,13 @@
|
|||||||
button.className = 'script-btn';
|
button.className = 'script-btn';
|
||||||
button.onclick = () => executeScript(script.name, button);
|
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');
|
const label = document.createElement('div');
|
||||||
label.className = 'script-label';
|
label.className = 'script-label';
|
||||||
label.textContent = script.label || script.name;
|
label.textContent = script.label || script.name;
|
||||||
@@ -1197,6 +1243,9 @@
|
|||||||
addCard.onclick = () => showAddScriptDialog();
|
addCard.onclick = () => showAddScriptDialog();
|
||||||
addCard.innerHTML = '<span class="add-card-icon">+</span>';
|
addCard.innerHTML = '<span class="add-card-icon">+</span>';
|
||||||
grid.appendChild(addCard);
|
grid.appendChild(addCard);
|
||||||
|
|
||||||
|
// Resolve MDI icons
|
||||||
|
resolveMdiIcons(grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeScript(scriptName, buttonElement) {
|
async function executeScript(scriptName, buttonElement) {
|
||||||
@@ -1311,7 +1360,7 @@
|
|||||||
|
|
||||||
tbody.innerHTML = scriptsList.map(script => `
|
tbody.innerHTML = scriptsList.map(script => `
|
||||||
<tr>
|
<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>${escapeHtml(script.label || script.name)}</td>
|
||||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
<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>
|
title="${escapeHtml(script.command || 'N/A')}">${escapeHtml(script.command || 'N/A')}</td>
|
||||||
@@ -1331,6 +1380,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
resolveMdiIcons(tbody);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading scripts:', 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>';
|
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('scriptOriginalName').value = '';
|
||||||
document.getElementById('scriptIsEdit').value = 'false';
|
document.getElementById('scriptIsEdit').value = 'false';
|
||||||
document.getElementById('scriptName').disabled = false;
|
document.getElementById('scriptName').disabled = false;
|
||||||
|
document.getElementById('scriptIconPreview').innerHTML = '';
|
||||||
title.textContent = t('scripts.dialog.add');
|
title.textContent = t('scripts.dialog.add');
|
||||||
|
|
||||||
// Reset dirty state
|
// Reset dirty state
|
||||||
@@ -1396,6 +1447,14 @@
|
|||||||
document.getElementById('scriptIcon').value = script.icon || '';
|
document.getElementById('scriptIcon').value = script.icon || '';
|
||||||
document.getElementById('scriptTimeout').value = script.timeout || 30;
|
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');
|
title.textContent = t('scripts.dialog.edit');
|
||||||
|
|
||||||
// Reset dirty state
|
// 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,7 @@
|
|||||||
"tab.quick_actions": "Actions",
|
"tab.quick_actions": "Actions",
|
||||||
"tab.scripts": "Scripts",
|
"tab.scripts": "Scripts",
|
||||||
"tab.callbacks": "Callbacks",
|
"tab.callbacks": "Callbacks",
|
||||||
|
"tab.links": "Links",
|
||||||
"tab.display": "Display",
|
"tab.display": "Display",
|
||||||
"display.loading": "Loading monitors...",
|
"display.loading": "Loading monitors...",
|
||||||
"display.error": "Failed to load monitors",
|
"display.error": "Failed to load monitors",
|
||||||
@@ -166,6 +167,36 @@
|
|||||||
"connection.reconnect": "Reconnect",
|
"connection.reconnect": "Reconnect",
|
||||||
"dialog.cancel": "Cancel",
|
"dialog.cancel": "Cancel",
|
||||||
"dialog.confirm": "Confirm",
|
"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.created_by": "Created by",
|
||||||
"footer.source_code": "Source Code"
|
"footer.source_code": "Source Code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,7 @@
|
|||||||
"tab.quick_actions": "Действия",
|
"tab.quick_actions": "Действия",
|
||||||
"tab.scripts": "Скрипты",
|
"tab.scripts": "Скрипты",
|
||||||
"tab.callbacks": "Колбэки",
|
"tab.callbacks": "Колбэки",
|
||||||
|
"tab.links": "Ссылки",
|
||||||
"tab.display": "Дисплей",
|
"tab.display": "Дисплей",
|
||||||
"display.loading": "Загрузка мониторов...",
|
"display.loading": "Загрузка мониторов...",
|
||||||
"display.error": "Не удалось загрузить мониторы",
|
"display.error": "Не удалось загрузить мониторы",
|
||||||
@@ -166,6 +167,36 @@
|
|||||||
"connection.reconnect": "Переподключиться",
|
"connection.reconnect": "Переподключиться",
|
||||||
"dialog.cancel": "Отмена",
|
"dialog.cancel": "Отмена",
|
||||||
"dialog.confirm": "Подтвердить",
|
"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.created_by": "Создано",
|
||||||
"footer.source_code": "Исходный код"
|
"footer.source_code": "Исходный код"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user