@neevjs/client 0.0.1 → 1.0.1-beta

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/dist/index.mjs CHANGED
@@ -1,9 +1,18 @@
1
+ // src/index.ts
2
+ import { VERSION as VERSION2 } from "@neevjs/shared";
3
+
1
4
  // src/core/AuthClient.ts
5
+ var USER_CACHE_KEY = "neev_user";
2
6
  var AuthClient = class {
3
7
  client;
4
8
  userData = null;
5
9
  constructor(client) {
6
10
  this.client = client;
11
+ try {
12
+ const cached = sessionStorage.getItem(USER_CACHE_KEY);
13
+ if (cached) this.userData = JSON.parse(cached);
14
+ } catch {
15
+ }
7
16
  }
8
17
  async login(email, password) {
9
18
  const res = await this.client.request("/auth/login", {
@@ -12,6 +21,7 @@ var AuthClient = class {
12
21
  });
13
22
  localStorage.setItem("neev_token", res.data.token);
14
23
  this.userData = res.data.user;
24
+ sessionStorage.setItem(USER_CACHE_KEY, JSON.stringify(this.userData));
15
25
  }
16
26
  async register(email, password, name) {
17
27
  const res = await this.client.request("/auth/register", {
@@ -20,9 +30,11 @@ var AuthClient = class {
20
30
  });
21
31
  localStorage.setItem("neev_token", res.data.token);
22
32
  this.userData = res.data.user;
33
+ sessionStorage.setItem(USER_CACHE_KEY, JSON.stringify(this.userData));
23
34
  }
24
35
  logout() {
25
36
  localStorage.removeItem("neev_token");
37
+ sessionStorage.removeItem(USER_CACHE_KEY);
26
38
  this.userData = null;
27
39
  }
28
40
  async user() {
@@ -30,6 +42,7 @@ var AuthClient = class {
30
42
  if (this.userData) return this.userData;
31
43
  const res = await this.client.request("/auth/me");
32
44
  this.userData = res.data;
45
+ sessionStorage.setItem(USER_CACHE_KEY, JSON.stringify(this.userData));
33
46
  return this.userData;
34
47
  }
35
48
  isAuthenticated() {
@@ -51,13 +64,23 @@ function createClient(config = {}) {
51
64
  req = await plugin.onRequest(req);
52
65
  }
53
66
  }
67
+ if (req._cachedResponse instanceof Response) {
68
+ let cachedRes = req._cachedResponse;
69
+ for (const plugin of plugins) {
70
+ if (plugin.onResponse) {
71
+ cachedRes = await plugin.onResponse(cachedRes, req);
72
+ }
73
+ }
74
+ return cachedRes.json();
75
+ }
54
76
  const headers = {
55
77
  "Content-Type": "application/json",
56
78
  ...req.options.headers ?? {}
57
79
  };
58
80
  let response;
59
81
  try {
60
- response = await fetch(baseURL + req.url, {
82
+ const finalBaseURL = req.options.baseURL ?? baseURL;
83
+ response = await fetch(finalBaseURL + req.url, {
61
84
  method: req.options.method ?? "GET",
62
85
  body: req.options.body,
63
86
  headers
@@ -65,20 +88,31 @@ function createClient(config = {}) {
65
88
  let finalResponse = response;
66
89
  for (const plugin of plugins) {
67
90
  if (plugin.onResponse) {
68
- finalResponse = await plugin.onResponse(finalResponse);
91
+ finalResponse = await plugin.onResponse(finalResponse, req);
92
+ }
93
+ }
94
+ if (finalResponse.status === 401) {
95
+ client.auth?.logout();
96
+ const error = new Error("Session expired. Please log in again.");
97
+ error.status = 401;
98
+ for (const plugin of plugins) {
99
+ plugin.onError?.(error, req);
69
100
  }
101
+ throw error;
70
102
  }
71
103
  if (!finalResponse.ok) {
72
104
  const errorBody = await finalResponse.json().catch(() => ({ error: "Unknown error" }));
73
- throw new Error(
105
+ const error = new Error(
74
106
  typeof errorBody.error === "string" ? errorBody.error : `HTTP ${finalResponse.status}`
75
107
  );
108
+ error.status = finalResponse.status;
109
+ throw error;
76
110
  }
77
111
  return finalResponse.json();
78
112
  } catch (err) {
79
113
  const error = err instanceof Error ? err : new Error(String(err));
80
114
  for (const plugin of plugins) {
81
- plugin.onError?.(error);
115
+ plugin.onError?.(error, req);
82
116
  }
83
117
  throw error;
84
118
  }
@@ -91,7 +125,6 @@ function createClient(config = {}) {
91
125
  request,
92
126
  use,
93
127
  auth: null
94
- // set below after client is defined
95
128
  };
96
129
  client.auth = new AuthClient(client);
97
130
  return client;
@@ -114,15 +147,150 @@ function useNeevClient() {
114
147
  return client;
115
148
  }
116
149
 
150
+ // src/core/SecureStore.ts
151
+ var SECURE_PREFIX = "neev_secure_";
152
+ var _encryptionKey = null;
153
+ async function getEncryptionKey() {
154
+ if (_encryptionKey) return _encryptionKey;
155
+ if (!window.crypto?.subtle) {
156
+ throw new Error(
157
+ "[NeevJS] SecureStore requires Web Crypto API (available in HTTPS or localhost contexts only)."
158
+ );
159
+ }
160
+ _encryptionKey = await window.crypto.subtle.generateKey(
161
+ { name: "AES-GCM", length: 256 },
162
+ false,
163
+ // non-extractable — the raw key bytes can NEVER be exported
164
+ ["encrypt", "decrypt"]
165
+ );
166
+ return _encryptionKey;
167
+ }
168
+ async function encrypt(data) {
169
+ const key = await getEncryptionKey();
170
+ const iv = window.crypto.getRandomValues(new Uint8Array(12));
171
+ const encoded = new TextEncoder().encode(JSON.stringify(data));
172
+ const ciphertext = await window.crypto.subtle.encrypt(
173
+ { name: "AES-GCM", iv },
174
+ key,
175
+ encoded
176
+ );
177
+ const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength);
178
+ combined.set(iv, 0);
179
+ combined.set(new Uint8Array(ciphertext), iv.byteLength);
180
+ return btoa(String.fromCharCode(...combined));
181
+ }
182
+ async function decrypt(encoded) {
183
+ const key = await getEncryptionKey();
184
+ const combined = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0));
185
+ const iv = combined.slice(0, 12);
186
+ const ciphertext = combined.slice(12);
187
+ const decrypted = await window.crypto.subtle.decrypt(
188
+ { name: "AES-GCM", iv },
189
+ key,
190
+ ciphertext
191
+ );
192
+ return JSON.parse(new TextDecoder().decode(decrypted));
193
+ }
194
+ var SecureStore = {
195
+ /**
196
+ * Encrypt and store a value in localStorage.
197
+ * The value cannot be read without the in-memory session key.
198
+ */
199
+ async set(key, value) {
200
+ const ciphertext = await encrypt(value);
201
+ localStorage.setItem(SECURE_PREFIX + key, ciphertext);
202
+ },
203
+ /**
204
+ * Retrieve and decrypt a value.
205
+ * Returns null if the key doesn't exist or cannot be decrypted
206
+ * (e.g. after a page refresh when the key is gone).
207
+ */
208
+ async get(key) {
209
+ const stored = localStorage.getItem(SECURE_PREFIX + key);
210
+ if (!stored) return null;
211
+ try {
212
+ return await decrypt(stored);
213
+ } catch {
214
+ localStorage.removeItem(SECURE_PREFIX + key);
215
+ return null;
216
+ }
217
+ },
218
+ /**
219
+ * Remove a specific key from SecureStore.
220
+ */
221
+ remove(key) {
222
+ localStorage.removeItem(SECURE_PREFIX + key);
223
+ },
224
+ /**
225
+ * Remove ALL SecureStore entries from localStorage.
226
+ * Call this on logout to clean up all session-bound encrypted data.
227
+ */
228
+ clear() {
229
+ const keysToRemove = [];
230
+ for (let i = 0; i < localStorage.length; i++) {
231
+ const k = localStorage.key(i);
232
+ if (k?.startsWith(SECURE_PREFIX)) keysToRemove.push(k);
233
+ }
234
+ keysToRemove.forEach((k) => localStorage.removeItem(k));
235
+ }
236
+ };
237
+
117
238
  // src/hooks/useModel.ts
118
239
  import { useCallback, useEffect, useRef, useState } from "react";
119
240
  var cache = /* @__PURE__ */ new Map();
120
- function useModel(name) {
241
+ var listeners = /* @__PURE__ */ new Map();
242
+ function emitChange(url, exclude) {
243
+ listeners.get(url)?.forEach((listener) => {
244
+ if (listener !== exclude) listener();
245
+ });
246
+ }
247
+ function mutateModel(url) {
248
+ cache.delete(url);
249
+ emitChange(url);
250
+ }
251
+ var inFlightRequests = /* @__PURE__ */ new Map();
252
+ var cacheTimestamps = /* @__PURE__ */ new Map();
253
+ var cacheError = /* @__PURE__ */ new Map();
254
+ var STALE_TIME = 60 * 1e3;
255
+ function useModel(name, options) {
121
256
  const client = useNeevClient();
122
- const url = `/${name}`;
257
+ const baseUrl = `/${name}`;
258
+ const url = options?.params ? `${baseUrl}?${new URLSearchParams(
259
+ Object.entries(options.params).filter(([, v]) => v !== void 0).map(([k, v]) => [k, String(v)])
260
+ ).toString()}` : baseUrl;
123
261
  const [data, setData] = useState(() => cache.get(url) ?? []);
124
262
  const [loading, setLoading] = useState(!cache.has(url));
125
263
  const [error, setError] = useState(null);
264
+ if (options?.suspense) {
265
+ if (cacheError.has(url)) {
266
+ throw cacheError.get(url);
267
+ }
268
+ const timestamp = cacheTimestamps.get(url);
269
+ const isFresh = timestamp && Date.now() - timestamp < STALE_TIME;
270
+ if (!isFresh && !cache.has(url)) {
271
+ if (!navigator.onLine) {
272
+ throw new Error("[NeevJS] Offline: No cached data available for this model.");
273
+ }
274
+ let requestPromise = inFlightRequests.get(url);
275
+ if (!requestPromise) {
276
+ requestPromise = client.request(url, { baseURL: options?.baseURL }).then((res) => {
277
+ const rows = Array.isArray(res) ? res : res.data ?? [];
278
+ cache.set(url, rows);
279
+ cacheTimestamps.set(url, Date.now());
280
+ cacheError.delete(url);
281
+ }).catch((err) => {
282
+ if (!navigator.onLine && cache.has(url)) return;
283
+ cacheError.set(url, err instanceof Error ? err : new Error(String(err)));
284
+ }).finally(() => {
285
+ if (inFlightRequests.get(url) === requestPromise) {
286
+ inFlightRequests.delete(url);
287
+ }
288
+ });
289
+ inFlightRequests.set(url, requestPromise);
290
+ }
291
+ throw requestPromise;
292
+ }
293
+ }
126
294
  const mountedRef = useRef(true);
127
295
  useEffect(() => {
128
296
  mountedRef.current = true;
@@ -130,51 +298,138 @@ function useModel(name) {
130
298
  mountedRef.current = false;
131
299
  };
132
300
  }, []);
133
- const fetchData = useCallback(async () => {
301
+ const fetchData = useCallback(async (force = false) => {
302
+ if (!navigator.onLine && cache.has(url)) {
303
+ if (mountedRef.current) {
304
+ setData(cache.get(url));
305
+ setError(null);
306
+ setLoading(false);
307
+ }
308
+ return;
309
+ }
310
+ if (!force && cache.has(url)) {
311
+ const timestamp = cacheTimestamps.get(url);
312
+ if (timestamp && Date.now() - timestamp < STALE_TIME) {
313
+ if (mountedRef.current) {
314
+ setData(cache.get(url));
315
+ setError(null);
316
+ setLoading(false);
317
+ }
318
+ return;
319
+ }
320
+ }
134
321
  if (mountedRef.current) setLoading(true);
322
+ let requestPromise = inFlightRequests.get(url);
323
+ if (!requestPromise) {
324
+ requestPromise = client.request(url, { baseURL: options?.baseURL });
325
+ inFlightRequests.set(url, requestPromise);
326
+ }
135
327
  try {
136
- const res = await client.request(url);
328
+ const res = await requestPromise;
137
329
  const rows = Array.isArray(res) ? res : res.data ?? [];
138
330
  cache.set(url, rows);
331
+ cacheTimestamps.set(url, Date.now());
332
+ cacheError.delete(url);
139
333
  if (mountedRef.current) {
140
334
  setData(rows);
141
335
  setError(null);
142
336
  }
143
337
  } catch (err) {
144
- if (mountedRef.current) {
338
+ if (!navigator.onLine && cache.has(url)) {
339
+ if (mountedRef.current) {
340
+ setData(cache.get(url));
341
+ setError(null);
342
+ }
343
+ } else if (mountedRef.current) {
145
344
  setError(err instanceof Error ? err : new Error(String(err)));
146
345
  }
147
346
  } finally {
347
+ if (inFlightRequests.get(url) === requestPromise) {
348
+ inFlightRequests.delete(url);
349
+ }
148
350
  if (mountedRef.current) setLoading(false);
149
351
  }
150
352
  }, [client, url]);
151
353
  useEffect(() => {
152
- void fetchData();
354
+ void fetchData(false);
153
355
  }, [fetchData]);
356
+ const handleRemoteChange = useCallback(() => {
357
+ void fetchData(true);
358
+ }, [fetchData]);
359
+ useEffect(() => {
360
+ if (!listeners.has(url)) listeners.set(url, /* @__PURE__ */ new Set());
361
+ listeners.get(url).add(handleRemoteChange);
362
+ return () => {
363
+ listeners.get(url)?.delete(handleRemoteChange);
364
+ };
365
+ }, [url, handleRemoteChange]);
154
366
  const create = useCallback(async (payload) => {
155
- await client.request(url, {
156
- method: "POST",
157
- body: JSON.stringify(payload)
158
- });
159
- cache.delete(url);
160
- await fetchData();
161
- }, [client, url, fetchData]);
367
+ try {
368
+ await client.request(url, {
369
+ method: "POST",
370
+ body: JSON.stringify(payload),
371
+ baseURL: options?.baseURL
372
+ });
373
+ cache.delete(url);
374
+ await fetchData(true);
375
+ emitChange(url, handleRemoteChange);
376
+ } catch (err) {
377
+ if (err.message?.includes("[NeevJS] Offline")) {
378
+ const newRecord = { ...payload, id: `temp_${Date.now()}` };
379
+ const currentData = cache.get(url) ?? [];
380
+ const updatedData = [...currentData, newRecord];
381
+ cache.set(url, updatedData);
382
+ setData(updatedData);
383
+ emitChange(url, handleRemoteChange);
384
+ } else {
385
+ throw err;
386
+ }
387
+ }
388
+ }, [client, url, fetchData, handleRemoteChange]);
162
389
  const update = useCallback(async (id, payload) => {
163
- await client.request(`${url}/${id}`, {
164
- method: "PUT",
165
- body: JSON.stringify(payload)
166
- });
167
- cache.delete(url);
168
- await fetchData();
169
- }, [client, url, fetchData]);
390
+ try {
391
+ await client.request(`${url}/${id}`, {
392
+ method: "PUT",
393
+ body: JSON.stringify(payload),
394
+ baseURL: options?.baseURL
395
+ });
396
+ cache.delete(url);
397
+ await fetchData(true);
398
+ emitChange(url, handleRemoteChange);
399
+ } catch (err) {
400
+ if (err.message?.includes("[NeevJS] Offline")) {
401
+ const currentData = cache.get(url) ?? [];
402
+ const updatedData = currentData.map((item) => String(item.id) === String(id) ? { ...item, ...payload } : item);
403
+ cache.set(url, updatedData);
404
+ setData(updatedData);
405
+ emitChange(url, handleRemoteChange);
406
+ } else {
407
+ throw err;
408
+ }
409
+ }
410
+ }, [client, url, fetchData, handleRemoteChange]);
170
411
  const remove = useCallback(async (id) => {
171
- await client.request(`${url}/${id}`, {
172
- method: "DELETE"
173
- });
174
- cache.delete(url);
175
- await fetchData();
176
- }, [client, url, fetchData]);
177
- return { data, loading, error, create, update, remove, refresh: fetchData };
412
+ try {
413
+ await client.request(`${url}/${id}`, {
414
+ method: "DELETE",
415
+ baseURL: options?.baseURL
416
+ });
417
+ cache.delete(url);
418
+ await fetchData(true);
419
+ emitChange(url, handleRemoteChange);
420
+ } catch (err) {
421
+ if (err.message?.includes("[NeevJS] Offline")) {
422
+ const currentData = cache.get(url) ?? [];
423
+ const filteredData = currentData.filter((item) => String(item.id) !== String(id));
424
+ cache.set(url, filteredData);
425
+ setData(filteredData);
426
+ emitChange(url, handleRemoteChange);
427
+ } else {
428
+ throw err;
429
+ }
430
+ }
431
+ }, [client, url, fetchData, handleRemoteChange]);
432
+ return { data, loading, error, create, update, remove, refresh: () => fetchData(true) };
178
433
  }
179
434
 
180
435
  // src/hooks/useAuth.ts
@@ -194,6 +449,8 @@ function useAuth() {
194
449
  import { useEffect as useEffect2, useState as useState2 } from "react";
195
450
  var syncState = {
196
451
  pendingCount: 0,
452
+ syncing: false,
453
+ errors: [],
197
454
  listeners: /* @__PURE__ */ new Set(),
198
455
  notify() {
199
456
  this.listeners.forEach((fn) => fn());
@@ -202,18 +459,19 @@ var syncState = {
202
459
  function useSyncStatus() {
203
460
  const [isOffline, setIsOffline] = useState2(!navigator.onLine);
204
461
  const [pending, setPending] = useState2(syncState.pendingCount);
205
- const [syncing, setSyncing] = useState2(false);
462
+ const [syncing, setSyncing] = useState2(syncState.syncing);
463
+ const [errors, setErrors] = useState2(syncState.errors);
206
464
  useEffect2(() => {
207
465
  function onOnline() {
208
466
  setIsOffline(false);
209
- setSyncing(true);
210
- setTimeout(() => setSyncing(false), 2e3);
211
467
  }
212
468
  function onOffline() {
213
469
  setIsOffline(true);
214
470
  }
215
471
  function onSyncUpdate() {
216
472
  setPending(syncState.pendingCount);
473
+ setSyncing(syncState.syncing);
474
+ setErrors([...syncState.errors]);
217
475
  }
218
476
  window.addEventListener("online", onOnline);
219
477
  window.addEventListener("offline", onOffline);
@@ -224,7 +482,95 @@ function useSyncStatus() {
224
482
  syncState.listeners.delete(onSyncUpdate);
225
483
  };
226
484
  }, []);
227
- return { isOffline, pending, syncing };
485
+ function clearErrors() {
486
+ syncState.errors = [];
487
+ syncState.notify();
488
+ }
489
+ return { isOffline, pending, syncing, errors, clearErrors };
490
+ }
491
+
492
+ // src/hooks/useStore.ts
493
+ import { useCallback as useCallback2, useSyncExternalStore } from "react";
494
+ var stores = /* @__PURE__ */ new Map();
495
+ var listeners2 = /* @__PURE__ */ new Map();
496
+ function subscribe(key, callback) {
497
+ if (!listeners2.has(key)) listeners2.set(key, /* @__PURE__ */ new Set());
498
+ listeners2.get(key).add(callback);
499
+ return () => {
500
+ listeners2.get(key).delete(callback);
501
+ };
502
+ }
503
+ function emitChange2(key) {
504
+ listeners2.get(key)?.forEach((fn) => fn());
505
+ }
506
+ var PERSIST_PREFIX = "neev_store_";
507
+ var SESSION_PREFIX = "neev_session_";
508
+ function readFromStorage(key, backend, fallback) {
509
+ try {
510
+ const prefix = backend === "sessionStorage" ? SESSION_PREFIX : PERSIST_PREFIX;
511
+ const raw = window[backend].getItem(prefix + key);
512
+ if (raw === null) return fallback;
513
+ const entry = JSON.parse(raw);
514
+ if (entry.expiresAt !== void 0 && Date.now() > entry.expiresAt) {
515
+ window[backend].removeItem(prefix + key);
516
+ return fallback;
517
+ }
518
+ return entry.value;
519
+ } catch {
520
+ return fallback;
521
+ }
522
+ }
523
+ function writeToStorage(key, value, backend, ttl) {
524
+ try {
525
+ const prefix = backend === "sessionStorage" ? SESSION_PREFIX : PERSIST_PREFIX;
526
+ const entry = {
527
+ value,
528
+ expiresAt: ttl !== void 0 ? Date.now() + ttl : void 0
529
+ };
530
+ window[backend].setItem(prefix + key, JSON.stringify(entry));
531
+ } catch {
532
+ }
533
+ }
534
+ function useStore(key, initialValue, options = {}) {
535
+ const { persist = false, session = false, ttl } = options;
536
+ const backend = persist ? "localStorage" : session ? "sessionStorage" : null;
537
+ if (!stores.has(key)) {
538
+ const resolved = backend ? readFromStorage(key, backend, initialValue) : initialValue;
539
+ stores.set(key, resolved);
540
+ }
541
+ const snapshot = useSyncExternalStore(
542
+ useCallback2((callback) => subscribe(key, callback), [key]),
543
+ useCallback2(() => stores.get(key), [key]),
544
+ useCallback2(() => initialValue, [])
545
+ // eslint-disable-line react-hooks/exhaustive-deps
546
+ );
547
+ const setValue = useCallback2(
548
+ (updater) => {
549
+ const current = stores.get(key);
550
+ const next = typeof updater === "function" ? updater(current) : updater;
551
+ stores.set(key, next);
552
+ if (backend) {
553
+ writeToStorage(key, next, backend, ttl);
554
+ }
555
+ emitChange2(key);
556
+ },
557
+ [key, backend, ttl]
558
+ );
559
+ return [snapshot, setValue];
560
+ }
561
+ function getStore(key) {
562
+ return stores.get(key);
563
+ }
564
+ function setStore(key, value) {
565
+ stores.set(key, value);
566
+ emitChange2(key);
567
+ }
568
+ function clearPersistedStore(key) {
569
+ try {
570
+ localStorage.removeItem(PERSIST_PREFIX + key);
571
+ sessionStorage.removeItem(SESSION_PREFIX + key);
572
+ } catch {
573
+ }
228
574
  }
229
575
 
230
576
  // src/components/Table.tsx
@@ -234,37 +580,44 @@ function Table({
234
580
  columns,
235
581
  onEdit,
236
582
  onDelete,
237
- emptyMessage = "No records found."
583
+ emptyMessage = "No records found.",
584
+ classNames = {},
585
+ styles = {},
586
+ unstyled = false
238
587
  }) {
239
588
  const { data, loading, error } = useModel(model);
240
589
  if (loading) {
241
- return /* @__PURE__ */ jsx2("div", { className: "neev-table-loading", children: /* @__PURE__ */ jsx2("p", { children: "Loading..." }) });
590
+ return /* @__PURE__ */ jsx2("div", { className: classNames.loadingState ?? "neev-table-loading", style: styles.loadingState, children: /* @__PURE__ */ jsx2("p", { children: "Loading..." }) });
242
591
  }
243
592
  if (error) {
244
- return /* @__PURE__ */ jsx2("div", { className: "neev-table-error", children: /* @__PURE__ */ jsxs("p", { children: [
593
+ return /* @__PURE__ */ jsx2("div", { className: classNames.errorState ?? "neev-table-error", style: styles.errorState, children: /* @__PURE__ */ jsxs("p", { children: [
245
594
  "Error: ",
246
595
  error.message
247
596
  ] }) });
248
597
  }
249
598
  if (data.length === 0) {
250
- return /* @__PURE__ */ jsx2("div", { className: "neev-table-empty", children: /* @__PURE__ */ jsx2("p", { children: emptyMessage }) });
599
+ return /* @__PURE__ */ jsx2("div", { className: classNames.emptyState ?? "neev-table-empty", style: styles.emptyState, children: /* @__PURE__ */ jsx2("p", { children: emptyMessage }) });
251
600
  }
252
601
  const resolvedColumns = columns ?? Object.keys(data[0]).map((key) => ({
253
602
  key,
254
603
  label: key.charAt(0).toUpperCase() + key.slice(1)
255
604
  }));
256
- return /* @__PURE__ */ jsx2("div", { className: "neev-table-wrapper", style: { overflowX: "auto" }, children: /* @__PURE__ */ jsxs("table", { className: "neev-table", style: { width: "100%", borderCollapse: "collapse" }, children: [
257
- /* @__PURE__ */ jsx2("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
605
+ return /* @__PURE__ */ jsx2("div", { className: classNames.root ?? "neev-table-wrapper", style: { ...!unstyled ? { overflowX: "auto" } : {}, ...styles.root }, children: /* @__PURE__ */ jsxs("table", { className: classNames.table ?? "neev-table", style: { ...!unstyled ? { width: "100%", borderCollapse: "collapse" } : {}, ...styles.table }, children: [
606
+ /* @__PURE__ */ jsx2("thead", { className: classNames.thead, style: styles.thead, children: /* @__PURE__ */ jsxs("tr", { className: classNames.tr, style: styles.tr, children: [
258
607
  resolvedColumns.map((col) => /* @__PURE__ */ jsx2(
259
608
  "th",
260
609
  {
610
+ className: classNames.th,
261
611
  style: {
262
- textAlign: "left",
263
- padding: "10px 12px",
264
- borderBottom: "2px solid #e5e7eb",
265
- fontWeight: 600,
266
- color: "#374151",
267
- background: "#f9fafb"
612
+ ...!unstyled ? {
613
+ textAlign: "left",
614
+ padding: "10px 12px",
615
+ borderBottom: "2px solid #e5e7eb",
616
+ fontWeight: 600,
617
+ color: "#374151",
618
+ background: "#f9fafb"
619
+ } : {},
620
+ ...styles.th
268
621
  },
269
622
  children: col.label ?? col.key
270
623
  },
@@ -273,45 +626,55 @@ function Table({
273
626
  (onEdit || onDelete) && /* @__PURE__ */ jsx2(
274
627
  "th",
275
628
  {
629
+ className: classNames.th,
276
630
  style: {
277
- textAlign: "left",
278
- padding: "10px 12px",
279
- borderBottom: "2px solid #e5e7eb",
280
- fontWeight: 600,
281
- color: "#374151",
282
- background: "#f9fafb"
631
+ ...!unstyled ? {
632
+ textAlign: "left",
633
+ padding: "10px 12px",
634
+ borderBottom: "2px solid #e5e7eb",
635
+ fontWeight: 600,
636
+ color: "#374151",
637
+ background: "#f9fafb"
638
+ } : {},
639
+ ...styles.th
283
640
  },
284
641
  children: "Actions"
285
642
  }
286
643
  )
287
644
  ] }) }),
288
- /* @__PURE__ */ jsx2("tbody", { children: data.map((row, rowIndex) => /* @__PURE__ */ jsxs(
645
+ /* @__PURE__ */ jsx2("tbody", { className: classNames.tbody, style: styles.tbody, children: data.map((row, rowIndex) => /* @__PURE__ */ jsxs(
289
646
  "tr",
290
647
  {
291
- style: { borderBottom: "1px solid #e5e7eb" },
648
+ className: classNames.tr,
649
+ style: { ...!unstyled ? { borderBottom: "1px solid #e5e7eb" } : {}, ...styles.tr },
292
650
  children: [
293
651
  resolvedColumns.map((col) => /* @__PURE__ */ jsx2(
294
652
  "td",
295
653
  {
296
- style: { padding: "10px 12px", color: "#1f2937", verticalAlign: "middle" },
654
+ className: classNames.td,
655
+ style: { ...!unstyled ? { padding: "10px 12px", color: "#1f2937", verticalAlign: "middle" } : {}, ...styles.td },
297
656
  children: col.render ? col.render(row[col.key], row) : String(row[col.key] ?? "")
298
657
  },
299
658
  col.key
300
659
  )),
301
- (onEdit || onDelete) && /* @__PURE__ */ jsxs("td", { style: { padding: "10px 12px", verticalAlign: "middle" }, children: [
660
+ (onEdit || onDelete) && /* @__PURE__ */ jsxs("td", { className: classNames.actions ?? classNames.td, style: { ...!unstyled ? { padding: "10px 12px", verticalAlign: "middle" } : {}, ...styles.td, ...styles.actions }, children: [
302
661
  onEdit && /* @__PURE__ */ jsx2(
303
662
  "button",
304
663
  {
305
664
  onClick: () => onEdit(row),
665
+ className: classNames.editButton,
306
666
  style: {
307
- marginRight: 8,
308
- padding: "4px 10px",
309
- fontSize: 13,
310
- cursor: "pointer",
311
- background: "#3b82f6",
312
- color: "#fff",
313
- border: "none",
314
- borderRadius: 4
667
+ ...!unstyled ? {
668
+ marginRight: 8,
669
+ padding: "4px 10px",
670
+ fontSize: 13,
671
+ cursor: "pointer",
672
+ background: "#3b82f6",
673
+ color: "#fff",
674
+ border: "none",
675
+ borderRadius: 4
676
+ } : {},
677
+ ...styles.editButton
315
678
  },
316
679
  children: "Edit"
317
680
  }
@@ -320,14 +683,18 @@ function Table({
320
683
  "button",
321
684
  {
322
685
  onClick: () => onDelete(row),
686
+ className: classNames.deleteButton,
323
687
  style: {
324
- padding: "4px 10px",
325
- fontSize: 13,
326
- cursor: "pointer",
327
- background: "#ef4444",
328
- color: "#fff",
329
- border: "none",
330
- borderRadius: 4
688
+ ...!unstyled ? {
689
+ padding: "4px 10px",
690
+ fontSize: 13,
691
+ cursor: "pointer",
692
+ background: "#ef4444",
693
+ color: "#fff",
694
+ border: "none",
695
+ borderRadius: 4
696
+ } : {},
697
+ ...styles.deleteButton
331
698
  },
332
699
  children: "Delete"
333
700
  }
@@ -351,22 +718,48 @@ function Form({
351
718
  onSuccess,
352
719
  onError,
353
720
  submitLabel = "Submit",
354
- children
721
+ classNames = {},
722
+ styles = {},
723
+ unstyled = false,
724
+ transformPayload,
725
+ onSubmitOverride,
726
+ children,
727
+ fieldErrors: externalFieldErrors = {},
728
+ validate
355
729
  }) {
356
730
  const { create, update } = useModel(model);
357
731
  const [submitting, setSubmitting] = useState3(false);
358
732
  const [formError, setFormError] = useState3(null);
733
+ const [internalFieldErrors, setInternalFieldErrors] = useState3({});
734
+ const allFieldErrors = { ...internalFieldErrors, ...externalFieldErrors };
359
735
  async function handleSubmit(e) {
360
736
  e.preventDefault();
361
737
  setSubmitting(true);
362
738
  setFormError(null);
739
+ setInternalFieldErrors({});
363
740
  const formData = new FormData(e.currentTarget);
364
741
  const payload = Object.fromEntries(formData.entries());
365
742
  try {
366
- if (editId !== void 0) {
367
- await update(editId, payload);
743
+ let finalPayload = payload;
744
+ if (validate) {
745
+ const validationErrors = validate(finalPayload);
746
+ if (validationErrors && Object.keys(validationErrors).length > 0) {
747
+ setInternalFieldErrors(validationErrors);
748
+ setSubmitting(false);
749
+ return;
750
+ }
751
+ }
752
+ if (transformPayload) {
753
+ finalPayload = await transformPayload(finalPayload);
754
+ }
755
+ if (onSubmitOverride) {
756
+ await onSubmitOverride(finalPayload);
368
757
  } else {
369
- await create(payload);
758
+ if (editId !== void 0) {
759
+ await update(editId, finalPayload);
760
+ } else {
761
+ await create(finalPayload);
762
+ }
370
763
  }
371
764
  onSuccess?.();
372
765
  } catch (err) {
@@ -377,7 +770,7 @@ function Form({
377
770
  setSubmitting(false);
378
771
  }
379
772
  }
380
- const inputStyle = {
773
+ const baseInputStyle = unstyled ? {} : {
381
774
  display: "block",
382
775
  width: "100%",
383
776
  padding: "8px 10px",
@@ -388,29 +781,33 @@ function Form({
388
781
  boxSizing: "border-box",
389
782
  marginTop: 4
390
783
  };
391
- const labelStyle = {
784
+ const baseLabelStyle = unstyled ? {} : {
392
785
  display: "block",
393
786
  fontSize: 13,
394
787
  fontWeight: 500,
395
788
  color: "#374151",
396
789
  marginBottom: 12
397
790
  };
398
- return /* @__PURE__ */ jsxs2("form", { onSubmit: (e) => void handleSubmit(e), style: { width: "100%" }, children: [
791
+ return /* @__PURE__ */ jsxs2("form", { className: classNames.root, onSubmit: (e) => void handleSubmit(e), style: { width: "100%", ...styles.root }, children: [
399
792
  formError && /* @__PURE__ */ jsx3(
400
793
  "div",
401
794
  {
795
+ className: classNames.error,
402
796
  style: {
403
- padding: "8px 12px",
404
- marginBottom: 12,
405
- background: "#fee2e2",
406
- color: "#dc2626",
407
- borderRadius: 6,
408
- fontSize: 13
797
+ ...!unstyled ? {
798
+ padding: "8px 12px",
799
+ marginBottom: 12,
800
+ background: "#fee2e2",
801
+ color: "#dc2626",
802
+ borderRadius: 6,
803
+ fontSize: 13
804
+ } : {},
805
+ ...styles.error
409
806
  },
410
807
  children: formError
411
808
  }
412
809
  ),
413
- fields?.map((field) => /* @__PURE__ */ jsxs2("label", { style: labelStyle, children: [
810
+ fields?.map((field) => /* @__PURE__ */ jsxs2("label", { className: classNames.label, style: { ...baseLabelStyle, ...styles.label }, children: [
414
811
  field.label ?? field.name,
415
812
  field.required && /* @__PURE__ */ jsx3("span", { style: { color: "#ef4444", marginLeft: 2 }, children: "*" }),
416
813
  field.type === "textarea" ? /* @__PURE__ */ jsx3(
@@ -420,7 +817,8 @@ function Form({
420
817
  placeholder: field.placeholder,
421
818
  required: field.required,
422
819
  defaultValue: String(initialValues[field.name] ?? ""),
423
- style: { ...inputStyle, minHeight: 80, resize: "vertical" }
820
+ className: classNames.textarea ?? classNames.input,
821
+ style: { ...baseInputStyle, minHeight: 80, resize: "vertical", ...styles.input, ...styles.textarea }
424
822
  }
425
823
  ) : field.type === "select" ? /* @__PURE__ */ jsxs2(
426
824
  "select",
@@ -428,7 +826,8 @@ function Form({
428
826
  name: field.name,
429
827
  required: field.required,
430
828
  defaultValue: String(initialValues[field.name] ?? ""),
431
- style: inputStyle,
829
+ className: classNames.select ?? classNames.input,
830
+ style: { ...baseInputStyle, ...styles.input, ...styles.select },
432
831
  children: [
433
832
  /* @__PURE__ */ jsx3("option", { value: "", children: "Select..." }),
434
833
  field.options?.map((opt) => /* @__PURE__ */ jsx3("option", { value: opt.value, children: opt.label }, opt.value))
@@ -442,7 +841,24 @@ function Form({
442
841
  placeholder: field.placeholder,
443
842
  required: field.required,
444
843
  defaultValue: String(initialValues[field.name] ?? ""),
445
- style: inputStyle
844
+ className: classNames.input,
845
+ style: { ...baseInputStyle, ...styles.input }
846
+ }
847
+ ),
848
+ allFieldErrors[field.name] && /* @__PURE__ */ jsx3(
849
+ "span",
850
+ {
851
+ className: classNames.fieldError,
852
+ style: {
853
+ ...!unstyled ? {
854
+ display: "block",
855
+ marginTop: 4,
856
+ fontSize: 12,
857
+ color: "#dc2626"
858
+ } : {},
859
+ ...styles.fieldError
860
+ },
861
+ children: allFieldErrors[field.name]
446
862
  }
447
863
  )
448
864
  ] }, field.name)),
@@ -452,17 +868,21 @@ function Form({
452
868
  {
453
869
  type: "submit",
454
870
  disabled: submitting,
871
+ className: classNames.submitButton,
455
872
  style: {
456
- marginTop: 8,
457
- padding: "9px 20px",
458
- background: submitting ? "#9ca3af" : "#111827",
459
- color: "#fff",
460
- border: "none",
461
- borderRadius: 6,
462
- fontSize: 14,
463
- fontWeight: 600,
464
- cursor: submitting ? "not-allowed" : "pointer",
465
- width: "100%"
873
+ ...!unstyled ? {
874
+ marginTop: 8,
875
+ padding: "9px 20px",
876
+ background: submitting ? "#9ca3af" : "#111827",
877
+ color: "#fff",
878
+ border: "none",
879
+ borderRadius: 6,
880
+ fontSize: 14,
881
+ fontWeight: 600,
882
+ cursor: submitting ? "not-allowed" : "pointer",
883
+ width: "100%"
884
+ } : {},
885
+ ...styles.submitButton
466
886
  },
467
887
  children: submitting ? "Saving..." : submitLabel
468
888
  }
@@ -473,34 +893,86 @@ function Form({
473
893
  // src/components/Protected.tsx
474
894
  import { useEffect as useEffect3, useState as useState4 } from "react";
475
895
  import { Fragment, jsx as jsx4 } from "react/jsx-runtime";
476
- function Protected({ children, fallback, role }) {
896
+ function Protected({ children, loadingFallback = null, fallback, role }) {
477
897
  const { isAuthenticated, user } = useAuth();
478
- const [checking, setChecking] = useState4(true);
479
- const [allowed, setAllowed] = useState4(false);
898
+ const hasToken = isAuthenticated();
899
+ const [checking, setChecking] = useState4(hasToken && !!role);
900
+ const [allowed, setAllowed] = useState4(hasToken && !role);
480
901
  useEffect3(() => {
481
- async function check() {
482
- if (!isAuthenticated()) {
483
- setAllowed(false);
484
- setChecking(false);
485
- return;
486
- }
487
- if (role) {
488
- const u = await user();
902
+ if (!hasToken) {
903
+ setAllowed(false);
904
+ setChecking(false);
905
+ return;
906
+ }
907
+ if (!role) {
908
+ setAllowed(true);
909
+ setChecking(false);
910
+ return;
911
+ }
912
+ let cancelled = false;
913
+ async function checkRole() {
914
+ const u = await user();
915
+ if (!cancelled) {
489
916
  setAllowed(u?.role === role);
490
- } else {
491
- setAllowed(true);
917
+ setChecking(false);
492
918
  }
493
- setChecking(false);
494
919
  }
495
- void check();
496
- }, [isAuthenticated, role, user]);
497
- if (checking) return /* @__PURE__ */ jsx4(Fragment, {});
920
+ void checkRole();
921
+ return () => {
922
+ cancelled = true;
923
+ };
924
+ }, [hasToken, role, user]);
925
+ if (checking) {
926
+ return /* @__PURE__ */ jsx4(Fragment, { children: loadingFallback });
927
+ }
498
928
  if (!allowed) {
499
929
  return /* @__PURE__ */ jsx4(Fragment, { children: fallback ?? /* @__PURE__ */ jsx4("div", { style: { padding: 24, textAlign: "center", color: "#6b7280" }, children: /* @__PURE__ */ jsx4("p", { children: "You are not authorized to view this page." }) }) });
500
930
  }
501
931
  return /* @__PURE__ */ jsx4(Fragment, { children });
502
932
  }
503
933
 
934
+ // src/components/NeevBoundary.tsx
935
+ import { Component, Suspense } from "react";
936
+ import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
937
+ var NeevErrorBoundary = class extends Component {
938
+ constructor(props) {
939
+ super(props);
940
+ this.state = { hasError: false, error: null };
941
+ }
942
+ static getDerivedStateFromError(error) {
943
+ return { hasError: true, error };
944
+ }
945
+ componentDidCatch(error, errorInfo) {
946
+ console.error("NeevBoundary caught an error:", error, errorInfo);
947
+ }
948
+ resetErrorBoundary = () => {
949
+ this.setState({ hasError: false, error: null });
950
+ };
951
+ render() {
952
+ if (this.state.hasError && this.state.error) {
953
+ if (this.props.fallback) {
954
+ return this.props.fallback(this.state.error, this.resetErrorBoundary);
955
+ }
956
+ return /* @__PURE__ */ jsxs3("div", { style: { padding: "1rem", background: "#fee2e2", color: "#b91c1c", borderRadius: "8px", fontFamily: "sans-serif" }, children: [
957
+ /* @__PURE__ */ jsx5("h3", { style: { margin: "0 0 8px 0" }, children: "Data Error" }),
958
+ /* @__PURE__ */ jsx5("p", { style: { margin: 0, fontSize: "14px" }, children: this.state.error.message }),
959
+ /* @__PURE__ */ jsx5(
960
+ "button",
961
+ {
962
+ onClick: this.resetErrorBoundary,
963
+ style: { marginTop: "12px", padding: "6px 12px", background: "#b91c1c", color: "white", border: "none", borderRadius: "4px", cursor: "pointer", fontSize: "13px" },
964
+ children: "Try again"
965
+ }
966
+ )
967
+ ] });
968
+ }
969
+ return this.props.children;
970
+ }
971
+ };
972
+ function NeevBoundary({ children, loadingFallback, errorFallback }) {
973
+ return /* @__PURE__ */ jsx5(NeevErrorBoundary, { fallback: errorFallback, children: /* @__PURE__ */ jsx5(Suspense, { fallback: loadingFallback ?? /* @__PURE__ */ jsx5("div", { style: { padding: "1rem", color: "#6b7280", fontFamily: "sans-serif" }, children: "Loading data..." }), children }) });
974
+ }
975
+
504
976
  // src/plugins/AuthPlugin.ts
505
977
  var AuthPlugin = {
506
978
  name: "auth",
@@ -520,18 +992,19 @@ var AuthPlugin = {
520
992
  };
521
993
 
522
994
  // src/plugins/LoggerPlugin.ts
995
+ import { VERSION } from "@neevjs/shared";
523
996
  var LoggerPlugin = {
524
997
  name: "logger",
525
998
  onRequest(req) {
526
- console.log(`[NeevJS] \u2192 ${req.options.method ?? "GET"} ${req.url}`);
999
+ console.log(`[NeevJS v${VERSION}] \u2192 ${req.options.method ?? "GET"} ${req.url}`);
527
1000
  return req;
528
1001
  },
529
- onResponse(res) {
530
- console.log(`[NeevJS] \u2190 ${res.status} ${res.url}`);
1002
+ onResponse(res, req) {
1003
+ console.log(`[NeevJS v${VERSION}] \u2190 ${res.status} ${req.options.method ?? "GET"} ${req.url}`);
531
1004
  return res;
532
1005
  },
533
- onError(err) {
534
- console.error("[NeevJS] \u2717 Error:", err.message);
1006
+ onError(err, req) {
1007
+ console.error(`[NeevJS v${VERSION}] \u2717 Error on ${req?.url ?? "unknown"}:`, err.message);
535
1008
  }
536
1009
  };
537
1010
 
@@ -543,25 +1016,36 @@ function createCachePlugin(options = {}) {
543
1016
  name: "cache",
544
1017
  onRequest(req) {
545
1018
  const method = (req.options.method ?? "GET").toUpperCase();
546
- if (method !== "GET") return req;
1019
+ if (method !== "GET") {
1020
+ store.delete(req.url);
1021
+ const parts = req.url.split("/");
1022
+ if (parts.length > 2) {
1023
+ const baseUrl = "/" + parts[1];
1024
+ store.delete(baseUrl);
1025
+ }
1026
+ return req;
1027
+ }
547
1028
  const entry = store.get(req.url);
548
1029
  if (entry && Date.now() < entry.expiresAt) {
549
1030
  const cached = new Response(JSON.stringify(entry.data), {
550
1031
  status: 200,
551
- headers: { "Content-Type": "application/json", "X-Neev-Cache": "HIT" }
1032
+ headers: {
1033
+ "Content-Type": "application/json",
1034
+ "X-Neev-Cache": "HIT"
1035
+ }
552
1036
  });
553
1037
  return { ...req, _cachedResponse: cached };
554
1038
  }
555
1039
  return req;
556
1040
  },
557
- async onResponse(res) {
558
- if (res.headers.get("X-Neev-Cache") === "HIT") return res;
559
- const url = res.url;
560
- if (!url) return res;
1041
+ async onResponse(res, req) {
1042
+ if (res.headers.get("X-Neev-Cache") === "HIT" || !res.ok) return res;
1043
+ const method = (req.options.method ?? "GET").toUpperCase();
1044
+ if (method !== "GET") return res;
561
1045
  try {
562
1046
  const clone = res.clone();
563
1047
  const data = await clone.json();
564
- store.set(url, { data, expiresAt: Date.now() + ttl });
1048
+ store.set(req.url, { data, expiresAt: Date.now() + ttl });
565
1049
  } catch {
566
1050
  }
567
1051
  return res;
@@ -592,27 +1076,54 @@ function saveQueue(queue) {
592
1076
  }
593
1077
  function createOfflinePlugin() {
594
1078
  let client;
1079
+ let onlineHandler = null;
595
1080
  async function processQueue() {
596
1081
  if (!navigator.onLine) return;
597
1082
  const queue = loadQueue();
598
1083
  if (queue.length === 0) return;
1084
+ syncState.syncing = true;
1085
+ syncState.notify();
599
1086
  const remaining = [];
1087
+ let hasChanges = false;
1088
+ const urlsToMutate = /* @__PURE__ */ new Set();
600
1089
  for (const action of queue) {
601
1090
  try {
602
1091
  await client.request(action.url, action.options);
603
- } catch {
604
- remaining.push({ ...action, retries: action.retries + 1 });
1092
+ hasChanges = true;
1093
+ const baseUrl = "/" + (action.url.split("/")[1] || "");
1094
+ urlsToMutate.add(baseUrl);
1095
+ } catch (err) {
1096
+ if (err.status && err.status >= 400 && err.status < 500) {
1097
+ syncState.errors.push(new Error(`Offline action on ${action.url} failed: ${err.message}`));
1098
+ hasChanges = true;
1099
+ const baseUrl = "/" + (action.url.split("/")[1] || "");
1100
+ urlsToMutate.add(baseUrl);
1101
+ } else {
1102
+ remaining.push({ ...action, retries: action.retries + 1 });
1103
+ }
605
1104
  }
606
1105
  }
607
1106
  saveQueue(remaining);
1107
+ syncState.syncing = false;
1108
+ syncState.notify();
1109
+ if (hasChanges) {
1110
+ urlsToMutate.forEach((url) => mutateModel(url));
1111
+ }
608
1112
  }
609
- window.addEventListener("online", () => {
610
- void processQueue();
611
- });
612
1113
  return {
613
1114
  name: "offline",
614
1115
  setup(c) {
615
1116
  client = c;
1117
+ if (onlineHandler) {
1118
+ window.removeEventListener("online", onlineHandler);
1119
+ }
1120
+ onlineHandler = () => {
1121
+ void processQueue();
1122
+ };
1123
+ window.addEventListener("online", onlineHandler);
1124
+ if (navigator.onLine && loadQueue().length > 0) {
1125
+ setTimeout(() => void processQueue(), 500);
1126
+ }
616
1127
  },
617
1128
  onRequest(req) {
618
1129
  const method = (req.options.method ?? "GET").toUpperCase();
@@ -641,16 +1152,23 @@ export {
641
1152
  CachePlugin,
642
1153
  Form,
643
1154
  LoggerPlugin,
1155
+ NeevBoundary,
644
1156
  NeevContext,
645
1157
  NeevProvider,
646
1158
  OfflinePlugin,
647
1159
  Protected,
1160
+ SecureStore,
648
1161
  Table,
1162
+ VERSION2 as VERSION,
1163
+ clearPersistedStore,
649
1164
  createCachePlugin,
650
1165
  createClient,
651
1166
  createOfflinePlugin,
1167
+ getStore,
1168
+ setStore,
652
1169
  useAuth,
653
1170
  useModel,
654
1171
  useNeevClient,
1172
+ useStore,
655
1173
  useSyncStatus
656
1174
  };