@majapisoftwares/ui 6.0.0 → 6.1.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/docs/STANDARDS.md
CHANGED
|
@@ -10,7 +10,7 @@ TypeScript em modo strict, React com componentes funcionais e composição. APIs
|
|
|
10
10
|
|
|
11
11
|
`src/styles.css` é a fonte dos tokens de cor, tipografia, raio, bordas, foco, estados e estrutura. Preset base-luma do shadcn (o mesmo da home de ui.shadcn.com: cantos bem arredondados, campos com fundo suave, sombras leves) sobre o tema neutral, fonte Geist, foco visível e espaço consistente. Cores são semânticas: background, foreground, primary, muted, destructive, success e warning. Temas claro/escuro preservam a semântica. Tokens e padrões podem evoluir nesta biblioteca, nunca por overrides locais em um produto.
|
|
12
12
|
|
|
13
|
-
Primitivos shadcn ficam em src/components/ui (Button, Badge, Card, Input, Table, Sheet, Sidebar, Tooltip, DropdownMenu, ContextMenu, Breadcrumb, Avatar, Checkbox, Collapsible, Alert, Empty, Select, Separator, Skeleton, Tabs, Field e Label). Padrões compostos: AppShell, PageHeader, MetricStrip, StatusBadge, DataTable, DetailsSheet, DetailList, Preferences, PreferenceControls e MajapiLogo. Templates React: AdminDashboard, AuthLayout, LoginForm e SignupForm. Marca, navegação, textos, dados, ações e slots são configuráveis. Todos os apps atuais e futuros devem consumir esta base pública; ela não conhece AWS ou outro domínio.
|
|
13
|
+
Primitivos shadcn ficam em src/components/ui (Button, Badge, Card, Input, Table, Sheet, Sidebar, Tooltip, DropdownMenu, ContextMenu, Breadcrumb, Avatar, Checkbox, Collapsible, Alert, Empty, Select, Separator, Skeleton, Tabs, Field e Label). Padrões compostos: AppShell (slots `sidebarHeader` e `footer`), WorkspaceSwitcher, UserMenu, PageHeader, MetricStrip, StatusBadge, DataTable, DetailsSheet, DetailList, Preferences, PreferenceControls e MajapiLogo. Templates React: AdminDashboard, AuthLayout, LoginForm e SignupForm. Marca, navegação, textos, dados, ações e slots são configuráveis. Todos os apps atuais e futuros devem consumir esta base pública; ela não conhece AWS ou outro domínio.
|
|
14
14
|
|
|
15
15
|
## Comportamentos de UX
|
|
16
16
|
|
package/package.json
CHANGED
|
@@ -46,7 +46,9 @@ export interface AppShellProps {
|
|
|
46
46
|
header?: ReactNode;
|
|
47
47
|
/** Rendered at the end of the header, e.g. locale or theme switchers. */
|
|
48
48
|
actions?: ReactNode;
|
|
49
|
-
/**
|
|
49
|
+
/** Below the logo in the sidebar header, e.g. `WorkspaceSwitcher`. */
|
|
50
|
+
sidebarHeader?: ReactNode;
|
|
51
|
+
/** Sidebar footer, e.g. `UserMenu`. */
|
|
50
52
|
footer?: ReactNode;
|
|
51
53
|
defaultMode?: SidebarMode;
|
|
52
54
|
defaultWidth?: number;
|
|
@@ -96,6 +98,7 @@ export function AppShell({
|
|
|
96
98
|
header,
|
|
97
99
|
actions,
|
|
98
100
|
footer,
|
|
101
|
+
sidebarHeader,
|
|
99
102
|
defaultMode,
|
|
100
103
|
defaultWidth,
|
|
101
104
|
fillHeight = false,
|
|
@@ -121,6 +124,7 @@ export function AppShell({
|
|
|
121
124
|
<div className="hidden group-data-[collapsible=icon]:block">
|
|
122
125
|
<SidebarCollapse />
|
|
123
126
|
</div>
|
|
127
|
+
{sidebarHeader}
|
|
124
128
|
</SidebarHeader>
|
|
125
129
|
<SidebarContent>
|
|
126
130
|
{navigation.map((group, index) => (
|
|
@@ -30,7 +30,8 @@ export function DetailsSheet({
|
|
|
30
30
|
<SheetDescription>{description}</SheetDescription>
|
|
31
31
|
) : null}
|
|
32
32
|
</SheetHeader>
|
|
33
|
-
|
|
33
|
+
{/* Same inset as SheetHeader (p-6). */}
|
|
34
|
+
<div className="px-6 pb-8">{children}</div>
|
|
34
35
|
</SheetContent>
|
|
35
36
|
</Sheet>
|
|
36
37
|
);
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import {
|
|
5
|
+
ChevronsUpDownIcon,
|
|
6
|
+
LanguagesIcon,
|
|
7
|
+
LogOutIcon,
|
|
8
|
+
MoonIcon,
|
|
9
|
+
SunIcon,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
|
|
12
|
+
import {
|
|
13
|
+
DropdownMenu,
|
|
14
|
+
DropdownMenuContent,
|
|
15
|
+
DropdownMenuGroup,
|
|
16
|
+
DropdownMenuItem,
|
|
17
|
+
DropdownMenuLabel,
|
|
18
|
+
DropdownMenuRadioGroup,
|
|
19
|
+
DropdownMenuRadioItem,
|
|
20
|
+
DropdownMenuSeparator,
|
|
21
|
+
DropdownMenuSub,
|
|
22
|
+
DropdownMenuSubContent,
|
|
23
|
+
DropdownMenuSubTrigger,
|
|
24
|
+
DropdownMenuTrigger,
|
|
25
|
+
} from "../components/ui/dropdown-menu";
|
|
26
|
+
import {
|
|
27
|
+
SidebarMenu,
|
|
28
|
+
SidebarMenuButton,
|
|
29
|
+
SidebarMenuItem,
|
|
30
|
+
useSidebar,
|
|
31
|
+
} from "../components/ui/sidebar";
|
|
32
|
+
import { defineMessages, locales, useMessages, type Locale } from "../lib/i18n";
|
|
33
|
+
import { usePreferences } from "./preferences";
|
|
34
|
+
|
|
35
|
+
const messages = defineMessages({
|
|
36
|
+
en: {
|
|
37
|
+
account: "Account menu",
|
|
38
|
+
theme: "Theme",
|
|
39
|
+
light: "Light",
|
|
40
|
+
dark: "Dark",
|
|
41
|
+
language: "Language",
|
|
42
|
+
signOut: "Sign out",
|
|
43
|
+
signingOut: "Signing out…",
|
|
44
|
+
},
|
|
45
|
+
"pt-BR": {
|
|
46
|
+
account: "Menu da conta",
|
|
47
|
+
theme: "Tema",
|
|
48
|
+
light: "Claro",
|
|
49
|
+
dark: "Escuro",
|
|
50
|
+
language: "Idioma",
|
|
51
|
+
signOut: "Sair",
|
|
52
|
+
signingOut: "Saindo…",
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const localeNames: Record<Locale, string> = {
|
|
57
|
+
en: "English",
|
|
58
|
+
"pt-BR": "Português",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function initials(name: string) {
|
|
62
|
+
return name
|
|
63
|
+
.split(/\s+/)
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.slice(0, 2)
|
|
66
|
+
.map((part) => part.charAt(0).toUpperCase())
|
|
67
|
+
.join("");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function Identity({
|
|
71
|
+
name,
|
|
72
|
+
email,
|
|
73
|
+
avatarUrl,
|
|
74
|
+
}: {
|
|
75
|
+
name: string;
|
|
76
|
+
email?: string;
|
|
77
|
+
avatarUrl?: string;
|
|
78
|
+
}) {
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
<Avatar className="size-8">
|
|
82
|
+
{avatarUrl ? <AvatarImage src={avatarUrl} alt="" /> : null}
|
|
83
|
+
<AvatarFallback>{initials(name)}</AvatarFallback>
|
|
84
|
+
</Avatar>
|
|
85
|
+
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
86
|
+
<span className="truncate font-medium">{name}</span>
|
|
87
|
+
{email ? (
|
|
88
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
89
|
+
{email}
|
|
90
|
+
</span>
|
|
91
|
+
) : null}
|
|
92
|
+
</div>
|
|
93
|
+
</>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Sidebar account menu (place it in `AppShell` `footer`). Theme and language
|
|
99
|
+
* come from `Preferences`; account actions are passed as `items`.
|
|
100
|
+
*/
|
|
101
|
+
export function UserMenu({
|
|
102
|
+
name,
|
|
103
|
+
email,
|
|
104
|
+
avatarUrl,
|
|
105
|
+
items,
|
|
106
|
+
onSignOut,
|
|
107
|
+
signingOut = false,
|
|
108
|
+
}: {
|
|
109
|
+
name: string;
|
|
110
|
+
email?: string;
|
|
111
|
+
avatarUrl?: string;
|
|
112
|
+
/** App-specific entries, e.g. profile or settings links. */
|
|
113
|
+
items?: ReactNode;
|
|
114
|
+
onSignOut?: () => void;
|
|
115
|
+
signingOut?: boolean;
|
|
116
|
+
}) {
|
|
117
|
+
const t = useMessages(messages);
|
|
118
|
+
const { isMobile } = useSidebar();
|
|
119
|
+
const { locale, setLocale, theme, setTheme } = usePreferences();
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<SidebarMenu>
|
|
123
|
+
<SidebarMenuItem>
|
|
124
|
+
<DropdownMenu>
|
|
125
|
+
<DropdownMenuTrigger
|
|
126
|
+
render={
|
|
127
|
+
<SidebarMenuButton
|
|
128
|
+
size="lg"
|
|
129
|
+
aria-label={t.account}
|
|
130
|
+
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
|
131
|
+
/>
|
|
132
|
+
}
|
|
133
|
+
>
|
|
134
|
+
<Identity name={name} email={email} avatarUrl={avatarUrl} />
|
|
135
|
+
<ChevronsUpDownIcon className="ml-auto size-4 text-muted-foreground" />
|
|
136
|
+
</DropdownMenuTrigger>
|
|
137
|
+
<DropdownMenuContent
|
|
138
|
+
className="w-(--anchor-width) min-w-60"
|
|
139
|
+
side={isMobile ? "bottom" : "right"}
|
|
140
|
+
align="end"
|
|
141
|
+
sideOffset={4}
|
|
142
|
+
>
|
|
143
|
+
<DropdownMenuGroup>
|
|
144
|
+
<DropdownMenuLabel className="flex items-center gap-2 font-normal text-foreground">
|
|
145
|
+
<Identity name={name} email={email} avatarUrl={avatarUrl} />
|
|
146
|
+
</DropdownMenuLabel>
|
|
147
|
+
</DropdownMenuGroup>
|
|
148
|
+
{items ? (
|
|
149
|
+
<>
|
|
150
|
+
<DropdownMenuSeparator />
|
|
151
|
+
<DropdownMenuGroup>{items}</DropdownMenuGroup>
|
|
152
|
+
</>
|
|
153
|
+
) : null}
|
|
154
|
+
<DropdownMenuSeparator />
|
|
155
|
+
<DropdownMenuGroup>
|
|
156
|
+
<DropdownMenuSub>
|
|
157
|
+
<DropdownMenuSubTrigger>
|
|
158
|
+
{theme === "dark" ? <MoonIcon /> : <SunIcon />}
|
|
159
|
+
{t.theme}
|
|
160
|
+
</DropdownMenuSubTrigger>
|
|
161
|
+
<DropdownMenuSubContent>
|
|
162
|
+
<DropdownMenuRadioGroup
|
|
163
|
+
value={theme}
|
|
164
|
+
onValueChange={(value) =>
|
|
165
|
+
setTheme(value as "light" | "dark")
|
|
166
|
+
}
|
|
167
|
+
>
|
|
168
|
+
<DropdownMenuRadioItem value="light">
|
|
169
|
+
<SunIcon />
|
|
170
|
+
{t.light}
|
|
171
|
+
</DropdownMenuRadioItem>
|
|
172
|
+
<DropdownMenuRadioItem value="dark">
|
|
173
|
+
<MoonIcon />
|
|
174
|
+
{t.dark}
|
|
175
|
+
</DropdownMenuRadioItem>
|
|
176
|
+
</DropdownMenuRadioGroup>
|
|
177
|
+
</DropdownMenuSubContent>
|
|
178
|
+
</DropdownMenuSub>
|
|
179
|
+
<DropdownMenuSub>
|
|
180
|
+
<DropdownMenuSubTrigger>
|
|
181
|
+
<LanguagesIcon />
|
|
182
|
+
{t.language}
|
|
183
|
+
</DropdownMenuSubTrigger>
|
|
184
|
+
<DropdownMenuSubContent>
|
|
185
|
+
<DropdownMenuRadioGroup
|
|
186
|
+
value={locale}
|
|
187
|
+
onValueChange={(value) => setLocale(value as Locale)}
|
|
188
|
+
>
|
|
189
|
+
{locales.map((value) => (
|
|
190
|
+
<DropdownMenuRadioItem key={value} value={value}>
|
|
191
|
+
{localeNames[value]}
|
|
192
|
+
</DropdownMenuRadioItem>
|
|
193
|
+
))}
|
|
194
|
+
</DropdownMenuRadioGroup>
|
|
195
|
+
</DropdownMenuSubContent>
|
|
196
|
+
</DropdownMenuSub>
|
|
197
|
+
</DropdownMenuGroup>
|
|
198
|
+
{onSignOut ? (
|
|
199
|
+
<>
|
|
200
|
+
<DropdownMenuSeparator />
|
|
201
|
+
<DropdownMenuGroup>
|
|
202
|
+
<DropdownMenuItem
|
|
203
|
+
variant="destructive"
|
|
204
|
+
disabled={signingOut}
|
|
205
|
+
onClick={onSignOut}
|
|
206
|
+
>
|
|
207
|
+
<LogOutIcon />
|
|
208
|
+
{signingOut ? t.signingOut : t.signOut}
|
|
209
|
+
</DropdownMenuItem>
|
|
210
|
+
</DropdownMenuGroup>
|
|
211
|
+
</>
|
|
212
|
+
) : null}
|
|
213
|
+
</DropdownMenuContent>
|
|
214
|
+
</DropdownMenu>
|
|
215
|
+
</SidebarMenuItem>
|
|
216
|
+
</SidebarMenu>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { cn } from "cn";
|
|
5
|
+
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
|
|
6
|
+
import {
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
DropdownMenuContent,
|
|
9
|
+
DropdownMenuGroup,
|
|
10
|
+
DropdownMenuItem,
|
|
11
|
+
DropdownMenuLabel,
|
|
12
|
+
DropdownMenuSeparator,
|
|
13
|
+
DropdownMenuTrigger,
|
|
14
|
+
} from "../components/ui/dropdown-menu";
|
|
15
|
+
import {
|
|
16
|
+
SidebarMenu,
|
|
17
|
+
SidebarMenuButton,
|
|
18
|
+
SidebarMenuItem,
|
|
19
|
+
useSidebar,
|
|
20
|
+
} from "../components/ui/sidebar";
|
|
21
|
+
import { Skeleton } from "../components/ui/skeleton";
|
|
22
|
+
import { defineMessages, useMessages } from "../lib/i18n";
|
|
23
|
+
|
|
24
|
+
const messages = defineMessages({
|
|
25
|
+
en: { switchWorkspace: "Switch workspace", workspaces: "Workspaces" },
|
|
26
|
+
"pt-BR": { switchWorkspace: "Trocar workspace", workspaces: "Workspaces" },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export interface Workspace {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
/** Second line, e.g. plan or owner. */
|
|
33
|
+
description?: string;
|
|
34
|
+
/** Square mark; defaults to the name's initial. */
|
|
35
|
+
icon?: ReactNode;
|
|
36
|
+
/** Workspaces sharing a group are listed together (e.g. by company). */
|
|
37
|
+
group?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function WorkspaceMark({
|
|
41
|
+
workspace,
|
|
42
|
+
className,
|
|
43
|
+
}: {
|
|
44
|
+
workspace: Workspace;
|
|
45
|
+
className?: string;
|
|
46
|
+
}) {
|
|
47
|
+
return (
|
|
48
|
+
<span
|
|
49
|
+
className={cn(
|
|
50
|
+
// Forced: focused menu items recolor all descendants to the accent color.
|
|
51
|
+
"flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground **:text-primary-foreground! [&_svg]:size-4",
|
|
52
|
+
className,
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
{workspace.icon ?? (
|
|
56
|
+
<span className="text-sm font-semibold">
|
|
57
|
+
{workspace.name.charAt(0).toUpperCase()}
|
|
58
|
+
</span>
|
|
59
|
+
)}
|
|
60
|
+
</span>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Sidebar workspace selector (place it in `AppShell` `sidebarHeader`).
|
|
66
|
+
* Selecting a workspace calls `onSelect`; routing and data stay in the app.
|
|
67
|
+
*/
|
|
68
|
+
export function WorkspaceSwitcher({
|
|
69
|
+
workspaces,
|
|
70
|
+
activeId,
|
|
71
|
+
onSelect,
|
|
72
|
+
actions,
|
|
73
|
+
loading = false,
|
|
74
|
+
}: {
|
|
75
|
+
workspaces: Workspace[];
|
|
76
|
+
activeId: string;
|
|
77
|
+
onSelect: (workspace: Workspace) => void;
|
|
78
|
+
/** Extra menu items after the list, e.g. "Manage workspaces". */
|
|
79
|
+
actions?: ReactNode;
|
|
80
|
+
loading?: boolean;
|
|
81
|
+
}) {
|
|
82
|
+
const t = useMessages(messages);
|
|
83
|
+
const { isMobile } = useSidebar();
|
|
84
|
+
const active = workspaces.find((workspace) => workspace.id === activeId);
|
|
85
|
+
const groups = workspaces.reduce<Map<string, Workspace[]>>(
|
|
86
|
+
(map, workspace) => {
|
|
87
|
+
const key = workspace.group ?? "";
|
|
88
|
+
map.set(key, [...(map.get(key) ?? []), workspace]);
|
|
89
|
+
return map;
|
|
90
|
+
},
|
|
91
|
+
new Map(),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (loading || !active)
|
|
95
|
+
return (
|
|
96
|
+
<SidebarMenu>
|
|
97
|
+
<SidebarMenuItem>
|
|
98
|
+
<div className="flex h-14 items-center gap-2 px-3 group-data-[collapsible=icon]:px-0">
|
|
99
|
+
<Skeleton className="size-8 rounded-lg" />
|
|
100
|
+
<Skeleton className="h-4 flex-1 group-data-[collapsible=icon]:hidden" />
|
|
101
|
+
</div>
|
|
102
|
+
</SidebarMenuItem>
|
|
103
|
+
</SidebarMenu>
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<SidebarMenu>
|
|
108
|
+
<SidebarMenuItem>
|
|
109
|
+
<DropdownMenu>
|
|
110
|
+
<DropdownMenuTrigger
|
|
111
|
+
render={
|
|
112
|
+
<SidebarMenuButton
|
|
113
|
+
size="lg"
|
|
114
|
+
aria-label={t.switchWorkspace}
|
|
115
|
+
// Card-like surface so the current workspace reads as a distinct control.
|
|
116
|
+
className="bg-card shadow-xs ring-1 ring-foreground/5 hover:bg-sidebar-accent data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground dark:ring-foreground/10"
|
|
117
|
+
/>
|
|
118
|
+
}
|
|
119
|
+
>
|
|
120
|
+
<WorkspaceMark workspace={active} />
|
|
121
|
+
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
122
|
+
<span className="truncate font-medium">{active.name}</span>
|
|
123
|
+
{active.description ? (
|
|
124
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
125
|
+
{active.description}
|
|
126
|
+
</span>
|
|
127
|
+
) : null}
|
|
128
|
+
</div>
|
|
129
|
+
<ChevronsUpDownIcon className="ml-auto text-muted-foreground" />
|
|
130
|
+
</DropdownMenuTrigger>
|
|
131
|
+
<DropdownMenuContent
|
|
132
|
+
className="w-(--anchor-width) min-w-60"
|
|
133
|
+
align="start"
|
|
134
|
+
side={isMobile ? "bottom" : "right"}
|
|
135
|
+
sideOffset={4}
|
|
136
|
+
>
|
|
137
|
+
{[...groups.entries()].map(([group, items], index) => (
|
|
138
|
+
<DropdownMenuGroup key={group || index}>
|
|
139
|
+
{index > 0 ? <DropdownMenuSeparator /> : null}
|
|
140
|
+
<DropdownMenuLabel>{group || t.workspaces}</DropdownMenuLabel>
|
|
141
|
+
{items.map((workspace) => (
|
|
142
|
+
<DropdownMenuItem
|
|
143
|
+
key={workspace.id}
|
|
144
|
+
onClick={() => onSelect(workspace)}
|
|
145
|
+
>
|
|
146
|
+
<WorkspaceMark workspace={workspace} className="size-6" />
|
|
147
|
+
<div className="grid flex-1 leading-tight">
|
|
148
|
+
<span className="truncate">{workspace.name}</span>
|
|
149
|
+
{workspace.description ? (
|
|
150
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
151
|
+
{workspace.description}
|
|
152
|
+
</span>
|
|
153
|
+
) : null}
|
|
154
|
+
</div>
|
|
155
|
+
{workspace.id === activeId ? (
|
|
156
|
+
<CheckIcon className="ml-auto" />
|
|
157
|
+
) : null}
|
|
158
|
+
</DropdownMenuItem>
|
|
159
|
+
))}
|
|
160
|
+
</DropdownMenuGroup>
|
|
161
|
+
))}
|
|
162
|
+
{actions ? (
|
|
163
|
+
<>
|
|
164
|
+
<DropdownMenuSeparator />
|
|
165
|
+
<DropdownMenuGroup>{actions}</DropdownMenuGroup>
|
|
166
|
+
</>
|
|
167
|
+
) : null}
|
|
168
|
+
</DropdownMenuContent>
|
|
169
|
+
</DropdownMenu>
|
|
170
|
+
</SidebarMenuItem>
|
|
171
|
+
</SidebarMenu>
|
|
172
|
+
);
|
|
173
|
+
}
|