feat(client): move widgets into its dedicated utility widgets module

This commit is contained in:
melvinchia3636
2026-06-08 17:04:39 +08:00
parent e95416946b
commit a06974f282
10 changed files with 46 additions and 457 deletions

View File

@@ -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<string, WidgetEntry> = {}
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) {

View File

@@ -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
}

View File

@@ -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 (
<Card
align={h < 2 ? 'center' : undefined}
direction={h < 2 ? 'row' : 'column'}
gap="md"
height="100%"
justify={h < 2 ? { base: 'center', sm: 'between' } : undefined}
>
<Flex
direction="column"
display={h === 1 ? { base: 'none', sm: 'flex' } : 'flex'}
>
<Text weight="medium">
{Intl.DateTimeFormat()
.resolvedOptions()
.timeZone.split('/')[1]
.replace('_', ' ')}
</Text>
<Text color="muted">
UTC {dayjs().utcOffset() > 0 ? '+' : ''}
{dayjs().utcOffset() / 60}
</Text>
</Flex>
<Text
asChild
style={{
margin: h < 2 ? '0' : 'auto'
}}
tracking="wider"
weight="semibold"
>
<Flex align="end">
<Text size={h < 2 ? '4xl' : { base: '4xl', sm: '6xl' }}>{time}</Text>
<Text
color="muted"
ml="xs"
size={h < 2 ? '2xl' : { base: '2xl', sm: '4xl' }}
>
{second}
</Text>
</Flex>
</Text>
</Card>
)
}
export default Clock
export const config: WidgetConfig = {
id: 'clock',
icon: 'tabler:clock',
minW: 2,
minH: 1,
maxH: 2
}

View File

@@ -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<HTMLDivElement | null>(null)
const { width } = useDivSize(containerRef)
const { language, derivedThemeColor: themeColor } = usePersonalization()
return (
<Card
ref={containerRef}
asChild
align={
h === 2 ? 'start' : h === 1 ? { base: 'center', sm: 'end' } : 'start'
}
bg="primary"
direction={h === 2 ? 'column' : h === 1 ? 'row' : 'column'}
gap="md"
height="100%"
justify={h === 2 ? 'end' : undefined}
>
<Text
as="div"
color={tinycolor(themeColor).isLight() ? 'bg-800' : 'bg-100'}
>
<Text
asChild
color="primary"
size={w === 2 && h === 1 ? { base: '2xl', sm: '4xl' } : '4xl'}
weight="semibold"
>
<Flex
centered
aspectRatio={h === 1 ? '1/1' : { base: '1/1', sm: 'auto' }}
bg={{ base: 'bg-100', dark: 'bg-900' }}
height={
w === 2 && h === 1
? { base: 'auto', sm: '100%' }
: h === 1
? '100%'
: { base: '100%', sm: 'auto' }
}
p={w === 2 && h === 1 ? { base: 'md', sm: 'md' } : 'md'}
r="md"
>
{dayjs().format('DD')}
</Flex>
</Text>
<Flex direction="column" gap="xs" minWidth="0" width="100%">
<Text
size={w === 2 && h === 1 ? { base: 'lg', sm: '2xl' } : '2xl'}
weight="semibold"
>
{dayjs()
.locale(language)
.format(width < 150 ? 'ddd' : 'dddd')}
</Text>
<Text>
{dayjs()
.locale(language)
.format(
width > 180
? language.startsWith('zh')
? 'YYYY[年] MMMM'
: 'MMMM YYYY'
: 'MM/YYYY'
)}
<Text
display={
w === 2 && h === 1 ? { base: 'none', sm: 'inline' } : 'inline'
}
>
,{' '}
{(() => {
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()}`
}
})()}
</Text>
</Text>
</Flex>
</Text>
</Card>
)
}
export const config: WidgetConfig = {
id: 'date',
icon: 'tabler:calendar-clock',
minW: 2,
minH: 1,
maxW: 4,
maxH: 2
}

View File

@@ -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 (
<Card
centered
bg="primary"
gap="sm"
height="100%"
position="relative"
style={{
isolation: 'isolate'
}}
>
<Box asChild position="absolute" right="1em" top="1em" zIndex="-1">
<Icon
color={colorWithOpacity('bg-800', '10%')}
icon="tabler:quote"
size="6em"
/>
</Box>
<Box
asChild
bottom="1em"
left="1em"
position="absolute"
style={{
transform: 'rotate(180deg)'
}}
zIndex="-1"
>
<Icon
color={colorWithOpacity('bg-800', '10%')}
icon="tabler:quote"
size="6em"
/>
</Box>
<WithQuery query={quoteQuery}>
{quote => (
<Text
align="center"
as="p"
color={tinycolor(themeColor).isLight() ? 'bg-800' : 'bg-100'}
size={{ base: 'lg', sm: 'xl' }}
>
{quote?.length ? (
<>
{quote[0].q}
<br />
<Text
as="div"
color={colorWithOpacity('bg-800', '50%')}
mt="md"
size="base"
weight="medium"
>
- {quote[0].a}
</Text>
</>
) : (
<Text align="center">No quote for today :(</Text>
)}
</Text>
)}
</WithQuery>
</Card>
)
}
export const config: WidgetConfig = {
id: 'quotes',
icon: 'tabler:quote',
minH: 2,
minW: 2
}

View File

@@ -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"
},

View File

@@ -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 && (
<Text
style={{
// Deliberately defined explicitly to prevent styling issue
marginTop: '1em'
}}
>
{message}
</Text>
)}
</Flex>
</Text>
)

View File

@@ -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)
})
})

View File

@@ -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'

View File

@@ -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<typeof widgetConfigSchema>
export type { WidgetConfig as default }