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
bf52c56f5a
commit
bbcbc86035
1 changed files with 298 additions and 117 deletions
|
|
@ -27,9 +27,20 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
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 { PageTabBar } from "@/components/PageTabBar";
|
||||||
import { PageSkeleton } from "@/components/PageSkeleton";
|
import { PageSkeleton } from "@/components/PageSkeleton";
|
||||||
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
import { StarRating } from "@/components/StarRating";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* VersionDiff component */
|
/* VersionDiff component */
|
||||||
|
|
@ -76,6 +87,10 @@ export function SkillDetail() {
|
||||||
const [versionA, setVersionA] = useState<string>("");
|
const [versionA, setVersionA] = useState<string>("");
|
||||||
const [versionB, setVersionB] = 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
|
// Install dialog state — used for both install and update flows
|
||||||
const [installDialog, setInstallDialog] = useState<{
|
const [installDialog, setInstallDialog] = useState<{
|
||||||
skillId: string;
|
skillId: string;
|
||||||
|
|
@ -102,6 +117,16 @@ export function SkillDetail() {
|
||||||
enabled: !!skillId,
|
enabled: !!skillId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: ratings = [],
|
||||||
|
isLoading: ratingsLoading,
|
||||||
|
isError: ratingsError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["skill-ratings", skillId],
|
||||||
|
queryFn: () => skillRegistryApi.getRatings(skillId),
|
||||||
|
enabled: !!skillId,
|
||||||
|
});
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
/* Breadcrumbs */
|
/* 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 */
|
/* Loading / error states */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
@ -274,12 +317,14 @@ export function SkillDetail() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Detail tabs */}
|
{/* Detail tabs */}
|
||||||
|
<TooltipProvider>
|
||||||
<Tabs value={detailTab} onValueChange={setDetailTab}>
|
<Tabs value={detailTab} onValueChange={setDetailTab}>
|
||||||
<PageTabBar
|
<PageTabBar
|
||||||
items={[
|
items={[
|
||||||
{ value: "overview", label: "Overview" },
|
{ value: "overview", label: "Overview" },
|
||||||
{ value: "versions", label: "Versions" },
|
{ value: "versions", label: "Versions" },
|
||||||
{ value: "diff", label: "Diff" },
|
{ value: "diff", label: "Diff" },
|
||||||
|
{ value: "ratings", label: "Ratings" },
|
||||||
]}
|
]}
|
||||||
value={detailTab}
|
value={detailTab}
|
||||||
onValueChange={setDetailTab}
|
onValueChange={setDetailTab}
|
||||||
|
|
@ -291,11 +336,11 @@ export function SkillDetail() {
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<span className="text-xs text-muted-foreground">Installs</span>
|
<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>
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<span className="text-xs text-muted-foreground">Average rating</span>
|
<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 ? (
|
{skill.averageRating != null ? (
|
||||||
<>
|
<>
|
||||||
<Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" />
|
<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 && (
|
{skill.averageRating != null && skill.ratingCount != null && (
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<span className="text-xs text-muted-foreground">Community</span>
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
|
|
@ -324,6 +369,25 @@ export function SkillDetail() {
|
||||||
: "\u2014"}
|
: "\u2014"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
@ -398,7 +462,124 @@ export function SkillDetail() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
{/* Install / Update dialog */}
|
{/* Install / Update dialog */}
|
||||||
<Dialog open={!!installDialog} onOpenChange={(open) => !open && setInstallDialog(null)}>
|
<Dialog open={!!installDialog} onOpenChange={(open) => !open && setInstallDialog(null)}>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue