edit athlete data
This commit is contained in:
@@ -435,6 +435,87 @@ async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONRe
|
|||||||
return JSONResponse({"ok": True, "filename": dest.name})
|
return JSONResponse({"ok": True, "filename": dest.name})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/athlete")
|
||||||
|
async def get_athlete() -> JSONResponse:
|
||||||
|
dd = _get_data_dir()
|
||||||
|
athlete_path = dd / "athlete.json"
|
||||||
|
if not athlete_path.exists():
|
||||||
|
raise HTTPException(404, "athlete.json not found — run bincio extract first")
|
||||||
|
|
||||||
|
data = json.loads(athlete_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
# Layer edits/athlete.yaml overrides on top
|
||||||
|
overrides = _read_athlete_edits(dd)
|
||||||
|
for key in ("max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"):
|
||||||
|
if key in overrides:
|
||||||
|
data[key] = overrides[key]
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"max_hr": data.get("max_hr"),
|
||||||
|
"ftp_w": data.get("ftp_w"),
|
||||||
|
"hr_zones": data.get("hr_zones"),
|
||||||
|
"power_zones": data.get("power_zones"),
|
||||||
|
"seasons": data.get("seasons", []),
|
||||||
|
"gear": data.get("gear", {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/athlete")
|
||||||
|
async def save_athlete(payload: dict[str, Any]) -> JSONResponse:
|
||||||
|
dd = _get_data_dir()
|
||||||
|
athlete_path = dd / "athlete.json"
|
||||||
|
if not athlete_path.exists():
|
||||||
|
raise HTTPException(404, "athlete.json not found — run bincio extract first")
|
||||||
|
|
||||||
|
# Write edits/athlete.yaml with validated fields
|
||||||
|
edits_dir = dd / "edits"
|
||||||
|
edits_dir.mkdir(exist_ok=True)
|
||||||
|
overrides: dict[str, Any] = {}
|
||||||
|
if payload.get("max_hr") is not None:
|
||||||
|
overrides["max_hr"] = int(payload["max_hr"])
|
||||||
|
if payload.get("ftp_w") is not None:
|
||||||
|
overrides["ftp_w"] = int(payload["ftp_w"])
|
||||||
|
if payload.get("hr_zones") is not None:
|
||||||
|
overrides["hr_zones"] = [[int(lo), int(hi)] for lo, hi in payload["hr_zones"]]
|
||||||
|
if payload.get("power_zones") is not None:
|
||||||
|
overrides["power_zones"] = [[int(lo), int(hi)] for lo, hi in payload["power_zones"]]
|
||||||
|
if payload.get("seasons") is not None:
|
||||||
|
overrides["seasons"] = [
|
||||||
|
{"name": str(s["name"]), "start": str(s["start"]), "end": str(s["end"])}
|
||||||
|
for s in payload["seasons"]
|
||||||
|
]
|
||||||
|
if payload.get("gear") is not None:
|
||||||
|
overrides["gear"] = payload["gear"]
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
(edits_dir / "athlete.yaml").write_text(
|
||||||
|
yaml.dump(overrides, allow_unicode=True, default_flow_style=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Patch athlete.json in-place (preserves power_curve, updated_at, etc.)
|
||||||
|
data = json.loads(athlete_path.read_text(encoding="utf-8"))
|
||||||
|
data.update(overrides)
|
||||||
|
athlete_path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
# Re-merge so _merged/athlete.json symlink stays valid
|
||||||
|
from bincio.render.merge import merge_all
|
||||||
|
merge_all(dd)
|
||||||
|
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
def _read_athlete_edits(data_dir: Path) -> dict:
|
||||||
|
path = data_dir / "edits" / "athlete.yaml"
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/activity/{activity_id}/images/{filename}")
|
@app.delete("/api/activity/{activity_id}/images/{filename}")
|
||||||
async def delete_image(activity_id: str, filename: str) -> JSONResponse:
|
async def delete_image(activity_id: str, filename: str) -> JSONResponse:
|
||||||
dd = _get_data_dir()
|
dd = _get_data_dir()
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let editUrl: string; // PUBLIC_EDIT_URL base
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ close: void; saved: void }>();
|
||||||
|
|
||||||
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
|
let loading = true;
|
||||||
|
let saving = false;
|
||||||
|
let error: string | null = null;
|
||||||
|
let status: string | null = null;
|
||||||
|
|
||||||
|
let maxHr: number | null = null;
|
||||||
|
let ftpW: number | null = null;
|
||||||
|
let hrZones: [number, number][] = [];
|
||||||
|
let powerZones: [number, number][] = [];
|
||||||
|
let seasons: { name: string; start: string; end: string }[] = [];
|
||||||
|
|
||||||
|
// ── Load ───────────────────────────────────────────────────────────────────
|
||||||
|
async function load() {
|
||||||
|
loading = true; error = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${editUrl}/api/athlete`);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const d = await res.json();
|
||||||
|
maxHr = d.max_hr ?? null;
|
||||||
|
ftpW = d.ftp_w ?? null;
|
||||||
|
hrZones = d.hr_zones ? d.hr_zones.map((z: number[]) => [z[0], z[1]] as [number,number]) : [];
|
||||||
|
powerZones = d.power_zones ? d.power_zones.map((z: number[]) => [z[0], z[1]] as [number,number]) : [];
|
||||||
|
seasons = d.seasons ?? [];
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
|
||||||
|
// ── Zone editing helpers ───────────────────────────────────────────────────
|
||||||
|
function onHiChange(zones: [number, number][], i: number) {
|
||||||
|
// Cascade: next row's lo = this row's hi
|
||||||
|
if (i + 1 < zones.length) zones[i + 1][0] = zones[i][1];
|
||||||
|
zones = [...zones]; // trigger reactivity
|
||||||
|
return zones;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addZone(zones: [number, number][]): [number, number][] {
|
||||||
|
const prevHi = zones.length ? zones[zones.length - 1][1] : 0;
|
||||||
|
return [...zones, [prevHi, prevHi + 50]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeZone(zones: [number, number][], i: number): [number, number][] {
|
||||||
|
const next = zones.filter((_, idx) => idx !== i);
|
||||||
|
// Re-cascade lo values
|
||||||
|
for (let j = 1; j < next.length; j++) next[j][0] = next[j - 1][1];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Season helpers ─────────────────────────────────────────────────────────
|
||||||
|
function addSeason() {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
seasons = [...seasons, { name: `${year}`, start: `${year}-01-01`, end: `${year}-12-31` }];
|
||||||
|
}
|
||||||
|
function removeSeason(i: number) {
|
||||||
|
seasons = seasons.filter((_, idx) => idx !== i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save ───────────────────────────────────────────────────────────────────
|
||||||
|
async function save() {
|
||||||
|
saving = true; status = null; error = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${editUrl}/api/athlete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
max_hr: maxHr,
|
||||||
|
ftp_w: ftpW,
|
||||||
|
hr_zones: hrZones,
|
||||||
|
power_zones: powerZones,
|
||||||
|
seasons,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
status = 'Saved.';
|
||||||
|
dispatch('saved');
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDrawer() { dispatch('close'); }
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
function onKeydown(e: KeyboardEvent) { if (e.key === 'Escape') closeDrawer(); }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={onKeydown} />
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50 z-40"
|
||||||
|
on:click={closeDrawer}
|
||||||
|
role="presentation"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Drawer -->
|
||||||
|
<aside class="fixed top-0 right-0 h-full w-full max-w-lg bg-zinc-950 border-l border-zinc-800 z-50 flex flex-col shadow-2xl overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-5 py-4 border-b border-zinc-800 flex-shrink-0">
|
||||||
|
<h2 class="text-base font-semibold text-white">Edit Athlete Profile</h2>
|
||||||
|
<button on:click={closeDrawer} class="text-zinc-400 hover:text-white text-xl leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex-1 flex items-center justify-center text-zinc-500 text-sm">Loading…</div>
|
||||||
|
{:else if error && !saving}
|
||||||
|
<div class="flex-1 flex items-center justify-center text-red-400 text-sm px-6 text-center">{error}</div>
|
||||||
|
{:else}
|
||||||
|
|
||||||
|
<!-- Scrollable body -->
|
||||||
|
<div class="flex-1 overflow-y-auto px-5 py-5 space-y-8">
|
||||||
|
|
||||||
|
<!-- Key numbers -->
|
||||||
|
<section>
|
||||||
|
<h3 class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-3">Key numbers</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs text-zinc-500 mb-1 block">Max HR (bpm)</span>
|
||||||
|
<input
|
||||||
|
type="number" min="100" max="250"
|
||||||
|
bind:value={maxHr}
|
||||||
|
class="w-full bg-zinc-900 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs text-zinc-500 mb-1 block">FTP (watts)</span>
|
||||||
|
<input
|
||||||
|
type="number" min="50" max="600"
|
||||||
|
bind:value={ftpW}
|
||||||
|
class="w-full bg-zinc-900 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- HR Zones -->
|
||||||
|
<section>
|
||||||
|
<h3 class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-3">HR Zones (bpm)</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each hrZones as zone, i}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-zinc-500 w-5">Z{i+1}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
bind:value={zone[0]}
|
||||||
|
disabled={i === 0}
|
||||||
|
class="w-20 bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500 disabled:opacity-40"
|
||||||
|
/>
|
||||||
|
<span class="text-zinc-600 text-xs">–</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
bind:value={zone[1]}
|
||||||
|
on:change={() => { hrZones = onHiChange(hrZones, i); }}
|
||||||
|
class="w-20 bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-zinc-600">bpm</span>
|
||||||
|
<button
|
||||||
|
on:click={() => { hrZones = removeZone(hrZones, i); }}
|
||||||
|
class="ml-auto text-zinc-600 hover:text-red-400 text-sm leading-none"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
on:click={() => { hrZones = addZone(hrZones); }}
|
||||||
|
class="mt-2 text-xs text-zinc-500 hover:text-white border border-zinc-700 hover:border-zinc-500 rounded px-3 py-1 transition-colors"
|
||||||
|
>+ Add zone</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Power Zones -->
|
||||||
|
<section>
|
||||||
|
<h3 class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-3">Power Zones (watts)</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each powerZones as zone, i}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-zinc-500 w-5">Z{i+1}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
bind:value={zone[0]}
|
||||||
|
disabled={i === 0}
|
||||||
|
class="w-20 bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500 disabled:opacity-40"
|
||||||
|
/>
|
||||||
|
<span class="text-zinc-600 text-xs">–</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
bind:value={zone[1]}
|
||||||
|
on:change={() => { powerZones = onHiChange(powerZones, i); }}
|
||||||
|
class="w-20 bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-zinc-600">W</span>
|
||||||
|
<button
|
||||||
|
on:click={() => { powerZones = removeZone(powerZones, i); }}
|
||||||
|
class="ml-auto text-zinc-600 hover:text-red-400 text-sm leading-none"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
on:click={() => { powerZones = addZone(powerZones); }}
|
||||||
|
class="mt-2 text-xs text-zinc-500 hover:text-white border border-zinc-700 hover:border-zinc-500 rounded px-3 py-1 transition-colors"
|
||||||
|
>+ Add zone</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Seasons -->
|
||||||
|
<section>
|
||||||
|
<h3 class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-1">Seasons</h3>
|
||||||
|
<p class="text-xs text-zinc-600 mb-3">Overlay multiple seasons on the power curve chart.</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each seasons as season, i}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={season.name}
|
||||||
|
placeholder="Name"
|
||||||
|
class="w-20 bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
bind:value={season.start}
|
||||||
|
class="bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<span class="text-zinc-600 text-xs">→</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
bind:value={season.end}
|
||||||
|
class="bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
on:click={() => removeSeason(i)}
|
||||||
|
class="text-zinc-600 hover:text-red-400 text-sm leading-none"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
on:click={addSeason}
|
||||||
|
class="mt-2 text-xs text-zinc-500 hover:text-white border border-zinc-700 hover:border-zinc-500 rounded px-3 py-1 transition-colors"
|
||||||
|
>+ Add season</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex items-center gap-3 px-5 py-4 border-t border-zinc-800 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
on:click={save}
|
||||||
|
disabled={saving}
|
||||||
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white text-sm font-medium rounded-md transition-colors"
|
||||||
|
>{saving ? 'Saving…' : 'Save'}</button>
|
||||||
|
<button
|
||||||
|
on:click={closeDrawer}
|
||||||
|
class="px-4 py-2 border border-zinc-700 hover:border-zinc-500 text-zinc-300 text-sm rounded-md transition-colors"
|
||||||
|
>Cancel</button>
|
||||||
|
{#if status}
|
||||||
|
<span class="text-green-400 text-sm">{status}</span>
|
||||||
|
{/if}
|
||||||
|
{#if error}
|
||||||
|
<span class="text-red-400 text-sm truncate">{error}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
@@ -2,11 +2,15 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { AthleteJson, BASIndex, ActivitySummary } from '../lib/types';
|
import type { AthleteJson, BASIndex, ActivitySummary } from '../lib/types';
|
||||||
import MmpChart from './MmpChart.svelte';
|
import MmpChart from './MmpChart.svelte';
|
||||||
|
import AthleteDrawer from './AthleteDrawer.svelte';
|
||||||
|
|
||||||
let athlete: AthleteJson | null = null;
|
let athlete: AthleteJson | null = null;
|
||||||
let activities: ActivitySummary[] = [];
|
let activities: ActivitySummary[] = [];
|
||||||
let loading = true;
|
let loading = true;
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
|
let drawerOpen = false;
|
||||||
|
|
||||||
|
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -19,6 +23,7 @@
|
|||||||
const index: BASIndex = await indexRes.json();
|
const index: BASIndex = await indexRes.json();
|
||||||
// Only activities with power data contribute to the curve
|
// Only activities with power data contribute to the curve
|
||||||
activities = index.activities.filter(a => a.mmp && a.privacy !== 'private');
|
activities = index.activities.filter(a => a.mmp && a.privacy !== 'private');
|
||||||
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -26,6 +31,13 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function onSaved() {
|
||||||
|
// Reload athlete.json after edits are saved
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL}data/athlete.json?t=${Date.now()}`);
|
||||||
|
if (res.ok) athlete = await res.json();
|
||||||
|
drawerOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
function fmtZone(zones: [number, number][], i: number): string {
|
function fmtZone(zones: [number, number][], i: number): string {
|
||||||
const [lo, hi] = zones[i];
|
const [lo, hi] = zones[i];
|
||||||
return hi >= 9000 ? `${lo}+ W` : `${lo}–${hi} W`;
|
return hi >= 9000 ? `${lo}+ W` : `${lo}–${hi} W`;
|
||||||
@@ -42,6 +54,16 @@
|
|||||||
<p class="text-red-400 text-sm">{error}</p>
|
<p class="text-red-400 text-sm">{error}</p>
|
||||||
{:else if athlete}
|
{:else if athlete}
|
||||||
|
|
||||||
|
<!-- Edit button (only when edit server is configured) -->
|
||||||
|
{#if editUrl}
|
||||||
|
<div class="flex justify-end mb-6">
|
||||||
|
<button
|
||||||
|
on:click={() => drawerOpen = true}
|
||||||
|
class="px-4 py-2 text-sm border border-zinc-700 hover:border-zinc-500 text-zinc-300 hover:text-white rounded-md transition-colors"
|
||||||
|
>Edit profile</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Power curve section -->
|
<!-- Power curve section -->
|
||||||
<section class="mb-10">
|
<section class="mb-10">
|
||||||
<h2 class="text-lg font-semibold text-white mb-4">Power Curve</h2>
|
<h2 class="text-lg font-semibold text-white mb-4">Power Curve</h2>
|
||||||
@@ -109,3 +131,11 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if drawerOpen && editUrl}
|
||||||
|
<AthleteDrawer
|
||||||
|
{editUrl}
|
||||||
|
on:close={() => drawerOpen = false}
|
||||||
|
on:saved={onSaved}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user