E5 S3: unified Plex card — scrim-reveal hover + rest polish
The poster card (grid + person page) gets the Seerr-style hover: on hover a bottom scrim slides up with the title, a "year · runtime · ★rating" meta line, and a 3-line synopsis — the info you'd otherwise click through for — plus a less-flat play ring (radial-gradient + ring shadow) and a top-right quick-action cluster (Info + watched-toggle, revealed on hover / always on touch). Rest-state badges (season / type tag) yield to the scrim on hover; the playable-cost tint stays. (§1 A + C from the enrichment study.) Backend (the data the scrim needs, mirroring S1's `art`): the grid (browse.unified_library) now emits a lang-coalesced, truncated `summary` (new query.summary_col) + the Plex `rating`; movie_card/show_card do the same (EN) for the detail/related/person cards. PlexCard.rating type added (summary already existed for episodes). Gate: tsc x3 / eslint / prettier / knip / vitest 326 green; ruff green; backend lane full suite green. Self-E2E on the live dev server (rebuilt on this branch, S2-enriched): the grid serves rating+summary (Shawshank 9.3, Godfather 9.2, real synopses) and the card renders the play ring + scrim (The Expanse: ring, synopsis, ★8.1) — verified via the DOM (the pane wouldn't composite for a screenshot).
This commit is contained in:
@@ -14,12 +14,21 @@ def _art_url(rating_key: str, art_key: str | None) -> str | None:
|
|||||||
return f"/api/plex/image/{rating_key}?variant=art" if art_key else None
|
return f"/api/plex/image/{rating_key}?variant=art" if art_key else None
|
||||||
|
|
||||||
|
|
||||||
|
# Card hover-overview: a short synopsis for the reveal panel, trimmed so a strip/related row of cards
|
||||||
|
# stays a few hundred bytes each rather than carrying full synopses. (These non-grid card paths serve
|
||||||
|
# the EN summary/rating; the main grid coalesces the HU twin in browse.unified_library.)
|
||||||
|
def _card_summary(summary: str | None) -> str | None:
|
||||||
|
return (summary or "")[:300] or None
|
||||||
|
|
||||||
|
|
||||||
def movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
def movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": it.rating_key,
|
"id": it.rating_key,
|
||||||
"type": "movie",
|
"type": "movie",
|
||||||
"title": it.title,
|
"title": it.title,
|
||||||
"year": it.year,
|
"year": it.year,
|
||||||
|
"rating": it.rating,
|
||||||
|
"summary": _card_summary(it.summary),
|
||||||
"duration_seconds": it.duration_s,
|
"duration_seconds": it.duration_s,
|
||||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||||
"art": _art_url(it.rating_key, it.art_key),
|
"art": _art_url(it.rating_key, it.art_key),
|
||||||
@@ -35,6 +44,8 @@ def show_card(sh: PlexShow, status: str = "new") -> dict:
|
|||||||
"type": "show",
|
"type": "show",
|
||||||
"title": sh.title,
|
"title": sh.title,
|
||||||
"year": sh.year,
|
"year": sh.year,
|
||||||
|
"rating": sh.rating,
|
||||||
|
"summary": _card_summary(sh.summary),
|
||||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||||
"art": _art_url(sh.rating_key, sh.art_key),
|
"art": _art_url(sh.rating_key, sh.art_key),
|
||||||
"season_count": sh.child_count,
|
"season_count": sh.child_count,
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ def title_col(model, lang: str):
|
|||||||
return func.coalesce(model.title_hu, model.title) if lang == "hu" else model.title
|
return func.coalesce(model.title_hu, model.title) if lang == "hu" else model.title
|
||||||
|
|
||||||
|
|
||||||
|
def summary_col(model, lang: str):
|
||||||
|
"""DISPLAY summary, HU-coalesced (EN fallback) — the card hover-overview + info page read this."""
|
||||||
|
return func.coalesce(model.summary_hu, model.summary) if lang == "hu" else model.summary
|
||||||
|
|
||||||
|
|
||||||
def genres_col(model, lang: str):
|
def genres_col(model, lang: str):
|
||||||
# type_coerce so the JSONB comparator (`.contains` / `@>`, GIN-backed) survives the coalesce.
|
# type_coerce so the JSONB comparator (`.contains` / `@>`, GIN-backed) survives the coalesce.
|
||||||
if lang == "hu":
|
if lang == "hu":
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from app.plex.query import (
|
|||||||
movie_status_case,
|
movie_status_case,
|
||||||
show_status_case,
|
show_status_case,
|
||||||
split_csv,
|
split_csv,
|
||||||
|
summary_col,
|
||||||
title_col,
|
title_col,
|
||||||
vector_col,
|
vector_col,
|
||||||
vector_match,
|
vector_match,
|
||||||
@@ -130,6 +131,8 @@ def unified_library(
|
|||||||
title_col(PlexItem, lang).label("title"),
|
title_col(PlexItem, lang).label("title"),
|
||||||
PlexItem.year.label("year"),
|
PlexItem.year.label("year"),
|
||||||
PlexItem.rating.label("rating"),
|
PlexItem.rating.label("rating"),
|
||||||
|
# Truncated so the card hover-overview costs a few hundred bytes, not the full synopsis.
|
||||||
|
func.left(summary_col(PlexItem, lang), 300).label("summary"),
|
||||||
PlexItem.added_at.label("added_at"),
|
PlexItem.added_at.label("added_at"),
|
||||||
PlexItem.originally_available_at.label("rel"),
|
PlexItem.originally_available_at.label("rel"),
|
||||||
PlexItem.duration_s.label("duration_s"),
|
PlexItem.duration_s.label("duration_s"),
|
||||||
@@ -170,6 +173,7 @@ def unified_library(
|
|||||||
title_col(PlexShow, lang).label("title"),
|
title_col(PlexShow, lang).label("title"),
|
||||||
PlexShow.year.label("year"),
|
PlexShow.year.label("year"),
|
||||||
PlexShow.rating.label("rating"),
|
PlexShow.rating.label("rating"),
|
||||||
|
func.left(summary_col(PlexShow, lang), 300).label("summary"),
|
||||||
PlexShow.added_at.label("added_at"),
|
PlexShow.added_at.label("added_at"),
|
||||||
PlexShow.originally_available_at.label("rel"),
|
PlexShow.originally_available_at.label("rel"),
|
||||||
cast(null(), Integer).label("duration_s"),
|
cast(null(), Integer).label("duration_s"),
|
||||||
@@ -227,6 +231,8 @@ def unified_library(
|
|||||||
"type": "movie",
|
"type": "movie",
|
||||||
"title": r.title,
|
"title": r.title,
|
||||||
"year": r.year,
|
"year": r.year,
|
||||||
|
"rating": r.rating,
|
||||||
|
"summary": r.summary,
|
||||||
"duration_seconds": r.duration_s,
|
"duration_seconds": r.duration_s,
|
||||||
"thumb": f"/api/plex/image/{r.rk}",
|
"thumb": f"/api/plex/image/{r.rk}",
|
||||||
"playable": r.playable,
|
"playable": r.playable,
|
||||||
@@ -241,6 +247,8 @@ def unified_library(
|
|||||||
"type": "show",
|
"type": "show",
|
||||||
"title": r.title,
|
"title": r.title,
|
||||||
"year": r.year,
|
"year": r.year,
|
||||||
|
"rating": r.rating,
|
||||||
|
"summary": r.summary,
|
||||||
"thumb": f"/api/plex/image/{r.rk}",
|
"thumb": f"/api/plex/image/{r.rk}",
|
||||||
"season_count": r.season_count,
|
"season_count": r.season_count,
|
||||||
"status": r.status,
|
"status": r.status,
|
||||||
|
|||||||
@@ -61,6 +61,26 @@ describe("PlexPosterCard a11y", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByRole("button", { name: "plex.markUnwatched" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "plex.markUnwatched" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reveals the synopsis + rating on the hover scrim when the card carries them", () => {
|
||||||
|
render(
|
||||||
|
<PlexPosterCard
|
||||||
|
card={{ ...movie(), year: 2023, rating: 7.5, summary: "A quiet synopsis." }}
|
||||||
|
onOpen={vi.fn()}
|
||||||
|
onInfo={vi.fn()}
|
||||||
|
onToggleWatched={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("A quiet synopsis.")).toBeTruthy();
|
||||||
|
expect(screen.getByText("7.5")).toBeTruthy(); // rating.toFixed(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the scrim when the card has no synopsis or rating", () => {
|
||||||
|
render(
|
||||||
|
<PlexPosterCard card={movie()} onOpen={vi.fn()} onInfo={vi.fn()} onToggleWatched={vi.fn()} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/synopsis/i)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SeasonCard a11y", () => {
|
describe("SeasonCard a11y", () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Tv2 } from "lucide-react";
|
import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Star, Tv2 } from "lucide-react";
|
||||||
import { type CSSProperties } from "react";
|
import { type CSSProperties } from "react";
|
||||||
import { formatRuntime } from "../lib/format";
|
import { formatRuntime } from "../lib/format";
|
||||||
import { Carousel } from "./Carousel";
|
import { Carousel } from "./Carousel";
|
||||||
@@ -52,6 +52,7 @@ export function PlexPosterCard({
|
|||||||
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
|
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
|
||||||
const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down
|
const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down
|
||||||
const watchedLabel = card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched");
|
const watchedLabel = card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched");
|
||||||
|
const runtime = formatRuntime(card.duration_seconds);
|
||||||
const pct =
|
const pct =
|
||||||
inProgress && card.duration_seconds
|
inProgress && card.duration_seconds
|
||||||
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
|
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
|
||||||
@@ -81,50 +82,15 @@ export function PlexPosterCard({
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
{/* Hover affordance: a play overlay on every card (movies/episodes play; a show opens). */}
|
{/* Playable-cost tint (always shown — a quick "will it direct-play" cue). */}
|
||||||
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
{card.playable && (
|
||||||
<div className="flex flex-col items-center gap-1 text-white">
|
<span
|
||||||
<Play className="w-10 h-10" fill="currentColor" />
|
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
|
||||||
<span className="text-xs font-medium">
|
title={t(`plex.playable.${card.playable}`)}
|
||||||
{card.type === "show"
|
/>
|
||||||
? t("plex.openShow")
|
|
||||||
: inProgress
|
|
||||||
? t("plex.resume")
|
|
||||||
: t("plex.play")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Quick watched toggle (movies/episodes), on hover. */}
|
|
||||||
{isPlayable && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggleWatched(card);
|
|
||||||
}}
|
|
||||||
title={watchedLabel}
|
|
||||||
aria-label={watchedLabel}
|
|
||||||
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
|
||||||
>
|
|
||||||
<WatchedIcon watched={card.status === "watched"} />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
{/* Media info page (movies/episodes). */}
|
{/* Rest-state watched badge (top-right). Yields to the hover quick-actions on a playable card
|
||||||
{isPlayable && (
|
(reveal-idle: gone on touch where the toggle is always shown); a watched show keeps it. */}
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onInfo();
|
|
||||||
}}
|
|
||||||
title={t("plex.info.title")}
|
|
||||||
aria-label={t("plex.info.title")}
|
|
||||||
className="absolute bottom-1.5 left-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
|
||||||
>
|
|
||||||
<Info className="w-4 h-4 text-white" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Watched badge when not hovering (the toggle replaces it on hover). Only a playable card
|
|
||||||
has that toggle, so only there does the badge yield to it (reveal-idle: gone on touch,
|
|
||||||
where the toggle is always shown); a watched show keeps the badge as its sole marker. */}
|
|
||||||
{card.status === "watched" && (
|
{card.status === "watched" && (
|
||||||
<span
|
<span
|
||||||
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${swapBadgeReveal(
|
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${swapBadgeReveal(
|
||||||
@@ -134,34 +100,99 @@ export function PlexPosterCard({
|
|||||||
{t("plex.watched")}
|
{t("plex.watched")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{card.type === "show" && card.season_count != null && (
|
|
||||||
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
|
||||||
{t("plex.seasons", { count: card.season_count })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{/* Aggregate in-progress badge for a partially-watched show. */}
|
{/* Aggregate in-progress badge for a partially-watched show. */}
|
||||||
{card.type === "show" && card.status === "in_progress" && (
|
{card.type === "show" && card.status === "in_progress" && (
|
||||||
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
|
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
|
||||||
{t("plex.inProgress")}
|
{t("plex.inProgress")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{card.playable && (
|
{/* Rest-state bottom badges — hidden on hover, where the scrim takes over the bottom. */}
|
||||||
<span
|
{card.type === "show" && card.season_count != null && (
|
||||||
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
|
<span className="pointer-events-none absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm transition-opacity group-hover:opacity-0">
|
||||||
title={t(`plex.playable.${card.playable}`)}
|
{t("plex.seasons", { count: card.season_count })}
|
||||||
/>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* Type tag — tell a movie card from a show card at a glance (unified feed). */}
|
|
||||||
<span
|
<span
|
||||||
className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/65 p-0.5 text-white/90"
|
className="pointer-events-none absolute bottom-1.5 right-1.5 rounded-sm bg-black/65 p-0.5 text-white/90 transition-opacity group-hover:opacity-0"
|
||||||
title={t(
|
title={t(
|
||||||
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
|
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{card.type === "show" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
|
{card.type === "show" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* Hover reveal: a soft dim, a less-flat play ring, and a bottom scrim that slides up with the
|
||||||
|
synopsis + rating (the info you'd otherwise click through for). */}
|
||||||
|
<div className="absolute inset-0 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||||
|
<div className="absolute inset-0 bg-black/25" />
|
||||||
|
<div className="absolute inset-x-0 top-[30%] flex justify-center">
|
||||||
|
<div
|
||||||
|
className="grid h-12 w-12 scale-90 place-items-center rounded-full text-white shadow-xl transition-transform duration-200 group-hover:scale-100"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle at 38% 32%, color-mix(in srgb, var(--accent) 72%, white), var(--accent))",
|
||||||
|
boxShadow:
|
||||||
|
"0 10px 22px -6px color-mix(in srgb, var(--accent) 60%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="w-5 h-5 translate-x-px fill-current" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(card.summary || card.rating != null) && (
|
||||||
|
<div className="absolute inset-x-0 bottom-0 translate-y-1.5 bg-gradient-to-t from-black/95 via-black/85 to-transparent p-2.5 transition-transform duration-200 group-hover:translate-y-0">
|
||||||
|
<div className="line-clamp-1 text-xs font-semibold leading-snug text-white">
|
||||||
|
{card.title}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1.5 text-2xs text-white/75">
|
||||||
|
{(card.year || runtime) && (
|
||||||
|
<span>{[card.year, runtime].filter(Boolean).join(" · ")}</span>
|
||||||
|
)}
|
||||||
|
{card.rating != null && (
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-amber-300">
|
||||||
|
<Star className="w-3 h-3 fill-current" />
|
||||||
|
{card.rating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{card.summary && (
|
||||||
|
<p className="mt-1 line-clamp-3 text-2xs leading-snug text-white/85">
|
||||||
|
{card.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick actions (playable cards): Info + watched toggle — revealed on hover, always on touch. */}
|
||||||
|
{isPlayable && (
|
||||||
|
<div className="reveal absolute right-1.5 top-1.5 z-10 flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onInfo();
|
||||||
|
}}
|
||||||
|
title={t("plex.info.title")}
|
||||||
|
aria-label={t("plex.info.title")}
|
||||||
|
className="rounded-full bg-black/60 p-1 transition hover:bg-black/80"
|
||||||
|
>
|
||||||
|
<Info className="h-4 w-4 text-white" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleWatched(card);
|
||||||
|
}}
|
||||||
|
title={watchedLabel}
|
||||||
|
aria-label={watchedLabel}
|
||||||
|
className="rounded-full bg-black/60 p-1 transition hover:bg-black/80"
|
||||||
|
>
|
||||||
|
<WatchedIcon watched={card.status === "watched"} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{pct > 0 && (
|
{pct > 0 && (
|
||||||
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
<div className="absolute inset-x-0 bottom-0 z-10 h-1 bg-black/40">
|
||||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface PlexCard {
|
|||||||
duration_seconds?: number | null;
|
duration_seconds?: number | null;
|
||||||
thumb: string;
|
thumb: string;
|
||||||
art?: string | null; // wide backdrop (movies/shows) for rails + crossfading hero; null when no art_key
|
art?: string | null; // wide backdrop (movies/shows) for rails + crossfading hero; null when no art_key
|
||||||
|
rating?: number | null; // Plex audience score (~IMDb), shown on the card hover-overview
|
||||||
playable?: string; // direct | remux | transcode
|
playable?: string; // direct | remux | transcode
|
||||||
status?: string; // new | watched | hidden
|
status?: string; // new | watched | hidden
|
||||||
position_seconds?: number;
|
position_seconds?: number;
|
||||||
@@ -51,7 +52,7 @@ export interface PlexCard {
|
|||||||
episode_number?: number | null; // episode
|
episode_number?: number | null; // episode
|
||||||
show_title?: string | null; // episode (in a mixed playlist)
|
show_title?: string | null; // episode (in a mixed playlist)
|
||||||
show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season)
|
show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season)
|
||||||
summary?: string | null; // episode
|
summary?: string | null; // movie/show hover-overview synopsis (trimmed) + episode summary
|
||||||
}
|
}
|
||||||
// Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that
|
// Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that
|
||||||
// also matches episodes (the grouped "Episodes" section).
|
// also matches episodes (the grouped "Episodes" section).
|
||||||
|
|||||||
Reference in New Issue
Block a user