diff --git a/client/src/core/dashboard/providers/WidgetProvider.tsx b/client/src/core/dashboard/providers/WidgetProvider.tsx index bde732599..5e2e13d81 100644 --- a/client/src/core/dashboard/providers/WidgetProvider.tsx +++ b/client/src/core/dashboard/providers/WidgetProvider.tsx @@ -7,13 +7,9 @@ import { useState } from 'react' -import { type WidgetConfig, useFederation } from '@lifeforge/shared' +import { useFederation, widgetConfigSchema } from '@lifeforge/shared' import { LoadingScreen } from '@lifeforge/ui' -import ClockWidget, { config as clockConfig } from '../widgets/Clock' -import DateWidget, { config as dateConfig } from '../widgets/Date' -import QuotesWidget, { config as quotesConfig } from '../widgets/Quotes' - export interface WidgetEntry { component: React.FC<{ dimension: { w: number; h: number } }> namespace: string | null @@ -41,15 +37,6 @@ export function useWidgets(): WidgetContextValue { return useContext(WidgetContext) } -const CORE_WIDGETS: Array<{ - component: React.FC<{ dimension: { w: number; h: number } }> - config: WidgetConfig -}> = [ - { component: ClockWidget, config: clockConfig }, - { component: DateWidget, config: dateConfig }, - { component: QuotesWidget, config: quotesConfig } -] - function WidgetProvider({ children }: { children: React.ReactNode }) { const { modules } = useFederation() @@ -59,25 +46,6 @@ function WidgetProvider({ children }: { children: React.ReactNode }) { const [loading, setLoading] = useState(true) - // Core widgets (static, always available) - const coreWidgets = useMemo(() => { - const result: Record = {} - - for (const { component, config } of CORE_WIDGETS) { - result[config.id] = { - component, - namespace: config.namespace || null, - icon: config.icon, - minW: config.minW, - minH: config.minH, - maxW: config.maxW, - maxH: config.maxH - } - } - - return result - }, []) - // Load federated widgets asynchronously useEffect(() => { async function loadFederatedWidgets() { @@ -93,9 +61,13 @@ function WidgetProvider({ children }: { children: React.ReactNode }) { // Call the import function to get the module const widgetModule = await widgetImportFn() - const config = widgetModule.config + const parsedConfig = widgetConfigSchema.safeParse( + widgetModule.config + ) + + if (parsedConfig.success) { + const config = parsedConfig.data - if (config?.id) { // Wrap the component with lazy() for proper React Suspense support const LazyComponent = lazy(widgetImportFn) @@ -108,6 +80,11 @@ function WidgetProvider({ children }: { children: React.ReactNode }) { maxW: config.maxW, maxH: config.maxH } + } else { + console.warn( + `Failed to validate widget config for module ${category.title}/${item.name}:`, + parsedConfig.error.format() + ) } } catch (e) { console.warn('Failed to load widget:', e) @@ -130,10 +107,10 @@ function WidgetProvider({ children }: { children: React.ReactNode }) { const value = useMemo( () => ({ - widgets: { ...coreWidgets, ...federatedWidgets }, + widgets: federatedWidgets, loading }), - [coreWidgets, federatedWidgets, loading] + [federatedWidgets, loading] ) if (loading) { diff --git a/client/src/core/dashboard/utils/arabicToChineseNumber.ts b/client/src/core/dashboard/utils/arabicToChineseNumber.ts deleted file mode 100644 index 44fcecec6..000000000 --- a/client/src/core/dashboard/utils/arabicToChineseNumber.ts +++ /dev/null @@ -1,95 +0,0 @@ -export function arabicToChinese( - number: string, - format: 'simplified' | 'traditional' = 'traditional' -): string { - const digits = getDigitMapping(format) - - const units = getUnitMapping(format) - - const specialTwenty = '廿' - - let chineseNumber = convertNumberToChinese( - number, - digits, - units, - format, - specialTwenty - ) - - chineseNumber = removeLeadingOne(chineseNumber, digits[1], units[1]) - chineseNumber = removeTrailingZero(chineseNumber) - - return chineseNumber -} - -function getDigitMapping(format: 'simplified' | 'traditional'): string[] { - return format === 'simplified' - ? ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'] - : ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'] -} - -function getUnitMapping(format: 'simplified' | 'traditional'): string[] { - return format === 'simplified' - ? ['', '十', '百', '千', '万', '十', '百', '千', '亿'] - : ['', '拾', '佰', '仟', '萬', '拾', '佰', '仟', '億'] -} - -function convertNumberToChinese( - numStr: string, - digits: string[], - units: string[], - format: 'simplified' | 'traditional', - specialTwenty: string -): string { - const result: string[] = [] - - const length = numStr.length - - for (let i = 0; i < length; i++) { - const digit = parseInt(numStr.charAt(i)) - - const chineseDigit = digits[digit] - - const unitIndex = length - i - 1 - - if ( - format === 'simplified' && - unitIndex === 1 && - digit === 2 && - length === 2 - ) { - result.push(specialTwenty) - } else if ( - chineseDigit !== '零' || - (unitIndex % 4 === 0 && chineseDigit === '零') - ) { - result.push(chineseDigit) - if (unitIndex > 0) result.push(units[unitIndex]) - } else if ( - chineseDigit === '零' && - (result.length === 0 || result[result.length - 1] !== '零') - ) { - result.push('零') - } - } - - return result.join('') -} - -function removeLeadingOne( - chineseNumber: string, - one: string, - ten: string -): string { - if (chineseNumber.startsWith(one + ten)) { - return chineseNumber.substring(1) - } - - return chineseNumber -} - -function removeTrailingZero(chineseNumber: string): string { - return chineseNumber.endsWith('零') - ? chineseNumber.slice(0, -1) - : chineseNumber -} diff --git a/client/src/core/dashboard/widgets/Clock.tsx b/client/src/core/dashboard/widgets/Clock.tsx deleted file mode 100644 index 27b1f7974..000000000 --- a/client/src/core/dashboard/widgets/Clock.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import dayjs from 'dayjs' -import { useEffect, useState } from 'react' - -import { type WidgetConfig } from '@lifeforge/shared' -import { Card, Flex, Text } from '@lifeforge/ui' - -function Clock({ dimension: { h } }: { dimension: { w: number; h: number } }) { - const [time, setTime] = useState(dayjs().format('HH:mm')) - - const [second, setSecond] = useState(dayjs().format('ss')) - - useEffect(() => { - const interval = setInterval(() => { - setTime(dayjs().format('HH:mm')) - setSecond(dayjs().format('ss')) - }, 1000) - - return () => clearInterval(interval) - }, []) - - return ( - - - - {Intl.DateTimeFormat() - .resolvedOptions() - .timeZone.split('/')[1] - .replace('_', ' ')} - - - UTC {dayjs().utcOffset() > 0 ? '+' : ''} - {dayjs().utcOffset() / 60} - - - - - {time} - - {second} - - - - - ) -} - -export default Clock - -export const config: WidgetConfig = { - id: 'clock', - icon: 'tabler:clock', - minW: 2, - minH: 1, - maxH: 2 -} diff --git a/client/src/core/dashboard/widgets/Date.tsx b/client/src/core/dashboard/widgets/Date.tsx deleted file mode 100644 index e08da98bd..000000000 --- a/client/src/core/dashboard/widgets/Date.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import dayjs from 'dayjs' -import weekOfYear from 'dayjs/plugin/weekOfYear' -import { useRef } from 'react' -import tinycolor from 'tinycolor2' - -import { useDivSize, usePersonalization } from '@lifeforge/shared' -import type { WidgetConfig } from '@lifeforge/shared' -import { Card, Flex, Text } from '@lifeforge/ui' - -import { arabicToChinese } from '../utils/arabicToChineseNumber' - -dayjs.extend(weekOfYear) - -export default function DateWidget({ - dimension: { w, h } -}: { - dimension: { w: number; h: number } -}) { - const containerRef = useRef(null) - - const { width } = useDivSize(containerRef) - - const { language, derivedThemeColor: themeColor } = usePersonalization() - - return ( - - - - - {dayjs().format('DD')} - - - - - {dayjs() - .locale(language) - .format(width < 150 ? 'ddd' : 'dddd')} - - - {dayjs() - .locale(language) - .format( - width > 180 - ? language.startsWith('zh') - ? 'YYYY[年] MMMM' - : 'MMMM YYYY' - : 'MM/YYYY' - )} - - ,{' '} - {(() => { - switch (language) { - case 'zh-CN': - case 'zh-TW': - return `第${arabicToChinese( - `${dayjs().week()}`, - language.endsWith('-CN') ? 'simplified' : 'traditional' - )}${language.endsWith('-CN') ? '周' : '週'}` - case 'ms': - return dayjs().week() < 4 - ? `Minggu ${ - ['pertama', 'kedua', 'ketiga'][dayjs().week() - 1] - }` - : `Minggu ke-${dayjs().week()}` - default: - return `Week ${dayjs().week()}` - } - })()} - - - - - - ) -} - -export const config: WidgetConfig = { - id: 'date', - icon: 'tabler:calendar-clock', - minW: 2, - minH: 1, - maxW: 4, - maxH: 2 -} diff --git a/client/src/core/dashboard/widgets/Quotes.tsx b/client/src/core/dashboard/widgets/Quotes.tsx deleted file mode 100644 index 78cbb6972..000000000 --- a/client/src/core/dashboard/widgets/Quotes.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import tinycolor from 'tinycolor2' - -import { usePersonalization } from '@lifeforge/shared' -import type { WidgetConfig } from '@lifeforge/shared' -import { - Box, - Card, - Icon, - Text, - WithQuery, - colorWithOpacity -} from '@lifeforge/ui' - -import forgeAPI from '@/forgeAPI' - -export default function Quotes() { - const quoteQuery = useQuery<{ q: string; a: string }[]>( - forgeAPI - .corsAnywhere({ - url: 'https://zenquotes.io/api/random' - }) - .queryOptions() as never - ) - - const { derivedThemeColor: themeColor } = usePersonalization() - - return ( - - - - - - - - - {quote => ( - - {quote?.length ? ( - <> - {quote[0].q} -
- - - {quote[0].a} - - - ) : ( - No quote for today :( - )} -
- )} -
-
- ) -} - -export const config: WidgetConfig = { - id: 'quotes', - icon: 'tabler:quote', - minH: 2, - minW: 2 -} diff --git a/locales/lifeforge--lang-en/dashboard.json b/locales/lifeforge--lang-en/dashboard.json index 63a0b1370..907a06c59 100644 --- a/locales/lifeforge--lang-en/dashboard.json +++ b/locales/lifeforge--lang-en/dashboard.json @@ -1,20 +1,7 @@ { "title": "Dashboard", "description": "Your mission control center, making everything more exciting.", - "widgets": { - "date": { - "title": "Date", - "description": "A simple decorative widget showing today's date." - }, - "clock": { - "title": "Clock", - "description": "A small clock to help you keep track of time." - }, - "quotes": { - "description": "A random quote to inspire you through the day.", - "title": "Quotes" - } - }, + "widgets": {}, "modals": { "manageWidgets": "Manage Widgets" }, diff --git a/packages/ui/src/components/feedback/LoadingScreen/index.tsx b/packages/ui/src/components/feedback/LoadingScreen/index.tsx index 665991167..924f8038d 100644 --- a/packages/ui/src/components/feedback/LoadingScreen/index.tsx +++ b/packages/ui/src/components/feedback/LoadingScreen/index.tsx @@ -22,11 +22,20 @@ export function LoadingScreen({ message, loaderSize }: LoadingScreenProps) { color="muted" icon="svg-spinners:ring-resize" style={{ - fontSize: loaderSize || '2rem', - marginBottom: '1em' + // Deliberately defined explicitly to prevent styling issue + fontSize: loaderSize || '2rem' }} /> - {message} + {message && ( + + {message} + + )} ) diff --git a/server/src/lib/modules/routes/modules.ts b/server/src/lib/modules/routes/modules.ts index 08b638012..38fab9d47 100644 --- a/server/src/lib/modules/routes/modules.ts +++ b/server/src/lib/modules/routes/modules.ts @@ -1,4 +1,5 @@ import { ROOT_DIR } from '@constants' +import { checkModulesAvailability } from '@functions/utils/checkModulesAvailability' import { execSync } from 'child_process' import fs from 'fs' import path from 'path' @@ -8,7 +9,6 @@ import forge from '../forge' import scanFederatedModules, { type ModuleManifestEntry } from '../utils/scanFederatedModules' -import { checkModulesAvailability } from '@functions/utils/checkModulesAvailability' const APPS_DIR = path.join(ROOT_DIR, 'apps') @@ -56,6 +56,7 @@ export const manifest = forge scanFederatedModules(internalAppsDir, modules, true, '/internal-modules') + return response.ok({ modules }) }) @@ -212,4 +213,4 @@ export const checkModuleAvailability = forge const available = await checkModulesAvailability(moduleId) return response.ok(available) - }) \ No newline at end of file + }) diff --git a/shared/src/index.ts b/shared/src/index.ts index c5a3065af..afb7bde2d 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -79,4 +79,5 @@ export type { ModuleCategory } from './interfaces/module_config.types' export type { default as WidgetConfig } from './interfaces/widget_config.types' +export { widgetConfigSchema } from './interfaces/widget_config.types' export { SYSTEM_CATEGORIES } from './providers/FederationProvider' diff --git a/shared/src/interfaces/widget_config.types.ts b/shared/src/interfaces/widget_config.types.ts index fb4a9e9b9..5823d54cd 100644 --- a/shared/src/interfaces/widget_config.types.ts +++ b/shared/src/interfaces/widget_config.types.ts @@ -1,9 +1,15 @@ -export default interface WidgetConfig { - namespace?: string - id: string - icon: string - minW?: number - minH?: number - maxW?: number - maxH?: number -} +import z from 'zod' + +export const widgetConfigSchema = z.object({ + namespace: z.string().optional(), + id: z.string(), + icon: z.string(), + minW: z.number().int().positive().optional(), + minH: z.number().int().positive().optional(), + maxW: z.number().int().positive().optional(), + maxH: z.number().int().positive().optional() +}) + +type WidgetConfig = z.infer + +export type { WidgetConfig as default }