@dataif/cli 0.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.
Files changed (183) hide show
  1. package/README.md +16 -0
  2. package/bin/dataif.js +623 -0
  3. package/package.json +26 -0
  4. package/scripts/build-template.mjs +72 -0
  5. package/templates/dataif/README.md +157 -0
  6. package/templates/dataif/infra/.env.example +119 -0
  7. package/templates/dataif/infra/.env.stg.example +119 -0
  8. package/templates/dataif/infra/airflow/Dockerfile +11 -0
  9. package/templates/dataif/infra/airflow/Dockerfile.release +17 -0
  10. package/templates/dataif/infra/airflow/requirements.txt +3 -0
  11. package/templates/dataif/infra/docker-compose.yml +306 -0
  12. package/templates/dataif/infra/init-db/01-init-dataif.sh +129 -0
  13. package/templates/dataif/infra/init-db/pnp-curated-views.sqlinc +444 -0
  14. package/templates/dataif/infra/init-db/pnp-raw-staging-curated.sqlinc +701 -0
  15. package/templates/dataif/infra/keycloak/Dockerfile +4 -0
  16. package/templates/dataif/infra/keycloak/realm-dataif.json +73 -0
  17. package/templates/dataif/infra/ollama/Dockerfile +9 -0
  18. package/templates/dataif/infra/ollama/bootstrap-model.sh +100 -0
  19. package/templates/dataif/infra/ollama/sabia-7b.Modelfile +14 -0
  20. package/templates/dataif/infra/postgres/Dockerfile +4 -0
  21. package/templates/dataif/pipelines/airflow/dags/generated/.gitkeep +1 -0
  22. package/templates/dataif/pipelines/airflow/dags/generated/2020_financeiro_fcc6f1f3_sync.py +9 -0
  23. package/templates/dataif/pipelines/dataif_pipelines/__init__.py +1 -0
  24. package/templates/dataif/pipelines/dataif_pipelines/airflow/__init__.py +1 -0
  25. package/templates/dataif/pipelines/dataif_pipelines/airflow/pnp_pipeline_factory.py +167 -0
  26. package/templates/dataif/pipelines/dataif_pipelines/connectors/__init__.py +1 -0
  27. package/templates/dataif/pipelines/dataif_pipelines/connectors/base/__init__.py +1 -0
  28. package/templates/dataif/pipelines/dataif_pipelines/connectors/base/connector.py +28 -0
  29. package/templates/dataif/pipelines/dataif_pipelines/connectors/base/types.py +14 -0
  30. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/__init__.py +1 -0
  31. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/config.py +19 -0
  32. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/connector.py +558 -0
  33. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/powerbi_microdados.py +728 -0
  34. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/transform.py +296 -0
  35. package/templates/dataif/pipelines/dataif_pipelines/jobs/__init__.py +1 -0
  36. package/templates/dataif/pipelines/dataif_pipelines/jobs/nilo_pipeline.py +112 -0
  37. package/templates/dataif/pipelines/dataif_pipelines/orchestration/__init__.py +21 -0
  38. package/templates/dataif/pipelines/dataif_pipelines/orchestration/pnp_workflow.py +783 -0
  39. package/templates/dataif/pipelines/dataif_pipelines/repositories/__init__.py +1 -0
  40. package/templates/dataif/pipelines/dataif_pipelines/repositories/pnp_raw_repository.py +860 -0
  41. package/templates/dataif/pipelines/dataif_pipelines/services/__init__.py +19 -0
  42. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_curated_service.py +66 -0
  43. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_download_service.py +534 -0
  44. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_quality_service.py +9 -0
  45. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_raw_ingestion_service.py +124 -0
  46. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_staging_service.py +271 -0
  47. package/templates/dataif/pipelines/dataif_pipelines/services/powerbi_catalog_service.py +159 -0
  48. package/templates/dataif/pipelines/sql/staging/020_pnp_matriculas.sql +112 -0
  49. package/templates/dataif/pipelines/sql/staging/030_pnp_eficiencia_academica.sql +83 -0
  50. package/templates/dataif/pipelines/sql/staging/040_pnp_servidores.sql +90 -0
  51. package/templates/dataif/pipelines/sql/staging/050_pnp_financeiro.sql +72 -0
  52. package/templates/dataif/pipelines/sql/views_curated/004_mv_pnp_dashboard_fast.sql +204 -0
  53. package/templates/dataif/pipelines/sql/views_curated/010_vw_pnp_admin_ingestao.sql +51 -0
  54. package/templates/dataif/pipelines/sql/views_curated/020_vw_pnp_qualidade_dados.sql +114 -0
  55. package/templates/dataif/pipelines/sql/views_curated/030_vw_pnp_matriculas.sql +67 -0
  56. package/templates/dataif/pipelines/sql/views_curated/040_vw_pnp_eficiencia.sql +33 -0
  57. package/templates/dataif/pipelines/sql/views_curated/050_vw_pnp_servidores.sql +30 -0
  58. package/templates/dataif/pipelines/sql/views_curated/060_vw_pnp_financeiro.sql +22 -0
  59. package/templates/dataif/pipelines/sql/views_curated/070_vw_pnp_vanna.sql +115 -0
  60. package/templates/dataif/scripts/configure-env.sh +149 -0
  61. package/templates/dataif/scripts/create_metabase_pnp_dashboard.py +943 -0
  62. package/templates/dataif/scripts/create_metabase_pnp_matriculas_dashboard.py +580 -0
  63. package/templates/dataif/scripts/deploy.sh +79 -0
  64. package/templates/dataif/scripts/fix_metabase_template_tag_ids.py +91 -0
  65. package/templates/dataif/scripts/pnp_powerbi_microdados_probe.py +14 -0
  66. package/templates/dataif/scripts/pnp_validate_raw_run.py +330 -0
  67. package/templates/dataif/scripts/publish-images.sh +31 -0
  68. package/templates/dataif/scripts/sync_metabase_dashboard_field_filters.py +241 -0
  69. package/templates/dataif/scripts/use-vanna-ollama.sh +139 -0
  70. package/templates/dataif/services/api/.dockerignore +18 -0
  71. package/templates/dataif/services/api/Dockerfile +12 -0
  72. package/templates/dataif/services/api/app/__init__.py +1 -0
  73. package/templates/dataif/services/api/app/auth.py +48 -0
  74. package/templates/dataif/services/api/app/config.py +59 -0
  75. package/templates/dataif/services/api/app/keycloak_admin.py +215 -0
  76. package/templates/dataif/services/api/app/main.py +2432 -0
  77. package/templates/dataif/services/api/app/metabase_admin.py +191 -0
  78. package/templates/dataif/services/api/app/metabase_bootstrap.py +44 -0
  79. package/templates/dataif/services/api/app/metabase_embed.py +15 -0
  80. package/templates/dataif/services/api/app/pnp_dag_provisioner.py +113 -0
  81. package/templates/dataif/services/api/app/pnp_instance_repository.py +951 -0
  82. package/templates/dataif/services/api/app/pnp_powerbi.py +438 -0
  83. package/templates/dataif/services/api/app/vanna_client.py +32 -0
  84. package/templates/dataif/services/api/requirements.txt +9 -0
  85. package/templates/dataif/services/vanna/.dockerignore +18 -0
  86. package/templates/dataif/services/vanna/Dockerfile +12 -0
  87. package/templates/dataif/services/vanna/app/config.py +57 -0
  88. package/templates/dataif/services/vanna/app/main.py +108 -0
  89. package/templates/dataif/services/vanna/app/runtime_config.py +114 -0
  90. package/templates/dataif/services/vanna/app/sql_guard.py +123 -0
  91. package/templates/dataif/services/vanna/app/vanna_engine.py +382 -0
  92. package/templates/dataif/services/vanna/requirements.txt +8 -0
  93. package/templates/dataif/services/web/.dockerignore +13 -0
  94. package/templates/dataif/services/web/Dockerfile +16 -0
  95. package/templates/dataif/services/web/index.html +12 -0
  96. package/templates/dataif/services/web/nginx.conf +74 -0
  97. package/templates/dataif/services/web/package-lock.json +4397 -0
  98. package/templates/dataif/services/web/package.json +32 -0
  99. package/templates/dataif/services/web/postcss.config.mjs +5 -0
  100. package/templates/dataif/services/web/src/App.jsx +2817 -0
  101. package/templates/dataif/services/web/src/adminAuth.js +245 -0
  102. package/templates/dataif/services/web/src/assets/avatar_placeholder.png +0 -0
  103. package/templates/dataif/services/web/src/assets/github_logo_icon_229278.svg +1 -0
  104. package/templates/dataif/services/web/src/assets/if-logo.png +0 -0
  105. package/templates/dataif/services/web/src/assets/if.svg +0 -0
  106. package/templates/dataif/services/web/src/assets/pnp-horizontal.svg +1 -0
  107. package/templates/dataif/services/web/src/components/AppHeader.jsx +233 -0
  108. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/mobile-header.tsx +56 -0
  109. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-account-card.tsx +209 -0
  110. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-item-button.tsx +67 -0
  111. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-item.tsx +108 -0
  112. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-list.tsx +83 -0
  113. package/templates/dataif/services/web/src/components/application/app-navigation/config.ts +23 -0
  114. package/templates/dataif/services/web/src/components/application/app-navigation/header-navigation.tsx +240 -0
  115. package/templates/dataif/services/web/src/components/application/pagination/pagination-base.tsx +376 -0
  116. package/templates/dataif/services/web/src/components/application/pagination/pagination-dot.tsx +52 -0
  117. package/templates/dataif/services/web/src/components/application/pagination/pagination-line.tsx +48 -0
  118. package/templates/dataif/services/web/src/components/application/pagination/pagination.tsx +328 -0
  119. package/templates/dataif/services/web/src/components/application/tabs/tabs.tsx +223 -0
  120. package/templates/dataif/services/web/src/components/base/avatar/avatar-label-group.tsx +28 -0
  121. package/templates/dataif/services/web/src/components/base/avatar/avatar.tsx +129 -0
  122. package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-add-button.tsx +32 -0
  123. package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-company-icon.tsx +24 -0
  124. package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-online-indicator.tsx +29 -0
  125. package/templates/dataif/services/web/src/components/base/avatar/base-components/index.tsx +4 -0
  126. package/templates/dataif/services/web/src/components/base/avatar/base-components/verified-tick.tsx +32 -0
  127. package/templates/dataif/services/web/src/components/base/badges/badge-types.ts +264 -0
  128. package/templates/dataif/services/web/src/components/base/badges/badges.tsx +415 -0
  129. package/templates/dataif/services/web/src/components/base/button-group/button-group.tsx +104 -0
  130. package/templates/dataif/services/web/src/components/base/buttons/button.tsx +267 -0
  131. package/templates/dataif/services/web/src/components/base/input/hint-text.tsx +31 -0
  132. package/templates/dataif/services/web/src/components/base/input/input.tsx +269 -0
  133. package/templates/dataif/services/web/src/components/base/input/label.tsx +48 -0
  134. package/templates/dataif/services/web/src/components/base/radio-buttons/radio-buttons.tsx +127 -0
  135. package/templates/dataif/services/web/src/components/base/select/combobox.tsx +150 -0
  136. package/templates/dataif/services/web/src/components/base/select/multi-select.tsx +361 -0
  137. package/templates/dataif/services/web/src/components/base/select/popover.tsx +32 -0
  138. package/templates/dataif/services/web/src/components/base/select/select-item.tsx +95 -0
  139. package/templates/dataif/services/web/src/components/base/select/select-native.tsx +67 -0
  140. package/templates/dataif/services/web/src/components/base/select/select.tsx +144 -0
  141. package/templates/dataif/services/web/src/components/base/tags/base-components/tag-close-x.tsx +32 -0
  142. package/templates/dataif/services/web/src/components/base/tooltip/tooltip.tsx +107 -0
  143. package/templates/dataif/services/web/src/components/foundations/dot-icon.tsx +22 -0
  144. package/templates/dataif/services/web/src/components/foundations/logo/untitledui-logo-minimal.tsx +170 -0
  145. package/templates/dataif/services/web/src/components/foundations/logo/untitledui-logo.tsx +58 -0
  146. package/templates/dataif/services/web/src/hooks/use-breakpoint.ts +34 -0
  147. package/templates/dataif/services/web/src/hooks/use-resize-observer.ts +67 -0
  148. package/templates/dataif/services/web/src/main.jsx +14 -0
  149. package/templates/dataif/services/web/src/providers/theme-provider.jsx +62 -0
  150. package/templates/dataif/services/web/src/styles/globals.css +60 -0
  151. package/templates/dataif/services/web/src/styles/theme.css +1326 -0
  152. package/templates/dataif/services/web/src/styles/typography.css +430 -0
  153. package/templates/dataif/services/web/src/styles.css +1287 -0
  154. package/templates/dataif/services/web/src/utils/cx.ts +24 -0
  155. package/templates/dataif/services/web/src/utils/is-react-component.ts +33 -0
  156. package/templates/dataif/services/web/vite.config.js +14 -0
  157. package/templates/dataif/sql/ddl/001_schemas.sql +6 -0
  158. package/templates/dataif/sql/ddl/003_pnp_raw_staging_curated.sql +699 -0
  159. package/templates/dataif/sql/migrations/001_pnp_phase1_backfill.sql +3 -0
  160. package/templates/dataif/sql/migrations/002_pnp_phase2_admin_config_backfill.sql +184 -0
  161. package/templates/dataif/sql/migrations/003_pnp_phase3_raw_tabular_backfill.sql +3 -0
  162. package/templates/dataif/sql/migrations/004_pnp_phase3_raw_backfill_support_index.sql +3 -0
  163. package/templates/dataif/sql/migrations/005_pnp_phase7_staging_support_indexes.sql +2 -0
  164. package/templates/dataif/sql/migrations/006_pnp_phase7_staging_autovacuum_tuning.sql +2 -0
  165. package/templates/dataif/sql/migrations/007_pnp_phase7b_run_packages.sql +20 -0
  166. package/templates/dataif/sql/migrations/008_pnp_phase7a_pipeline_endpoints.sql +169 -0
  167. package/templates/dataif/sql/migrations/009_pnp_phase8_curated.sql +35 -0
  168. package/templates/dataif/sql/migrations/010_pnp_phase10_staging_incremental_upsert.sql +3 -0
  169. package/templates/dataif/sql/migrations/010_pnp_pipeline_uuid.sql +51 -0
  170. package/templates/dataif/sql/migrations/011_app_settings.sql +7 -0
  171. package/templates/dataif/sql/staging/020_pnp_matriculas.sql +112 -0
  172. package/templates/dataif/sql/staging/030_pnp_eficiencia_academica.sql +83 -0
  173. package/templates/dataif/sql/staging/040_pnp_servidores.sql +90 -0
  174. package/templates/dataif/sql/staging/050_pnp_financeiro.sql +72 -0
  175. package/templates/dataif/sql/views_curated/003_vw_pnp_microdados_admin.sql +160 -0
  176. package/templates/dataif/sql/views_curated/004_mv_pnp_dashboard_fast.sql +204 -0
  177. package/templates/dataif/sql/views_curated/010_vw_pnp_admin_ingestao.sql +51 -0
  178. package/templates/dataif/sql/views_curated/020_vw_pnp_qualidade_dados.sql +114 -0
  179. package/templates/dataif/sql/views_curated/030_vw_pnp_matriculas.sql +67 -0
  180. package/templates/dataif/sql/views_curated/040_vw_pnp_eficiencia.sql +33 -0
  181. package/templates/dataif/sql/views_curated/050_vw_pnp_servidores.sql +30 -0
  182. package/templates/dataif/sql/views_curated/060_vw_pnp_financeiro.sql +22 -0
  183. package/templates/dataif/sql/views_curated/070_vw_pnp_vanna.sql +115 -0
@@ -0,0 +1,233 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ import { Moon01, Sun } from "@untitledui/icons";
4
+
5
+ import avatarPlaceholder from "@/assets/avatar_placeholder.png";
6
+ import githubLogo from "@/assets/github_logo_icon_229278.svg";
7
+ import ifLogo from "@/assets/if-logo.png";
8
+ import { useTheme } from "@/providers/theme-provider";
9
+
10
+ function routeToHref(path) {
11
+ return `#${path}`;
12
+ }
13
+
14
+ function ThemeToggle() {
15
+ const { theme, setTheme } = useTheme();
16
+ const isDark = theme === "dark";
17
+ const Icon = isDark ? Sun : Moon01;
18
+
19
+ return (
20
+ <button
21
+ type="button"
22
+ className="icon-button"
23
+ onClick={() => setTheme(isDark ? "light" : "dark")}
24
+ title={isDark ? "Ativar modo claro" : "Ativar modo escuro"}
25
+ aria-label={isDark ? "Ativar modo claro" : "Ativar modo escuro"}
26
+ >
27
+ <Icon className="size-5" />
28
+ </button>
29
+ );
30
+ }
31
+
32
+ function HeaderLogo() {
33
+ return (
34
+ <a className="app-brand" href={routeToHref("/")}>
35
+ <span className="app-brand-mark">
36
+ <img src={ifLogo} alt="Instituto Federal" />
37
+ </span>
38
+ <span className="app-brand-label">DataIF</span>
39
+ </a>
40
+ );
41
+ }
42
+
43
+ function NavLink({ item, active }) {
44
+ return (
45
+ <a className={`nav-link${active ? " active" : ""}`} href={routeToHref(item.path)}>
46
+ {item.label}
47
+ </a>
48
+ );
49
+ }
50
+
51
+ function AccountMenu({ adminNavItems, auth, onAdminSettings, onLogout, onRequestLogin, onSelectRoute }) {
52
+ const accountLabel =
53
+ auth.claims?.preferred_username ||
54
+ auth.claims?.email ||
55
+ (auth.status === "authenticated" ? "Administrador" : "Convidado");
56
+ const [isOpen, setIsOpen] = useState(false);
57
+ const menuRef = useRef(null);
58
+
59
+ useEffect(() => {
60
+ function handlePointerDown(event) {
61
+ if (!menuRef.current?.contains(event.target)) {
62
+ setIsOpen(false);
63
+ }
64
+ }
65
+
66
+ function handleEscape(event) {
67
+ if (event.key === "Escape") {
68
+ setIsOpen(false);
69
+ }
70
+ }
71
+
72
+ window.addEventListener("mousedown", handlePointerDown);
73
+ window.addEventListener("keydown", handleEscape);
74
+
75
+ return () => {
76
+ window.removeEventListener("mousedown", handlePointerDown);
77
+ window.removeEventListener("keydown", handleEscape);
78
+ };
79
+ }, []);
80
+
81
+ return (
82
+ <div className="account-menu" ref={menuRef}>
83
+ <button
84
+ type="button"
85
+ className="icon-button avatar-button"
86
+ onClick={() => setIsOpen((current) => !current)}
87
+ aria-label="Abrir menu do usuario"
88
+ aria-expanded={isOpen}
89
+ >
90
+ <img src={avatarPlaceholder} alt="" />
91
+ </button>
92
+
93
+ {isOpen ? (
94
+ <div className="account-dropdown">
95
+ <div className="account-dropdown-head">
96
+ <strong>{accountLabel}</strong>
97
+ <span>{auth.status === "authenticated" ? "Sessão ativa" : "Acesso visitante"}</span>
98
+ </div>
99
+
100
+ {auth.status === "authenticated" ? (
101
+ <>
102
+ <div className="account-dropdown-group">
103
+ <button
104
+ type="button"
105
+ className="account-dropdown-item"
106
+ onClick={() => {
107
+ setIsOpen(false);
108
+ onSelectRoute("/configurações");
109
+ }}
110
+ >
111
+ Conta
112
+ </button>
113
+ {adminNavItems.map((item) => (
114
+ <button
115
+ key={item.path}
116
+ type="button"
117
+ className="account-dropdown-item"
118
+ onClick={() => {
119
+ setIsOpen(false);
120
+ onSelectRoute(item.path);
121
+ }}
122
+ >
123
+ {item.label}
124
+ </button>
125
+ ))}
126
+ <button
127
+ type="button"
128
+ className="account-dropdown-item"
129
+ onClick={() => {
130
+ setIsOpen(false);
131
+ onAdminSettings();
132
+ }}
133
+ >
134
+ Configurações Admin
135
+ </button>
136
+ </div>
137
+
138
+ <button
139
+ type="button"
140
+ className="account-dropdown-item account-dropdown-item-danger"
141
+ onClick={() => {
142
+ setIsOpen(false);
143
+ onLogout();
144
+ }}
145
+ >
146
+ Sair
147
+ </button>
148
+ </>
149
+ ) : (
150
+ <button
151
+ type="button"
152
+ className="account-dropdown-item"
153
+ onClick={() => {
154
+ setIsOpen(false);
155
+ onRequestLogin();
156
+ }}
157
+ disabled={auth.status === "loading"}
158
+ >
159
+ {auth.status === "loading" ? "Verificando..." : "Login"}
160
+ </button>
161
+ )}
162
+ </div>
163
+ ) : null}
164
+ </div>
165
+ );
166
+ }
167
+
168
+ function HeaderActions({ adminNavItems, auth, githubRepoUrl, onAdminSettings, onLogout, onRequestLogin, onSelectRoute }) {
169
+ return (
170
+ <>
171
+ <ThemeToggle />
172
+ <a
173
+ className="icon-button"
174
+ href={githubRepoUrl}
175
+ target="_blank"
176
+ rel="noreferrer"
177
+ aria-label="GitHub"
178
+ title="GitHub"
179
+ >
180
+ <img src={githubLogo} alt="" className="github-icon" />
181
+ </a>
182
+ <AccountMenu
183
+ adminNavItems={adminNavItems}
184
+ auth={auth}
185
+ onAdminSettings={onAdminSettings}
186
+ onLogout={onLogout}
187
+ onRequestLogin={onRequestLogin}
188
+ onSelectRoute={onSelectRoute}
189
+ />
190
+ </>
191
+ );
192
+ }
193
+
194
+ export default function AppHeader({
195
+ auth,
196
+ githubRepoUrl,
197
+ adminNavItems,
198
+ onAdminSettings,
199
+ onLogout,
200
+ onRequestLogin,
201
+ onSelectRoute,
202
+ publicNavItems,
203
+ route,
204
+ }) {
205
+ return (
206
+ <header className="app-header">
207
+ <div className="app-header-inner">
208
+ <div className="app-header-top">
209
+ <HeaderLogo />
210
+ <div className="app-header-actions">
211
+ <HeaderActions
212
+ adminNavItems={adminNavItems}
213
+ auth={auth}
214
+ githubRepoUrl={githubRepoUrl}
215
+ onAdminSettings={onAdminSettings}
216
+ onLogout={onLogout}
217
+ onRequestLogin={onRequestLogin}
218
+ onSelectRoute={onSelectRoute}
219
+ />
220
+ </div>
221
+ </div>
222
+
223
+ <div className="app-header-navs">
224
+ <nav className="nav-row" aria-label="Navegacao principal">
225
+ {publicNavItems.map((item) => (
226
+ <NavLink key={item.path} item={item} active={route === item.path} />
227
+ ))}
228
+ </nav>
229
+ </div>
230
+ </div>
231
+ </header>
232
+ );
233
+ }
@@ -0,0 +1,56 @@
1
+ import type { PropsWithChildren, ReactNode } from "react";
2
+ import { X as CloseIcon, Menu02 } from "@untitledui/icons";
3
+ import {
4
+ Button as AriaButton,
5
+ Dialog as AriaDialog,
6
+ DialogTrigger as AriaDialogTrigger,
7
+ Modal as AriaModal,
8
+ ModalOverlay as AriaModalOverlay,
9
+ } from "react-aria-components";
10
+ import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
11
+ import { cx } from "@/utils/cx";
12
+
13
+ export const MobileNavigationHeader = ({ children, logo }: PropsWithChildren<{ logo?: ReactNode }>) => {
14
+ return (
15
+ <AriaDialogTrigger>
16
+ <header className="flex h-16 items-center justify-between border-b border-secondary bg-primary py-3 pr-2 pl-4 lg:hidden">
17
+ {logo || <UntitledLogo />}
18
+
19
+ <AriaButton
20
+ aria-label="Expand navigation menu"
21
+ className="group flex items-center justify-center rounded-lg bg-primary p-2 text-fg-secondary outline-focus-ring hover:bg-primary_hover hover:text-fg-secondary_hover focus-visible:outline-2 focus-visible:outline-offset-2"
22
+ >
23
+ <Menu02 className="size-6 transition duration-200 ease-in-out group-aria-expanded:opacity-0" />
24
+ <CloseIcon className="absolute size-6 opacity-0 transition duration-200 ease-in-out group-aria-expanded:opacity-100" />
25
+ </AriaButton>
26
+ </header>
27
+
28
+ <AriaModalOverlay
29
+ isDismissable
30
+ className={({ isEntering, isExiting }) =>
31
+ cx(
32
+ "fixed inset-0 z-50 cursor-pointer bg-overlay/70 pr-16 backdrop-blur-md lg:hidden",
33
+ isEntering && "duration-300 ease-in-out animate-in fade-in",
34
+ isExiting && "duration-200 ease-in-out animate-out fade-out",
35
+ )
36
+ }
37
+ >
38
+ {({ state }) => (
39
+ <>
40
+ <AriaButton
41
+ aria-label="Close navigation menu"
42
+ onPress={() => state.close()}
43
+ className="fixed top-3 right-2 flex cursor-pointer items-center justify-center rounded-lg p-2 text-fg-white/70 outline-focus-ring hover:bg-white/10 hover:text-fg-white focus-visible:outline-2 focus-visible:outline-offset-2"
44
+ >
45
+ <CloseIcon className="size-6" />
46
+ </AriaButton>
47
+
48
+ <AriaModal className="w-full cursor-auto will-change-transform">
49
+ <AriaDialog className="h-dvh outline-hidden focus:outline-hidden">{children}</AriaDialog>
50
+ </AriaModal>
51
+ </>
52
+ )}
53
+ </AriaModalOverlay>
54
+ </AriaDialogTrigger>
55
+ );
56
+ };
@@ -0,0 +1,209 @@
1
+ import type { FC, HTMLAttributes } from "react";
2
+ import { useCallback, useEffect, useRef } from "react";
3
+ import type { Placement } from "@react-types/overlays";
4
+ import { BookOpen01, ChevronSelectorVertical, LogOut01, Plus, Settings01, User01 } from "@untitledui/icons";
5
+ import { useFocusManager } from "react-aria";
6
+ import type { DialogProps as AriaDialogProps } from "react-aria-components";
7
+ import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
8
+ import { AvatarLabelGroup } from "@/components/base/avatar/avatar-label-group";
9
+ import { Button } from "@/components/base/buttons/button";
10
+ import { RadioButtonBase } from "@/components/base/radio-buttons/radio-buttons";
11
+ import { useBreakpoint } from "@/hooks/use-breakpoint";
12
+ import { cx } from "@/utils/cx";
13
+
14
+ type NavAccountType = {
15
+ /** Unique identifier for the nav item. */
16
+ id: string;
17
+ /** Name of the account holder. */
18
+ name: string;
19
+ /** Email address of the account holder. */
20
+ email: string;
21
+ /** Avatar image URL. */
22
+ avatar: string;
23
+ /** Online status of the account holder. This is used to display the online status indicator. */
24
+ status: "online" | "offline";
25
+ };
26
+
27
+ const placeholderAccounts: NavAccountType[] = [
28
+ {
29
+ id: "olivia",
30
+ name: "Olivia Rhye",
31
+ email: "olivia@untitledui.com",
32
+ avatar: "https://www.untitledui.com/images/avatars/olivia-rhye?fm=webp&q=80",
33
+ status: "online",
34
+ },
35
+ {
36
+ id: "sienna",
37
+ name: "Sienna Hewitt",
38
+ email: "sienna@untitledui.com",
39
+ avatar: "https://www.untitledui.com/images/avatars/transparent/sienna-hewitt?bg=%23E0E0E0",
40
+ status: "online",
41
+ },
42
+ ];
43
+
44
+ export const NavAccountMenu = ({
45
+ className,
46
+ selectedAccountId = "olivia",
47
+ ...dialogProps
48
+ }: AriaDialogProps & { className?: string; accounts?: NavAccountType[]; selectedAccountId?: string }) => {
49
+ const focusManager = useFocusManager();
50
+ const dialogRef = useRef<HTMLDivElement>(null);
51
+
52
+ const onKeyDown = useCallback(
53
+ (e: KeyboardEvent) => {
54
+ switch (e.key) {
55
+ case "ArrowDown":
56
+ focusManager?.focusNext({ tabbable: true, wrap: true });
57
+ break;
58
+ case "ArrowUp":
59
+ focusManager?.focusPrevious({ tabbable: true, wrap: true });
60
+ break;
61
+ }
62
+ },
63
+ [focusManager],
64
+ );
65
+
66
+ useEffect(() => {
67
+ const element = dialogRef.current;
68
+ if (element) {
69
+ element.addEventListener("keydown", onKeyDown);
70
+ }
71
+
72
+ return () => {
73
+ if (element) {
74
+ element.removeEventListener("keydown", onKeyDown);
75
+ }
76
+ };
77
+ }, [onKeyDown]);
78
+
79
+ return (
80
+ <AriaDialog
81
+ {...dialogProps}
82
+ ref={dialogRef}
83
+ className={cx("w-66 rounded-xl bg-secondary_alt shadow-lg ring ring-secondary_alt outline-hidden", className)}
84
+ >
85
+ <div className="rounded-xl bg-primary ring-1 ring-secondary">
86
+ <div className="flex flex-col gap-0.5 py-1.5">
87
+ <NavAccountCardMenuItem label="View profile" icon={User01} shortcut="⌘K->P" />
88
+ <NavAccountCardMenuItem label="Account settings" icon={Settings01} shortcut="⌘S" />
89
+ <NavAccountCardMenuItem label="Documentation" icon={BookOpen01} />
90
+ </div>
91
+ <div className="flex flex-col gap-0.5 border-t border-secondary py-1.5">
92
+ <div className="px-3 pt-1.5 pb-1 text-xs font-semibold text-tertiary">Switch account</div>
93
+
94
+ <div className="flex flex-col gap-0.5 px-1.5">
95
+ {placeholderAccounts.map((account) => (
96
+ <button
97
+ key={account.id}
98
+ className={cx(
99
+ "relative w-full cursor-pointer rounded-md px-2 py-1.5 text-left outline-focus-ring hover:bg-primary_hover focus:z-10 focus-visible:outline-2 focus-visible:outline-offset-2",
100
+ account.id === selectedAccountId && "bg-primary_hover",
101
+ )}
102
+ >
103
+ <AvatarLabelGroup status="online" size="md" src={account.avatar} title={account.name} subtitle={account.email} />
104
+
105
+ <RadioButtonBase isSelected={account.id === selectedAccountId} className="absolute top-2 right-2" />
106
+ </button>
107
+ ))}
108
+ </div>
109
+ </div>
110
+ <div className="flex flex-col gap-2 px-2 pt-0.5 pb-2">
111
+ <Button iconLeading={Plus} color="secondary" size="sm">
112
+ Add account
113
+ </Button>
114
+ </div>
115
+ </div>
116
+
117
+ <div className="pt-1 pb-1.5">
118
+ <NavAccountCardMenuItem label="Sign out" icon={LogOut01} shortcut="⌥⇧Q" />
119
+ </div>
120
+ </AriaDialog>
121
+ );
122
+ };
123
+
124
+ const NavAccountCardMenuItem = ({
125
+ icon: Icon,
126
+ label,
127
+ shortcut,
128
+ ...buttonProps
129
+ }: {
130
+ icon?: FC<{ className?: string }>;
131
+ label: string;
132
+ shortcut?: string;
133
+ } & HTMLAttributes<HTMLButtonElement>) => {
134
+ return (
135
+ <button {...buttonProps} className={cx("group/item w-full cursor-pointer px-1.5 focus:outline-hidden", buttonProps.className)}>
136
+ <div
137
+ className={cx(
138
+ "flex w-full items-center justify-between gap-3 rounded-md p-2 group-hover/item:bg-primary_hover",
139
+ // Focus styles.
140
+ "outline-focus-ring group-focus-visible/item:outline-2 group-focus-visible/item:outline-offset-2",
141
+ )}
142
+ >
143
+ <div className="flex gap-2 text-sm font-semibold text-secondary group-hover/item:text-secondary_hover">
144
+ {Icon && <Icon className="size-5 text-fg-quaternary" />} {label}
145
+ </div>
146
+
147
+ {shortcut && (
148
+ <kbd className="flex rounded px-1 py-px font-body text-xs font-medium text-tertiary ring-1 ring-secondary ring-inset">{shortcut}</kbd>
149
+ )}
150
+ </div>
151
+ </button>
152
+ );
153
+ };
154
+
155
+ export const NavAccountCard = ({
156
+ popoverPlacement,
157
+ selectedAccountId = "olivia",
158
+ items = placeholderAccounts,
159
+ }: {
160
+ popoverPlacement?: Placement;
161
+ selectedAccountId?: string;
162
+ items?: NavAccountType[];
163
+ }) => {
164
+ const triggerRef = useRef<HTMLDivElement>(null);
165
+ const isDesktop = useBreakpoint("lg");
166
+
167
+ const selectedAccount = placeholderAccounts.find((account) => account.id === selectedAccountId);
168
+
169
+ if (!selectedAccount) {
170
+ console.warn(`Account with ID ${selectedAccountId} not found in <NavAccountCard />`);
171
+ return null;
172
+ }
173
+
174
+ return (
175
+ <div ref={triggerRef} className="relative flex items-center gap-3 rounded-xl p-3 ring-1 ring-secondary ring-inset">
176
+ <AvatarLabelGroup
177
+ size="md"
178
+ src={selectedAccount.avatar}
179
+ title={selectedAccount.name}
180
+ subtitle={selectedAccount.email}
181
+ status={selectedAccount.status}
182
+ />
183
+
184
+ <div className="absolute top-1.5 right-1.5">
185
+ <AriaDialogTrigger>
186
+ <AriaButton className="flex cursor-pointer items-center justify-center rounded-md p-1.5 text-fg-quaternary outline-focus-ring transition duration-100 ease-linear hover:bg-primary_hover hover:text-fg-quaternary_hover focus-visible:outline-2 focus-visible:outline-offset-2 pressed:bg-primary_hover pressed:text-fg-quaternary_hover">
187
+ <ChevronSelectorVertical className="size-4 shrink-0" />
188
+ </AriaButton>
189
+ <AriaPopover
190
+ placement={popoverPlacement ?? (isDesktop ? "right bottom" : "top right")}
191
+ triggerRef={triggerRef}
192
+ offset={8}
193
+ className={({ isEntering, isExiting }) =>
194
+ cx(
195
+ "origin-(--trigger-anchor-point) will-change-transform",
196
+ isEntering &&
197
+ "duration-150 ease-out animate-in fade-in placement-right:slide-in-from-left-0.5 placement-top:slide-in-from-bottom-0.5 placement-bottom:slide-in-from-top-0.5",
198
+ isExiting &&
199
+ "duration-100 ease-in animate-out fade-out placement-right:slide-out-to-left-0.5 placement-top:slide-out-to-bottom-0.5 placement-bottom:slide-out-to-top-0.5",
200
+ )
201
+ }
202
+ >
203
+ <NavAccountMenu selectedAccountId={selectedAccountId} accounts={items} />
204
+ </AriaPopover>
205
+ </AriaDialogTrigger>
206
+ </div>
207
+ </div>
208
+ );
209
+ };
@@ -0,0 +1,67 @@
1
+ import type { FC, MouseEventHandler } from "react";
2
+ import { Pressable } from "react-aria-components";
3
+ import { Tooltip } from "@/components/base/tooltip/tooltip";
4
+ import { cx } from "@/utils/cx";
5
+
6
+ const styles = {
7
+ md: {
8
+ root: "size-10",
9
+ icon: "size-5",
10
+ },
11
+ lg: {
12
+ root: "size-12",
13
+ icon: "size-6",
14
+ },
15
+ };
16
+
17
+ interface NavItemButtonProps {
18
+ /** Whether the collapsible nav item is open. */
19
+ open?: boolean;
20
+ /** URL to navigate to when the button is clicked. */
21
+ href?: string;
22
+ /** Label text for the button. */
23
+ label: string;
24
+ /** Icon component to display. */
25
+ icon: FC<{ className?: string }>;
26
+ /** Whether the button is currently active. */
27
+ current?: boolean;
28
+ /** Size of the button. */
29
+ size?: "md" | "lg";
30
+ /** Handler for click events. */
31
+ onClick?: MouseEventHandler;
32
+ /** Additional CSS classes to apply to the button. */
33
+ className?: string;
34
+ /** Placement of the tooltip. */
35
+ tooltipPlacement?: "top" | "right" | "bottom" | "left";
36
+ }
37
+
38
+ export const NavItemButton = ({
39
+ current: current,
40
+ label,
41
+ href,
42
+ icon: Icon,
43
+ size = "md",
44
+ className,
45
+ tooltipPlacement = "right",
46
+ onClick,
47
+ }: NavItemButtonProps) => {
48
+ return (
49
+ <Tooltip title={label} placement={tooltipPlacement}>
50
+ <Pressable>
51
+ <a
52
+ href={href}
53
+ aria-label={label}
54
+ onClick={onClick}
55
+ className={cx(
56
+ "relative flex w-full cursor-pointer items-center justify-center rounded-md bg-primary p-2 text-fg-quaternary outline-focus-ring transition duration-100 ease-linear select-none hover:bg-primary_hover hover:text-fg-quaternary_hover focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2",
57
+ current && "bg-active text-fg-quaternary_hover hover:bg-secondary_hover",
58
+ styles[size].root,
59
+ className,
60
+ )}
61
+ >
62
+ <Icon aria-hidden="true" className={cx("shrink-0 transition-inherit-all", styles[size].icon)} />
63
+ </a>
64
+ </Pressable>
65
+ </Tooltip>
66
+ );
67
+ };
@@ -0,0 +1,108 @@
1
+ import type { FC, HTMLAttributes, MouseEventHandler, ReactNode } from "react";
2
+ import { ChevronDown, Share04 } from "@untitledui/icons";
3
+ import { Link as AriaLink } from "react-aria-components";
4
+ import { Badge } from "@/components/base/badges/badges";
5
+ import { cx, sortCx } from "@/utils/cx";
6
+
7
+ const styles = sortCx({
8
+ root: "group relative flex w-full cursor-pointer items-center rounded-md bg-primary outline-focus-ring transition duration-100 ease-linear select-none hover:bg-primary_hover focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2",
9
+ rootSelected: "bg-active hover:bg-secondary_hover",
10
+ });
11
+
12
+ interface NavItemBaseProps {
13
+ /** Whether the nav item shows only an icon. */
14
+ iconOnly?: boolean;
15
+ /** Whether the collapsible nav item is open. */
16
+ open?: boolean;
17
+ /** URL to navigate to when the nav item is clicked. */
18
+ href?: string;
19
+ /** Type of the nav item. */
20
+ type: "link" | "collapsible" | "collapsible-child";
21
+ /** Icon component to display. */
22
+ icon?: FC<HTMLAttributes<HTMLOrSVGElement>>;
23
+ /** Badge to display. */
24
+ badge?: ReactNode;
25
+ /** Whether the nav item is currently active. */
26
+ current?: boolean;
27
+ /** Whether to truncate the label text. */
28
+ truncate?: boolean;
29
+ /** Handler for click events. */
30
+ onClick?: MouseEventHandler;
31
+ /** Content to display. */
32
+ children?: ReactNode;
33
+ }
34
+
35
+ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children, truncate = true, onClick }: NavItemBaseProps) => {
36
+ const iconElement = Icon && <Icon aria-hidden="true" className="mr-2 size-5 shrink-0 text-fg-quaternary transition-inherit-all" />;
37
+
38
+ const badgeElement =
39
+ badge && (typeof badge === "string" || typeof badge === "number") ? (
40
+ <Badge className="ml-3" color="gray" type="pill-color" size="sm">
41
+ {badge}
42
+ </Badge>
43
+ ) : (
44
+ badge
45
+ );
46
+
47
+ const labelElement = (
48
+ <span
49
+ className={cx(
50
+ "flex-1 text-md font-semibold text-secondary transition-inherit-all group-hover:text-secondary_hover",
51
+ truncate && "truncate",
52
+ current && "text-secondary_hover",
53
+ )}
54
+ >
55
+ {children}
56
+ </span>
57
+ );
58
+
59
+ const isExternal = href && href.startsWith("http");
60
+ const externalIcon = isExternal && <Share04 className="size-4 stroke-[2.5px] text-fg-quaternary" />;
61
+
62
+ if (type === "collapsible") {
63
+ return (
64
+ <summary className={cx("px-3 py-2", styles.root, current && styles.rootSelected)} onClick={onClick}>
65
+ {iconElement}
66
+
67
+ {labelElement}
68
+
69
+ {badgeElement}
70
+
71
+ <ChevronDown aria-hidden="true" className="ml-3 size-4 shrink-0 stroke-[2.5px] text-fg-quaternary in-open:-scale-y-100" />
72
+ </summary>
73
+ );
74
+ }
75
+
76
+ if (type === "collapsible-child") {
77
+ return (
78
+ <AriaLink
79
+ href={href!}
80
+ target={isExternal ? "_blank" : "_self"}
81
+ rel="noopener noreferrer"
82
+ className={cx("py-2 pr-3 pl-10", styles.root, current && styles.rootSelected)}
83
+ onClick={onClick}
84
+ aria-current={current ? "page" : undefined}
85
+ >
86
+ {labelElement}
87
+ {externalIcon}
88
+ {badgeElement}
89
+ </AriaLink>
90
+ );
91
+ }
92
+
93
+ return (
94
+ <AriaLink
95
+ href={href!}
96
+ target={isExternal ? "_blank" : "_self"}
97
+ rel="noopener noreferrer"
98
+ className={cx("px-3 py-2", styles.root, current && styles.rootSelected)}
99
+ onClick={onClick}
100
+ aria-current={current ? "page" : undefined}
101
+ >
102
+ {iconElement}
103
+ {labelElement}
104
+ {externalIcon}
105
+ {badgeElement}
106
+ </AriaLink>
107
+ );
108
+ };