Skip to content

Commit ccf898d

Browse files
committed
improvement(org): add the host that mounts the design system on organization routes
Follows the app's theme in CSS, supplies the session preferences ported components read, and sends their notices through the existing toast stack.
1 parent 542613b commit ccf898d

4 files changed

Lines changed: 314 additions & 0 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use client'
2+
3+
import '@/platform/design-system/styles/embedded.css'
4+
import type { ReactNode } from 'react'
5+
import { InterfacePreferences } from '@/platform/app/(shell)/_components/session/interface-preferences'
6+
import { PlatformSurface } from '@/platform/design-system/components/surface/platform-surface'
7+
8+
interface RedesignChildrenProps {
9+
children: ReactNode
10+
}
11+
12+
/**
13+
* Mounts the design system. `system` follows the app's theme through `<html>`'s class, in CSS, so it
14+
* tracks every change and is right before hydration (`lib/postcss/platform-embed.cjs`).
15+
*
16+
* Never render a legacy component inside: the surface redefines tokens legacy utilities read. Compose
17+
* one beside a surface within {@link RedesignSession}.
18+
*/
19+
export function RedesignSurface({ children }: RedesignChildrenProps) {
20+
return <PlatformSurface theme='system'>{children}</PlatformSurface>
21+
}
22+
23+
/** The session state ported components read. Shared by every surface on a page. */
24+
export function RedesignSession({ children }: RedesignChildrenProps) {
25+
return <InterfacePreferences>{children}</InterfacePreferences>
26+
}
27+
28+
/** A page that is one design-system surface. */
29+
export function RedesignHost({ children }: RedesignChildrenProps) {
30+
return (
31+
<RedesignSession>
32+
<RedesignSurface>{children}</RedesignSurface>
33+
</RedesignSession>
34+
)
35+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import type { IconName } from '@/platform/design-system/catalog/icon-geometry'
2+
import type { Appearance } from '@/platform/design-system/navigation'
3+
4+
export type HomeMode = 'Build' | 'Search'
5+
export type HomeWidget = 'Activity' | 'Email' | 'Calendar'
6+
7+
export interface ShellItem {
8+
id: string
9+
title: string
10+
}
11+
12+
export interface InboxItem extends ShellItem {
13+
description: string
14+
icon: IconName
15+
unread: boolean
16+
href: string
17+
}
18+
19+
export interface ShellNotice extends ShellItem {
20+
description?: string
21+
icon: IconName
22+
action?: string
23+
href?: string
24+
}
25+
26+
/** The optional fields are the ones only real data carries; fixtures leave them out. */
27+
export interface ShellWorkspace {
28+
id: string
29+
name: string
30+
/** Absolute: a workspace opens on the legacy surface. */
31+
href?: string
32+
permissions?: 'admin' | 'write' | 'read' | null
33+
isPinned?: boolean
34+
}
35+
36+
export interface ShellChat extends ShellItem {
37+
mode?: string
38+
/** Absolute when the chat opens on the legacy surface. */
39+
href?: string
40+
isActive?: boolean
41+
isUnread?: boolean
42+
isPinned?: boolean
43+
updatedAt?: string
44+
}
45+
46+
/** `value` is absolute when the destination stays on the legacy surface. */
47+
export interface ShellDestination {
48+
id: string
49+
label: string
50+
icon: IconName
51+
value: string
52+
}
53+
54+
export interface SettingsNavItem {
55+
key: string
56+
title: string
57+
icon: IconName
58+
/** Absolute when the section stays on the legacy surface. */
59+
href?: string
60+
}
61+
62+
export interface SettingsNavGroup {
63+
label: string
64+
items: readonly SettingsNavItem[]
65+
}
66+
67+
export type ShellQueryStatus = 'pending' | 'error' | 'success'
68+
69+
export interface ShellData {
70+
/** The viewer's saved theme. Live sessions set it; fixtures read `?theme=` instead. */
71+
appearance?: Appearance
72+
organization: {
73+
name: string
74+
memberCount: number
75+
id?: string
76+
logo?: string | null
77+
}
78+
user: { name: string; id?: string; email?: string; image?: string | null }
79+
workspaces: ShellWorkspace[]
80+
chats: ShellChat[]
81+
inbox: InboxItem[]
82+
notifications: ShellNotice[]
83+
/** Shell features with no backend in this data source. Hidden, because empty would misinform. */
84+
unbacked?: readonly ('inbox' | 'sortWorkspaces')[]
85+
/** So a list can tell loading and failure from empty. Absent means the data is simply there. */
86+
status?: { workspaces: ShellQueryStatus; chats: ShellQueryStatus; profile: ShellQueryStatus }
87+
workspaceCreation?: { canCreate: boolean; organizationId: string | null; reason: string | null }
88+
/** Rows the Workspaces section shows before See more. Absent means every row. */
89+
workspacePageSize?: number
90+
/** False when the viewer's chat list would be refused, so the section is not offered. */
91+
chatsAvailable?: boolean
92+
/** The viewer's gated navigation. Absent means the static destination lists. */
93+
destinations?: readonly ShellDestination[]
94+
settingsNavigation?: readonly SettingsNavGroup[]
95+
}
96+
97+
/** A partial bulk failure still reports which chats went, so the view can narrow what is left. */
98+
interface ChatRemoval {
99+
removedIds: readonly string[]
100+
failure?: string
101+
}
102+
103+
/** The optional members are the ones only a real backend can honour. */
104+
export interface ShellChatActions {
105+
removeLabel: 'Archive' | 'Delete'
106+
/** What happens to removed chats, when there is more to say than the label. */
107+
removeDetail?: (count: number) => string
108+
rename: (id: string, title: string) => Promise<void>
109+
remove: (ids: readonly string[]) => Promise<ChatRemoval>
110+
setPinned?: (id: string, pinned: boolean) => void
111+
setRead?: (id: string, read: boolean) => void
112+
/** Reads a chat ahead of an intended open. Returns how to call it off. */
113+
prefetch?: (chat: ShellChat) => () => void
114+
retry?: () => void
115+
}
116+
117+
export interface ShellWorkspaceActions {
118+
/** Resolves with where the new workspace opens. */
119+
create: (name: string) => Promise<string>
120+
rename: (id: string, name: string) => Promise<void>
121+
setPinned: (id: string, pinned: boolean) => void
122+
retry: () => void
123+
}
124+
125+
export interface ShellOrganizationActions {
126+
canEditIdentity: boolean
127+
canInvite: boolean
128+
logoAccept: string
129+
rename: (name: string) => Promise<void>
130+
uploadLogo: (file: File) => Promise<void>
131+
}
132+
133+
interface ShellSupportRequest {
134+
type: string
135+
subject: string
136+
message: string
137+
images: readonly File[]
138+
}
139+
140+
export type ShellSupport =
141+
| { email: string; submit?: never }
142+
| {
143+
email?: never
144+
imageAccept: string
145+
requestTypes: readonly { value: string; label: string }[]
146+
/** Validates and compresses one image; rejects with why it cannot be attached. */
147+
prepareImage: (file: File) => Promise<File>
148+
submit: (request: ShellSupportRequest) => Promise<void>
149+
}
150+
151+
export interface ShellHelp {
152+
documentationUrl: string
153+
support: ShellSupport
154+
slackUrl?: string
155+
changelogUrl?: string
156+
downloadUrl?: string
157+
desktopUpdate?: { label: string; disabled: boolean; run: () => void }
158+
}
159+
160+
/**
161+
* What the shell's views can do, filled by session state on fixtures and by mutations on live data.
162+
* A view offers a control only when its action exists here.
163+
*/
164+
export interface ShellActions {
165+
chats: ShellChatActions
166+
workspaces?: ShellWorkspaceActions
167+
organization?: ShellOrganizationActions
168+
/** Absent for the anonymous viewer of an auth-disabled deployment, who cannot sign out. */
169+
signOut?: () => void
170+
help: ShellHelp
171+
/** Runs `leave` now, or once the viewer agrees to discard unsaved settings. */
172+
requestLeave: (leave: () => void) => void
173+
/** The accessible destination for a viewer who opened Settings directly. */
174+
settingsLanding: string
175+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { toast } from '@sim/emcn'
2+
import type { ShellNotice } from '@/platform/app/(shell)/_components/model'
3+
4+
type Notice = Omit<ShellNotice, 'href'> & { onAction?: () => void }
5+
6+
const ERROR_ICON = 'I047'
7+
8+
/** The notices a toast is showing, so re-raising or dismissing a notice id reaches its toast. */
9+
const toastIdByNotice = new Map<string, string>()
10+
11+
function dismiss(id: string) {
12+
const toastId = toastIdByNotice.get(id)
13+
if (toastId) toast.dismiss(toastId)
14+
}
15+
16+
function notify({ id, title, description, icon, action, onAction }: Notice) {
17+
dismiss(id)
18+
const toastId = toast({
19+
message: title,
20+
description,
21+
variant: icon === ERROR_ICON ? 'error' : 'default',
22+
action: action && onAction ? { label: action, onClick: onAction } : undefined,
23+
onDismiss: () => {
24+
if (toastIdByNotice.get(id) === toastId) toastIdByNotice.delete(id)
25+
},
26+
})
27+
toastIdByNotice.set(id, toastId)
28+
}
29+
30+
const NOTICES = { notify, dismiss }
31+
32+
/**
33+
* Delivers design-system notices through the app's existing toast stack, in place of the Platform
34+
* shell's tray. Same module path and hook as the tray, so ported components stay unchanged.
35+
*/
36+
export function usePlatformNotices() {
37+
return NOTICES
38+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
'use client'
2+
3+
import { createContext, type ReactNode, useContext, useEffect, useState } from 'react'
4+
import type { HomeMode, HomeWidget } from '@/platform/app/(shell)/_components/model'
5+
6+
function usePreferenceState() {
7+
const [cookieChoices, setCookieChoices] = useState<Record<string, boolean>>({})
8+
const [profileName, setProfileName] = useState<string>()
9+
const [picture, setPicture] = useState<string>()
10+
useEffect(
11+
() => () => {
12+
if (picture) URL.revokeObjectURL(picture)
13+
},
14+
[picture]
15+
)
16+
function updatePicture(file?: File) {
17+
if (file?.type.startsWith('image/')) setPicture(URL.createObjectURL(file))
18+
}
19+
const [homeMode, setHomeMode] = useState<HomeMode>('Build')
20+
const [hiddenWidgets, setHiddenWidgets] = useState<HomeWidget[]>([])
21+
const [removedRecents, setRemovedRecents] = useState<string[]>([])
22+
const [connections, setConnections] = useState<Partial<Record<'Email' | 'Calendar', boolean>>>({})
23+
24+
return {
25+
cookieChoices,
26+
setCookieChoices,
27+
profileName,
28+
setProfileName,
29+
picture,
30+
updatePicture,
31+
homeMode,
32+
setHomeMode,
33+
hiddenWidgets,
34+
setHiddenWidgets,
35+
removedRecents,
36+
setRemovedRecents,
37+
connections,
38+
setConnections,
39+
}
40+
}
41+
42+
const Preferences = createContext<ReturnType<typeof usePreferenceState> | null>(null)
43+
44+
interface InterfacePreferencesProps {
45+
children: ReactNode
46+
}
47+
48+
export function InterfacePreferences({ children }: InterfacePreferencesProps) {
49+
const preferences = usePreferenceState()
50+
return <Preferences.Provider value={preferences}>{children}</Preferences.Provider>
51+
}
52+
53+
export function useInterfacePreferences() {
54+
return useContext(Preferences)
55+
}
56+
57+
/** A dismissal applies to both Home and the chat panel for this interface session. */
58+
export function useRecentHistory<T extends { id: string }>(history: readonly T[]) {
59+
const session = useInterfacePreferences()
60+
if (!session) throw new Error('Recents need an interface session')
61+
const { removedRecents, setRemovedRecents } = session
62+
return {
63+
recents: history.filter((item) => !removedRecents.includes(item.id)),
64+
removeRecent: (id: string) => setRemovedRecents((items) => [...items, id]),
65+
}
66+
}

0 commit comments

Comments
 (0)