1
0
Fork 0

feat(roster): implement leadOrActual display preference

- Fetch current user's leadOrActual preference in roster page via payload.auth()
- Add RosterDisplayContext to avoid prop drilling through org chart components
- MemberCard shows 'Lead' or 'Actual' based on user preference
- Add preference radio buttons to PreferencesForm under Display section
- Update updatePreferences server action to save leadOrActual
- Add TODO for restyling radio buttons later
This commit is contained in:
Jason Fraley 2026-08-18 02:20:58 -04:00
parent feccbc0ced
commit 4c3c3373d3
6 changed files with 92 additions and 35 deletions

View file

@ -10,6 +10,7 @@
- [ ] Bidding and auctions – Allow sellers to list items as auctions with timed bidding instead of fixed-price buyouts.
- [ ] Marketplace fees and market price drift – Percentage listing/sale fees and price fluctuations that tend toward each asset's resting `baseBuyPrice`/`baseSellPrice` over time.
- [ ] Implement command palette – A feature that provides users with a list of available commands and allows them to execute them with a single keystroke or gesture. The command palette should be accessible from a designated keybind or gesture and should display a list of relevant commands based on the user's current context.
- [ ] Restyle roster leader label radio buttons in PreferencesForm – The native HTML radio buttons work but look out of place; replace with shadcn RadioGroup or SegmentedControl for visual consistency.
## MVP – Priority Deadline by 8/15

View file

@ -195,6 +195,7 @@ export async function updatePreferences(input: {
mutedAll: boolean;
mutedTypes: string[];
showCallsign: boolean;
leadOrActual: "lead" | "actual";
discordEnabled: boolean;
discordMutedTypes: string[];
}): Promise<ActionResult> {
@ -215,7 +216,10 @@ export async function updatePreferences(input: {
data: {
preferences: {
notifications: { mutedAll: input.mutedAll, mutedTypes },
display: { showCallsign: input.showCallsign },
display: {
showCallsign: input.showCallsign,
leadOrActual: input.leadOrActual,
},
discord: {
enabled: input.discordEnabled,
mutedTypes: discordMutedTypes,

View file

@ -91,6 +91,7 @@ export default async function AccountPage() {
mutedAll={notifications?.mutedAll ?? false}
mutedTypes={notifications?.mutedTypes ?? []}
showCallsign={fullUser.preferences?.display?.showCallsign ?? false}
leadOrActual={fullUser.preferences?.display?.leadOrActual ?? "lead"}
discordEnabled={discord?.enabled ?? false}
discordMutedTypes={discord?.mutedTypes ?? []}
/>

View file

@ -1,5 +1,6 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
import type { Media, Rank, User } from "@/payload-types";
import { UsersIcon } from "lucide-react";
import { RosterView } from "@/components/frontend/roster/RosterView";
@ -31,7 +32,9 @@ export type RosterAssignment = {
};
export default async function RosterPage() {
const headers = await nextHeaders();
const payload = await getPayload({ config });
const { user: currentUser } = await payload.auth({ headers, canSetHeaders: false });
const [profileRes, assignmentRes] = await Promise.all([
payload.find({
@ -128,7 +131,11 @@ export default async function RosterPage() {
</p>
</div>
<RosterView members={members} assignments={assignments} />
<RosterView
members={members}
assignments={assignments}
leadOrActual={currentUser?.preferences?.display?.leadOrActual ?? "lead"}
/>
</div>
);
}

View file

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes";
import { updatePreferences } from "@/app/(frontend)/account/actions";
@ -12,6 +13,7 @@ interface PreferencesFormProps {
mutedAll: boolean;
mutedTypes: string[];
showCallsign: boolean;
leadOrActual: "lead" | "actual";
discordEnabled: boolean;
discordMutedTypes: string[];
}
@ -20,6 +22,7 @@ export function PreferencesForm({
mutedAll: initialMutedAll,
mutedTypes: initialMutedTypes,
showCallsign: initialShowCallsign,
leadOrActual: initialLeadOrActual,
discordEnabled: initialDiscordEnabled,
discordMutedTypes: initialDiscordMutedTypes,
}: PreferencesFormProps) {
@ -27,6 +30,7 @@ export function PreferencesForm({
const [mutedAll, setMutedAll] = useState(initialMutedAll);
const [mutedTypes, setMutedTypes] = useState<string[]>(initialMutedTypes);
const [showCallsign, setShowCallsign] = useState(initialShowCallsign);
const [leadOrActual, setLeadOrActual] = useState<"lead" | "actual">(initialLeadOrActual);
const [discordEnabled, setDiscordEnabled] = useState(initialDiscordEnabled);
const [discordMutedTypes, setDiscordMutedTypes] = useState<string[]>(initialDiscordMutedTypes);
const [error, setError] = useState<string | null>(null);
@ -56,6 +60,7 @@ export function PreferencesForm({
mutedAll,
mutedTypes,
showCallsign,
leadOrActual,
discordEnabled,
discordMutedTypes,
});
@ -105,6 +110,35 @@ export function PreferencesForm({
<Checkbox checked={showCallsign} onCheckedChange={(c) => setShowCallsign(c === true)} />
Show callsign (username) instead of display name
</label>
<div className="flex flex-col gap-2 pl-6">
<Label className="text-sm text-muted-foreground">
Roster leader label
</Label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="leadOrActual"
value="lead"
checked={leadOrActual === "lead"}
onChange={() => setLeadOrActual("lead")}
className="accent-primary"
/>
Lead
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="leadOrActual"
value="actual"
checked={leadOrActual === "actual"}
onChange={() => setLeadOrActual("actual")}
className="accent-primary"
/>
Actual
</label>
</div>
</div>
</div>
<div className="flex flex-col gap-3">

View file

@ -1,11 +1,14 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { cn, pluralize } from "@/lib/utils";
import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Building2Icon, CrownIcon, ListIcon, NetworkIcon, ShieldIcon } from "lucide-react";
const RosterDisplayContext = React.createContext<"lead" | "actual">("lead");
const TYPE_ORDER: Record<string, number> = {
division: 0,
squad: 1,
@ -346,6 +349,9 @@ function AlphabeticalView({ members }: { members: RosterMember[] }) {
// ─── Member Card ────────────────────────────────────────────────────
function MemberCard({ member }: { member: RosterMember }) {
const leadOrActual = React.useContext(RosterDisplayContext);
const leadLabel = leadOrActual === "actual" ? "Actual" : "Lead";
return (
<Link
href={`/profile/${member.user.username}`}
@ -367,7 +373,7 @@ function MemberCard({ member }: { member: RosterMember }) {
<div className="flex items-center gap-1.5 shrink-0">
{member.isLeader && (
<span className="text-[10px] uppercase tracking-wider text-amber-400 bg-amber-950/50 border border-amber-800/50 px-1.5 py-0.5 rounded font-semibold">
Lead
{leadLabel}
</span>
)}
{member.assignmentName && (
@ -385,43 +391,47 @@ function MemberCard({ member }: { member: RosterMember }) {
export function RosterView({
members,
assignments,
leadOrActual = "lead",
}: {
members: RosterMember[];
assignments: RosterAssignment[];
leadOrActual?: "lead" | "actual";
}) {
return (
<Tabs defaultValue="org">
<TabsList variant="line">
<TabsTrigger value="org">
<NetworkIcon className="size-3" />
Org Chart
</TabsTrigger>
<TabsTrigger value="assignment">
<Building2Icon className="size-3" />
By Assignment
</TabsTrigger>
<TabsTrigger value="rank">
<ShieldIcon className="size-3" />
By Rank
</TabsTrigger>
<TabsTrigger value="alpha">
<ListIcon className="size-3" />
A–Z
</TabsTrigger>
</TabsList>
<RosterDisplayContext.Provider value={leadOrActual}>
<Tabs defaultValue="org">
<TabsList variant="line">
<TabsTrigger value="org">
<NetworkIcon className="size-3" />
Org Chart
</TabsTrigger>
<TabsTrigger value="assignment">
<Building2Icon className="size-3" />
By Assignment
</TabsTrigger>
<TabsTrigger value="rank">
<ShieldIcon className="size-3" />
By Rank
</TabsTrigger>
<TabsTrigger value="alpha">
<ListIcon className="size-3" />
A–Z
</TabsTrigger>
</TabsList>
<TabsContent value="org">
<OrgChartView assignments={assignments} members={members} />
</TabsContent>
<TabsContent value="assignment">
<ByAssignmentView assignments={assignments} members={members} />
</TabsContent>
<TabsContent value="rank">
<ByRankView members={members} />
</TabsContent>
<TabsContent value="alpha">
<AlphabeticalView members={members} />
</TabsContent>
</Tabs>
<TabsContent value="org">
<OrgChartView assignments={assignments} members={members} />
</TabsContent>
<TabsContent value="assignment">
<ByAssignmentView assignments={assignments} members={members} />
</TabsContent>
<TabsContent value="rank">
<ByRankView members={members} />
</TabsContent>
<TabsContent value="alpha">
<AlphabeticalView members={members} />
</TabsContent>
</Tabs>
</RosterDisplayContext.Provider>
);
}