Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions src/lib/reviews.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Reviews fetching utilities.
*
* In development: fetches from the local Signet daemon at localhost:3850.
* In production: set PUBLIC_REVIEWS_ENDPOINT to the Cloudflare Worker URL.
*
* All DOM rendering uses textContent — no innerHTML.
*/

// Inlined at build time via Astro's PUBLIC_ env convention.
// Fallback to local daemon for dev.
const REVIEWS_ENDPOINT =
(typeof import.meta !== "undefined" && (import.meta as { env?: { PUBLIC_REVIEWS_ENDPOINT?: string } }).env?.PUBLIC_REVIEWS_ENDPOINT) ||
"http://localhost:3850/api/marketplace/reviews";

export interface MarketplaceReview {
readonly id: string;
readonly targetType: "skill" | "mcp";
readonly targetId: string;
readonly displayName: string;
readonly rating: number;
readonly title: string;
readonly body: string;
readonly source: "local" | "synced";
readonly createdAt: string;
readonly updatedAt: string;
}

export interface ReviewsSummary {
readonly count: number;
readonly avgRating: number;
}

export interface ReviewsResult {
readonly reviews: MarketplaceReview[];
readonly total: number;
readonly summary: ReviewsSummary;
}

export async function fetchReviews(opts: {
type?: "skill" | "mcp";
id?: string;
limit?: number;
offset?: number;
} = {}): Promise<ReviewsResult> {
const url = new URL(REVIEWS_ENDPOINT);
if (opts.type) url.searchParams.set("type", opts.type);
if (opts.id) url.searchParams.set("id", opts.id);
if (opts.limit !== undefined) url.searchParams.set("limit", String(opts.limit));
if (opts.offset !== undefined) url.searchParams.set("offset", String(opts.offset));

const res = await fetch(url.toString());
if (!res.ok) throw new Error(`Reviews fetch failed: ${res.status}`);
return res.json() as Promise<ReviewsResult>;
}

/** Renders a star rating as unicode — safe for textContent. */
export function renderStars(rating: number): string {
const filled = Math.max(0, Math.min(5, Math.round(rating)));
return "★".repeat(filled) + "☆".repeat(5 - filled);
}

/** Format relative time, e.g. "2 days ago". */
export function formatRelativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 2) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}

/** Build a review card element using safe DOM APIs only. */
export function buildReviewCard(review: MarketplaceReview): HTMLElement {
const article = document.createElement("article");
article.className = "review-card";

// Header: stars + display name + time
const header = document.createElement("div");
header.className = "review-header";

const stars = document.createElement("span");
stars.className = "review-stars";
stars.textContent = renderStars(review.rating);

const meta = document.createElement("span");
meta.className = "review-meta";
const nameSpan = document.createElement("span");
nameSpan.className = "review-name";
nameSpan.textContent = review.displayName;
const timeSpan = document.createElement("span");
timeSpan.className = "review-time";
timeSpan.textContent = formatRelativeTime(review.updatedAt);
meta.appendChild(nameSpan);
meta.appendChild(timeSpan);

header.appendChild(stars);
header.appendChild(meta);

// Target badge
const badge = document.createElement("span");
badge.className = `review-target-badge review-target-${review.targetType}`;
badge.textContent = `${review.targetType}: ${review.targetId}`;

// Title
const title = document.createElement("p");
title.className = "review-title";
title.textContent = review.title;

// Body
const body = document.createElement("p");
body.className = "review-body";
body.textContent = review.body;

article.appendChild(header);
article.appendChild(badge);
article.appendChild(title);
article.appendChild(body);

return article;
}
149 changes: 149 additions & 0 deletions src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,22 @@ import "../styles/global.css";
<div class="state-loading">Loading servers...</div>
</div>
</section>

<!-- Recent Reviews -->
<section class="section">
<div class="section-header">
<h2 class="section-title">Recent Reviews</h2>
<span id="review-summary" class="review-summary-badge"></span>
</div>
<div id="recent-reviews" class="reviews-list">
<div class="state-loading">Loading reviews...</div>
</div>
</section>
</Layout>

<script>
import { fetchSkills, fetchMcpServers, buildCard, formatCount } from "@/lib/catalog";
import { fetchReviews, buildReviewCard, renderStars } from "@/lib/reviews";

const heroSearch = document.getElementById("hero-search") as HTMLInputElement | null;

Expand Down Expand Up @@ -186,6 +198,33 @@ import "../styles/global.css";
p.textContent = "Could not load servers.";
mcpEl.appendChild(p);
});

// Reviews
const reviewsEl = document.getElementById("recent-reviews")!;
const reviewSummaryEl = document.getElementById("review-summary")!;

fetchReviews({ limit: 5 }).then(({ reviews, summary }) => {
reviewsEl.replaceChildren();

if (summary.count > 0) {
reviewSummaryEl.textContent = `${renderStars(summary.avgRating)} ${summary.avgRating.toFixed(1)} · ${summary.count} review${summary.count === 1 ? "" : "s"}`;
}

if (reviews.length === 0) {
const p = document.createElement("p");
p.className = "state-empty";
p.textContent = "No reviews yet. Be the first to leave one via your Signet dashboard.";
reviewsEl.appendChild(p);
return;
}
reviews.forEach((r) => reviewsEl.appendChild(buildReviewCard(r)));
}).catch(() => {
reviewsEl.replaceChildren();
const p = document.createElement("p");
p.className = "state-empty";
p.textContent = "Reviews unavailable — is the Signet daemon running?";
reviewsEl.appendChild(p);
});
</script>

<style>
Expand Down Expand Up @@ -379,4 +418,114 @@ import "../styles/global.css";
.section-more:hover {
color: var(--sig-accent-hover);
}

/* Reviews */
.review-summary-badge {
font-family: var(--font-mono);
font-size: 10px;
color: var(--sig-accent);
letter-spacing: 0.04em;
}

.reviews-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}

.review-card {
padding: var(--space-md);
border: 1px solid var(--sig-border);
background: var(--sig-surface);
transition: border-color 0.15s;
}

.review-card:hover {
border-color: var(--sig-border-strong);
}

.review-header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: 6px;
}

.review-stars {
font-size: 11px;
color: var(--sig-icon-bg-4);
letter-spacing: 1px;
flex-shrink: 0;
}

.review-meta {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}

.review-name {
font-family: var(--font-display);
font-size: 11px;
font-weight: 600;
color: var(--sig-text-bright);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.review-time {
font-family: var(--font-mono);
font-size: 9px;
color: var(--sig-text-muted);
white-space: nowrap;
}

.review-target-badge {
display: inline-block;
font-family: var(--font-mono);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 1px 6px;
border: 1px solid var(--sig-border);
color: var(--sig-text-muted);
margin-bottom: 6px;
}

.review-target-skill {
border-color: var(--sig-icon-bg-2);
color: var(--sig-icon-bg-2);
opacity: 0.8;
}

.review-target-mcp {
border-color: var(--sig-icon-bg-1);
color: var(--sig-icon-bg-1);
opacity: 0.8;
}

.review-title {
font-family: var(--font-display);
font-size: 12px;
font-weight: 600;
color: var(--sig-text-bright);
margin: 0 0 4px;
}

.review-body {
font-family: var(--font-mono);
font-size: 11px;
color: var(--sig-text-muted);
line-height: 1.6;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
Loading