diff --git a/app/(dashboard)/users/page.tsx b/app/(dashboard)/users/page.tsx index 7da9b4c..3c7259b 100644 --- a/app/(dashboard)/users/page.tsx +++ b/app/(dashboard)/users/page.tsx @@ -3,7 +3,12 @@ import { useCallback, useState } from "react"; import { Mail } from "lucide-react"; import { toast } from "sonner"; -import { api, AdminError, type AdminUser } from "@/lib/api"; +import { + api, + AdminError, + type AdminUser, + type UserLimitsInput, +} from "@/lib/api"; import { GoogleIcon, FacebookIcon } from "@/components/ui/provider-icons"; import { usePagedList } from "@/lib/use-paged-list"; import { ListShell, EmptyRow } from "@/components/list-shell"; @@ -25,6 +30,7 @@ export default function UsersPage() { const list = usePagedList(fetcher, [q]); const confirm = useConfirm(); const [notify, setNotify] = useState(null); + const [limitsUser, setLimitsUser] = useState(null); async function toggleBan(u: AdminUser) { const banning = !u.banned_at; @@ -51,6 +57,7 @@ export default function UsersPage() { function menuItems(u: AdminUser): MenuItem[] { const items: MenuItem[] = [ { label: "Send notification", onClick: () => setNotify(u) }, + { label: "Edit limits", onClick: () => setLimitsUser(u) }, ]; // No ban option for fellow admins (avoid locking each other out). if (u.role !== "admin") { @@ -127,6 +134,11 @@ export default function UsersPage() { setNotify(null)} /> + setLimitsUser(null)} + onSaved={list.refresh} + /> ); } @@ -163,6 +175,135 @@ function AuthIcons({ user }: { user: AdminUser }) { ); } +// An effective limit of -1 means "unlimited" (premium default) — shown as an +// empty field. Saving an empty field sends null, which resets that limit to +// the plan default on the server. +function limitToField(v: number | undefined): string { + return v === undefined || v < 0 ? "" : String(v); +} + +function LimitsModal({ + user, + onClose, + onSaved, +}: { + user: AdminUser | null; + onClose: () => void; + onSaved: () => void; +}) { + const [bookmark, setBookmark] = useState(""); + const [pub, setPub] = useState(""); + const [priv, setPriv] = useState(""); + const [busy, setBusy] = useState(false); + // Re-seed the fields whenever a different user's modal opens. + const [seededFor, setSeededFor] = useState(null); + if (user && seededFor !== user.id) { + setSeededFor(user.id); + setBookmark(limitToField(user.bookmark_limit)); + setPub(limitToField(user.public_collection_limit)); + setPriv(limitToField(user.private_collection_limit)); + } + if (!user && seededFor !== null) setSeededFor(null); + + // "" → null (reset to plan default); a non-negative integer → that cap. + function parseField(raw: string): number | null | undefined { + const t = raw.trim(); + if (t === "") return null; + const n = Number(t); + if (!Number.isInteger(n) || n < 0) return undefined; // invalid + return n; + } + + async function save() { + if (!user) return; + const b = parseField(bookmark); + const p = parseField(pub); + const v = parseField(priv); + if (b === undefined || p === undefined || v === undefined) { + toast.error("Limits must be whole numbers (0 or more)."); + return; + } + const limits: UserLimitsInput = { + bookmark_limit: b, + public_collection_limit: p, + private_collection_limit: v, + }; + setBusy(true); + try { + await api.setUserLimits(user.id, limits); + toast.success(`Limits updated for @${user.handle}`); + onSaved(); + onClose(); + } catch (err) { + toast.error(err instanceof AdminError ? err.message : "Could not update."); + } finally { + setBusy(false); + } + } + + return ( + +
+

+ How many of each this user can save. Leave a field blank to use their + plan default. +

+ + + +
+ + +
+
+
+ ); +} + +function LimitField({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (v: string) => void; +}) { + return ( + + ); +} + function NotifyModal({ user, onClose, diff --git a/lib/api.ts b/lib/api.ts index 9b84697..a4010ab 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -106,6 +106,19 @@ export type AdminUser = { created_at: string; has_password: boolean; providers: string[]; + /** Effective save limits in force (plan default merged with any override); + * -1 means unlimited. */ + bookmark_limit: number; + public_collection_limit: number; + private_collection_limit: number; +}; + +/** Per-user save-limit override payload. null = reset that limit to the plan + * default; a number caps it for this user. */ +export type UserLimitsInput = { + bookmark_limit: number | null; + public_collection_limit: number | null; + private_collection_limit: number | null; }; export type AdminPost = { @@ -246,6 +259,12 @@ export const api = { unbanUser(id: string) { return call(`/admin/users/${id}/ban`, { method: "DELETE" }); }, + setUserLimits(id: string, limits: UserLimitsInput) { + return call(`/admin/users/${id}/limits`, { + method: "POST", + body: JSON.stringify(limits), + }); + }, posts(q: string, limit: number, offset: number) { return listed(`/admin/posts${qs({ q, limit, offset })}`); },