@unicitylabs/sphere-ui 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -140,15 +140,23 @@ Defined in `src/styles/tokens.css` via `@theme {}` block:
140
140
 
141
141
  Backward-compatible aliases `admin-card`, `admin-input`, etc. are also available.
142
142
 
143
- ## Media components (v0.1.16+)
143
+ ## Media components
144
144
 
145
- For uploading images (logos, banners, screenshots) and rendering marketplace cards:
145
+ Components for project preview UIs — same look as sphere wallet's marketplace, so dev-portal and backoffice can show creators what their project will look like.
146
146
 
147
+ **Upload (v0.1.16+):**
147
148
  - `<MediaUploader>` — drag-drop file upload + URL paste with progress, validation, error states. Pass an `uploadFn` prop that performs your presign → PUT → confirm flow against your backend.
148
149
  - `<MediaGallery>` — sortable list of screenshots (max 10) via @dnd-kit; uses `<MediaUploader>` inline for adding items.
149
- - `<MarketplaceProjectCard>` — live preview of how a project card will look in the sphere wallet marketplace. First marketplace-tier component in sphere-ui (rest are admin-tier).
150
150
 
151
- Helper exports: `MEDIA_LIMITS`, `isMimeAllowed`, `isSizeAllowed`, `humanSize`. Types: `MediaKind`, `MediaMime`, `MediaLimit`, `MediaUploadFn`, `MediaUploadResult`, `MediaItem`.
151
+ **Marketplace preview (v0.1.17+):**
152
+ - `<MarketplaceProjectCard>` — 1:1 visual copy of sphere wallet's regular marketplace card. Banner + accentColor gradient, logo overflow ring, category badge, Users/Target/ThumbsUp stats, optional install button overlay. Framer-motion hover lift.
153
+ - `<FeaturedProjectCard>` — 1:1 copy of sphere wallet's "featured" variant. Full-banner background, Star badge, bottom-overlay content.
154
+ - `<InstalledProjectIcon>` — 1:1 copy of sphere wallet's desktop dock icon. Accepts `showLabel` prop for dock vs grid layouts.
155
+ - `<ProjectPagePreview>` — stateless version of sphere wallet's full `/apps/:slug` page. Hero + stats + social + screenshots strip + quests + achievements + reviews placeholder. Accepts all data via props.
156
+
157
+ All preview components are decorative — they don't fetch data, don't wrap navigation, don't trigger install. Use them in dev-portal/backoffice forms to show the creator what users will see.
158
+
159
+ Helper exports: `MEDIA_LIMITS`, `isMimeAllowed`, `isSizeAllowed`, `humanSize`. Types: `MediaKind`, `MediaMime`, `MediaLimit`, `MediaUploadFn`, `MediaUploadResult`, `MediaItem`, `QuestPreviewSummary`, `AchievementPreviewSummary`.
152
160
 
153
161
  ## Development
154
162
 
package/dist/index.d.ts CHANGED
@@ -310,8 +310,19 @@ interface MediaUploaderProps {
310
310
  onChange: (url: string | null) => void;
311
311
  uploadFn: MediaUploadFn;
312
312
  label?: string;
313
- }
314
- declare function MediaUploader({ kind, ownerType, ownerId, value, onChange, uploadFn, label, }: MediaUploaderProps): react_jsx_runtime.JSX.Element;
313
+ /**
314
+ * When true, do NOT call uploadFn on file selection. Instead store the file
315
+ * locally, render a blob-URL preview, and call onFileSelected(file). The
316
+ * consumer is responsible for uploading later (e.g. after creating the
317
+ * parent entity to get a real ownerId).
318
+ *
319
+ * In this mode the URL paste fallback is hidden — the parent entity does
320
+ * not yet exist, so an external URL can't be persisted to it anyway.
321
+ */
322
+ deferUpload?: boolean;
323
+ onFileSelected?: (file: File | null) => void;
324
+ }
325
+ declare function MediaUploader({ kind, ownerType, ownerId, value, onChange, uploadFn, label, deferUpload, onFileSelected, }: MediaUploaderProps): react_jsx_runtime.JSX.Element;
315
326
 
316
327
  interface MediaItem {
317
328
  type: 'screenshot' | 'video';
@@ -332,23 +343,118 @@ interface MarketplaceProjectCardProps {
332
343
  tagline?: string;
333
344
  logoUrl?: string | null;
334
345
  bannerUrl?: string | null;
335
- rating?: number;
336
- userCount?: number;
346
+ /** Hex like "#FF6F00". Defaults to brand orange. */
347
+ accentColor?: string;
348
+ category?: 'game' | 'defi' | 'social' | 'tool' | 'nft' | 'other' | string;
349
+ users?: number;
350
+ quests?: number;
351
+ positivePercent?: number;
352
+ ratingCount?: number;
353
+ /** Show install button overlay. Pass `'installed'` or `'available'` to render the right state. Pass `'none'` (default) to hide it (used in preview mode). */
354
+ installState?: 'none' | 'available' | 'installed';
355
+ /** Only fires when `installState !== 'none'`. */
356
+ onInstallClick?: () => void;
357
+ /** Optional click handler for the whole card (apps wrapping with router Link externally should leave this undefined). */
358
+ onClick?: () => void;
337
359
  }
338
360
  /**
339
- * MarketplaceProjectCard — preview of a project card in sphere wallet marketplace style.
361
+ * MarketplaceProjectCard — 1:1 visual copy of sphere wallet's ProjectCard.
340
362
  *
341
363
  * Used by dev-portal & backoffice as a live preview while editing a project,
342
- * so authors can see roughly how their card will look in the marketplace.
364
+ * so authors can see exactly how their card will look in the marketplace.
365
+ *
366
+ * No `<Link>` dependency — wrap externally if router navigation is needed.
367
+ */
368
+ declare function MarketplaceProjectCard({ name, tagline, logoUrl, bannerUrl, accentColor, category, users, quests, positivePercent, ratingCount, installState, onInstallClick, onClick, }: MarketplaceProjectCardProps): react_jsx_runtime.JSX.Element;
369
+
370
+ interface FeaturedProjectCardProps {
371
+ name: string;
372
+ tagline?: string;
373
+ logoUrl?: string | null;
374
+ bannerUrl?: string | null;
375
+ /** Hex like "#FF6F00". Defaults to brand orange. */
376
+ accentColor?: string;
377
+ users?: number;
378
+ quests?: number;
379
+ positivePercent?: number;
380
+ ratingCount?: number;
381
+ onClick?: () => void;
382
+ }
383
+ /**
384
+ * FeaturedProjectCard — 1:1 visual copy of sphere wallet's FeaturedProjectCard.
385
+ *
386
+ * Wide hero-style card used in marketplace featured rails.
387
+ * No `<Link>` dependency — wrap externally if router navigation is needed.
388
+ */
389
+ declare function FeaturedProjectCard({ name, tagline, logoUrl, bannerUrl, accentColor, users, quests, positivePercent, ratingCount, onClick, }: FeaturedProjectCardProps): react_jsx_runtime.JSX.Element;
390
+
391
+ interface InstalledProjectIconProps {
392
+ name: string;
393
+ logoUrl?: string | null;
394
+ /** Hex like "#FF6F00". Defaults to brand orange. */
395
+ accentColor?: string;
396
+ onClick?: () => void;
397
+ /** When true, render the name label under the icon (dock vs grid layout). Default true. */
398
+ showLabel?: boolean;
399
+ }
400
+ /**
401
+ * InstalledProjectIcon — 1:1 visual copy of sphere wallet's desktop installed-project icon.
402
+ *
403
+ * Stripped of context menu / install state / router navigation logic.
404
+ * Drop-in preview component for design tooling.
405
+ */
406
+ declare function InstalledProjectIcon({ name, logoUrl, accentColor, onClick, showLabel, }: InstalledProjectIconProps): react_jsx_runtime.JSX.Element;
407
+
408
+ interface QuestPreviewSummary {
409
+ slug: string;
410
+ title: string;
411
+ description?: string;
412
+ points?: number;
413
+ difficulty?: 'easy' | 'medium' | 'hard';
414
+ }
415
+ interface AchievementPreviewSummary {
416
+ slug: string;
417
+ title: string;
418
+ imageUrl?: string | null;
419
+ points?: number;
420
+ }
421
+ interface ProjectPagePreviewProps {
422
+ name: string;
423
+ slug: string;
424
+ tagline?: string;
425
+ description?: string;
426
+ logoUrl?: string | null;
427
+ bannerUrl?: string | null;
428
+ accentColor?: string;
429
+ category?: string;
430
+ websiteUrl?: string;
431
+ discordUrl?: string;
432
+ twitterUrl?: string;
433
+ /** All media (screenshots + videos) for the strip */
434
+ media?: MediaItem[];
435
+ /** Live or static metrics — preview just shows what's passed */
436
+ users?: number;
437
+ activeQuests?: number;
438
+ positivePercent?: number;
439
+ ratingCount?: number;
440
+ /** Sample quests to show in the "Quests" section */
441
+ quests?: QuestPreviewSummary[];
442
+ /** Sample achievements */
443
+ achievements?: AchievementPreviewSummary[];
444
+ /** Project tags — first 3 are rendered as chips next to the category badge */
445
+ tags?: string[];
446
+ }
447
+ /**
448
+ * ProjectPagePreview — stateless 1:1 visual copy of sphere wallet's `/apps/:slug` page.
343
449
  *
344
- * Adapted to sphere-ui design tokens (dark-tier admin palette).
345
- * Visually adjacent to sphere wallet's ProjectCard but not a 1:1 copy.
450
+ * Used by dev-portal & backoffice as a live preview while editing a project.
451
+ * No router / hooks / data fetching every value comes via props.
346
452
  */
347
- declare function MarketplaceProjectCard({ name, tagline, logoUrl, bannerUrl, rating, userCount, }: MarketplaceProjectCardProps): react_jsx_runtime.JSX.Element;
453
+ declare function ProjectPagePreview({ name, tagline, description, logoUrl, bannerUrl, accentColor, category, websiteUrl, discordUrl, twitterUrl, media, users, activeQuests, positivePercent, ratingCount, quests, achievements, tags, }: ProjectPagePreviewProps): react_jsx_runtime.JSX.Element;
348
454
 
349
455
  declare const MEDIA_LIMITS: Record<MediaKind, MediaLimit>;
350
456
  declare function isMimeAllowed(kind: MediaKind, mime: string): mime is MediaMime;
351
457
  declare function isSizeAllowed(kind: MediaKind, size: number): boolean;
352
458
  declare function humanSize(bytes: number): string;
353
459
 
354
- export { AddressDisplay, AlertBanner, AppLogo, Button, type ButtonProps, type ButtonVariant, ChainInput, ConfirmDialog, CustomSelect, DashboardLayout, DataTable, EmptyState, Field, FormModal, IconArrowRight, IconBack, IconChain, IconCheck, IconChevronDown, IconChevronUp, IconChevronsDown, IconChevronsRight, IconCircle, IconDiamond, IconEdit, IconPlay, IconPlus, IconQuests, IconSearch, IconSettings, IconStar, IconTracks, IconTrash, IconUndo, IconX, Input, type InputProps, JsonPanel, JsonToggleButton, MEDIA_LIMITS, MarketplaceProjectCard, type MarketplaceProjectCardProps, MediaGallery, type MediaGalleryProps, type MediaItem, type MediaKind, type MediaLimit, type MediaMime, type MediaUploadFn, type MediaUploadResult, MediaUploader, type MediaUploaderProps, type MemoCondition, MemoConditionsEditor, type NavGroup, type NavItem, PageShell, SearchInput, Section, Select, type SelectOption, type SelectProps, SidebarNav, Skeleton, SkeletonCircle, type SkeletonCircleProps, type SkeletonCircleSize, type SkeletonProps, SkeletonText, type SkeletonTextProps, StatusBadge, Textarea, type TextareaProps, humanSize, isMimeAllowed, isSizeAllowed, tagColor };
460
+ export { type AchievementPreviewSummary, AddressDisplay, AlertBanner, AppLogo, Button, type ButtonProps, type ButtonVariant, ChainInput, ConfirmDialog, CustomSelect, DashboardLayout, DataTable, EmptyState, FeaturedProjectCard, type FeaturedProjectCardProps, Field, FormModal, IconArrowRight, IconBack, IconChain, IconCheck, IconChevronDown, IconChevronUp, IconChevronsDown, IconChevronsRight, IconCircle, IconDiamond, IconEdit, IconPlay, IconPlus, IconQuests, IconSearch, IconSettings, IconStar, IconTracks, IconTrash, IconUndo, IconX, Input, type InputProps, InstalledProjectIcon, type InstalledProjectIconProps, JsonPanel, JsonToggleButton, MEDIA_LIMITS, MarketplaceProjectCard, type MarketplaceProjectCardProps, MediaGallery, type MediaGalleryProps, type MediaItem, type MediaKind, type MediaLimit, type MediaMime, type MediaUploadFn, type MediaUploadResult, MediaUploader, type MediaUploaderProps, type MemoCondition, MemoConditionsEditor, type NavGroup, type NavItem, PageShell, ProjectPagePreview, type ProjectPagePreviewProps, type QuestPreviewSummary, SearchInput, Section, Select, type SelectOption, type SelectProps, SidebarNav, Skeleton, SkeletonCircle, type SkeletonCircleProps, type SkeletonCircleSize, type SkeletonProps, SkeletonText, type SkeletonTextProps, StatusBadge, Textarea, type TextareaProps, humanSize, isMimeAllowed, isSizeAllowed, tagColor };
package/dist/index.js CHANGED
@@ -1418,12 +1418,16 @@ function MediaUploader({
1418
1418
  value,
1419
1419
  onChange,
1420
1420
  uploadFn,
1421
- label
1421
+ label,
1422
+ deferUpload,
1423
+ onFileSelected
1422
1424
  }) {
1423
1425
  const limit = MEDIA_LIMITS[kind];
1424
1426
  const [state, setState] = useState7({ phase: "idle" });
1425
1427
  const [urlInput, setUrlInput] = useState7(value && !value.startsWith("blob:") ? value : "");
1426
1428
  const previewRef = useRef3(null);
1429
+ const fileInputRef = useRef3(null);
1430
+ const isPlaceholder = value?.includes("placehold.co") ?? false;
1427
1431
  useEffect5(
1428
1432
  () => () => {
1429
1433
  if (previewRef.current) {
@@ -1449,6 +1453,14 @@ function MediaUploader({
1449
1453
  });
1450
1454
  return;
1451
1455
  }
1456
+ if (deferUpload) {
1457
+ if (previewRef.current) URL.revokeObjectURL(previewRef.current);
1458
+ previewRef.current = URL.createObjectURL(file);
1459
+ setState({ phase: "pending", file });
1460
+ onFileSelected?.(file);
1461
+ onChange(null);
1462
+ return;
1463
+ }
1452
1464
  const abort = new AbortController();
1453
1465
  setState({ phase: "uploading", file, progress: 0, abort });
1454
1466
  if (previewRef.current) URL.revokeObjectURL(previewRef.current);
@@ -1472,7 +1484,7 @@ function MediaUploader({
1472
1484
  setState({ phase: "error", message });
1473
1485
  }
1474
1486
  },
1475
- [kind, ownerType, ownerId, uploadFn, onChange, limit]
1487
+ [kind, ownerType, ownerId, uploadFn, onChange, limit, deferUpload, onFileSelected]
1476
1488
  );
1477
1489
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
1478
1490
  accept: Object.fromEntries(limit.mimes.map((m) => [m, []])),
@@ -1498,31 +1510,88 @@ function MediaUploader({
1498
1510
  }
1499
1511
  }
1500
1512
  });
1513
+ const hasUploadedValue = !!value && !isPlaceholder && !value.startsWith("blob:");
1514
+ const hasPendingFile = state.phase === "pending" && !!previewRef.current;
1515
+ const hasDoneUpload = state.phase === "done" && hasUploadedValue;
1516
+ const hasImage = hasPendingFile || hasDoneUpload || state.phase === "idle" && hasUploadedValue;
1517
+ const thumbnailSrc = hasPendingFile ? previewRef.current : hasUploadedValue ? value : "";
1518
+ const isSelected = hasPendingFile;
1519
+ const handleReplace = (e) => {
1520
+ e.stopPropagation();
1521
+ fileInputRef.current?.querySelector("input")?.click();
1522
+ };
1523
+ const handleRemove = (e) => {
1524
+ e.stopPropagation();
1525
+ if (previewRef.current) {
1526
+ URL.revokeObjectURL(previewRef.current);
1527
+ previewRef.current = null;
1528
+ }
1529
+ setState({ phase: "idle" });
1530
+ onChange(null);
1531
+ setUrlInput("");
1532
+ if (deferUpload) onFileSelected?.(null);
1533
+ };
1501
1534
  return /* @__PURE__ */ jsxs18("div", { className: "space-y-2", children: [
1502
- label && /* @__PURE__ */ jsx23("div", { className: "text-sm text-[--text-secondary]", children: label }),
1535
+ label && /* @__PURE__ */ jsx23("div", { className: "text-sm text-neutral-700 dark:text-white/70", children: label }),
1503
1536
  /* @__PURE__ */ jsxs18(
1504
1537
  "div",
1505
1538
  {
1506
1539
  ...getRootProps(),
1507
- className: `border-2 border-dashed rounded-[--radius-md] p-6 text-center cursor-pointer transition-colors ${isDragActive ? "border-[--accent] bg-[--accent-glow]" : "border-[--border]"}`,
1540
+ className: `border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${isDragActive ? "border-orange-500 dark:border-brand-orange bg-orange-500/10 dark:bg-brand-orange/15" : "border-neutral-200 dark:border-white/8"}`,
1508
1541
  children: [
1509
- /* @__PURE__ */ jsx23("input", { ...getInputProps(), "aria-label": "file uploader" }),
1510
- state.phase === "idle" && /* @__PURE__ */ jsxs18(Fragment4, { children: [
1542
+ /* @__PURE__ */ jsx23("span", { ref: fileInputRef, style: { display: "contents" }, children: /* @__PURE__ */ jsx23("input", { ...getInputProps(), "aria-label": "file uploader" }) }),
1543
+ hasImage ? /* @__PURE__ */ jsxs18("div", { className: "flex items-center gap-4", children: [
1544
+ /* @__PURE__ */ jsx23(
1545
+ "img",
1546
+ {
1547
+ src: thumbnailSrc,
1548
+ alt: "uploaded",
1549
+ className: "w-16 h-16 rounded-lg object-cover border border-neutral-200 dark:border-white/8 shrink-0"
1550
+ }
1551
+ ),
1552
+ /* @__PURE__ */ jsxs18("div", { className: "flex-1 text-left min-w-0", children: [
1553
+ /* @__PURE__ */ jsxs18("div", { className: "text-sm text-green-500 dark:text-green-400 flex items-center gap-1", children: [
1554
+ /* @__PURE__ */ jsx23("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsx23("polyline", { points: "20 6 9 17 4 12" }) }),
1555
+ isSelected ? "Selected" : "Uploaded"
1556
+ ] }),
1557
+ isSelected && /* @__PURE__ */ jsx23("div", { className: "text-[10px] text-neutral-500 dark:text-white/45 mt-0.5", children: "Uploads when you save" }),
1558
+ hasPendingFile && /* @__PURE__ */ jsx23("div", { className: "text-xs text-neutral-500 dark:text-white/45 mt-0.5 truncate", children: state.file.name }),
1559
+ /* @__PURE__ */ jsxs18("div", { className: "flex gap-2 mt-2 text-xs", children: [
1560
+ /* @__PURE__ */ jsx23(
1561
+ "button",
1562
+ {
1563
+ type: "button",
1564
+ onClick: handleReplace,
1565
+ className: "text-neutral-700 dark:text-white/70 hover:text-orange-500 dark:hover:text-brand-orange underline-offset-2 hover:underline",
1566
+ children: "Replace"
1567
+ }
1568
+ ),
1569
+ /* @__PURE__ */ jsx23(
1570
+ "button",
1571
+ {
1572
+ type: "button",
1573
+ onClick: handleRemove,
1574
+ className: "text-neutral-700 dark:text-white/70 hover:text-red-500 dark:hover:text-red-400 underline-offset-2 hover:underline",
1575
+ children: "Remove"
1576
+ }
1577
+ )
1578
+ ] })
1579
+ ] })
1580
+ ] }) : state.phase === "idle" ? /* @__PURE__ */ jsxs18(Fragment4, { children: [
1511
1581
  /* @__PURE__ */ jsx23("div", { className: "text-sm mb-1", children: "Drop image here or click to choose" }),
1512
- /* @__PURE__ */ jsxs18("div", { className: "text-xs text-[--text-muted]", children: [
1582
+ /* @__PURE__ */ jsxs18("div", { className: "text-xs text-neutral-500 dark:text-white/45", children: [
1513
1583
  formatExtensions(limit.mimes),
1514
1584
  " \xB7 max ",
1515
1585
  humanSize(limit.maxSize),
1516
1586
  limit.maxWidth && limit.maxHeight && ` \xB7 ${limit.maxWidth}\xD7${limit.maxHeight}`
1517
1587
  ] })
1518
- ] }),
1519
- state.phase === "uploading" && /* @__PURE__ */ jsxs18(Fragment4, { children: [
1588
+ ] }) : state.phase === "uploading" ? /* @__PURE__ */ jsxs18(Fragment4, { children: [
1520
1589
  previewRef.current && /* @__PURE__ */ jsx23(
1521
1590
  "img",
1522
1591
  {
1523
1592
  src: previewRef.current,
1524
1593
  alt: "upload preview",
1525
- className: "max-w-[64px] max-h-[64px] rounded-[--radius-sm] object-cover border border-[--border] mx-auto mb-2"
1594
+ className: "max-w-[64px] max-h-[64px] rounded object-cover border border-neutral-200 dark:border-white/8 mx-auto mb-2"
1526
1595
  }
1527
1596
  ),
1528
1597
  /* @__PURE__ */ jsxs18("div", { className: "text-sm", children: [
@@ -1552,9 +1621,7 @@ function MediaUploader({
1552
1621
  children: "Cancel"
1553
1622
  }
1554
1623
  )
1555
- ] }),
1556
- state.phase === "done" && /* @__PURE__ */ jsx23("div", { className: "text-sm text-green-500", children: "\u2713 Uploaded" }),
1557
- state.phase === "error" && /* @__PURE__ */ jsxs18(Fragment4, { children: [
1624
+ ] }) : state.phase === "error" ? /* @__PURE__ */ jsxs18(Fragment4, { children: [
1558
1625
  /* @__PURE__ */ jsx23("div", { className: "text-sm text-red-500", children: state.message }),
1559
1626
  /* @__PURE__ */ jsx23(
1560
1627
  "button",
@@ -1568,30 +1635,23 @@ function MediaUploader({
1568
1635
  children: "Try again"
1569
1636
  }
1570
1637
  )
1571
- ] })
1638
+ ] }) : null
1572
1639
  ]
1573
1640
  }
1574
1641
  ),
1575
- /* @__PURE__ */ jsx23("div", { className: "text-xs text-[--text-muted]", children: "or paste URL:" }),
1576
- /* @__PURE__ */ jsx23(
1577
- "input",
1578
- {
1579
- type: "url",
1580
- placeholder: "https://...",
1581
- value: urlInput,
1582
- onChange: (e) => setUrlInput(e.target.value),
1583
- onBlur: () => urlInput && onChange(urlInput),
1584
- className: "w-full bg-[--bg-surface] border border-[--border] rounded-[--radius-sm] px-3 py-2 text-sm"
1585
- }
1586
- ),
1587
- value && /* @__PURE__ */ jsx23(
1588
- "img",
1589
- {
1590
- src: value,
1591
- alt: "current value preview",
1592
- className: "max-w-[64px] max-h-[64px] rounded-[--radius-sm] object-cover border border-[--border] mt-2"
1593
- }
1594
- )
1642
+ !deferUpload && /* @__PURE__ */ jsxs18(Fragment4, { children: [
1643
+ /* @__PURE__ */ jsx23("div", { className: "text-xs text-neutral-500 dark:text-white/45", children: "or paste URL:" }),
1644
+ /* @__PURE__ */ jsx23(
1645
+ Input,
1646
+ {
1647
+ type: "url",
1648
+ placeholder: "https://...",
1649
+ value: urlInput,
1650
+ onChange: (e) => setUrlInput(e.target.value),
1651
+ onBlur: () => urlInput && onChange(urlInput)
1652
+ }
1653
+ )
1654
+ ] })
1595
1655
  ] });
1596
1656
  }
1597
1657
 
@@ -1615,7 +1675,7 @@ function SortableTile({ item, onRemove }) {
1615
1675
  transform: CSS.Transform.toString(transform),
1616
1676
  transition
1617
1677
  };
1618
- return /* @__PURE__ */ jsxs19("div", { ref: setNodeRef, style, className: "relative w-24 h-24 rounded-[--radius-md] border border-[--border] overflow-hidden", children: [
1678
+ return /* @__PURE__ */ jsxs19("div", { ref: setNodeRef, style, className: "relative w-24 h-24 rounded-lg border border-neutral-200 dark:border-white/8 overflow-hidden", children: [
1619
1679
  /* @__PURE__ */ jsx24("button", { ...attributes, ...listeners, "aria-label": "drag handle", className: "absolute top-1 left-1 z-10 text-xs opacity-70 hover:opacity-100", children: "\u283F" }),
1620
1680
  /* @__PURE__ */ jsx24("img", { src: item.url, alt: `${item.type} thumbnail`, className: "w-full h-full object-cover" }),
1621
1681
  /* @__PURE__ */ jsx24(
@@ -1645,7 +1705,7 @@ function MediaGallery({ ownerType, ownerId, items, onChange, uploadFn, max = 10
1645
1705
  onDragOver: (e) => e.preventDefault(),
1646
1706
  onDrop: (e) => e.preventDefault(),
1647
1707
  children: [
1648
- /* @__PURE__ */ jsxs19("div", { className: "text-sm text-[--text-secondary]", children: [
1708
+ /* @__PURE__ */ jsxs19("div", { className: "text-sm text-neutral-700 dark:text-white/70", children: [
1649
1709
  "Screenshots (",
1650
1710
  items.length,
1651
1711
  "/",
@@ -1667,12 +1727,12 @@ function MediaGallery({ ownerType, ownerId, items, onChange, uploadFn, max = 10
1667
1727
  type: "button",
1668
1728
  "aria-label": "add screenshot",
1669
1729
  onClick: () => setAdding(true),
1670
- className: "w-24 h-24 rounded-[--radius-md] border-2 border-dashed border-[--border] text-2xl hover:border-[--accent]",
1730
+ className: "w-24 h-24 rounded-lg border-2 border-dashed border-neutral-200 dark:border-white/8 text-2xl hover:border-orange-500 dark:hover:border-brand-orange",
1671
1731
  children: "+"
1672
1732
  }
1673
1733
  )
1674
1734
  ] }) }) }),
1675
- adding && /* @__PURE__ */ jsxs19("div", { className: "border border-[--border] rounded-[--radius-md] p-3", children: [
1735
+ adding && /* @__PURE__ */ jsxs19("div", { className: "border border-neutral-200 dark:border-white/8 rounded-lg p-3", children: [
1676
1736
  /* @__PURE__ */ jsx24(
1677
1737
  MediaUploader,
1678
1738
  {
@@ -1700,77 +1760,598 @@ function MediaGallery({ ownerType, ownerId, items, onChange, uploadFn, max = 10
1700
1760
  }
1701
1761
 
1702
1762
  // src/components/media/MarketplaceProjectCard.tsx
1703
- import { jsx as jsx25, jsxs as jsxs20 } from "react/jsx-runtime";
1704
- function initials(name) {
1705
- return name.split(/\s+/).map((w) => w[0]).filter(Boolean).slice(0, 2).join("").toUpperCase();
1706
- }
1707
- function formatUsers(n) {
1708
- if (n < 1e3) return n.toString();
1709
- if (n < 1e6) return `${(n / 1e3).toFixed(1)}k`;
1710
- return `${(n / 1e6).toFixed(1)}M`;
1711
- }
1763
+ import { motion } from "framer-motion";
1764
+ import { Users, Target, ThumbsUp, Plus, Check } from "lucide-react";
1765
+ import { Fragment as Fragment5, jsx as jsx25, jsxs as jsxs20 } from "react/jsx-runtime";
1766
+ var categoryLabels = {
1767
+ game: "Game",
1768
+ defi: "DeFi",
1769
+ social: "Social",
1770
+ tool: "Tool",
1771
+ nft: "NFT",
1772
+ other: "Other"
1773
+ };
1712
1774
  function MarketplaceProjectCard({
1713
1775
  name,
1714
1776
  tagline,
1715
1777
  logoUrl,
1716
1778
  bannerUrl,
1717
- rating,
1718
- userCount
1779
+ accentColor = "#FF6F00",
1780
+ category,
1781
+ users = 0,
1782
+ quests = 0,
1783
+ positivePercent = 0,
1784
+ ratingCount = 0,
1785
+ installState = "none",
1786
+ onInstallClick,
1787
+ onClick
1719
1788
  }) {
1720
1789
  const hasBanner = !!bannerUrl;
1721
- const showStats = rating !== void 0 || userCount !== void 0;
1722
- return /* @__PURE__ */ jsxs20("div", { className: "w-[280px] rounded-[--radius-lg] overflow-hidden bg-[--bg-elevated] border border-[--border] font-[--font-body] shadow-[--shadow-md]", children: [
1723
- /* @__PURE__ */ jsx25("div", { className: "relative h-20 bg-[--bg-surface] overflow-hidden", children: hasBanner ? /* @__PURE__ */ jsx25(
1724
- "img",
1725
- {
1726
- src: bannerUrl,
1727
- alt: `${name} banner`,
1728
- className: "w-full h-full object-cover"
1729
- }
1730
- ) : /* @__PURE__ */ jsx25(
1731
- "div",
1732
- {
1733
- className: "absolute inset-0",
1734
- style: {
1735
- background: "linear-gradient(135deg, rgba(255,111,0,0.35) 0%, rgba(255,111,0,0.05) 100%)"
1736
- }
1737
- }
1738
- ) }),
1739
- /* @__PURE__ */ jsxs20("div", { className: "p-4", children: [
1740
- /* @__PURE__ */ jsxs20("div", { className: "flex items-start gap-3", children: [
1741
- logoUrl ? /* @__PURE__ */ jsx25(
1742
- "img",
1743
- {
1744
- src: logoUrl,
1745
- alt: `${name} logo`,
1746
- className: `w-12 h-12 rounded-[--radius-md] object-cover bg-[--bg-surface] border border-[--border] shrink-0 ${hasBanner ? "-mt-8 ring-2 ring-[--bg-elevated] relative z-10" : ""}`
1747
- }
1748
- ) : /* @__PURE__ */ jsx25(
1790
+ const installed = installState === "installed";
1791
+ const showInstall = installState !== "none";
1792
+ const handleInstall = (e) => {
1793
+ e.preventDefault();
1794
+ e.stopPropagation();
1795
+ onInstallClick?.();
1796
+ };
1797
+ const placeholderLogo = `https://placehold.co/44x44/${accentColor.slice(1)}/white?text=${name[0] ?? "?"}`;
1798
+ const card = /* @__PURE__ */ jsxs20(
1799
+ motion.div,
1800
+ {
1801
+ whileHover: { y: -4 },
1802
+ className: "no-text-shadow group rounded-2xl border border-neutral-200 dark:border-white/8 hover:border-orange-500/60 dark:hover:border-brand-orange/60 hover:shadow-lg hover:shadow-orange-500/10 dark:hover:shadow-brand-orange/15 transition-all duration-200 cursor-pointer relative overflow-hidden",
1803
+ children: [
1804
+ /* @__PURE__ */ jsxs20("div", { className: "relative h-24 overflow-hidden", "data-testid": "banner", children: [
1805
+ hasBanner ? /* @__PURE__ */ jsxs20(Fragment5, { children: [
1806
+ /* @__PURE__ */ jsx25(
1807
+ "div",
1808
+ {
1809
+ className: "absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-105",
1810
+ style: { backgroundImage: `url(${bannerUrl})` }
1811
+ }
1812
+ ),
1813
+ /* @__PURE__ */ jsx25("div", { className: "absolute inset-0", style: {
1814
+ background: `linear-gradient(to bottom, ${accentColor}33 0%, ${accentColor}99 100%)`
1815
+ } })
1816
+ ] }) : /* @__PURE__ */ jsx25("div", { className: "absolute inset-0", style: {
1817
+ background: `linear-gradient(135deg, ${accentColor}cc 0%, ${accentColor}44 100%)`
1818
+ } }),
1819
+ showInstall && /* @__PURE__ */ jsx25(
1820
+ "button",
1821
+ {
1822
+ onClick: handleInstall,
1823
+ title: installed ? "Remove from Desktop" : "Add to Desktop",
1824
+ className: `absolute top-3 right-3 z-10 w-8 h-8 rounded-lg flex items-center justify-center backdrop-blur-sm transition-all ${installed ? "bg-green-500/30 text-white border border-green-400/40" : "bg-black/30 text-white/70 border border-white/15 hover:bg-orange-500/40 hover:text-white hover:border-orange-400/40"}`,
1825
+ children: installed ? /* @__PURE__ */ jsx25(Check, { className: "w-4 h-4" }) : /* @__PURE__ */ jsx25(Plus, { className: "w-4 h-4" })
1826
+ }
1827
+ )
1828
+ ] }),
1829
+ /* @__PURE__ */ jsxs20("div", { className: "p-4 bg-white dark:bg-white/4 dark:backdrop-blur-2xl", children: [
1830
+ /* @__PURE__ */ jsxs20("div", { className: "flex items-start gap-3", children: [
1831
+ /* @__PURE__ */ jsx25(
1832
+ "img",
1833
+ {
1834
+ src: logoUrl ?? placeholderLogo,
1835
+ alt: name,
1836
+ className: "w-11 h-11 rounded-xl object-cover border border-neutral-200 dark:border-white/10 shrink-0 -mt-8 ring-2 ring-white dark:ring-[#0a0a0a] shadow-lg relative z-10",
1837
+ onError: (e) => {
1838
+ e.target.src = placeholderLogo;
1839
+ }
1840
+ }
1841
+ ),
1842
+ /* @__PURE__ */ jsxs20("div", { className: "min-w-0 flex-1", children: [
1843
+ /* @__PURE__ */ jsx25("h3", { className: "font-semibold text-neutral-900 dark:text-white text-sm truncate", children: name }),
1844
+ /* @__PURE__ */ jsx25("p", { className: "text-neutral-500 dark:text-white/45 text-xs mt-0.5 h-8 line-clamp-2", children: tagline })
1845
+ ] })
1846
+ ] }),
1847
+ /* @__PURE__ */ jsxs20("div", { className: "flex items-center justify-between mt-3 pt-3 border-t border-neutral-100 dark:border-white/5", children: [
1848
+ category ? /* @__PURE__ */ jsx25("span", { className: "inline-flex px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wider border", style: {
1849
+ backgroundColor: `${accentColor}15`,
1850
+ color: accentColor,
1851
+ borderColor: `${accentColor}30`
1852
+ }, children: categoryLabels[category] ?? category }) : /* @__PURE__ */ jsx25("span", {}),
1853
+ /* @__PURE__ */ jsxs20("div", { className: "flex items-center gap-3 text-[11px] text-neutral-400 dark:text-white/35", children: [
1854
+ /* @__PURE__ */ jsxs20("span", { className: "flex items-center gap-1", title: "Users", children: [
1855
+ /* @__PURE__ */ jsx25(Users, { className: "w-3 h-3" }),
1856
+ users.toLocaleString()
1857
+ ] }),
1858
+ /* @__PURE__ */ jsxs20("span", { className: "flex items-center gap-1", title: "Active quests", children: [
1859
+ /* @__PURE__ */ jsx25(Target, { className: "w-3 h-3" }),
1860
+ quests.toLocaleString()
1861
+ ] }),
1862
+ ratingCount > 0 && /* @__PURE__ */ jsxs20("span", { className: "flex items-center gap-1", title: `${ratingCount} reviews`, children: [
1863
+ /* @__PURE__ */ jsx25(ThumbsUp, { className: "w-3 h-3" }),
1864
+ positivePercent,
1865
+ "%"
1866
+ ] })
1867
+ ] })
1868
+ ] })
1869
+ ] })
1870
+ ]
1871
+ }
1872
+ );
1873
+ if (onClick) {
1874
+ return /* @__PURE__ */ jsx25("button", { type: "button", onClick, className: "block w-full text-left", children: card });
1875
+ }
1876
+ return card;
1877
+ }
1878
+
1879
+ // src/components/media/FeaturedProjectCard.tsx
1880
+ import { motion as motion2 } from "framer-motion";
1881
+ import { Star, Users as Users2, Target as Target2, ThumbsUp as ThumbsUp2 } from "lucide-react";
1882
+ import { jsx as jsx26, jsxs as jsxs21 } from "react/jsx-runtime";
1883
+ function FeaturedProjectCard({
1884
+ name,
1885
+ tagline,
1886
+ logoUrl,
1887
+ bannerUrl,
1888
+ accentColor = "#FF6F00",
1889
+ users = 0,
1890
+ quests = 0,
1891
+ positivePercent = 0,
1892
+ ratingCount = 0,
1893
+ onClick
1894
+ }) {
1895
+ const placeholderLogo = `https://placehold.co/40x40/${accentColor.slice(1)}/white?text=${name[0] ?? "?"}`;
1896
+ const card = /* @__PURE__ */ jsxs21(
1897
+ motion2.div,
1898
+ {
1899
+ whileHover: { scale: 1.02, y: -2 },
1900
+ whileTap: { scale: 0.98 },
1901
+ className: "relative w-72 sm:w-80 h-44 rounded-2xl overflow-hidden shrink-0 cursor-pointer group",
1902
+ children: [
1903
+ /* @__PURE__ */ jsx26(
1749
1904
  "div",
1750
1905
  {
1751
- className: `w-12 h-12 rounded-[--radius-md] flex items-center justify-center bg-[--accent-glow] text-[--accent] font-bold text-sm shrink-0 ${hasBanner ? "-mt-8 ring-2 ring-[--bg-elevated] relative z-10" : ""}`,
1752
- children: initials(name)
1906
+ className: "absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-105",
1907
+ "data-testid": "banner",
1908
+ style: {
1909
+ backgroundColor: accentColor,
1910
+ backgroundImage: bannerUrl ? `url(${bannerUrl})` : void 0
1911
+ }
1753
1912
  }
1754
1913
  ),
1755
- /* @__PURE__ */ jsxs20("div", { className: "flex-1 min-w-0 pt-0.5", children: [
1756
- /* @__PURE__ */ jsx25("div", { className: "text-[--text-primary] font-medium text-sm truncate", children: name }),
1757
- tagline && /* @__PURE__ */ jsx25("div", { className: "text-xs text-[--text-muted] mt-0.5 truncate", children: tagline })
1758
- ] })
1759
- ] }),
1760
- showStats && /* @__PURE__ */ jsxs20("div", { className: "flex items-center gap-3 mt-3 pt-3 border-t border-[--border-subtle] text-[11px] text-[--text-muted]", children: [
1761
- rating !== void 0 && /* @__PURE__ */ jsxs20("span", { className: "flex items-center gap-1", children: [
1762
- /* @__PURE__ */ jsx25("span", { className: "text-[--accent]", children: "\u2605" }),
1763
- " ",
1764
- rating.toFixed(1)
1914
+ /* @__PURE__ */ jsx26("div", { className: "absolute inset-0", style: {
1915
+ background: bannerUrl ? `linear-gradient(135deg, ${accentColor}66 0%, transparent 60%)` : `linear-gradient(135deg, ${accentColor}cc 0%, ${accentColor}44 100%)`
1916
+ } }),
1917
+ /* @__PURE__ */ jsx26("div", { className: "absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" }),
1918
+ /* @__PURE__ */ jsxs21("div", { className: "absolute top-3 right-3 flex items-center gap-1 px-2 py-1 rounded-full bg-amber-500/90 text-white text-[10px] font-bold uppercase tracking-wider", children: [
1919
+ /* @__PURE__ */ jsx26(Star, { className: "w-3 h-3", fill: "currentColor" }),
1920
+ "Featured"
1765
1921
  ] }),
1766
- userCount !== void 0 && /* @__PURE__ */ jsxs20("span", { children: [
1767
- "\xB7 ",
1768
- formatUsers(userCount),
1769
- " users"
1922
+ /* @__PURE__ */ jsxs21("div", { className: "absolute bottom-0 left-0 right-0 p-4", children: [
1923
+ /* @__PURE__ */ jsxs21("div", { className: "flex items-center gap-3", children: [
1924
+ /* @__PURE__ */ jsx26(
1925
+ "img",
1926
+ {
1927
+ src: logoUrl ?? placeholderLogo,
1928
+ alt: name,
1929
+ className: "w-10 h-10 rounded-xl object-cover border-2 border-white/20 shadow-lg",
1930
+ onError: (e) => {
1931
+ e.target.src = placeholderLogo;
1932
+ }
1933
+ }
1934
+ ),
1935
+ /* @__PURE__ */ jsxs21("div", { className: "min-w-0", children: [
1936
+ /* @__PURE__ */ jsx26("h3", { className: "font-semibold text-white text-sm truncate", children: name }),
1937
+ /* @__PURE__ */ jsx26("p", { className: "text-white/70 text-xs truncate", children: tagline })
1938
+ ] })
1939
+ ] }),
1940
+ /* @__PURE__ */ jsxs21("div", { className: "flex items-center gap-3 mt-2 text-[11px] text-white/60", children: [
1941
+ /* @__PURE__ */ jsxs21("span", { className: "flex items-center gap-1", title: "Users", children: [
1942
+ /* @__PURE__ */ jsx26(Users2, { className: "w-3 h-3" }),
1943
+ users.toLocaleString()
1944
+ ] }),
1945
+ /* @__PURE__ */ jsxs21("span", { className: "flex items-center gap-1", title: "Active quests", children: [
1946
+ /* @__PURE__ */ jsx26(Target2, { className: "w-3 h-3" }),
1947
+ quests.toLocaleString()
1948
+ ] }),
1949
+ ratingCount > 0 && /* @__PURE__ */ jsxs21("span", { className: "flex items-center gap-1", title: `${ratingCount} reviews`, children: [
1950
+ /* @__PURE__ */ jsx26(ThumbsUp2, { className: "w-3 h-3" }),
1951
+ positivePercent,
1952
+ "%"
1953
+ ] })
1954
+ ] })
1770
1955
  ] })
1771
- ] })
1772
- ] })
1773
- ] });
1956
+ ]
1957
+ }
1958
+ );
1959
+ if (onClick) {
1960
+ return /* @__PURE__ */ jsx26("button", { type: "button", onClick, className: "block text-left", draggable: false, children: card });
1961
+ }
1962
+ return card;
1963
+ }
1964
+
1965
+ // src/components/media/InstalledProjectIcon.tsx
1966
+ import { useState as useState9 } from "react";
1967
+ import { motion as motion3 } from "framer-motion";
1968
+ import { jsx as jsx27, jsxs as jsxs22 } from "react/jsx-runtime";
1969
+ function InstalledProjectIcon({
1970
+ name,
1971
+ logoUrl,
1972
+ accentColor = "#FF6F00",
1973
+ onClick,
1974
+ showLabel = true
1975
+ }) {
1976
+ const [imgError, setImgError] = useState9(false);
1977
+ return /* @__PURE__ */ jsx27("div", { className: "relative", children: /* @__PURE__ */ jsxs22(
1978
+ motion3.button,
1979
+ {
1980
+ type: "button",
1981
+ onClick,
1982
+ whileHover: { scale: 1.08, y: -4 },
1983
+ whileTap: { scale: 0.92 },
1984
+ transition: { duration: 0.05 },
1985
+ className: "flex flex-col items-center gap-2 p-3 rounded-2xl group cursor-pointer relative",
1986
+ children: [
1987
+ /* @__PURE__ */ jsxs22("div", { className: "relative", children: [
1988
+ /* @__PURE__ */ jsx27(
1989
+ "div",
1990
+ {
1991
+ className: "absolute -inset-1 blur-xl opacity-0 group-hover:opacity-50 transition-all duration-300 rounded-2xl",
1992
+ style: { backgroundColor: accentColor }
1993
+ }
1994
+ ),
1995
+ /* @__PURE__ */ jsxs22(
1996
+ "div",
1997
+ {
1998
+ className: "relative w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center shadow-lg group-hover:shadow-xl transition-all duration-200 overflow-hidden",
1999
+ style: { background: `linear-gradient(135deg, ${accentColor}, ${accentColor}cc)` },
2000
+ children: [
2001
+ /* @__PURE__ */ jsx27(
2002
+ "div",
2003
+ {
2004
+ className: "absolute inset-0 opacity-30 group-hover:opacity-50 transition-opacity duration-500",
2005
+ style: {
2006
+ backgroundImage: `radial-gradient(at 27% 37%, rgba(255,255,255,0.15) 0px, transparent 50%),
2007
+ radial-gradient(at 97% 21%, rgba(255,255,255,0.1) 0px, transparent 50%)`
2008
+ }
2009
+ }
2010
+ ),
2011
+ /* @__PURE__ */ jsx27("div", { className: "absolute top-0 right-0 w-8 h-8 bg-white/10 rounded-bl-full group-hover:w-10 group-hover:h-10 transition-all duration-300" }),
2012
+ logoUrl && !imgError ? /* @__PURE__ */ jsx27(
2013
+ "img",
2014
+ {
2015
+ src: logoUrl,
2016
+ alt: name,
2017
+ onError: () => setImgError(true),
2018
+ className: "w-9 h-9 sm:w-10 sm:h-10 object-contain rounded-lg relative z-10 drop-shadow-lg"
2019
+ }
2020
+ ) : /* @__PURE__ */ jsx27("span", { className: "text-white font-bold text-lg relative z-10", children: name[0] ?? "?" })
2021
+ ]
2022
+ }
2023
+ )
2024
+ ] }),
2025
+ showLabel && /* @__PURE__ */ jsx27("span", { className: "text-xs sm:text-sm font-medium text-neutral-500 dark:text-[rgba(255,255,255,0.45)] group-hover:text-neutral-900 dark:group-hover:text-white transition-colors truncate max-w-20 sm:max-w-24 text-center leading-tight", children: name })
2026
+ ]
2027
+ }
2028
+ ) });
2029
+ }
2030
+
2031
+ // src/components/media/ProjectPagePreview.tsx
2032
+ import { motion as motion4 } from "framer-motion";
2033
+ import {
2034
+ ArrowLeft,
2035
+ ExternalLink,
2036
+ Users as Users3,
2037
+ Target as Target3,
2038
+ ThumbsUp as ThumbsUp3,
2039
+ Globe,
2040
+ Plus as Plus2,
2041
+ Star as Star2,
2042
+ Trophy,
2043
+ MessageCircle,
2044
+ Twitter,
2045
+ Zap
2046
+ } from "lucide-react";
2047
+ import { jsx as jsx28, jsxs as jsxs23 } from "react/jsx-runtime";
2048
+ var categoryLabels2 = {
2049
+ game: "Game",
2050
+ defi: "DeFi",
2051
+ social: "Social",
2052
+ tool: "Tool",
2053
+ nft: "NFT",
2054
+ other: "Other"
2055
+ };
2056
+ var difficultyStyles = {
2057
+ easy: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/25",
2058
+ medium: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/25",
2059
+ hard: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25"
2060
+ };
2061
+ function getMediaThumb(m) {
2062
+ if (m.type !== "video") return m.url;
2063
+ const ytMatch = m.url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\s]+)/);
2064
+ return ytMatch ? `https://img.youtube.com/vi/${ytMatch[1]}/hqdefault.jpg` : m.url;
2065
+ }
2066
+ function ProjectPagePreview({
2067
+ name,
2068
+ tagline,
2069
+ description,
2070
+ logoUrl,
2071
+ bannerUrl,
2072
+ accentColor = "#FF6F00",
2073
+ category,
2074
+ websiteUrl,
2075
+ discordUrl,
2076
+ twitterUrl,
2077
+ media = [],
2078
+ users = 0,
2079
+ activeQuests = 0,
2080
+ positivePercent = 0,
2081
+ ratingCount = 0,
2082
+ quests = [],
2083
+ achievements = [],
2084
+ tags = []
2085
+ }) {
2086
+ const placeholderLogo = `https://placehold.co/80x80/${accentColor.slice(1)}/white?text=${name[0] ?? "?"}`;
2087
+ return /* @__PURE__ */ jsxs23(
2088
+ motion4.div,
2089
+ {
2090
+ initial: { opacity: 0 },
2091
+ animate: { opacity: 1 },
2092
+ className: "text-neutral-900 dark:text-white pb-12",
2093
+ children: [
2094
+ /* @__PURE__ */ jsxs23("div", { className: "relative mx-4 sm:mx-6 mt-2", children: [
2095
+ /* @__PURE__ */ jsxs23("div", { className: "relative h-48 sm:h-64 rounded-2xl overflow-hidden", children: [
2096
+ /* @__PURE__ */ jsx28(
2097
+ "div",
2098
+ {
2099
+ className: "absolute inset-0 bg-cover bg-center",
2100
+ "data-testid": "banner",
2101
+ style: {
2102
+ backgroundColor: accentColor,
2103
+ backgroundImage: bannerUrl ? `url(${bannerUrl})` : void 0
2104
+ }
2105
+ }
2106
+ ),
2107
+ /* @__PURE__ */ jsx28("div", { className: "absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" }),
2108
+ /* @__PURE__ */ jsxs23("div", { className: "absolute top-4 left-4 flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-black/40 backdrop-blur-sm text-white/80 text-sm", children: [
2109
+ /* @__PURE__ */ jsx28(ArrowLeft, { className: "w-4 h-4" }),
2110
+ "Explore"
2111
+ ] })
2112
+ ] }),
2113
+ /* @__PURE__ */ jsx28("div", { className: "absolute -bottom-6 left-6 sm:left-8 z-10", children: /* @__PURE__ */ jsx28(
2114
+ "img",
2115
+ {
2116
+ src: logoUrl ?? placeholderLogo,
2117
+ alt: name,
2118
+ className: "w-16 h-16 sm:w-20 sm:h-20 rounded-2xl object-cover border-4 border-white dark:border-[#060606] shadow-xl",
2119
+ onError: (e) => {
2120
+ e.target.src = placeholderLogo;
2121
+ }
2122
+ }
2123
+ ) })
2124
+ ] }),
2125
+ /* @__PURE__ */ jsx28("section", { className: "px-4 sm:px-6 pt-10 pb-6", children: /* @__PURE__ */ jsxs23("div", { className: "no-text-shadow max-w-5xl mx-auto bg-neutral-50 dark:bg-white/4 dark:backdrop-blur-2xl rounded-2xl border border-neutral-200 dark:border-white/8 p-6 sm:p-8", children: [
2126
+ /* @__PURE__ */ jsxs23("div", { className: "flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4", children: [
2127
+ /* @__PURE__ */ jsxs23("div", { children: [
2128
+ /* @__PURE__ */ jsx28("h1", { className: "text-2xl sm:text-3xl font-bold", children: name }),
2129
+ tagline && /* @__PURE__ */ jsx28("p", { className: "text-neutral-500 dark:text-white/55 mt-1", children: tagline }),
2130
+ (category || tags.length > 0) && /* @__PURE__ */ jsxs23("div", { className: "flex items-center gap-2 mt-3", children: [
2131
+ category && /* @__PURE__ */ jsx28(
2132
+ "span",
2133
+ {
2134
+ className: "inline-flex px-2.5 py-0.5 rounded-lg text-xs font-semibold uppercase tracking-wider",
2135
+ style: {
2136
+ backgroundColor: `${accentColor}15`,
2137
+ color: accentColor,
2138
+ border: `1px solid ${accentColor}30`
2139
+ },
2140
+ children: categoryLabels2[category] ?? category
2141
+ }
2142
+ ),
2143
+ tags.slice(0, 3).map((tag) => /* @__PURE__ */ jsx28(
2144
+ "span",
2145
+ {
2146
+ className: "px-2 py-0.5 rounded-md bg-neutral-100 dark:bg-white/6 text-neutral-500 dark:text-white/40 text-[10px] font-mono",
2147
+ children: tag
2148
+ },
2149
+ tag
2150
+ ))
2151
+ ] })
2152
+ ] }),
2153
+ /* @__PURE__ */ jsxs23("div", { className: "flex gap-2 shrink-0 flex-wrap", children: [
2154
+ /* @__PURE__ */ jsxs23(
2155
+ "button",
2156
+ {
2157
+ type: "button",
2158
+ className: "inline-flex items-center gap-2 px-5 py-2.5 rounded-xl font-semibold text-sm transition-all cursor-pointer bg-orange-500 dark:bg-brand-orange hover:bg-orange-600 dark:hover:bg-brand-orange-dark text-white shadow-lg shadow-orange-500/20",
2159
+ children: [
2160
+ /* @__PURE__ */ jsx28(Plus2, { className: "w-4 h-4" }),
2161
+ " Add to Desktop"
2162
+ ]
2163
+ }
2164
+ ),
2165
+ /* @__PURE__ */ jsxs23(
2166
+ "button",
2167
+ {
2168
+ type: "button",
2169
+ className: "inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium border border-neutral-200 dark:border-white/8 text-neutral-600 dark:text-white/55 hover:text-neutral-900 dark:hover:text-white hover:border-neutral-300 dark:hover:border-white/15 transition-colors cursor-pointer",
2170
+ children: [
2171
+ "Open ",
2172
+ /* @__PURE__ */ jsx28(ExternalLink, { className: "w-3.5 h-3.5" })
2173
+ ]
2174
+ }
2175
+ ),
2176
+ websiteUrl && /* @__PURE__ */ jsxs23(
2177
+ "a",
2178
+ {
2179
+ href: websiteUrl,
2180
+ target: "_blank",
2181
+ rel: "noopener noreferrer",
2182
+ className: "inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium border border-neutral-200 dark:border-white/8 text-neutral-600 dark:text-white/55 hover:text-neutral-900 dark:hover:text-white hover:border-neutral-300 dark:hover:border-white/15 transition-colors",
2183
+ children: [
2184
+ "Website ",
2185
+ /* @__PURE__ */ jsx28(Globe, { className: "w-3.5 h-3.5" })
2186
+ ]
2187
+ }
2188
+ )
2189
+ ] })
2190
+ ] }),
2191
+ /* @__PURE__ */ jsxs23("div", { className: "flex gap-6 sm:gap-10 border-t border-neutral-200 dark:border-white/8 mt-6 pt-5", children: [
2192
+ /* @__PURE__ */ jsxs23("div", { className: "text-center sm:text-left", children: [
2193
+ /* @__PURE__ */ jsxs23("div", { className: "flex items-center gap-1.5 justify-center sm:justify-start", children: [
2194
+ /* @__PURE__ */ jsx28(Users3, { className: "w-4 h-4 text-neutral-400 dark:text-white/30" }),
2195
+ /* @__PURE__ */ jsx28("span", { className: "text-xl sm:text-2xl font-bold font-mono", children: users.toLocaleString() })
2196
+ ] }),
2197
+ /* @__PURE__ */ jsx28("span", { className: "text-xs text-neutral-400 dark:text-white/35 uppercase tracking-wider", children: "Users" })
2198
+ ] }),
2199
+ /* @__PURE__ */ jsxs23("div", { className: "text-center sm:text-left", children: [
2200
+ /* @__PURE__ */ jsxs23("div", { className: "flex items-center gap-1.5 justify-center sm:justify-start", children: [
2201
+ /* @__PURE__ */ jsx28(Target3, { className: "w-4 h-4 text-neutral-400 dark:text-white/30" }),
2202
+ /* @__PURE__ */ jsx28("span", { className: "text-xl sm:text-2xl font-bold font-mono", children: activeQuests.toLocaleString() })
2203
+ ] }),
2204
+ /* @__PURE__ */ jsx28("span", { className: "text-xs text-neutral-400 dark:text-white/35 uppercase tracking-wider", children: "Active Quests" })
2205
+ ] }),
2206
+ ratingCount > 0 && /* @__PURE__ */ jsxs23("div", { className: "text-center sm:text-left", children: [
2207
+ /* @__PURE__ */ jsxs23("div", { className: "flex items-center gap-1.5 justify-center sm:justify-start", children: [
2208
+ /* @__PURE__ */ jsx28(ThumbsUp3, { className: "w-4 h-4 text-neutral-400 dark:text-white/30" }),
2209
+ /* @__PURE__ */ jsxs23("span", { className: "text-xl sm:text-2xl font-bold font-mono", children: [
2210
+ positivePercent,
2211
+ "%"
2212
+ ] })
2213
+ ] }),
2214
+ /* @__PURE__ */ jsxs23("span", { className: "text-xs text-neutral-400 dark:text-white/35 uppercase tracking-wider", children: [
2215
+ ratingCount.toLocaleString(),
2216
+ " ",
2217
+ ratingCount === 1 ? "review" : "reviews"
2218
+ ] })
2219
+ ] })
2220
+ ] })
2221
+ ] }) }),
2222
+ media.length > 0 && /* @__PURE__ */ jsx28("section", { className: "px-4 sm:px-6 pb-8", children: /* @__PURE__ */ jsxs23("div", { className: "max-w-5xl mx-auto", children: [
2223
+ /* @__PURE__ */ jsx28("h2", { className: "text-lg font-semibold mb-4", children: "Media" }),
2224
+ /* @__PURE__ */ jsx28("div", { className: "flex gap-3 overflow-x-auto scrollbar-hide pb-2", children: media.map((item, i) => {
2225
+ const isVideo = item.type === "video";
2226
+ return /* @__PURE__ */ jsxs23(
2227
+ "div",
2228
+ {
2229
+ className: "shrink-0 rounded-xl overflow-hidden border border-neutral-200 dark:border-white/8 relative",
2230
+ children: [
2231
+ /* @__PURE__ */ jsx28(
2232
+ "img",
2233
+ {
2234
+ src: getMediaThumb(item),
2235
+ alt: isVideo ? `Video ${i + 1}` : `Screenshot ${i + 1}`,
2236
+ draggable: false,
2237
+ className: "h-44 sm:h-52 w-72 sm:w-80 object-cover"
2238
+ }
2239
+ ),
2240
+ isVideo && /* @__PURE__ */ jsx28("div", { className: "absolute inset-0 flex items-center justify-center pointer-events-none", children: /* @__PURE__ */ jsx28("div", { className: "w-14 h-14 rounded-full bg-black/60 backdrop-blur-sm flex items-center justify-center", children: /* @__PURE__ */ jsx28("svg", { className: "w-6 h-6 text-white ml-1", fill: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx28("path", { d: "M8 5v14l11-7z" }) }) }) })
2241
+ ]
2242
+ },
2243
+ i
2244
+ );
2245
+ }) })
2246
+ ] }) }),
2247
+ description && /* @__PURE__ */ jsx28("section", { className: "px-4 sm:px-6 pb-8", children: /* @__PURE__ */ jsxs23("div", { className: "max-w-5xl mx-auto", children: [
2248
+ /* @__PURE__ */ jsx28("h2", { className: "text-lg font-semibold mb-4", children: "About" }),
2249
+ /* @__PURE__ */ jsx28("div", { className: "no-text-shadow bg-neutral-50 dark:bg-white/4 dark:backdrop-blur-2xl rounded-2xl border border-neutral-200 dark:border-white/8 p-6", children: /* @__PURE__ */ jsx28("p", { className: "text-neutral-600 dark:text-white/55 text-sm leading-relaxed whitespace-pre-line", children: description }) })
2250
+ ] }) }),
2251
+ quests.length > 0 && /* @__PURE__ */ jsx28("section", { className: "px-4 sm:px-6 pb-8", children: /* @__PURE__ */ jsxs23("div", { className: "max-w-5xl mx-auto", children: [
2252
+ /* @__PURE__ */ jsxs23("h2", { className: "text-lg font-semibold mb-4", children: [
2253
+ "Quests ",
2254
+ /* @__PURE__ */ jsxs23("span", { className: "text-neutral-400 dark:text-white/35 font-normal", children: [
2255
+ "(",
2256
+ quests.length,
2257
+ ")"
2258
+ ] })
2259
+ ] }),
2260
+ /* @__PURE__ */ jsx28("div", { className: "grid sm:grid-cols-2 gap-3", children: quests.map((quest) => /* @__PURE__ */ jsxs23(
2261
+ "div",
2262
+ {
2263
+ className: "no-text-shadow bg-white dark:bg-white/4 dark:backdrop-blur-2xl rounded-xl border border-neutral-200 dark:border-white/8 p-4 relative overflow-hidden",
2264
+ children: [
2265
+ /* @__PURE__ */ jsx28("div", { className: "absolute left-0 top-0 bottom-0 w-0.5", style: { backgroundColor: accentColor } }),
2266
+ /* @__PURE__ */ jsxs23("div", { className: "flex items-start gap-3", children: [
2267
+ /* @__PURE__ */ jsxs23("div", { className: "min-w-0 flex-1", children: [
2268
+ /* @__PURE__ */ jsx28("h4", { className: "font-medium text-sm text-neutral-900 dark:text-white", children: quest.title }),
2269
+ quest.description && /* @__PURE__ */ jsx28("p", { className: "text-xs text-neutral-500 dark:text-white/40 mt-0.5 line-clamp-2", children: quest.description }),
2270
+ quest.difficulty && /* @__PURE__ */ jsx28("span", { className: `inline-flex mt-2 px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wider border ${difficultyStyles[quest.difficulty]}`, children: quest.difficulty })
2271
+ ] }),
2272
+ typeof quest.points === "number" && /* @__PURE__ */ jsxs23("div", { className: "shrink-0 flex items-center gap-1 px-2 py-1 rounded-lg bg-orange-500/10 dark:bg-brand-orange-dim text-orange-600 dark:text-brand-orange text-xs font-semibold", children: [
2273
+ /* @__PURE__ */ jsx28(Zap, { className: "w-3 h-3" }),
2274
+ quest.points
2275
+ ] })
2276
+ ] })
2277
+ ]
2278
+ },
2279
+ quest.slug
2280
+ )) })
2281
+ ] }) }),
2282
+ achievements.length > 0 && /* @__PURE__ */ jsx28("section", { className: "px-4 sm:px-6 pb-8", children: /* @__PURE__ */ jsxs23("div", { className: "max-w-5xl mx-auto", children: [
2283
+ /* @__PURE__ */ jsxs23("h2", { className: "text-lg font-semibold mb-4", children: [
2284
+ "Achievements ",
2285
+ /* @__PURE__ */ jsxs23("span", { className: "text-neutral-400 dark:text-white/35 font-normal", children: [
2286
+ "(",
2287
+ achievements.length,
2288
+ ")"
2289
+ ] })
2290
+ ] }),
2291
+ /* @__PURE__ */ jsx28("div", { className: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3", children: achievements.map((ach) => /* @__PURE__ */ jsxs23(
2292
+ "div",
2293
+ {
2294
+ className: "no-text-shadow bg-white dark:bg-white/4 dark:backdrop-blur-2xl rounded-xl border border-neutral-200 dark:border-white/8 p-4 flex flex-col items-center text-center",
2295
+ children: [
2296
+ /* @__PURE__ */ jsx28("div", { className: "w-16 h-16 rounded-xl overflow-hidden flex items-center justify-center mb-3", style: {
2297
+ backgroundColor: `${accentColor}15`,
2298
+ border: `1px solid ${accentColor}30`
2299
+ }, children: ach.imageUrl ? /* @__PURE__ */ jsx28("img", { src: ach.imageUrl, alt: ach.title, className: "w-full h-full object-cover" }) : /* @__PURE__ */ jsx28(Trophy, { className: "w-8 h-8", style: { color: accentColor } }) }),
2300
+ /* @__PURE__ */ jsx28("h4", { className: "text-sm font-medium text-neutral-900 dark:text-white line-clamp-2", children: ach.title }),
2301
+ typeof ach.points === "number" && /* @__PURE__ */ jsxs23("div", { className: "mt-2 flex items-center gap-1 text-xs font-semibold text-orange-600 dark:text-brand-orange", children: [
2302
+ /* @__PURE__ */ jsx28(Zap, { className: "w-3 h-3" }),
2303
+ ach.points
2304
+ ] })
2305
+ ]
2306
+ },
2307
+ ach.slug
2308
+ )) })
2309
+ ] }) }),
2310
+ /* @__PURE__ */ jsx28("section", { className: "px-4 sm:px-6 pb-8", children: /* @__PURE__ */ jsxs23("div", { className: "max-w-5xl mx-auto", children: [
2311
+ /* @__PURE__ */ jsx28("h2", { className: "text-lg font-semibold mb-4", children: "Reviews" }),
2312
+ /* @__PURE__ */ jsxs23("div", { className: "no-text-shadow bg-neutral-50 dark:bg-white/4 dark:backdrop-blur-2xl rounded-2xl border border-neutral-200 dark:border-white/8 p-6 flex flex-col items-center text-center", children: [
2313
+ /* @__PURE__ */ jsx28(Star2, { className: "w-8 h-8 text-neutral-300 dark:text-white/20 mb-2" }),
2314
+ /* @__PURE__ */ jsx28("p", { className: "text-sm text-neutral-500 dark:text-white/40", children: "Reviews coming soon" })
2315
+ ] })
2316
+ ] }) }),
2317
+ (websiteUrl || discordUrl || twitterUrl) && /* @__PURE__ */ jsx28("section", { className: "px-4 sm:px-6 pb-8", children: /* @__PURE__ */ jsxs23("div", { className: "max-w-5xl mx-auto flex items-center gap-4", children: [
2318
+ websiteUrl && /* @__PURE__ */ jsx28(
2319
+ "a",
2320
+ {
2321
+ href: websiteUrl,
2322
+ target: "_blank",
2323
+ rel: "noopener noreferrer",
2324
+ "aria-label": "Website",
2325
+ className: "text-neutral-400 dark:text-white/35 hover:text-orange-500 dark:hover:text-brand-orange transition-colors",
2326
+ children: /* @__PURE__ */ jsx28(Globe, { className: "w-5 h-5" })
2327
+ }
2328
+ ),
2329
+ twitterUrl && /* @__PURE__ */ jsx28(
2330
+ "a",
2331
+ {
2332
+ href: twitterUrl,
2333
+ target: "_blank",
2334
+ rel: "noopener noreferrer",
2335
+ "aria-label": "Twitter / X",
2336
+ className: "text-neutral-400 dark:text-white/35 hover:text-orange-500 dark:hover:text-brand-orange transition-colors",
2337
+ children: /* @__PURE__ */ jsx28(Twitter, { className: "w-5 h-5" })
2338
+ }
2339
+ ),
2340
+ discordUrl && /* @__PURE__ */ jsx28(
2341
+ "a",
2342
+ {
2343
+ href: discordUrl,
2344
+ target: "_blank",
2345
+ rel: "noopener noreferrer",
2346
+ "aria-label": "Discord",
2347
+ className: "text-neutral-400 dark:text-white/35 hover:text-orange-500 dark:hover:text-brand-orange transition-colors",
2348
+ children: /* @__PURE__ */ jsx28(MessageCircle, { className: "w-5 h-5" })
2349
+ }
2350
+ )
2351
+ ] }) })
2352
+ ]
2353
+ }
2354
+ );
1774
2355
  }
1775
2356
  export {
1776
2357
  AddressDisplay,
@@ -1783,6 +2364,7 @@ export {
1783
2364
  DashboardLayout,
1784
2365
  DataTable,
1785
2366
  EmptyState,
2367
+ FeaturedProjectCard,
1786
2368
  Field,
1787
2369
  FormModal,
1788
2370
  IconArrowRight,
@@ -1807,6 +2389,7 @@ export {
1807
2389
  IconUndo,
1808
2390
  IconX,
1809
2391
  Input,
2392
+ InstalledProjectIcon,
1810
2393
  JsonPanel,
1811
2394
  JsonToggleButton,
1812
2395
  MEDIA_LIMITS,
@@ -1815,6 +2398,7 @@ export {
1815
2398
  MediaUploader,
1816
2399
  MemoConditionsEditor,
1817
2400
  PageShell,
2401
+ ProjectPagePreview,
1818
2402
  SearchInput,
1819
2403
  Section,
1820
2404
  Select,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unicitylabs/sphere-ui",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -48,6 +48,7 @@
48
48
  "@dnd-kit/sortable": "^8.0.0 || ^10.0.0",
49
49
  "@tanstack/react-query": "^5.0.0",
50
50
  "@tanstack/react-table": "^8.0.0",
51
+ "framer-motion": "^11.18.2",
51
52
  "lucide-react": ">=0.400.0",
52
53
  "react": "^19.0.0",
53
54
  "react-dom": "^19.0.0",
@@ -71,6 +72,7 @@
71
72
  "@types/react": "^19.0.0",
72
73
  "@types/react-dom": "^19.0.0",
73
74
  "@vitejs/plugin-react": "^4.7.0",
75
+ "framer-motion": "^11.18.2",
74
76
  "jsdom": "^25.0.1",
75
77
  "lucide-react": "^0.400.0",
76
78
  "react": "^19.0.0",