feat(frontend): rebuild as Ambient/WebGL console with all pages + command palette
Ground-up rombak UI: hapus semua component/page lama, bangun ulang dengan desain sistem Ambient (WebGL haze + drifting motes, signal-driven color) di atas kontrak API/WS/type yang sudah ada. - Design system: globals.css tokens + primitives (glass, button, badge, select, avatar, toast, chart SVG murni). - Shell: nav rail, topbar (status WS + pill signal + theme), AppFrame. - 8 halaman: dashboard, voice (orbital stage), media, messages (live feed + detail AI), moderation, analysis (search), recordings, + chatbot floating. - Command palette (Cmd/Ctrl+K) untuk navigasi cepat. - Server fetch di-page di-try/catch agar render graceful saat backend mati. Verified: tsc clean, next build 8/8 halaman, semua route 200.
This commit is contained in:
1 parent
b98101c576
commit
1b56212d1a
104 files changed
+2905
-7048
No files matched your search
@@ -1,83 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useId } from "react";
|
||||
|
||||
export interface AreaPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AreaActivityProps {
|
||||
data: AreaPoint[];
|
||||
height?: number;
|
||||
stroke?: string;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
import type { DailyActivityPoint } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Dual-area activity chart: total messages (signal) vs flagged (vermilion).
|
||||
* Pure SVG, scales to container. Includes a 7-day trailing window hint.
|
||||
*/
|
||||
export function AreaActivity({
|
||||
data,
|
||||
height = 160,
|
||||
stroke = "var(--color-signal)",
|
||||
className,
|
||||
label,
|
||||
}: AreaActivityProps) {
|
||||
const id = useId().replace(/:/g, "");
|
||||
const reduce = useReducedMotion();
|
||||
const width = 600;
|
||||
if (data.length === 0)
|
||||
return <div className={className} style={{ height }} />;
|
||||
daily,
|
||||
height = 200,
|
||||
}: {
|
||||
daily: DailyActivityPoint[];
|
||||
height?: number;
|
||||
}) {
|
||||
const w = 720;
|
||||
const pad = 8;
|
||||
const n = daily.length;
|
||||
const max = Math.max(...daily.map((d) => d.messages), 1);
|
||||
const x = (i: number) => pad + (i / Math.max(n - 1, 1)) * (w - pad * 2);
|
||||
const y = (v: number) => height - pad - (v / max) * (height - pad * 2);
|
||||
|
||||
const max = Math.max(...data.map((d) => d.value), 1);
|
||||
const stepX = width / Math.max(data.length - 1, 1);
|
||||
const pts = data.map((d, i) => {
|
||||
const x = i * stepX;
|
||||
const y = height - (d.value / max) * (height - 10) - 5;
|
||||
return [x, y] as const;
|
||||
});
|
||||
const line = pts
|
||||
.map(
|
||||
(p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`,
|
||||
)
|
||||
.join(" ");
|
||||
const area = `${line} L${width},${height} L0,${height} Z`;
|
||||
const pathLen = 1400;
|
||||
const msgLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.messages).toFixed(1)}`).join(" ");
|
||||
const flagLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.flagged).toFixed(1)}`).join(" ");
|
||||
const msgArea = `${msgLine} L${x(n - 1).toFixed(1)},${height - pad} L${x(0).toFixed(1)},${height - pad} Z`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={className}
|
||||
style={{ width: "100%", height }}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={label ?? "Activity chart"}
|
||||
>
|
||||
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className="w-full" style={{ height }}>
|
||||
<defs>
|
||||
<linearGradient id={`area-${id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.32" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0.02" />
|
||||
<linearGradient id="area-msg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-signal)" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="var(--color-signal)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<motion.path
|
||||
d={area}
|
||||
fill={`url(#area-${id})`}
|
||||
initial={reduce ? false : { pathLength: 0, opacity: 0.4 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1] }}
|
||||
/>
|
||||
<motion.path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
initial={reduce ? false : { pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ strokeDasharray: pathLen }}
|
||||
/>
|
||||
{[0.25, 0.5, 0.75].map((g) => (
|
||||
<line key={g} x1={pad} x2={w - pad} y1={height * g} y2={height * g} stroke="var(--color-hairline)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
<path d={msgArea} fill="url(#area-msg)" />
|
||||
<path d={msgLine} fill="none" stroke="var(--color-signal)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||
<path d={flagLine} fill="none" stroke="var(--color-vermilion)" strokeWidth={1.5} vectorEffect="non-scaling-stroke" strokeDasharray="3 3" />
|
||||
{daily.map((d, i) =>
|
||||
i % 2 === 0 ? (
|
||||
<text key={d.day} x={x(i)} y={height - 1} fill="var(--color-ink-faint)" fontSize={9} textAnchor="middle" className="mono">
|
||||
{d.day.slice(5)}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Stacked donut for moderation overview / composition. */
|
||||
export function Donut({
|
||||
segments,
|
||||
size = 132,
|
||||
thickness = 14,
|
||||
centerLabel,
|
||||
centerSub,
|
||||
}: {
|
||||
segments: { value: number; color: string; label: string }[];
|
||||
size?: number;
|
||||
thickness?: number;
|
||||
centerLabel?: string;
|
||||
centerSub?: string;
|
||||
}) {
|
||||
const total = segments.reduce((s, x) => s + x.value, 0) || 1;
|
||||
const r = size / 2 - thickness / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
let offset = 0;
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-hairline)" strokeWidth={thickness} />
|
||||
{segments.map((s, i) => {
|
||||
const len = (s.value / total) * c;
|
||||
const el = (
|
||||
<circle
|
||||
key={i}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth={thickness}
|
||||
strokeDasharray={`${len} ${c - len}`}
|
||||
strokeDashoffset={-offset}
|
||||
style={{ transition: "stroke-dashoffset 0.6s ease" }}
|
||||
/>
|
||||
);
|
||||
offset += len;
|
||||
return el;
|
||||
})}
|
||||
</svg>
|
||||
{(centerLabel || centerSub) && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{centerLabel && <span className="display text-lg">{centerLabel}</span>}
|
||||
{centerSub && <span className="mono text-[0.6rem] text-ink-faint">{centerSub}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { Sparkline } from "./sparkline";
|
||||
export { AreaActivity } from "./area-activity";
|
||||
export { RadialGauge } from "./radial-gauge";
|
||||
export { Donut } from "./donut";
|
||||
export { Equalizer } from "./waveform";
|
||||
@@ -1,90 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useId } from "react";
|
||||
|
||||
export interface RadialGaugeProps {
|
||||
/** 0..1 health ratio */
|
||||
value: number;
|
||||
size?: number;
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
}
|
||||
|
||||
const toneColor = {
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
};
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Circular progress gauge. value 0..1. */
|
||||
export function RadialGauge({
|
||||
value,
|
||||
size = 160,
|
||||
label,
|
||||
sublabel,
|
||||
tone = "signal",
|
||||
}: RadialGaugeProps) {
|
||||
const id = useId().replace(/:/g, "");
|
||||
const reduce = useReducedMotion();
|
||||
const stroke = 12;
|
||||
const r = (size - stroke) / 2;
|
||||
size = 120,
|
||||
}: {
|
||||
value: number;
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
size?: number;
|
||||
}) {
|
||||
const v = Math.max(0, Math.min(1, value));
|
||||
const stroke = tone === "vermilion" ? "var(--color-vermilion)" : tone === "amber" ? "var(--color-amber)" : "var(--color-signal)";
|
||||
const r = size / 2 - 10;
|
||||
const c = 2 * Math.PI * r;
|
||||
const pct = Math.max(0, Math.min(1, value));
|
||||
const dash = c * pct;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative inline-flex items-center justify-center"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="-rotate-90"
|
||||
role="img"
|
||||
aria-label={`${Math.round(pct * 100)}% ${label ?? "gauge"}`}
|
||||
>
|
||||
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-hairline)" strokeWidth={8} />
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="var(--color-hairline)"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<motion.circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={toneColor[tone]}
|
||||
strokeWidth={stroke}
|
||||
stroke={stroke}
|
||||
strokeWidth={8}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={c}
|
||||
initial={reduce ? false : { strokeDashoffset: c }}
|
||||
animate={{ strokeDashoffset: c - dash }}
|
||||
transition={{ duration: 1, ease: [0.22, 1, 0.36, 1] }}
|
||||
strokeDashoffset={c * (1 - v)}
|
||||
style={{ transition: "stroke-dashoffset 0.6s ease", filter: `drop-shadow(0 0 6px ${stroke})` }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||
<span
|
||||
className="display text-2xl mono"
|
||||
style={{ color: toneColor[tone] }}
|
||||
>
|
||||
{Math.round(pct * 100)}%
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={cn("display text-xl", tone === "vermilion" && "text-vermilion", tone === "amber" && "text-amber", tone === "signal" && "text-signal")}>
|
||||
{label}
|
||||
</span>
|
||||
{label && (
|
||||
<span className="text-[11px] font-medium text-[var(--color-ink-soft)] uppercase tracking-wide">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{sublabel && (
|
||||
<span className="text-[10px] text-[var(--color-ink-soft)]/70">
|
||||
{sublabel}
|
||||
</span>
|
||||
)}
|
||||
{sublabel && <span className="mono text-[0.6rem] text-ink-faint">{sublabel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface RibbonSegment {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number; // relative duration
|
||||
tone?: "signal" | "amber" | "vermilion" | "neutral";
|
||||
}
|
||||
|
||||
const toneClass = {
|
||||
signal: "bg-[var(--color-signal)]",
|
||||
amber: "bg-[var(--color-amber)]",
|
||||
vermilion: "bg-[var(--color-vermilion)]",
|
||||
neutral: "bg-[var(--color-ink-soft)]/40",
|
||||
};
|
||||
|
||||
export interface SessionRibbonProps {
|
||||
segments: RibbonSegment[];
|
||||
className?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function SessionRibbon({
|
||||
segments,
|
||||
className,
|
||||
height = 28,
|
||||
}: SessionRibbonProps) {
|
||||
const total = segments.reduce((s, x) => s + x.value, 0) || 1;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full gap-0.5 overflow-hidden rounded-[var(--radius-r-control)]",
|
||||
className,
|
||||
)}
|
||||
style={{ height }}
|
||||
>
|
||||
{segments.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={cn(
|
||||
"group relative flex items-center justify-center rounded-sm transition-all",
|
||||
toneClass[s.tone ?? "signal"],
|
||||
)}
|
||||
style={{ width: `${(s.value / total) * 100}%` }}
|
||||
title={`${s.label}: ${s.value}`}
|
||||
>
|
||||
<span className="pointer-events-none absolute inset-x-0 -top-6 hidden whitespace-nowrap rounded bg-[var(--color-ink)] px-1.5 py-0.5 text-[10px] text-[var(--color-canvas)] group-hover:block">
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
export interface SparklineProps {
|
||||
data: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
stroke?: string;
|
||||
className?: string;
|
||||
fill?: boolean;
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Minimal sparkline. Pure SVG, scales to container width. */
|
||||
export function Sparkline({
|
||||
data,
|
||||
width = 120,
|
||||
height = 36,
|
||||
stroke = "var(--color-signal)",
|
||||
values,
|
||||
className,
|
||||
stroke = "var(--color-signal)",
|
||||
fill = true,
|
||||
}: SparklineProps) {
|
||||
const id = useId().replace(/:/g, "");
|
||||
if (data.length < 2)
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="No data"
|
||||
/>
|
||||
);
|
||||
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
height = 40,
|
||||
}: {
|
||||
values: number[];
|
||||
className?: string;
|
||||
stroke?: string;
|
||||
fill?: boolean;
|
||||
height?: number;
|
||||
}) {
|
||||
if (values.length === 0) return null;
|
||||
const w = 100;
|
||||
const max = Math.max(...values, 1);
|
||||
const min = Math.min(...values, 0);
|
||||
const span = max - min || 1;
|
||||
const stepX = width / (data.length - 1);
|
||||
const pts = data.map((v, i) => {
|
||||
const x = i * stepX;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * w;
|
||||
const y = height - ((v - min) / span) * (height - 4) - 2;
|
||||
return [x, y] as const;
|
||||
});
|
||||
const line = pts
|
||||
.map(
|
||||
(p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`,
|
||||
)
|
||||
.join(" ");
|
||||
const area = `${line} L${width},${height} L0,${height} Z`;
|
||||
|
||||
const line = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(" ");
|
||||
const area = `${line} L${w},${height} L0,${height} Z`;
|
||||
const id = `spark-${stroke.replace(/[^a-z0-9]/gi, "")}`;
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={className}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label="Trend sparkline"
|
||||
>
|
||||
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className={cn("w-full", className)} style={{ height }}>
|
||||
<defs>
|
||||
<linearGradient id={`spark-${id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.28" />
|
||||
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{fill && <path d={area} fill={`url(#spark-${id})`} />}
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={1.6}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{fill && <path d={area} fill={`url(#${id})`} />}
|
||||
<path d={line} fill="none" stroke={stroke} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface WaveformProps {
|
||||
seed: string | number;
|
||||
bars?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
}
|
||||
|
||||
// deterministic pseudo-random from seed so the shape is stable per recording
|
||||
function hashSeed(seed: string | number): number {
|
||||
const s = String(seed);
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
export function Waveform({
|
||||
seed,
|
||||
bars = 40,
|
||||
height = 40,
|
||||
/** Live equalizer bars. `bars` are 0..1 levels. */
|
||||
export function Equalizer({
|
||||
bars,
|
||||
color = "var(--color-signal)",
|
||||
className,
|
||||
tone = "signal",
|
||||
}: WaveformProps) {
|
||||
const reduce = useReducedMotion();
|
||||
const values = useMemo(() => {
|
||||
let state = hashSeed(seed) || 1;
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < bars; i++) {
|
||||
state = (Math.imul(state, 1103515245) + 12345) >>> 0;
|
||||
const r = (state % 1000) / 1000;
|
||||
// envelope: louder in the middle, quieter at edges
|
||||
const env = Math.sin((i / (bars - 1)) * Math.PI);
|
||||
out.push(0.18 + r * 0.82 * (0.4 + env * 0.6));
|
||||
}
|
||||
return out;
|
||||
}, [seed, bars]);
|
||||
|
||||
const color = {
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
}[tone];
|
||||
|
||||
}: {
|
||||
bars: number[];
|
||||
color?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-end gap-[2px]", className)}
|
||||
style={{ height }}
|
||||
aria-hidden
|
||||
>
|
||||
{values.map((v, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="flex-1 rounded-[2px]"
|
||||
style={{ background: color, height: `${Math.max(8, v * 100)}%` }}
|
||||
initial={reduce ? false : { scaleY: 0.2, opacity: 0 }}
|
||||
animate={{ scaleY: 1, opacity: 1 }}
|
||||
whileHover={{ scaleY: 1.15 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: reduce ? 0 : i * 0.006,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className={cn("flex h-10 items-end gap-[3px]", className)}>
|
||||
{bars.length === 0 ? (
|
||||
<div className="flex w-full items-end gap-[3px]">
|
||||
{Array.from({ length: 28 }).map((_, i) => (
|
||||
<span key={i} className="flex-1 rounded-full bg-white/10" style={{ height: "12%" }} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
bars.map((b, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="flex-1 rounded-full"
|
||||
style={{
|
||||
height: `${Math.max(6, b * 100)}%`,
|
||||
background: color,
|
||||
boxShadow: b > 0.05 ? `0 0 8px ${color}` : "none",
|
||||
transition: "height 90ms linear",
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user