refactor(admin): organize user actions and improve content editing UX
This commit is contained in:
1 parent
e17346ab9f
commit
c6b919c01d
10 files changed
+688
-458
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
|
"use client";
|
||||||
import {
|
import {
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
DoorOpen,
|
DoorOpen,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
Wifi,
|
Wifi,
|
||||||
WifiOff,
|
WifiOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
|
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
|
||||||
import { UserWatchToggle } from "@/components/admin/user-watch-toggle";
|
import { UserWatchToggle } from "@/components/admin/user-watch-toggle";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
@@ -15,6 +17,7 @@ import { CurrencyIcon } from "@/components/shared/currency-icon";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { getAvatarUrl } from "@/lib/imager";
|
import { getAvatarUrl } from "@/lib/imager";
|
||||||
|
import { userDetailHref, userListHref } from "../user-list-navigation";
|
||||||
|
|
||||||
interface UserHeroProps {
|
interface UserHeroProps {
|
||||||
user: {
|
user: {
|
||||||
@@ -55,11 +58,14 @@ export function UserHero({
|
|||||||
canEdit,
|
canEdit,
|
||||||
watching,
|
watching,
|
||||||
}: UserHeroProps) {
|
}: UserHeroProps) {
|
||||||
|
const params = useSearchParams();
|
||||||
|
const list = params.get("list") ?? "";
|
||||||
|
const listHref = userListHref(list);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminBreadcrumb
|
<AdminBreadcrumb
|
||||||
items={[
|
items={[
|
||||||
{ label: "Users", href: "/admin/users" },
|
{ label: "Users", href: listHref },
|
||||||
{ label: user.username },
|
{ label: user.username },
|
||||||
{ label: mode === "edit" ? "Edit" : "View" },
|
{ label: mode === "edit" ? "Edit" : "View" },
|
||||||
]}
|
]}
|
||||||
@@ -99,14 +105,14 @@ export function UserHero({
|
|||||||
{/* Mode switch buttons */}
|
{/* Mode switch buttons */}
|
||||||
{mode === "show" && canEdit && (
|
{mode === "show" && canEdit && (
|
||||||
<Button variant="outline" size="sm" asChild className="ml-2">
|
<Button variant="outline" size="sm" asChild className="ml-2">
|
||||||
<Link href={`/admin/users/edit/${user.id}`}>
|
<Link href={userDetailHref(user.id, "edit", list)}>
|
||||||
<Pencil className="mr-1.5 h-3.5 w-3.5" /> Edit
|
<Pencil className="mr-1.5 h-3.5 w-3.5" /> Edit
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{mode === "edit" && (
|
{mode === "edit" && (
|
||||||
<Button variant="outline" size="sm" asChild className="ml-2">
|
<Button variant="outline" size="sm" asChild className="ml-2">
|
||||||
<Link href={`/admin/users/show/${user.id}`}>
|
<Link href={userDetailHref(user.id, "show", list)}>
|
||||||
<Eye className="mr-1.5 h-3.5 w-3.5" /> View
|
<Eye className="mr-1.5 h-3.5 w-3.5" /> View
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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<PendingDialog>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<number, string> = {
|
||||||
|
1: "User",
|
||||||
|
2: "Bronze",
|
||||||
|
3: "Silver",
|
||||||
|
4: "Gold",
|
||||||
|
5: "Moderator",
|
||||||
|
6: "Admin",
|
||||||
|
7: "Super Admin",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getUserColumns(
|
||||||
|
canEdit: boolean,
|
||||||
|
listQuery: string,
|
||||||
|
): DataTableColumn<UserRow>[] {
|
||||||
|
const cols: DataTableColumn<UserRow>[] = [
|
||||||
|
{ key: "id", label: "ID", sortable: true },
|
||||||
|
{
|
||||||
|
key: "username",
|
||||||
|
label: "User",
|
||||||
|
sortable: true,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Link
|
||||||
|
href={userDetailHref(row.id, "show", listQuery)}
|
||||||
|
className="flex items-center gap-2.5 hover:opacity-80"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={getAvatarUrl(row.look, {
|
||||||
|
size: "m",
|
||||||
|
direction: 2,
|
||||||
|
headDirection: 2,
|
||||||
|
})}
|
||||||
|
alt=""
|
||||||
|
width={64}
|
||||||
|
height={110}
|
||||||
|
className="h-[110px] w-16 shrink-0"
|
||||||
|
style={{ imageRendering: "pixelated" }}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="font-medium block">{row.username}</span>
|
||||||
|
{row.motto && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate block max-w-[200px]">
|
||||||
|
{row.motto}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ 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 (
|
||||||
|
<Badge
|
||||||
|
variant={r >= 7 ? "default" : r >= 5 ? "secondary" : "outline"}
|
||||||
|
>
|
||||||
|
{RANK_LABELS[r] || `Rank ${r}`}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "credits",
|
||||||
|
label: "Credits",
|
||||||
|
sortable: true,
|
||||||
|
render: (value) => (
|
||||||
|
<span className="tabular-nums">{Number(value).toLocaleString()}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "online",
|
||||||
|
label: "Status",
|
||||||
|
filterKey: "filter_online",
|
||||||
|
filterOptions: [
|
||||||
|
{ label: "Online", value: "1" },
|
||||||
|
{ label: "Offline", value: "0" },
|
||||||
|
],
|
||||||
|
render: (value) =>
|
||||||
|
value === "1" ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-[var(--admin-success)]">
|
||||||
|
<Wifi className="h-3.5 w-3.5" /> Online
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<WifiOff className="h-3.5 w-3.5" /> Offline
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "accountCreated",
|
||||||
|
label: "Registered",
|
||||||
|
sortable: true,
|
||||||
|
render: (value) => {
|
||||||
|
const ts = Number(value);
|
||||||
|
if (!ts) return "-";
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(ts * 1000).toLocaleDateString("it-IT", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "",
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" asChild title="View">
|
||||||
|
<Link href={userDetailHref(row.id, "show", listQuery)}>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{canEdit && (
|
||||||
|
<Button variant="ghost" size="icon" asChild title="Edit">
|
||||||
|
<Link href={userDetailHref(row.id, "edit", listQuery)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
@@ -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\?/);
|
||||||
|
});
|
||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -1,18 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Eye, Pencil, Wifi, WifiOff } from "lucide-react";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
bulkAdjustCurrency,
|
|
||||||
bulkBan,
|
|
||||||
bulkGiveBadge,
|
|
||||||
bulkUnban,
|
|
||||||
} from "@/actions/bulk-users";
|
|
||||||
import { DataTable } from "@/components/admin/data-table";
|
import { DataTable } from "@/components/admin/data-table";
|
||||||
import Link from "@/components/link";
|
|
||||||
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -31,164 +21,9 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { getAvatarUrl } from "@/lib/imager";
|
import type { PaginatedResult } from "@/types/common";
|
||||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
import { useUserBulkActions } from "./use-user-bulk-actions";
|
||||||
|
import { getUserColumns, type UserRow } from "./user-columns";
|
||||||
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<number, string> = {
|
|
||||||
1: "User",
|
|
||||||
2: "Bronze",
|
|
||||||
3: "Silver",
|
|
||||||
4: "Gold",
|
|
||||||
5: "Moderator",
|
|
||||||
6: "Admin",
|
|
||||||
7: "Super Admin",
|
|
||||||
};
|
|
||||||
|
|
||||||
function getColumns(canEdit: boolean): DataTableColumn<UserRow>[] {
|
|
||||||
const cols: DataTableColumn<UserRow>[] = [
|
|
||||||
{ key: "id", label: "ID", sortable: true },
|
|
||||||
{
|
|
||||||
key: "username",
|
|
||||||
label: "User",
|
|
||||||
sortable: true,
|
|
||||||
render: (_, row) => (
|
|
||||||
<Link
|
|
||||||
href={`/admin/users/show/${row.id}`}
|
|
||||||
className="flex items-center gap-2.5 hover:opacity-80"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={getAvatarUrl(row.look, {
|
|
||||||
size: "m",
|
|
||||||
direction: 2,
|
|
||||||
headDirection: 2,
|
|
||||||
})}
|
|
||||||
alt=""
|
|
||||||
width={64}
|
|
||||||
height={110}
|
|
||||||
className="h-[110px] w-16 shrink-0"
|
|
||||||
style={{ imageRendering: "pixelated" }}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="font-medium block">{row.username}</span>
|
|
||||||
{row.motto && (
|
|
||||||
<span className="text-xs text-muted-foreground truncate block max-w-[200px]">
|
|
||||||
{row.motto}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ 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 (
|
|
||||||
<Badge
|
|
||||||
variant={r >= 7 ? "default" : r >= 5 ? "secondary" : "outline"}
|
|
||||||
>
|
|
||||||
{RANK_LABELS[r] || `Rank ${r}`}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "credits",
|
|
||||||
label: "Credits",
|
|
||||||
sortable: true,
|
|
||||||
render: (value) => (
|
|
||||||
<span className="tabular-nums">{Number(value).toLocaleString()}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "online",
|
|
||||||
label: "Status",
|
|
||||||
filterKey: "filter_online",
|
|
||||||
filterOptions: [
|
|
||||||
{ label: "Online", value: "1" },
|
|
||||||
{ label: "Offline", value: "0" },
|
|
||||||
],
|
|
||||||
render: (value) =>
|
|
||||||
value === "1" ? (
|
|
||||||
<span className="flex items-center gap-1.5 text-[var(--admin-success)]">
|
|
||||||
<Wifi className="h-3.5 w-3.5" /> Online
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<WifiOff className="h-3.5 w-3.5" /> Offline
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "accountCreated",
|
|
||||||
label: "Registered",
|
|
||||||
sortable: true,
|
|
||||||
render: (value) => {
|
|
||||||
const ts = Number(value);
|
|
||||||
if (!ts) return "-";
|
|
||||||
return (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{new Date(ts * 1000).toLocaleDateString("it-IT", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "short",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "actions",
|
|
||||||
label: "",
|
|
||||||
render: (_, row) => (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button variant="ghost" size="icon" asChild title="View">
|
|
||||||
<Link href={`/admin/users/show/${row.id}`}>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
{canEdit && (
|
|
||||||
<Button variant="ghost" size="icon" asChild title="Edit">
|
|
||||||
<Link href={`/admin/users/edit/${row.id}`}>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return cols;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsersTableProps {
|
interface UsersTableProps {
|
||||||
data: PaginatedResult<UserRow>;
|
data: PaginatedResult<UserRow>;
|
||||||
@@ -196,144 +31,27 @@ interface UsersTableProps {
|
|||||||
canBan: boolean;
|
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) {
|
export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
|
||||||
const [pending, setPending] = useState<PendingDialog>(null);
|
const searchParams = useSearchParams();
|
||||||
const [busy, setBusy] = useState(false);
|
const {
|
||||||
const [banReason, setBanReason] = useState("Bulk ban");
|
pending,
|
||||||
const [banDuration, setBanDuration] = useState("-1");
|
setPending,
|
||||||
const [currencyType, setCurrencyType] = useState<
|
busy,
|
||||||
"credits" | "pixels" | "points"
|
banReason,
|
||||||
>("credits");
|
setBanReason,
|
||||||
const [currencyAmount, setCurrencyAmount] = useState("100");
|
banDuration,
|
||||||
const [badgeCode, setBadgeCode] = useState("");
|
setBanDuration,
|
||||||
|
currencyType,
|
||||||
function reportResult(
|
setCurrencyType,
|
||||||
label: string,
|
currencyAmount,
|
||||||
ok: number,
|
setCurrencyAmount,
|
||||||
total: number,
|
badgeCode,
|
||||||
failures = 0,
|
setBadgeCode,
|
||||||
) {
|
handleBulkAction,
|
||||||
if (ok > 0)
|
confirmBan,
|
||||||
toast.success(
|
confirmGiveCurrency,
|
||||||
`${label}: ${ok}/${total}${failures ? ` · ${failures} failed` : ""}`,
|
confirmGiveBadge,
|
||||||
);
|
} = useUserBulkActions();
|
||||||
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 bulkActions: {
|
const bulkActions: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -352,7 +70,7 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
|
|||||||
<>
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
data={data}
|
data={data}
|
||||||
columns={getColumns(canEdit)}
|
columns={getUserColumns(canEdit, searchParams.toString())}
|
||||||
searchPlaceholder="Search by username, email or IP..."
|
searchPlaceholder="Search by username, email or IP..."
|
||||||
presets={[
|
presets={[
|
||||||
{ label: "Online now", params: { filter_online: "1" } },
|
{ label: "Online now", params: { filter_online: "1" } },
|
||||||
@@ -366,9 +84,9 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
|
|||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={pending?.kind === "ban"}
|
open={pending?.kind === "ban"}
|
||||||
onOpenChange={(o) => !o && setPending(null)}
|
onOpenChange={(o) => !o && !busy && setPending(null)}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent className="max-h-[90dvh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Ban {pending?.kind === "ban" ? pending.ids.length : 0} users
|
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"}
|
open={pending?.kind === "give-currency"}
|
||||||
onOpenChange={(o) => !o && setPending(null)}
|
onOpenChange={(o) => !o && setPending(null)}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent className="max-h-[90dvh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Adjust currency for{" "}
|
Adjust currency for{" "}
|
||||||
@@ -492,7 +210,7 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
|
|||||||
open={pending?.kind === "give-badge"}
|
open={pending?.kind === "give-badge"}
|
||||||
onOpenChange={(o) => !o && setPending(null)}
|
onOpenChange={(o) => !o && setPending(null)}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent className="max-h-[90dvh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Give badge to{" "}
|
Give badge to{" "}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
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 { slugify } from "@/lib/format";
|
||||||
|
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
||||||
import { MediaPicker } from "./media-picker";
|
import { MediaPicker } from "./media-picker";
|
||||||
import { RichText } from "./rich-text";
|
import { RichText } from "./rich-text";
|
||||||
|
|
||||||
@@ -30,10 +32,13 @@ export function ArticleForm({
|
|||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const [status, setStatus] = useState(defaultValues?.status ?? "published");
|
const [status, setStatus] = useState(defaultValues?.status ?? "published");
|
||||||
|
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
|
||||||
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
|
ref={formRef}
|
||||||
action={action}
|
action={action}
|
||||||
className="admin-card"
|
className="admin-card"
|
||||||
style={{ display: "grid", gap: "1rem" }}
|
style={{ display: "grid", gap: "1rem" }}
|
||||||
@@ -76,6 +81,7 @@ export function ArticleForm({
|
|||||||
</span>
|
</span>
|
||||||
<div className="mt-1 flex gap-2 items-start">
|
<div className="mt-1 flex gap-2 items-start">
|
||||||
<input
|
<input
|
||||||
|
aria-label={t("image")}
|
||||||
name="image"
|
name="image"
|
||||||
value={image}
|
value={image}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -83,7 +89,7 @@ export function ArticleForm({
|
|||||||
setImageError(false);
|
setImageError(false);
|
||||||
}}
|
}}
|
||||||
placeholder={t("imagePlaceholder")}
|
placeholder={t("imagePlaceholder")}
|
||||||
className="input input-bordered flex-1"
|
className="input input-bordered min-w-0 flex-1"
|
||||||
/>
|
/>
|
||||||
<MediaPicker
|
<MediaPicker
|
||||||
onSelect={(url) => {
|
onSelect={(url) => {
|
||||||
@@ -168,9 +174,40 @@ export function ArticleForm({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" className="btn btn-primary justify-self-start">
|
<div className="sticky bottom-0 flex flex-wrap gap-2 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] py-3">
|
||||||
{t("save")}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (!formRef.current) return;
|
||||||
|
const values = new FormData(formRef.current);
|
||||||
|
setPreview({
|
||||||
|
title: String(values.get("title") ?? ""),
|
||||||
|
image: String(values.get("image") ?? ""),
|
||||||
|
summary: String(values.get("shortStory") ?? ""),
|
||||||
|
body: String(values.get("fullStory") ?? ""),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
<ArticleSaveButton label={t("save")} />
|
||||||
|
</div>
|
||||||
|
<ArticlePreview data={preview} onClose={() => setPreview(null)} />
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ArticleSaveButton({ label }: { label: string }) {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={pending}
|
||||||
|
aria-busy={pending}
|
||||||
|
>
|
||||||
|
{pending ? "Saving…" : label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
? `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: http: data:; style-src 'unsafe-inline'"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:light;background:Canvas;color:CanvasText}body{font:16px/1.6 system-ui;padding:20px;margin:auto;max-width:760px;overflow-wrap:anywhere}img{max-width:100%;height:auto}h1{line-height:1.2}pre{white-space:pre-wrap}</style></head><body><h1>${escapeHtml(data.title)}</h1>${data.image ? `<img src="${escapeHtml(data.image)}" alt="">` : ""}<p>${escapeHtml(data.summary)}</p>${data.body}</body></html>`
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={!!data}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-4xl h-[85dvh] flex flex-col min-h-0">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Article preview</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Content preview. The published page applies the site theme.
|
||||||
|
</p>
|
||||||
|
<iframe
|
||||||
|
title="Article content preview"
|
||||||
|
sandbox=""
|
||||||
|
srcDoc={document}
|
||||||
|
className="min-h-0 flex-1 w-full bg-background rounded border"
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import { uploadMediaAndReturn } from "@/actions/admin-media";
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { useMediaLibrary } from "./use-media-library";
|
||||||
type MediaFile = { name: string; url: string };
|
|
||||||
|
|
||||||
function validImageUrl(url: string): string {
|
function validImageUrl(url: string): string {
|
||||||
if (!url.startsWith("/")) return "";
|
if (!url.startsWith("/")) return "";
|
||||||
@@ -20,46 +19,8 @@ export function MediaPicker({
|
|||||||
current?: string;
|
current?: string;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [files, setFiles] = useState<MediaFile[]>([]);
|
const { files, loading, uploading, fileRef, error, load, handleUpload } =
|
||||||
const [loading, setLoading] = useState(false);
|
useMediaLibrary(open, setOpen, onSelect);
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/media");
|
|
||||||
const data = await res.json();
|
|
||||||
setFiles(data.files ?? []);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) load();
|
|
||||||
}, [open, load]);
|
|
||||||
|
|
||||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const files = e.target.files;
|
|
||||||
if (!files || files.length === 0) return;
|
|
||||||
setUploading(true);
|
|
||||||
const urls: string[] = [];
|
|
||||||
for (const file of Array.from(files)) {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.set("file", file);
|
|
||||||
const url = await uploadMediaAndReturn(fd);
|
|
||||||
if (url) urls.push(url);
|
|
||||||
}
|
|
||||||
if (urls.length > 0) {
|
|
||||||
await load();
|
|
||||||
onSelect(urls[urls.length - 1]); // select the last one for backward compatibility
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
setUploading(false);
|
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -88,104 +49,107 @@ export function MediaPicker({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{open && (
|
<Dialog
|
||||||
// biome-ignore lint/a11y/useSemanticElements: backdrop overlay uses role=button for click-to-close
|
open={open}
|
||||||
<div
|
onOpenChange={(next) => {
|
||||||
role="button"
|
if (!uploading) setOpen(next);
|
||||||
tabIndex={0}
|
}}
|
||||||
aria-label="Close media picker"
|
>
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-foreground/50"
|
<DialogContent
|
||||||
onClick={(e) => {
|
showCloseButton={false}
|
||||||
if (e.target === e.currentTarget) setOpen(false);
|
className="flex flex-col p-0 gap-0 max-h-[80dvh] sm:max-w-[680px] overflow-hidden"
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--admin-text-muted)]/14">
|
||||||
role="dialog"
|
<DialogTitle className="text-base">Media Library</DialogTitle>
|
||||||
aria-modal="true"
|
<div className="flex gap-2 items-center">
|
||||||
className="bg-[var(--admin-surface)] text-[var(--admin-text)] rounded-2xl w-[min(90vw,680px)] max-h-[80vh] flex flex-col shadow-xl"
|
<label className="btn btn-primary text-sm cursor-pointer">
|
||||||
>
|
{uploading ? "Uploading…" : "Upload"}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--admin-text-muted)]/14">
|
<input
|
||||||
<strong className="text-base">Media Library</strong>
|
ref={fileRef}
|
||||||
<div className="flex gap-2 items-center">
|
type="file"
|
||||||
<label className="btn btn-primary text-sm cursor-pointer">
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
{uploading ? "Uploading…" : "Upload"}
|
onChange={handleUpload}
|
||||||
<input
|
className="hidden"
|
||||||
ref={fileRef}
|
disabled={uploading}
|
||||||
type="file"
|
multiple
|
||||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
/>
|
||||||
onChange={handleUpload}
|
</label>
|
||||||
className="hidden"
|
<button
|
||||||
multiple
|
type="button"
|
||||||
/>
|
disabled={uploading}
|
||||||
</label>
|
onClick={() => setOpen(false)}
|
||||||
<button
|
className="btn text-sm"
|
||||||
type="button"
|
>
|
||||||
onClick={() => setOpen(false)}
|
Close
|
||||||
className="btn text-sm"
|
</button>
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-auto p-3">
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
|
|
||||||
Loading…
|
|
||||||
</p>
|
|
||||||
) : files.length === 0 ? (
|
|
||||||
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
|
|
||||||
No images yet. Upload one above.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2.5">
|
|
||||||
{files.map((f) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={f.name}
|
|
||||||
onClick={() => {
|
|
||||||
onSelect(f.url);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
className="border-2 border-transparent rounded-xl overflow-hidden cursor-pointer bg-[var(--admin-canvas)] p-0 flex flex-col transition-all duration-150"
|
|
||||||
style={{
|
|
||||||
borderColor:
|
|
||||||
f.url === current
|
|
||||||
? "var(--admin-accent)"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (f.url !== current)
|
|
||||||
e.currentTarget.style.borderColor =
|
|
||||||
"color-mix(in srgb, var(--admin-accent) 40%, transparent)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (f.url !== current)
|
|
||||||
e.currentTarget.style.borderColor = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={validImageUrl(f.url)}
|
|
||||||
alt={f.name}
|
|
||||||
className="w-full h-[100px] object-cover block"
|
|
||||||
/>
|
|
||||||
<span className="text-xs py-1 text-center overflow-hidden text-ellipsis whitespace-nowrap">
|
|
||||||
{f.name}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||||
)}
|
{error && (
|
||||||
|
<div role="alert" className="p-2 text-sm">
|
||||||
|
<p>{error}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
disabled={loading || uploading}
|
||||||
|
onClick={() => void load()}
|
||||||
|
>
|
||||||
|
Retry loading
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
|
||||||
|
No images yet. Upload one above.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2.5">
|
||||||
|
{files.map((f) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={f.name}
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(f.url);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="border-2 border-transparent rounded-xl overflow-hidden cursor-pointer bg-[var(--admin-canvas)] p-0 flex flex-col transition-all duration-150"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
f.url === current
|
||||||
|
? "var(--admin-accent)"
|
||||||
|
: "transparent",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (f.url !== current)
|
||||||
|
e.currentTarget.style.borderColor =
|
||||||
|
"color-mix(in srgb, var(--admin-accent) 40%, transparent)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (f.url !== current)
|
||||||
|
e.currentTarget.style.borderColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={validImageUrl(f.url)}
|
||||||
|
alt={f.name}
|
||||||
|
className="w-full h-[100px] object-cover block"
|
||||||
|
/>
|
||||||
|
<span className="text-xs py-1 text-center overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { uploadMediaAndReturn } from "@/actions/admin-media";
|
||||||
|
|
||||||
|
type MediaFile = { name: string; url: string };
|
||||||
|
export function useMediaLibrary(
|
||||||
|
open: boolean,
|
||||||
|
setOpen: (open: boolean) => void,
|
||||||
|
onSelect: (url: string) => void,
|
||||||
|
) {
|
||||||
|
const [files, setFiles] = useState<MediaFile[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/media");
|
||||||
|
if (!res.ok) throw Error("Could not load media library");
|
||||||
|
const data = await res.json();
|
||||||
|
setFiles(data.files ?? []);
|
||||||
|
} catch {
|
||||||
|
setError("Could not load media library. Please retry.");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) load();
|
||||||
|
}, [open, load]);
|
||||||
|
|
||||||
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const chosen = Array.from(e.target.files ?? []);
|
||||||
|
if (!chosen.length || uploading) return;
|
||||||
|
setUploading(true);
|
||||||
|
setError("");
|
||||||
|
const urls: string[] = [];
|
||||||
|
try {
|
||||||
|
for (const file of chosen) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("file", file);
|
||||||
|
const url = await uploadMediaAndReturn(fd);
|
||||||
|
if (!url)
|
||||||
|
throw Error(
|
||||||
|
`Could not upload ${file.name}. Check file type and size.`,
|
||||||
|
);
|
||||||
|
urls.push(url);
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
onSelect(urls[urls.length - 1]);
|
||||||
|
setOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
if (urls.length) await load();
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Upload failed. Please retry.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { files, loading, uploading, fileRef, error, load, handleUpload };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user