This commit is contained in:
@@ -4,12 +4,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import cgi
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from email.utils import formatdate
|
||||
@@ -23,6 +25,8 @@ from urllib.parse import parse_qs, urlparse
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
BLOGS_DIR = ROOT / "blogs"
|
||||
POSTS_DIR = ROOT / "posts"
|
||||
LIMA_DIR = ROOT / "lima"
|
||||
HZONE_ASSETS_DIR = ROOT / "assets" / "images" / "hzone"
|
||||
GENERATED_ORG_NAMES = {
|
||||
"blogs-list.org",
|
||||
"posts-list.org",
|
||||
@@ -31,6 +35,18 @@ GENERATED_ORG_NAMES = {
|
||||
"recently-updated.org",
|
||||
"wip.org",
|
||||
}
|
||||
GENERATED_CONTENT_NAMES = GENERATED_ORG_NAMES | {"lima-list.org"}
|
||||
ALLOWED_UPLOAD_EXTENSIONS = {
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".svg",
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov",
|
||||
}
|
||||
MONTH_NAMES = [
|
||||
"january",
|
||||
"february",
|
||||
@@ -112,6 +128,21 @@ class OrgPage:
|
||||
wip: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContentPage:
|
||||
path: str
|
||||
page_type: str
|
||||
title: str
|
||||
slug: str
|
||||
tags: list[str]
|
||||
content: str
|
||||
date: str
|
||||
comments: bool
|
||||
options: str
|
||||
format: str
|
||||
wip: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BuildJob:
|
||||
id: int
|
||||
@@ -216,25 +247,55 @@ def safe_relative_path(path: str) -> Path:
|
||||
full = (ROOT / rel).resolve()
|
||||
if not full.is_relative_to(ROOT):
|
||||
raise ValueError("Path must stay inside this repository.")
|
||||
if full.suffix != ".org":
|
||||
raise ValueError("Only .org files can be edited.")
|
||||
if not (full.is_relative_to(BLOGS_DIR) or full.is_relative_to(POSTS_DIR)):
|
||||
raise ValueError("Only blogs/ and posts/ files can be edited here.")
|
||||
if full.suffix == ".org" and (full.is_relative_to(BLOGS_DIR) or full.is_relative_to(POSTS_DIR)):
|
||||
return full
|
||||
if full.suffix == ".md" and full.is_relative_to(LIMA_DIR):
|
||||
return full
|
||||
raise ValueError("Only .org files under blogs/posts and .md files under lima can be edited here.")
|
||||
return full
|
||||
|
||||
|
||||
def safe_target_path(path: str, slug: str) -> Path:
|
||||
def safe_target_path(path: str, slug: str, page_type: str) -> Path:
|
||||
candidate = path.strip()
|
||||
if not candidate:
|
||||
raise ValueError("Path is required.")
|
||||
default_ext = ".md" if page_type == "lima" else ".org"
|
||||
if candidate.endswith("/"):
|
||||
candidate = f"{candidate}{slug}.org"
|
||||
candidate = f"{candidate}{slug}{default_ext}"
|
||||
elif not Path(candidate).suffix:
|
||||
candidate = f"{candidate}.org"
|
||||
candidate = f"{candidate}{default_ext}"
|
||||
return safe_relative_path(candidate)
|
||||
|
||||
|
||||
def read_page(path: Path) -> OrgPage:
|
||||
def markdown_title(content: str, fallback: str) -> str:
|
||||
for line in content.splitlines():
|
||||
match = re.match(r"^#\s+(.+?)\s*$", line)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return fallback.replace("-", " ").replace("_", " ").title()
|
||||
|
||||
|
||||
def read_markdown_page(path: Path) -> ContentPage:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
rel = path.relative_to(ROOT).as_posix()
|
||||
title = markdown_title(content, path.stem)
|
||||
return ContentPage(
|
||||
path=rel,
|
||||
page_type="lima",
|
||||
title=title,
|
||||
slug=path.stem,
|
||||
tags=[],
|
||||
content=content,
|
||||
date="",
|
||||
comments=True,
|
||||
options="",
|
||||
format="markdown",
|
||||
)
|
||||
|
||||
|
||||
def read_page(path: Path) -> ContentPage:
|
||||
if path.suffix == ".md" and path.is_relative_to(LIMA_DIR):
|
||||
return read_markdown_page(path)
|
||||
text = path.read_text(encoding="utf-8")
|
||||
meta: dict[str, str] = {}
|
||||
body_lines: list[str] = []
|
||||
@@ -250,7 +311,7 @@ def read_page(path: Path) -> OrgPage:
|
||||
page_type = "blog" if path.is_relative_to(BLOGS_DIR) else "post"
|
||||
slug = meta.get("SLUG") or path.stem
|
||||
tags = normalise_tags(meta.get("FILETAGS", ""))
|
||||
return OrgPage(
|
||||
return ContentPage(
|
||||
path=rel,
|
||||
page_type=page_type,
|
||||
title=meta.get("TITLE", path.stem),
|
||||
@@ -260,11 +321,12 @@ def read_page(path: Path) -> OrgPage:
|
||||
date=meta.get("DATE", org_date(datetime.fromtimestamp(path.stat().st_mtime))),
|
||||
comments=meta.get("COMMENTS", "t").lower() == "t",
|
||||
options=meta.get("OPTIONS", "num:nil"),
|
||||
format="org",
|
||||
wip=meta.get("WIP"),
|
||||
)
|
||||
|
||||
|
||||
def page_to_dict(page: OrgPage) -> dict[str, Any]:
|
||||
def page_to_dict(page: ContentPage) -> dict[str, Any]:
|
||||
return {
|
||||
"path": page.path,
|
||||
"pageType": page.page_type,
|
||||
@@ -275,17 +337,22 @@ def page_to_dict(page: OrgPage) -> dict[str, Any]:
|
||||
"date": page.date,
|
||||
"comments": page.comments,
|
||||
"options": page.options,
|
||||
"format": page.format,
|
||||
"wip": page.wip or "",
|
||||
}
|
||||
|
||||
|
||||
def list_pages() -> list[dict[str, Any]]:
|
||||
pages = []
|
||||
for base, page_type in ((BLOGS_DIR, "blog"), (POSTS_DIR, "post")):
|
||||
for base, page_type, extension in (
|
||||
(BLOGS_DIR, "blog", "*.org"),
|
||||
(POSTS_DIR, "post", "*.org"),
|
||||
(LIMA_DIR, "lima", "*.md"),
|
||||
):
|
||||
if not base.exists():
|
||||
continue
|
||||
for path in sorted(base.rglob("*.org")):
|
||||
if path.name in GENERATED_ORG_NAMES or "sync-conflict" in path.name:
|
||||
for path in sorted(base.rglob(extension)):
|
||||
if path.name in GENERATED_CONTENT_NAMES or "sync-conflict" in path.name:
|
||||
continue
|
||||
try:
|
||||
page = read_page(path)
|
||||
@@ -300,6 +367,7 @@ def list_pages() -> list[dict[str, Any]]:
|
||||
"slug": page.slug,
|
||||
"tags": page.tags,
|
||||
"date": page.date,
|
||||
"format": page.format,
|
||||
"timestamp": parsed.timestamp() if parsed else path.stat().st_mtime,
|
||||
}
|
||||
)
|
||||
@@ -311,10 +379,10 @@ def target_path(data: dict[str, Any], existing_path: str | None) -> Path:
|
||||
return safe_relative_path(existing_path)
|
||||
title = str(data.get("title") or "").strip()
|
||||
slug = slugify(str(data.get("slug") or title))
|
||||
page_type = str(data.get("pageType") or "blog")
|
||||
explicit_path = str(data.get("targetPath") or "").strip()
|
||||
if explicit_path:
|
||||
return safe_target_path(explicit_path, slug)
|
||||
page_type = str(data.get("pageType") or "blog")
|
||||
return safe_target_path(explicit_path, slug, page_type)
|
||||
if page_type == "blog":
|
||||
dt = parse_org_datetime(str(data.get("date") or "")) or datetime.now()
|
||||
folder = BLOGS_DIR / str(dt.year) / f"{dt.month:02d}-{MONTH_NAMES[dt.month - 1]}"
|
||||
@@ -324,10 +392,22 @@ def target_path(data: dict[str, Any], existing_path: str | None) -> Path:
|
||||
section = slugify(raw_section) if raw_section else ""
|
||||
folder = POSTS_DIR / section if section else POSTS_DIR
|
||||
return folder / f"{slug}.org"
|
||||
raise ValueError("pageType must be blog or post.")
|
||||
if page_type == "lima":
|
||||
return LIMA_DIR / f"{slug}.md"
|
||||
raise ValueError("pageType must be blog, post, or lima.")
|
||||
|
||||
|
||||
def render_org(data: dict[str, Any], previous: OrgPage | None) -> str:
|
||||
def render_markdown(data: dict[str, Any]) -> str:
|
||||
content = str(data.get("content") or "").replace("\r\n", "\n").strip()
|
||||
title = str(data.get("title") or "").strip()
|
||||
if not title:
|
||||
raise ValueError("Title is required.")
|
||||
if content:
|
||||
return content + "\n"
|
||||
return f"# {title}\n"
|
||||
|
||||
|
||||
def render_org(data: dict[str, Any], previous: ContentPage | None) -> str:
|
||||
title = str(data.get("title") or "").strip()
|
||||
if not title:
|
||||
raise ValueError("Title is required.")
|
||||
@@ -368,10 +448,39 @@ def save_page(data: dict[str, Any]) -> dict[str, Any]:
|
||||
target.parent.mkdir(parents=True)
|
||||
if target.exists() and not existing_path:
|
||||
raise ValueError(f"{target.relative_to(ROOT)} already exists.")
|
||||
target.write_text(render_org(data, previous), encoding="utf-8")
|
||||
if target.suffix == ".md":
|
||||
target.write_text(render_markdown(data), encoding="utf-8")
|
||||
else:
|
||||
target.write_text(render_org(data, previous), encoding="utf-8")
|
||||
return page_to_dict(read_page(target))
|
||||
|
||||
|
||||
def save_upload(filename: str, payload: bytes) -> dict[str, str]:
|
||||
original = Path(filename or "attachment").name
|
||||
ext = Path(original).suffix.lower()
|
||||
if ext not in ALLOWED_UPLOAD_EXTENSIONS:
|
||||
raise ValueError("Only common image and video files can be uploaded.")
|
||||
now = datetime.now()
|
||||
target_dir = HZONE_ASSETS_DIR / f"{now.year}" / f"{now.month:02d}"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
stem = slugify(Path(original).stem)
|
||||
target = target_dir / f"{now.strftime('%Y%m%d-%H%M%S')}-{stem}{ext}"
|
||||
counter = 2
|
||||
while target.exists():
|
||||
target = target_dir / f"{now.strftime('%Y%m%d-%H%M%S')}-{stem}-{counter}{ext}"
|
||||
counter += 1
|
||||
target.write_bytes(payload)
|
||||
rel = target.relative_to(ROOT).as_posix()
|
||||
url = f"/{rel}"
|
||||
mime = mimetypes.guess_type(target.name)[0] or ""
|
||||
alt = Path(original).stem.replace("-", " ").replace("_", " ").strip() or "attachment"
|
||||
if mime.startswith("video/") or ext in {".mp4", ".webm", ".mov"}:
|
||||
markdown = f'<video controls src="{url}"></video>'
|
||||
else:
|
||||
markdown = f""
|
||||
return {"url": url, "path": rel, "markdown": markdown, "filename": target.name}
|
||||
|
||||
|
||||
def run_build_commands() -> tuple[bool, str, str]:
|
||||
venv_python = ROOT / ".venv" / "bin" / "python"
|
||||
venv_pip = ROOT / ".venv" / "bin" / "pip"
|
||||
@@ -421,6 +530,7 @@ APP_HTML = r"""<!doctype html>
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { border: 1px solid var(--line); background: #fff; color: var(--ink); min-height: 38px; padding: 0 12px; border-radius: 6px; cursor: pointer; }
|
||||
button.primary { background: var(--accent); color: white; border-color: var(--accent); }
|
||||
button.icon { min-width: 38px; padding: 0 10px; font-weight: 700; }
|
||||
.app { min-height: 100vh; display: grid; grid-template-columns: minmax(260px, 360px) 1fr; }
|
||||
aside { border-right: 1px solid var(--line); background: #eee8dc; padding: 18px; overflow: auto; max-height: 100vh; }
|
||||
main { padding: 20px clamp(18px, 3vw, 42px); overflow: auto; max-height: 100vh; }
|
||||
@@ -439,6 +549,8 @@ APP_HTML = r"""<!doctype html>
|
||||
.page-item span { display: block; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
||||
form { display: grid; gap: 14px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.toolbar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; border: 1px solid var(--line); background: var(--panel); border-radius: 6px; padding: 7px; }
|
||||
.toolbar input[type="file"] { display: none; }
|
||||
label { display: grid; gap: 5px; font-size: 13px; font-weight: 700; color: var(--muted); }
|
||||
input, textarea, select { width: 100%; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--ink); padding: 9px 10px; }
|
||||
textarea { min-height: 48vh; resize: vertical; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; line-height: 1.45; font-size: 14px; }
|
||||
@@ -470,6 +582,7 @@ APP_HTML = r"""<!doctype html>
|
||||
<option value="">Blogs and posts</option>
|
||||
<option value="blog">Blogs</option>
|
||||
<option value="post">Posts</option>
|
||||
<option value="lima">Lima</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="pages" class="page-list"></div>
|
||||
@@ -485,6 +598,7 @@ APP_HTML = r"""<!doctype html>
|
||||
<select name="pageType">
|
||||
<option value="blog">Blog</option>
|
||||
<option value="post">Post</option>
|
||||
<option value="lima">Lima</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Post section
|
||||
@@ -506,6 +620,20 @@ APP_HTML = r"""<!doctype html>
|
||||
<input name="targetPath" placeholder="blogs/2026/05-may/my-page.org" />
|
||||
</label>
|
||||
</div>
|
||||
<div id="mdToolbar" class="toolbar" hidden>
|
||||
<button class="icon" type="button" data-md="bold" title="Bold">B</button>
|
||||
<button class="icon" type="button" data-md="italic" title="Italic"><i>I</i></button>
|
||||
<button class="icon" type="button" data-md="underline" title="Underline"><u>U</u></button>
|
||||
<button type="button" data-md="h1">H1</button>
|
||||
<button type="button" data-md="h2">H2</button>
|
||||
<button type="button" data-md="h3">H3</button>
|
||||
<button type="button" data-md="bullet">List</button>
|
||||
<button type="button" data-md="numbered">1. List</button>
|
||||
<button type="button" data-md="quote">Quote</button>
|
||||
<button type="button" data-md="link">Link</button>
|
||||
<button type="button" id="attachBtn">Insert attachment</button>
|
||||
<input id="attachInput" type="file" accept="image/*,video/*" />
|
||||
</div>
|
||||
<label>Content
|
||||
<textarea name="content" spellcheck="true"></textarea>
|
||||
</label>
|
||||
@@ -526,6 +654,8 @@ APP_HTML = r"""<!doctype html>
|
||||
const state = { pages: [], currentPath: "", build: null, pathManual: false };
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const editor = $("#editor");
|
||||
const mdToolbar = $("#mdToolbar");
|
||||
const attachInput = $("#attachInput");
|
||||
const statusBox = $("#status");
|
||||
const pagesBox = $("#pages");
|
||||
const buildLog = $("#buildLog");
|
||||
@@ -616,6 +746,7 @@ APP_HTML = r"""<!doctype html>
|
||||
$("#formTitle").textContent = state.currentPath ? "Edit page" : "New page";
|
||||
$("#pathLabel").textContent = state.currentPath;
|
||||
editor.targetPath.disabled = Boolean(state.currentPath);
|
||||
updateEditorMode();
|
||||
updateSuggestedPath();
|
||||
saveMessage.textContent = "";
|
||||
}
|
||||
@@ -643,10 +774,22 @@ APP_HTML = r"""<!doctype html>
|
||||
}
|
||||
});
|
||||
editor.slug.addEventListener("input", updateSuggestedPath);
|
||||
editor.pageType.addEventListener("change", updateSuggestedPath);
|
||||
editor.pageType.addEventListener("change", () => {
|
||||
if (!state.currentPath) {
|
||||
state.pathManual = false;
|
||||
editor.targetPath.value = "";
|
||||
}
|
||||
updateEditorMode();
|
||||
updateSuggestedPath();
|
||||
});
|
||||
editor.section.addEventListener("input", updateSuggestedPath);
|
||||
editor.date.addEventListener("input", updateSuggestedPath);
|
||||
editor.targetPath.addEventListener("input", () => { state.pathManual = true; });
|
||||
mdToolbar.querySelectorAll("[data-md]").forEach((button) => {
|
||||
button.addEventListener("click", () => applyMarkdown(button.dataset.md));
|
||||
});
|
||||
$("#attachBtn").addEventListener("click", () => attachInput.click());
|
||||
attachInput.addEventListener("change", uploadAttachment);
|
||||
$("#search").addEventListener("input", renderPages);
|
||||
$("#typeFilter").addEventListener("change", renderPages);
|
||||
$("#newBtn").addEventListener("click", () => setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
|
||||
@@ -670,6 +813,10 @@ APP_HTML = r"""<!doctype html>
|
||||
function updateSuggestedPath() {
|
||||
if (state.currentPath || state.pathManual) return;
|
||||
const slug = slugify(editor.slug.value || editor.title.value);
|
||||
if (editor.pageType.value === "lima") {
|
||||
editor.targetPath.value = `lima/${slug}.md`;
|
||||
return;
|
||||
}
|
||||
if (editor.pageType.value === "post") {
|
||||
const section = slugify(editor.section.value || "");
|
||||
editor.targetPath.value = section === "untitled" ? `posts/${slug}.org` : `posts/${section}/${slug}.org`;
|
||||
@@ -682,6 +829,83 @@ APP_HTML = r"""<!doctype html>
|
||||
editor.targetPath.value = `blogs/${year}/${String(month).padStart(2, "0")}-${months[month - 1]}/${slug}.org`;
|
||||
}
|
||||
|
||||
function updateEditorMode() {
|
||||
const isLima = editor.pageType.value === "lima";
|
||||
mdToolbar.hidden = !isLima;
|
||||
editor.tags.closest("label").hidden = isLima;
|
||||
editor.date.closest("label").hidden = isLima;
|
||||
editor.comments.closest("label").hidden = isLima;
|
||||
editor.section.closest("label").hidden = editor.pageType.value !== "post";
|
||||
const pathLabel = editor.targetPath.closest("label").firstChild;
|
||||
if (pathLabel) pathLabel.textContent = isLima ? "Markdown file path" : "Org file path";
|
||||
editor.targetPath.placeholder = isLima ? "lima/family-update.md" : "blogs/2026/05-may/my-page.org";
|
||||
}
|
||||
|
||||
function selectedText() {
|
||||
const area = editor.content;
|
||||
return {
|
||||
start: area.selectionStart,
|
||||
end: area.selectionEnd,
|
||||
text: area.value.slice(area.selectionStart, area.selectionEnd),
|
||||
};
|
||||
}
|
||||
|
||||
function replaceSelection(value, selectStart = null, selectEnd = null) {
|
||||
const area = editor.content;
|
||||
const { start, end } = selectedText();
|
||||
area.setRangeText(value, start, end, "end");
|
||||
area.focus();
|
||||
if (selectStart !== null && selectEnd !== null) {
|
||||
area.setSelectionRange(start + selectStart, start + selectEnd);
|
||||
}
|
||||
}
|
||||
|
||||
function linePrefix(prefix, fallback) {
|
||||
const area = editor.content;
|
||||
const { start, end, text } = selectedText();
|
||||
const value = text || fallback;
|
||||
const replacement = value.split("\n").map((line) => `${prefix}${line || fallback}`).join("\n");
|
||||
area.setRangeText(replacement, start, end, "end");
|
||||
area.focus();
|
||||
}
|
||||
|
||||
function applyMarkdown(action) {
|
||||
const { text } = selectedText();
|
||||
const sample = text || "text";
|
||||
if (action === "bold") replaceSelection(`**${sample}**`, 2, 2 + sample.length);
|
||||
if (action === "italic") replaceSelection(`*${sample}*`, 1, 1 + sample.length);
|
||||
if (action === "underline") replaceSelection(`<u>${sample}</u>`, 3, 3 + sample.length);
|
||||
if (action === "h1") linePrefix("# ", "Heading");
|
||||
if (action === "h2") linePrefix("## ", "Heading");
|
||||
if (action === "h3") linePrefix("### ", "Heading");
|
||||
if (action === "bullet") linePrefix("- ", "List item");
|
||||
if (action === "numbered") linePrefix("1. ", "List item");
|
||||
if (action === "quote") linePrefix("> ", "Quote");
|
||||
if (action === "link") {
|
||||
const label = sample === "text" ? "link text" : sample;
|
||||
replaceSelection(`[${label}](https://)`, 1, 1 + label.length);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAttachment() {
|
||||
const file = attachInput.files[0];
|
||||
if (!file) return;
|
||||
saveMessage.textContent = "Uploading attachment.";
|
||||
const body = new FormData();
|
||||
body.append("attachment", file);
|
||||
try {
|
||||
const res = await fetch("/api/upload", { method: "POST", body });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Upload failed");
|
||||
replaceSelection(`\n${data.markdown}\n`);
|
||||
saveMessage.textContent = "Attachment inserted.";
|
||||
} catch (err) {
|
||||
saveMessage.textContent = err.message;
|
||||
} finally {
|
||||
attachInput.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
setForm({ pageType: "blog", date: currentOrgDate(), comments: true });
|
||||
refreshPages();
|
||||
refreshBuild();
|
||||
@@ -734,6 +958,26 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if self.path == "/api/upload":
|
||||
try:
|
||||
form = cgi.FieldStorage(
|
||||
fp=self.rfile,
|
||||
headers=self.headers,
|
||||
environ={
|
||||
"REQUEST_METHOD": "POST",
|
||||
"CONTENT_TYPE": self.headers.get("Content-Type", ""),
|
||||
},
|
||||
)
|
||||
field = form["attachment"] if "attachment" in form else None
|
||||
if field is None or not getattr(field, "filename", ""):
|
||||
raise ValueError("No attachment was uploaded.")
|
||||
payload = field.file.read()
|
||||
if not payload:
|
||||
raise ValueError("Attachment is empty.")
|
||||
self.send_json(save_upload(field.filename, payload))
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
if self.path != "/api/page":
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user