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,180 @@
1
+ import { ref, computed } from "vue"
2
+ import { jsPDF } from "jspdf"
3
+ import * as pdfjs from "pdfjs-dist"
4
+
5
+ if (import.meta.client) {
6
+ const workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.mjs`
7
+ pdfjs.GlobalWorkerOptions.workerSrc = workerSrc
8
+ }
9
+
10
+ // Queue system for multiple files
11
+ interface CompressionTask {
12
+ file: File
13
+ resolve: (file: File) => void
14
+ reject: (error: Error) => void
15
+ }
16
+
17
+ export const usePdfCompressor = () => {
18
+ const loading = ref(false)
19
+ const progress = ref(0)
20
+ const queue = ref<CompressionTask[]>([])
21
+ const isProcessing = ref(false)
22
+ const skipRemaining = ref(false) // Skip compression for remaining files
23
+ let abortController: AbortController | null = null
24
+ let currentTask: CompressionTask | null = null
25
+
26
+ const abort = () => {
27
+ if (abortController) {
28
+ abortController.abort()
29
+ }
30
+ // Mark to skip compression for remaining files
31
+ skipRemaining.value = true
32
+
33
+ // Resolve all pending tasks with original files (skip compression)
34
+ while (queue.value.length > 0) {
35
+ const task = queue.value.shift()
36
+ if (task) {
37
+ task.resolve(task.file) // Return original file
38
+ }
39
+ }
40
+ // Reject current task only (it was being compressed)
41
+ if (currentTask) {
42
+ currentTask.reject(new Error("aborted"))
43
+ currentTask = null
44
+ }
45
+ loading.value = false
46
+ progress.value = 0
47
+ isProcessing.value = false
48
+ }
49
+
50
+ const resetSkip = () => {
51
+ skipRemaining.value = false
52
+ }
53
+
54
+ const processQueue = async () => {
55
+ if (isProcessing.value || queue.value.length === 0) return
56
+
57
+ isProcessing.value = true
58
+
59
+ while (queue.value.length > 0) {
60
+ const task = queue.value.shift()
61
+ if (!task) continue
62
+
63
+ currentTask = task
64
+ try {
65
+ // If skip was requested, return original file without compression
66
+ if (skipRemaining.value) {
67
+ task.resolve(task.file)
68
+ } else {
69
+ const compressedFile = await compressPdfInternal(task.file)
70
+ task.resolve(compressedFile)
71
+ }
72
+ } catch (error) {
73
+ task.reject(error as Error)
74
+ }
75
+ currentTask = null
76
+ }
77
+
78
+ isProcessing.value = false
79
+ // Reset skip flag after processing all files
80
+ skipRemaining.value = false
81
+ }
82
+
83
+ const compressPdfInternal = async (file: File): Promise<File> => {
84
+ if (!file.type.includes("pdf")) {
85
+ console.warn("File is not a PDF. Skipping compression.")
86
+ return file
87
+ }
88
+
89
+ loading.value = true
90
+ progress.value = 0
91
+ abortController = new AbortController()
92
+ const { signal } = abortController
93
+
94
+ try {
95
+ const pdfData = await file.arrayBuffer()
96
+ const pdfDoc = await pdfjs.getDocument({
97
+ data: pdfData,
98
+ // Needed so non-embedded/base-14 fonts get correct glyph widths instead of garbled spacing.
99
+ cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`,
100
+ cMapPacked: true,
101
+ standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/standard_fonts/`,
102
+ // Fall back to the system's Arabic/CJK fonts when they aren't embedded in the PDF.
103
+ useSystemFonts: true,
104
+ // Draw embedded glyph outlines directly instead of via @font-face+fillText, which breaks Arabic letter joining for some font subsets.
105
+ disableFontFace: true,
106
+ }).promise
107
+ const totalPages = pdfDoc.numPages
108
+ const doc = new jsPDF({
109
+ compress: true,
110
+ })
111
+
112
+ for (let i = 1; i <= totalPages; i++) {
113
+ if (signal.aborted) throw new Error("PDF compression aborted")
114
+
115
+ const page = await pdfDoc.getPage(i)
116
+ const originalViewport = page.getViewport({ scale: 1 })
117
+ // Using scale 2 (144 DPI) for better quality while maintaining reasonable size
118
+ const viewport = page.getViewport({ scale: 2 })
119
+
120
+ const canvas = document.createElement("canvas")
121
+ const context = canvas.getContext("2d")
122
+ canvas.width = viewport.width
123
+ canvas.height = viewport.height
124
+
125
+ if (!context) throw new Error("Could not get canvas context.")
126
+
127
+ await page.render({ canvasContext: context, viewport, canvas }).promise
128
+ const imgData = canvas.toDataURL("image/jpeg", 0.8) // .8 offers better quality
129
+
130
+ doc.addPage([originalViewport.width, originalViewport.height])
131
+ if (i === 1) doc.deletePage(1) // Remove the default blank page
132
+
133
+ doc.addImage(
134
+ imgData,
135
+ "JPEG",
136
+ 0,
137
+ 0,
138
+ originalViewport.width,
139
+ originalViewport.height,
140
+ undefined,
141
+ "FAST",
142
+ )
143
+ progress.value = i / totalPages
144
+ }
145
+
146
+ const compressedBlob = doc.output("blob")
147
+ const compressedFile = new File([compressedBlob], file.name, {
148
+ type: "application/pdf",
149
+ lastModified: Date.now(),
150
+ })
151
+
152
+ // Return the smaller of the two files
153
+ return compressedFile.size < file.size ? compressedFile : file
154
+ } catch (error) {
155
+ console.error("Failed to compress PDF:", error)
156
+ // Return original file on error
157
+ return file
158
+ } finally {
159
+ loading.value = false
160
+ progress.value = 0
161
+ abortController = null
162
+ }
163
+ }
164
+
165
+ const compressPdf = async (file: File): Promise<File> => {
166
+ return new Promise((resolve, reject) => {
167
+ queue.value.push({ file, resolve, reject })
168
+ processQueue()
169
+ })
170
+ }
171
+
172
+ return {
173
+ compressPdf,
174
+ loading,
175
+ progress,
176
+ abort,
177
+ resetSkip,
178
+ queueLength: computed(() => queue.value.length),
179
+ }
180
+ }
@@ -0,0 +1,72 @@
1
+ export type PlatformFeature = "pages" | "blocks" | "templates" | "billing";
2
+
3
+ const platformFeaturePaths: Record<PlatformFeature, string> = {
4
+ pages: "/admin/tables/pages",
5
+ // Blocks are managed inside the page builder on the platform (the builder
6
+ // writes/reads the `/blocks` API) — there is no dedicated blocks admin
7
+ // surface yet, so point there until one exists.
8
+ blocks: "/admin/tables/pages",
9
+ templates: "/admin/tables/templates",
10
+ billing: "/admin/billing",
11
+ };
12
+
13
+ /**
14
+ * Resolves where a platform-level workspace (pages, blocks, templates,
15
+ * billing) actually lives for the current database: the tenant's own
16
+ * `https://<slug>.inicontent.com` subdomain, or the custom domain attached to
17
+ * the database when one is pointed (`database.domains`). The platform
18
+ * database itself lives at the bare `https://inicontent.com` host.
19
+ *
20
+ * The current sessions are appended as query params (`?{db}_sid=...` and
21
+ * `?inicontent_sid=...`) because cookies are domain-scoped and cannot be
22
+ * copied cross-origin by JavaScript — the `sid-ingest` global middleware
23
+ * writes them back into cookies on the target.
24
+ */
25
+ export function usePlatformRedirect() {
26
+ const config = useRuntimeConfig();
27
+ const database = useState<Database>("database");
28
+
29
+ const databaseSlug = computed(() => {
30
+ const slug = database.value?.slug ?? config.public.database;
31
+ return slug && slug !== "inicontent" ? String(slug) : "inicontent";
32
+ });
33
+
34
+ const databaseSessionID = useScopedCookie<string>("sid", databaseSlug.value);
35
+ const platformSessionID = useScopedCookie<string>("sid", "inicontent");
36
+
37
+ const platformHost = computed(() => {
38
+ const customDomain = database.value?.domains
39
+ ?.map((domain) =>
40
+ String(domain)
41
+ .replace(/^https?:\/\//, "")
42
+ .replace(/\/+$/, ""),
43
+ )
44
+ .find(Boolean);
45
+ if (customDomain) return `https://${customDomain}`;
46
+ return databaseSlug.value === "inicontent"
47
+ ? "https://inicontent.com"
48
+ : `https://${databaseSlug.value}.inicontent.com`;
49
+ });
50
+
51
+ function platformUrl(feature: PlatformFeature) {
52
+ const url = new URL(platformFeaturePaths[feature], platformHost.value);
53
+ // On the platform database the database session IS the platform
54
+ // session, so the database key would duplicate the platform key.
55
+ if (databaseSlug.value !== "inicontent" && databaseSessionID.value) {
56
+ url.searchParams.set(
57
+ `${databaseSlug.value}_sid`,
58
+ databaseSessionID.value,
59
+ );
60
+ }
61
+ if (platformSessionID.value) {
62
+ url.searchParams.set("inicontent_sid", platformSessionID.value);
63
+ }
64
+ return url.toString();
65
+ }
66
+
67
+ return {
68
+ databaseSlug,
69
+ platformHost,
70
+ platformUrl,
71
+ };
72
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Composable for handling realtime data synchronization
3
+ * Establishes WebSocket connection and updates data reactively
4
+ */
5
+
6
+ import type { Ref } from "vue";
7
+
8
+ interface RealtimeMessage {
9
+ type: "subscribed" | "error" | "data_change";
10
+ action?: "create" | "update" | "delete";
11
+ data?: any;
12
+ database?: string;
13
+ table?: string;
14
+ timestamp?: string;
15
+ message?: string;
16
+ }
17
+
18
+ export function useRealtimeSync(
19
+ table: Ref<Table | undefined>,
20
+ data: Ref<apiResponse<Item[]> | undefined>,
21
+ database: Ref<Database | undefined>,
22
+ ) {
23
+ const websocket = ref<WebSocket | null>(null);
24
+ const isConnected = ref(false);
25
+ const isConnecting = ref(false);
26
+
27
+ const config = useRuntimeConfig();
28
+
29
+ /**
30
+ * Connect to realtime updates via WebSocket
31
+ */
32
+ const connect = () => {
33
+ if (!table.value || !database.value || !table.value.config?.realtime) {
34
+ return;
35
+ }
36
+
37
+ if (isConnecting.value || isConnected.value) {
38
+ return;
39
+ }
40
+
41
+ // Don't connect on server (import.meta.server is statically replaced at build time)
42
+ if (import.meta.server) {
43
+ return;
44
+ }
45
+
46
+ isConnecting.value = true;
47
+
48
+ try {
49
+ const apiBase = config.public.apiBase;
50
+ let wsUrl: string;
51
+ if (apiBase.startsWith("http")) {
52
+ const url = new URL(apiBase);
53
+ const wsProtocol = url.protocol === "https:" ? "wss:" : "ws:";
54
+ wsUrl = `${wsProtocol}//${url.host}/realtime`;
55
+ } else {
56
+ const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
57
+ wsUrl = `${wsProtocol}//${window.location.host}/realtime`;
58
+ }
59
+ websocket.value = new WebSocket(wsUrl);
60
+
61
+ websocket.value.onopen = () => {
62
+ isConnected.value = true;
63
+ isConnecting.value = false;
64
+
65
+ // Subscribe to table updates
66
+ const subscribeMessage = {
67
+ type: "subscribe",
68
+ database: database.value?.slug,
69
+ table: table.value?.slug,
70
+ userId: undefined, // Set from auth if available
71
+ sessionId: useScopedCookie<string>("sid", database.value?.slug).value,
72
+ };
73
+
74
+ websocket.value?.send(JSON.stringify(subscribeMessage));
75
+ console.log("Connected to realtime updates");
76
+ };
77
+
78
+ websocket.value.onmessage = (event) => {
79
+ handleRealtimeMessage(JSON.parse(event.data) as RealtimeMessage);
80
+ };
81
+
82
+ websocket.value.onerror = (error) => {
83
+ console.error("WebSocket error:", error);
84
+ isConnecting.value = false;
85
+ };
86
+
87
+ websocket.value.onclose = () => {
88
+ isConnected.value = false;
89
+ isConnecting.value = false;
90
+ console.log("Disconnected from realtime updates");
91
+
92
+ // Attempt to reconnect after 3 seconds
93
+ if (table.value?.config?.realtime) {
94
+ setTimeout(() => {
95
+ connect();
96
+ }, 3000);
97
+ }
98
+ };
99
+ } catch (error) {
100
+ console.error("Failed to establish WebSocket connection:", error);
101
+ isConnecting.value = false;
102
+ }
103
+ };
104
+
105
+ /**
106
+ * Handle incoming realtime messages
107
+ */
108
+ const handleRealtimeMessage = (message: RealtimeMessage) => {
109
+ if (message.type === "subscribed") {
110
+ console.log("Subscribed to realtime updates:", message.message);
111
+ } else if (message.type === "data_change") {
112
+ updateDataFromRealtimeChange(message);
113
+ } else if (message.type === "error") {
114
+ console.error("Realtime error:", message.message);
115
+ }
116
+ };
117
+
118
+ /**
119
+ * Update local data based on realtime changes
120
+ */
121
+ const updateDataFromRealtimeChange = (message: RealtimeMessage) => {
122
+ if (!data.value?.result || !message.action) {
123
+ return;
124
+ }
125
+
126
+ const dataArray = Array.isArray(data.value.result)
127
+ ? data.value.result
128
+ : [data.value.result];
129
+
130
+ switch (message.action) {
131
+ case "create": {
132
+ // Add new item to the beginning
133
+ if (Array.isArray(message.data)) {
134
+ dataArray.unshift(...message.data);
135
+ } else if (message.data) {
136
+ dataArray.unshift(message.data);
137
+ }
138
+ break;
139
+ }
140
+
141
+ case "update": {
142
+ // Update existing item
143
+ if (Array.isArray(message.data)) {
144
+ message.data.forEach((newItem) => {
145
+ const index = dataArray.findIndex((item) => item.id === newItem.id);
146
+ if (index !== -1) {
147
+ dataArray[index] = { ...dataArray[index], ...newItem };
148
+ }
149
+ });
150
+ } else if (message.data) {
151
+ const index = dataArray.findIndex(
152
+ (item) => item.id === message.data.id,
153
+ );
154
+ if (index !== -1) {
155
+ dataArray[index] = { ...dataArray[index], ...message.data };
156
+ }
157
+ }
158
+ break;
159
+ }
160
+
161
+ case "delete": {
162
+ // Remove deleted item
163
+ if (message.data?.id) {
164
+ const index = dataArray.findIndex(
165
+ (item) => item.id === message.data.id,
166
+ );
167
+ if (index !== -1) {
168
+ dataArray.splice(index, 1);
169
+ }
170
+ } else if (message.data?.deleted) {
171
+ // Handle bulk delete
172
+ const deletedIds = Array.isArray(message.data.deleted)
173
+ ? message.data.deleted
174
+ : [message.data.deleted];
175
+ deletedIds.forEach((id: any) => {
176
+ const index = dataArray.findIndex((item) => item.id === id);
177
+ if (index !== -1) {
178
+ dataArray.splice(index, 1);
179
+ }
180
+ });
181
+ }
182
+ break;
183
+ }
184
+ }
185
+
186
+ // Update reactive data
187
+ if (data.value) {
188
+ data.value.result = Array.isArray(data.value.result)
189
+ ? dataArray
190
+ : dataArray[0];
191
+ }
192
+ };
193
+
194
+ /**
195
+ * Disconnect from realtime updates
196
+ */
197
+ const disconnect = () => {
198
+ if (websocket.value) {
199
+ websocket.value.close();
200
+ websocket.value = null;
201
+ isConnected.value = false;
202
+ }
203
+ };
204
+
205
+ /**
206
+ * Watch table config changes and reconnect if realtime is toggled
207
+ */
208
+ watch(
209
+ () => table.value?.config?.realtime,
210
+ (isRealtimeEnabled) => {
211
+ if (isRealtimeEnabled) {
212
+ connect();
213
+ } else {
214
+ disconnect();
215
+ }
216
+ },
217
+ { immediate: true },
218
+ );
219
+
220
+ /**
221
+ * Cleanup on unmount
222
+ */
223
+ onBeforeUnmount(() => {
224
+ disconnect();
225
+ });
226
+
227
+ return {
228
+ isConnected,
229
+ isConnecting,
230
+ connect,
231
+ disconnect,
232
+ };
233
+ }
@@ -0,0 +1,180 @@
1
+ import { isObject } from "inibase/utils";
2
+ import Inison from "inison";
3
+ import type { Ref } from "vue";
4
+ import { effectScope } from "vue";
5
+
6
+ /**
7
+ * Client-side cache of referenced-table items fetched by id.
8
+ *
9
+ * Table references can arrive as full item objects (primary locale) or as raw
10
+ * ids (translated values, stored backend overlays). When only ids are present,
11
+ * grid columns and table fields need the referenced item's label fields. This
12
+ * composable collects every id referenced on the current render pass and
13
+ * fetches them all in ONE batched request per referenced table, then serves the
14
+ * results reactively from a module-level cache.
15
+ *
16
+ * The cache is keyed to the current UI language (requests carry a `locale`
17
+ * param), so when the language changes the cache is cleared and every id
18
+ * referenced so far is re-fetched in a single batched request per table.
19
+ */
20
+ const itemsCache = reactive(new Map<string, Record<string, Item>>());
21
+ const pending = new Map<string, Set<string>>();
22
+ // All ids ever registered this session, used to re-fetch after a language
23
+ // switch without relying on components re-mounting.
24
+ const registeredByTable = new Map<string, Set<string>>();
25
+ // Bumped on every language switch. Captured at request start so a response
26
+ // that resolved in the *previous* language is discarded instead of overwriting
27
+ // the fresh cache with stale labels.
28
+ let cacheVersion = 0;
29
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
30
+
31
+ // Captured synchronously at the first register() call (component setup) so the
32
+ // deferred flush can use composables without an active setup context.
33
+ let runtime:
34
+ | {
35
+ config: ReturnType<typeof useRuntimeConfig>;
36
+ database: Ref<Database | undefined>;
37
+ sessionID: Ref<string | null>;
38
+ language: Ref<string | null>;
39
+ }
40
+ | undefined;
41
+
42
+ function ensureRuntime() {
43
+ if (runtime) return runtime;
44
+ const database = useState<Database>("database");
45
+ runtime = {
46
+ config: useRuntimeConfig(),
47
+ database,
48
+ sessionID: useScopedCookie<string>("sid", database.value?.slug),
49
+ language: useScopedCookie<LanguagesType>("language", database.value?.slug),
50
+ };
51
+ return runtime;
52
+ }
53
+
54
+ // Installed once, detached from any component scope so it survives unmounts.
55
+ let languageListenerInstalled = false;
56
+
57
+ function installLanguageListener() {
58
+ if (languageListenerInstalled || import.meta.server) return;
59
+ languageListenerInstalled = true;
60
+ effectScope(true).run(() => {
61
+ const Language = ensureRuntime().language;
62
+ watch(Language, () => clearCacheOnLanguageChange());
63
+ });
64
+ }
65
+
66
+ function clearCacheOnLanguageChange() {
67
+ cacheVersion++;
68
+ itemsCache.clear();
69
+ pending.clear();
70
+ // Re-queue every id referenced so far and fetch them under the new locale.
71
+ for (const [tableSlug, ids] of registeredByTable) {
72
+ if (!ids.size) continue;
73
+ pending.set(tableSlug, new Set(ids));
74
+ }
75
+ scheduleFlush();
76
+ }
77
+
78
+ function scheduleFlush() {
79
+ if (flushTimer !== null) return;
80
+ flushTimer = setTimeout(() => {
81
+ flushTimer = null;
82
+ void flushPending();
83
+ }, 0);
84
+ }
85
+
86
+ async function flushPending() {
87
+ const batches = [...pending.entries()];
88
+ pending.clear();
89
+ for (const [tableSlug, ids] of batches) {
90
+ const unique = [...new Set([...ids].filter(Boolean))];
91
+ if (!unique.length) continue;
92
+ try {
93
+ await fetchByIds(tableSlug, unique);
94
+ } catch {
95
+ // leave the ids uncached; a later register() pass can retry
96
+ }
97
+ }
98
+ }
99
+
100
+ async function fetchByIds(tableSlug: string, ids: string[]) {
101
+ const { config, database, sessionID, language } = ensureRuntime();
102
+ if (!database.value?.slug) return;
103
+ const table = database.value.tables?.find(({ slug }) => slug === tableSlug);
104
+ const version = cacheVersion;
105
+ const requestedLocale = language.value;
106
+ const request = await $fetch<apiResponse<Item[]>>(
107
+ `${config.public.apiBase}${database.value.slug}/${tableSlug}`,
108
+ {
109
+ params: {
110
+ where: Inison.stringify({ id: `[]${ids.join(",")}` }),
111
+ // `perPage: -1` is required: without it inibase caps results at the
112
+ // default of 15 rows, silently dropping every id past the first 15.
113
+ options: Inison.stringify({
114
+ columns: table?.columns,
115
+ perPage: -1,
116
+ }),
117
+ locale: requestedLocale ?? undefined,
118
+ [`${database.value.slug}_sid`]: sessionID.value,
119
+ },
120
+ cache: "no-cache",
121
+ credentials: "include",
122
+ },
123
+ );
124
+ // Discard responses that resolved after the language changed — their labels
125
+ // belong to a previous locale and must not overwrite the fresh cache.
126
+ if (version !== cacheVersion) return;
127
+ const result = request?.result;
128
+ if (!Array.isArray(result) || !result.length) return;
129
+ const freshMap: Record<string, Item> = {
130
+ ...(itemsCache.get(tableSlug) ?? {}),
131
+ };
132
+ for (const item of result) {
133
+ if (!item || item.id === undefined || item.id === null) continue;
134
+ freshMap[String(item.id)] = item;
135
+ }
136
+ itemsCache.set(tableSlug, freshMap);
137
+ }
138
+
139
+ export default function useReferencedItems(tableSlug: string | undefined) {
140
+ const items = computed<Record<string, Item>>(() =>
141
+ tableSlug ? (itemsCache.get(tableSlug) ?? {}) : {},
142
+ );
143
+
144
+ /**
145
+ * Queue any raw id(s) in a value into the page's single batched fetch.
146
+ * Already-resolved object values are ignored and never trigger a request.
147
+ */
148
+ function register(value: unknown) {
149
+ if (import.meta.server || !tableSlug) return;
150
+ installLanguageListener();
151
+
152
+ const entries = ([] as unknown[]).concat(value);
153
+ const missing = new Set<string>();
154
+ let knownIds = registeredByTable.get(tableSlug);
155
+ if (!knownIds) {
156
+ knownIds = new Set();
157
+ registeredByTable.set(tableSlug, knownIds);
158
+ }
159
+ for (const entry of entries) {
160
+ if (entry === undefined || entry === null) continue;
161
+ if (isObject(entry)) continue; // already-resolved object: no fetch
162
+ const id = String(entry).trim();
163
+ if (!id) continue;
164
+ knownIds.add(id);
165
+ if (itemsCache.get(tableSlug)?.[id]) continue;
166
+ missing.add(id);
167
+ }
168
+ if (!missing.size) return;
169
+
170
+ let set = pending.get(tableSlug);
171
+ if (!set) {
172
+ set = new Set();
173
+ pending.set(tableSlug, set);
174
+ }
175
+ for (const id of missing) set.add(id);
176
+ scheduleFlush();
177
+ }
178
+
179
+ return { items, register };
180
+ }