fix: (web) improve quest table refresh UX

- Keep card visible during refresh to prevent flicker
- Add smooth animations when content loads
- Spin refresh icon independently from skeleton
- Show skeleton in place without replacing entire card
This commit is contained in:
syntaxbullet
2026-01-16 15:16:48 +01:00
parent 3ef9773990
commit 94e332ba57

View File

@@ -37,13 +37,6 @@ const TRIGGER_EVENT_LABELS: Record<string, string> = {
TRIVIA_WIN: "Trivia Win",
};
function formatQuestRewards(rewards: { xp?: number; balance?: number }): string {
const parts: string[] = [];
if (rewards?.xp) parts.push(`${rewards.xp}`);
if (rewards?.balance) parts.push(`${rewards.balance} 🪙`);
return parts.join(" • ") || "None";
}
function getTriggerEventLabel(triggerEvent: string): string {
return TRIGGER_EVENT_LABELS[triggerEvent] || triggerEvent;
}
@@ -71,7 +64,7 @@ function TruncatedText({ text, maxLength = 100 }: { text: string; maxLength?: nu
function QuestTableSkeleton() {
return (
<div className="space-y-3">
<div className="space-y-3 animate-pulse">
<div className="grid grid-cols-7 gap-4 px-4 py-2 text-sm font-medium text-muted-foreground">
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-32" />
@@ -98,7 +91,7 @@ function QuestTableSkeleton() {
function EmptyQuestState() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="flex flex-col items-center justify-center py-12 text-center animate-in fade-in duration-500">
<div className="rounded-full bg-muted/50 p-4 mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -127,65 +120,13 @@ function EmptyQuestState() {
);
}
export function QuestTable({ quests, isLoading, onRefresh }: QuestTableProps) {
if (isLoading) {
return (
<Card className="glass-card overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-bold text-primary">Quest Inventory</CardTitle>
<Badge variant="secondary" className="animate-pulse">
Loading...
</Badge>
</div>
</CardHeader>
<CardContent>
<QuestTableSkeleton />
</CardContent>
</Card>
);
function QuestTableContent({ quests }: { quests: QuestListItem[] }) {
if (quests.length === 0) {
return <EmptyQuestState />;
}
return (
<Card className="glass-card overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-bold text-primary">Quest Inventory</CardTitle>
<div className="flex items-center gap-3">
<Badge variant="outline" className="border-border/50">
{quests.length} quest{quests.length !== 1 ? "s" : ""}
</Badge>
<button
onClick={onRefresh}
className="p-2 rounded-md hover:bg-muted/50 transition-colors"
title="Refresh quests"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("text-muted-foreground", isLoading && "animate-spin")}
>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
</button>
</div>
</div>
</CardHeader>
<CardContent>
{quests.length === 0 ? (
<EmptyQuestState />
) : (
<div className="overflow-x-auto">
<div className="overflow-x-auto animate-in fade-in slide-in-from-bottom-2 duration-300">
<table className="w-full">
<thead>
<tr className="border-b border-border/50">
@@ -221,7 +162,8 @@ export function QuestTable({ quests, isLoading, onRefresh }: QuestTableProps) {
return (
<tr
key={quest.id}
className="border-b border-border/30 hover:bg-muted/20 transition-colors"
id={`quest-row-${quest.id}`}
className="border-b border-border/30 hover:bg-muted/20 transition-colors animate-in fade-in slide-in-from-left-2 duration-300"
>
<td className="py-3 px-4 text-sm text-muted-foreground font-mono">
#{quest.id}
@@ -266,6 +208,85 @@ export function QuestTable({ quests, isLoading, onRefresh }: QuestTableProps) {
</tbody>
</table>
</div>
);
}
export function QuestTable({ quests, isLoading, onRefresh }: QuestTableProps) {
const [isRefreshing, setIsRefreshing] = React.useState(false);
const previousQuestsRef = React.useRef<QuestListItem[]>(quests);
const [displayQuests, setDisplayQuests] = React.useState<QuestListItem[]>(quests);
React.useEffect(() => {
if (isLoading) {
setIsRefreshing(true);
} else {
setIsRefreshing(false);
previousQuestsRef.current = quests;
if (quests.length !== previousQuestsRef.current.length ||
JSON.stringify(quests) !== JSON.stringify(previousQuestsRef.current)) {
setDisplayQuests(quests);
}
}
}, [isLoading, quests]);
const handleRefresh = async () => {
setIsRefreshing(true);
onRefresh?.();
};
return (
<Card className="glass-card overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-bold text-primary">Quest Inventory</CardTitle>
<div className="flex items-center gap-3">
{isLoading ? (
<Badge variant="secondary" className="animate-pulse">
Loading...
</Badge>
) : (
<Badge variant="outline" className="border-border/50">
{quests.length} quest{quests.length !== 1 ? "s" : ""}
</Badge>
)}
<button
onClick={handleRefresh}
disabled={isRefreshing}
className={cn(
"p-2 rounded-md hover:bg-muted/50 transition-colors",
isRefreshing && "cursor-wait"
)}
title="Refresh quests"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
"text-muted-foreground transition-transform",
isRefreshing && "animate-spin"
)}
>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
</button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<QuestTableSkeleton />
) : (
<QuestTableContent quests={displayQuests} />
)}
</CardContent>
</Card>