feat: implement initial ObsiGate application with backend API, indexing, search, and basic frontend.
This commit is contained in:
parent
d6ae501f51
commit
c5e395005f
@ -7,8 +7,10 @@ import logging
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
@ -614,6 +616,64 @@ async def api_vaults(current_user=Depends(require_auth)):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def humanize_mtime(mtime: float) -> str:
|
||||||
|
delta = time.time() - mtime
|
||||||
|
if delta < 60: return "à l'instant"
|
||||||
|
if delta < 3600: return f"il y a {int(delta/60)} min"
|
||||||
|
if delta < 86400: return f"il y a {int(delta/3600)} h"
|
||||||
|
if delta < 604800: return f"il y a {int(delta/86400)} j"
|
||||||
|
return datetime.fromtimestamp(mtime).strftime("%d %b %Y")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/recent")
|
||||||
|
async def api_recent(limit: Optional[int] = Query(None), vault: Optional[str] = Query(None), current_user=Depends(require_auth)):
|
||||||
|
config = _load_config()
|
||||||
|
actual_limit = limit if limit is not None else config.get("recent_files_limit", 20)
|
||||||
|
|
||||||
|
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||||
|
|
||||||
|
all_files = []
|
||||||
|
for v_name, v_data in index.items():
|
||||||
|
if vault and v_name != vault:
|
||||||
|
continue
|
||||||
|
if "*" not in user_vaults and v_name not in user_vaults:
|
||||||
|
continue
|
||||||
|
for f in v_data.get("files", []):
|
||||||
|
all_files.append((v_name, f))
|
||||||
|
|
||||||
|
# Sort descending by ISO string "modified"
|
||||||
|
all_files.sort(key=lambda x: x[1].get("modified", ""), reverse=True)
|
||||||
|
recent = all_files[:actual_limit]
|
||||||
|
|
||||||
|
files_resp = []
|
||||||
|
for v_name, f in recent:
|
||||||
|
iso_modified = f.get("modified", "")
|
||||||
|
try:
|
||||||
|
mtime_dt = datetime.fromisoformat(iso_modified.replace("Z", "+00:00"))
|
||||||
|
mtime_val = mtime_dt.timestamp()
|
||||||
|
except Exception:
|
||||||
|
mtime_val = time.time()
|
||||||
|
|
||||||
|
files_resp.append({
|
||||||
|
"path": f["path"],
|
||||||
|
"title": f["title"],
|
||||||
|
"vault": v_name,
|
||||||
|
"mtime": mtime_val,
|
||||||
|
"mtime_human": humanize_mtime(mtime_val),
|
||||||
|
"mtime_iso": iso_modified,
|
||||||
|
"size_bytes": f.get("size", 0),
|
||||||
|
"tags": [f"#{t}" for t in f.get("tags", [])][:5],
|
||||||
|
"preview": f.get("content_preview", "")[:120]
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"files": files_resp,
|
||||||
|
"total": len(all_files),
|
||||||
|
"limit": actual_limit,
|
||||||
|
"generated_at": time.time()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/browse/{vault_name}", response_model=BrowseResponse)
|
@app.get("/api/browse/{vault_name}", response_model=BrowseResponse)
|
||||||
async def api_browse(vault_name: str, path: str = "", current_user=Depends(require_auth)):
|
async def api_browse(vault_name: str, path: str = "", current_user=Depends(require_auth)):
|
||||||
"""Browse directories and files in a vault at a given path level.
|
"""Browse directories and files in a vault at a given path level.
|
||||||
@ -1348,6 +1408,7 @@ _DEFAULT_CONFIG = {
|
|||||||
"watcher_debounce": 2.0,
|
"watcher_debounce": 2.0,
|
||||||
"tag_boost": 2.0,
|
"tag_boost": 2.0,
|
||||||
"prefix_max_expansions": 50,
|
"prefix_max_expansions": 50,
|
||||||
|
"recent_files_limit": 20,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
164
frontend/app.js
164
frontend/app.js
@ -2261,6 +2261,150 @@
|
|||||||
area.scrollTop = 0;
|
area.scrollTop = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Recent files
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let _recentRefreshTimer = null;
|
||||||
|
let _recentTimestampTimer = null;
|
||||||
|
let _recentFilesCache = [];
|
||||||
|
|
||||||
|
async function loadRecentFiles(vaultFilter) {
|
||||||
|
const listEl = document.getElementById("recent-list");
|
||||||
|
const emptyEl = document.getElementById("recent-empty");
|
||||||
|
if (!listEl) return;
|
||||||
|
|
||||||
|
let url = "/api/recent";
|
||||||
|
if (vaultFilter) url += `?vault=${encodeURIComponent(vaultFilter)}`;
|
||||||
|
try {
|
||||||
|
const data = await api(url);
|
||||||
|
_recentFilesCache = data.files || [];
|
||||||
|
renderRecentList(_recentFilesCache);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load recent files:", err);
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
if (emptyEl) { emptyEl.classList.remove("hidden"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecentList(files) {
|
||||||
|
const listEl = document.getElementById("recent-list");
|
||||||
|
const emptyEl = document.getElementById("recent-empty");
|
||||||
|
if (!listEl) return;
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
if (emptyEl) { emptyEl.classList.remove("hidden"); safeCreateIcons(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (emptyEl) emptyEl.classList.add("hidden");
|
||||||
|
|
||||||
|
files.forEach((f) => {
|
||||||
|
const item = el("div", { class: "recent-item", "data-vault": f.vault, "data-path": f.path });
|
||||||
|
|
||||||
|
// Header row: time + vault badge
|
||||||
|
const header = el("div", { class: "recent-item-header" });
|
||||||
|
const timeSpan = el("span", { class: "recent-time" }, [
|
||||||
|
icon("clock", 11),
|
||||||
|
document.createTextNode(f.mtime_human),
|
||||||
|
]);
|
||||||
|
const badge = el("span", { class: "recent-vault-badge" }, [document.createTextNode(f.vault)]);
|
||||||
|
header.appendChild(timeSpan);
|
||||||
|
header.appendChild(badge);
|
||||||
|
item.appendChild(header);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
const titleEl = el("div", { class: "recent-item-title" }, [
|
||||||
|
document.createTextNode(f.title || f.path.split("/").pop()),
|
||||||
|
]);
|
||||||
|
item.appendChild(titleEl);
|
||||||
|
|
||||||
|
// Path breadcrumb
|
||||||
|
const pathParts = f.path.split("/");
|
||||||
|
if (pathParts.length > 1) {
|
||||||
|
const pathEl = el("div", { class: "recent-item-path" }, [
|
||||||
|
document.createTextNode(pathParts.slice(0, -1).join(" / ")),
|
||||||
|
]);
|
||||||
|
item.appendChild(pathEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview
|
||||||
|
if (f.preview) {
|
||||||
|
const previewEl = el("div", { class: "recent-item-preview" }, [
|
||||||
|
document.createTextNode(f.preview),
|
||||||
|
]);
|
||||||
|
item.appendChild(previewEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
if (f.tags && f.tags.length > 0) {
|
||||||
|
const tagsEl = el("div", { class: "recent-item-tags" });
|
||||||
|
f.tags.forEach((t) => {
|
||||||
|
tagsEl.appendChild(el("span", { class: "tag-pill" }, [document.createTextNode(t)]));
|
||||||
|
});
|
||||||
|
item.appendChild(tagsEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click handler
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
openFile(f.vault, f.path);
|
||||||
|
closeMobileSidebar();
|
||||||
|
});
|
||||||
|
|
||||||
|
listEl.appendChild(item);
|
||||||
|
});
|
||||||
|
safeCreateIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _humanizeDelta(mtime) {
|
||||||
|
const delta = (Date.now() / 1000) - mtime;
|
||||||
|
if (delta < 60) return "à l'instant";
|
||||||
|
if (delta < 3600) return `il y a ${Math.floor(delta / 60)} min`;
|
||||||
|
if (delta < 86400) return `il y a ${Math.floor(delta / 3600)} h`;
|
||||||
|
if (delta < 604800) return `il y a ${Math.floor(delta / 86400)} j`;
|
||||||
|
return new Date(mtime * 1000).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _refreshRecentTimestamps() {
|
||||||
|
if (activeSidebarTab !== "recent" || !_recentFilesCache.length) return;
|
||||||
|
const items = document.querySelectorAll(".recent-item");
|
||||||
|
items.forEach((item, i) => {
|
||||||
|
if (i < _recentFilesCache.length) {
|
||||||
|
const timeSpan = item.querySelector(".recent-time");
|
||||||
|
if (timeSpan) {
|
||||||
|
// keep the icon, update text
|
||||||
|
const textNode = timeSpan.lastChild;
|
||||||
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
textNode.textContent = _humanizeDelta(_recentFilesCache[i].mtime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _populateRecentVaultFilter() {
|
||||||
|
const select = document.getElementById("recent-vault-filter");
|
||||||
|
if (!select) return;
|
||||||
|
// keep first option "Tous les vaults"
|
||||||
|
while (select.options.length > 1) select.remove(1);
|
||||||
|
allVaults.forEach((v) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = v.name;
|
||||||
|
opt.textContent = v.name;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initRecentTab() {
|
||||||
|
const select = document.getElementById("recent-vault-filter");
|
||||||
|
if (select) {
|
||||||
|
select.addEventListener("change", () => {
|
||||||
|
loadRecentFiles(select.value || null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Periodic timestamp refresh (every 60s)
|
||||||
|
_recentTimestampTimer = setInterval(_refreshRecentTimestamps, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sidebar tabs
|
// Sidebar tabs
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -2283,14 +2427,21 @@
|
|||||||
});
|
});
|
||||||
const filterInput = document.getElementById("sidebar-filter-input");
|
const filterInput = document.getElementById("sidebar-filter-input");
|
||||||
if (filterInput) {
|
if (filterInput) {
|
||||||
filterInput.placeholder = tab === "vaults" ? "Filtrer fichiers..." : "Filtrer tags...";
|
const placeholders = { vaults: "Filtrer fichiers...", tags: "Filtrer tags...", recent: "" };
|
||||||
|
filterInput.placeholder = placeholders[tab] || "";
|
||||||
}
|
}
|
||||||
const query = filterInput
|
const query = filterInput
|
||||||
? (sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase())
|
? (sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase())
|
||||||
: "";
|
: "";
|
||||||
if (query) {
|
if (query) {
|
||||||
if (tab === "vaults") performTreeSearch(query);
|
if (tab === "vaults") performTreeSearch(query);
|
||||||
else filterTagCloud(query);
|
else if (tab === "tags") filterTagCloud(query);
|
||||||
|
}
|
||||||
|
// Auto-load recent files when switching to the recent tab
|
||||||
|
if (tab === "recent") {
|
||||||
|
_populateRecentVaultFilter();
|
||||||
|
const vaultFilter = document.getElementById("recent-vault-filter");
|
||||||
|
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2477,6 +2628,7 @@
|
|||||||
_setField("cfg-title-boost", data.title_boost);
|
_setField("cfg-title-boost", data.title_boost);
|
||||||
_setField("cfg-tag-boost", data.tag_boost);
|
_setField("cfg-tag-boost", data.tag_boost);
|
||||||
_setField("cfg-prefix-exp", data.prefix_max_expansions);
|
_setField("cfg-prefix-exp", data.prefix_max_expansions);
|
||||||
|
_setField("cfg-recent-limit", data.recent_files_limit || 20);
|
||||||
// Watcher config
|
// Watcher config
|
||||||
_setCheckbox("cfg-watcher-enabled", data.watcher_enabled !== false);
|
_setCheckbox("cfg-watcher-enabled", data.watcher_enabled !== false);
|
||||||
_setCheckbox("cfg-watcher-polling", data.watcher_use_polling === true);
|
_setCheckbox("cfg-watcher-polling", data.watcher_use_polling === true);
|
||||||
@ -2527,6 +2679,7 @@
|
|||||||
title_boost: _getFieldNum("cfg-title-boost", 3.0),
|
title_boost: _getFieldNum("cfg-title-boost", 3.0),
|
||||||
tag_boost: _getFieldNum("cfg-tag-boost", 2.0),
|
tag_boost: _getFieldNum("cfg-tag-boost", 2.0),
|
||||||
prefix_max_expansions: _getFieldNum("cfg-prefix-exp", 50),
|
prefix_max_expansions: _getFieldNum("cfg-prefix-exp", 50),
|
||||||
|
recent_files_limit: _getFieldNum("cfg-recent-limit", 20),
|
||||||
watcher_enabled: _getCheckbox("cfg-watcher-enabled"),
|
watcher_enabled: _getCheckbox("cfg-watcher-enabled"),
|
||||||
watcher_use_polling: _getCheckbox("cfg-watcher-polling"),
|
watcher_use_polling: _getCheckbox("cfg-watcher-polling"),
|
||||||
watcher_polling_interval: _getFieldNum("cfg-watcher-interval", 5.0),
|
watcher_polling_interval: _getFieldNum("cfg-watcher-interval", 5.0),
|
||||||
@ -3871,6 +4024,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh recent tab if it is active
|
||||||
|
if (activeSidebarTab === "recent") {
|
||||||
|
const vaultFilter = document.getElementById("recent-vault-filter");
|
||||||
|
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
connectionState = "connected";
|
connectionState = "connected";
|
||||||
_updateBadge();
|
_updateBadge();
|
||||||
@ -4039,6 +4198,7 @@
|
|||||||
initEditor();
|
initEditor();
|
||||||
initSyncStatus();
|
initSyncStatus();
|
||||||
initLoginForm();
|
initLoginForm();
|
||||||
|
initRecentTab();
|
||||||
|
|
||||||
// Check auth status first
|
// Check auth status first
|
||||||
const authOk = await AuthManager.initAuth();
|
const authOk = await AuthManager.initAuth();
|
||||||
|
|||||||
@ -282,6 +282,11 @@
|
|||||||
<i data-lucide="tag" style="width:13px;height:13px"></i>
|
<i data-lucide="tag" style="width:13px;height:13px"></i>
|
||||||
<span>Tags</span>
|
<span>Tags</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="sidebar-tab" id="sidebar-tab-recent" role="tab"
|
||||||
|
aria-selected="false" aria-controls="sidebar-panel-recent" data-tab="recent">
|
||||||
|
<i data-lucide="clock" style="width:13px;height:13px"></i>
|
||||||
|
<span>Récent</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Vaults panel -->
|
<!-- Vaults panel -->
|
||||||
@ -304,6 +309,20 @@
|
|||||||
<div class="tag-cloud" id="tag-cloud"></div>
|
<div class="tag-cloud" id="tag-cloud"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent panel -->
|
||||||
|
<div class="sidebar-tab-panel" id="sidebar-panel-recent" role="tabpanel" aria-labelledby="sidebar-tab-recent">
|
||||||
|
<div class="recent-filter-bar">
|
||||||
|
<select id="recent-vault-filter">
|
||||||
|
<option value="">Tous les vaults</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="recent-list" class="recent-list"></div>
|
||||||
|
<div id="recent-empty" class="recent-empty hidden">
|
||||||
|
<i data-lucide="inbox" style="width:32px;height:32px"></i>
|
||||||
|
<span>Aucun fichier récent</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Sidebar resize handle -->
|
<!-- Sidebar resize handle -->
|
||||||
@ -383,6 +402,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Historique récent -->
|
||||||
|
<section class="config-section">
|
||||||
|
<h2>📋 Historique récent <span class="config-badge-restart">Redémarrage non requis</span></h2>
|
||||||
|
<div class="config-row">
|
||||||
|
<label class="config-label" for="cfg-recent-limit">Nombre de fichiers dans l'historique</label>
|
||||||
|
<input type="number" id="cfg-recent-limit" class="config-input config-input--num" min="5" max="100" step="5" value="20">
|
||||||
|
<span class="config-hint">Entre 5 et 100 fichiers (sauvegardé sur le serveur)</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Performance Settings — Backend -->
|
<!-- Performance Settings — Backend -->
|
||||||
<section class="config-section">
|
<section class="config-section">
|
||||||
<h2>Paramètres backend <span class="config-badge-restart">Redémarrage requis</span></h2>
|
<h2>Paramètres backend <span class="config-badge-restart">Redémarrage requis</span></h2>
|
||||||
|
|||||||
@ -3353,3 +3353,69 @@ body.popup-mode .content-area {
|
|||||||
box-shadow: 0 8px 30px rgba(0,0,0,0.15);
|
box-shadow: 0 8px 30px rgba(0,0,0,0.15);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Recent Tab Styles
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Liste récente */
|
||||||
|
.recent-list { display: flex; flex-direction: column; gap: 2px; padding: 4px 0; }
|
||||||
|
|
||||||
|
/* Item */
|
||||||
|
.recent-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.recent-item:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
.recent-item.active { border-left-color: var(--accent); background: var(--bg-secondary); }
|
||||||
|
|
||||||
|
/* Header: temps + badge vault */
|
||||||
|
.recent-item-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.recent-time { font-size: 11px; color: var(--text-muted); display: flex; align-items: center; gap: 4px; }
|
||||||
|
.recent-vault-badge {
|
||||||
|
font-size: 10px; font-weight: 600; padding: 1px 6px;
|
||||||
|
border-radius: 10px; background: var(--accent); color: #fff; opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Titre */
|
||||||
|
.recent-item-title { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 2px; }
|
||||||
|
|
||||||
|
/* Breadcrumb path */
|
||||||
|
.recent-item-path { font-size: 11px; color: var(--text-muted); margin-bottom: 4px; font-family: monospace; }
|
||||||
|
|
||||||
|
/* Preview */
|
||||||
|
.recent-item-preview {
|
||||||
|
font-size: 12px; color: var(--text-muted);
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
|
overflow: hidden; line-height: 1.4; margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.recent-item-tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
|
.recent-item-tags .tag-pill {
|
||||||
|
font-size: 10px; padding: 1px 6px; border-radius: 10px;
|
||||||
|
background: var(--bg-secondary); color: var(--accent); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filtre vault */
|
||||||
|
.recent-filter-bar { padding: 8px 12px 4px; }
|
||||||
|
.recent-filter-bar select {
|
||||||
|
width: 100%; padding: 4px 8px; font-size: 12px;
|
||||||
|
background: var(--bg-secondary); color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border); border-radius: 4px; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* État vide */
|
||||||
|
.recent-empty {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
padding: 32px 16px; color: var(--text-muted); gap: 8px; font-size: 13px;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user