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,302 @@
|
|
|
1
|
+
import type { IDBPDatabase } from "idb";
|
|
2
|
+
import { openDB } from "idb";
|
|
3
|
+
|
|
4
|
+
const DB_NAME = "inicontent-offline-queue";
|
|
5
|
+
const STORE_NAME = "pending-mutations";
|
|
6
|
+
const DB_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
type HTTPMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A single queued mutation that could not be sent to the remote API because
|
|
12
|
+
* the device was offline (or the request failed transiently).
|
|
13
|
+
*/
|
|
14
|
+
export type PendingMutation = {
|
|
15
|
+
/** unique id for this queued mutation */
|
|
16
|
+
id: string;
|
|
17
|
+
method: Exclude<HTTPMethod, "GET">;
|
|
18
|
+
/** full API url, including protocol and host */
|
|
19
|
+
url: string;
|
|
20
|
+
/** request body (if any) — kept as raw JSON-safe data */
|
|
21
|
+
body: Record<string, any> | string | null;
|
|
22
|
+
/** request query params (if any) */
|
|
23
|
+
params?: Record<string, any>;
|
|
24
|
+
/** database slug scope */
|
|
25
|
+
database: string;
|
|
26
|
+
/** table slug scope (if applicable) */
|
|
27
|
+
table: string;
|
|
28
|
+
timestamp: number;
|
|
29
|
+
retryCount: number;
|
|
30
|
+
status: "pending" | "retrying" | "conflict";
|
|
31
|
+
/** populated when status === 'conflict': what the server currently has */
|
|
32
|
+
conflictData?: { local: any; server: any } | null;
|
|
33
|
+
errorMessage?: string | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let dbPromise: Promise<IDBPDatabase> | null = null;
|
|
37
|
+
|
|
38
|
+
function initDB(): Promise<IDBPDatabase> {
|
|
39
|
+
if (!dbPromise) {
|
|
40
|
+
dbPromise = openDB(DB_NAME, DB_VERSION, {
|
|
41
|
+
upgrade(db) {
|
|
42
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
43
|
+
const store = db.createObjectStore(STORE_NAME, { keyPath: "id" });
|
|
44
|
+
store.createIndex("timestamp", "timestamp", { unique: false });
|
|
45
|
+
store.createIndex("status", "status", { unique: false });
|
|
46
|
+
store.createIndex("database", "database", { unique: false });
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return dbPromise;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Push a mutation onto the offline queue. No-op when the app is online and the
|
|
56
|
+
* request could be sent directly, but kept here so callers can enqueue
|
|
57
|
+
* unconditionally after a failed fetch.
|
|
58
|
+
*/
|
|
59
|
+
export async function enqueueMutation(
|
|
60
|
+
mutation: Omit<PendingMutation, "id" | "timestamp" | "retryCount" | "status">,
|
|
61
|
+
): Promise<PendingMutation> {
|
|
62
|
+
// Make the body JSON-safe before persisting — IndexedDB uses structured
|
|
63
|
+
// clone, which throws on functions/cycles. Falling back to JSON.stringify
|
|
64
|
+
// guarantees we never lose a queued change because of an exotic value.
|
|
65
|
+
let safeBody = mutation.body;
|
|
66
|
+
if (safeBody !== null && typeof safeBody === "object") {
|
|
67
|
+
try {
|
|
68
|
+
const clone = structuredClone(safeBody);
|
|
69
|
+
JSON.stringify(clone); // throws on non-serializable values
|
|
70
|
+
safeBody = clone as typeof safeBody;
|
|
71
|
+
} catch {
|
|
72
|
+
try {
|
|
73
|
+
safeBody = JSON.parse(JSON.stringify(safeBody)) as typeof safeBody;
|
|
74
|
+
} catch {
|
|
75
|
+
safeBody = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const record: PendingMutation = {
|
|
81
|
+
...mutation,
|
|
82
|
+
body: safeBody,
|
|
83
|
+
id: randomID(),
|
|
84
|
+
timestamp: Date.now(),
|
|
85
|
+
retryCount: 0,
|
|
86
|
+
status: "pending",
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
const db = await initDB();
|
|
90
|
+
await db.put(STORE_NAME, record);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.warn("[OfflineQueue] Failed to enqueue mutation:", error);
|
|
93
|
+
}
|
|
94
|
+
return record;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Fetch all pending (non-conflict) mutations, oldest first. */
|
|
98
|
+
export async function getPendingMutations(): Promise<PendingMutation[]> {
|
|
99
|
+
try {
|
|
100
|
+
const db = await initDB();
|
|
101
|
+
const all = await db.getAll(STORE_NAME);
|
|
102
|
+
return all
|
|
103
|
+
.filter((m) => m.status !== "conflict")
|
|
104
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.warn("[OfflineQueue] Failed to read mutations:", error);
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Fetch every queued mutation including conflicts. */
|
|
112
|
+
export async function getAllMutations(): Promise<PendingMutation[]> {
|
|
113
|
+
try {
|
|
114
|
+
const db = await initDB();
|
|
115
|
+
const all = await db.getAll(STORE_NAME);
|
|
116
|
+
return all.sort((a, b) => a.timestamp - b.timestamp);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.warn("[OfflineQueue] Failed to read mutations:", error);
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Get all mutations scoped to a specific database (and optionally table). */
|
|
124
|
+
export async function getMutationsByScope(
|
|
125
|
+
database: string,
|
|
126
|
+
table?: string,
|
|
127
|
+
): Promise<PendingMutation[]> {
|
|
128
|
+
try {
|
|
129
|
+
const db = await initDB();
|
|
130
|
+
const all = await db.getAll(STORE_NAME);
|
|
131
|
+
return all
|
|
132
|
+
.filter(
|
|
133
|
+
(m) =>
|
|
134
|
+
m.database === database &&
|
|
135
|
+
(!table || m.table === table) &&
|
|
136
|
+
m.status !== "conflict",
|
|
137
|
+
)
|
|
138
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.warn("[OfflineQueue] Failed to read scoped mutations:", error);
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Drop a mutation from the queue after successful sync or user discard. */
|
|
146
|
+
export async function removeMutation(id: string): Promise<void> {
|
|
147
|
+
try {
|
|
148
|
+
const db = await initDB();
|
|
149
|
+
await db.delete(STORE_NAME, id);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.warn(`[OfflineQueue] Failed to remove mutation ${id}:`, error);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Mark a mutation as conflicted, storing both sides for later resolution. */
|
|
156
|
+
export async function markConflict(
|
|
157
|
+
id: string,
|
|
158
|
+
serverData: any,
|
|
159
|
+
): Promise<PendingMutation | undefined> {
|
|
160
|
+
try {
|
|
161
|
+
const db = await initDB();
|
|
162
|
+
const existing = await db.get(STORE_NAME, id);
|
|
163
|
+
if (!existing) return undefined;
|
|
164
|
+
const updated: PendingMutation = {
|
|
165
|
+
...existing,
|
|
166
|
+
status: "conflict",
|
|
167
|
+
conflictData: { local: existing.body, server: serverData },
|
|
168
|
+
};
|
|
169
|
+
await db.put(STORE_NAME, updated);
|
|
170
|
+
return updated;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.warn(`[OfflineQueue] Failed to mark conflict for ${id}:`, error);
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Increment the retry counter for a mutation that failed transiently. */
|
|
178
|
+
export async function incrementRetry(id: string): Promise<void> {
|
|
179
|
+
try {
|
|
180
|
+
const db = await initDB();
|
|
181
|
+
const existing = await db.get(STORE_NAME, id);
|
|
182
|
+
if (!existing) return;
|
|
183
|
+
await db.put(STORE_NAME, {
|
|
184
|
+
...existing,
|
|
185
|
+
retryCount: (existing.retryCount ?? 0) + 1,
|
|
186
|
+
status: "pending",
|
|
187
|
+
});
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.warn(`[OfflineQueue] Failed to increment retry for ${id}:`, error);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Re-queue a conflicted mutation (user chose "keep local"). */
|
|
194
|
+
export async function requeueMutation(id: string): Promise<void> {
|
|
195
|
+
try {
|
|
196
|
+
const db = await initDB();
|
|
197
|
+
const existing = await db.get(STORE_NAME, id);
|
|
198
|
+
if (!existing) return;
|
|
199
|
+
await db.put(STORE_NAME, {
|
|
200
|
+
...existing,
|
|
201
|
+
status: "pending",
|
|
202
|
+
retryCount: 0,
|
|
203
|
+
conflictData: null,
|
|
204
|
+
});
|
|
205
|
+
} catch (error) {
|
|
206
|
+
console.warn(`[OfflineQueue] Failed to requeue mutation ${id}:`, error);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Total count of pending mutations (excluding conflicts). */
|
|
211
|
+
export async function getPendingCount(): Promise<number> {
|
|
212
|
+
try {
|
|
213
|
+
const db = await initDB();
|
|
214
|
+
const all = await db.getAll(STORE_NAME);
|
|
215
|
+
return all.filter((m) => m.status !== "conflict").length;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Total count of conflicted mutations. */
|
|
222
|
+
export async function getConflictCount(): Promise<number> {
|
|
223
|
+
try {
|
|
224
|
+
const db = await initDB();
|
|
225
|
+
const all = await db.getAll(STORE_NAME);
|
|
226
|
+
return all.filter((m) => m.status === "conflict").length;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Number of pending mutations scoped to a database (optional table). */
|
|
233
|
+
export async function getPendingCountByScope(
|
|
234
|
+
database: string,
|
|
235
|
+
table?: string,
|
|
236
|
+
): Promise<number> {
|
|
237
|
+
try {
|
|
238
|
+
const db = await initDB();
|
|
239
|
+
const all = await db.getAll(STORE_NAME);
|
|
240
|
+
return all.filter(
|
|
241
|
+
(m) =>
|
|
242
|
+
m.status !== "conflict" &&
|
|
243
|
+
m.database === database &&
|
|
244
|
+
(!table || m.table === table),
|
|
245
|
+
).length;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** All converged mutations for a given scope. */
|
|
252
|
+
export async function getConflicts(): Promise<PendingMutation[]> {
|
|
253
|
+
try {
|
|
254
|
+
const db = await initDB();
|
|
255
|
+
const all = await db.getAll(STORE_NAME);
|
|
256
|
+
return all
|
|
257
|
+
.filter((m) => m.status === "conflict")
|
|
258
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function clearAllMutations(): Promise<void> {
|
|
265
|
+
try {
|
|
266
|
+
const db = await initDB();
|
|
267
|
+
await db.clear(STORE_NAME);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
console.warn("[OfflineQueue] Failed to clear queue:", error);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Build virtual rows for queued POST (create) mutations scoped to a table, so
|
|
275
|
+
* items created while offline still appear in the table view until they sync.
|
|
276
|
+
* Each virtual row carries a `__pending` marker (with the queued mutation id)
|
|
277
|
+
* and a synthetic id so the grid can key it without colliding with real ids.
|
|
278
|
+
*/
|
|
279
|
+
export async function getPendingCreates(
|
|
280
|
+
database: string,
|
|
281
|
+
table: string,
|
|
282
|
+
): Promise<Item[]> {
|
|
283
|
+
try {
|
|
284
|
+
const mutations = await getMutationsByScope(database, table);
|
|
285
|
+
return mutations
|
|
286
|
+
.filter((m) => m.method === "POST")
|
|
287
|
+
.map((m) => {
|
|
288
|
+
const body =
|
|
289
|
+
m.body && typeof m.body === "object" && !Array.isArray(m.body)
|
|
290
|
+
? (m.body as Record<string, any>)
|
|
291
|
+
: {};
|
|
292
|
+
return {
|
|
293
|
+
...body,
|
|
294
|
+
id: `${String(body.id ?? "")}` || `pending__${m.id}`,
|
|
295
|
+
__pending: { queuedId: m.id },
|
|
296
|
+
} as Item;
|
|
297
|
+
});
|
|
298
|
+
} catch (error) {
|
|
299
|
+
console.warn("[OfflineQueue] Failed to build pending creates:", error);
|
|
300
|
+
return [];
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getConflicts,
|
|
3
|
+
getPendingCount,
|
|
4
|
+
getPendingMutations,
|
|
5
|
+
incrementRetry,
|
|
6
|
+
markConflict,
|
|
7
|
+
removeMutation,
|
|
8
|
+
requeueMutation,
|
|
9
|
+
} from "./useOfflineQueue";
|
|
10
|
+
|
|
11
|
+
type SyncResult = {
|
|
12
|
+
id: string;
|
|
13
|
+
method: string;
|
|
14
|
+
url: string;
|
|
15
|
+
status: "success" | "conflict" | "error" | "skipped";
|
|
16
|
+
message?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const MAX_RETRIES = 5;
|
|
20
|
+
|
|
21
|
+
/** Bound for a single mutation/conflict request so a hung request can never
|
|
22
|
+
* block the sync loop forever (navigator.onLine can stay true even when the
|
|
23
|
+
* device has no real route to the API). Generous enough that slow-but-working
|
|
24
|
+
* API calls are NOT mistaken for offline failures. */
|
|
25
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
26
|
+
|
|
27
|
+
let initialized = false;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Manages background sync of offline-queued mutations. On reconnect (or when
|
|
31
|
+
* explicitly triggered) it drains the queue in order, re-sending each mutation
|
|
32
|
+
* to the remote API. Conflicted writes are parked for manual resolution.
|
|
33
|
+
*
|
|
34
|
+
* Shared state is stored in Nuxt `useState` so every consumer (Header badge,
|
|
35
|
+
* conflict drawer, etc.) observes the same values.
|
|
36
|
+
*/
|
|
37
|
+
export function useOfflineSync() {
|
|
38
|
+
const isOnline = useState<boolean>("offline_isOnline", () =>
|
|
39
|
+
typeof navigator === "undefined" ? true : navigator.onLine !== false,
|
|
40
|
+
);
|
|
41
|
+
const isSyncing = useState<boolean>("offline_isSyncing", () => false);
|
|
42
|
+
const syncResults = useState<SyncResult[]>("offline_syncResults", () => []);
|
|
43
|
+
const conflicts = useState<any[]>("offline_conflicts", () => []);
|
|
44
|
+
const pendingCount = useState<number>("offline_pendingCount", () => 0);
|
|
45
|
+
|
|
46
|
+
async function refreshCounts() {
|
|
47
|
+
pendingCount.value = await getPendingCount();
|
|
48
|
+
conflicts.value = await getConflicts();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Send a single queued mutation to the API.
|
|
53
|
+
* @returns true on success, 'conflict' on 409/4xx conflict, false on transient failure
|
|
54
|
+
*/
|
|
55
|
+
async function sendMutation(mutation: any): Promise<boolean | "conflict"> {
|
|
56
|
+
const fetchOptions: Record<string, any> = {
|
|
57
|
+
method: mutation.method,
|
|
58
|
+
credentials: "include",
|
|
59
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
60
|
+
};
|
|
61
|
+
if (mutation.method !== "DELETE") {
|
|
62
|
+
fetchOptions.body = mutation.body ?? undefined;
|
|
63
|
+
}
|
|
64
|
+
if (mutation.params && Object.keys(mutation.params).length > 0) {
|
|
65
|
+
fetchOptions.params = mutation.params;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const res = await $fetch(mutation.url, fetchOptions);
|
|
70
|
+
// Some endpoints return a response with .code on conflict-like states
|
|
71
|
+
if (res && typeof res === "object" && "code" in res) {
|
|
72
|
+
const code = (res as any).code;
|
|
73
|
+
if (code === 409 || code === "409" || code === "CONFLICT") {
|
|
74
|
+
return "conflict";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// A response means it worked — remove from queue.
|
|
78
|
+
await removeMutation(mutation.id);
|
|
79
|
+
return true;
|
|
80
|
+
} catch (error: any) {
|
|
81
|
+
const status = error?.response?.status ?? error?.status;
|
|
82
|
+
// Treat 4xx (except network) as conflict, not retryable.
|
|
83
|
+
if (status && status >= 400 && status < 500) {
|
|
84
|
+
return "conflict";
|
|
85
|
+
}
|
|
86
|
+
// 5xx / network errors: retryable
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function syncPendingMutations(): Promise<SyncResult[]> {
|
|
92
|
+
if (isSyncing.value) return syncResults.value;
|
|
93
|
+
isSyncing.value = true;
|
|
94
|
+
const results: SyncResult[] = [];
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const mutations = await getPendingMutations();
|
|
98
|
+
if (mutations.length === 0) {
|
|
99
|
+
return results;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Nothing to do if the user is explicitly offline (e.g. "Sync now"
|
|
103
|
+
// clicked while offline) — every request would fail. The online
|
|
104
|
+
// event listener re-triggers sync automatically on reconnect.
|
|
105
|
+
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
|
106
|
+
return results;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const mutation of mutations) {
|
|
110
|
+
// Stop if we've gone offline mid-sync
|
|
111
|
+
if (typeof navigator !== "undefined" && !navigator.onLine) break;
|
|
112
|
+
|
|
113
|
+
let outcome: boolean | "conflict" = false;
|
|
114
|
+
let attempts = 0;
|
|
115
|
+
while (attempts <= MAX_RETRIES) {
|
|
116
|
+
outcome = await sendMutation(mutation);
|
|
117
|
+
if (outcome === true || outcome === "conflict") break;
|
|
118
|
+
await incrementRetry(mutation.id);
|
|
119
|
+
attempts++;
|
|
120
|
+
await new Promise((r) => setTimeout(r, 500 * Math.min(attempts, 4)));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (outcome === true) {
|
|
124
|
+
results.push({
|
|
125
|
+
id: mutation.id,
|
|
126
|
+
method: mutation.method,
|
|
127
|
+
url: mutation.url,
|
|
128
|
+
status: "success",
|
|
129
|
+
});
|
|
130
|
+
} else if (outcome === "conflict") {
|
|
131
|
+
// Fetch current server value to present to the resolution UI.
|
|
132
|
+
let serverData: any = null;
|
|
133
|
+
try {
|
|
134
|
+
const serverRes = await $fetch(mutation.url, {
|
|
135
|
+
method: "GET",
|
|
136
|
+
credentials: "include",
|
|
137
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
138
|
+
});
|
|
139
|
+
serverData = serverRes;
|
|
140
|
+
} catch {
|
|
141
|
+
serverData = null;
|
|
142
|
+
}
|
|
143
|
+
await markConflict(mutation.id, serverData);
|
|
144
|
+
results.push({
|
|
145
|
+
id: mutation.id,
|
|
146
|
+
method: mutation.method,
|
|
147
|
+
url: mutation.url,
|
|
148
|
+
status: "conflict",
|
|
149
|
+
});
|
|
150
|
+
} else {
|
|
151
|
+
results.push({
|
|
152
|
+
id: mutation.id,
|
|
153
|
+
method: mutation.method,
|
|
154
|
+
url: mutation.url,
|
|
155
|
+
status: "error",
|
|
156
|
+
message: "Max retries reached",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
syncResults.value = results;
|
|
162
|
+
return results;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
// Never let an unexpected failure wedge the sync flag — the finally
|
|
165
|
+
// below resets it, but log so we can trace what interrupted the sync.
|
|
166
|
+
console.warn("[OfflineSync] Sync interrupted:", error);
|
|
167
|
+
return results;
|
|
168
|
+
} finally {
|
|
169
|
+
// Always clear the flag so the UI can never show an endless spinner.
|
|
170
|
+
isSyncing.value = false;
|
|
171
|
+
|
|
172
|
+
// Refresh any cached Nuxt data so the UI shows the synced state.
|
|
173
|
+
try {
|
|
174
|
+
await refreshNuxtData();
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.warn("[OfflineSync] refreshNuxtData failed:", error);
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
await refreshCounts();
|
|
180
|
+
} catch (error) {
|
|
181
|
+
console.warn("[OfflineSync] refreshCounts failed:", error);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function initSync() {
|
|
187
|
+
if (initialized) return;
|
|
188
|
+
initialized = true;
|
|
189
|
+
|
|
190
|
+
if (typeof window !== "undefined") {
|
|
191
|
+
window.addEventListener("online", () => {
|
|
192
|
+
isOnline.value = true;
|
|
193
|
+
syncPendingMutations();
|
|
194
|
+
});
|
|
195
|
+
window.addEventListener("offline", () => {
|
|
196
|
+
isOnline.value = false;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Kick off the initial queue count (runs once, independent of any
|
|
200
|
+
// component lifecycle so callers can invoke it anywhere).
|
|
201
|
+
refreshCounts().then(() => {});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
isOnline,
|
|
207
|
+
isSyncing,
|
|
208
|
+
syncResults,
|
|
209
|
+
conflicts,
|
|
210
|
+
pendingCount,
|
|
211
|
+
syncPendingMutations,
|
|
212
|
+
refreshCounts,
|
|
213
|
+
initSync,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Convenience exports for components that only need conflict management
|
|
218
|
+
export async function resolveConflictKeepLocal(id: string) {
|
|
219
|
+
await requeueMutation(id);
|
|
220
|
+
// Immediately try to sync it
|
|
221
|
+
const state = useOfflineSync();
|
|
222
|
+
await state.syncPendingMutations();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function resolveConflictKeepServer(id: string) {
|
|
226
|
+
await removeMutation(id);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Discard ALL local changes (server wins for everything). */
|
|
230
|
+
export async function discardAllConflicts() {
|
|
231
|
+
const all = await getConflicts();
|
|
232
|
+
for (const c of all) {
|
|
233
|
+
await removeMutation(c.id);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
startAuthentication,
|
|
3
|
+
startRegistration,
|
|
4
|
+
} from "@simplewebauthn/browser";
|
|
5
|
+
|
|
6
|
+
interface PasskeyBeginOptions {
|
|
7
|
+
challenge: string;
|
|
8
|
+
challengeRef?: string;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type PasskeyBeginResponse = apiResponse<PasskeyBeginOptions>;
|
|
13
|
+
type LoginPayload = User & { sessionID: string };
|
|
14
|
+
type LoginResponse = apiResponse<LoginPayload>;
|
|
15
|
+
type PasskeyRegisterResponse = apiResponse<Record<string, unknown>>;
|
|
16
|
+
|
|
17
|
+
export function usePasskeyAuth() {
|
|
18
|
+
const config = useRuntimeConfig();
|
|
19
|
+
const database = useState<Database>("database");
|
|
20
|
+
const language = useScopedCookie<LanguagesType>("language", database.value?.slug);
|
|
21
|
+
const sessionID = useScopedCookie<string>("sid", database.value?.slug);
|
|
22
|
+
|
|
23
|
+
const isPasskeySupported = ref(false);
|
|
24
|
+
|
|
25
|
+
onMounted(() => {
|
|
26
|
+
isPasskeySupported.value =
|
|
27
|
+
typeof window.PublicKeyCredential !== "undefined";
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function mapPasskeyError(error: unknown, fallback: string) {
|
|
31
|
+
if (!(error instanceof Error)) return fallback;
|
|
32
|
+
|
|
33
|
+
if (error.name === "AbortError") {
|
|
34
|
+
return "Passkey prompt was closed before completion.";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (error.name === "NotAllowedError") {
|
|
38
|
+
return "Passkey verification was cancelled or not allowed.";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (error.name === "InvalidStateError") {
|
|
42
|
+
return "This device is already registered with a passkey for this account.";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return error.message || fallback;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function beginPasskeySignIn(identifier: string) {
|
|
49
|
+
const beginResponse = await $fetch<PasskeyBeginResponse>(
|
|
50
|
+
`${config.public.apiBase}${database.value.slug}/auth/passkey/authenticate/begin`,
|
|
51
|
+
{
|
|
52
|
+
method: "PUT",
|
|
53
|
+
credentials: "include",
|
|
54
|
+
body: {
|
|
55
|
+
identifier,
|
|
56
|
+
},
|
|
57
|
+
params: {
|
|
58
|
+
locale: language.value,
|
|
59
|
+
[`${database.value.slug}_sid`]: sessionID.value,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
if (!beginResponse.result?.challenge) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
beginResponse.message || "Passkey sign-in could not start",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let credential: unknown;
|
|
71
|
+
try {
|
|
72
|
+
credential = await startAuthentication({
|
|
73
|
+
optionsJSON: beginResponse.result,
|
|
74
|
+
});
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
mapPasskeyError(error, "Passkey sign-in could not be completed"),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return $fetch<LoginResponse>(
|
|
82
|
+
`${config.public.apiBase}${database.value.slug}/auth/passkey/authenticate/complete`,
|
|
83
|
+
{
|
|
84
|
+
method: "PUT",
|
|
85
|
+
credentials: "include",
|
|
86
|
+
body: {
|
|
87
|
+
identifier,
|
|
88
|
+
challenge: beginResponse.result.challenge,
|
|
89
|
+
challengeRef: beginResponse.result.challengeRef,
|
|
90
|
+
credential,
|
|
91
|
+
},
|
|
92
|
+
params: {
|
|
93
|
+
locale: language.value,
|
|
94
|
+
[`${database.value.slug}_sid`]: sessionID.value,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function registerCurrentUserPasskey() {
|
|
101
|
+
const beginResponse = await $fetch<PasskeyBeginResponse>(
|
|
102
|
+
`${config.public.apiBase}${database.value.slug}/auth/passkey/register/begin`,
|
|
103
|
+
{
|
|
104
|
+
method: "PUT",
|
|
105
|
+
credentials: "include",
|
|
106
|
+
body: {
|
|
107
|
+
source: "auth",
|
|
108
|
+
},
|
|
109
|
+
params: {
|
|
110
|
+
locale: language.value,
|
|
111
|
+
[`${database.value.slug}_sid`]: sessionID.value,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
if (!beginResponse.result?.challenge) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
beginResponse.message || "Passkey registration could not start",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let credential: unknown;
|
|
123
|
+
try {
|
|
124
|
+
credential = await startRegistration({
|
|
125
|
+
optionsJSON: beginResponse.result,
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
mapPasskeyError(error, "Passkey registration could not be completed"),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return $fetch<PasskeyRegisterResponse>(
|
|
134
|
+
`${config.public.apiBase}${database.value.slug}/auth/passkey/register/complete`,
|
|
135
|
+
{
|
|
136
|
+
method: "PUT",
|
|
137
|
+
credentials: "include",
|
|
138
|
+
body: {
|
|
139
|
+
challenge: beginResponse.result.challenge,
|
|
140
|
+
challengeRef: beginResponse.result.challengeRef,
|
|
141
|
+
credential,
|
|
142
|
+
},
|
|
143
|
+
params: {
|
|
144
|
+
locale: language.value,
|
|
145
|
+
[`${database.value.slug}_sid`]: sessionID.value,
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
isPasskeySupported,
|
|
153
|
+
beginPasskeySignIn,
|
|
154
|
+
registerCurrentUserPasskey,
|
|
155
|
+
};
|
|
156
|
+
}
|