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