@sableui/react 0.1.1 → 0.2.0
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/CHANGELOG.md +61 -0
- package/dist/{_contract-CYScOdf5.d.ts → _contract-o3tUc8Ig.d.ts} +1 -1
- package/dist/{chunk-J23ELQQZ.js → chunk-AOWBYEQ6.js} +4177 -1917
- package/dist/{chunk-RWP4XBSK.js → chunk-KFZ43RKU.js} +1 -1
- package/dist/{chunk-JI7WJP2L.js → chunk-X276MF2W.js} +5 -0
- package/dist/foundations/index.js +1 -1
- package/dist/index.d.ts +155 -101
- package/dist/index.js +582 -2778
- package/dist/ios/index.d.ts +6 -1
- package/dist/ios/index.js +4 -4
- package/dist/ios/preview.js +1 -1
- package/dist/ios/styles.css +20 -6
- package/dist/patterns/index.js +1683 -469
- package/dist/presets/index.d.ts +1 -1
- package/dist/presets/index.js +1 -1
- package/dist/styles.css +238 -40
- package/dist/{theme-provider-BWcizhAt.d.ts → theme-provider-BE-KqUG-.d.ts} +1 -1
- package/dist/themes/atelier/atelier.css +3 -0
- package/dist/themes/atelier/index.d.ts +1 -1
- package/dist/themes/base/base.css +11 -4
- package/dist/themes/base/index.d.ts +1 -1
- package/dist/themes/ember/ember.css +3 -0
- package/dist/themes/ember/index.d.ts +1 -1
- package/dist/themes/index.d.ts +2 -2
- package/dist/themes/meridian/index.d.ts +1 -1
- package/dist/themes/meridian/meridian.css +10 -5
- package/dist/themes/poppy/index.d.ts +1 -1
- package/dist/themes/poppy/poppy.css +16 -0
- package/dist/themes/prism/index.d.ts +1 -1
- package/dist/themes/prism/prism.css +4 -1
- package/dist/themes/verdant/index.d.ts +1 -1
- package/dist/themes/verdant/verdant.css +7 -2
- package/llms/components/bubble.md +5 -5
- package/llms/components/checkbox-card.md +1 -1
- package/llms/components/context-card.md +2 -2
- package/llms/components/dialog.md +1 -1
- package/llms/components/empty-state.md +4 -4
- package/llms/components/entity.md +1 -1
- package/llms/components/fieldset.md +1 -1
- package/llms/components/kbd.md +18 -6
- package/llms/components/pill.md +1 -1
- package/llms/components/side-nav.md +1 -1
- package/llms/components/skeleton.md +1 -1
- package/llms/components/theme-selector.md +1 -1
- package/llms/foundations/shape.md +1 -1
- package/llms/patterns/auth-page.md +132 -42
- package/llms/patterns/brand-landing-page.md +271 -175
- package/llms/patterns/chat-panel.md +120 -39
- package/llms/patterns/dashboard-page.md +277 -65
- package/llms/patterns/feed-page.md +245 -64
- package/llms/patterns/landing-page.md +473 -75
- package/llms/patterns/list-detail-page.md +385 -68
- package/llms/patterns/settings-page.md +279 -67
- package/llms/reference/token-intent.md +4 -2
- package/llms/reference/utilities.md +20 -5
- package/llms-full.txt +2244 -727
- package/llms.txt +14 -15
- package/package.json +1 -1
- package/registry.json +261 -238
- package/targets/ios.json +4 -15
- package/llms/components/description.md +0 -96
|
@@ -1,97 +1,414 @@
|
|
|
1
1
|
# ListDetailPage
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Searchable list beside the selected record's detail: interactive Entity rows in a CardList (5 of 12 columns) and a sticky detail pane (7) with an editable field, a label/value list, record actions with a destructive confirmation, and recent activity. Below lg it becomes a drill-in.
|
|
4
4
|
|
|
5
|
-
**Intent:** Reach for this when a route lists records AND
|
|
5
|
+
**Intent:** Reach for this when a route lists records AND works on one at a time (members + member, inbox + message, orders + order). For pure browsing with no selection, a single-column FeedPage or a DashboardPage table is simpler.
|
|
6
6
|
|
|
7
7
|
## Notes
|
|
8
8
|
|
|
9
|
-
- Region cap is `wide`;
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
- The
|
|
9
|
+
- Region cap is `wide`; Columns 12 → Col 5 (list) + Col 7 (detail). Below lg the list and detail share one column as a drill-in: selecting a row shows the detail with an `All members` back button (lg:hidden), and the back button returns to the list. The panes swap with `max-lg:hidden`, so desktop always shows both.
|
|
10
|
+
- Rows are interactive Entity inside one Card + CardList. Selection is `data-selected` on the row painted by `sable-surface-interactive`; `sable-surface` on the list card makes that step 12% ink off the card, so a selected row stays distinct from its muted avatar.
|
|
11
|
+
- Search filters the list in place (name, email, role). No matches swap the CardList for an EmptyState with a Clear search action; the selected detail stays open.
|
|
12
|
+
- A pending invite lives in the same list: a mail glyph in the Avatar, an `Invited` Badge, and an invite-specific detail (role on joining, sent, expires, invited by; Resend and Revoke actions).
|
|
13
|
+
- The role Select edits the record in place; its Field description states what the role can do. The owner's role is read-only and the owner has no remove action.
|
|
14
|
+
- Read-only facts are a native `<dl className="sable-list-dl sable-list-dl-2">` inside an `@container`, so pairs go two-up only when the pane is wide enough.
|
|
15
|
+
- Remove / revoke run through DestructiveActionModal; on confirm the next row is selected. Page-level counts (members, pending, seats) live in the PageHeader description and update with the list.
|
|
16
|
+
- The detail pane is sticky (lg:top-6) so it stays in view while a long list scrolls.
|
|
14
17
|
|
|
15
18
|
## Accessibility
|
|
16
19
|
|
|
17
|
-
-
|
|
18
|
-
- The
|
|
20
|
+
- Each row is a real button (Entity `interactive` + `onSelect`): Tab moves through rows, Enter/Space selects. The selected row carries visually hidden `(selected)` text in its name; list avatars are aria-hidden so initials are not read before the name.
|
|
21
|
+
- The detail pane is a labelled region (`Member details`) whose title is a level-2 heading.
|
|
22
|
+
- On a phone, drilling in moves focus to the back button; going back returns focus to the row that was open. After a remove, focus lands on the newly selected row; after Clear search, on the search field.
|
|
19
23
|
|
|
20
24
|
## Source
|
|
21
25
|
|
|
22
26
|
```tsx
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
{
|
|
30
|
-
{
|
|
31
|
-
{
|
|
27
|
+
import { useState, useEffect } from "react"
|
|
28
|
+
import { Avatar, Badge, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, Col, Columns, DestructiveActionModal, EmptyState, Entity, Field, Icon, Page, PageHeader, Row, SearchInput, Select, SelectContent, SelectItem, SelectTrigger, Stack, StatusDot } from "@sableui/react"
|
|
29
|
+
|
|
30
|
+
type Role = "owner" | "admin" | "editor" | "viewer"
|
|
31
|
+
|
|
32
|
+
const roles: { value: Role; label: string }[] = [
|
|
33
|
+
{ value: "owner", label: "Owner" },
|
|
34
|
+
{ value: "admin", label: "Admin" },
|
|
35
|
+
{ value: "editor", label: "Editor" },
|
|
36
|
+
{ value: "viewer", label: "Viewer" },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
const roleLabel: Record<Role, string> = {
|
|
40
|
+
owner: "Owner",
|
|
41
|
+
admin: "Admin",
|
|
42
|
+
editor: "Editor",
|
|
43
|
+
viewer: "Viewer",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const roleSummary: Record<Role, string> = {
|
|
47
|
+
owner: "Full access, including payouts and billing. Transfer ownership in studio settings.",
|
|
48
|
+
admin: "Manages members, editions and payouts.",
|
|
49
|
+
editor: "Drafts, prices and releases editions.",
|
|
50
|
+
viewer: "Sees editions and sales, but can't change them.",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type Activity = { id: string; icon: string; text: string; when: string }
|
|
54
|
+
|
|
55
|
+
type Member = {
|
|
56
|
+
id: string
|
|
57
|
+
name: string
|
|
58
|
+
email: string
|
|
59
|
+
initials: string
|
|
60
|
+
role: Role
|
|
61
|
+
pending: boolean
|
|
62
|
+
handle?: string
|
|
63
|
+
joined?: string
|
|
64
|
+
lastActive?: string
|
|
65
|
+
editions?: number
|
|
66
|
+
invitedBy?: string
|
|
67
|
+
sent?: string
|
|
68
|
+
expires?: string
|
|
69
|
+
resent?: boolean
|
|
70
|
+
activity: Activity[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const seats = 10
|
|
74
|
+
|
|
75
|
+
const initialMembers: Member[] = [
|
|
76
|
+
{
|
|
77
|
+
id: "u_01", name: "Ada Lovelace", handle: "@ada", email: "ada@atelier.art", initials: "AL", role: "owner", pending: false,
|
|
78
|
+
joined: "Mar 4, 2024", lastActive: "Online now", editions: 12,
|
|
79
|
+
activity: [
|
|
80
|
+
{ id: "a1", icon: "gem", text: "Released Analytical Engine No. 3 — edition of 50", when: "2 hours ago" },
|
|
81
|
+
{ id: "a2", icon: "mail", text: "Invited louise@bourgeois.studio as Editor", when: "2 days ago" },
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "u_02", name: "Grace Hopper", handle: "@grace", email: "grace@atelier.art", initials: "GH", role: "admin", pending: false,
|
|
86
|
+
joined: "Mar 9, 2024", lastActive: "20 minutes ago", editions: 4,
|
|
87
|
+
activity: [
|
|
88
|
+
{ id: "a1", icon: "user", text: "Changed Frida Kahlo's role to Viewer", when: "Yesterday" },
|
|
89
|
+
{ id: "a2", icon: "edit", text: "Updated pricing on First Compiler — edition of 10", when: "3 days ago" },
|
|
90
|
+
{ id: "a3", icon: "gem", text: "Released First Compiler — edition of 10", when: "Sep 28" },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "u_03", name: "Mary Shelley", handle: "@mary", email: "mary@atelier.art", initials: "MS", role: "editor", pending: false,
|
|
95
|
+
joined: "Jun 18, 2024", lastActive: "3 hours ago", editions: 7,
|
|
96
|
+
activity: [
|
|
97
|
+
{ id: "a1", icon: "calendar", text: "Scheduled Frankenstein Studies for Oct 14", when: "3 hours ago" },
|
|
98
|
+
{ id: "a2", icon: "gem", text: "Released The Last Man — edition of 25", when: "Sep 21" },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: "u_04", name: "Nina Simone", handle: "@nina", email: "nina@atelier.art", initials: "NS", role: "editor", pending: false,
|
|
103
|
+
joined: "Jan 12, 2025", lastActive: "Yesterday", editions: 3,
|
|
104
|
+
activity: [
|
|
105
|
+
{ id: "a1", icon: "upload", text: "Uploaded artwork for Little Girl Blue", when: "Yesterday" },
|
|
106
|
+
{ id: "a2", icon: "gem", text: "Released Feeling Good — edition of 100", when: "Aug 30" },
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "u_05", name: "Frida Kahlo", handle: "@frida", email: "frida@atelier.art", initials: "FK", role: "viewer", pending: false,
|
|
111
|
+
joined: "Jul 2, 2025", lastActive: "4 days ago", editions: 0,
|
|
112
|
+
activity: [{ id: "a1", icon: "comment", text: "Commented on the Las Dos Fridas draft", when: "4 days ago" }],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "i_01", name: "louise@bourgeois.studio", email: "louise@bourgeois.studio", initials: "", role: "editor", pending: true,
|
|
116
|
+
invitedBy: "Ada Lovelace", sent: "2 days ago", expires: "In 5 days",
|
|
117
|
+
activity: [],
|
|
118
|
+
},
|
|
32
119
|
]
|
|
33
120
|
|
|
34
121
|
export function ListDetailPage() {
|
|
122
|
+
const [members, setMembers] = useState(initialMembers)
|
|
123
|
+
const [selectedId, setSelectedId] = useState<string | null>("u_02")
|
|
124
|
+
const [query, setQuery] = useState("")
|
|
125
|
+
// Below lg the list and the detail share one column: selecting drills in.
|
|
126
|
+
const [showDetail, setShowDetail] = useState(false)
|
|
127
|
+
const [confirming, setConfirming] = useState(false)
|
|
128
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
129
|
+
const backRef = useRef<HTMLButtonElement>(null)
|
|
130
|
+
const searchRef = useRef<HTMLInputElement>(null)
|
|
131
|
+
|
|
132
|
+
const needle = query.trim().toLowerCase()
|
|
133
|
+
const visible = members.filter((m) =>
|
|
134
|
+
[m.name, m.email, m.handle ?? "", roleLabel[m.role]].some((v) => v.toLowerCase().includes(needle)),
|
|
135
|
+
)
|
|
136
|
+
const selected = members.find((m) => m.id === selectedId) ?? null
|
|
137
|
+
const active = members.filter((m) => !m.pending).length
|
|
138
|
+
const pending = members.length - active
|
|
139
|
+
|
|
140
|
+
// Drilling in on a phone moves focus to the detail's back control; the
|
|
141
|
+
// control is display:none at lg, so desktop focus stays on the list.
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (showDetail && backRef.current?.offsetParent) backRef.current.focus()
|
|
144
|
+
}, [showDetail, selectedId])
|
|
145
|
+
|
|
146
|
+
function select(id: string) {
|
|
147
|
+
setSelectedId(id)
|
|
148
|
+
setShowDetail(true)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function focusRow(id: string | undefined) {
|
|
152
|
+
requestAnimationFrame(() => {
|
|
153
|
+
listRef.current?.querySelector<HTMLButtonElement>(`[data-member-id="${id}"] button`)?.focus()
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function backToList() {
|
|
158
|
+
setShowDetail(false)
|
|
159
|
+
focusRow(selectedId ?? undefined)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function setRole(id: string, role: Role) {
|
|
163
|
+
setMembers((list) => list.map((m) => (m.id === id ? { ...m, role } : m)))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function resend(id: string) {
|
|
167
|
+
setMembers((list) =>
|
|
168
|
+
list.map((m) => (m.id === id ? { ...m, sent: "Just now", expires: "In 7 days", resent: true } : m)),
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function remove(id: string) {
|
|
173
|
+
const index = members.findIndex((m) => m.id === id)
|
|
174
|
+
const rest = members.filter((m) => m.id !== id)
|
|
175
|
+
setMembers(rest)
|
|
176
|
+
const next = rest[Math.min(index, rest.length - 1)]?.id
|
|
177
|
+
setSelectedId(next ?? null)
|
|
178
|
+
setShowDetail(false)
|
|
179
|
+
// The control that opened the confirmation is gone; land on the next row.
|
|
180
|
+
focusRow(next)
|
|
181
|
+
}
|
|
182
|
+
|
|
35
183
|
return (
|
|
36
184
|
<Page as="main" max="wide" gap={4} className="py-6">
|
|
37
185
|
<PageHeader
|
|
38
186
|
title="Members"
|
|
39
|
-
description="
|
|
40
|
-
actions={<Button>Invite member</Button>}
|
|
187
|
+
description={`${active} members · ${pending} pending ${pending === 1 ? "invite" : "invites"} · ${members.length} of ${seats} seats used`}
|
|
188
|
+
actions={<Button iconStart={<Icon name="plus" />}>Invite member</Button>}
|
|
41
189
|
/>
|
|
42
190
|
<Columns count={{ base: 1, lg: 12 }}>
|
|
43
|
-
<Col span={{ base: 1, lg:
|
|
191
|
+
<Col span={{ base: 1, lg: 5 }} className={showDetail ? "max-lg:hidden" : undefined}>
|
|
44
192
|
<Stack gap={3}>
|
|
45
|
-
<SearchInput
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
193
|
+
<SearchInput
|
|
194
|
+
ref={searchRef}
|
|
195
|
+
value={query}
|
|
196
|
+
onValueChange={setQuery}
|
|
197
|
+
placeholder="Search name, email or role"
|
|
198
|
+
aria-label="Search members"
|
|
199
|
+
/>
|
|
200
|
+
{/* sable-surface derives the rows' selected step from the card (12% ink),
|
|
201
|
+
so a selected row stays distinct from its muted avatar. */}
|
|
202
|
+
<Card ref={listRef} className="sable-surface">
|
|
203
|
+
{visible.length > 0 ? (
|
|
204
|
+
<CardList aria-label="Members">
|
|
205
|
+
{visible.map((m) => {
|
|
206
|
+
const isSelected = m.id === selectedId
|
|
207
|
+
return (
|
|
208
|
+
<Entity
|
|
209
|
+
key={m.id}
|
|
210
|
+
data-member-id={m.id}
|
|
211
|
+
data-selected={isSelected ? "" : undefined}
|
|
212
|
+
// No selected variant on Entity yet: the relative-surface recipe paints
|
|
213
|
+
// data-selected one step off the card.
|
|
214
|
+
className="sable-surface-interactive"
|
|
215
|
+
interactive
|
|
216
|
+
onSelect={() => select(m.id)}
|
|
217
|
+
leading={
|
|
218
|
+
<Avatar
|
|
219
|
+
fallback={m.pending ? <Icon name="mail" className="size-5" /> : m.initials}
|
|
220
|
+
status={m.lastActive === "Online now" ? <StatusDot tone="success" /> : undefined}
|
|
221
|
+
// The row's title already names it: keep the initials out of the row's name.
|
|
222
|
+
aria-hidden
|
|
223
|
+
/>
|
|
224
|
+
}
|
|
225
|
+
title={
|
|
226
|
+
<>
|
|
227
|
+
{m.name}
|
|
228
|
+
{isSelected ? <span className="sr-only"> (selected)</span> : null}
|
|
229
|
+
</>
|
|
230
|
+
}
|
|
231
|
+
subtitle={
|
|
232
|
+
m.pending
|
|
233
|
+
? `${roleLabel[m.role]} · invite sent ${m.sent?.toLowerCase()}`
|
|
234
|
+
: `${roleLabel[m.role]} · ${m.handle}`
|
|
235
|
+
}
|
|
236
|
+
action={m.pending ? <Badge tone="info">Invited</Badge> : undefined}
|
|
237
|
+
/>
|
|
238
|
+
)
|
|
239
|
+
})}
|
|
240
|
+
</CardList>
|
|
241
|
+
) : (
|
|
242
|
+
<CardContent>
|
|
243
|
+
<EmptyState
|
|
244
|
+
icon={<Icon name="search" />}
|
|
245
|
+
title="No members found"
|
|
246
|
+
description={`Nobody matches “${query.trim()}”. Try a name, email or role.`}
|
|
247
|
+
action={
|
|
248
|
+
<Button variant="secondary" size="sm" onClick={() => {
|
|
249
|
+
setQuery("")
|
|
250
|
+
searchRef.current?.focus()
|
|
251
|
+
}}>
|
|
252
|
+
Clear search
|
|
253
|
+
</Button>
|
|
254
|
+
}
|
|
255
|
+
/>
|
|
256
|
+
</CardContent>
|
|
257
|
+
)}
|
|
73
258
|
</Card>
|
|
74
259
|
</Stack>
|
|
75
260
|
</Col>
|
|
76
|
-
<Col span={{ base: 1, lg:
|
|
77
|
-
<
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
261
|
+
<Col span={{ base: 1, lg: 7 }} className={showDetail ? undefined : "max-lg:hidden"}>
|
|
262
|
+
<Stack role="region" aria-label="Member details" gap={3} className="lg:sticky lg:top-6">
|
|
263
|
+
<Button
|
|
264
|
+
ref={backRef}
|
|
265
|
+
variant="ghost"
|
|
266
|
+
size="sm"
|
|
267
|
+
iconStart={<Icon name="chevron-left" />}
|
|
268
|
+
onClick={backToList}
|
|
269
|
+
className="self-start lg:hidden"
|
|
270
|
+
>
|
|
271
|
+
All members
|
|
272
|
+
</Button>
|
|
273
|
+
{selected === null ? (
|
|
274
|
+
<EmptyState
|
|
275
|
+
surface="card"
|
|
276
|
+
icon={<Icon name="user" />}
|
|
277
|
+
title="No member selected"
|
|
278
|
+
description="Choose someone from the list to see their role and activity."
|
|
279
|
+
/>
|
|
280
|
+
) : (
|
|
281
|
+
<>
|
|
282
|
+
<Card>
|
|
283
|
+
<CardHeader>
|
|
284
|
+
<Row gap={2}>
|
|
285
|
+
<Avatar
|
|
286
|
+
size="lg"
|
|
287
|
+
fallback={selected.pending ? <Icon name="mail" className="size-6" /> : selected.initials}
|
|
288
|
+
status={selected.lastActive === "Online now" ? <StatusDot tone="success" /> : undefined}
|
|
289
|
+
alt={selected.pending ? "Pending invite" : selected.name}
|
|
290
|
+
/>
|
|
291
|
+
<Stack gap={0} className="min-w-0">
|
|
292
|
+
<CardTitle role="heading" aria-level={2} className="truncate">
|
|
293
|
+
{selected.name}
|
|
294
|
+
</CardTitle>
|
|
295
|
+
<CardDescription className="truncate">
|
|
296
|
+
{selected.pending ? "Hasn't joined yet" : `${selected.handle} · ${selected.email}`}
|
|
297
|
+
</CardDescription>
|
|
298
|
+
</Stack>
|
|
299
|
+
</Row>
|
|
300
|
+
{selected.pending ? (
|
|
301
|
+
<CardAction>
|
|
302
|
+
<Badge tone="info">Invited</Badge>
|
|
303
|
+
</CardAction>
|
|
304
|
+
) : null}
|
|
305
|
+
</CardHeader>
|
|
306
|
+
<CardContent>
|
|
307
|
+
<Stack gap={5}>
|
|
308
|
+
<Field
|
|
309
|
+
label={selected.pending ? "Role on joining" : "Role"}
|
|
310
|
+
description={roleSummary[selected.role]}
|
|
311
|
+
>
|
|
312
|
+
<Select
|
|
313
|
+
items={roles}
|
|
314
|
+
value={selected.role}
|
|
315
|
+
onValueChange={(value) => setRole(selected.id, value as Role)}
|
|
316
|
+
disabled={selected.role === "owner"}
|
|
317
|
+
>
|
|
318
|
+
<SelectTrigger className="max-w-72" />
|
|
319
|
+
<SelectContent>
|
|
320
|
+
{roles
|
|
321
|
+
.filter((r) => r.value !== "owner" || selected.role === "owner")
|
|
322
|
+
.map((r) => (
|
|
323
|
+
<SelectItem key={r.value} value={r.value}>
|
|
324
|
+
{r.label}
|
|
325
|
+
</SelectItem>
|
|
326
|
+
))}
|
|
327
|
+
</SelectContent>
|
|
328
|
+
</Select>
|
|
329
|
+
</Field>
|
|
330
|
+
<div className="@container">
|
|
331
|
+
{selected.pending ? (
|
|
332
|
+
<dl className="sable-list-dl sable-list-dl-2">
|
|
333
|
+
<dt>Sent</dt>
|
|
334
|
+
<dd>{selected.sent}</dd>
|
|
335
|
+
<dt>Expires</dt>
|
|
336
|
+
<dd>{selected.expires}</dd>
|
|
337
|
+
<dt>Invited by</dt>
|
|
338
|
+
<dd>{selected.invitedBy}</dd>
|
|
339
|
+
</dl>
|
|
340
|
+
) : (
|
|
341
|
+
<dl className="sable-list-dl sable-list-dl-2">
|
|
342
|
+
<dt>Email</dt>
|
|
343
|
+
<dd>{selected.email}</dd>
|
|
344
|
+
<dt>Joined</dt>
|
|
345
|
+
<dd>{selected.joined}</dd>
|
|
346
|
+
<dt>Last active</dt>
|
|
347
|
+
<dd className={selected.lastActive === "Online now" ? "text-success" : undefined}>
|
|
348
|
+
{selected.lastActive}
|
|
349
|
+
</dd>
|
|
350
|
+
<dt>Editions released</dt>
|
|
351
|
+
<dd>{selected.editions}</dd>
|
|
352
|
+
</dl>
|
|
353
|
+
)}
|
|
354
|
+
</div>
|
|
355
|
+
</Stack>
|
|
356
|
+
</CardContent>
|
|
357
|
+
{selected.role !== "owner" ? (
|
|
358
|
+
<CardFooter>
|
|
359
|
+
<Row gap={2} wrap>
|
|
360
|
+
{selected.pending ? (
|
|
361
|
+
<Button
|
|
362
|
+
variant="secondary"
|
|
363
|
+
size="sm"
|
|
364
|
+
iconStart={<Icon name={selected.resent ? "check" : "send"} />}
|
|
365
|
+
disabled={selected.resent}
|
|
366
|
+
onClick={() => resend(selected.id)}
|
|
367
|
+
>
|
|
368
|
+
{selected.resent ? "Invite resent" : "Resend invite"}
|
|
369
|
+
</Button>
|
|
370
|
+
) : null}
|
|
371
|
+
<Button variant="outline" size="sm" onClick={() => setConfirming(true)}>
|
|
372
|
+
{selected.pending ? "Revoke invite" : "Remove from studio"}
|
|
373
|
+
</Button>
|
|
374
|
+
</Row>
|
|
375
|
+
</CardFooter>
|
|
376
|
+
) : null}
|
|
377
|
+
</Card>
|
|
378
|
+
{selected.activity.length > 0 ? (
|
|
379
|
+
<Card>
|
|
380
|
+
<CardHeader>
|
|
381
|
+
<CardTitle role="heading" aria-level={3}>
|
|
382
|
+
Recent activity
|
|
383
|
+
</CardTitle>
|
|
384
|
+
</CardHeader>
|
|
385
|
+
<CardList>
|
|
386
|
+
{selected.activity.map((a) => (
|
|
387
|
+
<Entity
|
|
388
|
+
key={a.id}
|
|
389
|
+
leading={<Icon name={a.icon} className="text-muted-foreground" />}
|
|
390
|
+
title={a.text}
|
|
391
|
+
subtitle={a.when}
|
|
392
|
+
/>
|
|
393
|
+
))}
|
|
394
|
+
</CardList>
|
|
395
|
+
</Card>
|
|
396
|
+
) : null}
|
|
397
|
+
<DestructiveActionModal
|
|
398
|
+
open={confirming}
|
|
399
|
+
onOpenChange={setConfirming}
|
|
400
|
+
title={selected.pending ? "Revoke this invite?" : `Remove ${selected.name}?`}
|
|
401
|
+
description={
|
|
402
|
+
selected.pending
|
|
403
|
+
? `${selected.email} won't be able to join with the link they were sent.`
|
|
404
|
+
: "They lose access to the studio right away. Editions they released stay on sale."
|
|
405
|
+
}
|
|
406
|
+
confirmLabel={selected.pending ? "Revoke invite" : "Remove member"}
|
|
407
|
+
onConfirm={() => remove(selected.id)}
|
|
408
|
+
/>
|
|
409
|
+
</>
|
|
410
|
+
)}
|
|
411
|
+
</Stack>
|
|
95
412
|
</Col>
|
|
96
413
|
</Columns>
|
|
97
414
|
</Page>
|