mirror of
https://github.com/Lifeforge-app/lifeforge.git
synced 2026-06-27 22:36:06 +00:00
feat(client): move widgets into its dedicated utility widgets module
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user