Add sync coverage button, preflight-disable buttons, fix output wrapping (I2, I3, I6)
- I2: .wd-row now wraps and .wd-output takes the full row width, so streamed output no longer gets crushed beside the button on the filename's line. - I3: split .webinar-dash into token scope + a new .wd-bar toolbar above a .wd-grid two-pane layout. Extracted renderAll (called from onload) which renders a global "Sync coverage" button, generalised runIngest's subprocess handling into a shared runClaude keyed by run id, and added spawnGate to preflight-disable both the sync and per-file ingest buttons when claude can't be spawned (no desktop Obsidian / no child_process). A successful ingest or sync now re-renders the dashboard instead of requiring reopen. - I6: the right pane now shows the tracked script path and last-synced date from the coverage file's metadata, and flags when cfg.script disagrees with it. - Minors: documented the unescaped-pipe split regex, added role="img" to the coverage meter alongside its aria-label.
This commit is contained in:
201
.obsidian/plugins/webinar-dash/main.js
vendored
201
.obsidian/plugins/webinar-dash/main.js
vendored
@@ -94,6 +94,9 @@ function parseCoverageTable(markdown) {
|
|||||||
if (!inTable) return;
|
if (!inTable) return;
|
||||||
|
|
||||||
const body = t.endsWith("|") ? t.slice(1, -1) : t.slice(1);
|
const body = t.endsWith("|") ? t.slice(1, -1) : t.slice(1);
|
||||||
|
// Split on unescaped pipes only. Obsidian escapes the pipe of a piped
|
||||||
|
// wikilink inside a table cell as `\|`, and a plain split("|") tears
|
||||||
|
// `[[harness\|alias]]` into two cells, shifting every later column left.
|
||||||
const cells = body.split(/(?<!\\)\|/).map((c) => c.trim());
|
const cells = body.split(/(?<!\\)\|/).map((c) => c.trim());
|
||||||
|
|
||||||
if (cells.length < 3) {
|
if (cells.length < 3) {
|
||||||
@@ -220,7 +223,7 @@ function formatBytes(n) {
|
|||||||
return `${n.toLocaleString("en-US")} B`;
|
return `${n.toLocaleString("en-US")} B`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLeftPane(container, pipeline, onIngest) {
|
function renderLeftPane(container, pipeline, onIngest, gate) {
|
||||||
const pane = container.createDiv({ cls: "wd-pane" });
|
const pane = container.createDiv({ cls: "wd-pane" });
|
||||||
|
|
||||||
const queue = pane.createDiv({ cls: "wd-block" });
|
const queue = pane.createDiv({ cls: "wd-block" });
|
||||||
@@ -239,7 +242,12 @@ function renderLeftPane(container, pipeline, onIngest) {
|
|||||||
const btn = row.createEl("button", { cls: "wd-btn" });
|
const btn = row.createEl("button", { cls: "wd-btn" });
|
||||||
addIcon(btn, "terminal");
|
addIcon(btn, "terminal");
|
||||||
btn.createSpan({ text: "Ingest" });
|
btn.createSpan({ text: "Ingest" });
|
||||||
btn.addEventListener("click", () => onIngest(file, row));
|
if (gate && !gate.ok) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.setAttr("title", gate.reason);
|
||||||
|
} else {
|
||||||
|
btn.addEventListener("click", () => onIngest(file, row));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const done = pane.createDiv({ cls: "wd-block" });
|
const done = pane.createDiv({ cls: "wd-block" });
|
||||||
@@ -270,16 +278,29 @@ function renderLeftPane(container, pipeline, onIngest) {
|
|||||||
|
|
||||||
const STATUS_ICON = { covered: "check", partial: "minus", absent: "x" };
|
const STATUS_ICON = { covered: "check", partial: "minus", absent: "x" };
|
||||||
|
|
||||||
function renderRightPane(container, parsed, reconciled) {
|
function renderRightPane(container, parsed, reconciled, cfg) {
|
||||||
const pane = container.createDiv({ cls: "wd-pane" });
|
const pane = container.createDiv({ cls: "wd-pane" });
|
||||||
pane.createDiv({ cls: "wd-eyebrow", text: "Coverage — by station" });
|
pane.createDiv({ cls: "wd-eyebrow", text: "Coverage — by station" });
|
||||||
|
|
||||||
|
const meta = parsed.meta || {};
|
||||||
|
pane.createDiv({
|
||||||
|
cls: "wd-mono",
|
||||||
|
text: `${meta.script || "no script recorded"} — last synced ${meta.lastSynced || "never"}`,
|
||||||
|
});
|
||||||
|
if (cfg && cfg.script && meta.script && meta.script !== cfg.script) {
|
||||||
|
pane.createDiv({
|
||||||
|
cls: "wd-error",
|
||||||
|
text: `This dashboard is configured for ${cfg.script}, but the coverage file tracks ${meta.script}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const counts = { covered: 0, partial: 0, absent: 0 };
|
const counts = { covered: 0, partial: 0, absent: 0 };
|
||||||
for (const row of parsed.rows) counts[row.status] += 1;
|
for (const row of parsed.rows) counts[row.status] += 1;
|
||||||
const total = parsed.rows.length;
|
const total = parsed.rows.length;
|
||||||
|
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
const meter = pane.createDiv({ cls: "wd-meter" });
|
const meter = pane.createDiv({ cls: "wd-meter" });
|
||||||
|
meter.setAttr("role", "img");
|
||||||
meter.setAttr(
|
meter.setAttr(
|
||||||
"aria-label",
|
"aria-label",
|
||||||
`Coverage: ${counts.covered} covered, ${counts.partial} partial, ${counts.absent} absent of ${total}`
|
`Coverage: ${counts.covered} covered, ${counts.partial} partial, ${counts.absent} absent of ${total}`
|
||||||
@@ -357,21 +378,45 @@ class WebinarDashPlugin extends PluginBase {
|
|||||||
this.registerMarkdownCodeBlockProcessor("webinar-dash", async (source, el, ctx) => {
|
this.registerMarkdownCodeBlockProcessor("webinar-dash", async (source, el, ctx) => {
|
||||||
const cfg = parseConfig(source);
|
const cfg = parseConfig(source);
|
||||||
const root = el.createDiv({ cls: "webinar-dash" });
|
const root = el.createDiv({ cls: "webinar-dash" });
|
||||||
try {
|
await this.renderAll(root, cfg);
|
||||||
const pipeline = await this.readPipeline(cfg);
|
|
||||||
renderLeftPane(root, pipeline, (file, rowEl) => this.runIngest(file, rowEl));
|
|
||||||
} catch (err) {
|
|
||||||
root.createDiv({ cls: "wd-error", text: `Pipeline failed: ${err.message}` });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const { parsed, reconciled } = await this.readCoverage(cfg);
|
|
||||||
renderRightPane(root, parsed, reconciled);
|
|
||||||
} catch (err) {
|
|
||||||
root.createDiv({ cls: "wd-error", text: `Coverage failed: ${err.message}` });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async renderAll(root, cfg) {
|
||||||
|
root.empty();
|
||||||
|
const gate = this.spawnGate();
|
||||||
|
const refresh = () => { this.renderAll(root, cfg); };
|
||||||
|
|
||||||
|
const bar = root.createDiv({ cls: "wd-bar" });
|
||||||
|
const sync = bar.createEl("button", { cls: "wd-btn wd-btn-ghost" });
|
||||||
|
addIcon(sync, "refresh");
|
||||||
|
sync.createSpan({ text: "Sync coverage" });
|
||||||
|
if (!gate.ok) {
|
||||||
|
sync.disabled = true;
|
||||||
|
sync.setAttr("title", gate.reason);
|
||||||
|
} else {
|
||||||
|
sync.addEventListener("click", () =>
|
||||||
|
this.runClaude("__sync__", "sync script coverage", bar, "sync coverage", refresh)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grid = root.createDiv({ cls: "wd-grid" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pipeline = await this.readPipeline(cfg);
|
||||||
|
renderLeftPane(grid, pipeline, (file, rowEl) => this.runIngest(file, rowEl, refresh), gate);
|
||||||
|
} catch (err) {
|
||||||
|
grid.createDiv({ cls: "wd-error", text: `Pipeline failed: ${err.message}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { parsed, reconciled } = await this.readCoverage(cfg);
|
||||||
|
renderRightPane(grid, parsed, reconciled, cfg);
|
||||||
|
} catch (err) {
|
||||||
|
grid.createDiv({ cls: "wd-error", text: `Coverage failed: ${err.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async readCoverage(cfg) {
|
async readCoverage(cfg) {
|
||||||
const file = this.app.vault.getAbstractFileByPath(cfg.coverage);
|
const file = this.app.vault.getAbstractFileByPath(cfg.coverage);
|
||||||
if (!file) throw new Error(`no coverage file at ${cfg.coverage}`);
|
if (!file) throw new Error(`no coverage file at ${cfg.coverage}`);
|
||||||
@@ -415,60 +460,55 @@ class WebinarDashPlugin extends PluginBase {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
runIngest(file, rowEl) {
|
spawnGate() {
|
||||||
|
if (!this.vaultPath()) {
|
||||||
|
return { ok: false, reason: "Needs desktop Obsidian." };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
require("child_process");
|
||||||
|
} catch (_) {
|
||||||
|
return { ok: false, reason: "child_process unavailable — needs desktop Obsidian." };
|
||||||
|
}
|
||||||
|
return { ok: true, reason: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// One subprocess runner for every button. `key` is what makes a run unique in
|
||||||
|
// `this.running` — a file path for ingest, a constant for sync — so the guard
|
||||||
|
// survives the row re-renders that a DOM-scoped guard could not.
|
||||||
|
runClaude(key, prompt, hostEl, label, onSuccess) {
|
||||||
const Notice = OB ? OB.Notice : null;
|
const Notice = OB ? OB.Notice : null;
|
||||||
const notify = (msg) => { if (Notice) new Notice(msg); };
|
const notify = (msg) => { if (Notice) new Notice(msg); };
|
||||||
|
|
||||||
// In-flight state is keyed on the file, not the row. Obsidian rebuilds the
|
if (this.running.has(key)) {
|
||||||
// row on every re-render, so a row-scoped guard would let a re-render hand
|
notify(`Already running: ${label}.`);
|
||||||
// out a fresh row whose guard is unset — and a second click would then run
|
|
||||||
// a second unsupervised agent against the same file, concurrently writing
|
|
||||||
// the same wiki pages as the first.
|
|
||||||
if (this.running.has(file.path)) {
|
|
||||||
notify(`Already ingesting ${file.name}.`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isSafeFilename(file.name)) {
|
const gate = this.spawnGate();
|
||||||
rowEl.createDiv({
|
if (!gate.ok) {
|
||||||
cls: "wd-output",
|
notify(gate.reason);
|
||||||
text: `Refused: "${file.name}" contains a character that is unsafe to pass to a shell.`,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { spawn } = require("child_process");
|
||||||
const base = this.vaultPath();
|
const base = this.vaultPath();
|
||||||
if (!base) {
|
|
||||||
notify("Ingest needs desktop Obsidian.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let spawn;
|
// A retry reuses the same host element. Clear the previous run's status and
|
||||||
try {
|
// output so they are replaced rather than stacked.
|
||||||
({ spawn } = require("child_process"));
|
hostEl.querySelectorAll(".wd-status, .wd-output").forEach((el) => el.remove());
|
||||||
} catch (_) {
|
|
||||||
notify("child_process unavailable — ingest needs desktop Obsidian.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A retry reuses the same row. Clear the previous run's status and output
|
const button = hostEl.querySelector("button");
|
||||||
// so they are replaced rather than stacked on top of each other.
|
|
||||||
rowEl.querySelectorAll(".wd-status, .wd-output").forEach((el) => el.remove());
|
|
||||||
|
|
||||||
const button = rowEl.querySelector("button");
|
|
||||||
if (button) button.disabled = true;
|
if (button) button.disabled = true;
|
||||||
this.running.add(file.path);
|
this.running.add(key);
|
||||||
|
|
||||||
const status = rowEl.createSpan({ cls: "wd-status wd-status-running", text: "running 0s" });
|
const status = hostEl.createSpan({ cls: "wd-status wd-status-running", text: "running 0s" });
|
||||||
const output = rowEl.createDiv({ cls: "wd-output", text: "" });
|
const output = hostEl.createDiv({ cls: "wd-output", text: "" });
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
status.setText(`running ${Math.round((Date.now() - started) / 1000)}s`);
|
status.setText(`running ${Math.round((Date.now() - started) / 1000)}s`);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
this.registerInterval(timer);
|
this.registerInterval(timer);
|
||||||
|
|
||||||
// Bounded as it accumulates, not only when displayed, so a long or noisy
|
// Bounded as it accumulates, not only when displayed.
|
||||||
// run cannot grow this string without limit.
|
|
||||||
let buffered = "";
|
let buffered = "";
|
||||||
const append = (text) => {
|
const append = (text) => {
|
||||||
buffered = (buffered + text).slice(-8000);
|
buffered = (buffered + text).slice(-8000);
|
||||||
@@ -476,37 +516,29 @@ class WebinarDashPlugin extends PluginBase {
|
|||||||
output.scrollTop = output.scrollHeight;
|
output.scrollTop = output.scrollHeight;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Single exit path: every way this run can end clears the timer, releases
|
// Node can emit both `error` and `close` for one failure. `settled` keeps the
|
||||||
// the file, and re-enables the button.
|
// first, more specific message instead of letting `exit null` overwrite it.
|
||||||
const finish = (cls, label) => {
|
let settled = false;
|
||||||
|
const finish = (cls, text) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
this.running.delete(file.path);
|
this.running.delete(key);
|
||||||
status.className = `wd-status ${cls}`;
|
status.className = `wd-status ${cls}`;
|
||||||
status.setText(label);
|
status.setText(text);
|
||||||
if (button) button.disabled = false;
|
if (button) button.disabled = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
let child;
|
let child;
|
||||||
try {
|
try {
|
||||||
// `claude` resolves to a real .exe here, so libuv finds it via PATH and
|
// See the C1 comment in runIngest: no shell, deliberately.
|
||||||
// PATHEXT with no shell involved. Do NOT add `shell: true`: with a shell,
|
child = spawn("claude", ["-p", prompt], { cwd: base });
|
||||||
// Node concatenates argv without quoting and cmd.exe re-tokenizes, so the
|
|
||||||
// prompt arrives as two arguments and the filename is silently discarded.
|
|
||||||
// Verified: shell:true yields ["-p","ingest","Webinar script.md"], where the
|
|
||||||
// program sees only "ingest". If a .cmd shim ever needs supporting, use the
|
|
||||||
// single-string form spawn(`claude -p "ingest ${name}"`, { shell: true }).
|
|
||||||
child = spawn("claude", ["-p", `ingest "${file.name}"`], { cwd: base });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// spawn() throws synchronously for some argument shapes. Without this the
|
|
||||||
// timer would run forever and the row would stay disabled until reload.
|
|
||||||
append(`\nCould not start claude: ${err.message}`);
|
append(`\nCould not start claude: ${err.message}`);
|
||||||
finish("wd-status-failed", "failed");
|
finish("wd-status-failed", "failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode as UTF-8 across chunk boundaries. Raw Buffer chunks split wherever
|
|
||||||
// the OS buffer ends, and this vault's output is full of Cyrillic and em
|
|
||||||
// dashes that would otherwise decode as replacement characters.
|
|
||||||
child.stdout.setEncoding("utf8");
|
child.stdout.setEncoding("utf8");
|
||||||
child.stderr.setEncoding("utf8");
|
child.stderr.setEncoding("utf8");
|
||||||
child.stdout.on("data", append);
|
child.stdout.on("data", append);
|
||||||
@@ -521,12 +553,43 @@ class WebinarDashPlugin extends PluginBase {
|
|||||||
const secs = Math.round((Date.now() - started) / 1000);
|
const secs = Math.round((Date.now() - started) / 1000);
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
finish("wd-status-done", `done in ${secs}s`);
|
finish("wd-status-done", `done in ${secs}s`);
|
||||||
notify(`Ingested ${file.name}. Reopen the dashboard to refresh.`);
|
if (onSuccess) onSuccess();
|
||||||
} else {
|
} else {
|
||||||
finish("wd-status-failed", `failed - exit ${code}`);
|
finish("wd-status-failed", `failed - exit ${code}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runIngest(file, rowEl, onSuccess) {
|
||||||
|
const Notice = OB ? OB.Notice : null;
|
||||||
|
|
||||||
|
if (!isSafeFilename(file.name)) {
|
||||||
|
rowEl.querySelectorAll(".wd-status, .wd-output").forEach((el) => el.remove());
|
||||||
|
rowEl.createDiv({
|
||||||
|
cls: "wd-output",
|
||||||
|
text: `Refused: "${file.name}" contains a character that is unsafe to pass to a shell.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `claude` resolves to a real .exe here, so libuv finds it via PATH and
|
||||||
|
// PATHEXT with no shell involved. Do NOT add `shell: true`: with a shell,
|
||||||
|
// Node concatenates argv without quoting and cmd.exe re-tokenizes, so the
|
||||||
|
// prompt arrives as two arguments and the filename is silently discarded.
|
||||||
|
// Verified: shell:true yields ["-p","ingest","Webinar script.md"], where the
|
||||||
|
// program sees only "ingest". If a .cmd shim ever needs supporting, use the
|
||||||
|
// single-string form spawn(`claude -p "ingest ${name}"`, { shell: true }).
|
||||||
|
this.runClaude(
|
||||||
|
file.path,
|
||||||
|
`ingest "${file.name}"`,
|
||||||
|
rowEl,
|
||||||
|
file.name,
|
||||||
|
() => {
|
||||||
|
if (Notice) new Notice(`Ingested ${file.name}.`);
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = WebinarDashPlugin;
|
module.exports = WebinarDashPlugin;
|
||||||
|
|||||||
42
.obsidian/plugins/webinar-dash/styles.css
vendored
42
.obsidian/plugins/webinar-dash/styles.css
vendored
@@ -25,9 +25,9 @@
|
|||||||
|
|
||||||
font-family: var(--wd-sans);
|
font-family: var(--wd-sans);
|
||||||
color: var(--wd-fg);
|
color: var(--wd-fg);
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: minmax(0, 38fr) minmax(0, 62fr);
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-dark .webinar-dash {
|
.theme-dark .webinar-dash {
|
||||||
@@ -55,11 +55,37 @@
|
|||||||
--wd-danger: #ff5c50;
|
--wd-danger: #ff5c50;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
.wd-grid {
|
||||||
.webinar-dash { grid-template-columns: minmax(0, 1fr); }
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 38fr) minmax(0, 62fr);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.wd-grid > * { min-width: 0; }
|
||||||
|
|
||||||
|
.wd-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border: 1px solid var(--wd-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--wd-bg-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.webinar-dash > * { min-width: 0; }
|
.wd-btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wd-fg);
|
||||||
|
border-color: var(--wd-border-strong);
|
||||||
|
}
|
||||||
|
.wd-btn-ghost:hover {
|
||||||
|
background: var(--wd-bg-muted, var(--wd-bg-subtle));
|
||||||
|
border-color: var(--wd-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.wd-grid { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
.wd-pane { display: flex; flex-direction: column; gap: 20px; }
|
.wd-pane { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
|
||||||
@@ -76,7 +102,7 @@
|
|||||||
.wd-block { display: flex; flex-direction: column; gap: 12px; }
|
.wd-block { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
.wd-row {
|
.wd-row {
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||||
border: 1px solid var(--wd-border); border-radius: 4px;
|
border: 1px solid var(--wd-border); border-radius: 4px;
|
||||||
padding: 12px 12px 12px 16px; background: var(--wd-bg);
|
padding: 12px 12px 12px 16px; background: var(--wd-bg);
|
||||||
}
|
}
|
||||||
@@ -166,6 +192,8 @@
|
|||||||
.wd-status-done { color: var(--wd-ok); }
|
.wd-status-done { color: var(--wd-ok); }
|
||||||
.wd-status-failed { color: var(--wd-danger); }
|
.wd-status-failed { color: var(--wd-danger); }
|
||||||
.wd-output {
|
.wd-output {
|
||||||
|
flex-basis: 100%;
|
||||||
|
width: 100%;
|
||||||
font-family: var(--wd-mono); font-size: 11px; white-space: pre-wrap;
|
font-family: var(--wd-mono); font-size: 11px; white-space: pre-wrap;
|
||||||
max-height: 220px; overflow: auto; margin-top: 8px;
|
max-height: 220px; overflow: auto; margin-top: 8px;
|
||||||
border: 1px solid var(--wd-border); border-radius: 4px; padding: 8px;
|
border: 1px solid var(--wd-border); border-radius: 4px; padding: 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user