diff --git a/media_server/config.py b/media_server/config.py index 0911e3f..4c72903 100644 --- a/media_server/config.py +++ b/media_server/config.py @@ -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.""" diff --git a/media_server/config_manager.py b/media_server/config_manager.py index 7bef4b9..ff1e478 100644 --- a/media_server/config_manager.py +++ b/media_server/config_manager.py @@ -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() diff --git a/media_server/main.py b/media_server/main.py index 306ebb3..1113acc 100644 --- a/media_server/main.py +++ b/media_server/main.py @@ -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) diff --git a/media_server/routes/__init__.py b/media_server/routes/__init__.py index 05efa46..5305084 100644 --- a/media_server/routes/__init__.py +++ b/media_server/routes/__init__.py @@ -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"] diff --git a/media_server/routes/links.py b/media_server/routes/links.py new file mode 100644 index 0000000..45d5b31 --- /dev/null +++ b/media_server/routes/links.py @@ -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} diff --git a/media_server/services/websocket_manager.py b/media_server/services/websocket_manager.py index 07ee85f..0f5dbfa 100644 --- a/media_server/services/websocket_manager.py +++ b/media_server/services/websocket_manager.py @@ -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: diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index 8f5c769..27b10a4 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -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); diff --git a/media_server/static/index.html b/media_server/static/index.html index 490eb66..88d4014 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -66,6 +66,7 @@
+
+
@@ -329,6 +334,36 @@
+ + +
@@ -373,7 +408,10 @@