@unicitylabs/sphere-ui 0.1.15 → 0.1.17
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 +12 -1
- package/dist/index.d.ts +74 -1
- package/dist/index.js +400 -0
- package/package.json +3 -1
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
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.17",
|
|
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",
|