inicontent 1.0.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 (205) hide show
  1. package/.github/pull_request.yml +18 -0
  2. package/CONTRIBUTING.md +67 -0
  3. package/LICENSE +21 -0
  4. package/README.md +142 -0
  5. package/app/app.vue +106 -0
  6. package/app/assets/main.css +974 -0
  7. package/app/components/Api/EndpointCard.vue +169 -0
  8. package/app/components/Api/SchemaDocsTable.vue +208 -0
  9. package/app/components/Asset/Card.vue +1363 -0
  10. package/app/components/Asset/Grid.vue +295 -0
  11. package/app/components/Asset/Icon.vue +38 -0
  12. package/app/components/Asset/Preview.vue +486 -0
  13. package/app/components/Asset/Scanner.vue +700 -0
  14. package/app/components/Asset/Thumb.vue +708 -0
  15. package/app/components/Auth/Reset.vue +99 -0
  16. package/app/components/Auth/index.vue +346 -0
  17. package/app/components/ChatInterface.vue +2326 -0
  18. package/app/components/ChatPagePreview.vue +34 -0
  19. package/app/components/Column/Array.vue +29 -0
  20. package/app/components/Column/Asset.vue +21 -0
  21. package/app/components/Column/Boolean.vue +12 -0
  22. package/app/components/Column/Color.vue +9 -0
  23. package/app/components/Column/Date.vue +57 -0
  24. package/app/components/Column/Edit.vue +215 -0
  25. package/app/components/Column/Email.vue +9 -0
  26. package/app/components/Column/Html.vue +27 -0
  27. package/app/components/Column/Id.vue +20 -0
  28. package/app/components/Column/Locale.vue +32 -0
  29. package/app/components/Column/Object.vue +18 -0
  30. package/app/components/Column/Password.vue +7 -0
  31. package/app/components/Column/Role.vue +15 -0
  32. package/app/components/Column/S.vue +14 -0
  33. package/app/components/Column/Table/Single.vue +49 -0
  34. package/app/components/Column/Table/index.vue +42 -0
  35. package/app/components/Column/Tags.vue +31 -0
  36. package/app/components/Column/Text.vue +9 -0
  37. package/app/components/Column/Url.vue +9 -0
  38. package/app/components/Column/index.vue +41 -0
  39. package/app/components/Dashboard/Editor.vue +221 -0
  40. package/app/components/Dashboard/Grid.vue +270 -0
  41. package/app/components/Dashboard/View.vue +131 -0
  42. package/app/components/Dashboard/Widget/BarChart.vue +73 -0
  43. package/app/components/Dashboard/Widget/Counter.vue +51 -0
  44. package/app/components/Dashboard/Widget/LineChart.vue +76 -0
  45. package/app/components/Dashboard/Widget/PieChart.vue +75 -0
  46. package/app/components/Dashboard/Widget/Table.vue +94 -0
  47. package/app/components/Dashboard/WidgetEditor.vue +274 -0
  48. package/app/components/Data/Array.vue +102 -0
  49. package/app/components/Data/Html.vue +9 -0
  50. package/app/components/Data/Icon.vue +9 -0
  51. package/app/components/Data/Object.vue +10 -0
  52. package/app/components/Data/S.vue +16 -0
  53. package/app/components/Data/Table.vue +53 -0
  54. package/app/components/Data/Value.vue +34 -0
  55. package/app/components/Data/index.vue +14 -0
  56. package/app/components/Field/Array.vue +324 -0
  57. package/app/components/Field/Asset/Actions.vue +97 -0
  58. package/app/components/Field/Asset/index.vue +639 -0
  59. package/app/components/Field/Boolean.vue +26 -0
  60. package/app/components/Field/Checkbox.vue +48 -0
  61. package/app/components/Field/Color.vue +27 -0
  62. package/app/components/Field/Date.vue +30 -0
  63. package/app/components/Field/Email.vue +51 -0
  64. package/app/components/Field/EmailTemplate.vue +420 -0
  65. package/app/components/Field/Html/ColorPicker.vue +127 -0
  66. package/app/components/Field/Html/Editor.vue +544 -0
  67. package/app/components/Field/Html/index.vue +25 -0
  68. package/app/components/Field/Icon.vue +114 -0
  69. package/app/components/Field/JSON.vue +113 -0
  70. package/app/components/Field/Mention.vue +36 -0
  71. package/app/components/Field/Number.vue +27 -0
  72. package/app/components/Field/Object.vue +92 -0
  73. package/app/components/Field/Password.vue +55 -0
  74. package/app/components/Field/Radio.vue +47 -0
  75. package/app/components/Field/S.vue +44 -0
  76. package/app/components/Field/Select.vue +49 -0
  77. package/app/components/Field/Table.vue +348 -0
  78. package/app/components/Field/Tags.vue +113 -0
  79. package/app/components/Field/Text.vue +31 -0
  80. package/app/components/Field/Textarea.vue +33 -0
  81. package/app/components/Field/Url.vue +40 -0
  82. package/app/components/Field/Wrapper.vue +128 -0
  83. package/app/components/Field/index.vue +68 -0
  84. package/app/components/FloatingChatbot.vue +73 -0
  85. package/app/components/Form/Card.vue +151 -0
  86. package/app/components/Form/Drawer.vue +195 -0
  87. package/app/components/Form/index.vue +621 -0
  88. package/app/components/Header.vue +328 -0
  89. package/app/components/Offline/ConflictModal.vue +200 -0
  90. package/app/components/Offline/SyncStatus.vue +249 -0
  91. package/app/components/PlatformFeatureLanding.vue +65 -0
  92. package/app/components/Table/Backups.vue +638 -0
  93. package/app/components/Table/Flows.vue +814 -0
  94. package/app/components/Table/Grid.vue +296 -0
  95. package/app/components/Table/Icon.vue +36 -0
  96. package/app/components/Table/Logs.vue +181 -0
  97. package/app/components/Table/Schedules.vue +722 -0
  98. package/app/components/Table/Search/Button.vue +256 -0
  99. package/app/components/Table/Search/Items.vue +286 -0
  100. package/app/components/Table/Search/index.vue +87 -0
  101. package/app/components/Table/Settings/Schema.vue +801 -0
  102. package/app/components/Table/Settings/index.vue +631 -0
  103. package/app/components/Table/TranslateDrawer.vue +526 -0
  104. package/app/components/Table/Views/Kanban.vue +289 -0
  105. package/app/components/Table/Views/Table.vue +1273 -0
  106. package/app/components/Table/index.vue +890 -0
  107. package/app/composables/Translation/define.ts +41 -0
  108. package/app/composables/Translation/fetch.ts +107 -0
  109. package/app/composables/Translation/languages.ts +1 -0
  110. package/app/composables/Translation/loadCoreTranslations.ts +38 -0
  111. package/app/composables/Translation/translate.ts +250 -0
  112. package/app/composables/databaseCookies.ts +79 -0
  113. package/app/composables/fieldValidator.ts +72 -0
  114. package/app/composables/fieldsList.ts +301 -0
  115. package/app/composables/formatDatabase.ts +101 -0
  116. package/app/composables/index.ts +444 -0
  117. package/app/composables/openDrawer.ts +118 -0
  118. package/app/composables/optimizeFile.ts +287 -0
  119. package/app/composables/renderLabel.ts +88 -0
  120. package/app/composables/schemaClipboard.ts +95 -0
  121. package/app/composables/search.ts +353 -0
  122. package/app/composables/setThemeConfig.ts +48 -0
  123. package/app/composables/translationValue.ts +169 -0
  124. package/app/composables/useAssetPreview.ts +84 -0
  125. package/app/composables/useAssetUploader.ts +56 -0
  126. package/app/composables/useDashboardData.ts +310 -0
  127. package/app/composables/useDocumentThumbnail.ts +351 -0
  128. package/app/composables/useEcharts.ts +33 -0
  129. package/app/composables/useLocalDatabaseConfig.ts +51 -0
  130. package/app/composables/useOcr.ts +300 -0
  131. package/app/composables/useOfflineFetch.ts +194 -0
  132. package/app/composables/useOfflineItem.ts +107 -0
  133. package/app/composables/useOfflineQueue.ts +302 -0
  134. package/app/composables/useOfflineSync.ts +235 -0
  135. package/app/composables/usePasskeyAuth.ts +156 -0
  136. package/app/composables/usePdfCompressor.ts +180 -0
  137. package/app/composables/usePlatformRedirect.ts +72 -0
  138. package/app/composables/useRealtimeSync.ts +233 -0
  139. package/app/composables/useReferencedItems.ts +180 -0
  140. package/app/composables/useScanner.ts +664 -0
  141. package/app/composables/useSubscription.ts +650 -0
  142. package/app/composables/useThumbnailCache.ts +136 -0
  143. package/app/composables/useVideoCompressor.ts +194 -0
  144. package/app/error.vue +112 -0
  145. package/app/layouts/api.vue +188 -0
  146. package/app/layouts/dashboard.vue +91 -0
  147. package/app/layouts/default.vue +228 -0
  148. package/app/layouts/table.vue +471 -0
  149. package/app/locales/ar.ts +996 -0
  150. package/app/locales/en.ts +655 -0
  151. package/app/locales/es.ts +996 -0
  152. package/app/locales/fr.ts +997 -0
  153. package/app/middleware/dashboard.ts +33 -0
  154. package/app/middleware/database.ts +83 -0
  155. package/app/middleware/global.ts +1 -0
  156. package/app/middleware/sid-ingest.global.ts +29 -0
  157. package/app/middleware/table.ts +54 -0
  158. package/app/middleware/user.ts +26 -0
  159. package/app/pages/[[database]]/admin/api/auth/index.vue +306 -0
  160. package/app/pages/[[database]]/admin/api/index.vue +18 -0
  161. package/app/pages/[[database]]/admin/api/tables/[table]/index.vue +497 -0
  162. package/app/pages/[[database]]/admin/api/tables/index.vue +87 -0
  163. package/app/pages/[[database]]/admin/billing/index.vue +16 -0
  164. package/app/pages/[[database]]/admin/dashboards/[id]/edit.vue +66 -0
  165. package/app/pages/[[database]]/admin/dashboards/[id]/index.vue +117 -0
  166. package/app/pages/[[database]]/admin/dashboards/index.vue +20 -0
  167. package/app/pages/[[database]]/admin/index.vue +28 -0
  168. package/app/pages/[[database]]/admin/settings.vue +526 -0
  169. package/app/pages/[[database]]/admin/tables/[table]/[id]/edit.vue +143 -0
  170. package/app/pages/[[database]]/admin/tables/[table]/[id]/index.vue +146 -0
  171. package/app/pages/[[database]]/admin/tables/[table]/flows.vue +10 -0
  172. package/app/pages/[[database]]/admin/tables/[table]/index.vue +10 -0
  173. package/app/pages/[[database]]/admin/tables/[table]/new.vue +12 -0
  174. package/app/pages/[[database]]/admin/tables/[table]/schedules.vue +10 -0
  175. package/app/pages/[[database]]/admin/tables/[table]/settings.vue +30 -0
  176. package/app/pages/[[database]]/admin/tables/assets/[...path].vue +24 -0
  177. package/app/pages/[[database]]/admin/tables/assets/flows.vue +10 -0
  178. package/app/pages/[[database]]/admin/tables/backups/index.vue +10 -0
  179. package/app/pages/[[database]]/admin/tables/blocks/index.vue +16 -0
  180. package/app/pages/[[database]]/admin/tables/index.vue +20 -0
  181. package/app/pages/[[database]]/admin/tables/pages/index.vue +16 -0
  182. package/app/pages/[[database]]/admin/tables/templates/index.vue +16 -0
  183. package/app/pages/[[database]]/api/index.vue +120 -0
  184. package/app/pages/[[database]]/auth/index.vue +12 -0
  185. package/app/pages/[[database]]/auth/reset.vue +10 -0
  186. package/app/pages/[[database]]/index.vue +10 -0
  187. package/app/plugins/offline-warmup.client.ts +53 -0
  188. package/app/plugins/template-globals.ts +9 -0
  189. package/app/spa-loading-template.html +93 -0
  190. package/biome.json +53 -0
  191. package/index.d.ts +380 -0
  192. package/modules/naiveui.ts +41 -0
  193. package/nuxt.config.ts +269 -0
  194. package/package.json +56 -0
  195. package/pnpm-workspace.yaml +5 -0
  196. package/public/apple-touch-icon.png +0 -0
  197. package/public/favicon-16x16.png +0 -0
  198. package/public/favicon-32x32.png +0 -0
  199. package/public/favicon.ico +0 -0
  200. package/public/pwa-192x192.png +0 -0
  201. package/public/pwa-512x512.png +0 -0
  202. package/public/pwa-maskable-192x192.png +0 -0
  203. package/public/pwa-maskable-512x512.png +0 -0
  204. package/public/vendor/opencv.js +48 -0
  205. package/tsconfig.json +9 -0
@@ -0,0 +1,328 @@
1
+ <template>
2
+ <NPageHeader>
3
+ <template #avatar>
4
+ <NuxtLink v-if="String($route.matched[0]?.name).startsWith('database')"
5
+ :to="`/${$route.params.database ?? ''}`">
6
+ <NTag strong round :bordered="false" style="cursor:pointer;font-weight:600"
7
+ :style="ThemeConfig.revert && Theme === 'dark' ? 'color:#000;background-color:#fff' : ''">
8
+ <template #avatar>
9
+ <NAvatar fallbackSrc="/favicon.ico" :style='{
10
+ backgroundColor: "transparent"
11
+ }' :src='database?.icon?.publicURL ?? "/favicon.ico"' />
12
+ </template>
13
+ {{ t(database?.slug ?? 'inicontent') }}
14
+ </NTag>
15
+ </NuxtLink>
16
+ <NTag v-else strong round :bordered="false">
17
+ <template #avatar>
18
+ <NAvatar fallbackSrc="/favicon.ico" :src='database?.icon?.publicURL ?? "/favicon.ico"' />
19
+ </template>
20
+ {{ t(database?.slug ?? 'inicontent') }}
21
+ </NTag>
22
+ </template>
23
+ <template #title v-if='showBreadcrumb'>
24
+ <NBreadcrumb>
25
+ <NBreadcrumbItem v-for="(_, index) of breadcrumbArray">
26
+ <NuxtLink :to="breadCrumbItemLink(index)">
27
+ {{ breadCrumbItemLabel(index) }}
28
+ </NuxtLink>
29
+ </NBreadcrumbItem>
30
+ </NBreadcrumb>
31
+ </template>
32
+ <template #extra>
33
+ <NButtonGroup>
34
+ <LazyOfflineSyncStatus show-pwa />
35
+ <NPopover v-if="user?.role === config.public.idOne" :delay="600" scrollable style="max-height: 240px;">
36
+ <template #trigger>
37
+ <NButton secondary round size="small">{{ humanFileSize(totalSize) }}</NButton>
38
+ </template>
39
+ <div v-if="barSegments.length > 0"
40
+ style="height: 6px; border-radius: 3px; overflow: hidden; display: flex; gap: 2px; background: rgba(128,128,128,.2); margin-block-end: 10px;">
41
+ <div v-for="table in barSegments" :key="table.slug"
42
+ :style="{ width: `${((table.size ?? 0) / totalSize) * 100}%`, backgroundColor: tableColor(table) }" />
43
+ </div>
44
+ <NFlex v-for="(table, index) in sortedTables" :key="table.slug" justify="space-between" align="center">
45
+ <NFlex align="center" :size="6">
46
+ <span :style="{
47
+ width: '8px',
48
+ height: '8px',
49
+ borderRadius: '50%',
50
+ // Tables under the 5% bar threshold have no segment, so
51
+ // their dot renders grey — signaling 'too small to draw'.
52
+ backgroundColor: barSegments.some((t) => t.slug === table.slug)
53
+ ? tableColor(table)
54
+ : 'rgba(128,128,128,.5)',
55
+ flexShrink: 0,
56
+ }" />
57
+ <LazyTableIcon :table="table" />
58
+ <NText>{{ t(table.slug) }}</NText>
59
+ </NFlex>
60
+ <NText depth="3">{{ humanFileSize(table?.size) }}</NText>
61
+ </NFlex>
62
+ </NPopover>
63
+ <NDropdown v-if="languagesDropdownOptions?.length > 1" :value="Language" :options="languagesDropdownOptions"
64
+ @select="(v) => Language = v">
65
+ <NButton secondary round size="small">
66
+ <template #icon>
67
+ <NIcon>
68
+ <Icon name="tabler:language" />
69
+ </NIcon>
70
+ </template>
71
+ </NButton>
72
+ </NDropdown>
73
+ <NDropdown :options="userDropdownOptions" @select="onSelectUserDropdown">
74
+ <NButton secondary type="primary" round size="small">
75
+ <template #icon>
76
+ <NIcon>
77
+ <Icon name="tabler:user" />
78
+ </NIcon>
79
+ </template>
80
+ <template v-if="user">
81
+ <NText strong>{{ user.username.charAt(0).toUpperCase() +
82
+ user.username.slice(1) }}</NText>
83
+ </template>
84
+ </NButton>
85
+ </NDropdown>
86
+ </NButtonGroup>
87
+ </template>
88
+ </NPageHeader>
89
+ </template>
90
+
91
+ <script setup lang="ts">
92
+ import { isValidID } from "inibase/utils";
93
+ import { Icon, NIcon, NTag, NText, NFlex } from "#components";
94
+
95
+ const config = useRuntimeConfig();
96
+ const route = useRoute();
97
+ const user = useState<User | undefined>("user");
98
+ const database = useState<Database>("database");
99
+
100
+ const Language = useScopedCookie<LanguagesType>("language", database.value?.slug);
101
+ const Theme = useCookie<"light" | "dark">("theme", { sameSite: true });
102
+ const redirectTo = useScopedCookie<string | null>("redirectTo", database.value?.slug);
103
+
104
+ const ThemeConfig = useState<ThemeConfig>("ThemeConfig", () => ({
105
+ primaryColor: "#FF9800",
106
+ primaryColorHover: "#F7A42A",
107
+ primaryColorPressed: "#E19421",
108
+ primaryColorSuppl: "#CB7900",
109
+ }));
110
+
111
+ const showBreadcrumb = computed(
112
+ () =>
113
+ database.value?.slug &&
114
+ !["index", "auth", "database-auth"].includes(
115
+ String(route.matched[0]?.name),
116
+ ),
117
+ );
118
+
119
+ const sortedTables = computed(() =>
120
+ [...(database.value?.tables ?? [])].sort(
121
+ (a, b) => (b.size ?? 0) - (a.size ?? 0),
122
+ ),
123
+ );
124
+ const totalSize = computed(() =>
125
+ sortedTables.value.reduce((total, table) => total + (table.size ?? 0), 0),
126
+ );
127
+ // Only draw bar segments that represent a meaningful share of storage, so
128
+ // tiny tables don't render as sub-pixel slivers of clutter. The gap left at
129
+ // the end of the bar reads as "the remaining small tables".
130
+ const barSegments = computed(() =>
131
+ totalSize.value === 0
132
+ ? []
133
+ : sortedTables.value.filter(
134
+ (table) => ((table.size ?? 0) / totalSize.value) * 100 >= 5,
135
+ ),
136
+ );
137
+ // Spread hues evenly across however many tables exist, so colors never repeat
138
+ // (unlike a fixed palette that cycles once there are more tables than colors).
139
+ // Key color off the table's position in the FULL sorted list, so a row's dot
140
+ // and its bar segment always share the same hue — even after `barSegments`
141
+ // drops sub-5% tables (whose segment index would otherwise disagree with the
142
+ // row index). Hues spread evenly so they never repeat, regardless of count.
143
+ const tableColor = (table: Table) => {
144
+ const count = Math.max(sortedTables.value.length, 1);
145
+ const index = Math.max(
146
+ sortedTables.value.findIndex((t) => t.slug === table.slug),
147
+ 0,
148
+ );
149
+ return `hsl(${(360 / count) * index} 70% 52%)`;
150
+ };
151
+
152
+ function breadCrumbItemLink(index: number) {
153
+ const breadcrumbSegments = breadcrumbArray.value.slice(0, index + 1);
154
+ if (hideDatabaseInBreadcrumb.value && databaseParam.value)
155
+ return `/${databaseParam.value}/${breadcrumbSegments.join("/")}`;
156
+ return `/${breadcrumbSegments.join("/")}`;
157
+ }
158
+ const routeSegments = computed(() => route.path.split("/").filter(Boolean));
159
+ const databaseParam = computed(() => {
160
+ const param = route.params.database;
161
+ return typeof param === "string" ? param : undefined;
162
+ });
163
+ const hideDatabaseInBreadcrumb = computed(
164
+ () =>
165
+ !!databaseParam.value &&
166
+ routeSegments.value[0] === databaseParam.value &&
167
+ routeSegments.value[1] === "admin",
168
+ );
169
+ const breadcrumbArray = computed(() =>
170
+ hideDatabaseInBreadcrumb.value
171
+ ? routeSegments.value.slice(1)
172
+ : routeSegments.value,
173
+ );
174
+ const table = useState<Table>("table");
175
+ const currentItem = useState<Item>("currentItem");
176
+ const currentDashboard = useState<Dashboard | null>("currentDashboard");
177
+ const itemLabel = computed(() => renderLabel(table.value, currentItem.value));
178
+
179
+ function breadCrumbItemLabel(index: number) {
180
+ const childRoute = breadcrumbArray.value[index];
181
+ if (isValidID(childRoute)) {
182
+ if (currentDashboard.value?.name && route.path.includes("/dashboards/"))
183
+ return currentDashboard.value.name;
184
+ if (currentItem.value) return itemLabel.value;
185
+ }
186
+ if (childRoute === "admin") return t("adminPanel");
187
+ return t(childRoute);
188
+ }
189
+ const userDropdownOptions = computed(() => [
190
+ {
191
+ label: t("apiDocumentation"),
192
+ key: "api",
193
+ icon: () => h(NIcon, () => h(Icon, { name: "tabler:book" })),
194
+ show: !!user.value?.id,
195
+ },
196
+ {
197
+ label: t("settings"),
198
+ key: "settings",
199
+ icon: () => h(NIcon, () => h(Icon, { name: "tabler:settings" })),
200
+ show: user.value?.role === config.public.idOne,
201
+ },
202
+ {
203
+ label: () => h(NFlex,{ justify: "start", align: "center" }, () => [
204
+ t("billing"),
205
+ h(NIcon, { color: ThemeConfig.value.primaryColor}, () => h(Icon, { name: "tabler:external-link" }))
206
+ ]),
207
+ key: "billing",
208
+ icon: () => h(NIcon, () => h(Icon, { name: "tabler:credit-card" })),
209
+ show:
210
+ !!user.value?.id &&
211
+ (database.value?.slug === "inicontent" ||
212
+ user.value?.role === config.public.idOne),
213
+ disabled: route.path.includes("/admin/billing"),
214
+ },
215
+ {
216
+ label: t("toggleTheme"),
217
+ key: "theme",
218
+ icon: () =>
219
+ h(NIcon, () =>
220
+ h(Icon, {
221
+ name: Theme.value === "light" ? "tabler:moon" : "tabler:sun",
222
+ }),
223
+ ),
224
+ },
225
+ {
226
+ label: t("profile"),
227
+ key: "edit",
228
+ icon: () => h(NIcon, () => h(Icon, { name: "tabler:pencil" })),
229
+ show:
230
+ !!user.value?.id &&
231
+ database.value?.tables
232
+ ?.find(({ slug }) => slug === "users")
233
+ ?.allowedMethods?.includes("u"),
234
+ },
235
+ {
236
+ label: t("logout"),
237
+ key: "logout",
238
+ icon: () => h(NIcon, () => h(Icon, { name: "tabler:logout" })),
239
+ show: !!user.value?.id,
240
+ },
241
+ {
242
+ label: t("auth"),
243
+ key: "auth",
244
+ icon: () => h(NIcon, () => h(Icon, { name: "tabler:login" })),
245
+ show: !user.value?.id,
246
+ disabled: (route.name as string | undefined)?.endsWith("-auth"),
247
+ },
248
+ {
249
+ label: `${t("clearCache")} (${humanFileSize(cacheStats.value.size)})`,
250
+ key: "clearCache",
251
+ icon: () => h(NIcon, () => h(Icon, { name: "tabler:wash-machine" })),
252
+ show: !!user.value?.id && cacheStats.value.size > 0,
253
+ },
254
+ ]);
255
+
256
+ const sessionID = useScopedCookie<string>("sid", database.value?.slug);
257
+ const { platformUrl } = usePlatformRedirect();
258
+
259
+ async function onSelectUserDropdown(v: string) {
260
+ switch (v) {
261
+ case "api":
262
+ navigateTo(
263
+ `${route.params.database ? `/${route.params.database}` : ""}/admin/api`,
264
+ );
265
+ break;
266
+ case "edit":
267
+ navigateTo(
268
+ `${route.params.database ? `/${route.params.database}` : ""}/admin/tables/users/${
269
+ (user.value as User).id
270
+ }/edit`,
271
+ );
272
+ break;
273
+ case "billing":
274
+ navigateTo(platformUrl("billing"), { external: true });
275
+ break;
276
+ case "settings":
277
+ navigateTo(
278
+ `${route.params.database ? `/${route.params.database}` : ""}/admin/settings`,
279
+ );
280
+ break;
281
+ case "theme":
282
+ Theme.value = Theme.value === "dark" ? "light" : "dark";
283
+ break;
284
+ case "logout":
285
+ await $fetch(
286
+ `${config.public.apiBase}${
287
+ database.value?.slug ?? "inicontent"
288
+ }/auth/signout`,
289
+ {
290
+ credentials: "include",
291
+ params: {
292
+ locale: Language.value,
293
+ [`${database.value.slug}_sid`]: sessionID.value,
294
+ },
295
+ },
296
+ );
297
+ redirectTo.value = undefined;
298
+ user.value = undefined;
299
+ await navigateTo(
300
+ `${route.params.database ? `/${route.params.database}` : ""}/auth`,
301
+ );
302
+ break;
303
+ case "clearCache":
304
+ await clearAllCache();
305
+ cacheStats.value = await getCacheStats();
306
+ break;
307
+ }
308
+ }
309
+ const cacheStats = ref<{ total?: number; expired?: number; size: number }>({
310
+ size: 0,
311
+ });
312
+ onMounted(async () => {
313
+ cacheStats.value = await getCacheStats();
314
+ });
315
+
316
+ const languagesDropdownOptions = computed(() => {
317
+ const primary = database.value?.primaryLanguage;
318
+ const secondary = database.value?.secondaryLanguages ?? [];
319
+ const languages = [primary, ...secondary].filter(
320
+ (language): language is LanguagesType => !!language,
321
+ );
322
+
323
+ return [...new Set(languages)].map((language) => ({
324
+ label: t(`languages.${language}`),
325
+ key: language,
326
+ }));
327
+ });
328
+ </script>
@@ -0,0 +1,200 @@
1
+ <template>
2
+ <NModal
3
+ v-model:show="isOpen"
4
+ preset="card"
5
+ :title="t('resolveConflicts')"
6
+ :style="{ width: 'min(720px, calc(100vw - 32px))' }"
7
+ :mask-closable="false"
8
+ >
9
+ <template #header-extra>
10
+ <NButton
11
+ v-if="conflicts.length"
12
+ size="small"
13
+ type="error"
14
+ quaternary
15
+ @click="onDiscardAll"
16
+ >
17
+ {{ t('discardAll') }}
18
+ </NButton>
19
+ </template>
20
+
21
+ <!-- Explain why this popped up: an offline edit collided with the server -->
22
+ <NAlert
23
+ v-if="conflicts.length"
24
+ type="warning"
25
+ :show-icon="true"
26
+ style="margin-bottom: 16px"
27
+ >
28
+ {{ t('conflictModalIntro') }}
29
+ </NAlert>
30
+
31
+ <NSpin :show="isSyncing">
32
+ <NFlex v-if="conflicts.length === 0 && !isSyncing" vertical align="center" justify="center"
33
+ style="padding: 60px 0; gap: 12px">
34
+ <NIcon size="48" color="#888">
35
+ <Icon name="tabler:circle-check" />
36
+ </NIcon>
37
+ <NText depth="3">{{ t('noConflicts') }}</NText>
38
+ </NFlex>
39
+
40
+ <NFlex vertical :size="16">
41
+ <NCard
42
+ v-for="conflict in conflicts"
43
+ :key="conflict.id"
44
+ :title="`${conflict.method} · ${conflict.table || conflict.database}`"
45
+ size="small"
46
+ bordered
47
+ >
48
+ <template #header-extra>
49
+ <NTag size="small" type="warning" :bordered="false">
50
+ {{ conflict.method }}
51
+ </NTag>
52
+ </template>
53
+
54
+ <NDescriptions :column="1" size="small" label-placement="top">
55
+ <NDescriptionsItem :label="t('localVersion')">
56
+ <pre class="json">{{ pretty(conflict.conflictData?.local) }}</pre>
57
+ </NDescriptionsItem>
58
+ <NDescriptionsItem :label="t('serverVersion')">
59
+ <pre class="json">{{ pretty(conflict.conflictData?.server) }}</pre>
60
+ </NDescriptionsItem>
61
+ </NDescriptions>
62
+
63
+ <template #footer>
64
+ <NFlex justify="end" :size="8">
65
+ <NButton
66
+ size="small"
67
+ type="error"
68
+ @click="keepServer(conflict.id)"
69
+ >
70
+ <template #icon>
71
+ <NIcon><Icon name="tabler:server" /></NIcon>
72
+ </template>
73
+ {{ t('keepServer') }}
74
+ </NButton>
75
+ <NButton
76
+ size="small"
77
+ type="primary"
78
+ @click="keepLocal(conflict.id)"
79
+ >
80
+ <template #icon>
81
+ <NIcon><Icon name="tabler:device-mobile" /></NIcon>
82
+ </template>
83
+ {{ t('keepLocal') }}
84
+ </NButton>
85
+ </NFlex>
86
+ </template>
87
+ </NCard>
88
+ </NFlex>
89
+ </NSpin>
90
+ </NModal>
91
+ </template>
92
+
93
+ <script setup lang="ts">
94
+ import {
95
+ Icon,
96
+ NAlert,
97
+ NButton,
98
+ NCard,
99
+ NDescriptions,
100
+ NDescriptionsItem,
101
+ NFlex,
102
+ NIcon,
103
+ NModal,
104
+ NSpin,
105
+ NTag,
106
+ NText,
107
+ } from "#components";
108
+ import {
109
+ discardAllConflicts,
110
+ resolveConflictKeepLocal,
111
+ resolveConflictKeepServer,
112
+ useOfflineSync,
113
+ } from "~/composables/useOfflineSync";
114
+
115
+ // Shared bus: SyncStatus's "resolve conflicts" action opens the *same* modal.
116
+ // Keeping this a useState (not component-local) means the modal can live
117
+ // globally in the layout and still be reachable from anywhere.
118
+ const isOpen = useState<boolean>("offline_conflictModalOpen", () => false);
119
+
120
+ // Destructure at the top level so the template compiler auto-unwraps these
121
+ // refs. Accessing them as `sync.conflicts` (properties of the plain object
122
+ // returned by useOfflineSync) would NOT unwrap: `v-for="conflict in
123
+ // sync.conflicts"` would iterate the raw Ref and crash rendering.
124
+ const { conflicts, isSyncing, refreshCounts } = useOfflineSync();
125
+
126
+ /**
127
+ * Auto-open the modal whenever a NEW conflict batch is detected — i.e. a
128
+ * queued offline mutation collided with the server during a sync (typically
129
+ * right after the device reconnects). Re-armed when the conflict list empties
130
+ * or when the user manually closes the modal, so a later conflict batch pops
131
+ * it again instead of silently parking behind the header badge.
132
+ */
133
+ const armed = ref(true);
134
+
135
+ function arm() {
136
+ armed.value = true;
137
+ }
138
+
139
+ watch(
140
+ () => conflicts.value.length,
141
+ (count) => {
142
+ if (count === 0) {
143
+ arm();
144
+ return;
145
+ }
146
+ if (armed.value) {
147
+ isOpen.value = true;
148
+ armed.value = false;
149
+ }
150
+ },
151
+ );
152
+
153
+ watch(isOpen, (open) => {
154
+ if (!open) arm();
155
+ });
156
+
157
+ function pretty(value: any): string {
158
+ if (value === undefined || value === null) return "—";
159
+ if (typeof value === "string") {
160
+ try {
161
+ return JSON.stringify(JSON.parse(value), null, 2);
162
+ } catch {
163
+ return value;
164
+ }
165
+ }
166
+ return JSON.stringify(value, null, 2);
167
+ }
168
+
169
+ async function keepLocal(id: string) {
170
+ await resolveConflictKeepLocal(id);
171
+ await refreshCounts();
172
+ window.$message?.success(t("changeQueuedForSync"));
173
+ }
174
+
175
+ async function keepServer(id: string) {
176
+ await resolveConflictKeepServer(id);
177
+ await refreshCounts();
178
+ window.$message?.info(t("localChangeDiscarded"));
179
+ }
180
+
181
+ async function onDiscardAll() {
182
+ await discardAllConflicts();
183
+ await refreshCounts();
184
+ window.$message?.info(t("allLocalChangesDiscarded"));
185
+ }
186
+ </script>
187
+
188
+ <style scoped>
189
+ .json {
190
+ background: rgba(127, 127, 127, 0.08);
191
+ border-radius: 6px;
192
+ padding: 8px;
193
+ font-size: 12px;
194
+ white-space: pre-wrap;
195
+ word-break: break-word;
196
+ max-height: 240px;
197
+ overflow: auto;
198
+ margin: 0;
199
+ }
200
+ </style>