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 { 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',
|
||||
}}
|
||||
>
|
||||
{withMenuFilter && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="md"
|
||||
onClick={handleExpandAndSearch}
|
||||
aria-label="Search menu"
|
||||
color="var(--mantine-color-text)"
|
||||
radius="md"
|
||||
>
|
||||
<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,8 +483,12 @@ 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',
|
||||
}}
|
||||
>
|
||||
{withMenuFilter && (
|
||||
<Box style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
@@ -446,14 +496,14 @@ export const SidebarMenu = memo(function SidebarMenu({
|
||||
value={searchQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.currentTarget.value)}
|
||||
leftSection={<Search size={14} />}
|
||||
variant="filled"
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
rightSectionWidth={searchQuery ? 30 : isMac ? 48 : 68}
|
||||
rightSectionWidth={searchQuery ? 30 : 52}
|
||||
rightSection={
|
||||
searchQuery ? (
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => setSearchQuery('')} size="sm">
|
||||
<ActionIcon variant="subtle" onClick={() => setSearchQuery('')} size="sm">
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
@@ -479,16 +529,51 @@ export const SidebarMenu = memo(function SidebarMenu({
|
||||
withArrow
|
||||
>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="gray"
|
||||
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
|
||||
|
||||
@@ -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',
|
||||
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,
|
||||
|
||||
Vendored
+4
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@
|
||||
"accessDeniedTitle": "Access Denied",
|
||||
"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",
|
||||
"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