style(frontend): refactor UI components with sky color palette, improved spacing, and design polish
This commit is contained in:
@@ -38,13 +38,13 @@ export function Heatmap({ cells, loading }: HeatmapProps) {
|
||||
}
|
||||
|
||||
function getHeatClass(intensity: number): string {
|
||||
if (intensity === 0) return "bg-muted/30";
|
||||
if (intensity < 0.1) return "bg-blue-500/10";
|
||||
if (intensity < 0.2) return "bg-blue-500/20";
|
||||
if (intensity < 0.35) return "bg-blue-500/30";
|
||||
if (intensity < 0.5) return "bg-blue-500/45";
|
||||
if (intensity < 0.7) return "bg-blue-500/60";
|
||||
return "bg-blue-500/80";
|
||||
if (intensity === 0) return "bg-sky-50";
|
||||
if (intensity < 0.1) return "bg-sky-200/30";
|
||||
if (intensity < 0.2) return "bg-sky-300/30";
|
||||
if (intensity < 0.35) return "bg-sky-400/35";
|
||||
if (intensity < 0.5) return "bg-sky-400/55";
|
||||
if (intensity < 0.7) return "bg-primary/60";
|
||||
return "bg-primary/80";
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -54,14 +54,14 @@ export function Heatmap({ cells, loading }: HeatmapProps) {
|
||||
Heatmap Aktivitas
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Hari × jam — area biru = lebih ramai.
|
||||
Hari × jam — area biru langit = lebih ramai.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[520px]">
|
||||
{/* Header row */}
|
||||
<div className="mb-1 ml-8 flex gap-[2px]">
|
||||
<div className="mb-1 ml-8 flex gap-[3px]">
|
||||
{Array.from({ length: 24 }, (_, h) => (
|
||||
<div
|
||||
key={h}
|
||||
@@ -73,7 +73,7 @@ export function Heatmap({ cells, loading }: HeatmapProps) {
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{DAYS.map((day, d) => (
|
||||
<div key={d} className="mb-[2px] flex items-center gap-[2px]">
|
||||
<div key={d} className="mb-[3px] flex items-center gap-[3px]">
|
||||
<div className="w-8 shrink-0 text-right pr-1 text-[10px] text-muted-foreground">
|
||||
{day}
|
||||
</div>
|
||||
@@ -84,7 +84,7 @@ export function Heatmap({ cells, loading }: HeatmapProps) {
|
||||
<div
|
||||
key={h}
|
||||
className={cn(
|
||||
"flex-1 rounded-sm aspect-square",
|
||||
"flex-1 rounded-md aspect-square border border-sky-100/50",
|
||||
getHeatClass(intensity),
|
||||
)}
|
||||
title={`${day} ${h}:00 — ${cell?.count ?? 0} pesan`}
|
||||
@@ -98,10 +98,10 @@ export function Heatmap({ cells, loading }: HeatmapProps) {
|
||||
{/* Legend */}
|
||||
<div className="mt-3 flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<span>Sepi</span>
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-muted/30" />
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/20" />
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/45" />
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/80" />
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-sky-50" />
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-sky-300/30" />
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-sky-400/55" />
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-primary/80" />
|
||||
<span>Ramai</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function TrendChart({ trend, loading }: TrendChartProps) {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">Tren Harian</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Volume pesan per hari dengan status moderasi.
|
||||
Volume pesan per hari dengan status moderasi — area pink = flagged.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -48,7 +48,7 @@ export function TrendChart({ trend, loading }: TrendChartProps) {
|
||||
<LegendDot color="bg-accent" label="Flagged" />
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-border bg-background/50 p-4">
|
||||
<div className="overflow-hidden rounded-2xl border border-sky-100 bg-white/60 p-4">
|
||||
<div className="mb-3 flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>Rangkuman 7 hari terakhir</span>
|
||||
<span>{totalMessages} total pesan</span>
|
||||
@@ -61,16 +61,24 @@ export function TrendChart({ trend, loading }: TrendChartProps) {
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="trendFill" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.35" />
|
||||
<stop offset="0%" stopColor="#7EC8E3" stopOpacity="0.35" />
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="#3b82f6"
|
||||
stopColor="#7EC8E3"
|
||||
stopOpacity="0.02"
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="trendFillPink" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#FF9EBB" stopOpacity="0.3" />
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="#FF9EBB"
|
||||
stopOpacity="0.02"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g stroke="#334155" strokeWidth="1" opacity="0.35">
|
||||
<g stroke="#cbd5e1" strokeWidth="1" opacity="0.35">
|
||||
{Array.from({ length: 4 }, (_, index) => {
|
||||
const y = 40 + index * 45;
|
||||
return (
|
||||
@@ -89,32 +97,32 @@ export function TrendChart({ trend, loading }: TrendChartProps) {
|
||||
data={data}
|
||||
keyName="total"
|
||||
fill="url(#trendFill)"
|
||||
stroke="#3b82f6"
|
||||
stroke="#7EC8E3"
|
||||
/>
|
||||
<TrendLine
|
||||
data={data}
|
||||
keyName="total"
|
||||
color="#3b82f6"
|
||||
color="#7EC8E3"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<TrendLine
|
||||
data={data}
|
||||
keyName="clean"
|
||||
color="#10b981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
<TrendLine
|
||||
data={data}
|
||||
keyName="warned"
|
||||
color="#f59e0b"
|
||||
color="#34d399"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
<TrendLine
|
||||
data={data}
|
||||
keyName="flagged"
|
||||
color="#ef4444"
|
||||
color="#FF9EBB"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
<TrendArea
|
||||
data={data}
|
||||
keyName="flagged"
|
||||
fill="url(#trendFillPink)"
|
||||
stroke="#FF9EBB"
|
||||
/>
|
||||
|
||||
{data.map((item, index) => {
|
||||
const x =
|
||||
@@ -124,7 +132,7 @@ export function TrendChart({ trend, loading }: TrendChartProps) {
|
||||
Math.max((data.length - 1) * 56, 56);
|
||||
return (
|
||||
<g key={item.date} transform={`translate(${x}, 188)`}>
|
||||
<circle cx="0" cy="0" r="2.5" fill="#e2e8f0" />
|
||||
<circle cx="0" cy="0" r="2.5" fill="#7EC8E3" />
|
||||
<text
|
||||
x="0"
|
||||
y="18"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Users } from "lucide-react";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import type { UserStat } from "../../../shared/api/client";
|
||||
import {
|
||||
Badge,
|
||||
@@ -37,7 +38,7 @@ export function UserTable({ users, loading }: UserTableProps) {
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Users className="h-4 w-4 text-violet-400" />
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
User Paling Aktif
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
@@ -48,7 +49,7 @@ export function UserTable({ users, loading }: UserTableProps) {
|
||||
<ScrollArea className="max-h-[260px]">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="sticky top-0 z-10 bg-card/95 backdrop-blur border-b border-border text-left text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<tr className="sticky top-0 z-10 bg-white border-b border-sky-100 text-left text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="py-2 pl-4 pr-2 font-semibold">#</th>
|
||||
<th className="py-2 pr-2 font-semibold">User</th>
|
||||
<th className="py-2 pr-2 font-semibold text-right">Pesan</th>
|
||||
@@ -56,11 +57,14 @@ export function UserTable({ users, loading }: UserTableProps) {
|
||||
<th className="py-2 pr-4 font-semibold text-right">Flag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/20">
|
||||
<tbody className="divide-y divide-sky-50">
|
||||
{users.map((user, i) => (
|
||||
<tr
|
||||
key={user.user_id}
|
||||
className="hover:bg-muted/20 transition-colors"
|
||||
className={cn(
|
||||
"transition-colors rounded-xl",
|
||||
i % 2 === 0 ? "bg-white" : "bg-sky-50/20",
|
||||
)}
|
||||
>
|
||||
<td className="py-1.5 pl-4 pr-2 font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{medals[i] ?? i + 1}
|
||||
@@ -71,11 +75,11 @@ export function UserTable({ users, loading }: UserTableProps) {
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt=""
|
||||
className="h-6 w-6 rounded-full"
|
||||
className="h-6 w-6 rounded-full ring-2 ring-sky-100"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-[10px] font-bold">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-sky-100 text-[10px] font-bold text-primary">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
@@ -86,9 +90,9 @@ export function UserTable({ users, loading }: UserTableProps) {
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<div className="h-1 w-8 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-sky-50">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500/60"
|
||||
className="h-full rounded-full bg-gradient-to-r from-primary to-pink-300"
|
||||
style={{
|
||||
width: `${(user.message_count / maxMsgs) * 100}%`,
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Siren } from "lucide-react";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import type { ViolatorStat } from "../../../shared/api/client";
|
||||
import {
|
||||
Badge,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ActiveSpeaker } from "../../../shared/api/client";
|
||||
import { Skeleton } from "../../../shared/ui";
|
||||
import { EmptyStateMascot } from "../../../widgets/mascot/ChibiMascot";
|
||||
import { EmptyStateMascot } from "../../../shared/ui";
|
||||
|
||||
interface ActiveSpeakersProps {
|
||||
speakers: ActiveSpeaker[];
|
||||
|
||||
@@ -43,7 +43,7 @@ export function MusicSubPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-sky-200 bg-white p-4 shadow-md space-y-4">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
@@ -51,7 +51,7 @@ export function MusicSubPanel({
|
||||
placeholder="YouTube URL, Spotify track, or search terms"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Volume2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Volume2 className="h-4 w-4 shrink-0 text-[#7EC8E3]" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
@@ -59,14 +59,14 @@ export function MusicSubPanel({
|
||||
step={1}
|
||||
value={draftVolume}
|
||||
onChange={(e) => setDraftVolume(Number(e.target.value))}
|
||||
className="h-2 w-full cursor-pointer accent-primary"
|
||||
className="h-2 w-full cursor-pointer accent-[#7EC8E3]"
|
||||
/>
|
||||
<span className="w-10 shrink-0 text-right text-sm tabular-nums text-muted-foreground">
|
||||
{draftVolume}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={loading || !source.trim()} onClick={submit}>
|
||||
<Button className="bg-[#7EC8E3] text-white hover:bg-[#7EC8E3]/80" disabled={loading || !source.trim()} onClick={submit}>
|
||||
<Music2 className="mr-1.5 h-4 w-4" /> Queue
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={loading} onClick={onSkip}>
|
||||
|
||||
@@ -11,10 +11,10 @@ export function NowPlaying({ current, queue }: NowPlayingProps) {
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-card shadow-sm">
|
||||
<div className="rounded-2xl border border-sky-200 bg-white shadow-md">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#7EC8E3]/15 text-[#7EC8E3]">
|
||||
{current.mode === "screen" ? (
|
||||
<MonitorUp className="h-5 w-5" />
|
||||
) : (
|
||||
@@ -34,15 +34,15 @@ export function NowPlaying({ current, queue }: NowPlayingProps) {
|
||||
</div>
|
||||
|
||||
{queue.length > 0 && (
|
||||
<div className="border-t border-border p-4">
|
||||
<div className="border-t border-sky-100 p-4">
|
||||
<div className="mb-2 text-sm font-medium">Queue ({queue.length})</div>
|
||||
<div className="space-y-1.5">
|
||||
{queue.map((item, i) => (
|
||||
<div
|
||||
key={`${item.source}-${i}`}
|
||||
className="flex items-center gap-3 rounded-lg border border-border bg-background/60 p-2.5 text-sm"
|
||||
className="flex items-center gap-3 rounded-lg border-l-2 border-l-[#7EC8E3] border border-sky-200 bg-white p-2.5 text-sm"
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[#7EC8E3]/15 text-xs font-medium text-[#7EC8E3]">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Download, Mic } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge, Button, Skeleton } from "../../../shared/ui";
|
||||
import { Badge, Button, EmptyStateMascot, Skeleton } from "../../../shared/ui";
|
||||
import { formatBytes, formatDate } from "../../../shared/lib/utils";
|
||||
|
||||
interface VoiceRecording {
|
||||
@@ -61,7 +61,7 @@ export function RecordingsSubPanel() {
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-4 rounded-xl border border-border bg-background/60 p-4"
|
||||
className="flex items-center gap-4 rounded-xl border border-sky-200 bg-white p-4"
|
||||
>
|
||||
<Skeleton className="h-10 w-10 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
@@ -93,9 +93,7 @@ export function RecordingsSubPanel() {
|
||||
|
||||
if (recordings.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
No recordings found.
|
||||
</div>
|
||||
<EmptyStateMascot variant="sleeping" message="No recordings yet~" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,9 +102,9 @@ export function RecordingsSubPanel() {
|
||||
{recordings.map((rec) => (
|
||||
<div
|
||||
key={rec.id}
|
||||
className="flex items-center gap-4 rounded-xl border border-border bg-background/60 p-4"
|
||||
className="flex items-center gap-4 rounded-xl border border-sky-200 bg-white p-4"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#7EC8E3]/15 text-[#7EC8E3]">
|
||||
<Mic className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -143,7 +141,7 @@ export function RecordingsSubPanel() {
|
||||
href={rec.download_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
className="rounded-lg bg-[#7EC8E3] px-3 py-1.5 text-sm font-medium text-white hover:bg-[#7EC8E3]/80"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ScreenSubPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-sky-200 bg-white p-4 shadow-md space-y-4">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
@@ -32,7 +32,7 @@ export function ScreenSubPanel({
|
||||
placeholder="Screen share URL or local file path"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={loading || !source.trim()} onClick={submit}>
|
||||
<Button className="bg-[#7EC8E3] text-white hover:bg-[#7EC8E3]/80" disabled={loading || !source.trim()} onClick={submit}>
|
||||
<MonitorUp className="mr-1.5 h-4 w-4" /> Start
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={loading} onClick={onSkip}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MessageRecord } from "../../../shared/api/client";
|
||||
import { parseMetadata } from "../../../entities/message/types";
|
||||
import { EmptyStateMascot } from "../../../widgets/mascot/ChibiMascot";
|
||||
|
||||
interface ImageItem {
|
||||
url: string;
|
||||
@@ -8,6 +9,17 @@ interface ImageItem {
|
||||
message: MessageRecord;
|
||||
}
|
||||
|
||||
function kindBadge(kind: ImageItem["kind"]): string {
|
||||
switch (kind) {
|
||||
case "sticker":
|
||||
return "bg-pink-100 text-pink-700 border-pink-200";
|
||||
case "attachment":
|
||||
return "bg-primary-soft text-primary border-primary/30";
|
||||
case "embed":
|
||||
return "bg-purple-100 text-purple-700 border-purple-200";
|
||||
}
|
||||
}
|
||||
|
||||
export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
|
||||
const images: ImageItem[] = [];
|
||||
|
||||
@@ -57,9 +69,7 @@ export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">
|
||||
No images found.
|
||||
</div>
|
||||
<EmptyStateMascot variant="thinking" message="No images yet~" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,7 +84,7 @@ export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
|
||||
href={image.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-all hover:border-primary/30 hover:shadow-md"
|
||||
className="group overflow-hidden rounded-2xl border border-primary/20 bg-white shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden">
|
||||
{image.kind === "sticker" ? (
|
||||
@@ -92,14 +102,16 @@ export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute right-2 top-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-white backdrop-blur">
|
||||
<span
|
||||
className={`absolute right-2 top-2 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider shadow-sm backdrop-blur ${kindBadge(image.kind)}`}
|
||||
>
|
||||
{image.kind}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="truncate text-sm font-medium">{image.title}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 overflow-hidden rounded-full">
|
||||
<div className="h-4 w-4 overflow-hidden rounded-full ring-1 ring-primary/30">
|
||||
<img
|
||||
src={
|
||||
image.message.avatar_url ??
|
||||
|
||||
@@ -94,13 +94,13 @@ function aiVariant(status: string) {
|
||||
function severityColor(severity: string) {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "bg-red-500/20 text-red-300 border-red-500/30";
|
||||
return "bg-red-100 text-red-700 border-red-200";
|
||||
case "high":
|
||||
return "bg-orange-500/20 text-orange-300 border-orange-500/30";
|
||||
return "bg-orange-100 text-orange-700 border-orange-200";
|
||||
case "medium":
|
||||
return "bg-yellow-500/20 text-yellow-300 border-yellow-500/30";
|
||||
return "bg-yellow-100 text-yellow-700 border-yellow-200";
|
||||
case "low":
|
||||
return "bg-blue-500/20 text-blue-300 border-blue-500/30";
|
||||
return "bg-blue-100 text-blue-700 border-blue-200";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground border-border";
|
||||
}
|
||||
@@ -175,7 +175,13 @@ export function MessageCard({
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`group rounded-2xl border border-border bg-card ${compact ? "px-4 py-1.5" : "p-4"} shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${message.deleted_at ? "opacity-60" : ""}`}
|
||||
className={`group rounded-2xl border bg-white shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
|
||||
compact ? "px-4 py-1.5" : "p-4"
|
||||
} ${
|
||||
message.deleted_at
|
||||
? "border-red-200 opacity-60"
|
||||
: "border-primary/20"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex ${compact ? "gap-2" : "gap-3"}`}>
|
||||
{!compact && (
|
||||
@@ -185,7 +191,7 @@ export function MessageCard({
|
||||
"https://cdn.discordapp.com/embed/avatars/0.png"
|
||||
}
|
||||
alt=""
|
||||
className="h-10 w-10 shrink-0 rounded-full object-cover ring-1 ring-border"
|
||||
className="h-10 w-10 shrink-0 rounded-full object-cover ring-2 ring-primary/30"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
@@ -319,10 +325,10 @@ export function MessageCard({
|
||||
|
||||
{message.ai_analysis ? (
|
||||
<div
|
||||
className={`rounded-xl border-l-2 p-3 ${
|
||||
className={`rounded-xl border-l-4 p-3 ${
|
||||
aiStatus === "flagged"
|
||||
? "border-l-red-500 bg-red-500/5"
|
||||
: "border-l-blue-500 bg-blue-500/5"
|
||||
? "border-l-pink-400 bg-pink-50/50"
|
||||
: "border-l-emerald-400 bg-emerald-50/50"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
@@ -331,9 +337,7 @@ export function MessageCard({
|
||||
className="flex w-full items-center justify-between gap-2 text-left text-xs"
|
||||
>
|
||||
<span className="font-medium text-foreground/80">
|
||||
{aiStatus === "flagged"
|
||||
? "🚨"
|
||||
: "ℹ️"}{" "}
|
||||
{aiStatus === "flagged" ? "🚨 " : "ℹ️ "}
|
||||
{analysisSummary}
|
||||
</span>
|
||||
{showAnalysis ? (
|
||||
@@ -351,7 +355,7 @@ export function MessageCard({
|
||||
) : null}
|
||||
|
||||
{message.ai_error ? (
|
||||
<div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<div className="rounded-xl bg-pink-50/50 p-3 text-sm text-pink-700">
|
||||
AI error: {message.ai_error}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -370,7 +374,7 @@ export function MessageCard({
|
||||
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
|
||||
</Button>
|
||||
{aiStatus === "error" && (
|
||||
<span className="text-xs text-destructive/80">
|
||||
<span className="text-xs text-pink-600/80">
|
||||
Click to retry analysis
|
||||
</span>
|
||||
)}
|
||||
@@ -383,7 +387,7 @@ export function MessageCard({
|
||||
|
||||
export function MessageCardSkeleton() {
|
||||
return (
|
||||
<article className="rounded-2xl border border-border bg-card p-4 shadow-sm">
|
||||
<article className="rounded-2xl border border-primary/20 bg-white p-4 shadow-sm">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="h-10 w-10 shrink-0 rounded-full" />
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { MessageRecord } from "../../../shared/api/client";
|
||||
import {
|
||||
cardStagger,
|
||||
cardItem,
|
||||
} from "../../../shared/hooks/useFramerStagger";
|
||||
import { ScrollArea } from "../../../shared/ui";
|
||||
import { MessageCard, MessageCardSkeleton } from "./MessageCard";
|
||||
import { EmptyStateMascot } from "../../../widgets/mascot/ChibiMascot";
|
||||
|
||||
export interface MessageFeedProps {
|
||||
messages: MessageRecord[];
|
||||
@@ -81,27 +87,29 @@ export function MessageFeed({
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
);
|
||||
return <EmptyStateMascot variant="waving" message="No messages yet~" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
||||
<div className="space-y-3">
|
||||
<motion.div
|
||||
className="space-y-3"
|
||||
variants={cardStagger}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
{groupedMessages.map((group) =>
|
||||
group.messages.map((message, idx) => {
|
||||
const isFirstInGroup = idx === 0;
|
||||
const isCompact = !isFirstInGroup;
|
||||
return (
|
||||
<MessageCard
|
||||
key={message.id}
|
||||
message={message}
|
||||
onReanalyze={onReanalyze}
|
||||
compact={isCompact}
|
||||
/>
|
||||
<motion.div key={message.id} variants={cardItem}>
|
||||
<MessageCard
|
||||
message={message}
|
||||
onReanalyze={onReanalyze}
|
||||
compact={isCompact}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
@@ -115,11 +123,11 @@ export function MessageFeed({
|
||||
{loadingMore ? (
|
||||
<MessageCardSkeleton />
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground/40" />
|
||||
<div className="h-2 w-2 rounded-full bg-primary/40" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,22 @@ interface AlertDetail {
|
||||
brief: string;
|
||||
}
|
||||
|
||||
function severityToToastType(
|
||||
severity: string,
|
||||
): "error" | "warning" | "info" | "success" {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
case "high":
|
||||
return "error";
|
||||
case "medium":
|
||||
return "warning";
|
||||
case "low":
|
||||
return "info";
|
||||
default:
|
||||
return "warning";
|
||||
}
|
||||
}
|
||||
|
||||
export function ModerationAlertListener() {
|
||||
const { addToast } = useToast();
|
||||
|
||||
@@ -29,7 +45,7 @@ export function ModerationAlertListener() {
|
||||
|
||||
addToast(
|
||||
`🚨 ${username} ${sevLabel}${catLabel}: ${brief}`,
|
||||
"error",
|
||||
severityToToastType(severity),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Filter, RotateCw, Search, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import type { MessageRecord } from "../../shared/api/client";
|
||||
import {
|
||||
Badge,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "../../shared/ui";
|
||||
import { cardStagger, cardItem } from "../../shared/hooks/useFramerStagger";
|
||||
import { ImageGrid } from "./components/ImageGrid";
|
||||
import { MessageFeed } from "./components/MessageFeed";
|
||||
|
||||
@@ -94,53 +96,65 @@ export function MessagesPanel({
|
||||
}, [messages, searchResults, showSearch, aiFilter]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Messages</CardTitle>
|
||||
{guildName && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Monitoring all text channels in{" "}
|
||||
<span className="font-medium text-foreground">{guildName}</span>
|
||||
<motion.div
|
||||
className="grid gap-6"
|
||||
variants={cardStagger}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
<motion.div variants={cardItem}>
|
||||
<Card className="border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary">Messages</CardTitle>
|
||||
{guildName && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Monitoring all text channels in{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{guildName}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Messages are automatically captured from all text channels in the
|
||||
monitored guild. Real-time updates arrive via WebSocket.
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Messages are automatically captured from all text channels in the
|
||||
monitored guild. Real-time updates arrive via WebSocket.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{stats.total > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<motion.div
|
||||
variants={cardItem}
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<Badge variant="outline" className="text-xs border-primary/40 text-primary">
|
||||
{stats.total} total{hasMore && !showSearch ? "+" : ""}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-green-400 border-green-400/30"
|
||||
className="text-xs bg-emerald-100 text-emerald-700 border-emerald-200"
|
||||
>
|
||||
{stats.clean} clean
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-red-400 border-red-400/30"
|
||||
className="text-xs bg-pink-100 text-pink-700 border-pink-200"
|
||||
>
|
||||
{stats.flagged} flagged
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-orange-400 border-orange-400/30"
|
||||
className="text-xs bg-orange-100 text-orange-700 border-orange-200"
|
||||
>
|
||||
{stats.error} error
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground border-border">
|
||||
{stats.pending} pending
|
||||
</Badge>
|
||||
{stats.deleted > 0 && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<Badge variant="outline" className="text-xs bg-red-100 text-red-700 border-red-200">
|
||||
{stats.deleted} deleted
|
||||
</Badge>
|
||||
)}
|
||||
@@ -149,14 +163,17 @@ export function MessagesPanel({
|
||||
{stats.edited} edited
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<motion.div
|
||||
variants={cardItem}
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-primary" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
className="pl-9 rounded-full focus-visible:ring-primary"
|
||||
placeholder="Search message content..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
@@ -168,6 +185,7 @@ export function MessagesPanel({
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching || !searchQuery.trim()}
|
||||
size="sm"
|
||||
className="rounded-xl"
|
||||
>
|
||||
{isSearching ? "Searching..." : "Search"}
|
||||
</Button>
|
||||
@@ -199,6 +217,7 @@ export function MessagesPanel({
|
||||
setRetryingAll(false);
|
||||
}
|
||||
}}
|
||||
className="rounded-xl bg-pink-100 text-pink-700 hover:bg-pink-200 border-pink-200"
|
||||
>
|
||||
<RotateCw
|
||||
className={`mr-1.5 h-3.5 w-3.5 ${retryingAll ? "animate-spin" : ""}`}
|
||||
@@ -209,13 +228,13 @@ export function MessagesPanel({
|
||||
</Button>
|
||||
)}
|
||||
{retriedCount !== null && (
|
||||
<span className="text-xs text-green-400">
|
||||
<span className="text-xs text-emerald-600">
|
||||
{retriedCount} message{retriedCount !== 1 ? "s" : ""} queued for
|
||||
re-analysis
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<Filter className="h-4 w-4 text-primary" />
|
||||
{(
|
||||
[
|
||||
"all",
|
||||
@@ -228,51 +247,60 @@ export function MessagesPanel({
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setAiFilter(f)}
|
||||
className={`rounded-md px-2 py-1 text-xs font-medium transition-colors ${aiFilter === f ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
|
||||
aiFilter === f
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-primary-soft"
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{showSearch && searchResults.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<motion.div
|
||||
variants={cardItem}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
Found {searchResults.length} result
|
||||
{searchResults.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={viewTab}
|
||||
onValueChange={(v) => setViewTab(v as "all" | "images")}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">
|
||||
{showSearch
|
||||
? `Search (${filteredMessages.length})`
|
||||
: `All (${filteredMessages.length})`}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="images">Images</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="all">
|
||||
<MessageFeed
|
||||
messages={filteredMessages}
|
||||
onReanalyze={onReanalyze}
|
||||
emptyText={
|
||||
showSearch
|
||||
? "No messages found matching your search."
|
||||
: "No captures yet."
|
||||
}
|
||||
onLoadMore={showSearch ? undefined : onLoadMore}
|
||||
hasMore={showSearch ? false : hasMore}
|
||||
loadingMore={loadingMore}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="images">
|
||||
<ImageGrid messages={filteredMessages} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<motion.div variants={cardItem}>
|
||||
<Tabs
|
||||
value={viewTab}
|
||||
onValueChange={(v) => setViewTab(v as "all" | "images")}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">
|
||||
{showSearch
|
||||
? `Search (${filteredMessages.length})`
|
||||
: `All (${filteredMessages.length})`}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="images">Images</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="all">
|
||||
<MessageFeed
|
||||
messages={filteredMessages}
|
||||
onReanalyze={onReanalyze}
|
||||
emptyText={
|
||||
showSearch
|
||||
? "No messages found matching your search."
|
||||
: "No captures yet."
|
||||
}
|
||||
onLoadMore={showSearch ? undefined : onLoadMore}
|
||||
hasMore={showSearch ? false : hasMore}
|
||||
loadingMore={loadingMore}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="images">
|
||||
<ImageGrid messages={filteredMessages} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user