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:
@@ -37,13 +37,6 @@ const TRIGGER_EVENT_LABELS: Record<string, string> = {
|
|||||||
TRIVIA_WIN: "Trivia Win",
|
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 {
|
function getTriggerEventLabel(triggerEvent: string): string {
|
||||||
return TRIGGER_EVENT_LABELS[triggerEvent] || triggerEvent;
|
return TRIGGER_EVENT_LABELS[triggerEvent] || triggerEvent;
|
||||||
}
|
}
|
||||||
@@ -71,7 +64,7 @@ function TruncatedText({ text, maxLength = 100 }: { text: string; maxLength?: nu
|
|||||||
|
|
||||||
function QuestTableSkeleton() {
|
function QuestTableSkeleton() {
|
||||||
return (
|
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">
|
<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-8" />
|
||||||
<Skeleton className="h-4 w-32" />
|
<Skeleton className="h-4 w-32" />
|
||||||
@@ -98,7 +91,7 @@ function QuestTableSkeleton() {
|
|||||||
|
|
||||||
function EmptyQuestState() {
|
function EmptyQuestState() {
|
||||||
return (
|
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">
|
<div className="rounded-full bg-muted/50 p-4 mb-4">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -127,37 +120,142 @@ function EmptyQuestState() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuestTable({ quests, isLoading, onRefresh }: QuestTableProps) {
|
function QuestTableContent({ quests }: { quests: QuestListItem[] }) {
|
||||||
if (isLoading) {
|
if (quests.length === 0) {
|
||||||
return (
|
return <EmptyQuestState />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-16">
|
||||||
|
ID
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-40">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-64">
|
||||||
|
Description
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-36">
|
||||||
|
Trigger Event
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-20">
|
||||||
|
Target
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-32">
|
||||||
|
XP Reward
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-32">
|
||||||
|
AU Reward
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{quests.map((quest) => {
|
||||||
|
const requirements = quest.requirements as { target?: number };
|
||||||
|
const rewards = quest.rewards as { xp?: number; balance?: number };
|
||||||
|
const target = requirements?.target || 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={quest.id}
|
||||||
|
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}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm font-medium text-foreground">
|
||||||
|
{quest.name}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-muted-foreground">
|
||||||
|
<TruncatedText text={quest.description || ""} maxLength={50} />
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<Badge variant="outline" className="text-xs border-border/50">
|
||||||
|
{getTriggerEventLabel(quest.triggerEvent)}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-foreground font-mono">
|
||||||
|
{target}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-foreground">
|
||||||
|
{rewards?.xp ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span>⭐</span>
|
||||||
|
<span className="font-mono">{rewards.xp}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-foreground">
|
||||||
|
{rewards?.balance ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span>🪙</span>
|
||||||
|
<span className="font-mono">{rewards.balance}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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 (
|
return (
|
||||||
<Card className="glass-card overflow-hidden">
|
<Card className="glass-card overflow-hidden">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-xl font-bold text-primary">Quest Inventory</CardTitle>
|
<CardTitle className="text-xl font-bold text-primary">Quest Inventory</CardTitle>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="outline" className="border-border/50">
|
{isLoading ? (
|
||||||
{quests.length} quest{quests.length !== 1 ? "s" : ""}
|
<Badge variant="secondary" className="animate-pulse">
|
||||||
</Badge>
|
Loading...
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="border-border/50">
|
||||||
|
{quests.length} quest{quests.length !== 1 ? "s" : ""}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
onClick={handleRefresh}
|
||||||
className="p-2 rounded-md hover:bg-muted/50 transition-colors"
|
disabled={isRefreshing}
|
||||||
|
className={cn(
|
||||||
|
"p-2 rounded-md hover:bg-muted/50 transition-colors",
|
||||||
|
isRefreshing && "cursor-wait"
|
||||||
|
)}
|
||||||
title="Refresh quests"
|
title="Refresh quests"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -170,7 +268,10 @@ export function QuestTable({ quests, isLoading, onRefresh }: QuestTableProps) {
|
|||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className={cn("text-muted-foreground", isLoading && "animate-spin")}
|
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="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 3v5h5" />
|
||||||
@@ -182,90 +283,10 @@ export function QuestTable({ quests, isLoading, onRefresh }: QuestTableProps) {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{quests.length === 0 ? (
|
{isLoading ? (
|
||||||
<EmptyQuestState />
|
<QuestTableSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<QuestTableContent quests={displayQuests} />
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border/50">
|
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-16">
|
|
||||||
ID
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-40">
|
|
||||||
Name
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-64">
|
|
||||||
Description
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-36">
|
|
||||||
Trigger Event
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-20">
|
|
||||||
Target
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-32">
|
|
||||||
XP Reward
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground w-32">
|
|
||||||
AU Reward
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{quests.map((quest) => {
|
|
||||||
const requirements = quest.requirements as { target?: number };
|
|
||||||
const rewards = quest.rewards as { xp?: number; balance?: number };
|
|
||||||
const target = requirements?.target || 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={quest.id}
|
|
||||||
className="border-b border-border/30 hover:bg-muted/20 transition-colors"
|
|
||||||
>
|
|
||||||
<td className="py-3 px-4 text-sm text-muted-foreground font-mono">
|
|
||||||
#{quest.id}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm font-medium text-foreground">
|
|
||||||
{quest.name}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-muted-foreground">
|
|
||||||
<TruncatedText text={quest.description || ""} maxLength={50} />
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
<Badge variant="outline" className="text-xs border-border/50">
|
|
||||||
{getTriggerEventLabel(quest.triggerEvent)}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-foreground font-mono">
|
|
||||||
{target}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-foreground">
|
|
||||||
{rewards?.xp ? (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span>⭐</span>
|
|
||||||
<span className="font-mono">{rewards.xp}</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-foreground">
|
|
||||||
{rewards?.balance ? (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span>🪙</span>
|
|
||||||
<span className="font-mono">{rewards.balance}</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user