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,136 @@
1
+ import type { IDBPDatabase } from 'idb'
2
+ import { openDB } from 'idb'
3
+
4
+ const DB_NAME = 'inicontent-thumbnails'
5
+ const STORE_NAME = 'thumbnails'
6
+ const DB_VERSION = 1
7
+
8
+ let db: IDBPDatabase | null = null
9
+
10
+ /**
11
+ * Initialize the thumbnail cache database
12
+ */
13
+ async function initDB(): Promise<IDBPDatabase> {
14
+ if (db) return db
15
+
16
+ try {
17
+ db = await openDB(DB_NAME, DB_VERSION, {
18
+ upgrade(db) {
19
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
20
+ const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' })
21
+ store.createIndex('timestamp', 'timestamp', { unique: false })
22
+ }
23
+ },
24
+ })
25
+ return db
26
+ } catch (error) {
27
+ console.warn('Failed to initialize thumbnail cache:', error)
28
+ throw error
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Get cached thumbnail from IndexedDB
34
+ * @param key - Unique key for the asset
35
+ * @returns Cached thumbnail data URL or null if not found
36
+ */
37
+ export async function getCachedThumbnail(key: string): Promise<string | null> {
38
+ try {
39
+ const database = await initDB()
40
+ const cached = await database.get(STORE_NAME, key)
41
+ return cached?.data || null
42
+ } catch (error) {
43
+ console.warn(`Failed to retrieve cached thumbnail for ${key}:`, error)
44
+ return null
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Cache a generated thumbnail in IndexedDB
50
+ * @param key - Unique key for the asset
51
+ * @param dataUrl - Generated thumbnail data URL
52
+ * @param expirationDays - Cache expiration in days (default: 30)
53
+ */
54
+ export async function cacheThumbnail(
55
+ key: string,
56
+ dataUrl: string,
57
+ expirationDays = 30,
58
+ ): Promise<void> {
59
+ try {
60
+ const database = await initDB()
61
+ const expirationTime = Date.now() + expirationDays * 24 * 60 * 60 * 1000
62
+
63
+ await database.put(STORE_NAME, {
64
+ key,
65
+ data: dataUrl,
66
+ timestamp: Date.now(),
67
+ expiration: expirationTime,
68
+ })
69
+ } catch (error) {
70
+ console.warn(`Failed to cache thumbnail for ${key}:`, error)
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Clear expired thumbnails from cache
76
+ */
77
+ export async function clearExpiredThumbnails(): Promise<void> {
78
+ try {
79
+ const database = await initDB()
80
+ const allThumbnails = await database.getAll(STORE_NAME)
81
+ const now = Date.now()
82
+
83
+ for (const thumbnail of allThumbnails) {
84
+ if (thumbnail.expiration && thumbnail.expiration < now) {
85
+ await database.delete(STORE_NAME, thumbnail.key)
86
+ }
87
+ }
88
+ } catch (error) {
89
+ console.warn('Failed to clear expired thumbnails:', error)
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Clear all the cache
95
+ */
96
+ export async function clearAllCache(): Promise<void> {
97
+ try {
98
+ const database = await initDB()
99
+ await database.clear(STORE_NAME)
100
+ } catch (error) {
101
+ console.warn('Failed to clear all thumbnails:', error)
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Get cache stats (useful for debugging)
107
+ */
108
+ export async function getCacheStats(): Promise<{
109
+ total: number
110
+ expired: number
111
+ size: number
112
+ }> {
113
+ try {
114
+ const database = await initDB()
115
+ const allThumbnails = await database.getAll(STORE_NAME)
116
+ const now = Date.now()
117
+ let totalSize = 0
118
+ let expiredCount = 0
119
+
120
+ for (const thumbnail of allThumbnails) {
121
+ if (thumbnail.expiration && thumbnail.expiration < now) {
122
+ expiredCount++
123
+ }
124
+ totalSize += thumbnail.data?.length || 0
125
+ }
126
+
127
+ return {
128
+ total: allThumbnails.length,
129
+ expired: expiredCount,
130
+ size: totalSize, // in bytes
131
+ }
132
+ } catch (error) {
133
+ console.warn('Failed to get cache stats:', error)
134
+ return { total: 0, expired: 0, size: 0 }
135
+ }
136
+ }
@@ -0,0 +1,194 @@
1
+ import { ref } from "vue"
2
+ import { FFmpeg } from "@ffmpeg/ffmpeg"
3
+ import { fetchFile, toBlobURL } from "@ffmpeg/util"
4
+
5
+ // Use a specific version for stability and the single-threaded core
6
+ const FFMPEG_VERSION = "0.12.6"
7
+ const CORE_URL = `https://unpkg.com/@ffmpeg/core-mt@${FFMPEG_VERSION}/dist/esm/ffmpeg-core.js`
8
+ const WASM_URL = `https://unpkg.com/@ffmpeg/core-mt@${FFMPEG_VERSION}/dist/esm/ffmpeg-core.wasm`
9
+ const WORKER_URL = `https://unpkg.com/@ffmpeg/core-mt@${FFMPEG_VERSION}/dist/esm/ffmpeg-core.worker.js`
10
+
11
+ // Queue system for multiple files
12
+ interface CompressionTask {
13
+ file: File
14
+ resolve: (file: File) => void
15
+ reject: (error: Error) => void
16
+ }
17
+
18
+ export function useVideoCompressor() {
19
+ const loading = ref(false)
20
+ const progress = ref(0)
21
+ const queue = ref<CompressionTask[]>([])
22
+ const isProcessing = ref(false)
23
+ const skipRemaining = ref(false) // Skip compression for remaining files
24
+ let ffmpeg: FFmpeg | null = null
25
+ let currentTask: CompressionTask | null = null
26
+
27
+ const abort = () => {
28
+ try {
29
+ if (ffmpeg) {
30
+ ffmpeg.terminate()
31
+ ffmpeg = null
32
+ }
33
+ // Mark to skip compression for remaining files
34
+ skipRemaining.value = true
35
+
36
+ // Resolve all pending tasks with original files (skip compression)
37
+ while (queue.value.length > 0) {
38
+ const task = queue.value.shift()
39
+ if (task) {
40
+ task.resolve(task.file) // Return original file
41
+ }
42
+ }
43
+ // Reject current task only (it was being compressed)
44
+ if (currentTask) {
45
+ currentTask.reject(new Error("terminated"))
46
+ currentTask = null
47
+ }
48
+ } catch (e) {
49
+ console.error("Error aborting FFmpeg:", e)
50
+ } finally {
51
+ loading.value = false
52
+ progress.value = 0
53
+ isProcessing.value = false
54
+ }
55
+ }
56
+
57
+ const resetSkip = () => {
58
+ skipRemaining.value = false
59
+ }
60
+
61
+ const processQueue = async () => {
62
+ if (isProcessing.value || queue.value.length === 0) return
63
+
64
+ isProcessing.value = true
65
+
66
+ while (queue.value.length > 0) {
67
+ const task = queue.value.shift()
68
+ if (!task) continue
69
+
70
+ currentTask = task
71
+ try {
72
+ // If skip was requested, return original file without compression
73
+ if (skipRemaining.value) {
74
+ task.resolve(task.file)
75
+ } else {
76
+ const compressedFile = await compressVideoInternal(task.file)
77
+ task.resolve(compressedFile)
78
+ }
79
+ } catch (error) {
80
+ task.reject(error as Error)
81
+ }
82
+ currentTask = null
83
+ }
84
+
85
+ isProcessing.value = false
86
+ // Reset skip flag after processing all files
87
+ skipRemaining.value = false
88
+ }
89
+
90
+ const compressVideoInternal = async (videoFile: File): Promise<File> => {
91
+ ffmpeg = new FFmpeg()
92
+ let tempOutputFileName: string | null = null
93
+ let inputFileName: string | null = null
94
+
95
+ // ffmpeg.on("log", ({ message }) => {
96
+ // console.log(message)
97
+ // })
98
+
99
+ ffmpeg.on("progress", ({ progress: p, time }) => {
100
+ progress.value = p
101
+ // console.log(`Progress: ${p * 100}%, time: ${time / 1000000}s`)
102
+ })
103
+
104
+ try {
105
+ loading.value = true
106
+ progress.value = 0
107
+
108
+ await ffmpeg.load({
109
+ coreURL: await toBlobURL(CORE_URL, "text/javascript"),
110
+ wasmURL: await toBlobURL(WASM_URL, "application/wasm"),
111
+ workerURL: await toBlobURL(WORKER_URL, "text/javascript"),
112
+ })
113
+
114
+ // Use unique filename to avoid conflicts
115
+ inputFileName = `input_${Date.now()}_${videoFile.name}`
116
+ tempOutputFileName = `temp_output_${Date.now()}.mp4`
117
+ const finalOutputName = `${videoFile.name.split(".").slice(0, -1).join(".")}.mp4`
118
+
119
+ await ffmpeg.writeFile(inputFileName, await fetchFile(videoFile))
120
+
121
+ // Execute FFmpeg command for compression
122
+ await ffmpeg.exec([
123
+ "-i",
124
+ inputFileName,
125
+ "-vcodec",
126
+ "libx264",
127
+ "-crf",
128
+ "28", // Adjust CRF for quality/size balance (lower is better quality)
129
+ "-preset",
130
+ "fast", // 'fast' provides a good balance of speed and compression
131
+ "-movflags",
132
+ "+faststart",
133
+ tempOutputFileName,
134
+ ])
135
+
136
+ const data = await ffmpeg.readFile(tempOutputFileName)
137
+
138
+ // Create a new File object from the compressed data with a NEW instance
139
+ // This ensures the original file is not referenced
140
+ const blob = new Blob([data as any], { type: "video/mp4" })
141
+ const compressedFile = new File(
142
+ [blob],
143
+ finalOutputName,
144
+ {
145
+ type: "video/mp4",
146
+ lastModified: Date.now()
147
+ }
148
+ )
149
+
150
+ return compressedFile
151
+ } catch (error) {
152
+ if (String(error).includes("terminate")) {
153
+ throw new Error("terminated")
154
+ }
155
+ console.error("Video compression failed:", error)
156
+ return videoFile // Return original file on other errors
157
+ } finally {
158
+ loading.value = false
159
+ progress.value = 0
160
+ if (ffmpeg) {
161
+ try {
162
+ if (inputFileName) await ffmpeg.deleteFile(inputFileName)
163
+ if (tempOutputFileName) await ffmpeg.deleteFile(tempOutputFileName)
164
+ }
165
+ catch (e) {
166
+ console.warn("Could not cleanup files, ffmpeg may have been terminated.", e)
167
+ }
168
+ // Terminate FFmpeg instance to free resources
169
+ try {
170
+ await ffmpeg.terminate()
171
+ ffmpeg = null
172
+ } catch (e) {
173
+ console.warn("Could not terminate ffmpeg:", e)
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ const compressVideo = async (videoFile: File): Promise<File> => {
180
+ return new Promise((resolve, reject) => {
181
+ queue.value.push({ file: videoFile, resolve, reject })
182
+ processQueue()
183
+ })
184
+ }
185
+
186
+ return {
187
+ compressVideo,
188
+ loading,
189
+ progress,
190
+ abort,
191
+ resetSkip,
192
+ queueLength: computed(() => queue.value.length)
193
+ }
194
+ }
package/app/error.vue ADDED
@@ -0,0 +1,112 @@
1
+ <template>
2
+ <!--
3
+ Offline / no-data bootstrap failures render OUTSIDE the layout chain.
4
+ When this error is registered during the very first navigation (e.g.
5
+ the database middleware fails before anything is cached), Nuxt's root
6
+ <Suspense> never settles while error.vue's lazy layout chain
7
+ (dashboard → default → LazyHeader…) resolves, so the app stays blank.
8
+ A synchronous panel here renders instantly instead.
9
+ -->
10
+ <div v-if="rendersOffline" class="offline-page">
11
+ <NFlex vertical justify="center" align="center" style="min-height: 60vh">
12
+ <NResult :status="code" :title="message" />
13
+ </NFlex>
14
+ </div>
15
+
16
+ <NuxtLayout v-else :name="layoutName">
17
+ <NFlex vertical justify="center" align="center" style="min-height: 60vh">
18
+ <NResult :status="code" :title="message" />
19
+ </NFlex>
20
+ </NuxtLayout>
21
+ </template>
22
+
23
+ <script setup lang="ts">
24
+ import type { NuxtError } from "#app";
25
+
26
+ type errorCodes =
27
+ | "info"
28
+ | "success"
29
+ | "warning"
30
+ | "error"
31
+ | "404"
32
+ | "403"
33
+ | "500"
34
+ | "418";
35
+ const { error } = defineProps({
36
+ error: Object as () => NuxtError & { message: "database" | "table" | "item" },
37
+ });
38
+ const code = ref<errorCodes>();
39
+ const message = ref();
40
+
41
+ const route = useRoute();
42
+
43
+ const isOffline = typeof navigator !== "undefined" && !navigator.onLine;
44
+
45
+ // Network failures while offline (mapped to 503/"offline" by the database
46
+ // middleware) get a friendly offline page — not a raw fetch stack trace.
47
+ const rendersOffline = computed(
48
+ () => !!error && (isOffline || error.statusMessage === "offline"),
49
+ );
50
+
51
+ if (error && !rendersOffline.value) {
52
+ code.value = String(error.statusCode) as errorCodes;
53
+ switch (error.statusCode) {
54
+ case 404:
55
+ code.value = "warning";
56
+ message.value = `${t(error.message)} ${t("notFound")}`;
57
+ break;
58
+ case 403:
59
+ code.value = "warning";
60
+ message.value = t("accessDenied");
61
+ break;
62
+ default:
63
+ code.value = "error";
64
+ message.value = t("internalServerError");
65
+ console.clear();
66
+ console.error(error);
67
+ break;
68
+ }
69
+ } else if (!error) {
70
+ code.value = "error";
71
+ message.value = t("internalServerError");
72
+ } else {
73
+ // offline branch
74
+ code.value = "warning";
75
+ message.value = t("offline");
76
+ // Match the app's other pages (function form — evaluated reactively and
77
+ // applied after the suspense-resolved mount, unlike a plain object).
78
+ useHead(() => ({
79
+ title: `${t("inicontent")} | ${t("offline")}`,
80
+ }));
81
+ }
82
+
83
+ // Pages that run inside the "table" layout (with the sidebar menu) are
84
+ // /admin/tables/:slug/... (incl. backups/assets), /admin/dashboards and
85
+ // /admin/settings. Errors thrown there — like a 404 for a non-existing
86
+ // table — should render inside that layout so the user keeps the sidebar
87
+ // navigation instead of being dropped to the bare dashboard shell.
88
+ const tableErrorMessages = ["table", "item"];
89
+ const layoutName = computed(() => {
90
+ if (error && tableErrorMessages.includes(error.statusMessage ?? ""))
91
+ return "table";
92
+ const segments = route.path.split("/").filter(Boolean);
93
+ const tablesIndex = segments.indexOf("tables");
94
+ if (tablesIndex !== -1 && segments[tablesIndex + 1]) return "table";
95
+ if (
96
+ route.path.includes("/admin/dashboards") ||
97
+ route.path.includes("/admin/settings")
98
+ )
99
+ return "table";
100
+ return "dashboard";
101
+ });
102
+ </script>
103
+
104
+ <style scoped>
105
+ .offline-page {
106
+ min-height: 100vh;
107
+ display: flex;
108
+ align-items: center;
109
+ justify-content: center;
110
+ padding: 24px;
111
+ }
112
+ </style>
@@ -0,0 +1,188 @@
1
+ <template>
2
+ <NuxtLayout name="dashboard">
3
+ <NLayout position="absolute" has-sider>
4
+ <NLayoutSider v-if="database?.slug" :collapsed="!isMenuOpen"
5
+ @update-collapsed="(collapsed) => (isMenuOpen = !collapsed)" style="z-index: 1000" bordered
6
+ show-trigger="bar" collapse-mode="width" :collapsed-width="$device.isMobile ? 0 : 64" width="240"
7
+ :native-scrollbar="false">
8
+ <NMenu :collapsed="!isMenuOpen" :collapsed-icon-size="22" :collapsed-width="$device.isMobile ? 0 : 64"
9
+ :options="menuOptions" :default-value="defaultValue" :watch-props="['defaultValue']" accordion />
10
+ </NLayoutSider>
11
+ <NLayoutContent id="pageContent" position="absolute" :content-style="{
12
+ padding: $device.isMobile
13
+ ? '24px 12px'
14
+ : Language === 'ar'
15
+ ? '0 88px 24px 24px'
16
+ : '0 24px 24px 88px',
17
+ }" :native-scrollbar="false">
18
+ <div v-if="isMenuOpen" @mouseover="() => (isMenuOpen = false)"
19
+ style="width: 100%; height: 100%; right: 0; top: 0; position: absolute; background-color: #0000006e; z-index: 99; cursor: pointer;">
20
+ </div>
21
+ <slot></slot>
22
+ </NLayoutContent>
23
+ </NLayout>
24
+ </NuxtLayout>
25
+ </template>
26
+
27
+ <script setup lang="ts">
28
+ import type { MenuOption } from "naive-ui"
29
+ import { Icon, LazyTableIcon, NuxtLink, NIcon } from "#components"
30
+
31
+ const database = useState<Database>("database");
32
+ const Language = useScopedCookie<LanguagesType>("language", database.value?.slug)
33
+
34
+ onBeforeUpdate(() => {
35
+ clearNuxtState("isMenuOpen")
36
+ })
37
+
38
+ const route = useRoute()
39
+ const isMenuOpen = useState("isMenuOpen", () => false)
40
+ const table = useState<Table>("table")
41
+
42
+ const defaultValue = computed(() => {
43
+ const rawMethodKey = route.query.method as string | undefined
44
+ const normalizedMethod = rawMethodKey
45
+ ? methodAliases[rawMethodKey.toLowerCase()] ?? rawMethodKey
46
+ : undefined
47
+ const methodKey = normalizedMethod ?? "overview"
48
+ if (table.value?.slug) return `${table.value.slug}-${methodKey}`
49
+ const lastPathInRoute = decodeURIComponent(
50
+ route.path.split("/").filter(Boolean).at(-1) ?? "",
51
+ )
52
+ return lastPathInRoute
53
+ })
54
+
55
+ const methodAliases: Record<string, string> = {
56
+ create: "post",
57
+ read: "get",
58
+ update: "put",
59
+ post: "post",
60
+ get: "get",
61
+ put: "put",
62
+ delete: "delete",
63
+ }
64
+
65
+ const methodEntries = [
66
+ {
67
+ key: "overview",
68
+ icon: "tabler:book",
69
+ label: () => t("overview"),
70
+ show: () => true,
71
+ to: (baseUrl: string) => baseUrl,
72
+ },
73
+ {
74
+ key: "post",
75
+ icon: "tabler:plus",
76
+ label: () => t("methods.post"),
77
+ show: (allowed?: string) => allowed?.includes("c"),
78
+ to: (baseUrl: string) => ({ path: baseUrl, query: { method: "post" } }),
79
+ },
80
+ {
81
+ key: "get",
82
+ icon: "tabler:eye",
83
+ label: () => t("methods.get"),
84
+ show: (allowed?: string) => allowed?.includes("r"),
85
+ to: (baseUrl: string) => ({ path: baseUrl, query: { method: "get" } }),
86
+ },
87
+ {
88
+ key: "put",
89
+ icon: "tabler:pencil",
90
+ label: () => t("methods.put"),
91
+ show: (allowed?: string) => allowed?.includes("u"),
92
+ to: (baseUrl: string) => ({ path: baseUrl, query: { method: "put" } }),
93
+ },
94
+ {
95
+ key: "delete",
96
+ icon: "tabler:trash",
97
+ label: () => t("methods.delete"),
98
+ show: (allowed?: string) => allowed?.includes("d"),
99
+ to: (baseUrl: string) => ({ path: baseUrl, query: { method: "delete" } }),
100
+ },
101
+ ]
102
+
103
+ function buildMethodChildren(currentTable: Table) {
104
+ const baseUrl = `${route.params.database ? `/${route.params.database}` : ""}/admin/api/tables/${currentTable.slug}`
105
+ return methodEntries
106
+ .filter((entry) => entry.show(currentTable.allowedMethods))
107
+ .map((entry) => ({
108
+ label: () =>
109
+ h(
110
+ NuxtLink,
111
+ {
112
+ to: entry.to(baseUrl),
113
+ },
114
+ { default: () => entry.label() },
115
+ ),
116
+ key: `${currentTable.slug}-${entry.key}`,
117
+ icon: () => h(NIcon, () => h(Icon, { name: entry.icon })),
118
+ }))
119
+ }
120
+
121
+ function buildMenuOption(currentTable: Table): MenuOption {
122
+ const children = buildMethodChildren(currentTable)
123
+ return {
124
+ label: () =>
125
+ h(
126
+ NuxtLink,
127
+ {
128
+ to: `${route.params.database ? `/${route.params.database}` : ""}/admin/api/tables/${currentTable.slug}`,
129
+ },
130
+ { default: () => t(currentTable.slug) },
131
+ ),
132
+ key: `${currentTable.slug}Group`,
133
+ icon: () => h(LazyTableIcon, { table: currentTable }),
134
+ children: [
135
+ {
136
+ type: "group",
137
+ label: t(currentTable.slug),
138
+ key: currentTable.slug,
139
+ children,
140
+ },
141
+ ],
142
+ }
143
+ }
144
+
145
+ const primaryTables = computed(() =>
146
+ (database.value?.tables ?? []).filter(
147
+ ({ slug, allowedMethods, show }) =>
148
+ ![
149
+ "users",
150
+ "sessions",
151
+ "assets",
152
+ "translations",
153
+ "pages",
154
+ "blocks",
155
+ "templates",
156
+ ].includes(slug) &&
157
+ allowedMethods?.includes("r") &&
158
+ show !== false,
159
+ ),
160
+ )
161
+
162
+ const secondaryTables = computed(() =>
163
+ (database.value?.tables ?? []).filter(
164
+ ({ slug, allowedMethods, show }) =>
165
+ [
166
+ "users",
167
+ "sessions",
168
+ "assets",
169
+ "translations",
170
+ "pages",
171
+ "blocks",
172
+ "templates",
173
+ ].includes(slug) &&
174
+ allowedMethods?.includes("r") &&
175
+ show !== false,
176
+ ),
177
+ )
178
+
179
+ const menuOptions = computed(() => {
180
+ if (!database.value?.tables) return [] as MenuOption[]
181
+ const options: MenuOption[] = []
182
+ options.push(...primaryTables.value.map(buildMenuOption))
183
+ if (secondaryTables.value.length)
184
+ options.push({ key: "divider-1", type: "divider" })
185
+ options.push(...secondaryTables.value.map(buildMenuOption))
186
+ return options
187
+ })
188
+ </script>