diff --git a/dist-electron/main.cjs b/dist-electron/main.cjs index b382cc3..f63731e 100644 --- a/dist-electron/main.cjs +++ b/dist-electron/main.cjs @@ -22,7 +22,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge )); // electron/main.ts -var electron = __toESM(require("electron"), 1); +var import_electron3 = require("electron"); var import_node_path = __toESM(require("node:path"), 1); var fs3 = __toESM(require("node:fs"), 1); @@ -261,7 +261,7 @@ if ($unique.Count -eq 0) { } `; try { - const { stdout, stderr } = await execFileAsync("powershell", [ + const { stdout } = await execFileAsync("powershell", [ "-NoProfile", "-ExecutionPolicy", "Bypass", @@ -288,7 +288,7 @@ if ($unique.Count -eq 0) { } }; var createNotificationTracker = () => { - let persistedData = loadPersistedData(); + const persistedData = loadPersistedData(); let lastError; let saveTimeout = null; const scheduleSave = () => { @@ -773,9 +773,678 @@ var createUsageTracker = () => { }; }; +// src/i18n/locales/en-US.ts +var enUS = { + common: { + today: "Today", + yesterday: "Yesterday", + none: "None", + noData: "No data", + notAvailable: "N/A", + loading: "Loading...", + live: "Live", + focused: "Focused", + tracking: "Tracking", + unknown: "Unknown" + }, + locales: { + "zh-CN": "\u7B80\u4F53\u4E2D\u6587", + "en-US": "English" + }, + nav: { + dashboard: "Dashboard", + insights: "Insights", + apps: "Apps", + notifications: "Notifications", + settings: "Settings" + }, + sidebar: { + focusScore: "Focus score" + }, + themes: { + dark: { + name: "Dark", + description: "Easy on the eyes, perfect for night" + }, + light: { + name: "Light", + description: "Clean and bright for daytime" + }, + tokyo: { + name: "Tokyo", + description: "Cyberpunk vibes with purple accents" + }, + skin: { + name: "Skin", + description: "Warm and soft aesthetic" + } + }, + dashboard: { + greeting: { + morning: "Good morning", + afternoon: "Good afternoon", + evening: "Good evening" + }, + subtitle: "Your screen time for today", + cards: { + screenTime: "Screen Time", + totalToday: "Total today", + dailyAverage: "Daily Avg", + acrossDays: "Across {count} days", + noHistory: "No history yet", + topCategory: "Top Category", + notifications: "Notifications", + topNotificationApp: "Top: {name}" + }, + charts: { + dailyUsageTitle: "Daily usage", + dailyUsageSubtitle: "Minutes per day across all apps", + dailyUsageEmpty: "Start using apps to see your usage data", + categoriesTitle: "Categories", + categoriesSubtitle: "Time by category today", + categoriesEmpty: "No category data yet", + usageDataset: "Usage (minutes)", + minutesDataset: "Minutes" + }, + sections: { + topAppsToday: "Top Apps Today", + activeApps: "Active apps", + activeAppsSubtitle: "Currently open windows", + activeAppsEmpty: "No apps with visible windows detected", + currentFocus: "Current focus", + appsUsedToday: "Apps used today", + daysRecorded: "Days recorded", + noActiveApp: "No active app", + otherApps: "Other apps" + }, + trend: { + collecting: "Collecting data", + newData: "New data", + up: "Trending up", + down: "Trending down", + stable: "Stable" + } + }, + insights: { + title: "Insights", + subtitle: "Deep dive into your screen time patterns", + cards: { + focusScore: "Focus Score", + focusScoreSub: "Based on productive app usage", + appsUsed: "Apps Used", + topApp: "Top app", + topAppSub: "Most used by minutes", + totalTracked: "Total tracked", + totalTrackedSub: "All time screen time" + }, + charts: { + weeklyTrendTitle: "Weekly trend", + weeklyTrendSubtitle: "Screen time over the last 7 days", + weeklyTrendEmpty: "Start using apps to see trends", + categoryBreakdownTitle: "Category breakdown", + categoryBreakdownSubtitle: "How you spend your screen time", + categoryBreakdownEmpty: "No category data yet", + minutesDataset: "Minutes" + }, + quickStats: { + title: "Quick stats", + daysTracked: "Days tracked", + appsUsed: "Apps used", + dailyAverage: "Daily average", + topCategory: "Top category", + peakDay: "Peak day" + } + }, + apps: { + title: "Apps", + subtitle: "Screen time for {date}", + dateView: "View:", + stats: { + totalTime: "Total Time", + appsUsed: "Apps Used", + topCategory: "Top Category" + }, + openApps: { + title: "Open apps", + subtitle: "Apps with visible windows", + empty: "No apps with visible windows detected yet." + }, + backgroundApps: { + title: "Background processes", + subtitle: "Running without visible windows", + empty: "No background processes detected.", + processCount: "{count} process(es)" + }, + timeLimits: { + title: "Active Time Limits", + subtitle: "{count} app(s) with limits", + usage: "{used} / {limit} min", + exceeded: "Exceeded", + setLimit: "Set time limit", + edit: "Edit", + remove: "Remove", + save: "Save", + cancel: "Cancel", + limit: "Limit:", + minutesPlaceholder: "Minutes", + perDayUnit: "min/day" + }, + controls: { + searchPlaceholder: "Search apps...", + sortBy: "Sort by:", + time: "Time", + name: "Name", + category: "Category" + }, + empty: { + search: "No apps match your search", + date: "No apps tracked for {date}" + }, + cards: { + percentageOfTotal: "{count}% of total" + } + }, + notifications: { + title: "Notifications", + subtitle: "Track notification activity from your apps today", + status: { + disabledTitle: "Notification Logs Disabled", + disabledMessage: "Windows notification logs are disabled. Enable them in Event Viewer or run as Administrator.", + errorTitle: "Error Reading Logs", + errorMessage: "Failed to read notification logs.", + errorWithDetails: "Failed to read notification logs: {detail}" + }, + stats: { + totalToday: "Total Today", + apps: "Apps", + avgPerApp: "Avg per App", + topSender: "Top Sender" + }, + breakdown: { + title: "Breakdown by App", + subtitle: "Notifications received today per application", + countBadge: "{count} apps", + app: "Application", + count: "Count", + emptyTitle: "No notifications tracked yet", + emptySubtitle: "Notifications from your apps will appear here as they come in.", + emptyActionSubtitle: "Fix the issue above to start tracking notifications.", + otherApps: "Other Apps" + }, + tips: { + title: "Reduce Distractions", + subtitle: "Tips for managing notification overload", + focusAssistTitle: "Enable Focus Assist", + focusAssistDesc: "Use Windows Focus Assist to silence notifications during work hours. Access it from the Action Center or Settings.", + appNotificationsTitle: "Configure App Notifications", + appNotificationsDesc: "Go to Windows Settings > System > Notifications to customize which apps can send you notifications.", + timeLimitsTitle: "Set Time Limits", + timeLimitsDesc: "Use the Apps page to set daily time limits for distracting applications. You will receive a notification when limits are exceeded." + } + }, + settings: { + title: "Settings", + subtitle: "Customize your ScreenForge experience", + loadingSubtitle: "Loading...", + sections: { + appearance: "Appearance", + appearanceDesc: "Choose your preferred theme", + language: "Language", + languageDesc: "Choose your interface language", + behavior: "Behavior", + behaviorDesc: "Control how ScreenForge runs", + timeLimits: "Time Limits", + timeLimitsDesc: "Control app usage notifications", + data: "Data", + dataDesc: "Manage your tracked data", + about: "About" + }, + behavior: { + startWithWindows: "Start with Windows", + startWithWindowsDesc: "Launch ScreenForge when you log in", + minimizeToTray: "Minimize to tray", + minimizeToTrayDesc: "Keep running in background when closed" + }, + timeLimits: { + notifications: "Time limit notifications", + notificationsDesc: "Get notified when you exceed app time limits", + activeLimits: "Active limits", + activeLimitsDesc: "You have {count} app(s) with time limits. Manage them on the Apps page." + }, + data: { + clearAll: "Clear all data", + clearAllDesc: "Remove all tracked usage history", + clearButton: "Clear data", + clearConfirm: "Are you sure? This will delete all your usage data." + }, + about: { + version: "Version", + platform: "Platform", + builtWith: "Built with", + windows: "Windows", + techStack: "Electron + React" + } + }, + tables: { + topApps: "Top apps", + usageToday: "Usage today", + app: "App", + category: "Category", + time: "Time", + notifications: "Notifications", + empty: "No app data yet for today", + notificationToday: "Today", + notificationEmpty: "No notifications yet" + }, + datePicker: { + previousMonth: "Previous month", + nextMonth: "Next month", + dataAvailable: "Data available", + dataRange: "Data from {start} to {end}" + }, + suggestions: { + title: "Suggestions", + subtitle: "Based on your recent activity", + welcome: { + title: "Welcome to ScreenForge!", + detail: "Keep the app running to track your screen time automatically." + }, + entertainment: { + title: "High entertainment usage", + detail: "Consider setting time limits for entertainment apps to boost productivity." + }, + social: { + title: "Social apps taking over", + detail: "Try scheduling specific times for checking social media." + }, + productive: { + title: "Great focus!", + detail: "You're spending most of your time on productive tasks. Keep it up!" + }, + balance: { + title: "Balanced usage", + detail: "Your screen time is well distributed across different activities." + }, + breaks: { + title: "Remember to take breaks", + detail: "Use the 20-20-20 rule: every 20 minutes, look at something 20 feet away for 20 seconds." + } + }, + native: { + timeLimitReachedTitle: "Time Limit Reached", + timeLimitReachedBody: "You've used {appName} for {usedMinutes} minutes today. Your limit is {limitMinutes} minutes.", + trayTooltip: "ScreenForge - Screen Time Tracker", + trayShow: "Show ScreenForge", + trayQuit: "Quit" + }, + categories: { + Productivity: "Productivity", + Education: "Education", + Communication: "Communication", + Utilities: "Utilities", + Browsers: "Browsers", + Entertainment: "Entertainment", + Social: "Social", + System: "System", + Other: "Other", + Unknown: "Unknown" + } +}; + +// src/i18n/locales/zh-CN.ts +var zhCN = { + common: { + today: "\u4ECA\u5929", + yesterday: "\u6628\u5929", + none: "\u65E0", + noData: "\u6682\u65E0\u6570\u636E", + notAvailable: "\u6682\u65E0", + loading: "\u52A0\u8F7D\u4E2D...", + live: "\u5B9E\u65F6", + focused: "\u5F53\u524D\u7126\u70B9", + tracking: "\u8FFD\u8E2A\u4E2D", + unknown: "\u672A\u77E5" + }, + locales: { + "zh-CN": "\u7B80\u4F53\u4E2D\u6587", + "en-US": "English" + }, + nav: { + dashboard: "\u4EEA\u8868\u76D8", + insights: "\u6D1E\u5BDF", + apps: "\u5E94\u7528", + notifications: "\u901A\u77E5", + settings: "\u8BBE\u7F6E" + }, + sidebar: { + focusScore: "\u4E13\u6CE8\u5206" + }, + themes: { + dark: { + name: "\u6DF1\u8272", + description: "\u66F4\u62A4\u773C\uFF0C\u9002\u5408\u591C\u95F4\u4F7F\u7528" + }, + light: { + name: "\u6D45\u8272", + description: "\u6E05\u723D\u660E\u4EAE\uFF0C\u9002\u5408\u767D\u5929\u4F7F\u7528" + }, + tokyo: { + name: "\u4E1C\u4EAC", + description: "\u5E26\u6709\u9713\u8679\u611F\u7684\u672A\u6765\u98CE\u914D\u8272" + }, + skin: { + name: "\u6696\u80A4", + description: "\u6E29\u6696\u67D4\u548C\u7684\u754C\u9762\u98CE\u683C" + } + }, + dashboard: { + greeting: { + morning: "\u65E9\u4E0A\u597D", + afternoon: "\u4E0B\u5348\u597D", + evening: "\u665A\u4E0A\u597D" + }, + subtitle: "\u67E5\u770B\u4F60\u4ECA\u5929\u7684\u5C4F\u5E55\u4F7F\u7528\u60C5\u51B5", + cards: { + screenTime: "\u5C4F\u5E55\u65F6\u957F", + totalToday: "\u4ECA\u65E5\u603B\u8BA1", + dailyAverage: "\u65E5\u5747", + acrossDays: "\u8FD1 {count} \u5929\u5E73\u5747", + noHistory: "\u6682\u65E0\u5386\u53F2\u8BB0\u5F55", + topCategory: "\u6700\u9AD8\u5206\u7C7B", + notifications: "\u901A\u77E5\u6570", + topNotificationApp: "\u6700\u9AD8\uFF1A{name}" + }, + charts: { + dailyUsageTitle: "\u6BCF\u65E5\u4F7F\u7528\u8D8B\u52BF", + dailyUsageSubtitle: "\u6240\u6709\u5E94\u7528\u6BCF\u5929\u7684\u4F7F\u7528\u5206\u949F\u6570", + dailyUsageEmpty: "\u5F00\u59CB\u4F7F\u7528\u5E94\u7528\u540E\uFF0C\u8FD9\u91CC\u4F1A\u663E\u793A\u4F60\u7684\u4F7F\u7528\u6570\u636E", + categoriesTitle: "\u5206\u7C7B\u5206\u5E03", + categoriesSubtitle: "\u4ECA\u5929\u5404\u5206\u7C7B\u7684\u4F7F\u7528\u65F6\u957F", + categoriesEmpty: "\u6682\u65E0\u5206\u7C7B\u6570\u636E", + usageDataset: "\u4F7F\u7528\u65F6\u957F\uFF08\u5206\u949F\uFF09", + minutesDataset: "\u5206\u949F" + }, + sections: { + topAppsToday: "\u4ECA\u65E5\u5E38\u7528\u5E94\u7528", + activeApps: "\u6D3B\u8DC3\u5E94\u7528", + activeAppsSubtitle: "\u5F53\u524D\u5DF2\u6253\u5F00\u7684\u7A97\u53E3", + activeAppsEmpty: "\u6682\u672A\u68C0\u6D4B\u5230\u53EF\u89C1\u7A97\u53E3\u5E94\u7528", + currentFocus: "\u5F53\u524D\u4E13\u6CE8\u5E94\u7528", + appsUsedToday: "\u4ECA\u65E5\u4F7F\u7528\u5E94\u7528\u6570", + daysRecorded: "\u8BB0\u5F55\u5929\u6570", + noActiveApp: "\u5F53\u524D\u6CA1\u6709\u6D3B\u8DC3\u5E94\u7528", + otherApps: "\u5176\u4ED6\u5E94\u7528" + }, + trend: { + collecting: "\u6B63\u5728\u6536\u96C6\u6570\u636E", + newData: "\u521A\u5F00\u59CB\u8BB0\u5F55", + up: "\u5448\u4E0A\u5347\u8D8B\u52BF", + down: "\u5448\u4E0B\u964D\u8D8B\u52BF", + stable: "\u57FA\u672C\u7A33\u5B9A" + } + }, + insights: { + title: "\u6D1E\u5BDF", + subtitle: "\u66F4\u6DF1\u5165\u5730\u4E86\u89E3\u4F60\u7684\u5C4F\u5E55\u4F7F\u7528\u6A21\u5F0F", + cards: { + focusScore: "\u4E13\u6CE8\u5206", + focusScoreSub: "\u57FA\u4E8E\u9AD8\u6548\u5E94\u7528\u4F7F\u7528\u60C5\u51B5\u8BA1\u7B97", + appsUsed: "\u4F7F\u7528\u5E94\u7528\u6570", + topApp: "\u6700\u5E38\u7528\u5E94\u7528", + topAppSub: "\u6309\u5206\u949F\u6570\u7EDF\u8BA1", + totalTracked: "\u7D2F\u8BA1\u8BB0\u5F55", + totalTrackedSub: "\u5168\u90E8\u5386\u53F2\u5C4F\u5E55\u65F6\u957F" + }, + charts: { + weeklyTrendTitle: "\u8FD1\u4E00\u5468\u8D8B\u52BF", + weeklyTrendSubtitle: "\u8FC7\u53BB 7 \u5929\u7684\u5C4F\u5E55\u4F7F\u7528\u65F6\u957F", + weeklyTrendEmpty: "\u5F00\u59CB\u4F7F\u7528\u5E94\u7528\u540E\uFF0C\u8FD9\u91CC\u4F1A\u663E\u793A\u8D8B\u52BF", + categoryBreakdownTitle: "\u5206\u7C7B\u5360\u6BD4", + categoryBreakdownSubtitle: "\u4F60\u7684\u5C4F\u5E55\u65F6\u95F4\u4E3B\u8981\u82B1\u5728\u54EA\u91CC", + categoryBreakdownEmpty: "\u6682\u65E0\u5206\u7C7B\u6570\u636E", + minutesDataset: "\u5206\u949F" + }, + quickStats: { + title: "\u901F\u89C8\u7EDF\u8BA1", + daysTracked: "\u8BB0\u5F55\u5929\u6570", + appsUsed: "\u4F7F\u7528\u5E94\u7528\u6570", + dailyAverage: "\u65E5\u5747\u65F6\u957F", + topCategory: "\u6700\u9AD8\u5206\u7C7B", + peakDay: "\u5CF0\u503C\u65E5\u671F" + } + }, + apps: { + title: "\u5E94\u7528", + subtitle: "{date} \u7684\u5C4F\u5E55\u4F7F\u7528\u60C5\u51B5", + dateView: "\u67E5\u770B\uFF1A", + stats: { + totalTime: "\u603B\u65F6\u957F", + appsUsed: "\u4F7F\u7528\u5E94\u7528\u6570", + topCategory: "\u6700\u9AD8\u5206\u7C7B" + }, + openApps: { + title: "\u5DF2\u6253\u5F00\u5E94\u7528", + subtitle: "\u6709\u53EF\u89C1\u7A97\u53E3\u7684\u5E94\u7528", + empty: "\u6682\u672A\u68C0\u6D4B\u5230\u6709\u53EF\u89C1\u7A97\u53E3\u7684\u5E94\u7528\u3002" + }, + backgroundApps: { + title: "\u540E\u53F0\u8FDB\u7A0B", + subtitle: "\u6B63\u5728\u8FD0\u884C\u4F46\u6CA1\u6709\u53EF\u89C1\u7A97\u53E3", + empty: "\u672A\u68C0\u6D4B\u5230\u540E\u53F0\u8FDB\u7A0B\u3002", + processCount: "{count} \u4E2A\u8FDB\u7A0B" + }, + timeLimits: { + title: "\u5DF2\u542F\u7528\u65F6\u957F\u9650\u5236", + subtitle: "\u5171 {count} \u4E2A\u5E94\u7528\u8BBE\u7F6E\u4E86\u9650\u989D", + usage: "{used} / {limit} \u5206\u949F", + exceeded: "\u5DF2\u8D85\u9650", + setLimit: "\u8BBE\u7F6E\u65F6\u957F\u9650\u5236", + edit: "\u7F16\u8F91", + remove: "\u79FB\u9664", + save: "\u4FDD\u5B58", + cancel: "\u53D6\u6D88", + limit: "\u9650\u5236\uFF1A", + minutesPlaceholder: "\u5206\u949F", + perDayUnit: "\u5206\u949F/\u5929" + }, + controls: { + searchPlaceholder: "\u641C\u7D22\u5E94\u7528...", + sortBy: "\u6392\u5E8F\u65B9\u5F0F\uFF1A", + time: "\u65F6\u957F", + name: "\u540D\u79F0", + category: "\u5206\u7C7B" + }, + empty: { + search: "\u6CA1\u6709\u5339\u914D\u641C\u7D22\u6761\u4EF6\u7684\u5E94\u7528", + date: "{date} \u6682\u65E0\u5E94\u7528\u4F7F\u7528\u8BB0\u5F55" + }, + cards: { + percentageOfTotal: "\u5360\u603B\u65F6\u957F {count}%" + } + }, + notifications: { + title: "\u901A\u77E5", + subtitle: "\u8DDF\u8E2A\u4ECA\u5929\u6765\u81EA\u5404\u5E94\u7528\u7684\u901A\u77E5\u6D3B\u52A8", + status: { + disabledTitle: "\u901A\u77E5\u65E5\u5FD7\u672A\u542F\u7528", + disabledMessage: "Windows \u901A\u77E5\u65E5\u5FD7\u5F53\u524D\u5DF2\u5173\u95ED\uFF0C\u8BF7\u5728\u4E8B\u4EF6\u67E5\u770B\u5668\u4E2D\u542F\u7528\uFF0C\u6216\u5C1D\u8BD5\u4EE5\u7BA1\u7406\u5458\u8EAB\u4EFD\u8FD0\u884C\u3002", + errorTitle: "\u8BFB\u53D6\u65E5\u5FD7\u5931\u8D25", + errorMessage: "\u8BFB\u53D6\u901A\u77E5\u65E5\u5FD7\u5931\u8D25\u3002", + errorWithDetails: "\u8BFB\u53D6\u901A\u77E5\u65E5\u5FD7\u5931\u8D25\uFF1A{detail}" + }, + stats: { + totalToday: "\u4ECA\u65E5\u603B\u6570", + apps: "\u5E94\u7528\u6570", + avgPerApp: "\u5355\u5E94\u7528\u5E73\u5747", + topSender: "\u6700\u9AD8\u6765\u6E90" + }, + breakdown: { + title: "\u6309\u5E94\u7528\u62C6\u5206", + subtitle: "\u4ECA\u5929\u6BCF\u4E2A\u5E94\u7528\u6536\u5230\u7684\u901A\u77E5\u6570", + countBadge: "{count} \u4E2A\u5E94\u7528", + app: "\u5E94\u7528", + count: "\u6570\u91CF", + emptyTitle: "\u8FD8\u6CA1\u6709\u8BB0\u5F55\u5230\u901A\u77E5", + emptySubtitle: "\u6765\u81EA\u5E94\u7528\u7684\u901A\u77E5\u4F1A\u5728\u6536\u5230\u540E\u663E\u793A\u5728\u8FD9\u91CC\u3002", + emptyActionSubtitle: "\u5148\u89E3\u51B3\u4E0A\u65B9\u95EE\u9898\uFF0C\u4E4B\u540E\u5373\u53EF\u5F00\u59CB\u8DDF\u8E2A\u901A\u77E5\u3002", + otherApps: "\u5176\u4ED6\u5E94\u7528" + }, + tips: { + title: "\u51CF\u5C11\u5E72\u6270", + subtitle: "\u7BA1\u7406\u901A\u77E5\u8FC7\u8F7D\u7684\u5C0F\u5EFA\u8BAE", + focusAssistTitle: "\u5F00\u542F\u4E13\u6CE8\u52A9\u624B", + focusAssistDesc: "\u4F7F\u7528 Windows \u4E13\u6CE8\u52A9\u624B\u5728\u5DE5\u4F5C\u65F6\u6BB5\u9759\u97F3\u901A\u77E5\uFF0C\u53EF\u5728\u64CD\u4F5C\u4E2D\u5FC3\u6216\u8BBE\u7F6E\u4E2D\u5F00\u542F\u3002", + appNotificationsTitle: "\u914D\u7F6E\u5E94\u7528\u901A\u77E5", + appNotificationsDesc: "\u524D\u5F80 Windows \u8BBE\u7F6E > \u7CFB\u7EDF > \u901A\u77E5\uFF0C\u81EA\u5B9A\u4E49\u54EA\u4E9B\u5E94\u7528\u53EF\u4EE5\u5411\u4F60\u53D1\u9001\u901A\u77E5\u3002", + timeLimitsTitle: "\u8BBE\u7F6E\u65F6\u957F\u9650\u5236", + timeLimitsDesc: "\u4F60\u53EF\u4EE5\u5728\u201C\u5E94\u7528\u201D\u9875\u9762\u4E3A\u5BB9\u6613\u5206\u5FC3\u7684\u5E94\u7528\u8BBE\u7F6E\u6BCF\u65E5\u9650\u5236\uFF0C\u8D85\u9650\u540E\u4F1A\u6536\u5230\u63D0\u9192\u3002" + } + }, + settings: { + title: "\u8BBE\u7F6E", + subtitle: "\u81EA\u5B9A\u4E49\u4F60\u7684 ScreenForge \u4F7F\u7528\u4F53\u9A8C", + loadingSubtitle: "\u52A0\u8F7D\u4E2D...", + sections: { + appearance: "\u5916\u89C2", + appearanceDesc: "\u9009\u62E9\u4F60\u559C\u6B22\u7684\u4E3B\u9898", + language: "\u8BED\u8A00", + languageDesc: "\u9009\u62E9\u754C\u9762\u663E\u793A\u8BED\u8A00", + behavior: "\u884C\u4E3A", + behaviorDesc: "\u63A7\u5236 ScreenForge \u7684\u8FD0\u884C\u65B9\u5F0F", + timeLimits: "\u65F6\u957F\u9650\u5236", + timeLimitsDesc: "\u63A7\u5236\u5E94\u7528\u4F7F\u7528\u63D0\u9192", + data: "\u6570\u636E", + dataDesc: "\u7BA1\u7406\u5DF2\u8BB0\u5F55\u7684\u6570\u636E", + about: "\u5173\u4E8E" + }, + behavior: { + startWithWindows: "\u5F00\u673A\u542F\u52A8", + startWithWindowsDesc: "\u767B\u5F55 Windows \u540E\u81EA\u52A8\u542F\u52A8 ScreenForge", + minimizeToTray: "\u6700\u5C0F\u5316\u5230\u6258\u76D8", + minimizeToTrayDesc: "\u5173\u95ED\u7A97\u53E3\u540E\u7EE7\u7EED\u5728\u540E\u53F0\u8FD0\u884C" + }, + timeLimits: { + notifications: "\u65F6\u957F\u8D85\u9650\u63D0\u9192", + notificationsDesc: "\u5F53\u5E94\u7528\u4F7F\u7528\u8D85\u51FA\u9650\u5236\u65F6\u53D1\u9001\u63D0\u9192", + activeLimits: "\u5F53\u524D\u9650\u5236", + activeLimitsDesc: "\u4F60\u5DF2\u4E3A {count} \u4E2A\u5E94\u7528\u8BBE\u7F6E\u65F6\u957F\u9650\u5236\uFF0C\u53EF\u524D\u5F80\u201C\u5E94\u7528\u201D\u9875\u9762\u7BA1\u7406\u3002" + }, + data: { + clearAll: "\u6E05\u7A7A\u6240\u6709\u6570\u636E", + clearAllDesc: "\u5220\u9664\u6240\u6709\u5DF2\u8BB0\u5F55\u7684\u4F7F\u7528\u5386\u53F2", + clearButton: "\u6E05\u7A7A\u6570\u636E", + clearConfirm: "\u786E\u5B9A\u5417\uFF1F\u8FD9\u5C06\u5220\u9664\u4F60\u6240\u6709\u7684\u4F7F\u7528\u6570\u636E\u3002" + }, + about: { + version: "\u7248\u672C", + platform: "\u5E73\u53F0", + builtWith: "\u6280\u672F\u6808", + windows: "Windows", + techStack: "Electron + React" + } + }, + tables: { + topApps: "\u5E38\u7528\u5E94\u7528", + usageToday: "\u4ECA\u65E5\u4F7F\u7528\u60C5\u51B5", + app: "\u5E94\u7528", + category: "\u5206\u7C7B", + time: "\u65F6\u957F", + notifications: "\u901A\u77E5", + empty: "\u4ECA\u5929\u8FD8\u6CA1\u6709\u5E94\u7528\u6570\u636E", + notificationToday: "\u4ECA\u5929", + notificationEmpty: "\u8FD8\u6CA1\u6709\u901A\u77E5" + }, + datePicker: { + previousMonth: "\u4E0A\u4E2A\u6708", + nextMonth: "\u4E0B\u4E2A\u6708", + dataAvailable: "\u6709\u6570\u636E\u8BB0\u5F55", + dataRange: "\u6570\u636E\u8303\u56F4\uFF1A{start} \u81F3 {end}" + }, + suggestions: { + title: "\u5EFA\u8BAE", + subtitle: "\u57FA\u4E8E\u4F60\u6700\u8FD1\u7684\u6D3B\u52A8\u751F\u6210", + welcome: { + title: "\u6B22\u8FCE\u4F7F\u7528 ScreenForge\uFF01", + detail: "\u4FDD\u6301\u5E94\u7528\u5728\u540E\u53F0\u8FD0\u884C\uFF0C\u5373\u53EF\u81EA\u52A8\u8BB0\u5F55\u4F60\u7684\u5C4F\u5E55\u65F6\u957F\u3002" + }, + entertainment: { + title: "\u5A31\u4E50\u5E94\u7528\u4F7F\u7528\u504F\u9AD8", + detail: "\u53EF\u4EE5\u8003\u8651\u4E3A\u5A31\u4E50\u7C7B\u5E94\u7528\u8BBE\u7F6E\u65F6\u957F\u9650\u5236\uFF0C\u63D0\u5347\u4E13\u6CE8\u5EA6\u3002" + }, + social: { + title: "\u793E\u4EA4\u5E94\u7528\u5360\u6BD4\u504F\u9AD8", + detail: "\u8BD5\u7740\u7ED9\u67E5\u770B\u793E\u4EA4\u5A92\u4F53\u5B89\u6392\u56FA\u5B9A\u65F6\u95F4\uFF0C\u51CF\u5C11\u9891\u7E41\u6253\u65AD\u3002" + }, + productive: { + title: "\u4E13\u6CE8\u72B6\u6001\u4E0D\u9519\uFF01", + detail: "\u4F60\u5927\u90E8\u5206\u65F6\u95F4\u90FD\u82B1\u5728\u9AD8\u6548\u4EFB\u52A1\u4E0A\uFF0C\u7EE7\u7EED\u4FDD\u6301\u3002" + }, + balance: { + title: "\u4F7F\u7528\u5206\u5E03\u8F83\u5747\u8861", + detail: "\u4F60\u7684\u5C4F\u5E55\u65F6\u95F4\u5728\u4E0D\u540C\u6D3B\u52A8\u4E4B\u95F4\u5206\u914D\u5F97\u6BD4\u8F83\u5E73\u8861\u3002" + }, + breaks: { + title: "\u8BB0\u5F97\u9002\u5F53\u4F11\u606F", + detail: "\u8BD5\u8BD5 20-20-20 \u6CD5\u5219\uFF1A\u6BCF 20 \u5206\u949F\uFF0C\u770B\u5411 20 \u82F1\u5C3A\u5916\u7684\u7269\u4F53 20 \u79D2\u3002" + } + }, + native: { + timeLimitReachedTitle: "\u5DF2\u8FBE\u5230\u65F6\u957F\u9650\u5236", + timeLimitReachedBody: "\u4ECA\u5929\u4F60\u5DF2\u4F7F\u7528 {appName} {usedMinutes} \u5206\u949F\uFF0C\u8D85\u8FC7\u8BBE\u5B9A\u9650\u5236 {limitMinutes} \u5206\u949F\u3002", + trayTooltip: "ScreenForge - \u5C4F\u5E55\u65F6\u95F4\u8FFD\u8E2A\u5668", + trayShow: "\u663E\u793A ScreenForge", + trayQuit: "\u9000\u51FA" + }, + categories: { + Productivity: "\u751F\u4EA7\u529B", + Education: "\u6559\u80B2", + Communication: "\u6C9F\u901A", + Utilities: "\u5DE5\u5177", + Browsers: "\u6D4F\u89C8\u5668", + Entertainment: "\u5A31\u4E50", + Social: "\u793E\u4EA4", + System: "\u7CFB\u7EDF", + Other: "\u5176\u4ED6", + Unknown: "\u672A\u77E5" + } +}; + +// src/i18n/types.ts +var supportedLocales = ["zh-CN", "en-US"]; + +// src/i18n/core.ts +var dictionaries = { + "zh-CN": zhCN, + "en-US": enUS +}; +var defaultLocale = "zh-CN"; +var normalizeLocale = (value) => { + if (!value) return defaultLocale; + if (supportedLocales.includes(value)) return value; + if (value.toLowerCase().startsWith("zh")) return "zh-CN"; + if (value.toLowerCase().startsWith("en")) return "en-US"; + return defaultLocale; +}; +var getNestedValue = (tree, path4) => { + const value = path4.split(".").reduce((current, segment) => { + if (!current || typeof current === "string") { + return void 0; + } + return current[segment]; + }, tree); + return typeof value === "string" ? value : void 0; +}; +var interpolate = (template, params) => { + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (_match, key) => String(params[key] ?? "")); +}; +var translate = (locale, key, params) => { + const resolvedLocale = normalizeLocale(locale); + const template = getNestedValue(dictionaries[resolvedLocale], key) ?? getNestedValue(dictionaries[defaultLocale], key) ?? key; + return interpolate(template, params); +}; + // electron/main.ts -var { app: app3, BrowserWindow, Tray, Menu, nativeImage, Notification } = electron; -var { ipcMain } = electron; var isDev = Boolean(process.env.VITE_DEV_SERVER_URL); var usageTracker = createUsageTracker(); var notificationTracker = createNotificationTracker(); @@ -807,9 +1476,11 @@ var settings = { minimizeToTray: true, startWithWindows: false, timeLimits: [], - timeLimitNotificationsEnabled: true + timeLimitNotificationsEnabled: true, + language: defaultLocale }; var shownAlerts = []; +var mt = (key, params) => translate(settings.language, key, params); var getTodayDateString3 = () => { const now = /* @__PURE__ */ new Date(); const year = now.getFullYear(); @@ -818,11 +1489,11 @@ var getTodayDateString3 = () => { return `${year}-${month}-${day}`; }; var getSettingsPath = () => { - const userDataPath = app3.getPath("userData"); + const userDataPath = import_electron3.app.getPath("userData"); return import_node_path.default.join(userDataPath, "settings.json"); }; var getAlertsPath = () => { - const userDataPath = app3.getPath("userData"); + const userDataPath = import_electron3.app.getPath("userData"); return import_node_path.default.join(userDataPath, "alerts.json"); }; var loadSettings = () => { @@ -835,7 +1506,8 @@ var loadSettings = () => { minimizeToTray: loaded.minimizeToTray ?? true, startWithWindows: loaded.startWithWindows ?? false, timeLimits: loaded.timeLimits ?? [], - timeLimitNotificationsEnabled: loaded.timeLimitNotificationsEnabled ?? true + timeLimitNotificationsEnabled: loaded.timeLimitNotificationsEnabled ?? true, + language: normalizeLocale(loaded.language) }; } } catch { @@ -899,9 +1571,13 @@ var checkTimeLimits = () => { if (!alreadyNotified) { const appInfo = appLookup.get(limit.appId); const appName = appInfo?.name ?? limit.appId; - const notification = new Notification({ - title: "Time Limit Reached", - body: `You've used ${appName} for ${usedMinutes} minutes today. Your limit is ${limit.limitMinutes} minutes.`, + const notification = new import_electron3.Notification({ + title: mt("native.timeLimitReachedTitle"), + body: mt("native.timeLimitReachedBody", { + appName, + usedMinutes, + limitMinutes: limit.limitMinutes + }), icon: void 0, silent: false }); @@ -930,8 +1606,8 @@ var generateSuggestions = () => { if (snapshot.usageEntries.length === 0) { suggestions.push({ id: "welcome", - title: "Welcome to ScreenForge!", - detail: "Keep the app running to track your screen time automatically." + title: mt("suggestions.welcome.title"), + detail: mt("suggestions.welcome.detail") }); return suggestions; } @@ -948,40 +1624,64 @@ var generateSuggestions = () => { if (entertainmentMinutes > 0 && entertainmentMinutes / totalMinutes > 0.3) { suggestions.push({ id: "entertainment", - title: "High entertainment usage", - detail: "Consider setting time limits for entertainment apps to boost productivity." + title: mt("suggestions.entertainment.title"), + detail: mt("suggestions.entertainment.detail") }); } const socialMinutes = categoryMinutes.get("Social") ?? 0; if (socialMinutes > 0 && socialMinutes / totalMinutes > 0.2) { suggestions.push({ id: "social", - title: "Social apps taking over", - detail: "Try scheduling specific times for checking social media." + title: mt("suggestions.social.title"), + detail: mt("suggestions.social.detail") }); } const productiveMinutes = (categoryMinutes.get("Productivity") ?? 0) + (categoryMinutes.get("Education") ?? 0); if (productiveMinutes > 0 && productiveMinutes / totalMinutes > 0.5) { suggestions.push({ id: "productive", - title: "Great focus!", - detail: "You're spending most of your time on productive tasks. Keep it up!" + title: mt("suggestions.productive.title"), + detail: mt("suggestions.productive.detail") }); } if (suggestions.length === 0) { suggestions.push({ id: "balance", - title: "Balanced usage", - detail: "Your screen time is well distributed across different activities." + title: mt("suggestions.balance.title"), + detail: mt("suggestions.balance.detail") }); } suggestions.push({ id: "breaks", - title: "Remember to take breaks", - detail: "Use the 20-20-20 rule: every 20 minutes, look at something 20 feet away for 20 seconds." + title: mt("suggestions.breaks.title"), + detail: mt("suggestions.breaks.detail") }); return suggestions; }; +var buildTrayMenu = () => import_electron3.Menu.buildFromTemplate([ + { + label: mt("native.trayShow"), + click: () => { + if (mainWindow) { + mainWindow.show(); + mainWindow.focus(); + } + } + }, + { type: "separator" }, + { + label: mt("native.trayQuit"), + click: () => { + isQuitting = true; + import_electron3.app.quit(); + } + } +]); +var updateTrayMenu = () => { + if (!tray) return; + tray.setToolTip(mt("native.trayTooltip")); + tray.setContextMenu(buildTrayMenu()); +}; var createTray = () => { const size = 16; const canvas = Buffer.alloc(size * size * 4); @@ -1026,29 +1726,9 @@ var createTray = () => { for (let x = 7; x <= 11; x++) { setPixel(x, 8, accentColor); } - const trayIcon = nativeImage.createFromBuffer(canvas, { width: size, height: size }); - tray = new Tray(trayIcon); - tray.setToolTip("ScreenForge - Screen Time Tracker"); - const contextMenu = Menu.buildFromTemplate([ - { - label: "Show ScreenForge", - click: () => { - if (mainWindow) { - mainWindow.show(); - mainWindow.focus(); - } - } - }, - { type: "separator" }, - { - label: "Quit", - click: () => { - isQuitting = true; - app3.quit(); - } - } - ]); - tray.setContextMenu(contextMenu); + const trayIcon = import_electron3.nativeImage.createFromBuffer(canvas, { width: size, height: size }); + tray = new import_electron3.Tray(trayIcon); + updateTrayMenu(); tray.on("double-click", () => { if (mainWindow) { mainWindow.show(); @@ -1116,11 +1796,11 @@ var createAppIcon = () => { setPixel(x, 16, accentColor); setPixel(x, 17, accentColor); } - return nativeImage.createFromBuffer(canvas, { width: size, height: size }); + return import_electron3.nativeImage.createFromBuffer(canvas, { width: size, height: size }); }; var createWindow = async () => { const appIcon = createAppIcon(); - mainWindow = new BrowserWindow({ + mainWindow = new import_electron3.BrowserWindow({ width: 1240, height: 820, minWidth: 980, @@ -1173,33 +1853,33 @@ var createWindow = async () => { await mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL); mainWindow.webContents.openDevTools({ mode: "detach" }); } else { - const indexPath = import_node_path.default.join(app3.getAppPath(), "dist", "index.html"); + const indexPath = import_node_path.default.join(import_electron3.app.getAppPath(), "dist", "index.html"); await mainWindow.loadFile(indexPath); } }; var setAutoLaunch = (enable) => { if (process.platform !== "win32") return; - app3.setLoginItemSettings({ + import_electron3.app.setLoginItemSettings({ openAtLogin: enable, - path: app3.getPath("exe") + path: import_electron3.app.getPath("exe") }); }; -app3.whenReady().then(() => { +import_electron3.app.whenReady().then(() => { loadSettings(); loadAlerts(); - ipcMain.handle("usage:snapshot", () => usageTracker.getSnapshot()); - ipcMain.handle("usage:clear", () => { + import_electron3.ipcMain.handle("usage:snapshot", () => usageTracker.getSnapshot()); + import_electron3.ipcMain.handle("usage:clear", () => { usageTracker.clearData(); return usageTracker.getSnapshot(); }); - ipcMain.handle("theme:set", (_event, theme) => { + import_electron3.ipcMain.handle("theme:set", (_event, theme) => { applyThemeToWindow(theme); return true; }); - ipcMain.handle("suggestions:list", () => generateSuggestions()); - ipcMain.handle("notifications:summary", () => notificationTracker.getSummary()); - ipcMain.handle("settings:get", () => settings); - ipcMain.handle("settings:set", (_event, newSettings) => { + import_electron3.ipcMain.handle("suggestions:list", () => generateSuggestions()); + import_electron3.ipcMain.handle("notifications:summary", () => notificationTracker.getSummary()); + import_electron3.ipcMain.handle("settings:get", () => settings); + import_electron3.ipcMain.handle("settings:set", (_event, newSettings) => { if (typeof newSettings.minimizeToTray === "boolean") { settings.minimizeToTray = newSettings.minimizeToTray; } @@ -1213,54 +1893,58 @@ app3.whenReady().then(() => { if (typeof newSettings.timeLimitNotificationsEnabled === "boolean") { settings.timeLimitNotificationsEnabled = newSettings.timeLimitNotificationsEnabled; } + if (newSettings.language) { + settings.language = normalizeLocale(newSettings.language); + updateTrayMenu(); + } saveSettings(); return settings; }); - ipcMain.handle("timelimits:get", () => settings.timeLimits); - ipcMain.handle("timelimits:set", (_event, limits) => { + import_electron3.ipcMain.handle("timelimits:get", () => settings.timeLimits); + import_electron3.ipcMain.handle("timelimits:set", (_event, limits) => { settings.timeLimits = limits; saveSettings(); return settings.timeLimits; }); - ipcMain.handle("timelimits:add", (_event, limit) => { + import_electron3.ipcMain.handle("timelimits:add", (_event, limit) => { settings.timeLimits = settings.timeLimits.filter((l) => l.appId !== limit.appId); settings.timeLimits.push(limit); saveSettings(); return settings.timeLimits; }); - ipcMain.handle("timelimits:remove", (_event, appId) => { + import_electron3.ipcMain.handle("timelimits:remove", (_event, appId) => { settings.timeLimits = settings.timeLimits.filter((l) => l.appId !== appId); saveSettings(); return settings.timeLimits; }); - ipcMain.handle("timelimits:alerts", () => shownAlerts); + import_electron3.ipcMain.handle("timelimits:alerts", () => shownAlerts); createTray(); createWindow(); const timeLimitInterval = setInterval(checkTimeLimits, 3e4); setTimeout(checkTimeLimits, 5e3); - app3.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { + import_electron3.app.on("activate", () => { + if (import_electron3.BrowserWindow.getAllWindows().length === 0) { createWindow(); } else if (mainWindow) { mainWindow.show(); } }); - app3.on("will-quit", () => { + import_electron3.app.on("will-quit", () => { clearInterval(timeLimitInterval); }); }); -app3.on("before-quit", () => { +import_electron3.app.on("before-quit", () => { isQuitting = true; }); -app3.on("window-all-closed", () => { +import_electron3.app.on("window-all-closed", () => { if (process.platform === "darwin") { } else if (!settings.minimizeToTray) { usageTracker.dispose(); notificationTracker.dispose(); - app3.quit(); + import_electron3.app.quit(); } }); -app3.on("will-quit", () => { +import_electron3.app.on("will-quit", () => { usageTracker.dispose(); notificationTracker.dispose(); if (tray) { diff --git a/dist-electron/preload.cjs b/dist-electron/preload.cjs index 6582d25..6f3d621 100644 --- a/dist-electron/preload.cjs +++ b/dist-electron/preload.cjs @@ -1,8 +1,6 @@ -var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; -var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { @@ -12,25 +10,16 @@ var __copyProps = (to, from, except, desc) => { } return to; }; -var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( - // If the importer is in node compatibility mode or this is not an ESM - // file that has been converted to a CommonJS file using a Babel- - // compatible transform (i.e. "__esModule" has not been set), then set - // "default" to the CommonJS "module.exports" for node compatibility. - isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, - mod -)); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // electron/preload.ts var preload_exports = {}; module.exports = __toCommonJS(preload_exports); -var electron = __toESM(require("electron"), 1); -var { contextBridge, ipcRenderer } = electron; +var import_electron = require("electron"); var api = { getUsageSnapshot: async () => { try { - const snapshot = await ipcRenderer.invoke("usage:snapshot"); + const snapshot = await import_electron.ipcRenderer.invoke("usage:snapshot"); return { generatedAt: (/* @__PURE__ */ new Date()).toISOString(), ...snapshot }; } catch { return { @@ -44,14 +33,14 @@ var api = { }, getSuggestionFeed: async () => { try { - return await ipcRenderer.invoke("suggestions:list"); + return await import_electron.ipcRenderer.invoke("suggestions:list"); } catch { return []; } }, clearUsageData: async () => { try { - return await ipcRenderer.invoke("usage:clear"); + return await import_electron.ipcRenderer.invoke("usage:clear"); } catch { return { generatedAt: (/* @__PURE__ */ new Date()).toISOString(), @@ -64,7 +53,7 @@ var api = { }, getNotificationSummary: async () => { try { - return await ipcRenderer.invoke("notifications:summary"); + return await import_electron.ipcRenderer.invoke("notifications:summary"); } catch { return { total: 0, @@ -75,7 +64,7 @@ var api = { }, setTheme: async (theme) => { try { - return await ipcRenderer.invoke("theme:set", theme); + return await import_electron.ipcRenderer.invoke("theme:set", theme); } catch { return false; } @@ -83,60 +72,62 @@ var api = { // Settings getSettings: async () => { try { - return await ipcRenderer.invoke("settings:get"); + return await import_electron.ipcRenderer.invoke("settings:get"); } catch { return { minimizeToTray: true, startWithWindows: false, timeLimits: [], - timeLimitNotificationsEnabled: true + timeLimitNotificationsEnabled: true, + language: "zh-CN" }; } }, setSettings: async (settings) => { try { - return await ipcRenderer.invoke("settings:set", settings); + return await import_electron.ipcRenderer.invoke("settings:set", settings); } catch { return { minimizeToTray: true, startWithWindows: false, timeLimits: [], - timeLimitNotificationsEnabled: true + timeLimitNotificationsEnabled: true, + language: "zh-CN" }; } }, // Time limits getTimeLimits: async () => { try { - return await ipcRenderer.invoke("timelimits:get"); + return await import_electron.ipcRenderer.invoke("timelimits:get"); } catch { return []; } }, setTimeLimits: async (limits) => { try { - return await ipcRenderer.invoke("timelimits:set", limits); + return await import_electron.ipcRenderer.invoke("timelimits:set", limits); } catch { return []; } }, addTimeLimit: async (limit) => { try { - return await ipcRenderer.invoke("timelimits:add", limit); + return await import_electron.ipcRenderer.invoke("timelimits:add", limit); } catch { return []; } }, removeTimeLimit: async (appId) => { try { - return await ipcRenderer.invoke("timelimits:remove", appId); + return await import_electron.ipcRenderer.invoke("timelimits:remove", appId); } catch { return []; } }, getTimeLimitAlerts: async () => { try { - return await ipcRenderer.invoke("timelimits:alerts"); + return await import_electron.ipcRenderer.invoke("timelimits:alerts"); } catch { return []; } @@ -146,10 +137,10 @@ var api = { const handler = (_event, data) => { callback(data); }; - ipcRenderer.on("time-limit-exceeded", handler); + import_electron.ipcRenderer.on("time-limit-exceeded", handler); return () => { - ipcRenderer.removeListener("time-limit-exceeded", handler); + import_electron.ipcRenderer.removeListener("time-limit-exceeded", handler); }; } }; -contextBridge.exposeInMainWorld("screenforge", api); +import_electron.contextBridge.exposeInMainWorld("screenforge", api); diff --git a/electron/main.ts b/electron/main.ts index f21c4da..cfb9825 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,19 +1,17 @@ -import * as electron from 'electron' +import { app, BrowserWindow, Tray, Menu, nativeImage, Notification, ipcMain } from 'electron' import path from 'node:path' import * as fs from 'node:fs' import { createNotificationTracker } from './notifications' import { createUsageTracker } from './telemetry' - -const { app, BrowserWindow, Tray, Menu, nativeImage, Notification } = electron -const { ipcMain } = electron +import { defaultLocale, normalizeLocale, translate, type LocaleCode } from '../src/i18n/core' const isDev = Boolean(process.env.VITE_DEV_SERVER_URL) const usageTracker = createUsageTracker() const notificationTracker = createNotificationTracker() -let mainWindow: electron.BrowserWindow | null = null -let tray: electron.Tray | null = null +let mainWindow: BrowserWindow | null = null +let tray: Tray | null = null let isQuitting = false const ZOOM_STEP = 0.1 @@ -57,6 +55,7 @@ interface AppSettings { startWithWindows: boolean timeLimits: AppTimeLimit[] timeLimitNotificationsEnabled: boolean + language: LocaleCode } // Track which alerts have been shown today to avoid spam @@ -71,11 +70,15 @@ let settings: AppSettings = { startWithWindows: false, timeLimits: [], timeLimitNotificationsEnabled: true, + language: defaultLocale, } let shownAlerts: TimeLimitAlert[] = [] // Get today's date in Windows local timezone +const mt = (key: string, params?: Record) => + translate(settings.language, key, params) + const getTodayDateString = (): string => { const now = new Date() const year = now.getFullYear() @@ -105,6 +108,7 @@ const loadSettings = () => { startWithWindows: loaded.startWithWindows ?? false, timeLimits: loaded.timeLimits ?? [], timeLimitNotificationsEnabled: loaded.timeLimitNotificationsEnabled ?? true, + language: normalizeLocale(loaded.language), } } } catch { @@ -188,8 +192,12 @@ const checkTimeLimits = () => { // Show notification const notification = new Notification({ - title: 'Time Limit Reached', - body: `You've used ${appName} for ${usedMinutes} minutes today. Your limit is ${limit.limitMinutes} minutes.`, + title: mt('native.timeLimitReachedTitle'), + body: mt('native.timeLimitReachedBody', { + appName, + usedMinutes, + limitMinutes: limit.limitMinutes, + }), icon: undefined, silent: false, }) @@ -226,8 +234,8 @@ const generateSuggestions = () => { if (snapshot.usageEntries.length === 0) { suggestions.push({ id: 'welcome', - title: 'Welcome to ScreenForge!', - detail: 'Keep the app running to track your screen time automatically.', + title: mt('suggestions.welcome.title'), + detail: mt('suggestions.welcome.detail'), }) return suggestions } @@ -250,8 +258,8 @@ const generateSuggestions = () => { if (entertainmentMinutes > 0 && entertainmentMinutes / totalMinutes > 0.3) { suggestions.push({ id: 'entertainment', - title: 'High entertainment usage', - detail: 'Consider setting time limits for entertainment apps to boost productivity.', + title: mt('suggestions.entertainment.title'), + detail: mt('suggestions.entertainment.detail'), }) } @@ -260,8 +268,8 @@ const generateSuggestions = () => { if (socialMinutes > 0 && socialMinutes / totalMinutes > 0.2) { suggestions.push({ id: 'social', - title: 'Social apps taking over', - detail: 'Try scheduling specific times for checking social media.', + title: mt('suggestions.social.title'), + detail: mt('suggestions.social.detail'), }) } @@ -270,8 +278,8 @@ const generateSuggestions = () => { if (productiveMinutes > 0 && productiveMinutes / totalMinutes > 0.5) { suggestions.push({ id: 'productive', - title: 'Great focus!', - detail: 'You\'re spending most of your time on productive tasks. Keep it up!', + title: mt('suggestions.productive.title'), + detail: mt('suggestions.productive.detail'), }) } @@ -279,20 +287,46 @@ const generateSuggestions = () => { if (suggestions.length === 0) { suggestions.push({ id: 'balance', - title: 'Balanced usage', - detail: 'Your screen time is well distributed across different activities.', + title: mt('suggestions.balance.title'), + detail: mt('suggestions.balance.detail'), }) } suggestions.push({ id: 'breaks', - title: 'Remember to take breaks', - detail: 'Use the 20-20-20 rule: every 20 minutes, look at something 20 feet away for 20 seconds.', + title: mt('suggestions.breaks.title'), + detail: mt('suggestions.breaks.detail'), }) return suggestions } +const buildTrayMenu = () => Menu.buildFromTemplate([ + { + label: mt('native.trayShow'), + click: () => { + if (mainWindow) { + mainWindow.show() + mainWindow.focus() + } + }, + }, + { type: 'separator' }, + { + label: mt('native.trayQuit'), + click: () => { + isQuitting = true + app.quit() + }, + }, +]) + +const updateTrayMenu = () => { + if (!tray) return + tray.setToolTip(mt('native.trayTooltip')) + tray.setContextMenu(buildTrayMenu()) +} + const createTray = () => { // Create the ScreenForge tray icon programmatically // 16x16 icon with monitor shape in blue accent color @@ -346,29 +380,7 @@ const createTray = () => { const trayIcon = nativeImage.createFromBuffer(canvas, { width: size, height: size }) tray = new Tray(trayIcon) - tray.setToolTip('ScreenForge - Screen Time Tracker') - - const contextMenu = Menu.buildFromTemplate([ - { - label: 'Show ScreenForge', - click: () => { - if (mainWindow) { - mainWindow.show() - mainWindow.focus() - } - }, - }, - { type: 'separator' }, - { - label: 'Quit', - click: () => { - isQuitting = true - app.quit() - }, - }, - ]) - - tray.setContextMenu(contextMenu) + updateTrayMenu() // Double-click to show window tray.on('double-click', () => { @@ -564,6 +576,10 @@ app.whenReady().then(() => { if (typeof newSettings.timeLimitNotificationsEnabled === 'boolean') { settings.timeLimitNotificationsEnabled = newSettings.timeLimitNotificationsEnabled } + if (newSettings.language) { + settings.language = normalizeLocale(newSettings.language) + updateTrayMenu() + } saveSettings() return settings }) diff --git a/electron/notifications.ts b/electron/notifications.ts index b607221..6f84986 100644 --- a/electron/notifications.ts +++ b/electron/notifications.ts @@ -261,7 +261,7 @@ if ($unique.Count -eq 0) { ` try { - const { stdout, stderr } = await execFileAsync('powershell', [ + const { stdout } = await execFileAsync('powershell', [ '-NoProfile', '-ExecutionPolicy', 'Bypass', @@ -291,7 +291,7 @@ if ($unique.Count -eq 0) { } export const createNotificationTracker = () => { - let persistedData = loadPersistedData() + const persistedData = loadPersistedData() let lastError: string | undefined let saveTimeout: NodeJS.Timeout | null = null diff --git a/electron/preload.ts b/electron/preload.ts index 6585401..6ac4037 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,6 +1,5 @@ -import * as electron from 'electron' - -const { contextBridge, ipcRenderer } = electron +import { contextBridge, ipcRenderer } from 'electron' +import type { IpcRendererEvent } from 'electron' // Time limit interface interface AppTimeLimit { @@ -15,6 +14,7 @@ interface AppSettings { startWithWindows: boolean timeLimits: AppTimeLimit[] timeLimitNotificationsEnabled: boolean + language: 'zh-CN' | 'en-US' } const api = { @@ -80,6 +80,7 @@ const api = { startWithWindows: false, timeLimits: [], timeLimitNotificationsEnabled: true, + language: 'zh-CN', } } }, @@ -92,6 +93,7 @@ const api = { startWithWindows: false, timeLimits: [], timeLimitNotificationsEnabled: true, + language: 'zh-CN', } } }, @@ -133,7 +135,7 @@ const api = { }, // Event listeners onTimeLimitExceeded: (callback: (data: { appId: string; appName: string; usedMinutes: number; limitMinutes: number }) => void) => { - const handler = (_event: electron.IpcRendererEvent, data: { appId: string; appName: string; usedMinutes: number; limitMinutes: number }) => { + const handler = (_event: IpcRendererEvent, data: { appId: string; appName: string; usedMinutes: number; limitMinutes: number }) => { callback(data) } ipcRenderer.on('time-limit-exceeded', handler) diff --git a/eslint.config.js b/eslint.config.js index 5e6b472..012aa9e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,13 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores([ + 'dist', + 'dist-electron', + 'release', + 'screenforge-landing/.next', + 'screenforge-landing/out', + ]), { files: ['**/*.{ts,tsx}'], extends: [ @@ -20,4 +26,10 @@ export default defineConfig([ globals: globals.browser, }, }, + { + files: ['src/i18n/I18nProvider.tsx', 'screenforge-landing/src/app/**/*.{ts,tsx}'], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, ]) diff --git a/package.json b/package.json index e39b42f..413cbf8 100644 --- a/package.json +++ b/package.json @@ -16,15 +16,17 @@ "type": "module", "main": "dist-electron/main.cjs", "scripts": { - "dev": "concurrently \"vite\" \"npm:electron:dev\"", - "electron:dev": "npm run electron:build && wait-on tcp:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron dist-electron/main.cjs", + "dev": "concurrently \"vite --strictPort\" \"npm:electron:dev\"", + "electron:dev": "npm run electron:build && wait-on tcp:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 cmd /c \"set ELECTRON_RUN_AS_NODE=&& electron .\"", + "dev:debug": "concurrently \"vite --strictPort\" \"npm:electron:dev:debug\"", + "electron:dev:debug": "npm run electron:build && wait-on tcp:5173 && cross-env ELECTRON_ENABLE_LOGGING=true VITE_DEV_SERVER_URL=http://localhost:5173 cmd /c \"set ELECTRON_RUN_AS_NODE=&& electron --inspect=9229 .\"", "generate:icon": "node scripts/generate-icon.mjs", "prebuild": "npm run generate:icon", "build": "tsc -b && vite build && npm run electron:build", "electron:build": "esbuild electron/main.ts --bundle --platform=node --format=cjs --external:electron --outfile=dist-electron/main.cjs && esbuild electron/preload.ts --bundle --platform=node --format=cjs --external:electron --outfile=dist-electron/preload.cjs", "lint": "eslint .", "preview": "vite preview", - "electron:start": "electron dist-electron/main.cjs", + "electron:start": "cmd /c \"set ELECTRON_RUN_AS_NODE=&& electron .\"", "dist": "npm run build && electron-builder --win nsis", "dist:portable": "npm run build && electron-builder --win portable" }, diff --git a/screenforge-landing/src/app/faq/FAQContent.tsx b/screenforge-landing/src/app/faq/FAQContent.tsx index 19da0db..a10064e 100644 --- a/screenforge-landing/src/app/faq/FAQContent.tsx +++ b/screenforge-landing/src/app/faq/FAQContent.tsx @@ -2,7 +2,6 @@ import { useState } from 'react' import { motion } from 'framer-motion' -import Image from 'next/image' import Header from '@/components/Header' import Footer from '@/components/Footer' diff --git a/screenforge-landing/src/components/Header.tsx b/screenforge-landing/src/components/Header.tsx index 923d469..f08bb6b 100644 --- a/screenforge-landing/src/components/Header.tsx +++ b/screenforge-landing/src/components/Header.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useEffect, useState, useSyncExternalStore } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { useTheme } from 'next-themes' import Image from 'next/image' @@ -12,16 +12,16 @@ const navLinks = [ ] const GITHUB_REPO = 'raghav3615/screenforge' +const subscribe = () => () => {} export default function Header() { const [scrolled, setScrolled] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [stars, setStars] = useState(null) const { theme, setTheme } = useTheme() - const [mounted, setMounted] = useState(false) + const mounted = useSyncExternalStore(subscribe, () => true, () => false) useEffect(() => { - setMounted(true) const handleScroll = () => { setScrolled(window.scrollY > 20) } diff --git a/screenforge-landing/src/components/ThemeToggle.tsx b/screenforge-landing/src/components/ThemeToggle.tsx index 82de46c..06b574b 100644 --- a/screenforge-landing/src/components/ThemeToggle.tsx +++ b/screenforge-landing/src/components/ThemeToggle.tsx @@ -1,16 +1,14 @@ "use client"; import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; import { cn } from "@/lib/utils"; +const subscribe = () => () => {}; + export function ThemeToggle() { const { theme, setTheme } = useTheme(); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); + const mounted = useSyncExternalStore(subscribe, () => true, () => false); if (!mounted) { return ( diff --git a/src/App.tsx b/src/App.tsx index 46d5e8f..4a9dd20 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,11 +3,13 @@ import './App.css' import type { ThemeName, PageName, SuggestionItem, NotificationSummary } from './types/models' import type { UsageSnapshot } from './services/usageService' import { + fetchSettings, fetchNotificationSummary, fetchSuggestions, fetchUsageSnapshot, } from './services/usageService' import { calculateFocusScore } from './utils/analytics' +import { useI18n } from './i18n/I18nProvider' import Dashboard from './pages/Dashboard' import Insights from './pages/Insights' import Apps from './pages/Apps' @@ -16,15 +18,8 @@ import Settings from './pages/Settings' const themes: ThemeName[] = ['dark', 'light', 'tokyo', 'skin'] -const navItems: { id: PageName; label: string }[] = [ - { id: 'dashboard', label: 'Dashboard' }, - { id: 'insights', label: 'Insights' }, - { id: 'apps', label: 'Apps' }, - { id: 'notifications', label: 'Notifications' }, - { id: 'settings', label: 'Settings' }, -] - const App = () => { + const { t, setLocale, translateThemeName } = useI18n() const [theme, setTheme] = useState(() => { const saved = localStorage.getItem('screenforge-theme') return (saved as ThemeName) || 'dark' @@ -34,6 +29,14 @@ const App = () => { const [suggestions, setSuggestions] = useState([]) const [notificationSummary, setNotificationSummary] = useState(null) + const navItems: { id: PageName; label: string }[] = [ + { id: 'dashboard', label: t('nav.dashboard') }, + { id: 'insights', label: t('nav.insights') }, + { id: 'apps', label: t('nav.apps') }, + { id: 'notifications', label: t('nav.notifications') }, + { id: 'settings', label: t('nav.settings') }, + ] + useEffect(() => { document.documentElement.setAttribute('data-theme', theme) localStorage.setItem('screenforge-theme', theme) @@ -41,30 +44,30 @@ const App = () => { }, [theme]) useEffect(() => { - let interval: number | undefined - const load = async () => { - const [usageSnapshot, suggestionFeed, notificationFeed] = await Promise.all([ + const [usageSnapshot, suggestionFeed, notificationFeed, settings] = await Promise.all([ fetchUsageSnapshot(), fetchSuggestions(), fetchNotificationSummary(), + fetchSettings(), ]) setSnapshot(usageSnapshot) setSuggestions(suggestionFeed) setNotificationSummary(notificationFeed) + setLocale(settings.language) } load() - interval = window.setInterval(() => { + const intervalId = window.setInterval(() => { fetchUsageSnapshot().then(setSnapshot) fetchNotificationSummary().then(setNotificationSummary) }, 5000) return () => { - if (interval) window.clearInterval(interval) + window.clearInterval(intervalId) } - }, []) + }, [setLocale]) // Calculate focus score using shared utility const focusScore = snapshot @@ -123,7 +126,7 @@ const App = () => { ))}
-
Focus score
+
{t('sidebar.focusScore')}
{focusScore}
@@ -137,7 +140,7 @@ const App = () => { className={`theme-pill ${theme === option ? 'theme-pill--active' : ''}`} onClick={() => setTheme(option)} > - {option} + {translateThemeName(option)} ))} diff --git a/src/components/AppUsageTable.tsx b/src/components/AppUsageTable.tsx index 5e8ef70..1f78744 100644 --- a/src/components/AppUsageTable.tsx +++ b/src/components/AppUsageTable.tsx @@ -1,5 +1,5 @@ import type { AppInfo } from '../types/models' -import { formatMinutes, formatSeconds } from '../utils/analytics' +import { useI18n } from '../i18n/I18nProvider' import './AppUsageTable.css' interface AppUsageRow { @@ -15,24 +15,26 @@ interface AppUsageTableProps { subtitle?: string } -const AppUsageTable = ({ rows, title = 'Top apps', subtitle = 'Usage today' }: AppUsageTableProps) => { +const AppUsageTable = ({ rows, title, subtitle }: AppUsageTableProps) => { + const { formatMinutes, formatSeconds, t, translateCategory } = useI18n() + return (
-

{title}

-

{subtitle}

+

{title || t('tables.topApps')}

+

{subtitle || t('tables.usageToday')}

- App - Category - Time - Notifications + {t('tables.app')} + {t('tables.category')} + {t('tables.time')} + {t('tables.notifications')}
{rows.length === 0 ? ( -
No app data yet for today
+
{t('tables.empty')}
) : ( rows.map((row) => (
@@ -40,7 +42,7 @@ const AppUsageTable = ({ rows, title = 'Top apps', subtitle = 'Usage today' }: A {row.app.name} - {row.app.category} + {translateCategory(row.app.category)} {row.seconds !== undefined ? formatSeconds(row.seconds) : formatMinutes(row.minutes)} {row.notifications}
diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx index c9ea4ec..e290ff4 100644 --- a/src/components/DatePicker.tsx +++ b/src/components/DatePicker.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect, useMemo } from 'react' -import { getTodayDateString, formatDateLabel } from '../utils/analytics' +import { getTodayDateString } from '../utils/analytics' +import { useI18n } from '../i18n/I18nProvider' import './DatePicker.css' interface DatePickerProps { @@ -9,6 +10,7 @@ interface DatePickerProps { } const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps) => { + const { formatDate, formatDateLabel, formatMonthLabel, t } = useI18n() const [isOpen, setIsOpen] = useState(false) const [viewMonth, setViewMonth] = useState(() => { // Start with the selected date's month @@ -43,8 +45,7 @@ const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps) } }, [availableDates]) - // Check if a date has data - const hasData = (dateStr: string) => availableDates.includes(dateStr) + const availableDateSet = useMemo(() => new Set(availableDates), [availableDates]) // Get days for the current view month const calendarDays = useMemo(() => { @@ -82,7 +83,7 @@ const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps) date: d, dateStr, isCurrentMonth: d.getMonth() === month, - hasData: hasData(dateStr), + hasData: availableDateSet.has(dateStr), isToday: dateStr === today, isSelected: dateStr === selectedDate, isFuture: d > todayDate, @@ -90,7 +91,7 @@ const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps) } return days - }, [viewMonth, availableDates, selectedDate]) + }, [availableDateSet, selectedDate, viewMonth]) const goToPrevMonth = () => { setViewMonth(prev => { @@ -115,7 +116,15 @@ const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps) setIsOpen(false) } - const monthLabel = viewMonth.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) + const monthLabel = formatMonthLabel(viewMonth) + const weekdayLabels = useMemo(() => { + const sunday = new Date('2024-03-03T00:00:00') + return Array.from({ length: 7 }, (_, index) => { + const date = new Date(sunday) + date.setDate(sunday.getDate() + index) + return formatDate(date, { weekday: 'short' }) + }) + }, [formatDate]) return (
@@ -140,7 +149,7 @@ const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps) className="datepicker__nav" onClick={goToPrevMonth} type="button" - aria-label="Previous month" + aria-label={t('datePicker.previousMonth')} > @@ -151,7 +160,7 @@ const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps) className="datepicker__nav" onClick={goToNextMonth} type="button" - aria-label="Next month" + aria-label={t('datePicker.nextMonth')} > @@ -160,7 +169,7 @@ const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps)
- {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(day => ( + {weekdayLabels.map(day => (
{day}
))}
@@ -190,11 +199,14 @@ const DatePicker = ({ selectedDate, availableDates, onChange }: DatePickerProps)
- Data available + {t('datePicker.dataAvailable')}
- Data from {formatDateLabel(dataRange.earliest)} to {formatDateLabel(dataRange.latest)} + {t('datePicker.dataRange', { + start: formatDateLabel(dataRange.earliest), + end: formatDateLabel(dataRange.latest), + })}
diff --git a/src/components/NotificationSummary.tsx b/src/components/NotificationSummary.tsx index 5a0e072..634e202 100644 --- a/src/components/NotificationSummary.tsx +++ b/src/components/NotificationSummary.tsx @@ -1,4 +1,5 @@ import type { AppInfo } from '../types/models' +import { useI18n } from '../i18n/I18nProvider' import './NotificationSummary.css' interface NotificationRow { @@ -12,21 +13,22 @@ interface NotificationSummaryProps { } const NotificationSummary = ({ total, rows }: NotificationSummaryProps) => { + const { t } = useI18n() const maxCount = rows.length > 0 ? Math.max(...rows.map(r => r.notifications)) : 1 return (
-

Notifications

+

{t('nav.notifications')}

{total}
- Today + {t('tables.notificationToday')}
{rows.length === 0 ? ( -
No notifications yet
+
{t('tables.notificationEmpty')}
) : ( rows.map((row) => { const percentage = (row.notifications / maxCount) * 100 diff --git a/src/components/SuggestionPanel.tsx b/src/components/SuggestionPanel.tsx index 3732d19..044d794 100644 --- a/src/components/SuggestionPanel.tsx +++ b/src/components/SuggestionPanel.tsx @@ -1,4 +1,5 @@ import type { SuggestionItem } from '../types/models' +import { useI18n } from '../i18n/I18nProvider' import './SuggestionPanel.css' interface SuggestionPanelProps { @@ -6,11 +7,13 @@ interface SuggestionPanelProps { } const SuggestionPanel = ({ items }: SuggestionPanelProps) => { + const { t } = useI18n() + return (
-

Suggestions

-

Based on your recent activity

+

{t('suggestions.title')}

+

{t('suggestions.subtitle')}

{items.map((item) => ( diff --git a/src/i18n/I18nProvider.tsx b/src/i18n/I18nProvider.tsx new file mode 100644 index 0000000..ba2fedb --- /dev/null +++ b/src/i18n/I18nProvider.tsx @@ -0,0 +1,84 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import type { ThemeName } from '../types/models' +import { + defaultLocale, + formatDate, + formatDurationMinutes, + formatDurationSeconds, + formatMonthLabel, + formatRelativeDateLabel, + getLocaleOptions, + normalizeLocale, + translate, + translateCategoryLabel, + translateThemeDescription, + translateThemeName, + type LocaleCode, +} from './core' + +const LOCALE_STORAGE_KEY = 'screenforge-locale' + +interface I18nContextValue { + locale: LocaleCode + setLocale: (locale: LocaleCode) => void + t: (key: string, params?: Record) => string + formatDate: (value: Date | string, options?: Intl.DateTimeFormatOptions) => string + formatDateLabel: (dateString: string) => string + formatMonthLabel: (date: Date) => string + formatMinutes: (minutes: number) => string + formatSeconds: (seconds: number) => string + translateCategory: (category: string) => string + translateThemeName: (theme: ThemeName) => string + translateThemeDescription: (theme: ThemeName) => string + localeOptions: Array<{ code: LocaleCode; label: string }> +} + +const I18nContext = createContext(null) + +const getInitialLocale = (): LocaleCode => { + if (typeof window === 'undefined') return defaultLocale + + const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY) + if (stored) return normalizeLocale(stored) + + return normalizeLocale(window.navigator.language) +} + +export const I18nProvider = ({ children }: { children: ReactNode }) => { + const [locale, setLocaleState] = useState(getInitialLocale) + const setLocale = useCallback((nextLocale: LocaleCode) => { + setLocaleState(normalizeLocale(nextLocale)) + }, []) + + useEffect(() => { + window.localStorage.setItem(LOCALE_STORAGE_KEY, locale) + }, [locale]) + + const value = useMemo(() => ({ + locale, + setLocale, + t: (key, params) => translate(locale, key, params), + formatDate: (value, options) => formatDate(locale, value, options), + formatDateLabel: (dateString) => formatRelativeDateLabel(locale, dateString), + formatMonthLabel: (date) => formatMonthLabel(locale, date), + formatMinutes: (minutes) => formatDurationMinutes(locale, minutes), + formatSeconds: (seconds) => formatDurationSeconds(locale, seconds), + translateCategory: (category) => translateCategoryLabel(locale, category), + translateThemeName: (theme) => translateThemeName(locale, theme), + translateThemeDescription: (theme) => translateThemeDescription(locale, theme), + localeOptions: getLocaleOptions(), + }), [locale, setLocale]) + + return {children} +} + +export const useI18n = () => { + const context = useContext(I18nContext) + if (!context) { + throw new Error('useI18n must be used within an I18nProvider') + } + return context +} + +export type { LocaleCode } diff --git a/src/i18n/core.ts b/src/i18n/core.ts new file mode 100644 index 0000000..af6cf0a --- /dev/null +++ b/src/i18n/core.ts @@ -0,0 +1,148 @@ +import type { ThemeName } from '../types/models' +import { enUS } from './locales/en-US' +import { zhCN } from './locales/zh-CN' +import { supportedLocales, type LocaleCode, type TranslationTree } from './types' + +const dictionaries: Record = { + 'zh-CN': zhCN, + 'en-US': enUS, +} + +export const defaultLocale: LocaleCode = 'zh-CN' + +export const normalizeLocale = (value?: string | null): LocaleCode => { + if (!value) return defaultLocale + if (supportedLocales.includes(value as LocaleCode)) return value as LocaleCode + if (value.toLowerCase().startsWith('zh')) return 'zh-CN' + if (value.toLowerCase().startsWith('en')) return 'en-US' + return defaultLocale +} + +const getNestedValue = (tree: TranslationTree, path: string): string | undefined => { + const value = path.split('.').reduce((current, segment) => { + if (!current || typeof current === 'string') { + return undefined + } + return current[segment] + }, tree) + + return typeof value === 'string' ? value : undefined +} + +const interpolate = (template: string, params?: Record) => { + if (!params) return template + return template.replace(/\{(\w+)\}/g, (_match, key) => String(params[key] ?? '')) +} + +export const translate = (locale: LocaleCode, key: string, params?: Record) => { + const resolvedLocale = normalizeLocale(locale) + const template = + getNestedValue(dictionaries[resolvedLocale], key) ?? + getNestedValue(dictionaries[defaultLocale], key) ?? + key + + return interpolate(template, params) +} + +export const getLocaleOptions = (): Array<{ code: LocaleCode; label: string }> => + supportedLocales.map((code) => ({ + code, + label: translate(code, `locales.${code}`), + })) + +export const translateThemeName = (locale: LocaleCode, theme: ThemeName) => + translate(locale, `themes.${theme}.name`) + +export const translateThemeDescription = (locale: LocaleCode, theme: ThemeName) => + translate(locale, `themes.${theme}.description`) + +export const translateCategoryLabel = (locale: LocaleCode, category: string) => { + const resolvedLocale = normalizeLocale(locale) + const localeTree = dictionaries[resolvedLocale].categories as TranslationTree + const defaultTree = dictionaries[defaultLocale].categories as TranslationTree + const translated = + (localeTree[category] as string | undefined) ?? + (defaultTree[category] as string | undefined) + + return translated ?? category +} + +export const formatDate = ( + locale: LocaleCode, + value: Date | string, + options?: Intl.DateTimeFormatOptions, +) => { + const date = value instanceof Date ? value : new Date(value) + return new Intl.DateTimeFormat(normalizeLocale(locale), options).format(date) +} + +const getDateString = (daysOffset = 0) => { + const now = new Date() + now.setDate(now.getDate() + daysOffset) + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export const formatRelativeDateLabel = (locale: LocaleCode, dateString: string) => { + const today = getDateString(0) + const yesterday = getDateString(-1) + + if (dateString === today) return translate(locale, 'common.today') + if (dateString === yesterday) return translate(locale, 'common.yesterday') + + return formatDate(locale, `${dateString}T00:00:00`, { + weekday: 'short', + month: 'short', + day: 'numeric', + }) +} + +export const formatMonthLabel = (locale: LocaleCode, date: Date) => + formatDate(locale, date, { year: 'numeric', month: 'long' }) + +export const formatDurationMinutes = (locale: LocaleCode, minutes: number) => { + const resolvedLocale = normalizeLocale(locale) + + if (resolvedLocale === 'zh-CN') { + if (minutes === 0) return '0分' + if (minutes < 1) return '<1分' + const hours = Math.floor(minutes / 60) + const mins = Math.round(minutes % 60) + if (hours === 0) return `${mins}分` + return `${hours}小时 ${mins}分` + } + + if (minutes === 0) return '0m' + if (minutes < 1) return '<1m' + const hours = Math.floor(minutes / 60) + const mins = Math.round(minutes % 60) + if (hours === 0) return `${mins}m` + return `${hours}h ${mins}m` +} + +export const formatDurationSeconds = (locale: LocaleCode, totalSeconds: number) => { + const resolvedLocale = normalizeLocale(locale) + + if (resolvedLocale === 'zh-CN') { + if (totalSeconds < 60) return `${Math.floor(totalSeconds)}秒` + const minutes = Math.floor(totalSeconds / 60) + const seconds = Math.floor(totalSeconds % 60) + if (minutes < 60) return `${minutes}分 ${seconds}秒` + const hours = Math.floor(minutes / 60) + const mins = minutes % 60 + return `${hours}小时 ${mins}分` + } + + if (totalSeconds < 60) return `${Math.floor(totalSeconds)}s` + const minutes = Math.floor(totalSeconds / 60) + const seconds = Math.floor(totalSeconds % 60) + if (minutes < 60) return `${minutes}m ${seconds}s` + const hours = Math.floor(minutes / 60) + const mins = minutes % 60 + return `${hours}h ${mins}m` +} + +export { supportedLocales } +export type { LocaleCode, TranslationTree } diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..a78bfc4 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,3 @@ +export * from './core' +export * from './I18nProvider' +export * from './types' diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts new file mode 100644 index 0000000..b32a97a --- /dev/null +++ b/src/i18n/locales/en-US.ts @@ -0,0 +1,318 @@ +import type { TranslationTree } from '../types' + +export const enUS = { + common: { + today: 'Today', + yesterday: 'Yesterday', + none: 'None', + noData: 'No data', + notAvailable: 'N/A', + loading: 'Loading...', + live: 'Live', + focused: 'Focused', + tracking: 'Tracking', + unknown: 'Unknown', + }, + locales: { + 'zh-CN': '简体中文', + 'en-US': 'English', + }, + nav: { + dashboard: 'Dashboard', + insights: 'Insights', + apps: 'Apps', + notifications: 'Notifications', + settings: 'Settings', + }, + sidebar: { + focusScore: 'Focus score', + }, + themes: { + dark: { + name: 'Dark', + description: 'Easy on the eyes, perfect for night', + }, + light: { + name: 'Light', + description: 'Clean and bright for daytime', + }, + tokyo: { + name: 'Tokyo', + description: 'Cyberpunk vibes with purple accents', + }, + skin: { + name: 'Skin', + description: 'Warm and soft aesthetic', + }, + }, + dashboard: { + greeting: { + morning: 'Good morning', + afternoon: 'Good afternoon', + evening: 'Good evening', + }, + subtitle: 'Your screen time for today', + cards: { + screenTime: 'Screen Time', + totalToday: 'Total today', + dailyAverage: 'Daily Avg', + acrossDays: 'Across {count} days', + noHistory: 'No history yet', + topCategory: 'Top Category', + notifications: 'Notifications', + topNotificationApp: 'Top: {name}', + }, + charts: { + dailyUsageTitle: 'Daily usage', + dailyUsageSubtitle: 'Minutes per day across all apps', + dailyUsageEmpty: 'Start using apps to see your usage data', + categoriesTitle: 'Categories', + categoriesSubtitle: 'Time by category today', + categoriesEmpty: 'No category data yet', + usageDataset: 'Usage (minutes)', + minutesDataset: 'Minutes', + }, + sections: { + topAppsToday: 'Top Apps Today', + activeApps: 'Active apps', + activeAppsSubtitle: 'Currently open windows', + activeAppsEmpty: 'No apps with visible windows detected', + currentFocus: 'Current focus', + appsUsedToday: 'Apps used today', + daysRecorded: 'Days recorded', + noActiveApp: 'No active app', + otherApps: 'Other apps', + }, + trend: { + collecting: 'Collecting data', + newData: 'New data', + up: 'Trending up', + down: 'Trending down', + stable: 'Stable', + }, + }, + insights: { + title: 'Insights', + subtitle: 'Deep dive into your screen time patterns', + cards: { + focusScore: 'Focus Score', + focusScoreSub: 'Based on productive app usage', + appsUsed: 'Apps Used', + topApp: 'Top app', + topAppSub: 'Most used by minutes', + totalTracked: 'Total tracked', + totalTrackedSub: 'All time screen time', + }, + charts: { + weeklyTrendTitle: 'Weekly trend', + weeklyTrendSubtitle: 'Screen time over the last 7 days', + weeklyTrendEmpty: 'Start using apps to see trends', + categoryBreakdownTitle: 'Category breakdown', + categoryBreakdownSubtitle: 'How you spend your screen time', + categoryBreakdownEmpty: 'No category data yet', + minutesDataset: 'Minutes', + }, + quickStats: { + title: 'Quick stats', + daysTracked: 'Days tracked', + appsUsed: 'Apps used', + dailyAverage: 'Daily average', + topCategory: 'Top category', + peakDay: 'Peak day', + }, + }, + apps: { + title: 'Apps', + subtitle: 'Screen time for {date}', + dateView: 'View:', + stats: { + totalTime: 'Total Time', + appsUsed: 'Apps Used', + topCategory: 'Top Category', + }, + openApps: { + title: 'Open apps', + subtitle: 'Apps with visible windows', + empty: 'No apps with visible windows detected yet.', + }, + backgroundApps: { + title: 'Background processes', + subtitle: 'Running without visible windows', + empty: 'No background processes detected.', + processCount: '{count} process(es)', + }, + timeLimits: { + title: 'Active Time Limits', + subtitle: '{count} app(s) with limits', + usage: '{used} / {limit} min', + exceeded: 'Exceeded', + setLimit: 'Set time limit', + edit: 'Edit', + remove: 'Remove', + save: 'Save', + cancel: 'Cancel', + limit: 'Limit:', + minutesPlaceholder: 'Minutes', + perDayUnit: 'min/day', + }, + controls: { + searchPlaceholder: 'Search apps...', + sortBy: 'Sort by:', + time: 'Time', + name: 'Name', + category: 'Category', + }, + empty: { + search: 'No apps match your search', + date: 'No apps tracked for {date}', + }, + cards: { + percentageOfTotal: '{count}% of total', + }, + }, + notifications: { + title: 'Notifications', + subtitle: 'Track notification activity from your apps today', + status: { + disabledTitle: 'Notification Logs Disabled', + disabledMessage: 'Windows notification logs are disabled. Enable them in Event Viewer or run as Administrator.', + errorTitle: 'Error Reading Logs', + errorMessage: 'Failed to read notification logs.', + errorWithDetails: 'Failed to read notification logs: {detail}', + }, + stats: { + totalToday: 'Total Today', + apps: 'Apps', + avgPerApp: 'Avg per App', + topSender: 'Top Sender', + }, + breakdown: { + title: 'Breakdown by App', + subtitle: 'Notifications received today per application', + countBadge: '{count} apps', + app: 'Application', + count: 'Count', + emptyTitle: 'No notifications tracked yet', + emptySubtitle: 'Notifications from your apps will appear here as they come in.', + emptyActionSubtitle: 'Fix the issue above to start tracking notifications.', + otherApps: 'Other Apps', + }, + tips: { + title: 'Reduce Distractions', + subtitle: 'Tips for managing notification overload', + focusAssistTitle: 'Enable Focus Assist', + focusAssistDesc: 'Use Windows Focus Assist to silence notifications during work hours. Access it from the Action Center or Settings.', + appNotificationsTitle: 'Configure App Notifications', + appNotificationsDesc: 'Go to Windows Settings > System > Notifications to customize which apps can send you notifications.', + timeLimitsTitle: 'Set Time Limits', + timeLimitsDesc: 'Use the Apps page to set daily time limits for distracting applications. You will receive a notification when limits are exceeded.', + }, + }, + settings: { + title: 'Settings', + subtitle: 'Customize your ScreenForge experience', + loadingSubtitle: 'Loading...', + sections: { + appearance: 'Appearance', + appearanceDesc: 'Choose your preferred theme', + language: 'Language', + languageDesc: 'Choose your interface language', + behavior: 'Behavior', + behaviorDesc: 'Control how ScreenForge runs', + timeLimits: 'Time Limits', + timeLimitsDesc: 'Control app usage notifications', + data: 'Data', + dataDesc: 'Manage your tracked data', + about: 'About', + }, + behavior: { + startWithWindows: 'Start with Windows', + startWithWindowsDesc: 'Launch ScreenForge when you log in', + minimizeToTray: 'Minimize to tray', + minimizeToTrayDesc: 'Keep running in background when closed', + }, + timeLimits: { + notifications: 'Time limit notifications', + notificationsDesc: 'Get notified when you exceed app time limits', + activeLimits: 'Active limits', + activeLimitsDesc: 'You have {count} app(s) with time limits. Manage them on the Apps page.', + }, + data: { + clearAll: 'Clear all data', + clearAllDesc: 'Remove all tracked usage history', + clearButton: 'Clear data', + clearConfirm: 'Are you sure? This will delete all your usage data.', + }, + about: { + version: 'Version', + platform: 'Platform', + builtWith: 'Built with', + windows: 'Windows', + techStack: 'Electron + React', + }, + }, + tables: { + topApps: 'Top apps', + usageToday: 'Usage today', + app: 'App', + category: 'Category', + time: 'Time', + notifications: 'Notifications', + empty: 'No app data yet for today', + notificationToday: 'Today', + notificationEmpty: 'No notifications yet', + }, + datePicker: { + previousMonth: 'Previous month', + nextMonth: 'Next month', + dataAvailable: 'Data available', + dataRange: 'Data from {start} to {end}', + }, + suggestions: { + title: 'Suggestions', + subtitle: 'Based on your recent activity', + welcome: { + title: 'Welcome to ScreenForge!', + detail: 'Keep the app running to track your screen time automatically.', + }, + entertainment: { + title: 'High entertainment usage', + detail: 'Consider setting time limits for entertainment apps to boost productivity.', + }, + social: { + title: 'Social apps taking over', + detail: 'Try scheduling specific times for checking social media.', + }, + productive: { + title: 'Great focus!', + detail: "You're spending most of your time on productive tasks. Keep it up!", + }, + balance: { + title: 'Balanced usage', + detail: 'Your screen time is well distributed across different activities.', + }, + breaks: { + title: 'Remember to take breaks', + detail: 'Use the 20-20-20 rule: every 20 minutes, look at something 20 feet away for 20 seconds.', + }, + }, + native: { + timeLimitReachedTitle: 'Time Limit Reached', + timeLimitReachedBody: "You've used {appName} for {usedMinutes} minutes today. Your limit is {limitMinutes} minutes.", + trayTooltip: 'ScreenForge - Screen Time Tracker', + trayShow: 'Show ScreenForge', + trayQuit: 'Quit', + }, + categories: { + Productivity: 'Productivity', + Education: 'Education', + Communication: 'Communication', + Utilities: 'Utilities', + Browsers: 'Browsers', + Entertainment: 'Entertainment', + Social: 'Social', + System: 'System', + Other: 'Other', + Unknown: 'Unknown', + }, +} as const satisfies TranslationTree diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts new file mode 100644 index 0000000..60eb845 --- /dev/null +++ b/src/i18n/locales/zh-CN.ts @@ -0,0 +1,318 @@ +import type { TranslationTree } from '../types' + +export const zhCN = { + common: { + today: '今天', + yesterday: '昨天', + none: '无', + noData: '暂无数据', + notAvailable: '暂无', + loading: '加载中...', + live: '实时', + focused: '当前焦点', + tracking: '追踪中', + unknown: '未知', + }, + locales: { + 'zh-CN': '简体中文', + 'en-US': 'English', + }, + nav: { + dashboard: '仪表盘', + insights: '洞察', + apps: '应用', + notifications: '通知', + settings: '设置', + }, + sidebar: { + focusScore: '专注分', + }, + themes: { + dark: { + name: '深色', + description: '更护眼,适合夜间使用', + }, + light: { + name: '浅色', + description: '清爽明亮,适合白天使用', + }, + tokyo: { + name: '东京', + description: '带有霓虹感的未来风配色', + }, + skin: { + name: '暖肤', + description: '温暖柔和的界面风格', + }, + }, + dashboard: { + greeting: { + morning: '早上好', + afternoon: '下午好', + evening: '晚上好', + }, + subtitle: '查看你今天的屏幕使用情况', + cards: { + screenTime: '屏幕时长', + totalToday: '今日总计', + dailyAverage: '日均', + acrossDays: '近 {count} 天平均', + noHistory: '暂无历史记录', + topCategory: '最高分类', + notifications: '通知数', + topNotificationApp: '最高:{name}', + }, + charts: { + dailyUsageTitle: '每日使用趋势', + dailyUsageSubtitle: '所有应用每天的使用分钟数', + dailyUsageEmpty: '开始使用应用后,这里会显示你的使用数据', + categoriesTitle: '分类分布', + categoriesSubtitle: '今天各分类的使用时长', + categoriesEmpty: '暂无分类数据', + usageDataset: '使用时长(分钟)', + minutesDataset: '分钟', + }, + sections: { + topAppsToday: '今日常用应用', + activeApps: '活跃应用', + activeAppsSubtitle: '当前已打开的窗口', + activeAppsEmpty: '暂未检测到可见窗口应用', + currentFocus: '当前专注应用', + appsUsedToday: '今日使用应用数', + daysRecorded: '记录天数', + noActiveApp: '当前没有活跃应用', + otherApps: '其他应用', + }, + trend: { + collecting: '正在收集数据', + newData: '刚开始记录', + up: '呈上升趋势', + down: '呈下降趋势', + stable: '基本稳定', + }, + }, + insights: { + title: '洞察', + subtitle: '更深入地了解你的屏幕使用模式', + cards: { + focusScore: '专注分', + focusScoreSub: '基于高效应用使用情况计算', + appsUsed: '使用应用数', + topApp: '最常用应用', + topAppSub: '按分钟数统计', + totalTracked: '累计记录', + totalTrackedSub: '全部历史屏幕时长', + }, + charts: { + weeklyTrendTitle: '近一周趋势', + weeklyTrendSubtitle: '过去 7 天的屏幕使用时长', + weeklyTrendEmpty: '开始使用应用后,这里会显示趋势', + categoryBreakdownTitle: '分类占比', + categoryBreakdownSubtitle: '你的屏幕时间主要花在哪里', + categoryBreakdownEmpty: '暂无分类数据', + minutesDataset: '分钟', + }, + quickStats: { + title: '速览统计', + daysTracked: '记录天数', + appsUsed: '使用应用数', + dailyAverage: '日均时长', + topCategory: '最高分类', + peakDay: '峰值日期', + }, + }, + apps: { + title: '应用', + subtitle: '{date} 的屏幕使用情况', + dateView: '查看:', + stats: { + totalTime: '总时长', + appsUsed: '使用应用数', + topCategory: '最高分类', + }, + openApps: { + title: '已打开应用', + subtitle: '有可见窗口的应用', + empty: '暂未检测到有可见窗口的应用。', + }, + backgroundApps: { + title: '后台进程', + subtitle: '正在运行但没有可见窗口', + empty: '未检测到后台进程。', + processCount: '{count} 个进程', + }, + timeLimits: { + title: '已启用时长限制', + subtitle: '共 {count} 个应用设置了限额', + usage: '{used} / {limit} 分钟', + exceeded: '已超限', + setLimit: '设置时长限制', + edit: '编辑', + remove: '移除', + save: '保存', + cancel: '取消', + limit: '限制:', + minutesPlaceholder: '分钟', + perDayUnit: '分钟/天', + }, + controls: { + searchPlaceholder: '搜索应用...', + sortBy: '排序方式:', + time: '时长', + name: '名称', + category: '分类', + }, + empty: { + search: '没有匹配搜索条件的应用', + date: '{date} 暂无应用使用记录', + }, + cards: { + percentageOfTotal: '占总时长 {count}%', + }, + }, + notifications: { + title: '通知', + subtitle: '跟踪今天来自各应用的通知活动', + status: { + disabledTitle: '通知日志未启用', + disabledMessage: 'Windows 通知日志当前已关闭,请在事件查看器中启用,或尝试以管理员身份运行。', + errorTitle: '读取日志失败', + errorMessage: '读取通知日志失败。', + errorWithDetails: '读取通知日志失败:{detail}', + }, + stats: { + totalToday: '今日总数', + apps: '应用数', + avgPerApp: '单应用平均', + topSender: '最高来源', + }, + breakdown: { + title: '按应用拆分', + subtitle: '今天每个应用收到的通知数', + countBadge: '{count} 个应用', + app: '应用', + count: '数量', + emptyTitle: '还没有记录到通知', + emptySubtitle: '来自应用的通知会在收到后显示在这里。', + emptyActionSubtitle: '先解决上方问题,之后即可开始跟踪通知。', + otherApps: '其他应用', + }, + tips: { + title: '减少干扰', + subtitle: '管理通知过载的小建议', + focusAssistTitle: '开启专注助手', + focusAssistDesc: '使用 Windows 专注助手在工作时段静音通知,可在操作中心或设置中开启。', + appNotificationsTitle: '配置应用通知', + appNotificationsDesc: '前往 Windows 设置 > 系统 > 通知,自定义哪些应用可以向你发送通知。', + timeLimitsTitle: '设置时长限制', + timeLimitsDesc: '你可以在“应用”页面为容易分心的应用设置每日限制,超限后会收到提醒。', + }, + }, + settings: { + title: '设置', + subtitle: '自定义你的 ScreenForge 使用体验', + loadingSubtitle: '加载中...', + sections: { + appearance: '外观', + appearanceDesc: '选择你喜欢的主题', + language: '语言', + languageDesc: '选择界面显示语言', + behavior: '行为', + behaviorDesc: '控制 ScreenForge 的运行方式', + timeLimits: '时长限制', + timeLimitsDesc: '控制应用使用提醒', + data: '数据', + dataDesc: '管理已记录的数据', + about: '关于', + }, + behavior: { + startWithWindows: '开机启动', + startWithWindowsDesc: '登录 Windows 后自动启动 ScreenForge', + minimizeToTray: '最小化到托盘', + minimizeToTrayDesc: '关闭窗口后继续在后台运行', + }, + timeLimits: { + notifications: '时长超限提醒', + notificationsDesc: '当应用使用超出限制时发送提醒', + activeLimits: '当前限制', + activeLimitsDesc: '你已为 {count} 个应用设置时长限制,可前往“应用”页面管理。', + }, + data: { + clearAll: '清空所有数据', + clearAllDesc: '删除所有已记录的使用历史', + clearButton: '清空数据', + clearConfirm: '确定吗?这将删除你所有的使用数据。', + }, + about: { + version: '版本', + platform: '平台', + builtWith: '技术栈', + windows: 'Windows', + techStack: 'Electron + React', + }, + }, + tables: { + topApps: '常用应用', + usageToday: '今日使用情况', + app: '应用', + category: '分类', + time: '时长', + notifications: '通知', + empty: '今天还没有应用数据', + notificationToday: '今天', + notificationEmpty: '还没有通知', + }, + datePicker: { + previousMonth: '上个月', + nextMonth: '下个月', + dataAvailable: '有数据记录', + dataRange: '数据范围:{start} 至 {end}', + }, + suggestions: { + title: '建议', + subtitle: '基于你最近的活动生成', + welcome: { + title: '欢迎使用 ScreenForge!', + detail: '保持应用在后台运行,即可自动记录你的屏幕时长。', + }, + entertainment: { + title: '娱乐应用使用偏高', + detail: '可以考虑为娱乐类应用设置时长限制,提升专注度。', + }, + social: { + title: '社交应用占比偏高', + detail: '试着给查看社交媒体安排固定时间,减少频繁打断。', + }, + productive: { + title: '专注状态不错!', + detail: '你大部分时间都花在高效任务上,继续保持。', + }, + balance: { + title: '使用分布较均衡', + detail: '你的屏幕时间在不同活动之间分配得比较平衡。', + }, + breaks: { + title: '记得适当休息', + detail: '试试 20-20-20 法则:每 20 分钟,看向 20 英尺外的物体 20 秒。', + }, + }, + native: { + timeLimitReachedTitle: '已达到时长限制', + timeLimitReachedBody: '今天你已使用 {appName} {usedMinutes} 分钟,超过设定限制 {limitMinutes} 分钟。', + trayTooltip: 'ScreenForge - 屏幕时间追踪器', + trayShow: '显示 ScreenForge', + trayQuit: '退出', + }, + categories: { + Productivity: '生产力', + Education: '教育', + Communication: '沟通', + Utilities: '工具', + Browsers: '浏览器', + Entertainment: '娱乐', + Social: '社交', + System: '系统', + Other: '其他', + Unknown: '未知', + }, +} as const satisfies TranslationTree diff --git a/src/i18n/types.ts b/src/i18n/types.ts new file mode 100644 index 0000000..400f95d --- /dev/null +++ b/src/i18n/types.ts @@ -0,0 +1,7 @@ +export const supportedLocales = ['zh-CN', 'en-US'] as const + +export type LocaleCode = (typeof supportedLocales)[number] + +export type TranslationTree = { + [key: string]: string | TranslationTree +} diff --git a/src/main.tsx b/src/main.tsx index 702dbaa..0c4594f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,9 +3,12 @@ import { createRoot } from 'react-dom/client' import '@fontsource/inter/index.css' import './index.css' import App from './App.tsx' +import { I18nProvider } from './i18n/I18nProvider.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/src/pages/Apps.tsx b/src/pages/Apps.tsx index 958a4ed..8273c77 100644 --- a/src/pages/Apps.tsx +++ b/src/pages/Apps.tsx @@ -1,13 +1,12 @@ import { useMemo, useState, useEffect } from 'react' import type { UsageSnapshot } from '../services/usageService' import type { AppInfo, AppTimeLimit } from '../types/models' +import { useI18n } from '../i18n/I18nProvider' import { - formatSeconds, getAppTotals, getEntriesForDate, getAvailableDates, getTodayDateString, - formatDateLabel, getCategoryTotals, } from '../utils/analytics' import { fetchTimeLimits, addTimeLimit, removeTimeLimit } from '../services/usageService' @@ -21,6 +20,7 @@ interface AppsProps { type SortBy = 'time' | 'name' | 'category' const Apps = ({ snapshot }: AppsProps) => { + const { t, formatSeconds, formatDateLabel, translateCategory } = useI18n() const [sortBy, setSortBy] = useState('time') const [search, setSearch] = useState('') const [timeLimits, setTimeLimits] = useState([]) @@ -45,11 +45,11 @@ const Apps = ({ snapshot }: AppsProps) => { return dates.slice(0, 14) // Last 14 days max }, [snapshot]) - // Ensure selected date is valid - useEffect(() => { - if (!availableDates.includes(selectedDate)) { - setSelectedDate(availableDates[0] || getTodayDateString()) + const activeSelectedDate = useMemo(() => { + if (availableDates.includes(selectedDate)) { + return selectedDate } + return availableDates[0] || getTodayDateString() }, [availableDates, selectedDate]) const runningNow = useMemo(() => { @@ -73,7 +73,7 @@ const Apps = ({ snapshot }: AppsProps) => { } // Filter entries for selected date - const dateEntries = getEntriesForDate(snapshot.usageEntries, selectedDate) + const dateEntries = getEntriesForDate(snapshot.usageEntries, activeSelectedDate) const appTotals = getAppTotals(dateEntries, snapshot.apps) const catTotals = getCategoryTotals(dateEntries, snapshot.apps) const totalSec = appTotals.reduce((s, a) => s + a.seconds, 0) @@ -97,7 +97,10 @@ const Apps = ({ snapshot }: AppsProps) => { if (search) { const q = search.toLowerCase() sorted = sorted.filter( - (a) => a.app.name.toLowerCase().includes(q) || a.app.category.toLowerCase().includes(q) + (a) => + a.app.name.toLowerCase().includes(q) || + a.app.category.toLowerCase().includes(q) || + translateCategory(a.app.category).toLowerCase().includes(q) ) } @@ -107,7 +110,7 @@ const Apps = ({ snapshot }: AppsProps) => { categoryTotals: catTotals, todayUsageByApp: todayUsage, } - }, [snapshot, sortBy, search, selectedDate]) + }, [activeSelectedDate, snapshot, sortBy, search, translateCategory]) const handleSetLimit = async (appId: string) => { const minutes = parseInt(limitInputValue, 10) @@ -136,15 +139,15 @@ const Apps = ({ snapshot }: AppsProps) => { return timeLimits.find((l) => l.appId === appId) } - const isToday = selectedDate === getTodayDateString() + const isToday = activeSelectedDate === getTodayDateString() return ( <>
-
Apps
+
{t('apps.title')}
- Screen time for {formatDateLabel(selectedDate)} + {t('apps.subtitle', { date: formatDateLabel(activeSelectedDate) })}
@@ -152,9 +155,9 @@ const Apps = ({ snapshot }: AppsProps) => { {/* Date Selector & Stats */}
- View: + {t('apps.dateView')} @@ -162,15 +165,15 @@ const Apps = ({ snapshot }: AppsProps) => {
{formatSeconds(totalSeconds)} - Total Time + {t('apps.stats.totalTime')}
{appList.length} - Apps Used + {t('apps.stats.appsUsed')}
- {categoryTotals[0]?.category ?? 'None'} - Top Category + {categoryTotals[0] ? translateCategory(categoryTotals[0].category) : t('common.none')} + {t('apps.stats.topCategory')}
@@ -179,11 +182,11 @@ const Apps = ({ snapshot }: AppsProps) => { <>
-
Open apps
-
Apps with visible windows
+
{t('apps.openApps.title')}
+
{t('apps.openApps.subtitle')}
{openApps.length === 0 ? ( -
No apps with visible windows detected yet.
+
{t('apps.openApps.empty')}
) : (
{openApps.map((p) => ( @@ -197,7 +200,7 @@ const Apps = ({ snapshot }: AppsProps) => { /> {p.appInfo?.name ?? p.process} {p.appId === snapshot?.activeAppId && ( - focused + {t('common.focused')} )}
))} @@ -207,18 +210,18 @@ const Apps = ({ snapshot }: AppsProps) => {
-
Background processes
-
Running without visible windows
+
{t('apps.backgroundApps.title')}
+
{t('apps.backgroundApps.subtitle')}
{backgroundApps.length === 0 ? ( -
No background processes detected.
+
{t('apps.backgroundApps.empty')}
) : (
{backgroundApps.slice(0, 16).map((p) => (
{p.appInfo?.name ?? p.process} - {p.count} {p.count === 1 ? 'process' : 'processes'} + {t('apps.backgroundApps.processCount', { count: p.count })}
))} @@ -232,9 +235,9 @@ const Apps = ({ snapshot }: AppsProps) => { {timeLimits.length > 0 && isToday && (
-
Active Time Limits
+
{t('apps.timeLimits.title')}
- {timeLimits.length} app{timeLimits.length !== 1 ? 's' : ''} with limits + {t('apps.timeLimits.subtitle', { count: timeLimits.length })}
@@ -255,7 +258,7 @@ const Apps = ({ snapshot }: AppsProps) => { /> {app?.name ?? limit.appId} - {usedMinutes}m / {limit.limitMinutes}m + {t('apps.timeLimits.usage', { used: usedMinutes, limit: limit.limitMinutes })}
{ }} />
- {isExceeded && exceeded} + {isExceeded && {t('apps.timeLimits.exceeded')}}
) })} @@ -278,20 +281,20 @@ const Apps = ({ snapshot }: AppsProps) => { setSearch(e.target.value)} />
- Sort by: + {t('apps.controls.sortBy')}
@@ -300,8 +303,8 @@ const Apps = ({ snapshot }: AppsProps) => { {appList.length === 0 ? (
{search - ? 'No apps match your search' - : `No apps tracked for ${formatDateLabel(selectedDate)}`} + ? t('apps.empty.search') + : t('apps.empty.date', { date: formatDateLabel(activeSelectedDate) })}
) : ( appList.map(({ app, seconds }) => { @@ -369,6 +372,7 @@ const AppCard = ({ onCancelEdit, onRemoveLimit, }: AppCardProps) => { + const { t, formatSeconds, translateCategory } = useI18n() const isExceeded = limit && todayMinutes >= limit.limitMinutes const limitProgress = limit ? Math.min(100, Math.round((todayMinutes / limit.limitMinutes) * 100)) : 0 @@ -378,12 +382,12 @@ const AppCard = ({
{app.name}
-
{app.category}
+
{translateCategory(app.category)}
{formatSeconds(seconds)} - {percentage}% of total + {t('apps.cards.percentageOfTotal', { count: percentage })}
@@ -397,7 +401,7 @@ const AppCard = ({ onLimitInputChange(e.target.value)} onKeyDown={(e) => { @@ -407,16 +411,16 @@ const AppCard = ({ autoFocus min={1} /> - min/day - - + {t('apps.timeLimits.perDayUnit')} + +
) : limit ? (
- Limit: + {t('apps.timeLimits.limit')} - {todayMinutes}m / {limit.limitMinutes}m + {t('apps.timeLimits.usage', { used: todayMinutes, limit: limit.limitMinutes })}
@@ -429,13 +433,13 @@ const AppCard = ({ />
- - + +
) : ( )}
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 16736b0..861945e 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -17,9 +17,8 @@ import NotificationSummary from '../components/NotificationSummary' import SuggestionPanel from '../components/SuggestionPanel' import type { ThemeName, SuggestionItem, NotificationSummary as NotifSummaryType, AppInfo } from '../types/models' import type { UsageSnapshot } from '../services/usageService' +import { useI18n } from '../i18n/I18nProvider' import { - formatMinutes, - formatSeconds, getAppTotals, getCategoryTotals, getDailyTotals, @@ -110,6 +109,7 @@ const buildFallbackApp = (appId: string): AppInfo => { } const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: DashboardProps) => { + const { t, formatMinutes, formatSeconds, formatDate, translateCategory } = useI18n() const { dailyTotals, todaySeconds, @@ -181,8 +181,8 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo }, [snapshot, notificationSummary]) const activeAppName = snapshot?.activeAppId - ? snapshot.apps.find((app) => app.id === snapshot.activeAppId)?.name ?? 'Other apps' - : 'No active app' + ? snapshot.apps.find((app) => app.id === snapshot.activeAppId)?.name ?? t('dashboard.sections.otherApps') + : t('dashboard.sections.noActiveApp') // Get currently running apps with windows (active apps) const activeApps = useMemo(() => { @@ -198,7 +198,7 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo }, [snapshot]) const chartLabels = dailyTotals.map((entry) => - new Date(entry.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + formatDate(`${entry.date}T00:00:00`, { month: 'short', day: 'numeric' }), ) const axisColor = @@ -210,7 +210,7 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo labels: chartLabels, datasets: [ { - label: 'Usage (minutes)', + label: t('dashboard.charts.usageDataset'), data: dailyTotals.map((entry) => entry.minutes), borderColor: 'rgba(79, 139, 255, 0.9)', backgroundColor: 'rgba(79, 139, 255, 0.15)', @@ -222,10 +222,10 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo } const categoryChartData = { - labels: todayCategoryTotals.map((row) => row.category), + labels: todayCategoryTotals.map((row) => translateCategory(row.category)), datasets: [ { - label: 'Minutes', + label: t('dashboard.charts.minutesDataset'), data: todayCategoryTotals.map((row) => row.minutes), backgroundColor: ['#4f8bff', '#8c7dff', '#2ed47a', '#ff8b6a', '#f7b955'], borderRadius: 12, @@ -237,31 +237,35 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo <>
-
{getGreeting()}
-
Your screen time for today
+
{getGreeting(t)}
+
{t('dashboard.subtitle')}
0 ? `Across ${dailyTotals.length} days` : 'No history yet'} + sub={dailyTotals.length > 0 + ? t('dashboard.cards.acrossDays', { count: dailyTotals.length }) + : t('dashboard.cards.noHistory')} />
@@ -270,10 +274,10 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo
-

Daily usage

-

Minutes per day across all apps

+

{t('dashboard.charts.dailyUsageTitle')}

+

{t('dashboard.charts.dailyUsageSubtitle')}

- {getTrendLabel(dailyTotals)} + {getTrendLabel(dailyTotals, t)}
{dailyTotals.length > 0 ? ( ) : ( -
Start using apps to see your usage data
+
{t('dashboard.charts.dailyUsageEmpty')}
)}
-

Categories

-

Time by category today

+

{t('dashboard.charts.categoriesTitle')}

+

{t('dashboard.charts.categoriesSubtitle')}

- Today + {t('common.today')}
{todayCategoryTotals.length > 0 ? ( ) : ( -
No category data yet
+
{t('dashboard.charts.categoriesEmpty')}
)}
- + {notificationSummary && }
-

Active apps

-

Currently open windows

+

{t('dashboard.sections.activeApps')}

+

{t('dashboard.sections.activeAppsSubtitle')}

{activeApps.length === 0 ? ( -
No apps with visible windows detected
+
{t('dashboard.sections.activeAppsEmpty')}
) : (
{activeApps.map((app) => ( @@ -346,7 +350,7 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo {app.appInfo?.name ?? app.process} {app.appId === snapshot?.activeAppId && ( - focused + {t('common.focused')} )}
))} @@ -357,21 +361,21 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo
- Tracking + {t('common.tracking')}
- Live + {t('common.live')}
- Current focus + {t('dashboard.sections.currentFocus')} {activeAppName}
- Apps used today + {t('dashboard.sections.appsUsedToday')} {todayAppsCount}
- Days recorded + {t('dashboard.sections.daysRecorded')} {dailyTotals.length}
@@ -381,24 +385,24 @@ const Dashboard = ({ snapshot, suggestions, notificationSummary, theme }: Dashbo ) } -const getGreeting = () => { +const getGreeting = (t: (key: string) => string) => { const hour = new Date().getHours() - if (hour < 12) return 'Good morning' - if (hour < 18) return 'Good afternoon' - return 'Good evening' + if (hour < 12) return t('dashboard.greeting.morning') + if (hour < 18) return t('dashboard.greeting.afternoon') + return t('dashboard.greeting.evening') } -const getTrendLabel = (dailyTotals: { minutes: number }[]) => { - if (dailyTotals.length < 2) return 'Collecting data' +const getTrendLabel = (dailyTotals: { minutes: number }[], t: (key: string) => string) => { + if (dailyTotals.length < 2) return t('dashboard.trend.collecting') const recent = dailyTotals.slice(-3) const older = dailyTotals.slice(-6, -3) - if (older.length === 0) return 'New data' + if (older.length === 0) return t('dashboard.trend.newData') const recentAvg = recent.reduce((s, e) => s + e.minutes, 0) / recent.length const olderAvg = older.reduce((s, e) => s + e.minutes, 0) / older.length const diff = ((recentAvg - olderAvg) / Math.max(olderAvg, 1)) * 100 - if (diff > 10) return 'Trending up' - if (diff < -10) return 'Trending down' - return 'Stable' + if (diff > 10) return t('dashboard.trend.up') + if (diff < -10) return t('dashboard.trend.down') + return t('dashboard.trend.stable') } export default Dashboard diff --git a/src/pages/Insights.tsx b/src/pages/Insights.tsx index 6cb1b80..d4ca74d 100644 --- a/src/pages/Insights.tsx +++ b/src/pages/Insights.tsx @@ -12,7 +12,8 @@ import { import { Doughnut, Line } from 'react-chartjs-2' import type { UsageSnapshot } from '../services/usageService' import type { ThemeName } from '../types/models' -import { formatMinutes, getDailyTotals, getCategoryTotals, getAppTotals, calculateFocusScore, getTodayEntries } from '../utils/analytics' +import { useI18n } from '../i18n/I18nProvider' +import { getDailyTotals, getCategoryTotals, getAppTotals, calculateFocusScore, getTodayEntries } from '../utils/analytics' import './Insights.css' ChartJS.register(ArcElement, CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend) @@ -23,6 +24,7 @@ interface InsightsProps { } const Insights = ({ snapshot, theme }: InsightsProps) => { + const { t, formatMinutes, formatDate, translateCategory } = useI18n() const { dailyTotals, categoryTotals, @@ -40,9 +42,9 @@ const Insights = ({ snapshot, theme }: InsightsProps) => { appTotals: [], focusScore: 0, todayAppsCount: 0, - topAppName: 'N/A', - topCategoryName: 'N/A', - peakDayLabel: 'N/A', + topAppName: t('common.notAvailable'), + topCategoryName: t('common.notAvailable'), + peakDayLabel: t('common.notAvailable'), } } @@ -67,11 +69,11 @@ const Insights = ({ snapshot, theme }: InsightsProps) => { appTotals: apps, focusScore: score, todayAppsCount: todayAppTotals.length, - topAppName: topApp?.app.name ?? 'N/A', - topCategoryName: topCategory?.category ?? 'N/A', - peakDayLabel: peakDay ? new Date(peakDay.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : 'N/A', + topAppName: topApp?.app.name ?? t('common.notAvailable'), + topCategoryName: topCategory ? translateCategory(topCategory.category) : t('common.notAvailable'), + peakDayLabel: peakDay ? formatDate(`${peakDay.date}T00:00:00`, { month: 'short', day: 'numeric' }) : t('common.notAvailable'), } - }, [snapshot]) + }, [snapshot, formatDate, t, translateCategory]) const style = typeof window !== 'undefined' ? getComputedStyle(document.documentElement) : null const axisColor = style?.getPropertyValue('--text-muted').trim() || 'rgba(255,255,255,0.6)' @@ -84,10 +86,10 @@ const Insights = ({ snapshot, theme }: InsightsProps) => { const weeklyData = useMemo(() => { const last7 = dailyTotals.slice(-7) return { - labels: last7.map((d) => new Date(d.date).toLocaleDateString('en-US', { weekday: 'short' })), + labels: last7.map((d) => formatDate(`${d.date}T00:00:00`, { weekday: 'short' })), datasets: [ { - label: 'Minutes', + label: t('insights.charts.minutesDataset'), data: last7.map((d) => d.minutes), borderColor: accentColor, backgroundColor: 'transparent', @@ -96,10 +98,10 @@ const Insights = ({ snapshot, theme }: InsightsProps) => { }, ], } - }, [dailyTotals, accentColor]) + }, [dailyTotals, accentColor, formatDate, t]) const categoryDonutData = useMemo(() => ({ - labels: categoryTotals.map((c) => c.category), + labels: categoryTotals.map((c) => translateCategory(c.category)), datasets: [ { data: categoryTotals.map((c) => c.minutes), @@ -107,7 +109,7 @@ const Insights = ({ snapshot, theme }: InsightsProps) => { borderWidth: 0, }, ], - }), [categoryTotals]) + }), [categoryTotals, translateCategory]) const totalTime = dailyTotals.reduce((s, d) => s + d.minutes, 0) const avgDaily = dailyTotals.length > 0 ? Math.round(totalTime / dailyTotals.length) : 0 @@ -116,31 +118,31 @@ const Insights = ({ snapshot, theme }: InsightsProps) => { <>
-
Insights
-
Deep dive into your screen time patterns
+
{t('insights.title')}
+
{t('insights.subtitle')}
-
Focus Score
+
{t('insights.cards.focusScore')}
{focusScore}
-
Based on productive app usage
+
{t('insights.cards.focusScoreSub')}
-
Apps Used
+
{t('insights.cards.appsUsed')}
{todayAppsCount}
-
Today
+
{t('common.today')}
-
Top app
+
{t('insights.cards.topApp')}
{topAppName}
-
Most used by minutes
+
{t('insights.cards.topAppSub')}
-
Total tracked
+
{t('insights.cards.totalTracked')}
{formatMinutes(totalTime)}
-
All time screen time
+
{t('insights.cards.totalTrackedSub')}
@@ -148,8 +150,8 @@ const Insights = ({ snapshot, theme }: InsightsProps) => {
-

Weekly trend

-

Screen time over the last 7 days

+

{t('insights.charts.weeklyTrendTitle')}

+

{t('insights.charts.weeklyTrendSubtitle')}

{dailyTotals.length > 0 ? ( @@ -165,15 +167,15 @@ const Insights = ({ snapshot, theme }: InsightsProps) => { }} /> ) : ( -
Start using apps to see trends
+
{t('insights.charts.weeklyTrendEmpty')}
)}
-

Category breakdown

-

How you spend your screen time

+

{t('insights.charts.categoryBreakdownTitle')}

+

{t('insights.charts.categoryBreakdownSubtitle')}

{categoryTotals.length > 0 ? ( @@ -190,33 +192,33 @@ const Insights = ({ snapshot, theme }: InsightsProps) => { />
) : ( -
No category data yet
+
{t('insights.charts.categoryBreakdownEmpty')}
)}
-

Quick stats

+

{t('insights.quickStats.title')}

- Days tracked + {t('insights.quickStats.daysTracked')} {dailyTotals.length}
- Apps used + {t('insights.quickStats.appsUsed')} {appTotals.length}
- Daily average + {t('insights.quickStats.dailyAverage')} {formatMinutes(avgDaily)}
- Top category + {t('insights.quickStats.topCategory')} {topCategoryName}
- Peak day + {t('insights.quickStats.peakDay')} {peakDayLabel}
diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index 5cf8a46..8aaa666 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react' import type { UsageSnapshot } from '../services/usageService' import type { NotificationSummary as NotifSummaryType } from '../types/models' +import { useI18n } from '../i18n/I18nProvider' import './Notifications.css' interface NotificationsProps { @@ -9,6 +10,7 @@ interface NotificationsProps { } const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) => { + const { t, translateCategory } = useI18n() const { appNotifications, totalNotifications, avgPerApp, topSender, statusInfo } = useMemo(() => { if (!snapshot || !notificationSummary) { return { @@ -16,7 +18,7 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) => totalNotifications: 0, avgPerApp: 0, topSender: null, - statusInfo: { status: 'loading' as const, message: 'Loading...' } + statusInfo: { status: 'loading' as const, message: t('common.loading') } } } @@ -32,7 +34,7 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) => // For entries without app info, create a placeholder const processedEntries = entries.map(e => ({ - app: e.app ?? { id: e.appId, name: e.appId === 'other' ? 'Other Apps' : e.appId, category: 'Unknown', color: '#6b7280' }, + app: e.app ?? { id: e.appId, name: e.appId === 'other' ? t('notifications.breakdown.otherApps') : e.appId, category: 'Unknown', color: '#6b7280' }, count: e.count, })) @@ -44,12 +46,14 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) => if (notificationSummary.status === 'no-logs') { statusInfo = { status: 'no-logs', - message: 'Windows notification logs are disabled. Enable them in Event Viewer or run as Administrator.', + message: t('notifications.status.disabledMessage'), } } else if (notificationSummary.status === 'error') { statusInfo = { status: 'error', - message: notificationSummary.errorMessage ?? 'Failed to read notification logs.', + message: notificationSummary.errorMessage + ? t('notifications.status.errorWithDetails', { detail: notificationSummary.errorMessage }) + : t('notifications.status.errorMessage'), } } else { statusInfo = { status: 'ok', message: '' } @@ -62,7 +66,7 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) => topSender: top, statusInfo, } - }, [snapshot, notificationSummary]) + }, [snapshot, notificationSummary, t]) const maxCount = appNotifications.length > 0 ? appNotifications[0].count : 1 @@ -70,8 +74,8 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) => <>
-
Notifications
-
Track notification activity from your apps today
+
{t('notifications.title')}
+
{t('notifications.subtitle')}
@@ -80,7 +84,7 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) =>
- {statusInfo.status === 'no-logs' ? 'Notification Logs Disabled' : 'Error Reading Logs'} + {statusInfo.status === 'no-logs' ? t('notifications.status.disabledTitle') : t('notifications.status.errorTitle')} {statusInfo.message}
@@ -91,24 +95,24 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) =>
{totalNotifications} - Total Today + {t('notifications.stats.totalToday')}
{appNotifications.length} - Apps + {t('notifications.stats.apps')}
{avgPerApp} - Avg per App + {t('notifications.stats.avgPerApp')}
- {topSender?.app.name ?? 'None'} + {topSender?.app.name ?? t('common.none')} - Top Sender + {t('notifications.stats.topSender')}
@@ -116,28 +120,28 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) =>
-

Breakdown by App

-

Notifications received today per application

+

{t('notifications.breakdown.title')}

+

{t('notifications.breakdown.subtitle')}

{appNotifications.length > 0 && ( - {appNotifications.length} apps + {t('notifications.breakdown.countBadge', { count: appNotifications.length })} )}
{appNotifications.length === 0 ? (
-
No notifications tracked yet
+
{t('notifications.breakdown.emptyTitle')}
{statusInfo.status === 'ok' - ? 'Notifications from your apps will appear here as they come in.' - : 'Fix the issue above to start tracking notifications.'} + ? t('notifications.breakdown.emptySubtitle') + : t('notifications.breakdown.emptyActionSubtitle')}
) : (
- Application - Count + {t('notifications.breakdown.app')} + {t('notifications.breakdown.count')}
{appNotifications.map(({ app, count }) => { const percentage = Math.round((count / totalNotifications) * 100) @@ -147,7 +151,7 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) =>
{app.name} - {app.category} + {translateCategory(app.category)}
@@ -172,34 +176,31 @@ const Notifications = ({ snapshot, notificationSummary }: NotificationsProps) => {/* Tips Section */}
-

Reduce Distractions

-

Tips for managing notification overload

+

{t('notifications.tips.title')}

+

{t('notifications.tips.subtitle')}

- Enable Focus Assist + {t('notifications.tips.focusAssistTitle')}

- Use Windows Focus Assist to silence notifications during work hours. - Access it from the Action Center or Settings. + {t('notifications.tips.focusAssistDesc')}

- Configure App Notifications + {t('notifications.tips.appNotificationsTitle')}

- Go to Windows Settings > System > Notifications to customize - which apps can send you notifications. + {t('notifications.tips.appNotificationsDesc')}

- Set Time Limits + {t('notifications.tips.timeLimitsTitle')}

- Use the Apps page to set daily time limits for distracting applications. - You will receive a notification when limits are exceeded. + {t('notifications.tips.timeLimitsDesc')}

diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index d91b10e..10fbed2 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { clearUsageData, fetchSettings, updateSettings, fetchTimeLimits } from '../services/usageService' import type { ThemeName, AppSettings, AppTimeLimit } from '../types/models' +import { useI18n, type LocaleCode } from '../i18n/I18nProvider' import './Settings.css' interface SettingsProps { @@ -8,14 +9,8 @@ interface SettingsProps { onThemeChange: (theme: ThemeName) => void } -const themes: { id: ThemeName; name: string; description: string }[] = [ - { id: 'dark', name: 'Dark', description: 'Easy on the eyes, perfect for night' }, - { id: 'light', name: 'Light', description: 'Clean and bright for daytime' }, - { id: 'tokyo', name: 'Tokyo', description: 'Cyberpunk vibes with purple accents' }, - { id: 'skin', name: 'Skin', description: 'Warm and soft aesthetic' }, -] - const Settings = ({ theme, onThemeChange }: SettingsProps) => { + const { locale, setLocale, localeOptions, t, translateThemeName, translateThemeDescription } = useI18n() const [startWithWindows, setStartWithWindows] = useState(false) const [minimizeToTray, setMinimizeToTray] = useState(true) const [timeLimitNotificationsEnabled, setTimeLimitNotificationsEnabled] = useState(true) @@ -34,6 +29,7 @@ const Settings = ({ theme, onThemeChange }: SettingsProps) => { setMinimizeToTray(settings.minimizeToTray) setTimeLimitNotificationsEnabled(settings.timeLimitNotificationsEnabled) setTimeLimits(limits) + setLocale(settings.language) } catch { // Fall back to defaults } finally { @@ -41,7 +37,7 @@ const Settings = ({ theme, onThemeChange }: SettingsProps) => { } } loadSettings() - }, []) + }, [setLocale]) const handleSettingChange = async (key: keyof AppSettings, value: boolean) => { try { @@ -54,13 +50,22 @@ const Settings = ({ theme, onThemeChange }: SettingsProps) => { } } + const handleLanguageChange = async (nextLanguage: LocaleCode) => { + setLocale(nextLanguage) + try { + await updateSettings({ language: nextLanguage }) + } catch { + // Ignore errors and keep local UI language + } + } + if (loading) { return ( <>
-
Settings
-
Loading...
+
{t('settings.title')}
+
{t('settings.loadingSubtitle')}
@@ -71,25 +76,43 @@ const Settings = ({ theme, onThemeChange }: SettingsProps) => { <>
-
Settings
-
Customize your ScreenForge experience
+
{t('settings.title')}
+
{t('settings.subtitle')}
-

Appearance

-

Choose your preferred theme

+

{t('settings.sections.appearance')}

+

{t('settings.sections.appearanceDesc')}

+
+ {(['dark', 'light', 'tokyo', 'skin'] as ThemeName[]).map((themeOption) => ( + + ))} +
+
+ +
+

{t('settings.sections.language')}

+

{t('settings.sections.languageDesc')}

- {themes.map((t) => ( + {localeOptions.map((option) => ( ))} @@ -97,13 +120,13 @@ const Settings = ({ theme, onThemeChange }: SettingsProps) => {
-

Behavior

-

Control how ScreenForge runs

+

{t('settings.sections.behavior')}

+

{t('settings.sections.behaviorDesc')}

-

Time Limits

-

Control app usage notifications

+

{t('settings.sections.timeLimits')}

+

{t('settings.sections.timeLimitsDesc')}

-

Data

-

Manage your tracked data

+

{t('settings.sections.data')}

+

{t('settings.sections.dataDesc')}

-
Clear all data
-
Remove all tracked usage history
+
{t('settings.data.clearAll')}
+
{t('settings.data.clearAllDesc')}
-

About

+

{t('settings.sections.about')}

- Version + {t('settings.about.version')} 1.0.0
- Platform - Windows + {t('settings.about.platform')} + {t('settings.about.windows')}
- Built with - Electron + React + {t('settings.about.builtWith')} + {t('settings.about.techStack')}
diff --git a/src/services/usageService.ts b/src/services/usageService.ts index 283586e..acd1408 100644 --- a/src/services/usageService.ts +++ b/src/services/usageService.ts @@ -66,6 +66,7 @@ export const fetchSettings = async (): Promise => { startWithWindows: false, timeLimits: [], timeLimitNotificationsEnabled: true, + language: 'zh-CN', } } @@ -79,6 +80,7 @@ export const updateSettings = async (settings: Partial): Promise { @@ -20,36 +21,15 @@ export const getDateString = (daysOffset: number = 0): string => { } // Format a date string for display -export const formatDateLabel = (dateString: string): string => { - const today = getTodayDateString() - const yesterday = getDateString(-1) - - if (dateString === today) return 'Today' - if (dateString === yesterday) return 'Yesterday' - - const date = new Date(dateString + 'T00:00:00') - return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) -} +export const formatDateLabel = (dateString: string, locale: LocaleCode = defaultLocale): string => + formatRelativeDateLabel(locale, dateString) -export const formatMinutes = (minutes: number) => { - if (minutes === 0) return '0m' - if (minutes < 1) return '<1m' - const hours = Math.floor(minutes / 60) - const mins = Math.round(minutes % 60) - if (hours === 0) return `${mins}m` - return `${hours}h ${mins}m` -} +export const formatMinutes = (minutes: number, locale: LocaleCode = defaultLocale) => + formatDurationMinutes(locale, minutes) // Format seconds with more granularity for real-time display -export const formatSeconds = (totalSeconds: number) => { - if (totalSeconds < 60) return `${Math.floor(totalSeconds)}s` - const minutes = Math.floor(totalSeconds / 60) - const seconds = Math.floor(totalSeconds % 60) - if (minutes < 60) return `${minutes}m ${seconds}s` - const hours = Math.floor(minutes / 60) - const mins = minutes % 60 - return `${hours}h ${mins}m` -} +export const formatSeconds = (totalSeconds: number, locale: LocaleCode = defaultLocale) => + formatDurationSeconds(locale, totalSeconds) export const getTotalMinutes = (entries: UsageEntry[]) => entries.reduce((sum, entry) => sum + entry.minutes, 0)