Skip to content
Merged
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
615 changes: 33 additions & 582 deletions package-lock.json

Large diffs are not rendered by default.

20 changes: 14 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useCallback } from 'react';
import { Routes, Route, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { ThemeToggle } from './ThemeToggle';
import ApiUsage from './ApiUsage';
Expand All @@ -8,14 +8,15 @@ import ServerError from './components/ServerError';
import useDocumentTitle from "./hooks/useDocumentTitle";
import NotFound from './components/NotFound';
import { startRouteLoading, stopRouteLoading } from './hooks/useRouteLoading';
import useDocumentTitle from './hooks/useDocumentTitle';
import { formatUsdc, formatUsdShortcut } from './utils/format';
import {
EXPLORER_BASE_URL,
MIN_DEPOSIT,
NETWORK_FEE,
PRESET_AMOUNTS,
} from "./config/constants";
import CompareDrawer from './components/CompareDrawer';
import CompareTray from './components/CompareTray';

type DepositStage = "input" | "approving" | "pending" | "confirmed" | "failed";
type DemoOutcome = "confirmed" | "failed";
Expand Down Expand Up @@ -751,10 +752,17 @@ function App() {
</nav>
</footer>

<ShortcutsModal
isOpen={isShortcutsModalOpen}
onClose={() => setIsShortcutsModalOpen(false)}
/>
<ShortcutsModal
isOpen={isShortcutsModalOpen}
onClose={() => setIsShortcutsModalOpen(false)}
/>

<CompareTray />
<CompareDrawer />
</div>
);
}


{isDepositOpen && (
<div
Expand Down
116 changes: 90 additions & 26 deletions src/components/ApiCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Skeleton from "./Skeleton";
import TagChip from "./TagChip";
import { formatPrice } from "../utils/format";
import { useCollections } from "../state/collectionsStore";
import { useFavorites } from "../hooks/useFavorites";
import type { APIItem } from "../data/mockApis";
import RatingHistogram from "./RatingHistogram";
import { useCompareStore, compareStore } from "../state/compareStore";
Expand Down Expand Up @@ -104,6 +105,60 @@ export function ApiCardSkeleton() {

// ─── Save-to-collection popover ───────────────────────────────────────────────

// ─── Favorite button ─────────────────────────────────────────────────────────

interface FavoriteButtonProps {
endpointId: string;
isFavorite: boolean;
onToggle: (id: string) => void;
}

function FavoriteButton({ endpointId, isFavorite, onToggle }: FavoriteButtonProps) {
const btnRef = useRef<HTMLButtonElement>(null);

return (
<button
ref={btnRef}
onClick={(e) => { e.stopPropagation(); onToggle(endpointId); }}
style={{
position: "absolute",
top: "8px",
left: "8px",
zIndex: 10,
background: isFavorite ? "var(--accent, rgba(78,133,255,0.9))" : "rgba(0,0,0,0.5)",
border: "none",
borderRadius: "50%",
width: "32px",
height: "32px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "background 160ms ease, transform 160ms ease",
flexShrink: 0,
}}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.transform = "scale(1.1)")}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.transform = "scale(1)")}
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
aria-pressed={isFavorite}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill={isFavorite ? "white" : "none"}
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</button>
);
}

// ─── Bookmark button ─────────────────────────────────────────────────────────

interface BookmarkButtonProps {
Expand Down Expand Up @@ -347,7 +402,8 @@ export default function ApiCard({
const uptimePercent = api.uptimePercent;
const isCompact = density === "compact";

const comparedApis = useCompareStore();
const { isFavorite, toggleFavorite } = useFavorites();
const { apis: comparedApis } = useCompareStore();
const isCompared = comparedApis.some(item => item.id === api.id);
const canCompare = isCompared || comparedApis.length < 3;

Expand Down Expand Up @@ -386,31 +442,39 @@ export default function ApiCard({
{/* Absolutely-positioned bookmark button in the top-right corner */}
<BookmarkButton endpointId={api.id} />

{/* Compare button - absolutely positioned, top-left */}
<button
onClick={handleCompareClick}
disabled={!canCompare}
className="api-card__compare-btn"
style={{
position: "absolute",
top: "8px",
left: "8px",
zIndex: 10,
background: isCompared ? "var(--accent)" : "rgba(0,0,0,0.5)",
color: "white",
border: "none",
borderRadius: "8px",
padding: "4px 8px",
fontSize: "0.75rem",
fontWeight: 600,
cursor: canCompare ? "pointer" : "not-allowed",
opacity: isCompared ? 1 : 0, // rely on css for hover visibility
transition: "opacity 0.2s, background 0.2s"
}}
aria-label={isCompared ? `Remove ${api.name} from comparison` : `Add ${api.name} to comparison`}
>
{isCompared ? "Compared" : "Compare"}
</button>
<FavoriteButton
endpointId={api.id}
isFavorite={isFavorite(api.id)}
onToggle={toggleFavorite}
/>

{/* Pin button - absolutely positioned, top-left */}
<button
onClick={handleCompareClick}
disabled={!canCompare}
className="api-card__compare-btn"
style={{
position: "absolute",
top: "8px",
left: "48px",
zIndex: 10,
background: isCompared ? "var(--accent)" : "rgba(0,0,0,0.5)",
color: "white",
border: "none",
borderRadius: "8px",
padding: "4px 8px",
fontSize: "0.75rem",
fontWeight: 600,
cursor: canCompare ? "pointer" : "not-allowed",
opacity: isCompared ? 1 : 0.6,
transition: "opacity 0.2s, background 0.2s"
}}
aria-label={isCompared ? `Remove ${api.name} from comparison` : `Add ${api.name} to comparison`}
aria-pressed={isCompared}
>
{isCompared ? "Pinned" : "Pin"}
</button>


<div
className="api-marketplace-card-header"
Expand Down
144 changes: 139 additions & 5 deletions src/components/CompareDrawer.css
Original file line number Diff line number Diff line change
@@ -1,14 +1,148 @@
.compare-drawer-overlay {
position: fixed;
inset: 0;
background: var(--backdrop);
z-index: 100;
background: rgba(3, 8, 22, 0.8);
backdrop-filter: blur(8px);
z-index: 200;
display: none;
align-items: center;
justify-content: center;
}

.compare-drawer-overlay.open {
display: flex;
}

.compare-drawer {
width: 95%;
max-width: 1200px;
max-height: 85vh;
background: var(--surface-strong);
border: 1px solid var(--line-strong, rgba(169,184,255,0.28));
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
box-shadow: var(--shadow);
overflow: hidden;
animation: drawer-pop 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}

@keyframes drawer-pop {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}

.compare-drawer-header {
padding: 20px 24px;
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
align-items: center;
}

.compare-drawer-actions {
display: flex;
gap: 12px;
align-items: center;
}

.compare-drawer-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}

.compare-drawer-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}

.compare-drawer-empty {
color: var(--muted);
text-align: center;
margin-top: 40px;
}

.compare-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
}

.compare-column {
border: 1px solid var(--line);
border-radius: var(--radius-md);
padding: 20px;
background: var(--surface);
display: flex;
flex-direction: column;
gap: 16px;
position: relative;
}

.compare-column-remove {
position: absolute;
top: 12px;
right: 12px;
background: rgba(255, 0, 0, 0.1);
color: var(--danger);
border: none;
border-radius: 50%;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
}

.compare-column-remove:hover {
background: rgba(255, 0, 0, 0.2);
}

.compare-column-header {
font-weight: bold;
font-size: 1.25rem;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
margin-right: 24px;
word-wrap: break-word;
}

.compare-stat {
display: flex;
justify-content: flex-end;
/* hide by default on desktop, used as sheet on mobile */
pointer-events: none;
flex-direction: column;
gap: 4px;
}

.compare-stat-label {
font-size: 0.75rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}

.compare-stat-value {
font-size: 1rem;
font-weight: 500;
}

@media (max-width: 768px) {
.compare-grid {
grid-template-columns: 1fr;
}

.compare-drawer {
width: 100%;
height: 100%;
max-height: 100vh;
border-radius: 0;
}
}


.compare-drawer-overlay.open {
pointer-events: auto;
}
Expand Down
Loading