feat: improve magazine review navigation
Some checks are pending
Test and SonarQube / quality (push) Waiting to run
Some checks are pending
Test and SonarQube / quality (push) Waiting to run
This commit is contained in:
parent
c1d86ec13d
commit
31b13a4e16
18
CHANGELOG.md
18
CHANGELOG.md
@ -9,6 +9,24 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
|
||||
---
|
||||
|
||||
## [1.26.6] — 2026-09-09
|
||||
|
||||
### Added
|
||||
- **Safer Magazine Deletion.** Each magazine card now offers a confirmed delete action that removes its source PDF,
|
||||
OCR/layout data, articles and generated assets together.
|
||||
- **Rectangle-to-Metadata Extraction.** In article review, **Pick from page** beside Title and Author OCRs a drawn
|
||||
rectangle directly into that metadata field without adding it to narratable article content.
|
||||
- **Whole-Issue Page Navigation.** Review now has a separate **Magazine p. N / total** navigator, allowing every
|
||||
PDF page to be inspected across article boundaries while keeping the article-only navigator available.
|
||||
|
||||
### Changed
|
||||
- **Review Selection Controls Are Clearer.** **Draw image** is now **Draw graphic**, and **Clear all fields** resets
|
||||
all selected text and graphics on the current page for clean manual article-boundary review.
|
||||
- **Headline Detection Is Not Pretended to Be a Percentage.** The fixed 0.55 fallback now displays as
|
||||
**headline detected** rather than a misleading repeated `55%` confidence badge.
|
||||
|
||||
---
|
||||
|
||||
## [1.26.5] — 2026-09-09
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -32,6 +32,7 @@ import math
|
||||
import re
|
||||
import statistics
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unicodedata
|
||||
|
||||
import requests
|
||||
@ -1860,6 +1861,39 @@ def add_manual_region(doc_dir: Path, layout: dict[str, Any], page_no: int,
|
||||
return layout
|
||||
|
||||
|
||||
def extract_region_text(doc_dir: Path, layout: dict[str, Any], page_no: int,
|
||||
bbox: Sequence[float], vision_ocr_url: str | None = None,
|
||||
vision_ocr_model: str | None = None,
|
||||
vision_ocr_fallback_url: str | None = None,
|
||||
vision_ocr_fallback_model: str | None = None,
|
||||
timeout: float = 150.0) -> str:
|
||||
"""OCR a user-selected rectangle without changing the saved layout.
|
||||
|
||||
Used for metadata fields such as article title/author: unlike a manual
|
||||
region, the selection is a one-off extraction and must not become prose
|
||||
that is later narrated.
|
||||
"""
|
||||
page_meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None)
|
||||
if not page_meta or len(bbox) != 4 or bbox[2] <= 0 or bbox[3] <= 0:
|
||||
raise ValueError("Invalid page selection")
|
||||
with tempfile.NamedTemporaryFile(prefix=f".extract-{page_no}-", suffix=".png",
|
||||
dir=doc_dir / "pages", delete=False) as tmp:
|
||||
crop_path = Path(tmp.name)
|
||||
try:
|
||||
_crop_page_png(doc_dir, page_no, page_meta, bbox, crop_path)
|
||||
regions = None
|
||||
if vision_ocr_url and vision_ocr_model:
|
||||
regions = vision_ocr_page_chain(crop_path, [
|
||||
(vision_ocr_url, vision_ocr_model),
|
||||
(vision_ocr_fallback_url, vision_ocr_fallback_model),
|
||||
], timeout=timeout)
|
||||
if regions:
|
||||
return " ".join(r.get("text", "") for r in regions if r.get("text")).strip()
|
||||
return " ".join(p.get("text", "") for p in ocr_page(crop_path) if p.get("text")).strip()
|
||||
finally:
|
||||
crop_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def save_layout(doc_dir: Path, layout: dict[str, Any]) -> None:
|
||||
(doc_dir / "layout.json").write_text(
|
||||
json.dumps(layout, ensure_ascii=False), "utf-8")
|
||||
|
||||
@ -686,6 +686,27 @@ async def magazine_add_region(doc_id: str, page: int, request: Request):
|
||||
return {"job_id": job_id}
|
||||
|
||||
|
||||
@router.post("/api/magazine/{doc_id}/pages/{page}/extract")
|
||||
async def magazine_extract_region(doc_id: str, page: int, request: Request):
|
||||
"""OCR a selected page rectangle for a metadata field without saving it."""
|
||||
d, _ = _require_magazine(doc_id)
|
||||
body = await request.json()
|
||||
bbox = body.get("bbox")
|
||||
if page < 1 or not (isinstance(bbox, list) and len(bbox) == 4
|
||||
and all(isinstance(v, (int, float)) for v in bbox)):
|
||||
raise HTTPException(400, "bbox must be [x, y, width, height]")
|
||||
layout = mag.load_layout(d)
|
||||
settings = _load_settings()
|
||||
text = await asyncio.to_thread(
|
||||
mag.extract_region_text, d, layout, page, [float(v) for v in bbox],
|
||||
str(settings.get("vision_ocr_url") or "").strip() or None,
|
||||
str(settings.get("vision_ocr_model") or "").strip() or None,
|
||||
str(settings.get("vision_ocr_fallback_url") or "").strip() or None,
|
||||
str(settings.get("vision_ocr_fallback_model") or "").strip() or None,
|
||||
)
|
||||
return {"text": text}
|
||||
|
||||
|
||||
@router.get("/api/magazine/{doc_id}/images/{name}")
|
||||
async def magazine_image(doc_id: str, name: str):
|
||||
d, _ = _require_magazine(doc_id)
|
||||
|
||||
@ -145,12 +145,33 @@ function renderLibrary() {
|
||||
const thumbnail = '<img class="mag-card-thumb" src="/api/magazine/' + encodeURIComponent(d.id) +
|
||||
'/pages/1" alt="First page of ' + esc(d.title || 'magazine') +
|
||||
'" loading="lazy" onerror="this.hidden=true">';
|
||||
return '<button type="button" class="mag-card" onclick="magOpen(\'' + d.id + '\')">' +
|
||||
return '<div class="mag-card-wrap"><button type="button" class="mag-card" onclick="magOpen(\'' + d.id + '\')">' +
|
||||
thumbnail + '<span class="mag-card-title">' + esc(d.title || 'Untitled') + '</span>' +
|
||||
'<span class="mag-card-sub">' + esc(d.issue || '') + '</span>' + badge + '</button>';
|
||||
'<span class="mag-card-sub">' + esc(d.issue || '') + '</span>' + badge + '</button>' +
|
||||
'<button type="button" class="mag-card-delete" onclick="magDelete(\'' + d.id + '\')" ' +
|
||||
'title="Delete this magazine" aria-label="Delete ' + esc(d.title || 'magazine') + '"><span class="mdi mdi-delete-outline"></span></button></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function magDelete(docId) {
|
||||
const doc = state.docs.find((item) => item.id === docId);
|
||||
const title = doc?.title || 'this magazine';
|
||||
if (!window.confirm(`Delete “${title}”? This permanently removes its PDF, OCR, articles, and generated files.`)) return;
|
||||
try {
|
||||
await api('/api/reader/docs/' + encodeURIComponent(docId), { method: 'DELETE' });
|
||||
if (state.doc?.id === docId) {
|
||||
magForgetLastDoc();
|
||||
state.doc = null;
|
||||
state.article = null;
|
||||
}
|
||||
state.docs = state.docs.filter((item) => item.id !== docId);
|
||||
renderLibrary();
|
||||
say(`Deleted “${title}”`, 'ok');
|
||||
} catch (e) {
|
||||
say('Could not delete magazine: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add-magazine dropzone (mirrors reader.js's setupReaderDropzone) ─────────
|
||||
const MAG_DROPZONE_DEFAULT_NOTE = 'Scanned issues work too — pages without selectable text are read with OCR.';
|
||||
|
||||
@ -388,9 +409,16 @@ async function magRefreshArticles() {
|
||||
// ── Contents tree ────────────────────────────────────────────────────────────
|
||||
|
||||
function confidenceLabel(c) {
|
||||
if (c >= 0.85) return { cls: 'ok', text: Math.round(c * 100) + '% · listed in contents' };
|
||||
if (c >= 0.5) return { cls: 'warn', text: Math.round(c * 100) + '% · from headline — check boundaries' };
|
||||
return { cls: 'bad', text: Math.round(c * 100) + '% · little body copy — likely not an article' };
|
||||
// 0.55 is the backend's explicit heuristic marker: no reliable contents
|
||||
// entry was available, so an article was started from a page headline. It
|
||||
// is not a calibrated probability and a column of identical “55%” badges
|
||||
// falsely suggests a measured OCR score.
|
||||
if (Math.abs(Number(c) - 0.55) < 0.001) {
|
||||
return { cls: 'warn', text: 'Headline detected — check boundaries', badge: 'headline detected' };
|
||||
}
|
||||
if (c >= 0.85) return { cls: 'ok', text: Math.round(c * 100) + '% · listed in contents', badge: Math.round(c * 100) + '%' };
|
||||
if (c >= 0.5) return { cls: 'warn', text: Math.round(c * 100) + '% · from headline — check boundaries', badge: Math.round(c * 100) + '%' };
|
||||
return { cls: 'bad', text: Math.round(c * 100) + '% · little body copy — likely not an article', badge: Math.round(c * 100) + '%' };
|
||||
}
|
||||
|
||||
function renderContents() {
|
||||
@ -428,7 +456,7 @@ function renderContents() {
|
||||
'<span>' + fmtPages(a.pages) + '</span>' +
|
||||
'<span>' + fmtDuration(a.estimated_seconds) + '</span>' +
|
||||
'<span class="mag-pill ' + conf.cls + '" title="' + esc(conf.text) + '">' +
|
||||
Math.round(a.confidence * 100) + '%</span>' +
|
||||
esc(conf.badge) + '</span>' +
|
||||
'<span class="mag-pill ' + (done ? 'ok' : '') + '">' +
|
||||
(done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + '</span>' +
|
||||
'</div>' +
|
||||
@ -494,6 +522,13 @@ function magReviewGoPage(page) {
|
||||
renderReview();
|
||||
}
|
||||
|
||||
function magReviewGoMagazinePage(page) {
|
||||
const lastPage = Number(state.layout?.page_count || 0);
|
||||
if (!Number.isInteger(page) || page < 1 || page > lastPage) return;
|
||||
state.reviewPage = page;
|
||||
renderReview();
|
||||
}
|
||||
|
||||
// One glyph per region kind, shown centred in its box -- the fastest way to
|
||||
// tell "a paragraph of text" from "a photo" from "furniture that will not be
|
||||
// read" without reading the label. No table detection exists yet (P2), so a
|
||||
@ -550,11 +585,14 @@ function renderReview() {
|
||||
const spanIdx = state.reviewSpan.indexOf(page);
|
||||
const prevPage = spanIdx > 0 ? state.reviewSpan[spanIdx - 1] : null;
|
||||
const nextPage = spanIdx >= 0 && spanIdx < state.reviewSpan.length - 1 ? state.reviewSpan[spanIdx + 1] : null;
|
||||
const issuePages = Number(state.layout?.page_count || 0);
|
||||
const previousIssuePage = page > 1 ? page - 1 : null;
|
||||
const nextIssuePage = page < issuePages ? page + 1 : null;
|
||||
|
||||
let html =
|
||||
'<div class="mag-review-head">' +
|
||||
'<label>Title<input id="mag-rev-title" type="text" value="' + esc(a.title) + '"></label>' +
|
||||
'<label>Author<input id="mag-rev-author" type="text" value="' + esc(a.author) + '"></label>' +
|
||||
'<label>Title<span class="mag-review-field-control"><input id="mag-rev-title" type="text" value="' + esc(a.title) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'title\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
|
||||
'<label>Author<span class="mag-review-field-control"><input id="mag-rev-author" type="text" value="' + esc(a.author) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'author\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
|
||||
'<div class="mag-pill ' + conf.cls + '">' + esc(conf.text) + '</div>' +
|
||||
'</div>' +
|
||||
'<p class="muted">Untick anything that is not part of this article — a sidebar, an advert, ' +
|
||||
@ -566,14 +604,21 @@ function renderReview() {
|
||||
'<div class="mag-review-page-controls" role="group" aria-label="Review pages">' +
|
||||
'<button type="button" class="btn-sm" aria-label="Previous page" ' + (prevPage ? '' : 'disabled') +
|
||||
' onclick="magReviewGoPage(' + prevPage + ')"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' +
|
||||
'<strong class="mag-review-page-number">Page ' + (page ?? '—') + '</strong>' +
|
||||
'<strong class="mag-review-page-number">Article p. ' + (page ?? '—') + '</strong>' +
|
||||
'<button type="button" class="btn-sm" aria-label="Next page" ' + (nextPage ? '' : 'disabled') +
|
||||
' onclick="magReviewGoPage(' + nextPage + ')"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
|
||||
'<div class="mag-review-page-controls mag-review-issue-controls" role="group" aria-label="Whole magazine pages">' +
|
||||
'<button type="button" class="btn-sm" aria-label="Previous magazine page" ' + (previousIssuePage ? '' : 'disabled') +
|
||||
' onclick="magReviewGoMagazinePage(' + previousIssuePage + ')"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' +
|
||||
'<strong class="mag-review-page-number">Magazine ' + (page ?? '—') + ' / ' + issuePages + '</strong>' +
|
||||
'<button type="button" class="btn-sm" aria-label="Next magazine page" ' + (nextIssuePage ? '' : 'disabled') +
|
||||
' onclick="magReviewGoMagazinePage(' + nextIssuePage + ')"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
|
||||
'<div class="mag-review-draw-controls" role="group" aria-label="Draw regions">' +
|
||||
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw()" id="mag-draw-toggle">' +
|
||||
'<span class="mdi mdi-vector-square-edit" aria-hidden="true"></span> Draw text</button>' +
|
||||
'<button type="button" class="btn-sm" id="mag-draw-image-toggle" onclick="magReviewToggleDraw(\'image\')">' +
|
||||
'<span class="mdi mdi-image-outline" aria-hidden="true"></span> Draw image</button></div>' +
|
||||
'<span class="mdi mdi-image-outline" aria-hidden="true"></span> Draw graphic</button>' +
|
||||
'<button type="button" class="btn-sm" onclick="magReviewClearCurrentPage()"><span class="mdi mdi-checkbox-multiple-blank-outline" aria-hidden="true"></span> Clear all fields</button></div>' +
|
||||
'<span class="mag-review-pagenav-spacer"></span>' +
|
||||
'<button type="button" class="btn-sm" id="mag-reocr-btn" onclick="magReocrPage()">' +
|
||||
'<span class="mdi mdi-text-recognition"></span> OCR again (this page)</button>' +
|
||||
@ -693,6 +738,18 @@ function magReviewToggleImage(id, checked) {
|
||||
}
|
||||
}
|
||||
|
||||
function magReviewClearCurrentPage() {
|
||||
if (state.reviewPage == null) return;
|
||||
if (!_magReviewChecked) _magReviewChecked = new Set(state.article.blocks || []);
|
||||
const page = state.reviewPage;
|
||||
(state.layout.blocks || []).filter((block) => block.page === page)
|
||||
.forEach((block) => _magReviewChecked.delete(block.id));
|
||||
const imageIds = new Set((state.layout.images || []).filter((image) => image.page === page).map((image) => image.id));
|
||||
state.article.images = (state.article.images || []).filter((id) => !imageIds.has(id));
|
||||
renderReview();
|
||||
say('All fields on this page cleared — select only the article content to keep.', 'ok');
|
||||
}
|
||||
|
||||
function magReviewFocusBlock(blockId) {
|
||||
const cb = document.querySelector('#mag-review input[data-block="' + CSS.escape(blockId) + '"]');
|
||||
if (state.drawMode) return;
|
||||
@ -718,7 +775,10 @@ function magReviewToggleDraw(kind = 'text') {
|
||||
state.drawMode = state.drawMode && state.drawKind === kind ? false : true;
|
||||
state.drawKind = kind;
|
||||
const hint = $('mag-draw-hint');
|
||||
if (hint) hint.textContent = state.drawMode ? ' Drag around the whole ' + (kind === 'image' ? 'image or chart' : 'text region') + '. Press Escape to cancel.' : '';
|
||||
const field = kind === 'title' ? 'title' : kind === 'author' ? 'author' : '';
|
||||
if (hint) hint.textContent = state.drawMode
|
||||
? (field ? ` Drag around the ${field} text to OCR it into this field. Press Escape to cancel.`
|
||||
: ' Drag around the whole ' + (kind === 'image' ? 'image or chart' : 'text region') + '. Press Escape to cancel.') : '';
|
||||
const wrap = $('mag-review-page-wrap');
|
||||
if (wrap) wrap.classList.toggle('is-drawing', state.drawMode);
|
||||
const btn = $('mag-draw-toggle');
|
||||
@ -801,7 +861,13 @@ function magReviewOpenDrawPanel(box) {
|
||||
box.hidden = false;
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'mag-draw-panel';
|
||||
panel.innerHTML =
|
||||
const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : '';
|
||||
panel.innerHTML = field
|
||||
? '<div class="mag-draw-panel-row"><strong>Extract ' + field + '</strong></div>' +
|
||||
'<p class="muted">OCR the selected rectangle and use its recognised text for the article ' + field + '.</p>' +
|
||||
'<div class="mag-draw-panel-row"><button type="button" class="btn-sm" onclick="magReviewCancelDrawPanel()">Cancel</button>' +
|
||||
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()"><span class="mdi mdi-text-recognition"></span> Use recognised text</button></div>'
|
||||
:
|
||||
'<div class="mag-draw-panel-row">' +
|
||||
'<label><input type="radio" name="mag-draw-kind" value="text" ' + (state.drawKind !== 'image' ? 'checked' : '') + '> Text</label>' +
|
||||
'<label><input type="radio" name="mag-draw-kind" value="image" ' + (state.drawKind === 'image' ? 'checked' : '') + '> Image</label>' +
|
||||
@ -830,7 +896,8 @@ async function magReviewSubmitDraw() {
|
||||
const box = $('mag-draw-rect');
|
||||
const pageMeta = (state.layout.pages || []).find((p) => p.page === state.reviewPage);
|
||||
if (!box || !pageMeta) return;
|
||||
const kind = document.querySelector('input[name="mag-draw-kind"]:checked')?.value || 'text';
|
||||
const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : '';
|
||||
const kind = field ? 'text' : (document.querySelector('input[name="mag-draw-kind"]:checked')?.value || 'text');
|
||||
const text = ($('mag-draw-text')?.value || '').trim();
|
||||
const bbox = [
|
||||
parseFloat(box.style.left) / 100 * pageMeta.width,
|
||||
@ -847,6 +914,22 @@ async function magReviewSubmitDraw() {
|
||||
if (!_magReviewChecked) _magReviewChecked = new Set(article.blocks || []);
|
||||
magReviewCancelDrawPanel();
|
||||
state.drawMode = false;
|
||||
if (field) {
|
||||
magReviewCancelDrawPanel();
|
||||
say(`Reading selected ${field}…`, '');
|
||||
try {
|
||||
const result = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/extract', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bbox }),
|
||||
});
|
||||
const value = String(result.text || '').replace(/\s+/g, ' ').trim();
|
||||
if (!value) return say('No text found in that selection', 'err');
|
||||
article[field] = value;
|
||||
const input = $(field === 'title' ? 'mag-rev-title' : 'mag-rev-author');
|
||||
if (input) input.value = value;
|
||||
say(`${field === 'title' ? 'Title' : 'Author'} filled from selected text`, 'ok');
|
||||
} catch (e) { say(`Could not read selected ${field}: ` + e.message, 'err'); }
|
||||
return;
|
||||
}
|
||||
say('Adding region…', '');
|
||||
let jobId;
|
||||
try {
|
||||
@ -1547,10 +1630,12 @@ async function magInit() {
|
||||
}
|
||||
|
||||
root.magOpen = magOpen;
|
||||
root.magDelete = magDelete;
|
||||
root.magCreate = magCreate;
|
||||
root.magIngest = magIngest;
|
||||
root.magReview = magReview;
|
||||
root.magReviewGoPage = magReviewGoPage;
|
||||
root.magReviewGoMagazinePage = magReviewGoMagazinePage;
|
||||
root.magReviewFocusBlock = magReviewFocusBlock;
|
||||
root.magReviewHighlightBox = magReviewHighlightBox;
|
||||
root.magReviewToggleBox = magReviewToggleBox;
|
||||
|
||||
@ -4777,11 +4777,16 @@ input[list="lang-codes"] {
|
||||
.mag-new-actions { display: flex; justify-content: flex-end; margin-top: 14px; }
|
||||
|
||||
.mag-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
||||
.mag-card { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; text-align: left;
|
||||
.mag-card-wrap { position: relative; min-width: 0; }
|
||||
.mag-card { display: flex; width: 100%; min-height: 100%; flex-direction: column; gap: 6px; align-items: flex-start; text-align: left;
|
||||
padding: 14px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--surface); color: var(--text); cursor: pointer; }
|
||||
.mag-card:hover { border-color: var(--accent); }
|
||||
.mag-card:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.18); }
|
||||
.mag-card-delete { position: absolute; top: 7px; right: 7px; width: 32px; height: 32px; display: grid; place-items: center;
|
||||
border: 1px solid var(--border); border-radius: 7px; background: color-mix(in srgb, var(--surface) 88%, transparent);
|
||||
color: var(--subtext); cursor: pointer; opacity: .82; }
|
||||
.mag-card-delete:hover, .mag-card-delete:focus-visible { color: var(--error); border-color: var(--error); background: var(--surface); opacity: 1; }
|
||||
.mag-card-thumb { display: block; width: 100%; height: 190px; object-fit: contain; object-position: center;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: calc(var(--radius) - 2px); }
|
||||
.mag-card-title { font-weight: 600; }
|
||||
@ -4856,6 +4861,9 @@ input[list="lang-codes"] {
|
||||
|
||||
.mag-review-head { display: grid; grid-template-columns: 2fr 1fr auto; gap: 12px; align-items: end; margin-bottom: 10px; }
|
||||
.mag-review-head label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--subtext); }
|
||||
.mag-review-field-control { display: flex; gap: 6px; align-items: center; }
|
||||
.mag-review-field-control input { flex: 1 1 auto; min-width: 0; }
|
||||
.mag-review-field-control .btn-sm { white-space: nowrap; }
|
||||
.mag-review-head input { padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--surface); color: var(--text); }
|
||||
.mag-page-head { margin: 16px 0 6px; font-size: 13px; color: var(--subtext); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user