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:
@@ -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 { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import { ThemeProvider, DensityType } from '@repo/ui/provider';
|
import { ThemeProvider, DensityType } from '@repo/ui/provider';
|
||||||
import { NotFound, Forbidden, Maintenance, ComingSoon } from '@repo/ui/components';
|
import { NotFound, Forbidden, Maintenance, ComingSoon } from '@repo/ui/components';
|
||||||
import { LoadingScreen } from '../core/components/loading-screen';
|
import { LoadingScreen } from '../core/components/loading-screen';
|
||||||
import { useThemeStore } from '../core/stores/theme.store';
|
import { useThemeStore } from '../core/stores/theme.store';
|
||||||
|
import { initializeAndPurgeHistoryBackground } from './modules/layouts/hooks/useHistoryTracker';
|
||||||
|
|
||||||
const AuthModule = lazy(() => import('./auth'));
|
const AuthModule = lazy(() => import('./auth'));
|
||||||
const AppModule = lazy(() => import('./modules'));
|
const AppModule = lazy(() => import('./modules'));
|
||||||
@@ -14,6 +15,12 @@ export default function App() {
|
|||||||
const colorScheme = useThemeStore((s) => s.colorScheme);
|
const colorScheme = useThemeStore((s) => s.colorScheme);
|
||||||
const [density, setDensity] = useState<DensityType>('compact');
|
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 (
|
return (
|
||||||
<ThemeProvider colorScheme={colorScheme} density={density}>
|
<ThemeProvider colorScheme={colorScheme} density={density}>
|
||||||
<BrowserRouter>
|
<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 { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
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 { 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 { MenuItemType } from '../types/menu.types';
|
||||||
import type { SidebarVariant } from '@repo/ui/components';
|
import type { SidebarVariant } from '@repo/ui/components';
|
||||||
import { useTranslation } from '@repo/core-i18n';
|
import { useTranslation } from '@repo/core-i18n';
|
||||||
import { shortcutsData } from '@repo/ui/constants';
|
import { shortcutsData } from '@repo/ui/constants';
|
||||||
|
import { LAYOUT_EVENTS } from '../../../../core/constants/events';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Props
|
// Props
|
||||||
@@ -24,6 +26,10 @@ interface SidebarMenuProps {
|
|||||||
withToggle?: boolean;
|
withToggle?: boolean;
|
||||||
/** Whether to show the sticky menu filter input (default: true) */
|
/** Whether to show the sticky menu filter input (default: true) */
|
||||||
withMenuFilter?: boolean;
|
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,
|
variantOverride,
|
||||||
withToggle = false,
|
withToggle = false,
|
||||||
withMenuFilter = true,
|
withMenuFilter = true,
|
||||||
|
showHistory = true,
|
||||||
|
showBookmark = true,
|
||||||
}: SidebarMenuProps) {
|
}: SidebarMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -373,9 +381,11 @@ export const SidebarMenu = memo(function SidebarMenu({
|
|||||||
|
|
||||||
// -- Mini (Collapsed) Mode ------------------------------------------------
|
// -- Mini (Collapsed) Mode ------------------------------------------------
|
||||||
if (isMini) {
|
if (isMini) {
|
||||||
|
const hasTopSection = withMenuFilter || showHistory || showBookmark;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
|
<Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
{withMenuFilter && (
|
{hasTopSection && (
|
||||||
<Box
|
<Box
|
||||||
p="md"
|
p="md"
|
||||||
style={{
|
style={{
|
||||||
@@ -386,19 +396,53 @@ export const SidebarMenu = memo(function SidebarMenu({
|
|||||||
backdropFilter: 'blur(8px)',
|
backdropFilter: 'blur(8px)',
|
||||||
borderBottom: '1px solid var(--app-shell-border-color)',
|
borderBottom: '1px solid var(--app-shell-border-color)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ActionIcon
|
{withMenuFilter && (
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
size="md"
|
variant="subtle"
|
||||||
onClick={handleExpandAndSearch}
|
size="md"
|
||||||
aria-label="Search menu"
|
onClick={handleExpandAndSearch}
|
||||||
color="var(--mantine-color-text)"
|
aria-label="Search menu"
|
||||||
radius="md"
|
color="var(--mantine-color-text)"
|
||||||
>
|
>
|
||||||
<Search size={20} />
|
<Search size={20} />
|
||||||
</ActionIcon>
|
</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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -425,9 +469,11 @@ export const SidebarMenu = memo(function SidebarMenu({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -- Expanded Mode --------------------------------------------------------
|
// -- Expanded Mode --------------------------------------------------------
|
||||||
|
const hasTopSection = withMenuFilter || showHistory || showBookmark;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
|
<Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
{withMenuFilter && (
|
{hasTopSection && (
|
||||||
<Box
|
<Box
|
||||||
p="md"
|
p="md"
|
||||||
style={{
|
style={{
|
||||||
@@ -437,58 +483,97 @@ export const SidebarMenu = memo(function SidebarMenu({
|
|||||||
backgroundColor: 'color-mix(in srgb, var(--mantine-color-body) 80%, transparent)',
|
backgroundColor: 'color-mix(in srgb, var(--mantine-color-body) 80%, transparent)',
|
||||||
backdropFilter: 'blur(8px)',
|
backdropFilter: 'blur(8px)',
|
||||||
borderBottom: '1px solid var(--app-shell-border-color)',
|
borderBottom: '1px solid var(--app-shell-border-color)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '12px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
{withMenuFilter && (
|
||||||
<TextInput
|
<Box style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
ref={inputRef}
|
<TextInput
|
||||||
placeholder={`${t('common:searchMenu')}...`}
|
ref={inputRef}
|
||||||
value={searchQuery}
|
placeholder={`${t('common:searchMenu')}...`}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.currentTarget.value)}
|
value={searchQuery}
|
||||||
leftSection={<Search size={14} />}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.currentTarget.value)}
|
||||||
variant="filled"
|
leftSection={<Search size={14} />}
|
||||||
radius="md"
|
variant="default"
|
||||||
size="xs"
|
radius="md"
|
||||||
style={{ flex: 1 }}
|
size="xs"
|
||||||
rightSectionWidth={searchQuery ? 30 : isMac ? 48 : 68}
|
style={{ flex: 1 }}
|
||||||
rightSection={
|
rightSectionWidth={searchQuery ? 30 : 52}
|
||||||
searchQuery ? (
|
rightSection={
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={() => setSearchQuery('')} size="sm">
|
searchQuery ? (
|
||||||
<X size={14} />
|
<ActionIcon variant="subtle" onClick={() => setSearchQuery('')} size="sm">
|
||||||
</ActionIcon>
|
<X size={14} />
|
||||||
) : (
|
</ActionIcon>
|
||||||
<span
|
) : (
|
||||||
style={{
|
<span
|
||||||
fontSize: '10px',
|
style={{
|
||||||
fontWeight: 600,
|
fontSize: '10px',
|
||||||
pointerEvents: 'none',
|
fontWeight: 600,
|
||||||
padding: '2px 4px',
|
pointerEvents: 'none',
|
||||||
color: 'var(--mantine-color-dimmed)',
|
padding: '2px 4px',
|
||||||
border: '1px solid var(--app-shell-border-color)',
|
color: 'var(--mantine-color-dimmed)',
|
||||||
borderRadius: 'var(--mantine-radius-sm)',
|
border: '1px solid var(--app-shell-border-color)',
|
||||||
}}
|
borderRadius: 'var(--mantine-radius-sm)',
|
||||||
>
|
}}
|
||||||
{filterMenuShortcutText}
|
>
|
||||||
</span>
|
{filterMenuShortcutText}
|
||||||
)
|
</span>
|
||||||
}
|
)
|
||||||
/>
|
}
|
||||||
<Tooltip
|
/>
|
||||||
label={isAllExpanded ? t('common:collapseAll') : t('common:expandAll')}
|
<Tooltip
|
||||||
position="bottom"
|
label={isAllExpanded ? t('common:collapseAll') : t('common:expandAll')}
|
||||||
withArrow
|
position="bottom"
|
||||||
>
|
withArrow
|
||||||
<ActionIcon
|
|
||||||
variant="light"
|
|
||||||
color="gray"
|
|
||||||
onClick={handleToggleExpandAll}
|
|
||||||
size={30}
|
|
||||||
aria-label={isAllExpanded ? t('common:collapseAll') : t('common:expandAll')}
|
|
||||||
>
|
>
|
||||||
{isAllExpanded ? <PanelTopOpen size={16} /> : <PanelTopClose size={16} />}
|
<ActionIcon
|
||||||
</ActionIcon>
|
variant="default"
|
||||||
</Tooltip>
|
onClick={handleToggleExpandAll}
|
||||||
</Box>
|
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>
|
</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 { registerModuleNamespace } from '@repo/core-i18n';
|
||||||
import HeaderLayout from './components/header.layout';
|
import HeaderLayout from './components/header.layout';
|
||||||
import { SidebarMenu } from './components/sidebar';
|
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 { MENU_ITEMS } from './data/menu.data';
|
||||||
|
|
||||||
import navEn from './locales/en/nav.json';
|
import navEn from './locales/en/nav.json';
|
||||||
import navId from './locales/id/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)
|
// Namespace Registration (Module Scope)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Called once at import time — safe, idempotent, outside React render cycle.
|
// Called once at import time — safe, idempotent, outside React render cycle.
|
||||||
registerModuleNamespace('nav', { en: navEn, id: navId });
|
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 }) {
|
export default function ModuleLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
useHistoryTracker();
|
||||||
|
|
||||||
const configAppShell: CoreAppShellConfig = {
|
const configAppShell: CoreAppShellConfig = {
|
||||||
variant: 'header-first',
|
variant: 'header-first',
|
||||||
features: {
|
features: {
|
||||||
@@ -38,6 +49,8 @@ export default function ModuleLayout({ children }: { children: React.ReactNode }
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
<HistoryDrawer />
|
||||||
|
<BookmarkDrawer />
|
||||||
</CoreAppShell>
|
</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 informationId from './locales/id/information.json';
|
||||||
import informationEn from './locales/en/information.json';
|
import informationEn from './locales/en/information.json';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
registerModuleNamespace('information', {
|
registerModuleNamespace('information', {
|
||||||
id: informationId,
|
id: informationId,
|
||||||
@@ -17,6 +18,10 @@ registerModuleNamespace('information', {
|
|||||||
export default function InformationPage() {
|
export default function InformationPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = t('information:info.pageTitle');
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CorePageContainer>
|
<CorePageContainer>
|
||||||
<ModulePageHeader
|
<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 { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from '@repo/core-i18n';
|
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 { 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 (
|
return (
|
||||||
<Container fluid p="md">
|
<CorePageContainer>
|
||||||
<Paper shadow="sm" p="md" radius="md">
|
<ModulePageHeader
|
||||||
<Title order={2} mb="xs">
|
icon={Settings}
|
||||||
{t('common:configuration')}
|
title={t('setting:pageTitle')}
|
||||||
</Title>
|
description={t('setting:pageDescription')}
|
||||||
<Text c="dimmed">{t('common:configurationDesc')}</Text>
|
disableMinimize={true}
|
||||||
</Paper>
|
moduleKey="SYSTEM_SETTING"
|
||||||
</Container>
|
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 { useState } from 'react';
|
||||||
import { usePublishEvent } from '@repo/core-events';
|
import { usePublishEvent } from '@repo/core-events';
|
||||||
import { Button, Group, Stack, TextInput, Badge } from '@repo/ui/components';
|
import { Button, Group, Stack, TextInput, Badge } from '@repo/ui/components';
|
||||||
|
import { AUTH_EVENTS } from '../../../../core/constants/events';
|
||||||
|
|
||||||
// ─── ProfileSettingsUI ──────────────────────────────────────────
|
// ─── ProfileSettingsUI ──────────────────────────────────────────
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ export function ProfileSettingsUI() {
|
|||||||
const [saveCount, setSaveCount] = useState(0);
|
const [saveCount, setSaveCount] = useState(0);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
publish('AUTH:PROFILE_UPDATED', {
|
publish(AUTH_EVENTS.PROFILE_UPDATED, {
|
||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useAppEvent } from '@repo/core-events';
|
import { useAppEvent } from '@repo/core-events';
|
||||||
|
import { AUTH_EVENTS } from '../../../../core/constants/events';
|
||||||
import type { ProfileUpdatedPayload } from '@repo/core-events';
|
import type { ProfileUpdatedPayload } from '@repo/core-events';
|
||||||
import { secureIndexedDB, AppStorageKey } from '../../../../core/storage/local';
|
import { secureIndexedDB, AppStorageKey } from '../../../../core/storage/local';
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ interface StorageSyncListenerProps {
|
|||||||
* and persists the profile data to IndexedDB via `@repo/core-storage`.
|
* and persists the profile data to IndexedDB via `@repo/core-storage`.
|
||||||
*/
|
*/
|
||||||
export function StorageSyncListener({ onLog }: StorageSyncListenerProps) {
|
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})`);
|
onLog(`Received AUTH:PROFILE_UPDATED for "${payload.name}" (${payload.email})`);
|
||||||
|
|
||||||
// Persist to IndexedDB via local AppStorageKey.
|
// 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 { LiveStockGrid } from './stock-grid/live-stock-grid.ui';
|
||||||
import { ProfileSettingsUI } from './auth-sync/profile-settings.ui';
|
import { ProfileSettingsUI } from './auth-sync/profile-settings.ui';
|
||||||
import { StorageSyncListener } from './auth-sync/storage-sync.listener';
|
import { StorageSyncListener } from './auth-sync/storage-sync.listener';
|
||||||
|
import { DEVICE_EVENTS, AUTH_EVENTS } from '../../../core/constants/events';
|
||||||
|
|
||||||
// ─── Events Demo Page ───────────────────────────────────────────
|
// ─── Events Demo Page ───────────────────────────────────────────
|
||||||
|
|
||||||
@@ -64,8 +65,8 @@ export default function EventsDemoPage() {
|
|||||||
🖨️ Showcase 1: Cross-Platform Printer Abstraction
|
🖨️ Showcase 1: Cross-Platform Printer Abstraction
|
||||||
</Title>
|
</Title>
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
The CashierUI publishes a <code>DEVICE:PRINT_RECEIPT</code> event.
|
The CashierUI publishes a <code>{DEVICE_EVENTS.PRINT_RECEIPT}</code> event.
|
||||||
A headless PrinterListener decides whether to use Electron IPC or browser print.
|
The PrinterListener listens for it and simulates interacting with a physical printer.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Headless listener — renders nothing visible */}
|
{/* Headless listener — renders nothing visible */}
|
||||||
@@ -125,8 +126,8 @@ export default function EventsDemoPage() {
|
|||||||
💾 Showcase 3: Auth/Profile Sync with <code>@repo/core-storage</code>
|
💾 Showcase 3: Auth/Profile Sync with <code>@repo/core-storage</code>
|
||||||
</Title>
|
</Title>
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
ProfileSettingsUI publishes <code>AUTH:PROFILE_UPDATED</code>.
|
ProfileSettingsUI publishes <code>{AUTH_EVENTS.PROFILE_UPDATED}</code>.
|
||||||
A headless StorageSyncListener persists it to IndexedDB via <code>secureIndexedDB</code>.
|
StorageSyncListener silently catches it in the background and saves to IndexedDB via <code>secureIndexedDB</code>.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Headless listener — renders nothing visible */}
|
{/* Headless listener — renders nothing visible */}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Table,
|
Table,
|
||||||
Badge,
|
Badge,
|
||||||
} from '@repo/ui/components';
|
} from '@repo/ui/components';
|
||||||
|
import { DEVICE_EVENTS } from '../../../../core/constants/events';
|
||||||
|
|
||||||
// ─── Mock Receipt Data ──────────────────────────────────────────
|
// ─── Mock Receipt Data ──────────────────────────────────────────
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ export function CashierUI() {
|
|||||||
const total = items.reduce((sum, item) => sum + item.qty * item.price, 0);
|
const total = items.reduce((sum, item) => sum + item.qty * item.price, 0);
|
||||||
|
|
||||||
const handlePrint = () => {
|
const handlePrint = () => {
|
||||||
publish('DEVICE:PRINT_RECEIPT', {
|
publish(DEVICE_EVENTS.PRINT_RECEIPT, {
|
||||||
receiptId: `RCP-${Date.now().toString(36).toUpperCase()}`,
|
receiptId: `RCP-${Date.now().toString(36).toUpperCase()}`,
|
||||||
items,
|
items,
|
||||||
total,
|
total,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useAppEvent } from '@repo/core-events';
|
import { useAppEvent } from '@repo/core-events';
|
||||||
|
import { DEVICE_EVENTS } from '../../../../core/constants/events';
|
||||||
import type { PrintReceiptPayload } from '@repo/core-events';
|
import type { PrintReceiptPayload } from '@repo/core-events';
|
||||||
|
|
||||||
// ─── Props ──────────────────────────────────────────────────────
|
// ─── Props ──────────────────────────────────────────────────────
|
||||||
@@ -29,7 +30,7 @@ interface PrinterListenerProps {
|
|||||||
* The event bus supports unlimited subscribers per event.
|
* The event bus supports unlimited subscribers per event.
|
||||||
*/
|
*/
|
||||||
export function PrinterListener({ onLog }: PrinterListenerProps) {
|
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;
|
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
|
||||||
|
|
||||||
if (isElectron) {
|
if (isElectron) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { publish } from '@repo/core-events';
|
import { publish } from '@repo/core-events';
|
||||||
|
import { WS_EVENTS } from '../../../../core/constants/events';
|
||||||
|
|
||||||
// ─── Stock Tickers ──────────────────────────────────────────────
|
// ─── Stock Tickers ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ export function startMockWebSocket(config: MockWebSocketConfig): MockWebSocketHa
|
|||||||
prices.set(id, newPrice);
|
prices.set(id, newPrice);
|
||||||
|
|
||||||
// Publish to the event bus
|
// Publish to the event bus
|
||||||
publish('WS:STOCK_UPDATE', {
|
publish(WS_EVENTS.STOCK_UPDATE, {
|
||||||
id,
|
id,
|
||||||
price: newPrice,
|
price: newPrice,
|
||||||
change,
|
change,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, useState, useRef } from 'react';
|
import { memo, useState, useRef } from 'react';
|
||||||
import { useAppEvent } from '@repo/core-events';
|
import { useAppEvent } from '@repo/core-events';
|
||||||
|
import { WS_EVENTS } from '../../../../core/constants/events';
|
||||||
|
|
||||||
// ─── Props ──────────────────────────────────────────────────────
|
// ─── Props ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ export const StockRow = memo(function StockRow({ stockId }: StockRowProps) {
|
|||||||
const renderCountRef = useRef(0);
|
const renderCountRef = useRef(0);
|
||||||
renderCountRef.current += 1;
|
renderCountRef.current += 1;
|
||||||
|
|
||||||
useAppEvent('WS:STOCK_UPDATE', (payload) => {
|
useAppEvent(WS_EVENTS.STOCK_UPDATE, (payload) => {
|
||||||
// ── Critical filter ──────────────────────────────────────────
|
// ── Critical filter ──────────────────────────────────────────
|
||||||
// This is the key performance optimization. Only the row whose
|
// This is the key performance optimization. Only the row whose
|
||||||
// ID matches the event payload will call setState. All other
|
// ID matches the event payload will call setState. All other
|
||||||
|
|||||||
@@ -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,6 +8,9 @@ export const AppStorageKey = {
|
|||||||
REFRESH_TOKEN: 'refresh_token',
|
REFRESH_TOKEN: 'refresh_token',
|
||||||
MOCK_DB_COMPANY_A: 'mock_db_company_a',
|
MOCK_DB_COMPANY_A: 'mock_db_company_a',
|
||||||
OFFLINE_DRAFT: 'offline_draft',
|
OFFLINE_DRAFT: 'offline_draft',
|
||||||
|
SYSTEM_SETTINGS: 'system_settings',
|
||||||
|
HISTORY_PAGE: 'history_page',
|
||||||
|
BOOKMARK_PAGE: 'bookmark_page',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey];
|
export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey];
|
||||||
@@ -23,14 +26,18 @@ export const PLAIN_KEYS = new Set<AppStorageKeyValue>([
|
|||||||
AppStorageKey.THEME,
|
AppStorageKey.THEME,
|
||||||
AppStorageKey.MOCK_DB_COMPANY_A,
|
AppStorageKey.MOCK_DB_COMPANY_A,
|
||||||
AppStorageKey.OFFLINE_DRAFT,
|
AppStorageKey.OFFLINE_DRAFT,
|
||||||
|
AppStorageKey.SYSTEM_SETTINGS,
|
||||||
|
AppStorageKey.HISTORY_PAGE,
|
||||||
|
AppStorageKey.BOOKMARK_PAGE,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const secureStorage = createLocalStorage<AppStorageKeyValue>({
|
export const secureStorage = createLocalStorage<AppStorageKeyValue>({
|
||||||
encryptedKeys: ENCRYPTED_KEYS,
|
encryptedKeys: ENCRYPTED_KEYS,
|
||||||
plainTextKeys: PLAIN_KEYS,
|
plainTextKeys: PLAIN_KEYS,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const secureIndexedDB = createIndexedDB<AppStorageKeyValue>({
|
export const secureIndexedDB = createIndexedDB<AppStorageKeyValue>({
|
||||||
dbName: 'eigen_erp_db',
|
dbName: 'e_apps_db',
|
||||||
storeName: 'web_store',
|
storeName: 'web_store',
|
||||||
encryptedKeys: ENCRYPTED_KEYS,
|
encryptedKeys: ENCRYPTED_KEYS,
|
||||||
plainTextKeys: PLAIN_KEYS,
|
plainTextKeys: PLAIN_KEYS,
|
||||||
|
|||||||
Vendored
+4
@@ -72,5 +72,9 @@ declare module '@repo/core-events' {
|
|||||||
// ── App Lifecycle ────────────────────────────────────────
|
// ── App Lifecycle ────────────────────────────────────────
|
||||||
'APP:INITIALIZED': undefined;
|
'APP:INITIALIZED': undefined;
|
||||||
'APP:ERROR': { message: string; code?: string };
|
'APP:ERROR': { message: string; code?: string };
|
||||||
|
|
||||||
|
// ── Layout ───────────────────────────────────────────────
|
||||||
|
'LAYOUT:TOGGLE_HISTORY_DRAWER': undefined;
|
||||||
|
'LAYOUT:TOGGLE_BOOKMARK_DRAWER': undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,10 @@
|
|||||||
"accessDeniedTitle": "Access Denied",
|
"accessDeniedTitle": "Access Denied",
|
||||||
"noCreateAccess": "You do not have the required permission to create data in this module."
|
"noCreateAccess": "You do not have the required permission to create data in this module."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"system_menu": {
|
||||||
|
"history": "History",
|
||||||
|
"bookmark": "Bookmark"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,6 +78,10 @@
|
|||||||
"accessDeniedTitle": "Akses Ditolak",
|
"accessDeniedTitle": "Akses Ditolak",
|
||||||
"noCreateAccess": "Anda tidak memiliki hak akses untuk menambah data di modul ini."
|
"noCreateAccess": "Anda tidak memiliki hak akses untuk menambah data di modul ini."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"system_menu": {
|
||||||
|
"history": "Riwayat",
|
||||||
|
"bookmark": "Bookmark"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user