diff --git a/apps/web/src/apps/index.tsx b/apps/web/src/apps/index.tsx index 761d569..b132d29 100644 --- a/apps/web/src/apps/index.tsx +++ b/apps/web/src/apps/index.tsx @@ -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('compact'); + useEffect(() => { + // Execution runs purely in the background (fire and forget) + // Will not block the initial UI rendering process + initializeAndPurgeHistoryBackground(); + }, []); + return ( diff --git a/apps/web/src/apps/modules/layouts/components/bookmark/index.tsx b/apps/web/src/apps/modules/layouts/components/bookmark/index.tsx new file mode 100644 index 0000000..928b44e --- /dev/null +++ b/apps/web/src/apps/modules/layouts/components/bookmark/index.tsx @@ -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([]); + + // 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(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 ( + setOpened(false)} + position="right" + size="sm" + title={ + + + + {t('bookmark:title')} + + + } + styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column', height: 'calc(100vh - 60px)' } }} + > + {items.length === 0 ? ( + + {t('bookmark:empty')} + + ) : ( + <> + + + {t('bookmark:clearAll')} + + + + + {items.map((item) => ( + setOpened(false)} + label={ + + {item.title} + + } + description={ + + + {item.path} + + + } + leftSection={ + + + + } + rightSection={ + { + e.preventDefault(); + handleRemoveItem(item.id, e); + }} + aria-label={t('bookmark:removeItem')} + > + + + } + styles={{ + root: { + padding: '8px 12px', + borderBottom: '1px solid var(--mantine-color-default-border)', + }, + }} + /> + ))} + + + + )} + + ); +} diff --git a/apps/web/src/apps/modules/layouts/components/history/index.tsx b/apps/web/src/apps/modules/layouts/components/history/index.tsx new file mode 100644 index 0000000..43e1f02 --- /dev/null +++ b/apps/web/src/apps/modules/layouts/components/history/index.tsx @@ -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([]); + + // 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(AppStorageKey.HISTORY_PAGE); + const settings = + (await secureIndexedDB.getItem(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 ( + setOpened(false)} + position="right" + size="sm" + title={ + + + + {t('history:title')} + + + } + styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column', height: 'calc(100vh - 60px)' } }} + > + {items.length === 0 ? ( + + {t('history:empty')} + + ) : ( + <> + + + + + + + + {t('history:clearRange')} + + + + + + + {t('history:clearRange')} + handleClearRange('lastHour')}>{t('history:ranges.lastHour')} + handleClearRange('today')}>{t('history:ranges.today')} + + } onClick={() => handleClearRange('allTime')}> + {t('history:ranges.allTime')} + + + + + + + + {items.map((item) => ( + setOpened(false)} + label={ + + {item.title} + + } + description={ + + + {item.path} + + + } + leftSection={ + + + + } + rightSection={ + { + e.preventDefault(); + handleRemoveItem(item.id, e); + }} + aria-label={t('history:removeItem')} + > + + + } + styles={{ + root: { + padding: '8px 12px', + borderBottom: '1px solid var(--mantine-color-default-border)', + }, + }} + /> + ))} + + + + )} + + ); +} diff --git a/apps/web/src/apps/modules/layouts/components/sidebar.tsx b/apps/web/src/apps/modules/layouts/components/sidebar.tsx index 8ff44ec..03d42cd 100644 --- a/apps/web/src/apps/modules/layouts/components/sidebar.tsx +++ b/apps/web/src/apps/modules/layouts/components/sidebar.tsx @@ -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 ( - {withMenuFilter && ( + {hasTopSection && ( - - - + {withMenuFilter && ( + + + + )} + + {(showHistory || showBookmark) && ( + + {showHistory && ( + + publish(LAYOUT_EVENTS.TOGGLE_HISTORY_DRAWER, undefined)} + aria-label="History" + > + + + + )} + {showBookmark && ( + + publish(LAYOUT_EVENTS.TOGGLE_BOOKMARK_DRAWER, undefined)} + aria-label="Bookmark" + > + + + + )} + + )} )} @@ -425,9 +469,11 @@ export const SidebarMenu = memo(function SidebarMenu({ } // -- Expanded Mode -------------------------------------------------------- + const hasTopSection = withMenuFilter || showHistory || showBookmark; + return ( - {withMenuFilter && ( + {hasTopSection && ( - - ) => setSearchQuery(e.currentTarget.value)} - leftSection={} - variant="filled" - radius="md" - size="xs" - style={{ flex: 1 }} - rightSectionWidth={searchQuery ? 30 : isMac ? 48 : 68} - rightSection={ - searchQuery ? ( - setSearchQuery('')} size="sm"> - - - ) : ( - - {filterMenuShortcutText} - - ) - } - /> - - + ) => setSearchQuery(e.currentTarget.value)} + leftSection={} + variant="default" + radius="md" + size="xs" + style={{ flex: 1 }} + rightSectionWidth={searchQuery ? 30 : 52} + rightSection={ + searchQuery ? ( + setSearchQuery('')} size="sm"> + + + ) : ( + + {filterMenuShortcutText} + + ) + } + /> + - {isAllExpanded ? : } - - - + + {isAllExpanded ? : } + + + + )} + + {(showHistory || showBookmark) && ( + + {showHistory && ( + + )} + {showBookmark && ( + + )} + + )} )} diff --git a/apps/web/src/apps/modules/layouts/hooks/useHistoryTracker.ts b/apps/web/src/apps/modules/layouts/hooks/useHistoryTracker.ts new file mode 100644 index 0000000..042dcc2 --- /dev/null +++ b/apps/web/src/apps/modules/layouts/hooks/useHistoryTracker.ts @@ -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) => { + 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(AppStorageKey.HISTORY_PAGE)) || []; + + if (data.length === 0) { + isHistoryReady = true; + return; + } + + const settings = + (await secureIndexedDB.getItem(AppStorageKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS; + const now = Date.now(); + const unitMs: Record = { + 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]); +} diff --git a/apps/web/src/apps/modules/layouts/locales/en/bookmark.json b/apps/web/src/apps/modules/layouts/locales/en/bookmark.json new file mode 100644 index 0000000..0191e20 --- /dev/null +++ b/apps/web/src/apps/modules/layouts/locales/en/bookmark.json @@ -0,0 +1,8 @@ +{ + "title": "Bookmarks", + "empty": "No saved bookmarks found.", + "clearAll": "Clear All", + "removeItem": "Remove Bookmark", + "savedAt": "Saved {{time}}", + "clearSuccess": "Bookmarks cleared successfully." +} diff --git a/apps/web/src/apps/modules/layouts/locales/en/history.json b/apps/web/src/apps/modules/layouts/locales/en/history.json new file mode 100644 index 0000000..755e965 --- /dev/null +++ b/apps/web/src/apps/modules/layouts/locales/en/history.json @@ -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." +} diff --git a/apps/web/src/apps/modules/layouts/locales/id/bookmark.json b/apps/web/src/apps/modules/layouts/locales/id/bookmark.json new file mode 100644 index 0000000..e882a53 --- /dev/null +++ b/apps/web/src/apps/modules/layouts/locales/id/bookmark.json @@ -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." +} \ No newline at end of file diff --git a/apps/web/src/apps/modules/layouts/locales/id/history.json b/apps/web/src/apps/modules/layouts/locales/id/history.json new file mode 100644 index 0000000..55ada57 --- /dev/null +++ b/apps/web/src/apps/modules/layouts/locales/id/history.json @@ -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." +} diff --git a/apps/web/src/apps/modules/layouts/module.layout.tsx b/apps/web/src/apps/modules/layouts/module.layout.tsx index c9b6465..e935d37 100644 --- a/apps/web/src/apps/modules/layouts/module.layout.tsx +++ b/apps/web/src/apps/modules/layouts/module.layout.tsx @@ -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} + + ); } diff --git a/apps/web/src/apps/modules/layouts/types/saved-page.types.ts b/apps/web/src/apps/modules/layouts/types/saved-page.types.ts new file mode 100644 index 0000000..8e30546 --- /dev/null +++ b/apps/web/src/apps/modules/layouts/types/saved-page.types.ts @@ -0,0 +1,6 @@ +export interface SavedPageItem { + id: string; + title: string; + path: string; + timestamp: number; +} diff --git a/apps/web/src/apps/modules/system/information/index.tsx b/apps/web/src/apps/modules/system/information/index.tsx index 3fcfc84..9a87d36 100644 --- a/apps/web/src/apps/modules/system/information/index.tsx +++ b/apps/web/src/apps/modules/system/information/index.tsx @@ -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 ( ; + +export function HistorySetting({ onDirtyChange }: { onDirtyChange: (isDirty: boolean) => void }) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + + const { + control, + handleSubmit, + reset, + formState: { isDirty }, + } = useForm({ + 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(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(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(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 ( +
+ + + + {t('setting:history.retentionTitle')} + + + {t('setting:history.retentionDesc')} + + + + + + + + + + + + + {isDirty && ( + + )} + + +
+ ); +} diff --git a/apps/web/src/apps/modules/system/setting/components/notification-setting.tsx b/apps/web/src/apps/modules/system/setting/components/notification-setting.tsx new file mode 100644 index 0000000..4ff73f2 --- /dev/null +++ b/apps/web/src/apps/modules/system/setting/components/notification-setting.tsx @@ -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; + +export function NotificationSetting({ onDirtyChange }: { onDirtyChange: (isDirty: boolean) => void }) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + + const { control, handleSubmit, reset, formState: { isDirty } } = useForm({ + 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 ( +
+ + + {t('setting:notification.title')} + {t('setting:notification.desc')} + + + + + + + + + {isDirty && ( + + )} + + +
+ ); +} diff --git a/apps/web/src/apps/modules/system/setting/index.tsx b/apps/web/src/apps/modules/system/setting/index.tsx index 9df8096..153ec85 100644 --- a/apps/web/src/apps/modules/system/setting/index.tsx +++ b/apps/web/src/apps/modules/system/setting/index.tsx @@ -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('history'); + const [isDirty, setIsDirty] = useState(false); + const [pendingTab, setPendingTab] = useState(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 ( - - - - {t('common:configuration')} - - {t('common:configurationDesc')} - - + + + + + + }> + {t('setting:tabs.history')} + + }> + {t('setting:tabs.notification')} + + + + + + {activeTab === 'history' && } + + + + + + {activeTab === 'notification' && } + + + + + {t('setting:unsaved.title')}} + yOffset={50} + > + + {t('setting:unsaved.message')} + + + + + + + ); } diff --git a/apps/web/src/apps/modules/system/setting/locales/en/setting.json b/apps/web/src/apps/modules/system/setting/locales/en/setting.json new file mode 100644 index 0000000..01cebc1 --- /dev/null +++ b/apps/web/src/apps/modules/system/setting/locales/en/setting.json @@ -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" + } +} \ No newline at end of file diff --git a/apps/web/src/apps/modules/system/setting/locales/id/setting.json b/apps/web/src/apps/modules/system/setting/locales/id/setting.json new file mode 100644 index 0000000..873e175 --- /dev/null +++ b/apps/web/src/apps/modules/system/setting/locales/id/setting.json @@ -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" + } +} \ No newline at end of file diff --git a/apps/web/src/apps/modules/system/setting/types/setting.types.ts b/apps/web/src/apps/modules/system/setting/types/setting.types.ts new file mode 100644 index 0000000..7341f43 --- /dev/null +++ b/apps/web/src/apps/modules/system/setting/types/setting.types.ts @@ -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', +}; diff --git a/apps/web/src/apps/showcase/events-demo/auth-sync/profile-settings.ui.tsx b/apps/web/src/apps/showcase/events-demo/auth-sync/profile-settings.ui.tsx index 33b10eb..5e774c4 100644 --- a/apps/web/src/apps/showcase/events-demo/auth-sync/profile-settings.ui.tsx +++ b/apps/web/src/apps/showcase/events-demo/auth-sync/profile-settings.ui.tsx @@ -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, diff --git a/apps/web/src/apps/showcase/events-demo/auth-sync/storage-sync.listener.tsx b/apps/web/src/apps/showcase/events-demo/auth-sync/storage-sync.listener.tsx index 7725582..88f61f5 100644 --- a/apps/web/src/apps/showcase/events-demo/auth-sync/storage-sync.listener.tsx +++ b/apps/web/src/apps/showcase/events-demo/auth-sync/storage-sync.listener.tsx @@ -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. diff --git a/apps/web/src/apps/showcase/events-demo/index.tsx b/apps/web/src/apps/showcase/events-demo/index.tsx index 664b057..86b7e10 100644 --- a/apps/web/src/apps/showcase/events-demo/index.tsx +++ b/apps/web/src/apps/showcase/events-demo/index.tsx @@ -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 - The CashierUI publishes a DEVICE:PRINT_RECEIPT event. - A headless PrinterListener decides whether to use Electron IPC or browser print. + The CashierUI publishes a {DEVICE_EVENTS.PRINT_RECEIPT} event. + The PrinterListener listens for it and simulates interacting with a physical printer. {/* Headless listener — renders nothing visible */} @@ -125,8 +126,8 @@ export default function EventsDemoPage() { 💾 Showcase 3: Auth/Profile Sync with @repo/core-storage - ProfileSettingsUI publishes AUTH:PROFILE_UPDATED. - A headless StorageSyncListener persists it to IndexedDB via secureIndexedDB. + ProfileSettingsUI publishes {AUTH_EVENTS.PROFILE_UPDATED}. + StorageSyncListener silently catches it in the background and saves to IndexedDB via secureIndexedDB. {/* Headless listener — renders nothing visible */} diff --git a/apps/web/src/apps/showcase/events-demo/printer/cashier.ui.tsx b/apps/web/src/apps/showcase/events-demo/printer/cashier.ui.tsx index 475befb..4e5bef6 100644 --- a/apps/web/src/apps/showcase/events-demo/printer/cashier.ui.tsx +++ b/apps/web/src/apps/showcase/events-demo/printer/cashier.ui.tsx @@ -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, diff --git a/apps/web/src/apps/showcase/events-demo/printer/printer.listener.tsx b/apps/web/src/apps/showcase/events-demo/printer/printer.listener.tsx index 406dc4c..bddc46c 100644 --- a/apps/web/src/apps/showcase/events-demo/printer/printer.listener.tsx +++ b/apps/web/src/apps/showcase/events-demo/printer/printer.listener.tsx @@ -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) { diff --git a/apps/web/src/apps/showcase/events-demo/stock-grid/mock-websocket.service.ts b/apps/web/src/apps/showcase/events-demo/stock-grid/mock-websocket.service.ts index dcf19e8..b83c961 100644 --- a/apps/web/src/apps/showcase/events-demo/stock-grid/mock-websocket.service.ts +++ b/apps/web/src/apps/showcase/events-demo/stock-grid/mock-websocket.service.ts @@ -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, diff --git a/apps/web/src/apps/showcase/events-demo/stock-grid/stock-row.ui.tsx b/apps/web/src/apps/showcase/events-demo/stock-grid/stock-row.ui.tsx index f949ff8..f5d489d 100644 --- a/apps/web/src/apps/showcase/events-demo/stock-grid/stock-row.ui.tsx +++ b/apps/web/src/apps/showcase/events-demo/stock-grid/stock-row.ui.tsx @@ -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 diff --git a/apps/web/src/core/constants/events.ts b/apps/web/src/core/constants/events.ts new file mode 100644 index 0000000..0cdd2f4 --- /dev/null +++ b/apps/web/src/core/constants/events.ts @@ -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; diff --git a/apps/web/src/core/storage/local/index.ts b/apps/web/src/core/storage/local/index.ts index 8d92d59..b455d43 100644 --- a/apps/web/src/core/storage/local/index.ts +++ b/apps/web/src/core/storage/local/index.ts @@ -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([ AppStorageKey.THEME, AppStorageKey.MOCK_DB_COMPANY_A, AppStorageKey.OFFLINE_DRAFT, + AppStorageKey.SYSTEM_SETTINGS, + AppStorageKey.HISTORY_PAGE, + AppStorageKey.BOOKMARK_PAGE, ]); export const secureStorage = createLocalStorage({ encryptedKeys: ENCRYPTED_KEYS, plainTextKeys: PLAIN_KEYS, }); + export const secureIndexedDB = createIndexedDB({ - dbName: 'eigen_erp_db', + dbName: 'e_apps_db', storeName: 'web_store', encryptedKeys: ENCRYPTED_KEYS, plainTextKeys: PLAIN_KEYS, diff --git a/apps/web/src/types/events.d.ts b/apps/web/src/types/events.d.ts index e95b272..b94bffb 100644 --- a/apps/web/src/types/events.d.ts +++ b/apps/web/src/types/events.d.ts @@ -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; } } diff --git a/packages/core-i18n/src/locales/en/common.json b/packages/core-i18n/src/locales/en/common.json index 68140b7..366eea6 100644 --- a/packages/core-i18n/src/locales/en/common.json +++ b/packages/core-i18n/src/locales/en/common.json @@ -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" } } } \ No newline at end of file diff --git a/packages/core-i18n/src/locales/id/common.json b/packages/core-i18n/src/locales/id/common.json index 977cd03..6b8fe60 100644 --- a/packages/core-i18n/src/locales/id/common.json +++ b/packages/core-i18n/src/locales/id/common.json @@ -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" } } } \ No newline at end of file