Merge pull request 'core/page-provider' (#39) from core/page-provider into main

Reviewed-on: eigen/fe-monorepo-template#39
This commit is contained in:
2026-07-27 15:57:53 +00:00
7 changed files with 483 additions and 54 deletions
+313 -1
View File
@@ -1,3 +1,315 @@
import { TextInput, PasswordInput, Checkbox, Button, Divider } from '@repo/ui/components';
import { Mail, Lock, LogIn, Apple, Globe, MessageCircle } from 'lucide-react';
const GoogleIcon = (props: any) => (
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
const MicrosoftIcon = (props: any) => (
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M1 1h10.5v10.5H1z" fill="#f35325" />
<path d="M12.5 1H23v10.5H12.5z" fill="#81bc06" />
<path d="M1 12.5h10.5V23H1z" fill="#05a6f0" />
<path d="M12.5 12.5H23V23H12.5z" fill="#ffba08" />
</svg>
);
const AndroidIcon = (props: any) => (
<svg viewBox="0 0 24 24" fill="#3DDC84" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M17.5 12C18.33 12 19 11.33 19 10.5C19 9.67 18.33 9 17.5 9C16.67 9 16 9.67 16 10.5C16 11.33 16.67 12 17.5 12ZM6.5 12C7.33 12 8 11.33 8 10.5C8 9.67 7.33 9 6.5 9C5.67 9 5 9.67 5 10.5C5 11.33 5.67 12 6.5 12Z" />
<path d="M19.46 8.52C19.31 8.52 19.17 8.56 19.04 8.65L17.12 9.99C15.56 9.29 13.83 8.9 12 8.9C10.17 8.9 8.44 9.29 6.88 9.99L4.96 8.65C4.83 8.56 4.69 8.52 4.54 8.52C4.1 8.52 3.74 8.88 3.74 9.32C3.74 9.58 3.87 9.82 4.08 9.97L5.7 11.11C3.47 12.8 2 15.39 2 18.29H22C22 15.39 20.53 12.8 18.3 11.11L19.92 9.97C20.13 9.82 20.26 9.58 20.26 9.32C20.26 8.88 19.9 8.52 19.46 8.52Z" />
</svg>
);
const ChromeIcon = (props: any) => (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 7C9.23858 7 7 9.23858 7 12C7 14.7614 9.23858 17 12 17C14.7614 17 17 14.7614 17 12C17 9.23858 14.7614 7 12 7Z"
fill="#FACC15"
/>
<path d="M12 2C16.8 2 20.8 5.4 21.8 10H12L7 18.6C5.1 16.9 4 14.6 4 12C4 7.6 7.6 4 12 4V2Z" fill="#F87171" />
<path
d="M22 12C22 17.5 17.5 22 12 22C10 22 8.1 21.4 6.5 20.3L11.5 11.6L21.8 10C21.9 10.6 22 11.3 22 12Z"
fill="#4ADE80"
/>
<circle cx="12" cy="12" r="4" fill="#3B82F6" />
</svg>
);
const Ring = ({ size }: { size: number }) => (
<div
className="absolute rounded-full border border-white/60"
style={{ width: size, height: size, top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}
/>
);
const IconCircle = ({ x, y, children }: { x: number; y: number; children: React.ReactNode }) => (
<div
className="absolute flex items-center justify-center w-10 h-10 bg-white rounded-full shadow-lg"
style={{
left: `calc(50% + ${x}px)`,
top: `calc(50% + ${y}px)`,
transform: 'translate(-50%, -50%)',
}}
>
{children}
</div>
);
export default function LoginPage() {
return <div>Login Page</div>;
return (
<div className="bg-white w-full min-h-screen flex overflow-hidden">
{/* Left Column - Login Form */}
<div className="w-full lg:w-1/2 p-6 sm:p-10 lg:p-16 flex flex-col justify-center relative">
{/* Logo (Top Left) */}
<div className="absolute top-8 left-8 sm:top-12 sm:left-12 flex items-center gap-2">
{/* Image Logo (Uncomment and set src to your logo path) */}
{/* <img src="/logo.png" alt="Logo" className="w-8 h-8 object-contain" /> */}
{/* Text Logo (Remove if using image) */}
<div className="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold text-xl">
E
</div>
<div className="font-bold text-gray-900 text-xl tracking-tight select-none">Eigen</div>
</div>
<div className="w-full max-w-[400px] mx-auto mt-12 lg:mt-0">
{/* Header */}
<div className="relative mb-6">
<div className="absolute inset-0 flex items-center justify-center opacity-40 z-0">
<div
// className="w-[180px] h-[100px]"
className="w-[480px] h-[120px]"
style={{
backgroundImage:
'linear-gradient(to right, #cbd5e1 1px, transparent 1px), linear-gradient(to bottom, #cbd5e1 1px, transparent 1px)',
backgroundSize: '16px 16px',
maskImage: 'radial-gradient(ellipse at center, black 30%, transparent 70%)',
WebkitMaskImage: 'radial-gradient(ellipse at center, black 30%, transparent 70%)',
}}
></div>
</div>
<div className="w-14 h-14 bg-brand-600 rounded-2xl flex items-center justify-center text-white shadow-lg shadow-brand-500/30 mx-auto relative z-10">
<LogIn size={26} strokeWidth={2.5} />
</div>
</div>
<h1 className="text-2xl sm:text-[26px] font-bold text-gray-900 text-center mb-2 tracking-tight">
Login to your account!
</h1>
<p className="text-gray-500 text-sm text-center mb-10">
Enter your registered email address and password to login!
</p>
{/* Form */}
<div className="space-y-5">
<TextInput
label="Email"
placeholder="eg. pixelcot@gmail.com"
leftSection={<Mail size={18} className="text-gray-400" />}
size="md"
styles={{
label: { color: '#64748b', fontSize: '13px', fontWeight: 500, marginBottom: '6px' },
input: { borderRadius: '10px', border: '1px solid #e2e8f0', color: '#1e293b' },
}}
/>
<PasswordInput
label="Password"
placeholder="***************"
leftSection={<Lock size={18} className="text-gray-400" />}
size="md"
styles={{
label: { color: '#64748b', fontSize: '13px', fontWeight: 500, marginBottom: '6px' },
input: { borderRadius: '10px', border: '1px solid #e2e8f0', color: '#1e293b' },
}}
/>
<div className="flex items-center justify-between pt-1">
<Checkbox
label="Remember me"
size="sm"
styles={{
label: { color: '#64748b', fontSize: '13px', fontWeight: 500 },
}}
/>
<a href="#" className="text-[13px] text-brand-600 font-semibold hover:underline">
Forgot Password ?
</a>
</div>
<Button
fullWidth
size="md"
className="bg-brand-600 hover:bg-brand-700 mt-2 transition-colors duration-200"
styles={{ root: { borderRadius: '10px', height: '44px', fontWeight: 600 } }}
>
Login
</Button>
<Divider
label="Or login with"
labelPosition="center"
my="lg"
styles={{ label: { color: '#94a3b8', fontSize: '12px' } }}
/>
<div className="grid grid-cols-3 gap-4">
<Button
variant="default"
size="md"
className="border-gray-200 hover:bg-gray-50 transition-colors"
styles={{ root: { borderRadius: '10px', height: '44px' } }}
>
<GoogleIcon className="w-5 h-5" />
</Button>
<Button
variant="default"
size="md"
className="border-gray-200 hover:bg-gray-50 transition-colors"
styles={{ root: { borderRadius: '10px', height: '44px' } }}
>
<Apple className="w-[22px] h-[22px] text-black" fill="black" />
</Button>
<Button
variant="default"
size="md"
className="border-gray-200 hover:bg-gray-50 transition-colors"
styles={{ root: { borderRadius: '10px', height: '44px' } }}
>
<MicrosoftIcon className="w-5 h-5" />
</Button>
</div>
</div>
</div>
</div>
{/* Right Column - Graphic */}
<div className="hidden lg:flex w-1/2 bg-gradient-to-br from-brand-300 via-brand-100 to-brand-50 relative flex-col items-center justify-center p-12 overflow-hidden shadow-inner border-l border-brand-100">
{/* 1. Glossy & Glassmorphic Orbs */}
<div className="absolute -top-10 -right-10 w-72 h-72 bg-white/40 rounded-full blur-3xl pointer-events-none mix-blend-overlay" />
<div className="absolute top-1/3 left-1/4 w-96 h-96 bg-brand-400/20 rounded-full blur-3xl pointer-events-none" />
<div className="absolute -bottom-10 -left-10 w-80 h-80 bg-white/60 rounded-full blur-3xl pointer-events-none" />
<h2 className="z-10 mt-6 mb-3 text-3xl xl:text-4xl font-bold tracking-tight text-center text-brand-950 leading-tight">
Write Better{' '}
<span className="block sm:inline text-transparent bg-clip-text bg-gradient-to-r from-brand-700 via-brand-600 to-brand-500 filter drop-shadow-[0_2px_8px_rgba(var(--color-brand-500),0.15)]">
Everywhere
</span>
</h2>
{/* Graphics Area */}
<div className="flex-1 w-full relative flex items-center justify-center min-h-[400px]">
{/* Concentric Rings */}
<Ring size={200} />
<Ring size={300} />
<Ring size={420} />
<Ring size={540} />
{/* Center Logo */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
<div className="w-[72px] h-[72px] bg-brand-600 rounded-full flex items-center justify-center shadow-xl shadow-brand-600/40 relative">
{/* Glowing backdrop */}
<div className="absolute inset-0 bg-brand-600 rounded-full blur-md opacity-40"></div>
{/* Custom P/Pen Logo from reference */}
<svg
viewBox="0 0 32 32"
className="w-[42px] h-[42px] text-white relative z-10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 7C12 6.44772 12.4477 6 13 6H18C21.3137 6 24 8.68629 24 12C24 15.3137 21.3137 18 18 18H15V25C15 25.5523 14.5523 26 14 26H13C12.4477 26 12 25.5523 12 25V7ZM15 9V15H18C19.6569 15 21 13.6569 21 12C21 10.3431 19.6569 9 18 9H15Z"
fill="white"
/>
<path d="M16.5 10L16.5 14L15 14L15 10L16.5 10Z" className="fill-brand-600" />
<path d="M15.75 16L14 20L17.5 20L15.75 16Z" className="fill-brand-600" />
</svg>
</div>
</div>
{/* Orbiting Icons */}
{/* Ring 1 (r=100) */}
<div className="animate-floating" style={{ animationDelay: '0s' }}>
<IconCircle x={-70} y={-70}>
<Mail className="w-5 h-5 text-[#0078D4]" fill="#0078D4" stroke="#fff" />
</IconCircle>
</div>
<div className="animate-floating" style={{ animationDelay: '0.5s' }}>
<IconCircle x={85} y={35}>
<MessageCircle className="w-[22px] h-[22px] text-[#0084FF]" fill="#0084FF" stroke="none" />
</IconCircle>
</div>
{/* Ring 2 (r=150) */}
<div className="animate-floating" style={{ animationDelay: '1s' }}>
<IconCircle x={0} y={-150}>
<Globe className="w-6 h-6 text-[#0078D7]" />
</IconCircle>
</div>
<div className="animate-floating" style={{ animationDelay: '0.2s' }}>
<IconCircle x={-60} y={138}>
<span className="text-[#6001D2] font-bold italic text-xl -ml-1">y!</span>
</IconCircle>
</div>
{/* Ring 3 (r=210) */}
<div className="animate-floating" style={{ animationDelay: '0.8s' }}>
<IconCircle x={-185} y={-70}>
<AndroidIcon className="w-6 h-6" />
</IconCircle>
</div>
<div className="animate-floating" style={{ animationDelay: '1.5s' }}>
<IconCircle x={200} y={50}>
<Apple className="w-[22px] h-[22px] text-black" fill="black" />
</IconCircle>
</div>
{/* Ring 4 (r=270) */}
<div className="animate-floating" style={{ animationDelay: '0.4s' }}>
<IconCircle x={-190} y={180}>
<ChromeIcon className="w-[26px] h-[26px]" />
</IconCircle>
</div>
</div>
<p className="text-[13px] text-center text-gray-600 mt-6 max-w-[85%] z-10 leading-[1.6]">
Compatible with{' '}
<span className="font-bold italic text-gray-900">Gmail, Outlook Web, LinkedIn and most web editors</span> for
a smooth writing experience anywhere online.
</p>
{/* Pagination Indicators */}
<div className="flex gap-[6px] mt-8 z-10 mb-2">
<div className="w-6 h-[4px] rounded-full bg-brand-500"></div>
<div className="w-6 h-[4px] rounded-full bg-brand-200"></div>
<div className="w-6 h-[4px] rounded-full bg-brand-200"></div>
</div>
</div>
</div>
);
}
+36 -6
View File
@@ -5,7 +5,7 @@ export const AppStorageKey = {
THEME: 'app_theme',
ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token',
USER_ID: 'u_id',
USER_ID: 'uid',
SIDEBAR_OPEN_MENUS: 'sidebar_open_menus',
} as const;
@@ -21,18 +21,43 @@ export const AppDatabaseKey = {
export type AppDatabaseKeyValue = (typeof AppDatabaseKey)[keyof typeof AppDatabaseKey];
function getUserId(userIdKey: string) {
const rawId = localStorage.getItem(userIdKey);
if (!rawId) return null;
try {
return JSON.parse(rawId);
} catch (error) {
console.error('Failed to parse User ID:', error);
return null;
}
}
export const APP_STORAGE_ENCRYPTED_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.USER_ID,
AppStorageKey.ACCESS_TOKEN,
AppStorageKey.REFRESH_TOKEN,
]);
export const APP_STORAGE_PLAIN_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.USER_ID,
AppStorageKey.LANGUAGE,
AppStorageKey.THEME,
AppStorageKey.SIDEBAR_OPEN_MENUS,
]);
export const APP_STORAGE_PERSONALIZED_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.LANGUAGE,
AppStorageKey.THEME,
AppStorageKey.SIDEBAR_OPEN_MENUS,
]);
export const appStorage = createLocalStorage<AppStorageKeyValue>({
encryptedKeys: APP_STORAGE_ENCRYPTED_KEYS,
plainTextKeys: APP_STORAGE_PLAIN_KEYS,
personalizedKeys: APP_STORAGE_PERSONALIZED_KEYS,
getUserId: () => getUserId(AppStorageKey.USER_ID) || null,
});
export const APP_DATABASE_ENCRYPTED_KEYS = new Set<AppDatabaseKeyValue>([]);
export const APP_DATABASE_PLAIN_KEYS = new Set<AppDatabaseKeyValue>([
@@ -43,14 +68,19 @@ export const APP_DATABASE_PLAIN_KEYS = new Set<AppDatabaseKeyValue>([
AppDatabaseKey.BOOKMARK_PAGE,
]);
export const appStorage = createLocalStorage<AppStorageKeyValue>({
encryptedKeys: APP_STORAGE_ENCRYPTED_KEYS,
plainTextKeys: APP_STORAGE_PLAIN_KEYS,
});
export const APP_DATABASE_PERSONALIZED_KEYS = new Set<AppDatabaseKeyValue>([
AppDatabaseKey.USER_PROFILE,
AppDatabaseKey.OFFLINE_DRAFT,
AppDatabaseKey.SYSTEM_SETTINGS,
AppDatabaseKey.HISTORY_PAGE,
AppDatabaseKey.BOOKMARK_PAGE,
]);
export const appDatabase = createIndexedDB<AppDatabaseKeyValue>({
dbName: 'e_apps_db',
storeName: 'web_store',
encryptedKeys: APP_DATABASE_ENCRYPTED_KEYS,
plainTextKeys: APP_DATABASE_PLAIN_KEYS,
personalizedKeys: APP_DATABASE_PERSONALIZED_KEYS,
getUserId: () => getUserId(AppStorageKey.USER_ID) || null,
});
+29
View File
@@ -1,3 +1,32 @@
@import 'tailwindcss';
@import '@repo/ui/theme.css';
@source "../../../packages/ui/src";
/* =========================================
ANIMATION: FLOATING
========================================= */
/* --- Keyframes Definition --- */
@keyframes floating {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px); /* Peak float distance (adjust as needed) */
}
}
/* --- Main Utility Class --- */
.animate-floating {
animation: floating 4s ease-in-out infinite;
}
/* --- Speed Variations (Optional) --- */
.animate-floating-fast {
animation: floating 3s ease-in-out infinite;
}
.animate-floating-slow {
animation: floating 5s ease-in-out infinite;
}
@@ -37,7 +37,7 @@ function openDatabase(dbName: string, storeName: string, version: number): Promi
/**
* Execute a single IndexedDB transaction and return the result.
* Handles open → transaction → request → close lifecycle cleanly.
* Resolves only when the entire transaction is completed to prevent silent failures.
*/
function withTransaction<R>(
db: IDBDatabase,
@@ -48,10 +48,20 @@ function withTransaction<R>(
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, mode);
const store = tx.objectStore(storeName);
let requestResult: R;
const request = operation(store);
request.onsuccess = () => resolve(request.result);
request.onsuccess = () => {
requestResult = request.result;
};
request.onerror = () => reject(request.error);
// Resolve promise ONLY after transaction is successfully committed
tx.oncomplete = () => resolve(requestResult);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
@@ -67,6 +77,8 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
private readonly version: number;
private readonly encryptedKeys: Set<TKey>;
private readonly plainTextKeys: Set<TKey>;
private readonly personalizedKeys?: Set<TKey>;
private readonly getUserId?: () => string | null | undefined;
private dbPromise: Promise<IDBDatabase> | null = null;
constructor(config?: IndexedDBConfig<TKey>, encryptionUtils?: EncryptionUtils) {
@@ -76,6 +88,8 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
this.version = config?.version ?? 1;
this.encryptedKeys = config?.encryptedKeys ?? new Set();
this.plainTextKeys = config?.plainTextKeys ?? new Set();
this.personalizedKeys = config?.personalizedKeys;
this.getUserId = config?.getUserId;
}
/** Lazy-open the database connection (cached). */
@@ -96,37 +110,46 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
return this.encryptedKeys.has(key);
}
private resolveKey(key: TKey): string {
if (this.personalizedKeys?.has(key)) {
const userId = this.getUserId?.() || 'guest';
return `${String(key)}_${userId}`;
}
return String(key);
}
async setItem<T>(key: TKey, value: T): Promise<void> {
this.validateKey(key);
const db = await this.getDB();
const resolvedKey = this.resolveKey(key);
const serialized = JSON.stringify(value);
const payload = this.shouldEncrypt(key) ? this.encryption.encrypt(serialized) : serialized;
await withTransaction(db, this.storeName, 'readwrite', (store) => store.put(payload, key as string));
await withTransaction(db, this.storeName, 'readwrite', (store) => store.put(payload, resolvedKey));
}
async getItem<T>(key: TKey): Promise<T | null> {
this.validateKey(key);
const db = await this.getDB();
const resolvedKey = this.resolveKey(key);
const raw = await withTransaction<string | undefined>(
db,
this.storeName,
'readonly',
(store) => store.get(key as string) as IDBRequest<string | undefined>,
(store) => store.get(resolvedKey) as IDBRequest<string | undefined>,
);
if (raw === undefined || raw === null) return null;
try {
if (this.shouldEncrypt(key)) {
const decrypted = this.encryption.decrypt(raw);
const decrypted = this.shouldEncrypt(key) ? this.encryption.decrypt(raw) : raw;
if (!decrypted) return null;
return JSON.parse(decrypted) as T;
}
return JSON.parse(raw) as T;
} catch {
console.warn(`[core-storage] Failed to parse IndexedDB key "${key}". Removing corrupt entry.`);
console.warn(`[core-storage] Failed to parse IndexedDB key "${String(key)}". Removing corrupt entry.`);
await this.removeItem(key);
return null;
}
@@ -135,12 +158,16 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
async removeItem(key: TKey): Promise<void> {
this.validateKey(key);
const db = await this.getDB();
await withTransaction(db, this.storeName, 'readwrite', (store) => store.delete(key as string));
const resolvedKey = this.resolveKey(key);
await withTransaction(db, this.storeName, 'readwrite', (store) => store.delete(resolvedKey));
}
async clear(): Promise<void> {
const db = await this.getDB();
await withTransaction(db, this.storeName, 'readwrite', (store) => store.clear());
const allValidKeys = await this.keys();
for (const key of allValidKeys) {
await this.removeItem(key);
}
}
async hasItem(key: TKey): Promise<boolean> {
@@ -150,13 +177,34 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
async keys(): Promise<TKey[]> {
const db = await this.getDB();
const allKeys = await withTransaction<string[]>(
const rawKeys = await withTransaction<string[]>(
db,
this.storeName,
'readonly',
(store) => store.getAllKeys() as IDBRequest<string[]>,
);
return allKeys as TKey[];
const resultSet = new Set<TKey>();
const currentUserId = this.getUserId?.() || 'guest';
for (const rawKey of rawKeys) {
const isGlobalKey = this.encryptedKeys.has(rawKey as TKey) || this.plainTextKeys.has(rawKey as TKey);
if (isGlobalKey && !this.personalizedKeys?.has(rawKey as TKey)) {
resultSet.add(rawKey as TKey);
}
if (this.personalizedKeys) {
for (const pKey of this.personalizedKeys) {
if (rawKey === `${String(pKey)}_${currentUserId}`) {
resultSet.add(pKey);
break;
}
}
}
}
return Array.from(resultSet);
}
}
@@ -4,6 +4,8 @@ import type { IStorageService } from '../types/storage.interface';
export interface StorageOptions<TKey extends string> {
encryptedKeys?: Set<TKey>;
plainTextKeys?: Set<TKey>;
personalizedKeys?: Set<TKey>;
getUserId?: () => string | null | undefined;
}
/**
@@ -13,11 +15,15 @@ export class LocalStorageService<TKey extends string> implements IStorageService
private readonly encryption: EncryptionUtils;
private readonly encryptedKeys: Set<TKey>;
private readonly plainTextKeys: Set<TKey>;
private readonly personalizedKeys?: Set<TKey>;
private readonly getUserId?: () => string | null | undefined;
constructor(options?: StorageOptions<TKey>, encryptionUtils?: EncryptionUtils) {
this.encryption = encryptionUtils ?? EncryptionUtils.getInstance();
this.encryptedKeys = options?.encryptedKeys ?? new Set();
this.plainTextKeys = options?.plainTextKeys ?? new Set();
this.personalizedKeys = options?.personalizedKeys;
this.getUserId = options?.getUserId;
}
private validateKey(key: TKey): void {
@@ -26,25 +32,35 @@ export class LocalStorageService<TKey extends string> implements IStorageService
}
}
private resolveKey(key: TKey): string {
if (this.personalizedKeys?.has(key)) {
const userId = this.getUserId?.() || 'guest';
return `${String(key)}_${userId}`;
}
return String(key);
}
private shouldEncrypt(key: TKey): boolean {
return this.encryptedKeys.has(key);
}
async setItem<T>(key: TKey, value: T): Promise<void> {
this.validateKey(key);
const resolvedKey = this.resolveKey(key);
const serialized = JSON.stringify(value);
if (this.shouldEncrypt(key)) {
const encrypted = this.encryption.encrypt(serialized);
localStorage.setItem(key as string, encrypted);
localStorage.setItem(resolvedKey, encrypted);
} else {
localStorage.setItem(key as string, serialized);
localStorage.setItem(resolvedKey, serialized);
}
}
async getItem<T>(key: TKey): Promise<T | null> {
this.validateKey(key);
const raw = localStorage.getItem(key as string);
const resolvedKey = this.resolveKey(key);
const raw = localStorage.getItem(resolvedKey);
if (raw === null) return null;
try {
@@ -55,15 +71,16 @@ export class LocalStorageService<TKey extends string> implements IStorageService
}
return JSON.parse(raw) as T;
} catch {
console.warn(`[core-storage] Failed to parse key "${key}". Removing corrupt entry.`);
localStorage.removeItem(key as string);
console.warn(`[core-storage] Failed to parse key "${String(key)}". Removing corrupt entry.`);
localStorage.removeItem(resolvedKey);
return null;
}
}
async removeItem(key: TKey): Promise<void> {
this.validateKey(key);
localStorage.removeItem(key as string);
const resolvedKey = this.resolveKey(key);
localStorage.removeItem(resolvedKey);
}
async clear(): Promise<void> {
@@ -71,7 +88,8 @@ export class LocalStorageService<TKey extends string> implements IStorageService
}
async hasItem(key: TKey): Promise<boolean> {
return localStorage.getItem(key as string) !== null;
const resolvedKey = this.resolveKey(key);
return localStorage.getItem(resolvedKey) !== null;
}
async keys(): Promise<TKey[]> {
@@ -52,6 +52,7 @@ export const agGridMantineTheme = themeQuartz
/* ── Row Styling ────────────────────────────────────────────── */
oddRowBackgroundColor: 'var(--mantine-color-default-hover)',
// oddRowBackgroundColor: 'color-mix(in srgb, var(--mantine-color-text) 2.5%, transparent)',
rowHoverColor: 'var(--mantine-primary-color-light)',
selectedRowBackgroundColor: 'var(--mantine-primary-color-light)',
@@ -61,6 +62,7 @@ export const agGridMantineTheme = themeQuartz
/* ── Spacing ────────────────────────────────────────────────── */
spacing: 'var(--mantine-spacing-sm)',
// spacing: 'var(--mantine-spacing-xs)',
})
/* ═══════════════════════════════════════════════════════════════
@@ -3,8 +3,6 @@ import { useState, useEffect, useCallback } from 'react';
import { useEnterpriseModuleConfigContext } from './use-module.context';
import { DraftConfig, FormPageType } from '../entities/entity';
import {
appStorage,
AppStorageKey,
appDatabase,
AppDatabaseKey,
} from '../../../../../../apps/web/src/core/storage/local';
@@ -19,36 +17,28 @@ export function useFormDraftContext({ config }: FormDraftContextProps) {
const { config: moduleConfig } = useEnterpriseModuleConfigContext();
const [hasDraft, setHasDraft] = useState(false);
const [draftData, setDraftData] = useState<any>(null);
const [userID, setUserID] = useState<string>('UNRESOLVED_PRINCIPAL');
const isEnabled = config?.enableDraft === true;
// Use a generic 'CREATE' key for both CREATE and DUPLICATE so that drafts saved
// during DUPLICATE can be restored when entering a new CREATE form.
const draftKey = `${userID}:draft:${moduleConfig.moduleKey}:CREATE`;
const draftKey = `draft:${moduleConfig.moduleKey}:CREATE`;
// Check for existing draft on mount
useEffect(() => {
if (!isEnabled) return;
// Fetch user ID asynchronously using appStorage
appStorage.getItem(AppStorageKey.USER_ID).then((id: any) => {
const resolvedUserId = id || 'UNRESOLVED_PRINCIPAL';
setUserID(resolvedUserId);
const resolvedDraftKey = `${resolvedUserId}:draft:${moduleConfig.moduleKey}:CREATE`;
appDatabase
.getItem(AppDatabaseKey.OFFLINE_DRAFT)
.then((allDrafts: any) => {
const drafts = allDrafts || {};
if (drafts[resolvedDraftKey]) {
setDraftData(drafts[resolvedDraftKey]);
if (drafts[draftKey]) {
setDraftData(drafts[draftKey]);
setHasDraft(true);
}
})
.catch((err) => {
console.error('[Draft Recovery] Failed to read from appDatabase:', err);
});
});
}, [isEnabled, moduleConfig.moduleKey]);
}, [isEnabled, draftKey]);
const saveDraft = useCallback(
(data: any) => {