mirror of
https://github.com/Lifeforge-app/lifeforge.git
synced 2026-06-28 06:46:24 +00:00
feat: client codebase refactoring completed
This commit is contained in:
@@ -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
|
||||
|
||||
1
apps/lifeforge--wallet
Submodule
1
apps/lifeforge--wallet
Submodule
Submodule apps/lifeforge--wallet added at 1a8dc9953d
@@ -2,8 +2,5 @@
|
||||
"name": "@lifeforge/apps",
|
||||
"private": true,
|
||||
"description": "LifeForge modules",
|
||||
"dependencies": {
|
||||
"@lifeforge/TedMeadow--lang-ru": "workspace:*",
|
||||
"@lifeforge/lifeforge--achievements": "workspace:*"
|
||||
}
|
||||
"dependencies": {}
|
||||
}
|
||||
|
||||
80
bun.lock
80
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function Quotes() {
|
||||
return (
|
||||
<Card
|
||||
centered
|
||||
bg="custom-500"
|
||||
bg="primary"
|
||||
gap="sm"
|
||||
height="100%"
|
||||
position="relative"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
|
||||
@@ -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('#') && (
|
||||
<>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FontFamily } from '../components/FontFamilySelectorModal/tabs/GoogleFontSelector'
|
||||
import type { FontFamily } from '..'
|
||||
|
||||
const fontRuleMap = new Map<string, number[]>()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,7 @@ function Sidebar() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
return (
|
||||
<Transition>
|
||||
<Transition duration="300ms">
|
||||
<Flex
|
||||
shadow
|
||||
as="aside"
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,7 +28,8 @@ export {
|
||||
TAILWIND_PALETTE,
|
||||
COLORS,
|
||||
colorWithOpacity,
|
||||
ColorWithOpacity
|
||||
ColorWithOpacity,
|
||||
surface
|
||||
} from './system'
|
||||
|
||||
export type {
|
||||
|
||||
@@ -3,3 +3,5 @@ export * from './constants'
|
||||
export * from './color-with-opacity'
|
||||
|
||||
export * from './color-resolver'
|
||||
|
||||
export * from './surfaces'
|
||||
|
||||
24
packages/ui/src/system/colors/surfaces.ts
Normal file
24
packages/ui/src/system/colors/surfaces.ts
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -312,8 +312,6 @@ export default class ForgeEndpoint<
|
||||
return this.mutateRaw(data)
|
||||
}
|
||||
|
||||
console.log(data)
|
||||
|
||||
// Encrypt the request
|
||||
const { payload, session } = await encryptRequest(data)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user