Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5be24fd13 | ||
|
|
15bc4440cc | ||
|
|
11a20f5755 | ||
|
|
05f8674628 | ||
|
|
d2b2252770 | ||
|
|
a2387253ec | ||
|
|
411260767b | ||
|
|
731d0ce0c5 | ||
|
|
361f2affde | ||
|
|
a17f7bb07e | ||
|
|
2d03b827d3 | ||
|
|
835094ea2c | ||
|
|
7bd7e4c99e | ||
|
|
1722a48055 | ||
|
|
29178898bb | ||
|
|
9a45c371fc | ||
|
|
298cbdd6db | ||
|
|
f636b31fca | ||
|
|
b255b5865f | ||
|
|
5003c1bec8 | ||
|
|
a8a6242f87 | ||
|
|
39c0fa2531 | ||
|
|
051b1dafcd | ||
|
|
383844cacf | ||
|
|
b7923edbfd | ||
|
|
522fa22026 | ||
|
|
e41d5d6314 | ||
|
|
c62ab8bc96 | ||
|
|
6df3a3b9ef | ||
|
|
7452cf6f69 | ||
|
|
b4e8a26eb4 | ||
|
|
7ff601bcb8 | ||
|
|
70af109cb2 | ||
|
|
1504708208 |
@@ -27,9 +27,13 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: bun run test
|
||||
env:
|
||||
VITE_CONVEX_URL: https://example.invalid
|
||||
|
||||
- name: Coverage
|
||||
run: bun run coverage
|
||||
env:
|
||||
VITE_CONVEX_URL: https://example.invalid
|
||||
|
||||
- name: ClawHub CLI Verify
|
||||
run: bun run --cwd packages/clawhub verify
|
||||
|
||||
@@ -24,3 +24,7 @@ coverage
|
||||
playwright-report
|
||||
test-results
|
||||
.playwright
|
||||
convex/_generated/
|
||||
skills-lock.json
|
||||
*/skills/*
|
||||
skills/*
|
||||
@@ -87,3 +87,11 @@
|
||||
- **32K document limit per query.** Split `.collect()` calls by a partition field (e.g., one day at a time instead of a 7-day range). See `rebuildTrendingLeaderboardAction` in `convex/leaderboards.ts` for an example.
|
||||
- **Common mistakes**: `.filter().collect()` without an index; `ctx.db.get()` on large docs in a loop for list views; while loops that paginate the whole table to find filtered results.
|
||||
- **Before writing or reviewing Convex queries, check deployment health.** Run `bunx convex insights` to check for OCC conflicts, `bytesReadLimit`, and `documentsReadLimit` errors. Run `bunx convex logs --failure` to see individual error messages and stack traces. This helps identify which functions are causing bandwidth issues so you can prioritize fixes.
|
||||
|
||||
<!-- convex-ai-start -->
|
||||
This project uses [Convex](https://convex.dev) as its backend.
|
||||
|
||||
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
|
||||
|
||||
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
|
||||
<!-- convex-ai-end -->
|
||||
|
||||
@@ -45,3 +45,11 @@
|
||||
|
||||
- Tests use `._handler` to call mutation handlers directly with mock `db` objects.
|
||||
- Mock `db` objects MUST include `normalizeId: vi.fn()` for trigger wrapper compatibility.
|
||||
|
||||
<!-- convex-ai-start -->
|
||||
This project uses [Convex](https://convex.dev) as its backend.
|
||||
|
||||
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
|
||||
|
||||
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
|
||||
<!-- convex-ai-end -->
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
# ClawHub Design System
|
||||
|
||||
This document outlines the design rules, patterns, and guidelines for the ClawHub platform to ensure consistency, accessibility, and maintainability across all components.
|
||||
|
||||
---
|
||||
|
||||
## Color System
|
||||
|
||||
### Brand Palette (OpenClaw)
|
||||
|
||||
ClawHub uses a strict **3-5 color palette** based on the OpenClaw brand:
|
||||
|
||||
| Token | Light Mode | Dark Mode | Usage |
|
||||
|-------|------------|-----------|-------|
|
||||
| `--accent` | `#dc2626` | `#dc2626` | Primary actions, interactive elements, emphasis |
|
||||
| `--accent-deep` | `#b91c1c` | `#ef4444` | Hover states, secondary emphasis |
|
||||
| `--ink` | `#0a0a0a` | `#fafafa` | Primary text |
|
||||
| `--ink-soft` | `#525252` | `#a1a1a1` | Secondary text, descriptions |
|
||||
| `--surface` | `#ffffff` | `#121212` | Card backgrounds, elevated surfaces |
|
||||
| `--bg` | `#fafafa` | `#0a0a0a` | Page background |
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Never exceed 5 colors** without explicit design approval
|
||||
2. **Never use purple/violet prominently** unless explicitly requested
|
||||
3. **Always override text color** when changing background color to ensure contrast
|
||||
4. **Use semantic tokens** (`--accent`, `--ink`, `--surface`) instead of raw colors
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Stack
|
||||
|
||||
```css
|
||||
--font-sans: 'Geist', system-ui, sans-serif;
|
||||
--font-mono: 'Geist Mono', monospace;
|
||||
--font-display: 'Geist', system-ui, sans-serif;
|
||||
```
|
||||
|
||||
### Scale
|
||||
|
||||
| Token | Size | Usage |
|
||||
|-------|------|-------|
|
||||
| `--fs-xs` | 0.75rem (12px) | Labels, badges, metadata |
|
||||
| `--fs-sm` | 0.875rem (14px) | Body text, descriptions |
|
||||
| `--fs-base` | 1rem (16px) | Default body text |
|
||||
| `--fs-md` | 1.125rem (18px) | Subheadings |
|
||||
| `--fs-lg` | 1.25rem (20px) | Section titles |
|
||||
| `--fs-xl` | 1.5rem (24px) | Page headings |
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Maximum 2 font families** per page
|
||||
2. **Line height 1.4-1.6** for body text (use `leading-relaxed`)
|
||||
3. **Never use decorative fonts** for body text
|
||||
4. **Minimum font size: 14px** for readability
|
||||
5. Use `text-balance` or `text-pretty` for titles
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
### Method Priority
|
||||
|
||||
Use this hierarchy for layout decisions:
|
||||
|
||||
1. **Flexbox** - Default for most layouts
|
||||
2. **CSS Grid** - Only for complex 2D layouts (cards, galleries)
|
||||
3. **Never use floats** or absolute positioning unless absolutely necessary
|
||||
|
||||
### Spacing Scale
|
||||
|
||||
```css
|
||||
--space-1: 0.25rem /* 4px */
|
||||
--space-2: 0.5rem /* 8px */
|
||||
--space-3: 0.75rem /* 12px */
|
||||
--space-4: 1rem /* 16px */
|
||||
--space-5: 1.5rem /* 24px */
|
||||
--space-6: 2rem /* 32px */
|
||||
```
|
||||
|
||||
### Grid Patterns
|
||||
|
||||
#### Auto-fit Grid (Recommended for Cards)
|
||||
```css
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
```
|
||||
- Automatically adjusts columns based on container width
|
||||
- Prevents orphan items on partial rows
|
||||
- Maintains consistent card widths
|
||||
|
||||
#### Fixed Grid (When exact columns needed)
|
||||
```css
|
||||
/* 3-column at desktop, 2 at tablet, 1 at mobile */
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@media (max-width: 860px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
```
|
||||
|
||||
### Container Widths
|
||||
|
||||
| Size | Max Width | Usage |
|
||||
|------|-----------|-------|
|
||||
| Default | `--page-max` (1200px) | Standard pages |
|
||||
| Narrow | `--page-narrow` (720px) | Reading content, forms |
|
||||
| Wide | Full width | Dashboards, data tables |
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Cards
|
||||
|
||||
```css
|
||||
.card {
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Always use `display: flex; flex-direction: column;` for consistent height
|
||||
- Add `flex: 1` to content area for equal-height cards in grids
|
||||
- Include hover state with `border-color` and subtle `box-shadow`
|
||||
|
||||
### Buttons
|
||||
|
||||
| Variant | Usage |
|
||||
|---------|-------|
|
||||
| `primary` | Main actions (Submit, Save, Download) |
|
||||
| `secondary` | Alternative actions |
|
||||
| `ghost` | Tertiary actions, navigation |
|
||||
| `destructive` | Delete, remove, dangerous actions |
|
||||
|
||||
**Rules:**
|
||||
- Always include visible focus state
|
||||
- Minimum touch target: 44x44px on mobile
|
||||
- Include `aria-label` when icon-only
|
||||
|
||||
### Form Controls
|
||||
|
||||
- Labels above inputs (not inline)
|
||||
- Error states use `--status-error-fg`
|
||||
- Focus rings use `--accent` with 0.2 opacity
|
||||
- Minimum input height: 40px
|
||||
|
||||
---
|
||||
|
||||
## Responsive Breakpoints
|
||||
|
||||
```css
|
||||
/* Mobile first - base styles for mobile */
|
||||
|
||||
@media (min-width: 520px) {
|
||||
/* Small tablets, large phones */
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
/* Tablets */
|
||||
}
|
||||
|
||||
@media (min-width: 860px) {
|
||||
/* Small desktops, landscape tablets */
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
/* Desktops */
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
/* Large desktops */
|
||||
}
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Mobile-first approach** - Base styles target mobile
|
||||
2. **Progressive enhancement** - Add complexity as viewport increases
|
||||
3. **Test intermediate breakpoints** - Avoid jarring layout jumps
|
||||
4. **Never hide critical content** on mobile
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Color Contrast
|
||||
|
||||
- Normal text: Minimum 4.5:1 ratio
|
||||
- Large text (18px+): Minimum 3:1 ratio
|
||||
- Interactive elements: Minimum 3:1 ratio
|
||||
|
||||
### Focus States
|
||||
|
||||
```css
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
```
|
||||
|
||||
### Screen Readers
|
||||
|
||||
- Use `sr-only` class for visually hidden but accessible text
|
||||
- Always include `alt` text for images (empty `alt=""` for decorative)
|
||||
- Use semantic HTML elements (`main`, `nav`, `article`, `section`)
|
||||
- Proper heading hierarchy (h1 > h2 > h3, no skipping)
|
||||
|
||||
### Motion
|
||||
|
||||
```css
|
||||
/* Respect user preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Animation
|
||||
|
||||
### Timing
|
||||
|
||||
```css
|
||||
--transition-fast: 150ms;
|
||||
--transition-base: 200ms;
|
||||
--transition-slow: 300ms;
|
||||
```
|
||||
|
||||
### Easing
|
||||
|
||||
- Use `ease` or `ease-out` for most transitions
|
||||
- Use `ease-in-out` for enter/exit animations
|
||||
- Never use `linear` except for continuous animations
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Subtle by default** - Avoid flashy animations
|
||||
2. **Purpose-driven** - Animation should provide feedback
|
||||
3. **Respect preferences** - Support `prefers-reduced-motion`
|
||||
4. **Performance** - Use `transform` and `opacity` only
|
||||
|
||||
---
|
||||
|
||||
## Icons
|
||||
|
||||
### Usage
|
||||
|
||||
- Use Lucide icons consistently
|
||||
- Standard sizes: 14px, 16px, 20px, 24px
|
||||
- Include `aria-hidden="true"` for decorative icons
|
||||
- Never use emojis as icons
|
||||
|
||||
### Placement
|
||||
|
||||
- Left of labels in buttons and navigation
|
||||
- Right of labels for external links or dropdowns
|
||||
- Centered when used alone with `aria-label`
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode
|
||||
|
||||
### Implementation
|
||||
|
||||
```css
|
||||
[data-theme="dark"] {
|
||||
/* Dark mode overrides */
|
||||
}
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
1. Never use pure white (`#ffffff`) on dark backgrounds
|
||||
2. Reduce shadow intensity in dark mode
|
||||
3. Adjust image brightness if needed
|
||||
4. Test contrast ratios in both modes
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### CSS
|
||||
|
||||
1. Use CSS custom properties for theming
|
||||
2. Avoid deeply nested selectors (max 3 levels)
|
||||
3. Use `will-change` sparingly
|
||||
4. Prefer `transform` over `top/left` for animations
|
||||
|
||||
### Images
|
||||
|
||||
1. Always specify `width` and `height` attributes
|
||||
2. Use `loading="lazy"` for below-fold images
|
||||
3. Use appropriate formats (WebP with fallbacks)
|
||||
4. Include placeholder or skeleton states
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
### CSS Class Naming
|
||||
|
||||
```css
|
||||
/* Component */
|
||||
.component-name { }
|
||||
|
||||
/* Component modifier */
|
||||
.component-name.variant { }
|
||||
|
||||
/* Component child */
|
||||
.component-name-child { }
|
||||
|
||||
/* State */
|
||||
.component-name.is-active { }
|
||||
.component-name[data-state="open"] { }
|
||||
```
|
||||
|
||||
### File Organization
|
||||
|
||||
```
|
||||
src/
|
||||
components/
|
||||
ui/ # Primitive components (Button, Input, Card)
|
||||
layout/ # Layout components (Container, Header)
|
||||
styles.css # Global styles and design tokens
|
||||
lib/
|
||||
theme.ts # Theme utilities
|
||||
preferences.ts # User preference management
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
Before shipping any UI changes, verify:
|
||||
|
||||
- [ ] Color contrast meets WCAG AA standards
|
||||
- [ ] Focus states are visible
|
||||
- [ ] Layout works at all breakpoints
|
||||
- [ ] Animations respect `prefers-reduced-motion`
|
||||
- [ ] Text is readable at default browser zoom
|
||||
- [ ] Interactive elements have 44px minimum touch target
|
||||
- [ ] Semantic HTML is used appropriately
|
||||
- [ ] Dark mode has been tested
|
||||
@@ -20,6 +20,8 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
@@ -32,6 +34,7 @@
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/router-plugin": "1.167.2",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clawhub-schema": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.34.1",
|
||||
@@ -41,6 +44,8 @@
|
||||
"ignore": "^7.0.5",
|
||||
"lucide-react": "^0.577.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"next": "^16.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"nitro": "3.0.260311-beta",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -51,6 +56,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6",
|
||||
@@ -266,6 +272,56 @@
|
||||
|
||||
"@fontsource/manrope": ["@fontsource/manrope@5.2.8", "", {}, "sha512-gJHJmcuUk7qWcNCfcAri/DJQtXtBYqi9yKratr4jXhSo0I3xUtNNKI+igQIcw5c+m95g0vounk8ZnX/kb8o0TA=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -282,6 +338,24 @@
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="],
|
||||
|
||||
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
|
||||
|
||||
"@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="],
|
||||
@@ -440,7 +514,9 @@
|
||||
|
||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
|
||||
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
||||
|
||||
@@ -536,6 +612,8 @@
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
|
||||
@@ -744,6 +822,8 @@
|
||||
|
||||
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"clawhub": ["clawhub@workspace:packages/clawhub"],
|
||||
|
||||
"clawhub-schema": ["clawhub-schema@workspace:packages/schema"],
|
||||
@@ -752,6 +832,8 @@
|
||||
|
||||
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
@@ -1096,6 +1178,10 @@
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="],
|
||||
|
||||
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
"nf3": ["nf3@0.3.13", "", {}, "sha512-drDt0yl4d/yUhlpD0GzzqahSpA5eUNeIfFq0/aoZb0UlPY0ZwP4u1EfREVvZrYdEnJ3OU9Le9TrzbvWgEkkeKw=="],
|
||||
|
||||
"nitro": ["nitro@3.0.260311-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.4", "db0": "^0.3.4", "env-runner": "^0.1.6", "h3": "^2.0.1-rc.16", "hookable": "^6.0.1", "nf3": "^0.3.11", "ocache": "^0.1.2", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.0-rc.8", "srvx": "^0.11.9", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.6" }, "peerDependencies": { "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.59.0", "vite": "^7 || ^8 || >=8.0.0-0", "xml2js": "^0.6.2", "zephyr-agent": "^0.1.15" }, "optionalPeers": ["dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-0o0fJ9LUh4WKUqJNX012jyieUOtMCnadkNDWr0mHzdraoHpJP/1CGNefjRyZyMXSpoJfwoWdNEZu2iGf35TUvQ=="],
|
||||
@@ -1224,6 +1310,8 @@
|
||||
|
||||
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
|
||||
"shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
|
||||
@@ -1264,6 +1352,8 @@
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
@@ -1308,6 +1398,8 @@
|
||||
|
||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
|
||||
@@ -1400,10 +1492,14 @@
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
@@ -1416,14 +1512,14 @@
|
||||
|
||||
"@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
|
||||
"@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
@@ -1436,6 +1532,12 @@
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-switch/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
@@ -1450,6 +1552,8 @@
|
||||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||
@@ -1492,7 +1596,11 @@
|
||||
|
||||
"jsdom/undici": ["undici@7.24.5", "", {}, "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q=="],
|
||||
|
||||
<<<<<<< staging
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
=======
|
||||
"nitro/rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
|
||||
>>>>>>> main
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
@@ -1510,6 +1618,33 @@
|
||||
|
||||
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
<<<<<<< staging
|
||||
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-switch/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-toggle-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-toggle/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
=======
|
||||
"vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"vitest/vite": ["vite@8.0.1", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw=="],
|
||||
@@ -1583,5 +1718,6 @@
|
||||
"vitest/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
|
||||
>>>>>>> main
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ import type * as publishers from "../publishers.js";
|
||||
import type * as rateLimits from "../rateLimits.js";
|
||||
import type * as search from "../search.js";
|
||||
import type * as seed from "../seed.js";
|
||||
import type * as seedDemo from "../seedDemo.js";
|
||||
import type * as seedSouls from "../seedSouls.js";
|
||||
import type * as skillStatEvents from "../skillStatEvents.js";
|
||||
import type * as skillTransfers from "../skillTransfers.js";
|
||||
@@ -216,6 +217,7 @@ declare const fullApi: ApiFromModules<{
|
||||
rateLimits: typeof rateLimits;
|
||||
search: typeof search;
|
||||
seed: typeof seed;
|
||||
seedDemo: typeof seedDemo;
|
||||
seedSouls: typeof seedSouls;
|
||||
skillStatEvents: typeof skillStatEvents;
|
||||
skillTransfers: typeof skillTransfers;
|
||||
|
||||
@@ -469,6 +469,11 @@ export const seedSkillMutation = internalMutation({
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.patch(userId, {
|
||||
publishedSkills: 1,
|
||||
totalStars: 0,
|
||||
totalDownloads: 0,
|
||||
});
|
||||
|
||||
const versionId = await ctx.db.insert("skillVersions", {
|
||||
skillId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
extractWorkflowFilenameFromWorkflowRef,
|
||||
verifyGitHubActionsTrustedPublishJwt,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { MutationCtx } from "../_generated/server";
|
||||
|
||||
function getSkillContribution(skill: Doc<"skills">) {
|
||||
if (skill.softDeletedAt) {
|
||||
return { publishedSkills: 0, totalStars: 0, totalDownloads: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
publishedSkills: 1,
|
||||
totalStars: skill.stats?.stars ?? 0,
|
||||
totalDownloads: skill.stats?.downloads ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function patchUserStats(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
userId: Id<"users">,
|
||||
delta: { publishedSkills: number; totalStars: number; totalDownloads: number },
|
||||
) {
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user) return;
|
||||
|
||||
await ctx.db.patch(userId, {
|
||||
publishedSkills: Math.max(0, (user.publishedSkills ?? 0) + delta.publishedSkills),
|
||||
totalStars: Math.max(0, (user.totalStars ?? 0) + delta.totalStars),
|
||||
totalDownloads: Math.max(0, (user.totalDownloads ?? 0) + delta.totalDownloads),
|
||||
});
|
||||
}
|
||||
|
||||
export async function adjustUserSkillStatsForSkillChange(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
previousSkill: Doc<"skills"> | null | undefined,
|
||||
nextSkill: Doc<"skills"> | null | undefined,
|
||||
) {
|
||||
if (!previousSkill && !nextSkill) return;
|
||||
|
||||
const prevOwnerId = previousSkill?.ownerUserId ?? null;
|
||||
const nextOwnerId = nextSkill?.ownerUserId ?? null;
|
||||
const prevContribution = previousSkill ? getSkillContribution(previousSkill) : null;
|
||||
const nextContribution = nextSkill ? getSkillContribution(nextSkill) : null;
|
||||
|
||||
if (prevOwnerId && prevOwnerId === nextOwnerId) {
|
||||
await patchUserStats(ctx, prevOwnerId, {
|
||||
publishedSkills: (nextContribution?.publishedSkills ?? 0) - (prevContribution?.publishedSkills ?? 0),
|
||||
totalStars: (nextContribution?.totalStars ?? 0) - (prevContribution?.totalStars ?? 0),
|
||||
totalDownloads: (nextContribution?.totalDownloads ?? 0) - (prevContribution?.totalDownloads ?? 0),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (prevOwnerId) {
|
||||
await patchUserStats(ctx, prevOwnerId, {
|
||||
publishedSkills: -(prevContribution?.publishedSkills ?? 0),
|
||||
totalStars: -(prevContribution?.totalStars ?? 0),
|
||||
totalDownloads: -(prevContribution?.totalDownloads ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
if (nextOwnerId) {
|
||||
await patchUserStats(ctx, nextOwnerId, {
|
||||
publishedSkills: nextContribution?.publishedSkills ?? 0,
|
||||
totalStars: nextContribution?.totalStars ?? 0,
|
||||
totalDownloads: nextContribution?.totalDownloads ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ const users = defineTable({
|
||||
githubFetchedAt: v.optional(v.number()),
|
||||
githubProfileSyncedAt: v.optional(v.number()),
|
||||
trustedPublisher: v.optional(v.boolean()),
|
||||
publishedSkills: v.optional(v.number()),
|
||||
totalStars: v.optional(v.number()),
|
||||
totalDownloads: v.optional(v.number()),
|
||||
personalPublisherId: v.optional(v.id("publishers")),
|
||||
requiresModerationAt: v.optional(v.number()),
|
||||
requiresModerationReason: v.optional(v.string()),
|
||||
@@ -40,7 +43,8 @@ const users = defineTable({
|
||||
})
|
||||
.index("email", ["email"])
|
||||
.index("phone", ["phone"])
|
||||
.index("handle", ["handle"]);
|
||||
.index("handle", ["handle"])
|
||||
.index("by_active_handle", ["deletedAt", "deactivatedAt", "handle"]);
|
||||
|
||||
const publishers = defineTable({
|
||||
kind: v.union(v.literal("user"), v.literal("org")),
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { internalMutation } from "./functions";
|
||||
|
||||
const DEMO_SKILLS = [
|
||||
{
|
||||
slug: "mcp-github",
|
||||
displayName: "MCP GitHub",
|
||||
summary: "Full GitHub API integration via MCP — issues, PRs, repos, code search, and actions.",
|
||||
downloads: 14200,
|
||||
stars: 342,
|
||||
installs: 8100,
|
||||
},
|
||||
{
|
||||
slug: "claude-memory",
|
||||
displayName: "Claude Memory",
|
||||
summary: "Persistent memory layer for Claude — stores context across conversations with vector recall.",
|
||||
downloads: 11800,
|
||||
stars: 287,
|
||||
installs: 6400,
|
||||
},
|
||||
{
|
||||
slug: "web-scraper-pro",
|
||||
displayName: "Web Scraper Pro",
|
||||
summary: "Intelligent web scraping with automatic pagination, JS rendering, and structured data extraction.",
|
||||
downloads: 9400,
|
||||
stars: 198,
|
||||
installs: 5200,
|
||||
},
|
||||
{
|
||||
slug: "sql-analyst",
|
||||
displayName: "SQL Analyst",
|
||||
summary: "Natural language to SQL with schema introspection, query optimization, and result visualization.",
|
||||
downloads: 8700,
|
||||
stars: 221,
|
||||
installs: 4800,
|
||||
},
|
||||
{
|
||||
slug: "pytest-agent",
|
||||
displayName: "Pytest Agent",
|
||||
summary: "Automated test generation and execution for Python — coverage analysis, mutation testing, fixtures.",
|
||||
downloads: 7200,
|
||||
stars: 156,
|
||||
installs: 3900,
|
||||
},
|
||||
{
|
||||
slug: "docker-compose-helper",
|
||||
displayName: "Docker Compose Helper",
|
||||
summary: "Generate, validate, and debug Docker Compose configurations with multi-service orchestration.",
|
||||
downloads: 6800,
|
||||
stars: 134,
|
||||
installs: 3600,
|
||||
},
|
||||
{
|
||||
slug: "api-docs-generator",
|
||||
displayName: "API Docs Generator",
|
||||
summary: "Auto-generate OpenAPI specs and beautiful documentation from any codebase or endpoint.",
|
||||
downloads: 5900,
|
||||
stars: 178,
|
||||
installs: 3100,
|
||||
},
|
||||
{
|
||||
slug: "slack-bot-builder",
|
||||
displayName: "Slack Bot Builder",
|
||||
summary: "Build and deploy Slack bots with natural language — slash commands, modals, and event handlers.",
|
||||
downloads: 5400,
|
||||
stars: 112,
|
||||
installs: 2800,
|
||||
},
|
||||
{
|
||||
slug: "terraform-assistant",
|
||||
displayName: "Terraform Assistant",
|
||||
summary: "Infrastructure as code helper — plan reviews, drift detection, module generation for AWS/GCP/Azure.",
|
||||
downloads: 4800,
|
||||
stars: 145,
|
||||
installs: 2400,
|
||||
},
|
||||
{
|
||||
slug: "regex-wizard",
|
||||
displayName: "Regex Wizard",
|
||||
summary: "Natural language to regex with live testing, explanation, and edge case generation.",
|
||||
downloads: 4200,
|
||||
stars: 89,
|
||||
installs: 2100,
|
||||
},
|
||||
{
|
||||
slug: "git-history-explorer",
|
||||
displayName: "Git History Explorer",
|
||||
summary: "Semantic search through git history — find commits by intent, trace code evolution, blame analysis.",
|
||||
downloads: 3900,
|
||||
stars: 102,
|
||||
installs: 1900,
|
||||
},
|
||||
{
|
||||
slug: "cron-scheduler",
|
||||
displayName: "Cron Scheduler",
|
||||
summary: "Natural language to cron expressions with timezone handling, overlap protection, and monitoring.",
|
||||
downloads: 3400,
|
||||
stars: 67,
|
||||
installs: 1600,
|
||||
},
|
||||
{
|
||||
slug: "jwt-debugger",
|
||||
displayName: "JWT Debugger",
|
||||
summary: "Decode, verify, and generate JWTs with visual payload inspection and expiry tracking.",
|
||||
downloads: 3100,
|
||||
stars: 78,
|
||||
installs: 1400,
|
||||
},
|
||||
{
|
||||
slug: "graphql-builder",
|
||||
displayName: "GraphQL Builder",
|
||||
summary: "Schema-first GraphQL development — type generation, resolver scaffolding, and playground integration.",
|
||||
downloads: 2800,
|
||||
stars: 94,
|
||||
installs: 1200,
|
||||
},
|
||||
{
|
||||
slug: "security-scanner",
|
||||
displayName: "Security Scanner",
|
||||
summary: "OWASP-aware security scanning for codebases — dependency audit, secret detection, SAST patterns.",
|
||||
downloads: 2500,
|
||||
stars: 156,
|
||||
installs: 1100,
|
||||
},
|
||||
{
|
||||
slug: "markdown-slides",
|
||||
displayName: "Markdown Slides",
|
||||
summary: "Turn markdown into presentation decks with themes, speaker notes, and PDF export.",
|
||||
downloads: 2200,
|
||||
stars: 45,
|
||||
installs: 900,
|
||||
},
|
||||
{
|
||||
slug: "env-manager",
|
||||
displayName: "Env Manager",
|
||||
summary: "Environment variable management across projects — sync .env files, validate schemas, rotate secrets.",
|
||||
downloads: 1800,
|
||||
stars: 56,
|
||||
installs: 800,
|
||||
},
|
||||
{
|
||||
slug: "csv-transform",
|
||||
displayName: "CSV Transform",
|
||||
summary: "Powerful CSV/TSV manipulation — column transforms, joins, pivots, and format conversion.",
|
||||
downloads: 1500,
|
||||
stars: 34,
|
||||
installs: 600,
|
||||
},
|
||||
{
|
||||
slug: "ssh-config-manager",
|
||||
displayName: "SSH Config Manager",
|
||||
summary: "Manage SSH configs, keys, and tunnels with natural language — jump hosts, port forwarding, agent setup.",
|
||||
downloads: 1200,
|
||||
stars: 42,
|
||||
installs: 500,
|
||||
},
|
||||
{
|
||||
slug: "changelog-writer",
|
||||
displayName: "Changelog Writer",
|
||||
summary: "Generate changelogs from git history with conventional commit parsing and release note formatting.",
|
||||
downloads: 980,
|
||||
stars: 28,
|
||||
installs: 400,
|
||||
},
|
||||
];
|
||||
|
||||
const DEMO_OWNERS = [
|
||||
{ handle: "anthropic", displayName: "Anthropic", highlighted: true },
|
||||
{ handle: "openai-labs", displayName: "OpenAI Labs", highlighted: false },
|
||||
{ handle: "devtools-co", displayName: "DevTools Co", highlighted: false },
|
||||
{ handle: "securityfirst", displayName: "SecurityFirst", highlighted: true },
|
||||
{ handle: "dataflow", displayName: "DataFlow", highlighted: false },
|
||||
];
|
||||
|
||||
export const seedDemoSkills = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
// Check if we already seeded
|
||||
const existingSkill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", "mcp-github"))
|
||||
.first();
|
||||
if (existingSkill) {
|
||||
return { seeded: false, reason: "already seeded" };
|
||||
}
|
||||
|
||||
// Create a seed user
|
||||
const seedUserId = await ctx.db.insert("users", {
|
||||
name: "ClawHub Demo",
|
||||
displayName: "ClawHub Demo",
|
||||
handle: "clawhub-demo",
|
||||
image: undefined,
|
||||
role: "admin",
|
||||
});
|
||||
|
||||
// Create publisher accounts
|
||||
const publisherIds: string[] = [];
|
||||
for (const owner of DEMO_OWNERS) {
|
||||
const pubId = await ctx.db.insert("publishers", {
|
||||
kind: "org",
|
||||
handle: owner.handle,
|
||||
displayName: owner.displayName,
|
||||
linkedUserId: seedUserId,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
publisherIds.push(pubId);
|
||||
|
||||
// Add membership
|
||||
await ctx.db.insert("publisherMembers", {
|
||||
publisherId: pubId as Id<"publishers">,
|
||||
userId: seedUserId,
|
||||
role: "owner",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const DAY = 86400000;
|
||||
let totalPublishedSkills = 0;
|
||||
let totalStars = 0;
|
||||
let totalDownloads = 0;
|
||||
|
||||
for (let i = 0; i < DEMO_SKILLS.length; i++) {
|
||||
const s = DEMO_SKILLS[i];
|
||||
const ownerIdx = i % publisherIds.length;
|
||||
const createdDaysAgo = Math.floor(Math.random() * 90) + 7;
|
||||
const updatedDaysAgo = Math.floor(Math.random() * createdDaysAgo);
|
||||
const createdAt = now - createdDaysAgo * DAY;
|
||||
const updatedAt = now - updatedDaysAgo * DAY;
|
||||
const version = `${Math.floor(Math.random() * 3) + 1}.${Math.floor(Math.random() * 10)}.${Math.floor(Math.random() * 20)}`;
|
||||
|
||||
const isHighlighted = i < 6;
|
||||
const badges = isHighlighted
|
||||
? { highlighted: { byUserId: seedUserId, at: now } }
|
||||
: undefined;
|
||||
|
||||
const numVersions = Math.floor(Math.random() * 8) + 1;
|
||||
const numComments = Math.floor(Math.random() * 15);
|
||||
|
||||
// Create skill first (without latestVersionId)
|
||||
const skillId = await ctx.db.insert("skills", {
|
||||
slug: s.slug,
|
||||
displayName: s.displayName,
|
||||
summary: s.summary,
|
||||
ownerUserId: seedUserId,
|
||||
ownerPublisherId: publisherIds[ownerIdx] as Id<"publishers">,
|
||||
tags: {},
|
||||
badges,
|
||||
moderationStatus: "active",
|
||||
moderationVerdict: "clean",
|
||||
stats: {
|
||||
downloads: s.downloads,
|
||||
installsCurrent: Math.floor(s.installs * 0.3),
|
||||
installsAllTime: s.installs,
|
||||
stars: s.stars,
|
||||
versions: numVersions,
|
||||
comments: numComments,
|
||||
},
|
||||
statsDownloads: s.downloads,
|
||||
statsStars: s.stars,
|
||||
statsInstallsCurrent: Math.floor(s.installs * 0.3),
|
||||
statsInstallsAllTime: s.installs,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
// Create skillBadges entry for highlighted skills
|
||||
if (isHighlighted) {
|
||||
await ctx.db.insert("skillBadges", {
|
||||
skillId,
|
||||
kind: "highlighted",
|
||||
byUserId: seedUserId,
|
||||
at: now,
|
||||
});
|
||||
}
|
||||
|
||||
// Now create version with real skillId
|
||||
const versionId = await ctx.db.insert("skillVersions", {
|
||||
skillId,
|
||||
version,
|
||||
changelog: `Release ${version} — improvements and bug fixes.`,
|
||||
files: [],
|
||||
parsed: { frontmatter: {} },
|
||||
createdBy: seedUserId,
|
||||
createdAt: updatedAt,
|
||||
});
|
||||
|
||||
// Patch skill with version info
|
||||
await ctx.db.patch(skillId, {
|
||||
latestVersionId: versionId,
|
||||
latestVersionSummary: {
|
||||
version,
|
||||
createdAt: updatedAt,
|
||||
changelog: `Release ${version}`,
|
||||
},
|
||||
tags: { latest: versionId },
|
||||
});
|
||||
|
||||
totalPublishedSkills += 1;
|
||||
totalStars += s.stars;
|
||||
totalDownloads += s.downloads;
|
||||
|
||||
// Create digest for search
|
||||
await ctx.db.insert("skillSearchDigest", {
|
||||
skillId,
|
||||
slug: s.slug,
|
||||
displayName: s.displayName,
|
||||
summary: s.summary,
|
||||
ownerUserId: seedUserId,
|
||||
ownerPublisherId: publisherIds[ownerIdx] as Id<"publishers">,
|
||||
ownerHandle: DEMO_OWNERS[ownerIdx].handle,
|
||||
ownerName: DEMO_OWNERS[ownerIdx].displayName,
|
||||
ownerDisplayName: DEMO_OWNERS[ownerIdx].displayName,
|
||||
ownerImage: undefined,
|
||||
latestVersionId: versionId,
|
||||
latestVersionSummary: {
|
||||
version,
|
||||
createdAt: updatedAt,
|
||||
changelog: `Release ${version}`,
|
||||
},
|
||||
tags: { latest: versionId },
|
||||
badges,
|
||||
stats: {
|
||||
downloads: s.downloads,
|
||||
installsCurrent: Math.floor(s.installs * 0.3),
|
||||
installsAllTime: s.installs,
|
||||
stars: s.stars,
|
||||
versions: numVersions,
|
||||
comments: numComments,
|
||||
},
|
||||
versions: numVersions,
|
||||
comments: numComments,
|
||||
moderationReason: undefined,
|
||||
isSuspicious: false,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.patch(seedUserId, {
|
||||
publishedSkills: totalPublishedSkills,
|
||||
totalStars,
|
||||
totalDownloads,
|
||||
});
|
||||
|
||||
return { seeded: true, count: DEMO_SKILLS.length };
|
||||
},
|
||||
});
|
||||
|
||||
// Repair globalStats count to match actual seeded data
|
||||
export const repairGlobalStats = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
// Count active digests — push filter server-side
|
||||
const digests = await ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
|
||||
.filter((q) => q.eq(q.field("moderationStatus"), "active"))
|
||||
.collect();
|
||||
|
||||
const count = digests.length;
|
||||
|
||||
// Update globalStats
|
||||
const stats = await ctx.db
|
||||
.query("globalStats")
|
||||
.filter((q) => q.eq(q.field("key"), "default"))
|
||||
.first();
|
||||
|
||||
if (stats) {
|
||||
await ctx.db.patch(stats._id, { activeSkillsCount: count, updatedAt: Date.now() });
|
||||
} else {
|
||||
await ctx.db.insert("globalStats", {
|
||||
key: "default",
|
||||
activeSkillsCount: count,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return { count };
|
||||
},
|
||||
});
|
||||
|
||||
// Repair function to add missing skillBadges for already-seeded data
|
||||
export const repairHighlightedBadges = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const highlightedSlugs = DEMO_SKILLS.slice(0, 6).map((s) => s.slug);
|
||||
let fixed = 0;
|
||||
|
||||
for (const slug of highlightedSlugs) {
|
||||
const skill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slug))
|
||||
.first();
|
||||
if (!skill) continue;
|
||||
|
||||
// Check if badge already exists
|
||||
const existing = await ctx.db
|
||||
.query("skillBadges")
|
||||
.withIndex("by_skill_kind", (q) =>
|
||||
q.eq("skillId", skill._id).eq("kind", "highlighted"),
|
||||
)
|
||||
.first();
|
||||
if (existing) continue;
|
||||
|
||||
await ctx.db.insert("skillBadges", {
|
||||
skillId: skill._id,
|
||||
kind: "highlighted",
|
||||
byUserId: skill.ownerUserId,
|
||||
at: Date.now(),
|
||||
});
|
||||
fixed++;
|
||||
}
|
||||
|
||||
return { fixed };
|
||||
},
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
import { internalAction, internalMutation, internalQuery } from "./functions";
|
||||
import { applySkillStatDeltas, bumpDailySkillStats } from "./lib/skillStats";
|
||||
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
|
||||
|
||||
/**
|
||||
* Event types that affect skill stats:
|
||||
@@ -259,6 +260,7 @@ export const processSkillStatEventsInternal = internalMutation({
|
||||
// Don't update `updatedAt` — stat changes shouldn't move the
|
||||
// skill's position in the by_active_updated index.
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, { ...skill, ...patch });
|
||||
}
|
||||
|
||||
// NOTE: Daily stats (skillDailyStats) are written by the 15-minute
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
extractDigestFields,
|
||||
upsertSkillSearchDigest,
|
||||
} from "./lib/skillSearchDigest";
|
||||
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
|
||||
import schema from "./schema";
|
||||
|
||||
export { publishVersionForUser } from "./lib/skillPublish";
|
||||
@@ -758,6 +759,7 @@ async function hardDeleteSkillStep(
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
}
|
||||
|
||||
switch (phase) {
|
||||
@@ -2529,6 +2531,7 @@ export const report = mutation({
|
||||
const nextSkill = { ...skill, ...updates };
|
||||
await ctx.db.patch(skill._id, updates);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
if (shouldAutoHide) {
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, now);
|
||||
@@ -4255,6 +4258,7 @@ export const applyBanToOwnedSkillsBatchInternal = internalMutation({
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, args.bannedAt);
|
||||
}
|
||||
|
||||
@@ -4366,6 +4370,7 @@ export const restoreOwnedSkillsForUnbanBatchInternal = internalMutation({
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, false, now);
|
||||
restoredCount += 1;
|
||||
@@ -5305,6 +5310,7 @@ export const setSoftDeleted = mutation({
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, args.deleted, now);
|
||||
|
||||
@@ -5343,6 +5349,7 @@ export const changeOwner = mutation({
|
||||
lastReviewedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, { ...skill, ownerUserId: args.ownerUserId });
|
||||
|
||||
const embeddings = await listSkillEmbeddingsForSkill(ctx, skill._id);
|
||||
for (const embedding of embeddings) {
|
||||
@@ -6423,6 +6430,7 @@ export const insertVersion = internalMutation({
|
||||
// Digest sync is handled after the version patch below (line ~4222),
|
||||
// which captures the final state including latestVersionId and tags.
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, null, skill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, null, skill);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,20 +3,15 @@ import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import {
|
||||
assertAdmin,
|
||||
assertModerator,
|
||||
getOptionalActiveAuthUserId,
|
||||
requireUser,
|
||||
} from "./lib/access";
|
||||
import { assertAdmin, assertModerator, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
|
||||
import { syncGitHubProfile } from "./lib/githubAccount";
|
||||
import { toPublicUser } from "./lib/public";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
getPublisherByHandle,
|
||||
getUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
import { toPublicUser } from "./lib/public";
|
||||
import {
|
||||
getLatestActiveReservedHandle,
|
||||
isHandleReservedForAnotherUser,
|
||||
@@ -301,9 +296,7 @@ export async function ensureHandler(ctx: MutationCtx) {
|
||||
updates.updatedAt = Date.now();
|
||||
await ctx.db.patch(userId, updates);
|
||||
}
|
||||
const ensuredUser = hasUpdates
|
||||
? ({ ...user, ...updates } as Doc<"users">)
|
||||
: ((await ctx.db.get(userId)) ?? user);
|
||||
const ensuredUser = hasUpdates ? ({ ...user, ...updates } as Doc<"users">) : ((await ctx.db.get(userId)) ?? user);
|
||||
await ensurePersonalPublisherForUser(ctx, ensuredUser);
|
||||
return await ctx.db.get(userId);
|
||||
}
|
||||
@@ -393,6 +386,23 @@ export const list = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const listPublic = query({
|
||||
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
const limit = clampInt(args.limit ?? 40, 1, 100);
|
||||
const result = await queryUsersForPublicList(ctx, {
|
||||
limit,
|
||||
search: args.search,
|
||||
});
|
||||
return {
|
||||
items: result.items
|
||||
.map((user) => toPublicUser(user))
|
||||
.filter((user): user is NonNullable<ReturnType<typeof toPublicUser>> => Boolean(user)),
|
||||
total: result.total,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function normalizeSearchQuery(search?: string) {
|
||||
const trimmed = search?.trim().toLowerCase();
|
||||
return trimmed ? trimmed : undefined;
|
||||
@@ -425,6 +435,27 @@ async function queryUsersForAdminList(
|
||||
};
|
||||
}
|
||||
|
||||
async function queryUsersForPublicList(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
args: { limit: number; search?: string },
|
||||
) {
|
||||
const normalizedSearch = normalizeSearchQuery(args.search);
|
||||
const scanLimit = normalizedSearch
|
||||
? computeUserSearchScanLimit(args.limit)
|
||||
: clampInt(args.limit * 6, args.limit, MAX_USER_SEARCH_SCAN);
|
||||
const scannedUsers = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_active_handle", (q) => q.eq("deletedAt", undefined).eq("deactivatedAt", undefined))
|
||||
.order("desc")
|
||||
.take(scanLimit);
|
||||
const activeUsers = scannedUsers.filter((user) => Boolean(user.handle));
|
||||
const result = buildUserSearchResults(activeUsers, normalizedSearch);
|
||||
return {
|
||||
items: result.items.slice(0, args.limit),
|
||||
total: result.total,
|
||||
};
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(Math.trunc(value), min), max);
|
||||
}
|
||||
@@ -436,6 +467,20 @@ export const getByHandle = query({
|
||||
},
|
||||
});
|
||||
|
||||
/** Lightweight stats for user hover tooltips. Uses the skills by_owner index. */
|
||||
export const getHoverStats = query({
|
||||
args: { userId: v.id("users") },
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db.get(args.userId);
|
||||
|
||||
return {
|
||||
publishedSkills: user?.publishedSkills ?? 0,
|
||||
totalStars: user?.totalStars ?? 0,
|
||||
totalDownloads: user?.totalDownloads ?? 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getReservedHandleInternal = internalQuery({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -854,8 +899,7 @@ async function ensurePublisherHandleWithActor(
|
||||
|
||||
if (existing) {
|
||||
const nextDisplayName =
|
||||
args.displayName?.trim() &&
|
||||
(!existing.displayName || existing.displayName === existing.handle)
|
||||
args.displayName?.trim() && (!existing.displayName || existing.displayName === existing.handle)
|
||||
? displayName
|
||||
: existing.displayName;
|
||||
await ctx.db.patch(existing._id, {
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
@@ -58,6 +60,7 @@
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/router-plugin": "1.167.2",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clawhub-schema": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.34.1",
|
||||
@@ -67,6 +70,8 @@
|
||||
"ignore": "^7.0.5",
|
||||
"lucide-react": "^0.577.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"next": "^16.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"nitro": "3.0.260311-beta",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -77,6 +82,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 704 B |
@@ -0,0 +1,45 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
|
||||
<rect width="512" height="512" rx="0" fill="none"/>
|
||||
<!-- Body segments -->
|
||||
<g fill="#F03020" stroke="#fff" stroke-width="6" stroke-linejoin="round">
|
||||
<!-- Tail fan - center -->
|
||||
<ellipse cx="256" cy="460" rx="42" ry="30"/>
|
||||
<!-- Tail fan - left -->
|
||||
<ellipse cx="210" cy="455" rx="32" ry="24" transform="rotate(25 210 455)"/>
|
||||
<!-- Tail fan - right -->
|
||||
<ellipse cx="302" cy="455" rx="32" ry="24" transform="rotate(-25 302 455)"/>
|
||||
<!-- Lower body segment -->
|
||||
<rect x="208" y="380" width="96" height="55" rx="12"/>
|
||||
<!-- Mid-lower body -->
|
||||
<rect x="204" y="335" width="104" height="55" rx="12"/>
|
||||
<!-- Mid body -->
|
||||
<rect x="200" y="290" width="112" height="55" rx="14"/>
|
||||
<!-- Upper body / thorax -->
|
||||
<ellipse cx="256" cy="255" rx="72" ry="65"/>
|
||||
<!-- Head -->
|
||||
<circle cx="256" cy="195" r="58"/>
|
||||
<!-- Left claw arm -->
|
||||
<path d="M195 230 Q150 190 120 170 Q100 155 95 140" stroke-width="18" fill="none" stroke-linecap="round"/>
|
||||
<!-- Right claw arm -->
|
||||
<path d="M317 230 Q362 190 392 170 Q412 155 417 140" stroke-width="18" fill="none" stroke-linecap="round"/>
|
||||
<!-- Left claw -->
|
||||
<ellipse cx="88" cy="115" rx="52" ry="42" transform="rotate(-20 88 115)"/>
|
||||
<!-- Right claw -->
|
||||
<ellipse cx="424" cy="115" rx="52" ry="42" transform="rotate(20 424 115)"/>
|
||||
<!-- Left claw notch -->
|
||||
<path d="M65 95 Q78 80 72 65" stroke-width="6" fill="none" stroke="#fff" stroke-linecap="round"/>
|
||||
<!-- Right claw notch -->
|
||||
<path d="M447 95 Q434 80 440 65" stroke-width="6" fill="none" stroke="#fff" stroke-linecap="round"/>
|
||||
<!-- Left legs -->
|
||||
<path d="M205 290 Q175 295 155 305" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<path d="M202 320 Q172 330 152 340" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<path d="M205 350 Q178 358 158 370" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<!-- Right legs -->
|
||||
<path d="M307 290 Q337 295 357 305" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<path d="M310 320 Q340 330 360 340" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<path d="M307 350 Q334 358 354 370" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
</g>
|
||||
<!-- Eyes -->
|
||||
<circle cx="236" cy="185" r="10" fill="#000"/>
|
||||
<circle cx="276" cy="185" r="10" fill="#000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 169 KiB |
@@ -1,12 +1,25 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import Header from "../components/Header";
|
||||
|
||||
const siteModeMock = vi.fn(() => "souls");
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
|
||||
Link: (props: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
hash?: string;
|
||||
to?: string;
|
||||
}) => (
|
||||
<a href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`} className={props.className}>
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/" }),
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@convex-dev/auth/react", () => ({
|
||||
@@ -16,19 +29,30 @@ vi.mock("@convex-dev/auth/react", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => ({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
}),
|
||||
const authStatusMock = vi.fn(() => ({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => authStatusMock(),
|
||||
}));
|
||||
|
||||
const setThemeMock = vi.fn();
|
||||
const setModeMock = vi.fn();
|
||||
|
||||
vi.mock("../lib/theme", () => ({
|
||||
applyTheme: vi.fn(),
|
||||
THEME_OPTIONS: [
|
||||
{ value: "claw", label: "Claw", description: "" },
|
||||
{ value: "hub", label: "Hub", description: "" },
|
||||
],
|
||||
useThemeMode: () => ({
|
||||
theme: "hub",
|
||||
mode: "system",
|
||||
setMode: vi.fn(),
|
||||
setTheme: setThemeMock,
|
||||
setMode: setModeMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -56,14 +80,10 @@ vi.mock("../lib/roles", () => ({
|
||||
|
||||
vi.mock("../lib/site", () => ({
|
||||
getClawHubSiteUrl: () => "https://clawhub.ai",
|
||||
getSiteMode: () => "souls",
|
||||
getSiteMode: () => siteModeMock(),
|
||||
getSiteName: () => "OnlyCrabs",
|
||||
}));
|
||||
|
||||
vi.mock("../lib/convexError", () => ({
|
||||
getUserFacingConvexError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/gravatar", () => ({
|
||||
gravatarUrl: vi.fn(),
|
||||
}));
|
||||
@@ -78,15 +98,46 @@ vi.mock("../components/ui/dropdown-menu", () => ({
|
||||
|
||||
vi.mock("../components/ui/toggle-group", () => ({
|
||||
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
ToggleGroupItem: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
|
||||
}));
|
||||
|
||||
describe("Header", () => {
|
||||
it("hides Packages navigation in soul mode on mobile and desktop", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
|
||||
render(<Header />);
|
||||
|
||||
expect(screen.queryByText("Packages")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders direct desktop theme family controls and plain Skills tab", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
setThemeMock.mockClear();
|
||||
setModeMock.mockClear();
|
||||
|
||||
render(<Header />);
|
||||
|
||||
expect(screen.getByText("Theme")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Claw" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Hub" })).toBeTruthy();
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Souls")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Users")).toHaveLength(1);
|
||||
expect(screen.getByPlaceholderText("Search skills, plugins, users")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Hub" }));
|
||||
expect(setThemeMock).toHaveBeenCalledWith("hub");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Cycle theme family/i }));
|
||||
expect(setThemeMock).toHaveBeenCalledWith("claw");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
|
||||
expect(setModeMock).toHaveBeenCalledWith("light");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Souls")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Users")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ const useAuthStatusMock = vi.fn();
|
||||
let useActionCallCount = 0;
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useAction: () => {
|
||||
const action = [previewImport, previewCandidate, importSkill][useActionCallCount % 3];
|
||||
|
||||
@@ -26,6 +26,7 @@ const useAuthStatusMock = vi.fn();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useMutation: () => generateUploadUrl,
|
||||
useAction: () => publishRelease,
|
||||
useQuery: () => undefined,
|
||||
|
||||
@@ -1,99 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
|
||||
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convex: {},
|
||||
convexHttp: { query: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { beforeLoad?: unknown }) => ({ __config: config }),
|
||||
createFileRoute: () => (config: { validateSearch?: unknown; component?: unknown }) => ({
|
||||
__config: config,
|
||||
}),
|
||||
redirect: (options: unknown) => ({ redirect: options }),
|
||||
Link: "a",
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
import { Route } from "../routes/search";
|
||||
|
||||
function runBeforeLoad(
|
||||
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean },
|
||||
hostname = "clawdhub.com",
|
||||
) {
|
||||
function runValidateSearch(search: Record<string, unknown>) {
|
||||
const route = Route as unknown as {
|
||||
__config: {
|
||||
beforeLoad?: (args: {
|
||||
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean };
|
||||
location: { url: URL };
|
||||
}) => void;
|
||||
validateSearch?: (search: Record<string, unknown>) => unknown;
|
||||
};
|
||||
};
|
||||
const beforeLoad = route.__config.beforeLoad as (args: {
|
||||
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean };
|
||||
location: { url: URL };
|
||||
}) => void;
|
||||
let thrown: unknown;
|
||||
|
||||
try {
|
||||
beforeLoad({ search, location: { url: new URL(`https://${hostname}/search`) } });
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
return thrown;
|
||||
const validateSearch = route.__config.validateSearch;
|
||||
return validateSearch ? validateSearch(search) : {};
|
||||
}
|
||||
|
||||
describe("search route", () => {
|
||||
it("redirects skills host to the skills index", () => {
|
||||
expect(runBeforeLoad({ q: "crab", highlighted: true }, "clawdhub.com")).toEqual({
|
||||
redirect: {
|
||||
to: "/skills",
|
||||
search: {
|
||||
q: "crab",
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: true,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
it("validates search with query", () => {
|
||||
expect(runValidateSearch({ q: "crab" })).toEqual({
|
||||
q: "crab",
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards nonSuspicious filter to skills index", () => {
|
||||
expect(runBeforeLoad({ q: "crab", nonSuspicious: true }, "clawdhub.com")).toEqual({
|
||||
redirect: {
|
||||
to: "/skills",
|
||||
search: {
|
||||
q: "crab",
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
it("validates search with type filter", () => {
|
||||
expect(runValidateSearch({ q: "crab", type: "skills" })).toEqual({
|
||||
q: "crab",
|
||||
type: "skills",
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects souls host with query to home search", () => {
|
||||
expect(runBeforeLoad({ q: "crab", highlighted: true }, "onlycrabs.ai")).toEqual({
|
||||
redirect: {
|
||||
to: "/",
|
||||
search: {
|
||||
q: "crab",
|
||||
highlighted: undefined,
|
||||
search: undefined,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
it("ignores invalid type filter", () => {
|
||||
expect(runValidateSearch({ q: "crab", type: "invalid" })).toEqual({
|
||||
q: "crab",
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects souls host without query to home with search mode", () => {
|
||||
expect(runBeforeLoad({}, "onlycrabs.ai")).toEqual({
|
||||
redirect: {
|
||||
to: "/",
|
||||
search: {
|
||||
q: undefined,
|
||||
highlighted: undefined,
|
||||
search: true,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
it("accepts the users type filter", () => {
|
||||
expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({
|
||||
q: "vincent",
|
||||
type: "users",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips empty query", () => {
|
||||
expect(runValidateSearch({ q: " " })).toEqual({
|
||||
q: undefined,
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("has a component (not a redirect-only route)", () => {
|
||||
const route = Route as unknown as {
|
||||
__config: { component?: unknown };
|
||||
};
|
||||
expect(route.__config.component).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,18 @@ import { SkillDetailPage } from "../components/SkillDetailPage";
|
||||
const navigateMock = vi.fn();
|
||||
const useAuthStatusMock = vi.fn();
|
||||
|
||||
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
|
||||
|
||||
|
||||
vi.mock("../components/UserBadge", () => ({
|
||||
UserBadge: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convex: {},
|
||||
convexHttp: { query: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: ({ children }: { children: unknown }) => children,
|
||||
useNavigate: () => navigateMock,
|
||||
@@ -15,6 +27,7 @@ const useQueryMock = vi.fn();
|
||||
const getReadmeMock = vi.fn();
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useMutation: () => vi.fn(),
|
||||
useAction: () => getReadmeMock,
|
||||
@@ -24,8 +37,8 @@ vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../components/SkillDiffCard", () => ({
|
||||
SkillDiffCard: () => <div data-testid="skill-diff-card" />,
|
||||
vi.mock("../components/SkillCommentsPanel", () => ({
|
||||
SkillCommentsPanel: () => <div data-testid="skill-comments-panel" />,
|
||||
}));
|
||||
|
||||
describe("SkillDetailPage", () => {
|
||||
@@ -58,11 +71,8 @@ describe("SkillDetailPage", () => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const { container } = render(<SkillDetailPage slug="weather" />);
|
||||
// Loading state now renders a skeleton, not text
|
||||
expect(
|
||||
container.querySelector('[class*="animate-pulse"], [data-slot="skeleton"]'),
|
||||
).toBeTruthy();
|
||||
render(<SkillDetailPage slug="weather" />);
|
||||
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
|
||||
expect(screen.queryByText(/Skill not found/i)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -135,174 +145,11 @@ describe("SkillDetailPage", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// With initialData, should render content instead of skeleton
|
||||
expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
|
||||
expect(screen.queryByText(/Loading skill/i)).toBeNull();
|
||||
expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
|
||||
expect(screen.getByRole("tab", { name: "Files" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows capability tags on the skill page without other scan findings", async () => {
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "skillId" in args) return [];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
render(
|
||||
<SkillDetailPage
|
||||
slug="skill-pay"
|
||||
initialData={{
|
||||
result: {
|
||||
skill: {
|
||||
_id: skillId,
|
||||
_creationTime: 0,
|
||||
slug: "skill-pay",
|
||||
displayName: "SkillPay",
|
||||
summary: "Crypto payments for AI skills.",
|
||||
ownerUserId: ownerId,
|
||||
ownerPublisherId,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {
|
||||
stars: 12,
|
||||
downloads: 34,
|
||||
installsCurrent: 5,
|
||||
installsAllTime: 8,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
owner: {
|
||||
_id: ownerPublisherId,
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: ownerId,
|
||||
},
|
||||
latestVersion: {
|
||||
_id: versionId,
|
||||
_creationTime: 0,
|
||||
skillId,
|
||||
version: "1.0.0",
|
||||
fingerprint: "abc",
|
||||
changelog: "Initial release",
|
||||
parsed: { license: "MIT-0", frontmatter: {} },
|
||||
capabilityTags: ["crypto", "requires-wallet", "can-make-purchases"],
|
||||
sha256hash: "abc123",
|
||||
files: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
size: 10,
|
||||
storageId,
|
||||
sha256: "abc",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
createdBy: ownerId,
|
||||
createdAt: 0,
|
||||
},
|
||||
forkOf: null,
|
||||
canonical: null,
|
||||
},
|
||||
readme: "# SkillPay",
|
||||
readmeError: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "SkillPay" })).toBeTruthy();
|
||||
expect(screen.getByText("Capability signals")).toBeTruthy();
|
||||
expect(screen.getByText("Crypto")).toBeTruthy();
|
||||
expect(screen.getByText("Requires wallet")).toBeTruthy();
|
||||
expect(screen.getByText("Can make purchases")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("prefers the full frontmatter description over the shortened summary in the header", async () => {
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "skillId" in args) return [];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const fullDescription =
|
||||
"Add credit-based payments to any OpenClaw skill. Register paid skills, charge users per call, track earnings, and withdraw USDC. Use when a user wants to monetize a skill.";
|
||||
|
||||
render(
|
||||
<SkillDetailPage
|
||||
slug="skill-pay"
|
||||
initialData={{
|
||||
result: {
|
||||
skill: {
|
||||
_id: skillId,
|
||||
_creationTime: 0,
|
||||
slug: "skill-pay",
|
||||
displayName: "SkillPay",
|
||||
summary: "Add credit-based payments to any OpenClaw skill. Register paid skills...",
|
||||
ownerUserId: ownerId,
|
||||
ownerPublisherId,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {
|
||||
stars: 12,
|
||||
downloads: 34,
|
||||
installsCurrent: 5,
|
||||
installsAllTime: 8,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
owner: {
|
||||
_id: ownerPublisherId,
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: ownerId,
|
||||
},
|
||||
latestVersion: {
|
||||
_id: versionId,
|
||||
_creationTime: 0,
|
||||
skillId,
|
||||
version: "1.0.0",
|
||||
fingerprint: "abc",
|
||||
changelog: "Initial release",
|
||||
parsed: {
|
||||
license: "MIT-0",
|
||||
frontmatter: {
|
||||
description: fullDescription,
|
||||
},
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
size: 10,
|
||||
storageId,
|
||||
sha256: "abc",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
createdBy: ownerId,
|
||||
createdAt: 0,
|
||||
},
|
||||
forkOf: null,
|
||||
canonical: null,
|
||||
},
|
||||
readme: "# SkillPay",
|
||||
readmeError: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "SkillPay" })).toBeTruthy();
|
||||
// The header now always shows skill.summary (not frontmatter.description)
|
||||
expect(
|
||||
screen.getByText("Add credit-based payments to any OpenClaw skill. Register paid skills..."),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Files" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Compare" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not refetch readme when SSR data already matches the latest version", async () => {
|
||||
@@ -375,7 +222,7 @@ describe("SkillDetailPage", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
|
||||
expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
|
||||
expect(getReadmeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -394,17 +241,17 @@ describe("SkillDetailPage", () => {
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "skillId" in args) return [];
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
slug: "weather",
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:steipete",
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
slug: "weather",
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:steipete",
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: {
|
||||
_id: "publishers:steipete",
|
||||
_creationTime: 0,
|
||||
@@ -417,9 +264,8 @@ describe("SkillDetailPage", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const { container } = render(<SkillDetailPage slug="weather" redirectToCanonical />);
|
||||
// Loading state now renders a skeleton, not text
|
||||
expect(container.querySelector('[class*="animate-pulse"]')).toBeTruthy();
|
||||
render(<SkillDetailPage slug="weather" redirectToCanonical />);
|
||||
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigateMock).toHaveBeenCalled();
|
||||
@@ -524,7 +370,7 @@ describe("SkillDetailPage", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Skill not found/i)).toBeNull();
|
||||
expect(screen.queryByText(/Loading skill/i)).toBeNull();
|
||||
expect(screen.getAllByText("Weather").length).toBeGreaterThan(0);
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -634,10 +480,24 @@ describe("SkillDetailPage", () => {
|
||||
it("defers compare version query until compare tab is requested", async () => {
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (
|
||||
args &&
|
||||
typeof args === "object" &&
|
||||
"skillId" in args &&
|
||||
"limit" in args &&
|
||||
(args as { limit: number }).limit === 50
|
||||
) {
|
||||
return [
|
||||
{ _id: "skillVersions:1", version: "1.0.0", files: [] },
|
||||
{ _id: "skillVersions:2", version: "1.1.0", files: [] },
|
||||
];
|
||||
}
|
||||
if (args && typeof args === "object" && "skillId" in args && "limit" in args) {
|
||||
if ((args as { limit: number }).limit === 200) return [];
|
||||
}
|
||||
if (args && typeof args === "object" && "limit" in args) {
|
||||
return [];
|
||||
}
|
||||
if (args && typeof args === "object" && "skillId" in args) return [];
|
||||
if (args && typeof args === "object" && "slug" in args) {
|
||||
return {
|
||||
skill: {
|
||||
@@ -666,6 +526,7 @@ describe("SkillDetailPage", () => {
|
||||
|
||||
render(<SkillDetailPage slug="weather" />);
|
||||
expect(await screen.findByText("Weather")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /compare/i })).toBeTruthy();
|
||||
|
||||
expect(
|
||||
useQueryMock.mock.calls.some((call) => {
|
||||
@@ -679,9 +540,7 @@ describe("SkillDetailPage", () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
const compareTab = screen.getByRole("tab", { name: /compare/i });
|
||||
fireEvent.mouseEnter(compareTab);
|
||||
fireEvent.click(compareTab);
|
||||
fireEvent.click(screen.getByRole("button", { name: /compare/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute:
|
||||
() =>
|
||||
(config: {
|
||||
beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown;
|
||||
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>;
|
||||
component?: unknown;
|
||||
head?: unknown;
|
||||
@@ -31,6 +32,7 @@ vi.mock("../lib/skillPage", () => ({
|
||||
async function loadRoute() {
|
||||
return (await import("../routes/$owner/$slug")).Route as unknown as {
|
||||
__config: {
|
||||
beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown;
|
||||
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>;
|
||||
head?: (args: {
|
||||
params: { owner: string; slug: string };
|
||||
@@ -45,6 +47,14 @@ async function loadRoute() {
|
||||
};
|
||||
}
|
||||
|
||||
async function runBeforeLoad(params: { owner: string; slug: string }) {
|
||||
const route = await loadRoute();
|
||||
const beforeLoad = route.__config.beforeLoad as ((args: {
|
||||
params: { owner: string; slug: string };
|
||||
}) => unknown) | undefined;
|
||||
return beforeLoad?.({ params });
|
||||
}
|
||||
|
||||
async function runLoader(params: { owner: string; slug: string }) {
|
||||
const route = await loadRoute();
|
||||
const loader = route.__config.loader as (args: {
|
||||
@@ -71,6 +81,18 @@ function runHead(
|
||||
}
|
||||
|
||||
describe("skill route loader", () => {
|
||||
it("allows numeric owner handles in beforeLoad", () => {
|
||||
expect(() => runBeforeLoad({ owner: "123abc", slug: "weather" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("allows raw owner ids in beforeLoad", () => {
|
||||
expect(() => runBeforeLoad({ owner: "users:abc123", slug: "weather" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("allows raw publisher ids in beforeLoad", () => {
|
||||
expect(() => runBeforeLoad({ owner: "publishers:abc123", slug: "weather" })).not.toThrow();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSkillPageDataMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
|
||||
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
|
||||
}));
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
|
||||
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
|
||||
}));
|
||||
@@ -66,7 +67,7 @@ describe("SkillsIndex", () => {
|
||||
it("renders an empty state when no skills are returned", async () => {
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByText("No skills match that filter")).toBeTruthy();
|
||||
expect(screen.getByText("No skills found")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows loading state before fetch completes", async () => {
|
||||
@@ -74,9 +75,9 @@ describe("SkillsIndex", () => {
|
||||
convexHttpMock.query.mockReturnValue(new Promise(() => {}));
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
// Header subtitle shows "Loading skills..."
|
||||
expect(screen.getAllByText("Loading skills...").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.queryByText("No skills match that filter")).toBeNull();
|
||||
// Results area shows skeleton or dash while loading
|
||||
expect(screen.getByText("\u2014")).toBeTruthy();
|
||||
expect(screen.queryByText("No skills found")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows empty state immediately when search returns no results", async () => {
|
||||
@@ -91,8 +92,8 @@ describe("SkillsIndex", () => {
|
||||
});
|
||||
|
||||
// Should show empty state, not loading
|
||||
expect(screen.getByText("No skills match that filter")).toBeTruthy();
|
||||
expect(screen.queryByText("Loading skills...")).toBeNull();
|
||||
expect(screen.getByText("No skills found")).toBeTruthy();
|
||||
expect(screen.queryByText(/Loading skills/)).toBeNull();
|
||||
});
|
||||
|
||||
it("skips list fetch and calls search when query is set", async () => {
|
||||
@@ -136,7 +137,7 @@ describe("SkillsIndex", () => {
|
||||
|
||||
render(<SkillsIndex />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search skills by name, slug, or summary...");
|
||||
const input = screen.getByPlaceholderText("Search skills...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "cli-design-framework" } });
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -161,7 +162,7 @@ describe("SkillsIndex", () => {
|
||||
|
||||
render(<SkillsIndex />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search skills by name, slug, or summary...");
|
||||
const input = screen.getByPlaceholderText("Search skills...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "cli-design-framework" } });
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -251,9 +252,12 @@ describe("SkillsIndex", () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links[0]?.textContent).toContain("Older High Score");
|
||||
expect(links[1]?.textContent).toContain("Newer Low Score");
|
||||
const titles = Array.from(
|
||||
document.querySelectorAll(".skill-list-item-name"),
|
||||
).map((node) => node.textContent);
|
||||
|
||||
expect(titles[0]).toBe("Older High Score");
|
||||
expect(titles[1]).toBe("Newer Low Score");
|
||||
});
|
||||
|
||||
it("passes nonSuspiciousOnly to list query when filter is active", async () => {
|
||||
@@ -288,42 +292,6 @@ describe("SkillsIndex", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes capabilityTag to list query when tag filter is active", async () => {
|
||||
searchMock = { tag: "crypto" };
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
|
||||
expect(convexHttpMock.query).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
capabilityTag: "crypto",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows and clears the active capability tag filter", async () => {
|
||||
searchMock = { tag: "crypto" };
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
|
||||
const capabilityChip = screen.getByRole("button", { name: /crypto/i });
|
||||
expect(capabilityChip).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(capabilityChip);
|
||||
});
|
||||
|
||||
expect(navigateMock).toHaveBeenCalled();
|
||||
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
|
||||
replace?: boolean;
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
};
|
||||
expect(lastCall.replace).toBe(true);
|
||||
expect(lastCall.search({ tag: "crypto" })).toEqual({
|
||||
tag: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows load-more button when more results are available", async () => {
|
||||
vi.stubGlobal("IntersectionObserver", undefined);
|
||||
convexHttpMock.query.mockResolvedValue({
|
||||
@@ -356,7 +324,7 @@ describe("SkillsIndex", () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "Load more" }).hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.getByText(/Loading/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { createRef, type ComponentProps } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SkillsToolbar } from '../routes/skills/-SkillsToolbar';
|
||||
|
||||
function renderToolbar(overrides?: Partial<ComponentProps<typeof SkillsToolbar>>) {
|
||||
return render(
|
||||
<SkillsToolbar
|
||||
searchInputRef={createRef<HTMLInputElement>()}
|
||||
query=""
|
||||
hasQuery={false}
|
||||
sort="downloads"
|
||||
dir="desc"
|
||||
view="list"
|
||||
highlightedOnly={false}
|
||||
nonSuspiciousOnly={false}
|
||||
capabilityTag={undefined}
|
||||
onQueryChange={vi.fn()}
|
||||
onToggleHighlighted={vi.fn()}
|
||||
onToggleNonSuspicious={vi.fn()}
|
||||
onCapabilityTagChange={vi.fn()}
|
||||
onSortChange={vi.fn()}
|
||||
onToggleDir={vi.fn()}
|
||||
onToggleView={vi.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('SkillsToolbar', () => {
|
||||
it('keeps filter chips on a dark-mode surface', () => {
|
||||
renderToolbar();
|
||||
|
||||
const staffPicksButton = screen.getByRole('button', { name: 'Staff Picks' });
|
||||
|
||||
expect(staffPicksButton.className).toContain('dark:bg-[rgba(14,28,37,0.84)]');
|
||||
expect(staffPicksButton.className).toContain('dark:text-[rgba(245,238,232,0.88)]');
|
||||
});
|
||||
|
||||
it('uses a readable active color treatment in dark mode', () => {
|
||||
renderToolbar({ highlightedOnly: true });
|
||||
|
||||
const staffPicksButton = screen.getByRole('button', { name: 'Staff Picks' });
|
||||
|
||||
expect(staffPicksButton.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(staffPicksButton.className).toContain('dark:bg-[rgba(255,131,95,0.14)]');
|
||||
expect(staffPicksButton.className).toContain('dark:text-[#ffd5c9]');
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ const useAuthStatusMock = vi.fn();
|
||||
let useActionCallCount = 0;
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useMutation: () => generateUploadUrl,
|
||||
useAction: () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useRef } from "react";
|
||||
import { convex } from "../convex/client";
|
||||
import { getUserFacingAuthError, normalizeAuthErrorMessage } from "../lib/authErrorMessage";
|
||||
import { clearAuthError, setAuthError } from "../lib/useAuthError";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
import { UserBootstrap } from "./UserBootstrap";
|
||||
|
||||
function getPendingAuthCode() {
|
||||
@@ -82,10 +83,12 @@ export function AuthErrorHandler() {
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ConvexAuthProvider client={convex} shouldHandleCode={false}>
|
||||
<AuthCodeHandler />
|
||||
<AuthErrorHandler />
|
||||
<UserBootstrap />
|
||||
{children}
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<AuthCodeHandler />
|
||||
<AuthErrorHandler />
|
||||
<UserBootstrap />
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</ConvexAuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Database,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
Package,
|
||||
Plug,
|
||||
Shield,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { SkillCategory } from "../lib/categories";
|
||||
|
||||
type FilterItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type SortOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type BrowseSidebarProps = {
|
||||
categories?: SkillCategory[];
|
||||
activeCategory?: string;
|
||||
onCategoryChange?: (slug: string | undefined) => void;
|
||||
sortOptions: SortOption[];
|
||||
activeSort: string;
|
||||
onSortChange: (value: string) => void;
|
||||
filters: FilterItem[];
|
||||
onFilterToggle: (key: string) => void;
|
||||
};
|
||||
|
||||
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
||||
"mcp-tools": <Plug size={15} />,
|
||||
prompts: <MessageSquare size={15} />,
|
||||
workflows: <GitBranch size={15} />,
|
||||
"dev-tools": <Wrench size={15} />,
|
||||
data: <Database size={15} />,
|
||||
security: <Shield size={15} />,
|
||||
automation: <Zap size={15} />,
|
||||
other: <Package size={15} />,
|
||||
};
|
||||
|
||||
export function BrowseSidebar({
|
||||
categories,
|
||||
activeCategory,
|
||||
onCategoryChange,
|
||||
sortOptions,
|
||||
activeSort,
|
||||
onSortChange,
|
||||
filters,
|
||||
onFilterToggle,
|
||||
}: BrowseSidebarProps) {
|
||||
return (
|
||||
<aside className="browse-sidebar" aria-label="Browse filters">
|
||||
<fieldset className="sidebar-section" role="radiogroup" aria-label="Sort order">
|
||||
<legend className="sidebar-title">Sort by</legend>
|
||||
{sortOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`sidebar-option${activeSort === opt.value ? " is-active" : ""}`}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={activeSort === opt.value}
|
||||
onClick={() => onSortChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
{categories && onCategoryChange ? (
|
||||
<fieldset className="sidebar-section" role="radiogroup" aria-label="Category filter">
|
||||
<legend className="sidebar-title">Categories</legend>
|
||||
<button
|
||||
className={`sidebar-option${!activeCategory ? " is-active" : ""}`}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={!activeCategory}
|
||||
onClick={() => onCategoryChange(undefined)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.slug}
|
||||
className={`sidebar-option${activeCategory === cat.slug ? " is-active" : ""}`}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={activeCategory === cat.slug}
|
||||
onClick={() => onCategoryChange(cat.slug)}
|
||||
>
|
||||
<span className="sidebar-option-icon" aria-hidden="true">
|
||||
{CATEGORY_ICONS[cat.slug]}
|
||||
</span>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
<fieldset className="sidebar-section" aria-label="Toggle filters">
|
||||
<legend className="sidebar-title">Filters</legend>
|
||||
{filters.map((f) => (
|
||||
<label key={f.key} className="sidebar-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={f.active}
|
||||
onChange={() => onFilterToggle(f.key)}
|
||||
aria-label={f.label}
|
||||
/>
|
||||
<span>{f.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -69,17 +69,7 @@ function DeploymentDriftBannerContent() {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
margin: "16px auto 0",
|
||||
width: "min(1100px, calc(100vw - 32px))",
|
||||
border: "1px solid #f59e0b",
|
||||
background: "#fff7ed",
|
||||
color: "#9a3412",
|
||||
borderRadius: "14px",
|
||||
padding: "12px 16px",
|
||||
fontSize: "0.95rem",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
className="mx-auto mt-4 w-[min(1100px,calc(100vw-32px))] rounded-[14px] border border-status-warning-fg/40 bg-status-warning-bg px-4 py-3 text-[0.95rem] leading-[1.4] text-status-warning-fg"
|
||||
>
|
||||
Deploy mismatch detected. Frontend expects backend build <code>{drift.expectedBuildSha}</code>{" "}
|
||||
but Convex reports <code>{drift.actualBuildSha}</code>.
|
||||
|
||||
@@ -1,34 +1,51 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { FOOTER_NAV_SECTIONS } from "../lib/nav-items";
|
||||
import { getSiteName } from "../lib/site";
|
||||
import { Separator } from "./ui/separator";
|
||||
|
||||
export function Footer() {
|
||||
const siteName = getSiteName();
|
||||
return (
|
||||
<footer className="mt-auto px-7 pb-8 pt-12">
|
||||
<div className="mx-auto max-w-[1200px]">
|
||||
<Separator className="mb-6" />
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[0.82rem] text-[color:var(--ink-soft)]">
|
||||
<span className="font-semibold text-[color:var(--ink)]">{siteName}</span>
|
||||
<FooterLink href="https://openclaw.ai">OpenClaw</FooterLink>
|
||||
<FooterLink href="https://vercel.com">Vercel</FooterLink>
|
||||
<FooterLink href="https://www.convex.dev">Convex</FooterLink>
|
||||
<FooterLink href="https://github.com/openclaw/clawhub">Open source (MIT)</FooterLink>
|
||||
<FooterLink href="https://steipete.me">Peter Steinberger</FooterLink>
|
||||
<footer className="site-footer" role="contentinfo">
|
||||
<div className="site-footer-inner">
|
||||
<div className="site-footer-divider" aria-hidden="true" />
|
||||
<div className="footer-grid">
|
||||
{FOOTER_NAV_SECTIONS.map((section) => (
|
||||
<div key={section.title} className="footer-col">
|
||||
<h4 className="footer-col-title">{section.title}</h4>
|
||||
{section.items.map((item) => {
|
||||
if (item.kind === "link") {
|
||||
return (
|
||||
<Link key={item.label} to={item.to} search={item.search ?? {}}>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
if (item.kind === "external") {
|
||||
return (
|
||||
<a key={item.label} href={item.href} target="_blank" rel="noreferrer">
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
// kind === "text"
|
||||
return <span key={item.label}>{item.label}</span>;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="footer-bottom">
|
||||
<span>
|
||||
{siteName} — An{" "}
|
||||
<a href="https://openclaw.ai" target="_blank" rel="noreferrer">
|
||||
OpenClaw
|
||||
</a>{" "}
|
||||
project by{" "}
|
||||
<a href="https://steipete.me" target="_blank" rel="noreferrer">
|
||||
Peter Steinberger
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLink({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[color:var(--ink-soft)] transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Menu, Monitor, Moon, Plus, Search, Sun } from "lucide-react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { Ghost, Github, Menu, Monitor, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
|
||||
import { type ComponentType, useMemo, useRef, useState } from "react";
|
||||
import { getUserFacingAuthError } from "../lib/authErrorMessage";
|
||||
import { gravatarUrl } from "../lib/gravatar";
|
||||
import {
|
||||
filterNavItems,
|
||||
type NavIconName,
|
||||
PRIMARY_NAV_ITEMS,
|
||||
SECONDARY_NAV_ITEMS,
|
||||
} from "../lib/nav-items";
|
||||
import { isModerator } from "../lib/roles";
|
||||
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
|
||||
import { applyTheme, useThemeMode } from "../lib/theme";
|
||||
import { applyTheme, THEME_OPTIONS, useThemeMode } from "../lib/theme";
|
||||
import { startThemeTransition } from "../lib/theme-transition";
|
||||
import { useAuthError } from "../lib/useAuthError";
|
||||
import { SignInButton } from "./SignInButton";
|
||||
import { setAuthError, useAuthError } from "../lib/useAuthError";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -19,309 +24,444 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "./ui/sheet";
|
||||
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
|
||||
|
||||
const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?: string }>> = {
|
||||
wrench: Wrench,
|
||||
plug: Plug,
|
||||
ghost: Ghost,
|
||||
};
|
||||
|
||||
const THEME_FAMILY_ICONS: Record<string, ComponentType<{ size?: number; className?: string }>> = {
|
||||
claw: Ghost,
|
||||
hub: Plug,
|
||||
};
|
||||
|
||||
const THEME_MODE_SEQUENCE: Array<"system" | "light" | "dark"> = ["system", "light", "dark"];
|
||||
|
||||
export default function Header() {
|
||||
const { isAuthenticated, isLoading, me } = useAuthStatus();
|
||||
const { signOut } = useAuthActions();
|
||||
const { mode, setMode } = useThemeMode();
|
||||
const { signIn, signOut } = useAuthActions();
|
||||
const { theme, mode, setMode, setTheme } = useThemeMode();
|
||||
const toggleRef = useRef<HTMLDivElement | null>(null);
|
||||
const siteMode = getSiteMode();
|
||||
const siteName = useMemo(() => getSiteName(siteMode), [siteMode]);
|
||||
const isSoulMode = siteMode === "souls";
|
||||
const clawHubUrl = getClawHubSiteUrl();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const avatar = me?.image ?? (me?.email ? gravatarUrl(me.email) : undefined);
|
||||
const handle = me?.handle ?? me?.displayName ?? "user";
|
||||
const initial = (me?.displayName ?? me?.name ?? handle).charAt(0).toUpperCase();
|
||||
const isStaff = isModerator(me);
|
||||
const hasResolvedUser = Boolean(me);
|
||||
const navCtx = useMemo(
|
||||
() => ({ isSoulMode, isAuthenticated: hasResolvedUser, isStaff }),
|
||||
[hasResolvedUser, isSoulMode, isStaff],
|
||||
);
|
||||
const primaryItems = useMemo(() => filterNavItems(PRIMARY_NAV_ITEMS, navCtx), [navCtx]);
|
||||
const secondaryItems = useMemo(() => filterNavItems(SECONDARY_NAV_ITEMS, navCtx), [navCtx]);
|
||||
const { error: authError, clear: clearAuthError } = useAuthError();
|
||||
const signInRedirectTo = getCurrentRelativeUrl();
|
||||
|
||||
const setTheme = (next: "system" | "light" | "dark") => {
|
||||
const [navSearchQuery, setNavSearchQuery] = useState("");
|
||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const themeLabel = THEME_OPTIONS.find((option) => option.value === theme)?.label ?? "Claw";
|
||||
const ThemeFamilyIcon = THEME_FAMILY_ICONS[theme] ?? Wrench;
|
||||
const ThemeModeIcon = getThemeModeIcon(mode);
|
||||
|
||||
const setThemeMode = (next: "system" | "light" | "dark") => {
|
||||
startThemeTransition({
|
||||
nextTheme: next,
|
||||
currentTheme: mode,
|
||||
setTheme: (value) => {
|
||||
const nextMode = value as "system" | "light" | "dark";
|
||||
applyTheme(nextMode);
|
||||
applyTheme(nextMode, theme);
|
||||
setMode(nextMode);
|
||||
},
|
||||
context: { element: toggleRef.current },
|
||||
});
|
||||
};
|
||||
|
||||
const navLinks = (
|
||||
<>
|
||||
{isSoulMode ? (
|
||||
<a
|
||||
href={clawHubUrl}
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
ClawHub
|
||||
</a>
|
||||
) : null}
|
||||
{isSoulMode ? (
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
Souls
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
Skills
|
||||
</Link>
|
||||
)}
|
||||
{isSoulMode ? null : (
|
||||
<Link
|
||||
to="/plugins"
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
Plugins
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to={isSoulMode ? "/souls" : "/skills"}
|
||||
search={
|
||||
isSoulMode
|
||||
? { q: undefined, sort: undefined, dir: undefined, view: undefined, focus: "search" }
|
||||
: {
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: "search",
|
||||
}
|
||||
}
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)] inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
Search
|
||||
</Link>
|
||||
{isSoulMode ? null : (
|
||||
<Link
|
||||
to="/about"
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
)}
|
||||
{me ? (
|
||||
<Link
|
||||
to="/stars"
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
Stars
|
||||
</Link>
|
||||
) : null}
|
||||
{isStaff ? (
|
||||
<Link
|
||||
to="/management"
|
||||
search={{ skill: undefined }}
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
Management
|
||||
</Link>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const setThemeFamily = (nextTheme: string) => {
|
||||
applyTheme(mode, nextTheme);
|
||||
setTheme(nextTheme);
|
||||
};
|
||||
|
||||
const cycleThemeFamily = () => {
|
||||
const currentIndex = Math.max(
|
||||
0,
|
||||
THEME_OPTIONS.findIndex((option) => option.value === theme),
|
||||
);
|
||||
const nextTheme = THEME_OPTIONS[(currentIndex + 1) % THEME_OPTIONS.length]?.value ?? "claw";
|
||||
setThemeFamily(nextTheme);
|
||||
};
|
||||
|
||||
const cycleThemeMode = () => {
|
||||
const currentIndex = Math.max(0, THEME_MODE_SEQUENCE.indexOf(mode));
|
||||
const nextMode = THEME_MODE_SEQUENCE[(currentIndex + 1) % THEME_MODE_SEQUENCE.length] ?? "system";
|
||||
setThemeMode(nextMode);
|
||||
};
|
||||
|
||||
const handleNavSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = navSearchQuery.trim();
|
||||
if (!q) return;
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q, type: undefined },
|
||||
});
|
||||
setNavSearchQuery("");
|
||||
setMobileSearchOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-[color:var(--line)] bg-[color:var(--nav-bg)] backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-16 max-w-[1200px] items-center justify-between gap-4 px-5">
|
||||
{/* Brand */}
|
||||
<Link
|
||||
to="/"
|
||||
search={{ q: undefined, highlighted: undefined, search: undefined }}
|
||||
className="flex items-center gap-2.5 font-display text-lg font-bold text-[color:var(--ink)] no-underline transition-opacity hover:opacity-80"
|
||||
>
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] p-0.5">
|
||||
<img
|
||||
src="/clawd-logo.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
</span>
|
||||
<span>{siteName}</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden items-center gap-6 md:flex">{navLinks}</nav>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Publish CTA (desktop, authenticated) */}
|
||||
{isAuthenticated && me && (
|
||||
<Link
|
||||
to="/publish-skill"
|
||||
search={{ updateSlug: undefined }}
|
||||
className="hidden sm:block"
|
||||
>
|
||||
<Button variant="primary" size="sm">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Publish
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Mobile nav trigger */}
|
||||
<div className="md:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label="Open menu">
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-72">
|
||||
<SheetHeader>
|
||||
<header className="navbar">
|
||||
<div className="navbar-inner">
|
||||
{/* Row 1: Brand + Search + Actions */}
|
||||
<div className="navbar-top">
|
||||
<div className="nav-mobile">
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<button
|
||||
className="nav-mobile-trigger"
|
||||
type="button"
|
||||
aria-label="Open menu"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
>
|
||||
<Menu className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<SheetContent side="left" className="mobile-nav-sheet">
|
||||
<SheetHeader className="pr-10">
|
||||
<SheetTitle>{siteName}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Browse sections, switch theme, and access account actions.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<nav className="mt-6 flex flex-col gap-4">{navLinks}</nav>
|
||||
{/* Mobile theme toggle */}
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-[color:var(--ink-soft)]">
|
||||
Theme
|
||||
</span>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={mode}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setTheme(value as "system" | "light" | "dark");
|
||||
<div className="mobile-nav-section">
|
||||
{isSoulMode ? (
|
||||
<SheetClose asChild>
|
||||
<a href={clawHubUrl} className="mobile-nav-link">
|
||||
ClawHub
|
||||
</a>
|
||||
</SheetClose>
|
||||
) : null}
|
||||
{primaryItems.map((item) => (
|
||||
<SheetClose key={item.to + item.label} asChild>
|
||||
<Link to={item.to} search={item.search ?? {}} className="mobile-nav-link">
|
||||
{item.label}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
))}
|
||||
{secondaryItems.map((item) => (
|
||||
<SheetClose key={item.to + item.label} asChild>
|
||||
<Link to={item.to} search={item.search ?? {}} className="mobile-nav-link">
|
||||
{item.label === "Management" ? "Manage" : item.label}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
))}
|
||||
</div>
|
||||
<div className="mobile-nav-section">
|
||||
<div className="mobile-nav-section-title">Theme family</div>
|
||||
{THEME_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className="mobile-nav-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThemeFamily(option.value);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{theme === option.value ? <span className="mobile-nav-meta">Selected</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mobile-nav-section">
|
||||
<div className="mobile-nav-section-title">Theme mode</div>
|
||||
<button
|
||||
className="mobile-nav-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThemeMode("system");
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
aria-label="Theme mode"
|
||||
>
|
||||
<ToggleGroupItem value="system" aria-label="System theme">
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="light" aria-label="Light theme">
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark" aria-label="Dark theme">
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
System
|
||||
</button>
|
||||
<button
|
||||
className="mobile-nav-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThemeMode("light");
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
className="mobile-nav-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThemeMode("dark");
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
Dark
|
||||
</button>
|
||||
</div>
|
||||
{/* Mobile publish link */}
|
||||
{isAuthenticated && me && (
|
||||
<div className="mt-6">
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
<Button variant="primary" className="w-full">
|
||||
<Plus className="h-4 w-4" />
|
||||
Publish Skill
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
{/* Desktop theme toggle */}
|
||||
<div className="theme-toggle hidden md:block" ref={toggleRef}>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={mode}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setTheme(value as "system" | "light" | "dark");
|
||||
}}
|
||||
aria-label="Theme mode"
|
||||
>
|
||||
<ToggleGroupItem value="system" aria-label="System theme">
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="light" aria-label="Light theme">
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark" aria-label="Dark theme">
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<Link
|
||||
to="/"
|
||||
search={{ q: undefined, highlighted: undefined, search: undefined }}
|
||||
className="brand"
|
||||
>
|
||||
<span className="brand-mark">
|
||||
<img src="/clawd-logo.png" alt="" aria-hidden="true" className="brand-mark-image" />
|
||||
</span>
|
||||
<span className="brand-name brand-name-responsive">{siteName}</span>
|
||||
</Link>
|
||||
|
||||
{/* User menu / Sign in */}
|
||||
{isAuthenticated && me ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<form className="navbar-search" onSubmit={handleNavSearch} role="search" aria-label="Site search">
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="navbar-search-input"
|
||||
type="search"
|
||||
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
|
||||
value={navSearchQuery}
|
||||
onChange={(e) => setNavSearchQuery(e.target.value)}
|
||||
aria-label="Search"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="nav-actions">
|
||||
<button
|
||||
className="navbar-search-mobile-trigger"
|
||||
type="button"
|
||||
aria-label="Search"
|
||||
onClick={() => setMobileSearchOpen(!mobileSearchOpen)}
|
||||
>
|
||||
<Search size={18} aria-hidden="true" />
|
||||
</button>
|
||||
<div className="theme-toggle" ref={toggleRef}>
|
||||
<div className="theme-picker-desktop" aria-label={`Theme family, current ${themeLabel}`}>
|
||||
<div className="theme-family-toggle" role="group" aria-label="Theme family">
|
||||
{THEME_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className="theme-family-button"
|
||||
data-state={theme === option.value ? "on" : "off"}
|
||||
aria-pressed={theme === option.value}
|
||||
onClick={() => setThemeFamily(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="theme-cycle-group" aria-label="Theme controls">
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-2 rounded-full border border-[color:var(--line)] bg-[color:var(--surface)] px-2 py-1.5 text-sm font-semibold text-[color:var(--ink)] transition-colors hover:border-[color:var(--border-ui-hover)]"
|
||||
className="theme-cycle-button theme-cycle-button-family"
|
||||
onClick={cycleThemeFamily}
|
||||
aria-label={`Cycle theme family. Current: ${themeLabel}`}
|
||||
title={`Theme family: ${themeLabel}`}
|
||||
>
|
||||
<Avatar className="h-7 w-7">
|
||||
{avatar && (
|
||||
<AvatarImage src={avatar} alt={me.displayName ?? me.name ?? "User avatar"} />
|
||||
)}
|
||||
<AvatarFallback className="text-xs">{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden font-mono text-xs sm:inline">@{handle}</span>
|
||||
<span className="text-xs text-[color:var(--ink-soft)]">▾</span>
|
||||
<ThemeFamilyIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/dashboard">Dashboard</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings">Settings</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => void signOut()}>Sign out</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<>
|
||||
{authError ? (
|
||||
<div
|
||||
className="flex items-center gap-1 text-[0.85rem] text-red-600 dark:text-red-400"
|
||||
role="alert"
|
||||
<button
|
||||
type="button"
|
||||
className="theme-cycle-button theme-cycle-button-mode"
|
||||
onClick={cycleThemeMode}
|
||||
aria-label={`Cycle theme mode. Current: ${mode}`}
|
||||
title={`Theme mode: ${mode}`}
|
||||
>
|
||||
{authError}
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAuthError}
|
||||
aria-label="Dismiss"
|
||||
className="ml-1 cursor-pointer border-none bg-transparent p-0.5 text-inherit opacity-70 hover:opacity-100"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<SignInButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<ToggleGroup
|
||||
className="theme-mode-toggle"
|
||||
type="single"
|
||||
value={mode}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setThemeMode(value as "system" | "light" | "dark");
|
||||
}}
|
||||
aria-label={`Theme mode, ${themeLabel} preset`}
|
||||
>
|
||||
<span>Sign in</span>
|
||||
<span className="hidden text-white/70 sm:inline">with GitHub</span>
|
||||
</SignInButton>
|
||||
</>
|
||||
)}
|
||||
<ToggleGroupItem value="system" aria-label="System theme">
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">System</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="light" aria-label="Light theme">
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">Light</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark" aria-label="Dark theme">
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">Dark</span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
{isAuthenticated && me ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="user-trigger" type="button">
|
||||
{avatar ? (
|
||||
<img src={avatar} alt={me.displayName ?? me.name ?? "User avatar"} />
|
||||
) : (
|
||||
<span className="user-menu-fallback">{initial}</span>
|
||||
)}
|
||||
<span className="mono">@{handle}</span>
|
||||
<span className="user-menu-chevron">▾</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/dashboard">Dashboard</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings">Settings</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => void signOut()}>Sign out</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<>
|
||||
{authError ? (
|
||||
<div className="error mr-2 text-[0.85rem]" role="alert">
|
||||
{authError}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAuthError}
|
||||
aria-label="Dismiss"
|
||||
className="cursor-pointer border-none bg-transparent px-0.5 py-0 text-inherit"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
clearAuthError();
|
||||
void signIn(
|
||||
"github",
|
||||
signInRedirectTo ? { redirectTo: signInRedirectTo } : undefined,
|
||||
).catch((error) => {
|
||||
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Github size={16} aria-hidden="true" />
|
||||
<span className="sign-in-label">Sign in</span>
|
||||
<span className="sign-in-provider">with GitHub</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile search bar (expandable) */}
|
||||
{mobileSearchOpen ? (
|
||||
<form className="navbar-search-mobile" onSubmit={handleNavSearch}>
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="navbar-search-input"
|
||||
type="text"
|
||||
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
|
||||
value={navSearchQuery}
|
||||
onChange={(e) => setNavSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{/* Row 2: Content type tabs */}
|
||||
<nav className="navbar-tabs" aria-label="Content types">
|
||||
<div className="navbar-tabs-primary">
|
||||
{isSoulMode ? (
|
||||
<a href={clawHubUrl} className="navbar-tab">
|
||||
ClawHub
|
||||
</a>
|
||||
) : null}
|
||||
{primaryItems.map((item) => {
|
||||
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
|
||||
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
|
||||
location.pathname.startsWith(prefix)
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={item.to + item.label}
|
||||
to={item.to}
|
||||
className="navbar-tab"
|
||||
search={item.search ?? {}}
|
||||
data-status={isActiveByPrefix ? "active" : undefined}
|
||||
>
|
||||
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="navbar-tabs-secondary">
|
||||
{secondaryItems.map((item) => {
|
||||
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
|
||||
location.pathname.startsWith(prefix)
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={item.to + item.label}
|
||||
to={item.to}
|
||||
search={item.search ?? {}}
|
||||
className="navbar-tab navbar-tab-secondary"
|
||||
data-status={isActiveByPrefix ? "active" : undefined}
|
||||
>
|
||||
{item.label === "Management" ? "Manage" : item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentRelativeUrl() {
|
||||
if (typeof window === "undefined") return "/";
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
function getThemeModeIcon(mode: "system" | "light" | "dark") {
|
||||
switch (mode) {
|
||||
case "light":
|
||||
return Sun;
|
||||
case "dark":
|
||||
return Moon;
|
||||
case "system":
|
||||
default:
|
||||
return Monitor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function InstallSwitcher({ exampleSlug = "sonoscli" }: InstallSwitcherPro
|
||||
type="button"
|
||||
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
|
||||
pm === entry.id
|
||||
? "bg-[color:var(--accent)] text-white shadow-sm"
|
||||
? "bg-accent text-accent-fg shadow-sm"
|
||||
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
|
||||
}`}
|
||||
role="tab"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { FileText, Package, Plug, User } from "lucide-react";
|
||||
|
||||
type MarketplaceIconProps = {
|
||||
kind: "skill" | "plugin" | "soul" | "user";
|
||||
label: string;
|
||||
imageUrl?: string | null;
|
||||
size?: "sm" | "md";
|
||||
};
|
||||
|
||||
const TONES = [
|
||||
{ accent: "oklch(0.63 0.16 42)", wash: "oklch(0.95 0.04 42)" },
|
||||
{ accent: "oklch(0.61 0.15 168)", wash: "oklch(0.95 0.04 168)" },
|
||||
{ accent: "oklch(0.59 0.14 236)", wash: "oklch(0.95 0.04 236)" },
|
||||
{ accent: "oklch(0.66 0.13 92)", wash: "oklch(0.96 0.04 92)" },
|
||||
] as const;
|
||||
|
||||
function hashTone(label: string) {
|
||||
let sum = 0;
|
||||
for (const char of label) sum += char.charCodeAt(0);
|
||||
return TONES[sum % TONES.length] ?? TONES[0];
|
||||
}
|
||||
|
||||
function getIcon(kind: MarketplaceIconProps["kind"]) {
|
||||
switch (kind) {
|
||||
case "plugin":
|
||||
return Plug;
|
||||
case "soul":
|
||||
return FileText;
|
||||
case "user":
|
||||
return User;
|
||||
default:
|
||||
return Package;
|
||||
}
|
||||
}
|
||||
|
||||
export function MarketplaceIcon({
|
||||
kind,
|
||||
label,
|
||||
imageUrl,
|
||||
size = "sm",
|
||||
}: MarketplaceIconProps) {
|
||||
const Icon = getIcon(kind);
|
||||
const tone = hashTone(label);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`marketplace-icon marketplace-icon-${size}`}
|
||||
style={
|
||||
{
|
||||
"--marketplace-icon-accent": tone.accent,
|
||||
"--marketplace-icon-wash": tone.wash,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img className="marketplace-icon-image" src={imageUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<Icon className="marketplace-icon-glyph" strokeWidth={1.8} />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { familyLabel } from "../lib/packageLabels";
|
||||
import type { PackageListItem } from "../lib/packageApi";
|
||||
|
||||
type PluginListItemProps = {
|
||||
item: PackageListItem;
|
||||
};
|
||||
|
||||
export function PluginListItem({ item }: PluginListItemProps) {
|
||||
return (
|
||||
<Link to="/plugins/$name" params={{ name: item.name }} className="skill-list-item" aria-label={`Plugin: ${item.displayName}`}>
|
||||
<MarketplaceIcon kind="plugin" label={item.displayName} />
|
||||
<div className="skill-list-item-body">
|
||||
<div className="skill-list-item-main">
|
||||
{item.ownerHandle ? (
|
||||
<>
|
||||
<span className="skill-list-item-owner">@{item.ownerHandle}</span>
|
||||
<span className="skill-list-item-sep">/</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="skill-list-item-name">{item.displayName}</span>
|
||||
<Badge variant="compact">{familyLabel(item.family)}</Badge>
|
||||
{item.isOfficial ? <Badge variant="accent">Verified</Badge> : null}
|
||||
</div>
|
||||
<p className="skill-list-item-summary">{item.summary ?? "Plugin package for agent workflows."}</p>
|
||||
<div className="skill-list-item-meta">
|
||||
<span className="skill-list-item-meta-item">Plugin</span>
|
||||
{item.latestVersion ? (
|
||||
<span className="skill-list-item-meta-item">v{item.latestVersion}</span>
|
||||
) : null}
|
||||
<span className="skill-list-item-meta-item">
|
||||
{item.ownerHandle ? `@${item.ownerHandle}` : "community"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { PublicSkill } from "../lib/publicUser";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
import { Badge } from "./ui/badge";
|
||||
import type { PublicSkill } from "../lib/publicUser";
|
||||
|
||||
type SkillCardProps = {
|
||||
skill: PublicSkill;
|
||||
@@ -12,7 +12,6 @@ type SkillCardProps = {
|
||||
summaryFallback: string;
|
||||
meta: ReactNode;
|
||||
href?: string;
|
||||
verified?: boolean;
|
||||
};
|
||||
|
||||
export function SkillCard({
|
||||
@@ -23,7 +22,6 @@ export function SkillCard({
|
||||
summaryFallback,
|
||||
meta,
|
||||
href,
|
||||
verified,
|
||||
}: SkillCardProps) {
|
||||
const owner = encodeURIComponent(String(skill.ownerUserId));
|
||||
const link = href ?? `/${owner}/${skill.slug}`;
|
||||
@@ -31,43 +29,28 @@ export function SkillCard({
|
||||
const hasTags = badges.length || chip || platformLabels?.length;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={link}
|
||||
className="group flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[22px] no-underline transition-all duration-200 ease-out hover:-translate-y-0.5 hover:shadow-[0_12px_28px_rgba(29,26,23,0.12)] hover:border-[color:var(--border-ui-hover)]"
|
||||
>
|
||||
<Link to={link} className="card skill-card">
|
||||
{hasTags ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div className="skill-card-tags">
|
||||
{badges.map((label) => (
|
||||
<Badge key={label} variant="default">
|
||||
<Badge key={label}>
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
{chip ? (
|
||||
<Badge variant="accent" className="text-[0.72rem] px-2.5 py-0.5">
|
||||
{chip}
|
||||
</Badge>
|
||||
) : null}
|
||||
{chip ? <Badge variant="accent">{chip}</Badge> : null}
|
||||
{platformLabels?.map((label) => (
|
||||
<Badge key={label} variant="compact">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
{verified && (
|
||||
<span className="inline-flex items-center gap-1 text-[0.72rem] font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<h3 className="font-display text-base font-bold leading-tight text-[color:var(--ink)] group-hover:text-[color:var(--accent)]">
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
<p className="line-clamp-2 text-sm leading-relaxed text-[color:var(--ink-soft)]">
|
||||
{skill.summary ?? summaryFallback}
|
||||
</p>
|
||||
<div className="mt-auto flex flex-col gap-2 pt-1 text-[0.82rem] text-[color:var(--ink-soft)]">
|
||||
{meta}
|
||||
<div className="skill-card-header">
|
||||
<MarketplaceIcon kind="skill" label={skill.displayName} size="md" />
|
||||
<h3 className="skill-card-title">{skill.displayName}</h3>
|
||||
</div>
|
||||
<p className="skill-card-summary">{skill.summary ?? summaryFallback}</p>
|
||||
<div className="skill-card-footer">{meta}</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,16 @@ import { useNavigate } from "@tanstack/react-router";
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { canManageSkill, isModerator } from "../lib/roles";
|
||||
import { hasOwnProperty } from "../lib/hasOwnProperty";
|
||||
import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import { Card } from "./ui/card";
|
||||
import { ClientOnly } from "./ClientOnly";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { Container } from "./layout/Container";
|
||||
import { SkillDetailSkeleton } from "./skeletons/SkillDetailSkeleton";
|
||||
import { SkillCommentsPanel } from "./SkillCommentsPanel";
|
||||
import { SkillDetailTabs } from "./SkillDetailTabs";
|
||||
import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs";
|
||||
import { SkillMetadataSidebar } from "./SkillMetadataSidebar";
|
||||
import {
|
||||
buildSkillHref,
|
||||
formatConfigSnippet,
|
||||
@@ -25,8 +22,6 @@ import {
|
||||
import { SkillHeader } from "./SkillHeader";
|
||||
import { SkillOwnershipPanel } from "./SkillOwnershipPanel";
|
||||
import { SkillReportDialog } from "./SkillReportDialog";
|
||||
import { Card } from "./ui/card";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
type SkillDetailPageProps = {
|
||||
slug: string;
|
||||
@@ -38,11 +33,13 @@ type SkillDetailPageProps = {
|
||||
type SkillFile = Doc<"skillVersions">["files"][number];
|
||||
|
||||
function formatReportError(error: unknown) {
|
||||
if (hasOwnProperty(error, "data")) {
|
||||
if (error && typeof error === "object" && "data" in error) {
|
||||
const data = (error as { data?: unknown }).data;
|
||||
if (typeof data === "string" && data.trim()) return data.trim();
|
||||
if (
|
||||
hasOwnProperty(data, "message") &&
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
"message" in data &&
|
||||
typeof (data as { message?: unknown }).message === "string"
|
||||
) {
|
||||
const message = (data as { message?: string }).message?.trim();
|
||||
@@ -98,7 +95,7 @@ export function SkillDetailPage({
|
||||
);
|
||||
const [tagName, setTagName] = useState("latest");
|
||||
const [tagVersionId, setTagVersionId] = useState<Id<"skillVersions"> | "">("");
|
||||
const [activeTab, setActiveTab] = useState<"files" | "compare" | "versions">("files");
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>("readme");
|
||||
const [shouldPrefetchCompare, setShouldPrefetchCompare] = useState(false);
|
||||
const [isReportDialogOpen, setIsReportDialogOpen] = useState(false);
|
||||
const [reportReason, setReportReason] = useState("");
|
||||
@@ -215,7 +212,6 @@ export function SkillDetailPage({
|
||||
?.clawdis;
|
||||
const osLabels = useMemo(() => formatOsList(clawdis?.os), [clawdis?.os]);
|
||||
const nixPlugin = clawdis?.nix?.plugin;
|
||||
const nixSystems = clawdis?.nix?.systems ?? [];
|
||||
const nixSnippet = nixPlugin ? formatNixInstallSnippet(nixPlugin) : null;
|
||||
const configRequirements = clawdis?.config;
|
||||
const configExample = configRequirements?.example
|
||||
@@ -299,13 +295,10 @@ export function SkillDetailPage({
|
||||
|
||||
const deleteTag = (tag: string) => {
|
||||
if (!skill) return;
|
||||
toast(`Delete tag "${tag}"?`, {
|
||||
action: {
|
||||
label: "Delete",
|
||||
onClick: () => {
|
||||
void deleteTags({ skillId: skill._id, tags: [tag] });
|
||||
},
|
||||
},
|
||||
if (!window.confirm(`Delete tag "${tag}"?`)) return;
|
||||
void deleteTags({
|
||||
skillId: skill._id,
|
||||
tags: [tag],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -324,9 +317,9 @@ export function SkillDetailPage({
|
||||
const submission = await reportSkill({ skillId: skill._id, reason: trimmedReason });
|
||||
closeReportDialog();
|
||||
if (submission.reported) {
|
||||
toast.success("Thanks — your report has been submitted.");
|
||||
window.alert("Thanks — your report has been submitted.");
|
||||
} else {
|
||||
toast.info("You have already reported this skill.");
|
||||
window.alert("You have already reported this skill.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to report skill", error);
|
||||
@@ -336,19 +329,19 @@ export function SkillDetailPage({
|
||||
};
|
||||
|
||||
if (isLoadingSkill || wantsCanonicalRedirect) {
|
||||
return <SkillDetailSkeleton />;
|
||||
return (
|
||||
<main className="section">
|
||||
<Card>
|
||||
<div className="loading-indicator">Loading skill…</div>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (result === null || !skill) {
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container>
|
||||
<EmptyState
|
||||
title="Skill not found"
|
||||
description="The skill you're looking for doesn't exist or may have been removed."
|
||||
action={{ label: "Browse skills", href: "/skills" }}
|
||||
/>
|
||||
</Container>
|
||||
<main className="section">
|
||||
<Card>Skill not found.</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -356,80 +349,91 @@ export function SkillDetailPage({
|
||||
const tagEntries = Object.entries(skill.tags ?? {}) as Array<[string, Id<"skillVersions">]>;
|
||||
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container>
|
||||
<div className="flex flex-col gap-6">
|
||||
<SkillHeader
|
||||
skill={skill}
|
||||
owner={owner}
|
||||
<main className="section">
|
||||
<div className="skill-detail-stack">
|
||||
<SkillHeader
|
||||
skill={skill}
|
||||
owner={owner}
|
||||
ownerHandle={ownerHandle}
|
||||
latestVersion={latestVersion}
|
||||
modInfo={modInfo}
|
||||
canManage={canManage}
|
||||
isAuthenticated={isAuthenticated}
|
||||
isStaff={isStaff}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={() => void toggleStar({ skillId: skill._id })}
|
||||
onOpenReport={openReportDialog}
|
||||
forkOf={forkOf}
|
||||
forkOfLabel={forkOfLabel}
|
||||
forkOfHref={forkOfHref}
|
||||
forkOfOwnerHandle={forkOfOwnerHandle}
|
||||
canonical={canonical}
|
||||
canonicalHref={canonicalHref}
|
||||
canonicalOwnerHandle={canonicalOwnerHandle}
|
||||
staffModerationNote={staffModerationNote}
|
||||
staffVisibilityTag={staffVisibilityTag}
|
||||
isAutoHidden={isAutoHidden}
|
||||
isRemoved={isRemoved}
|
||||
nixPlugin={nixPlugin}
|
||||
hasPluginBundle={hasPluginBundle}
|
||||
configRequirements={configRequirements}
|
||||
cliHelp={cliHelp}
|
||||
tagEntries={tagEntries}
|
||||
versionById={versionById}
|
||||
tagName={tagName}
|
||||
onTagNameChange={setTagName}
|
||||
tagVersionId={tagVersionId}
|
||||
onTagVersionChange={setTagVersionId}
|
||||
onTagSubmit={submitTag}
|
||||
onTagDelete={deleteTag}
|
||||
tagVersions={versions ?? []}
|
||||
clawdis={clawdis}
|
||||
osLabels={osLabels}
|
||||
/>
|
||||
|
||||
{isOwner && skill ? (
|
||||
<SkillOwnershipPanel
|
||||
skillId={skill._id}
|
||||
slug={skill.slug}
|
||||
ownerHandle={ownerHandle}
|
||||
latestVersion={latestVersion}
|
||||
modInfo={modInfo}
|
||||
canManage={canManage}
|
||||
isAuthenticated={isAuthenticated}
|
||||
isStaff={isStaff}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={() => void toggleStar({ skillId: skill._id })}
|
||||
onOpenReport={openReportDialog}
|
||||
forkOf={forkOf}
|
||||
forkOfLabel={forkOfLabel}
|
||||
forkOfHref={forkOfHref}
|
||||
forkOfOwnerHandle={forkOfOwnerHandle}
|
||||
canonical={canonical}
|
||||
canonicalHref={canonicalHref}
|
||||
canonicalOwnerHandle={canonicalOwnerHandle}
|
||||
staffModerationNote={staffModerationNote}
|
||||
staffVisibilityTag={staffVisibilityTag}
|
||||
isAutoHidden={isAutoHidden}
|
||||
isRemoved={isRemoved}
|
||||
nixPlugin={nixPlugin}
|
||||
hasPluginBundle={hasPluginBundle}
|
||||
configRequirements={configRequirements}
|
||||
cliHelp={cliHelp}
|
||||
tagEntries={tagEntries}
|
||||
versionById={versionById}
|
||||
tagName={tagName}
|
||||
onTagNameChange={setTagName}
|
||||
tagVersionId={tagVersionId}
|
||||
onTagVersionChange={setTagVersionId}
|
||||
onTagSubmit={submitTag}
|
||||
onTagDelete={deleteTag}
|
||||
tagVersions={versions ?? []}
|
||||
clawdis={clawdis}
|
||||
osLabels={osLabels}
|
||||
ownerId={owner?._id ?? null}
|
||||
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isOwner && skill ? (
|
||||
<SkillOwnershipPanel
|
||||
skillId={skill._id}
|
||||
slug={skill.slug}
|
||||
ownerHandle={ownerHandle}
|
||||
ownerId={owner?._id ?? null}
|
||||
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
|
||||
/>
|
||||
) : null}
|
||||
<SkillMetadataSidebar
|
||||
skill={skill}
|
||||
latestVersion={latestVersion}
|
||||
owner={owner}
|
||||
ownerHandle={ownerHandle}
|
||||
clawdis={clawdis}
|
||||
osLabels={osLabels}
|
||||
tagEntries={tagEntries}
|
||||
isMalwareBlocked={modInfo?.isMalwareBlocked}
|
||||
isRemoved={modInfo?.isRemoved}
|
||||
nixPlugin={nixPlugin}
|
||||
/>
|
||||
|
||||
<div className="detail-content-full">
|
||||
{nixSnippet ? (
|
||||
<Card>
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Install via Nix
|
||||
</h2>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
{nixSystems.length ? `Systems: ${nixSystems.join(", ")}` : "nix-clawdbot"}
|
||||
</p>
|
||||
<pre className="hero-install-code mt-3">{nixSnippet}</pre>
|
||||
</h3>
|
||||
<pre className="hero-install-code mt-2">
|
||||
{nixSnippet}
|
||||
</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{configExample ? (
|
||||
<Card>
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Config example
|
||||
</h2>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
Starter config for this plugin bundle.
|
||||
</p>
|
||||
<pre className="hero-install-code mt-3">{configExample}</pre>
|
||||
</h3>
|
||||
<pre className="hero-install-code mt-2">
|
||||
{configExample}
|
||||
</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
@@ -452,11 +456,12 @@ export function SkillDetailPage({
|
||||
<ClientOnly
|
||||
fallback={
|
||||
<Card>
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Comments</h2>
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Comments
|
||||
</h2>
|
||||
<p className="section-subtitle mt-3 mb-0">
|
||||
Loading comments...
|
||||
</p>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
@@ -467,17 +472,17 @@ export function SkillDetailPage({
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SkillReportDialog
|
||||
isOpen={isAuthenticated && isReportDialogOpen}
|
||||
isSubmitting={isSubmittingReport}
|
||||
reportReason={reportReason}
|
||||
reportError={reportError}
|
||||
onReasonChange={setReportReason}
|
||||
onCancel={closeReportDialog}
|
||||
onSubmit={() => void submitReport()}
|
||||
/>
|
||||
</Container>
|
||||
<SkillReportDialog
|
||||
isOpen={isAuthenticated && isReportDialogOpen}
|
||||
isSubmitting={isSubmittingReport}
|
||||
reportReason={reportReason}
|
||||
reportError={reportError}
|
||||
onReasonChange={setReportReason}
|
||||
onCancel={closeReportDialog}
|
||||
onSubmit={() => void submitReport()}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { SkillVersionsPanel } from "./SkillVersionsPanel";
|
||||
import { Card } from "./ui/card";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
|
||||
const SkillDiffCard = lazy(() =>
|
||||
import("./SkillDiffCard").then((module) => ({ default: module.SkillDiffCard })),
|
||||
@@ -15,9 +14,11 @@ const SkillFilesPanel = lazy(() =>
|
||||
|
||||
type SkillFile = Doc<"skillVersions">["files"][number];
|
||||
|
||||
export type DetailTab = "readme" | "files" | "compare" | "versions";
|
||||
|
||||
type SkillDetailTabsProps = {
|
||||
activeTab: "files" | "compare" | "versions";
|
||||
setActiveTab: (tab: "files" | "compare" | "versions") => void;
|
||||
activeTab: DetailTab;
|
||||
setActiveTab: (tab: DetailTab) => void;
|
||||
onCompareIntent: () => void;
|
||||
readmeContent: string | null;
|
||||
readmeError: string | null;
|
||||
@@ -46,13 +47,30 @@ export function SkillDetailTabs({
|
||||
suppressVersionScanResults,
|
||||
scanResultsSuppressedMessage,
|
||||
}: SkillDetailTabsProps) {
|
||||
const compareEnabled = (versions?.length ?? 0) > 1;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as typeof activeTab)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="compare"
|
||||
<div className="card tab-card">
|
||||
<div className="tab-header">
|
||||
<button
|
||||
className={`tab-button${activeTab === "readme" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setActiveTab("readme")}
|
||||
>
|
||||
README
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button${activeTab === "files" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setActiveTab("files")}
|
||||
>
|
||||
Files
|
||||
</button>
|
||||
{compareEnabled ? (
|
||||
<button
|
||||
className={`tab-button${activeTab === "compare" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setActiveTab("compare")}
|
||||
onMouseEnter={() => {
|
||||
onCompareIntent();
|
||||
void import("./SkillDiffCard");
|
||||
@@ -63,37 +81,64 @@ export function SkillDetailTabs({
|
||||
}}
|
||||
>
|
||||
Compare
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="versions">Versions</TabsTrigger>
|
||||
</TabsList>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className={`tab-button${activeTab === "versions" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setActiveTab("versions")}
|
||||
>
|
||||
Versions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TabsContent value="files">
|
||||
<Suspense fallback={<Skeleton className="h-40 w-full" />}>
|
||||
<SkillFilesPanel
|
||||
versionId={latestVersionId}
|
||||
readmeContent={readmeContent}
|
||||
readmeError={readmeError}
|
||||
latestFiles={latestFiles}
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
{activeTab === "readme" ? (
|
||||
<div className="tab-body">
|
||||
{readmeContent ? (
|
||||
<div className="markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readmeContent}</ReactMarkdown>
|
||||
</div>
|
||||
) : readmeError ? (
|
||||
<div className="empty-state px-[var(--space-4)] py-[var(--space-6)]">
|
||||
<p className="empty-state-title">No README available</p>
|
||||
<p className="empty-state-body">
|
||||
This skill doesn't have a SKILL.md file yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="stat p-4">
|
||||
Loading README...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TabsContent value="compare">
|
||||
<Suspense fallback={<Skeleton className="h-40 w-full" />}>
|
||||
{activeTab === "files" ? (
|
||||
<Suspense fallback={<div className="tab-body stat">Loading file viewer...</div>}>
|
||||
<SkillFilesPanel
|
||||
versionId={latestVersionId}
|
||||
latestFiles={latestFiles}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
{activeTab === "compare" ? (
|
||||
<div className="tab-body">
|
||||
<Suspense fallback={<div className="stat">Loading diff viewer...</div>}>
|
||||
<SkillDiffCard skill={skill} versions={diffVersions ?? []} variant="embedded" />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TabsContent value="versions">
|
||||
<SkillVersionsPanel
|
||||
versions={versions}
|
||||
nixPlugin={nixPlugin}
|
||||
skillSlug={skill.slug}
|
||||
suppressScanResults={suppressVersionScanResults}
|
||||
suppressedMessage={scanResultsSuppressedMessage}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
{activeTab === "versions" ? (
|
||||
<SkillVersionsPanel
|
||||
versions={versions}
|
||||
nixPlugin={nixPlugin}
|
||||
skillSlug={skill.slug}
|
||||
suppressScanResults={suppressVersionScanResults}
|
||||
suppressedMessage={scanResultsSuppressedMessage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("SkillDiffCard", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("diff-editor").getAttribute("data-side-by-side")).toBe("false");
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Inline" }).className).toContain("shadow-sm");
|
||||
expect(screen.getByRole("button", { name: "Inline" }).className).toContain("is-active");
|
||||
expect(screen.getByTestId("diff-editor").getAttribute("data-inline-fallback")).toBe("false");
|
||||
});
|
||||
|
||||
@@ -106,6 +106,6 @@ describe("SkillDiffCard", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("diff-editor").getAttribute("data-side-by-side")).toBe("true");
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Side-by-side" }).className).toContain("shadow-sm");
|
||||
expect(screen.getByRole("button", { name: "Side-by-side" }).className).toContain("is-active");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,12 +13,9 @@ import {
|
||||
selectDefaultFilePath,
|
||||
sortVersionsBySemver,
|
||||
} from "../lib/diffing";
|
||||
import { ClientOnly } from "./ClientOnly";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { isDarkThemeResolved, onThemeChange } from "../lib/theme";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card } from "./ui/card";
|
||||
import { Label } from "./ui/label";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { ClientOnly } from "./ClientOnly";
|
||||
|
||||
type SkillDiffCardProps = {
|
||||
skill: Doc<"skills">;
|
||||
@@ -236,15 +233,18 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
|
||||
|
||||
useEffect(() => {
|
||||
if (!monaco || typeof document === "undefined") return;
|
||||
const observer = new MutationObserver(() => {
|
||||
applyMonacoTheme(monaco);
|
||||
});
|
||||
const syncTheme = () => applyMonacoTheme(monaco);
|
||||
const observer = new MutationObserver(syncTheme);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme"],
|
||||
attributeFilter: ["data-theme", "data-theme-family", "data-theme-resolved"],
|
||||
});
|
||||
applyMonacoTheme(monaco);
|
||||
return () => observer.disconnect();
|
||||
const removeThemeListener = onThemeChange(syncTheme);
|
||||
syncTheme();
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
removeThemeListener();
|
||||
};
|
||||
}, [monaco]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -277,174 +277,145 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
|
||||
const fileSelected = Boolean(selectedItem);
|
||||
const diffOptions = useMemo(() => buildDiffOptions(viewMode), [viewMode]);
|
||||
|
||||
const Wrapper = variant === "card" ? Card : "div";
|
||||
const wrapperClassName = variant === "card" ? "flex flex-col gap-4" : "flex flex-col gap-4";
|
||||
const containerClass = variant === "card" ? "card diff-card" : "diff-card diff-card-embedded";
|
||||
|
||||
return (
|
||||
<Wrapper className={wrapperClassName}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className={containerClass}>
|
||||
<div className="diff-header">
|
||||
<div>
|
||||
<h2 className="m-0 font-display text-[1.2rem] font-bold text-[color:var(--ink)]">
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Compare versions
|
||||
</h2>
|
||||
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
|
||||
<p className="section-subtitle m-0">
|
||||
Inline or side-by-side diff for any file.
|
||||
</p>
|
||||
</div>
|
||||
<fieldset className="inline-flex items-center gap-0.5 rounded-full border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-[3px]">
|
||||
<legend className="sr-only">Diff layout</legend>
|
||||
<button
|
||||
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
|
||||
viewMode === "split"
|
||||
? "bg-[color:var(--surface)] text-[color:var(--ink)] shadow-sm"
|
||||
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => updateViewMode("split")}
|
||||
>
|
||||
Side-by-side
|
||||
</button>
|
||||
<button
|
||||
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
|
||||
viewMode === "inline"
|
||||
? "bg-[color:var(--surface)] text-[color:var(--ink)] shadow-sm"
|
||||
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => updateViewMode("inline")}
|
||||
>
|
||||
Inline
|
||||
</button>
|
||||
</fieldset>
|
||||
{!diffUnavailable ? (
|
||||
<fieldset className="diff-toggle-group">
|
||||
<legend className="sr-only">Diff layout</legend>
|
||||
<button
|
||||
className={`diff-toggle${viewMode === "split" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => updateViewMode("split")}
|
||||
>
|
||||
Side-by-side
|
||||
</button>
|
||||
<button
|
||||
className={`diff-toggle${viewMode === "inline" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => updateViewMode("inline")}
|
||||
>
|
||||
Inline
|
||||
</button>
|
||||
</fieldset>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex min-w-[140px] flex-1 flex-col gap-1">
|
||||
<Label htmlFor="diff-left">Left</Label>
|
||||
<select
|
||||
id="diff-left"
|
||||
className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
|
||||
value={leftVersionId ?? ""}
|
||||
onChange={(event) => setLeftVersionId(event.target.value as Id<"skillVersions">)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select version
|
||||
</option>
|
||||
{renderOptions(versionOptions)}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setLeftVersionId(rightVersionId);
|
||||
setRightVersionId(leftVersionId);
|
||||
}}
|
||||
disabled={!leftVersionId || !rightVersionId}
|
||||
>
|
||||
Swap
|
||||
</Button>
|
||||
<div className="flex min-w-[140px] flex-1 flex-col gap-1">
|
||||
<Label htmlFor="diff-right">Right</Label>
|
||||
<select
|
||||
id="diff-right"
|
||||
className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
|
||||
value={rightVersionId ?? ""}
|
||||
onChange={(event) => setRightVersionId(event.target.value as Id<"skillVersions">)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select version
|
||||
</option>
|
||||
{renderOptions(versionOptions)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{!diffUnavailable ? (
|
||||
<>
|
||||
<div className="diff-controls">
|
||||
<div className="diff-select">
|
||||
<label htmlFor="diff-left">Left</label>
|
||||
<select
|
||||
id="diff-left"
|
||||
className="search-input"
|
||||
value={leftVersionId ?? ""}
|
||||
onChange={(event) => setLeftVersionId(event.target.value as Id<"skillVersions">)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select version
|
||||
</option>
|
||||
{renderOptions(versionOptions)}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
className="diff-swap"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLeftVersionId(rightVersionId);
|
||||
setRightVersionId(leftVersionId);
|
||||
}}
|
||||
disabled={!leftVersionId || !rightVersionId}
|
||||
>
|
||||
Swap
|
||||
</Button>
|
||||
<div className="diff-select">
|
||||
<label htmlFor="diff-right">Right</label>
|
||||
<select
|
||||
id="diff-right"
|
||||
className="search-input"
|
||||
value={rightVersionId ?? ""}
|
||||
onChange={(event) => setRightVersionId(event.target.value as Id<"skillVersions">)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select version
|
||||
</option>
|
||||
{renderOptions(versionOptions)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-[color:var(--ink-soft)]">
|
||||
<span>
|
||||
Left {leftLabel} • Right {rightLabel}
|
||||
</span>
|
||||
{diffUnavailable ? <span>Need at least 2 versions.</span> : null}
|
||||
</div>
|
||||
<div className="diff-meta">
|
||||
<span>
|
||||
Left {leftLabel} • Right {rightLabel}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] md:grid-cols-[minmax(160px,220px)_1fr]">
|
||||
<div className="flex max-h-[500px] flex-col overflow-y-auto border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] md:border-r md:border-b-0">
|
||||
{fileDiffItems.length === 0 ? (
|
||||
<div className="p-3 text-sm text-[color:var(--ink-soft)]">No files to compare.</div>
|
||||
<div className="diff-layout">
|
||||
<div className="diff-files">
|
||||
{diffUnavailable ? (
|
||||
<div className="diff-empty">
|
||||
Publish another version to compare changes side by side.
|
||||
</div>
|
||||
) : fileDiffItems.length === 0 ? (
|
||||
<div className="diff-empty">No files to compare.</div>
|
||||
) : (
|
||||
fileDiffItems.map((item) => (
|
||||
<button
|
||||
key={item.path}
|
||||
type="button"
|
||||
className={`flex w-full cursor-pointer items-center gap-2 border-none px-3 py-2 text-left text-sm transition-colors hover:bg-[color:var(--surface)] ${
|
||||
item.path === selectedPath
|
||||
? "bg-[color:var(--surface)] font-semibold text-[color:var(--ink)]"
|
||||
: "bg-transparent text-[color:var(--ink)]"
|
||||
}`}
|
||||
className={`diff-file${item.path === selectedPath ? " is-active" : ""}`}
|
||||
onClick={() => setSelectedPath(item.path)}
|
||||
>
|
||||
<Badge
|
||||
variant={
|
||||
item.status === "added"
|
||||
? "success"
|
||||
: item.status === "removed"
|
||||
? "destructive"
|
||||
: item.status === "changed"
|
||||
? "warning"
|
||||
: "compact"
|
||||
}
|
||||
className="shrink-0 text-[0.65rem]"
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
<span className="truncate font-mono text-xs">{item.path}</span>
|
||||
<span className={`diff-pill diff-pill-${item.status}`}>{item.status}</span>
|
||||
<span className="diff-file-name">{item.path}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="relative min-h-[300px]">
|
||||
<div className="diff-view">
|
||||
{error ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
|
||||
{error}
|
||||
</div>
|
||||
<div className="diff-empty">{error}</div>
|
||||
) : sizeWarning ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
|
||||
<div className="diff-empty">
|
||||
{sizeWarning.side === "left" ? "Left" : "Right"} file exceeds 200KB:{" "}
|
||||
{sizeWarning.path}
|
||||
</div>
|
||||
) : diffUnavailable ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
|
||||
Publish another version to compare.
|
||||
</div>
|
||||
<div className="diff-empty">Publish another version to compare.</div>
|
||||
) : !selectionReady ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
|
||||
Select two versions to compare.
|
||||
</div>
|
||||
<div className="diff-empty">Select two versions to compare.</div>
|
||||
) : !fileSelected ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-sm text-[color:var(--ink-soft)]">
|
||||
Select a file to compare.
|
||||
</div>
|
||||
<div className="diff-empty">Select a file to compare.</div>
|
||||
) : (
|
||||
<ClientOnly fallback={<Skeleton className="h-full w-full" />}>
|
||||
<ClientOnly fallback={<div className="diff-empty">Preparing diff…</div>}>
|
||||
<DiffEditor
|
||||
key={`diff-${viewMode}`}
|
||||
className={`h-full min-h-[400px] w-full ${viewMode === "inline" ? "max-w-full" : ""}`}
|
||||
className={`diff-monaco diff-monaco-${viewMode}`}
|
||||
original={leftText}
|
||||
modified={rightText}
|
||||
theme={getMonacoThemeName()}
|
||||
loading={<Skeleton className="h-full w-full" />}
|
||||
loading={<div className="diff-empty">Loading diff…</div>}
|
||||
options={diffOptions}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[color:var(--surface)]/80">
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? <div className="diff-loading">Loading…</div> : null}
|
||||
</ClientOnly>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -472,7 +443,7 @@ function renderOptions(options: VersionOption[]) {
|
||||
|
||||
function getMonacoThemeName() {
|
||||
if (typeof document === "undefined") return "clawhub-light";
|
||||
return document.documentElement.dataset.theme === "dark" ? "clawhub-dark" : "clawhub-light";
|
||||
return isDarkThemeResolved() ? "clawhub-dark" : "clawhub-light";
|
||||
}
|
||||
|
||||
function buildDiffOptions(viewMode: "split" | "inline"): DiffEditorProps["options"] {
|
||||
@@ -508,7 +479,7 @@ function applyMonacoTheme(monaco: NonNullable<ReturnType<typeof useMonaco>>) {
|
||||
const diffDiagonal = styles.getPropertyValue("--diff-diagonal").trim() || "#22222233";
|
||||
const background = surface;
|
||||
const gutter = surfaceMuted;
|
||||
const isDark = document.documentElement.dataset.theme === "dark";
|
||||
const isDark = isDarkThemeResolved();
|
||||
const base = isDark ? "vs-dark" : "vs";
|
||||
|
||||
const diffInserted = withAlpha(diffAdded, isDark ? 0.22 : 0.2);
|
||||
|
||||
@@ -9,10 +9,6 @@ vi.mock("convex/react", () => ({
|
||||
useAction: () => getFileTextMock,
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownPreview", () => ({
|
||||
MarkdownPreview: ({ children }: { children: string }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
type SkillFile = Doc<"skillVersions">["files"][number];
|
||||
|
||||
function makeFile(path: string, size: number): SkillFile {
|
||||
@@ -34,8 +30,6 @@ describe("SkillFilesPanel", () => {
|
||||
render(
|
||||
<SkillFilesPanel
|
||||
versionId={"skillVersions:1" as Id<"skillVersions">}
|
||||
readmeContent={"# skill"}
|
||||
readmeError={null}
|
||||
latestFiles={[makeFile("scripts/run.sh", 10)]}
|
||||
/>,
|
||||
);
|
||||
@@ -68,8 +62,6 @@ describe("SkillFilesPanel", () => {
|
||||
render(
|
||||
<SkillFilesPanel
|
||||
versionId={"skillVersions:1" as Id<"skillVersions">}
|
||||
readmeContent={"# skill"}
|
||||
readmeError={null}
|
||||
latestFiles={[makeFile("a.txt", 5), makeFile("b.txt", 6)]}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -2,23 +2,17 @@ import { useAction } from "convex/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { MarkdownPreview } from "./MarkdownPreview";
|
||||
import { formatBytes } from "./skillDetailUtils";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
type SkillFile = Doc<"skillVersions">["files"][number];
|
||||
|
||||
type SkillFilesPanelProps = {
|
||||
versionId: Id<"skillVersions"> | null;
|
||||
readmeContent: string | null;
|
||||
readmeError: string | null;
|
||||
latestFiles: SkillFile[];
|
||||
};
|
||||
|
||||
export function SkillFilesPanel({
|
||||
versionId,
|
||||
readmeContent,
|
||||
readmeError,
|
||||
latestFiles,
|
||||
}: SkillFilesPanelProps) {
|
||||
const getFileText = useAction(api.skills.getFileText);
|
||||
@@ -91,82 +85,56 @@ export function SkillFilesPanel({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid max-w-full gap-5 overflow-x-auto">
|
||||
<div>
|
||||
<h2 className="m-0 font-display text-[1.2rem] font-bold text-[color:var(--ink)]">
|
||||
SKILL.md
|
||||
</h2>
|
||||
<div>
|
||||
{readmeContent ? (
|
||||
<MarkdownPreview>{readmeContent}</MarkdownPreview>
|
||||
) : readmeError ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
Failed to load SKILL.md: {readmeError}
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] md:grid-cols-[minmax(180px,280px)_1fr]">
|
||||
<div className="flex flex-col border-b border-[color:var(--line)] md:border-r md:border-b-0">
|
||||
<div className="flex items-center justify-between border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-2">
|
||||
<h3 className="m-0 font-display text-[1.05rem] font-bold text-[color:var(--ink)]">
|
||||
<div className="tab-body">
|
||||
<div className="file-browser">
|
||||
<div className="file-list">
|
||||
<div className="file-list-header">
|
||||
<h3 className="section-title text-[1.05rem] m-0">
|
||||
Files
|
||||
</h3>
|
||||
<span className="m-0 text-sm text-[color:var(--ink-soft)]">
|
||||
<span className="section-subtitle m-0">
|
||||
{latestFiles.length} total
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex max-h-[400px] flex-col overflow-y-auto">
|
||||
<div className="file-list-body">
|
||||
{latestFiles.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-[color:var(--ink-soft)]">
|
||||
No files available.
|
||||
</div>
|
||||
<div className="stat">No files available.</div>
|
||||
) : (
|
||||
latestFiles.map((file) => (
|
||||
<button
|
||||
key={file.path}
|
||||
className={`flex w-full cursor-pointer items-center justify-between border-none px-3 py-2 text-left text-sm transition-colors hover:bg-[color:var(--surface-muted)] ${
|
||||
selectedPath === file.path
|
||||
? "bg-[color:var(--surface-muted)] font-semibold text-[color:var(--ink)]"
|
||||
: "bg-transparent text-[color:var(--ink)]"
|
||||
className={`file-row file-row-button${
|
||||
selectedPath === file.path ? " is-active" : ""
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => handleSelect(file.path)}
|
||||
aria-current={selectedPath === file.path ? "true" : undefined}
|
||||
>
|
||||
<span className="truncate font-mono text-xs">{file.path}</span>
|
||||
<span className="ml-2 shrink-0 text-xs text-[color:var(--ink-soft)]">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
<span className="file-path">{file.path}</span>
|
||||
<span className="file-meta">{formatBytes(file.size)}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-2">
|
||||
<div className="truncate font-mono text-xs">{selectedPath ?? "Select a file"}</div>
|
||||
<div className="file-viewer">
|
||||
<div className="file-viewer-header">
|
||||
<div className="file-path">{selectedPath ?? "Select a file"}</div>
|
||||
{fileMeta ? (
|
||||
<span className="ml-2 shrink-0 text-xs text-[color:var(--ink-soft)]">
|
||||
<span className="file-meta">
|
||||
{formatBytes(fileMeta.size)} · {fileMeta.sha256.slice(0, 12)}…
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-[200px] p-3">
|
||||
<div className="file-viewer-body">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<div className="stat">Loading…</div>
|
||||
) : fileError ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
Failed to load file: {fileError}
|
||||
</div>
|
||||
<div className="stat">Failed to load file: {fileError}</div>
|
||||
) : fileContent ? (
|
||||
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">
|
||||
{fileContent}
|
||||
</pre>
|
||||
<pre className="file-viewer-code">{fileContent}</pre>
|
||||
) : (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">Select a file to preview.</div>
|
||||
<div className="stat">Select a file to preview.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
} from "clawhub-schema/licenseConstants";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Package } from "lucide-react";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
import { getRuntimeEnv } from "../lib/runtimeEnv";
|
||||
import { SkillInstallCard } from "./SkillInstallCard";
|
||||
|
||||
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent } from "./ui/card";
|
||||
import { Input } from "./ui/input";
|
||||
import { UserBadge } from "./UserBadge";
|
||||
|
||||
export type SkillModerationInfo = {
|
||||
@@ -117,10 +110,9 @@ export function SkillHeader({
|
||||
onTagSubmit,
|
||||
onTagDelete,
|
||||
tagVersions,
|
||||
clawdis,
|
||||
osLabels,
|
||||
clawdis: _clawdis,
|
||||
osLabels: _osLabels,
|
||||
}: SkillHeaderProps) {
|
||||
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
|
||||
const formattedStats = formatSkillStatsTriplet(skill.stats);
|
||||
const suppressScanResults =
|
||||
!isStaff &&
|
||||
@@ -134,8 +126,8 @@ export function SkillHeader({
|
||||
return (
|
||||
<>
|
||||
{modInfo?.isPendingScan ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-amber-300/50 bg-amber-50 p-5 dark:border-amber-500/30 dark:bg-amber-950/40">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="pending-banner">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Security scan in progress</strong>
|
||||
<p>
|
||||
Your skill is being scanned by VirusTotal. It will be visible to others once the scan
|
||||
@@ -145,8 +137,8 @@ export function SkillHeader({
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isMalwareBlocked ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="pending-banner pending-banner-blocked">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Skill blocked — malicious content detected</strong>
|
||||
<p>
|
||||
ClawHub Security flagged this skill as malicious. Downloads are disabled. Review the
|
||||
@@ -155,15 +147,15 @@ export function SkillHeader({
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isSuspicious ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-amber-300/50 bg-amber-50 p-5 dark:border-amber-500/30 dark:bg-amber-950/40">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="pending-banner pending-banner-warning">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Skill flagged — suspicious patterns detected</strong>
|
||||
<p>
|
||||
ClawHub Security flagged this skill as suspicious. Review the scan results before
|
||||
using.
|
||||
</p>
|
||||
{canManage ? (
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
<p className="pending-banner-appeal">
|
||||
If you believe this skill has been incorrectly flagged, please{" "}
|
||||
<a
|
||||
href="https://github.com/openclaw/clawhub/issues"
|
||||
@@ -178,142 +170,109 @@ export function SkillHeader({
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isRemoved ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="pending-banner pending-banner-blocked">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Skill removed by moderator</strong>
|
||||
<p>This skill has been removed and is not visible to others.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isHiddenByMod ? (
|
||||
<div className="rounded-[var(--radius-md)] border border-red-300/50 bg-red-50 p-5 dark:border-red-500/30 dark:bg-red-950/40">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="pending-banner pending-banner-blocked">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Skill hidden</strong>
|
||||
<p>This skill is currently hidden and not visible to others.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<div className={`flex flex-col gap-5${hasPluginBundle ? " pb-2" : ""}`}>
|
||||
<div className="flex flex-col gap-5 md:flex-row md:gap-8">
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
{skill.displayName}
|
||||
</h1>
|
||||
{latestVersion?.version ? (
|
||||
<Badge variant="compact">v{latestVersion.version}</Badge>
|
||||
<div className="card skill-hero">
|
||||
<div className={`skill-hero-top${hasPluginBundle ? " has-plugin" : ""}`}>
|
||||
<div className="skill-hero-header">
|
||||
<div className="skill-hero-title">
|
||||
<div className="skill-hero-title-row">
|
||||
<h1 className="section-title m-0">
|
||||
{skill.displayName}
|
||||
</h1>
|
||||
{latestVersion?.version ? (
|
||||
<span className="plugin-version-badge">v{latestVersion.version}</span>
|
||||
) : null}
|
||||
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
|
||||
</div>
|
||||
<p className="section-subtitle">{skill.summary ?? "No summary provided."}</p>
|
||||
|
||||
{isStaff && staffModerationNote ? (
|
||||
<div className="skill-hero-note">{staffModerationNote}</div>
|
||||
) : null}
|
||||
{nixPlugin ? (
|
||||
<div className="skill-hero-note">
|
||||
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="skill-hero-inline-meta">
|
||||
<div className="skill-hero-stats-row">
|
||||
<span className="stat">⭐ {formattedStats.stars}</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat"><Package size={14} aria-hidden="true" /> {formattedStats.downloads}</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">{formatCompactStat(skill.stats.installsCurrent ?? 0)} current</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">{formattedStats.installsAllTime} all-time</span>
|
||||
</div>
|
||||
<div className="skill-hero-meta-row">
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix="by"
|
||||
size="md"
|
||||
showName
|
||||
/>
|
||||
{forkOf && forkOfHref ? (
|
||||
<>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
{forkOfLabel}{" "}
|
||||
<a href={forkOfHref}>
|
||||
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
|
||||
{forkOf.skill.slug}
|
||||
</a>
|
||||
{forkOf.version ? ` (${forkOf.version})` : null}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
|
||||
</div>
|
||||
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
|
||||
{skill.summary ?? "No summary provided."}
|
||||
</p>
|
||||
|
||||
{isStaff && staffModerationNote ? (
|
||||
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
|
||||
{staffModerationNote}
|
||||
</div>
|
||||
) : null}
|
||||
{nixPlugin ? (
|
||||
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
|
||||
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-[color:var(--ink-soft)]">
|
||||
⭐ {formattedStats.stars}
|
||||
</span>
|
||||
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
|
||||
<span className="flex items-center gap-1 text-sm text-[color:var(--ink-soft)]">
|
||||
<Package size={14} aria-hidden="true" /> {formattedStats.downloads}
|
||||
</span>
|
||||
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
|
||||
<span className="text-sm text-[color:var(--ink-soft)]">
|
||||
{formatCompactStat(skill.stats.installsCurrent ?? 0)} current
|
||||
</span>
|
||||
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
|
||||
<span className="text-sm text-[color:var(--ink-soft)]">
|
||||
{formattedStats.installsAllTime} all-time
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix="by"
|
||||
size="md"
|
||||
showName
|
||||
/>
|
||||
{forkOf && forkOfHref ? (
|
||||
<>
|
||||
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
|
||||
<span className="text-sm text-[color:var(--ink-soft)]">
|
||||
{forkOfLabel}{" "}
|
||||
<a href={forkOfHref}>
|
||||
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
|
||||
{forkOf.skill.slug}
|
||||
</a>
|
||||
{forkOf.version ? ` (${forkOf.version})` : null}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{canonicalHref ? (
|
||||
<>
|
||||
<span className="text-[color:var(--ink-soft)] opacity-40">·</span>
|
||||
<span className="text-sm text-[color:var(--ink-soft)]">
|
||||
canonical:{" "}
|
||||
<a href={canonicalHref}>
|
||||
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
|
||||
{canonical?.skill?.slug}
|
||||
</a>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
<Badge variant="compact">{PLATFORM_SKILL_LICENSE}</Badge>
|
||||
{getSkillBadges(skill).map((badge) => (
|
||||
<Badge key={badge} variant="compact">
|
||||
{badge}
|
||||
</Badge>
|
||||
))}
|
||||
{isStaff && staffVisibilityTag ? (
|
||||
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
|
||||
{staffVisibilityTag}
|
||||
</Badge>
|
||||
{canonicalHref ? (
|
||||
<>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
canonical:{" "}
|
||||
<a href={canonicalHref}>
|
||||
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
|
||||
{canonical?.skill?.slug}
|
||||
</a>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="skill-hero-badges">
|
||||
{getSkillBadges(skill).map((badge) => (
|
||||
<Badge key={badge} variant="compact">
|
||||
{badge}
|
||||
</Badge>
|
||||
))}
|
||||
{isStaff && staffVisibilityTag ? (
|
||||
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
|
||||
{staffVisibilityTag}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3 md:w-[220px] md:shrink-0">
|
||||
{!nixPlugin && !modInfo?.isMalwareBlocked && !modInfo?.isRemoved ? (
|
||||
<a
|
||||
href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}
|
||||
className="inline-flex w-full items-center justify-center gap-2 whitespace-nowrap font-semibold text-sm min-h-[44px] rounded-[var(--radius-pill)] px-4 py-[11px] border-none bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] text-white transition-all duration-200 no-underline hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]"
|
||||
>
|
||||
Download zip
|
||||
</a>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-semibold text-[color:var(--ink-soft)]">
|
||||
License
|
||||
</span>
|
||||
<span className="text-sm text-[color:var(--ink)]">
|
||||
{PLATFORM_SKILL_LICENSE} · {PLATFORM_SKILL_LICENSE_SUMMARY}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="skill-hero-sidebar">
|
||||
<div className="skill-actions">
|
||||
{isAuthenticated ? (
|
||||
<button
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full border transition-all duration-200 ${isStarred ? "border-amber-400/60 bg-amber-50 text-amber-500 dark:border-amber-500/40 dark:bg-amber-950/40" : "border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink-soft)] hover:text-amber-500"}`}
|
||||
className={`star-toggle${isStarred ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={onToggleStar}
|
||||
aria-label={isStarred ? "Unstar skill" : "Star skill"}
|
||||
@@ -322,18 +281,16 @@ export function SkillHeader({
|
||||
</button>
|
||||
) : null}
|
||||
{isAuthenticated ? (
|
||||
<Button variant="ghost" size="sm" onClick={onOpenReport}>
|
||||
<Button variant="ghost" size="sm" type="button" onClick={onOpenReport}>
|
||||
Report
|
||||
</Button>
|
||||
) : null}
|
||||
{isStaff ? (
|
||||
<Link
|
||||
to="/management"
|
||||
search={{ skill: skill.slug }}
|
||||
className="inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold text-xs min-h-[34px] rounded-[var(--radius-pill)] px-3 py-1.5 border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] transition-all duration-200 no-underline"
|
||||
>
|
||||
Manage
|
||||
</Link>
|
||||
<Button asChild size="sm">
|
||||
<Link to="/management" search={{ skill: skill.slug }}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -341,13 +298,12 @@ export function SkillHeader({
|
||||
|
||||
{/* Security scan — full width below the header columns */}
|
||||
{suppressScanResults ? (
|
||||
<div className="rounded-[var(--radius-sm)] border border-amber-200/60 bg-amber-50/60 px-3 py-2 text-sm text-[color:var(--ink-soft)] dark:border-amber-500/20 dark:bg-amber-950/30">
|
||||
{overrideScanMessage}
|
||||
</div>
|
||||
<div className="skill-hero-note">{overrideScanMessage}</div>
|
||||
) : latestVersion?.sha256hash ||
|
||||
latestVersion?.llmAnalysis ||
|
||||
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ||
|
||||
(latestVersion?.capabilityTags?.length ?? 0) > 0 ? (
|
||||
<div className="skill-hero-scan-row">
|
||||
<SecurityScanResults
|
||||
sha256hash={latestVersion?.sha256hash}
|
||||
vtAnalysis={latestVersion?.vtAnalysis}
|
||||
@@ -355,77 +311,67 @@ export function SkillHeader({
|
||||
staticFindings={latestVersion?.staticScan?.findings}
|
||||
capabilityTags={latestVersion?.capabilityTags}
|
||||
/>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">
|
||||
<p className="scan-disclaimer">
|
||||
Like a lobster shell, security has layers — review code before you run it.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{hasPluginBundle ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-display text-base font-bold text-[color:var(--ink)]">
|
||||
Plugin bundle (nix)
|
||||
</div>
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
Skill pack · CLI binary · Config
|
||||
<div className="skill-panel bundle-card">
|
||||
<div className="bundle-header">
|
||||
<div className="bundle-title">Plugin bundle (nix)</div>
|
||||
<div className="bundle-subtitle">Skill pack · CLI binary · Config</div>
|
||||
</div>
|
||||
<div className="bundle-includes">
|
||||
<span>SKILL.md</span>
|
||||
<span>CLI</span>
|
||||
<span>Config</span>
|
||||
</div>
|
||||
{configRequirements ? (
|
||||
<div className="bundle-section">
|
||||
<div className="bundle-section-title">Config requirements</div>
|
||||
<div className="bundle-meta">
|
||||
{configRequirements.requiredEnv?.length ? (
|
||||
<div className="stat">
|
||||
<strong>Required env</strong>
|
||||
<span>{configRequirements.requiredEnv.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{configRequirements.stateDirs?.length ? (
|
||||
<div className="stat">
|
||||
<strong>State dirs</strong>
|
||||
<span>{configRequirements.stateDirs.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge>SKILL.md</Badge>
|
||||
<Badge>CLI</Badge>
|
||||
<Badge>Config</Badge>
|
||||
</div>
|
||||
{configRequirements ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm font-semibold text-[color:var(--ink)]">
|
||||
Config requirements
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{configRequirements.requiredEnv?.length ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Required env</strong>
|
||||
<span>{configRequirements.requiredEnv.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{configRequirements.stateDirs?.length ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>State dirs</strong>
|
||||
<span>{configRequirements.stateDirs.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{cliHelp ? (
|
||||
<details className="flex flex-col gap-2">
|
||||
<summary className="cursor-pointer text-sm font-semibold text-[color:var(--ink)]">
|
||||
CLI help (from plugin)
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-x-auto rounded-[var(--radius-sm)] bg-[color:var(--surface-muted)] p-3 font-mono text-xs">
|
||||
{cliHelp}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{cliHelp ? (
|
||||
<details className="bundle-section bundle-details">
|
||||
<summary>CLI help (from plugin)</summary>
|
||||
<pre className="hero-install-code mono">{cliHelp}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-[color:var(--line)] pt-4">
|
||||
<div className="skill-tag-row">
|
||||
{tagEntries.length === 0 ? (
|
||||
<span className="m-0 text-sm text-[color:var(--ink-soft)]">No tags yet.</span>
|
||||
<span className="section-subtitle m-0">
|
||||
No tags yet.
|
||||
</span>
|
||||
) : (
|
||||
tagEntries.map(([tag, versionId]) => (
|
||||
<Badge key={tag} className="gap-1.5">
|
||||
<Badge key={tag}>
|
||||
{tag}
|
||||
<span className="text-[0.68rem] opacity-70">
|
||||
<span className="tag-meta">
|
||||
v{versionById.get(versionId)?.version ?? versionId}
|
||||
</span>
|
||||
{canManage && tag !== "latest" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 cursor-pointer border-none bg-transparent p-0 text-current opacity-60 hover:opacity-100"
|
||||
className="tag-delete"
|
||||
onClick={() => onTagDelete(tag)}
|
||||
aria-label={`Delete tag ${tag}`}
|
||||
title={`Delete tag "${tag}"`}
|
||||
@@ -444,16 +390,16 @@ export function SkillHeader({
|
||||
event.preventDefault();
|
||||
onTagSubmit();
|
||||
}}
|
||||
className="flex flex-wrap items-end gap-2 border-t border-[color:var(--line)] pt-4"
|
||||
className="tag-form"
|
||||
>
|
||||
<Input
|
||||
<input
|
||||
className="search-input"
|
||||
value={tagName}
|
||||
onChange={(event) => onTagNameChange(event.target.value)}
|
||||
placeholder="latest"
|
||||
className="w-auto max-w-[160px]"
|
||||
/>
|
||||
<select
|
||||
className="min-h-[44px] rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] focus:outline-none dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
|
||||
className="search-input"
|
||||
value={tagVersionId ?? ""}
|
||||
onChange={(event) => onTagVersionChange(event.target.value as Id<"skillVersions">)}
|
||||
>
|
||||
@@ -463,12 +409,13 @@ export function SkillHeader({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit">Update tag</Button>
|
||||
<Button type="submit">
|
||||
Update tag
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<SkillInstallCard clawdis={clawdis} osLabels={osLabels} />
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
PLATFORM_SKILL_LICENSE_URL,
|
||||
} from "clawhub-schema/licenseConstants";
|
||||
import { formatInstallCommand, formatInstallLabel } from "./skillDetailUtils";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Card, CardContent } from "./ui/card";
|
||||
import { formatInstallCommand, formatInstallLabel } from "./skillDetailUtils";
|
||||
|
||||
type SkillInstallCardProps = {
|
||||
clawdis: ClawdisSkillMetadata | undefined;
|
||||
@@ -32,229 +26,180 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) {
|
||||
const hasInstallSpecs = installSpecs.length > 0;
|
||||
const hasDependencies = dependencies.length > 0;
|
||||
const hasLinks = Boolean(links?.homepage || links?.repository || links?.documentation);
|
||||
const hasLicense = true;
|
||||
|
||||
if (!hasRuntimeRequirements && !hasInstallSpecs && !hasDependencies && !hasLinks && !hasLicense) {
|
||||
if (!hasRuntimeRequirements && !hasInstallSpecs && !hasDependencies && !hasLinks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-[color:var(--line)] pt-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card className="p-4">
|
||||
<CardContent className="gap-2">
|
||||
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
|
||||
License
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Badge variant="accent">{PLATFORM_SKILL_LICENSE}</Badge>
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<span>{PLATFORM_SKILL_LICENSE_SUMMARY}</span>
|
||||
</div>
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Terms</strong>
|
||||
<a
|
||||
href={PLATFORM_SKILL_LICENSE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-1"
|
||||
>
|
||||
{PLATFORM_SKILL_LICENSE_URL}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="skill-hero-content">
|
||||
<div className="skill-hero-panels">
|
||||
{hasRuntimeRequirements ? (
|
||||
<Card className="p-4">
|
||||
<CardContent className="gap-2">
|
||||
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
|
||||
Runtime requirements
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{clawdis?.emoji ? <Badge>{clawdis.emoji} Clawdis</Badge> : null}
|
||||
{osLabels.length ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>OS</strong>
|
||||
<span className="ml-1">{osLabels.join(" · ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requirements?.bins?.length ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Bins</strong>
|
||||
<span className="ml-1">{requirements.bins.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requirements?.anyBins?.length ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Any bin</strong>
|
||||
<span className="ml-1">{requirements.anyBins.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requirements?.env?.length ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Env</strong>
|
||||
<span className="ml-1">{requirements.env.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requirements?.config?.length ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Config</strong>
|
||||
<span className="ml-1">{requirements.config.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{clawdis?.primaryEnv ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Primary env</strong>
|
||||
<span className="ml-1">{clawdis.primaryEnv}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{envVars.length > 0 ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Environment variables</strong>
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{envVars.map((env, index) => (
|
||||
<div key={`${env.name}-${index}`} className="flex items-baseline gap-2">
|
||||
<code className="text-[0.85rem]">{env.name}</code>
|
||||
{env.required === false ? (
|
||||
<span className="text-xs text-[color:var(--ink-soft)]">optional</span>
|
||||
) : env.required === true ? (
|
||||
<span className="text-xs text-[color:var(--accent)]">required</span>
|
||||
) : null}
|
||||
{env.description ? (
|
||||
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
|
||||
— {env.description}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{hasDependencies ? (
|
||||
<Card className="p-4">
|
||||
<CardContent className="gap-2">
|
||||
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
|
||||
Dependencies
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{dependencies.map((dep, index) => (
|
||||
<div
|
||||
key={`${dep.name}-${index}`}
|
||||
className="text-sm text-[color:var(--ink-soft)]"
|
||||
>
|
||||
<div>
|
||||
<strong>{dep.name}</strong>
|
||||
<span className="ml-2 text-[0.85rem] text-[color:var(--ink-soft)]">
|
||||
{dep.type}
|
||||
{dep.version ? ` ${dep.version}` : ""}
|
||||
</span>
|
||||
{dep.url ? (
|
||||
<div className="break-all text-[0.8rem]">
|
||||
<a href={dep.url} target="_blank" rel="noopener noreferrer">
|
||||
{dep.url}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{dep.repository && dep.repository !== dep.url ? (
|
||||
<div className="text-[0.8rem]">
|
||||
<a href={dep.repository} target="_blank" rel="noopener noreferrer">
|
||||
Source
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{hasInstallSpecs ? (
|
||||
<Card className="p-4">
|
||||
<CardContent className="gap-2">
|
||||
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
|
||||
Install
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{installSpecs.map((spec, index) => {
|
||||
const command = formatInstallCommand(spec);
|
||||
return (
|
||||
<div
|
||||
key={`${spec.id ?? spec.kind}-${index}`}
|
||||
className="text-sm text-[color:var(--ink-soft)]"
|
||||
>
|
||||
<div>
|
||||
<strong>{spec.label ?? formatInstallLabel(spec)}</strong>
|
||||
{spec.bins?.length ? (
|
||||
<div className="text-[0.85rem] text-[color:var(--ink-soft)]">
|
||||
Bins: {spec.bins.join(", ")}
|
||||
</div>
|
||||
<div className="skill-panel">
|
||||
<h3 className="section-title text-[1rem] m-0">
|
||||
Runtime requirements
|
||||
</h3>
|
||||
<div className="skill-panel-body">
|
||||
{clawdis?.emoji ? <Badge>{clawdis.emoji} Clawdis</Badge> : null}
|
||||
{osLabels.length ? (
|
||||
<div className="stat">
|
||||
<strong>OS</strong>
|
||||
<span>{osLabels.join(" · ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requirements?.bins?.length ? (
|
||||
<div className="stat">
|
||||
<strong>Bins</strong>
|
||||
<span>{requirements.bins.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requirements?.anyBins?.length ? (
|
||||
<div className="stat">
|
||||
<strong>Any bin</strong>
|
||||
<span>{requirements.anyBins.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requirements?.env?.length ? (
|
||||
<div className="stat">
|
||||
<strong>Env</strong>
|
||||
<span>{requirements.env.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requirements?.config?.length ? (
|
||||
<div className="stat">
|
||||
<strong>Config</strong>
|
||||
<span>{requirements.config.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{clawdis?.primaryEnv ? (
|
||||
<div className="stat">
|
||||
<strong>Primary env</strong>
|
||||
<span>{clawdis.primaryEnv}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{envVars.length > 0 ? (
|
||||
<div className="stat">
|
||||
<strong>Environment variables</strong>
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
{envVars.map((env, index) => (
|
||||
<div
|
||||
key={`${env.name}-${index}`}
|
||||
className="flex items-baseline gap-2"
|
||||
>
|
||||
<code className="text-[0.85rem]">{env.name}</code>
|
||||
{env.required === false ? (
|
||||
<span className="text-ink-soft text-[0.75rem]">
|
||||
optional
|
||||
</span>
|
||||
) : env.required === true ? (
|
||||
<span className="text-ink-accent text-[0.75rem]">
|
||||
required
|
||||
</span>
|
||||
) : null}
|
||||
{command ? (
|
||||
<code className="mt-0.5 block font-mono text-xs">{command}</code>
|
||||
{env.description ? (
|
||||
<span className="text-ink-soft text-[0.8rem]">
|
||||
— {env.description}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{hasDependencies ? (
|
||||
<div className="skill-panel">
|
||||
<h3 className="section-title text-[1rem] m-0">
|
||||
Dependencies
|
||||
</h3>
|
||||
<div className="skill-panel-body">
|
||||
{dependencies.map((dep, index) => (
|
||||
<div key={`${dep.name}-${index}`} className="stat">
|
||||
<div>
|
||||
<strong>{dep.name}</strong>
|
||||
<span className="text-ink-soft text-[0.85rem] ml-2">
|
||||
{dep.type}
|
||||
{dep.version ? ` ${dep.version}` : ""}
|
||||
</span>
|
||||
{dep.url ? (
|
||||
<div className="text-[0.8rem] break-all">
|
||||
<a href={dep.url} target="_blank" rel="noopener noreferrer">
|
||||
{dep.url}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{dep.repository && dep.repository !== dep.url ? (
|
||||
<div className="text-[0.8rem]">
|
||||
<a href={dep.repository} target="_blank" rel="noopener noreferrer">
|
||||
Source
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{hasInstallSpecs ? (
|
||||
<div className="skill-panel">
|
||||
<h3 className="section-title text-[1rem] m-0">
|
||||
Install
|
||||
</h3>
|
||||
<div className="skill-panel-body">
|
||||
{installSpecs.map((spec, index) => {
|
||||
const command = formatInstallCommand(spec);
|
||||
return (
|
||||
<div key={`${spec.id ?? spec.kind}-${index}`} className="stat">
|
||||
<div>
|
||||
<strong>{spec.label ?? formatInstallLabel(spec)}</strong>
|
||||
{spec.bins?.length ? (
|
||||
<div className="text-ink-soft text-[0.85rem]">
|
||||
Bins: {spec.bins.join(", ")}
|
||||
</div>
|
||||
) : null}
|
||||
{command ? <code>{command}</code> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{hasLinks ? (
|
||||
<Card className="p-4">
|
||||
<CardContent className="gap-2">
|
||||
<h3 className="m-0 font-display text-base font-bold text-[color:var(--ink)]">
|
||||
Links
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{links?.homepage ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Homepage</strong>
|
||||
<a
|
||||
href={links.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-1 break-all"
|
||||
>
|
||||
{links.homepage}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{links?.repository ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Repository</strong>
|
||||
<a
|
||||
href={links.repository}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-1 break-all"
|
||||
>
|
||||
{links.repository}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{links?.documentation ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<strong>Docs</strong>
|
||||
<a
|
||||
href={links.documentation}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-1"
|
||||
>
|
||||
{links.documentation}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="skill-panel">
|
||||
<h3 className="section-title text-[1rem] m-0">
|
||||
Links
|
||||
</h3>
|
||||
<div className="skill-panel-body">
|
||||
{links?.homepage ? (
|
||||
<div className="stat">
|
||||
<strong>Homepage</strong>
|
||||
<a href={links.homepage} target="_blank" rel="noopener noreferrer" className="break-all">
|
||||
{links.homepage}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{links?.repository ? (
|
||||
<div className="stat">
|
||||
<strong>Repository</strong>
|
||||
<a href={links.repository} target="_blank" rel="noopener noreferrer" className="break-all">
|
||||
{links.repository}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{links?.documentation ? (
|
||||
<div className="stat">
|
||||
<strong>Docs</strong>
|
||||
<a href={links.documentation} target="_blank" rel="noopener noreferrer">
|
||||
{links.documentation}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Package, Star } from "lucide-react";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
|
||||
type SkillListItemProps = {
|
||||
skill: PublicSkill;
|
||||
ownerHandle?: string | null;
|
||||
owner?: PublicPublisher | null;
|
||||
};
|
||||
|
||||
export function SkillListItem({ skill, ownerHandle, owner }: SkillListItemProps) {
|
||||
const handle = ownerHandle ?? owner?.handle ?? null;
|
||||
const ownerSegment = handle?.trim() || String(skill.ownerPublisherId ?? skill.ownerUserId);
|
||||
const href = `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(skill.slug)}`;
|
||||
const badges = getSkillBadges(skill);
|
||||
|
||||
return (
|
||||
<Link to={href} className="skill-list-item">
|
||||
<MarketplaceIcon kind="skill" label={skill.displayName} />
|
||||
<div className="skill-list-item-body">
|
||||
<div className="skill-list-item-main">
|
||||
{handle ? (
|
||||
<>
|
||||
<span className="skill-list-item-owner">@{handle}</span>
|
||||
<span className="skill-list-item-sep">/</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="skill-list-item-name">{skill.displayName}</span>
|
||||
{badges.map((b) => (
|
||||
<Badge key={b} variant="compact">
|
||||
{b}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{skill.summary ? <p className="skill-list-item-summary">{skill.summary}</p> : null}
|
||||
<div className="skill-list-item-meta">
|
||||
<span className="skill-list-item-meta-item">Updated {timeAgo(skill.updatedAt)}</span>
|
||||
<span className="skill-list-item-meta-item">
|
||||
<Star size={14} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
|
||||
</span>
|
||||
<span className="skill-list-item-meta-item">
|
||||
<Package size={14} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
} from "clawhub-schema/licenseConstants";
|
||||
import { Calendar, Download, Package, Scale, Star, Tag } from "lucide-react";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
import { getRuntimeEnv } from "../lib/runtimeEnv";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { UserBadge } from "./UserBadge";
|
||||
|
||||
type SkillMetadataSidebarProps = {
|
||||
skill: PublicSkill;
|
||||
latestVersion: { version?: string; _id: Id<"skillVersions"> } | null;
|
||||
owner: PublicPublisher | null;
|
||||
ownerHandle: string | null;
|
||||
clawdis?: ClawdisSkillMetadata;
|
||||
osLabels: string[];
|
||||
tagEntries: Array<[string, Id<"skillVersions">]>;
|
||||
isMalwareBlocked?: boolean;
|
||||
isRemoved?: boolean;
|
||||
nixPlugin?: string;
|
||||
};
|
||||
|
||||
export function SkillMetadataSidebar({
|
||||
skill,
|
||||
latestVersion,
|
||||
owner,
|
||||
ownerHandle,
|
||||
clawdis: _clawdis,
|
||||
osLabels,
|
||||
tagEntries,
|
||||
isMalwareBlocked,
|
||||
isRemoved,
|
||||
nixPlugin,
|
||||
}: SkillMetadataSidebarProps) {
|
||||
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
|
||||
const showDownload = !nixPlugin && !isMalwareBlocked && !isRemoved;
|
||||
|
||||
return (
|
||||
<div className="detail-meta-bar">
|
||||
{/* Stats row */}
|
||||
<div className="meta-bar-stats">
|
||||
<div className="meta-stat">
|
||||
<Download size={14} aria-hidden="true" />
|
||||
<span className="meta-stat-value">{formatCompactStat(skill.stats.downloads)}</span>
|
||||
<span className="meta-stat-label">downloads</span>
|
||||
</div>
|
||||
<div className="meta-stat">
|
||||
<Star size={14} aria-hidden="true" />
|
||||
<span className="meta-stat-value">{formatCompactStat(skill.stats.stars)}</span>
|
||||
<span className="meta-stat-label">stars</span>
|
||||
</div>
|
||||
<div className="meta-stat">
|
||||
<Package size={14} aria-hidden="true" />
|
||||
<span className="meta-stat-value">{formatCompactStat(skill.stats.versions ?? 0)}</span>
|
||||
<span className="meta-stat-label">versions</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details row */}
|
||||
<div className="meta-bar-details">
|
||||
<div className="meta-detail">
|
||||
<Calendar size={12} aria-hidden="true" />
|
||||
<span>Updated {timeAgo(skill.updatedAt)}</span>
|
||||
</div>
|
||||
{latestVersion?.version ? (
|
||||
<div className="meta-detail">
|
||||
<Tag size={12} aria-hidden="true" />
|
||||
<span>v{latestVersion.version}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="meta-detail">
|
||||
<Scale size={12} aria-hidden="true" />
|
||||
<span>{PLATFORM_SKILL_LICENSE}</span>
|
||||
</div>
|
||||
{osLabels.length > 0 ? (
|
||||
<div className="meta-detail">
|
||||
<span>{osLabels.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Tags and Publisher row */}
|
||||
<div className="meta-bar-footer">
|
||||
<div className="meta-bar-publisher">
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix=""
|
||||
size="sm"
|
||||
showName
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tagEntries.length > 0 ? (
|
||||
<div className="meta-bar-tags">
|
||||
{tagEntries.map(([tag]) => (
|
||||
<Badge key={tag} variant="compact">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showDownload ? (
|
||||
<Button asChild variant="primary" size="sm">
|
||||
<a href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}>
|
||||
<Download size={14} aria-hidden="true" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Badge } from "./ui/badge";
|
||||
|
||||
type LlmAnalysisDimension = {
|
||||
name: string;
|
||||
@@ -388,9 +389,9 @@ export function SecurityScanResults({
|
||||
<div className="scan-findings-title">Capability signals</div>
|
||||
<div className="scan-capability-tags">
|
||||
{visibleCapabilityTags.map((tag) => (
|
||||
<span key={tag} className="tag scan-capability-tag">
|
||||
<Badge key={tag} className="scan-capability-tag">
|
||||
{SKILL_CAPABILITY_LABELS[tag] ?? tag}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="scan-capability-note">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import type { ReactNode } from "react";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
import type { PublicSoul } from "../lib/publicUser";
|
||||
|
||||
type SoulCardProps = {
|
||||
@@ -10,18 +11,13 @@ type SoulCardProps = {
|
||||
|
||||
export function SoulCard({ soul, summaryFallback, meta }: SoulCardProps) {
|
||||
return (
|
||||
<Link
|
||||
to="/souls/$slug"
|
||||
params={{ slug: soul.slug }}
|
||||
className="group flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[22px] no-underline transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_28px_rgba(29,26,23,0.12)]"
|
||||
>
|
||||
<h3 className="font-display text-base font-bold text-[color:var(--ink)] group-hover:text-[color:var(--accent)]">
|
||||
{soul.displayName}
|
||||
</h3>
|
||||
<p className="line-clamp-2 text-sm leading-relaxed text-[color:var(--ink-soft)]">
|
||||
{soul.summary ?? summaryFallback}
|
||||
</p>
|
||||
<div className="mt-auto flex items-center gap-3 pt-2">{meta}</div>
|
||||
<Link to="/souls/$slug" params={{ slug: soul.slug }} className="card skill-card">
|
||||
<div className="skill-card-header">
|
||||
<MarketplaceIcon kind="soul" label={soul.displayName} size="md" />
|
||||
<h3 className="skill-card-title">{soul.displayName}</h3>
|
||||
</div>
|
||||
<p className="skill-card-summary">{soul.summary ?? summaryFallback}</p>
|
||||
<div className="skill-card-footer">{meta}</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Package, Star, Download } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { hasOwnProperty } from "../lib/hasOwnProperty";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import type { PublicPublisher, PublicUser } from "../lib/publicUser";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
type UserBadgeProps = {
|
||||
user: PublicUser | PublicPublisher | null | undefined;
|
||||
@@ -40,7 +47,14 @@ export function UserBadge({
|
||||
displayName!.toLowerCase() !== handle!.toLowerCase();
|
||||
const initial = (displayName ?? handle ?? "u").charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
// Resolve userId for stats query — PublicUser has _id directly,
|
||||
// PublicPublisher has linkedUserId
|
||||
const userId =
|
||||
user && hasOwnProperty(user, "kind")
|
||||
? (user as PublicPublisher).linkedUserId ?? null
|
||||
: user?._id ?? null;
|
||||
|
||||
const badge = (
|
||||
<span className={`user-badge user-badge-${size}`}>
|
||||
{prefix ? <span className="user-badge-prefix">{prefix}</span> : null}
|
||||
<span className="user-avatar" aria-hidden="true">
|
||||
@@ -67,4 +81,77 @@ export function UserBadge({
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!userId) return badge;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{badge}</TooltipTrigger>
|
||||
<UserStatsTooltipContent userId={userId} displayName={displayName} handle={handle} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
type HoverStats = { publishedSkills: number; totalStars: number; totalDownloads: number };
|
||||
|
||||
function UserStatsTooltipContent({
|
||||
userId,
|
||||
displayName,
|
||||
handle,
|
||||
}: {
|
||||
userId: string;
|
||||
displayName: string | null;
|
||||
handle: string | null;
|
||||
}) {
|
||||
const [stats, setStats] = useState<HoverStats | null>(null);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
|
||||
// One-shot fetch on mount (tooltip content only mounts when open)
|
||||
useEffect(() => {
|
||||
if (fetched) return;
|
||||
setFetched(true);
|
||||
void convexHttp
|
||||
.query(api.users.getHoverStats, { userId: userId as Id<"users"> })
|
||||
.then(setStats)
|
||||
.catch(() => {});
|
||||
}, [userId, fetched]);
|
||||
|
||||
return (
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="min-w-[140px] p-0"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col gap-space-1 px-3 py-2">
|
||||
{displayName && (
|
||||
<span className="text-fs-sm font-semibold text-ink truncate max-w-[180px]">
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{handle && (
|
||||
<span className="text-fs-xs text-ink-soft">@{handle}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-line flex items-center gap-space-3 px-3 py-2">
|
||||
{stats === null ? (
|
||||
<span className="text-fs-xs text-ink-soft">Loading...</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Published skills">
|
||||
<Package size={12} />
|
||||
{formatCompactStat(stats.publishedSkills)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Stars received">
|
||||
<Star size={12} />
|
||||
{formatCompactStat(stats.totalStars)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Total downloads">
|
||||
<Download size={12} />
|
||||
{formatCompactStat(stats.totalDownloads)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
import type { PublicUser } from "../lib/publicUser";
|
||||
|
||||
type UserListItemProps = {
|
||||
user: PublicUser;
|
||||
};
|
||||
|
||||
export function UserListItem({ user }: UserListItemProps) {
|
||||
const handle = user.handle?.trim();
|
||||
if (!handle) return null;
|
||||
|
||||
const displayName = user.displayName ?? user.name ?? handle;
|
||||
|
||||
return (
|
||||
<Link to="/u/$handle" params={{ handle }} className="skill-list-item user-list-item" aria-label={`User: ${displayName}`}>
|
||||
<MarketplaceIcon kind="user" label={displayName} imageUrl={user.image} />
|
||||
<div className="skill-list-item-body">
|
||||
<div className="skill-list-item-main">
|
||||
<span className="skill-list-item-name">{displayName}</span>
|
||||
<span className="skill-list-item-owner">@{handle}</span>
|
||||
</div>
|
||||
<p className="skill-list-item-summary">{user.bio?.trim() || "Builder on ClawHub."}</p>
|
||||
<div className="skill-list-item-meta">
|
||||
<span className="skill-list-item-meta-item">User</span>
|
||||
<span className="skill-list-item-meta-item">Profile</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -2,23 +2,23 @@ import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface ContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
size?: "default" | "narrow" | "wide";
|
||||
size?: "default" | "narrow" | "wide";
|
||||
}
|
||||
|
||||
const Container = React.forwardRef<HTMLDivElement, ContainerProps>(
|
||||
({ className, size = "default", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mx-auto w-full px-4 sm:px-6 lg:px-7",
|
||||
size === "default" && "max-w-[1200px]",
|
||||
size === "narrow" && "max-w-[900px]",
|
||||
size === "wide" && "max-w-[1400px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
({ className, size = "default", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mx-auto w-full px-4 sm:px-6 lg:px-7",
|
||||
size === "default" && "max-w-page-max",
|
||||
size === "narrow" && "max-w-page-narrow",
|
||||
size === "wide" && "w-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Container.displayName = "Container";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1200px] px-7 py-10">
|
||||
<div className="mx-auto max-w-page-max px-7 py-10">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-52" />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export function SkillDetailSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[1200px] px-7 py-10">
|
||||
<div className="mx-auto max-w-page-max px-7 py-10">
|
||||
{/* Breadcrumb */}
|
||||
<Skeleton className="mb-6 h-4 w-48" />
|
||||
|
||||
|
||||
@@ -10,37 +10,16 @@ const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Base styles matching .tag
|
||||
"inline-flex items-center gap-1.5 rounded-[var(--radius-pill)] text-[0.8rem] font-semibold",
|
||||
// Variant styles
|
||||
variant === "default" && [
|
||||
"bg-[rgba(43,198,164,0.16)] px-3 py-1 text-[#1a6b5b]",
|
||||
"dark:bg-[rgba(232,106,71,0.2)] dark:text-[#ffd0bf]",
|
||||
],
|
||||
variant === "accent" && [
|
||||
"bg-[rgba(255,107,74,0.16)] px-3 py-1 text-[color:var(--accent-deep)]",
|
||||
"dark:bg-[rgba(232,106,71,0.24)] dark:text-[#ffd0bf]",
|
||||
],
|
||||
variant === "compact" && [
|
||||
"bg-[rgba(43,198,164,0.16)] px-2.5 py-0.5 text-[0.72rem] text-[#1a6b5b]",
|
||||
"dark:bg-[rgba(232,106,71,0.2)] dark:text-[#ffd0bf]",
|
||||
],
|
||||
variant === "pending" && [
|
||||
"bg-[rgba(240,196,106,0.2)] px-3 py-1 text-[#8a6914]",
|
||||
"dark:bg-[rgba(243,201,122,0.18)] dark:text-[color:var(--gold)]",
|
||||
],
|
||||
variant === "success" && [
|
||||
"bg-emerald-100 px-3 py-1 text-emerald-700",
|
||||
"dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||
],
|
||||
variant === "warning" && [
|
||||
"bg-amber-100 px-3 py-1 text-amber-700",
|
||||
"dark:bg-amber-900/30 dark:text-amber-300",
|
||||
],
|
||||
variant === "destructive" && [
|
||||
"bg-red-100 px-3 py-1 text-red-700",
|
||||
"dark:bg-red-900/30 dark:text-red-300",
|
||||
],
|
||||
// Base styles
|
||||
"inline-flex items-center gap-1.5 rounded-[var(--radius-pill)] text-fs-sm font-semibold",
|
||||
// Variant styles — all token-driven, no dark: overrides needed
|
||||
variant === "default" && "bg-hover-bg px-3 py-1 text-ink-soft border border-line",
|
||||
variant === "accent" && "bg-active-bg px-3 py-1 text-accent-deep border border-line",
|
||||
variant === "compact" && "bg-hover-bg px-2.5 py-0.5 text-fs-xs text-ink-soft border border-line",
|
||||
variant === "pending" && "bg-status-warning-bg px-3 py-1 text-status-warning-fg border border-line",
|
||||
variant === "success" && "bg-status-success-bg px-3 py-1 text-status-success-fg border border-line",
|
||||
variant === "warning" && "bg-status-warning-bg px-3 py-1 text-status-warning-fg border border-line",
|
||||
variant === "destructive" && "bg-status-error-bg px-3 py-1 text-status-error-fg border border-line",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -33,15 +33,15 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-200 ease-out",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
|
||||
"disabled:pointer-events-none disabled:opacity-60",
|
||||
// Hover lift (matches .btn:hover)
|
||||
"hover:not-disabled:-translate-y-px hover:not-disabled:shadow-[0_10px_20px_rgba(29,26,23,0.12)]",
|
||||
// Hover lift
|
||||
"hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover",
|
||||
// Variant styles
|
||||
variant === "default" &&
|
||||
"border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)]",
|
||||
variant === "primary" &&
|
||||
"border-none bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] text-white dark:from-[#c35640] dark:to-[#953827] dark:shadow-[0_10px_22px_rgba(58,23,16,0.42),inset_0_1px_0_rgba(255,201,184,0.18)]",
|
||||
"border border-accent bg-accent/10 text-[color:var(--ink)]",
|
||||
variant === "destructive" &&
|
||||
"border border-red-300/40 bg-red-50 text-red-700 hover:not-disabled:bg-red-100 dark:border-red-500/30 dark:bg-red-950/50 dark:text-red-300",
|
||||
"border border-status-error-fg/20 bg-status-error-bg text-status-error-fg hover:not-disabled:bg-active-bg",
|
||||
variant === "ghost" &&
|
||||
"border-transparent bg-transparent text-[color:var(--ink-soft)] hover:not-disabled:bg-[color:var(--surface-muted)] hover:not-disabled:text-[color:var(--ink)] hover:not-disabled:shadow-none hover:not-disabled:translate-y-0",
|
||||
variant === "outline" &&
|
||||
|
||||
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Matches .card
|
||||
"flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[22px] transition-all duration-200 ease-out",
|
||||
"flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-space-5 transition-all duration-200 ease-out",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -16,7 +16,7 @@ const DialogOverlay = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Matches .report-dialog-backdrop
|
||||
"fixed inset-0 z-80 grid place-items-center bg-[rgba(21,24,35,0.42)] p-5 backdrop-blur-[3px]",
|
||||
"fixed inset-0 z-80 grid place-items-center bg-overlay-bg p-5 backdrop-blur-[3px]",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||
className,
|
||||
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Matches .report-dialog
|
||||
"fixed top-1/2 left-1/2 z-80 grid w-[min(100%,560px)] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5 shadow-[0_24px_50px_rgba(18,22,34,0.24)]",
|
||||
"fixed top-1/2 left-1/2 z-80 grid w-[min(100%,560px)] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5 shadow-dialog",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className,
|
||||
|
||||
@@ -18,7 +18,7 @@ const DropdownMenuContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[180px] rounded-xl border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
|
||||
"z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -34,7 +34,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-pointer select-none items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"flex cursor-pointer select-none items-center gap-2 rounded-[var(--radius-sm)] px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -7,16 +7,12 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
// Base styles matching .form-input
|
||||
"w-full min-h-[44px] rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
|
||||
"border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)]",
|
||||
"placeholder:text-[rgba(88,115,133,0.72)]",
|
||||
// Focus styles matching .form-input:focus
|
||||
"focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)]",
|
||||
// Dark mode matching [data-theme="dark"] .form-input
|
||||
"dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]",
|
||||
"dark:placeholder:text-[rgba(184,205,216,0.68)]",
|
||||
"dark:focus:border-[rgba(255,131,95,0.75)] dark:focus:shadow-[0_0_0_3px_rgba(255,131,95,0.2)]",
|
||||
// Base styles
|
||||
"w-full min-h-[44px] rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
|
||||
"border-input-border bg-input-bg",
|
||||
"placeholder:text-input-placeholder",
|
||||
// Focus
|
||||
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
|
||||
// Disabled
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
className,
|
||||
|
||||
@@ -10,9 +10,8 @@ const Label = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Matches .form-label
|
||||
"text-[0.74rem] font-bold uppercase tracking-[0.14em]",
|
||||
"text-[rgba(70,95,113,0.9)]",
|
||||
"dark:text-[rgba(206,227,238,0.76)]",
|
||||
"text-fs-xs font-bold uppercase tracking-[0.14em]",
|
||||
"text-label-fg",
|
||||
"peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -14,12 +14,11 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Matches .form-input styling for consistency
|
||||
"flex w-full min-h-[44px] items-center justify-between rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-sm text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
|
||||
"border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)]",
|
||||
"placeholder:text-[rgba(88,115,133,0.72)]",
|
||||
"focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)]",
|
||||
"dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]",
|
||||
// Matches form input token styling
|
||||
"flex w-full min-h-[44px] items-center justify-between rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-sm text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
|
||||
"border-input-border bg-input-bg",
|
||||
"placeholder:text-input-placeholder",
|
||||
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
className,
|
||||
)}
|
||||
@@ -69,7 +68,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] shadow-[var(--shadow)]",
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] shadow-[var(--shadow)]",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
@@ -115,7 +114,7 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer select-none items-center rounded-lg py-2 pr-8 pl-3 text-sm font-semibold outline-none transition-colors",
|
||||
"relative flex w-full cursor-pointer select-none items-center rounded-[var(--radius-sm)] py-2 pr-8 pl-3 text-sm font-semibold outline-none transition-colors",
|
||||
"focus:bg-[color:var(--surface-muted)] focus:text-[color:var(--ink)]",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
|
||||
@@ -15,7 +15,7 @@ const SheetOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-80 bg-[rgba(21,24,35,0.42)] backdrop-blur-[3px]",
|
||||
"fixed inset-0 z-80 bg-overlay-bg backdrop-blur-[3px]",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||
className,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent",
|
||||
"transition-colors duration-200 ease-out",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"data-[state=checked]:bg-[color:var(--accent)] data-[state=unchecked]:bg-[color:var(--surface-muted)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm ring-0",
|
||||
"transition-transform duration-200 ease-out",
|
||||
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitive.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
@@ -8,16 +8,12 @@ const Textarea = React.forwardRef<
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Base styles matching .form-input
|
||||
"w-full min-h-[100px] resize-y rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
|
||||
"border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)]",
|
||||
"placeholder:text-[rgba(88,115,133,0.72)]",
|
||||
// Base styles
|
||||
"w-full min-h-[100px] resize-y rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
|
||||
"border-input-border bg-input-bg",
|
||||
"placeholder:text-input-placeholder",
|
||||
// Focus
|
||||
"focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)]",
|
||||
// Dark mode
|
||||
"dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]",
|
||||
"dark:placeholder:text-[rgba(184,205,216,0.68)]",
|
||||
"dark:focus:border-[rgba(255,131,95,0.75)] dark:focus:shadow-[0_0_0_3px_rgba(255,131,95,0.2)]",
|
||||
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
|
||||
// Disabled
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
className,
|
||||
|
||||
@@ -9,7 +9,7 @@ const ToggleGroup = React.forwardRef<
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-[38px] items-center gap-0.5 rounded-full border border-[color:var(--line)] bg-[color:var(--surface)] p-[3px]",
|
||||
"inline-flex h-[38px] items-center gap-0.5 rounded-[var(--radius-pill)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[3px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -24,7 +24,7 @@ const ToggleGroupItem = React.forwardRef<
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-[30px] w-[30px] items-center justify-center rounded-full text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] data-[state=on]:bg-[color:var(--accent)] data-[state=on]:text-white",
|
||||
"inline-flex h-[30px] w-[30px] items-center justify-center rounded-[var(--radius-pill)] text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] data-[state=on]:bg-accent data-[state=on]:text-accent-fg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
export type SkillCategory = {
|
||||
slug: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
keywords: string[];
|
||||
};
|
||||
|
||||
export const SKILL_CATEGORIES: SkillCategory[] = [
|
||||
{ slug: "mcp-tools", label: "MCP Tools", keywords: ["mcp", "tool", "server"] },
|
||||
{ slug: "prompts", label: "Prompts", keywords: ["prompt", "template", "system"] },
|
||||
{ slug: "workflows", label: "Workflows", keywords: ["workflow", "pipeline", "chain"] },
|
||||
{ slug: "dev-tools", label: "Dev Tools", keywords: ["dev", "debug", "lint", "test", "build"] },
|
||||
{ slug: "data", label: "Data & APIs", keywords: ["api", "data", "fetch", "http", "rest", "graphql"] },
|
||||
{ slug: "security", label: "Security", keywords: ["security", "scan", "auth", "encrypt"] },
|
||||
{ slug: "automation", label: "Automation", keywords: ["auto", "cron", "schedule", "bot"] },
|
||||
{ slug: "other", label: "Other", keywords: [] },
|
||||
{ slug: "mcp-tools", label: "MCP Tools", icon: "plug", keywords: ["mcp", "tool", "server"] },
|
||||
{ slug: "prompts", label: "Prompts", icon: "message-square", keywords: ["prompt", "template", "system"] },
|
||||
{ slug: "workflows", label: "Workflows", icon: "git-branch", keywords: ["workflow", "pipeline", "chain"] },
|
||||
{ slug: "dev-tools", label: "Dev Tools", icon: "wrench", keywords: ["dev", "debug", "lint", "test", "build"] },
|
||||
{ slug: "data", label: "Data & APIs", icon: "database", keywords: ["api", "data", "fetch", "http", "rest", "graphql"] },
|
||||
{ slug: "security", label: "Security", icon: "shield", keywords: ["security", "scan", "auth", "encrypt"] },
|
||||
{ slug: "automation", label: "Automation", icon: "zap", keywords: ["auto", "cron", "schedule", "bot"] },
|
||||
{ slug: "other", label: "Other", icon: "package", keywords: [] },
|
||||
];
|
||||
|
||||
export const ALL_CATEGORY_KEYWORDS = SKILL_CATEGORIES.flatMap((c) => c.keywords);
|
||||
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Lightweight custom theme support for ClawHub.
|
||||
*
|
||||
* Accepts tweakcn theme URLs, bare tweakcn names, tweakcn JSON payloads,
|
||||
* or raw CSS variable blocks, then maps them into ClawHub's token system.
|
||||
*/
|
||||
|
||||
const SUPPORTED_COLOR_VARS = [
|
||||
"background",
|
||||
"foreground",
|
||||
"card",
|
||||
"card-foreground",
|
||||
"popover",
|
||||
"popover-foreground",
|
||||
"primary",
|
||||
"primary-foreground",
|
||||
"secondary",
|
||||
"secondary-foreground",
|
||||
"muted",
|
||||
"muted-foreground",
|
||||
"accent",
|
||||
"accent-foreground",
|
||||
"destructive",
|
||||
"destructive-foreground",
|
||||
"border",
|
||||
"input",
|
||||
"ring",
|
||||
"info",
|
||||
"info-foreground",
|
||||
"success",
|
||||
"success-foreground",
|
||||
"warning",
|
||||
"warning-foreground",
|
||||
] as const;
|
||||
|
||||
const SUPPORTED_DESIGN_VARS = ["radius"] as const;
|
||||
const SUPPORTED_FONT_VARS = ["font-sans", "font-serif", "font-mono"] as const;
|
||||
|
||||
const ALL_SUPPORTED_VARS = new Set<string>([
|
||||
...SUPPORTED_COLOR_VARS,
|
||||
...SUPPORTED_DESIGN_VARS,
|
||||
...SUPPORTED_FONT_VARS,
|
||||
]);
|
||||
|
||||
const CUSTOM_THEME_STORAGE_KEY = "clawhub-custom-theme";
|
||||
const CUSTOM_THEME_STYLE_ID = "clawhub-custom-theme-style";
|
||||
const CUSTOM_THEME_FONT_LINK_ID = "clawhub-custom-theme-fonts";
|
||||
|
||||
const SYSTEM_FONTS = new Set([
|
||||
"system-ui",
|
||||
"-apple-system",
|
||||
"blinkmacsystemfont",
|
||||
"segoe ui",
|
||||
"roboto",
|
||||
"helvetica neue",
|
||||
"arial",
|
||||
"sans-serif",
|
||||
"serif",
|
||||
"monospace",
|
||||
"sf mono",
|
||||
"sfmono-regular",
|
||||
"consolas",
|
||||
"liberation mono",
|
||||
"menlo",
|
||||
"courier new",
|
||||
"dm sans",
|
||||
"georgia",
|
||||
"times new roman",
|
||||
"times",
|
||||
"ui-monospace",
|
||||
"ui-sans-serif",
|
||||
"ui-serif",
|
||||
"bricolage grotesque",
|
||||
"manrope",
|
||||
"ibm plex mono",
|
||||
]);
|
||||
|
||||
export interface CustomThemeData {
|
||||
name?: string | undefined;
|
||||
source?: string | undefined;
|
||||
light: Record<string, string>;
|
||||
dark: Record<string, string>;
|
||||
}
|
||||
|
||||
function hasDom(): boolean {
|
||||
return (
|
||||
typeof document !== "undefined" &&
|
||||
typeof document.getElementById === "function" &&
|
||||
typeof document.createElement === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function extractBraceContent(css: string, startAfterBrace: number): string {
|
||||
let depth = 1;
|
||||
let i = startAfterBrace;
|
||||
while (i < css.length && depth > 0) {
|
||||
if (css[i] === "{") depth++;
|
||||
else if (css[i] === "}") depth--;
|
||||
i++;
|
||||
}
|
||||
return css.substring(startAfterBrace, i - 1);
|
||||
}
|
||||
|
||||
function extractVariables(block: string): Record<string, string> {
|
||||
const vars: Record<string, string> = {};
|
||||
const regex = /--([\w-]+)\s*:\s*([^;]+);/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(block)) !== null) {
|
||||
const name = match[1]?.trim();
|
||||
const value = match[2]?.trim();
|
||||
if (!name || !value || !ALL_SUPPORTED_VARS.has(name)) continue;
|
||||
vars[name] = value;
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
function filterSupported(vars: Record<string, string>): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
if (ALL_SUPPORTED_VARS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseThemeCSS(css: string): CustomThemeData {
|
||||
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
const light: Record<string, string> = {};
|
||||
const dark: Record<string, string> = {};
|
||||
|
||||
const rootRegex = /:root\s*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = rootRegex.exec(cleaned)) !== null) {
|
||||
const start = match.index + match[0].length;
|
||||
Object.assign(light, extractVariables(extractBraceContent(cleaned, start)));
|
||||
}
|
||||
|
||||
const darkRegex = /\.dark\s*\{/g;
|
||||
while ((match = darkRegex.exec(cleaned)) !== null) {
|
||||
const start = match.index + match[0].length;
|
||||
Object.assign(dark, extractVariables(extractBraceContent(cleaned, start)));
|
||||
}
|
||||
|
||||
if (Object.keys(light).length === 0 && Object.keys(dark).length === 0) {
|
||||
const vars = extractVariables(cleaned);
|
||||
Object.assign(light, vars);
|
||||
Object.assign(dark, vars);
|
||||
}
|
||||
|
||||
return { light, dark };
|
||||
}
|
||||
|
||||
export function parseTweakcnJSON(json: unknown): CustomThemeData {
|
||||
if (!json || typeof json !== "object") {
|
||||
throw new Error("Invalid theme JSON");
|
||||
}
|
||||
|
||||
const obj = json as Record<string, unknown>;
|
||||
const name = typeof obj.name === "string" ? obj.name : undefined;
|
||||
const cssVars = obj.cssVars as Record<string, Record<string, string>> | undefined;
|
||||
|
||||
if (!cssVars || typeof cssVars !== "object") {
|
||||
throw new Error('Theme JSON missing "cssVars" object');
|
||||
}
|
||||
|
||||
const light = filterSupported({ ...cssVars.theme, ...cssVars.light });
|
||||
const dark = filterSupported({ ...cssVars.theme, ...cssVars.dark });
|
||||
|
||||
if (Object.keys(light).length === 0 && Object.keys(dark).length === 0) {
|
||||
throw new Error("No supported theme variables found in JSON");
|
||||
}
|
||||
|
||||
return { name, light, dark };
|
||||
}
|
||||
|
||||
const TWEAKCN_URL_PATTERNS = [
|
||||
/^https?:\/\/(?:www\.)?tweakcn\.com\/r\/themes\/([^/?#]+)/,
|
||||
/^https?:\/\/(?:www\.)?tweakcn\.com\/themes\/([^/?#]+)/,
|
||||
/^https?:\/\/(?:www\.)?tweakcn\.com\/editor\/theme\?theme=([^&#]+)/,
|
||||
];
|
||||
|
||||
export function isTweakcnURL(input: string): boolean {
|
||||
const trimmed = input.trim();
|
||||
return TWEAKCN_URL_PATTERNS.some((re) => re.test(trimmed));
|
||||
}
|
||||
|
||||
function extractTweakcnThemeId(url: string): string | null {
|
||||
const trimmed = url.trim();
|
||||
for (const re of TWEAKCN_URL_PATTERNS) {
|
||||
const match = trimmed.match(re);
|
||||
if (match?.[1]) return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isBareThemeName(input: string): boolean {
|
||||
return /^[a-zA-Z0-9][\w-]*$/.test(input);
|
||||
}
|
||||
|
||||
export async function fetchTweakcnTheme(urlOrName: string): Promise<CustomThemeData> {
|
||||
const themeId = isBareThemeName(urlOrName) ? urlOrName : extractTweakcnThemeId(urlOrName);
|
||||
|
||||
if (!themeId) {
|
||||
throw new Error("Could not extract theme ID from URL");
|
||||
}
|
||||
|
||||
const response = await fetch(`https://tweakcn.com/r/themes/${encodeURIComponent(themeId)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch theme: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return parseTweakcnJSON(await response.json());
|
||||
}
|
||||
|
||||
export async function parseThemeInput(input: string): Promise<CustomThemeData> {
|
||||
const trimmed = input.trim();
|
||||
|
||||
if (isTweakcnURL(trimmed)) {
|
||||
return fetchTweakcnTheme(trimmed);
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
return parseTweakcnJSON(JSON.parse(trimmed));
|
||||
} catch {
|
||||
// fall through to CSS parsing
|
||||
}
|
||||
}
|
||||
|
||||
if (isBareThemeName(trimmed)) {
|
||||
return fetchTweakcnTheme(trimmed);
|
||||
}
|
||||
|
||||
const parsed = parseThemeCSS(trimmed);
|
||||
if (Object.keys(parsed.light).length === 0 && Object.keys(parsed.dark).length === 0) {
|
||||
throw new Error("No theme variables found. Paste CSS, a tweakcn theme URL, or a theme name.");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function extractGoogleFonts(theme: CustomThemeData): string[] {
|
||||
const fonts = new Set<string>();
|
||||
|
||||
for (const mode of [theme.light, theme.dark]) {
|
||||
for (const key of SUPPORTED_FONT_VARS) {
|
||||
const value = mode[key];
|
||||
if (!value) continue;
|
||||
const families = value.split(",").map((part) => part.trim().replace(/^["']|["']$/g, ""));
|
||||
for (const family of families) {
|
||||
if (family && !SYSTEM_FONTS.has(family.toLowerCase())) {
|
||||
fonts.add(family);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(fonts);
|
||||
}
|
||||
|
||||
function loadGoogleFonts(fonts: string[]): void {
|
||||
if (!hasDom()) return;
|
||||
document.getElementById(CUSTOM_THEME_FONT_LINK_ID)?.remove();
|
||||
if (fonts.length === 0) return;
|
||||
|
||||
const families = fonts.map((font) => `family=${font.replace(/\s+/g, "+")}:wght@300..800`).join("&");
|
||||
const link = document.createElement("link");
|
||||
link.id = CUSTOM_THEME_FONT_LINK_ID;
|
||||
link.rel = "stylesheet";
|
||||
link.href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
function unloadGoogleFonts(): void {
|
||||
if (!hasDom()) return;
|
||||
document.getElementById(CUSTOM_THEME_FONT_LINK_ID)?.remove();
|
||||
}
|
||||
|
||||
function getVar(vars: Record<string, string>, key: string, fallback: string): string {
|
||||
return vars[key] ?? fallback;
|
||||
}
|
||||
|
||||
function buildModeLines(vars: Record<string, string>): string[] {
|
||||
const background = getVar(vars, "background", "var(--bg)");
|
||||
const foreground = getVar(vars, "foreground", "var(--ink)");
|
||||
const card = getVar(vars, "card", background);
|
||||
const secondary = getVar(vars, "secondary", card);
|
||||
const muted = getVar(vars, "muted", secondary);
|
||||
const primary = getVar(vars, "primary", "var(--accent)");
|
||||
const primaryForeground = getVar(vars, "primary-foreground", "var(--accent-fg)");
|
||||
const accent = getVar(vars, "accent", primary);
|
||||
const border = getVar(vars, "border", "var(--line)");
|
||||
const input = getVar(vars, "input", border);
|
||||
const ring = getVar(vars, "ring", primary);
|
||||
const destructive = getVar(vars, "destructive", "var(--status-error-fg)");
|
||||
const success = getVar(vars, "success", "var(--seafoam)");
|
||||
const warning = getVar(vars, "warning", "var(--gold)");
|
||||
const mutedForeground = getVar(vars, "muted-foreground", foreground);
|
||||
const radius = vars.radius;
|
||||
const fontSans = vars["font-sans"];
|
||||
const fontMono = vars["font-mono"];
|
||||
|
||||
const lines = [
|
||||
` --bg: ${background};`,
|
||||
` --bg-soft: ${muted};`,
|
||||
` --surface: ${card};`,
|
||||
` --surface-muted: ${secondary};`,
|
||||
` --nav-bg: color-mix(in srgb, ${background} 96%, transparent);`,
|
||||
` --ink: ${foreground};`,
|
||||
` --ink-soft: ${mutedForeground};`,
|
||||
` --accent: ${primary};`,
|
||||
` --accent-fg: ${primaryForeground};`,
|
||||
` --accent-deep: ${accent};`,
|
||||
` --accent-subtle: color-mix(in srgb, ${primary} 14%, transparent);`,
|
||||
` --seafoam: ${success};`,
|
||||
` --gold: ${warning};`,
|
||||
` --line: color-mix(in srgb, ${border} 72%, transparent);`,
|
||||
` --border-ui: color-mix(in srgb, ${border} 90%, transparent);`,
|
||||
` --border-ui-hover: color-mix(in srgb, ${ring} 50%, ${border});`,
|
||||
` --border-ui-active: color-mix(in srgb, ${ring} 70%, ${border});`,
|
||||
` --input-border: ${input};`,
|
||||
` --input-bg: ${card};`,
|
||||
` --input-placeholder: color-mix(in srgb, ${mutedForeground} 72%, transparent);`,
|
||||
` --input-focus-border: ${ring};`,
|
||||
` --input-focus-ring: color-mix(in srgb, ${ring} 18%, transparent);`,
|
||||
` --label-fg: color-mix(in srgb, ${foreground} 82%, transparent);`,
|
||||
` --hover-bg: color-mix(in srgb, ${secondary} 72%, transparent);`,
|
||||
` --active-bg: color-mix(in srgb, ${primary} 12%, transparent);`,
|
||||
` --overlay-bg: color-mix(in srgb, ${background} 76%, transparent);`,
|
||||
` --status-success-bg: color-mix(in srgb, ${success} 14%, transparent);`,
|
||||
` --status-success-fg: ${success};`,
|
||||
` --status-warning-bg: color-mix(in srgb, ${warning} 14%, transparent);`,
|
||||
` --status-warning-fg: ${warning};`,
|
||||
` --status-error-bg: color-mix(in srgb, ${destructive} 14%, transparent);`,
|
||||
` --status-error-fg: ${destructive};`,
|
||||
` --diff-added: ${success};`,
|
||||
` --diff-added-strong: ${success};`,
|
||||
` --diff-removed: ${destructive};`,
|
||||
` --diff-removed-strong: ${destructive};`,
|
||||
` --diff-diagonal: color-mix(in srgb, ${primary} 12%, transparent);`,
|
||||
];
|
||||
|
||||
if (radius) {
|
||||
lines.push(` --r-lg: ${radius};`);
|
||||
lines.push(` --r-md: ${radius};`);
|
||||
lines.push(` --r-sm: ${radius};`);
|
||||
}
|
||||
if (fontSans) {
|
||||
lines.push(` --font-display: ${fontSans};`);
|
||||
lines.push(` --font-body: ${fontSans};`);
|
||||
}
|
||||
if (fontMono) {
|
||||
lines.push(` --font-mono: ${fontMono};`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildCustomThemeCSS(theme: CustomThemeData): string {
|
||||
return [
|
||||
":root.theme-custom {",
|
||||
...buildModeLines(theme.light),
|
||||
"}",
|
||||
"",
|
||||
":root.theme-custom.dark {",
|
||||
...buildModeLines(theme.dark),
|
||||
"}",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function applyCustomTheme(theme: CustomThemeData): void {
|
||||
if (!hasDom()) return;
|
||||
|
||||
let styleEl = document.getElementById(CUSTOM_THEME_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement("style");
|
||||
styleEl.id = CUSTOM_THEME_STYLE_ID;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
|
||||
styleEl.textContent = buildCustomThemeCSS(theme);
|
||||
document.documentElement.classList.add("theme-custom");
|
||||
loadGoogleFonts(extractGoogleFonts(theme));
|
||||
}
|
||||
|
||||
export function removeCustomTheme(): void {
|
||||
if (!hasDom()) return;
|
||||
document.getElementById(CUSTOM_THEME_STYLE_ID)?.remove();
|
||||
document.documentElement.classList.remove("theme-custom");
|
||||
unloadGoogleFonts();
|
||||
}
|
||||
|
||||
export function getStoredCustomTheme(): CustomThemeData | null {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(CUSTOM_THEME_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as CustomThemeData;
|
||||
if (parsed && typeof parsed.light === "object" && typeof parsed.dark === "object") {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// ignore bad storage payloads
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setStoredCustomTheme(theme: CustomThemeData): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(CUSTOM_THEME_STORAGE_KEY, JSON.stringify(theme));
|
||||
}
|
||||
|
||||
export function clearStoredCustomTheme(): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.removeItem(CUSTOM_THEME_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function syncCustomThemeFromStorage(): void {
|
||||
const theme = getStoredCustomTheme();
|
||||
if (theme) {
|
||||
applyCustomTheme(theme);
|
||||
return;
|
||||
}
|
||||
removeCustomTheme();
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Standardized icon sizes for consistent sizing across the app */
|
||||
export const ICON_SM = 14;
|
||||
export const ICON_MD = 16;
|
||||
export const ICON_LG = 24;
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Shared navigation configuration used by Header and Footer to eliminate
|
||||
* triple duplication of nav link definitions.
|
||||
*/
|
||||
|
||||
/** Lucide icon name used as a key to look up the component at render time. */
|
||||
export type NavIconName = "wrench" | "plug" | "ghost";
|
||||
|
||||
export interface NavItem {
|
||||
/** Visible link text */
|
||||
label: string;
|
||||
/** Route path passed to `<Link to>` */
|
||||
to: string;
|
||||
/** Optional search params object passed to `<Link search>` */
|
||||
search?: Record<string, unknown>;
|
||||
/** Optional lucide icon name shown beside the label in navbar tabs */
|
||||
icon?: NavIconName;
|
||||
/** Link only shown when user is authenticated */
|
||||
authRequired: boolean;
|
||||
/** Link only shown for staff / moderator users */
|
||||
staffOnly: boolean;
|
||||
/** Link only shown when siteMode === "souls" */
|
||||
soulModeOnly: boolean;
|
||||
/** Link hidden when siteMode === "souls" */
|
||||
soulModeHide: boolean;
|
||||
/** Additional path prefixes that should also highlight this nav item (e.g. /skill for /skills) */
|
||||
activePathPrefixes?: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search-param shapes (kept here so Header, Footer, and mobile menu all agree)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SKILLS_SEARCH = {
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
} as const;
|
||||
|
||||
const SOULS_SEARCH = {
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
} as const;
|
||||
|
||||
const USERS_SEARCH = { q: undefined } as const;
|
||||
|
||||
const MANAGEMENT_SEARCH = { skill: undefined } as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primary nav items (desktop tabs row + mobile dropdown top section)
|
||||
// These map to the "content-type" tabs: Skills | Plugins | Souls
|
||||
// In soul-mode the order is: ClawHub (external), Souls
|
||||
// In skills-mode: Skills, Plugins, Souls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PRIMARY_NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
label: "Skills",
|
||||
to: "/skills",
|
||||
search: SKILLS_SEARCH,
|
||||
icon: "wrench",
|
||||
authRequired: false,
|
||||
staffOnly: false,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: true,
|
||||
activePathPrefixes: ["/skill/"],
|
||||
},
|
||||
{
|
||||
label: "Plugins",
|
||||
to: "/plugins",
|
||||
icon: "plug",
|
||||
authRequired: false,
|
||||
staffOnly: false,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: true,
|
||||
activePathPrefixes: ["/plugin/"],
|
||||
},
|
||||
{
|
||||
label: "Souls",
|
||||
to: "/souls",
|
||||
search: SOULS_SEARCH,
|
||||
icon: "ghost",
|
||||
authRequired: false,
|
||||
staffOnly: false,
|
||||
soulModeOnly: false,
|
||||
// In soul-mode this is the primary tab; in skills-mode it is also shown.
|
||||
soulModeHide: false,
|
||||
activePathPrefixes: ["/soul/"],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Secondary nav items (secondary tabs row + mobile dropdown lower section)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SECONDARY_NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
label: "Users",
|
||||
to: "/users",
|
||||
search: USERS_SEARCH,
|
||||
authRequired: false,
|
||||
staffOnly: false,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: true,
|
||||
},
|
||||
{
|
||||
label: "About",
|
||||
to: "/about",
|
||||
authRequired: false,
|
||||
staffOnly: false,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: true,
|
||||
},
|
||||
{
|
||||
label: "Stars",
|
||||
to: "/stars",
|
||||
authRequired: true,
|
||||
staffOnly: false,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: false,
|
||||
},
|
||||
{
|
||||
label: "Dashboard",
|
||||
to: "/dashboard",
|
||||
authRequired: true,
|
||||
staffOnly: false,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: false,
|
||||
},
|
||||
{
|
||||
label: "Management",
|
||||
to: "/management",
|
||||
search: MANAGEMENT_SEARCH,
|
||||
authRequired: true,
|
||||
staffOnly: true,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: false,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Footer sections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FooterNavSection {
|
||||
title: string;
|
||||
items: FooterNavItem[];
|
||||
}
|
||||
|
||||
export type FooterNavItem =
|
||||
| { kind: "link"; label: string; to: string; search?: Record<string, unknown> }
|
||||
| { kind: "external"; label: string; href: string }
|
||||
| { kind: "text"; label: string };
|
||||
|
||||
export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
|
||||
{
|
||||
title: "Browse",
|
||||
items: [
|
||||
{ kind: "link", label: "Skills", to: "/skills", search: SKILLS_SEARCH },
|
||||
{ kind: "link", label: "Plugins", to: "/plugins" },
|
||||
{ kind: "link", label: "Souls", to: "/souls", search: SOULS_SEARCH },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Publish",
|
||||
items: [
|
||||
{
|
||||
kind: "link",
|
||||
label: "Publish Skill",
|
||||
to: "/publish-skill",
|
||||
search: { updateSlug: undefined },
|
||||
},
|
||||
{
|
||||
kind: "link",
|
||||
label: "Publish Plugin",
|
||||
to: "/publish-plugin",
|
||||
search: {
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "external",
|
||||
label: "Documentation",
|
||||
href: "https://github.com/openclaw/clawhub",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Community",
|
||||
items: [
|
||||
{ kind: "external", label: "GitHub", href: "https://github.com/openclaw/clawhub" },
|
||||
{ kind: "link", label: "About", to: "/about" },
|
||||
{ kind: "external", label: "OpenClaw", href: "https://openclaw.ai" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Platform",
|
||||
items: [
|
||||
{ kind: "text", label: "MIT Licensed" },
|
||||
{ kind: "external", label: "Deployed on Vercel", href: "https://vercel.com" },
|
||||
{ kind: "external", label: "Powered by Convex", href: "https://www.convex.dev" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Filter a nav item array based on current mode/auth/staff context. */
|
||||
export function filterNavItems(
|
||||
items: NavItem[],
|
||||
ctx: { isSoulMode: boolean; isAuthenticated: boolean; isStaff: boolean },
|
||||
): NavItem[] {
|
||||
return items.filter((item) => {
|
||||
if (item.soulModeOnly && !ctx.isSoulMode) return false;
|
||||
if (item.soulModeHide && ctx.isSoulMode) return false;
|
||||
if (item.authRequired && !ctx.isAuthenticated) return false;
|
||||
if (item.staffOnly && !ctx.isStaff) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -277,32 +277,51 @@ export async function fetchPluginCatalog(params: {
|
||||
executesCode?: boolean;
|
||||
limit?: number;
|
||||
}): Promise<PluginCatalogResult> {
|
||||
if (params.family) {
|
||||
const response = await fetchPackages({
|
||||
q: params.q,
|
||||
cursor: params.cursor,
|
||||
family: params.family,
|
||||
isOfficial: params.isOfficial,
|
||||
executesCode: params.executesCode,
|
||||
limit: params.limit,
|
||||
});
|
||||
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
|
||||
try {
|
||||
if (params.family) {
|
||||
const response = await fetchPackages({
|
||||
q: params.q,
|
||||
cursor: params.cursor,
|
||||
family: params.family,
|
||||
isOfficial: params.isOfficial,
|
||||
executesCode: params.executesCode,
|
||||
limit: params.limit,
|
||||
});
|
||||
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
|
||||
return {
|
||||
items: response.results.map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
|
||||
const browseResponse = response as PackageCatalogBrowseResponse;
|
||||
return {
|
||||
items: response.results.map((entry) => entry.package),
|
||||
items: browseResponse?.items ?? [],
|
||||
nextCursor: browseResponse?.nextCursor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.q?.trim()) {
|
||||
const url = await packageApiUrl(`${ApiRoutes.plugins}/search`);
|
||||
url.searchParams.set("q", params.q.trim());
|
||||
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
const response = await fetchJson<{
|
||||
results?: Array<{ score: number; package: PackageListItem }>;
|
||||
}>(url);
|
||||
return {
|
||||
items: (response?.results ?? []).map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
|
||||
const browseResponse = response as PackageCatalogBrowseResponse;
|
||||
return {
|
||||
items: browseResponse.items,
|
||||
nextCursor: browseResponse.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.q?.trim()) {
|
||||
const url = await packageApiUrl(`${ApiRoutes.plugins}/search`);
|
||||
url.searchParams.set("q", params.q.trim());
|
||||
const url = await packageApiUrl(ApiRoutes.plugins);
|
||||
if (params.cursor) url.searchParams.set("cursor", params.cursor);
|
||||
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
@@ -310,53 +329,53 @@ export async function fetchPluginCatalog(params: {
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
const response = await fetchJson<{
|
||||
results: Array<{ score: number; package: PackageListItem }>;
|
||||
}>(url);
|
||||
const result = await fetchJson<PluginCatalogResult>(url);
|
||||
return {
|
||||
items: response.results.map((entry) => entry.package),
|
||||
nextCursor: null,
|
||||
items: result?.items ?? [],
|
||||
nextCursor: result?.nextCursor ?? null,
|
||||
};
|
||||
} catch {
|
||||
// Return empty result on API error to prevent SSR crashes
|
||||
return { items: [], nextCursor: null };
|
||||
}
|
||||
|
||||
const url = await packageApiUrl(ApiRoutes.plugins);
|
||||
if (params.cursor) url.searchParams.set("cursor", params.cursor);
|
||||
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
return await fetchJson<PluginCatalogResult>(url);
|
||||
}
|
||||
|
||||
export async function fetchPackageDetail(name: string) {
|
||||
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}`);
|
||||
const response = await packageFetch(url, "application/json");
|
||||
if (response.status === 404) {
|
||||
return {
|
||||
package: null,
|
||||
owner: null,
|
||||
} satisfies PackageDetailResponse;
|
||||
export async function fetchPackageDetail(name: string): Promise<PackageDetailResponse> {
|
||||
try {
|
||||
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}`);
|
||||
const response = await packageFetch(url, "application/json");
|
||||
if (response.status === 404 || !response.ok) {
|
||||
return { package: null, owner: null };
|
||||
}
|
||||
return (await response.json()) as PackageDetailResponse;
|
||||
} catch {
|
||||
// Return empty result on API error to prevent SSR crashes
|
||||
return { package: null, owner: null };
|
||||
}
|
||||
if (!response.ok) throw await createPackageApiError(response);
|
||||
return (await response.json()) as PackageDetailResponse;
|
||||
}
|
||||
|
||||
export async function fetchPackageVersion(name: string, version: string) {
|
||||
const url = await packageApiUrl(
|
||||
`${ApiRoutes.packages}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`,
|
||||
);
|
||||
return await fetchJson<PackageVersionDetail>(url);
|
||||
export async function fetchPackageVersion(name: string, version: string): Promise<PackageVersionDetail | null> {
|
||||
try {
|
||||
const url = await packageApiUrl(
|
||||
`${ApiRoutes.packages}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`,
|
||||
);
|
||||
return await fetchJson<PackageVersionDetail>(url);
|
||||
} catch {
|
||||
// Return null on API error to prevent SSR crashes
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPackageReadme(name: string, version?: string | null) {
|
||||
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
|
||||
url.searchParams.set("path", "README.md");
|
||||
if (version) url.searchParams.set("version", version);
|
||||
const response = await packageFetch(url, "text/plain");
|
||||
if (response.ok) return await response.text();
|
||||
if (response.status === 403 || response.status === 423 || response.status === 404) return null;
|
||||
throw await createPackageApiError(response);
|
||||
export async function fetchPackageReadme(name: string, version?: string | null): Promise<string | null> {
|
||||
try {
|
||||
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
|
||||
url.searchParams.set("path", "README.md");
|
||||
if (version) url.searchParams.set("version", version);
|
||||
const response = await packageFetch(url, "text/plain");
|
||||
if (response.ok) return await response.text();
|
||||
return null;
|
||||
} catch {
|
||||
// Return null on API error to prevent SSR crashes
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
|
||||
const PREFERENCES_KEY = "clawhub-preferences";
|
||||
|
||||
export type LayoutDensity = "comfortable" | "compact";
|
||||
export type ListViewMode = "grid" | "list";
|
||||
export type SidebarPosition = "left" | "right";
|
||||
export type CodeFontSize = "small" | "medium" | "large";
|
||||
export type AnimationLevel = "full" | "reduced" | "none";
|
||||
|
||||
export interface UserPreferences {
|
||||
// Display preferences
|
||||
layoutDensity: LayoutDensity;
|
||||
listViewMode: ListViewMode;
|
||||
showDescriptions: boolean;
|
||||
showStats: boolean;
|
||||
showTags: boolean;
|
||||
|
||||
// Advanced layout options
|
||||
advancedMode: boolean;
|
||||
sidebarPosition: SidebarPosition;
|
||||
stickyHeader: boolean;
|
||||
|
||||
// Code & content preferences
|
||||
codeFontSize: CodeFontSize;
|
||||
lineNumbers: boolean;
|
||||
wordWrap: boolean;
|
||||
|
||||
// Accessibility & motion
|
||||
animationLevel: AnimationLevel;
|
||||
reducedMotion: boolean;
|
||||
highContrast: boolean;
|
||||
|
||||
// Notification preferences
|
||||
emailNotifications: boolean;
|
||||
browserNotifications: boolean;
|
||||
|
||||
// Experimental features
|
||||
experimentalFeatures: boolean;
|
||||
}
|
||||
|
||||
const defaultPreferences: UserPreferences = {
|
||||
layoutDensity: "comfortable",
|
||||
listViewMode: "grid",
|
||||
showDescriptions: true,
|
||||
showStats: true,
|
||||
showTags: true,
|
||||
|
||||
advancedMode: false,
|
||||
sidebarPosition: "right",
|
||||
stickyHeader: true,
|
||||
|
||||
codeFontSize: "medium",
|
||||
lineNumbers: true,
|
||||
wordWrap: true,
|
||||
|
||||
animationLevel: "full",
|
||||
reducedMotion: false,
|
||||
highContrast: false,
|
||||
|
||||
emailNotifications: true,
|
||||
browserNotifications: false,
|
||||
|
||||
experimentalFeatures: false,
|
||||
};
|
||||
|
||||
// Simple event emitter for cross-tab sync
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function notifyListeners() {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function getStoredPreferences(): UserPreferences {
|
||||
if (typeof window === "undefined") return defaultPreferences;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(PREFERENCES_KEY);
|
||||
if (!stored) return defaultPreferences;
|
||||
const parsed = JSON.parse(stored) as Partial<UserPreferences>;
|
||||
return { ...defaultPreferences, ...parsed };
|
||||
} catch {
|
||||
return defaultPreferences;
|
||||
}
|
||||
}
|
||||
|
||||
function savePreferences(prefs: UserPreferences) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify(prefs));
|
||||
notifyListeners();
|
||||
} catch {
|
||||
// Storage might be full or disabled
|
||||
}
|
||||
}
|
||||
|
||||
// Server snapshot for SSR
|
||||
function getServerSnapshot(): UserPreferences {
|
||||
return defaultPreferences;
|
||||
}
|
||||
|
||||
export function usePreferences() {
|
||||
const preferences = useSyncExternalStore(
|
||||
subscribe,
|
||||
getStoredPreferences,
|
||||
getServerSnapshot
|
||||
);
|
||||
|
||||
const updatePreference = useCallback(<K extends keyof UserPreferences>(
|
||||
key: K,
|
||||
value: UserPreferences[K]
|
||||
) => {
|
||||
const current = getStoredPreferences();
|
||||
const updated = { ...current, [key]: value };
|
||||
savePreferences(updated);
|
||||
}, []);
|
||||
|
||||
const updatePreferences = useCallback((updates: Partial<UserPreferences>) => {
|
||||
const current = getStoredPreferences();
|
||||
const updated = { ...current, ...updates };
|
||||
savePreferences(updated);
|
||||
}, []);
|
||||
|
||||
const resetPreferences = useCallback(() => {
|
||||
savePreferences(defaultPreferences);
|
||||
}, []);
|
||||
|
||||
// Apply preferences as CSS variables/classes
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
|
||||
// Layout density
|
||||
root.dataset.density = preferences.layoutDensity;
|
||||
|
||||
// Animation level
|
||||
root.dataset.animation = preferences.animationLevel;
|
||||
|
||||
// High contrast mode
|
||||
root.classList.toggle("high-contrast", preferences.highContrast);
|
||||
|
||||
// Reduced motion
|
||||
root.classList.toggle("reduce-motion", preferences.reducedMotion || preferences.animationLevel === "none");
|
||||
|
||||
// Code font size
|
||||
root.style.setProperty("--code-font-size",
|
||||
preferences.codeFontSize === "small" ? "12px" :
|
||||
preferences.codeFontSize === "large" ? "16px" : "14px"
|
||||
);
|
||||
}, [preferences]);
|
||||
|
||||
return {
|
||||
preferences,
|
||||
updatePreference,
|
||||
updatePreferences,
|
||||
resetPreferences,
|
||||
isAdvancedMode: preferences.advancedMode,
|
||||
};
|
||||
}
|
||||
|
||||
export { defaultPreferences };
|
||||
@@ -1,18 +1,22 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { applyTheme, getStoredTheme, useThemeMode } from "./theme";
|
||||
import { applyTheme, getStoredTheme, getStoredThemeName, getStoredThemeSelection, useThemeMode } from "./theme";
|
||||
|
||||
describe("theme", () => {
|
||||
let store: Record<string, string>;
|
||||
|
||||
function Harness() {
|
||||
const { mode, setMode } = useThemeMode();
|
||||
const { family, mode, setFamily, setMode } = useThemeMode();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="mode">{mode}</div>
|
||||
<div data-testid="family">{family}</div>
|
||||
<button type="button" onClick={() => setMode("dark")}>
|
||||
dark
|
||||
</button>
|
||||
<button type="button" onClick={() => setFamily("hub")}>
|
||||
hub
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,27 +43,43 @@ describe("theme", () => {
|
||||
afterEach(() => {
|
||||
document.documentElement.classList.remove("dark");
|
||||
delete document.documentElement.dataset.theme;
|
||||
delete document.documentElement.dataset.themeResolved;
|
||||
delete document.documentElement.dataset.themeFamily;
|
||||
delete document.documentElement.dataset.themeMode;
|
||||
window.localStorage.clear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("reads stored theme with fallback", () => {
|
||||
it("reads stored selection with legacy fallback", () => {
|
||||
expect(getStoredTheme()).toBe("system");
|
||||
expect(getStoredThemeName()).toBe("claw");
|
||||
|
||||
window.localStorage.setItem(
|
||||
"clawhub-theme-selection",
|
||||
JSON.stringify({ theme: "hub", mode: "light" }),
|
||||
);
|
||||
expect(getStoredThemeSelection()).toEqual({ theme: "hub", mode: "light" });
|
||||
|
||||
window.localStorage.clear();
|
||||
window.localStorage.setItem("clawhub-theme", "dark");
|
||||
expect(getStoredTheme()).toBe("dark");
|
||||
window.localStorage.setItem("clawhub-theme", "nope");
|
||||
expect(getStoredTheme()).toBe("system");
|
||||
window.localStorage.setItem("clawdhub-theme", "dark");
|
||||
expect(getStoredTheme()).toBe("dark");
|
||||
|
||||
window.localStorage.clear();
|
||||
window.localStorage.setItem("clawdhub-theme", "openknot");
|
||||
expect(getStoredThemeSelection()).toEqual({ theme: "claw", mode: "dark" });
|
||||
});
|
||||
|
||||
it("applies theme and toggles dark class", () => {
|
||||
applyTheme("dark");
|
||||
it("applies family and resolved mode to the document", () => {
|
||||
applyTheme("dark", "hub");
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
expect(document.documentElement.dataset.themeResolved).toBe("dark");
|
||||
expect(document.documentElement.dataset.themeFamily).toBe("hub");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
|
||||
applyTheme("light");
|
||||
applyTheme("light", "claw");
|
||||
expect(document.documentElement.dataset.theme).toBe("light");
|
||||
expect(document.documentElement.dataset.themeResolved).toBe("light");
|
||||
expect(document.documentElement.dataset.themeFamily).toBe("claw");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -69,38 +89,30 @@ describe("theme", () => {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}));
|
||||
applyTheme("system");
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
applyTheme("system", "claw");
|
||||
expect(document.documentElement.dataset.themeResolved).toBe("dark");
|
||||
});
|
||||
|
||||
it("useThemeMode persists and applies mode", async () => {
|
||||
it("useThemeMode persists family and mode", async () => {
|
||||
vi.stubGlobal("matchMedia", () => ({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}));
|
||||
|
||||
render(<Harness />);
|
||||
expect(screen.getByTestId("mode").textContent).toBe("system");
|
||||
expect(screen.getByTestId("family").textContent).toBe("claw");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "hub" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "dark" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
expect(document.documentElement.dataset.themeFamily).toBe("hub");
|
||||
expect(document.documentElement.dataset.themeResolved).toBe("dark");
|
||||
});
|
||||
|
||||
expect(window.localStorage.getItem("clawhub-theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("loads stored theme after mount without a mismatched initial render", async () => {
|
||||
window.localStorage.setItem("clawhub-theme", "dark");
|
||||
vi.stubGlobal("matchMedia", () => ({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}));
|
||||
|
||||
render(<Harness />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mode").textContent).toBe("dark");
|
||||
});
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
expect(window.localStorage.getItem("clawhub-theme-name")).toBe("hub");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,53 +1,228 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
clearStoredCustomTheme,
|
||||
getStoredCustomTheme,
|
||||
parseThemeInput,
|
||||
setStoredCustomTheme,
|
||||
syncCustomThemeFromStorage,
|
||||
type CustomThemeData,
|
||||
} from './customTheme';
|
||||
|
||||
export type ThemeMode = "system" | "light" | "dark";
|
||||
export type ThemeName = 'claw' | 'hub';
|
||||
export type ThemeMode = 'system' | 'light' | 'dark';
|
||||
export type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
const THEME_KEY = "clawhub-theme";
|
||||
const LEGACY_THEME_KEY = "clawdhub-theme";
|
||||
export type ThemeSelection = {
|
||||
theme: ThemeName;
|
||||
mode: ThemeMode;
|
||||
};
|
||||
|
||||
const THEME_SELECTION_KEY = 'clawhub-theme-selection';
|
||||
const THEME_KEY = 'clawhub-theme';
|
||||
const LEGACY_THEME_KEY = 'clawdhub-theme';
|
||||
const THEME_NAME_KEY = 'clawhub-theme-name';
|
||||
const THEME_CHANGE_EVENT = 'clawhub:themechange';
|
||||
|
||||
export const THEME_OPTIONS: Array<{ value: ThemeName; label: string; description: string }> = [
|
||||
{
|
||||
value: 'claw',
|
||||
label: 'Claw',
|
||||
description: 'OpenClaw black, white, and red.',
|
||||
},
|
||||
{
|
||||
value: 'hub',
|
||||
label: 'Hub',
|
||||
description: 'Marketplace monochrome index with terminal-style contrast.',
|
||||
},
|
||||
];
|
||||
|
||||
export const THEME_FAMILY_OPTIONS = THEME_OPTIONS;
|
||||
|
||||
const VALID_THEME_NAMES = new Set<ThemeName>(['claw', 'hub']);
|
||||
const VALID_THEME_MODES = new Set<ThemeMode>(['system', 'light', 'dark']);
|
||||
|
||||
const LEGACY_MAP: Record<string, ThemeSelection> = {
|
||||
dark: { theme: 'claw', mode: 'dark' },
|
||||
light: { theme: 'claw', mode: 'light' },
|
||||
system: { theme: 'claw', mode: 'system' },
|
||||
defaultTheme: { theme: 'claw', mode: 'dark' },
|
||||
docsTheme: { theme: 'claw', mode: 'light' },
|
||||
lightTheme: { theme: 'claw', mode: 'dark' },
|
||||
landingTheme: { theme: 'claw', mode: 'dark' },
|
||||
newTheme: { theme: 'claw', mode: 'dark' },
|
||||
openknot: { theme: 'claw', mode: 'dark' },
|
||||
fieldmanual: { theme: 'hub', mode: 'dark' },
|
||||
clawdash: { theme: 'hub', mode: 'light' },
|
||||
};
|
||||
|
||||
function parseThemeSelection(themeRaw: unknown, modeRaw: unknown): ThemeSelection {
|
||||
const theme = typeof themeRaw === 'string' ? themeRaw : '';
|
||||
const mode = typeof modeRaw === 'string' ? modeRaw : '';
|
||||
|
||||
const normalizedTheme = VALID_THEME_NAMES.has(theme as ThemeName)
|
||||
? (theme as ThemeName)
|
||||
: (LEGACY_MAP[theme]?.theme ?? 'claw');
|
||||
const normalizedMode = VALID_THEME_MODES.has(mode as ThemeMode)
|
||||
? (mode as ThemeMode)
|
||||
: (LEGACY_MAP[theme]?.mode ?? 'system');
|
||||
|
||||
return { theme: normalizedTheme, mode: normalizedMode };
|
||||
}
|
||||
|
||||
function persistThemeSelection(selection: ThemeSelection) {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(THEME_SELECTION_KEY, JSON.stringify(selection));
|
||||
window.localStorage.setItem(THEME_KEY, selection.mode);
|
||||
window.localStorage.setItem(THEME_NAME_KEY, selection.theme);
|
||||
}
|
||||
|
||||
export function getStoredThemeSelection(): ThemeSelection {
|
||||
if (typeof window === 'undefined') return { theme: 'claw', mode: 'system' };
|
||||
|
||||
try {
|
||||
const storedSelection = window.localStorage.getItem(THEME_SELECTION_KEY);
|
||||
if (storedSelection) {
|
||||
const parsed = JSON.parse(storedSelection) as Partial<ThemeSelection>;
|
||||
return parseThemeSelection(parsed.theme, parsed.mode);
|
||||
}
|
||||
} catch {
|
||||
// fall through to legacy keys
|
||||
}
|
||||
|
||||
const storedMode = window.localStorage.getItem(THEME_KEY);
|
||||
const storedTheme = window.localStorage.getItem(THEME_NAME_KEY);
|
||||
if (storedMode || storedTheme) {
|
||||
return parseThemeSelection(storedTheme, storedMode);
|
||||
}
|
||||
|
||||
const legacy = window.localStorage.getItem(LEGACY_THEME_KEY);
|
||||
if (legacy) {
|
||||
return parseThemeSelection(legacy, undefined);
|
||||
}
|
||||
|
||||
return { theme: 'claw', mode: 'system' };
|
||||
}
|
||||
|
||||
export function getStoredTheme(): ThemeMode {
|
||||
if (typeof window === "undefined") return "system";
|
||||
const stored = window.localStorage.getItem(THEME_KEY);
|
||||
if (stored === "light" || stored === "dark" || stored === "system") return stored;
|
||||
const legacy = window.localStorage.getItem(LEGACY_THEME_KEY);
|
||||
if (legacy === "light" || legacy === "dark" || legacy === "system") return legacy;
|
||||
return "system";
|
||||
return getStoredThemeSelection().mode;
|
||||
}
|
||||
|
||||
function resolveTheme(mode: ThemeMode) {
|
||||
if (mode !== "system") return mode;
|
||||
if (typeof window === "undefined") return "light";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
export function getStoredThemeName(): ThemeName {
|
||||
return getStoredThemeSelection().theme;
|
||||
}
|
||||
|
||||
export function applyTheme(mode: ThemeMode) {
|
||||
if (typeof document === "undefined") return;
|
||||
const resolved = resolveTheme(mode);
|
||||
export function getThemeFamilyLabel(theme: ThemeName): string {
|
||||
return THEME_OPTIONS.find((option) => option.value === theme)?.label ?? 'Claw';
|
||||
}
|
||||
|
||||
function resolveMode(mode: ThemeMode): ResolvedTheme {
|
||||
if (mode !== 'system') return mode;
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeName, mode: ThemeMode): ResolvedTheme {
|
||||
void theme;
|
||||
return resolveMode(mode);
|
||||
}
|
||||
|
||||
export function isDarkResolvedTheme(resolvedTheme: string | null | undefined): boolean {
|
||||
return resolvedTheme === 'dark';
|
||||
}
|
||||
|
||||
export function isDarkThemeResolved(): boolean {
|
||||
if (typeof document === 'undefined') return false;
|
||||
return isDarkResolvedTheme(document.documentElement.dataset.themeResolved);
|
||||
}
|
||||
|
||||
export function applyTheme(selectionOrMode: ThemeSelection | ThemeMode, theme: ThemeName = 'claw') {
|
||||
const selection = typeof selectionOrMode === 'string' ? { theme, mode: selectionOrMode } : selectionOrMode;
|
||||
applyThemeSelection(selection);
|
||||
}
|
||||
|
||||
export function applyThemeSelection(selection: ThemeSelection) {
|
||||
if (typeof document === 'undefined') return;
|
||||
const resolved = resolveTheme(selection.theme, selection.mode);
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
document.documentElement.classList.toggle("dark", resolved === "dark");
|
||||
document.documentElement.dataset.themeResolved = resolved;
|
||||
document.documentElement.dataset.themeMode = selection.mode;
|
||||
document.documentElement.dataset.themeFamily = selection.theme;
|
||||
document.documentElement.classList.toggle('dark', isDarkResolvedTheme(resolved));
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT));
|
||||
}
|
||||
}
|
||||
|
||||
export function onThemeChange(handler: () => void) {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
window.addEventListener(THEME_CHANGE_EVENT, handler);
|
||||
return () => window.removeEventListener(THEME_CHANGE_EVENT, handler);
|
||||
}
|
||||
|
||||
export function useThemeMode() {
|
||||
const [mode, setMode] = useState<ThemeMode>("system");
|
||||
const [selection, setSelection] = useState<ThemeSelection>({ theme: 'claw', mode: 'system' });
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
const [customTheme, setCustomTheme] = useState<CustomThemeData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMode(getStoredTheme());
|
||||
setSelection(getStoredThemeSelection());
|
||||
setCustomTheme(getStoredCustomTheme());
|
||||
setIsHydrated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHydrated) return;
|
||||
applyTheme(mode);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(THEME_KEY, mode);
|
||||
}
|
||||
if (mode !== "system" || typeof window === "undefined") return;
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = () => applyTheme(mode);
|
||||
media.addEventListener("change", handler);
|
||||
return () => media.removeEventListener("change", handler);
|
||||
}, [isHydrated, mode]);
|
||||
applyThemeSelection(selection);
|
||||
persistThemeSelection(selection);
|
||||
syncCustomThemeFromStorage();
|
||||
|
||||
return { mode, setMode };
|
||||
if (selection.mode !== 'system' || typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = () => {
|
||||
applyThemeSelection(selection);
|
||||
syncCustomThemeFromStorage();
|
||||
};
|
||||
|
||||
if (typeof media.addEventListener === 'function') {
|
||||
media.addEventListener('change', handler);
|
||||
return () => media.removeEventListener('change', handler);
|
||||
}
|
||||
|
||||
media.addListener(handler);
|
||||
return () => media.removeListener(handler);
|
||||
}, [isHydrated, selection]);
|
||||
|
||||
const importCustomTheme = async (input: string) => {
|
||||
const parsed = await parseThemeInput(input);
|
||||
const theme = {
|
||||
...parsed,
|
||||
source: input.trim(),
|
||||
};
|
||||
setStoredCustomTheme(theme);
|
||||
setCustomTheme(theme);
|
||||
syncCustomThemeFromStorage();
|
||||
return theme;
|
||||
};
|
||||
|
||||
const clearCustomTheme = () => {
|
||||
clearStoredCustomTheme();
|
||||
setCustomTheme(null);
|
||||
syncCustomThemeFromStorage();
|
||||
};
|
||||
|
||||
return {
|
||||
theme: selection.theme,
|
||||
family: selection.theme,
|
||||
mode: selection.mode,
|
||||
selection,
|
||||
customTheme,
|
||||
setTheme: (theme: ThemeName) => setSelection((current) => ({ ...current, theme })),
|
||||
setFamily: (theme: ThemeName) => setSelection((current) => ({ ...current, theme })),
|
||||
setMode: (mode: ThemeMode) => setSelection((current) => ({ ...current, mode })),
|
||||
importCustomTheme,
|
||||
clearCustomTheme,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
const MINUTE = 60_000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
const WEEK = 7 * DAY;
|
||||
const MONTH = 30 * DAY;
|
||||
const YEAR = 365 * DAY;
|
||||
|
||||
export function timeAgo(timestamp: number): string {
|
||||
const diff = Date.now() - timestamp;
|
||||
if (diff < MINUTE) return "just now";
|
||||
if (diff < HOUR) {
|
||||
const m = Math.floor(diff / MINUTE);
|
||||
return `${m}m ago`;
|
||||
}
|
||||
if (diff < DAY) {
|
||||
const h = Math.floor(diff / HOUR);
|
||||
return `${h}h ago`;
|
||||
}
|
||||
if (diff < WEEK) {
|
||||
const d = Math.floor(diff / DAY);
|
||||
return `${d}d ago`;
|
||||
}
|
||||
if (diff < MONTH) {
|
||||
const w = Math.floor(diff / WEEK);
|
||||
return `${w}w ago`;
|
||||
}
|
||||
if (diff < YEAR) {
|
||||
const m = Math.floor(diff / MONTH);
|
||||
return `${m}mo ago`;
|
||||
}
|
||||
const y = Math.floor(diff / YEAR);
|
||||
return `${y}y ago`;
|
||||
}
|
||||
@@ -1,33 +1,38 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
// Tiny external store for auth errors raised during sign-in.
|
||||
// Syncs across components in this tab.
|
||||
let authError: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emitChange() {
|
||||
for (const listener of listeners) listener();
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function getAuthErrorSnapshot() {
|
||||
return authError;
|
||||
return authError;
|
||||
}
|
||||
|
||||
export function setAuthError(error: string | null) {
|
||||
if (authError === error) return;
|
||||
authError = error;
|
||||
emitChange();
|
||||
if (authError === error) return;
|
||||
authError = error;
|
||||
emitChange();
|
||||
}
|
||||
|
||||
export function clearAuthError() {
|
||||
setAuthError(null);
|
||||
setAuthError(null);
|
||||
}
|
||||
|
||||
export function useAuthError() {
|
||||
const error = useSyncExternalStore(subscribe, getAuthErrorSnapshot, getAuthErrorSnapshot);
|
||||
return { error, clear: clearAuthError };
|
||||
const error = useSyncExternalStore(
|
||||
subscribe,
|
||||
getAuthErrorSnapshot,
|
||||
getAuthErrorSnapshot,
|
||||
);
|
||||
return { error, clear: clearAuthError };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useAction } from "convex/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { fetchPluginCatalog, type PackageListItem } from "./packageApi";
|
||||
import type { PublicUser } from "./publicUser";
|
||||
|
||||
export type UnifiedSkillResult = {
|
||||
type: "skill";
|
||||
skill: {
|
||||
_id: string;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
summary?: string | null;
|
||||
ownerUserId: string;
|
||||
ownerPublisherId?: string | null;
|
||||
stats: { downloads: number; stars: number; versions?: number };
|
||||
updatedAt: number;
|
||||
createdAt: number;
|
||||
};
|
||||
ownerHandle: string | null;
|
||||
score: number;
|
||||
};
|
||||
|
||||
export type UnifiedPluginResult = {
|
||||
type: "plugin";
|
||||
plugin: PackageListItem;
|
||||
};
|
||||
|
||||
export type UnifiedUserResult = {
|
||||
type: "user";
|
||||
user: PublicUser;
|
||||
};
|
||||
|
||||
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult | UnifiedUserResult;
|
||||
|
||||
export function useUnifiedSearch(
|
||||
query: string,
|
||||
activeType: "all" | "skills" | "plugins" | "users",
|
||||
) {
|
||||
const searchSkills = useAction(api.search.searchSkills);
|
||||
const [results, setResults] = useState<UnifiedResult[]>([]);
|
||||
const [skillCount, setSkillCount] = useState(0);
|
||||
const [pluginCount, setPluginCount] = useState(0);
|
||||
const [userCount, setUserCount] = useState(0);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const requestRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
requestRef.current += 1;
|
||||
setResults([]);
|
||||
setSkillCount(0);
|
||||
setPluginCount(0);
|
||||
setUserCount(0);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
requestRef.current += 1;
|
||||
const requestId = requestRef.current;
|
||||
setIsSearching(true);
|
||||
|
||||
const handle = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const promises: [
|
||||
Promise<unknown> | null,
|
||||
Promise<{ items: PackageListItem[] }> | null,
|
||||
Promise<{ items: PublicUser[] }> | null,
|
||||
] = [null, null, null];
|
||||
|
||||
if (activeType === "all" || activeType === "skills") {
|
||||
promises[0] = searchSkills({
|
||||
query: trimmed,
|
||||
limit: 25,
|
||||
nonSuspiciousOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeType === "all" || activeType === "plugins") {
|
||||
promises[1] = fetchPluginCatalog({ q: trimmed, limit: 25 });
|
||||
}
|
||||
|
||||
if (activeType === "all" || activeType === "users") {
|
||||
promises[2] = convexHttp.query(api.users.listPublic, { search: trimmed, limit: 25 });
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
promises.map((p) => p ?? Promise.resolve(null)),
|
||||
);
|
||||
|
||||
if (requestId !== requestRef.current) return;
|
||||
|
||||
const skillsRaw = settled[0].status === "fulfilled" ? settled[0].value : null;
|
||||
const pluginsRaw = settled[1].status === "fulfilled" ? settled[1].value : null;
|
||||
const usersRaw = settled[2].status === "fulfilled" ? settled[2].value : null;
|
||||
|
||||
const skillResults: UnifiedSkillResult[] = (
|
||||
(skillsRaw as Array<{ skill: UnifiedSkillResult["skill"]; ownerHandle: string | null; score: number }>) ?? []
|
||||
).map((entry) => ({
|
||||
type: "skill" as const,
|
||||
skill: entry.skill,
|
||||
ownerHandle: entry.ownerHandle,
|
||||
score: entry.score,
|
||||
}));
|
||||
|
||||
const pluginResults: UnifiedPluginResult[] = (
|
||||
(pluginsRaw as { items: PackageListItem[] })?.items ?? []
|
||||
).map((item) => ({
|
||||
type: "plugin" as const,
|
||||
plugin: item,
|
||||
}));
|
||||
|
||||
setSkillCount(skillResults.length);
|
||||
setPluginCount(pluginResults.length);
|
||||
const userResults: UnifiedUserResult[] = (
|
||||
(usersRaw as { items: PublicUser[] })?.items ?? []
|
||||
).map((user) => ({
|
||||
type: "user" as const,
|
||||
user,
|
||||
}));
|
||||
setUserCount(userResults.length);
|
||||
|
||||
const merged: UnifiedResult[] = [];
|
||||
if (activeType === "all") {
|
||||
merged.push(...skillResults, ...pluginResults, ...userResults);
|
||||
} else if (activeType === "skills") {
|
||||
merged.push(...skillResults);
|
||||
} else if (activeType === "plugins") {
|
||||
merged.push(...pluginResults);
|
||||
} else {
|
||||
merged.push(...userResults);
|
||||
}
|
||||
|
||||
setResults(merged);
|
||||
} catch (error) {
|
||||
console.error("Unified search failed:", error);
|
||||
if (requestId === requestRef.current) {
|
||||
setResults([]);
|
||||
setSkillCount(0);
|
||||
setPluginCount(0);
|
||||
setUserCount(0);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestRef.current) {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, 300);
|
||||
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [query, activeType, searchSkills]);
|
||||
|
||||
return { results, skillCount, pluginCount, userCount, isSearching };
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
import { Route as AdminRouteImport } from './routes/admin'
|
||||
import { Route as AboutRouteImport } from './routes/about'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as UsersIndexRouteImport } from './routes/users/index'
|
||||
import { Route as SoulsIndexRouteImport } from './routes/souls/index'
|
||||
import { Route as SkillsIndexRouteImport } from './routes/skills/index'
|
||||
import { Route as PluginsIndexRouteImport } from './routes/plugins/index'
|
||||
@@ -95,6 +96,11 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const UsersIndexRoute = UsersIndexRouteImport.update({
|
||||
id: '/users/',
|
||||
path: '/users/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SoulsIndexRoute = SoulsIndexRouteImport.update({
|
||||
id: '/souls/',
|
||||
path: '/souls/',
|
||||
@@ -187,6 +193,7 @@ export interface FileRoutesByFullPath {
|
||||
'/plugins/': typeof PluginsIndexRoute
|
||||
'/skills/': typeof SkillsIndexRoute
|
||||
'/souls/': typeof SoulsIndexRoute
|
||||
'/users/': typeof UsersIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
@@ -214,6 +221,7 @@ export interface FileRoutesByTo {
|
||||
'/plugins': typeof PluginsIndexRoute
|
||||
'/skills': typeof SkillsIndexRoute
|
||||
'/souls': typeof SoulsIndexRoute
|
||||
'/users': typeof UsersIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -242,6 +250,7 @@ export interface FileRoutesById {
|
||||
'/plugins/': typeof PluginsIndexRoute
|
||||
'/skills/': typeof SkillsIndexRoute
|
||||
'/souls/': typeof SoulsIndexRoute
|
||||
'/users/': typeof UsersIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -271,6 +280,7 @@ export interface FileRouteTypes {
|
||||
| '/plugins/'
|
||||
| '/skills/'
|
||||
| '/souls/'
|
||||
| '/users/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
@@ -298,6 +308,7 @@ export interface FileRouteTypes {
|
||||
| '/plugins'
|
||||
| '/skills'
|
||||
| '/souls'
|
||||
| '/users'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
@@ -325,6 +336,7 @@ export interface FileRouteTypes {
|
||||
| '/plugins/'
|
||||
| '/skills/'
|
||||
| '/souls/'
|
||||
| '/users/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -353,6 +365,7 @@ export interface RootRouteChildren {
|
||||
PluginsIndexRoute: typeof PluginsIndexRoute
|
||||
SkillsIndexRoute: typeof SkillsIndexRoute
|
||||
SoulsIndexRoute: typeof SoulsIndexRoute
|
||||
UsersIndexRoute: typeof UsersIndexRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -441,6 +454,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/users/': {
|
||||
id: '/users/'
|
||||
path: '/users'
|
||||
fullPath: '/users/'
|
||||
preLoaderRoute: typeof UsersIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/souls/': {
|
||||
id: '/souls/'
|
||||
path: '/souls'
|
||||
@@ -561,6 +581,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
PluginsIndexRoute: PluginsIndexRoute,
|
||||
SkillsIndexRoute: SkillsIndexRoute,
|
||||
SoulsIndexRoute: SoulsIndexRoute,
|
||||
UsersIndexRoute: UsersIndexRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
|
||||
import { SkillDetailPage } from "../../components/SkillDetailPage";
|
||||
import { buildSkillMeta } from "../../lib/og";
|
||||
import { fetchSkillPageData } from "../../lib/skillPage";
|
||||
|
||||
export const Route = createFileRoute("/$owner/$slug")({
|
||||
beforeLoad: ({ params }) => {
|
||||
const isHandle = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(params.owner);
|
||||
const isOwnerId = params.owner.startsWith("users:") || params.owner.startsWith("publishers:");
|
||||
if (!isHandle && !isOwnerId) {
|
||||
throw notFound();
|
||||
}
|
||||
},
|
||||
loader: async ({ params }) => {
|
||||
const data = await fetchSkillPageData(params.slug);
|
||||
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
|
||||
|
||||
@@ -92,6 +92,24 @@ export const Route = createRootRoute({
|
||||
rel: "stylesheet",
|
||||
href: appCss,
|
||||
},
|
||||
{
|
||||
rel: "icon",
|
||||
href: "/favicon.ico",
|
||||
type: "image/x-icon",
|
||||
},
|
||||
{
|
||||
rel: "icon",
|
||||
href: "/logo.svg",
|
||||
type: "image/svg+xml",
|
||||
},
|
||||
{
|
||||
rel: "apple-touch-icon",
|
||||
href: "/logo192.png",
|
||||
},
|
||||
{
|
||||
rel: "manifest",
|
||||
href: "/manifest.json",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,69 +1,65 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { AlertTriangle, Shield, ShieldAlert } from "lucide-react";
|
||||
import { Container } from "../components/layout/Container";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { Separator } from "../components/ui/separator";
|
||||
import { getSiteMode, getSiteName, getSiteUrlForMode } from "../lib/site";
|
||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { getSiteMode, getSiteName, getSiteUrlForMode } from '../lib/site';
|
||||
|
||||
const prohibitedCategories = [
|
||||
{
|
||||
title: "Bypass and unauthorized access",
|
||||
title: 'Bypass and unauthorized access',
|
||||
examples:
|
||||
"Auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, reusable session theft, live call or agent takeover.",
|
||||
'Auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, reusable session theft, live call or agent takeover.',
|
||||
},
|
||||
{
|
||||
title: "Platform abuse and ban evasion",
|
||||
title: 'Platform abuse and ban evasion',
|
||||
examples:
|
||||
"Stealth accounts after bans, account warming/farming, fake engagement, multi-account automation, spam posting, marketplace or social automation built to avoid detection.",
|
||||
'Stealth accounts after bans, account warming/farming, fake engagement, multi-account automation, spam posting, marketplace or social automation built to avoid detection.',
|
||||
},
|
||||
{
|
||||
title: "Fraud and deception",
|
||||
title: 'Fraud and deception',
|
||||
examples:
|
||||
"Fake certificates, fake invoices, deceptive payment flows, fake social proof, scam outreach, or synthetic-identity workflows built to create accounts for fraud.",
|
||||
'Fake certificates, fake invoices, deceptive payment flows, fake social proof, scam outreach, or synthetic-identity workflows built to create accounts for fraud.',
|
||||
},
|
||||
{
|
||||
title: "Privacy-invasive surveillance",
|
||||
title: 'Privacy-invasive surveillance',
|
||||
examples:
|
||||
"Mass contact scraping for spam, doxxing, stalking, covert monitoring, biometric / face-matching workflows without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.",
|
||||
'Mass contact scraping for spam, doxxing, stalking, covert monitoring, biometric / face-matching workflows without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.',
|
||||
},
|
||||
{
|
||||
title: "Non-consensual impersonation",
|
||||
title: 'Non-consensual impersonation',
|
||||
examples:
|
||||
"Face swap, digital twins, cloned influencers, fake personas, or other identity manipulation used to impersonate or mislead.",
|
||||
'Face swap, digital twins, cloned influencers, fake personas, or other identity manipulation used to impersonate or mislead.',
|
||||
},
|
||||
{
|
||||
title: "Explicit sexual content",
|
||||
title: 'Explicit sexual content',
|
||||
examples:
|
||||
"NSFW image, video, or text generation, especially wrappers around third-party APIs with safety checks disabled.",
|
||||
'NSFW image, video, or text generation, especially wrappers around third-party APIs with safety checks disabled.',
|
||||
},
|
||||
{
|
||||
title: "Hidden or misleading execution",
|
||||
title: 'Hidden or misleading execution',
|
||||
examples:
|
||||
"Obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, or remote `npx @latest` execution without reviewability.",
|
||||
'Obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, or remote `npx @latest` execution without reviewability.',
|
||||
},
|
||||
];
|
||||
|
||||
const recentPatterns = [
|
||||
"Create stealth seller accounts after marketplace bans.",
|
||||
"Modify Telegram pairing so unapproved users automatically receive pairing codes.",
|
||||
"Cultivate Reddit or Twitter accounts with undetectable automation.",
|
||||
"Generate professional certificates or invoices for arbitrary use.",
|
||||
"Generate NSFW content with safety checks disabled.",
|
||||
"Scrape leads, enrich contacts, and launch cold outreach at scale.",
|
||||
"Buy, publish, or download leaked data or breach dumps.",
|
||||
"Bulk-create email or social accounts with synthetic identities or CAPTCHA solving.",
|
||||
'Create stealth seller accounts after marketplace bans.',
|
||||
'Modify Telegram pairing so unapproved users automatically receive pairing codes.',
|
||||
'Cultivate Reddit or Twitter accounts with undetectable automation.',
|
||||
'Generate professional certificates or invoices for arbitrary use.',
|
||||
'Generate NSFW content with safety checks disabled.',
|
||||
'Scrape leads, enrich contacts, and launch cold outreach at scale.',
|
||||
'Buy, publish, or download leaked data or breach dumps.',
|
||||
'Bulk-create email or social accounts with synthetic identities or CAPTCHA solving.',
|
||||
];
|
||||
|
||||
export const Route = createFileRoute("/about")({
|
||||
export const Route = createFileRoute('/about')({
|
||||
head: () => {
|
||||
const mode = getSiteMode();
|
||||
const siteName = getSiteName(mode);
|
||||
const siteUrl = getSiteUrlForMode(mode);
|
||||
const title = `About · ${siteName}`;
|
||||
const description =
|
||||
"What ClawHub allows, what we do not host, and the abuse patterns that lead to removal or account bans.";
|
||||
'What ClawHub allows, what we do not host, and the abuse patterns that lead to removal or account bans.';
|
||||
|
||||
return {
|
||||
links: [
|
||||
@@ -74,11 +70,11 @@ export const Route = createFileRoute("/about")({
|
||||
],
|
||||
meta: [
|
||||
{ title },
|
||||
{ name: "description", content: description },
|
||||
{ property: "og:title", content: title },
|
||||
{ property: "og:description", content: description },
|
||||
{ property: "og:type", content: "website" },
|
||||
{ property: "og:url", content: `${siteUrl}/about` },
|
||||
{ name: 'description', content: description },
|
||||
{ property: 'og:title', content: title },
|
||||
{ property: 'og:description', content: description },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
{ property: 'og:url', content: `${siteUrl}/about` },
|
||||
],
|
||||
};
|
||||
},
|
||||
@@ -87,126 +83,105 @@ export const Route = createFileRoute("/about")({
|
||||
|
||||
function AboutPage() {
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container size="wide">
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-4 pt-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge>About</Badge>
|
||||
<Badge variant="accent">Policy</Badge>
|
||||
</div>
|
||||
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)]">
|
||||
What ClawHub Will Not Host
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-[color:var(--ink-soft)]">
|
||||
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to
|
||||
evade defenses, abuse platforms, scam people, invade privacy, or enable
|
||||
non-consensual behavior, it does not belong here.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 rounded-[var(--radius-sm)] bg-[color:var(--surface-muted)] px-4 py-3 text-sm font-medium text-[color:var(--ink-soft)]">
|
||||
<Shield className="h-4 w-4 shrink-0 text-[color:var(--accent)]" />
|
||||
We moderate based on end-to-end abuse patterns, not just isolated keywords.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<main className="section about-page">
|
||||
<div className="about-bento">
|
||||
<section className="about-panel about-panel-hero">
|
||||
<div className="about-hero-copy">
|
||||
<div className="skill-card-tags mb-3">
|
||||
<Badge>About</Badge>
|
||||
<Badge variant="accent">Policy</Badge>
|
||||
</div>
|
||||
<h1 className="about-title">What ClawHub will not host</h1>
|
||||
<p className="about-lead">
|
||||
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to
|
||||
evade defenses, scam people, invade privacy, or enable non-consensual behavior, it
|
||||
does not belong here.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-4">
|
||||
{prohibitedCategories.map((category) => (
|
||||
<Card key={category.title}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldAlert className="h-4 w-4 shrink-0 text-[color:var(--gold)]" />
|
||||
{category.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm leading-relaxed text-[color:var(--ink-soft)]">
|
||||
{category.examples}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<aside className="about-panel about-panel-callout">
|
||||
<div className="about-callout">
|
||||
<span className="about-callout-label">Moderation stance</span>
|
||||
<p>
|
||||
We judge end-to-end abuse patterns, not keyword theater. Useful tooling stays.
|
||||
Predatory workflows get removed.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="about-panel about-panel-categories">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Immediate rejection categories</h2>
|
||||
</div>
|
||||
<div className="about-grid">
|
||||
{prohibitedCategories.map((category, index) => (
|
||||
<article
|
||||
key={category.title}
|
||||
className={`about-rule-card${index % 3 === 0 ? ' about-rule-card-featured' : ''}`}
|
||||
>
|
||||
<h2>{category.title}</h2>
|
||||
<p>{category.examples}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 text-[color:var(--gold)]" />
|
||||
Recent patterns we are explicitly not okay with
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{recentPatterns.map((pattern) => (
|
||||
<li
|
||||
key={pattern}
|
||||
className="flex items-start gap-2 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] px-4 py-3 text-sm text-[color:var(--ink-soft)]"
|
||||
>
|
||||
<span className="mt-0.5 shrink-0 text-[color:var(--gold)]" aria-hidden="true">
|
||||
-
|
||||
</span>
|
||||
{pattern}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Enforcement</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<ul className="flex flex-col gap-2">
|
||||
<li className="flex items-start gap-2 text-sm text-[color:var(--ink-soft)]">
|
||||
<span className="mt-0.5 shrink-0 text-[color:var(--accent)]" aria-hidden="true">
|
||||
1.
|
||||
</span>
|
||||
We may hide, remove, or hard-delete violating skills.
|
||||
</li>
|
||||
<li className="flex items-start gap-2 text-sm text-[color:var(--ink-soft)]">
|
||||
<span className="mt-0.5 shrink-0 text-[color:var(--accent)]" aria-hidden="true">
|
||||
2.
|
||||
</span>
|
||||
We may revoke tokens, soft-delete associated content, and ban repeat or severe
|
||||
offenders.
|
||||
</li>
|
||||
<li className="flex items-start gap-2 text-sm text-[color:var(--ink-soft)]">
|
||||
<span className="mt-0.5 shrink-0 text-[color:var(--accent)]" aria-hidden="true">
|
||||
3.
|
||||
</span>
|
||||
We do not guarantee warning-first enforcement for obvious abuse.
|
||||
</li>
|
||||
</ul>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
<Button variant="primary">Browse Skills</Button>
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button variant="outline">Reviewer Doc</Button>
|
||||
</a>
|
||||
<section className="about-panel about-panel-patterns">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Recent patterns we are explicitly not okay with</h2>
|
||||
</div>
|
||||
<div className="about-patterns">
|
||||
{recentPatterns.map((pattern) => (
|
||||
<div key={pattern} className="about-pattern">
|
||||
{pattern}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Container>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-panel about-panel-enforcement">
|
||||
<div>
|
||||
<span className="about-callout-label">Enforcement</span>
|
||||
<div className="management-sublist">
|
||||
<div className="management-subitem">
|
||||
We may hide, remove, or hard-delete violating skills.
|
||||
</div>
|
||||
<div className="management-subitem">
|
||||
We may revoke tokens, soft-delete associated content, and ban repeat or severe
|
||||
offenders.
|
||||
</div>
|
||||
<div className="management-subitem">
|
||||
We do not guarantee warning-first enforcement for obvious abuse.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-panel about-panel-actions">
|
||||
<span className="about-callout-label">Next steps</span>
|
||||
<p className="about-panel-copy">
|
||||
If you are reviewing a borderline workflow, use the reviewer doc. If you are browsing,
|
||||
stay in the public catalog.
|
||||
</p>
|
||||
<div className="skill-card-tags">
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/skills">
|
||||
Browse Skills
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<a
|
||||
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Reviewer Doc
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,12 +16,9 @@ import { useEffect, useState } from "react";
|
||||
import semver from "semver";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc } from "../../convex/_generated/dataModel";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Container } from "../components/layout/Container";
|
||||
import { SignInButton } from "../components/SignInButton";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent } from "../components/ui/card";
|
||||
import { Card } from "../components/ui/card";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import { familyLabel } from "../lib/packageLabels";
|
||||
import type { PublicSkill } from "../lib/publicUser";
|
||||
@@ -110,8 +107,7 @@ function Dashboard() {
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPublisherId) return;
|
||||
const personal =
|
||||
publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
|
||||
const personal = publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
|
||||
if (personal?.publisher._id) {
|
||||
setSelectedPublisherId(personal.publisher._id);
|
||||
}
|
||||
@@ -119,38 +115,72 @@ function Dashboard() {
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
<Container className="py-10">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-start gap-3">
|
||||
<span>Sign in to access your dashboard.</span>
|
||||
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
<main className="section">
|
||||
<Card>Sign in to access your dashboard.</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const skills = mySkills ?? [];
|
||||
const packages = myPackages ?? [];
|
||||
const isLoading = mySkills === undefined;
|
||||
const ownerHandle =
|
||||
selectedPublisher?.publisher.handle ?? me.handle ?? me.name ?? me.displayName ?? me._id;
|
||||
|
||||
// Welcome state for new users with no content
|
||||
if (!isLoading && skills.length === 0 && packages.length === 0) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="empty-state">
|
||||
<h1 className="empty-state-title text-[1.4rem] font-[family-name:var(--font-display)]">
|
||||
Welcome to ClawHub
|
||||
</h1>
|
||||
<p className="empty-state-body">
|
||||
You're signed in as @{ownerHandle}. Get started by publishing your first skill or plugin.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
Publish a Skill
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Browse Skills
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-10">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="grid gap-1.5">
|
||||
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)]">
|
||||
<main className="section">
|
||||
<div className="dashboard-header">
|
||||
<div>
|
||||
<h1 className="section-title m-0">
|
||||
Publisher Dashboard
|
||||
</h1>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
Owner-only view for skills and plugins, including security scans and verification.
|
||||
<p className="section-subtitle m-0">
|
||||
Manage your published skills and plugins.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{publishers && publishers.length > 0 ? (
|
||||
<select
|
||||
className="min-h-[44px] rounded-[var(--radius-pill)] border border-[color:var(--line)] bg-[color:var(--surface)] px-4 py-[11px] text-sm text-[color:var(--ink)] transition-all duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35"
|
||||
className="input"
|
||||
value={selectedPublisherId}
|
||||
onChange={(event) => setSelectedPublisherId(event.target.value)}
|
||||
>
|
||||
@@ -168,7 +198,10 @@ function Dashboard() {
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/publish-plugin" search={{ ...emptyPluginPublishSearch, ownerHandle }}>
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{ ...emptyPluginPublishSearch, ownerHandle }}
|
||||
>
|
||||
<Plug className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Plugin
|
||||
</Link>
|
||||
@@ -176,109 +209,101 @@ function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Owner panel */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="grid gap-10">
|
||||
{/* Skills section */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<Card className="dashboard-owner-panel">
|
||||
<div className="dashboard-owner-grid">
|
||||
<section className="dashboard-collection-block">
|
||||
<div className="dashboard-section-header">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
Publisher Skills
|
||||
</h2>
|
||||
<p className="mt-1.5 text-sm text-[color:var(--ink-soft)]">
|
||||
<h2 className="dashboard-collection-title">Publisher Skills</h2>
|
||||
<p className="section-subtitle mt-1.5 mb-0 mx-0">
|
||||
Hidden skill versions remain visible here while checks are pending.
|
||||
</p>
|
||||
</div>
|
||||
{skills.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Upload}
|
||||
title="No skills yet."
|
||||
description="Publish your first skill to share it with the community."
|
||||
>
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Skill
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<div className="hidden grid-cols-[2fr_2fr_1.5fr_auto] gap-4 border-b border-[color:var(--line)] px-4 pb-2 text-xs font-semibold uppercase tracking-wider text-[color:var(--ink-soft)] md:grid">
|
||||
<span>Skill</span>
|
||||
<span>Summary</span>
|
||||
<span>Status</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{skills.map((skill) => (
|
||||
<SkillRow key={skill._id} skill={skill} ownerHandle={ownerHandle} />
|
||||
))}
|
||||
</div>
|
||||
{skills.length === 0 ? (
|
||||
<div className="dashboard-inline-empty">
|
||||
<div className="dashboard-inline-empty-copy">
|
||||
<strong>No skills yet.</strong> Publish your first skill to share it with the community.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Skill
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-list">
|
||||
<div className="dashboard-list-header">
|
||||
<span>Skill</span>
|
||||
<span>Summary</span>
|
||||
<span>Status</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{skills.map((skill) => (
|
||||
<SkillRow key={skill._id} skill={skill} ownerHandle={ownerHandle} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Plugins section */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<section className="dashboard-collection-block">
|
||||
<div className="dashboard-section-header">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
Publisher Plugins
|
||||
</h2>
|
||||
<p className="mt-1.5 text-sm text-[color:var(--ink-soft)]">
|
||||
<h2 className="dashboard-collection-title">Publisher Plugins</h2>
|
||||
<p className="section-subtitle mt-1.5 mb-0 mx-0">
|
||||
Owner-only package view with VirusTotal, static scan, and verification state.
|
||||
</p>
|
||||
</div>
|
||||
{packages.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Plug}
|
||||
title="No plugins yet."
|
||||
description="Publish your first plugin release to validate and distribute it."
|
||||
>
|
||||
<Button asChild variant="primary">
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{ ...emptyPluginPublishSearch, ownerHandle }}
|
||||
>
|
||||
<Plug className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<div className="hidden grid-cols-[2fr_2fr_1.5fr_auto] gap-4 border-b border-[color:var(--line)] px-4 pb-2 text-xs font-semibold uppercase tracking-wider text-[color:var(--ink-soft)] md:grid">
|
||||
<span>Plugin</span>
|
||||
<span>Summary</span>
|
||||
<span>Status</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{packages.map((pkg) => (
|
||||
<PackageRow key={pkg._id} pkg={pkg} ownerHandle={ownerHandle} />
|
||||
))}
|
||||
</div>
|
||||
{packages.length === 0 ? (
|
||||
<div className="dashboard-inline-empty">
|
||||
<div className="dashboard-inline-empty-copy">
|
||||
<strong>No plugins yet.</strong> Publish your first plugin release to validate and distribute it.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</CardContent>
|
||||
<Button asChild variant="primary">
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{ ...emptyPluginPublishSearch, ownerHandle }}
|
||||
>
|
||||
<Plug className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-list">
|
||||
<div className="dashboard-list-header">
|
||||
<span>Plugin</span>
|
||||
<span>Summary</span>
|
||||
<span>Status</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{packages.map((pkg) => (
|
||||
<PackageRow key={pkg._id} pkg={pkg} ownerHandle={ownerHandle} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle: string | null }) {
|
||||
return (
|
||||
<div className="grid items-start gap-4 border-b border-[color:var(--line)] px-4 py-4 last:border-b-0 md:grid-cols-[2fr_2fr_1.5fr_auto]">
|
||||
{/* Primary info */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="dashboard-list-row">
|
||||
<div className="dashboard-list-primary">
|
||||
<div className="dashboard-list-title">
|
||||
<Link
|
||||
to="/$owner/$slug"
|
||||
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
|
||||
className="font-display text-sm font-bold text-[color:var(--ink)] hover:text-[color:var(--accent)]"
|
||||
className="dashboard-skill-name"
|
||||
>
|
||||
{skill.displayName}
|
||||
</Link>
|
||||
<span className="font-mono text-xs text-[color:var(--ink-soft)]">/{skill.slug}</span>
|
||||
<span className="dashboard-list-id">/{skill.slug}</span>
|
||||
{skill.pendingReview ? (
|
||||
<Badge variant="pending">
|
||||
<Clock className="h-3 w-3" aria-hidden="true" />
|
||||
@@ -286,44 +311,35 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[color:var(--ink-soft)]">
|
||||
<span className="inline-flex w-14 items-center justify-end gap-1 tabular-nums">
|
||||
<ArrowDownToLine size={13} aria-hidden="true" />{" "}
|
||||
{formatCompactStat(skill.stats.downloads)}
|
||||
<div className="dashboard-inline-metrics">
|
||||
<span>
|
||||
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
|
||||
</span>
|
||||
<span className="inline-flex w-14 items-center justify-end gap-1 tabular-nums">
|
||||
<span>
|
||||
<Star size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
|
||||
</span>
|
||||
<span className="inline-flex w-14 items-center justify-end gap-1 tabular-nums">
|
||||
<span>
|
||||
<Package size={13} aria-hidden="true" /> {skill.stats.versions}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
{skill.summary ?? "No summary provided."}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex flex-col gap-1 text-xs text-[color:var(--ink-soft)]">
|
||||
<div className="dashboard-list-summary">{skill.summary ?? "No summary provided."}</div>
|
||||
<div className="dashboard-list-status">
|
||||
{skill.pendingReview ? (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="dashboard-inline-status-item">
|
||||
<ShieldCheck size={13} aria-hidden="true" />
|
||||
VT pending
|
||||
</span>
|
||||
<span className="text-[color:var(--ink-soft)]">
|
||||
<span className="dashboard-inline-status-note">
|
||||
Hidden until verification checks finish.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Visible</span>
|
||||
<span className="dashboard-inline-status-note">Visible</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="dashboard-row-actions">
|
||||
<Button asChild size="sm">
|
||||
<Link to="/publish-skill" search={{ updateSlug: skill.slug }}>
|
||||
<Upload className="h-3 w-3" aria-hidden="true" />
|
||||
@@ -331,7 +347,10 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/$owner/$slug" params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}>
|
||||
<Link
|
||||
to="/$owner/$slug"
|
||||
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -388,9 +407,7 @@ function PackageStatusTag({
|
||||
function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle: string }) {
|
||||
const scanLabel = scanStatusLabel(pkg.scanStatus);
|
||||
const nextVersion = pkg.latestVersion ? semver.inc(pkg.latestVersion, "patch") : null;
|
||||
const sourceLabel = pkg.sourceRepo
|
||||
?.replace(/^https?:\/\/github\.com\//, "")
|
||||
.replace(/\.git$/, "");
|
||||
const sourceLabel = pkg.sourceRepo?.replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "");
|
||||
const scanTone =
|
||||
pkg.scanStatus === "pending"
|
||||
? "pending"
|
||||
@@ -411,20 +428,15 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
|
||||
: "default";
|
||||
|
||||
return (
|
||||
<div className="grid items-start gap-4 border-b border-[color:var(--line)] px-4 py-4 last:border-b-0 md:grid-cols-[2fr_2fr_1.5fr_auto]">
|
||||
{/* Primary info */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to="/plugins/$name"
|
||||
params={{ name: pkg.name }}
|
||||
className="font-display text-sm font-bold text-[color:var(--ink)] hover:text-[color:var(--accent)]"
|
||||
>
|
||||
<div className="dashboard-list-row">
|
||||
<div className="dashboard-list-primary">
|
||||
<div className="dashboard-list-title">
|
||||
<Link to="/plugins/$name" params={{ name: pkg.name }} className="dashboard-skill-name">
|
||||
{pkg.displayName}
|
||||
</Link>
|
||||
<span className="font-mono text-xs text-[color:var(--ink-soft)]">{pkg.name}</span>
|
||||
<span className="dashboard-list-id">{pkg.name}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="dashboard-inline-tags">
|
||||
<PackageStatusTag label={familyLabel(pkg.family)} tone="default" />
|
||||
<PackageStatusTag label={pkg.channel} tone="default" />
|
||||
{scanLabel ? <PackageStatusTag label={scanLabel} tone={scanTone} /> : null}
|
||||
@@ -438,41 +450,34 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[color:var(--ink-soft)]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ArrowDownToLine size={13} aria-hidden="true" />{" "}
|
||||
{formatCompactStat(pkg.stats.downloads)}
|
||||
<div className="dashboard-inline-metrics">
|
||||
<span>
|
||||
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.downloads)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>
|
||||
<Star size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.stars)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>
|
||||
<Package size={13} aria-hidden="true" /> {pkg.stats.versions}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>
|
||||
<GitBranch size={13} aria-hidden="true" /> {pkg.latestVersion ?? "No tag"}
|
||||
</span>
|
||||
{pkg.runtimeId ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>
|
||||
<Plug size={13} aria-hidden="true" /> {pkg.runtimeId}
|
||||
</span>
|
||||
) : null}
|
||||
{sourceLabel ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>
|
||||
<ShieldCheck size={13} aria-hidden="true" /> {sourceLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
{pkg.summary ?? "No summary provided."}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex flex-col gap-1 text-xs text-[color:var(--ink-soft)]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<div className="dashboard-list-summary">{pkg.summary ?? "No summary provided."}</div>
|
||||
<div className="dashboard-list-status">
|
||||
<span className="dashboard-inline-status-item">
|
||||
<ShieldCheck size={13} aria-hidden="true" />{" "}
|
||||
{releaseStatusLabel(
|
||||
"VT",
|
||||
@@ -480,18 +485,16 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
|
||||
pkg.scanStatus === "pending" ? "pending" : "unknown",
|
||||
)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="dashboard-inline-status-item">
|
||||
<CheckCircle2 size={13} aria-hidden="true" />{" "}
|
||||
{releaseStatusLabel("LLM", pkg.latestRelease?.llmStatus)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="dashboard-inline-status-item">
|
||||
<AlertTriangle size={13} aria-hidden="true" />{" "}
|
||||
{releaseStatusLabel("Static", pkg.latestRelease?.staticScanStatus)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="dashboard-row-actions">
|
||||
<Button asChild size="sm">
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useAction, useQuery } from "convex/react";
|
||||
import { ArrowRight, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Code2,
|
||||
Download,
|
||||
Flame,
|
||||
Ghost,
|
||||
Package,
|
||||
Search,
|
||||
Sparkles,
|
||||
Star,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { InstallSwitcher } from "../components/InstallSwitcher";
|
||||
import { Container } from "../components/layout/Container";
|
||||
import { SkillCardSkeletonGrid } from "../components/skeletons/SkillCardSkeleton";
|
||||
import { SkillCard } from "../components/SkillCard";
|
||||
import { SkillListItem } from "../components/SkillListItem";
|
||||
import { SkillStatsTripletLine } from "../components/SkillStats";
|
||||
import { SoulCard } from "../components/SoulCard";
|
||||
import { SoulStatsTripletLine } from "../components/SoulStats";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Card } from "../components/ui/card";
|
||||
import { UserBadge } from "../components/UserBadge";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import type { PublicPublisher, PublicSkill, PublicSoul } from "../lib/publicUser";
|
||||
import { getSiteMode } from "../lib/site";
|
||||
|
||||
@@ -27,6 +39,19 @@ function Home() {
|
||||
return mode === "souls" ? <OnlyCrabsHome /> : <SkillsHome />;
|
||||
}
|
||||
|
||||
const popularSearches = ["AI Writing", "Screenshot", "Productivity", "Analytics", "Automation"];
|
||||
|
||||
const categories = [
|
||||
{ name: "Productivity", icon: "⚡", count: 324, className: "productivity" },
|
||||
{ name: "AI & ML", icon: "🧠", count: 218, className: "ai" },
|
||||
{ name: "Developer Tools", icon: "💻", count: 456, className: "developer" },
|
||||
{ name: "Design", icon: "🎨", count: 189, className: "design" },
|
||||
{ name: "Analytics", icon: "📊", count: 142, className: "analytics" },
|
||||
{ name: "Security", icon: "🔐", count: 98, className: "security" },
|
||||
{ name: "Automation", icon: "⚙️", count: 276, className: "automation" },
|
||||
{ name: "Media", icon: "🖼️", count: 167, className: "media" },
|
||||
];
|
||||
|
||||
function SkillsHome() {
|
||||
type SkillPageEntry = {
|
||||
skill: PublicSkill;
|
||||
@@ -36,223 +61,430 @@ function SkillsHome() {
|
||||
};
|
||||
|
||||
const [highlighted, setHighlighted] = useState<SkillPageEntry[]>([]);
|
||||
const [popular, setPopular] = useState<SkillPageEntry[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const totalSkills = useQuery(api.skills.countPublicSkills);
|
||||
const totalSkillsText =
|
||||
typeof totalSkills === "number" ? totalSkills.toLocaleString("en-US") : null;
|
||||
const [trending, setTrending] = useState<SkillPageEntry[]>([]);
|
||||
const [recent, setRecent] = useState<SkillPageEntry[]>([]);
|
||||
const [skillCount, setSkillCount] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void Promise.all([
|
||||
convexHttp
|
||||
.query(api.skills.listHighlightedPublic, { limit: 6 })
|
||||
.then((r) => {
|
||||
if (!cancelled) setHighlighted(r as SkillPageEntry[]);
|
||||
})
|
||||
.catch(() => {}),
|
||||
convexHttp
|
||||
.query(api.skills.listPublicPageV4, {
|
||||
numItems: 12,
|
||||
sort: "downloads",
|
||||
dir: "desc",
|
||||
nonSuspiciousOnly: true,
|
||||
})
|
||||
.then((r) => {
|
||||
if (!cancelled) setPopular((r as { page: SkillPageEntry[] }).page);
|
||||
})
|
||||
.catch(() => {}),
|
||||
]).then(() => {
|
||||
if (!cancelled) setLoaded(true);
|
||||
});
|
||||
|
||||
Promise.all([
|
||||
convexHttp.query(api.skills.listHighlightedPublic, { limit: 6 }),
|
||||
convexHttp.query(api.skills.listPublicPageV4, {
|
||||
numItems: 6,
|
||||
sort: "downloads",
|
||||
dir: "desc",
|
||||
nonSuspiciousOnly: true,
|
||||
}),
|
||||
convexHttp.query(api.skills.listPublicPageV4, {
|
||||
numItems: 6,
|
||||
sort: "updated",
|
||||
dir: "desc",
|
||||
nonSuspiciousOnly: true,
|
||||
}),
|
||||
convexHttp.query(api.skills.countPublicSkills, {}),
|
||||
])
|
||||
.then(([h, t, r, c]) => {
|
||||
if (cancelled) return;
|
||||
setHighlighted((h as SkillPageEntry[]).slice(0, 6));
|
||||
setTrending((t as { page: SkillPageEntry[] }).page);
|
||||
setRecent((r as { page: SkillPageEntry[] }).page);
|
||||
setSkillCount(c as number);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = searchQuery.trim();
|
||||
if (!q) return;
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q, type: undefined },
|
||||
});
|
||||
};
|
||||
|
||||
const highlightedGridStyle = {
|
||||
"--staff-picks-cols-lg": String(Math.max(1, Math.min(highlighted.length, 6))),
|
||||
"--staff-picks-cols-md": String(Math.max(1, Math.min(highlighted.length, 3))),
|
||||
"--staff-picks-cols-sm": String(Math.max(1, Math.min(highlighted.length, 2))),
|
||||
"--staff-picks-cols-xs": "1",
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="relative overflow-hidden py-20 px-7">
|
||||
<div className="mx-auto max-w-[1200px]">
|
||||
<div className="grid items-center gap-10 md:grid-cols-[1.15fr_1fr]">
|
||||
<div className="flex flex-col gap-5 fade-up" data-delay="1">
|
||||
<span className="inline-flex w-fit items-center gap-2 rounded-[var(--radius-pill)] bg-[color:var(--accent)]/10 px-4 py-1.5 text-xs font-bold text-[color:var(--accent)]">
|
||||
A versioned registry for AI agent skills
|
||||
</span>
|
||||
<h1 className="font-display text-[clamp(2.2rem,4vw,3.6rem)] font-extrabold leading-[1.1] tracking-tight text-[color:var(--ink)]">
|
||||
ClawHub, the skill dock for sharp agents.
|
||||
{/* Hero Section */}
|
||||
<section className="home-hero">
|
||||
<div className="home-hero-inner">
|
||||
<div className="home-hero-grid">
|
||||
<div className="home-hero-copy">
|
||||
{/* Badge */}
|
||||
<div className="home-hero-kicker">
|
||||
<Sparkles size={14} className="home-hero-kicker-icon" />
|
||||
<span>
|
||||
{skillCount != null
|
||||
? `${formatCompactStat(skillCount)} curated tools`
|
||||
: "Thousands of curated tools"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<h1 className="home-hero-title">
|
||||
Discover tools that{" "}
|
||||
<span className="home-hero-title-accent">power your work</span>
|
||||
</h1>
|
||||
<p className="max-w-lg text-lg leading-relaxed text-[color:var(--ink-soft)]">
|
||||
Browse, install, and publish skill packs. Versioned like npm, searchable with
|
||||
vectors, no gatekeeping.
|
||||
|
||||
{/* Subheadline */}
|
||||
<p className="home-hero-subtitle">
|
||||
The modern marketplace for internet tools. Find, compare, and install the best
|
||||
software to supercharge your productivity.
|
||||
</p>
|
||||
{/* Stats bar */}
|
||||
{totalSkillsText && (
|
||||
<p className="text-sm font-semibold text-[color:var(--ink-soft)]">
|
||||
{totalSkillsText} skills available
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
<Button variant="primary" size="lg">
|
||||
Browse skills
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
<Button variant="outline" size="lg">
|
||||
Publish Skill
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-card hero-search-card fade-up" data-delay="2">
|
||||
<div className="hero-install" style={{ marginTop: 18 }}>
|
||||
<div className="text-sm font-semibold text-[color:var(--ink-soft)]">
|
||||
Search skills. Versioned, rollback-ready.
|
||||
|
||||
{/* Search */}
|
||||
<form className="home-hero-search" onSubmit={handleSearch}>
|
||||
<div className="home-hero-search-wrapper">
|
||||
<Search size={20} className="home-hero-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="home-hero-search-input"
|
||||
placeholder="Search for tools, categories, or features..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="home-hero-search-btn">
|
||||
<span>Search</span>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<InstallSwitcher exampleSlug="sonoscli" />
|
||||
</form>
|
||||
|
||||
{/* Popular searches */}
|
||||
<div className="home-hero-popular">
|
||||
<span className="home-hero-popular-label">Popular:</span>
|
||||
{popularSearches.map((search) => (
|
||||
<button
|
||||
key={search}
|
||||
type="button"
|
||||
className="home-hero-popular-tag"
|
||||
onClick={() => setSearchQuery(search)}
|
||||
>
|
||||
{search}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Staff Picks */}
|
||||
<section className="py-12">
|
||||
<Container>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Staff Picks
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
|
||||
Curated signal — highlighted for quick trust.
|
||||
</p>
|
||||
</div>
|
||||
{/* Discovery Panels */}
|
||||
<div className="home-hero-panels" id="home-discovery">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: true,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="hidden text-sm font-semibold text-[color:var(--accent)] hover:underline sm:block"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
{!loaded && highlighted.length === 0 ? (
|
||||
<SkillCardSkeletonGrid count={6} />
|
||||
) : highlighted.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">No highlighted skills yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
|
||||
{highlighted.map((entry) => (
|
||||
<SkillCard
|
||||
key={entry.skill._id}
|
||||
skill={entry.skill}
|
||||
badge={getSkillBadges(entry.skill)}
|
||||
summaryFallback="A fresh skill bundle."
|
||||
meta={
|
||||
<>
|
||||
<UserBadge
|
||||
user={entry.owner}
|
||||
fallbackHandle={entry.ownerHandle ?? null}
|
||||
prefix="by"
|
||||
link={false}
|
||||
/>
|
||||
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
|
||||
<SkillStatsTripletLine stats={entry.skill.stats} />
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
{/* Popular */}
|
||||
<section className="py-12">
|
||||
<Container>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Popular skills
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
|
||||
Most-downloaded, verified picks.
|
||||
</p>
|
||||
</div>
|
||||
{!loaded && popular.length === 0 ? (
|
||||
<SkillCardSkeletonGrid count={6} />
|
||||
) : popular.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">No skills yet. Be the first.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
|
||||
{popular.map((entry) => (
|
||||
<SkillCard
|
||||
key={entry.skill._id}
|
||||
skill={entry.skill}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<>
|
||||
<UserBadge
|
||||
user={entry.owner}
|
||||
fallbackHandle={entry.ownerHandle ?? null}
|
||||
prefix="by"
|
||||
link={false}
|
||||
/>
|
||||
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
|
||||
<SkillStatsTripletLine stats={entry.skill.stats} />
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center pt-4">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
sort: "downloads" as const,
|
||||
dir: "desc" as const,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-hero-panel"
|
||||
>
|
||||
<Button variant="outline">
|
||||
See all skills
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="home-hero-panel-icon">
|
||||
<Zap size={20} />
|
||||
</div>
|
||||
<strong>Skills</strong>
|
||||
<span>Browse ranked skill bundles</span>
|
||||
</Link>
|
||||
<Link to="/plugins" className="home-hero-panel">
|
||||
<div className="home-hero-panel-icon">
|
||||
<Code2 size={20} />
|
||||
</div>
|
||||
<strong>Plugins</strong>
|
||||
<span>Agent-ready packages</span>
|
||||
</Link>
|
||||
<Link to="/users" search={{ q: undefined }} className="home-hero-panel">
|
||||
<div className="home-hero-panel-icon">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<strong>Builders</strong>
|
||||
<span>Meet the creators</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-hero-panel"
|
||||
>
|
||||
<div className="home-hero-panel-icon">
|
||||
<Ghost size={20} />
|
||||
</div>
|
||||
<strong>Souls</strong>
|
||||
<span>SOUL.md discovery</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<section className="home-section">
|
||||
<div className="home-stats">
|
||||
<div className="home-stat">
|
||||
<div className="home-stat-value">
|
||||
{skillCount != null ? formatCompactStat(skillCount) : "2.4K"}+
|
||||
</div>
|
||||
<div className="home-stat-label">Curated Tools</div>
|
||||
</div>
|
||||
<div className="home-stat">
|
||||
<div className="home-stat-value">180K+</div>
|
||||
<div className="home-stat-label">Active Users</div>
|
||||
</div>
|
||||
<div className="home-stat">
|
||||
<div className="home-stat-value">12M+</div>
|
||||
<div className="home-stat-label">Total Downloads</div>
|
||||
</div>
|
||||
<div className="home-stat">
|
||||
<div className="home-stat-value">4.8</div>
|
||||
<div className="home-stat-label">Avg. Rating</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Trending */}
|
||||
{trending.length > 0 ? (
|
||||
<section className="home-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">
|
||||
<span className="home-section-title-icon trending">
|
||||
<TrendingUp size={16} />
|
||||
</span>
|
||||
Trending Now
|
||||
</h2>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: "downloads" as const,
|
||||
dir: "desc" as const,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-section-link"
|
||||
>
|
||||
View all
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="results-list">
|
||||
{trending.map((entry) => (
|
||||
<SkillListItem
|
||||
key={entry.skill._id}
|
||||
skill={entry.skill}
|
||||
ownerHandle={entry.ownerHandle}
|
||||
owner={entry.owner}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Recently updated */}
|
||||
{recent.length > 0 ? (
|
||||
<section className="home-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">
|
||||
<span className="home-section-title-icon recent">
|
||||
<Sparkles size={16} />
|
||||
</span>
|
||||
Recently Updated
|
||||
</h2>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: "updated" as const,
|
||||
dir: "desc" as const,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-section-link"
|
||||
>
|
||||
View all
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="results-list">
|
||||
{recent.map((entry) => (
|
||||
<SkillListItem
|
||||
key={entry.skill._id}
|
||||
skill={entry.skill}
|
||||
ownerHandle={entry.ownerHandle}
|
||||
owner={entry.owner}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Staff picks */}
|
||||
{highlighted.length > 0 ? (
|
||||
<section className="home-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">
|
||||
<span className="home-section-title-icon featured">
|
||||
<Star size={16} />
|
||||
</span>
|
||||
Staff Picks
|
||||
</h2>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: true,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-section-link"
|
||||
>
|
||||
View all
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="home-staff-picks-grid" style={highlightedGridStyle}>
|
||||
{highlighted.map((entry) => (
|
||||
<SkillCard
|
||||
key={entry.skill._id}
|
||||
skill={entry.skill}
|
||||
badge={getSkillBadges(entry.skill)}
|
||||
summaryFallback="A fresh skill bundle."
|
||||
meta={
|
||||
<div className="skill-card-footer-rows">
|
||||
<UserBadge
|
||||
user={entry.owner}
|
||||
fallbackHandle={entry.ownerHandle ?? null}
|
||||
prefix="by"
|
||||
link={false}
|
||||
/>
|
||||
<div className="stat">
|
||||
<SkillStatsTripletLine stats={entry.skill.stats} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Categories */}
|
||||
<section className="home-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Browse by Category</h2>
|
||||
</div>
|
||||
<div className="home-categories">
|
||||
{categories.map((category) => (
|
||||
<Link
|
||||
key={category.name}
|
||||
to="/skills"
|
||||
search={{
|
||||
q: category.name,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-category-card"
|
||||
>
|
||||
<div className={`home-category-icon ${category.className}`}>{category.icon}</div>
|
||||
<div className="home-category-content">
|
||||
<div className="home-category-name">{category.name}</div>
|
||||
<div className="home-category-count">{category.count} tools</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick links */}
|
||||
<section className="home-section">
|
||||
<div className="home-quick-links">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: "stars" as const,
|
||||
dir: "desc" as const,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-quick-link"
|
||||
>
|
||||
<Star size={14} className="home-quick-link-icon" />
|
||||
Most starred
|
||||
</Link>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: "newest" as const,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-quick-link"
|
||||
>
|
||||
<Sparkles size={14} className="home-quick-link-icon" />
|
||||
New this week
|
||||
</Link>
|
||||
<Link to="/plugins" className="home-quick-link">
|
||||
<Code2 size={14} className="home-quick-link-icon" />
|
||||
Browse plugins
|
||||
</Link>
|
||||
<Link to="/users" search={{ q: undefined }} className="home-quick-link">
|
||||
<Users size={14} className="home-quick-link-icon" />
|
||||
Browse users
|
||||
</Link>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: true,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-quick-link"
|
||||
>
|
||||
<Star size={14} className="home-quick-link-icon" />
|
||||
Staff picks
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
@@ -274,45 +506,23 @@ function OnlyCrabsHome() {
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="relative overflow-hidden py-20 px-7">
|
||||
<div className="mx-auto max-w-[1200px]">
|
||||
<div className="grid items-center gap-10 md:grid-cols-[1.15fr_1fr]">
|
||||
<div className="flex flex-col gap-5 fade-up" data-delay="1">
|
||||
<span className="inline-flex w-fit items-center gap-2 rounded-[var(--radius-pill)] bg-[color:var(--accent)]/10 px-4 py-1.5 text-xs font-bold text-[color:var(--accent)]">
|
||||
SOUL.md, shared.
|
||||
</span>
|
||||
<h1 className="font-display text-[clamp(2.2rem,4vw,3.6rem)] font-extrabold leading-[1.1] tracking-tight text-[color:var(--ink)]">
|
||||
SoulHub, where system lore lives.
|
||||
<section className="home-hero">
|
||||
<div className="home-hero-inner">
|
||||
<div className="home-hero-grid">
|
||||
<div className="home-hero-copy">
|
||||
<div className="home-hero-kicker">
|
||||
<Ghost size={14} className="home-hero-kicker-icon" />
|
||||
<span>OnlyCrabs</span>
|
||||
</div>
|
||||
<h1 className="home-hero-title">
|
||||
<span className="home-hero-title-accent">SoulHub</span>, where system lore lives.
|
||||
</h1>
|
||||
<p className="max-w-lg text-lg leading-relaxed text-[color:var(--ink-soft)]">
|
||||
<p className="home-hero-subtitle">
|
||||
Share SOUL.md bundles, version them like docs, and keep personal system lore in one
|
||||
public place.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
<Button variant="primary" size="lg">
|
||||
Publish Soul
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" size="lg">
|
||||
Browse souls
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-card hero-search-card fade-up" data-delay="2">
|
||||
<form
|
||||
className="search-bar"
|
||||
className="home-hero-search"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void navigate({
|
||||
@@ -327,72 +537,67 @@ function OnlyCrabsHome() {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Search className="h-4 w-4 text-[color:var(--ink-soft)]" />
|
||||
<Input
|
||||
placeholder="Search souls, prompts, or lore"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="border-none bg-transparent shadow-none focus:shadow-none focus:ring-0"
|
||||
/>
|
||||
</form>
|
||||
<div className="hero-install" style={{ marginTop: 18 }}>
|
||||
<div className="text-sm font-semibold text-[color:var(--ink-soft)]">
|
||||
Search souls. Versioned, readable, easy to remix.
|
||||
<div className="home-hero-search-wrapper">
|
||||
<Search size={20} className="home-hero-search-icon" />
|
||||
<input
|
||||
className="home-hero-search-input"
|
||||
type="text"
|
||||
placeholder="Search souls, prompts, or lore"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<button type="submit" className="home-hero-search-btn">
|
||||
<span>Search</span>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-12">
|
||||
<Container>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Latest souls
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
|
||||
Newest SOUL.md bundles across the hub.
|
||||
</p>
|
||||
</div>
|
||||
{latest.length === 0 ? (
|
||||
<SkillCardSkeletonGrid count={6} />
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
|
||||
{latest.map((soul) => (
|
||||
<SoulCard
|
||||
key={soul._id}
|
||||
soul={soul}
|
||||
summaryFallback="A SOUL.md bundle."
|
||||
meta={
|
||||
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
|
||||
<SoulStatsTripletLine stats={soul.stats} />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center pt-4">
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
<Button variant="outline">
|
||||
See all souls
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
<section className="home-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">
|
||||
<span className="home-section-title-icon recent">
|
||||
<Sparkles size={16} />
|
||||
</span>
|
||||
Latest Souls
|
||||
</h2>
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-section-link"
|
||||
>
|
||||
View all
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid">
|
||||
{latest.length === 0 ? (
|
||||
<Card>No souls yet. Be the first.</Card>
|
||||
) : (
|
||||
latest.map((soul) => (
|
||||
<SoulCard
|
||||
key={soul._id}
|
||||
soul={soul}
|
||||
summaryFallback="A SOUL.md bundle."
|
||||
meta={
|
||||
<div className="stat">
|
||||
<SoulStatsTripletLine stats={soul.stats} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
fetchPackageReadme,
|
||||
fetchPackageVersion,
|
||||
getPackageDownloadPath,
|
||||
isRateLimitedPackageApiError,
|
||||
type PackageDetailResponse,
|
||||
type PackageVersionDetail,
|
||||
} from "../../lib/packageApi";
|
||||
@@ -36,6 +35,7 @@ type PluginDetailLoaderData = {
|
||||
|
||||
export const Route = createFileRoute("/plugins/$name")({
|
||||
loader: async ({ params }): Promise<PluginDetailLoaderData> => {
|
||||
// All fetch functions now handle errors internally and return null/empty on failure
|
||||
const requestedName = params.name;
|
||||
const candidateNames = requestedName.includes("/")
|
||||
? [requestedName]
|
||||
@@ -43,24 +43,9 @@ export const Route = createFileRoute("/plugins/$name")({
|
||||
|
||||
let resolvedName = requestedName;
|
||||
let detail: PackageDetailResponse = { package: null, owner: null };
|
||||
|
||||
for (const candidateName of candidateNames) {
|
||||
let candidateDetail: PackageDetailResponse;
|
||||
try {
|
||||
candidateDetail = await fetchPackageDetail(candidateName);
|
||||
} catch (error) {
|
||||
if (isRateLimitedPackageApiError(error)) {
|
||||
return {
|
||||
detail: { package: null, owner: null },
|
||||
version: null,
|
||||
readme: null,
|
||||
rateLimited: {
|
||||
scope: "detail",
|
||||
retryAfterSeconds: error.retryAfterSeconds,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const candidateDetail = await fetchPackageDetail(candidateName);
|
||||
if (candidateDetail.package) {
|
||||
detail = candidateDetail;
|
||||
resolvedName = candidateName;
|
||||
@@ -70,35 +55,18 @@ export const Route = createFileRoute("/plugins/$name")({
|
||||
}
|
||||
|
||||
if (!detail.package) {
|
||||
return {
|
||||
detail,
|
||||
version: null,
|
||||
readme: null,
|
||||
rateLimited: null,
|
||||
};
|
||||
return { detail, version: null, readme: null, rateLimited: null };
|
||||
}
|
||||
|
||||
let metadataRateLimited: PluginDetailRateLimitState = null;
|
||||
const readmePromise = fetchPackageReadme(resolvedName).catch((error: unknown) => {
|
||||
if (!isRateLimitedPackageApiError(error)) throw error;
|
||||
metadataRateLimited ??= {
|
||||
scope: "metadata",
|
||||
retryAfterSeconds: error.retryAfterSeconds,
|
||||
};
|
||||
return null;
|
||||
});
|
||||
const versionPromise = detail.package?.latestVersion
|
||||
? fetchPackageVersion(resolvedName, detail.package.latestVersion).catch((error: unknown) => {
|
||||
if (!isRateLimitedPackageApiError(error)) throw error;
|
||||
metadataRateLimited ??= {
|
||||
scope: "metadata",
|
||||
retryAfterSeconds: error.retryAfterSeconds,
|
||||
};
|
||||
return null;
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
const [version, readme] = await Promise.all([versionPromise, readmePromise]);
|
||||
return { detail, version, readme, rateLimited: metadataRateLimited };
|
||||
// Fetch readme and version in parallel - functions handle errors internally
|
||||
const [version, readme] = await Promise.all([
|
||||
detail.package.latestVersion
|
||||
? fetchPackageVersion(resolvedName, detail.package.latestVersion)
|
||||
: Promise.resolve(null),
|
||||
fetchPackageReadme(resolvedName),
|
||||
]);
|
||||
|
||||
return { detail, version, readme, rateLimited: null };
|
||||
},
|
||||
head: ({ params, loaderData }) => ({
|
||||
meta: [
|
||||
@@ -296,9 +264,8 @@ function PluginDetailRoute() {
|
||||
: [];
|
||||
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container>
|
||||
<div className="flex flex-col gap-5">
|
||||
<main className="section">
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
{/* Header card */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
@@ -316,7 +283,7 @@ function PluginDetailRoute() {
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)] mb-1">
|
||||
<h1 className="mb-1 break-words font-display text-2xl font-bold text-[color:var(--ink)]">
|
||||
{pkg.displayName}
|
||||
{pkg.latestVersion ? (
|
||||
<span className="ml-2 inline-block rounded-[var(--radius-pill)] bg-[color:var(--surface-muted)] px-2 py-0.5 text-xs font-semibold text-[color:var(--ink-soft)]">
|
||||
@@ -324,16 +291,16 @@ function PluginDetailRoute() {
|
||||
</span>
|
||||
) : null}
|
||||
</h1>
|
||||
<p className="text-sm text-[color:var(--ink-soft)] mb-2">
|
||||
<p className="mb-2 break-words text-sm text-[color:var(--ink-soft)]">
|
||||
{pkg.summary ?? "No summary provided."}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-[color:var(--ink-soft)]">
|
||||
<span className="font-mono text-xs">{pkg.name}</span>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm text-[color:var(--ink-soft)]">
|
||||
<span className="break-all font-mono text-xs">{pkg.name}</span>
|
||||
{pkg.runtimeId ? (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
<span>
|
||||
runtime <span className="font-mono text-xs">{pkg.runtimeId}</span>
|
||||
runtime <span className="break-all font-mono text-xs">{pkg.runtimeId}</span>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
@@ -360,7 +327,7 @@ function PluginDetailRoute() {
|
||||
{/* Install */}
|
||||
<div className="mt-4">
|
||||
<div className="flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3 sm:flex-row sm:items-center sm:gap-2">
|
||||
<pre className="min-w-0 flex-1 overflow-x-auto font-mono text-xs text-[color:var(--ink)]">
|
||||
<pre className="plugin-detail-code-block min-w-0 flex-1 font-mono text-xs text-[color:var(--ink)]">
|
||||
<code>{installSnippet}</code>
|
||||
</pre>
|
||||
<CopyButton text={installSnippet} />
|
||||
@@ -373,13 +340,12 @@ function PluginDetailRoute() {
|
||||
<span className="text-sm">
|
||||
Latest release: <strong>v{pkg.latestVersion}</strong>
|
||||
</span>
|
||||
<a
|
||||
href={getPackageDownloadPath(name, pkg.latestVersion)}
|
||||
className="inline-flex min-h-[34px] w-full items-center justify-center gap-2 rounded-[var(--radius-pill)] border border-[color:var(--border-ui)] bg-transparent px-3 py-1.5 text-xs font-semibold text-[color:var(--ink)] transition-all duration-200 no-underline hover:border-[color:var(--border-ui-hover)] hover:bg-[color:var(--surface)] sm:w-auto sm:whitespace-nowrap"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Download zip
|
||||
</a>
|
||||
<Button asChild variant="outline" size="sm" className="w-full no-underline sm:w-auto">
|
||||
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Download zip
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
@@ -399,7 +365,7 @@ function PluginDetailRoute() {
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{CAPABILITY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
<dd className="min-w-0 break-words text-[color:var(--ink)]">
|
||||
{key === "capabilityTags" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((tag) => (
|
||||
@@ -441,7 +407,9 @@ function PluginDetailRoute() {
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
|
||||
</dt>
|
||||
<dd className="font-mono text-xs text-[color:var(--ink)]">{String(value)}</dd>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{String(value)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -506,7 +474,7 @@ function PluginDetailRoute() {
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[color:var(--accent)] hover:underline"
|
||||
className="inline-flex max-w-full flex-wrap items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
|
||||
>
|
||||
{display}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
@@ -519,7 +487,7 @@ function PluginDetailRoute() {
|
||||
{verification.sourceCommit ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
|
||||
<dd className="font-mono text-xs text-[color:var(--ink)]">
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceCommit.slice(0, 12)}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -527,7 +495,7 @@ function PluginDetailRoute() {
|
||||
{verification.sourceTag ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
|
||||
<dd className="font-mono text-xs text-[color:var(--ink)]">
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceTag}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -562,7 +530,9 @@ function PluginDetailRoute() {
|
||||
{Object.entries(pkg.tags).map(([key, value]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
|
||||
<dd className="font-mono text-xs text-[color:var(--ink)]">{value}</dd>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -579,7 +549,6 @@ function PluginDetailRoute() {
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { AlertTriangle, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Container } from "../../components/layout/Container";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { BrowseSidebar } from "../../components/BrowseSidebar";
|
||||
import { PluginListItem } from "../../components/PluginListItem";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { formatRetryDelay } from "../../lib/formatRetryDelay";
|
||||
import {
|
||||
fetchPluginCatalog,
|
||||
isRateLimitedPackageApiError,
|
||||
type PackageListItem,
|
||||
} from "../../lib/packageApi";
|
||||
import { familyLabel } from "../../lib/packageLabels";
|
||||
|
||||
type PluginSearchState = {
|
||||
q?: string;
|
||||
@@ -29,8 +22,18 @@ type PluginsLoaderData = {
|
||||
nextCursor: string | null;
|
||||
rateLimited: boolean;
|
||||
retryAfterSeconds: number | null;
|
||||
apiError?: boolean;
|
||||
};
|
||||
|
||||
function formatRetryDelay(retryAfterSeconds: number | null) {
|
||||
if (!retryAfterSeconds || retryAfterSeconds <= 0) return "in a moment";
|
||||
if (retryAfterSeconds < 60) {
|
||||
return `in about ${retryAfterSeconds} second${retryAfterSeconds === 1 ? "" : "s"}`;
|
||||
}
|
||||
const minutes = Math.ceil(retryAfterSeconds / 60);
|
||||
return `in about ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/plugins/")({
|
||||
validateSearch: (search): PluginSearchState => ({
|
||||
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
|
||||
@@ -44,111 +47,112 @@ export const Route = createFileRoute("/plugins/")({
|
||||
? true
|
||||
: undefined,
|
||||
executesCode:
|
||||
search.executesCode === true || search.executesCode === "true" || search.executesCode === "1"
|
||||
search.executesCode === true ||
|
||||
search.executesCode === "true" ||
|
||||
search.executesCode === "1"
|
||||
? true
|
||||
: undefined,
|
||||
}),
|
||||
loaderDeps: ({ search }) => search,
|
||||
loader: async ({ deps }) => {
|
||||
try {
|
||||
const data = await fetchPluginCatalog({
|
||||
q: deps.q,
|
||||
cursor: deps.q ? undefined : deps.cursor,
|
||||
family: deps.family,
|
||||
isOfficial: deps.verified,
|
||||
executesCode: deps.executesCode,
|
||||
limit: 50,
|
||||
});
|
||||
return {
|
||||
items: data.items,
|
||||
nextCursor: data.nextCursor,
|
||||
rateLimited: false,
|
||||
retryAfterSeconds: null,
|
||||
} satisfies PluginsLoaderData;
|
||||
} catch (error) {
|
||||
if (!isRateLimitedPackageApiError(error)) throw error;
|
||||
return {
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
rateLimited: true,
|
||||
retryAfterSeconds: error.retryAfterSeconds,
|
||||
} satisfies PluginsLoaderData;
|
||||
}
|
||||
loader: async ({ deps }): Promise<PluginsLoaderData> => {
|
||||
// fetchPluginCatalog now handles errors internally and returns empty results
|
||||
const data = await fetchPluginCatalog({
|
||||
q: deps.q,
|
||||
cursor: deps.q ? undefined : deps.cursor,
|
||||
family: deps.family,
|
||||
isOfficial: deps.verified,
|
||||
executesCode: deps.executesCode,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const items = data?.items ?? [];
|
||||
return {
|
||||
items,
|
||||
nextCursor: data?.nextCursor ?? null,
|
||||
rateLimited: false,
|
||||
retryAfterSeconds: null,
|
||||
apiError: items.length === 0 && !deps.q && !deps.family && !deps.verified && !deps.executesCode,
|
||||
};
|
||||
},
|
||||
component: PluginsIndex,
|
||||
});
|
||||
|
||||
function VerifiedBadge() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Verified publisher"
|
||||
className="inline-block shrink-0 align-middle"
|
||||
>
|
||||
<path
|
||||
d="M8 0L9.79 1.52L12.12 1.21L12.93 3.41L15.01 4.58L14.42 6.84L15.56 8.82L14.12 10.5L14.12 12.82L11.86 13.41L10.34 15.27L8 14.58L5.66 15.27L4.14 13.41L1.88 12.82L1.88 10.5L0.44 8.82L1.58 6.84L0.99 4.58L3.07 3.41L3.88 1.21L6.21 1.52L8 0Z"
|
||||
fill="#3b82f6"
|
||||
/>
|
||||
<path
|
||||
d="M5.5 8L7 9.5L10.5 6"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginsIndex() {
|
||||
function PluginsIndex() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const { items, nextCursor, rateLimited, retryAfterSeconds } =
|
||||
Route.useLoaderData() as PluginsLoaderData;
|
||||
const loaderData = Route.useLoaderData() as PluginsLoaderData | undefined;
|
||||
|
||||
// Defensive handling for when loader data is unavailable (SSR errors, etc.)
|
||||
const items = loaderData?.items ?? [];
|
||||
const nextCursor = loaderData?.nextCursor ?? null;
|
||||
const rateLimited = loaderData?.rateLimited ?? false;
|
||||
const retryAfterSeconds = loaderData?.retryAfterSeconds ?? null;
|
||||
const apiError = loaderData?.apiError ?? !loaderData;
|
||||
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? "");
|
||||
}, [search.q]);
|
||||
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container size="wide">
|
||||
<header className="mb-6">
|
||||
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)] mb-2">Plugins</h1>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">Browse the plugin catalog.</p>
|
||||
</header>
|
||||
const handleFilterToggle = (key: string) => {
|
||||
if (key === "verified") {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
verified: prev.verified ? undefined : true,
|
||||
}),
|
||||
});
|
||||
} else if (key === "executesCode") {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
executesCode: prev.executesCode ? undefined : true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<form
|
||||
className="relative flex flex-1 items-center"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
q: query.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 h-4 w-4 text-[color:var(--ink-soft)] opacity-50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search plugins..."
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</form>
|
||||
const handleFamilySort = (value: string) => {
|
||||
const family =
|
||||
value === "code-plugin" || value === "bundle-plugin" ? value : undefined;
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
family: family as "code-plugin" | "bundle-plugin" | undefined,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
q: query.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="browse-page">
|
||||
<div className="browse-page-header">
|
||||
<button
|
||||
className="browse-sidebar-toggle"
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
aria-label="Toggle filters"
|
||||
>
|
||||
Filters
|
||||
</button>
|
||||
<h1 className="browse-title">Plugins</h1>
|
||||
<div className="browse-page-actions">
|
||||
<Button asChild variant="primary">
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
@@ -159,177 +163,103 @@ export function PluginsIndex() {
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
}}
|
||||
className="inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold text-sm min-h-[44px] rounded-[var(--radius-pill)] px-4 py-[11px] border-none bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] text-white transition-all duration-200 no-underline hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]"
|
||||
>
|
||||
Publish Plugin
|
||||
Publish
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<form className="browse-page-search" onSubmit={handleSearch}>
|
||||
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="browse-search-input"
|
||||
placeholder="Search plugins..."
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</form>
|
||||
<div className={`browse-layout${sidebarOpen ? " sidebar-open" : ""}`}>
|
||||
<BrowseSidebar
|
||||
sortOptions={[
|
||||
{ value: "all", label: "All types" },
|
||||
{ value: "code-plugin", label: "Code plugins" },
|
||||
{ value: "bundle-plugin", label: "Bundle plugins" },
|
||||
]}
|
||||
activeSort={search.family ?? "all"}
|
||||
onSortChange={handleFamilySort}
|
||||
filters={[
|
||||
{ key: "verified", label: "Verified only", active: search.verified ?? false },
|
||||
{ key: "executesCode", label: "Executes code", active: search.executesCode ?? false },
|
||||
]}
|
||||
onFilterToggle={handleFilterToggle}
|
||||
/>
|
||||
<div className="browse-results">
|
||||
<div className="browse-results-toolbar">
|
||||
<span className="browse-results-count">
|
||||
{items.length} plugin{items.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-stretch gap-2">
|
||||
<div
|
||||
className="flex min-w-0 flex-wrap items-center rounded-[var(--radius-pill)] border border-[color:var(--line)]"
|
||||
role="group"
|
||||
aria-label="Filter by type"
|
||||
>
|
||||
{[
|
||||
{ value: undefined, label: "All" },
|
||||
{ value: "code-plugin" as const, label: "Code" },
|
||||
{ value: "bundle-plugin" as const, label: "Bundles" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.label}
|
||||
className={`flex-1 px-3 py-1.5 text-sm font-semibold transition-colors first:rounded-l-[var(--radius-pill)] last:rounded-r-[var(--radius-pill)] sm:flex-none ${
|
||||
(search.family ?? undefined) === opt.value
|
||||
? "bg-[color:var(--accent)] text-white"
|
||||
: "text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
|
||||
}`}
|
||||
|
||||
{apiError ? (
|
||||
<div className="empty-state">
|
||||
<AlertTriangle size={20} aria-hidden="true" />
|
||||
<p className="empty-state-title">Unable to load plugins</p>
|
||||
<p className="empty-state-body">
|
||||
The plugin catalog is temporarily unavailable. Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
) : rateLimited ? (
|
||||
<div className="empty-state">
|
||||
<AlertTriangle size={20} aria-hidden="true" />
|
||||
<p className="empty-state-title">Plugin catalog is temporarily unavailable</p>
|
||||
<p className="empty-state-body">
|
||||
Try again {formatRetryDelay(retryAfterSeconds)}.
|
||||
</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">No plugins found</p>
|
||||
<p className="empty-state-body">Try a different search term or remove filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="results-list">
|
||||
{items.map((item) => (
|
||||
<PluginListItem key={item.name} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!search.q && (search.cursor || nextCursor) ? (
|
||||
<div className="mt-5 flex justify-center gap-3">
|
||||
{search.cursor ? (
|
||||
<Button
|
||||
type="button"
|
||||
aria-pressed={(search.family ?? undefined) === opt.value}
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
q: query.trim() || undefined,
|
||||
family: opt.value,
|
||||
}),
|
||||
search: (prev) => ({ ...prev, cursor: undefined }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant={search.verified ? "primary" : "outline"}
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-none"
|
||||
aria-pressed={search.verified ?? false}
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
q: query.trim() || undefined,
|
||||
verified: prev.verified ? undefined : true,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<VerifiedBadge /> Verified
|
||||
</Button>
|
||||
<Button
|
||||
variant={search.executesCode ? "primary" : "outline"}
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-none"
|
||||
aria-pressed={search.executesCode ?? false}
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
q: query.trim() || undefined,
|
||||
executesCode: prev.executesCode ? undefined : true,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Executes code
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{rateLimited ? (
|
||||
<EmptyState
|
||||
icon={AlertTriangle}
|
||||
title="Plugin catalog is temporarily unavailable"
|
||||
description={`The public plugin API is rate-limited right now. Try again ${formatRetryDelay(
|
||||
retryAfterSeconds,
|
||||
)}.`}
|
||||
action={{
|
||||
label: "Try again",
|
||||
onClick: () => window.location.reload(),
|
||||
}}
|
||||
/>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No plugins match that filter"
|
||||
description="Try a different search or filter."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fill,minmax(280px,1fr))] sm:gap-5">
|
||||
{items.map((item) => (
|
||||
<Link key={item.name} to="/plugins/$name" params={{ name: item.name }}>
|
||||
<Card className="h-full cursor-pointer hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge variant="compact">{familyLabel(item.family)}</Badge>
|
||||
{item.isOfficial ? (
|
||||
<Badge variant="accent">
|
||||
<VerifiedBadge /> Verified
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<h3 className="font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
{item.displayName}
|
||||
</h3>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
{item.summary ?? "No summary provided."}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5 pt-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="min-w-0 break-words text-sm text-[color:var(--ink-soft)]">
|
||||
{item.ownerHandle ? `by ${item.ownerHandle}` : "community"}
|
||||
</span>
|
||||
{item.latestVersion ? (
|
||||
<span className="text-sm text-[color:var(--ink-soft)]">
|
||||
v{item.latestVersion}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{!search.q && (search.cursor || nextCursor) ? (
|
||||
<div className="mt-6 flex flex-col items-stretch justify-center gap-3 sm:flex-row sm:items-center">
|
||||
{search.cursor ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
First page
|
||||
</Button>
|
||||
) : null}
|
||||
{nextCursor ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: nextCursor,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Next page
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
First page
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{nextCursor ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({ ...prev, cursor: nextCursor }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Next page
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,171 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { detectSiteMode } from "../lib/site";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Search } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { PluginListItem } from "../components/PluginListItem";
|
||||
import { SkillListItem } from "../components/SkillListItem";
|
||||
import { UserListItem } from "../components/UserListItem";
|
||||
import { Card } from "../components/ui/card";
|
||||
import type { PublicSkill, PublicUser } from "../lib/publicUser";
|
||||
import {
|
||||
useUnifiedSearch,
|
||||
type UnifiedPluginResult,
|
||||
type UnifiedSkillResult,
|
||||
type UnifiedUserResult,
|
||||
} from "../lib/useUnifiedSearch";
|
||||
|
||||
type SearchState = {
|
||||
q?: string;
|
||||
type?: "all" | "skills" | "plugins" | "users";
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/search")({
|
||||
validateSearch: (search) => ({
|
||||
validateSearch: (search): SearchState => ({
|
||||
q: typeof search.q === "string" && search.q.trim() ? search.q : undefined,
|
||||
highlighted: search.highlighted === "1" || search.highlighted === "true" ? true : undefined,
|
||||
nonSuspicious:
|
||||
search.nonSuspicious === "1" || search.nonSuspicious === "true" ? true : undefined,
|
||||
type:
|
||||
search.type === "skills" || search.type === "plugins" || search.type === "users"
|
||||
? search.type
|
||||
: undefined,
|
||||
}),
|
||||
beforeLoad: ({ search, location }) => {
|
||||
const hostname =
|
||||
(location as { url?: URL }).url?.hostname ??
|
||||
(typeof window !== "undefined" ? window.location.hostname : undefined);
|
||||
const mode = detectSiteMode(hostname);
|
||||
if (mode === "skills") {
|
||||
throw redirect({
|
||||
to: "/skills",
|
||||
search: {
|
||||
q: search.q || undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: search.highlighted || undefined,
|
||||
nonSuspicious: search.nonSuspicious || undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
},
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
component: UnifiedSearchPage,
|
||||
});
|
||||
|
||||
throw redirect({
|
||||
to: "/",
|
||||
search: {
|
||||
q: search.q || undefined,
|
||||
highlighted: undefined,
|
||||
search: search.q ? undefined : true,
|
||||
},
|
||||
function UnifiedSearchPage() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
const activeType = search.type ?? "all";
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
|
||||
const { results, skillCount, pluginCount, userCount, isSearching } = useUnifiedSearch(
|
||||
search.q ?? "",
|
||||
activeType,
|
||||
);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q: query.trim() || undefined, type: search.type },
|
||||
});
|
||||
};
|
||||
|
||||
const setType = (type: "all" | "skills" | "plugins" | "users") => {
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q: search.q, type: type === "all" ? undefined : type },
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="browse-page">
|
||||
<h1 className="browse-title mb-4">
|
||||
{search.q ? (
|
||||
<>
|
||||
Search results for <span className="text-[color:var(--accent)]">"{search.q}"</span>
|
||||
</>
|
||||
) : (
|
||||
"Search"
|
||||
)}
|
||||
</h1>
|
||||
|
||||
<form className="search-page-form" onSubmit={handleSearch}>
|
||||
<div className="browse-search-bar max-w-[560px] flex-1">
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="browse-search-input"
|
||||
type="text"
|
||||
placeholder="Search skills, plugins, users..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="search-tabs">
|
||||
<button
|
||||
className={`search-tab${activeType === "all" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setType("all")}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
className={`search-tab${activeType === "skills" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setType("skills")}
|
||||
>
|
||||
Skills
|
||||
{skillCount > 0 ? (
|
||||
<span className="search-tab-count">{skillCount}</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
className={`search-tab${activeType === "plugins" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setType("plugins")}
|
||||
>
|
||||
Plugins
|
||||
{pluginCount > 0 ? (
|
||||
<span className="search-tab-count">{pluginCount}</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
className={`search-tab${activeType === "users" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setType("users")}
|
||||
>
|
||||
Users
|
||||
{userCount > 0 ? <span className="search-tab-count">{userCount}</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isSearching ? (
|
||||
<Card>
|
||||
<div className="loading-indicator">Searching...</div>
|
||||
</Card>
|
||||
) : !search.q ? (
|
||||
<Card className="text-center p-10">
|
||||
<p className="text-ink-soft">
|
||||
Enter a search term to find skills, plugins, and users
|
||||
</p>
|
||||
</Card>
|
||||
) : results.length === 0 ? (
|
||||
<Card className="text-center p-10">
|
||||
<p className="text-ink-soft">No results found for "{search.q}"</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="results-list">
|
||||
{results.map((item) =>
|
||||
item.type === "skill" ? (
|
||||
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
|
||||
) : item.type === "plugin" ? (
|
||||
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
|
||||
) : (
|
||||
<UserResultRow key={`user-${item.user._id}`} result={item} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillResultRow({ result }: { result: UnifiedSkillResult }) {
|
||||
const skill = result.skill as unknown as PublicSkill;
|
||||
return (
|
||||
<SkillListItem
|
||||
skill={skill}
|
||||
ownerHandle={result.ownerHandle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginResultRow({ result }: { result: UnifiedPluginResult }) {
|
||||
return <PluginListItem item={result.plugin} />;
|
||||
}
|
||||
|
||||
function UserResultRow({ result }: { result: UnifiedUserResult }) {
|
||||
const user = result.user as PublicUser;
|
||||
return <UserListItem user={user} />;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import {
|
||||
Eye,
|
||||
Grid3X3,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Monitor,
|
||||
Moon,
|
||||
RotateCcw,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { Container } from "../components/layout/Container";
|
||||
import { SignInButton } from "../components/SignInButton";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { SignInButton } from "../components/SignInButton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -21,9 +34,25 @@ import {
|
||||
} from "../components/ui/dialog";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../components/ui/select";
|
||||
import { Separator } from "../components/ui/separator";
|
||||
import { Switch } from "../components/ui/switch";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { gravatarUrl } from "../lib/gravatar";
|
||||
import {
|
||||
type AnimationLevel,
|
||||
type CodeFontSize,
|
||||
type LayoutDensity,
|
||||
type ListViewMode,
|
||||
usePreferences,
|
||||
} from "../lib/preferences";
|
||||
import { getThemeFamilyLabel, THEME_FAMILY_OPTIONS, useThemeMode } from "../lib/theme";
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
component: Settings,
|
||||
@@ -33,6 +62,16 @@ export function Settings() {
|
||||
const me = useQuery(api.users.me);
|
||||
const updateProfile = useMutation(api.users.updateProfile);
|
||||
const deleteAccount = useMutation(api.users.deleteAccount);
|
||||
const {
|
||||
customTheme,
|
||||
family: themeFamily,
|
||||
importCustomTheme,
|
||||
mode: themeMode,
|
||||
setFamily: setThemeFamily,
|
||||
setMode: setThemeMode,
|
||||
clearCustomTheme,
|
||||
} = useThemeMode();
|
||||
const { preferences, updatePreference, resetPreferences, isAdvancedMode } = usePreferences();
|
||||
const tokens = useQuery(api.tokens.listMine, me ? {} : "skip") as
|
||||
| Array<{
|
||||
_id: Id<"apiTokens">;
|
||||
@@ -69,6 +108,7 @@ export function Settings() {
|
||||
const [memberHandle, setMemberHandle] = useState("");
|
||||
const [memberRole, setMemberRole] = useState<"owner" | "admin" | "publisher">("publisher");
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [customThemeInput, setCustomThemeInput] = useState("");
|
||||
const orgs = (publisherMemberships ?? []).filter((entry) => entry.publisher.kind === "org");
|
||||
const selectedOrg =
|
||||
orgs.find((entry) => entry.publisher.handle === selectedOrgHandle) ?? orgs[0] ?? null;
|
||||
@@ -151,6 +191,20 @@ export function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onImportCustomTheme() {
|
||||
try {
|
||||
const importedTheme = await importCustomTheme(customThemeInput);
|
||||
setCustomThemeInput("");
|
||||
toast.success(`Imported ${importedTheme.name ?? "custom theme"}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to import theme");
|
||||
}
|
||||
}
|
||||
function onRemoveCustomTheme() {
|
||||
clearCustomTheme();
|
||||
toast.success("Removed custom theme");
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="narrow" className="py-10">
|
||||
<main className="flex flex-col gap-6">
|
||||
@@ -208,6 +262,387 @@ export function Settings() {
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Customization */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings2 size={18} />
|
||||
Customization
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Personalize your ClawHub experience
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="advanced-mode" className="text-sm text-[color:var(--ink-soft)]">
|
||||
Advanced
|
||||
</Label>
|
||||
<Switch
|
||||
id="advanced-mode"
|
||||
checked={isAdvancedMode}
|
||||
onCheckedChange={(checked) => updatePreference("advancedMode", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Theme Section */}
|
||||
<div className="space-y-3">
|
||||
<Label id="theme" className="text-sm font-semibold text-[color:var(--ink)]">Theme</Label>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{THEME_FAMILY_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setThemeFamily(option.value)}
|
||||
className="rounded-[var(--r-md)] border p-4 text-left transition-colors"
|
||||
style={{
|
||||
borderColor:
|
||||
themeFamily === option.value ? "var(--accent)" : "var(--border-ui)",
|
||||
background:
|
||||
themeFamily === option.value
|
||||
? "var(--accent-subtle)"
|
||||
: "var(--surface-muted)",
|
||||
}}
|
||||
>
|
||||
<div className="text-sm font-semibold text-[color:var(--ink)]">{option.label}</div>
|
||||
<div className="mt-1 text-xs text-[color:var(--ink-soft)]">
|
||||
{option.description}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={themeMode === "light" ? "primary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setThemeMode("light")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Sun size={14} />
|
||||
Light
|
||||
</Button>
|
||||
<Button
|
||||
variant={themeMode === "dark" ? "primary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setThemeMode("dark")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Moon size={14} />
|
||||
Dark
|
||||
</Button>
|
||||
<Button
|
||||
variant={themeMode === "system" ? "primary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setThemeMode("system")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Monitor size={14} />
|
||||
System
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3 rounded-[var(--r-md)] border border-[color:var(--border-ui)] bg-[color:var(--surface-muted)] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[color:var(--ink)]">
|
||||
tweakcn overlay
|
||||
</div>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">
|
||||
Paste a tweakcn URL, a tweakcn theme name, CSS variables, or JSON.
|
||||
</p>
|
||||
</div>
|
||||
{customTheme ? (
|
||||
<Badge variant="accent">
|
||||
{customTheme.name} on {getThemeFamilyLabel(themeFamily)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={customThemeInput}
|
||||
onChange={(event) => setCustomThemeInput(event.target.value)}
|
||||
placeholder="https://tweakcn.com/... or midnight-ocean"
|
||||
aria-label="Import tweakcn theme"
|
||||
/>
|
||||
<Button type="button" variant="primary" onClick={onImportCustomTheme}>
|
||||
Import
|
||||
</Button>
|
||||
{customTheme ? (
|
||||
<Button type="button" variant="ghost" onClick={onRemoveCustomTheme}>
|
||||
<RotateCcw size={14} />
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{customTheme ? (
|
||||
<div className="text-xs text-[color:var(--ink-soft)]">
|
||||
Source: {customTheme.source}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Layout Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)]">Layout</Label>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="layout-density" className="text-xs text-[color:var(--ink-soft)]">
|
||||
Density
|
||||
</Label>
|
||||
<Select
|
||||
value={preferences.layoutDensity}
|
||||
onValueChange={(value) => updatePreference("layoutDensity", value as LayoutDensity)}
|
||||
>
|
||||
<SelectTrigger id="layout-density">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="comfortable">
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutGrid size={14} />
|
||||
Comfortable
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="compact">
|
||||
<span className="flex items-center gap-2">
|
||||
<Grid3X3 size={14} />
|
||||
Compact
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="list-view" className="text-xs text-[color:var(--ink-soft)]">
|
||||
Default view
|
||||
</Label>
|
||||
<Select
|
||||
value={preferences.listViewMode}
|
||||
onValueChange={(value) => updatePreference("listViewMode", value as ListViewMode)}
|
||||
>
|
||||
<SelectTrigger id="list-view">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="grid">
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutGrid size={14} />
|
||||
Grid
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="list">
|
||||
<span className="flex items-center gap-2">
|
||||
<List size={14} />
|
||||
List
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="show-descriptions" className="text-sm">Show descriptions</Label>
|
||||
<Switch
|
||||
id="show-descriptions"
|
||||
checked={preferences.showDescriptions}
|
||||
onCheckedChange={(checked) => updatePreference("showDescriptions", checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="show-stats" className="text-sm">Show statistics</Label>
|
||||
<Switch
|
||||
id="show-stats"
|
||||
checked={preferences.showStats}
|
||||
onCheckedChange={(checked) => updatePreference("showStats", checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="show-tags" className="text-sm">Show tags</Label>
|
||||
<Switch
|
||||
id="show-tags"
|
||||
checked={preferences.showTags}
|
||||
onCheckedChange={(checked) => updatePreference("showTags", checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="sticky-header" className="text-sm">Sticky header</Label>
|
||||
<Switch
|
||||
id="sticky-header"
|
||||
checked={preferences.stickyHeader}
|
||||
onCheckedChange={(checked) => updatePreference("stickyHeader", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdvancedMode ? (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* Code & Content Section - Advanced */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
|
||||
<Sparkles size={14} className="text-[color:var(--accent)]" />
|
||||
Code & Content
|
||||
</Label>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code-font-size" className="text-xs text-[color:var(--ink-soft)]">
|
||||
Code font size
|
||||
</Label>
|
||||
<Select
|
||||
value={preferences.codeFontSize}
|
||||
onValueChange={(value) => updatePreference("codeFontSize", value as CodeFontSize)}
|
||||
>
|
||||
<SelectTrigger id="code-font-size">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="small">Small (12px)</SelectItem>
|
||||
<SelectItem value="medium">Medium (14px)</SelectItem>
|
||||
<SelectItem value="large">Large (16px)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="animation-level" className="text-xs text-[color:var(--ink-soft)]">
|
||||
Animation level
|
||||
</Label>
|
||||
<Select
|
||||
value={preferences.animationLevel}
|
||||
onValueChange={(value) => updatePreference("animationLevel", value as AnimationLevel)}
|
||||
>
|
||||
<SelectTrigger id="animation-level">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full">
|
||||
<span className="flex items-center gap-2">
|
||||
<Zap size={14} />
|
||||
Full
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="reduced">Reduced</SelectItem>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="line-numbers" className="text-sm">Line numbers in code</Label>
|
||||
<Switch
|
||||
id="line-numbers"
|
||||
checked={preferences.lineNumbers}
|
||||
onCheckedChange={(checked) => updatePreference("lineNumbers", checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="word-wrap" className="text-sm">Word wrap in code</Label>
|
||||
<Switch
|
||||
id="word-wrap"
|
||||
checked={preferences.wordWrap}
|
||||
onCheckedChange={(checked) => updatePreference("wordWrap", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Accessibility Section - Advanced */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
|
||||
<Eye size={14} className="text-[color:var(--accent)]" />
|
||||
Accessibility
|
||||
</Label>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="reduced-motion" className="text-sm">Reduced motion</Label>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">Minimize animations</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="reduced-motion"
|
||||
checked={preferences.reducedMotion}
|
||||
onCheckedChange={(checked) => updatePreference("reducedMotion", checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="high-contrast" className="text-sm">High contrast</Label>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">Increase color contrast</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="high-contrast"
|
||||
checked={preferences.highContrast}
|
||||
onCheckedChange={(checked) => updatePreference("highContrast", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Experimental Features - Advanced */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
|
||||
<Sparkles size={14} className="text-[color:var(--gold)]" />
|
||||
Experimental
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="experimental-features" className="text-sm">Enable experimental features</Label>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">Try new features before they're released</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="experimental-features"
|
||||
checked={preferences.experimentalFeatures}
|
||||
onCheckedChange={(checked) => updatePreference("experimentalFeatures", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Reset Section */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[color:var(--ink)]">Reset preferences</p>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">Restore all settings to defaults</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
resetPreferences();
|
||||
toast.success("Preferences reset to defaults");
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Organizations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { RefObject } from "react";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { SkillCardSkeletonGrid } from "../../components/skeletons/SkillCardSkeleton";
|
||||
import { SkillCard } from "../../components/SkillCard";
|
||||
import { SkillListItem } from "../../components/SkillListItem";
|
||||
import { getPlatformLabels } from "../../components/skillDetailUtils";
|
||||
import { SkillMetricsRow, SkillStatsTripletLine } from "../../components/SkillStats";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { SkillStatsTripletLine } from "../../components/SkillStats";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { UserBadge } from "../../components/UserBadge";
|
||||
import { getSkillBadges } from "../../lib/badges";
|
||||
@@ -29,7 +25,7 @@ export function SkillsResults({
|
||||
isLoadingSkills,
|
||||
sorted,
|
||||
view,
|
||||
listDoneLoading,
|
||||
listDoneLoading: _listDoneLoading,
|
||||
hasQuery,
|
||||
canLoadMore,
|
||||
isLoadingMore,
|
||||
@@ -40,14 +36,27 @@ export function SkillsResults({
|
||||
return (
|
||||
<>
|
||||
{isLoadingSkills ? (
|
||||
<SkillCardSkeletonGrid count={6} />
|
||||
<div className="skeleton-list">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className="skeleton-row">
|
||||
<div className="skeleton-icon" />
|
||||
<div className="skeleton-row-body">
|
||||
<div className="skeleton-bar skeleton-bar-lg" />
|
||||
<div className="skeleton-bar skeleton-bar-sm" />
|
||||
<div className="skeleton-bar skeleton-bar-xs" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : sorted.length === 0 ? (
|
||||
<EmptyState
|
||||
title={listDoneLoading || hasQuery ? "No skills match that filter" : "Loading skills..."}
|
||||
description={hasQuery ? "Try adjusting your search or filters." : undefined}
|
||||
/>
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">No skills found</p>
|
||||
<p className="empty-state-body">
|
||||
{hasQuery ? "Try a different search term or remove filters." : "No skills have been published yet."}
|
||||
</p>
|
||||
</div>
|
||||
) : view === "cards" ? (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
|
||||
<div className="grid">
|
||||
{sorted.map((entry) => {
|
||||
const skill = entry.skill;
|
||||
const clawdis = entry.latestVersion?.parsed?.clawdis;
|
||||
@@ -65,105 +74,57 @@ export function SkillsResults({
|
||||
platformLabels={platforms.length ? platforms : undefined}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<>
|
||||
<div className="skill-card-footer-rows">
|
||||
<UserBadge
|
||||
user={entry.owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix="by"
|
||||
link={false}
|
||||
/>
|
||||
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
|
||||
<div className="stat">
|
||||
<SkillStatsTripletLine stats={skill.stats} />
|
||||
</span>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* List view */
|
||||
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)]">
|
||||
{/* Table header */}
|
||||
<div className="grid grid-cols-[minmax(160px,1.2fr)_minmax(120px,1.6fr)_minmax(100px,0.8fr)_minmax(120px,1fr)] gap-4 border-b border-[color:var(--line)] bg-[color:var(--surface-muted)] px-5 py-3 text-xs font-bold uppercase tracking-wider text-[color:var(--ink-soft)]">
|
||||
<span>Skill</span>
|
||||
<span>Summary</span>
|
||||
<span>Author</span>
|
||||
<span className="text-right">Stats</span>
|
||||
</div>
|
||||
{sorted.map((entry, i) => {
|
||||
<div className="results-list">
|
||||
{sorted.map((entry) => {
|
||||
const skill = entry.skill;
|
||||
const ownerHandle = entry.owner?.handle ?? entry.ownerHandle ?? null;
|
||||
const skillHref = buildSkillHref(skill, ownerHandle);
|
||||
return (
|
||||
<Link
|
||||
<SkillListItem
|
||||
key={skill._id}
|
||||
className={`grid grid-cols-[minmax(160px,1.2fr)_minmax(120px,1.6fr)_minmax(100px,0.8fr)_minmax(120px,1fr)] items-center gap-4 px-5 py-3.5 no-underline transition-colors hover:bg-[color:var(--surface-muted)] ${
|
||||
i % 2 === 0 ? "bg-[color:var(--surface)]" : "bg-[color:var(--bg-soft)]"
|
||||
}`}
|
||||
to={skillHref}
|
||||
>
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-semibold text-[color:var(--ink)]">
|
||||
{skill.displayName}
|
||||
</span>
|
||||
{getSkillBadges(skill).map((badge) => (
|
||||
<Badge key={badge} variant="compact">
|
||||
{badge}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
{entry.latestVersion?.version ? (
|
||||
<span className="font-mono text-xs text-[color:var(--ink-soft)]">
|
||||
v{entry.latestVersion.version}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="truncate text-sm text-[color:var(--ink-soft)]">
|
||||
{skill.summary ?? "No summary provided."}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
<UserBadge
|
||||
user={entry.owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix=""
|
||||
link={false}
|
||||
/>
|
||||
</span>
|
||||
<span className="flex flex-wrap justify-end gap-3 text-xs text-[color:var(--ink-soft)]">
|
||||
<SkillMetricsRow stats={skill.stats} />
|
||||
</span>
|
||||
</Link>
|
||||
skill={skill}
|
||||
ownerHandle={ownerHandle}
|
||||
owner={entry.owner}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{(canLoadMore || isLoadingMore) && (
|
||||
<div ref={canAutoLoad ? loadMoreRef : null} className="flex justify-center pt-4">
|
||||
{canLoadMore || isLoadingMore ? (
|
||||
<div
|
||||
ref={canAutoLoad ? loadMoreRef : null}
|
||||
className="card mt-4 flex justify-center"
|
||||
>
|
||||
{canAutoLoad ? (
|
||||
isLoadingMore ? (
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-[color:var(--ink-soft)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading more...
|
||||
</div>
|
||||
"Loading more..."
|
||||
) : (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">Scroll to load more</div>
|
||||
"Scroll to load more"
|
||||
)
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadMore}
|
||||
disabled={isLoadingMore}
|
||||
loading={isLoadingMore}
|
||||
>
|
||||
Load more
|
||||
<Button type="button" onClick={loadMore} disabled={isLoadingMore}>
|
||||
{isLoadingMore ? "Loading..." : "Load more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ export function SkillsToolbar({
|
||||
onClick={view === "list" ? onToggleView : undefined}
|
||||
className={`inline-flex h-[30px] w-[30px] items-center justify-center rounded-full transition-colors ${
|
||||
view === "cards"
|
||||
? "bg-[color:var(--accent)] text-white"
|
||||
? "bg-accent text-accent-fg"
|
||||
: "text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
|
||||
}`}
|
||||
aria-label="Grid view"
|
||||
@@ -231,7 +231,7 @@ export function SkillsToolbar({
|
||||
onClick={view === "cards" ? onToggleView : undefined}
|
||||
className={`inline-flex h-[30px] w-[30px] items-center justify-center rounded-full transition-colors ${
|
||||
view === "list"
|
||||
? "bg-[color:var(--accent)] text-white"
|
||||
? "bg-accent text-accent-fg"
|
||||
: "text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
|
||||
}`}
|
||||
aria-label="List view"
|
||||
@@ -260,10 +260,10 @@ function FilterChip({
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={`inline-flex min-h-[36px] items-center gap-1.5 rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 text-xs font-semibold transition-all duration-150 dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)] ${
|
||||
className={`inline-flex items-center gap-1.5 rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 min-h-[36px] text-xs font-semibold transition-all duration-150 ${
|
||||
active
|
||||
? "border-[color:var(--accent)]/30 bg-[color:var(--accent)]/10 text-[color:var(--accent)] dark:border-[rgba(255,131,95,0.34)] dark:bg-[rgba(255,131,95,0.14)] dark:text-[#ffd5c9]"
|
||||
: "text-[color:var(--ink-soft)] hover:border-[color:var(--border-ui-hover)] hover:text-[color:var(--ink)] dark:text-[rgba(245,238,232,0.88)] dark:hover:border-[rgba(255,255,255,0.2)] dark:hover:text-[rgba(245,238,232,0.96)]"
|
||||
? "border-[color:var(--accent)]/30 bg-[color:var(--accent)]/10 text-[color:var(--accent)]"
|
||||
: "text-[color:var(--ink-soft)] hover:border-[color:var(--border-ui-hover)] hover:text-[color:var(--ink)] dark:text-[rgba(245,238,232,0.88)] dark:hover:text-[rgba(245,238,232,0.96)]"
|
||||
}`}
|
||||
>
|
||||
{active && !icon && <Check className="h-3 w-3" />}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useQuery } from "convex/react";
|
||||
import { useRef } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { SKILL_CAPABILITY_TAGS } from "../../../convex/lib/skillCapabilityTags";
|
||||
import { Container } from "../../components/layout/Container";
|
||||
import { BrowseSidebar } from "../../components/BrowseSidebar";
|
||||
import { SKILL_CATEGORIES } from "../../lib/categories";
|
||||
import { formatCompactStat } from "../../lib/numberFormat";
|
||||
import { parseSort } from "./-params";
|
||||
import { SkillsResults } from "./-SkillsResults";
|
||||
import { SkillsToolbar } from "./-SkillsToolbar";
|
||||
import { useSkillsBrowseModel, type SkillsSearchState } from "./-useSkillsBrowseModel";
|
||||
|
||||
const SKILL_CAPABILITY_TAG_SET = new Set<string>(SKILL_CAPABILITY_TAGS);
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "downloads", label: "Most downloaded" },
|
||||
{ value: "stars", label: "Most starred" },
|
||||
{ value: "installs", label: "Most installed" },
|
||||
{ value: "updated", label: "Recently updated" },
|
||||
{ value: "newest", label: "Newest" },
|
||||
{ value: "name", label: "Name" },
|
||||
];
|
||||
|
||||
export const Route = createFileRoute("/skills/")({
|
||||
validateSearch: (search): SkillsSearchState => {
|
||||
@@ -27,10 +35,6 @@ export const Route = createFileRoute("/skills/")({
|
||||
search.nonSuspicious === true
|
||||
? true
|
||||
: undefined,
|
||||
tag:
|
||||
typeof search.tag === "string" && SKILL_CAPABILITY_TAG_SET.has(search.tag)
|
||||
? search.tag
|
||||
: undefined,
|
||||
view: search.view === "cards" || search.view === "list" ? search.view : undefined,
|
||||
focus: search.focus === "search" ? "search" : undefined,
|
||||
};
|
||||
@@ -46,7 +50,6 @@ export const Route = createFileRoute("/skills/")({
|
||||
dir: search.dir || undefined,
|
||||
highlighted: search.highlighted || undefined,
|
||||
nonSuspicious: search.nonSuspicious || undefined,
|
||||
tag: search.tag || undefined,
|
||||
view: search.view || undefined,
|
||||
focus: search.focus || undefined,
|
||||
},
|
||||
@@ -62,7 +65,8 @@ export function SkillsIndex() {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const totalSkills = useQuery(api.skills.countPublicSkills);
|
||||
const totalSkillsText =
|
||||
typeof totalSkills === "number" ? totalSkills.toLocaleString("en-US") : null;
|
||||
typeof totalSkills === "number" ? formatCompactStat(totalSkills) : null;
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const model = useSkillsBrowseModel({
|
||||
navigate,
|
||||
@@ -70,60 +74,120 @@ export function SkillsIndex() {
|
||||
searchInputRef,
|
||||
});
|
||||
|
||||
const sortOptionsWithRelevance = model.hasQuery
|
||||
? [{ value: "relevance", label: "Relevance" }, ...SORT_OPTIONS]
|
||||
: SORT_OPTIONS;
|
||||
|
||||
const handleFilterToggle = useCallback(
|
||||
(key: string) => {
|
||||
if (key === "highlighted") model.onToggleHighlighted();
|
||||
else if (key === "nonSuspicious") model.onToggleNonSuspicious();
|
||||
},
|
||||
[model.onToggleHighlighted, model.onToggleNonSuspicious],
|
||||
);
|
||||
|
||||
const handleCategoryChange = useCallback(
|
||||
(slug: string | undefined) => {
|
||||
if (slug) {
|
||||
const cat = SKILL_CATEGORIES.find((c) => c.slug === slug);
|
||||
if (cat?.keywords[0]) {
|
||||
model.onQueryChange(cat.keywords[0]);
|
||||
}
|
||||
} else {
|
||||
model.onQueryChange("");
|
||||
}
|
||||
},
|
||||
[model.onQueryChange],
|
||||
);
|
||||
|
||||
const activeCategory = useMemo(() => {
|
||||
if (!model.query) return undefined;
|
||||
return (
|
||||
SKILL_CATEGORIES.find((c) =>
|
||||
c.keywords.some((k) => k === model.query.trim().toLowerCase()),
|
||||
)?.slug ?? undefined
|
||||
);
|
||||
}, [model.query]);
|
||||
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container size="wide">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<header>
|
||||
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)]">
|
||||
Skills
|
||||
<span className="ml-2 text-lg font-normal text-[color:var(--ink-soft)] opacity-70">
|
||||
({model.hasQuery || model.highlightedOnly || model.nonSuspiciousOnly
|
||||
? model.sorted.length.toLocaleString("en-US")
|
||||
: totalSkillsText ?? "…"})
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
|
||||
<main className="browse-page">
|
||||
<div className="browse-page-header">
|
||||
<button
|
||||
className="browse-sidebar-toggle"
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
aria-label="Toggle filters"
|
||||
>
|
||||
Filters
|
||||
</button>
|
||||
<h1 className="browse-title">
|
||||
Skills
|
||||
{totalSkillsText ? (
|
||||
<span className="browse-count">{totalSkillsText}</span>
|
||||
) : null}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="browse-page-search">
|
||||
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="browse-search-input"
|
||||
value={model.query}
|
||||
onChange={(event) => model.onQueryChange(event.target.value)}
|
||||
placeholder="Search skills..."
|
||||
/>
|
||||
</div>
|
||||
<div className={`browse-layout${sidebarOpen ? " sidebar-open" : ""}`}>
|
||||
<BrowseSidebar
|
||||
categories={SKILL_CATEGORIES}
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
sortOptions={sortOptionsWithRelevance}
|
||||
activeSort={model.sort}
|
||||
onSortChange={model.onSortChange}
|
||||
filters={[
|
||||
{ key: "highlighted", label: "Staff picks", active: model.highlightedOnly },
|
||||
{ key: "nonSuspicious", label: "Hide suspicious", active: model.nonSuspiciousOnly },
|
||||
]}
|
||||
onFilterToggle={handleFilterToggle}
|
||||
/>
|
||||
<div className="browse-results">
|
||||
<div className="browse-results-toolbar">
|
||||
<span className="browse-results-count">
|
||||
{model.isLoadingSkills
|
||||
? "Loading skills..."
|
||||
: `Browse the skill library${model.activeFilters.length ? ` (${model.activeFilters.join(", ")})` : ""}.`}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Toolbar */}
|
||||
<SkillsToolbar
|
||||
searchInputRef={searchInputRef}
|
||||
query={model.query}
|
||||
hasQuery={model.hasQuery}
|
||||
sort={model.sort}
|
||||
dir={model.dir}
|
||||
view={model.view}
|
||||
highlightedOnly={model.highlightedOnly}
|
||||
nonSuspiciousOnly={model.nonSuspiciousOnly}
|
||||
capabilityTag={model.capabilityTag}
|
||||
onQueryChange={model.onQueryChange}
|
||||
onToggleHighlighted={model.onToggleHighlighted}
|
||||
onToggleNonSuspicious={model.onToggleNonSuspicious}
|
||||
onCapabilityTagChange={model.onCapabilityTagChange}
|
||||
onSortChange={model.onSortChange}
|
||||
onToggleDir={model.onToggleDir}
|
||||
onToggleView={model.onToggleView}
|
||||
/>
|
||||
|
||||
{/* Results count */}
|
||||
{model.sorted.length > 0 && (
|
||||
<p className="text-xs font-medium text-[color:var(--ink-soft)]">
|
||||
{model.sorted.length}
|
||||
{!model.hasQuery && totalSkillsText ? ` of ${totalSkillsText}` : ""} skills
|
||||
{model.hasQuery ? ` matching "${model.query}"` : ""}
|
||||
{model.highlightedOnly || model.nonSuspiciousOnly || model.capabilityTag
|
||||
? ` (filtered)`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
? "\u2014"
|
||||
: `${model.sorted.length} results`}
|
||||
{(model.hasQuery || model.highlightedOnly || model.nonSuspiciousOnly) ? (
|
||||
<button
|
||||
className="browse-clear-btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
model.onQueryChange("");
|
||||
if (model.highlightedOnly) model.onToggleHighlighted();
|
||||
if (model.nonSuspiciousOnly) model.onToggleNonSuspicious();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="browse-view-toggle">
|
||||
<button
|
||||
className={`browse-view-btn${model.view === "list" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={model.view === "cards" ? model.onToggleView : undefined}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
className={`browse-view-btn${model.view === "cards" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={model.view === "list" ? model.onToggleView : undefined}
|
||||
>
|
||||
Cards
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SkillsResults
|
||||
isLoadingSkills={model.isLoadingSkills}
|
||||
sorted={model.sorted}
|
||||
@@ -137,7 +201,7 @@ export function SkillsIndex() {
|
||||
loadMore={model.loadMore}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,15 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useAction, useQuery } from "convex/react";
|
||||
import { Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Container } from "../../components/layout/Container";
|
||||
import { SkillCardSkeletonGrid } from "../../components/skeletons/SkillCardSkeleton";
|
||||
import { SoulCard } from "../../components/SoulCard";
|
||||
import { SoulMetricsRow, SoulStatsTripletLine } from "../../components/SoulStats";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import type { PublicSoul } from "../../lib/publicUser";
|
||||
|
||||
const sortKeys = ["newest", "downloads", "stars", "name", "updated"] as const;
|
||||
type SortKey = (typeof sortKeys)[number];
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
function parseSort(value: unknown): SortKey {
|
||||
if (typeof value !== "string") return "newest";
|
||||
if ((sortKeys as readonly string[]).includes(value)) return value as SortKey;
|
||||
return "newest";
|
||||
}
|
||||
|
||||
function parseDir(value: unknown, sort: SortKey): SortDir {
|
||||
if (value === "asc" || value === "desc") return value;
|
||||
return sort === "name" ? "asc" : "desc";
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/souls/")({
|
||||
validateSearch: (search) => {
|
||||
return {
|
||||
@@ -37,223 +20,61 @@ export const Route = createFileRoute("/souls/")({
|
||||
focus: search.focus === "search" ? "search" : undefined,
|
||||
};
|
||||
},
|
||||
component: SoulsIndex,
|
||||
component: SoulsHoldingPage,
|
||||
});
|
||||
|
||||
function SoulsIndex() {
|
||||
const navigate = Route.useNavigate();
|
||||
const search = Route.useSearch();
|
||||
const sort = search.sort ?? "newest";
|
||||
const dir = parseDir(search.dir, sort);
|
||||
const view = search.view ?? "list";
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
|
||||
const souls = useQuery(api.souls.list, { limit: 500 }) as PublicSoul[] | undefined;
|
||||
const ensureSoulSeeds = useAction(api.seed.ensureSoulSeeds);
|
||||
const seedEnsuredRef = useRef(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const isLoadingSouls = souls === undefined;
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? "");
|
||||
}, [search.q]);
|
||||
|
||||
// Auto-focus search input when focus=search param is present
|
||||
useEffect(() => {
|
||||
if (search.focus === "search" && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
// Clear the focus param from URL to avoid re-focusing on navigation
|
||||
void navigate({ search: (prev) => ({ ...prev, focus: undefined }), replace: true });
|
||||
}
|
||||
}, [search.focus, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (seedEnsuredRef.current) return;
|
||||
seedEnsuredRef.current = true;
|
||||
void ensureSoulSeeds({});
|
||||
}, [ensureSoulSeeds]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const value = query.trim().toLowerCase();
|
||||
const all = souls ?? [];
|
||||
if (!value) return all;
|
||||
return all.filter((soul) => {
|
||||
if (soul.slug.toLowerCase().includes(value)) return true;
|
||||
if (soul.displayName.toLowerCase().includes(value)) return true;
|
||||
return (soul.summary ?? "").toLowerCase().includes(value);
|
||||
});
|
||||
}, [query, souls]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const multiplier = dir === "asc" ? 1 : -1;
|
||||
const results = [...filtered];
|
||||
results.sort((a, b) => {
|
||||
switch (sort) {
|
||||
case "downloads":
|
||||
return (a.stats.downloads - b.stats.downloads) * multiplier;
|
||||
case "stars":
|
||||
return (a.stats.stars - b.stats.stars) * multiplier;
|
||||
case "updated":
|
||||
return (a.updatedAt - b.updatedAt) * multiplier;
|
||||
case "name":
|
||||
return (
|
||||
(a.displayName.localeCompare(b.displayName) || a.slug.localeCompare(b.slug)) *
|
||||
multiplier
|
||||
);
|
||||
default:
|
||||
return (a.createdAt - b.createdAt) * multiplier;
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}, [dir, filtered, sort]);
|
||||
|
||||
const showing = sorted.length;
|
||||
const total = souls?.length;
|
||||
|
||||
function SoulsHoldingPage() {
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container>
|
||||
<header className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)] mb-2">Souls</h1>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
{isLoadingSouls
|
||||
? "Loading souls..."
|
||||
: `${showing}${typeof total === "number" ? ` of ${total}` : ""} souls.`}
|
||||
</p>
|
||||
<main className="browse-page souls-coming-page">
|
||||
<section className="souls-coming-hero">
|
||||
<div>
|
||||
<div className="skill-card-tags mb-3">
|
||||
<Badge>Souls</Badge>
|
||||
<Badge variant="accent">Coming soon</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:items-end">
|
||||
<div className="relative flex items-center">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 h-4 w-4 text-[color:var(--ink-soft)] opacity-50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
className="pl-9"
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
const trimmed = next.trim();
|
||||
setQuery(next);
|
||||
void navigate({
|
||||
search: (prev) => ({ ...prev, q: trimmed ? next : undefined }),
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
placeholder="Filter by name, slug, or summary..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="min-h-[34px] rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3 py-1.5 text-sm text-[color:var(--ink)] dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
|
||||
value={sort}
|
||||
onChange={(event) => {
|
||||
const nextSort = parseSort(event.target.value);
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
sort: nextSort,
|
||||
dir: parseDir(prev.dir, nextSort),
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
aria-label="Sort souls"
|
||||
>
|
||||
<option value="newest">Newest</option>
|
||||
<option value="updated">Recently updated</option>
|
||||
<option value="downloads">Downloads</option>
|
||||
<option value="stars">Stars</option>
|
||||
<option value="name">Name</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`Sort direction ${dir}`}
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
dir: parseDir(prev.dir, sort) === "asc" ? "desc" : "asc",
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{dir === "asc" ? "\u2191" : "\u2193"}
|
||||
</Button>
|
||||
<Button
|
||||
variant={view === "cards" ? "primary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
view: prev.view === "cards" ? undefined : "cards",
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{view === "cards" ? "List" : "Cards"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mt-6">
|
||||
{isLoadingSouls ? (
|
||||
<SkillCardSkeletonGrid count={6} />
|
||||
) : showing === 0 ? (
|
||||
<EmptyState
|
||||
title="No souls match that filter"
|
||||
description="Try a different search term."
|
||||
/>
|
||||
) : view === "cards" ? (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
|
||||
{sorted.map((soul) => (
|
||||
<SoulCard
|
||||
key={soul._id}
|
||||
soul={soul}
|
||||
summaryFallback="A SOUL.md bundle."
|
||||
meta={
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
<SoulStatsTripletLine stats={soul.stats} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{sorted.map((soul) => (
|
||||
<Link
|
||||
key={soul._id}
|
||||
className="flex items-center justify-between gap-4 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-4 transition-all duration-200 hover:-translate-y-px hover:shadow-[0_10px_20px_rgba(29,26,23,0.12)]"
|
||||
to="/souls/$slug"
|
||||
params={{ slug: soul.slug }}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-display font-bold text-[color:var(--ink)]">
|
||||
{soul.displayName}
|
||||
</span>
|
||||
<span className="text-sm text-[color:var(--ink-soft)]">/{soul.slug}</span>
|
||||
</div>
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
{soul.summary ?? "SOUL.md bundle."}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<SoulMetricsRow stats={soul.stats} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="about-title">SOUL.md discovery is on deck</h1>
|
||||
<p className="about-lead">
|
||||
This page is the holding area for public SOUL.md profiles you’ll be able to discover,
|
||||
compare, and share. We’re not shipping a half-baked directory just to tick a box.
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
<div className="souls-coming-grid">
|
||||
<article className="about-rule-card">
|
||||
<h2>Discover</h2>
|
||||
<p>Browse public system personas, writing voices, and full character sheets.</p>
|
||||
</article>
|
||||
<article className="about-rule-card">
|
||||
<h2>Share</h2>
|
||||
<p>Publish versioned SOUL.md files with attribution and clean history.</p>
|
||||
</article>
|
||||
<article className="about-rule-card">
|
||||
<h2>Compare</h2>
|
||||
<p>Inspect changes, stats, and adoption without the usual metadata sludge.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-enforcement">
|
||||
<div>
|
||||
<span className="about-callout-label">In the meantime</span>
|
||||
<p className="about-lead mb-0">
|
||||
ClawHub already handles skills and plugins. Souls will get the same discovery treatment
|
||||
once the publishing flow is ready.
|
||||
</p>
|
||||
</div>
|
||||
<div className="skill-card-tags">
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/skills" search={{ q: undefined, sort: "downloads", dir: "desc", highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}>
|
||||
Browse Skills
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/users" search={{ q: undefined }}>
|
||||
Browse Users
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { toast } from "sonner";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc } from "../../convex/_generated/dataModel";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Container } from "../components/layout/Container";
|
||||
import { SignInButton } from "../components/SignInButton";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
@@ -26,8 +25,8 @@ function Stars() {
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container size="narrow">
|
||||
<main className="browse-page">
|
||||
<div className="browse-page-narrow">
|
||||
<EmptyState
|
||||
icon={Star}
|
||||
title="Sign in to see your highlights"
|
||||
@@ -35,14 +34,14 @@ function Stars() {
|
||||
>
|
||||
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
|
||||
</EmptyState>
|
||||
</Container>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container size="narrow">
|
||||
<main className="browse-page">
|
||||
<div className="browse-page-narrow">
|
||||
<div className="flex flex-col gap-6">
|
||||
<header>
|
||||
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)]">
|
||||
@@ -105,7 +104,7 @@ function Stars() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { Star, User } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import type { Doc } from "../../../convex/_generated/dataModel";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Container } from "../../components/layout/Container";
|
||||
import { SignInButton } from "../../components/SignInButton";
|
||||
import { SkillCardSkeletonGrid } from "../../components/skeletons/SkillCardSkeleton";
|
||||
import { SkillCard } from "../../components/SkillCard";
|
||||
import { SkillStatsTripletLine } from "../../components/SkillStats";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
|
||||
import { SkillListItem } from "../../components/SkillListItem";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { Skeleton } from "../../components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs";
|
||||
import { getSkillBadges } from "../../lib/badges";
|
||||
import type { PublicSkill, PublicUser } from "../../lib/publicUser";
|
||||
|
||||
export const Route = createFileRoute("/u/$handle")({
|
||||
@@ -51,28 +40,18 @@ function UserProfile() {
|
||||
|
||||
if (user === undefined) {
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container size="narrow">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Skeleton className="h-20 w-20 rounded-full" />
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</Container>
|
||||
<main className="section">
|
||||
<Card>
|
||||
<div className="loading-indicator">Loading user…</div>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (user === null) {
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container size="narrow">
|
||||
<EmptyState
|
||||
icon={User}
|
||||
title="User not found"
|
||||
description="This user doesn't exist or their account has been removed."
|
||||
/>
|
||||
</Container>
|
||||
<main className="section">
|
||||
<Card>User not found.</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -87,134 +66,93 @@ function UserProfile() {
|
||||
const published = publishedSkills ?? [];
|
||||
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container size="narrow">
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Profile header */}
|
||||
<Card className="flex-row items-center gap-4 p-6">
|
||||
<Avatar className="h-16 w-16">
|
||||
{avatar && <AvatarImage src={avatar} alt={displayName} />}
|
||||
<AvatarFallback className="text-xl">{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h1 className="font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
{displayName}
|
||||
</h1>
|
||||
<p className="font-mono text-sm text-[color:var(--ink-soft)]">@{displayHandle}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
{isSelf ? (
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "stars" | "installed")}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="stars">Stars</TabsTrigger>
|
||||
<TabsTrigger value="installed">Installed</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="stars" className="mt-6">
|
||||
<PublishedAndStarred
|
||||
published={published}
|
||||
isLoadingPublished={isLoadingPublished}
|
||||
skills={skills}
|
||||
isLoadingSkills={isLoadingSkills}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="installed" className="mt-6">
|
||||
<InstalledSection
|
||||
includeRemoved={includeRemoved}
|
||||
onToggleRemoved={() => setIncludeRemoved((value) => !value)}
|
||||
data={installed}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<PublishedAndStarred
|
||||
published={published}
|
||||
isLoadingPublished={isLoadingPublished}
|
||||
skills={skills}
|
||||
isLoadingSkills={isLoadingSkills}
|
||||
/>
|
||||
)}
|
||||
<main className="browse-page">
|
||||
<div className="profile-header">
|
||||
<div className="profile-avatar-lg" aria-hidden="true">
|
||||
{avatar ? <img src={avatar} alt="" /> : <span>{initial}</span>}
|
||||
</div>
|
||||
</Container>
|
||||
<div className="profile-info">
|
||||
<h1 className="profile-display-name">{displayName}</h1>
|
||||
<span className="profile-handle">@{displayHandle}</span>
|
||||
<div className="profile-stats-row">
|
||||
<span className="profile-stat">{published.length} published</span>
|
||||
<span className="profile-stat">{skills.length} stars</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelf ? (
|
||||
<div className="profile-tabs" role="tablist" aria-label="Profile tabs">
|
||||
<button
|
||||
className={tab === "stars" ? "profile-tab is-active" : "profile-tab"}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === "stars"}
|
||||
onClick={() => setTab("stars")}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
className={tab === "installed" ? "profile-tab is-active" : "profile-tab"}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === "installed"}
|
||||
onClick={() => setTab("installed")}
|
||||
>
|
||||
Installed
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "installed" && isSelf ? (
|
||||
<InstalledSection
|
||||
includeRemoved={includeRemoved}
|
||||
onToggleRemoved={() => setIncludeRemoved((value) => !value)}
|
||||
data={installed}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{published.length > 0 ? (
|
||||
<>
|
||||
<h2 className="home-section-title mb-3">
|
||||
Published ({published.length})
|
||||
</h2>
|
||||
{isLoadingPublished ? (
|
||||
<Card>
|
||||
<div className="loading-indicator">Loading published skills...</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="results-list mb-6">
|
||||
{published.map((skill) => (
|
||||
<SkillListItem key={skill._id} skill={skill} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<h2 className="home-section-title mb-3">
|
||||
Stars ({skills.length})
|
||||
</h2>
|
||||
{isLoadingSkills ? (
|
||||
<Card>
|
||||
<div className="loading-indicator">Loading stars...</div>
|
||||
</Card>
|
||||
) : skills.length === 0 ? (
|
||||
<Card>No stars yet.</Card>
|
||||
) : (
|
||||
<div className="results-list">
|
||||
{skills.map((skill) => (
|
||||
<SkillListItem key={skill._id} skill={skill} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function PublishedAndStarred({
|
||||
published,
|
||||
isLoadingPublished,
|
||||
skills,
|
||||
isLoadingSkills,
|
||||
}: {
|
||||
published: PublicSkill[];
|
||||
isLoadingPublished: boolean;
|
||||
skills: PublicSkill[];
|
||||
isLoadingSkills: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Published */}
|
||||
<section>
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Published</h2>
|
||||
<p className="mt-1 mb-4 text-sm text-[color:var(--ink-soft)]">
|
||||
Skills published by this user.
|
||||
</p>
|
||||
{isLoadingPublished ? (
|
||||
<SkillCardSkeletonGrid count={3} />
|
||||
) : published.length > 0 ? (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
|
||||
{published.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
badge={getSkillBadges(skill)}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
|
||||
<SkillStatsTripletLine stats={skill.stats} />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">No published skills yet.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Stars */}
|
||||
<section>
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Stars</h2>
|
||||
<p className="mt-1 mb-4 text-sm text-[color:var(--ink-soft)]">
|
||||
Skills this user has starred.
|
||||
</p>
|
||||
{isLoadingSkills ? (
|
||||
<SkillCardSkeletonGrid count={3} />
|
||||
) : skills.length === 0 ? (
|
||||
<EmptyState icon={Star} title="No stars yet" />
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-5">
|
||||
{skills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
badge={getSkillBadges(skill)}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<span className="text-[0.8rem] text-[color:var(--ink-soft)]">
|
||||
<SkillStatsTripletLine stats={skill.stats} />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledSection(props: {
|
||||
includeRemoved: boolean;
|
||||
onToggleRemoved: () => void;
|
||||
@@ -223,51 +161,51 @@ function InstalledSection(props: {
|
||||
const clearTelemetry = useMutation(api.telemetry.clearMyTelemetry);
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const data = props.data;
|
||||
|
||||
if (data === undefined) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Installed</h2>
|
||||
<SkillCardSkeletonGrid count={3} />
|
||||
</div>
|
||||
<>
|
||||
<h2 className="section-title text-xl">
|
||||
Installed
|
||||
</h2>
|
||||
<Card>
|
||||
<div className="loading-indicator">Loading telemetry…</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (data === null) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Installed</h2>
|
||||
<EmptyState title="Sign in to view your installed skills">
|
||||
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
|
||||
</EmptyState>
|
||||
</div>
|
||||
<>
|
||||
<h2 className="section-title text-xl">
|
||||
Installed
|
||||
</h2>
|
||||
<Card>Sign in to view your installed skills.</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Installed</h2>
|
||||
<p className="max-w-2xl text-sm text-[color:var(--ink-soft)]">
|
||||
<>
|
||||
<h2 className="section-title text-xl">
|
||||
Installed
|
||||
</h2>
|
||||
<p className="section-subtitle max-w-[760px]">
|
||||
Private view. Only you can see your folders/roots. Everyone else only sees aggregated
|
||||
install counts per skill.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={props.onToggleRemoved}>
|
||||
<div className="profile-actions">
|
||||
<Button type="button" onClick={props.onToggleRemoved}>
|
||||
{props.includeRemoved ? "Hide removed" : "Show removed"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowRaw((value) => !value)}>
|
||||
<Button type="button" onClick={() => setShowRaw((value) => !value)}>
|
||||
{showRaw ? "Hide JSON" : "Show JSON"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
toast("Delete all telemetry data?", {
|
||||
action: {
|
||||
label: "Delete",
|
||||
onClick: () => void clearTelemetry(),
|
||||
},
|
||||
});
|
||||
if (!window.confirm("Delete all telemetry data?")) return;
|
||||
void clearTelemetry();
|
||||
}}
|
||||
>
|
||||
Delete telemetry
|
||||
@@ -275,59 +213,47 @@ function InstalledSection(props: {
|
||||
</div>
|
||||
|
||||
{showRaw ? (
|
||||
<Card className="mb-4">
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap break-all">
|
||||
<Card className="telemetry-json mb-4">
|
||||
<pre className="mono m-0 whitespace-pre-wrap">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{data.roots.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No telemetry yet"
|
||||
description='Run "clawhub sync" from the CLI to start tracking.'
|
||||
/>
|
||||
<Card>No telemetry yet. Run `clawhub sync` from the CLI.</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{data.roots.map((root) => (
|
||||
<Card key={root.rootId}>
|
||||
<div className="flex items-center justify-between">
|
||||
<Card key={root.rootId} className="telemetry-root">
|
||||
<div className="telemetry-root-header">
|
||||
<div>
|
||||
<h3 className="font-display text-sm font-bold text-[color:var(--ink)]">
|
||||
{root.label}
|
||||
</h3>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">
|
||||
<div className="telemetry-root-title">{root.label}</div>
|
||||
<div className="telemetry-root-meta">
|
||||
Last sync {new Date(root.lastSeenAt).toLocaleString()}
|
||||
{root.expiredAt ? " · stale" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="default">{root.skills.length} skills</Badge>
|
||||
<Badge>{root.skills.length} skills</Badge>
|
||||
</div>
|
||||
{root.skills.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
No skills found in this root.
|
||||
</p>
|
||||
<div className="stat">No skills found in this root.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="telemetry-skill-list">
|
||||
{root.skills.map((entry) => (
|
||||
<a
|
||||
key={`${root.rootId}:${entry.skill.slug}`}
|
||||
className="flex items-center justify-between rounded-[var(--radius-sm)] px-3 py-2 text-sm no-underline transition-colors hover:bg-[color:var(--surface-muted)]"
|
||||
href={`/${encodeURIComponent(String(entry.skill.ownerUserId))}/${entry.skill.slug}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-semibold text-[color:var(--ink)]">
|
||||
{entry.skill.displayName}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-[color:var(--ink-soft)]">
|
||||
/{entry.skill.slug}
|
||||
</span>
|
||||
</span>
|
||||
<span className="font-mono text-xs text-[color:var(--ink-soft)]">
|
||||
<div key={`${root.rootId}:${entry.skill.slug}`} className="telemetry-skill-row">
|
||||
<a
|
||||
className="telemetry-skill-link"
|
||||
href={`/${encodeURIComponent(String(entry.skill.ownerUserId))}/${entry.skill.slug}`}
|
||||
>
|
||||
<span>{entry.skill.displayName}</span>
|
||||
<span className="telemetry-skill-slug">/{entry.skill.slug}</span>
|
||||
</a>
|
||||
<div className="telemetry-skill-meta mono">
|
||||
{entry.lastVersion ? `v${entry.lastVersion}` : "v?"}{" "}
|
||||
{entry.removedAt ? "· removed" : ""}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -335,7 +261,7 @@ function InstalledSection(props: {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { UserListItem } from "../../components/UserListItem";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { convexHttp } from "../../convex/client";
|
||||
import type { PublicUser } from "../../lib/publicUser";
|
||||
|
||||
type UserSearchState = {
|
||||
q?: string;
|
||||
};
|
||||
|
||||
type UsersLoaderResult = { items: PublicUser[]; total: number };
|
||||
|
||||
export const Route = createFileRoute("/users/")({
|
||||
validateSearch: (search): UserSearchState => ({
|
||||
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
|
||||
}),
|
||||
component: UsersIndex,
|
||||
});
|
||||
|
||||
function UsersIndex() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
const [result, setResult] = useState<UsersLoaderResult | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchUsers = useCallback(async (q?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await convexHttp.query(api.users.listPublic, {
|
||||
limit: 48,
|
||||
search: q,
|
||||
});
|
||||
setResult(data as UsersLoaderResult);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? "");
|
||||
void fetchUsers(search.q);
|
||||
}, [search.q, fetchUsers]);
|
||||
|
||||
const users = result?.items ?? [];
|
||||
|
||||
return (
|
||||
<main className="browse-page">
|
||||
<div className="browse-page-header">
|
||||
<h1 className="browse-title">
|
||||
Users
|
||||
{typeof result?.total === "number" ? (
|
||||
<span className="browse-count">{result.total}</span>
|
||||
) : null}
|
||||
</h1>
|
||||
</div>
|
||||
<form
|
||||
className="browse-page-search"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void navigate({
|
||||
search: {
|
||||
q: query.trim() || undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="browse-search-input"
|
||||
placeholder="Search users..."
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="browse-results">
|
||||
<div className="browse-results-toolbar">
|
||||
<span className="browse-results-count">
|
||||
{loading ? "Loading users..." : `${users.length} users`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<div className="loading-indicator">Loading users...</div>
|
||||
</Card>
|
||||
) : users.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">No users found</p>
|
||||
<p className="empty-state-body">Try a different handle or name.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="results-list">
|
||||
{users.map((user) => (
|
||||
<UserListItem key={user._id} user={user} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||