Compare commits
1 Commits
6f6a4e4aec
...
99dbbb1019
| Author | SHA1 | Date | |
|---|---|---|---|
| 99dbbb1019 |
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
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)
|
||||
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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Исходный код"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user