- Remove production DB dump (db_backup_*.sql) and update.log from git tracking - Add DB backups to .gitignore - Replace all console.log/console.error with structured logger module - Translate Dutch error messages to English (link-discord.ts) - Remove dead code blocks (register-form.tsx false && pattern) - Add ESLint flat config with TypeScript, React, Next.js, jsx-a11y, and security plugins - Add Prettier config - Add eslint-plugin-security for security-aware linting - Fix all 119+ ESLint warnings across the codebase: - Resolve security/detect-object-injection with safe access patterns - Resolve security/detect-non-literal-fs-filename with path traversal validation - Replace <img> with next/image <Image> component - Remove unused variables and imports - Replace non-null assertions with proper type guards - Replace <a> with <Link> for internal navigation - Use next/script Script component for external scripts - Fix setState-in-useEffect anti-patterns (navbar-color-picker, logo-generator, theme-switcher) - Add lint and format scripts to package.json All checks: typecheck ✓, tests 58/58 ✓, lint 0 errors 0 warnings ✓
161 lines
6.1 KiB
TypeScript
161 lines
6.1 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import type { ReactNode } from "react";
|
|
|
|
export interface OnlineUser {
|
|
id: number;
|
|
username: string;
|
|
look: string;
|
|
roomId: number | null;
|
|
roomName: string | null;
|
|
lastActivity: Date;
|
|
}
|
|
|
|
type State = "ok" | "warn" | "danger" | "neutral";
|
|
|
|
/** A single live-status tile (online users, emulator, DB, …). */
|
|
export function StatusCard({
|
|
label,
|
|
value,
|
|
hint,
|
|
state: _state = "neutral",
|
|
icon,
|
|
}: {
|
|
label: string;
|
|
value: ReactNode;
|
|
hint?: string;
|
|
state?: State;
|
|
icon?: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="bg-[var(--color-surface)] border border-[var(--border-subtle)] rounded-2xl p-5 shadow-card hover:shadow-card-hover transition-shadow duration-200">
|
|
<div className="flex items-center justify-between gap-3 mb-3">
|
|
<span className="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--color-text-muted)]">{label}</span>
|
|
{icon ? (
|
|
<span className="w-8 h-8 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/15 to-[var(--color-primary)]/5 grid place-items-center text-[var(--color-primary)] flex-none">
|
|
{icon}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="text-2xl font-extrabold text-[var(--color-text)] leading-tight">{value}</div>
|
|
{hint ? <div className="text-[0.7rem] text-[var(--color-text-muted)] mt-1.5 font-medium">{hint}</div> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** A row in the system-diagnostics list with an ok/warn/error pill. */
|
|
export function DiagnosticRow({
|
|
label,
|
|
detail,
|
|
state,
|
|
}: {
|
|
label: string;
|
|
detail: string;
|
|
state: Exclude<State, "neutral">;
|
|
}) {
|
|
const text = state === "ok" ? "OK" : state === "warn" ? "WARN" : "ERROR";
|
|
const dotColor = state === "ok" ? "bg-green-600 shadow-[0_0_0_3px_rgba(22,163,74,0.22)]" :
|
|
state === "warn" ? "bg-[var(--color-primary)] shadow-[0_0_0_3px_rgba(238,180,37,0.22)]" :
|
|
"bg-red-500 shadow-[0_0_0_3px_rgba(239,68,68,0.22)]";
|
|
const badgeColor = state === "ok" ? "bg-green-600/20" :
|
|
state === "danger" ? "bg-red-500/22" :
|
|
"bg-[var(--color-primary)]/18";
|
|
|
|
return (
|
|
<div className="flex items-center gap-2.5 py-2 px-0.5 border-t border-[var(--border-subtle)] first:border-t-0">
|
|
<span className={`flex-none w-[9px] h-[9px] rounded-full ${dotColor}`} aria-hidden />
|
|
<span className="font-bold text-sm">{label}</span>
|
|
<span className="flex-1 text-[0.82rem] text-[var(--color-text-muted)]">{detail}</span>
|
|
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${badgeColor} text-[var(--color-text)]`}>
|
|
{text}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Small labelled key/value used in the server-info card. */
|
|
export function InfoItem({ label, value }: { label: string; value: ReactNode }) {
|
|
return (
|
|
<div className="flex flex-col gap-0.5 text-sm">
|
|
<span className="text-[var(--color-text-muted)]">{label}</span>
|
|
<strong>{value}</strong>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Live list of online users with room and activity tracking */
|
|
export function OnlineUsersWidget({
|
|
users,
|
|
isLoading = false,
|
|
}: {
|
|
users: OnlineUser[];
|
|
isLoading?: boolean;
|
|
}) {
|
|
const formatLastSeen = (date: Date) => {
|
|
const now = new Date();
|
|
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
|
|
if (diff < 60) return "Just now";
|
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
return `${Math.floor(diff / 86400)}d ago`;
|
|
};
|
|
|
|
return (
|
|
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--border-subtle)] p-6">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="m-0 text-lg font-semibold text-[var(--color-text)]">Online Now</h3>
|
|
<span className="text-sm text-[var(--color-text-muted)] bg-[var(--color-primary)]/10 px-3 py-1 rounded-full">
|
|
{isLoading ? "..." : users.length} users
|
|
</span>
|
|
</div>
|
|
|
|
<div className="max-h-80 overflow-y-auto flex flex-col gap-3">
|
|
{users.length === 0 ? (
|
|
<div className="py-8 text-center bg-[var(--color-text-muted)]/5 rounded-lg">
|
|
<span className="text-[var(--color-text-muted)] text-sm">No users online</span>
|
|
</div>
|
|
) : (
|
|
users.map((user) => (
|
|
<div
|
|
key={user.id}
|
|
className="flex items-center gap-3 p-3 bg-[var(--color-text-muted)]/5 rounded-lg transition-all duration-150 hover:bg-[var(--color-text-muted)]/10 hover:translate-x-0.5"
|
|
>
|
|
<div className="relative flex-none w-[50px] h-[62px]">
|
|
<Image
|
|
src={`https://www.habbo.com/habbo-imaging/avatarimage?width=50&height=62&direction=2&headonly=1&crop=face&action=wav&gender=m&look=${encodeURIComponent(user.look)}`}
|
|
alt={user.username}
|
|
fill
|
|
className="rounded object-cover border border-[var(--border-subtle)]"
|
|
unoptimized
|
|
/>
|
|
{user.roomId && user.roomName && (
|
|
<div className="absolute -bottom-0.5 -right-0.5">
|
|
<span className="block w-[10px] h-[10px] bg-green-500 rounded-full border-2 border-[var(--color-surface)]" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium text-[var(--color-text)] mb-1 truncate">
|
|
<a href={`/u/${user.username}`} className="text-inherit no-underline hover:text-[var(--color-primary)] hover:underline">
|
|
{user.username}
|
|
</a>
|
|
</div>
|
|
<div className="text-[0.75rem] text-[var(--color-text-muted)]">
|
|
{user.roomId && user.roomName ? (
|
|
<span className="text-green-500 font-medium">🏠 {user.roomName}</span>
|
|
) : (
|
|
<span className="text-red-500">⏱️ {formatLastSeen(user.lastActivity)}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|