@unicitylabs/sphere-ui 0.1.14 → 0.1.16

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
@@ -6,11 +6,12 @@ Shared UI library for the Sphere ecosystem. Provides a unified design system, co
6
6
 
7
7
  | App | Description |
8
8
  |-----|-------------|
9
- | [sphere](https://github.com/unicity-sphere/sphere) | Wallet & marketplace |
10
9
  | [sphere-dev-portal](https://github.com/unicity-sphere/sphere-dev-portal) | Developer Portal |
11
10
  | [sphere-backoffice](https://github.com/unicity-sphere/sphere-backoffice) | Admin panel |
12
11
  | [sphere-quest](https://github.com/unicity-sphere/sphere-quest) | Quest frontend (iframe) |
13
12
 
13
+ > **Planned migration: sphere wallet** — the sphere wallet currently does not consume sphere-ui; migration is a separate multi-PR project tracked outside this repo.
14
+
14
15
  ## Installation
15
16
 
16
17
  ```bash
@@ -139,6 +140,16 @@ Defined in `src/styles/tokens.css` via `@theme {}` block:
139
140
 
140
141
  Backward-compatible aliases `admin-card`, `admin-input`, etc. are also available.
141
142
 
143
+ ## Media components (v0.1.16+)
144
+
145
+ For uploading images (logos, banners, screenshots) and rendering marketplace cards:
146
+
147
+ - `<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
+ - `<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
+
151
+ Helper exports: `MEDIA_LIMITS`, `isMimeAllowed`, `isSizeAllowed`, `humanSize`. Types: `MediaKind`, `MediaMime`, `MediaLimit`, `MediaUploadFn`, `MediaUploadResult`, `MediaItem`.
152
+
142
153
  ## Development
143
154
 
144
155
  ```bash
@@ -118,4 +118,25 @@ interface TopEntitiesTableProps {
118
118
  */
119
119
  declare function TopEntitiesTable({ entities, title, valueLabel, secondaryLabel, hideBars, emptyState, accentColor, className, }: TopEntitiesTableProps): react_jsx_runtime.JSX.Element;
120
120
 
121
- export { type DateRangeLabel, DateRangePicker, type DateRangePickerProps, type DateRangePreset, type DateRangeValue, KPICard, type KPICardProps, TimeseriesChart, type TimeseriesChartProps, type TimeseriesSeries, TopEntitiesTable, type TopEntitiesTableProps, type TopEntity, PRESET_LABELS as dateRangePresetLabels };
121
+ interface AnalyticsSkeletonProps {
122
+ /** Rendered above the grid — usually the existing page header (title + DateRangePicker). */
123
+ header?: React.ReactNode;
124
+ /** Hide KPI rows — useful if the consumer already renders its own. */
125
+ showKpiRows?: boolean;
126
+ /** Hide chart blocks. */
127
+ showCharts?: boolean;
128
+ /** Hide the split bottom row (top quests + platform breakdown). */
129
+ showBottomTables?: boolean;
130
+ className?: string;
131
+ }
132
+ /**
133
+ * Skeleton state for the shared Analytics page layout. Mirrors the
134
+ * structure AdminProjectAnalyticsPage / ProjectAnalyticsPage render
135
+ * after data arrives: two rows of four KPI cards, two chart blocks,
136
+ * and a split row of two table cards. Consumers pass the same
137
+ * `header` they render in the real state so the date-range picker
138
+ * stays interactive while data loads.
139
+ */
140
+ declare function AnalyticsSkeleton({ header, showKpiRows, showCharts, showBottomTables, className, }: AnalyticsSkeletonProps): react_jsx_runtime.JSX.Element;
141
+
142
+ export { AnalyticsSkeleton, type AnalyticsSkeletonProps, type DateRangeLabel, DateRangePicker, type DateRangePickerProps, type DateRangePreset, type DateRangeValue, KPICard, type KPICardProps, TimeseriesChart, type TimeseriesChartProps, type TimeseriesSeries, TopEntitiesTable, type TopEntitiesTableProps, type TopEntity, PRESET_LABELS as dateRangePresetLabels };
@@ -414,7 +414,132 @@ function TopEntitiesTable({
414
414
  }) })
415
415
  ] });
416
416
  }
417
+
418
+ // src/components/Skeleton.tsx
419
+ import { jsx as jsx5 } from "react/jsx-runtime";
420
+ function Skeleton({
421
+ width = "100%",
422
+ height = "1rem",
423
+ radius = "var(--radius-md)",
424
+ className = ""
425
+ }) {
426
+ return /* @__PURE__ */ jsx5(
427
+ "div",
428
+ {
429
+ className: `animate-skeleton-pulse ${className}`.trim(),
430
+ "aria-busy": "true",
431
+ style: {
432
+ width,
433
+ height,
434
+ borderRadius: radius,
435
+ background: "var(--bg-hover)",
436
+ border: "1px solid var(--border)"
437
+ }
438
+ }
439
+ );
440
+ }
441
+
442
+ // src/components/SkeletonText.tsx
443
+ import { jsx as jsx6 } from "react/jsx-runtime";
444
+ function SkeletonText({
445
+ lines = 1,
446
+ lineHeight = "0.875rem",
447
+ gap = "0.5rem",
448
+ className = ""
449
+ }) {
450
+ return /* @__PURE__ */ jsx6(
451
+ "div",
452
+ {
453
+ className,
454
+ role: "status",
455
+ "aria-busy": "true",
456
+ style: { display: "flex", flexDirection: "column", gap },
457
+ children: Array.from({ length: lines }).map((_, i) => {
458
+ const isLast = i === lines - 1 && lines > 1;
459
+ return /* @__PURE__ */ jsx6(
460
+ Skeleton,
461
+ {
462
+ width: isLast ? "70%" : "100%",
463
+ height: lineHeight,
464
+ radius: "var(--radius-sm)"
465
+ },
466
+ i
467
+ );
468
+ })
469
+ }
470
+ );
471
+ }
472
+
473
+ // src/components/SkeletonCircle.tsx
474
+ import { jsx as jsx7 } from "react/jsx-runtime";
475
+ var NAMED_SIZES = {
476
+ sm: "1.5rem",
477
+ md: "2.5rem",
478
+ lg: "4rem"
479
+ };
480
+ function resolveSize(size) {
481
+ if (size === "sm" || size === "md" || size === "lg") return NAMED_SIZES[size];
482
+ return size;
483
+ }
484
+ function SkeletonCircle({ size = "md", className = "" }) {
485
+ const dim = resolveSize(size);
486
+ return /* @__PURE__ */ jsx7(Skeleton, { width: dim, height: dim, radius: "50%", className });
487
+ }
488
+
489
+ // src/analytics/AnalyticsSkeleton.tsx
490
+ import { Fragment, jsx as jsx8, jsxs as jsxs5 } from "react/jsx-runtime";
491
+ function AnalyticsSkeleton({
492
+ header,
493
+ showKpiRows = true,
494
+ showCharts = true,
495
+ showBottomTables = true,
496
+ className = ""
497
+ }) {
498
+ return /* @__PURE__ */ jsxs5("div", { className: `space-y-6 ${className}`, "aria-busy": "true", "aria-label": "Loading analytics", children: [
499
+ header,
500
+ showKpiRows && /* @__PURE__ */ jsxs5(Fragment, { children: [
501
+ /* @__PURE__ */ jsx8("div", { className: "grid grid-cols-2 lg:grid-cols-4 gap-4", children: Array.from({ length: 4 }).map((_, i) => /* @__PURE__ */ jsx8(KpiCardSkeleton, {}, i)) }),
502
+ /* @__PURE__ */ jsx8("div", { className: "grid grid-cols-2 lg:grid-cols-4 gap-4", children: Array.from({ length: 4 }).map((_, i) => /* @__PURE__ */ jsx8(KpiCardSkeleton, {}, i)) })
503
+ ] }),
504
+ showCharts && /* @__PURE__ */ jsxs5(Fragment, { children: [
505
+ /* @__PURE__ */ jsx8(ChartBlockSkeleton, {}),
506
+ /* @__PURE__ */ jsx8(ChartBlockSkeleton, {})
507
+ ] }),
508
+ showBottomTables && /* @__PURE__ */ jsxs5("div", { className: "grid lg:grid-cols-2 gap-4", children: [
509
+ /* @__PURE__ */ jsx8(TableBlockSkeleton, { rows: 5 }),
510
+ /* @__PURE__ */ jsx8(TableBlockSkeleton, { rows: 5 })
511
+ ] })
512
+ ] });
513
+ }
514
+ function KpiCardSkeleton() {
515
+ return /* @__PURE__ */ jsxs5("div", { className: "rounded-xl border border-(--border,rgba(255,255,255,0.08)) bg-(--surface,rgba(255,255,255,0.02)) p-5", children: [
516
+ /* @__PURE__ */ jsxs5("div", { className: "flex items-center gap-2 mb-3", children: [
517
+ /* @__PURE__ */ jsx8(SkeletonCircle, { size: "sm" }),
518
+ /* @__PURE__ */ jsx8(Skeleton, { width: "60%", height: "10px" })
519
+ ] }),
520
+ /* @__PURE__ */ jsx8(Skeleton, { width: "40%", height: "28px" })
521
+ ] });
522
+ }
523
+ function ChartBlockSkeleton() {
524
+ return /* @__PURE__ */ jsxs5("div", { className: "rounded-xl border border-(--border,rgba(255,255,255,0.08)) bg-(--surface,rgba(255,255,255,0.02)) p-5", children: [
525
+ /* @__PURE__ */ jsx8(Skeleton, { width: "30%", height: "14px", className: "mb-4" }),
526
+ /* @__PURE__ */ jsx8(Skeleton, { width: "100%", height: "240px", radius: "8px" })
527
+ ] });
528
+ }
529
+ function TableBlockSkeleton({ rows }) {
530
+ return /* @__PURE__ */ jsxs5("div", { className: "rounded-xl border border-(--border,rgba(255,255,255,0.08)) bg-(--surface,rgba(255,255,255,0.02)) p-5", children: [
531
+ /* @__PURE__ */ jsx8(Skeleton, { width: "40%", height: "14px", className: "mb-4" }),
532
+ /* @__PURE__ */ jsx8("div", { className: "space-y-3", children: Array.from({ length: rows }).map((_, i) => /* @__PURE__ */ jsxs5("div", { children: [
533
+ /* @__PURE__ */ jsxs5("div", { className: "flex items-center justify-between mb-1.5", children: [
534
+ /* @__PURE__ */ jsx8("div", { style: { width: "55%" }, children: /* @__PURE__ */ jsx8(SkeletonText, { lines: 1 }) }),
535
+ /* @__PURE__ */ jsx8(Skeleton, { width: "40px", height: "12px" })
536
+ ] }),
537
+ /* @__PURE__ */ jsx8(Skeleton, { width: "100%", height: "6px", radius: "3px" })
538
+ ] }, i)) })
539
+ ] });
540
+ }
417
541
  export {
542
+ AnalyticsSkeleton,
418
543
  DateRangePicker,
419
544
  KPICard,
420
545
  TimeseriesChart,
package/dist/index.d.ts CHANGED
@@ -278,4 +278,77 @@ declare const IconStar: (p: IconProps) => react_jsx_runtime.JSX.Element;
278
278
  declare const IconDiamond: (p: IconProps) => react_jsx_runtime.JSX.Element;
279
279
  declare const IconCircle: (p: IconProps) => react_jsx_runtime.JSX.Element;
280
280
 
281
- 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, 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, tagColor };
281
+ type MediaKind = 'logo' | 'banner' | 'screenshot' | 'image' | 'background';
282
+ type MediaMime = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/svg+xml';
283
+ interface MediaLimit {
284
+ mimes: readonly MediaMime[];
285
+ maxSize: number;
286
+ maxWidth?: number;
287
+ maxHeight?: number;
288
+ aspectRatio?: number;
289
+ aspectTolerance?: number;
290
+ }
291
+ interface MediaUploadResult {
292
+ publicUrl: string;
293
+ assetId: string;
294
+ }
295
+ interface MediaUploadFn {
296
+ (file: File, opts: {
297
+ kind: MediaKind;
298
+ ownerType: 'project' | 'organization' | 'quest' | 'achievement' | 'track';
299
+ ownerId: string;
300
+ onProgress?: (pct: number) => void;
301
+ signal?: AbortSignal;
302
+ }): Promise<MediaUploadResult>;
303
+ }
304
+
305
+ interface MediaUploaderProps {
306
+ kind: MediaKind;
307
+ ownerType: 'project' | 'organization' | 'quest' | 'achievement' | 'track';
308
+ ownerId: string;
309
+ value?: string | null;
310
+ onChange: (url: string | null) => void;
311
+ uploadFn: MediaUploadFn;
312
+ label?: string;
313
+ }
314
+ declare function MediaUploader({ kind, ownerType, ownerId, value, onChange, uploadFn, label, }: MediaUploaderProps): react_jsx_runtime.JSX.Element;
315
+
316
+ interface MediaItem {
317
+ type: 'screenshot' | 'video';
318
+ url: string;
319
+ }
320
+ interface MediaGalleryProps {
321
+ ownerType: 'project';
322
+ ownerId: string;
323
+ items: MediaItem[];
324
+ onChange: (items: MediaItem[]) => void;
325
+ uploadFn: MediaUploadFn;
326
+ max?: number;
327
+ }
328
+ declare function MediaGallery({ ownerType, ownerId, items, onChange, uploadFn, max }: MediaGalleryProps): react_jsx_runtime.JSX.Element;
329
+
330
+ interface MarketplaceProjectCardProps {
331
+ name: string;
332
+ tagline?: string;
333
+ logoUrl?: string | null;
334
+ bannerUrl?: string | null;
335
+ rating?: number;
336
+ userCount?: number;
337
+ }
338
+ /**
339
+ * MarketplaceProjectCard — preview of a project card in sphere wallet marketplace style.
340
+ *
341
+ * 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.
343
+ *
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.
346
+ */
347
+ declare function MarketplaceProjectCard({ name, tagline, logoUrl, bannerUrl, rating, userCount, }: MarketplaceProjectCardProps): react_jsx_runtime.JSX.Element;
348
+
349
+ declare const MEDIA_LIMITS: Record<MediaKind, MediaLimit>;
350
+ declare function isMimeAllowed(kind: MediaKind, mime: string): mime is MediaMime;
351
+ declare function isSizeAllowed(kind: MediaKind, size: number): boolean;
352
+ declare function humanSize(bytes: number): string;
353
+
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 };
package/dist/index.js CHANGED
@@ -1379,6 +1379,399 @@ var IconPlay = (p) => /* @__PURE__ */ jsx22(I, { d: P.play, ...p });
1379
1379
  var IconStar = (p) => /* @__PURE__ */ jsx22(I, { d: P.star, ...p });
1380
1380
  var IconDiamond = (p) => /* @__PURE__ */ jsx22(I, { d: P.diamond, ...p });
1381
1381
  var IconCircle = (p) => /* @__PURE__ */ jsx22(I, { d: P.circle, ...p });
1382
+
1383
+ // src/components/media/MediaUploader.tsx
1384
+ import { useCallback as useCallback4, useEffect as useEffect5, useRef as useRef3, useState as useState7 } from "react";
1385
+ import { useDropzone } from "react-dropzone";
1386
+
1387
+ // src/components/media/media-limits.ts
1388
+ var MB = 1024 * 1024;
1389
+ var MEDIA_LIMITS = {
1390
+ logo: { mimes: ["image/png", "image/jpeg", "image/webp", "image/svg+xml"], maxSize: 1 * MB, maxWidth: 1024, maxHeight: 1024, aspectRatio: 1, aspectTolerance: 0.05 },
1391
+ banner: { mimes: ["image/png", "image/jpeg", "image/webp"], maxSize: 3 * MB, maxWidth: 1920, maxHeight: 640, aspectRatio: 3, aspectTolerance: 0.15 },
1392
+ screenshot: { mimes: ["image/png", "image/jpeg", "image/webp"], maxSize: 5 * MB, maxWidth: 2560, maxHeight: 1440 },
1393
+ image: { mimes: ["image/png", "image/jpeg", "image/webp", "image/svg+xml"], maxSize: 1 * MB, maxWidth: 1024, maxHeight: 1024, aspectRatio: 1, aspectTolerance: 0.05 },
1394
+ background: { mimes: ["image/png", "image/jpeg", "image/webp"], maxSize: 3 * MB, maxWidth: 1920, maxHeight: 1080 }
1395
+ };
1396
+ function isMimeAllowed(kind, mime) {
1397
+ return MEDIA_LIMITS[kind].mimes.includes(mime);
1398
+ }
1399
+ function isSizeAllowed(kind, size) {
1400
+ return size > 0 && size <= MEDIA_LIMITS[kind].maxSize;
1401
+ }
1402
+ function humanSize(bytes) {
1403
+ if (bytes < 1024) return `${bytes} B`;
1404
+ if (bytes < MB) return `${(bytes / 1024).toFixed(0)} KB`;
1405
+ const mb = bytes / MB;
1406
+ return Number.isInteger(mb) ? `${mb} MB` : `${mb.toFixed(1)} MB`;
1407
+ }
1408
+
1409
+ // src/components/media/MediaUploader.tsx
1410
+ import { Fragment as Fragment4, jsx as jsx23, jsxs as jsxs18 } from "react/jsx-runtime";
1411
+ function formatExtensions(mimes) {
1412
+ return mimes.map((m) => m.split("/")[1].toUpperCase()).join(", ");
1413
+ }
1414
+ function MediaUploader({
1415
+ kind,
1416
+ ownerType,
1417
+ ownerId,
1418
+ value,
1419
+ onChange,
1420
+ uploadFn,
1421
+ label
1422
+ }) {
1423
+ const limit = MEDIA_LIMITS[kind];
1424
+ const [state, setState] = useState7({ phase: "idle" });
1425
+ const [urlInput, setUrlInput] = useState7(value && !value.startsWith("blob:") ? value : "");
1426
+ const previewRef = useRef3(null);
1427
+ useEffect5(
1428
+ () => () => {
1429
+ if (previewRef.current) {
1430
+ URL.revokeObjectURL(previewRef.current);
1431
+ previewRef.current = null;
1432
+ }
1433
+ },
1434
+ []
1435
+ );
1436
+ const handleFile = useCallback4(
1437
+ async (file) => {
1438
+ if (!isMimeAllowed(kind, file.type)) {
1439
+ setState({
1440
+ phase: "error",
1441
+ message: `Format not supported (${formatExtensions(limit.mimes)} only)`
1442
+ });
1443
+ return;
1444
+ }
1445
+ if (!isSizeAllowed(kind, file.size)) {
1446
+ setState({
1447
+ phase: "error",
1448
+ message: `File too large (max ${humanSize(limit.maxSize)})`
1449
+ });
1450
+ return;
1451
+ }
1452
+ const abort = new AbortController();
1453
+ setState({ phase: "uploading", file, progress: 0, abort });
1454
+ if (previewRef.current) URL.revokeObjectURL(previewRef.current);
1455
+ previewRef.current = URL.createObjectURL(file);
1456
+ try {
1457
+ const result = await uploadFn(file, {
1458
+ kind,
1459
+ ownerType,
1460
+ ownerId,
1461
+ signal: abort.signal,
1462
+ onProgress: (pct) => setState((s) => s.phase === "uploading" ? { ...s, progress: pct } : s)
1463
+ });
1464
+ onChange(result.publicUrl);
1465
+ setState({ phase: "done" });
1466
+ } catch (e) {
1467
+ if (abort.signal.aborted) {
1468
+ setState({ phase: "idle" });
1469
+ return;
1470
+ }
1471
+ const message = e instanceof Error ? e.message : "Upload failed, please retry";
1472
+ setState({ phase: "error", message });
1473
+ }
1474
+ },
1475
+ [kind, ownerType, ownerId, uploadFn, onChange, limit]
1476
+ );
1477
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
1478
+ accept: Object.fromEntries(limit.mimes.map((m) => [m, []])),
1479
+ maxSize: limit.maxSize,
1480
+ multiple: false,
1481
+ onDropAccepted: (files) => {
1482
+ if (files[0]) void handleFile(files[0]);
1483
+ },
1484
+ onDropRejected: (rejections) => {
1485
+ const firstError = rejections[0]?.errors[0];
1486
+ if (firstError?.code === "file-too-large") {
1487
+ setState({
1488
+ phase: "error",
1489
+ message: `File too large (max ${humanSize(limit.maxSize)})`
1490
+ });
1491
+ } else if (firstError?.code === "file-invalid-type") {
1492
+ setState({
1493
+ phase: "error",
1494
+ message: `Format not supported (${formatExtensions(limit.mimes)} only)`
1495
+ });
1496
+ } else {
1497
+ setState({ phase: "error", message: firstError?.message ?? "File rejected" });
1498
+ }
1499
+ }
1500
+ });
1501
+ return /* @__PURE__ */ jsxs18("div", { className: "space-y-2", children: [
1502
+ label && /* @__PURE__ */ jsx23("div", { className: "text-sm text-[--text-secondary]", children: label }),
1503
+ /* @__PURE__ */ jsxs18(
1504
+ "div",
1505
+ {
1506
+ ...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]"}`,
1508
+ children: [
1509
+ /* @__PURE__ */ jsx23("input", { ...getInputProps(), "aria-label": "file uploader" }),
1510
+ state.phase === "idle" && /* @__PURE__ */ jsxs18(Fragment4, { children: [
1511
+ /* @__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: [
1513
+ formatExtensions(limit.mimes),
1514
+ " \xB7 max ",
1515
+ humanSize(limit.maxSize),
1516
+ limit.maxWidth && limit.maxHeight && ` \xB7 ${limit.maxWidth}\xD7${limit.maxHeight}`
1517
+ ] })
1518
+ ] }),
1519
+ state.phase === "uploading" && /* @__PURE__ */ jsxs18(Fragment4, { children: [
1520
+ previewRef.current && /* @__PURE__ */ jsx23(
1521
+ "img",
1522
+ {
1523
+ src: previewRef.current,
1524
+ alt: "upload preview",
1525
+ className: "max-w-[64px] max-h-[64px] rounded-[--radius-sm] object-cover border border-[--border] mx-auto mb-2"
1526
+ }
1527
+ ),
1528
+ /* @__PURE__ */ jsxs18("div", { className: "text-sm", children: [
1529
+ "Uploading ",
1530
+ state.file.name,
1531
+ "\u2026"
1532
+ ] }),
1533
+ /* @__PURE__ */ jsx23(
1534
+ "progress",
1535
+ {
1536
+ className: "w-full",
1537
+ value: state.progress,
1538
+ max: 100,
1539
+ "aria-label": "upload progress",
1540
+ "aria-valuetext": `${state.progress}%`
1541
+ }
1542
+ ),
1543
+ /* @__PURE__ */ jsx23(
1544
+ "button",
1545
+ {
1546
+ type: "button",
1547
+ onClick: (e) => {
1548
+ e.stopPropagation();
1549
+ state.abort.abort();
1550
+ },
1551
+ className: "text-xs mt-1 underline",
1552
+ children: "Cancel"
1553
+ }
1554
+ )
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: [
1558
+ /* @__PURE__ */ jsx23("div", { className: "text-sm text-red-500", children: state.message }),
1559
+ /* @__PURE__ */ jsx23(
1560
+ "button",
1561
+ {
1562
+ type: "button",
1563
+ onClick: (e) => {
1564
+ e.stopPropagation();
1565
+ setState({ phase: "idle" });
1566
+ },
1567
+ className: "text-xs mt-1 underline",
1568
+ children: "Try again"
1569
+ }
1570
+ )
1571
+ ] })
1572
+ ]
1573
+ }
1574
+ ),
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
+ )
1595
+ ] });
1596
+ }
1597
+
1598
+ // src/components/media/MediaGallery.tsx
1599
+ import { useState as useState8 } from "react";
1600
+ import {
1601
+ DndContext,
1602
+ closestCenter
1603
+ } from "@dnd-kit/core";
1604
+ import {
1605
+ arrayMove,
1606
+ SortableContext,
1607
+ useSortable,
1608
+ horizontalListSortingStrategy
1609
+ } from "@dnd-kit/sortable";
1610
+ import { CSS } from "@dnd-kit/utilities";
1611
+ import { jsx as jsx24, jsxs as jsxs19 } from "react/jsx-runtime";
1612
+ function SortableTile({ item, onRemove }) {
1613
+ const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: item.url });
1614
+ const style = {
1615
+ transform: CSS.Transform.toString(transform),
1616
+ transition
1617
+ };
1618
+ return /* @__PURE__ */ jsxs19("div", { ref: setNodeRef, style, className: "relative w-24 h-24 rounded-[--radius-md] border border-[--border] overflow-hidden", children: [
1619
+ /* @__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
+ /* @__PURE__ */ jsx24("img", { src: item.url, alt: `${item.type} thumbnail`, className: "w-full h-full object-cover" }),
1621
+ /* @__PURE__ */ jsx24(
1622
+ "button",
1623
+ {
1624
+ type: "button",
1625
+ "aria-label": "remove screenshot",
1626
+ onClick: onRemove,
1627
+ className: "absolute top-1 right-1 z-10 text-xs bg-black/50 rounded-full w-5 h-5 flex items-center justify-center text-white",
1628
+ children: "\u2715"
1629
+ }
1630
+ )
1631
+ ] });
1632
+ }
1633
+ function MediaGallery({ ownerType, ownerId, items, onChange, uploadFn, max = 10 }) {
1634
+ const [adding, setAdding] = useState8(false);
1635
+ function handleDragEnd(e) {
1636
+ if (!e.over || e.active.id === e.over.id) return;
1637
+ const oldIndex = items.findIndex((i) => i.url === e.active.id);
1638
+ const newIndex = items.findIndex((i) => i.url === e.over.id);
1639
+ onChange(arrayMove(items, oldIndex, newIndex));
1640
+ }
1641
+ return /* @__PURE__ */ jsxs19(
1642
+ "div",
1643
+ {
1644
+ className: "space-y-2",
1645
+ onDragOver: (e) => e.preventDefault(),
1646
+ onDrop: (e) => e.preventDefault(),
1647
+ children: [
1648
+ /* @__PURE__ */ jsxs19("div", { className: "text-sm text-[--text-secondary]", children: [
1649
+ "Screenshots (",
1650
+ items.length,
1651
+ "/",
1652
+ max,
1653
+ ")"
1654
+ ] }),
1655
+ /* @__PURE__ */ jsx24(DndContext, { collisionDetection: closestCenter, onDragEnd: handleDragEnd, children: /* @__PURE__ */ jsx24(SortableContext, { items: items.map((i) => i.url), strategy: horizontalListSortingStrategy, children: /* @__PURE__ */ jsxs19("div", { className: "flex flex-wrap gap-2", children: [
1656
+ items.map((item, i) => /* @__PURE__ */ jsx24(
1657
+ SortableTile,
1658
+ {
1659
+ item,
1660
+ onRemove: () => onChange(items.filter((_, j) => j !== i))
1661
+ },
1662
+ item.url
1663
+ )),
1664
+ items.length < max && !adding && /* @__PURE__ */ jsx24(
1665
+ "button",
1666
+ {
1667
+ type: "button",
1668
+ "aria-label": "add screenshot",
1669
+ onClick: () => setAdding(true),
1670
+ className: "w-24 h-24 rounded-[--radius-md] border-2 border-dashed border-[--border] text-2xl hover:border-[--accent]",
1671
+ children: "+"
1672
+ }
1673
+ )
1674
+ ] }) }) }),
1675
+ adding && /* @__PURE__ */ jsxs19("div", { className: "border border-[--border] rounded-[--radius-md] p-3", children: [
1676
+ /* @__PURE__ */ jsx24(
1677
+ MediaUploader,
1678
+ {
1679
+ kind: "screenshot",
1680
+ ownerType,
1681
+ ownerId,
1682
+ uploadFn,
1683
+ onChange: (url) => {
1684
+ if (url) {
1685
+ const isDuplicate = items.some((i) => i.url === url);
1686
+ if (!isDuplicate) {
1687
+ onChange([...items, { type: "screenshot", url }]);
1688
+ }
1689
+ }
1690
+ setAdding(false);
1691
+ },
1692
+ label: "Add screenshot"
1693
+ }
1694
+ ),
1695
+ /* @__PURE__ */ jsx24("button", { type: "button", onClick: () => setAdding(false), className: "text-xs mt-2 underline", children: "Cancel" })
1696
+ ] })
1697
+ ]
1698
+ }
1699
+ );
1700
+ }
1701
+
1702
+ // 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
+ }
1712
+ function MarketplaceProjectCard({
1713
+ name,
1714
+ tagline,
1715
+ logoUrl,
1716
+ bannerUrl,
1717
+ rating,
1718
+ userCount
1719
+ }) {
1720
+ 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(
1749
+ "div",
1750
+ {
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)
1753
+ }
1754
+ ),
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)
1765
+ ] }),
1766
+ userCount !== void 0 && /* @__PURE__ */ jsxs20("span", { children: [
1767
+ "\xB7 ",
1768
+ formatUsers(userCount),
1769
+ " users"
1770
+ ] })
1771
+ ] })
1772
+ ] })
1773
+ ] });
1774
+ }
1382
1775
  export {
1383
1776
  AddressDisplay,
1384
1777
  AlertBanner,
@@ -1416,6 +1809,10 @@ export {
1416
1809
  Input,
1417
1810
  JsonPanel,
1418
1811
  JsonToggleButton,
1812
+ MEDIA_LIMITS,
1813
+ MarketplaceProjectCard,
1814
+ MediaGallery,
1815
+ MediaUploader,
1419
1816
  MemoConditionsEditor,
1420
1817
  PageShell,
1421
1818
  SearchInput,
@@ -1427,5 +1824,8 @@ export {
1427
1824
  SkeletonText,
1428
1825
  StatusBadge,
1429
1826
  Textarea,
1827
+ humanSize,
1828
+ isMimeAllowed,
1829
+ isSizeAllowed,
1430
1830
  tagColor
1431
1831
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unicitylabs/sphere-ui",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -51,6 +51,7 @@
51
51
  "lucide-react": ">=0.400.0",
52
52
  "react": "^19.0.0",
53
53
  "react-dom": "^19.0.0",
54
+ "react-dropzone": "^14.4.1",
54
55
  "recharts": "^3.0.0"
55
56
  },
56
57
  "peerDependenciesMeta": {
@@ -74,6 +75,7 @@
74
75
  "lucide-react": "^0.400.0",
75
76
  "react": "^19.0.0",
76
77
  "react-dom": "^19.0.0",
78
+ "react-dropzone": "^14.4.1",
77
79
  "recharts": "^3.8.1",
78
80
  "tsup": "^8.0.0",
79
81
  "typescript": "~5.9.0",