feat: Implement history and bookmark management features

- Added BookmarkDrawer component for managing saved bookmarks.
- Added HistoryDrawer component for viewing and managing browsing history.
- Introduced useHistoryTracker hook to track page visits and sync history across tabs.
- Created HistorySetting and NotificationSetting components for user-configurable settings.
- Implemented IndexedDB storage for bookmarks and history items.
- Added event constants for layout, device, and authentication events.
- Updated localization files for new features in English and Indonesian.
- Enhanced system settings to include history retention configuration.
This commit is contained in:
Firman Ramdhani
2026-07-13 14:09:03 +07:00
parent 164ec284ae
commit eeab2df2f2
30 changed files with 1173 additions and 87 deletions
+8 -1
View File
@@ -1,9 +1,10 @@
import { lazy, Suspense, useState } from 'react';
import { lazy, Suspense, useEffect, useState } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { ThemeProvider, DensityType } from '@repo/ui/provider';
import { NotFound, Forbidden, Maintenance, ComingSoon } from '@repo/ui/components';
import { LoadingScreen } from '../core/components/loading-screen';
import { useThemeStore } from '../core/stores/theme.store';
import { initializeAndPurgeHistoryBackground } from './modules/layouts/hooks/useHistoryTracker';
const AuthModule = lazy(() => import('./auth'));
const AppModule = lazy(() => import('./modules'));
@@ -14,6 +15,12 @@ export default function App() {
const colorScheme = useThemeStore((s) => s.colorScheme);
const [density, setDensity] = useState<DensityType>('compact');
useEffect(() => {
// Execution runs purely in the background (fire and forget)
// Will not block the initial UI rendering process
initializeAndPurgeHistoryBackground();
}, []);
return (
<ThemeProvider colorScheme={colorScheme} density={density}>
<BrowserRouter>
@@ -0,0 +1,148 @@
import { useState, useEffect } from 'react';
import { Drawer, Text, Stack, NavLink, ThemeIcon, ActionIcon, ScrollArea, Group } from '@repo/ui/components';
import { useAppEvent } from '@repo/core-events';
import { useTranslation } from '@repo/core-i18n';
import { Bookmark, Trash2 } from 'lucide-react';
import { LAYOUT_EVENTS } from '../../../../../core/constants/events';
import { secureIndexedDB, AppStorageKey } from '../../../../../core/storage/local';
import { Link } from 'react-router-dom';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type { SavedPageItem } from '../../types/saved-page.types';
// Setup relative time formatting
dayjs.extend(relativeTime);
export function BookmarkDrawer() {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [items, setItems] = useState<SavedPageItem[]>([]);
// Listen to the global event bus to toggle the drawer
useAppEvent(LAYOUT_EVENTS.TOGGLE_BOOKMARK_DRAWER, () => {
setOpened((prev) => !prev);
});
const loadData = async () => {
try {
const data = await secureIndexedDB.getItem<SavedPageItem[]>(AppStorageKey.BOOKMARK_PAGE);
setItems(data || []);
} catch (e) {
console.error('Failed to load bookmark items', e);
setItems([]);
}
};
useEffect(() => {
if (opened) {
loadData();
}
}, [opened]);
const handleRemoveItem = async (id: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
try {
const newItems = items.filter((item) => item.id !== id);
await secureIndexedDB.setItem(AppStorageKey.BOOKMARK_PAGE, newItems);
setItems(newItems);
} catch (err) {
console.error('Failed to remove bookmark item', err);
}
};
const handleClearAll = async () => {
try {
await secureIndexedDB.removeItem(AppStorageKey.BOOKMARK_PAGE);
setItems([]);
} catch (err) {
console.error('Failed to clear bookmarks', err);
}
};
return (
<Drawer
opened={opened}
onClose={() => setOpened(false)}
position="right"
size="sm"
title={
<Group gap="sm">
<Bookmark size={18} style={{ color: 'var(--mantine-color-dimmed)' }} />
<Text fw={600} size="sm">
{t('bookmark:title')}
</Text>
</Group>
}
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column', height: 'calc(100vh - 60px)' } }}
>
{items.length === 0 ? (
<Text c="dimmed" size="sm" ta="center" mt="xl" p="md">
{t('bookmark:empty')}
</Text>
) : (
<>
<Group justify="flex-end" px="md" py="xs">
<Text
size="xs"
c="red"
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '4px' }}
onClick={handleClearAll}
>
<Trash2 size={12} /> {t('bookmark:clearAll')}
</Text>
</Group>
<ScrollArea flex={1} type="scroll" px="xs" py="xs" pb={'xl'}>
<Stack gap={2}>
{items.map((item) => (
<NavLink
key={item.id}
component={Link as any}
to={item.path}
onClick={() => setOpened(false)}
label={
<Text size="sm" truncate="end" fw={500} lineClamp={1}>
{item.title}
</Text>
}
description={
<Stack gap={2}>
<Text size="xs" c="dimmed" truncate="end" mt={2}>
{item.path}
</Text>
</Stack>
}
leftSection={
<ThemeIcon variant="transparent" c="dimmed" size="sm">
<Bookmark size={16} />
</ThemeIcon>
}
rightSection={
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={(e) => {
e.preventDefault();
handleRemoveItem(item.id, e);
}}
aria-label={t('bookmark:removeItem')}
>
<Trash2 size={14} />
</ActionIcon>
}
styles={{
root: {
padding: '8px 12px',
borderBottom: '1px solid var(--mantine-color-default-border)',
},
}}
/>
))}
</Stack>
</ScrollArea>
</>
)}
</Drawer>
);
}
@@ -0,0 +1,205 @@
import { useState, useEffect } from 'react';
import {
Drawer,
Text,
Stack,
Group,
NavLink,
ThemeIcon,
ActionIcon,
ScrollArea,
Menu,
UnstyledButton,
} from '@repo/ui/components';
import { useAppEvent } from '@repo/core-events';
import { useTranslation } from '@repo/core-i18n';
import { History, Clock, Trash2 } from 'lucide-react';
import { LAYOUT_EVENTS } from '../../../../../core/constants/events';
import { secureIndexedDB, AppStorageKey } from '../../../../../core/storage/local';
import { Link } from 'react-router-dom';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type { SavedPageItem } from '../../types/saved-page.types';
import type { SystemSettings } from '../../../system/setting/types/setting.types';
import { DEFAULT_SYSTEM_SETTINGS } from '../../../system/setting/types/setting.types';
// Setup relative time formatting
dayjs.extend(relativeTime);
export function HistoryDrawer() {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [items, setItems] = useState<SavedPageItem[]>([]);
// Listen to the global event bus to toggle the drawer
useAppEvent(LAYOUT_EVENTS.TOGGLE_HISTORY_DRAWER, () => {
setOpened((prev) => !prev);
});
const loadData = async () => {
try {
const data = await secureIndexedDB.getItem<SavedPageItem[]>(AppStorageKey.HISTORY_PAGE);
const settings =
(await secureIndexedDB.getItem<SystemSettings>(AppStorageKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS;
const now = Date.now();
const unitMs = {
seconds: 1000,
minutes: 60 * 1000,
hours: 60 * 60 * 1000,
days: 24 * 60 * 60 * 1000,
};
const retentionMs = settings.historyRetentionValue * unitMs[settings.historyRetentionUnit];
const validItems = (data || []).filter((item) => now - item.timestamp < retentionMs);
// Save back if we purged some items
if (data && validItems.length !== data.length) {
await secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, validItems);
}
setItems(validItems);
} catch (e) {
console.error('Failed to load history items', e);
setItems([]);
}
};
useEffect(() => {
if (opened) {
loadData();
}
}, [opened]);
const handleClearRange = async (range: 'lastHour' | 'today' | 'allTime') => {
try {
if (range === 'allTime') {
await secureIndexedDB.removeItem(AppStorageKey.HISTORY_PAGE);
setItems([]);
return;
}
const now = Date.now();
const cutoff = range === 'lastHour' ? now - 60 * 60 * 1000 : new Date().setHours(0, 0, 0, 0); // start of today
const newItems = items.filter((item) => item.timestamp < cutoff);
await secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, newItems);
setItems(newItems);
} catch (e) {
console.error('Failed to clear history items', e);
}
};
const handleRemoveItem = async (id: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
try {
const newItems = items.filter((item) => item.id !== id);
await secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, newItems);
setItems(newItems);
} catch (err) {
console.error('Failed to remove history item', err);
}
};
return (
<Drawer
opened={opened}
onClose={() => setOpened(false)}
position="right"
size="sm"
title={
<Group gap="sm">
<History size={18} style={{ color: 'var(--mantine-color-dimmed)' }} />
<Text fw={600} size="sm">
{t('history:title')}
</Text>
</Group>
}
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column', height: 'calc(100vh - 60px)' } }}
>
{items.length === 0 ? (
<Text c="dimmed" size="sm" ta="center" mt="xl" p="md">
{t('history:empty')}
</Text>
) : (
<>
<Group justify="flex-end" px="md" py="xs">
<Menu shadow="md" width={200} position="bottom-end">
<Menu.Target>
<UnstyledButton>
<Group gap={6} c="dimmed" style={{ transition: 'color 0.2s' }}>
<Trash2 size={14} />
<Text size="xs" fw={500} style={{ cursor: 'pointer' }}>
{t('history:clearRange')}
</Text>
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t('history:clearRange')}</Menu.Label>
<Menu.Item onClick={() => handleClearRange('lastHour')}>{t('history:ranges.lastHour')}</Menu.Item>
<Menu.Item onClick={() => handleClearRange('today')}>{t('history:ranges.today')}</Menu.Item>
<Menu.Divider />
<Menu.Item color="red" leftSection={<Trash2 size={14} />} onClick={() => handleClearRange('allTime')}>
{t('history:ranges.allTime')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
<ScrollArea flex={1} type="scroll" px="xs" py="xs" pb={'xl'}>
<Stack gap={2}>
{items.map((item) => (
<NavLink
key={item.id}
component={Link as any}
to={item.path}
onClick={() => setOpened(false)}
label={
<Text size="sm" truncate="end" fw={500} lineClamp={1}>
{item.title}
</Text>
}
description={
<Stack gap={2}>
<Text size="xs" c="dimmed" truncate="end" mt={2}>
{item.path}
</Text>
</Stack>
}
leftSection={
<ThemeIcon variant="transparent" c="dimmed" size="sm">
<Clock size={16} />
</ThemeIcon>
}
rightSection={
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={(e) => {
e.preventDefault();
handleRemoveItem(item.id, e);
}}
aria-label={t('history:removeItem')}
>
<Trash2 size={14} />
</ActionIcon>
}
styles={{
root: {
padding: '8px 12px',
borderBottom: '1px solid var(--mantine-color-default-border)',
},
}}
/>
))}
</Stack>
</ScrollArea>
</>
)}
</Drawer>
);
}
@@ -1,12 +1,14 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Box, NavLink, Stack, Tooltip, ActionIcon, Divider, Menu, TextInput } from '@repo/ui/components';
import { Box, NavLink, Stack, Tooltip, ActionIcon, Divider, Menu, TextInput, Button, Group } from '@repo/ui/components';
import { useCoreAppShell } from '@repo/ui/components';
import { ChevronsLeft, ChevronsRight, PanelTopClose, PanelTopOpen, Search, X } from 'lucide-react';
import { ChevronsLeft, ChevronsRight, PanelTopClose, PanelTopOpen, Search, X, History, Bookmark } from 'lucide-react';
import { publish } from '@repo/core-events';
import type { MenuItemType } from '../types/menu.types';
import type { SidebarVariant } from '@repo/ui/components';
import { useTranslation } from '@repo/core-i18n';
import { shortcutsData } from '@repo/ui/constants';
import { LAYOUT_EVENTS } from '../../../../core/constants/events';
// ---------------------------------------------------------------------------
// Props
@@ -24,6 +26,10 @@ interface SidebarMenuProps {
withToggle?: boolean;
/** Whether to show the sticky menu filter input (default: true) */
withMenuFilter?: boolean;
/** Whether to show the History button (default: true) */
showHistory?: boolean;
/** Whether to show the Bookmark button (default: true) */
showBookmark?: boolean;
}
// ---------------------------------------------------------------------------
@@ -221,6 +227,8 @@ export const SidebarMenu = memo(function SidebarMenu({
variantOverride,
withToggle = false,
withMenuFilter = true,
showHistory = true,
showBookmark = true,
}: SidebarMenuProps) {
const { t } = useTranslation();
@@ -373,9 +381,11 @@ export const SidebarMenu = memo(function SidebarMenu({
// -- Mini (Collapsed) Mode ------------------------------------------------
if (isMini) {
const hasTopSection = withMenuFilter || showHistory || showBookmark;
return (
<Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
{withMenuFilter && (
{hasTopSection && (
<Box
p="md"
style={{
@@ -386,19 +396,53 @@ export const SidebarMenu = memo(function SidebarMenu({
backdropFilter: 'blur(8px)',
borderBottom: '1px solid var(--app-shell-border-color)',
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
}}
>
<ActionIcon
variant="subtle"
size="md"
onClick={handleExpandAndSearch}
aria-label="Search menu"
color="var(--mantine-color-text)"
radius="md"
>
<Search size={20} />
</ActionIcon>
{withMenuFilter && (
<ActionIcon
variant="subtle"
size="md"
onClick={handleExpandAndSearch}
aria-label="Search menu"
color="var(--mantine-color-text)"
>
<Search size={20} />
</ActionIcon>
)}
{(showHistory || showBookmark) && (
<Stack gap="xs" align="center">
{showHistory && (
<Tooltip label={t('common:system_menu.history')} position="right" withArrow>
<ActionIcon
variant="subtle"
size="md"
color="var(--mantine-color-text)"
onClick={() => publish(LAYOUT_EVENTS.TOGGLE_HISTORY_DRAWER, undefined)}
aria-label="History"
>
<History size={18} />
</ActionIcon>
</Tooltip>
)}
{showBookmark && (
<Tooltip label={t('common:system_menu.bookmark')} position="right" withArrow>
<ActionIcon
variant="subtle"
size="md"
color="var(--mantine-color-text)"
onClick={() => publish(LAYOUT_EVENTS.TOGGLE_BOOKMARK_DRAWER, undefined)}
aria-label="Bookmark"
>
<Bookmark size={18} />
</ActionIcon>
</Tooltip>
)}
</Stack>
)}
</Box>
)}
@@ -425,9 +469,11 @@ export const SidebarMenu = memo(function SidebarMenu({
}
// -- Expanded Mode --------------------------------------------------------
const hasTopSection = withMenuFilter || showHistory || showBookmark;
return (
<Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
{withMenuFilter && (
{hasTopSection && (
<Box
p="md"
style={{
@@ -437,58 +483,97 @@ export const SidebarMenu = memo(function SidebarMenu({
backgroundColor: 'color-mix(in srgb, var(--mantine-color-body) 80%, transparent)',
backdropFilter: 'blur(8px)',
borderBottom: '1px solid var(--app-shell-border-color)',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
<Box style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<TextInput
ref={inputRef}
placeholder={`${t('common:searchMenu')}...`}
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.currentTarget.value)}
leftSection={<Search size={14} />}
variant="filled"
radius="md"
size="xs"
style={{ flex: 1 }}
rightSectionWidth={searchQuery ? 30 : isMac ? 48 : 68}
rightSection={
searchQuery ? (
<ActionIcon variant="subtle" color="gray" onClick={() => setSearchQuery('')} size="sm">
<X size={14} />
</ActionIcon>
) : (
<span
style={{
fontSize: '10px',
fontWeight: 600,
pointerEvents: 'none',
padding: '2px 4px',
color: 'var(--mantine-color-dimmed)',
border: '1px solid var(--app-shell-border-color)',
borderRadius: 'var(--mantine-radius-sm)',
}}
>
{filterMenuShortcutText}
</span>
)
}
/>
<Tooltip
label={isAllExpanded ? t('common:collapseAll') : t('common:expandAll')}
position="bottom"
withArrow
>
<ActionIcon
variant="light"
color="gray"
onClick={handleToggleExpandAll}
size={30}
aria-label={isAllExpanded ? t('common:collapseAll') : t('common:expandAll')}
{withMenuFilter && (
<Box style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<TextInput
ref={inputRef}
placeholder={`${t('common:searchMenu')}...`}
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.currentTarget.value)}
leftSection={<Search size={14} />}
variant="default"
radius="md"
size="xs"
style={{ flex: 1 }}
rightSectionWidth={searchQuery ? 30 : 52}
rightSection={
searchQuery ? (
<ActionIcon variant="subtle" onClick={() => setSearchQuery('')} size="sm">
<X size={14} />
</ActionIcon>
) : (
<span
style={{
fontSize: '10px',
fontWeight: 600,
pointerEvents: 'none',
padding: '2px 4px',
color: 'var(--mantine-color-dimmed)',
border: '1px solid var(--app-shell-border-color)',
borderRadius: 'var(--mantine-radius-sm)',
}}
>
{filterMenuShortcutText}
</span>
)
}
/>
<Tooltip
label={isAllExpanded ? t('common:collapseAll') : t('common:expandAll')}
position="bottom"
withArrow
>
{isAllExpanded ? <PanelTopOpen size={16} /> : <PanelTopClose size={16} />}
</ActionIcon>
</Tooltip>
</Box>
<ActionIcon
variant="default"
onClick={handleToggleExpandAll}
size={30}
radius="md"
fw={400}
aria-label={isAllExpanded ? t('common:collapseAll') : t('common:expandAll')}
>
{isAllExpanded ? <PanelTopOpen size={16} /> : <PanelTopClose size={16} />}
</ActionIcon>
</Tooltip>
</Box>
)}
{(showHistory || showBookmark) && (
<Group wrap="nowrap" gap="xs" grow>
{showHistory && (
<Button
variant="default"
radius="md"
fw={400}
size="xs"
leftSection={<History size={14} />}
fullWidth
onClick={() => publish(LAYOUT_EVENTS.TOGGLE_HISTORY_DRAWER, undefined)}
styles={{ section: { marginRight: 6 }, inner: { color: 'var(--mantine-color-text)' } }}
>
{t('common:system_menu.history')}
</Button>
)}
{showBookmark && (
<Button
variant="default"
radius="md"
fw={400}
size="xs"
leftSection={<Bookmark size={14} />}
fullWidth
onClick={() => publish(LAYOUT_EVENTS.TOGGLE_BOOKMARK_DRAWER, undefined)}
styles={{ inner: { color: 'var(--mantine-color-text)' } }}
>
{t('common:system_menu.bookmark')}
</Button>
)}
</Group>
)}
</Box>
)}
@@ -0,0 +1,114 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { secureIndexedDB, AppStorageKey } from '../../../../core/storage/local';
import type { SavedPageItem } from '../types/saved-page.types';
import type { SystemSettings } from '../../system/setting/types/setting.types';
import { DEFAULT_SYSTEM_SETTINGS } from '../../system/setting/types/setting.types';
// ---------------------------------------------------------------------------
// 1. In-Memory Cache & Broadcast Channel
// ---------------------------------------------------------------------------
export let memoryHistoryCache: SavedPageItem[] = [];
export let isHistoryReady = false;
// Create a secret communication path between tabs
const historyChannel = typeof window !== 'undefined' ? new BroadcastChannel('app_history_sync') : null;
// Listen for updates from other tabs passively in the background
if (historyChannel) {
historyChannel.onmessage = (event: MessageEvent<SavedPageItem>) => {
const incomingItem = event.data;
// Remove duplicates if another tab visits a page that is already in history
const filteredData = memoryHistoryCache.filter((item) => item.path !== incomingItem.path);
// Update this tab's local memory (Without needing to query IndexedDB)
memoryHistoryCache = [incomingItem, ...filteredData].slice(0, 500);
};
}
// ---------------------------------------------------------------------------
// 2. Background Task: Initialization & Purge (Remain the same)
// ---------------------------------------------------------------------------
export async function initializeAndPurgeHistoryBackground() {
if (isHistoryReady) return;
try {
const data = (await secureIndexedDB.getItem<SavedPageItem[]>(AppStorageKey.HISTORY_PAGE)) || [];
if (data.length === 0) {
isHistoryReady = true;
return;
}
const settings =
(await secureIndexedDB.getItem<SystemSettings>(AppStorageKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS;
const now = Date.now();
const unitMs: Record<string, number> = {
seconds: 1000,
minutes: 60 * 1000,
hours: 60 * 60 * 1000,
days: 24 * 60 * 60 * 1000,
};
const retentionMs = settings.historyRetentionValue * (unitMs[settings.historyRetentionUnit] || unitMs.days);
const validItems = data.filter((item: SavedPageItem) => now - item.timestamp < retentionMs);
memoryHistoryCache = validItems;
isHistoryReady = true;
if (validItems.length !== data.length) {
await secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, validItems);
}
} catch (err) {
console.error('[HistoryManager] Failed to init history', err);
memoryHistoryCache = [];
isHistoryReady = true;
}
}
// ---------------------------------------------------------------------------
// 3. The Hook: Track & Broadcast
// ---------------------------------------------------------------------------
export function useHistoryTracker() {
const location = useLocation();
useEffect(() => {
const trackPage = () => {
if (!isHistoryReady) return;
const currentPath = location.pathname;
if (currentPath === '/' || currentPath === '/app' || currentPath.includes('/auth')) return;
const filteredData = memoryHistoryCache.filter((item: SavedPageItem) => item.path !== currentPath);
const pageTitle =
document.title && document.title.length > 0
? document.title
: currentPath.split('/').pop()?.replace(/-/g, ' ') || currentPath;
const newItem: SavedPageItem = {
id: `hist_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
path: currentPath,
title: pageTitle,
timestamp: Date.now(),
};
// 1. Update In-Memory Cache in this Tab (Instant)
memoryHistoryCache = [newItem, ...filteredData].slice(0, 500);
// 2. Notify all other tabs about this new item (Instant)
historyChannel?.postMessage(newItem);
// 3. Save to IndexedDB in the background (Asynchronous)
secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, memoryHistoryCache).catch((err) => {
console.error('[HistoryManager] Failed to track history', err);
});
};
const timer = setTimeout(() => {
trackPage();
}, 500);
return () => clearTimeout(timer);
}, [location.pathname, location.search]);
}
@@ -0,0 +1,8 @@
{
"title": "Bookmarks",
"empty": "No saved bookmarks found.",
"clearAll": "Clear All",
"removeItem": "Remove Bookmark",
"savedAt": "Saved {{time}}",
"clearSuccess": "Bookmarks cleared successfully."
}
@@ -0,0 +1,13 @@
{
"title": "History",
"empty": "No recent history found.",
"clearAll": "Clear History",
"clearRange": "Clear by Range",
"ranges": {
"lastHour": "Last Hour",
"today": "Today",
"allTime": "All Time"
},
"removeItem": "Remove History",
"clearSuccess": "History cleared successfully."
}
@@ -0,0 +1,8 @@
{
"title": "Bookmarks",
"empty": "Belum ada halaman yang ditandai.",
"clearAll": "Hapus Semua",
"removeItem": "Hapus Markah",
"savedAt": "Disimpan {{time}}",
"clearSuccess": "Semua markah berhasil dihapus."
}
@@ -0,0 +1,13 @@
{
"title": "Riwayat",
"empty": "Belum ada riwayat halaman terbaru.",
"clearAll": "Hapus Riwayat",
"clearRange": "Hapus Rentang",
"ranges": {
"lastHour": "1 Jam Terakhir",
"today": "Hari Ini",
"allTime": "Semua Waktu"
},
"removeItem": "Hapus Item",
"clearSuccess": "Riwayat berhasil dihapus."
}
@@ -2,18 +2,29 @@ import { CoreAppShell, CoreAppShellConfig } from '@repo/ui/components';
import { registerModuleNamespace } from '@repo/core-i18n';
import HeaderLayout from './components/header.layout';
import { SidebarMenu } from './components/sidebar';
import { HistoryDrawer } from './components/history';
import { BookmarkDrawer } from './components/bookmark';
import { useHistoryTracker } from './hooks/useHistoryTracker';
import { MENU_ITEMS } from './data/menu.data';
import navEn from './locales/en/nav.json';
import navId from './locales/id/nav.json';
import historyEn from './locales/en/history.json';
import historyId from './locales/id/history.json';
import bookmarkEn from './locales/en/bookmark.json';
import bookmarkId from './locales/id/bookmark.json';
// ---------------------------------------------------------------------------
// Namespace Registration (Module Scope)
// ---------------------------------------------------------------------------
// Called once at import time — safe, idempotent, outside React render cycle.
registerModuleNamespace('nav', { en: navEn, id: navId });
registerModuleNamespace('history', { en: historyEn, id: historyId });
registerModuleNamespace('bookmark', { en: bookmarkEn, id: bookmarkId });
export default function ModuleLayout({ children }: { children: React.ReactNode }) {
useHistoryTracker();
const configAppShell: CoreAppShellConfig = {
variant: 'header-first',
features: {
@@ -38,6 +49,8 @@ export default function ModuleLayout({ children }: { children: React.ReactNode }
}}
>
{children}
<HistoryDrawer />
<BookmarkDrawer />
</CoreAppShell>
);
}
@@ -0,0 +1,6 @@
export interface SavedPageItem {
id: string;
title: string;
path: string;
timestamp: number;
}
@@ -8,6 +8,7 @@ import { System } from './components/system';
import informationId from './locales/id/information.json';
import informationEn from './locales/en/information.json';
import { useEffect } from 'react';
registerModuleNamespace('information', {
id: informationId,
@@ -17,6 +18,10 @@ registerModuleNamespace('information', {
export default function InformationPage() {
const { t } = useTranslation();
useEffect(() => {
document.title = t('information:info.pageTitle');
}, [t]);
return (
<CorePageContainer>
<ModulePageHeader
@@ -0,0 +1,149 @@
import { useState, useEffect } from 'react';
import { Stack, Button, Group, Text, Box } from '@repo/ui/components';
import { useTranslation } from '@repo/core-i18n';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { FieldNumberInput, FieldSelect } from '@repo/ui/form';
import { secureIndexedDB, AppStorageKey } from '../../../../../core/storage/local';
import type { SystemSettings, RetentionUnit } from '../types/setting.types';
import { DEFAULT_SYSTEM_SETTINGS } from '../types/setting.types';
const historySchema = z.object({
historyRetentionValue: z.coerce.number().min(1, { message: 'Must be at least 1' }),
historyRetentionUnit: z.enum(['seconds', 'minutes', 'hours', 'days']),
});
type HistorySettingsFormValues = z.infer<typeof historySchema>;
export function HistorySetting({ onDirtyChange }: { onDirtyChange: (isDirty: boolean) => void }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const {
control,
handleSubmit,
reset,
formState: { isDirty },
} = useForm<HistorySettingsFormValues>({
resolver: zodResolver(historySchema),
defaultValues: {
historyRetentionValue: DEFAULT_SYSTEM_SETTINGS.historyRetentionValue,
historyRetentionUnit: DEFAULT_SYSTEM_SETTINGS.historyRetentionUnit,
},
});
useEffect(() => {
onDirtyChange(isDirty);
}, [isDirty, onDirtyChange]);
useEffect(() => {
const loadSettings = async () => {
try {
const settings = await secureIndexedDB.getItem<SystemSettings>(AppStorageKey.SYSTEM_SETTINGS);
if (settings) {
reset({
historyRetentionValue: settings.historyRetentionValue,
historyRetentionUnit: settings.historyRetentionUnit,
});
} else {
reset({
historyRetentionValue: DEFAULT_SYSTEM_SETTINGS.historyRetentionValue,
historyRetentionUnit: DEFAULT_SYSTEM_SETTINGS.historyRetentionUnit,
});
}
} catch (err) {
console.error('Failed to load system settings', err);
}
};
loadSettings();
}, [reset]);
const handleReset = async () => {
try {
const settings = await secureIndexedDB.getItem<SystemSettings>(AppStorageKey.SYSTEM_SETTINGS);
if (settings) {
reset({
historyRetentionValue: settings.historyRetentionValue,
historyRetentionUnit: settings.historyRetentionUnit,
});
} else {
reset({
historyRetentionValue: DEFAULT_SYSTEM_SETTINGS.historyRetentionValue,
historyRetentionUnit: DEFAULT_SYSTEM_SETTINGS.historyRetentionUnit,
});
}
} catch (err) {
console.error('Failed to reset system settings', err);
}
};
const onSubmit = async (data: HistorySettingsFormValues) => {
setLoading(true);
try {
const currentSettings =
(await secureIndexedDB.getItem<SystemSettings>(AppStorageKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS;
const newSettings = {
...currentSettings,
historyRetentionValue: data.historyRetentionValue,
historyRetentionUnit: data.historyRetentionUnit as RetentionUnit,
};
await secureIndexedDB.setItem(AppStorageKey.SYSTEM_SETTINGS, newSettings);
reset(data); // Reset form to clear dirty state
} catch (err) {
console.error('Failed to save settings', err);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<Box>
<Text fw={500} size="md" mb="xs">
{t('setting:history.retentionTitle')}
</Text>
<Text c="dimmed" size="sm" mb="md">
{t('setting:history.retentionDesc')}
</Text>
<Group align="flex-top">
<Box w={120}>
<FieldNumberInput
control={control}
name="historyRetentionValue"
min={1}
allowNegative={false}
allowDecimal={false}
/>
</Box>
<Box w={150}>
<FieldSelect
control={control}
name="historyRetentionUnit"
data={[
{ value: 'seconds', label: t('setting:history.units.seconds') },
{ value: 'minutes', label: t('setting:history.units.minutes') },
{ value: 'hours', label: t('setting:history.units.hours') },
{ value: 'days', label: t('setting:history.units.days') },
]}
clearable={false}
/>
</Box>
</Group>
</Box>
<Group justify="flex-start" mt="md">
<Button type="submit" loading={loading} disabled={!isDirty}>
{t('setting:history.save')}
</Button>
{isDirty && (
<Button variant="default" onClick={handleReset}>
{t('setting:history.reset')}
</Button>
)}
</Group>
</Stack>
</form>
);
}
@@ -0,0 +1,88 @@
import { useState, useEffect } from 'react';
import { Stack, Button, Group, Text, Box } from '@repo/ui/components';
import { useTranslation } from '@repo/core-i18n';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { FieldSwitch } from '@repo/ui/form';
const notificationSchema = z.object({
email: z.boolean(),
push: z.boolean(),
});
type NotificationSettings = z.infer<typeof notificationSchema>;
export function NotificationSetting({ onDirtyChange }: { onDirtyChange: (isDirty: boolean) => void }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const { control, handleSubmit, reset, formState: { isDirty } } = useForm<NotificationSettings>({
resolver: zodResolver(notificationSchema),
defaultValues: {
email: false,
push: false,
},
});
useEffect(() => {
onDirtyChange(isDirty);
}, [isDirty, onDirtyChange]);
const handleReset = () => {
reset({
email: false,
push: false,
});
};
const onSubmit = async (data: NotificationSettings) => {
setLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
// Reset form to clear dirty state
reset(data);
} catch (err) {
console.error('Failed to save notification settings', err);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<Box>
<Text fw={500} size="md" mb="xs">{t('setting:notification.title')}</Text>
<Text c="dimmed" size="sm" mb="md">{t('setting:notification.desc')}</Text>
<Stack gap="sm">
<FieldSwitch
control={control}
name="email"
label={t('setting:notification.email')}
description={t('setting:notification.emailDesc')}
/>
<FieldSwitch
control={control}
name="push"
label={t('setting:notification.push')}
description={t('setting:notification.pushDesc')}
/>
</Stack>
</Box>
<Group justify="flex-start" mt="md">
<Button type="submit" loading={loading} disabled={!isDirty}>
{t('setting:notification.save')}
</Button>
{isDirty && (
<Button variant="default" onClick={handleReset}>
{t('setting:notification.reset')}
</Button>
)}
</Group>
</Stack>
</form>
);
}
@@ -1,17 +1,109 @@
import { Container, Paper, Title, Text } from '@repo/ui/components';
import { useTranslation } from '@repo/core-i18n';
import { useEffect, useState } from 'react';
import { Tabs, Card, CorePageContainer, Modal, Button, Text, Group } from '@repo/ui/components';
import { Settings, History, Bell } from 'lucide-react';
import { ModulePageHeader } from '@repo/ui/foundations';
import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
export default function ConfigurationPage() {
import { HistorySetting } from './components/history-setting';
import { NotificationSetting } from './components/notification-setting';
import settingId from './locales/id/setting.json';
import settingEn from './locales/en/setting.json';
registerModuleNamespace('setting', {
id: settingId,
en: settingEn,
});
export default function SettingPage() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<string>('history');
const [isDirty, setIsDirty] = useState(false);
const [pendingTab, setPendingTab] = useState<string | null>(null);
useEffect(() => {
document.title = t('setting:pageTitle');
}, [t]);
const handleTabChange = (value: string | null) => {
if (!value) return;
if (value === activeTab) return;
if (isDirty) {
setPendingTab(value);
} else {
setActiveTab(value);
}
};
const handleDiscardChanges = () => {
if (pendingTab) {
setActiveTab(pendingTab);
setIsDirty(false);
setPendingTab(null);
}
};
const handleCancelSwitch = () => {
setPendingTab(null);
};
return (
<Container fluid p="md">
<Paper shadow="sm" p="md" radius="md">
<Title order={2} mb="xs">
{t('common:configuration')}
</Title>
<Text c="dimmed">{t('common:configurationDesc')}</Text>
</Paper>
</Container>
<CorePageContainer>
<ModulePageHeader
icon={Settings}
title={t('setting:pageTitle')}
description={t('setting:pageDescription')}
disableMinimize={true}
moduleKey="SYSTEM_SETTING"
breadcrumbs={[
{ label: t('nav:system'), type: 'text' },
{ label: t('setting:pageTitle'), type: 'link', href: '/app/system/setting' },
]}
/>
<Tabs value={activeTab} onChange={handleTabChange} variant="outline" radius="md">
<Tabs.List mb="md">
<Tabs.Tab value="history" leftSection={<History size={16} />}>
{t('setting:tabs.history')}
</Tabs.Tab>
<Tabs.Tab value="notification" leftSection={<Bell size={16} />}>
{t('setting:tabs.notification')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="history">
<Card shadow="sm" radius="md" withBorder padding="xl">
{activeTab === 'history' && <HistorySetting onDirtyChange={setIsDirty} />}
</Card>
</Tabs.Panel>
<Tabs.Panel value="notification">
<Card shadow="sm" radius="md" withBorder padding="xl">
{activeTab === 'notification' && <NotificationSetting onDirtyChange={setIsDirty} />}
</Card>
</Tabs.Panel>
</Tabs>
<Modal
opened={!!pendingTab}
onClose={handleCancelSwitch}
title={<Text fw={600}>{t('setting:unsaved.title')}</Text>}
yOffset={50}
>
<Text size="sm" mb="xl">
{t('setting:unsaved.message')}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={handleCancelSwitch}>
{t('setting:unsaved.cancel')}
</Button>
<Button color="red" onClick={handleDiscardChanges}>
{t('setting:unsaved.discard')}
</Button>
</Group>
</Modal>
</CorePageContainer>
);
}
@@ -0,0 +1,37 @@
{
"pageTitle": "System Settings",
"pageDescription": "Manage global application preferences and configurations.",
"tabs": {
"history": "History & Storage",
"notification": "Notifications"
},
"history": {
"retentionTitle": "History Retention",
"retentionDesc": "Automatically clear history items older than this duration.",
"save": "Save Changes",
"reset": "Discard Changes",
"units": {
"seconds": "Seconds",
"minutes": "Minutes",
"hours": "Hours",
"days": "Days"
}
},
"notification": {
"title": "Notification Settings",
"desc": "Manage how and when you receive notifications.",
"email": "Email Notifications",
"emailDesc": "Receive updates and alerts via email.",
"push": "Push Notifications",
"pushDesc": "Receive desktop push notifications.",
"save": "Save Changes",
"reset": "Discard Changes",
"saveSuccess": "Notification settings saved successfully."
},
"unsaved": {
"title": "Unsaved Changes",
"message": "You have unsaved changes in the current tab. Are you sure you want to leave without saving?",
"discard": "Continue (Discard Changes)",
"cancel": "Cancel"
}
}
@@ -0,0 +1,37 @@
{
"pageTitle": "Pengaturan Sistem",
"pageDescription": "Kelola preferensi dan konfigurasi aplikasi secara global.",
"tabs": {
"history": "Riwayat & Penyimpanan",
"notification": "Notifikasi"
},
"history": {
"retentionTitle": "Durasi Penyimpanan Riwayat",
"retentionDesc": "Hapus otomatis item riwayat yang usianya lebih lama dari batas waktu ini.",
"save": "Simpan Perubahan",
"reset": "Buang Perubahan",
"units": {
"seconds": "Detik",
"minutes": "Menit",
"hours": "Jam",
"days": "Hari"
}
},
"notification": {
"title": "Pengaturan Notifikasi",
"desc": "Kelola bagaimana dan kapan Anda menerima notifikasi.",
"email": "Notifikasi Email",
"emailDesc": "Terima pembaruan dan peringatan melalui email.",
"push": "Notifikasi Push",
"pushDesc": "Terima notifikasi push di desktop Anda.",
"save": "Simpan Perubahan",
"reset": "Buang Perubahan",
"saveSuccess": "Pengaturan notifikasi berhasil disimpan."
},
"unsaved": {
"title": "Perubahan Belum Disimpan",
"message": "Anda memiliki perubahan yang belum disimpan pada tab ini. Apakah Anda yakin ingin berpindah tab tanpa menyimpan?",
"discard": "Lanjutkan (Buang Perubahan)",
"cancel": "Batal"
}
}
@@ -0,0 +1,11 @@
export type RetentionUnit = 'seconds' | 'minutes' | 'hours' | 'days';
export interface SystemSettings {
historyRetentionValue: number;
historyRetentionUnit: RetentionUnit;
}
export const DEFAULT_SYSTEM_SETTINGS: SystemSettings = {
historyRetentionValue: 7,
historyRetentionUnit: 'days',
};
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { usePublishEvent } from '@repo/core-events';
import { Button, Group, Stack, TextInput, Badge } from '@repo/ui/components';
import { AUTH_EVENTS } from '../../../../core/constants/events';
// ─── ProfileSettingsUI ──────────────────────────────────────────
@@ -29,7 +30,7 @@ export function ProfileSettingsUI() {
const [saveCount, setSaveCount] = useState(0);
const handleSave = () => {
publish('AUTH:PROFILE_UPDATED', {
publish(AUTH_EVENTS.PROFILE_UPDATED, {
id: 'user-1',
name,
email,
@@ -1,4 +1,5 @@
import { useAppEvent } from '@repo/core-events';
import { AUTH_EVENTS } from '../../../../core/constants/events';
import type { ProfileUpdatedPayload } from '@repo/core-events';
import { secureIndexedDB, AppStorageKey } from '../../../../core/storage/local';
@@ -16,7 +17,7 @@ interface StorageSyncListenerProps {
* and persists the profile data to IndexedDB via `@repo/core-storage`.
*/
export function StorageSyncListener({ onLog }: StorageSyncListenerProps) {
useAppEvent('AUTH:PROFILE_UPDATED', (payload: ProfileUpdatedPayload) => {
useAppEvent(AUTH_EVENTS.PROFILE_UPDATED, (payload: ProfileUpdatedPayload) => {
onLog(`Received AUTH:PROFILE_UPDATED for "${payload.name}" (${payload.email})`);
// Persist to IndexedDB via local AppStorageKey.
@@ -14,6 +14,7 @@ import { PrinterListener } from './printer/printer.listener';
import { LiveStockGrid } from './stock-grid/live-stock-grid.ui';
import { ProfileSettingsUI } from './auth-sync/profile-settings.ui';
import { StorageSyncListener } from './auth-sync/storage-sync.listener';
import { DEVICE_EVENTS, AUTH_EVENTS } from '../../../core/constants/events';
// ─── Events Demo Page ───────────────────────────────────────────
@@ -64,8 +65,8 @@ export default function EventsDemoPage() {
🖨 Showcase 1: Cross-Platform Printer Abstraction
</Title>
<Text size="sm" c="dimmed" mb="md">
The CashierUI publishes a <code>DEVICE:PRINT_RECEIPT</code> event.
A headless PrinterListener decides whether to use Electron IPC or browser print.
The CashierUI publishes a <code>{DEVICE_EVENTS.PRINT_RECEIPT}</code> event.
The PrinterListener listens for it and simulates interacting with a physical printer.
</Text>
{/* Headless listener — renders nothing visible */}
@@ -125,8 +126,8 @@ export default function EventsDemoPage() {
💾 Showcase 3: Auth/Profile Sync with <code>@repo/core-storage</code>
</Title>
<Text size="sm" c="dimmed" mb="md">
ProfileSettingsUI publishes <code>AUTH:PROFILE_UPDATED</code>.
A headless StorageSyncListener persists it to IndexedDB via <code>secureIndexedDB</code>.
ProfileSettingsUI publishes <code>{AUTH_EVENTS.PROFILE_UPDATED}</code>.
StorageSyncListener silently catches it in the background and saves to IndexedDB via <code>secureIndexedDB</code>.
</Text>
{/* Headless listener — renders nothing visible */}
@@ -10,6 +10,7 @@ import {
Table,
Badge,
} from '@repo/ui/components';
import { DEVICE_EVENTS } from '../../../../core/constants/events';
// ─── Mock Receipt Data ──────────────────────────────────────────
@@ -42,7 +43,7 @@ export function CashierUI() {
const total = items.reduce((sum, item) => sum + item.qty * item.price, 0);
const handlePrint = () => {
publish('DEVICE:PRINT_RECEIPT', {
publish(DEVICE_EVENTS.PRINT_RECEIPT, {
receiptId: `RCP-${Date.now().toString(36).toUpperCase()}`,
items,
total,
@@ -1,4 +1,5 @@
import { useAppEvent } from '@repo/core-events';
import { DEVICE_EVENTS } from '../../../../core/constants/events';
import type { PrintReceiptPayload } from '@repo/core-events';
// ─── Props ──────────────────────────────────────────────────────
@@ -29,7 +30,7 @@ interface PrinterListenerProps {
* The event bus supports unlimited subscribers per event.
*/
export function PrinterListener({ onLog }: PrinterListenerProps) {
useAppEvent('DEVICE:PRINT_RECEIPT', (payload: PrintReceiptPayload) => {
useAppEvent(DEVICE_EVENTS.PRINT_RECEIPT, (payload: PrintReceiptPayload) => {
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
if (isElectron) {
@@ -1,4 +1,5 @@
import { publish } from '@repo/core-events';
import { WS_EVENTS } from '../../../../core/constants/events';
// ─── Stock Tickers ──────────────────────────────────────────────
@@ -80,7 +81,7 @@ export function startMockWebSocket(config: MockWebSocketConfig): MockWebSocketHa
prices.set(id, newPrice);
// Publish to the event bus
publish('WS:STOCK_UPDATE', {
publish(WS_EVENTS.STOCK_UPDATE, {
id,
price: newPrice,
change,
@@ -1,5 +1,6 @@
import { memo, useState, useRef } from 'react';
import { useAppEvent } from '@repo/core-events';
import { WS_EVENTS } from '../../../../core/constants/events';
// ─── Props ──────────────────────────────────────────────────────
@@ -36,7 +37,7 @@ export const StockRow = memo(function StockRow({ stockId }: StockRowProps) {
const renderCountRef = useRef(0);
renderCountRef.current += 1;
useAppEvent('WS:STOCK_UPDATE', (payload) => {
useAppEvent(WS_EVENTS.STOCK_UPDATE, (payload) => {
// ── Critical filter ──────────────────────────────────────────
// This is the key performance optimization. Only the row whose
// ID matches the event payload will call setState. All other
+21
View File
@@ -0,0 +1,21 @@
export const LAYOUT_EVENTS = {
TOGGLE_HISTORY_DRAWER: 'LAYOUT:TOGGLE_HISTORY_DRAWER',
TOGGLE_BOOKMARK_DRAWER: 'LAYOUT:TOGGLE_BOOKMARK_DRAWER',
} as const;
export const DEVICE_EVENTS = {
PRINT_RECEIPT: 'DEVICE:PRINT_RECEIPT',
} as const;
export const WS_EVENTS = {
STOCK_UPDATE: 'WS:STOCK_UPDATE',
} as const;
export const AUTH_EVENTS = {
PROFILE_UPDATED: 'AUTH:PROFILE_UPDATED',
} as const;
export const APP_EVENTS = {
INITIALIZED: 'APP:INITIALIZED',
ERROR: 'APP:ERROR',
} as const;
+8 -1
View File
@@ -8,6 +8,9 @@ export const AppStorageKey = {
REFRESH_TOKEN: 'refresh_token',
MOCK_DB_COMPANY_A: 'mock_db_company_a',
OFFLINE_DRAFT: 'offline_draft',
SYSTEM_SETTINGS: 'system_settings',
HISTORY_PAGE: 'history_page',
BOOKMARK_PAGE: 'bookmark_page',
} as const;
export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey];
@@ -23,14 +26,18 @@ export const PLAIN_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.THEME,
AppStorageKey.MOCK_DB_COMPANY_A,
AppStorageKey.OFFLINE_DRAFT,
AppStorageKey.SYSTEM_SETTINGS,
AppStorageKey.HISTORY_PAGE,
AppStorageKey.BOOKMARK_PAGE,
]);
export const secureStorage = createLocalStorage<AppStorageKeyValue>({
encryptedKeys: ENCRYPTED_KEYS,
plainTextKeys: PLAIN_KEYS,
});
export const secureIndexedDB = createIndexedDB<AppStorageKeyValue>({
dbName: 'eigen_erp_db',
dbName: 'e_apps_db',
storeName: 'web_store',
encryptedKeys: ENCRYPTED_KEYS,
plainTextKeys: PLAIN_KEYS,
+4
View File
@@ -72,5 +72,9 @@ declare module '@repo/core-events' {
// ── App Lifecycle ────────────────────────────────────────
'APP:INITIALIZED': undefined;
'APP:ERROR': { message: string; code?: string };
// ── Layout ───────────────────────────────────────────────
'LAYOUT:TOGGLE_HISTORY_DRAWER': undefined;
'LAYOUT:TOGGLE_BOOKMARK_DRAWER': undefined;
}
}