- Merge Scripts/Callbacks/Links tabs into single Settings tab with collapsible sections - Rename Actions tab to Quick Access showing both scripts and configured links - Add prev/next buttons to mini (secondary) player - Add optional description field to links (backend + frontend) - Add CSS chevron indicators on collapsible settings sections - Persist section collapse/expand state in localStorage - Fix race condition in Quick Access rendering with generation counter - Order settings sections: Scripts, Links, Callbacks Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
189 lines
5.2 KiB
Python
189 lines
5.2 KiB
Python
"""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
|
|
description: 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")
|
|
description: str = Field(default="", description="Optional description")
|
|
|
|
|
|
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,
|
|
description=config.description,
|
|
)
|
|
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}
|