This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
@@ -1,28 +1,31 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function OnlineLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-5 w-64 mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-6 px-4 py-3 border-b last:border-0">
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-5 w-64 mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-6 px-4 py-3 border-b last:border-0"
|
||||
>
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,107 +10,128 @@ import { getAvatarUrl } from "@/lib/imager";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
|
||||
interface OnlineUserRow {
|
||||
id: number;
|
||||
username: string;
|
||||
motto: string;
|
||||
look: string;
|
||||
id: number;
|
||||
username: string;
|
||||
motto: string;
|
||||
look: string;
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<OnlineUserRow>[] = [
|
||||
{ key: "id", label: "ID", sortable: true },
|
||||
{
|
||||
key: "username",
|
||||
label: "Username",
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={getAvatarUrl(row.look, { size: "s", headOnly: true })} alt="" className="h-8 w-8 rounded" />
|
||||
<span className="font-medium">{row.username}</span>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--admin-success)]/70 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-[var(--admin-success)]" />
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "motto",
|
||||
label: "Motto",
|
||||
render: (value) => <span className="max-w-[250px] truncate block">{String(value) || "-"}</span>,
|
||||
},
|
||||
{
|
||||
key: "look",
|
||||
label: "Avatar",
|
||||
render: (_, row) => <img src={getAvatarUrl(row.look)} alt={row.username} className="h-16" />,
|
||||
},
|
||||
{ key: "id", label: "ID", sortable: true },
|
||||
{
|
||||
key: "username",
|
||||
label: "Username",
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={getAvatarUrl(row.look, { size: "s", headOnly: true })}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded"
|
||||
/>
|
||||
<span className="font-medium">{row.username}</span>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--admin-success)]/70 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-[var(--admin-success)]" />
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "motto",
|
||||
label: "Motto",
|
||||
render: (value) => (
|
||||
<span className="max-w-[250px] truncate block">
|
||||
{String(value) || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "look",
|
||||
label: "Avatar",
|
||||
render: (_, row) => (
|
||||
<img src={getAvatarUrl(row.look)} alt={row.username} className="h-16" />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
interface OnlineTableProps {
|
||||
data: PaginatedResult<OnlineUserRow>;
|
||||
data: PaginatedResult<OnlineUserRow>;
|
||||
}
|
||||
|
||||
const REFRESH_INTERVAL = 15_000; // 15 seconds
|
||||
|
||||
export function OnlineTable({ data }: OnlineTableProps) {
|
||||
const router = useRouter();
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [lastUpdated, setLastUpdated] = useState(() => new Date());
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const router = useRouter();
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [lastUpdated, setLastUpdated] = useState(() => new Date());
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const doRefresh = useCallback(() => {
|
||||
setIsRefreshing(true);
|
||||
router.refresh();
|
||||
setLastUpdated(new Date());
|
||||
// Small delay to show spinner
|
||||
setTimeout(() => setIsRefreshing(false), 500);
|
||||
}, [router]);
|
||||
const doRefresh = useCallback(() => {
|
||||
setIsRefreshing(true);
|
||||
router.refresh();
|
||||
setLastUpdated(new Date());
|
||||
// Small delay to show spinner
|
||||
setTimeout(() => setIsRefreshing(false), 500);
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const timer = setInterval(doRefresh, REFRESH_INTERVAL);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoRefresh, doRefresh]);
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const timer = setInterval(doRefresh, REFRESH_INTERVAL);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoRefresh, doRefresh]);
|
||||
|
||||
// Update lastUpdated when data changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
setLastUpdated(new Date());
|
||||
}, [data.total]);
|
||||
// Update lastUpdated when data changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
setLastUpdated(new Date());
|
||||
}, [data.total]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Auto-refresh controls */}
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Button variant="outline" size="sm" onClick={doRefresh} disabled={isRefreshing}>
|
||||
<RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant={autoRefresh ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
>
|
||||
{autoRefresh ? (
|
||||
<>
|
||||
<Pause className="mr-1.5 h-3.5 w-3.5" /> Auto-refresh ON
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-1.5 h-3.5 w-3.5" /> Auto-refresh OFF
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{autoRefresh && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Every {REFRESH_INTERVAL / 1000}s
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Auto-refresh controls */}
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={doRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-1.5 h-3.5 w-3.5 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant={autoRefresh ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
>
|
||||
{autoRefresh ? (
|
||||
<>
|
||||
<Pause className="mr-1.5 h-3.5 w-3.5" /> Auto-refresh ON
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-1.5 h-3.5 w-3.5" /> Auto-refresh OFF
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{autoRefresh && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Every {REFRESH_INTERVAL / 1000}s
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DataTable data={data} columns={columns} searchPlaceholder="Search by username or motto..." />
|
||||
</div>
|
||||
);
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search by username or motto..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,46 +3,48 @@ import { PERMS } from "@/lib/permissions";
|
||||
import { OnlineTable } from "./online-table";
|
||||
|
||||
export default async function OnlineUsersPage({
|
||||
params: paramsPromise,
|
||||
searchParams,
|
||||
params: paramsPromise,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
const result = await fetchAdminList(
|
||||
{
|
||||
permission: PERMS.USERS_VIEW,
|
||||
model: "user",
|
||||
searchFields: ["username", "motto"],
|
||||
baseWhere: { online: "1" },
|
||||
defaultPerPage: 50,
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
motto: true,
|
||||
look: true,
|
||||
},
|
||||
},
|
||||
paramsPromise,
|
||||
searchParams,
|
||||
);
|
||||
const result = await fetchAdminList(
|
||||
{
|
||||
permission: PERMS.USERS_VIEW,
|
||||
model: "user",
|
||||
searchFields: ["username", "motto"],
|
||||
baseWhere: { online: "1" },
|
||||
defaultPerPage: 50,
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
motto: true,
|
||||
look: true,
|
||||
},
|
||||
},
|
||||
paramsPromise,
|
||||
searchParams,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Online Users</h1>
|
||||
<p className="text-muted-foreground">Currently online users ({result.total} connected)</p>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Online Users</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Currently online users ({result.total} connected)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<OnlineTable
|
||||
data={{
|
||||
rows: result.rows,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
lastPage: result.lastPage,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
<OnlineTable
|
||||
data={{
|
||||
rows: result.rows,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
lastPage: result.lastPage,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user