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
bea6144e5a
commit
67568a08f6
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,131 +317,269 @@ export function SkillDetail() {
|
|||
)}
|
||||
|
||||
{/* Detail tabs */}
|
||||
<Tabs value={detailTab} onValueChange={setDetailTab}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "overview", label: "Overview" },
|
||||
{ value: "versions", label: "Versions" },
|
||||
{ value: "diff", label: "Diff" },
|
||||
]}
|
||||
value={detailTab}
|
||||
onValueChange={setDetailTab}
|
||||
align="start"
|
||||
/>
|
||||
<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}
|
||||
align="start"
|
||||
/>
|
||||
|
||||
{/* Overview tab */}
|
||||
<TabsContent value="overview" className="space-y-1 pt-4">
|
||||
<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>
|
||||
</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">
|
||||
{skill.averageRating != null ? (
|
||||
<>
|
||||
<Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" />
|
||||
{skill.averageRating.toFixed(1)}
|
||||
</>
|
||||
) : (
|
||||
"No ratings yet"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{skill.averageRating != null && skill.ratingCount != null && (
|
||||
{/* Overview tab */}
|
||||
<TabsContent value="overview" className="space-y-1 pt-4">
|
||||
<div className="space-y-1">
|
||||
<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-xs text-muted-foreground">Installs</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">Source</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{skill.sourceId}</span>
|
||||
<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-semibold">
|
||||
{skill.averageRating != null ? (
|
||||
<>
|
||||
<Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" />
|
||||
{skill.averageRating.toFixed(1)}
|
||||
</>
|
||||
) : (
|
||||
"No ratings yet"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{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-semibold">({skill.ratingCount} ratings)</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-muted-foreground">Source</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{skill.sourceId}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-muted-foreground">Active version</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{skill.activeVersionId
|
||||
? `v${skill.activeVersionId.split("@").pop() ?? skill.activeVersionId}`
|
||||
: "\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>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-muted-foreground">Active version</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{skill.activeVersionId
|
||||
? `v${skill.activeVersionId.split("@").pop() ?? skill.activeVersionId}`
|
||||
: "\u2014"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</TabsContent>
|
||||
|
||||
{/* Versions tab */}
|
||||
<TabsContent value="versions" className="pt-4">
|
||||
{versions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No versions fetched yet.</p>
|
||||
) : (
|
||||
<ScrollArea className="max-h-96">
|
||||
<div className="space-y-1">
|
||||
{versions.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className="flex items-center justify-between py-2 px-2 rounded hover:bg-accent/50"
|
||||
>
|
||||
<div>
|
||||
<span className="text-xs font-mono text-muted-foreground">{v.version}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{relativeTime(new Date(v.fetchedAt))}
|
||||
</span>
|
||||
{/* Versions tab */}
|
||||
<TabsContent value="versions" className="pt-4">
|
||||
{versions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No versions fetched yet.</p>
|
||||
) : (
|
||||
<ScrollArea className="max-h-96">
|
||||
<div className="space-y-1">
|
||||
{versions.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className="flex items-center justify-between py-2 px-2 rounded hover:bg-accent/50"
|
||||
>
|
||||
<div>
|
||||
<span className="text-xs font-mono text-muted-foreground">{v.version}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{relativeTime(new Date(v.fetchedAt))}
|
||||
</span>
|
||||
</div>
|
||||
{v.id === skill.activeVersionId && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{v.id === skill.activeVersionId && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Diff tab */}
|
||||
<TabsContent value="diff" className="pt-4 space-y-4">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Select value={versionA} onValueChange={setVersionA}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Select version A" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{versions.map((v) => (
|
||||
<SelectItem key={v.id} value={v.id}>
|
||||
{v.version}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={versionB} onValueChange={setVersionB}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Select version B" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{versions.map((v) => (
|
||||
<SelectItem key={v.id} value={v.id}>
|
||||
{v.version}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{versionA && versionB ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Version diff requires file content endpoint (coming in a future update).
|
||||
</p>
|
||||
<VersionDiff oldContent="" newContent="" />
|
||||
{/* Diff tab */}
|
||||
<TabsContent value="diff" className="pt-4 space-y-4">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Select value={versionA} onValueChange={setVersionA}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Select version A" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{versions.map((v) => (
|
||||
<SelectItem key={v.id} value={v.id}>
|
||||
{v.version}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={versionB} onValueChange={setVersionB}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Select version B" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{versions.map((v) => (
|
||||
<SelectItem key={v.id} value={v.id}>
|
||||
{v.version}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select two versions above to compare them.
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{versionA && versionB ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Version diff requires file content endpoint (coming in a future update).
|
||||
</p>
|
||||
<VersionDiff oldContent="" newContent="" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select two versions above to compare them.
|
||||
</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