feat(calendar): image/attachment per event, direct links, copy link, ImageModal component
- calendar_item schema: flyer → image (type: image), attachment (assetUrl)
- CalendarItemData: image + attachment fields
- event anchor IDs (#event-{slug}) with onMount hash scroll + auto-open
- share button uses direct calendar anchor URL
- copy link button (mdi:link-variant) copies anchor URL to clipboard
- ImageModal extracted as reusable component
- action buttons always show text (no expand-on-hover)
- onDestroy window guard (SSR fix)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+19
-4
@@ -383,15 +383,30 @@ export interface LiveStrommixBlockData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Kalender-Item (calendar_item) – aus OpenAPI; terminZeit = ISO date-time.
|
/** Kalender-Item (calendar_item) – aus OpenAPI; terminZeit = ISO date-time.
|
||||||
* Lokale Erweiterungen (terminEnde, location, tags) sind im RustyCMS-Schema
|
* Lokale Erweiterungen sind im RustyCMS-Schema ergänzt und werden bis zur
|
||||||
* ergänzt und werden bis zur OpenAPI-Regen hier inline mitgeführt. */
|
* OpenAPI-Regen hier inline mitgeführt. */
|
||||||
export type CalendarItemData = components["schemas"]["calendar_item"] & {
|
export type CalendarItemData = components["schemas"]["calendar_item"] & {
|
||||||
/** Optionales Endedatum für Mehrtagesveranstaltungen (ISO date-time). */
|
/** Optionales Endedatum für Mehrtagesveranstaltungen (ISO date-time). */
|
||||||
terminEnde?: string;
|
terminEnde?: string;
|
||||||
/** Optionale Veranstaltungsort-Beschreibung (Adresse oder Name). */
|
/** Optionaler Veranstaltungsort (string oder {text, lat, lng}). */
|
||||||
location?: string;
|
location?: string | { text?: string; lat?: number; lng?: number };
|
||||||
/** Optionale Tags zur Filterung. */
|
/** Optionale Tags zur Filterung. */
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
/** Optionales Veranstaltungsbild (aufgelöstes image-Feld oder Slug). */
|
||||||
|
image?:
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
_type?: "img";
|
||||||
|
_slug?: string;
|
||||||
|
src?: string;
|
||||||
|
description?: string;
|
||||||
|
file?: { url?: string };
|
||||||
|
};
|
||||||
|
/** Optionaler Dateianhang (z.B. PDF-Flyer). */
|
||||||
|
attachment?: {
|
||||||
|
src?: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Kalender-Block (_type: "calendar"). items nach Resolve: CalendarItemData[]. Basis aus OpenAPI (calendar). */
|
/** Kalender-Block (_type: "calendar"). items nach Resolve: CalendarItemData[]. Basis aus OpenAPI (calendar). */
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
import "$lib/iconify-offline";
|
||||||
|
|
||||||
|
let {
|
||||||
|
src,
|
||||||
|
alt = "",
|
||||||
|
onclose,
|
||||||
|
}: { src: string; alt?: string; onclose: () => void } = $props();
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onclose();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => window.addEventListener("keydown", onKeydown));
|
||||||
|
onDestroy(() => {
|
||||||
|
if (typeof window !== "undefined")
|
||||||
|
window.removeEventListener("keydown", onKeydown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 p-4 cursor-zoom-out"
|
||||||
|
onclick={onclose}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={alt || "Bild Vollansicht"}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
{src}
|
||||||
|
{alt}
|
||||||
|
class="max-w-full max-h-full object-contain rounded shadow-2xl cursor-default"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onclose}
|
||||||
|
class="absolute top-4 right-4 p-2 rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors"
|
||||||
|
aria-label="Schließen"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:close" class="size-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy, tick } from "svelte";
|
||||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||||
import type { CalendarBlockData, CalendarItemData } from "$lib/block-types";
|
import type { CalendarBlockData, CalendarItemData } from "$lib/block-types";
|
||||||
import { t as tStatic, T } from "$lib/translations";
|
import { t as tStatic, T } from "$lib/translations";
|
||||||
@@ -11,8 +11,13 @@
|
|||||||
mapsUrl,
|
mapsUrl,
|
||||||
type CalendarItemICS,
|
type CalendarItemICS,
|
||||||
} from "$lib/calendar-ics";
|
} from "$lib/calendar-ics";
|
||||||
|
import {
|
||||||
|
getCmsImageUrl,
|
||||||
|
extractCmsImageField,
|
||||||
|
} from "$lib/rusty-image";
|
||||||
import "$lib/iconify-offline";
|
import "$lib/iconify-offline";
|
||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
|
import ImageModal from "$lib/components/ImageModal.svelte";
|
||||||
|
|
||||||
type EventCardItem = CalendarItemData & {
|
type EventCardItem = CalendarItemData & {
|
||||||
start: Date;
|
start: Date;
|
||||||
@@ -442,6 +447,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let shareToastKey = $state<string | null>(null);
|
let shareToastKey = $state<string | null>(null);
|
||||||
|
let copyToastKey = $state<string | null>(null);
|
||||||
|
let modalImage = $state<{ src: string; alt: string } | null>(null);
|
||||||
|
let linkedSlug = $state<string | null>(null);
|
||||||
|
|
||||||
|
function eventId(item: EventCardItem): string {
|
||||||
|
if (item._slug) return `event-${item._slug}`;
|
||||||
|
const titlePart = (item.title || "event")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
return `event-${titlePart}-${dayKey(item.start)}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function shareEvent(
|
async function shareEvent(
|
||||||
ev: EventCardItem,
|
ev: EventCardItem,
|
||||||
@@ -457,8 +474,7 @@
|
|||||||
const timeInfo = ev.timeStr ? ` · ${ev.timeStr}` : "";
|
const timeInfo = ev.timeStr ? ` · ${ev.timeStr}` : "";
|
||||||
const parts = [`${titleStr}`, `${dateStr}${timeInfo}`];
|
const parts = [`${titleStr}`, `${dateStr}${timeInfo}`];
|
||||||
if (locStr) parts.push(locStr);
|
if (locStr) parts.push(locStr);
|
||||||
const href = getEventLinkHref(ev.link);
|
const url = `${window.location.origin}${window.location.pathname}#${eventId(ev)}`;
|
||||||
const url = href || window.location.href;
|
|
||||||
const text = parts.join("\n");
|
const text = parts.join("\n");
|
||||||
const key = ev._slug ?? String(ev.start.getTime());
|
const key = ev._slug ?? String(ev.start.getTime());
|
||||||
|
|
||||||
@@ -488,9 +504,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyEventLink(ev: EventCardItem): Promise<void> {
|
||||||
|
const url = `${window.location.origin}${window.location.pathname}#${eventId(ev)}`;
|
||||||
|
const key = ev._slug ?? String(ev.start.getTime());
|
||||||
|
await navigator.clipboard.writeText(url).catch(() => {});
|
||||||
|
copyToastKey = key;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (copyToastKey === key) copyToastKey = null;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
let widgetEl: HTMLElement | null = $state(null);
|
let widgetEl: HTMLElement | null = $state(null);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
feedHost = window.location.host;
|
feedHost = window.location.host;
|
||||||
hasHover = window.matchMedia(
|
hasHover = window.matchMedia(
|
||||||
"(hover: hover) and (pointer: fine)",
|
"(hover: hover) and (pointer: fine)",
|
||||||
@@ -498,6 +524,15 @@
|
|||||||
tickTimer = setInterval(() => {
|
tickTimer = setInterval(() => {
|
||||||
nowMs = Date.now();
|
nowMs = Date.now();
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
if (hash.startsWith("event-")) {
|
||||||
|
linkedSlug = hash;
|
||||||
|
await tick();
|
||||||
|
document
|
||||||
|
.getElementById(hash)
|
||||||
|
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (tickTimer) clearInterval(tickTimer);
|
if (tickTimer) clearInterval(tickTimer);
|
||||||
@@ -527,10 +562,11 @@
|
|||||||
item.isMultiDay
|
item.isMultiDay
|
||||||
)}
|
)}
|
||||||
<li
|
<li
|
||||||
|
id={eventId(item)}
|
||||||
class="event-item {isNext ? 'event-item-next' : ''}"
|
class="event-item {isNext ? 'event-item-next' : ''}"
|
||||||
data-urgency={urgency}
|
data-urgency={urgency}
|
||||||
>
|
>
|
||||||
<details class="group" open={openByDefault}>
|
<details class="group/event" open={openByDefault || linkedSlug === eventId(item)}>
|
||||||
<summary
|
<summary
|
||||||
class="cursor-pointer list-none p-3 flex items-start gap-3 hover:bg-himmel-50/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-himmel-500"
|
class="cursor-pointer list-none p-3 flex items-start gap-3 hover:bg-himmel-50/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-himmel-500"
|
||||||
>
|
>
|
||||||
@@ -647,7 +683,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<Icon
|
<Icon
|
||||||
icon="mdi:chevron-down"
|
icon="mdi:chevron-down"
|
||||||
class="size-4 text-stein-400 shrink-0 mt-1 transition-transform group-open:rotate-180"
|
class="size-4 text-stein-400 shrink-0 mt-1 transition-transform group-open/event:rotate-180"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</summary>
|
</summary>
|
||||||
@@ -677,6 +713,31 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if item.image}
|
||||||
|
{@const imageField = extractCmsImageField(item.image)}
|
||||||
|
{#if imageField}
|
||||||
|
{@const thumbSrc = getCmsImageUrl(imageField.url, { width: 160, height: 160, fit: "cover" })}
|
||||||
|
{@const fullSrc = getCmsImageUrl(imageField.url, { width: 1400, fit: "contain" })}
|
||||||
|
{@const imageAlt = imageField.alt ?? (typeof item.image === "object" && "description" in item.image ? (item.image.description ?? "") : "")}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (modalImage = { src: fullSrc, alt: imageAlt })}
|
||||||
|
class="group relative w-24 h-24 rounded overflow-hidden border border-stein-200 shrink-0 hover:opacity-90 transition-opacity"
|
||||||
|
aria-label="Bild vergrößern"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={thumbSrc}
|
||||||
|
alt={imageAlt}
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span class="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/20 transition-colors">
|
||||||
|
<Icon icon="mdi:magnify-plus" class="size-5 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 pt-1 text-xs">
|
<div class="flex flex-wrap gap-2 pt-1 text-xs">
|
||||||
{#if eventHref}
|
{#if eventHref}
|
||||||
<a
|
<a
|
||||||
@@ -698,76 +759,103 @@
|
|||||||
href={mapsUrl(locText)}
|
href={mapsUrl(locText)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="btn-outline btn-sm"
|
class="btn-outline btn-sm group"
|
||||||
|
aria-label={t(T.calendar_open_maps)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon="mdi:map-marker"
|
icon="mdi:map-marker"
|
||||||
class="size-3.5 shrink-0"
|
class="size-3.5 shrink-0"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{t(T.calendar_open_maps)}
|
<span class="whitespace-nowrap">{t(T.calendar_open_maps)}</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if item.attachment?.src}
|
||||||
|
{@const dlSrc = `/cms-files?src=${encodeURIComponent(item.attachment.src)}&dl=1`}
|
||||||
|
<a
|
||||||
|
href={dlSrc}
|
||||||
|
class="btn-outline btn-sm group"
|
||||||
|
aria-label={item.attachment.label || "Anhang herunterladen"}
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:file-download" class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
|
<span class="whitespace-nowrap">{item.attachment.label || "Anhang herunterladen"}</span>
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
downloadICS(toICS(item), fileSlug(title))}
|
downloadICS(toICS(item), fileSlug(title))}
|
||||||
class="btn-outline btn-sm"
|
class="btn-outline btn-sm group"
|
||||||
|
aria-label={t(T.calendar_download_ics)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon="mdi:download"
|
icon="mdi:download"
|
||||||
class="size-3.5"
|
class="size-3.5 shrink-0"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{t(T.calendar_download_ics)}
|
<span class="whitespace-nowrap">{t(T.calendar_download_ics)}</span>
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
href={googleCalUrl(toICS(item))}
|
href={googleCalUrl(toICS(item))}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="btn-outline btn-sm"
|
class="btn-outline btn-sm group"
|
||||||
|
aria-label={t(T.calendar_add_to_google)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon="mdi:google"
|
icon="mdi:google"
|
||||||
class="size-3.5"
|
class="size-3.5 shrink-0"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{t(T.calendar_add_to_google)}
|
<span class="whitespace-nowrap">{t(T.calendar_add_to_google)}</span>
|
||||||
</a>
|
</a>
|
||||||
{#if feedHost}
|
{#if feedHost}
|
||||||
{@const shareKey =
|
{@const shareKey =
|
||||||
item._slug ?? String(item.start.getTime())}
|
item._slug ?? String(item.start.getTime())}
|
||||||
|
{@const copied = copyToastKey === shareKey}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => copyEventLink(item)}
|
||||||
|
class="btn-outline btn-sm group"
|
||||||
|
aria-label={t(T.post_action_copy)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={copied ? "mdi:check" : "mdi:link-variant"}
|
||||||
|
class="size-3.5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="whitespace-nowrap">{copied ? t(T.post_action_copied) : t(T.post_action_copy)}</span>
|
||||||
|
</button>
|
||||||
{@const shared = shareToastKey === shareKey}
|
{@const shared = shareToastKey === shareKey}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
shareEvent(item, title, locText)}
|
shareEvent(item, title, locText)}
|
||||||
class="btn-outline btn-sm"
|
class="btn-outline btn-sm group"
|
||||||
aria-label={t(T.calendar_share_aria)}
|
aria-label={t(T.calendar_share_aria)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon={shared
|
icon={shared
|
||||||
? "mdi:check"
|
? "mdi:check"
|
||||||
: "mdi:share-variant"}
|
: "mdi:share-variant"}
|
||||||
class="size-3.5"
|
class="size-3.5 shrink-0"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{shared
|
<span class="whitespace-nowrap">{shared ? t(T.calendar_share_copied) : t(T.calendar_share)}</span>
|
||||||
? t(T.calendar_share_copied)
|
|
||||||
: t(T.calendar_share)}
|
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => jumpToEventInGrid(item)}
|
onclick={() => jumpToEventInGrid(item)}
|
||||||
class="btn-outline btn-sm"
|
class="btn-outline btn-sm group"
|
||||||
|
aria-label={t(T.calendar_jump_to_month)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon="mdi:calendar-search"
|
icon="mdi:calendar-search"
|
||||||
class="size-3.5"
|
class="size-3.5 shrink-0"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{t(T.calendar_jump_to_month)}
|
<span class="whitespace-nowrap">{t(T.calendar_jump_to_month)}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1136,6 +1224,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if modalImage}
|
||||||
|
<ImageModal src={modalImage.src} alt={modalImage.alt} onclose={() => (modalImage = null)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Heute-Markierung im Kalendergrid: dezenter Rand + leichtes Glow.
|
/* Heute-Markierung im Kalendergrid: dezenter Rand + leichtes Glow.
|
||||||
Ergänzt das Event-Badge, damit auch leere Tage als "Heute"
|
Ergänzt das Event-Badge, damit auch leere Tage als "Heute"
|
||||||
|
|||||||
@@ -69,6 +69,12 @@
|
|||||||
"map-marker-outline": {
|
"map-marker-outline": {
|
||||||
"body": "<path fill=\"currentColor\" d=\"M12 6.5A2.5 2.5 0 0 1 14.5 9a2.5 2.5 0 0 1-2.5 2.5A2.5 2.5 0 0 1 9.5 9A2.5 2.5 0 0 1 12 6.5M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7m0 2a5 5 0 0 0-5 5c0 1 0 3 5 9.71C17 12 17 10 17 9a5 5 0 0 0-5-5\"/>"
|
"body": "<path fill=\"currentColor\" d=\"M12 6.5A2.5 2.5 0 0 1 14.5 9a2.5 2.5 0 0 1-2.5 2.5A2.5 2.5 0 0 1 9.5 9A2.5 2.5 0 0 1 12 6.5M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7m0 2a5 5 0 0 0-5 5c0 1 0 3 5 9.71C17 12 17 10 17 9a5 5 0 0 0-5-5\"/>"
|
||||||
},
|
},
|
||||||
|
"magnify-plus": {
|
||||||
|
"body": "<path fill=\"currentColor\" d=\"M9 2a7 7 0 0 1 7 7c0 1.57-.5 3-1.39 4.19l.8.81H16l6 6l-2 2l-6-6v-.59l-.81-.8A6.9 6.9 0 0 1 9 16a7 7 0 0 1-7-7a7 7 0 0 1 7-7M8 5v3H5v2h3v3h2v-3h3V8h-3V5z\"/>"
|
||||||
|
},
|
||||||
|
"file-download": {
|
||||||
|
"body": "<path fill=\"currentColor\" d=\"M14 2H6c-1.11 0-2 .89-2 2v16c0 1.11.89 2 2 2h12c1.11 0 2-.89 2-2V8zm-2 17l-4-4h2.5v-3h3v3H16zm1-10V3.5L18.5 9z\"/>"
|
||||||
|
},
|
||||||
"download": {
|
"download": {
|
||||||
"body": "<path fill=\"currentColor\" d=\"M5 20h14v-2H5m14-9h-4V3H9v6H5l7 7z\"/>"
|
"body": "<path fill=\"currentColor\" d=\"M5 20h14v-2H5m14-9h-4V3H9v6H5l7 7z\"/>"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user