feat(12-02): SkillDetail Ratings tab and Overview usage stats
- Add Ratings tab (4th tab) with rate form, personal history, community section - Overview tab: conditionally render taskCount, avgCostUsd, lastUsedAt rows - Import StarRating, Separator, Skeleton, Textarea, EmptyState, TooltipProvider - saveRatingMutation calls addRating, invalidates ratings query on success - ratingsQuery loads personal history with loading/empty/error states
This commit is contained in:
parent
86d4de87e3
commit
7d96ecd339
1 changed files with 298 additions and 117 deletions
|
|
@ -27,9 +27,20 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { PageTabBar } from "@/components/PageTabBar";
|
||||
import { PageSkeleton } from "@/components/PageSkeleton";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { StarRating } from "@/components/StarRating";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* VersionDiff component */
|
||||
|
|
@ -76,6 +87,10 @@ export function SkillDetail() {
|
|||
const [versionA, setVersionA] = useState<string>("");
|
||||
const [versionB, setVersionB] = useState<string>("");
|
||||
|
||||
// Rating form state
|
||||
const [pendingStars, setPendingStars] = useState(0);
|
||||
const [pendingNote, setPendingNote] = useState("");
|
||||
|
||||
// Install dialog state — used for both install and update flows
|
||||
const [installDialog, setInstallDialog] = useState<{
|
||||
skillId: string;
|
||||
|
|
@ -102,6 +117,16 @@ export function SkillDetail() {
|
|||
enabled: !!skillId,
|
||||
});
|
||||
|
||||
const {
|
||||
data: ratings = [],
|
||||
isLoading: ratingsLoading,
|
||||
isError: ratingsError,
|
||||
} = useQuery({
|
||||
queryKey: ["skill-ratings", skillId],
|
||||
queryFn: () => skillRegistryApi.getRatings(skillId),
|
||||
enabled: !!skillId,
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Breadcrumbs */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
|
@ -173,6 +198,24 @@ export function SkillDetail() {
|
|||
},
|
||||
});
|
||||
|
||||
const saveRatingMutation = useMutation({
|
||||
mutationFn: ({ stars, note }: { stars: number; note: string }) =>
|
||||
skillRegistryApi.addRating(skillId, {
|
||||
stars,
|
||||
versionId: skill?.activeVersionId ?? undefined,
|
||||
note: note.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["skill-ratings", skillId] });
|
||||
setPendingStars(0);
|
||||
setPendingNote("");
|
||||
pushToast({ title: "Rating saved", tone: "success" });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
pushToast({ title: "Failed to save rating", body: err.message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Loading / error states */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
|
@ -274,12 +317,14 @@ export function SkillDetail() {
|
|||
)}
|
||||
|
||||
{/* Detail tabs */}
|
||||
<TooltipProvider>
|
||||
<Tabs value={detailTab} onValueChange={setDetailTab}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "overview", label: "Overview" },
|
||||
{ value: "versions", label: "Versions" },
|
||||
{ value: "diff", label: "Diff" },
|
||||
{ value: "ratings", label: "Ratings" },
|
||||
]}
|
||||
value={detailTab}
|
||||
onValueChange={setDetailTab}
|
||||
|
|
@ -291,11 +336,11 @@ export function SkillDetail() {
|
|||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-muted-foreground">Installs</span>
|
||||
<span className="text-sm font-medium">{skill.ratingCount ?? "\u2014"}</span>
|
||||
<span className="text-sm font-semibold">{skill.ratingCount ?? "\u2014"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-muted-foreground">Average rating</span>
|
||||
<span className="flex items-center gap-1 text-sm font-medium">
|
||||
<span className="flex items-center gap-1 text-sm font-semibold">
|
||||
{skill.averageRating != null ? (
|
||||
<>
|
||||
<Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" />
|
||||
|
|
@ -309,7 +354,7 @@ export function SkillDetail() {
|
|||
{skill.averageRating != null && skill.ratingCount != null && (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-muted-foreground">Community</span>
|
||||
<span className="text-sm font-medium">({skill.ratingCount} ratings)</span>
|
||||
<span className="text-sm font-semibold">({skill.ratingCount} ratings)</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
|
|
@ -324,6 +369,25 @@ export function SkillDetail() {
|
|||
: "\u2014"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Usage stats — Phase 12 */}
|
||||
{skill.taskCount != null && skill.taskCount > 0 && (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-muted-foreground">Tasks completed</span>
|
||||
<span className="text-sm font-semibold">{skill.taskCount}</span>
|
||||
</div>
|
||||
)}
|
||||
{skill.avgCostUsd != null && (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-muted-foreground">Avg task cost</span>
|
||||
<span className="text-sm font-semibold">${skill.avgCostUsd.toFixed(4)}</span>
|
||||
</div>
|
||||
)}
|
||||
{skill.lastUsedAt != null && (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-muted-foreground">Last used</span>
|
||||
<span className="text-sm font-semibold">{relativeTime(new Date(skill.lastUsedAt))}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
|
@ -398,7 +462,124 @@ export function SkillDetail() {
|
|||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Ratings tab */}
|
||||
<TabsContent value="ratings" className="pt-4 space-y-6">
|
||||
{/* Section 1: Rate this skill */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Rate this skill
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<StarRating value={pendingStars} onChange={setPendingStars} size="md" />
|
||||
<Textarea
|
||||
placeholder="Add a note about this version\u2026"
|
||||
value={pendingNote}
|
||||
onChange={(e) => setPendingNote(e.target.value)}
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
disabled={pendingStars === 0 || saveRatingMutation.isPending}
|
||||
onClick={() => {
|
||||
if (pendingStars === 0) return;
|
||||
saveRatingMutation.mutate({ stars: pendingStars, note: pendingNote });
|
||||
}}
|
||||
>
|
||||
{saveRatingMutation.isPending ? "Saving\u2026" : "Save rating"}
|
||||
</Button>
|
||||
{saveRatingMutation.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{(saveRatingMutation.error as Error).message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Section 2: Your Rating History */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Your Rating History
|
||||
</h2>
|
||||
{ratingsError && (
|
||||
<p className="text-sm text-destructive">Failed to load ratings.</p>
|
||||
)}
|
||||
{ratingsLoading && (
|
||||
<div aria-hidden="true" className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
)}
|
||||
{!ratingsLoading && !ratingsError && ratings.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Star}
|
||||
message="No ratings yet"
|
||||
/>
|
||||
)}
|
||||
{!ratingsLoading && !ratingsError && ratings.length > 0 && (
|
||||
<ScrollArea className="max-h-80">
|
||||
<div className="space-y-1">
|
||||
{ratings.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="flex items-start justify-between py-2 px-2 rounded hover:bg-accent/50"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<StarRating value={r.stars} readonly size="sm" />
|
||||
{r.versionId && (
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{r.versionId.slice(-7)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{r.note && (
|
||||
<p className="text-sm text-muted-foreground">{r.note}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0 ml-4">
|
||||
{relativeTime(new Date(r.createdAt))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Section 3: Community Ratings */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Community Ratings
|
||||
</h2>
|
||||
{skill.averageRating != null ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-muted-foreground">Average</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<StarRating value={Math.round(skill.averageRating)} readonly size="sm" />
|
||||
<span className="text-sm font-semibold">{skill.averageRating.toFixed(1)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-muted-foreground">Ratings</span>
|
||||
<span className="text-sm font-semibold">{skill.ratingCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No community ratings available.</p>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Install / Update dialog */}
|
||||
<Dialog open={!!installDialog} onOpenChange={(open) => !open && setInstallDialog(null)}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue