From 10e42bcf261f0f5312b0f508aedaa478029e5773 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 1 May 2026 00:44:06 +0200 Subject: [PATCH] fix: address framework papercuts (#66, #68, #71, #73, #75, #76, #81, #90, #92, #93, #94, #95) - #95: users.login_redirect_url setting; auto-fallback to / when Dashboard absent - #94 + #68: worker entrypoint loads .env via shared load_dotenv_into_environ; schema-per-module driven by SM_SCHEMA_PER_MODULE (URL fallback kept) - #93: CSP gains worker-src/child-src for blob: workers (MapLibre, WASM) - #92: NavIcon swaps hand-rolled SVGs for tree-shaken lucide-react named imports - #90: register_event_handlers gains optional app=; back-compat dispatch shim - #81: SM_USERS_*_TOKEN_SECRET re-enabled as boot-time env-var override - #76: init_db accepts poolclass (NullPool) for cross-loop test setups - #75: drop tests/__init__.py from module scaffold (pytest plugin collision) - #71: testing plugin pre-builds eager Celery for non-client fixtures; new task_always_eager / task_eager_propagates settings honoured by build_celery - #66: vite resolve.dedupe + optimizeDeps for React/JSX runtimes so module-shipped pages share React identity with the host Adds shared dotenv helpers (env_str, env_bool, load_dotenv_into_environ, BOOL_LITERALS_*) in simple_module_core to consolidate duplicated env parsing. --- .../_optional/background_tasks/run_worker.py | 13 +- .../templates/host/client_app/vite.config.ts | 16 +- .../templates/module/tests/__init__.py | 0 .../cli/tests/test_scaffolding_module.py | 6 +- framework/core/simple_module_core/dotenv.py | 33 +- framework/core/simple_module_core/module.py | 9 +- framework/db/simple_module_db/base.py | 26 +- framework/db/simple_module_db/session.py | 13 +- .../simple_module_hosting/app_builder.py | 19 +- .../simple_module_hosting/middleware.py | 7 + .../testing/simple_module_test/plugin.py | 20 ++ host/client_app/vite.config.ts | 14 + .../background_tasks/celery_app.py | 5 + .../background_tasks/settings.py | 8 + modules/users/users/endpoints/views.py | 6 +- modules/users/users/module.py | 8 + modules/users/users/pages/Login.tsx | 8 +- modules/users/users/settings.py | 32 +- packages/ui/src/components/NavIcon.tsx | 332 ++++++++---------- scripts/run_worker.py | 13 +- 20 files changed, 376 insertions(+), 212 deletions(-) delete mode 100644 framework/cli/simple_module_cli/templates/module/tests/__init__.py diff --git a/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py index 82788b90..4ef96412 100644 --- a/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +++ b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py @@ -11,7 +11,16 @@ from __future__ import annotations -from background_tasks.celery_app import build_celery -from background_tasks.settings import BackgroundTasksSettings +from pathlib import Path + +from simple_module_core.dotenv import load_dotenv_into_environ + +# Match the precedence uvicorn gives the web process: load ``.env`` into the +# environment before importing settings, so the worker doesn't fall back to +# SQLite defaults when celery is launched outside the host's cwd. +load_dotenv_into_environ(Path(__file__).resolve().parent.parent / ".env") + +from background_tasks.celery_app import build_celery # noqa: E402 +from background_tasks.settings import BackgroundTasksSettings # noqa: E402 celery = build_celery(BackgroundTasksSettings()) diff --git a/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts index 969688a1..5bed11e8 100644 --- a/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts +++ b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts @@ -22,11 +22,21 @@ if (fs.existsSync(manifestPath)) { export default defineConfig({ plugins: [react(), tailwindcss()], root: __dirname, - // Pre-bundle the use-sync-external-store CJS shim so its named export - // resolves under ESM. recharts (and anything else pulling in react-redux) - // breaks without this. + // Force every importer to resolve to one React copy — without it, + // plugin-react's Fast Refresh preamble check fires in a realm where its + // global was never set ("can't detect preamble"). + resolve: { + dedupe: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'], + }, + // ``use-sync-external-store`` is the CJS shim recharts/react-redux pull + // in; pre-bundling resolves its named export under ESM. optimizeDeps: { include: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', 'use-sync-external-store', 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector', diff --git a/framework/cli/simple_module_cli/templates/module/tests/__init__.py b/framework/cli/simple_module_cli/templates/module/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/framework/cli/tests/test_scaffolding_module.py b/framework/cli/tests/test_scaffolding_module.py index ece250ff..4773e570 100644 --- a/framework/cli/tests/test_scaffolding_module.py +++ b/framework/cli/tests/test_scaffolding_module.py @@ -19,13 +19,17 @@ async def test_creates_expected_module_files(self, tmp_path): "my_feature/module.py", "my_feature/endpoints/__init__.py", "my_feature/endpoints/api.py", - "tests/__init__.py", "tests/test_module.py", ".gitignore", "README.md", ]: assert (dest / relpath).is_file(), f"missing: {relpath}" + # `tests/__init__.py` is intentionally NOT shipped: under + # `--import-mode=importlib`, having one in two modules makes + # pytest try to register `tests.conftest` as a plugin twice. + assert not (dest / "tests" / "__init__.py").exists() + async def test_pyproject_declares_entry_point_and_deps(self, tmp_path): """pyproject.toml sets the entry_point and pins the framework API range.""" from simple_module_cli.scaffolding import create_module diff --git a/framework/core/simple_module_core/dotenv.py b/framework/core/simple_module_core/dotenv.py index 4c75bc11..6842ca80 100644 --- a/framework/core/simple_module_core/dotenv.py +++ b/framework/core/simple_module_core/dotenv.py @@ -1,4 +1,4 @@ -"""Minimal ``.env`` parser — dependency-free. +"""Minimal ``.env`` parser + env-var helpers — dependency-free. Used in places that can't or shouldn't pull in ``pydantic-settings`` (the diagnostics CLI runs before the host package is imported; the users-module @@ -11,6 +11,9 @@ import os from pathlib import Path +BOOL_LITERALS_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"}) +BOOL_LITERALS_FALSE = frozenset({"0", "false", "f", "no", "n", "off"}) + def parse_dotenv(path: Path | None = None) -> dict[str, str]: """Parse a ``.env`` file into a dict. Empty dict if the file is missing. @@ -36,3 +39,31 @@ def parse_dotenv(path: Path | None = None) -> dict[str, str]: key, _, value = line.partition("=") parsed[key.strip()] = value.strip().strip('"').strip("'") return parsed + + +def load_dotenv_into_environ(path: Path | None = None) -> None: + """Merge ``parse_dotenv(path)`` into ``os.environ`` via ``setdefault``. + + Same precedence as the web process under uvicorn: real environment wins + over file values. Worker entrypoints call this before importing settings. + """ + for key, value in parse_dotenv(path).items(): + os.environ.setdefault(key, value) + + +def env_str(name: str, default: str) -> str: + """Return ``$name`` if set and non-empty, else ``default``.""" + value = os.environ.get(name, "").strip() + return value or default + + +def env_bool(name: str, default: bool = False) -> bool: + """Parse ``$name`` as a boolean, returning ``default`` when unset/blank.""" + raw = os.environ.get(name, "").strip().lower() + if not raw: + return default + if raw in BOOL_LITERALS_TRUE: + return True + if raw in BOOL_LITERALS_FALSE: + return False + return default diff --git a/framework/core/simple_module_core/module.py b/framework/core/simple_module_core/module.py index da88f952..84b91610 100644 --- a/framework/core/simple_module_core/module.py +++ b/framework/core/simple_module_core/module.py @@ -107,8 +107,13 @@ def register_permissions(self, registry: PermissionRegistry) -> None: def register_feature_flags(self, registry: FeatureFlagRegistry) -> None: """Declare feature flags this module exposes.""" - def register_event_handlers(self, bus: EventBus) -> None: - """Subscribe to events published by other modules.""" + def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None: + """Subscribe to events published by other modules. + + ``app`` is optional for back-compat; pass it through to handlers + that need ``app.state.sm.db.session_factory`` to persist on the + framework's engine instead of building their own. + """ def register_health_checks(self, registry: HealthRegistry) -> None: """Contribute health checks for the ``/health/ready`` endpoint.""" diff --git a/framework/db/simple_module_db/base.py b/framework/db/simple_module_db/base.py index 36d428fd..1def82c1 100644 --- a/framework/db/simple_module_db/base.py +++ b/framework/db/simple_module_db/base.py @@ -4,6 +4,7 @@ import os +from simple_module_core.dotenv import env_bool from sqlalchemy import MetaData from sqlmodel import SQLModel @@ -39,13 +40,28 @@ def _register_base(base: type[SQLModel]) -> None: def _default_provider() -> DatabaseProvider: - """Resolve the active provider from ``SM_DATABASE_URL`` at import time. - - Falls back to SQLite when the variable is unset, keeping the common - dev-loop happy without requiring callers to plumb the provider through. + """Resolve the schema layout to register module tables under. + + Drives whether module tables get a dedicated Postgres schema + (``orders.``) or share the public schema with prefixed names + (``orders_
``). ``SM_SCHEMA_PER_MODULE`` is authoritative when + set; the ``SM_DATABASE_URL`` fallback is kept for back-compat with + deployments that haven't migrated to the explicit knob — workers and + the web process can disagree on whether the URL is set, so prefer + the explicit form going forward. """ + explicit = os.environ.get("SM_SCHEMA_PER_MODULE") + if explicit is not None: + return ( + DatabaseProvider.POSTGRESQL + if env_bool("SM_SCHEMA_PER_MODULE") + else DatabaseProvider.SQLITE + ) + url = os.environ.get("SM_DATABASE_URL", "") - return detect_provider(url) if url else DatabaseProvider.SQLITE + if url: + return detect_provider(url) + return DatabaseProvider.SQLITE def create_module_base( diff --git a/framework/db/simple_module_db/session.py b/framework/db/simple_module_db/session.py index a3f0c3df..eb3843f5 100644 --- a/framework/db/simple_module_db/session.py +++ b/framework/db/simple_module_db/session.py @@ -33,12 +33,15 @@ def init_db( max_overflow: int = 20, pool_pre_ping: bool = True, pool_recycle: int = 1800, + poolclass: type | None = None, ) -> DatabaseState: """Create an async engine and session factory. - The pool options only take effect for server-side providers (Postgres). - SQLite uses SQLAlchemy's default pool (single-file, no network), so - passing ``pool_size``/etc. would raise ``TypeError`` — skipped below. + Pool tuning only applies to server-side providers (Postgres) — SQLite + rejects ``pool_size``/etc. Pass ``poolclass=NullPool`` from test + fixtures running against asyncpg/Postgres so pytest-asyncio's per-test + event loops don't outlive pooled connections; the pool-tuning kwargs + are ignored in that case. Returns a ``DatabaseState`` that should be stored on ``app.state.db``. """ @@ -46,9 +49,11 @@ def init_db( connect_args: dict = {} engine_kwargs: dict = {"echo": echo, "connect_args": connect_args} + if poolclass is not None: + engine_kwargs["poolclass"] = poolclass if provider == DatabaseProvider.SQLITE: connect_args["check_same_thread"] = False - else: + elif poolclass is None: engine_kwargs.update( pool_size=pool_size, max_overflow=max_overflow, diff --git a/framework/hosting/simple_module_hosting/app_builder.py b/framework/hosting/simple_module_hosting/app_builder.py index 79d8945d..c30d7028 100644 --- a/framework/hosting/simple_module_hosting/app_builder.py +++ b/framework/hosting/simple_module_hosting/app_builder.py @@ -2,6 +2,7 @@ from __future__ import annotations +import inspect import logging import os from collections.abc import AsyncGenerator @@ -75,6 +76,22 @@ def _resolve_project_root() -> Path: _PROJECT_ROOT = _resolve_project_root() +def _register_event_handlers(mod, event_bus: EventBus, app: FastAPI) -> None: + """Dispatch to ``mod.register_event_handlers`` with or without ``app``. + + Back-compat shim for modules that still override the one-arg form + ``(self, bus)``; passing ``app=`` to those crashes. + """ + sig = inspect.signature(mod.register_event_handlers) + accepts_app = "app" in sig.parameters or any( + p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values() + ) + if accepts_app: + mod.register_event_handlers(event_bus, app=app) + else: + mod.register_event_handlers(event_bus) + + def create_app(settings: Settings | None = None) -> FastAPI: """Build and configure the full FastAPI application. @@ -218,7 +235,7 @@ class _HostServices: mod.register_menu_items(menu_registry) mod.register_permissions(perm_registry) mod.register_feature_flags(ff_registry) - mod.register_event_handlers(event_bus) + _register_event_handlers(mod, event_bus, app) mod.register_health_checks(health_registry) logger.info( diff --git a/framework/hosting/simple_module_hosting/middleware.py b/framework/hosting/simple_module_hosting/middleware.py index 3fdbce37..174647f6 100644 --- a/framework/hosting/simple_module_hosting/middleware.py +++ b/framework/hosting/simple_module_hosting/middleware.py @@ -79,6 +79,11 @@ class SecurityHeadersMiddleware: "img-src 'self' data: blob:; " "font-src 'self' https://fonts.gstatic.com data:; " "connect-src 'self'; " + # Allow blob: workers — MapLibre, comlink, web-tree-sitter and most + # WASM libs ship their worker as a Blob URL. `child-src` is the + # legacy fallback some browsers consult before `worker-src`. + "worker-src 'self' blob:; " + "child-src 'self' blob:; " "frame-ancestors 'self'; " "base-uri 'self'; " "form-action 'self'" @@ -104,6 +109,8 @@ def dev_csp(vite_dev_url: str) -> str: "img-src 'self' data: blob:; " "font-src 'self' https://fonts.gstatic.com data:; " f"connect-src 'self' {vite_dev_url} {ws_url}; " + "worker-src 'self' blob:; " + "child-src 'self' blob:; " "frame-ancestors 'self'; " "base-uri 'self'; " "form-action 'self'" diff --git a/framework/testing/simple_module_test/plugin.py b/framework/testing/simple_module_test/plugin.py index 8d53d563..8276909f 100644 --- a/framework/testing/simple_module_test/plugin.py +++ b/framework/testing/simple_module_test/plugin.py @@ -8,12 +8,32 @@ from __future__ import annotations +import contextlib + import pytest from simple_module_test.app_factory import build_test_app as _build_test_app from simple_module_test.fake_events import FakeEventBus +def _bootstrap_eager_celery() -> None: + """Register a process-wide eager Celery app before any test runs. + + Tests that don't use the ``client`` fixture never trigger the lifespan, + so the host's ``build_celery`` call never runs and ``task.delay()`` + falls through to the broker. Skipped silently when ``background_tasks`` + isn't installed. + """ + with contextlib.suppress(ImportError): + from background_tasks.celery_app import build_celery + from background_tasks.settings import BackgroundTasksSettings + + build_celery(BackgroundTasksSettings(task_always_eager=True, task_eager_propagates=True)) + + +_bootstrap_eager_celery() + + @pytest.fixture def fake_event_bus() -> FakeEventBus: """Fresh recording EventBus for each test.""" diff --git a/host/client_app/vite.config.ts b/host/client_app/vite.config.ts index a3fc9514..c8900d89 100644 --- a/host/client_app/vite.config.ts +++ b/host/client_app/vite.config.ts @@ -49,6 +49,20 @@ export default defineConfig({ // feed Vite the per-module tsconfigs. resolve: { tsconfigPaths: true, + // Force every importer (host, workspace module, wheel-installed module) + // to resolve to one React copy — without it, plugin-react's Fast + // Refresh preamble check fires in a realm where its global was never + // set ("can't detect preamble"). + dedupe: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'], + }, + optimizeDeps: { + include: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + ], }, root: __dirname, build: { diff --git a/modules/background_tasks/background_tasks/celery_app.py b/modules/background_tasks/background_tasks/celery_app.py index e7aa87d0..1eaa5b40 100644 --- a/modules/background_tasks/background_tasks/celery_app.py +++ b/modules/background_tasks/background_tasks/celery_app.py @@ -55,6 +55,11 @@ def build_celery(settings: BackgroundTasksSettings) -> Celery: broker_url=settings.broker_url, result_backend=settings.result_backend, task_default_queue=settings.task_default_queue, + # Run tasks synchronously inside the calling process. Tests + # toggle this on (via ``SM_BG_TASKS_TASK_ALWAYS_EAGER=true``) so + # ``task.delay()`` doesn't reach for a real broker. + task_always_eager=settings.task_always_eager, + task_eager_propagates=settings.task_eager_propagates, # ``task_track_started`` gives us the ``STARTED`` state so # ``task_prerun`` can flip our row to ``running``. task_track_started=True, diff --git a/modules/background_tasks/background_tasks/settings.py b/modules/background_tasks/background_tasks/settings.py index bfe03a0e..63d84a6d 100644 --- a/modules/background_tasks/background_tasks/settings.py +++ b/modules/background_tasks/background_tasks/settings.py @@ -22,6 +22,7 @@ from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from simple_module_core.dotenv import env_bool from simple_module_core.environments import NON_PROD_ENVIRONMENTS from background_tasks.constants import ( @@ -47,6 +48,13 @@ class BackgroundTasksSettings(BaseSettings): result_backend: str = Field(default=DEFAULT_RESULT_BACKEND, json_schema_extra=_CELERY_RESTART) task_default_queue: str = Field(default=DEFAULT_QUEUE, json_schema_extra=_CELERY_RESTART) + # Run tasks synchronously inside the calling process. Read at + # module-import time so tests can flip it on via ``SM_BG_TASKS_*`` + # without going through DB-backed hydration (which never fires for + # suites that don't use the FastAPI lifespan). + task_always_eager: bool = env_bool("SM_BG_TASKS_TASK_ALWAYS_EAGER") + task_eager_propagates: bool = True + # A task that has been ``running`` longer than this without a heartbeat is # flipped to ``stuck`` by the beat sweep. 5 min is long enough to cover # normal slow jobs while still surfacing wedged workers within one UI diff --git a/modules/users/users/endpoints/views.py b/modules/users/users/endpoints/views.py index 333df581..fd4955f7 100644 --- a/modules/users/users/endpoints/views.py +++ b/modules/users/users/endpoints/views.py @@ -68,7 +68,11 @@ async def login_page(request: Request, inertia: InertiaDep) -> InertiaResponse: dev_accounts.append({"label": "User", "email": user_email, "password": user_password}) return await inertia.render( _PAGE_LOGIN, - {"allow_signup": users_settings.allow_signup, "dev_accounts": dev_accounts}, + { + "allow_signup": users_settings.allow_signup, + "dev_accounts": dev_accounts, + "login_redirect_url": users_settings.login_redirect_url, + }, ) diff --git a/modules/users/users/module.py b/modules/users/users/module.py index 390c0f83..16d46c7e 100644 --- a/modules/users/users/module.py +++ b/modules/users/users/module.py @@ -147,6 +147,14 @@ async def on_startup(self, app: FastAPI) -> None: window_seconds=s.auth_rate_limit_window_seconds, ) + # Auto-fall-back from the default ``/dashboard/`` to ``/`` when the + # Dashboard module isn't installed, so ``--preset minimal`` doesn't + # 404 on login. Operator-set overrides are preserved. + if s.login_redirect_url == "/dashboard/" and not any( + m.meta.name == "Dashboard" for m in app.state.sm.modules + ): + s.login_redirect_url = "/" + reconfigure_cookie_transport(auth_backend, s) await asyncio.gather( diff --git a/modules/users/users/pages/Login.tsx b/modules/users/users/pages/Login.tsx index 1b21d5e8..0733be38 100644 --- a/modules/users/users/pages/Login.tsx +++ b/modules/users/users/pages/Login.tsx @@ -21,10 +21,12 @@ interface DevAccount { interface Props { allow_signup: boolean; dev_accounts: DevAccount[]; + login_redirect_url: string; } function Login() { - const { allow_signup, dev_accounts } = usePage<{ props: Props }>().props as unknown as Props; + const { allow_signup, dev_accounts, login_redirect_url } = usePage<{ props: Props }>() + .props as unknown as Props; const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -34,8 +36,8 @@ function Login() { const nextUrl = typeof window !== 'undefined' - ? new URLSearchParams(window.location.search).get('next') || '/dashboard/' - : '/dashboard/'; + ? new URLSearchParams(window.location.search).get('next') || login_redirect_url + : login_redirect_url; const submitLogin = (username: string, pwd: string) => { setError(null); diff --git a/modules/users/users/settings.py b/modules/users/users/settings.py index 3e6cba57..64b20e03 100644 --- a/modules/users/users/settings.py +++ b/modules/users/users/settings.py @@ -1,13 +1,11 @@ """Users module settings — DB-backed via ``register_module_settings``. -Construction no longer reads ``SM_USERS_*`` environment variables. Values -come from pydantic defaults at boot, then get hydrated from the DB by the -hosting lifespan before module ``on_startup`` runs. Runtime changes go -through ``settings.reload.apply_changes_and_reload``. - -The one remaining env read is ``SM_ENVIRONMENT``, consulted by the -``@model_validator`` to refuse placeholder token secrets in production — -that's a host-level setting, not a users-module field. +Most fields hydrate from the DB after boot, with pydantic defaults filling in +until then. Runtime changes go through ``settings.reload.apply_changes_and_reload``. + +The two token-secret fields read ``SM_USERS_*`` at import time as a bootstrap +path: a fresh production deploy needs to clear the validator below before any +DB-backed settings can be seeded, otherwise the two paths deadlock. """ from __future__ import annotations @@ -16,6 +14,7 @@ from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from simple_module_core.dotenv import env_str from simple_module_core.environments import NON_PROD_ENVIRONMENTS _PLACEHOLDER_RESET_SECRET = "dev-reset-token-secret-change-me" @@ -31,10 +30,23 @@ class UsersSettings(BaseSettings): allow_signup: bool = False require_verification: bool = True + # Where the login page sends a successful sign-in. Sites without the + # bundled ``dashboard`` module (``sm new --preset minimal``) override + # this to wherever their post-login landing lives. + login_redirect_url: str = "/dashboard/" + # Token secrets — MUST be set in production. Dev default is a deterministic # placeholder that's obvious in logs so it can't be mistaken for a real key. - reset_password_token_secret: str = "dev-reset-token-secret-change-me" - verification_token_secret: str = "dev-verify-token-secret-change-me" + # Resolved at module-import time so ``info.default`` stays serializable + # for the settings admin UI. + reset_password_token_secret: str = env_str( + "SM_USERS_RESET_PASSWORD_TOKEN_SECRET", + _PLACEHOLDER_RESET_SECRET, + ) + verification_token_secret: str = env_str( + "SM_USERS_VERIFICATION_TOKEN_SECRET", + _PLACEHOLDER_VERIFY_SECRET, + ) reset_password_token_lifetime_seconds: int = 60 * 60 # 1 hour verification_token_lifetime_seconds: int = 60 * 60 * 24 * 7 # 7 days diff --git a/packages/ui/src/components/NavIcon.tsx b/packages/ui/src/components/NavIcon.tsx index 3037b7e7..7bd90a39 100644 --- a/packages/ui/src/components/NavIcon.tsx +++ b/packages/ui/src/components/NavIcon.tsx @@ -1,181 +1,159 @@ -import type React from 'react'; +import { + Activity, + AlertCircle, + Archive, + BarChart3, + Bell, + Bookmark, + Briefcase, + Calendar, + Camera, + Check, + ChevronDown, + ChevronRight, + Clock, + Cloud, + Code, + Cog, + Copy, + Cpu, + CreditCard, + Database, + Download, + Edit, + Eye, + EyeOff, + File, + Files, + FileText, + Filter, + Flag, + Folder, + Gift, + Globe, + Heart, + Home, + Image, + Inbox, + Info, + Key, + Layers, + Layout, + Link, + List, + Lock, + LogOut, + Mail, + Map as MapIcon, + Menu, + MessageSquare, + Package, + Pencil, + Plus, + RefreshCw, + Save, + Search, + Send, + Server, + Settings, + Share, + Shield, + ShieldCheck, + ShoppingBag, + ShoppingCart, + Sparkles, + Star, + Tag, + Terminal, + Trash, + Upload, + User, + Users, + X, + Zap, +} from 'lucide-react'; -const ICONS: Record = { - home: ( - - ), - package: ( - - ), - users: ( - - ), - user: ( - - ), - settings: ( - - ), - shield: ( - - ), - activity: ( - - ), - files: ( - - ), - layers: ( - - ), - 'shield-check': ( - - ), - 'log-out': ( - - ), -}; +const ICON_MAP = { + activity: Activity, + 'alert-circle': AlertCircle, + archive: Archive, + 'bar-chart-3': BarChart3, + bell: Bell, + bookmark: Bookmark, + briefcase: Briefcase, + calendar: Calendar, + camera: Camera, + check: Check, + 'chevron-down': ChevronDown, + 'chevron-right': ChevronRight, + clock: Clock, + cloud: Cloud, + code: Code, + cog: Cog, + copy: Copy, + cpu: Cpu, + 'credit-card': CreditCard, + database: Database, + download: Download, + edit: Edit, + eye: Eye, + 'eye-off': EyeOff, + file: File, + 'file-text': FileText, + files: Files, + filter: Filter, + flag: Flag, + folder: Folder, + gift: Gift, + globe: Globe, + heart: Heart, + home: Home, + image: Image, + inbox: Inbox, + info: Info, + key: Key, + layers: Layers, + layout: Layout, + link: Link, + list: List, + lock: Lock, + 'log-out': LogOut, + mail: Mail, + map: MapIcon, + menu: Menu, + 'message-square': MessageSquare, + package: Package, + pencil: Pencil, + plus: Plus, + 'refresh-cw': RefreshCw, + save: Save, + search: Search, + send: Send, + server: Server, + settings: Settings, + share: Share, + shield: Shield, + 'shield-check': ShieldCheck, + 'shopping-bag': ShoppingBag, + 'shopping-cart': ShoppingCart, + sparkles: Sparkles, + star: Star, + tag: Tag, + terminal: Terminal, + trash: Trash, + upload: Upload, + user: User, + users: Users, + x: X, + zap: Zap, +} as const; + +export type NavIconName = keyof typeof ICON_MAP; export function NavIcon({ name }: { name: string }) { - return <>{ICONS[name] || }; + if (!(name in ICON_MAP)) { + return