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.
- package/.github/pull_request.yml +18 -0
- package/CONTRIBUTING.md +67 -0
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/app/app.vue +106 -0
- package/app/assets/main.css +974 -0
- package/app/components/Api/EndpointCard.vue +169 -0
- package/app/components/Api/SchemaDocsTable.vue +208 -0
- package/app/components/Asset/Card.vue +1363 -0
- package/app/components/Asset/Grid.vue +295 -0
- package/app/components/Asset/Icon.vue +38 -0
- package/app/components/Asset/Preview.vue +486 -0
- package/app/components/Asset/Scanner.vue +700 -0
- package/app/components/Asset/Thumb.vue +708 -0
- package/app/components/Auth/Reset.vue +99 -0
- package/app/components/Auth/index.vue +346 -0
- package/app/components/ChatInterface.vue +2326 -0
- package/app/components/ChatPagePreview.vue +34 -0
- package/app/components/Column/Array.vue +29 -0
- package/app/components/Column/Asset.vue +21 -0
- package/app/components/Column/Boolean.vue +12 -0
- package/app/components/Column/Color.vue +9 -0
- package/app/components/Column/Date.vue +57 -0
- package/app/components/Column/Edit.vue +215 -0
- package/app/components/Column/Email.vue +9 -0
- package/app/components/Column/Html.vue +27 -0
- package/app/components/Column/Id.vue +20 -0
- package/app/components/Column/Locale.vue +32 -0
- package/app/components/Column/Object.vue +18 -0
- package/app/components/Column/Password.vue +7 -0
- package/app/components/Column/Role.vue +15 -0
- package/app/components/Column/S.vue +14 -0
- package/app/components/Column/Table/Single.vue +49 -0
- package/app/components/Column/Table/index.vue +42 -0
- package/app/components/Column/Tags.vue +31 -0
- package/app/components/Column/Text.vue +9 -0
- package/app/components/Column/Url.vue +9 -0
- package/app/components/Column/index.vue +41 -0
- package/app/components/Dashboard/Editor.vue +221 -0
- package/app/components/Dashboard/Grid.vue +270 -0
- package/app/components/Dashboard/View.vue +131 -0
- package/app/components/Dashboard/Widget/BarChart.vue +73 -0
- package/app/components/Dashboard/Widget/Counter.vue +51 -0
- package/app/components/Dashboard/Widget/LineChart.vue +76 -0
- package/app/components/Dashboard/Widget/PieChart.vue +75 -0
- package/app/components/Dashboard/Widget/Table.vue +94 -0
- package/app/components/Dashboard/WidgetEditor.vue +274 -0
- package/app/components/Data/Array.vue +102 -0
- package/app/components/Data/Html.vue +9 -0
- package/app/components/Data/Icon.vue +9 -0
- package/app/components/Data/Object.vue +10 -0
- package/app/components/Data/S.vue +16 -0
- package/app/components/Data/Table.vue +53 -0
- package/app/components/Data/Value.vue +34 -0
- package/app/components/Data/index.vue +14 -0
- package/app/components/Field/Array.vue +324 -0
- package/app/components/Field/Asset/Actions.vue +97 -0
- package/app/components/Field/Asset/index.vue +639 -0
- package/app/components/Field/Boolean.vue +26 -0
- package/app/components/Field/Checkbox.vue +48 -0
- package/app/components/Field/Color.vue +27 -0
- package/app/components/Field/Date.vue +30 -0
- package/app/components/Field/Email.vue +51 -0
- package/app/components/Field/EmailTemplate.vue +420 -0
- package/app/components/Field/Html/ColorPicker.vue +127 -0
- package/app/components/Field/Html/Editor.vue +544 -0
- package/app/components/Field/Html/index.vue +25 -0
- package/app/components/Field/Icon.vue +114 -0
- package/app/components/Field/JSON.vue +113 -0
- package/app/components/Field/Mention.vue +36 -0
- package/app/components/Field/Number.vue +27 -0
- package/app/components/Field/Object.vue +92 -0
- package/app/components/Field/Password.vue +55 -0
- package/app/components/Field/Radio.vue +47 -0
- package/app/components/Field/S.vue +44 -0
- package/app/components/Field/Select.vue +49 -0
- package/app/components/Field/Table.vue +348 -0
- package/app/components/Field/Tags.vue +113 -0
- package/app/components/Field/Text.vue +31 -0
- package/app/components/Field/Textarea.vue +33 -0
- package/app/components/Field/Url.vue +40 -0
- package/app/components/Field/Wrapper.vue +128 -0
- package/app/components/Field/index.vue +68 -0
- package/app/components/FloatingChatbot.vue +73 -0
- package/app/components/Form/Card.vue +151 -0
- package/app/components/Form/Drawer.vue +195 -0
- package/app/components/Form/index.vue +621 -0
- package/app/components/Header.vue +328 -0
- package/app/components/Offline/ConflictModal.vue +200 -0
- package/app/components/Offline/SyncStatus.vue +249 -0
- package/app/components/PlatformFeatureLanding.vue +65 -0
- package/app/components/Table/Backups.vue +638 -0
- package/app/components/Table/Flows.vue +814 -0
- package/app/components/Table/Grid.vue +296 -0
- package/app/components/Table/Icon.vue +36 -0
- package/app/components/Table/Logs.vue +181 -0
- package/app/components/Table/Schedules.vue +722 -0
- package/app/components/Table/Search/Button.vue +256 -0
- package/app/components/Table/Search/Items.vue +286 -0
- package/app/components/Table/Search/index.vue +87 -0
- package/app/components/Table/Settings/Schema.vue +801 -0
- package/app/components/Table/Settings/index.vue +631 -0
- package/app/components/Table/TranslateDrawer.vue +526 -0
- package/app/components/Table/Views/Kanban.vue +289 -0
- package/app/components/Table/Views/Table.vue +1273 -0
- package/app/components/Table/index.vue +890 -0
- package/app/composables/Translation/define.ts +41 -0
- package/app/composables/Translation/fetch.ts +107 -0
- package/app/composables/Translation/languages.ts +1 -0
- package/app/composables/Translation/loadCoreTranslations.ts +38 -0
- package/app/composables/Translation/translate.ts +250 -0
- package/app/composables/databaseCookies.ts +79 -0
- package/app/composables/fieldValidator.ts +72 -0
- package/app/composables/fieldsList.ts +301 -0
- package/app/composables/formatDatabase.ts +101 -0
- package/app/composables/index.ts +444 -0
- package/app/composables/openDrawer.ts +118 -0
- package/app/composables/optimizeFile.ts +287 -0
- package/app/composables/renderLabel.ts +88 -0
- package/app/composables/schemaClipboard.ts +95 -0
- package/app/composables/search.ts +353 -0
- package/app/composables/setThemeConfig.ts +48 -0
- package/app/composables/translationValue.ts +169 -0
- package/app/composables/useAssetPreview.ts +84 -0
- package/app/composables/useAssetUploader.ts +56 -0
- package/app/composables/useDashboardData.ts +310 -0
- package/app/composables/useDocumentThumbnail.ts +351 -0
- package/app/composables/useEcharts.ts +33 -0
- package/app/composables/useLocalDatabaseConfig.ts +51 -0
- package/app/composables/useOcr.ts +300 -0
- package/app/composables/useOfflineFetch.ts +194 -0
- package/app/composables/useOfflineItem.ts +107 -0
- package/app/composables/useOfflineQueue.ts +302 -0
- package/app/composables/useOfflineSync.ts +235 -0
- package/app/composables/usePasskeyAuth.ts +156 -0
- package/app/composables/usePdfCompressor.ts +180 -0
- package/app/composables/usePlatformRedirect.ts +72 -0
- package/app/composables/useRealtimeSync.ts +233 -0
- package/app/composables/useReferencedItems.ts +180 -0
- package/app/composables/useScanner.ts +664 -0
- package/app/composables/useSubscription.ts +650 -0
- package/app/composables/useThumbnailCache.ts +136 -0
- package/app/composables/useVideoCompressor.ts +194 -0
- package/app/error.vue +112 -0
- package/app/layouts/api.vue +188 -0
- package/app/layouts/dashboard.vue +91 -0
- package/app/layouts/default.vue +228 -0
- package/app/layouts/table.vue +471 -0
- package/app/locales/ar.ts +996 -0
- package/app/locales/en.ts +655 -0
- package/app/locales/es.ts +996 -0
- package/app/locales/fr.ts +997 -0
- package/app/middleware/dashboard.ts +33 -0
- package/app/middleware/database.ts +83 -0
- package/app/middleware/global.ts +1 -0
- package/app/middleware/sid-ingest.global.ts +29 -0
- package/app/middleware/table.ts +54 -0
- package/app/middleware/user.ts +26 -0
- package/app/pages/[[database]]/admin/api/auth/index.vue +306 -0
- package/app/pages/[[database]]/admin/api/index.vue +18 -0
- package/app/pages/[[database]]/admin/api/tables/[table]/index.vue +497 -0
- package/app/pages/[[database]]/admin/api/tables/index.vue +87 -0
- package/app/pages/[[database]]/admin/billing/index.vue +16 -0
- package/app/pages/[[database]]/admin/dashboards/[id]/edit.vue +66 -0
- package/app/pages/[[database]]/admin/dashboards/[id]/index.vue +117 -0
- package/app/pages/[[database]]/admin/dashboards/index.vue +20 -0
- package/app/pages/[[database]]/admin/index.vue +28 -0
- package/app/pages/[[database]]/admin/settings.vue +526 -0
- package/app/pages/[[database]]/admin/tables/[table]/[id]/edit.vue +143 -0
- package/app/pages/[[database]]/admin/tables/[table]/[id]/index.vue +146 -0
- package/app/pages/[[database]]/admin/tables/[table]/flows.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/index.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/new.vue +12 -0
- package/app/pages/[[database]]/admin/tables/[table]/schedules.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/settings.vue +30 -0
- package/app/pages/[[database]]/admin/tables/assets/[...path].vue +24 -0
- package/app/pages/[[database]]/admin/tables/assets/flows.vue +10 -0
- package/app/pages/[[database]]/admin/tables/backups/index.vue +10 -0
- package/app/pages/[[database]]/admin/tables/blocks/index.vue +16 -0
- package/app/pages/[[database]]/admin/tables/index.vue +20 -0
- package/app/pages/[[database]]/admin/tables/pages/index.vue +16 -0
- package/app/pages/[[database]]/admin/tables/templates/index.vue +16 -0
- package/app/pages/[[database]]/api/index.vue +120 -0
- package/app/pages/[[database]]/auth/index.vue +12 -0
- package/app/pages/[[database]]/auth/reset.vue +10 -0
- package/app/pages/[[database]]/index.vue +10 -0
- package/app/plugins/offline-warmup.client.ts +53 -0
- package/app/plugins/template-globals.ts +9 -0
- package/app/spa-loading-template.html +93 -0
- package/biome.json +53 -0
- package/index.d.ts +380 -0
- package/modules/naiveui.ts +41 -0
- package/nuxt.config.ts +269 -0
- package/package.json +56 -0
- package/pnpm-workspace.yaml +5 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/pwa-192x192.png +0 -0
- package/public/pwa-512x512.png +0 -0
- package/public/pwa-maskable-192x192.png +0 -0
- package/public/pwa-maskable-512x512.png +0 -0
- package/public/vendor/opencv.js +48 -0
- 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
|
+
}
|