Improve flag config validation and remove settings completion tracking (#390)
* Fix mobile league settings: prevent sports scroll and remove completion checkmarks - Add overflow-hidden to sport label text span so flex truncation is properly contained - Add min-w-0 to SettingsSection header inner flex item to prevent title/description overflow - Remove completion check marks from mobile grid nav buttons (league settings has no completion concept) - Remove "X of Y set" counter from mobile nav header - Remove isComplete field from SettingsGridSection type and all data https://claude.ai/code/session_01T7iTb9YZLuWJFtKV753tdB * Fix race window in user creation and restore FlagSvg safety fallback auth.server.ts: switch generateId to application-generated UUIDs so the ID is known before the insert, then move flagConfig assignment from create.after (a separate UPDATE) to create.before so it lands in the initial INSERT with no race window. FlagSvg.tsx: validate the config with parseFlagConfig before rendering; corrupt or missing data falls back to a neutral gray triband flag rather than silently rendering blank SVG shapes. https://claude.ai/code/session_01T7iTb9YZLuWJFtKV753tdB --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d342f1ad25
commit
6df4f86920
6 changed files with 38 additions and 46 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import type { LucideProps } from "lucide-react";
|
import type { LucideProps } from "lucide-react";
|
||||||
import { ArrowLeft, Check } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
type SettingsNavSection = {
|
type SettingsNavSection = {
|
||||||
|
|
@ -11,28 +11,22 @@ type SettingsNavSection = {
|
||||||
export type SettingsGridSection = SettingsNavSection & {
|
export type SettingsGridSection = SettingsNavSection & {
|
||||||
icon: ComponentType<LucideProps>;
|
icon: ComponentType<LucideProps>;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
isComplete: boolean;
|
|
||||||
isDanger?: boolean;
|
isDanger?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SettingsMobileGridNav({
|
export function SettingsMobileGridNav({
|
||||||
sections,
|
sections,
|
||||||
completedCount,
|
|
||||||
onSectionChange,
|
onSectionChange,
|
||||||
}: {
|
}: {
|
||||||
sections: readonly SettingsGridSection[];
|
sections: readonly SettingsGridSection[];
|
||||||
completedCount: number;
|
|
||||||
onSectionChange: (sectionId: string) => void;
|
onSectionChange: (sectionId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-5 lg:hidden">
|
<div className="mb-5 lg:hidden">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Manage
|
Manage
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{completedCount} of {sections.length} set
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{sections.map((section) => (
|
{sections.map((section) => (
|
||||||
|
|
@ -41,15 +35,10 @@ export function SettingsMobileGridNav({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSectionChange(section.id)}
|
onClick={() => onSectionChange(section.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 active:scale-[0.98]",
|
"flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 active:scale-[0.98]",
|
||||||
section.isDanger && "border-destructive/40"
|
section.isDanger && "border-destructive/40"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{section.isComplete && (
|
|
||||||
<div className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
|
||||||
<Check className="h-3 w-3" strokeWidth={3} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-10 items-center justify-center rounded-lg",
|
"flex h-10 w-10 items-center justify-center rounded-lg",
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ export function SettingsSection({
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<GradientIcon icon={icon} className="mt-1 h-6 w-6 shrink-0" />
|
<GradientIcon icon={icon} className="mt-1 h-6 w-6 shrink-0" />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">{title}</h2>
|
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">{title}</h2>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">{description}</p>
|
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ export function SportsSection({
|
||||||
disabled={!canEditSports}
|
disabled={!canEditSports}
|
||||||
/>
|
/>
|
||||||
<SportIcon sport={ss.sport} />
|
<SportIcon sport={ss.sport} />
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1 overflow-hidden">
|
||||||
<span className="block truncate font-medium">
|
<span className="block truncate font-medium">
|
||||||
{ss.sport.slug === BRACKT_SLUG ? "Brackt" : `${ss.sport.name} - ${ss.name} (${ss.year})`}
|
{ss.sport.slug === BRACKT_SLUG ? "Brackt" : `${ss.sport.name} - ${ss.name} (${ss.year})`}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import type { FlagConfig } from "~/lib/flag-types";
|
import { parseFlagConfig, type FlagConfig } from "~/lib/flag-types";
|
||||||
|
|
||||||
interface FlagSvgProps {
|
interface FlagSvgProps {
|
||||||
config: FlagConfig;
|
config: FlagConfig;
|
||||||
|
|
@ -8,8 +8,11 @@ interface FlagSvgProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FALLBACK_FLAG: FlagConfig = { pattern: "triband-h", colors: ["#9ca3af", "#6b7280", "#4b5563"] };
|
||||||
|
|
||||||
export function FlagSvg({ config, size = 48, className, title }: FlagSvgProps) {
|
export function FlagSvg({ config, size = 48, className, title }: FlagSvgProps) {
|
||||||
const [c0 = "", c1 = "", c2 = ""] = config.colors;
|
const safeConfig = parseFlagConfig(config) ?? FALLBACK_FLAG;
|
||||||
|
const [c0, c1, c2] = safeConfig.colors;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -21,28 +24,28 @@ export function FlagSvg({ config, size = 48, className, title }: FlagSvgProps) {
|
||||||
className={cn("block shrink-0 overflow-hidden", className)}
|
className={cn("block shrink-0 overflow-hidden", className)}
|
||||||
>
|
>
|
||||||
{title ? <title>{title}</title> : null}
|
{title ? <title>{title}</title> : null}
|
||||||
{config.pattern === "triband-h" && (
|
{safeConfig.pattern === "triband-h" && (
|
||||||
<>
|
<>
|
||||||
<rect width="100" height="34" fill={c0} />
|
<rect width="100" height="34" fill={c0} />
|
||||||
<rect y="33" width="100" height="34" fill={c1} />
|
<rect y="33" width="100" height="34" fill={c1} />
|
||||||
<rect y="66" width="100" height="34" fill={c2} />
|
<rect y="66" width="100" height="34" fill={c2} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{config.pattern === "triband-v" && (
|
{safeConfig.pattern === "triband-v" && (
|
||||||
<>
|
<>
|
||||||
<rect width="34" height="100" fill={c0} />
|
<rect width="34" height="100" fill={c0} />
|
||||||
<rect x="33" width="34" height="100" fill={c1} />
|
<rect x="33" width="34" height="100" fill={c1} />
|
||||||
<rect x="66" width="34" height="100" fill={c2} />
|
<rect x="66" width="34" height="100" fill={c2} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{config.pattern === "diagonal" && (
|
{safeConfig.pattern === "diagonal" && (
|
||||||
<>
|
<>
|
||||||
<rect width="100" height="100" fill={c0} />
|
<rect width="100" height="100" fill={c0} />
|
||||||
<polygon points="0,0 100,0 0,100" fill={c1} />
|
<polygon points="0,0 100,0 0,100" fill={c1} />
|
||||||
<polygon points="100,100 100,28 28,100" fill={c2} />
|
<polygon points="100,100 100,28 28,100" fill={c2} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{config.pattern === "nordic-cross" && (
|
{safeConfig.pattern === "nordic-cross" && (
|
||||||
<>
|
<>
|
||||||
<rect width="100" height="100" fill={c0} />
|
<rect width="100" height="100" fill={c0} />
|
||||||
<rect x="30" width="18" height="100" fill={c1} />
|
<rect x="30" width="18" height="100" fill={c1} />
|
||||||
|
|
@ -51,7 +54,7 @@ export function FlagSvg({ config, size = 48, className, title }: FlagSvgProps) {
|
||||||
<rect y="46" width="100" height="6" fill={c2} />
|
<rect y="46" width="100" height="6" fill={c2} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{config.pattern === "cross" && (
|
{safeConfig.pattern === "cross" && (
|
||||||
<>
|
<>
|
||||||
<rect width="100" height="100" fill={c0} />
|
<rect width="100" height="100" fill={c0} />
|
||||||
<rect x="40" width="20" height="100" fill={c1} />
|
<rect x="40" width="20" height="100" fill={c1} />
|
||||||
|
|
@ -60,28 +63,28 @@ export function FlagSvg({ config, size = 48, className, title }: FlagSvgProps) {
|
||||||
<rect y="46" width="100" height="8" fill={c2} />
|
<rect y="46" width="100" height="8" fill={c2} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{config.pattern === "canton" && (
|
{safeConfig.pattern === "canton" && (
|
||||||
<>
|
<>
|
||||||
<rect width="100" height="100" fill={c0} />
|
<rect width="100" height="100" fill={c0} />
|
||||||
<rect width="50" height="50" fill={c1} />
|
<rect width="50" height="50" fill={c1} />
|
||||||
<rect x="12" y="12" width="26" height="26" fill={c2} />
|
<rect x="12" y="12" width="26" height="26" fill={c2} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{config.pattern === "triangle" && (
|
{safeConfig.pattern === "triangle" && (
|
||||||
<>
|
<>
|
||||||
<rect width="100" height="100" fill={c0} />
|
<rect width="100" height="100" fill={c0} />
|
||||||
<polygon points="0,0 64,50 0,100" fill={c1} />
|
<polygon points="0,0 64,50 0,100" fill={c1} />
|
||||||
<polygon points="0,22 36,50 0,78" fill={c2} />
|
<polygon points="0,22 36,50 0,78" fill={c2} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{config.pattern === "chevron" && (
|
{safeConfig.pattern === "chevron" && (
|
||||||
<>
|
<>
|
||||||
<rect width="100" height="100" fill={c0} />
|
<rect width="100" height="100" fill={c0} />
|
||||||
<polygon points="0,0 56,50 0,100 28,100 84,50 28,0" fill={c1} />
|
<polygon points="0,0 56,50 0,100 28,100 84,50 28,0" fill={c1} />
|
||||||
<polygon points="0,22 32,50 0,78 0,62 14,50 0,38" fill={c2} />
|
<polygon points="0,22 32,50 0,78 0,62 14,50 0,38" fill={c2} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{config.pattern === "quartered" && (
|
{safeConfig.pattern === "quartered" && (
|
||||||
<>
|
<>
|
||||||
<rect width="50" height="50" fill={c0} />
|
<rect width="50" height="50" fill={c0} />
|
||||||
<rect x="50" width="50" height="50" fill={c1} />
|
<rect x="50" width="50" height="50" fill={c1} />
|
||||||
|
|
@ -89,21 +92,21 @@ export function FlagSvg({ config, size = 48, className, title }: FlagSvgProps) {
|
||||||
<rect x="50" y="50" width="50" height="50" fill={c2} />
|
<rect x="50" y="50" width="50" height="50" fill={c2} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{config.pattern === "bordered" && (
|
{safeConfig.pattern === "bordered" && (
|
||||||
<>
|
<>
|
||||||
<rect width="100" height="100" fill={c0} />
|
<rect width="100" height="100" fill={c0} />
|
||||||
<rect x="12" y="12" width="76" height="76" fill={c1} />
|
<rect x="12" y="12" width="76" height="76" fill={c1} />
|
||||||
<rect x="24" y="24" width="52" height="52" fill={c2} />
|
<rect x="24" y="24" width="52" height="52" fill={c2} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{config.pattern === "circle" && (
|
{safeConfig.pattern === "circle" && (
|
||||||
<>
|
<>
|
||||||
<rect width="100" height="100" fill={c0} />
|
<rect width="100" height="100" fill={c0} />
|
||||||
<circle cx="50" cy="50" r="30" fill={c1} />
|
<circle cx="50" cy="50" r="30" fill={c1} />
|
||||||
<circle cx="50" cy="50" r="15" fill={c2} />
|
<circle cx="50" cy="50" r="15" fill={c2} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{config.pattern === "star" && (
|
{safeConfig.pattern === "star" && (
|
||||||
<>
|
<>
|
||||||
<rect width="100" height="100" fill={c0} />
|
<rect width="100" height="100" fill={c0} />
|
||||||
<polygon
|
<polygon
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,13 @@ export const auth = betterAuth({
|
||||||
databaseHooks: {
|
databaseHooks: {
|
||||||
user: {
|
user: {
|
||||||
create: {
|
create: {
|
||||||
after: async (user) => {
|
before: async (data) => {
|
||||||
await db
|
return {
|
||||||
.update(schema.users)
|
data: {
|
||||||
.set({ flagConfig: generateFlagConfig(user.id) })
|
...data,
|
||||||
.where(eq(schema.users.id, user.id));
|
flagConfig: generateFlagConfig(data.id),
|
||||||
|
} as typeof data,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
|
|
@ -63,7 +65,7 @@ export const auth = betterAuth({
|
||||||
},
|
},
|
||||||
advanced: {
|
advanced: {
|
||||||
database: {
|
database: {
|
||||||
generateId: false,
|
generateId: () => crypto.randomUUID(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
|
|
|
||||||
|
|
@ -307,16 +307,15 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
||||||
};
|
};
|
||||||
|
|
||||||
const sectionGridData: SettingsGridSection[] = [
|
const sectionGridData: SettingsGridSection[] = [
|
||||||
{ id: "league-basics", label: "League Basics", icon: Shield, subtitle: "Name, teams, join", isComplete: true },
|
{ id: "league-basics", label: "League Basics", icon: Shield, subtitle: "Name, teams, join" },
|
||||||
{ id: "draft-setup", label: "Draft Settings", icon: Swords, subtitle: "Format, timing, rounds", isComplete: !!(draftDate && draftTime) },
|
{ id: "draft-setup", label: "Draft Settings", icon: Swords, subtitle: "Format, timing, rounds" },
|
||||||
{ id: "draft-order", label: "Draft Order", icon: ListOrdered, subtitle: "Pick order, randomize", isComplete: draftOrderSet },
|
{ id: "draft-order", label: "Draft Order", icon: ListOrdered, subtitle: "Pick order, randomize" },
|
||||||
{ id: "sports", label: "Sports", icon: Trophy, subtitle: `${selectedSports.size} season${selectedSports.size !== 1 ? "s" : ""} selected`, isComplete: selectedSports.size > 0 },
|
{ id: "sports", label: "Sports", icon: Trophy, subtitle: `${selectedSports.size} season${selectedSports.size !== 1 ? "s" : ""} selected` },
|
||||||
{ id: "scoring", label: "Scoring", icon: TrendingUp, subtitle: "Place values, ties", isComplete: true },
|
{ id: "scoring", label: "Scoring", icon: TrendingUp, subtitle: "Place values, ties" },
|
||||||
{ id: "notifications", label: "Notifications", icon: Bell, subtitle: "Discord alerts", isComplete: !!league.discordWebhookUrl },
|
{ id: "notifications", label: "Notifications", icon: Bell, subtitle: "Discord alerts" },
|
||||||
{ id: "people", label: "People", icon: Users, subtitle: `${teamsWithOwners} of ${teamCountValue} teams`, isComplete: false },
|
{ id: "people", label: "People", icon: Users, subtitle: `${teamsWithOwners} of ${teamCountValue} teams` },
|
||||||
{ id: "history-danger", label: "History & Danger", icon: AlertTriangle, subtitle: "Audit log, dissolve", isComplete: false, isDanger: true },
|
{ id: "history-danger", label: "History & Danger", icon: AlertTriangle, subtitle: "Audit log, dissolve", isDanger: true },
|
||||||
];
|
];
|
||||||
const completedSectionCount = sectionGridData.filter((s) => s.isComplete).length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-5xl px-4 py-6 sm:py-8">
|
<div className="container mx-auto max-w-5xl px-4 py-6 sm:py-8">
|
||||||
|
|
@ -328,7 +327,6 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
<SettingsMobileGridNav
|
<SettingsMobileGridNav
|
||||||
sections={sectionGridData}
|
sections={sectionGridData}
|
||||||
completedCount={completedSectionCount}
|
|
||||||
onSectionChange={(id) => handleSectionChange(id as SettingsSectionId, { mobile: true })}
|
onSectionChange={(id) => handleSectionChange(id as SettingsSectionId, { mobile: true })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue