@loopbacktools/app 0.0.0-stage → 0.4.1

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/src/app.tsx ADDED
@@ -0,0 +1,335 @@
1
+ import {
2
+ Button,
3
+ ClipboardText,
4
+ Empty,
5
+ type LinkComponentProps,
6
+ LinkButton,
7
+ LinkProvider,
8
+ Loader,
9
+ Text,
10
+ } from "@cloudflare/kumo";
11
+ import { useAtomValue } from "@effect/atom-react";
12
+ import { Inbox, InboxProvider } from "@loopbacktools/inbox";
13
+ import type { LoopbackApi } from "@loopbacktools/sdk";
14
+ import { Plus, Tray } from "@phosphor-icons/react";
15
+ import { AsyncResult, Atom } from "effect/reactivity";
16
+ import {
17
+ type ComponentType,
18
+ createContext,
19
+ forwardRef,
20
+ type ReactNode,
21
+ use,
22
+ useCallback,
23
+ useEffect,
24
+ } from "react";
25
+ import {
26
+ BrowserRouter,
27
+ Link,
28
+ Navigate,
29
+ Route,
30
+ Routes,
31
+ useLocation,
32
+ useNavigate,
33
+ useParams,
34
+ useSearchParams,
35
+ } from "react-router";
36
+ import { Header } from "./header.tsx";
37
+ import { loadPage } from "./location.ts";
38
+ import { Page, Section } from "./page.tsx";
39
+ import { askInOldLink, safeNext, workspacePath } from "./paths.ts";
40
+ import type { LoopbackAppOptions, SignedIn as Signed } from "./ports.ts";
41
+
42
+ function Waiting() {
43
+ return (
44
+ <main className="flex h-full items-center justify-center">
45
+ <Loader />
46
+ </main>
47
+ );
48
+ }
49
+
50
+ /** Kumo's links, followed by the app's router rather than by loading the page again. */
51
+ const RouterLink = forwardRef<HTMLAnchorElement, LinkComponentProps>(
52
+ ({ href, to, ...rest }, ref) => <Link ref={ref} to={href ?? to ?? ""} {...rest} />,
53
+ );
54
+
55
+ const SignedInContext = createContext<Signed | null>(null);
56
+
57
+ /** Who is signed in, for a page behind `SignedIn`. */
58
+ const useSignedInHere = (): Signed => {
59
+ const signedIn = use(SignedInContext);
60
+ if (signedIn === null) throw new Error("Only a page for someone signed in knows who they are.");
61
+ return signedIn;
62
+ };
63
+
64
+ /** A workspace that isn't the person's: they may have been removed, or the link is wrong. */
65
+ function NotTheirs() {
66
+ return (
67
+ <main className="flex h-full items-center justify-center p-4">
68
+ <Empty
69
+ icon={<Tray size={32} />}
70
+ title="Not one of your workspaces"
71
+ description="You may have been removed from it, or the link is wrong."
72
+ contents={
73
+ <LinkButton href="/" variant="secondary">
74
+ Go to your inbox
75
+ </LinkButton>
76
+ }
77
+ />
78
+ </main>
79
+ );
80
+ }
81
+
82
+ /** How agents sign in to a client's deployment, as its server says. */
83
+ const agentSignInAtom = Atom.family((client: LoopbackApi) => Atom.make(client.agentSignIn));
84
+
85
+ /** How to connect an agent to a workspace, as the server says its agents sign in. */
86
+ function Connect({
87
+ workspace,
88
+ client,
89
+ }: {
90
+ readonly workspace: string;
91
+ readonly client: LoopbackApi;
92
+ }) {
93
+ const signIn = useAtomValue(agentSignInAtom(client));
94
+ if (AsyncResult.isInitial(signIn)) return <Waiting />;
95
+ const mcp = `${window.location.origin}/mcp`;
96
+ const oauth = AsyncResult.isSuccess(signIn) && signIn.value === "oauth";
97
+ return (
98
+ <Page
99
+ back={{ to: workspacePath(workspace), label: "Back to the inbox" }}
100
+ title="Connect an agent"
101
+ description={
102
+ oauth
103
+ ? "Agents connect to Loopback over MCP and sign in as you. When one connects, you pick the workspace it asks in."
104
+ : "Agents connect to Loopback over MCP, sending this deployment's token."
105
+ }
106
+ >
107
+ <Section title="Claude Code">
108
+ <ClipboardText
109
+ text={
110
+ oauth
111
+ ? `claude mcp add --transport http loopback ${mcp}`
112
+ : `claude mcp add --transport http loopback ${mcp} --header "Authorization: Bearer $LOOPBACK_TOKEN"`
113
+ }
114
+ />
115
+ <Text variant="secondary" size="sm">
116
+ {oauth
117
+ ? "Then run /mcp in Claude Code and sign in when it asks."
118
+ : "With the token in LOOPBACK_TOKEN, in the shell you run it from."}
119
+ </Text>
120
+ </Section>
121
+ <Section title="Any other MCP client">
122
+ <ClipboardText text={mcp} />
123
+ <Text variant="secondary" size="sm">
124
+ {oauth
125
+ ? "Add it as a remote server over streamable HTTP. It signs in with OAuth."
126
+ : "Add it as a remote server over streamable HTTP, with the header Authorization: Bearer and the token."}
127
+ </Text>
128
+ </Section>
129
+ </Page>
130
+ );
131
+ }
132
+
133
+ const noPreference = (): string | undefined => undefined;
134
+
135
+ /**
136
+ * The Loopback app, for a deployment to render: its inbox, a page on connecting agents, and the
137
+ * deployment's own pages, routed by path. Render it inside a `RegistryProvider`, as the inbox needs.
138
+ * The deployment's hooks are bound here, once: the app's pages call the same ones on every render.
139
+ */
140
+ export const createLoopbackApp = (options: LoopbackAppOptions): ComponentType => {
141
+ const { SignIn, continueAt } = options.session;
142
+ const useSignedIn = options.session.useSignedIn;
143
+ const useList = options.workspaces.useList;
144
+ const usePreferred = options.workspaces.usePreferred ?? noPreference;
145
+ const useClient = options.workspaces.useClient;
146
+ const { create } = options.workspaces;
147
+ const { pages = [], workspaceMenu, accountMenu = [], Banner } = options.extensions ?? {};
148
+
149
+ /** A page for someone signed in; anyone else is sent to sign in, and brought back after. */
150
+ function SignedIn({ children }: { readonly children: ReactNode }) {
151
+ const signedIn = useSignedIn();
152
+ const location = useLocation();
153
+ if (signedIn === undefined) return <Waiting />;
154
+ if (signedIn === null) {
155
+ const next = `${location.pathname}${location.search}${location.hash}`;
156
+ return <Navigate to={`/sign-in?next=${encodeURIComponent(next)}`} replace />;
157
+ }
158
+ return <SignedInContext value={signedIn}>{children}</SignedInContext>;
159
+ }
160
+
161
+ /**
162
+ * The deployment's sign-in page, until the person is signed in; then where they were going: a
163
+ * page of the server's the deployment names, loaded in full, or else `next`.
164
+ */
165
+ function SignInPage() {
166
+ const signedIn = useSignedIn();
167
+ const [search] = useSearchParams();
168
+ const next = safeNext(search.get("next"));
169
+ const onward = signedIn == null ? undefined : continueAt?.(search);
170
+ useEffect(() => {
171
+ if (onward !== undefined) loadPage(onward);
172
+ }, [onward]);
173
+ if (signedIn === undefined || onward !== undefined) return <Waiting />;
174
+ if (signedIn !== null) return <Navigate to={next} replace />;
175
+ return <SignIn next={next} />;
176
+ }
177
+
178
+ /**
179
+ * The app's front door: the workspace the person prefers, or their first, and the ask an old link
180
+ * names in it. Someone in no workspace is offered a new one, where the deployment makes them.
181
+ */
182
+ function Home() {
183
+ const navigate = useNavigate();
184
+ const location = useLocation();
185
+ const list = useList();
186
+ const preferred = usePreferred();
187
+ if (list === undefined) return <Waiting />;
188
+ const workspace = list.find((candidate) => candidate.id === preferred)?.id ?? list[0]?.id;
189
+ if (workspace !== undefined) {
190
+ return <Navigate to={workspacePath(workspace, askInOldLink(location.hash))} replace />;
191
+ }
192
+ return (
193
+ <main className="flex h-full items-center justify-center p-4">
194
+ <Empty
195
+ icon={<Tray size={32} />}
196
+ title="You're not in a workspace"
197
+ description={
198
+ create === undefined
199
+ ? "Ask someone to invite you to theirs."
200
+ : "Make one to connect your agents to, or ask someone to invite you to theirs."
201
+ }
202
+ contents={
203
+ create === undefined ? undefined : (
204
+ <Button
205
+ variant="primary"
206
+ icon={<Plus />}
207
+ onClick={() =>
208
+ void create().then(async (id) => {
209
+ if (id !== undefined) await navigate(workspacePath(id));
210
+ })
211
+ }
212
+ >
213
+ New workspace
214
+ </Button>
215
+ )
216
+ }
217
+ />
218
+ </main>
219
+ );
220
+ }
221
+
222
+ /**
223
+ * One workspace's inbox, live, through the client the deployment gives for it. Another workspace
224
+ * is another client, so switching reconnects to that one.
225
+ */
226
+ function WorkspaceInbox({
227
+ workspace,
228
+ list,
229
+ }: {
230
+ readonly workspace: string;
231
+ readonly list: readonly { readonly id: string; readonly name: string }[];
232
+ }) {
233
+ const signedIn = useSignedInHere();
234
+ const client = useClient(workspace);
235
+ const { askId } = useParams();
236
+ const navigate = useNavigate();
237
+ const onSelect = useCallback(
238
+ (id: string | null) => void navigate(workspacePath(workspace, id ?? undefined)),
239
+ [navigate, workspace],
240
+ );
241
+ const hrefFor = useCallback((id: string) => workspacePath(workspace, id), [workspace]);
242
+ return (
243
+ <InboxProvider client={client}>
244
+ <div className="flex h-full flex-col">
245
+ <Header
246
+ workspace={workspace}
247
+ signedIn={signedIn}
248
+ workspaces={list}
249
+ workspaceItems={workspaceMenu?.(workspace) ?? []}
250
+ accountItems={accountMenu}
251
+ create={create}
252
+ />
253
+ {Banner === undefined ? null : <Banner workspace={workspace} />}
254
+ <Inbox selectedId={askId ?? null} onSelect={onSelect} hrefFor={hrefFor} />
255
+ </div>
256
+ </InboxProvider>
257
+ );
258
+ }
259
+
260
+ function ConnectPage({ workspace }: { readonly workspace: string }) {
261
+ return <Connect workspace={workspace} client={useClient(workspace)} />;
262
+ }
263
+
264
+ /** A page of one of the person's workspaces, or a page saying it isn't one of theirs. */
265
+ function Theirs({ page }: { readonly page: "inbox" | "connect" }) {
266
+ const { workspace = "" } = useParams();
267
+ const list = useList();
268
+ if (list === undefined) return <Waiting />;
269
+ if (!list.some((candidate) => candidate.id === workspace)) return <NotTheirs />;
270
+ return page === "inbox" ? (
271
+ <WorkspaceInbox workspace={workspace} list={list} />
272
+ ) : (
273
+ <ConnectPage workspace={workspace} />
274
+ );
275
+ }
276
+
277
+ return function LoopbackApp() {
278
+ return (
279
+ <BrowserRouter>
280
+ <LinkProvider component={RouterLink}>
281
+ <Routes>
282
+ <Route path="/sign-in" element={<SignInPage />} />
283
+ <Route
284
+ path="/w/:workspace"
285
+ element={
286
+ <SignedIn>
287
+ <Theirs page="inbox" />
288
+ </SignedIn>
289
+ }
290
+ />
291
+ <Route
292
+ path="/w/:workspace/asks/:askId"
293
+ element={
294
+ <SignedIn>
295
+ <Theirs page="inbox" />
296
+ </SignedIn>
297
+ }
298
+ />
299
+ <Route
300
+ path="/w/:workspace/connect"
301
+ element={
302
+ <SignedIn>
303
+ <Theirs page="connect" />
304
+ </SignedIn>
305
+ }
306
+ />
307
+ {pages.map(({ path, Page: Shown, signedIn }) => (
308
+ <Route
309
+ key={path}
310
+ path={path}
311
+ element={
312
+ signedIn ? (
313
+ <SignedIn>
314
+ <Shown />
315
+ </SignedIn>
316
+ ) : (
317
+ <Shown />
318
+ )
319
+ }
320
+ />
321
+ ))}
322
+ <Route
323
+ path="*"
324
+ element={
325
+ <SignedIn>
326
+ <Home />
327
+ </SignedIn>
328
+ }
329
+ />
330
+ </Routes>
331
+ </LinkProvider>
332
+ </BrowserRouter>
333
+ );
334
+ };
335
+ };
package/src/header.tsx ADDED
@@ -0,0 +1,196 @@
1
+ import { Button, DropdownMenu, LinkButton, Text } from "@cloudflare/kumo";
2
+ import { useAttention, useInbox, useNotifications } from "@loopbacktools/inbox";
3
+ import { Bell, BellSlash, CaretDown, Check, Plugs, Plus, SignOut } from "@phosphor-icons/react";
4
+ import { useCallback } from "react";
5
+ import { Link, useNavigate } from "react-router";
6
+ import { connectPath, workspacePath } from "./paths.ts";
7
+ import type { MenuItem, SignedIn, WorkspaceSummary } from "./ports.ts";
8
+
9
+ const CONNECTION = {
10
+ connecting: { label: "Connecting", tone: "bg-kumo-line" },
11
+ live: { label: "Live", tone: "bg-kumo-success" },
12
+ reconnecting: { label: "Reconnecting", tone: "bg-kumo-warning" },
13
+ } as const;
14
+
15
+ /** Goes where a menu item goes, or does what it does. */
16
+ const useChoose = () => {
17
+ const navigate = useNavigate();
18
+ return (item: MenuItem) => {
19
+ if (item.to !== undefined) void navigate(item.to);
20
+ void item.onSelect?.();
21
+ };
22
+ };
23
+
24
+ /**
25
+ * The workspace open, and the way to the others: only where there is more than one, or a way to make
26
+ * one, or the deployment has items of its own for it.
27
+ */
28
+ function WorkspaceMenu({
29
+ workspace,
30
+ list,
31
+ items,
32
+ create,
33
+ }: {
34
+ readonly workspace: string;
35
+ readonly list: readonly WorkspaceSummary[];
36
+ readonly items: readonly MenuItem[];
37
+ readonly create: (() => Promise<string | undefined>) | undefined;
38
+ }) {
39
+ const navigate = useNavigate();
40
+ const choose = useChoose();
41
+ if (list.length < 2 && create === undefined && items.length === 0) return null;
42
+ const current = list.find((candidate) => candidate.id === workspace);
43
+ const make = async () => {
44
+ const id = await create?.();
45
+ if (id !== undefined) await navigate(workspacePath(id));
46
+ };
47
+ return (
48
+ <DropdownMenu>
49
+ <DropdownMenu.Trigger>
50
+ <Button variant="ghost" size="sm" icon={<CaretDown />}>
51
+ {current?.name ?? "Workspace"}
52
+ </Button>
53
+ </DropdownMenu.Trigger>
54
+ <DropdownMenu.Content>
55
+ {list.map((candidate) => (
56
+ <DropdownMenu.Item
57
+ key={candidate.id}
58
+ icon={candidate.id === workspace ? Check : undefined}
59
+ onClick={() => void navigate(workspacePath(candidate.id))}
60
+ >
61
+ {candidate.name}
62
+ </DropdownMenu.Item>
63
+ ))}
64
+ {items.length > 0 || create !== undefined ? <DropdownMenu.Separator /> : null}
65
+ {items.map((item) => (
66
+ <DropdownMenu.Item key={item.label} icon={item.icon} onClick={() => choose(item)}>
67
+ {item.label}
68
+ </DropdownMenu.Item>
69
+ ))}
70
+ {create === undefined ? null : (
71
+ <DropdownMenu.Item icon={Plus} onClick={() => void make()}>
72
+ New workspace
73
+ </DropdownMenu.Item>
74
+ )}
75
+ </DropdownMenu.Content>
76
+ </DropdownMenu>
77
+ );
78
+ }
79
+
80
+ /**
81
+ * The person signed in: a menu of the deployment's items and signing out. With no items and no name
82
+ * to show, signing out is a button of its own, and with nothing to sign out of either, there is none.
83
+ */
84
+ function AccountMenu({
85
+ signedIn,
86
+ items,
87
+ }: {
88
+ readonly signedIn: SignedIn;
89
+ readonly items: readonly MenuItem[];
90
+ }) {
91
+ const navigate = useNavigate();
92
+ const choose = useChoose();
93
+ const { name, signOut } = signedIn;
94
+ const leave =
95
+ signOut === undefined
96
+ ? undefined
97
+ : async () => {
98
+ await signOut();
99
+ await navigate("/");
100
+ };
101
+ if (items.length === 0 && name === undefined) {
102
+ return leave === undefined ? null : (
103
+ <Button variant="ghost" size="sm" onClick={() => void leave()}>
104
+ Sign out
105
+ </Button>
106
+ );
107
+ }
108
+ return (
109
+ <DropdownMenu>
110
+ <DropdownMenu.Trigger>
111
+ <Button variant="ghost" size="sm">
112
+ {name ?? "Account"}
113
+ </Button>
114
+ </DropdownMenu.Trigger>
115
+ <DropdownMenu.Content>
116
+ {items.map((item) => (
117
+ <DropdownMenu.Item key={item.label} icon={item.icon} onClick={() => choose(item)}>
118
+ {item.label}
119
+ </DropdownMenu.Item>
120
+ ))}
121
+ {leave === undefined ? null : (
122
+ <>
123
+ {items.length > 0 ? <DropdownMenu.Separator /> : null}
124
+ <DropdownMenu.Item icon={SignOut} onClick={() => void leave()}>
125
+ Sign out
126
+ </DropdownMenu.Item>
127
+ </>
128
+ )}
129
+ </DropdownMenu.Content>
130
+ </DropdownMenu>
131
+ );
132
+ }
133
+
134
+ /** The bar above a workspace's inbox. */
135
+ export function Header({
136
+ workspace,
137
+ signedIn,
138
+ workspaces,
139
+ workspaceItems,
140
+ accountItems,
141
+ create,
142
+ }: {
143
+ readonly workspace: string;
144
+ readonly signedIn: SignedIn;
145
+ /** Every workspace the person can open. */
146
+ readonly workspaces: readonly WorkspaceSummary[];
147
+ readonly workspaceItems: readonly MenuItem[];
148
+ readonly accountItems: readonly MenuItem[];
149
+ readonly create: (() => Promise<string | undefined>) | undefined;
150
+ }) {
151
+ const navigate = useNavigate();
152
+ const inbox = useInbox();
153
+ const notifications = useNotifications();
154
+ const open = useCallback(
155
+ (askId: string) => void navigate(workspacePath(workspace, askId)),
156
+ [navigate, workspace],
157
+ );
158
+ useAttention(inbox, notifications.enabled, open);
159
+ const connection = CONNECTION[inbox.status];
160
+ return (
161
+ <header className="flex items-center gap-2 border-b border-kumo-line bg-kumo-base px-4 py-2">
162
+ <Link to={workspacePath(workspace)} className="flex items-center gap-2">
163
+ <img src="/favicon.svg" alt="" className="size-6" />
164
+ <Text bold>Loopback</Text>
165
+ </Link>
166
+ <WorkspaceMenu
167
+ workspace={workspace}
168
+ list={workspaces}
169
+ items={workspaceItems}
170
+ create={create}
171
+ />
172
+ <span className="inline-flex items-center gap-1.5 text-xs text-kumo-subtle">
173
+ <span className={`size-2 rounded-full ${connection.tone}`} />
174
+ {connection.label}
175
+ </span>
176
+ <span className="ml-auto flex items-center gap-1">
177
+ <LinkButton href={connectPath(workspace)} variant="ghost" size="sm" icon={<Plugs />}>
178
+ Connect an agent
179
+ </LinkButton>
180
+ {notifications.supported ? (
181
+ <Button
182
+ variant="ghost"
183
+ size="sm"
184
+ shape="square"
185
+ aria-label={
186
+ notifications.enabled ? "Turn notifications off" : "Notify me when an agent asks"
187
+ }
188
+ icon={notifications.enabled ? <Bell /> : <BellSlash />}
189
+ onClick={() => void notifications.toggle()}
190
+ />
191
+ ) : null}
192
+ <AccountMenu signedIn={signedIn} items={accountItems} />
193
+ </span>
194
+ </header>
195
+ );
196
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export { createLoopbackApp } from "./app.tsx";
2
+ export { Page, Section } from "./page.tsx";
3
+ export { connectPath, workspacePath } from "./paths.ts";
4
+ export type {
5
+ ExtensionPage,
6
+ Extensions,
7
+ LoopbackAppOptions,
8
+ MenuItem,
9
+ Session,
10
+ SignedIn,
11
+ Workspaces,
12
+ WorkspaceSummary,
13
+ } from "./ports.ts";
14
+ export { followSystemTheme } from "./theme.ts";
@@ -0,0 +1,2 @@
1
+ /** Loads a page in full, as one of the server's own pages needs, rather than routing to it. */
2
+ export const loadPage = (url: string): void => window.location.assign(url);
package/src/page.tsx ADDED
@@ -0,0 +1,62 @@
1
+ import { Text } from "@cloudflare/kumo";
2
+ import type { ReactNode } from "react";
3
+ import { Link } from "react-router";
4
+
5
+ /** A page of its own, off the inbox: a way back, a heading, and its sections. */
6
+ export function Page({
7
+ back,
8
+ title,
9
+ description,
10
+ children,
11
+ }: {
12
+ readonly back: { readonly to: string; readonly label: string };
13
+ readonly title: string;
14
+ readonly description?: string;
15
+ readonly children: ReactNode;
16
+ }) {
17
+ return (
18
+ <main className="mx-auto flex max-w-2xl flex-col gap-6 px-4 py-10">
19
+ <Link to={back.to} className="text-sm text-kumo-subtle">
20
+ ← {back.label}
21
+ </Link>
22
+ <div className="flex flex-col gap-2">
23
+ <Text variant="heading" as="h1">
24
+ {title}
25
+ </Text>
26
+ {description === undefined ? null : <Text variant="secondary">{description}</Text>}
27
+ </div>
28
+ {children}
29
+ </main>
30
+ );
31
+ }
32
+
33
+ /** One part of a page: a card with a heading. A dangerous one is bordered in red. */
34
+ export function Section({
35
+ title,
36
+ description,
37
+ danger = false,
38
+ children,
39
+ }: {
40
+ readonly title: string;
41
+ readonly description?: string | undefined;
42
+ readonly danger?: boolean;
43
+ readonly children: ReactNode;
44
+ }) {
45
+ return (
46
+ <section
47
+ className={`flex flex-col gap-4 rounded-xl border bg-kumo-elevated p-5 ${
48
+ danger ? "border-kumo-danger" : "border-kumo-line"
49
+ }`}
50
+ >
51
+ <div className="flex flex-col gap-1">
52
+ <Text bold>{title}</Text>
53
+ {description === undefined ? null : (
54
+ <Text variant="secondary" size="sm">
55
+ {description}
56
+ </Text>
57
+ )}
58
+ </div>
59
+ {children}
60
+ </section>
61
+ );
62
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,21 @@
1
+ /** Where a workspace's inbox, or one ask in it, is in the app. */
2
+ export const workspacePath = (workspace: string, askId?: string): string =>
3
+ `/w/${encodeURIComponent(workspace)}${askId === undefined ? "" : `/asks/${encodeURIComponent(askId)}`}`;
4
+
5
+ /** Where the page on connecting an agent to a workspace is. */
6
+ export const connectPath = (workspace: string): string => `${workspacePath(workspace)}/connect`;
7
+
8
+ /**
9
+ * Where to send someone once they have signed in: a path in this app, never another site, so a link
10
+ * to the sign-in page can't be made to send people elsewhere.
11
+ */
12
+ export const safeNext = (next: string | null): string =>
13
+ next !== null && next.startsWith("/") && !next.startsWith("//") && !next.startsWith("/\\")
14
+ ? next
15
+ : "/";
16
+
17
+ /** The ask an old link names, from before links had paths: `/#/<id>`. */
18
+ export const askInOldLink = (hash: string): string | undefined => {
19
+ const id = /^#\/(.+)$/.exec(hash)?.[1];
20
+ return id === undefined ? undefined : decodeURIComponent(id);
21
+ };