Skip to content

Commit 94a3f60

Browse files
jamiepineclaude
andcommitted
Add @spaceui/icons package, FileThumb, GridItem, SpaceItem, TabBar fix
- New @spaceui/icons package: full Spacedrive icon library (197 PNGs, 393 bearded SVGs, 17 code SVGs, 12 brand SVGs) with getIcon/ getBeardedIcon resolution utilities - New FileThumb component: presentational thumbnail with icon fallback, bearded badge overlay, IconSource type for cross-bundler compat - New GridItem component: presentational grid card composing FileThumb - New SpaceItem primitive: sidebar item matching SpacesSidebar styling (gap-2, rounded-md, polymorphic icon: component/URL/color dot/element) - TabBar: close button only renders on active tab - Fix ToggleGroup DTS build (ReactNode bigint compat) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a4f83ae commit 94a3f60

635 files changed

Lines changed: 1449 additions & 13 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"linked": [
77
["@spaceui/primitives", "@spaceui/forms", "@spaceui/ai", "@spaceui/explorer"]
88
],
9-
"access": "restricted",
9+
"access": "public",
1010
"baseBranch": "main",
1111
"updateInternalDependencies": "patch",
1212
"ignore": []

bun.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import clsx from "clsx";
5+
6+
/** An icon source — either a URL string or an object with a .src property (e.g., Next.js StaticImageData) */
7+
export type IconSource = string | { src: string };
8+
9+
/** Resolve an IconSource to a URL string */
10+
function resolveIconSrc(src: IconSource): string {
11+
return typeof src === "string" ? src : src.src;
12+
}
13+
14+
export interface FileThumbProps {
15+
/** Kind icon (e.g., Document, Folder PNG). Accepts URL string or StaticImageData. */
16+
iconSrc: IconSource;
17+
/** Optional thumbnail image (photo, video poster, etc.). Accepts URL string or StaticImageData. */
18+
thumbnailSrc?: IconSource | null;
19+
/** Optional extension badge overlay (bearded icon SVG URL) */
20+
beardedIconSrc?: string | null;
21+
/** Size in pixels (width and height) */
22+
size?: number;
23+
/** Scale factor for fallback icon relative to size (0-1, default 1) */
24+
iconScale?: number;
25+
/** Custom frame class for the thumbnail image (border, radius, bg) */
26+
frameClassName?: string;
27+
className?: string;
28+
}
29+
30+
export function FileThumb({
31+
iconSrc,
32+
thumbnailSrc,
33+
beardedIconSrc,
34+
size = 100,
35+
iconScale = 1,
36+
frameClassName,
37+
className,
38+
}: FileThumbProps) {
39+
const [thumbLoaded, setThumbLoaded] = useState(false);
40+
const [thumbError, setThumbError] = useState(false);
41+
42+
const resolvedIcon = resolveIconSrc(iconSrc);
43+
const resolvedThumb = thumbnailSrc ? resolveIconSrc(thumbnailSrc) : null;
44+
45+
const iconSize = size * iconScale;
46+
47+
// Below 60px, show only bearded icon at full size; above, show as overlay at 40%
48+
const isSmallIcon = size < 60;
49+
const badgeSize = isSmallIcon ? iconSize : iconSize * 0.4;
50+
51+
// Scale border radius with size (8% of size, clamped between 2px and 8px)
52+
const borderRadius = Math.min(8, Math.max(2, size * 0.08));
53+
54+
return (
55+
<div
56+
className={clsx(
57+
"relative pointer-events-none flex shrink-0 grow-0 items-center justify-center",
58+
className,
59+
)}
60+
style={{
61+
width: size,
62+
height: size,
63+
minWidth: size,
64+
minHeight: size,
65+
maxWidth: size,
66+
maxHeight: size,
67+
}}
68+
>
69+
{/* Base kind icon — always rendered first (instant), thumbnail loads over it */}
70+
{/* Hide document icon if small and showing bearded badge */}
71+
{!(isSmallIcon && beardedIconSrc) && (
72+
<img
73+
src={resolvedIcon}
74+
alt=""
75+
className={clsx(
76+
"object-contain transition-opacity",
77+
thumbLoaded && resolvedThumb && "opacity-0",
78+
)}
79+
style={{
80+
width: iconSize,
81+
height: iconSize,
82+
maxWidth: "100%",
83+
maxHeight: "100%",
84+
}}
85+
/>
86+
)}
87+
88+
{/* Thumbnail image (loads over the icon) */}
89+
{resolvedThumb && !thumbError && (
90+
<img
91+
src={resolvedThumb}
92+
alt=""
93+
className={clsx(
94+
"absolute inset-0 m-auto max-h-full max-w-full object-contain transition-opacity",
95+
frameClassName || "border border-app-line/50 bg-app-box/30",
96+
!thumbLoaded && "opacity-0",
97+
)}
98+
style={frameClassName ? undefined : { borderRadius: `${borderRadius}px` }}
99+
onLoad={() => setThumbLoaded(true)}
100+
onError={() => setThumbError(true)}
101+
/>
102+
)}
103+
104+
{/* Bearded icon badge overlay */}
105+
{beardedIconSrc && (
106+
<img
107+
src={beardedIconSrc}
108+
alt=""
109+
className="absolute left-1/2 top-[55%] -translate-x-1/2 -translate-y-1/2"
110+
style={{
111+
width: badgeSize,
112+
height: badgeSize,
113+
}}
114+
/>
115+
)}
116+
</div>
117+
);
118+
}

packages/explorer/src/GridItem.tsx

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"use client";
2+
3+
import clsx from "clsx";
4+
import { FileThumb, type FileThumbProps } from "./FileThumb";
5+
6+
export interface GridItemTag {
7+
color: string;
8+
name: string;
9+
}
10+
11+
export interface GridItemProps {
12+
/** File/folder display name */
13+
name: string;
14+
/** File extension (shown appended to name) */
15+
extension?: string | null;
16+
/** File size formatted as string (e.g., "1.2 MB") */
17+
sizeText?: string | null;
18+
/** Whether this item is currently selected */
19+
selected?: boolean;
20+
/** Tags to display as colored dots below the name */
21+
tags?: GridItemTag[];
22+
/** Size of the thumbnail area in pixels */
23+
thumbSize?: number;
24+
/** Props passed through to FileThumb (icon source accepts URL string or StaticImageData) */
25+
thumb: Pick<FileThumbProps, "iconSrc" | "thumbnailSrc" | "beardedIconSrc" | "frameClassName">;
26+
/** Volume capacity bar (optional, for volume items) */
27+
volumeBar?: React.ReactNode;
28+
/** Event handlers */
29+
onClick?: (e: React.MouseEvent) => void;
30+
onDoubleClick?: (e: React.MouseEvent) => void;
31+
onContextMenu?: (e: React.MouseEvent) => void;
32+
className?: string;
33+
}
34+
35+
export function GridItem({
36+
name,
37+
extension,
38+
sizeText,
39+
selected = false,
40+
tags,
41+
thumbSize = 80,
42+
thumb,
43+
volumeBar,
44+
onClick,
45+
onDoubleClick,
46+
onContextMenu,
47+
className,
48+
}: GridItemProps) {
49+
const displayName = extension ? `${name}.${extension}` : name;
50+
51+
return (
52+
<div
53+
onClick={onClick}
54+
onDoubleClick={onDoubleClick}
55+
onContextMenu={onContextMenu}
56+
tabIndex={-1}
57+
className={clsx(
58+
"cursor-default transition-colors outline-none focus:outline-none",
59+
"flex flex-col items-center gap-2 p-1 rounded-lg",
60+
className,
61+
)}
62+
>
63+
{/* Thumbnail container */}
64+
<div
65+
className={clsx(
66+
"rounded-lg p-2",
67+
selected ? "bg-app-box" : "bg-transparent",
68+
)}
69+
>
70+
<FileThumb
71+
{...thumb}
72+
size={thumbSize}
73+
/>
74+
</div>
75+
76+
{/* Name + metadata */}
77+
<div className="w-full flex flex-col items-center">
78+
<div
79+
className={clsx(
80+
"text-sm truncate px-2 py-0.5 rounded-md inline-block max-w-full",
81+
selected ? "bg-accent text-white" : "text-ink",
82+
)}
83+
>
84+
{displayName}
85+
</div>
86+
87+
{/* Volume capacity bar */}
88+
{volumeBar}
89+
90+
{/* File size */}
91+
{sizeText && !volumeBar && (
92+
<div className="text-xs text-ink-dull mt-0.5">
93+
{sizeText}
94+
</div>
95+
)}
96+
97+
{/* Tag dots */}
98+
{tags && tags.length > 0 && (
99+
<div
100+
className="flex items-center gap-1 mt-1"
101+
title={tags.map((t) => t.name).join(", ")}
102+
>
103+
{tags.slice(0, 3).map((tag) => (
104+
<div
105+
key={tag.name}
106+
className="size-2 rounded-full"
107+
style={{ backgroundColor: tag.color }}
108+
title={tag.name}
109+
/>
110+
))}
111+
{tags.length > 3 && (
112+
<span className="text-[10px] text-ink-faint font-medium">
113+
+{tags.length - 3}
114+
</span>
115+
)}
116+
</div>
117+
)}
118+
</div>
119+
</div>
120+
);
121+
}

packages/explorer/src/index.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1-
// @spaceui/explorer — small, reusable explorer primitives
1+
// @spaceui/explorer — presentational file management components
22
//
3-
// Large feature components (Inspector, QuickPreview, PathBar, DragOverlay,
4-
// FileGrid, FileList, FileThumb, KindIcon) belong in @sd/interface,
5-
// not in the shared UI library.
3+
// These are dumb components: props-in, callbacks-out, no data fetching,
4+
// no context, no business logic. Consumers wire up their own state.
65

76
export { TagPill } from "./TagPill";
87
export type { TagPillProps } from "./TagPill";
98

109
export { RenameInput } from "./RenameInput";
1110
export type { RenameInputProps } from "./RenameInput";
11+
12+
export { FileThumb } from "./FileThumb";
13+
export type { FileThumbProps, IconSource } from "./FileThumb";
14+
15+
export { GridItem } from "./GridItem";
16+
export type { GridItemProps, GridItemTag } from "./GridItem";

packages/icons/icons/Album-20.png

734 Bytes

packages/icons/icons/Album.png

39.4 KB
60.2 KB

packages/icons/icons/Alias-20.png

531 Bytes

packages/icons/icons/Alias.png

63 KB

0 commit comments

Comments
 (0)