Skip to content

Commit ddae2c0

Browse files
authored
Connect app: improve space creation & selection UI (#417)
1 parent 566d18d commit ddae2c0

File tree

11 files changed

+277
-352
lines changed

11 files changed

+277
-352
lines changed

apps/connect/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# connect
22

33
## 0.1.2
4+
45
### Patch Changes
56

67
- Updated dependencies [cb54727]
@@ -11,13 +12,15 @@
1112
- @graphprotocol/hypergraph@0.3.0
1213

1314
## 0.1.1
15+
1416
### Patch Changes
1517

1618
- Updated dependencies [8622688]
1719
- @graphprotocol/hypergraph-react@0.2.0
1820
- @graphprotocol/hypergraph@0.2.0
1921

2022
## 0.1.0
23+
2124
### Patch Changes
2225

2326
- 114d743: breaking changes of the authentication flow to improve security and fix invitations

apps/connect/package.json

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,38 +14,37 @@
1414
"check:fix": "pnpm run lint:fix && pnpm run format"
1515
},
1616
"dependencies": {
17-
"@base-ui-components/react": "1.0.0-beta.0",
17+
"@base-ui-components/react": "1.0.0-beta.1",
1818
"@graphprotocol/grc-20": "^0.21.6",
1919
"@graphprotocol/hypergraph": "workspace:*",
2020
"@graphprotocol/hypergraph-react": "workspace:*",
21+
"@heroicons/react": "^2.2.0",
2122
"@privy-io/react-auth": "^2.13.0",
2223
"@tanstack/react-query": "^5.75.5",
2324
"@tanstack/react-router": "^1.120.2",
2425
"@tanstack/react-router-devtools": "^1.122.0",
2526
"@xstate/store": "^3.5.1",
2627
"clsx": "^2.1.1",
2728
"effect": "^3.17.1",
28-
"framer-motion": "^12.10.1",
2929
"graphql-request": "^7.2.0",
30-
"lucide-react": "^0.508.0",
3130
"react": "^19.1.0",
3231
"react-dom": "^19.1.0",
33-
"tailwind-merge": "^3.2.0",
32+
"tailwind-merge": "^3.3.1",
3433
"viem": "^2.30.6",
3534
"vite": "^6.3.5"
3635
},
3736
"devDependencies": {
38-
"@tailwindcss/vite": "^4.1.10",
37+
"@tailwindcss/vite": "^4.1.11",
3938
"@tanstack/router-plugin": "^1.120.2",
4039
"@types/node": "^24.1.0",
4140
"@types/react": "^19.1.3",
4241
"@types/react-dom": "^19.1.3",
4342
"@vitejs/plugin-react": "^4.4.1",
44-
"prettier": "^3.6.0",
45-
"prettier-plugin-tailwindcss": "^0.6.13",
46-
"tailwindcss": "^4.1.10",
47-
"unplugin-fonts": "^1.3.1",
48-
"vite-plugin-node-polyfills": "^0.23.0",
43+
"prettier": "^3.6.2",
44+
"prettier-plugin-tailwindcss": "^0.6.14",
45+
"tailwindcss": "^4.1.11",
46+
"unplugin-fonts": "^1.4.0",
47+
"vite-plugin-node-polyfills": "^0.24.0",
4948
"vite-plugin-svgr": "^4.3.0"
5049
}
5150
}

apps/connect/src/components/CreateSpaceCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ export function CreateSpaceCard({ className, ...props }: CreateSpaceCardProps) {
149149
className="c-input grow"
150150
/>
151151
<select
152-
className="c-input min-w-22"
152+
className="c-input shrink-0"
153153
value={spaceType}
154154
onChange={(e) => setSpaceType(e.target.value as 'private' | 'public')}
155155
>

apps/connect/src/components/SpacesCard.tsx

Lines changed: 136 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import { Checkbox } from '@base-ui-components/react/checkbox';
12
import { Popover } from '@base-ui-components/react/popover';
3+
import { CheckIcon, EyeSlashIcon } from '@heroicons/react/24/solid';
4+
import { Fragment, useState } from 'react';
25
import { Loading } from '@/components/ui/Loading';
36
import type { PrivateSpaceData } from '@/hooks/use-private-spaces';
47
import type { PublicSpaceData } from '@/hooks/use-public-spaces';
@@ -7,16 +10,16 @@ import { cn } from '@/lib/utils';
710
interface SpacesCardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
811
spaces: (PublicSpaceData | PrivateSpaceData)[];
912
status?: 'loading' | { error: boolean | string } | undefined;
10-
selected?: Set<string>;
11-
onSelected?: (spaceId: string, selected: boolean) => void;
12-
currentAppId?: string;
13+
selected?: Set<string> | undefined;
14+
onSelectedChange?: ((spaceId: string, selected: boolean) => void) | undefined;
15+
currentAppId?: string | undefined;
1316
}
1417

1518
export function SpacesCard({
1619
spaces,
1720
status,
1821
selected,
19-
onSelected,
22+
onSelectedChange,
2023
currentAppId,
2124
className,
2225
...props
@@ -30,17 +33,16 @@ export function SpacesCard({
3033
return (
3134
<div
3235
className={cn(
33-
`group/card c-card scroll-y scrollbar-none
34-
has-data-error:bg-error-dark
36+
`group/card c-card scroll-y scrollbar-none has-data-error:bg-error-dark
3537
has-data-error:text-error-light
36-
flex flex-col`,
38+
isolate flex flex-col`,
3739
className,
3840
)}
3941
{...props}
4042
>
4143
<h2
4244
className={`
43-
c-card-title group-has-data-error/card:text-error-light sticky top-(--offset) shrink-0
45+
c-card-title group-has-data-error/card:text-error-light sticky top-(--offset) z-10 shrink-0
4446
bg-[color-mix(in_oklab,var(--color-foreground)_calc(var(--progress)*0.25),transparent)]
4547
text-[color-mix(in_oklab,var(--color-background)_var(--progress),var(--color-foreground-muted))]
4648
backdrop-blur-sm
@@ -71,65 +73,22 @@ export function SpacesCard({
7173
return (
7274
<ul className="grid-cols-auto-fill-36 grid gap-4">
7375
{spaces.map((space) => {
74-
// Determine if space is selected
7576
const isPublicSpace = !('apps' in space);
76-
const isSelected = isPublicSpace ? true : (selected?.has(space.id) ?? false);
77-
const isDisabled =
78-
!isPublicSpace && 'apps' in space && space.apps.some((app) => app.id === currentAppId);
77+
const wasAlreadySelected = !isPublicSpace && space.apps.some((app) => app.id === currentAppId);
78+
const isSelected = isPublicSpace ? true : wasAlreadySelected || (selected?.has(space.id) ?? false);
79+
const isDisabled = isPublicSpace ? true : wasAlreadySelected;
7980

8081
return (
81-
<li key={space.id} className="group/list-item">
82-
<Popover.Root openOnHover delay={50}>
83-
<Popover.Trigger
84-
className={`
85-
group-nth-[5n]/list-item:bg-gradient-violet
86-
group-nth-[5n+1]/list-item:bg-gradient-lavender
87-
group-nth-[5n+2]/list-item:bg-gradient-aqua
88-
group-nth-[5n+3]/list-item:bg-gradient-peach
89-
group-nth-[5n+4]/list-item:bg-gradient-clearmint
90-
flex aspect-video w-full items-end overflow-clip rounded-lg px-3 py-2
91-
${isSelected ? 'ring-2 ring-primary ring-offset-2' : ''}
92-
${isDisabled ? 'ring-2 ring-primary ring-offset-2 cursor-not-allowed' : 'cursor-pointer'}
93-
`}
94-
onClick={() => {
95-
if (!isDisabled && onSelected) {
96-
onSelected(space.id, !isSelected);
97-
}
98-
}}
99-
>
100-
<span className="text-sm leading-tight font-semibold">{space.name || space.id}</span>
101-
</Popover.Trigger>
102-
<Popover.Portal>
103-
<Popover.Positioner side="bottom" sideOffset={12}>
104-
<Popover.Popup className="c-popover">
105-
<Popover.Arrow className="c-popover-arrow">
106-
<ArrowSvg />
107-
</Popover.Arrow>
108-
{!('apps' in space) ? (
109-
<Popover.Title className="font-semibold">Public space</Popover.Title>
110-
) : space.apps.length === 0 ? (
111-
<Popover.Title className="font-semibold">
112-
No app has access to this private space
113-
</Popover.Title>
114-
) : (
115-
<>
116-
<Popover.Title className="font-semibold">
117-
Apps with access to this private space
118-
</Popover.Title>
119-
<Popover.Description>
120-
<ul className="list-disc">
121-
{space.apps.map((app) => (
122-
<li key={app.id}>{app.name || app.id}</li>
123-
))}
124-
</ul>
125-
</Popover.Description>
126-
</>
127-
)}
128-
</Popover.Popup>
129-
</Popover.Positioner>
130-
</Popover.Portal>
131-
</Popover.Root>
132-
</li>
82+
<SpaceTile
83+
key={space.id}
84+
visibility={isPublicSpace ? 'public' : 'private'}
85+
space={space}
86+
selected={isSelected}
87+
onSelectedChange={
88+
onSelectedChange ? (newSelected) => onSelectedChange(space.id, newSelected) : undefined
89+
}
90+
disabled={isDisabled}
91+
/>
13392
);
13493
})}
13594
</ul>
@@ -140,6 +99,119 @@ export function SpacesCard({
14099
);
141100
}
142101

102+
interface SpaceTileProps extends Omit<React.HTMLAttributes<HTMLLIElement>, 'children'> {
103+
visibility: 'public' | 'private';
104+
space: PublicSpaceData | PrivateSpaceData;
105+
selected?: boolean | undefined;
106+
onSelectedChange?: ((selected: boolean) => void) | undefined;
107+
disabled?: boolean | undefined;
108+
}
109+
110+
function SpaceTile({
111+
visibility,
112+
space,
113+
selected = false,
114+
onSelectedChange,
115+
disabled = false,
116+
className,
117+
...props
118+
}: SpaceTileProps) {
119+
const mode = onSelectedChange !== undefined ? 'selection' : 'view';
120+
const Root = mode === 'selection' ? Fragment : Popover.Root;
121+
const Trigger = mode === 'selection' ? Checkbox.Root : Popover.Trigger;
122+
const [popoverOpen, setPopoverOpen] = useState(false);
123+
124+
return (
125+
<li
126+
data-mode={mode}
127+
data-visibility={visibility}
128+
data-selected={selected || undefined}
129+
data-disabled={(mode === 'selection' && disabled) || undefined}
130+
className={cn('group/space', className)}
131+
{...props}
132+
>
133+
<Root
134+
{...(mode === 'view'
135+
? {
136+
open: popoverOpen,
137+
onOpenChange: setPopoverOpen,
138+
}
139+
: {})}
140+
>
141+
<Trigger
142+
{...(mode === 'selection'
143+
? {
144+
disabled,
145+
checked: selected,
146+
onCheckedChange: (checked) => onSelectedChange?.(checked),
147+
}
148+
: {})}
149+
className={`
150+
group-nth-[5n]/space:bg-gradient-violet
151+
group-nth-[5n+1]/space:bg-gradient-lavender
152+
group-nth-[5n+2]/space:bg-gradient-aqua
153+
group-nth-[5n+4]/space:bg-gradient-clearmint
154+
group-nth-[5n+3]/space:bg-gradient-peach
155+
relative flex aspect-video w-full cursor-pointer items-end rounded-lg px-3 py-2
156+
group-data-disabled/space:cursor-not-allowed
157+
`}
158+
>
159+
<span className="truncate text-sm leading-tight font-semibold whitespace-normal">
160+
{space.name || space.id}
161+
</span>
162+
{mode === 'selection' ? (
163+
<span
164+
className={`
165+
group-data-selected/space:bg-primary
166+
text-primary-foreground
167+
absolute top-1 right-1 flex size-5 items-center justify-center rounded-md bg-white/50 opacity-0 transition group-hover/space:opacity-100
168+
group-data-selected/space:opacity-100
169+
group-data-selected/space:group-data-disabled/space:bg-gray-800/50
170+
`}
171+
>
172+
<span className="sr-only group-not-data-selected/space:hidden">Selected</span>
173+
<CheckIcon className="size-3 opacity-0 transition group-data-selected/space:opacity-100" />
174+
</span>
175+
) : null}
176+
{visibility === 'private' ? (
177+
<span className="bg-background/50 text-foreground absolute top-1 left-1 flex h-4 items-center gap-1 rounded-md px-1 text-xs leading-none font-semibold">
178+
<EyeSlashIcon className="size-3" />
179+
Private
180+
</span>
181+
) : null}
182+
</Trigger>
183+
{mode === 'view' ? (
184+
<Popover.Portal>
185+
<Popover.Positioner side="bottom" sideOffset={12}>
186+
<Popover.Popup className="c-popover">
187+
<Popover.Arrow className="c-popover-arrow">
188+
<ArrowSvg />
189+
</Popover.Arrow>
190+
{visibility === 'public' ? (
191+
<Popover.Title className="font-semibold">Public space</Popover.Title>
192+
) : (space as PrivateSpaceData).apps.length === 0 ? (
193+
<Popover.Title className="font-semibold">No app has access to this private space</Popover.Title>
194+
) : (
195+
<>
196+
<Popover.Title className="font-semibold">Apps with access to this private space</Popover.Title>
197+
<Popover.Description>
198+
<ul className="list-disc">
199+
{(space as PrivateSpaceData).apps.map((app) => (
200+
<li key={app.id}>{app.name || app.id}</li>
201+
))}
202+
</ul>
203+
</Popover.Description>
204+
</>
205+
)}
206+
</Popover.Popup>
207+
</Popover.Positioner>
208+
</Popover.Portal>
209+
) : null}
210+
</Root>
211+
</li>
212+
);
213+
}
214+
143215
function ArrowSvg(props: React.ComponentProps<'svg'>) {
144216
return (
145217
<svg width="20" height="10" viewBox="0 0 20 10" role="presentation" {...props}>
Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
'use client';
22

3-
import { Loader2 } from 'lucide-react';
43
import { cn } from '@/lib/utils';
54

65
interface LoadingProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -12,13 +11,30 @@ interface LoadingProps extends React.HTMLAttributes<HTMLDivElement> {
1211

1312
export function Loading({ hideLabel = false, className, children = 'Loading...', ...props }: LoadingProps) {
1413
return (
15-
<div
14+
<span
1615
data-hide-label={hideLabel || undefined}
1716
className={cn('group/loading flex items-center gap-[0.5em] font-semibold', className)}
1817
{...props}
1918
>
20-
<Loader2 className="size-[1em] shrink-0 animate-spin" />
19+
<LoadingIcon className="size-[1em] shrink-0 animate-spin" />
2120
{children ? <div className="group-data-[hide-label]/loading:sr-only">{children}</div> : null}
22-
</div>
21+
</span>
22+
);
23+
}
24+
25+
function LoadingIcon(props: React.ComponentProps<'svg'>) {
26+
return (
27+
<svg
28+
viewBox="0 0 24 24"
29+
fill="none"
30+
stroke="currentColor"
31+
stroke-width="2"
32+
stroke-linecap="round"
33+
stroke-linejoin="round"
34+
role="presentation"
35+
{...props}
36+
>
37+
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
38+
</svg>
2339
);
2440
}

apps/connect/src/css/_components.css

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@
2222
}
2323

2424
@utility c-input {
25-
@apply text-foreground font-regular placeholder:text-foreground/25 bg-background min-h-12 rounded-lg indent-3 text-lg inset-shadow-xs/15 -outline-offset-2 transition disabled:opacity-25;
25+
@apply text-foreground font-regular placeholder:text-foreground/25 bg-background relative min-h-12 appearance-none rounded-lg px-3 text-lg inset-shadow-xs/15 -outline-offset-2 transition disabled:opacity-25;
26+
27+
&:is(select) {
28+
--light-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="%232a2b2e"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /></svg>');
29+
--dark-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="white"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /></svg>');
30+
@apply bg-(image:--light-image) bg-size-[theme(spacing.4)] bg-position-[right_theme(spacing.2)_center] bg-no-repeat pr-7 dark:bg-(image:--dark-image);
31+
}
2632
}
2733

2834
@utility c-card {

apps/connect/src/hooks/use-public-spaces.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { gql, request } from 'graphql-request';
44

55
const publicSpacesQueryDocument = gql`
66
query Spaces($accountAddress: String!) {
7-
spaces(filter: {members: {some: {address: {is: $accountAddress}}}}) {
7+
spaces(filter: { members: { some: { address: { is: $accountAddress } } } }) {
88
id
99
type
1010
mainVotingAddress

0 commit comments

Comments
 (0)