Toggle tasks and edit view settings via htmx
Task checkboxes post themselves (hx-post="?toggle=N") and flip back when the save fails, replacing tasks.js. View settings are a server-rendered form dialog with real radios and a select, replacing view-settings.js; with that, postReplace has no callers left. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,40 +3,6 @@ function encodePickedPath(p) {
|
|||||||
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
|
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
// postReplace POSTs to action with the optional form body, then loads target
|
|
||||||
// into the current history entry — so the action and its result occupy one
|
|
||||||
// entry instead of two, and back-navigation skips past the stale pre-mutation
|
|
||||||
// snapshot in bfcache. body may be null for empty POSTs.
|
|
||||||
//
|
|
||||||
// We can't just call window.location.replace(target): when target differs from
|
|
||||||
// the current URL only by fragment, the browser updates the URL bar without
|
|
||||||
// re-fetching, so a server-side mutation wouldn't be reflected. Instead,
|
|
||||||
// rewrite the current entry's URL via history.replaceState, then reload — the
|
|
||||||
// reload always re-fetches and preserves the (new) URL including its fragment.
|
|
||||||
function navigateReplace(target) {
|
|
||||||
window.history.replaceState(null, '', target);
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
function postReplace(action, body, target) {
|
|
||||||
var init = { method: 'POST', redirect: 'manual' };
|
|
||||||
if (body) {
|
|
||||||
init.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
|
||||||
init.body = body;
|
|
||||||
}
|
|
||||||
fetch(action, init).then(function (res) {
|
|
||||||
if (res.type === 'opaqueredirect' || res.ok) {
|
|
||||||
navigateReplace(target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return res.text().then(function (msg) {
|
|
||||||
alert(msg || ('Request failed (' + res.status + ')'));
|
|
||||||
});
|
|
||||||
}).catch(function () {
|
|
||||||
alert('Network error');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function promptPageName(title, initial, confirmLabel, onName) {
|
function promptPageName(title, initial, confirmLabel, onName) {
|
||||||
var input = document.createElement('input');
|
var input = document.createElement('input');
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
|
|||||||
@@ -44,3 +44,37 @@ The destination folder already exists but has no page of its own. Merge this pag
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>{{end}}
|
</div>{{end}}
|
||||||
|
|
||||||
|
{{define "view"}}<div class="modal-backdrop">
|
||||||
|
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?settings" hx-target="#modal-host">
|
||||||
|
<div class="panel-header"><span id="modal-title">View settings</span></div>
|
||||||
|
<div class="panel-body col">
|
||||||
|
<fieldset class="col gap-1">
|
||||||
|
<legend class="caption">View style</legend>
|
||||||
|
<div class="row gap-1 segmented">
|
||||||
|
<label class="btn"><input type="radio" name="view" value="list"{{if eq .View "list"}} checked{{end}} />List</label>
|
||||||
|
<label class="btn"><input type="radio" name="view" value="thumbnail"{{if eq .View "thumbnail"}} checked{{end}} />Thumbnail</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<label class="col gap-1">
|
||||||
|
<span class="caption">Sort by</span>
|
||||||
|
<select class="input" name="sort">
|
||||||
|
<option value="name"{{if eq .Sort "name"}} selected{{end}}>Name</option>
|
||||||
|
<option value="modified"{{if eq .Sort "modified"}} selected{{end}}>Modified</option>
|
||||||
|
<option value="size"{{if eq .Sort "size"}} selected{{end}}>Size</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<fieldset class="col gap-1">
|
||||||
|
<legend class="caption">Order</legend>
|
||||||
|
<div class="row gap-1 segmented">
|
||||||
|
<label class="btn"><input type="radio" name="order" value="asc"{{if eq .Order "asc"}} checked{{end}} />Asc</label>
|
||||||
|
<label class="btn"><input type="radio" name="order" value="desc"{{if eq .Order "desc"}} checked{{end}} />Desc</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<div class="panel-footer">
|
||||||
|
<button class="btn" type="button" data-modal-close>CANCEL</button>
|
||||||
|
<button class="btn" type="submit" autofocus>SAVE</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>{{end}}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<p class="empty">{{if .Entries}}No page{{else}}Empty folder{{end}} — <a href="?edit">[CREATE]</a></p>
|
<p class="empty">{{if .Entries}}No page{{else}}Empty folder{{end}} — <a href="?edit">[CREATE]</a></p>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Entries}}
|
{{if .Entries}}
|
||||||
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button>{{if .CanEdit}} <button class="btn btn-small" id="view-settings-btn" onclick="openViewSettings()" title="View & sorting" data-view="{{.View}}" data-sort="{{.Sort}}" data-order="{{.Order}}">view</button>{{end}}</h2>
|
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button>{{if .CanEdit}} <button class="btn btn-small" hx-get="?dialog=view" hx-target="#modal-host" title="View & sorting">view</button>{{end}}</h2>
|
||||||
{{if eq .View "thumbnail"}}
|
{{if eq .View "thumbnail"}}
|
||||||
<div class="thumb-grid">
|
<div class="thumb-grid">
|
||||||
{{range .Entries}}
|
{{range .Entries}}
|
||||||
@@ -44,12 +44,10 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .CanEdit}}<script src="{{asset "page/view-settings.js"}}"></script>{{end}}
|
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if or .Content .SpecialContent}}
|
{{if or .Content .SpecialContent}}
|
||||||
<script src="{{asset "page/content.js"}}"></script>
|
<script src="{{asset "page/content.js"}}"></script>
|
||||||
{{if not .SuppressTOC}}<script src="{{asset "page/toc.js"}}"></script>{{end}}
|
{{if not .SuppressTOC}}<script src="{{asset "page/toc.js"}}"></script>{{end}}
|
||||||
<script src="{{asset "page/tasks.js"}}"></script>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Content}}
|
{{if .Content}}
|
||||||
<script src="{{asset "page/sections.js"}}"></script>
|
<script src="{{asset "page/sections.js"}}"></script>
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
(function () {
|
|
||||||
document.querySelectorAll('input.task-checkbox[data-task-index]').forEach(function (cb) {
|
|
||||||
cb.addEventListener('change', function () {
|
|
||||||
var idx = cb.dataset.taskIndex;
|
|
||||||
var checked = cb.checked;
|
|
||||||
cb.disabled = true;
|
|
||||||
fetch(window.location.pathname + '?toggle=' + idx, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: 'checked=' + checked
|
|
||||||
}).then(function (res) {
|
|
||||||
if (!res.ok) {
|
|
||||||
cb.checked = !checked;
|
|
||||||
alert('Failed to save task state (' + res.status + ')');
|
|
||||||
}
|
|
||||||
}).catch(function () {
|
|
||||||
cb.checked = !checked;
|
|
||||||
alert('Failed to save task state');
|
|
||||||
}).finally(function () {
|
|
||||||
cb.disabled = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
// View-settings modal: lets the user pick the folder listing's view style,
|
|
||||||
// sort key, and order, then persists them by POSTing to the folder with
|
|
||||||
// ?settings. Reuses openModal/closeModal and postReplace from page/actions.js.
|
|
||||||
function openViewSettings() {
|
|
||||||
var btn = document.getElementById('view-settings-btn');
|
|
||||||
var state = {
|
|
||||||
view: (btn && btn.dataset.view) || 'list',
|
|
||||||
sort: (btn && btn.dataset.sort) || 'name',
|
|
||||||
order: (btn && btn.dataset.order) || 'asc'
|
|
||||||
};
|
|
||||||
|
|
||||||
// segmented builds a row of mutually-exclusive .btn toggles bound to a
|
|
||||||
// single state key, marking the current choice with .is-active.
|
|
||||||
function segmented(key, options) {
|
|
||||||
var wrap = document.createElement('div');
|
|
||||||
wrap.className = 'row gap-1';
|
|
||||||
options.forEach(function (opt) {
|
|
||||||
var b = document.createElement('button');
|
|
||||||
b.type = 'button';
|
|
||||||
b.className = 'btn';
|
|
||||||
b.textContent = opt.label;
|
|
||||||
if (state[key] === opt.value) b.classList.add('is-active');
|
|
||||||
b.addEventListener('click', function () {
|
|
||||||
state[key] = opt.value;
|
|
||||||
wrap.querySelectorAll('button').forEach(function (x) {
|
|
||||||
x.classList.remove('is-active');
|
|
||||||
});
|
|
||||||
b.classList.add('is-active');
|
|
||||||
});
|
|
||||||
wrap.appendChild(b);
|
|
||||||
});
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
function field(labelText, control) {
|
|
||||||
var row = document.createElement('div');
|
|
||||||
row.className = 'col gap-1';
|
|
||||||
var label = document.createElement('span');
|
|
||||||
label.className = 'caption';
|
|
||||||
label.textContent = labelText;
|
|
||||||
row.appendChild(label);
|
|
||||||
row.appendChild(control);
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sortSelect = document.createElement('select');
|
|
||||||
sortSelect.className = 'input';
|
|
||||||
[['name', 'Name'], ['modified', 'Modified'], ['size', 'Size']].forEach(function (o) {
|
|
||||||
var opt = document.createElement('option');
|
|
||||||
opt.value = o[0];
|
|
||||||
opt.textContent = o[1];
|
|
||||||
if (state.sort === o[0]) opt.selected = true;
|
|
||||||
sortSelect.appendChild(opt);
|
|
||||||
});
|
|
||||||
sortSelect.addEventListener('change', function () { state.sort = sortSelect.value; });
|
|
||||||
|
|
||||||
var body = document.createElement('div');
|
|
||||||
body.className = 'col';
|
|
||||||
body.appendChild(field('View style', segmented('view', [
|
|
||||||
{ value: 'list', label: 'List' },
|
|
||||||
{ value: 'thumbnail', label: 'Thumbnail' }
|
|
||||||
])));
|
|
||||||
body.appendChild(field('Sort by', sortSelect));
|
|
||||||
body.appendChild(field('Order', segmented('order', [
|
|
||||||
{ value: 'asc', label: 'Asc' },
|
|
||||||
{ value: 'desc', label: 'Desc' }
|
|
||||||
])));
|
|
||||||
|
|
||||||
openModal({
|
|
||||||
title: 'View settings',
|
|
||||||
body: body,
|
|
||||||
confirm: {
|
|
||||||
label: 'SAVE',
|
|
||||||
onConfirm: function () {
|
|
||||||
var action = window.location.pathname + '?settings';
|
|
||||||
var formBody = 'view=' + encodeURIComponent(state.view) +
|
|
||||||
'&sort=' + encodeURIComponent(state.sort) +
|
|
||||||
'&order=' + encodeURIComponent(state.order);
|
|
||||||
var target = window.location.pathname;
|
|
||||||
closeModal();
|
|
||||||
postReplace(action, formBody, target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -241,6 +241,20 @@ footer {
|
|||||||
.danger { color: var(--danger); }
|
.danger { color: var(--danger); }
|
||||||
.danger:hover { color: var(--danger-hover); }
|
.danger:hover { color: var(--danger-hover); }
|
||||||
.btn.is-active { color: var(--primary-hover); }
|
.btn.is-active { color: var(--primary-hover); }
|
||||||
|
/* Segmented choice: radios styled as .btn toggles. The label is the button;
|
||||||
|
the input stays in the accessibility tree and keyboard order. */
|
||||||
|
.segmented input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.segmented .btn { position: relative; }
|
||||||
|
.segmented .btn:has(input:checked) { color: var(--primary-hover); }
|
||||||
|
.segmented .btn:has(input:focus-visible) { outline: 1px solid var(--secondary); }
|
||||||
|
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
|
||||||
|
legend { padding: 0; margin-bottom: var(--space-1); } /* not a flex item: .gap-* skips it */
|
||||||
|
|
||||||
/* === Form controls ===
|
/* === Form controls ===
|
||||||
.input baseline is shared by search-input, modal inputs, and the editor
|
.input baseline is shared by search-input, modal inputs, and the editor
|
||||||
|
|||||||
+7
-3
@@ -46,13 +46,17 @@ func renderDialog(w http.ResponseWriter, name string, data any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// serveDialog handles GET ?dialog=<name> for the page at urlPath.
|
// serveDialog handles GET ?dialog=<name> for the page at urlPath.
|
||||||
func (h *handler) serveDialog(w http.ResponseWriter, r *http.Request, urlPath string) {
|
func (h *handler) serveDialog(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
||||||
page := normalizeMovePath(urlPath)
|
page := normalizeMovePath(urlPath)
|
||||||
if page == "/" {
|
name := r.URL.Query().Get("dialog")
|
||||||
|
if page == "/" && (name == "delete" || name == "move") {
|
||||||
http.Error(w, "not available for the wiki root", http.StatusBadRequest)
|
http.Error(w, "not available for the wiki root", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch r.URL.Query().Get("dialog") {
|
switch name {
|
||||||
|
case "view":
|
||||||
|
view, sortKey, order := readPageSettings(fsPath).viewSettings()
|
||||||
|
renderDialog(w, "view", struct{ View, Sort, Order string }{view, sortKey, order})
|
||||||
case "delete":
|
case "delete":
|
||||||
renderDialog(w, "delete", struct{ Path string }{urlPath})
|
renderDialog(w, "delete", struct{ Path string }{urlPath})
|
||||||
case "move":
|
case "move":
|
||||||
|
|||||||
+28
-1
@@ -47,14 +47,41 @@ test("task checkbox toggle persists", async ({ page }) => {
|
|||||||
await expect(page.locator("main input.task-checkbox").first()).toBeChecked();
|
await expect(page.locator("main input.task-checkbox").first()).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("unchecking a task persists", async ({ page }) => {
|
||||||
|
await page.goto("/Projects/");
|
||||||
|
const box = page.locator("main input.task-checkbox").nth(1);
|
||||||
|
await expect(box).toBeChecked();
|
||||||
|
await box.uncheck();
|
||||||
|
await expect(box).toBeEnabled();
|
||||||
|
await expect.poll(() => readFileSync(join(wiki, "Projects", "index.md"), "utf8")).toContain("- [ ] second task");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed task save reverts the checkbox", async ({ page }) => {
|
||||||
|
page.on("dialog", (d) => d.dismiss());
|
||||||
|
await page.route("**/?toggle=*", (route) => route.fulfill({ status: 500, body: "boom" }));
|
||||||
|
await page.goto("/Projects/");
|
||||||
|
const box = page.locator("main input.task-checkbox").nth(1);
|
||||||
|
const before = await box.isChecked();
|
||||||
|
await box.click();
|
||||||
|
await expect(box).toBeEnabled();
|
||||||
|
await expect(box).toBeChecked({ checked: before });
|
||||||
|
});
|
||||||
|
|
||||||
test("view settings switch the listing to thumbnails", async ({ page }) => {
|
test("view settings switch the listing to thumbnails", async ({ page }) => {
|
||||||
await page.goto("/Projects/");
|
await page.goto("/Projects/");
|
||||||
await expect(page.locator("main table.data-table")).toBeVisible();
|
await expect(page.locator("main table.data-table")).toBeVisible();
|
||||||
await page.getByTitle("View & sorting").click();
|
await page.getByTitle("View & sorting").click();
|
||||||
await modal(page).getByRole("button", { name: "Thumbnail" }).click();
|
await modal(page).getByText("Thumbnail").click();
|
||||||
|
await modal(page).getByLabel("Sort by").selectOption("size");
|
||||||
|
await modal(page).getByText("Desc").click();
|
||||||
await modal(page).getByRole("button", { name: "SAVE" }).click();
|
await modal(page).getByRole("button", { name: "SAVE" }).click();
|
||||||
await expect(page.locator("main .thumb-grid")).toBeVisible();
|
await expect(page.locator("main .thumb-grid")).toBeVisible();
|
||||||
await expect(page).toHaveURL(/\/Projects\/$/);
|
await expect(page).toHaveURL(/\/Projects\/$/);
|
||||||
|
// Reopening shows the saved state.
|
||||||
|
await page.getByTitle("View & sorting").click();
|
||||||
|
await expect(modal(page).getByLabel("Thumbnail")).toBeChecked();
|
||||||
|
await expect(modal(page).getByLabel("Sort by")).toHaveValue("size");
|
||||||
|
await expect(modal(page).getByLabel("Desc")).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("editor saves a page", async ({ page }) => {
|
test("editor saves a page", async ({ page }) => {
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.URL.Query().Has("dialog") {
|
if r.URL.Query().Has("dialog") {
|
||||||
h.serveDialog(w, r, urlPath)
|
h.serveDialog(w, r, urlPath, fsPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,8 +406,6 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
Content: rendered,
|
Content: rendered,
|
||||||
Entries: entries,
|
Entries: entries,
|
||||||
View: view,
|
View: view,
|
||||||
Sort: sortKey,
|
|
||||||
Order: order,
|
|
||||||
SpecialContent: specialContent,
|
SpecialContent: specialContent,
|
||||||
SidebarWidget: sidebarWidget,
|
SidebarWidget: sidebarWidget,
|
||||||
SuppressTOC: suppressTOC,
|
SuppressTOC: suppressTOC,
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ func (h *handler) handleSettings(w http.ResponseWriter, r *http.Request, urlPath
|
|||||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, urlPath, http.StatusSeeOther)
|
redirectAfter(w, r, pageURL(urlPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
// writePageSettings performs a read-modify-write of <dir>/.page-settings,
|
// writePageSettings performs a read-modify-write of <dir>/.page-settings,
|
||||||
|
|||||||
@@ -58,8 +58,6 @@ type pageData struct {
|
|||||||
Content template.HTML
|
Content template.HTML
|
||||||
Entries []entry
|
Entries []entry
|
||||||
View string // listing view style: "list" or "thumbnail"
|
View string // listing view style: "list" or "thumbnail"
|
||||||
Sort string // listing sort key: "name" / "modified" / "size"
|
|
||||||
Order string // listing sort order: "asc" / "desc"
|
|
||||||
SpecialContent template.HTML
|
SpecialContent template.HTML
|
||||||
SidebarWidget template.HTML
|
SidebarWidget template.HTML
|
||||||
SuppressTOC bool
|
SuppressTOC bool
|
||||||
|
|||||||
@@ -17,18 +17,19 @@ var taskCheckboxRe = regexp.MustCompile(`<input(?: checked="")? disabled="" type
|
|||||||
// then a `[ ]` / `[x]` / `[X]` checkbox marker.
|
// then a `[ ]` / `[x]` / `[X]` checkbox marker.
|
||||||
var taskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+)\[([ xX])\]`)
|
var taskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+)\[([ xX])\]`)
|
||||||
|
|
||||||
// rewriteTaskCheckboxes enables and indexes the task checkboxes in rendered
|
// rewriteTaskCheckboxes makes the task checkboxes in rendered HTML toggle
|
||||||
// HTML so JS can wire them up. Each checkbox gains a data-task-index matching
|
// their source line. The disabled attribute is removed and each checkbox
|
||||||
// its position among task list items in source order; the disabled attribute
|
// posts itself to ?toggle=N (htmx), N being its position among task list
|
||||||
// is removed so the user can toggle them.
|
// items in source order. An unchecked box sends no value, i.e. checked=false;
|
||||||
|
// a failed save flips the box back.
|
||||||
func rewriteTaskCheckboxes(in []byte) []byte {
|
func rewriteTaskCheckboxes(in []byte) []byte {
|
||||||
idx := 0
|
idx := 0
|
||||||
return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte {
|
return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte {
|
||||||
checked := bytes.Contains(match, []byte("checked"))
|
checked := bytes.Contains(match, []byte("checked"))
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
out.WriteString(`<input type="checkbox" class="task-checkbox" data-task-index="`)
|
out.WriteString(`<input type="checkbox" class="task-checkbox" name="checked" value="true" hx-post="?toggle=`)
|
||||||
out.WriteString(strconv.Itoa(idx))
|
out.WriteString(strconv.Itoa(idx))
|
||||||
out.WriteByte('"')
|
out.WriteString(`" hx-swap="none" hx-disabled-elt="this" hx-on::response-error="this.checked = !this.checked" hx-on::send-error="this.checked = !this.checked"`)
|
||||||
if checked {
|
if checked {
|
||||||
out.WriteString(` checked=""`)
|
out.WriteString(` checked=""`)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user