diff --git a/src/app/admin/users/_components/user-hero.tsx b/src/app/admin/users/_components/user-hero.tsx index 51ed1d02..1fe7ce28 100644 --- a/src/app/admin/users/_components/user-hero.tsx +++ b/src/app/admin/users/_components/user-hero.tsx @@ -1,3 +1,4 @@ +"use client"; import { ArrowLeftRight, DoorOpen, @@ -8,6 +9,7 @@ import { Wifi, WifiOff, } from "lucide-react"; +import { useSearchParams } from "next/navigation"; import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb"; import { UserWatchToggle } from "@/components/admin/user-watch-toggle"; import Link from "@/components/link"; @@ -15,6 +17,7 @@ import { CurrencyIcon } from "@/components/shared/currency-icon"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { getAvatarUrl } from "@/lib/imager"; +import { userDetailHref, userListHref } from "../user-list-navigation"; interface UserHeroProps { user: { @@ -55,11 +58,14 @@ export function UserHero({ canEdit, watching, }: UserHeroProps) { + const params = useSearchParams(); + const list = params.get("list") ?? ""; + const listHref = userListHref(list); return ( <> - + Edit )} {mode === "edit" && ( diff --git a/src/app/admin/users/use-user-bulk-actions.ts b/src/app/admin/users/use-user-bulk-actions.ts new file mode 100644 index 00000000..cff853e8 --- /dev/null +++ b/src/app/admin/users/use-user-bulk-actions.ts @@ -0,0 +1,185 @@ +"use client"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + bulkAdjustCurrency, + bulkBan, + bulkGiveBadge, + bulkUnban, +} from "@/actions/bulk-users"; + +type PendingDialog = + | { kind: "ban"; ids: number[] } + | { kind: "give-currency"; ids: number[] } + | { kind: "give-badge"; ids: number[] } + | null; + +export function useUserBulkActions() { + const [pending, setPending] = useState(null); + const [busy, setBusy] = useState(false); + const [banReason, setBanReason] = useState("Bulk ban"); + const [banDuration, setBanDuration] = useState("-1"); + const [currencyType, setCurrencyType] = useState< + "credits" | "pixels" | "points" + >("credits"); + const [currencyAmount, setCurrencyAmount] = useState("100"); + const [badgeCode, setBadgeCode] = useState(""); + + function reportResult( + label: string, + ok: number, + total: number, + failures = 0, + ) { + if (ok > 0) + toast.success( + `${label}: ${ok}/${total}${failures ? ` · ${failures} failed` : ""}`, + ); + if (failures > 0 && ok === 0) + toast.error(`${label} failed for ${failures} users`); + } + + async function handleBulkAction(action: string, ids: (number | string)[]) { + if (busy) return; + const numericIds = ids.map((i) => Number(i)); + + if (action === "unban") { + setBusy(true); + try { + const result = await bulkUnban({ userIds: numericIds }); + if (result.ok) { + const data = result.data as { unbanned: number; total: number }; + reportResult("Unbanned", data.unbanned, data.total); + } else toast.error(result.error); + } catch { + toast.error( + "Network error. The outcome is uncertain; check the user before retrying.", + ); + } finally { + setBusy(false); + } + return; + } + + if (action === "ban") { + setPending({ kind: "ban", ids: numericIds }); + return; + } + + if (action === "give-currency") { + setPending({ kind: "give-currency", ids: numericIds }); + return; + } + + if (action === "give-badge") { + setPending({ kind: "give-badge", ids: numericIds }); + return; + } + } + + async function confirmBan() { + if (pending?.kind !== "ban") return; + setBusy(true); + try { + const result = await bulkBan({ + userIds: pending.ids, + reason: banReason, + duration: Number(banDuration), + }); + if (result.ok) { + const data = result.data as { banned: number }; + reportResult("Banned", data.banned, pending.ids.length); + setPending(null); + } else toast.error(result.error); + } catch { + toast.error( + "Network error. The outcome is uncertain; check the user before retrying.", + ); + } finally { + setBusy(false); + } + } + + async function confirmGiveCurrency() { + if (pending?.kind !== "give-currency") return; + setBusy(true); + try { + const result = await bulkAdjustCurrency({ + userIds: pending.ids, + amount: Number(currencyAmount), + type: currencyType, + }); + if (result.ok) { + const data = result.data as { + adjusted: number; + total: number; + failedIds: { userId: number; reason: string }[]; + }; + reportResult( + `Adjusted ${currencyAmount} ${currencyType}`, + data.adjusted, + data.total, + data.failedIds.length, + ); + setPending(null); + } else toast.error(result.error); + } catch { + toast.error( + "Network error. The outcome is uncertain; check the user before retrying.", + ); + } finally { + setBusy(false); + } + } + + async function confirmGiveBadge() { + if (pending?.kind !== "give-badge") return; + setBusy(true); + try { + const result = await bulkGiveBadge({ + userIds: pending.ids, + badgeCode: badgeCode.trim().toUpperCase(), + }); + if (result.ok) { + const data = result.data as { + given: number; + total: number; + failedIds: { userId: number; reason: string }[]; + }; + reportResult( + `Given badge ${badgeCode}`, + data.given, + data.total, + data.failedIds.length, + ); + setPending(null); + } else toast.error(result.error); + } catch { + toast.error( + "Network error. The outcome is uncertain; check the user before retrying.", + ); + } finally { + setBusy(false); + } + } + + return { + pending, + setPending, + busy, + banReason, + setBanReason, + banDuration, + setBanDuration, + currencyType, + setCurrencyType, + currencyAmount, + setCurrencyAmount, + badgeCode, + setBadgeCode, + handleBulkAction, + confirmBan, + confirmGiveCurrency, + confirmGiveBadge, + }; +} diff --git a/src/app/admin/users/user-columns.tsx b/src/app/admin/users/user-columns.tsx new file mode 100644 index 00000000..1aff996e --- /dev/null +++ b/src/app/admin/users/user-columns.tsx @@ -0,0 +1,166 @@ +"use client"; +import { Eye, Pencil, Wifi, WifiOff } from "lucide-react"; +import Link from "@/components/link"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { getAvatarUrl } from "@/lib/imager"; +import type { DataTableColumn } from "@/types/common"; +import { userDetailHref } from "./user-list-navigation"; +export interface UserRow { + id: number; + username: string; + mail: string | null; + rank: number; + credits: number; + pixels: number; + online: string; + motto: string; + look: string; + ipRegister: string; + accountCreated: number; + lastLogin: number | null; +} + +const RANK_LABELS: Record = { + 1: "User", + 2: "Bronze", + 3: "Silver", + 4: "Gold", + 5: "Moderator", + 6: "Admin", + 7: "Super Admin", +}; + +export function getUserColumns( + canEdit: boolean, + listQuery: string, +): DataTableColumn[] { + const cols: DataTableColumn[] = [ + { key: "id", label: "ID", sortable: true }, + { + key: "username", + label: "User", + sortable: true, + render: (_, row) => ( + + +
+ {row.username} + {row.motto && ( + + {row.motto} + + )} +
+ + ), + }, + { key: "mail", label: "Email", sortable: true }, + { + key: "rank", + label: "Rank", + sortable: true, + filterKey: "filter_rank", + filterOptions: [ + { label: "User (1)", value: "1" }, + { label: "Bronze (2)", value: "2" }, + { label: "Silver (3)", value: "3" }, + { label: "Gold (4)", value: "4" }, + { label: "Moderator (5)", value: "5" }, + { label: "Admin (6)", value: "6" }, + { label: "Super Admin (7+)", value: "7" }, + ], + render: (value) => { + const r = Number(value); + return ( + = 7 ? "default" : r >= 5 ? "secondary" : "outline"} + > + {RANK_LABELS[r] || `Rank ${r}`} + + ); + }, + }, + { + key: "credits", + label: "Credits", + sortable: true, + render: (value) => ( + {Number(value).toLocaleString()} + ), + }, + { + key: "online", + label: "Status", + filterKey: "filter_online", + filterOptions: [ + { label: "Online", value: "1" }, + { label: "Offline", value: "0" }, + ], + render: (value) => + value === "1" ? ( + + Online + + ) : ( + + Offline + + ), + }, + { + key: "accountCreated", + label: "Registered", + sortable: true, + render: (value) => { + const ts = Number(value); + if (!ts) return "-"; + return ( + + {new Date(ts * 1000).toLocaleDateString("it-IT", { + day: "2-digit", + month: "short", + year: "numeric", + })} + + ); + }, + }, + { + key: "actions", + label: "", + render: (_, row) => ( +
+ + {canEdit && ( + + )} +
+ ), + }, + ]; + + return cols; +} diff --git a/src/app/admin/users/user-list-navigation.test.ts b/src/app/admin/users/user-list-navigation.test.ts new file mode 100644 index 00000000..93378c33 --- /dev/null +++ b/src/app/admin/users/user-list-navigation.test.ts @@ -0,0 +1,19 @@ +import { expect, it } from "vitest"; +import { userDetailHref, userListHref } from "./user-list-navigation"; + +it("preserves search filters and pagination through detail and edit links", () => { + const query = "search=Mario%20Rossi&page=3&filter_online=1&perPage=50"; + for (const mode of ["show", "edit"] as const) { + const link = new URL( + userDetailHref(9, mode, query), + "https://example.test", + ); + expect(userListHref(link.searchParams.get("list") ?? "")).toBe( + "/admin/users?search=Mario+Rossi&page=3&filter_online=1&perPage=50", + ); + } +}); +it("always returns a local users-list URL", () => { + expect(userListHref("")).toBe("/admin/users"); + expect(userListHref("https://other.example")).toMatch(/^\/admin\/users\?/); +}); diff --git a/src/app/admin/users/user-list-navigation.ts b/src/app/admin/users/user-list-navigation.ts new file mode 100644 index 00000000..f3a1c5f0 --- /dev/null +++ b/src/app/admin/users/user-list-navigation.ts @@ -0,0 +1,11 @@ +export function userListHref(query: string): string { + const search = new URLSearchParams(query).toString(); + return search ? `/admin/users?${search}` : "/admin/users"; +} +export function userDetailHref( + id: number, + mode: "show" | "edit", + query: string, +): string { + return `/admin/users/${mode}/${id}?list=${encodeURIComponent(query)}`; +} diff --git a/src/app/admin/users/users-table.tsx b/src/app/admin/users/users-table.tsx index ac9a47bd..b3188eff 100644 --- a/src/app/admin/users/users-table.tsx +++ b/src/app/admin/users/users-table.tsx @@ -1,18 +1,8 @@ "use client"; -import { Eye, Pencil, Wifi, WifiOff } from "lucide-react"; -import { useState } from "react"; -import { toast } from "sonner"; -import { - bulkAdjustCurrency, - bulkBan, - bulkGiveBadge, - bulkUnban, -} from "@/actions/bulk-users"; +import { useSearchParams } from "next/navigation"; import { DataTable } from "@/components/admin/data-table"; -import Link from "@/components/link"; import { CurrencyIcon } from "@/components/shared/currency-icon"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -31,164 +21,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { getAvatarUrl } from "@/lib/imager"; -import type { DataTableColumn, PaginatedResult } from "@/types/common"; - -interface UserRow { - id: number; - username: string; - mail: string | null; - rank: number; - credits: number; - pixels: number; - online: string; - motto: string; - look: string; - ipRegister: string; - accountCreated: number; - lastLogin: number | null; -} - -const RANK_LABELS: Record = { - 1: "User", - 2: "Bronze", - 3: "Silver", - 4: "Gold", - 5: "Moderator", - 6: "Admin", - 7: "Super Admin", -}; - -function getColumns(canEdit: boolean): DataTableColumn[] { - const cols: DataTableColumn[] = [ - { key: "id", label: "ID", sortable: true }, - { - key: "username", - label: "User", - sortable: true, - render: (_, row) => ( - - -
- {row.username} - {row.motto && ( - - {row.motto} - - )} -
- - ), - }, - { key: "mail", label: "Email", sortable: true }, - { - key: "rank", - label: "Rank", - sortable: true, - filterKey: "filter_rank", - filterOptions: [ - { label: "User (1)", value: "1" }, - { label: "Bronze (2)", value: "2" }, - { label: "Silver (3)", value: "3" }, - { label: "Gold (4)", value: "4" }, - { label: "Moderator (5)", value: "5" }, - { label: "Admin (6)", value: "6" }, - { label: "Super Admin (7+)", value: "7" }, - ], - render: (value) => { - const r = Number(value); - return ( - = 7 ? "default" : r >= 5 ? "secondary" : "outline"} - > - {RANK_LABELS[r] || `Rank ${r}`} - - ); - }, - }, - { - key: "credits", - label: "Credits", - sortable: true, - render: (value) => ( - {Number(value).toLocaleString()} - ), - }, - { - key: "online", - label: "Status", - filterKey: "filter_online", - filterOptions: [ - { label: "Online", value: "1" }, - { label: "Offline", value: "0" }, - ], - render: (value) => - value === "1" ? ( - - Online - - ) : ( - - Offline - - ), - }, - { - key: "accountCreated", - label: "Registered", - sortable: true, - render: (value) => { - const ts = Number(value); - if (!ts) return "-"; - return ( - - {new Date(ts * 1000).toLocaleDateString("it-IT", { - day: "2-digit", - month: "short", - year: "numeric", - })} - - ); - }, - }, - { - key: "actions", - label: "", - render: (_, row) => ( -
- - {canEdit && ( - - )} -
- ), - }, - ]; - - return cols; -} +import type { PaginatedResult } from "@/types/common"; +import { useUserBulkActions } from "./use-user-bulk-actions"; +import { getUserColumns, type UserRow } from "./user-columns"; interface UsersTableProps { data: PaginatedResult; @@ -196,144 +31,27 @@ interface UsersTableProps { canBan: boolean; } -type PendingDialog = - | { kind: "ban"; ids: number[] } - | { kind: "give-currency"; ids: number[] } - | { kind: "give-badge"; ids: number[] } - | null; - export function UsersTable({ data, canEdit, canBan }: UsersTableProps) { - const [pending, setPending] = useState(null); - const [busy, setBusy] = useState(false); - const [banReason, setBanReason] = useState("Bulk ban"); - const [banDuration, setBanDuration] = useState("-1"); - const [currencyType, setCurrencyType] = useState< - "credits" | "pixels" | "points" - >("credits"); - const [currencyAmount, setCurrencyAmount] = useState("100"); - const [badgeCode, setBadgeCode] = useState(""); - - function reportResult( - label: string, - ok: number, - total: number, - failures = 0, - ) { - if (ok > 0) - toast.success( - `${label}: ${ok}/${total}${failures ? ` · ${failures} failed` : ""}`, - ); - if (failures > 0 && ok === 0) - toast.error(`${label} failed for ${failures} users`); - } - - async function handleBulkAction(action: string, ids: (number | string)[]) { - const numericIds = ids.map((i) => Number(i)); - - if (action === "unban") { - setBusy(true); - try { - const result = await bulkUnban({ userIds: numericIds }); - if (result.ok) { - const data = result.data as { unbanned: number; total: number }; - reportResult("Unbanned", data.unbanned, data.total); - } else toast.error(result.error); - } finally { - setBusy(false); - } - return; - } - - if (action === "ban") { - setPending({ kind: "ban", ids: numericIds }); - return; - } - - if (action === "give-currency") { - setPending({ kind: "give-currency", ids: numericIds }); - return; - } - - if (action === "give-badge") { - setPending({ kind: "give-badge", ids: numericIds }); - return; - } - } - - async function confirmBan() { - if (pending?.kind !== "ban") return; - setBusy(true); - try { - const result = await bulkBan({ - userIds: pending.ids, - reason: banReason, - duration: Number(banDuration), - }); - if (result.ok) { - const data = result.data as { banned: number }; - reportResult("Banned", data.banned, pending.ids.length); - } else toast.error(result.error); - setPending(null); - } finally { - setBusy(false); - } - } - - async function confirmGiveCurrency() { - if (pending?.kind !== "give-currency") return; - setBusy(true); - try { - const result = await bulkAdjustCurrency({ - userIds: pending.ids, - amount: Number(currencyAmount), - type: currencyType, - }); - if (result.ok) { - const data = result.data as { - adjusted: number; - total: number; - failedIds: { userId: number; reason: string }[]; - }; - reportResult( - `Adjusted ${currencyAmount} ${currencyType}`, - data.adjusted, - data.total, - data.failedIds.length, - ); - } else toast.error(result.error); - setPending(null); - } finally { - setBusy(false); - } - } - - async function confirmGiveBadge() { - if (pending?.kind !== "give-badge") return; - setBusy(true); - try { - const result = await bulkGiveBadge({ - userIds: pending.ids, - badgeCode: badgeCode.trim().toUpperCase(), - }); - if (result.ok) { - const data = result.data as { - given: number; - total: number; - failedIds: { userId: number; reason: string }[]; - }; - reportResult( - `Given badge ${badgeCode}`, - data.given, - data.total, - data.failedIds.length, - ); - } else toast.error(result.error); - setPending(null); - } finally { - setBusy(false); - } - } - + const searchParams = useSearchParams(); + const { + pending, + setPending, + busy, + banReason, + setBanReason, + banDuration, + setBanDuration, + currencyType, + setCurrencyType, + currencyAmount, + setCurrencyAmount, + badgeCode, + setBadgeCode, + handleBulkAction, + confirmBan, + confirmGiveCurrency, + confirmGiveBadge, + } = useUserBulkActions(); const bulkActions: { label: string; value: string; @@ -352,7 +70,7 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) { <> !o && setPending(null)} + onOpenChange={(o) => !o && !busy && setPending(null)} > - + Ban {pending?.kind === "ban" ? pending.ids.length : 0} users @@ -421,7 +139,7 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) { open={pending?.kind === "give-currency"} onOpenChange={(o) => !o && setPending(null)} > - + Adjust currency for{" "} @@ -492,7 +210,7 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) { open={pending?.kind === "give-badge"} onOpenChange={(o) => !o && setPending(null)} > - + Give badge to{" "} diff --git a/src/components/admin/article-form.tsx b/src/components/admin/article-form.tsx index 4ccaaaec..14c51266 100644 --- a/src/components/admin/article-form.tsx +++ b/src/components/admin/article-form.tsx @@ -1,8 +1,10 @@ "use client"; import { useTranslations } from "next-intl"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; +import { useFormStatus } from "react-dom"; import { slugify } from "@/lib/format"; +import { ArticlePreview, type ArticlePreviewData } from "./article-preview"; import { MediaPicker } from "./media-picker"; import { RichText } from "./rich-text"; @@ -30,10 +32,13 @@ export function ArticleForm({ const [imageError, setImageError] = useState(false); const [status, setStatus] = useState(defaultValues?.status ?? "published"); + const formRef = useRef(null); + const [preview, setPreview] = useState(null); const suggestedSlug = useMemo(() => slugify(title), [title]); return (
{ @@ -83,7 +89,7 @@ export function ArticleForm({ setImageError(false); }} placeholder={t("imagePlaceholder")} - className="input input-bordered flex-1" + className="input input-bordered min-w-0 flex-1" /> { @@ -168,9 +174,40 @@ export function ArticleForm({ ) : null}
- +
+ + +
+ setPreview(null)} /> ); } + +function ArticleSaveButton({ label }: { label: string }) { + const { pending } = useFormStatus(); + return ( + + ); +} diff --git a/src/components/admin/article-preview.tsx b/src/components/admin/article-preview.tsx new file mode 100644 index 00000000..32dfc6d7 --- /dev/null +++ b/src/components/admin/article-preview.tsx @@ -0,0 +1,57 @@ +"use client"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +function escapeHtml(value: string) { + return value.replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ + c + ] ?? c, + ); +} +export interface ArticlePreviewData { + title: string; + image: string; + summary: string; + body: string; +} +export function ArticlePreview({ + data, + onClose, +}: { + data: ArticlePreviewData | null; + onClose: () => void; +}) { + const document = data + ? `

${escapeHtml(data.title)}

${data.image ? `` : ""}

${escapeHtml(data.summary)}

${data.body}` + : ""; + return ( + { + if (!open) onClose(); + }} + > + + + Article preview + +

+ Content preview. The published page applies the site theme. +

+