fix: vault home recursive + backup viewer fixes + AI status at startup
- Vault home (#12): recursive file listing (rglob), rel_dir in metadata, 'recursif' badge, updated roadmap description - Backup viewer: fix close button (inline onclick + event delegation fallback for text nodes), improve error messages - Backup API: remove response_model to prevent Pydantic 500 errors, add try/except + logging on backups/diff/restore endpoints, per-entry error handling in _list_backup_files, encoding safety (errors='replace') on read_text - AI status: call /api/ai/status at startup in _initAIStatus instead of waiting for user click on sync badge
This commit is contained in:
+8
-6
@@ -153,21 +153,23 @@
|
||||
- **Effort** : 0.5 jour | **Impact** : 🟢
|
||||
- `indexer.py` et `watcher.py` ont des définitions séparées → mutualiser
|
||||
|
||||
### 11. Indicateur AI Actif — FAIT
|
||||
### ✅ 11. Indicateur AI Actif — FAIT
|
||||
- **Effort** : 0.5 jour | **Impact** : 🟡
|
||||
- Dans le bouton/indicateur à droite du titre, en cliquant il y a un panneau avec les statuts de syncro. Ajouter un indicateur visuel dans ce panneau pour indiquer si l'IA est active, configurée, et fonctionnelle. Durant la phase de traitement de l'IA, le bouton/indicateur devrait changer de couleur pour un blanc qui flash indiquant le statut de la tâche AI en cours. ✅
|
||||
- Dans le bouton/indicateur à droite du titre, en cliquant il y a un panneau avec les statuts de syncro. Ajouter un indicateur visuel dans ce panneau pour indiquer si l'IA est active, configurée, et fonctionnelle. Durant la phase de traitement de l'IA, le bouton/indicateur devrait changer de couleur pour un blanc qui flash indiquant le statut de la tâche AI en cours.
|
||||
- **Fichiers** : `backend/main.py` (`/api/ai/status`), `frontend/js/ai.js`, `frontend/js/sync.js`, `frontend/style.css`
|
||||
- [x] **Badge header flash blanc** : le `#sync-badge` devient blanc avec animation flash quand une tâche AI est en cours
|
||||
- [x] **Événements** : `ai-processing-start` / `ai-processing-end` dispatchés depuis `ai.js`
|
||||
- [x] **Section IA dans panneau sync** : statut (configurée/non configurée/traitement en cours) avec icône et couleur
|
||||
- [x] **Endpoint `/api/ai/status`** : vérifie si au moins un provider AI est configuré
|
||||
|
||||
### 12. Page d'accueil de voute — FAIT
|
||||
### 12. Page d'accueil de voute — FAIT (récursif)
|
||||
- **Effort** : 1 jour | **Impact** : 🟡
|
||||
- Une page d'accueil qui affiche les fichiers de la voute actuelle dans l'ordre du plus récent au plus ancien. Si l'utilisateur change de répertoire dans cette voute, la liste devrait se mettre à jour automatiquement. ✅
|
||||
- Une page d'accueil qui affiche les fichiers de la voute actuelle dans l'ordre du plus récent au plus ancien, récursivement (répertoire + tous les sous-répertoires). Si l'utilisateur change de répertoire dans cette voute, la liste se met à jour automatiquement. ✅
|
||||
- **Fichiers** : `backend/main.py` (`/api/vault/{vault}/files`), `frontend/js/vaulthome.js`, `frontend/js/sidebar.js`, `frontend/style.css`
|
||||
- [x] **Endpoint `/api/vault/{vault}/files?dir=...`** : liste les fichiers d'un répertoire triés par date de modification décroissante
|
||||
- [x] **Affichage style résultats de recherche** : chaque fichier utilise le même look `.search-result-item` (extension, icône, titre, métadonnées)
|
||||
- [x] **Endpoint `/api/vault/{vault}/files?dir=...&recursive=true`** : liste les fichiers d'un répertoire et de tous ses sous-répertoires récursivement (`rglob`), triés par date de modification décroissante
|
||||
- [x] **Champ `rel_dir`** : chaque fichier indique son sous-répertoire relatif pour afficher le chemin dans les résultats
|
||||
- [x] **Affichage style résultats de recherche** : chaque fichier utilise le même look `.search-result-item` (extension, icône, titre, métadonnées avec `sous-dossier/ · date · taille`)
|
||||
- [x] **Badge « récursif »** : indicateur visuel dans le header confirmant le mode récursif
|
||||
- [x] **Breadcrumb navigable** : fil d'Ariane cliquable (vault → dossier → sous-dossier)
|
||||
- [x] **Mise à jour automatique** : quand on clique sur un dossier dans l'arborescence, la page se met à jour
|
||||
- [x] **Déclenchement** : affiché automatiquement quand un vault spécifique est sélectionné
|
||||
|
||||
+85
-15
@@ -1952,7 +1952,17 @@ def _list_backup_files(vault_name: str, relative_path: str) -> list[dict]:
|
||||
prefix = original_name + "."
|
||||
backups = []
|
||||
|
||||
for f in backup_dir.iterdir():
|
||||
try:
|
||||
dir_entries = list(backup_dir.iterdir())
|
||||
except PermissionError:
|
||||
logger.warning(f"Permission denied reading backup dir: {backup_dir}")
|
||||
return []
|
||||
except OSError as e:
|
||||
logger.error(f"Error reading backup dir {backup_dir}: {e}")
|
||||
return []
|
||||
|
||||
for f in dir_entries:
|
||||
try:
|
||||
if not f.is_file():
|
||||
continue
|
||||
name = f.name
|
||||
@@ -1964,19 +1974,27 @@ def _list_backup_files(vault_name: str, relative_path: str) -> list[dict]:
|
||||
ts = int(ts_part)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
try:
|
||||
dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
||||
except (OSError, OverflowError, ValueError) as ts_err:
|
||||
logger.warning(f"Skipping backup with invalid timestamp {ts}: {ts_err}")
|
||||
continue
|
||||
st = f.stat()
|
||||
backups.append({
|
||||
"timestamp": ts,
|
||||
"datetime": dt,
|
||||
"size": f.stat().st_size,
|
||||
"size": st.st_size,
|
||||
"filename": name,
|
||||
})
|
||||
except Exception as entry_err:
|
||||
logger.warning(f"Skipping unreadable backup entry {f}: {entry_err}")
|
||||
continue
|
||||
|
||||
backups.sort(key=lambda b: b["timestamp"], reverse=True)
|
||||
return backups
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}/backups", response_model=BackupListResponse)
|
||||
@app.get("/api/file/{vault_name}/backups")
|
||||
async def api_file_backups(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
@@ -2004,12 +2022,16 @@ async def api_file_backups(
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
try:
|
||||
backups = _list_backup_files(vault_name, path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing backups for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Erreur lors de la lecture des backups: {str(e)}")
|
||||
|
||||
return {"vault": vault_name, "path": path, "backups": backups}
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}/diff", response_model=DiffResponse)
|
||||
@app.get("/api/file/{vault_name}/diff")
|
||||
async def api_file_diff(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
@@ -2050,10 +2072,14 @@ async def api_file_diff(
|
||||
backup_path = backup_dir / f"{original_name}.{ts}.bak"
|
||||
if not backup_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Backup version {ts} not found for {path}")
|
||||
content = backup_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
content = backup_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to read backup {ts}: {e}")
|
||||
dt = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
return content, f"{path}@{dt}"
|
||||
|
||||
try:
|
||||
# Read left side (backup version)
|
||||
left_content, left_label = _read_backup(version)
|
||||
|
||||
@@ -2061,7 +2087,10 @@ async def api_file_diff(
|
||||
if compare_with is not None:
|
||||
right_content, right_label = _read_backup(compare_with)
|
||||
else:
|
||||
right_content = file_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
right_content = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to read current file: {e}")
|
||||
right_label = f"{path} (current)"
|
||||
|
||||
# Generate unified diff using difflib
|
||||
@@ -2081,9 +2110,14 @@ async def api_file_diff(
|
||||
"compare_with": compare_with,
|
||||
"diff": "".join(diff_lines),
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating diff for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Erreur lors de la génération du diff: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/api/file/{vault_name}/restore", response_model=RestoreResponse)
|
||||
@app.post("/api/file/{vault_name}/restore")
|
||||
async def api_file_restore(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
@@ -3075,7 +3109,8 @@ async def api_update_vault_settings(vault_name: str, body: dict = Body(...), cur
|
||||
async def api_vault_recent_files(
|
||||
vault_name: str,
|
||||
dir: str = Query("", description="Directory path within the vault (empty = root)"),
|
||||
limit: int = Query(100, description="Maximum number of files to return"),
|
||||
limit: int = Query(200, description="Maximum number of files to return"),
|
||||
recursive: bool = Query(True, description="If true, list files recursively from directory and all subdirectories"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""List files in a vault directory sorted by modification time (newest first).
|
||||
@@ -3084,13 +3119,18 @@ async def api_vault_recent_files(
|
||||
Unlike /api/browse, this endpoint sorts by mtime and returns
|
||||
additional metadata (size, modified time, extension).
|
||||
|
||||
When recursive=True (default), lists files from the directory
|
||||
AND all its subdirectories, with a ``rel_dir`` field indicating
|
||||
the subdirectory path relative to the requested directory.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
dir: Relative directory path within the vault (empty for root).
|
||||
limit: Maximum files to return (default 100).
|
||||
limit: Maximum files to return (default 200).
|
||||
recursive: If true, recursively list files in subdirectories (default true).
|
||||
|
||||
Returns:
|
||||
JSON with vault, directory, and list of file entries.
|
||||
JSON with vault, directory, count, recursive flag, and list of file entries.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
@@ -3107,21 +3147,46 @@ async def api_vault_recent_files(
|
||||
|
||||
files = []
|
||||
ignored = {'.obsidian', '.trash', '.git', '.obsigate-backup', '__pycache__', 'node_modules'}
|
||||
dir_prefix = (dir or "").strip("/")
|
||||
|
||||
try:
|
||||
for entry in dir_path.iterdir():
|
||||
if entry.name.startswith('.') and entry.name not in ('.', '..'):
|
||||
iterator = dir_path.rglob('*') if recursive else dir_path.iterdir()
|
||||
for entry in iterator:
|
||||
if not entry.is_file():
|
||||
continue
|
||||
if entry.name.startswith('.'):
|
||||
continue
|
||||
if entry.name in ignored:
|
||||
continue
|
||||
if not entry.is_file():
|
||||
|
||||
# Skip entries whose parent dir chain contains an ignored dir (for rglob)
|
||||
if recursive and dir_prefix:
|
||||
skip = False
|
||||
try:
|
||||
for part in entry.relative_to(dir_path).parts[:-1]:
|
||||
if part.startswith('.') or part in ignored:
|
||||
skip = True
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
if skip:
|
||||
continue
|
||||
|
||||
stat = entry.stat()
|
||||
rel_path = str(entry.relative_to(vault_root)).replace("\\", "/")
|
||||
|
||||
# Compute relative directory from the requested dir
|
||||
if recursive and dir_prefix:
|
||||
try:
|
||||
rel_to_dir = str(entry.parent.relative_to(dir_path)).replace("\\", "/")
|
||||
except ValueError:
|
||||
rel_to_dir = ""
|
||||
else:
|
||||
rel_to_dir = ""
|
||||
|
||||
ext = entry.suffix.lower() if entry.suffix else ""
|
||||
|
||||
files.append({
|
||||
file_entry = {
|
||||
"name": entry.name,
|
||||
"path": rel_path,
|
||||
"vault": vault_name,
|
||||
@@ -3129,7 +3194,11 @@ async def api_vault_recent_files(
|
||||
"modified": stat.st_mtime,
|
||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
"extension": ext.lstrip(".") if ext else "",
|
||||
})
|
||||
}
|
||||
if rel_to_dir and rel_to_dir != ".":
|
||||
file_entry["rel_dir"] = rel_to_dir
|
||||
|
||||
files.append(file_entry)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Permission denied reading directory")
|
||||
|
||||
@@ -3142,6 +3211,7 @@ async def api_vault_recent_files(
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"directory": dir,
|
||||
"recursive": recursive,
|
||||
"count": len(files),
|
||||
"files": files,
|
||||
}
|
||||
|
||||
+21
-9
@@ -59,7 +59,8 @@ export async function openBackupViewer(vault, path) {
|
||||
const data = await api(`/api/file/${encodeURIComponent(vault)}/backups?path=${encodeURIComponent(path)}`);
|
||||
state.backups = data.backups || [];
|
||||
} catch (err) {
|
||||
setBody(renderError('Failed to load backups'));
|
||||
const msg = err?.detail || err?.message || 'Erreur inconnue';
|
||||
setBody(renderError(`Erreur API: ${msg}`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,6 +96,17 @@ function createOverlay() {
|
||||
panel.id = 'backup-viewer-panel';
|
||||
overlay.appendChild(panel);
|
||||
|
||||
// Event delegation fallback for close button (in case inline onclick fails)
|
||||
panel.addEventListener('click', (e) => {
|
||||
// e.target might be a text node (e.g. "×") — use parentElement as fallback
|
||||
const target = e.target.nodeType === Node.TEXT_NODE ? e.target.parentElement : e.target;
|
||||
const closeBtn = target && target.closest ? target.closest('.backup-close-btn') : null;
|
||||
if (closeBtn) {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
@@ -113,6 +125,9 @@ function close() {
|
||||
}
|
||||
}
|
||||
|
||||
// Expose globally for inline onclick handlers (bypasses text-node target issues)
|
||||
window._backupViewerClose = close;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -121,7 +136,7 @@ function renderLoading() {
|
||||
return `
|
||||
<div class="backup-header">
|
||||
<h3>Backups</h3>
|
||||
<button class="backup-close-btn" title="Fermer">×</button>
|
||||
<button class="backup-close-btn" title="Fermer" onclick="window._backupViewerClose()">×</button>
|
||||
</div>
|
||||
<div class="backup-body">
|
||||
<div class="tree-loading" style="padding:2rem;text-align:center">
|
||||
@@ -143,10 +158,10 @@ function renderError(msg) {
|
||||
}
|
||||
|
||||
function renderEmpty() {
|
||||
return `
|
||||
return \`
|
||||
<div class="backup-header">
|
||||
<h3>Backups — ${escapeHtml(state.path)}</h3>
|
||||
<button class="backup-close-btn" title="Fermer">×</button>
|
||||
<h3>Backups — \${escapeHtml(state.path)}</h3>
|
||||
<button class="backup-close-btn" title="Fermer" onclick="window._backupViewerClose()">×</button>
|
||||
</div>
|
||||
<div class="backup-body" style="text-align:center;padding:2rem;color:var(--text-muted)">
|
||||
<p>Aucun backup disponible pour ce fichier.</p>
|
||||
@@ -177,7 +192,7 @@ function renderMainView() {
|
||||
</div>
|
||||
<div class="backup-header-right">
|
||||
<span class="backup-count">${b.length} backup${b.length > 1 ? 's' : ''}</span>
|
||||
<button class="backup-close-btn" title="Fermer">×</button>
|
||||
<button class="backup-close-btn" title="Fermer" onclick="window._backupViewerClose()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -220,9 +235,6 @@ function bindEvents() {
|
||||
const panel = document.getElementById('backup-viewer-panel');
|
||||
if (!panel) return;
|
||||
|
||||
// Close button
|
||||
panel.querySelector('.backup-close-btn')?.addEventListener('click', close);
|
||||
|
||||
// Left select
|
||||
const leftSelect = document.getElementById('backup-left-select');
|
||||
leftSelect?.addEventListener('change', async () => {
|
||||
|
||||
+14
-12
@@ -1,6 +1,7 @@
|
||||
/* ObsiGate — Sync: SSE client + PWA registration */
|
||||
import { state } from './state.js';
|
||||
import { showToast } from './ui.js';
|
||||
import { api } from './auth.js';
|
||||
import { loadVaults, loadTags, refreshTagsForContext, refreshSidebarTreePreservingState } from './sidebar.js';
|
||||
import { loadRecentFiles } from './config.js';
|
||||
import { openFile } from './viewer.js';
|
||||
@@ -339,16 +340,16 @@ function _renderSyncPanel(panel) {
|
||||
let _aiProcessing = false;
|
||||
let _aiConfigured = null; // null = unknown, true/false
|
||||
|
||||
/** Check if AI is configured by looking for the AI toolbar in the DOM.
|
||||
* The toolbar is only rendered when the AI API responds successfully. */
|
||||
function _checkAIStatus() {
|
||||
const container = document.getElementById('ai-toolbar-container');
|
||||
if (container && container.children.length > 0) {
|
||||
_aiConfigured = true;
|
||||
} else if (_aiConfigured === null) {
|
||||
// Keep unknown state until we have evidence
|
||||
_aiConfigured = null;
|
||||
/** Check if AI is configured by calling the /api/ai/status endpoint.
|
||||
* Updates _aiConfigured and refreshes the panel if open. */
|
||||
async function _checkAIStatus() {
|
||||
try {
|
||||
const status = await api('/api/ai/status');
|
||||
_aiConfigured = status.configured === true;
|
||||
} catch {
|
||||
_aiConfigured = false;
|
||||
}
|
||||
_updateAIStatusInPanel();
|
||||
}
|
||||
|
||||
function _renderAIStatusSection(panel) {
|
||||
@@ -420,6 +421,9 @@ function _setBadgeAIProcessing(active) {
|
||||
|
||||
/** Initialize AI status tracking. */
|
||||
function _initAIStatus() {
|
||||
// Check AI status immediately at startup
|
||||
_checkAIStatus();
|
||||
|
||||
// Listen for AI processing events from ai.js
|
||||
document.addEventListener('ai-processing-start', () => {
|
||||
_aiProcessing = true;
|
||||
@@ -433,13 +437,11 @@ function _initAIStatus() {
|
||||
_updateAIStatusInPanel();
|
||||
});
|
||||
|
||||
// Check AI status on first sync panel open
|
||||
// Re-check AI status when sync panel opens (in case config changed)
|
||||
const badge = document.getElementById('sync-badge');
|
||||
if (badge) {
|
||||
const origClick = badge.onclick;
|
||||
badge.addEventListener('click', () => {
|
||||
_checkAIStatus();
|
||||
_updateAIStatusInPanel();
|
||||
}, { once: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
* Vault Home Page module for ObsiGate.
|
||||
*
|
||||
* When a specific vault is selected (not "All"), replaces the dashboard
|
||||
* with a file listing sorted by modification time (newest first).
|
||||
* with a recursive file listing sorted by modification time (newest first).
|
||||
* Files from the selected directory AND all its subdirectories are shown
|
||||
* with their relative path displayed in the metadata line.
|
||||
* The listing updates automatically when browsing into subdirectories.
|
||||
*
|
||||
* The visual style mirrors the search results (.search-result-item) for
|
||||
@@ -184,7 +186,7 @@ function buildBreadcrumbHTML(parentEl, vaultName, dirPath) {
|
||||
|
||||
async function loadAndRender(vaultName, dirPath, container) {
|
||||
try {
|
||||
const url = `/api/vault/${encodeURIComponent(vaultName)}/files?dir=${encodeURIComponent(dirPath)}&limit=200`;
|
||||
const url = `/api/vault/${encodeURIComponent(vaultName)}/files?dir=${encodeURIComponent(dirPath)}&limit=200&recursive=true`;
|
||||
const data = await api(url);
|
||||
|
||||
if (!data.files || data.files.length === 0) {
|
||||
@@ -204,7 +206,15 @@ async function loadAndRender(vaultName, dirPath, container) {
|
||||
]);
|
||||
header.appendChild(count);
|
||||
|
||||
// Sort dropdown (already sorted by mtime from backend)
|
||||
// Recursive indicator
|
||||
if (data.recursive) {
|
||||
const recursiveBadge = el('span', { class: 'vault-home-recursive-badge' }, [
|
||||
document.createTextNode('récursif'),
|
||||
]);
|
||||
header.appendChild(recursiveBadge);
|
||||
}
|
||||
|
||||
// Directory label
|
||||
const dirLabel = dirPath ? `/${dirPath}` : ' (racine)';
|
||||
const dirInfo = el('span', { class: 'vault-home-dir-info' }, [
|
||||
document.createTextNode(dirLabel),
|
||||
@@ -233,8 +243,16 @@ async function loadAndRender(vaultName, dirPath, container) {
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
|
||||
// Build metadata line: rel_dir (if any) + date + size
|
||||
const metaParts = [];
|
||||
if (f.rel_dir) {
|
||||
metaParts.push(f.rel_dir + '/');
|
||||
}
|
||||
metaParts.push(dateStr);
|
||||
metaParts.push(formatSize(f.size));
|
||||
|
||||
const metaDiv = el('div', { class: 'search-result-vault' }, [
|
||||
document.createTextNode(`${dateStr} · ${formatSize(f.size)}`),
|
||||
document.createTextNode(metaParts.join(' · ')),
|
||||
]);
|
||||
|
||||
const item = el('div', {
|
||||
|
||||
@@ -4071,6 +4071,17 @@ body.resizing-v {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.vault-home-recursive-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.vault-home-dir-info {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
|
||||
Reference in New Issue
Block a user