feat: client codebase refactoring completed

This commit is contained in:
melvinchia3636
2026-05-31 08:34:01 +08:00
parent 60042df248
commit 8da48370bc
51 changed files with 1529 additions and 861 deletions

View File

@@ -59,8 +59,14 @@ export const config: WidgetConfig = {
}
```
> [!IMPORTANT]
> **Import paths for module widgets require special handling.** Module widgets live under `apps/<module>/client/src/` and use different import paths than core:
> - `@lifeforge/ui` → `lifeforge-ui` (no `@` prefix)
> - `@lifeforge/shared` → `shared`
> - `@/forgeAPI` → `@/utils/forgeAPI` (or `@/forgeAPI` depending on module setup)
> [!TIP]
> For small/compact widgets where space is limited, the `title` prop can be omitted from the `Widget` wrapper. The widget will still display properly without a header, giving more room for content. See `apps/momentVault/client/src/widgets/QuickAudioCapture.tsx` for an example.
> For small/compact widgets where space is limited, the `title` prop can be omitted from the `Widget` wrapper. The widget will still display properly without a header, giving more room for content.
### Widget with Dimension Constraints
@@ -80,9 +86,19 @@ export const config: WidgetConfig = {
```tsx
import { useQuery } from '@tanstack/react-query'
import { Button, EmptyStateScreen, Widget, WithQuery } from '@lifeforge/ui'
import { Link } from '@lifeforge/shared'
import type { WidgetConfig } from '@lifeforge/shared'
import {
Button,
Card,
EmptyStateScreen,
Flex,
Icon,
Text,
Widget,
WithQuery,
surface
} from 'lifeforge-ui'
import { Link } from 'shared'
import type { WidgetConfig } from 'shared'
import forgeAPI from '@/utils/forgeAPI'
@@ -93,8 +109,6 @@ export default function MyDataWidget() {
<Widget
actionComponent={
<Button
as={Link}
className="p-2!"
icon="tabler:chevron-right"
to="/<module-route>"
variant="plain"
@@ -107,11 +121,34 @@ export default function MyDataWidget() {
<WithQuery query={dataQuery}>
{data => (
data.length > 0 ? (
<ul>
<Flex direction="column" gap="sm">
{data.map(item => (
<li key={item.id}>{item.name}</li>
<Card
key={item.id}
isInteractive
as={Link}
to="/<module-route>"
>
<Flex align="center" gap="md">
<Flex
align="center"
bg={surface.light}
flexShrink="0"
height="2.5rem"
justify="center"
r="md"
width="2.5rem"
>
<Icon color="muted" icon={item.icon} size="1.25em" />
</Flex>
<Box>
<Text weight="medium">{item.name}</Text>
<Text color="muted" size="sm">{item.description}</Text>
</Box>
</Flex>
</Card>
))}
</ul>
</Flex>
) : (
<EmptyStateScreen
smaller
@@ -138,25 +175,67 @@ export const config: WidgetConfig = {
### Widget with Dimension-Responsive Layout
Widgets can receive dimension props to adapt their layout:
Widgets receive `dimension` props to adapt their layout. Use `Card` as the root element for responsive widgets that don't need the `Widget` header wrapper:
```tsx
function ResponsiveWidget({
import { Card, Flex, Text } from '@lifeforge/ui'
import type { WidgetConfig } from '@lifeforge/shared'
export default function ResponsiveWidget({
dimension: { w, h }
}: {
dimension: { w: number; h: number }
}) {
return (
<div
className={clsx(
'shadow-custom component-bg flex size-full rounded-lg p-4',
h < 2 ? 'flex-row' : 'flex-col' // Adjust based on height
)}
<Card
align={h < 2 ? 'center' : undefined}
direction={h < 2 ? 'row' : 'column'}
gap="md"
height="100%"
justify={h < 2 ? 'between' : undefined}
>
{/* Content that adapts to dimensions */}
</div>
<Text weight="medium">Widget Content</Text>
<Text color="muted" size="sm">
Adapts to {w}x{h}
</Text>
</Card>
)
}
export const config: WidgetConfig = {
id: '<widgetId>',
icon: 'tabler:icon-name',
minW: 2,
minH: 1
}
```
> [!NOTE]
> When using `Card` directly as a widget root (no `Widget` wrapper), `Card` already provides the correct default background (`surface.default`), shadow, padding, and rounded corners — no inline styles or Tailwind classes needed.
### Legacy Pattern (Do NOT Use)
```tsx
// ❌ OLD — uses removed component-bg classes, clsx, inline styles, and @iconify/react
import { Icon } from '@iconify/react'
import clsx from 'clsx'
<div
className={clsx(
'shadow-custom component-bg flex size-full rounded-lg p-4',
h < 2 ? 'flex-row' : 'flex-col'
)}
>
```
```tsx
// ✅ NEW — use Card with direction/align/justify props
<Card
align={h < 2 ? 'center' : undefined}
direction={h < 2 ? 'row' : 'column'}
gap="md"
height="100%"
>
```
---
@@ -241,31 +320,42 @@ interface WidgetConfig {
## Widget Component Props Reference
The `Widget` component from `lifeforge-ui` accepts:
The `Widget` component from `@lifeforge/ui` accepts:
| Prop | Type | Description |
| ----------------- | ----------------- | ---------------------------------------------------- |
| `icon` | `string` | Iconify icon name |
| `title` | `string` | Widget title (auto-translated if namespace provided) |
| `iconColor` | `TokenizedColor` | Optional custom icon color |
| `title` | `ReactNode` | Widget title (auto-translated if namespace provided) |
| `description` | `ReactNode` | Widget description |
| `namespace` | `string \| false` | Translation namespace, `false` to disable |
| `className` | `string` | Additional CSS classes |
| `actionComponent` | `ReactNode` | Component beside title (e.g., navigation button) |
| `variant` | `'default' \| 'large-icon'` | Visual variant |
| `children` | `ReactNode` | Widget content |
---
## Common UI Components for Widgets
Import from `lifeforge-ui`:
Import from `lifeforge-ui` (modules) or `@lifeforge/ui` (core):
| Component | Usage |
| ------------------ | -------------------------------------------- |
| `Widget` | Container wrapper with title and icon |
| `Card` | Card wrapper for dimension-responsive widgets|
| `Flex` | Flexbox layout container |
| `Box` | Generic layout primitive |
| `Text` | Typography (with spacing and style props) |
| `Icon` | Icon display (uses `size` prop, not `width`/`height`) |
| `WithQuery` | Handles loading/error states for API queries |
| `EmptyStateScreen` | Shows when no data exists |
| `Scrollbar` | Scrollable content wrapper |
| `Button` | Action buttons |
| `LoadingScreen` | Loading indicator |
| `surface` | Pre-built bg presets (`surface.light`, `surface.lightInteractive`, `surface.default`, `surface.defaultInteractive`) |
> [!WARNING]
> Never import `Icon` from `@iconify/react` directly. Always use the `Icon` primitive from the UI library. Use the `size` prop (not `width`/`height`) for sizing, and the `color` prop (not `className="text-..."`) for colors.
---
@@ -275,14 +365,14 @@ Examine these existing widgets for patterns:
### Simple Display Widgets (No API)
- `client/src/apps/dashboard/widgets/Clock.tsx` - Time display with dimensions
- `client/src/apps/dashboard/widgets/Date.tsx` - Date display with theming
- `client/src/apps/dashboard/widgets/Quotes.tsx` - External API fetch
- `client/src/core/dashboard/widgets/Clock.tsx` - Time display with dimensions
- `client/src/core/dashboard/widgets/Date.tsx` - Date display with theming
- `client/src/core/dashboard/widgets/Quotes.tsx` - External API fetch
### Data-Driven Widgets
- `apps/calendar/client/src/widgets/TodaysEvent.tsx` - List with items
- `apps/wallet/client/src/widgets/AssetsBalance.tsx` - Grid layout with toggle
- `apps/wallet/client/src/widgets/AssetsBalance.tsx` - Grid layout with toggle (not yet migrated — see as legacy reference)
- `apps/todoList/client/src/widgets/TodoList.tsx` - List with context provider
- `apps/codeTime/client/src/widgets/CodeTime.tsx` - Chart visualization
@@ -298,7 +388,12 @@ Examine these existing widgets for patterns:
- [ ] Create `apps/<module>/client/src/widgets/<WidgetName>.tsx`
- [ ] Export default React component
- [ ] Export named `config: WidgetConfig`
- [ ] Use `Widget` wrapper component
- [ ] Use `Widget` wrapper component (or `Card` for dimension-responsive widgets without header)
- [ ] Import `Icon` from UI library, not `@iconify/react`
- [ ] Use `size` prop for icon sizing, `color` prop for icon colors
- [ ] No `className` with Tailwind classes
- [ ] No `component-bg-*` classes — use `surface` presets or `Card` instead
- [ ] Use `@lifeforge/ui` / `@lifeforge/shared` for core, `lifeforge-ui` / `shared` for modules
- [ ] Add `namespace` for translations
- [ ] Set appropriate dimension constraints
- [ ] Add locale entries in all language files

Submodule apps/lifeforge--wallet added at 1a8dc9953d

View File

@@ -2,8 +2,5 @@
"name": "@lifeforge/apps",
"private": true,
"description": "LifeForge modules",
"dependencies": {
"@lifeforge/TedMeadow--lang-ru": "workspace:*",
"@lifeforge/lifeforge--achievements": "workspace:*"
}
"dependencies": {}
}

View File

@@ -14,6 +14,7 @@
"dotenv": "^17.2.3",
"i18next": "^25.7.4",
"lodash": "^4.17.21",
"opentype.js": "^2.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.1",
@@ -27,6 +28,7 @@
"@tailwindcss/vite": "^4.1.18",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/lodash": "^4.17.21",
"@types/opentype.js": "^1.3.10",
"@types/prettier": "^3.0.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@@ -64,6 +66,28 @@
"@lifeforge/ui": "workspace:*",
},
},
"apps/lifeforge--wallet": {
"name": "@lifeforge/lifeforge--wallet",
"version": "0.0.5",
"dependencies": {
"@vis.gl/react-google-maps": "^1.5.5",
"chart.js": "^4.5.0",
"moment-range": "^4.0.2",
"react-chartjs-2": "^5.3.0",
"react-to-print": "^3.1.1",
"react-virtualized": "^9.22.6",
"recharts": "^2.15.0",
"zustand": "^5.0.8",
},
"devDependencies": {
"@types/react-virtualized": "^9.22.3",
},
"peerDependencies": {
"@lifeforge/server-utils": "workspace:*",
"@lifeforge/shared": "workspace:*",
"@lifeforge/ui": "workspace:*",
},
},
"client": {
"name": "@lifeforge/client",
"version": "0.0.0",
@@ -106,8 +130,6 @@
"react-virtualized": "^9.22.6",
"recharts": "^2.15.0",
"socket.io-client": "^4.8.1",
"tailwindcss": "^4.1.14",
"tailwindcss-animate": "^1.0.7",
"tinycolor2": "^1.6.0",
"typescript": "^5.9.3",
"uuid": "^13.0.0",
@@ -332,6 +354,7 @@
"lodash": "^4.17.21",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"node-cache": "^5.1.2",
"openai": "^6.7.0",
"pdf2pic": "^3.2.0",
"pocketbase": "^0.26.2",
@@ -353,6 +376,7 @@
"@types/lodash": "^4.17.21",
"@types/morgan": "^1.9.9",
"@types/multer": "^1.4.12",
"@types/node-cache": "^4.2.5",
"@types/request": "^2.48.12",
"@types/speakeasy": "^2.0.10",
"@types/uuid": "^10.0.0",
@@ -607,6 +631,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@googlemaps/js-api-loader": ["@googlemaps/js-api-loader@2.0.2", "", { "dependencies": { "@types/google.maps": "^3.53.1" } }, "sha512-bKVuTqatS8Jven5aFqVB7rCHF1VFEzpzyi0ruzO0GUR+A7m9oMqMgtnmpANj7kMYEvvhty8Fk7TnJ1MKjWHu+Q=="],
"@headlessui/react": ["@headlessui/react@2.2.10", "", { "dependencies": { "@floating-ui/react": "^0.26.16", "@react-aria/focus": "^3.20.2", "@react-aria/interactions": "^3.25.0", "@tanstack/react-virtual": "^3.13.9", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA=="],
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
@@ -661,6 +687,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@lifeforge/TedMeadow--lang-ru": ["@lifeforge/TedMeadow--lang-ru@workspace:locales/TedMeadow--lang-ru"],
"@lifeforge/client": ["@lifeforge/client@workspace:client"],
@@ -669,6 +697,8 @@
"@lifeforge/lifeforge--lang-en": ["@lifeforge/lifeforge--lang-en@workspace:locales/lifeforge--lang-en"],
"@lifeforge/lifeforge--wallet": ["@lifeforge/lifeforge--wallet@workspace:apps/lifeforge--wallet"],
"@lifeforge/log": ["@lifeforge/log@workspace:packages/lifeforge-log"],
"@lifeforge/server": ["@lifeforge/server@workspace:server"],
@@ -1121,6 +1151,8 @@
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/google.maps": ["@types/google.maps@3.65.0", "", {}, "sha512-u4SHiRC3m27lPa4vDBxh2AI7mDcHcheX6GSHn1Mwi0Gap8/uhM2kFppiFTnWASXLHZO+1ahHciLeEIV+Sjqk/A=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
@@ -1149,9 +1181,11 @@
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
"@types/node-cache": ["@types/node-cache@4.2.5", "", { "dependencies": { "node-cache": "*" } }, "sha512-faK2Owokboz53g8ooq2dw3iDJ6/HMTCIa2RvMte5WMTiABy+wA558K+iuyRtlR67Un5q9gEKysSDtqZYbSa0Pg=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@types/opentype.js": ["@types/opentype.js@1.3.9", "", {}, "sha512-KOGywvDPncA4/tTWV5xKNhjpsoSSAHIx3mHOhL5l3XX+c6Xu2dQnHvGs7mRNQsQRte1EqmQ0cPQQ8Z14lkv+yw=="],
"@types/opentype.js": ["@types/opentype.js@1.3.10", "", {}, "sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ=="],
"@types/prettier": ["@types/prettier@3.0.0", "", { "dependencies": { "prettier": "*" } }, "sha512-mFMBfMOz8QxhYVbuINtswBp9VL2b4Y0QqYHwqLz3YbgtfAcat2Dl6Y1o4e22S/OVE6Ebl9m7wWiMT2lSbAs1wA=="],
@@ -1309,6 +1343,8 @@
"@vanilla-extract/vite-plugin": ["@vanilla-extract/vite-plugin@5.2.2", "", { "dependencies": { "@vanilla-extract/compiler": "^0.7.0", "@vanilla-extract/integration": "^8.0.9" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-AUyB4fDR2b/Mo0lcXhhlf6RxnDPYwFMyKKopalJ4BwQNKYzZSoTwHJ1PLPO9SKhpz7lzXc0Z18GHQZOewzl3YA=="],
"@vis.gl/react-google-maps": ["@vis.gl/react-google-maps@1.8.3", "", { "dependencies": { "@googlemaps/js-api-loader": "^2.0.2", "@types/google.maps": "^3.54.10", "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "react": ">=16.8.0 || ^19.0 || ^19.0.0-rc", "react-dom": ">=16.8.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-DW7nEuvOJ299DmdBnvGiUARrgS/+sTEO1iJgG9J8YaErZqLoq7S4TJ22f3EjJvR4dti4L4gft43JEK77nnKXDw=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@vitest/browser": ["@vitest/browser@1.6.1", "", { "dependencies": { "@vitest/utils": "1.6.1", "magic-string": "^0.30.5", "sirv": "^2.0.4" }, "peerDependencies": { "playwright": "*", "safaridriver": "*", "vitest": "1.6.1", "webdriverio": "*" }, "optionalPeers": ["playwright", "safaridriver", "webdriverio"] }, "sha512-9ZYW6KQ30hJ+rIfJoGH4wAub/KAb4YrFzX0kVLASvTm7nJWVC5EAv5SlzlXVl3h3DaUq5aqHlZl77nmOPnALUQ=="],
@@ -1563,6 +1599,8 @@
"character-reference-invalid": ["character-reference-invalid@1.1.4", "", {}, "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg=="],
"chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
"check-error": ["check-error@1.0.3", "", { "dependencies": { "get-func-name": "^2.0.2" } }, "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg=="],
"cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
@@ -1693,6 +1731,8 @@
"cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="],
"d": ["d@1.0.2", "", { "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" } }, "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw=="],
"d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
@@ -1913,6 +1953,12 @@
"es-toolkit": ["es-toolkit@1.47.0", "", {}, "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw=="],
"es5-ext": ["es5-ext@0.10.64", "", { "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", "esniff": "^2.0.1", "next-tick": "^1.1.0" } }, "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg=="],
"es6-iterator": ["es6-iterator@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.35", "es6-symbol": "^3.1.1" } }, "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g=="],
"es6-symbol": ["es6-symbol@3.1.4", "", { "dependencies": { "d": "^1.0.2", "ext": "^1.7.0" } }, "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg=="],
"esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="],
"esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="],
@@ -2001,6 +2047,8 @@
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"esniff": ["esniff@2.0.1", "", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", "event-emitter": "^0.3.5", "type": "^2.7.2" } }, "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
@@ -2031,6 +2079,8 @@
"eval": ["eval@0.1.8", "", { "dependencies": { "@types/node": "*", "require-like": ">= 0.1.1" } }, "sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw=="],
"event-emitter": ["event-emitter@0.3.5", "", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="],
"event-stream": ["event-stream@3.3.4", "", { "dependencies": { "duplexer": "~0.1.1", "from": "~0", "map-stream": "~0.1.0", "pause-stream": "0.0.11", "split": "0.3", "stream-combiner": "~0.0.4", "through": "~2.3.1" } }, "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
@@ -2051,6 +2101,8 @@
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"ext": ["ext@1.7.0", "", { "dependencies": { "type": "^2.7.2" } }, "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
@@ -2739,6 +2791,10 @@
"modern-ahocorasick": ["modern-ahocorasick@1.1.0", "", {}, "sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ=="],
"moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="],
"moment-range": ["moment-range@4.0.2", "", { "dependencies": { "es6-symbol": "^3.1.0" }, "peerDependencies": { "moment": ">= 2" } }, "sha512-n8sceWwSTjmz++nFHzeNEUsYtDqjgXgcOBzsHi+BoXQU2FW+eU92LUaK8gqOiSu5PG57Q9sYj1Fz4LRDj4FtKA=="],
"morgan": ["morgan@1.10.1", "", { "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", "on-headers": "~1.1.0" } }, "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
@@ -2773,6 +2829,10 @@
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
"next-tick": ["next-tick@1.1.0", "", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="],
"node-cache": ["node-cache@5.1.2", "", { "dependencies": { "clone": "2.x" } }, "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="],
@@ -2837,7 +2897,7 @@
"opencollective-postinstall": ["opencollective-postinstall@2.0.3", "", { "bin": { "opencollective-postinstall": "index.js" } }, "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q=="],
"opentype.js": ["opentype.js@1.3.5", "", { "bin": { "ot": "bin/ot" } }, "sha512-thKDiELidAApOvXlncrpwDZKJCa9fXLEKM4+FoEWI+qTLDeNb+h7EkN+8a7KQODsB1GZ+Exz9KknkoPrEdXZDw=="],
"opentype.js": ["opentype.js@2.0.0", "", { "bin": { "ot": "bin/ot" } }, "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A=="],
"optimist": ["optimist@0.6.0", "", { "dependencies": { "minimist": "~0.0.1", "wordwrap": "~0.0.2" } }, "sha512-ubrZPyOU0AHpXkmwqfWolap+eHMwQ484AKivkf0ZGyysd6fUJZl7ow9iu5UNV1vCZv46HQ7EM83IC3NGJ820hg=="],
@@ -3015,6 +3075,8 @@
"react-aria": ["react-aria@3.48.0", "", { "dependencies": { "@internationalized/date": "^3.12.1", "@internationalized/number": "^3.6.6", "@internationalized/string": "^3.2.8", "@react-types/shared": "^3.34.0", "@swc/helpers": "^0.5.0", "aria-hidden": "^1.2.3", "clsx": "^2.0.0", "react-stately": "3.46.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w=="],
"react-chartjs-2": ["react-chartjs-2@5.3.1", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A=="],
"react-currency-input-field": ["react-currency-input-field@3.10.0", "", { "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-GRmZogHh1e1LrmgXg/fKHSuRLYUnj/c/AumfvfuDMA0UX1mDR6u2NR0fzDemRdq4tNHNLucJeJ2OKCr3ehqyDA=="],
"react-custom-scrollbars": ["react-custom-scrollbars@4.2.1", "", { "dependencies": { "dom-css": "^2.0.0", "prop-types": "^15.5.10", "raf": "^3.1.0" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.0 || ^16.0.0", "react-dom": "^0.14.0 || ^15.0.0 || ^16.0.0" } }, "sha512-VtJTUvZ7kPh/auZWIbBRceGPkE30XBYe+HktFxuMWBR2eVQQ+Ur6yFJMoaYcNpyGq22uYJ9Wx4UAEcC0K+LNPQ=="],
@@ -3067,6 +3129,8 @@
"react-syntax-highlighter": ["react-syntax-highlighter@15.6.6", "", { "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^3.6.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw=="],
"react-to-print": ["react-to-print@3.3.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ~19" } }, "sha512-7j9GIeNZA9glZlbv9mIbIHDOOx+WYfRMbJzh04NiSKjdaeGkxJuKjJQrtRuNKtt5AvEVVjrLCPokZ9yJX51Fvg=="],
"react-toastify": ["react-toastify@11.1.0", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg=="],
"react-tooltip": ["react-tooltip@5.30.1", "", { "dependencies": { "@floating-ui/dom": "^1.6.1", "classnames": "^2.3.0" }, "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0" } }, "sha512-1lSPLQXuVooePxadUpmcwLgOsF1mIty7UZTJ9XnyfX4drOzStYs4JMXnazcDLguQr41W5OUZddOp9kfvArdpEQ=="],
@@ -3375,8 +3439,6 @@
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tar": ["tar@7.5.15", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="],
@@ -3465,6 +3527,8 @@
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-detect": ["type-detect@4.1.0", "", {}, "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw=="],
@@ -3687,6 +3751,10 @@
"@joshwooding/vite-plugin-react-docgen-typescript/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"@lifeforge/client/@types/opentype.js": ["@types/opentype.js@1.3.9", "", {}, "sha512-KOGywvDPncA4/tTWV5xKNhjpsoSSAHIx3mHOhL5l3XX+c6Xu2dQnHvGs7mRNQsQRte1EqmQ0cPQQ8Z14lkv+yw=="],
"@lifeforge/client/opentype.js": ["opentype.js@1.3.5", "", { "bin": { "ot": "bin/ot" } }, "sha512-thKDiELidAApOvXlncrpwDZKJCa9fXLEKM4+FoEWI+qTLDeNb+h7EkN+8a7KQODsB1GZ+Exz9KknkoPrEdXZDw=="],
"@lifeforge/client/react-i18next": ["react-i18next@15.7.4", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.4.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw=="],
"@lifeforge/log/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],

View File

@@ -29,7 +29,7 @@ export default function DateWidget({
align={
h === 2 ? 'start' : h === 1 ? { base: 'center', sm: 'end' } : 'start'
}
bg="custom-500"
bg="primary"
direction={h === 2 ? 'column' : h === 1 ? 'row' : 'column'}
gap="md"
height="100%"
@@ -41,7 +41,7 @@ export default function DateWidget({
>
<Text
asChild
color="custom-500"
color="primary"
size={w === 2 && h === 1 ? { base: '2xl', sm: '4xl' } : '4xl'}
weight="semibold"
>

View File

@@ -28,7 +28,7 @@ export default function Quotes() {
return (
<Card
centered
bg="custom-500"
bg="primary"
gap="sm"
height="100%"
position="relative"

View File

@@ -4,7 +4,6 @@ import { toast } from 'react-toastify'
import { useFederation, usePersonalization } from '@lifeforge/shared'
import {
Box,
EmptyStateScreen,
Grid,
ModuleHeader,
@@ -97,7 +96,7 @@ function Modules() {
<WithQuery query={modulesQuery}>
{modules =>
modules.length > 0 ? (
<Box mb="2xl">
<Stack gap="2xl" mb="2xl">
{Object.entries(groupedModules).map(([category, mods]) => (
<Stack key={category} as="section">
<Text as="h2" size="2xl" weight="medium">
@@ -124,7 +123,7 @@ function Modules() {
</Grid>
</Stack>
))}
</Box>
</Stack>
) : (
<EmptyStateScreen
icon="tabler:apps-off"

View File

@@ -8,6 +8,7 @@ import {
Button,
ConfirmationModal,
FilePickerModal,
Flex,
OptionsColumn,
useModalStore
} from '@lifeforge/ui'
@@ -91,36 +92,38 @@ function BgImageSelector() {
title={t('bgImageSelector.title')}
>
{bgImage !== '' ? (
<>
<Flex
direction={{ base: 'column', md: 'row' }}
gap="sm"
width={{ base: '100%', md: 'auto' }}
>
<Button
className="w-1/2 md:w-auto"
icon="tabler:adjustments"
variant="plain"
width={{ base: '100%', md: 'auto' }}
onClick={handleAdjustBgImage}
>
adjust
</Button>
<Button
dangerous
className="w-1/2 md:w-auto"
icon="tabler:trash"
variant="plain"
width={{ base: '100%', md: 'auto' }}
onClick={handleDeleteBgImage}
>
remove
</Button>
</>
</Flex>
) : (
<>
<Button
className="w-full md:w-auto"
icon="tabler:photo-hexagon"
variant="secondary"
onClick={handleOpenImageSelector}
>
select
</Button>
</>
<Button
icon="tabler:photo-hexagon"
variant="secondary"
width={{ base: '100%', md: 'auto' }}
onClick={handleOpenImageSelector}
>
select
</Button>
)}
</OptionsColumn>
</>

View File

@@ -1,7 +1,7 @@
import _ from 'lodash'
import { useTranslation } from 'react-i18next'
import { OptionsColumn, SliderInput } from '@lifeforge/ui'
import { OptionsColumn, SliderInput, surface } from '@lifeforge/ui'
function AdjustmentColumn({
icon,
@@ -20,18 +20,17 @@ function AdjustmentColumn({
return (
<OptionsColumn
breakpoint="md"
// className="dark:bg-bg-800/30 min-w-0"
bg={surface.light}
description={t(
`bgImageSelector.modals.adjustBackground.columns.${_.camelCase(title)}.desc`
)}
icon={icon}
orientation="vertical"
title={t(
`bgImageSelector.modals.adjustBackground.columns.${_.camelCase(title)}.title`
)}
>
<SliderInput
className="min-w-0"
max={max}
min={0}
step={1}

View File

@@ -1,7 +1,5 @@
import { Icon } from '@iconify/react'
import clsx from 'clsx'
import { usePersonalization } from '@lifeforge/shared'
import { Box, Flex, Icon, Stack, Text, colorWithOpacity, surface } from '@lifeforge/ui'
import { BG_BLURS } from '../constants/bg_blurs'
@@ -21,57 +19,93 @@ function ResultShowcase({
const { bgImage } = usePersonalization()
return (
<div
className="shadow-custom relative isolate max-h-84 w-full shrink-0 overflow-y-auto rounded-md bg-cover bg-center bg-no-repeat md:overflow-hidden"
<Box
shadow
height="21rem"
maxHeight="21rem"
overflow="hidden"
position="relative"
r="md"
style={{
backgroundImage: `url(${bgImage})`
backgroundImage: `url(${bgImage})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
isolation: 'isolate'
}}
width="100%"
>
<div
className="flex size-full flex-col p-12"
<Box
height="100%"
p="xl"
style={{
backdropFilter: `brightness(${bgBrightness}%) blur(${BG_BLURS[bgBlur]}) contrast(${bgContrast}%) saturate(${bgSaturation}%)`
}}
width="100%"
>
<div
className="bg-bg-50 shadow-custom dark:bg-bg-950 absolute top-0 left-0 z-[-1] size-full"
style={{
opacity: `${overlayOpacity}%`
}}
/>
<div
className={clsx(
'shadow-custom component-bg flex size-full flex-col gap-3 rounded-lg p-4'
)}
<Stack
shadow
bg={surface.default}
height="100%"
p="md"
r="lg"
width="100%"
>
<h1 className="flex items-center gap-2 text-2xl font-semibold">
<Icon className="size-8 shrink-0" icon="tabler:box" />
Lorem ipsum dolor sit amet
</h1>
<p className="text-bg-500">
<Box
shadow
bg={{ base: 'bg-50', dark: 'bg-950' }}
height="100%"
position="absolute"
style={{
inset: 0,
opacity: `${overlayOpacity}%`,
zIndex: '-1',
borderRadius: 'inherit'
}}
width="100%"
/>
<Flex align="center" gap="sm" position="relative" zIndex="2">
<Icon icon="tabler:box" size="1.5em" />
<Text as="h1" size="xl" weight="semibold">
Lorem ipsum dolor sit amet
</Text>
</Flex>
<Text as="p" color="muted">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam ac.
</p>
<div className="flex gap-3">
<div
className={clsx(
'component-bg-lighter flex w-full flex-col items-start gap-3 rounded-lg p-4 sm:flex-row sm:items-center'
)}
</Text>
<Flex
align="center"
bg={surface.light}
direction={{ base: 'column', sm: 'row' }}
gap="md"
mt="md"
p="md"
position="relative"
r="lg"
>
<Flex
align="center"
bg={colorWithOpacity('custom-500', '20%')}
flexShrink="0"
justify="center"
p="md"
r="md"
>
<span className="bg-custom-500/20 text-custom-500 block rounded-md p-4">
<Icon className="size-8" icon="tabler:box" />
</span>
<div className="flex flex-col">
<h2 className="text-lg font-semibold">Lorem ipsum dolor</h2>
<p className="text-bg-500">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Nullam ac.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<Icon color="custom-500" icon="tabler:box" size="2em" />
</Flex>
<Box>
<Text as="h2" size="lg" weight="semibold">
Lorem ipsum dolor
</Text>
<Text as="p" color="muted">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam
ac.
</Text>
</Box>
</Flex>
</Stack>
</Box>
</Box>
)
}

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { usePersonalization } from '@lifeforge/shared'
import { Button, ModalHeader } from '@lifeforge/ui'
import { Button, ModalHeader, Stack } from '@lifeforge/ui'
import { useUserPersonalization } from '@/providers/features/UserPersonalizationProvider'
@@ -92,7 +92,7 @@ function AdjustBgImageModal({ onClose }: { onClose: () => void }) {
}, [])
return (
<div className="min-w-[40vw]">
<Stack gap="none" minWidth="40vw">
<ModalHeader
icon="tabler:adjustments"
title={t('bgImageSelector.modals.adjustBackground.title')}
@@ -105,15 +105,15 @@ function AdjustBgImageModal({ onClose }: { onClose: () => void }) {
bgSaturation={bgSaturation}
overlayOpacity={overlayOpacity}
/>
<div className="mt-6 w-full min-w-0 flex-1 space-y-3">
<Stack gap="md" mt="lg" px="xs" width="100%">
{ADJUSTMENTS_COLUMNS.map(({ title, ...props }) => (
<AdjustmentColumn key={title} title={title} {...props} />
))}
<Button className="mt-8 w-full" icon="uil:save" onClick={onSaveChanges}>
<Button icon="uil:save" mt="lg" width="100%" onClick={onSaveChanges}>
Save
</Button>
</div>
</div>
</Stack>
</Stack>
)
}

View File

@@ -6,7 +6,8 @@ import {
Flex,
Listbox,
ListboxOption,
Text
Text,
surface
} from '@lifeforge/ui'
import { useUserPersonalization } from '@/providers/features/UserPersonalizationProvider'
@@ -34,15 +35,15 @@ function DefaultBgTempSelector({
return (
<Listbox
bg={{
base: 'bg-100',
hover: 'bg-200',
dark: 'bg-800',
darkHover: 'bg-700'
}}
bg={surface.lightInteractive}
minWidth="12em"
renderContent={() => (
<Flex align="center" gap="sm" maxWidth="12em" minWidth="0">
<Flex
align="center"
gap="sm"
maxWidth={{ base: 'none', md: '12em' }}
minWidth="0"
>
<Box
bg="bg-500"
className={bgTemp}
@@ -60,6 +61,7 @@ function DefaultBgTempSelector({
</Flex>
)}
value={bgTemp.startsWith('#') ? 'bg-custom' : bgTemp}
width="100%"
onChange={color => {
changeBgTemp(color === 'bg-custom' ? customBgTemp : color)
}}

View File

@@ -33,7 +33,12 @@ function BgTempSelector() {
icon="tabler:temperature"
title={t('bgTempSelector.title')}
>
<Flex align="center" direction={{ base: 'column', sm: 'row' }} gap="md">
<Flex
align="center"
direction={{ base: 'column', sm: 'row' }}
gap="md"
width="100%"
>
<DefaultBgTempSelector bgTemp={bgTemp} customBgTemp={customBgTemp} />
{bgTemp.startsWith('#') && (
<>

View File

@@ -6,7 +6,8 @@ import {
Listbox,
ListboxOption,
OptionsColumn,
Text
Text,
surface
} from '@lifeforge/ui'
import { useUserPersonalization } from '@/providers/features/UserPersonalizationProvider'
@@ -37,12 +38,7 @@ function BorderRadiusSelector() {
title={t('borderRadiusSelector.title')}
>
<Listbox
bg={{
base: 'bg-100',
hover: 'bg-200',
dark: 'bg-800',
darkHover: 'bg-700'
}}
bg={surface.lightInteractive}
minWidth="12em"
renderContent={() => (
<Flex align="center" gap="sm" maxWidth="12em" minWidth="0">

View File

@@ -7,7 +7,7 @@ import { FormModal, defineForm } from '@lifeforge/ui'
import forgeAPI from '@/forgeAPI'
import { detectFontMetadata } from '../utils/detectFontMetadata'
import type { CustomFont } from './FontFamilySelectorModal/tabs/CustomFontSelector'
import type { CustomFont } from './FontFamilySelectorModal/tabs/custom'
function CustomFontUploadModal({
onClose,
@@ -62,7 +62,7 @@ function CustomFontUploadModal({
label: 'fontFamily.inputs.fontFile',
required: true,
acceptedMimeTypes: {
'font/*': ['ttf', 'otf', 'woff', 'woff2']
font: ['ttf', 'otf', 'woff', 'woff2']
}
},
displayName: {
@@ -89,6 +89,9 @@ function CustomFontUploadModal({
(currentState.file as any).file
)
//TODO
console.log(currentState.file.file, metadata)
formStateStore.setState({
family: metadata.family,
weight: metadata.weight,

View File

@@ -1,104 +0,0 @@
import { Icon } from '@iconify/react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import clsx from 'clsx'
import { useEffect } from 'react'
import { toast } from 'react-toastify'
import { usePromiseLoading } from '@lifeforge/shared'
import { Button } from '@lifeforge/ui'
import forgeAPI from '@/forgeAPI'
import {
addFontToStylesheet,
removeFontFromStylesheet
} from '../../../utils/fontFamily'
import type { FontFamily } from '../tabs/GoogleFontSelector'
function FontListItem({
font,
selectedFont,
isPinned,
setSelectedFont
}: {
font: FontFamily
selectedFont: string | null
isPinned: boolean
setSelectedFont: (font: string) => void
}) {
const queryClient = useQueryClient()
const togglePinMutation = useMutation(
forgeAPI.user.personalization.toggleGoogleFontsPin.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['user', 'personalization', 'listGoogleFontsPin']
})
},
onError: () => {
toast.error('Failed to toggle font pin')
}
})
)
const [loadingPin, handleTogglePin] = usePromiseLoading(async () => {
await togglePinMutation.mutateAsync({ family: font.family })
})
useEffect(() => {
addFontToStylesheet(font)
return () => {
removeFontFromStylesheet(font.family)
}
}, [font])
return (
<button
className={clsx(
'component-bg-lighter-with-hover relative w-full min-w-0 rounded-lg p-6 text-left',
selectedFont === font.family && 'border-custom-500 border-2'
)}
onClick={() => setSelectedFont(font.family)}
>
<div className="flex w-full min-w-0 flex-col pr-6 text-lg font-medium md:flex-row md:items-center md:gap-2">
<span className="min-w-0 truncate">{font.family}</span>
<span className="text-bg-500 hidden text-base whitespace-nowrap md:block">
({font.variants.length} variants)
</span>
<span className="text-bg-500 block text-base whitespace-nowrap md:hidden">
{font.variants.length} variants
</span>
</div>
<Button
className={clsx(
'absolute top-2 right-2 p-1',
isPinned && 'text-custom-500'
)}
icon={isPinned ? 'tabler:heart-filled' : 'tabler:heart'}
loading={loadingPin}
variant="plain"
onClick={e => {
e.stopPropagation()
handleTogglePin()
}}
/>
{selectedFont === font.family && (
<Icon
className="text-custom-500 absolute right-1.5 bottom-2 size-6"
icon="tabler:circle-check-filled"
/>
)}
<p
className="relative mt-4 truncate overflow-hidden py-4 text-4xl"
style={{
fontFamily: font.family
}}
>
The quick brown fox jumps over the lazy dog
</p>
</button>
)
}
export default FontListItem

View File

@@ -3,12 +3,12 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'react-toastify'
import { usePersonalization } from '@lifeforge/shared'
import { Button, ModalHeader, Tabs } from '@lifeforge/ui'
import { Button, ModalHeader, Stack, Tabs } from '@lifeforge/ui'
import { useUserPersonalization } from '@/providers/features/UserPersonalizationProvider'
import CustomFontSelector from './tabs/CustomFontSelector'
import GoogleFontSelector from './tabs/GoogleFontSelector'
import CustomFontSelector from './tabs/custom'
import GoogleFontSelector from './tabs/google'
type TabType = 'google' | 'custom'
@@ -26,7 +26,7 @@ function FontFamilySelectorModal({ onClose }: { onClose: () => void }) {
const [selectedFont, setSelectedFont] = useState<string | null>(fontFamily)
return (
<div className="flex h-full min-h-[80vh] min-w-[60vw] flex-col">
<Stack gap="md" height="100%" minHeight="80vh" minWidth="60vw">
<ModalHeader
icon="tabler:text-size"
namespace="common.personalization"
@@ -34,7 +34,6 @@ function FontFamilySelectorModal({ onClose }: { onClose: () => void }) {
onClose={onClose}
/>
<Tabs
className="mb-4"
currentTab={activeTab}
enabled={['google', 'custom'] as const}
items={
@@ -53,21 +52,23 @@ function FontFamilySelectorModal({ onClose }: { onClose: () => void }) {
}
onTabChange={setActiveTab}
/>
{activeTab === 'google' ? (
<GoogleFontSelector
selectedFont={selectedFont}
setSelectedFont={setSelectedFont}
/>
) : (
<CustomFontSelector
selectedFont={selectedFont}
setSelectedFont={setSelectedFont}
/>
)}
<Stack flex="1" width="100%">
{activeTab === 'google' ? (
<GoogleFontSelector
selectedFont={selectedFont}
setSelectedFont={setSelectedFont}
/>
) : (
<CustomFontSelector
selectedFont={selectedFont}
setSelectedFont={setSelectedFont}
/>
)}
</Stack>
{selectedFont && selectedFont !== fontFamily && (
<Button
className="mt-6"
icon="tabler:check"
mt="lg"
onClick={() => {
changeFontFamily(selectedFont)
onClose()
@@ -77,7 +78,7 @@ function FontFamilySelectorModal({ onClose }: { onClose: () => void }) {
Select
</Button>
)}
</div>
</Stack>
)
}

View File

@@ -1,203 +0,0 @@
import { Icon } from '@iconify/react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from 'react-toastify'
import { usePersonalization } from '@lifeforge/shared'
import {
Button,
Card,
ConfirmationModal,
ContextMenu,
ContextMenuItem,
EmptyStateScreen,
Scrollbar,
WithQuery,
useModalStore
} from '@lifeforge/ui'
import forgeAPI from '@/forgeAPI'
import CustomFontUploadModal from '../../CustomFontUploadModal'
export type CustomFont = {
id: string
displayName: string
family: string
weight: number
file: string
collectionId: string
}
function CustomFontSelector({
selectedFont,
setSelectedFont
}: {
selectedFont: string | null
setSelectedFont: (font: string | null) => void
}) {
const { t } = useTranslation('common.personalization')
const { fontFamily } = usePersonalization()
const { open } = useModalStore()
const queryClient = useQueryClient()
const customFontsQuery = useQuery(
forgeAPI.user.customFonts.list.queryOptions()
)
const deleteMutation = useMutation(
forgeAPI.user.customFonts.remove.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['user', 'customFonts', 'list']
})
toast.success('Custom font deleted successfully!')
},
onError: () => {
toast.error('Failed to delete custom font')
}
})
)
const handleUploadClick = () => {
open(CustomFontUploadModal, {
openType: 'create'
})
}
const handleDeleteClick = (font: CustomFont) => {
open(ConfirmationModal, {
title: t('fontFamily.customFonts.delete.title'),
description: t('fontFamily.customFonts.delete.description'),
confirmationButton: 'delete',
onConfirm: async () => {
await deleteMutation.mutateAsync({ id: font.id })
// If the deleted font was selected, clear selection
if (selectedFont === `custom:${font.id}`) {
setSelectedFont(null)
}
}
})
}
const isCustomFontSelected = (fontId: string) => {
return selectedFont === `custom:${fontId}`
}
return (
<div className="flex h-full flex-1 flex-col">
<Button
className="mb-4 w-full"
icon="tabler:upload"
onClick={handleUploadClick}
>
{t('fontFamily.buttons.uploadButton')}
</Button>
<WithQuery query={customFontsQuery}>
{fonts =>
fonts.length > 0 ? (
<Scrollbar className="flex-1">
<div className="space-y-3 p-2">
{fonts.map(font => (
<Card
key={font.id}
isInteractive
className={`component-bg-lighter-with-hover flex-between relative ${
isCustomFontSelected(font.id)
? 'ring-custom-500 ring-2'
: ''
}`}
role="button"
tabIndex={0}
onClick={() =>
setSelectedFont(
selectedFont === `custom:${font.id}`
? null
: `custom:${font.id}`
)
}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
setSelectedFont(
selectedFont === `custom:${font.id}`
? null
: `custom:${font.id}`
)
}
}}
>
<div className="flex flex-1 flex-col gap-4 sm:flex-row sm:items-center">
<div className="dark:bg-bg-700/50 shadow-custom bg-bg-200 flex size-12 shrink-0 items-center justify-center rounded-lg">
<Icon
className="text-bg-500 size-6"
icon="tabler:typography"
/>
</div>
<div className="flex-between w-full gap-8">
<div>
<p className="font-medium">{font.displayName}</p>
<p className="text-bg-500 text-sm">
{font.family} Weight {font.weight}
</p>
</div>
{isCustomFontSelected(font.id) && (
<Icon
className="text-custom-500 mr-2 size-5 shrink-0"
icon="tabler:check"
/>
)}
</div>
</div>
<div className="flex items-center gap-2">
<ContextMenu
classNames={{
wrapper: 'sm:static absolute top-2 right-2'
}}
>
<ContextMenuItem
icon="tabler:pencil"
label="Edit"
onClick={() => {
open(CustomFontUploadModal, {
openType: 'edit',
initialData: font
})
}}
/>
<ContextMenuItem
dangerous
disabled={fontFamily === `custom:${font.id}`}
icon="tabler:trash"
label="Delete"
onClick={() => handleDeleteClick(font)}
/>
</ContextMenu>
</div>
</Card>
))}
</div>
</Scrollbar>
) : (
<div className="flex-center flex-1">
<EmptyStateScreen
icon="tabler:file-typography"
message={{
id: 'customFont',
namespace: 'common.personalization',
tKey: 'fontFamily'
}}
/>
</div>
)
}
</WithQuery>
</div>
)
}
export default CustomFontSelector

View File

@@ -1,288 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import _ from 'lodash'
import { useEffect, useMemo, useRef, useState } from 'react'
import { AutoSizer } from 'react-virtualized'
import { usePersonalization } from '@lifeforge/shared'
import {
EmptyStateScreen,
Listbox,
ListboxOption,
Pagination,
Scrollbar,
SearchInput,
WithQuery
} from '@lifeforge/ui'
import forgeAPI from '@/forgeAPI'
import FontListItem from '../components/FontListItem'
export interface FontFamily {
family: string
variants: string[]
subsets: string[]
version: string
lastModified: Date
files: Files
category: Category
kind: Kind
menu: string
colorCapabilities?: ColorCapability[]
}
enum Category {
Display = 'display',
Handwriting = 'handwriting',
Monospace = 'monospace',
SansSerif = 'sans-serif',
Serif = 'serif'
}
enum ColorCapability {
COLRv0 = 'COLRv0',
COLRv1 = 'COLRv1',
SVG = 'SVG'
}
interface Files {
regular?: string
italic?: string
'500'?: string
'600'?: string
'700'?: string
'800'?: string
'100'?: string
'200'?: string
'300'?: string
'900'?: string
'100italic'?: string
'200italic'?: string
'300italic'?: string
'500italic'?: string
'600italic'?: string
'700italic'?: string
'800italic'?: string
'900italic'?: string
}
enum Kind {
WebfontsWebfont = 'webfonts#webfont'
}
function GoogleFontSelector({
selectedFont,
setSelectedFont
}: {
selectedFont: string | null
setSelectedFont: (font: string | null) => void
}) {
const { fontFamily } = usePersonalization()
const apiKeyAvailable = useQuery(
forgeAPI
.checkAPIKeys({
keys: 'gcloud'
})
.queryOptions()
)
const fontsQuery = useQuery<{
enabled: boolean
items: FontFamily[]
}>(
forgeAPI.user.personalization.listGoogleFonts.queryOptions({
enabled: apiKeyAvailable.data === true
})
)
const pinnedFontsQuery = useQuery<string[]>(
forgeAPI.user.personalization.listGoogleFontsPin.queryOptions({
enabled: apiKeyAvailable.data === true
})
)
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState<string>('')
const [page, setPage] = useState(1)
const scrollableRef = useRef<any>(null)
const filteredFonts = useMemo(
() =>
fontsQuery.data?.items
.filter(font => {
return (
(font.category === selectedCategory || !selectedCategory) &&
font.family.toLowerCase().includes(searchQuery.toLowerCase())
)
})
.sort((a, b) => {
const aPinned = pinnedFontsQuery.data?.includes(a.family) ? 1 : 0
const bPinned = pinnedFontsQuery.data?.includes(b.family) ? 1 : 0
if (aPinned !== bPinned) {
return bPinned - aPinned // Pinned fonts first
}
return a.family.localeCompare(b.family) // Then sort alphabetically
}),
[fontsQuery.data, selectedCategory, searchQuery, pinnedFontsQuery.data]
)
useEffect(() => {
setPage(1)
scrollableRef.current?.scrollToTop()
}, [selectedCategory, searchQuery])
useEffect(() => {
if (!fontsQuery.data) return
const indexOfCurrentFontFamily = fontsQuery.data.items.findIndex(
font => font.family === fontFamily
)
setPage(
indexOfCurrentFontFamily !== -1
? Math.ceil((indexOfCurrentFontFamily + 1) / 10)
: 1
)
}, [fontsQuery.data])
return (
<WithQuery query={apiKeyAvailable}>
{apiKeyAvailable =>
apiKeyAvailable ? (
<>
<div className="mb-4 flex flex-col items-center gap-2 md:flex-row">
<Listbox
className="component-bg-lighter-with-hover md:max-w-56"
renderContent={() => (
<span>
{_.startCase(selectedCategory || '') || 'All category'}
</span>
)}
value={selectedCategory}
onChange={setSelectedCategory}
>
<ListboxOption key="all" label="All category" value={null} />
{[
...new Set(fontsQuery.data?.items.map(font => font.category))
].map(category => (
<ListboxOption
key={category}
label={_.startCase(category)}
value={category}
/>
))}
</Listbox>
<SearchInput
className="component-bg-lighter-with-hover"
debounceMs={300}
namespace="common.personalization"
searchTarget="fontFamily.items.fontFamily"
value={searchQuery}
onChange={setSearchQuery}
/>
</div>
<WithQuery query={pinnedFontsQuery}>
{pinnedFontsData => (
<WithQuery query={fontsQuery}>
{data =>
!data.enabled ? (
<EmptyStateScreen
icon="tabler:key-off"
message={{
id: 'apiKey',
namespace: 'common.personalization',
tKey: 'fontFamily'
}}
/>
) : filteredFonts!.length > 0 ? (
<div className="h-full w-full flex-1">
<AutoSizer>
{({ height, width }) => (
<Scrollbar
ref={scrollableRef}
style={{
height: `${height}px`,
width: `${width}px`
}}
>
<div className="w-full space-y-3">
<Pagination
page={page}
totalPages={Math.ceil(
filteredFonts!.length / 10
)}
onPageChange={page => {
setPage(page)
scrollableRef.current?.scrollToTop()
}}
/>
{filteredFonts
?.slice((page - 1) * 10, page * 10)
.map(font => (
<FontListItem
key={font.family}
font={font}
isPinned={pinnedFontsData.some(
pinnedFont => pinnedFont === font.family
)}
selectedFont={selectedFont}
setSelectedFont={setSelectedFont}
/>
))}
<Pagination
page={page}
totalPages={Math.ceil(
filteredFonts!.length / 10
)}
onPageChange={page => {
setPage(page)
scrollableRef.current?.scrollToTop()
}}
/>
</div>
</Scrollbar>
)}
</AutoSizer>
</div>
) : (
<div className="flex-center flex-1">
<EmptyStateScreen
icon="tabler:search-off"
message={{
id: 'search',
namespace: 'common.personalization',
tKey: 'fontFamily'
}}
/>
</div>
)
}
</WithQuery>
)}
</WithQuery>
</>
) : (
<div className="flex-center flex-1">
<EmptyStateScreen
icon="tabler:key-off"
message={{
id: 'apiKey',
namespace: 'common.personalization',
tKey: 'fontFamily'
}}
/>
</div>
)
}
</WithQuery>
)
}
export default GoogleFontSelector

View File

@@ -0,0 +1,232 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from 'react-toastify'
import { AutoSizer } from 'react-virtualized'
import { usePersonalization } from '@lifeforge/shared'
import {
Box,
Button,
Card,
ConfirmationModal,
ContextMenu,
ContextMenuItem,
EmptyStateScreen,
Flex,
Icon,
Ring,
Scrollbar,
Stack,
Text,
WithQuery,
colorWithOpacity,
surface,
useModalStore
} from '@lifeforge/ui'
import forgeAPI from '@/forgeAPI'
import CustomFontUploadModal from '../../../CustomFontUploadModal'
export type CustomFont = {
id: string
displayName: string
family: string
weight: number
file: string
collectionId: string
}
function CustomFontSelector({
selectedFont,
setSelectedFont
}: {
selectedFont: string | null
setSelectedFont: (font: string | null) => void
}) {
const { t } = useTranslation('common.personalization')
const { fontFamily } = usePersonalization()
const { open } = useModalStore()
const queryClient = useQueryClient()
const customFontsQuery = useQuery(
forgeAPI.user.customFonts.list.queryOptions()
)
const deleteMutation = useMutation(
forgeAPI.user.customFonts.remove.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['user', 'customFonts', 'list']
})
toast.success('Custom font deleted successfully!')
},
onError: () => {
toast.error('Failed to delete custom font')
}
})
)
const handleUploadClick = () => {
open(CustomFontUploadModal, {
openType: 'create'
})
}
const handleDeleteClick = (font: CustomFont) => {
open(ConfirmationModal, {
title: t('fontFamily.customFonts.delete.title'),
description: t('fontFamily.customFonts.delete.description'),
confirmationButton: 'delete',
onConfirm: async () => {
await deleteMutation.mutateAsync({ id: font.id })
if (selectedFont === `custom:${font.id}`) {
setSelectedFont(null)
}
}
})
}
const isCustomFontSelected = (fontId: string) => {
return selectedFont === `custom:${fontId}`
}
return (
<Stack flex="1" minHeight="0">
<Button
icon="tabler:upload"
mb="sm"
width="100%"
onClick={handleUploadClick}
>
{t('fontFamily.buttons.uploadButton')}
</Button>
<WithQuery query={customFontsQuery}>
{fonts =>
fonts.length > 0 ? (
<Flex direction="column" flex="1">
<AutoSizer>
{({ width, height }) => (
<Scrollbar
style={{
height: `${height}px`,
width: `${width}px`
}}
>
<Stack p="sm">
{fonts.map(font => (
<Ring
key={font.id}
asChild
ringColor="custom-500"
ringWidth={
isCustomFontSelected(font.id) ? '2px' : '0px'
}
>
<Card
isInteractive
align="center"
bg={surface.lightInteractive}
direction="row"
role="button"
tabIndex={0}
onClick={() => setSelectedFont(`custom:${font.id}`)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
setSelectedFont(
selectedFont === `custom:${font.id}`
? null
: `custom:${font.id}`
)
}
}}
>
<Flex align="center" gap="md" width="100%">
<Flex
align="center"
bg={
selectedFont === `custom:${font.id}`
? colorWithOpacity('custom-500', '10%')
: { base: 'bg-200', dark: 'bg-700' }
}
flexShrink="0"
height="3rem"
justify="center"
r="lg"
width="3rem"
>
<Icon
color={
selectedFont === `custom:${font.id}`
? 'custom-500'
: 'muted'
}
icon="tabler:typography"
size="1.5em"
/>
</Flex>
<Box>
<Text as="h3" weight="medium">
{font.displayName}
</Text>
<Text as="p" color="muted" size="sm">
{font.family} Weight {font.weight}
</Text>
</Box>
</Flex>
<Flex align="center" gap="md">
{isCustomFontSelected(font.id) && (
<Icon color="primary" icon="tabler:check" />
)}
<ContextMenu>
<ContextMenuItem
icon="tabler:pencil"
label="Edit"
onClick={() => {
open(CustomFontUploadModal, {
openType: 'edit',
initialData: font
})
}}
/>
<ContextMenuItem
dangerous
disabled={fontFamily === `custom:${font.id}`}
icon="tabler:trash"
label="Delete"
onClick={() => handleDeleteClick(font)}
/>
</ContextMenu>
</Flex>
</Card>
</Ring>
))}
</Stack>
</Scrollbar>
)}
</AutoSizer>
</Flex>
) : (
<Flex centered flex="1">
<EmptyStateScreen
icon="tabler:file-typography"
message={{
id: 'customFont',
namespace: 'common.personalization',
tKey: 'fontFamily'
}}
/>
</Flex>
)
}
</WithQuery>
</Stack>
)
}
export default CustomFontSelector

View File

@@ -0,0 +1,129 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { toast } from 'react-toastify'
import { usePromiseLoading } from '@lifeforge/shared'
import {
Box,
Button,
Card,
Flex,
Icon,
Ring,
Text,
Transition,
surface
} from '@lifeforge/ui'
import {
addFontToStylesheet,
removeFontFromStylesheet
} from '@/core/personalization/components/FontFamilySelector/components/FontFamilySelectorModal/tabs/google/utils/stylesheet'
import forgeAPI from '@/forgeAPI'
import type { FontFamily } from '..'
function FontListItem({
font,
selectedFont,
isPinned,
setSelectedFont
}: {
font: FontFamily
selectedFont: string | null
isPinned: boolean
setSelectedFont: (font: string) => void
}) {
const queryClient = useQueryClient()
const togglePinMutation = useMutation(
forgeAPI.user.personalization.toggleGoogleFontsPin.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['user', 'personalization', 'listGoogleFontsPin']
})
},
onError: () => {
toast.error('Failed to toggle font pin')
}
})
)
const [loadingPin, handleTogglePin] = usePromiseLoading(async () => {
await togglePinMutation.mutateAsync({ family: font.family })
})
useEffect(() => {
addFontToStylesheet(font)
return () => {
removeFontFromStylesheet(font.family)
}
}, [font])
return (
<Transition duration="150ms" property="all">
<Ring
asChild
ringColor="custom-500"
ringWidth={selectedFont === font.family ? '2px' : '0px'}
>
<Card
as="button"
bg={surface.lightInteractive}
position="relative"
onClick={() => setSelectedFont(font.family)}
>
<Flex
align={{ base: 'start', md: 'center' }}
direction={{ base: 'column', md: 'row' }}
gapX="sm"
mb="sm"
pr="lg"
width="100%"
>
<Text truncate as="h3" size="lg" weight="medium">
{font.family}
</Text>
<Text as="p" color="muted" size="sm" whiteSpace="nowrap">
({font.variants.length} variants)
</Text>
</Flex>
<Button
icon={isPinned ? 'tabler:heart-filled' : 'tabler:heart'}
loading={loadingPin}
position="absolute"
right="0.5rem"
top="0.5rem"
variant="plain"
onClick={e => {
e.stopPropagation()
handleTogglePin()
}}
/>
{selectedFont === font.family && (
<Box asChild bottom="0.5rem" position="absolute" right="0.5rem">
<Icon
color="primary"
icon="tabler:circle-check-filled"
size="1.5em"
/>
</Box>
)}
<Text
truncate
as="p"
mt="md"
py="md"
size="4xl"
style={{ fontFamily: font.family }}
>
The quick brown fox jumps over the lazy dog
</Text>
</Card>
</Ring>
</Transition>
)
}
export default FontListItem

View File

@@ -0,0 +1,60 @@
import _ from 'lodash'
import {
Flex,
Listbox,
ListboxOption,
SearchInput,
surface
} from '@lifeforge/ui'
import { useGoogleFont } from '../contexts/GoogleFontContext'
function GoogleFontFilter() {
const {
categories,
searchQuery,
selectedCategory,
setSearchQuery,
setSelectedCategory
} = useGoogleFont()
return (
<Flex
align={{ base: 'start', md: 'center' }}
as="header"
direction={{ base: 'column', md: 'row' }}
gap="sm"
>
<Listbox
bg={surface.lightInteractive}
minWidth={{ base: '100%', md: '14rem' }}
renderContent={() =>
_.startCase(selectedCategory || '') || 'All category'
}
value={selectedCategory}
width="min-content"
onChange={setSelectedCategory}
>
<ListboxOption key="all" label="All category" value={null} />
{categories.map(category => (
<ListboxOption
key={category}
label={_.startCase(category)}
value={category}
/>
))}
</Listbox>
<SearchInput
bg={surface.lightInteractive}
debounceMs={300}
namespace="common.personalization"
searchTarget="fontFamily.items.fontFamily"
value={searchQuery}
onChange={setSearchQuery}
/>
</Flex>
)
}
export default GoogleFontFilter

View File

@@ -0,0 +1,106 @@
import { AutoSizer } from 'react-virtualized'
import {
Box,
EmptyStateScreen,
Flex,
Pagination,
Scrollbar,
Stack
} from '@lifeforge/ui'
import { useGoogleFont } from '../contexts/GoogleFontContext'
import FontListItem from './FontListItem'
function GoogleFontList({
pinnedFonts,
fontsEnabled
}: {
pinnedFonts: string[]
fontsEnabled: boolean
}) {
const {
selectedFont,
setSelectedFont,
filteredFonts,
page,
scrollableRef,
setPage
} = useGoogleFont()
if (!fontsEnabled) {
return (
<Flex centered flex="1">
<EmptyStateScreen
icon="tabler:key-off"
message={{
id: 'apiKey',
namespace: 'common.personalization',
tKey: 'fontFamily'
}}
/>
</Flex>
)
}
if (filteredFonts.length === 0) {
return (
<Flex centered flex="1">
<EmptyStateScreen
icon="tabler:search-off"
message={{
id: 'search',
namespace: 'common.personalization',
tKey: 'fontFamily'
}}
/>
</Flex>
)
}
return (
<Box flex="1" height="100%" width="100%">
<AutoSizer>
{({ height, width }) => (
<Scrollbar
ref={scrollableRef}
style={{
height: `${height}px`,
width: `${width}px`
}}
>
<Stack px="sm">
<Pagination
page={page}
totalPages={Math.ceil(filteredFonts!.length / 10)}
onPageChange={page => {
setPage(page)
scrollableRef.current?.scrollToTop()
}}
/>
{filteredFonts?.slice((page - 1) * 10, page * 10).map(font => (
<FontListItem
key={font.family}
font={font}
isPinned={pinnedFonts.some(pf => pf === font.family)}
selectedFont={selectedFont}
setSelectedFont={setSelectedFont}
/>
))}
<Pagination
page={page}
totalPages={Math.ceil(filteredFonts!.length / 10)}
onPageChange={page => {
setPage(page)
scrollableRef.current?.scrollToTop()
}}
/>
</Stack>
</Scrollbar>
)}
</AutoSizer>
</Box>
)
}
export default GoogleFontList

View File

@@ -0,0 +1,143 @@
import { useQuery } from '@tanstack/react-query'
import {
type ReactNode,
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { usePersonalization } from '@lifeforge/shared'
import forgeAPI from '@/forgeAPI'
import type { FontFamily } from '../index'
interface GoogleFontContextValue {
categories: string[]
filteredFonts: FontFamily[]
fontsQuery: ReturnType<
typeof useQuery<{
enabled: boolean
items: FontFamily[]
}>
>
page: number
pinnedFontsQuery: ReturnType<typeof useQuery<string[]>>
scrollableRef: React.MutableRefObject<any>
searchQuery: string
selectedCategory: string | null
selectedFont: string | null
setSelectedFont: (font: string | null) => void
setPage: React.Dispatch<React.SetStateAction<number>>
setSearchQuery: React.Dispatch<React.SetStateAction<string>>
setSelectedCategory: React.Dispatch<React.SetStateAction<string | null>>
}
const GoogleFontContext = createContext<GoogleFontContextValue | null>(null)
function GoogleFontProvider({
children,
selectedFont,
setSelectedFont
}: {
children: ReactNode
selectedFont: string | null
setSelectedFont: (font: string | null) => void
}) {
const { fontFamily } = usePersonalization()
const fontsQuery = useQuery<{
enabled: boolean
items: FontFamily[]
}>(forgeAPI.user.personalization.listGoogleFonts.queryOptions())
const pinnedFontsQuery = useQuery<string[]>(
forgeAPI.user.personalization.listGoogleFontsPin.queryOptions()
)
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState<string>('')
const [page, setPage] = useState(1)
const scrollableRef = useRef<any>(null)
const categories = useMemo(
() => [...new Set(fontsQuery.data?.items.map(font => font.category))],
[fontsQuery.data?.items]
)
const filteredFonts = useMemo(
() =>
fontsQuery.data?.items
.filter(font => {
return (
(font.category === selectedCategory || !selectedCategory) &&
font.family.toLowerCase().includes(searchQuery.toLowerCase())
)
})
.sort((a, b) => {
const aPinned = pinnedFontsQuery.data?.includes(a.family) ? 1 : 0
const bPinned = pinnedFontsQuery.data?.includes(b.family) ? 1 : 0
if (aPinned !== bPinned) {
return bPinned - aPinned
}
return a.family.localeCompare(b.family)
}),
[fontsQuery.data, selectedCategory, searchQuery, pinnedFontsQuery.data]
)
useEffect(() => {
setPage(1)
scrollableRef.current?.scrollToTop()
}, [selectedCategory, searchQuery])
useEffect(() => {
if (!fontsQuery.data) return
const idx = filteredFonts?.findIndex(font => font.family === fontFamily)
setPage(idx && idx !== -1 ? Math.ceil((idx + 1) / 10) : 1)
}, [fontsQuery.data])
return (
<GoogleFontContext
value={{
categories,
filteredFonts: filteredFonts ?? [],
fontsQuery,
page,
pinnedFontsQuery,
scrollableRef,
searchQuery,
selectedCategory,
selectedFont,
setPage,
setSearchQuery,
setSelectedCategory,
setSelectedFont
}}
>
{children}
</GoogleFontContext>
)
}
function useGoogleFont(): GoogleFontContextValue {
const context = useContext(GoogleFontContext)
if (!context) {
throw new Error('useGoogleFont must be used within a GoogleFontProvider')
}
return context
}
export { GoogleFontProvider, useGoogleFont }

View File

@@ -0,0 +1,80 @@
import { useQuery } from '@tanstack/react-query'
import type { InferOutput } from '@lifeforge/shared'
import { EmptyStateScreen, Flex, WithQuery } from '@lifeforge/ui'
import forgeAPI from '@/forgeAPI'
import GoogleFontFilter from './components/GoogleFontFilter'
import GoogleFontList from './components/GoogleFontList'
import { GoogleFontProvider, useGoogleFont } from './contexts/GoogleFontContext'
export type FontFamily = InferOutput<
typeof forgeAPI.user.personalization.listGoogleFonts
>['items'][number]
function GoogleFontSelectorContent() {
const apiKeyAvailable = useQuery(
forgeAPI
.checkAPIKeys({
keys: 'gcloud'
})
.queryOptions()
)
const { pinnedFontsQuery, fontsQuery } = useGoogleFont()
return (
<WithQuery query={apiKeyAvailable}>
{apiKeyAvailable =>
apiKeyAvailable ? (
<WithQuery query={pinnedFontsQuery}>
{pinnedFontsData => (
<WithQuery query={fontsQuery}>
{fonts => (
<>
<GoogleFontFilter />
<GoogleFontList
fontsEnabled={fonts.enabled}
pinnedFonts={pinnedFontsData}
/>
</>
)}
</WithQuery>
)}
</WithQuery>
) : (
<Flex centered flex="1">
<EmptyStateScreen
icon="tabler:key-off"
message={{
id: 'apiKey',
namespace: 'common.personalization',
tKey: 'fontFamily'
}}
/>
</Flex>
)
}
</WithQuery>
)
}
function GoogleFontSelector({
selectedFont,
setSelectedFont
}: {
selectedFont: string | null
setSelectedFont: (font: string | null) => void
}) {
return (
<GoogleFontProvider
selectedFont={selectedFont}
setSelectedFont={setSelectedFont}
>
<GoogleFontSelectorContent />
</GoogleFontProvider>
)
}
export default GoogleFontSelector

View File

@@ -1,4 +1,4 @@
import type { FontFamily } from '../components/FontFamilySelectorModal/tabs/GoogleFontSelector'
import type { FontFamily } from '..'
const fontRuleMap = new Map<string, number[]>()

View File

@@ -1,12 +1,14 @@
import { Icon } from '@iconify/react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { usePersonalization } from '@lifeforge/shared'
import {
Box,
Button,
Flex,
Icon,
OptionsColumn,
Text,
WithQuery,
useModalStore
} from '@lifeforge/ui'
@@ -39,44 +41,41 @@ function FontFamilySelector() {
title={t('fontFamily.title')}
tooltip={
<>
<h3 className="mb-2 flex items-center gap-2 text-lg font-medium">
<Icon className="size-5" icon="simple-icons:googlefonts" />
{t('fontFamily.tooltipTitle')}
</h3>
<p className="text-bg-500 relative z-40 text-sm">
<Flex align="center" gap="sm">
<Icon icon="simple-icons:googlefonts" />
<Text size="lg" weight="medium">
{t('fontFamily.tooltipTitle')}
</Text>
</Flex>
<Text as="p" color="muted" mt="sm">
{t('fontFamily.tooltip')}
</p>
</Text>
</>
}
>
<Flex
align="center"
direction={{ base: 'column', md: 'row' }}
flexShrink="0"
gap="lg"
width="100%"
>
{fontFamily.startsWith('custom:') ? (
<WithQuery query={customFontQuery}>
{customFont => (
<div
className="shrink-0"
style={{
fontFamily: customFont.family
}}
>
{customFont.displayName}
</div>
<Box asChild flexShrink="0">
<Text size="lg" style={{ fontFamily: customFont.family }}>
{customFont.displayName}
</Text>
</Box>
)}
</WithQuery>
) : (
<div
className="shrink-0"
style={{
fontFamily
}}
>
{fontFamily || 'Onest'}
</div>
<Box asChild flexShrink="0">
<Text size="lg" style={{ fontFamily }}>
{fontFamily || 'Onest'}
</Text>
</Box>
)}
<Button
icon="tabler:text-size"

View File

@@ -1,4 +1,4 @@
import * as opentype from 'opentype.js'
import { parse } from 'opentype.js'
export interface FontMetadata {
family: string
@@ -23,7 +23,9 @@ export async function detectFontMetadata(
try {
const arrayBuffer = await fileData.arrayBuffer()
const font = opentype.parse(arrayBuffer)
const font = parse(arrayBuffer)
font.names = (font.names as any).windows || font.names
// Get font family from name table
const family =

View File

@@ -9,7 +9,8 @@ import {
ListboxOption,
OptionsColumn,
Text,
WithQuery
WithQuery,
surface
} from '@lifeforge/ui'
import forgeAPI from '@/forgeAPI'
@@ -34,12 +35,7 @@ function LanguageSelector() {
<WithQuery loaderSize="1.5em" query={languagesQuery}>
{langs => (
<Listbox
bg={{
base: 'bg-100',
hover: 'bg-200',
dark: 'bg-800',
darkHover: 'bg-700'
}}
bg={surface.lightInteractive}
minWidth="16em"
renderContent={() => (
<Flex align="center" gap="sm" maxWidth="16em" minWidth="0">

View File

@@ -7,7 +7,8 @@ import {
Flex,
Listbox,
ListboxOption,
Text
Text,
surface
} from '@lifeforge/ui'
import { useUserPersonalization } from '@/providers/features/UserPersonalizationProvider'
@@ -46,12 +47,7 @@ function DefaultThemeColorSelector({
return (
<Listbox
bg={{
base: 'bg-100',
hover: 'bg-200',
dark: 'bg-800',
darkHover: 'bg-700'
}}
bg={surface.lightInteractive}
minWidth="16em"
renderContent={() => (
<Flex align="center" gap="sm" maxWidth="16em" minWidth="0">
@@ -100,7 +96,7 @@ function DefaultThemeColorSelector({
)}
renderColorAndIcon={() => (
<Box
bg="custom-500"
bg="primary"
className={`theme-${color}`}
display="inline-block"
flexShrink="0"

View File

@@ -28,7 +28,7 @@ function ThemeColorSelector() {
return (
<OptionsColumn
breakpoint="lg"
breakpoint={themeColor.startsWith('#') ? 'lg' : 'md'}
description={t('themeColorSelector.desc')}
icon="tabler:palette"
title={t('themeColorSelector.title')}

View File

@@ -71,7 +71,7 @@ function ThemeSelector() {
{theme === id && (
<Box bottom="0.75em" position="absolute" right="0.75em">
<Icon
color="custom-500"
color="primary"
icon="tabler:circle-check-filled"
size="1.5em"
/>

View File

@@ -14,7 +14,7 @@ function Sidebar() {
const [searchQuery, setSearchQuery] = useState('')
return (
<Transition>
<Transition duration="300ms">
<Flex
shadow
as="aside"

View File

@@ -10,7 +10,8 @@ import {
Flex,
Icon,
Text,
Transition
Transition,
surface
} from '@lifeforge/ui'
function SidebarBottomBar() {
@@ -41,16 +42,7 @@ function SidebarBottomBar() {
<Transition>
<Card
align="center"
bg={
sidebarExpanded
? {
base: 'bg-100',
dark: 'bg-800',
darkHover: 'bg-700',
hover: 'bg-200'
}
: 'transparent'
}
bg={sidebarExpanded ? surface.lightInteractive : 'transparent'}
direction="row"
gap="xl"
justify={sidebarExpanded ? 'between' : 'center'}

View File

@@ -1,5 +1,13 @@
import { useMainSidebarState } from '@lifeforge/shared'
import { Box, Button, Flex, Icon, SearchInput, Text } from '@lifeforge/ui'
import {
Box,
Button,
Flex,
Icon,
SearchInput,
Text,
surface
} from '@lifeforge/ui'
function SidebarHeader({
searchQuery,
@@ -16,7 +24,7 @@ function SidebarHeader({
align="center"
flexShrink="0"
height="6em"
justify="between"
justify={sidebarExpanded ? 'between' : 'center'}
overflow={!sidebarExpanded ? 'hidden' : 'auto'}
px="lg"
>
@@ -40,12 +48,7 @@ function SidebarHeader({
{sidebarExpanded && (
<Box px="md">
<SearchInput
bg={{
base: 'bg-50',
hover: 'bg-100',
dark: 'bg-800',
darkHover: 'bg-700'
}}
bg={surface.lightInteractive}
mb="md"
namespace="common.sidebar"
searchTarget="module"

View File

@@ -37,6 +37,7 @@
"@tailwindcss/vite": "^4.1.18",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/lodash": "^4.17.21",
"@types/opentype.js": "^1.3.10",
"@types/prettier": "^3.0.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@@ -74,6 +75,7 @@
"dotenv": "^17.2.3",
"i18next": "^25.7.4",
"lodash": "^4.17.21",
"opentype.js": "^2.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.1",

View File

@@ -124,6 +124,40 @@ type ThemeConditionPropName =
/>
```
#### Pre-built Surface Presets (`surface`)
The most common background patterns are available as a pre-built `surface` object exported from `@lifeforge/ui`:
```typescript
import { surface } from '@lifeforge/ui'
// Light static surface — for non-interactive light backgrounds
surface.light
// => { base: 'bg-100', dark: 'bg-800' }
// Light interactive surface — for controls (Listbox, SearchInput, selectable Cards)
surface.lightInteractive
// => { base: 'bg-100', hover: 'bg-200', dark: 'bg-800', darkHover: colorWithOpacity('bg-700', '50%') }
// Default surface — for static Cards
surface.default
// => { base: 'bg-50', dark: 'bg-900' }
// Default interactive surface — for clickable Cards
surface.defaultInteractive
// => { base: 'bg-50', dark: 'bg-900', hover: 'bg-100', darkHover: 'bg-800' }
```
These are `as const` objects that can be spread or passed directly into the `bg` prop:
```tsx
<Listbox bg={surface.lightInteractive} ... />
<Card bg={surface.lightInteractive} ... /> {/* Override Card's default */}
<Card isInteractive ... /> {/* Uses surface.defaultInteractive automatically */}
```
> The `surface.default` and `surface.defaultInteractive` presets already serve as the built-in defaults for the `Card` component — you only need to pass them explicitly when overriding or when using them on non-Card primitives.
### B. Opacity Modifiers (`colorWithOpacity`)
To prevent heavy runtimes, colors can be blended with transparency using CSS `color-mix` through the `colorWithOpacity` helper:
```typescript
@@ -134,6 +168,20 @@ const semiTransparentPrimary = colorWithOpacity('primary', '30%')
```
*Supported Opacity Levels:* `'5%'`, `'10%'`, `'20%'`, `'30%'`, `'40%'`, `'50%'`, `'60%'`, `'70%'`, `'80%'`, `'90%'`.
`colorWithOpacity` can be used **directly in the `bg` prop** — no inline `style` needed:
```tsx
<Flex
align="center"
bg={colorWithOpacity('custom-500', '20%')}
justify="center"
p="md"
r="md"
>
<Icon color="custom-500" icon="tabler:box" />
</Flex>
```
This replaces the old pattern of inline `style={{ backgroundColor: 'color-mix(...)' }}`.
### C. Responsive Properties & Breakpoints
Layout props support responsive values. Breakpoints are defined as:
- `base`: Mobile first (default, no media query)
@@ -198,6 +246,13 @@ interface BoxOwnProps<T extends ElementType = 'div'> {
minHeight?: ResponsiveProp<string>; maxHeight?: ResponsiveProp<string>
inset?: ResponsiveProp<string>; top?: ResponsiveProp<string>; bottom?: ResponsiveProp<string>
left?: ResponsiveProp<string>; right?: ResponsiveProp<string>; zIndex?: ResponsiveProp<string>
```
> [!WARNING]
> **`top`, `right`, `bottom`, `left`, and `inset` accept raw CSS strings** (e.g. `"0.5rem"`, `"8px"`, `"50%"`), not `SpaceToken` values like `sm`, `md`, `lg`.
> Spacing tokens only work with padding (`p`, `px`, `py`, `pt`, `pr`, `pb`, `pl`) and margin (`m`, `mx`, `my`, `mt`, `mr`, `mb`, `ml`) props.
> ❌ `top="sm"` — will not resolve
> ✅ `top="0.5rem"` — correct
// Border Radius (Uses RadiusTokens)
r?: ResponsiveProp<RadiusToken> // All corners
@@ -313,6 +368,17 @@ export function Stack<T extends ElementType = 'div'>(props: FlexProps<T>) {
```
#### Example:
For listing content, Stack's default `gap="sm"` is usually sufficient — no need to explicitly set `gap`:
```tsx
<Stack>
<Text>Item 1</Text>
<Text>Item 2</Text>
<Text>Item 3</Text>
</Stack>
```
Override `gap` only when you need more or less spacing than the default:
```tsx
<Stack gap="md">
<Text size="lg">Form Title</Text>
@@ -345,6 +411,8 @@ interface TextOwnProps {
```
#### Example: Headline and Subtitle
`Text` also accepts all spacing props (`mt`, `mb`, `py`, `px`, etc.) and `style` directly — no wrapping `Box` needed.
```tsx
<Stack gap="xs">
<Text as="h1" size="3xl" weight="bold" leading="tight" tracking="tight">
@@ -354,6 +422,10 @@ interface TextOwnProps {
Welcome back to LifeForge.
</Text>
</Stack>
<Text as="p" mt="md" py="sm" style={{ fontFamily: 'Inter' }}>
Direct spacing and style no Box wrapper.
</Text>
```
---
@@ -541,18 +613,17 @@ When constructing standard layout components, follow this order:
The core button component manages accessibility, internationalization translations, dynamic loading spinners, and personalization background-contrast matching.
```typescript
type ButtonProps = {
variant?: 'primary' | 'secondary' | 'tertiary' | 'plain'
dangerous?: boolean // Destructive actions (red style)
icon?: string // Iconify name, e.g. 'tabler:plus'
iconPosition?: 'start' | 'end'
loading?: boolean // When true, disables clicks and renders a spinner
disabled?: boolean
namespace?: string // i18n translation prefix (defaults to 'common.buttons')
children?: ReactNode
}
type ButtonProps<T extends ElementType = 'button'> = ButtonOwnProps &
FlexProps<T>
```
| Category | Props |
|---|---|---|
| **Button-specific** | `variant`, `dangerous`, `icon`, `iconPosition`, `loading`, `disabled`, `namespace` |
| **Inherited from Flex** | All `FlexProps``mt`, `width`, `position`, `top`, `gap`, and everything from `BoxProps` (`bg`, `p`, `r`, `shadow`, etc.) |
Since `Button` extends `FlexProps` (which extends `BoxProps`), **any layout prop available on `Flex` or `Box` can be passed directly** — no wrapping `Box` needed. Only reach for `Box asChild` when you need a prop that Flex/Box doesn't support (e.g. CSS properties only available via inline `style`).
#### Notable Engineering Features:
1. **Dynamic Contrast Matching:** In `useButtonStyleProps`, when `variant="primary"` is set, the button fetches the user's active theme color (`derivedThemeColor`) and runs `getMostReadableColor()` to compute a text color with optimal contrast.
2. **Smart i18n Translation:** If the children is a string, it automatically attempts to search for translations across various namespaces (e.g., `buttons.cancel`, `common.buttons:cancel`).
@@ -564,6 +635,8 @@ type ButtonProps = {
variant="primary"
icon="tabler:send"
loading={isSubmitting}
mt="lg" // Inherited from Flex — no Box wrapper needed
width="100%" // Inherited from Flex
onClick={onSubmit}
>
sendFeedback
@@ -773,7 +846,198 @@ export default UserCreationPage
---
## 8. Developer Quick-Reference Checklist
---
## 8. Migration Guide: Legacy `component-bg-*` Utility Classes
The legacy `component-bg-*` utility classes have been **removed** from the codebase. These were Tailwind-based utilities defined in `src/core/styles/themes/componentBG.css`. Every one of them must be replaced with the corresponding `bg` prop on UI primitives (`Box`, `Flex`, `Card`, etc.).
### Legacy → Modern Mapping
| Legacy Class | Equivalent `surface` Preset |
|---|---|
| `component-bg` | `surface.default` |
| `component-bg-with-hover` | `surface.defaultInteractive` |
| `component-bg-lighter` | `surface.light` |
| `component-bg-lighter-with-hover` | `surface.lightInteractive` |
| `darker-component-bg-with-hover` | `{ base: 'bg-200', dark: 'bg-800', hover: 'bg-200', darkHover: 'bg-800' }` (no preset — inline if needed) |
The legacy classes also had `.has-bg-image &` nested variants that applied `backdrop-blur-xs` and reduced opacity. In the new system, the `.has-bg-image` and `.hasBgImage`/`.darkHasBgImage` state keys on the `bg` prop handle this automatically — you **do not** need to manually apply backdrop or opacity.
### Migration Pattern
Before (using removed Tailwind utilities):
```tsx
<button className="component-bg-lighter-with-hover w-full rounded-lg p-6 text-left">
<span className="font-medium">Item</span>
</button>
```
After (using UI primitives with `surface` preset):
```tsx
import { Box, Text, surface } from '@lifeforge/ui'
<Box
as="button"
bg={surface.lightInteractive}
p="lg"
r="lg"
width="100%"
>
<Text weight="medium">Item</Text>
</Box>
```
Most legacy classes map directly to a `surface` preset (see table above). Prefer using the preset over writing the object inline.
### Selected/Active State Borders
Legacy code often combined `component-bg-lighter-with-hover` with Tailwind border utilities to indicate selection:
```tsx
className="component-bg-lighter-with-hover border-2 border-custom-500"
```
Replace this with `Card` (which already handles interactive backgrounds and shadows), or use `Box` with `bg` state conditions directly:
```tsx
<Card isInteractive r="lg">
<Text weight="medium">Item</Text>
</Card>
```
The `Card` component with `isInteractive` applies the correct background mapping automatically:
```tsx
bg={
isInteractive
? { base: 'bg-50', dark: 'bg-900', hover: 'bg-100', darkHover: 'bg-800' }
: { base: 'bg-50', dark: 'bg-900' }
}
```
If you need custom background shades, use `Box` directly:
```tsx
<Box
as="button"
bg={{
base: 'bg-100',
dark: 'bg-800',
hover: 'bg-200',
darkHover: 'bg-700'
}}
p="lg"
r="lg"
width="100%"
>
<Text weight="medium">Item</Text>
</Box>
```
For selected-state borders, use the `Ring` primitive to wrap the card instead of absolute-position hacks:
```tsx
<Ring ringWidth="2px" ringColor="custom-500" r="lg">
<Card isInteractive ...>
...
</Card>
</Ring>
```
> [!NOTE]
> `Ring` applies the outline to its child using `outline`, which does not affect layout — no absolute positioning needed.
### Check Icon Overlay
When an item is selected, a check icon overlay can be placed with absolute positioning:
Before:
```tsx
<Icon className="text-custom-500 absolute right-1.5 bottom-2 size-6" icon="tabler:check" />
```
After:
```tsx
<Box position="absolute" bottom="sm" right="sm">
<Icon color="custom-500" icon="tabler:check" size="1.5em" />
</Box>
```
### Running a Migration
1. Search for `component-bg` in your component files.
2. Map each class to the `bg` prop using the table above.
3. Remove `className` entirely — use primitive props (`p`, `py`, `r`, `width`, etc.) for all layout.
4. Replace selected-state borders with `Bordered` overlay pattern.
5. Replace all `text-custom-500`, `text-bg-500` etc. Tailwind color classes with the `color` prop on `Text` or `Icon`.
---
## 9. Migration Guide: `@iconify/react` → `@lifeforge/ui` Icon Primitive
The standalone `Icon` from `@iconify/react` must never be imported directly. Always use the `Icon` primitive exported by `@lifeforge/ui`.
### Key Differences
| Aspect | `@iconify/react` Icon | `@lifeforge/ui` Icon |
|---|---|---|
| Import | `import { Icon } from '@iconify/react'` | `import { Icon } from '@lifeforge/ui'` |
| Sizing | `width` / `height` props (e.g. `width="1.5em"`) | **`size`** prop (e.g. `size="1.5em"`) |
| Color | `className="text-custom-500"` (Tailwind) | `color="custom-500"` (design token) |
| Layout | `className="absolute right-1.5 bottom-2"` (Tailwind) | `Box asChild position="absolute" bottom="sm" right="sm"` |
| Inherits | — | All `Text` props (`ml`, `mr`, `display`, `truncate`, etc.) |
> `Icon` is built on top of the `Text` primitive, so any prop that `Text` accepts (e.g. `ml`, `mr`, `display`, `truncate`, `wrap`, `className`, `style`) works directly on `Icon` without needing a `Box` wrapper.
### Migration Pattern
Before:
```tsx
import { Icon } from '@iconify/react'
<Icon
className="text-custom-500"
icon="tabler:check"
width="1.5em"
height="1.5em"
/>
```
After (positioned):
```tsx
import { Box, Icon } from '@lifeforge/ui'
<Box asChild position="absolute" bottom="sm" right="sm">
<Icon
color="custom-500"
icon="tabler:check"
size="1.5em"
/>
</Box>
```
After (inline — no positioning needed):
```tsx
import { Icon } from '@lifeforge/ui'
<Icon
color="custom-500"
icon="tabler:check"
/>
```
> `Box asChild` is only needed when the Icon needs layout props it doesn't inherit (`position`, `top`, `left`, etc.). For spacing (`ml`, `mr`) and display properties, pass them directly to `Icon` since it inherits all `Text` props.
### Migration Checklist
1. Replace `import { Icon } from '@iconify/react'` with `import { Icon } from '@lifeforge/ui'` (or add `Icon` to the existing destructured import from `@lifeforge/ui`).
2. Replace `width="..." height="..."` with a single `size="..."`.
3. Replace `className="text-custom-500"` / `className="text-bg-500"` with `color="custom-500"` / `color="muted"`.
4. For positioned icons (`position`, `top`, `right`, `bottom`), wrap with `Box asChild`. For spacing and display, pass props directly to `Icon` (it inherits all `Text` props).
5. Remove all remaining `className` props from `Icon` — they are not used anywhere in the codebase.
---
## 10. Developer Quick-Reference Checklist
Before submitting a pull request, verify that you have adhered to all core design patterns:

View File

@@ -1,11 +1,11 @@
import { Card } from '@/components/layout'
import { Card, type CardProps } from '@/components/layout'
import { Flex, Icon, Text } from '@/components/primitives'
import { Tooltip } from '@/components/utilities'
import type { ResponsiveProp, SpaceToken } from '@/system'
type DirectionValue = 'row' | 'column' | 'row-reverse' | 'column-reverse'
interface OptionsColumnProps {
interface OptionsColumnProps extends Omit<CardProps, 'title'> {
/** The title of the configuration column */
title: string | React.ReactNode
/** A brief description of the configuration column */
@@ -32,7 +32,8 @@ export function OptionsColumn({
orientation = 'horizontal',
tooltip,
children,
breakpoint = 'md'
breakpoint = 'md',
...rest
}: OptionsColumnProps) {
const getDirection = (): ResponsiveProp<DirectionValue> => {
if (orientation === 'vertical') return 'column'
@@ -56,7 +57,13 @@ export function OptionsColumn({
}
return (
<Card direction={getDirection()} gapX="xl" gapY="md" justify="between">
<Card
direction={getDirection()}
gapX="xl"
gapY="md"
justify="between"
{...rest}
>
<Flex align="center" flexShrink="1" gap="md">
<Icon color="muted" icon={icon} mx="sm" size="1.8em" />
<Flex direction="column">

View File

@@ -58,10 +58,12 @@ export function SearchResults({
renderPhoto={({ photo, imageProps: { src, alt, style } }) => (
<Transition>
<Ring
asChild
ringColor={
photo.fullResURL === file
? 'custom-500'
: {
base: 'transparent',
hover: 'bg-400',
darkHover: 'bg-600'
}
@@ -75,6 +77,7 @@ export function SearchResults({
overflow="hidden"
r="md"
style={style as CSSProperties}
width="100%"
onClick={() => {
setFile(photo.fullResURL)
setPreview(photo.src)

View File

@@ -154,18 +154,22 @@ export function FileInput({
<Flex align="center" gap="xl" justify="between" mt="md">
<Flex align="center" gap="sm" minWidth="0">
<Icon
color="muted"
icon={
FILE_ICONS[
(file instanceof File
? file.name.split('.').pop()
: '') as keyof typeof FILE_ICONS
] || 'tabler:file'
}
size="1.5rem"
/>
color="muted"
icon={
FILE_ICONS[
(file instanceof File
? file.name.split('.').pop()
: '') as keyof typeof FILE_ICONS
] || 'tabler:file'
}
size="1.5rem"
/>
<Text truncate as="p">
{file instanceof File ? file.name : file}
{file instanceof File
? file.name
: file === 'keep'
? (preview ?? '<File to be remained unchanged>')
: (preview ?? file)}
</Text>
</Flex>
<Button

View File

@@ -28,7 +28,7 @@ function _IconEntry({
width="100%"
onClick={handleIconSelected}
>
<Icon icon={`${iconSet}:${icon}`} size="8em" />
<Icon icon={`${iconSet}:${icon}`} size="2em" />
<Text
align="center"
mt="md"

View File

@@ -1,12 +1,12 @@
import clsx from 'clsx'
import { Box } from '@/components/primitives'
import { Box, type BoxProps } from '@/components/primitives'
import * as styles from './SliderInput.css'
import { SliderHeader } from './components/SliderHeader'
import { SliderTicks } from './components/SliderTicks'
interface SliderInputProps {
interface SliderInputProps extends Omit<BoxProps<'div'>, 'value' | 'onChange'> {
label?: string
icon?: string
value: number
@@ -17,7 +17,6 @@ interface SliderInputProps {
max?: number
step?: number
className?: string
wrapperClassName?: string
namespace?: string
}
@@ -32,13 +31,13 @@ export function SliderInput({
max = 100,
step = 1,
className,
wrapperClassName,
namespace
namespace,
...rest
}: SliderInputProps) {
const progress = ((value - min) / (max - min)) * 100
return (
<Box className={wrapperClassName} width="100%">
<Box width="100%" {...rest}>
<SliderHeader
icon={icon}
label={label}

View File

@@ -1,5 +1,7 @@
import React from 'react'
import { surface } from '@/system/colors/surfaces'
import { Flex, type FlexProps } from '../../primitives'
export type CardProps<T extends React.ElementType = 'div'> = FlexProps<T> & {
@@ -14,16 +16,7 @@ export function Card<T extends React.ElementType = 'div'>({
return (
<Flex
shadow
bg={
isInteractive
? {
base: 'bg-50',
dark: 'bg-900',
hover: 'bg-100',
darkHover: 'bg-800'
}
: { base: 'bg-50', dark: 'bg-900' }
}
bg={isInteractive ? surface.defaultInteractive : surface.default}
direction="column"
p="md"
position="relative"

View File

@@ -12,8 +12,6 @@ export function NavButton({
}): React.ReactElement {
const icon = direction === 'previous' ? 'uil:angle-left' : 'uil:angle-right'
const label = direction === 'previous' ? 'Previous' : 'Next'
if (hidden) {
return <Box width={{ base: '3em', sm: '8em' }} />
}
@@ -25,6 +23,7 @@ export function NavButton({
base: 'none',
sm: 'flex'
}}
justify={direction === 'previous' ? 'start' : 'end'}
width="9em"
>
<Button
@@ -33,7 +32,7 @@ export function NavButton({
variant="plain"
onClick={onClick}
>
{label}
{direction === 'previous' ? 'Previous' : 'Next'}
</Button>
</Flex>
<Flex

View File

@@ -28,7 +28,8 @@ export {
TAILWIND_PALETTE,
COLORS,
colorWithOpacity,
ColorWithOpacity
ColorWithOpacity,
surface
} from './system'
export type {

View File

@@ -3,3 +3,5 @@ export * from './constants'
export * from './color-with-opacity'
export * from './color-resolver'
export * from './surfaces'

View File

@@ -0,0 +1,24 @@
import { colorWithOpacity } from './color-with-opacity'
export const surface = {
light: {
base: 'bg-100' as const,
dark: 'bg-800' as const
},
lightInteractive: {
base: 'bg-100' as const,
dark: 'bg-800' as const,
hover: colorWithOpacity('bg-200', '70%'),
darkHover: colorWithOpacity('bg-700', '50%')
},
default: {
base: 'bg-50' as const,
dark: 'bg-900' as const
},
defaultInteractive: {
base: 'bg-50' as const,
dark: 'bg-900' as const,
hover: 'bg-100' as const,
darkHover: 'bg-800' as const
}
} as const

View File

@@ -27,6 +27,8 @@
},
"dependencies": {
"@lifeforge/log": "workspace:*",
"@lifeforge/server-utils": "workspace:*",
"@lifeforge/shared": "workspace:*",
"bcryptjs": "^3.0.2",
"chalk": "^5.4.1",
"commander": "^14.0.2",
@@ -42,17 +44,16 @@
"lodash": "^4.17.21",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"node-cache": "^5.1.2",
"openai": "^6.7.0",
"pdf2pic": "^3.2.0",
"pocketbase": "^0.26.2",
"request": "^2.88.2",
"@lifeforge/shared": "workspace:*",
"socket.io": "^4.8.1",
"speakeasy": "^2.0.0",
"tesseract.js": "^6.0.1",
"uuid": "^9.0.1",
"zod": "4.3.5",
"@lifeforge/server-utils": "workspace:*"
"zod": "4.3.5"
},
"devDependencies": {
"@eslint/js": "^9.31.0",
@@ -65,6 +66,7 @@
"@types/lodash": "^4.17.21",
"@types/morgan": "^1.9.9",
"@types/multer": "^1.4.12",
"@types/node-cache": "^4.2.5",
"@types/request": "^2.48.12",
"@types/speakeasy": "^2.0.10",
"@types/uuid": "^10.0.0",

View File

@@ -28,8 +28,6 @@ const get = forge
.execute()
.catch(() => null)
console.log(await pb.getFullList.collection('entries').execute())
if (!entry) {
throw new ClientError('API Key not found', 404)
}

View File

@@ -1,9 +1,12 @@
import NodeCache from 'node-cache'
import z from 'zod'
import { ClientError } from '@lifeforge/server-utils'
import forge from '../forge'
const fontCache = new NodeCache({ stdTTL: 86400, checkperiod: 3600 })
interface FontFamily {
family: string
variants: string[]
@@ -67,6 +70,13 @@ export const listGoogleFonts = forge
api: { getAPIKey }
}
}) => {
const cached = fontCache.get<{ enabled: boolean; items: FontFamily[] }>(
'listGoogleFonts'
)
if (cached) {
return cached
}
const key = await getAPIKey('gcloud', pb)
if (!key) {
@@ -82,10 +92,14 @@ export const listGoogleFonts = forge
const data = await response.json()
return {
const result = {
enabled: true,
items: data.items as FontFamily[]
}
fontCache.set('listGoogleFonts', result)
return result
}
)
@@ -105,6 +119,14 @@ export const getGoogleFont = forge
api: { getAPIKey }
}
}) => {
const cacheKey = `getGoogleFont:${family}`
const cached = fontCache.get<{ enabled: boolean; items?: unknown }>(
cacheKey
)
if (cached) {
return cached
}
const key = await getAPIKey('gcloud', pb).catch(() => null)
if (!key) {
@@ -119,10 +141,14 @@ export const getGoogleFont = forge
const data = await response.json()
return {
const result = {
enabled: true,
items: data.items
}
fontCache.set(cacheKey, result)
return result
}
)

View File

@@ -312,8 +312,6 @@ export default class ForgeEndpoint<
return this.mutateRaw(data)
}
console.log(data)
// Encrypt the request
const { payload, session } = await encryptRequest(data)