@igstack/app-catalog-frontend-core 0.0.1 → 0.1.1-alpha-20260302025010

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/esm/App.d.ts CHANGED
@@ -2,7 +2,7 @@ import { QueryClient } from '@tanstack/react-query';
2
2
  import { TRPCRouter } from '@igstack/app-catalog-backend-core';
3
3
  import { TRPCClient } from '@trpc/client';
4
4
  import { AcDb } from './userDb/AcDb.js';
5
- import { createEhRouter } from '~/util/createEhRouter';
5
+ import { createEhRouter } from './util/createEhRouter.js';
6
6
  export interface AppProps {
7
7
  router: ReturnType<typeof createEhRouter>;
8
8
  queryClient: QueryClient;
@@ -1 +1 @@
1
- {"version":3,"file":"App.js","sources":["../../src/App.tsx"],"sourcesContent":["import { QueryClientProvider } from '@tanstack/react-query'\nimport { RouterProvider } from '@tanstack/react-router'\nimport { DbProvider } from './userDb/DbContext'\nimport type { QueryClient } from '@tanstack/react-query'\nimport type { TRPCRouter } from '@igstack/app-catalog-backend-core'\nimport type { TRPCClient } from '@trpc/client'\nimport type { AcDb } from './userDb/AcDb'\nimport type { createEhRouter } from '~/util/createEhRouter'\nimport { TRPCProvider } from '~/api/infra/trpc'\n\nexport interface AppProps {\n router: ReturnType<typeof createEhRouter>\n queryClient: QueryClient\n trpcClient: TRPCClient<TRPCRouter>\n db: AcDb\n}\n\nexport function App({ router, queryClient, trpcClient, db }: AppProps) {\n return (\n <QueryClientProvider client={queryClient}>\n <TRPCProvider queryClient={queryClient} trpcClient={trpcClient}>\n <DbProvider db={db}>\n <RouterProvider router={router} />\n </DbProvider>\n </TRPCProvider>\n </QueryClientProvider>\n )\n}\n"],"names":[],"mappings":";;;;;AAiBO,SAAS,IAAI,EAAE,QAAQ,aAAa,YAAY,MAAgB;AACrE,6BACG,qBAAA,EAAoB,QAAQ,aAC3B,UAAA,oBAAC,gBAAa,aAA0B,YACtC,UAAA,oBAAC,YAAA,EAAW,IACV,UAAA,oBAAC,gBAAA,EAAe,OAAA,CAAgB,GAClC,GACF,EAAA,CACF;AAEJ;"}
1
+ {"version":3,"file":"App.js","sources":["../../src/App.tsx"],"sourcesContent":["import { QueryClientProvider } from '@tanstack/react-query'\nimport { RouterProvider } from '@tanstack/react-router'\nimport { DbProvider } from './userDb/DbContext'\nimport type { QueryClient } from '@tanstack/react-query'\nimport type { TRPCRouter } from '@igstack/app-catalog-backend-core'\nimport type { TRPCClient } from '@trpc/client'\nimport type { AcDb } from './userDb/AcDb'\nimport type { createEhRouter } from './util/createEhRouter'\nimport { TRPCProvider } from './api/infra/trpc'\n\nexport interface AppProps {\n router: ReturnType<typeof createEhRouter>\n queryClient: QueryClient\n trpcClient: TRPCClient<TRPCRouter>\n db: AcDb\n}\n\nexport function App({ router, queryClient, trpcClient, db }: AppProps) {\n return (\n <QueryClientProvider client={queryClient}>\n <TRPCProvider queryClient={queryClient} trpcClient={trpcClient}>\n <DbProvider db={db}>\n <RouterProvider router={router} />\n </DbProvider>\n </TRPCProvider>\n </QueryClientProvider>\n )\n}\n"],"names":[],"mappings":";;;;;AAiBO,SAAS,IAAI,EAAE,QAAQ,aAAa,YAAY,MAAgB;AACrE,6BACG,qBAAA,EAAoB,QAAQ,aAC3B,UAAA,oBAAC,gBAAa,aAA0B,YACtC,UAAA,oBAAC,YAAA,EAAW,IACV,UAAA,oBAAC,gBAAA,EAAe,OAAA,CAAgB,GAClC,GACF,EAAA,CACF;AAEJ;"}
@@ -1,7 +1,7 @@
1
1
  import { QueryClient } from '@tanstack/react-query';
2
2
  import { TRPCRouter } from '@igstack/app-catalog-backend-core';
3
3
  import { TRPCClient } from '@trpc/client';
4
- import { AcDb } from '~/userDb/AcDb';
4
+ import { AcDb } from '../../userDb/AcDb.js';
5
5
  export interface CreateQueryParams {
6
6
  trpcClient: TRPCClient<TRPCRouter>;
7
7
  db: AcDb;
@@ -1 +1 @@
1
- {"version":3,"file":"createQueryClient.js","sources":["../../../../src/api/infra/createQueryClient.ts"],"sourcesContent":["import { QueryClient } from '@tanstack/react-query'\nimport type { TRPCRouter } from '@igstack/app-catalog-backend-core'\nimport type { TRPCClient } from '@trpc/client'\nimport type { AcDb } from '~/userDb/AcDb'\n\nexport interface CreateQueryParams {\n trpcClient: TRPCClient<TRPCRouter>\n db: AcDb\n}\n\nexport function createQueryClient({ trpcClient, db }: CreateQueryParams) {\n return new QueryClient({\n defaultOptions: {\n queries: {\n gcTime: 1000 * 60 * 60 * 24 * 7, // 7 days\n meta: {\n trpcClient,\n db,\n },\n },\n mutations: {\n meta: {\n trpcClient,\n db,\n },\n },\n },\n })\n}\n"],"names":[],"mappings":";AAUO,SAAS,kBAAkB,EAAE,YAAY,MAAyB;AACvE,SAAO,IAAI,YAAY;AAAA,IACrB,gBAAgB;AAAA,MACd,SAAS;AAAA,QACP,QAAQ,MAAO,KAAK,KAAK,KAAK;AAAA;AAAA,QAC9B,MAAM;AAAA,UACJ;AAAA,UACA;AAAA,QAAA;AAAA,MACF;AAAA,MAEF,WAAW;AAAA,QACT,MAAM;AAAA,UACJ;AAAA,UACA;AAAA,QAAA;AAAA,MACF;AAAA,IACF;AAAA,EACF,CACD;AACH;"}
1
+ {"version":3,"file":"createQueryClient.js","sources":["../../../../src/api/infra/createQueryClient.ts"],"sourcesContent":["import { QueryClient } from '@tanstack/react-query'\nimport type { TRPCRouter } from '@igstack/app-catalog-backend-core'\nimport type { TRPCClient } from '@trpc/client'\nimport type { AcDb } from '../../userDb/AcDb'\n\nexport interface CreateQueryParams {\n trpcClient: TRPCClient<TRPCRouter>\n db: AcDb\n}\n\nexport function createQueryClient({ trpcClient, db }: CreateQueryParams) {\n return new QueryClient({\n defaultOptions: {\n queries: {\n gcTime: 1000 * 60 * 60 * 24 * 7, // 7 days\n meta: {\n trpcClient,\n db,\n },\n },\n mutations: {\n meta: {\n trpcClient,\n db,\n },\n },\n },\n })\n}\n"],"names":[],"mappings":";AAUO,SAAS,kBAAkB,EAAE,YAAY,MAAyB;AACvE,SAAO,IAAI,YAAY;AAAA,IACrB,gBAAgB;AAAA,MACd,SAAS;AAAA,QACP,QAAQ,MAAO,KAAK,KAAK,KAAK;AAAA;AAAA,QAC9B,MAAM;AAAA,UACJ;AAAA,UACA;AAAA,QAAA;AAAA,MACF;AAAA,MAEF,WAAW;AAAA,QACT,MAAM;AAAA,UACJ;AAAA,UACA;AAAA,QAAA;AAAA,MACF;AAAA,IACF;AAAA,EACF,CACD;AACH;"}
@@ -1 +1 @@
1
- {"version":3,"file":"appPropsFactory.js","sources":["../../src/appPropsFactory.ts"],"sourcesContent":["import { createBrowserHistory } from '@tanstack/react-router'\nimport { createTRPCClient, httpBatchLink } from '@trpc/client'\nimport { AcDb } from './userDb/AcDb'\nimport type { TRPCRouter } from '@igstack/app-catalog-backend-core'\nimport type { AppProps } from './App'\nimport type { EhPlugin } from './modules/pluginCore/types'\nimport { createQueryClient } from '~/api/infra/createQueryClient'\nimport { createEhRouter } from '~/util/createEhRouter'\n\n// registerSW();\nexport function appPropsFactory(): AppProps {\n const trpcClient = createTRPCClient<TRPCRouter>({\n links: [\n httpBatchLink({\n url: `${window.location.origin}/api/trpc`,\n }),\n ],\n })\n\n const db = new AcDb()\n const queryClient = createQueryClient({ trpcClient, db })\n const plugins: Array<EhPlugin> = [\n // Future plugins can be added here\n ]\n const router = createEhRouter({\n history: createBrowserHistory(),\n context: {\n queryClient,\n trpcClient,\n db,\n plugins,\n boostrapHealth: {},\n },\n })\n\n return { router, queryClient, trpcClient, db }\n}\n"],"names":[],"mappings":";;;;;AAUO,SAAS,kBAA4B;AAC1C,QAAM,aAAa,iBAA6B;AAAA,IAC9C,OAAO;AAAA,MACL,cAAc;AAAA,QACZ,KAAK,GAAG,OAAO,SAAS,MAAM;AAAA,MAAA,CAC/B;AAAA,IAAA;AAAA,EACH,CACD;AAED,QAAM,KAAK,IAAI,KAAA;AACf,QAAM,cAAc,kBAAkB,EAAE,YAAY,IAAI;AACxD,QAAM,UAA2B;AAAA;AAAA,EAAA;AAGjC,QAAM,SAAS,eAAe;AAAA,IAC5B,SAAS,qBAAA;AAAA,IACT,SAAS;AAAA,MACP;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,gBAAgB,CAAA;AAAA,IAAC;AAAA,EACnB,CACD;AAED,SAAO,EAAE,QAAQ,aAAa,YAAY,GAAA;AAC5C;"}
1
+ {"version":3,"file":"appPropsFactory.js","sources":["../../src/appPropsFactory.ts"],"sourcesContent":["import { createBrowserHistory } from '@tanstack/react-router'\nimport { createTRPCClient, httpBatchLink } from '@trpc/client'\nimport { AcDb } from './userDb/AcDb'\nimport type { TRPCRouter } from '@igstack/app-catalog-backend-core'\nimport type { AppProps } from './App'\nimport type { EhPlugin } from './modules/pluginCore/types'\nimport { createQueryClient } from './api/infra/createQueryClient'\nimport { createEhRouter } from './util/createEhRouter'\n\n// registerSW();\nexport function appPropsFactory(): AppProps {\n const trpcClient = createTRPCClient<TRPCRouter>({\n links: [\n httpBatchLink({\n url: `${window.location.origin}/api/trpc`,\n }),\n ],\n })\n\n const db = new AcDb()\n const queryClient = createQueryClient({ trpcClient, db })\n const plugins: Array<EhPlugin> = [\n // Future plugins can be added here\n ]\n const router = createEhRouter({\n history: createBrowserHistory(),\n context: {\n queryClient,\n trpcClient,\n db,\n plugins,\n boostrapHealth: {},\n },\n })\n\n return { router, queryClient, trpcClient, db }\n}\n"],"names":[],"mappings":";;;;;AAUO,SAAS,kBAA4B;AAC1C,QAAM,aAAa,iBAA6B;AAAA,IAC9C,OAAO;AAAA,MACL,cAAc;AAAA,QACZ,KAAK,GAAG,OAAO,SAAS,MAAM;AAAA,MAAA,CAC/B;AAAA,IAAA;AAAA,EACH,CACD;AAED,QAAM,KAAK,IAAI,KAAA;AACf,QAAM,cAAc,kBAAkB,EAAE,YAAY,IAAI;AACxD,QAAM,UAA2B;AAAA;AAAA,EAAA;AAGjC,QAAM,SAAS,eAAe;AAAA,IAC5B,SAAS,qBAAA;AAAA,IACT,SAAS;AAAA,MACP;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,gBAAgB,CAAA;AAAA,IAAC;AAAA,EACnB,CACD;AAED,SAAO,EAAE,QAAQ,aAAa,YAAY,GAAA;AAC5C;"}
@@ -3,6 +3,7 @@ import { useNavigate, useRouter, useSearch } from "@tanstack/react-router";
3
3
  import { useState, useRef, useEffect, useMemo } from "react";
4
4
  import { useAppCatalogContext } from "../../context/AppCatalogContext.js";
5
5
  import { AppCatalogGrid } from "../grid/AppCatalogGrid.js";
6
+ import { searchApps } from "../../utils/searchApps.js";
6
7
  import { Input } from "../../../../ui/input.js";
7
8
  function AppCatalogPage() {
8
9
  const navigate = useNavigate();
@@ -53,21 +54,16 @@ function AppCatalogPage() {
53
54
  });
54
55
  }, [searchValue, navigate, router.state.location.pathname, search]);
55
56
  const filteredApps = useMemo(() => {
56
- const normalizedSearchValue = searchValue.trim().toLowerCase();
57
- return apps.filter((app) => {
58
- var _a, _b;
59
- if (normalizedSearchValue === "") {
60
- return true;
57
+ const searchedApps = searchApps(apps, searchValue);
58
+ if (filterTag === void 0) {
59
+ return searchedApps;
60
+ }
61
+ return searchedApps.filter(
62
+ (app) => {
63
+ var _a;
64
+ return (_a = app.tags) == null ? void 0 : _a.some((tag) => tag.toLowerCase() === filterTag.toLowerCase());
61
65
  }
62
- const name = app.displayName.toLowerCase() || "";
63
- const slug = app.slug.toLowerCase() || "";
64
- const description = ((_a = app.description) == null ? void 0 : _a.toLowerCase()) || "";
65
- const tags = ((_b = app.tags) == null ? void 0 : _b.join(" ").toLowerCase()) || "";
66
- return name.includes(normalizedSearchValue) || slug.includes(normalizedSearchValue) || description.includes(normalizedSearchValue) || tags.includes(normalizedSearchValue);
67
- }).filter((app) => {
68
- var _a;
69
- return filterTag === void 0 || ((_a = app.tags) == null ? void 0 : _a.some((tag) => tag.toLowerCase() === filterTag.toLowerCase()));
70
- });
66
+ );
71
67
  }, [apps, searchValue, filterTag]);
72
68
  const handleAppClick = (app) => {
73
69
  setSelectedAppSlug(app.slug);
@@ -1 +1 @@
1
- {"version":3,"file":"AppCatalogPage.js","sources":["../../../../../../src/modules/appCatalog/ui/pages/AppCatalogPage.tsx"],"sourcesContent":["import { useNavigate, useRouter, useSearch } from '@tanstack/react-router'\nimport { useEffect, useMemo, useRef, useState } from 'react'\n\nimport type { AppForCatalog } from '@igstack/app-catalog-backend-core'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { AppCatalogGrid } from '../grid/AppCatalogGrid'\n\nimport { Input } from '~/ui/input'\n\nexport function AppCatalogPage() {\n const navigate = useNavigate()\n const router = useRouter()\n const search = useSearch({ strict: false })\n const { apps, isLoadingApps, tagsDefinitions } = useAppCatalogContext()\n const [searchValue, setSearchValue] = useState('')\n\n // Local state for app selection (source of truth)\n const [selectedAppSlug, setSelectedAppSlug] = useState<string | undefined>()\n\n const filterTag = search.filterTag\n\n // Initialize from URL on mount (once only)\n const isInitializedRef = useRef(false)\n useEffect(() => {\n if (!isInitializedRef.current) {\n if (search.app) {\n setSelectedAppSlug(search.app)\n }\n if (search.q) {\n setSearchValue(search.q)\n }\n isInitializedRef.current = true\n }\n }, [search.app, search.q])\n\n // Sync app selection state to URL (async side effect)\n useEffect(() => {\n // Don't sync until after initialization\n if (!isInitializedRef.current) return\n if (selectedAppSlug === search.app) return // Already in sync\n\n const currentPath = router.state.location.pathname\n navigate({\n to: currentPath,\n search: { ...search, app: selectedAppSlug },\n replace: true, // Use replace to avoid polluting history\n })\n }, [selectedAppSlug, navigate, router.state.location.pathname, search])\n\n // Sync search value state to URL (async side effect)\n useEffect(() => {\n // Don't sync until after initialization\n if (!isInitializedRef.current) return\n\n const normalizedSearchValue = searchValue.trim()\n const urlSearchValue = search.q || ''\n\n if (normalizedSearchValue === urlSearchValue) return // Already in sync\n\n const currentPath = router.state.location.pathname\n navigate({\n to: currentPath,\n search: {\n ...search,\n q: normalizedSearchValue || undefined, // Remove param if empty\n },\n replace: true, // Use replace to avoid polluting history\n })\n }, [searchValue, navigate, router.state.location.pathname, search])\n\n const filteredApps = useMemo(() => {\n const normalizedSearchValue = searchValue.trim().toLowerCase()\n\n return apps\n .filter((app) => {\n if (normalizedSearchValue === '') {\n return true\n }\n const name = app.displayName.toLowerCase() || ''\n const slug = app.slug.toLowerCase() || ''\n const description = app.description?.toLowerCase() || ''\n const tags = app.tags?.join(' ').toLowerCase() || ''\n\n return (\n name.includes(normalizedSearchValue) ||\n slug.includes(normalizedSearchValue) ||\n description.includes(normalizedSearchValue) ||\n tags.includes(normalizedSearchValue)\n )\n })\n .filter((app) => {\n return (\n filterTag === undefined ||\n app.tags?.some((tag) => tag.toLowerCase() === filterTag.toLowerCase())\n )\n })\n }, [apps, searchValue, filterTag])\n\n const handleAppClick = (app: AppForCatalog) => {\n setSelectedAppSlug(app.slug)\n }\n\n if (isLoadingApps) {\n return <div className=\"py-6 text-muted-foreground\">Loading…</div>\n }\n\n // Use first tag definition for grouping\n const groupingDefinition = tagsDefinitions[0]\n\n return (\n <div className=\"flex flex-col flex-1 min-h-0\">\n <div className=\"pb-4 shrink-0\">\n <div className=\"flex items-start justify-between gap-4 pb-4\">\n <div>\n <div className=\"font-medium text-2xl\">App Catalog</div>\n <div className=\"text-sm text-muted-foreground\">\n {filteredApps.length} apps available\n </div>\n </div>\n </div>\n\n <div className=\"w-full\">\n <Input\n value={searchValue}\n onChange={(e) => setSearchValue(e.target.value)}\n placeholder=\"Search apps by name, description, or tags…\"\n aria-label=\"Search apps\"\n />\n </div>\n </div>\n\n <div className=\"flex-1 min-h-0\">\n <AppCatalogGrid\n apps={filteredApps}\n selectedAppSlug={selectedAppSlug}\n groupingDefinition={groupingDefinition}\n onAppClick={handleAppClick}\n />\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"AppCatalogPage.js","sources":["../../../../../../src/modules/appCatalog/ui/pages/AppCatalogPage.tsx"],"sourcesContent":["import { useNavigate, useRouter, useSearch } from '@tanstack/react-router'\nimport { useEffect, useMemo, useRef, useState } from 'react'\n\nimport type { AppForCatalog } from '@igstack/app-catalog-backend-core'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { AppCatalogGrid } from '../grid/AppCatalogGrid'\nimport { searchApps } from '../../utils/searchApps'\n\nimport { Input } from '~/ui/input'\n\nexport function AppCatalogPage() {\n const navigate = useNavigate()\n const router = useRouter()\n const search = useSearch({ strict: false })\n const { apps, isLoadingApps, tagsDefinitions } = useAppCatalogContext()\n const [searchValue, setSearchValue] = useState('')\n\n // Local state for app selection (source of truth)\n const [selectedAppSlug, setSelectedAppSlug] = useState<string | undefined>()\n\n const filterTag = search.filterTag\n\n // Initialize from URL on mount (once only)\n const isInitializedRef = useRef(false)\n useEffect(() => {\n if (!isInitializedRef.current) {\n if (search.app) {\n setSelectedAppSlug(search.app)\n }\n if (search.q) {\n setSearchValue(search.q)\n }\n isInitializedRef.current = true\n }\n }, [search.app, search.q])\n\n // Sync app selection state to URL (async side effect)\n useEffect(() => {\n // Don't sync until after initialization\n if (!isInitializedRef.current) return\n if (selectedAppSlug === search.app) return // Already in sync\n\n const currentPath = router.state.location.pathname\n navigate({\n to: currentPath,\n search: { ...search, app: selectedAppSlug },\n replace: true, // Use replace to avoid polluting history\n })\n }, [selectedAppSlug, navigate, router.state.location.pathname, search])\n\n // Sync search value state to URL (async side effect)\n useEffect(() => {\n // Don't sync until after initialization\n if (!isInitializedRef.current) return\n\n const normalizedSearchValue = searchValue.trim()\n const urlSearchValue = search.q || ''\n\n if (normalizedSearchValue === urlSearchValue) return // Already in sync\n\n const currentPath = router.state.location.pathname\n navigate({\n to: currentPath,\n search: {\n ...search,\n q: normalizedSearchValue || undefined, // Remove param if empty\n },\n replace: true, // Use replace to avoid polluting history\n })\n }, [searchValue, navigate, router.state.location.pathname, search])\n\n const filteredApps = useMemo(() => {\n // First apply search with smart sorting\n const searchedApps = searchApps(apps, searchValue)\n\n // Then apply tag filter if present\n if (filterTag === undefined) {\n return searchedApps\n }\n\n return searchedApps.filter((app) =>\n app.tags?.some((tag) => tag.toLowerCase() === filterTag.toLowerCase()),\n )\n }, [apps, searchValue, filterTag])\n\n const handleAppClick = (app: AppForCatalog) => {\n setSelectedAppSlug(app.slug)\n }\n\n if (isLoadingApps) {\n return <div className=\"py-6 text-muted-foreground\">Loading…</div>\n }\n\n // Use first tag definition for grouping\n const groupingDefinition = tagsDefinitions[0]\n\n return (\n <div className=\"flex flex-col flex-1 min-h-0\">\n <div className=\"pb-4 shrink-0\">\n <div className=\"flex items-start justify-between gap-4 pb-4\">\n <div>\n <div className=\"font-medium text-2xl\">App Catalog</div>\n <div className=\"text-sm text-muted-foreground\">\n {filteredApps.length} apps available\n </div>\n </div>\n </div>\n\n <div className=\"w-full\">\n <Input\n value={searchValue}\n onChange={(e) => setSearchValue(e.target.value)}\n placeholder=\"Search apps by name, description, or tags…\"\n aria-label=\"Search apps\"\n />\n </div>\n </div>\n\n <div className=\"flex-1 min-h-0\">\n <AppCatalogGrid\n apps={filteredApps}\n selectedAppSlug={selectedAppSlug}\n groupingDefinition={groupingDefinition}\n onAppClick={handleAppClick}\n />\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,10 @@
1
+ import { AppForCatalog } from '@igstack/app-catalog-backend-core';
2
+ /**
3
+ * Search and sort apps by relevance.
4
+ * Prioritizes prefix matches in display name over other matches.
5
+ *
6
+ * @param apps - Array of apps to search
7
+ * @param searchQuery - Search query string
8
+ * @returns Filtered and sorted array of apps
9
+ */
10
+ export declare function searchApps(apps: Array<AppForCatalog>, searchQuery: string): Array<AppForCatalog>;
@@ -0,0 +1,48 @@
1
+ function searchApps(apps, searchQuery) {
2
+ const normalizedQuery = searchQuery.trim().toLowerCase();
3
+ if (normalizedQuery === "") {
4
+ return apps;
5
+ }
6
+ const scoredApps = apps.map((app) => {
7
+ var _a, _b;
8
+ const name = app.displayName.toLowerCase();
9
+ const slug = app.slug.toLowerCase();
10
+ const description = ((_a = app.description) == null ? void 0 : _a.toLowerCase()) || "";
11
+ const tags = ((_b = app.tags) == null ? void 0 : _b.join(" ").toLowerCase()) || "";
12
+ const nameMatch = name.includes(normalizedQuery);
13
+ const slugMatch = slug.includes(normalizedQuery);
14
+ const descriptionMatch = description.includes(normalizedQuery);
15
+ const tagsMatch = tags.includes(normalizedQuery);
16
+ if (!nameMatch && !slugMatch && !descriptionMatch && !tagsMatch) {
17
+ return null;
18
+ }
19
+ let score = 0;
20
+ if (name.startsWith(normalizedQuery)) {
21
+ score = 0;
22
+ } else if (slug.startsWith(normalizedQuery)) {
23
+ score = 1;
24
+ } else if (nameMatch) {
25
+ score = 2;
26
+ } else if (slugMatch) {
27
+ score = 3;
28
+ } else if (tagsMatch) {
29
+ score = 4;
30
+ } else if (descriptionMatch) {
31
+ score = 5;
32
+ }
33
+ return { app, score };
34
+ }).filter(
35
+ (item) => item !== null
36
+ );
37
+ scoredApps.sort((a, b) => {
38
+ if (a.score !== b.score) {
39
+ return a.score - b.score;
40
+ }
41
+ return a.app.displayName.localeCompare(b.app.displayName);
42
+ });
43
+ return scoredApps.map((item) => item.app);
44
+ }
45
+ export {
46
+ searchApps
47
+ };
48
+ //# sourceMappingURL=searchApps.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"searchApps.js","sources":["../../../../../src/modules/appCatalog/utils/searchApps.ts"],"sourcesContent":["import type { AppForCatalog } from '@igstack/app-catalog-backend-core'\n\n/**\n * Search and sort apps by relevance.\n * Prioritizes prefix matches in display name over other matches.\n *\n * @param apps - Array of apps to search\n * @param searchQuery - Search query string\n * @returns Filtered and sorted array of apps\n */\nexport function searchApps(\n apps: Array<AppForCatalog>,\n searchQuery: string,\n): Array<AppForCatalog> {\n const normalizedQuery = searchQuery.trim().toLowerCase()\n\n if (normalizedQuery === '') {\n return apps\n }\n\n // Filter and score apps\n const scoredApps = apps\n .map((app) => {\n const name = app.displayName.toLowerCase()\n const slug = app.slug.toLowerCase()\n const description = app.description?.toLowerCase() || ''\n const tags = app.tags?.join(' ').toLowerCase() || ''\n\n // Check if any field matches\n const nameMatch = name.includes(normalizedQuery)\n const slugMatch = slug.includes(normalizedQuery)\n const descriptionMatch = description.includes(normalizedQuery)\n const tagsMatch = tags.includes(normalizedQuery)\n\n if (!nameMatch && !slugMatch && !descriptionMatch && !tagsMatch) {\n return null\n }\n\n // Calculate score (lower is better)\n let score = 0\n\n // Highest priority: prefix match in display name\n if (name.startsWith(normalizedQuery)) {\n score = 0\n }\n // Second priority: prefix match in slug\n else if (slug.startsWith(normalizedQuery)) {\n score = 1\n }\n // Third priority: anywhere in display name\n else if (nameMatch) {\n score = 2\n }\n // Fourth priority: anywhere in slug\n else if (slugMatch) {\n score = 3\n }\n // Fifth priority: tags\n else if (tagsMatch) {\n score = 4\n }\n // Lowest priority: description\n else if (descriptionMatch) {\n score = 5\n }\n\n return { app, score }\n })\n .filter(\n (item): item is { app: AppForCatalog; score: number } => item !== null,\n )\n\n // Sort by score (ascending - lower score = higher priority)\n scoredApps.sort((a, b) => {\n if (a.score !== b.score) {\n return a.score - b.score\n }\n // If same score, sort alphabetically by display name\n return a.app.displayName.localeCompare(b.app.displayName)\n })\n\n return scoredApps.map((item) => item.app)\n}\n"],"names":[],"mappings":"AAUO,SAAS,WACd,MACA,aACsB;AACtB,QAAM,kBAAkB,YAAY,KAAA,EAAO,YAAA;AAE3C,MAAI,oBAAoB,IAAI;AAC1B,WAAO;AAAA,EACT;AAGA,QAAM,aAAa,KAChB,IAAI,CAAC,QAAQ;AAZX;AAaD,UAAM,OAAO,IAAI,YAAY,YAAA;AAC7B,UAAM,OAAO,IAAI,KAAK,YAAA;AACtB,UAAM,gBAAc,SAAI,gBAAJ,mBAAiB,kBAAiB;AACtD,UAAM,SAAO,SAAI,SAAJ,mBAAU,KAAK,KAAK,kBAAiB;AAGlD,UAAM,YAAY,KAAK,SAAS,eAAe;AAC/C,UAAM,YAAY,KAAK,SAAS,eAAe;AAC/C,UAAM,mBAAmB,YAAY,SAAS,eAAe;AAC7D,UAAM,YAAY,KAAK,SAAS,eAAe;AAE/C,QAAI,CAAC,aAAa,CAAC,aAAa,CAAC,oBAAoB,CAAC,WAAW;AAC/D,aAAO;AAAA,IACT;AAGA,QAAI,QAAQ;AAGZ,QAAI,KAAK,WAAW,eAAe,GAAG;AACpC,cAAQ;AAAA,IACV,WAES,KAAK,WAAW,eAAe,GAAG;AACzC,cAAQ;AAAA,IACV,WAES,WAAW;AAClB,cAAQ;AAAA,IACV,WAES,WAAW;AAClB,cAAQ;AAAA,IACV,WAES,WAAW;AAClB,cAAQ;AAAA,IACV,WAES,kBAAkB;AACzB,cAAQ;AAAA,IACV;AAEA,WAAO,EAAE,KAAK,MAAA;AAAA,EAChB,CAAC,EACA;AAAA,IACC,CAAC,SAAwD,SAAS;AAAA,EAAA;AAItE,aAAW,KAAK,CAAC,GAAG,MAAM;AACxB,QAAI,EAAE,UAAU,EAAE,OAAO;AACvB,aAAO,EAAE,QAAQ,EAAE;AAAA,IACrB;AAEA,WAAO,EAAE,IAAI,YAAY,cAAc,EAAE,IAAI,WAAW;AAAA,EAC1D,CAAC;AAED,SAAO,WAAW,IAAI,CAAC,SAAS,KAAK,GAAG;AAC1C;"}
@@ -1,5 +1,5 @@
1
- import { EhRouterInitParams } from '~/types/types';
2
- export declare function createEhRouter({ context, history }: EhRouterInitParams): import('@tanstack/router-core').RouterCore<import('@tanstack/router-core').Route<import('@tanstack/react-router').Register, any, "/", "/", string, "__root__", undefined, {}, import('~/types/types').EhRouterContext, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, {}, undefined, import('~/routeTree.gen').RootRouteChildren, import('~/routeTree.gen').FileRouteTypes, unknown, unknown, undefined>, "never", false, import('@tanstack/history').RouterHistory, Record<string, any>>;
1
+ import { EhRouterInitParams } from '../types/types.js';
2
+ export declare function createEhRouter({ context, history }: EhRouterInitParams): import('@tanstack/router-core').RouterCore<import('@tanstack/router-core').Route<import('@tanstack/react-router').Register, any, "/", "/", string, "__root__", undefined, {}, import('../types/types.js').EhRouterContext, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, {}, undefined, import('../routeTree.gen').RootRouteChildren, import('../routeTree.gen').FileRouteTypes, unknown, unknown, undefined>, "never", false, import('@tanstack/history').RouterHistory, Record<string, any>>;
3
3
  declare module '@tanstack/react-router' {
4
4
  interface Register {
5
5
  router: ReturnType<typeof createEhRouter>;
@@ -1 +1 @@
1
- {"version":3,"file":"createEhRouter.js","sources":["../../../src/util/createEhRouter.tsx"],"sourcesContent":["import { createRouter } from '@tanstack/react-router'\nimport type { EhRouterInitParams } from '~/types/types'\nimport { routeTree } from '~/routeTree.gen'\n\nexport function createEhRouter({ context, history }: EhRouterInitParams) {\n return createRouter({\n routeTree,\n context,\n history,\n // Since we're using React Query, we don't want loader calls to ever be stale\n // This will ensure that the loader is always called when the route is preloaded or visited\n defaultPreloadStaleTime: 0,\n defaultNotFoundComponent: () => <div>404 - Page Not Found</div>,\n pathParamsAllowedCharacters: ['@'],\n })\n}\n\ndeclare module '@tanstack/react-router' {\n interface Register {\n router: ReturnType<typeof createEhRouter>\n }\n}\n"],"names":[],"mappings":";;;AAIO,SAAS,eAAe,EAAE,SAAS,WAA+B;AACvE,SAAO,aAAa;AAAA,IAClB;AAAA,IACA;AAAA,IACA;AAAA;AAAA;AAAA,IAGA,yBAAyB;AAAA,IACzB,0BAA0B,MAAM,oBAAC,OAAA,EAAI,UAAA,uBAAA,CAAoB;AAAA,IACzD,6BAA6B,CAAC,GAAG;AAAA,EAAA,CAClC;AACH;"}
1
+ {"version":3,"file":"createEhRouter.js","sources":["../../../src/util/createEhRouter.tsx"],"sourcesContent":["import { createRouter } from '@tanstack/react-router'\nimport type { EhRouterInitParams } from '../types/types'\nimport { routeTree } from '../routeTree.gen'\n\nexport function createEhRouter({ context, history }: EhRouterInitParams) {\n return createRouter({\n routeTree,\n context,\n history,\n // Since we're using React Query, we don't want loader calls to ever be stale\n // This will ensure that the loader is always called when the route is preloaded or visited\n defaultPreloadStaleTime: 0,\n defaultNotFoundComponent: () => <div>404 - Page Not Found</div>,\n pathParamsAllowedCharacters: ['@'],\n })\n}\n\ndeclare module '@tanstack/react-router' {\n interface Register {\n router: ReturnType<typeof createEhRouter>\n }\n}\n"],"names":[],"mappings":";;;AAIO,SAAS,eAAe,EAAE,SAAS,WAA+B;AACvE,SAAO,aAAa;AAAA,IAClB;AAAA,IACA;AAAA,IACA;AAAA;AAAA;AAAA,IAGA,yBAAyB;AAAA,IACzB,0BAA0B,MAAM,oBAAC,OAAA,EAAI,UAAA,uBAAA,CAAoB;AAAA,IACzD,6BAA6B,CAAC,GAAG;AAAA,EAAA,CAClC;AACH;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "0.0.1",
3
+ "version": "0.1.1-alpha-20260302025010",
4
4
  "description": "Frontend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -133,8 +133,8 @@
133
133
  "vite-plugin-static-copy": "^3.1.4",
134
134
  "vite-plugin-svgr": "^4.2.0",
135
135
  "vitest": "3.2.2",
136
- "@igstack/app-catalog-backend-core": "0.0.1",
137
- "@igstack/app-catalog-shared-core": "0.0.1"
136
+ "@igstack/app-catalog-backend-core": "0.1.1-alpha-20260302025010",
137
+ "@igstack/app-catalog-shared-core": "0.1.1-alpha-20260302025010"
138
138
  },
139
139
  "peerDependencies": {
140
140
  "react": "19.1.2",
package/src/App.tsx CHANGED
@@ -5,8 +5,8 @@ import type { QueryClient } from '@tanstack/react-query'
5
5
  import type { TRPCRouter } from '@igstack/app-catalog-backend-core'
6
6
  import type { TRPCClient } from '@trpc/client'
7
7
  import type { AcDb } from './userDb/AcDb'
8
- import type { createEhRouter } from '~/util/createEhRouter'
9
- import { TRPCProvider } from '~/api/infra/trpc'
8
+ import type { createEhRouter } from './util/createEhRouter'
9
+ import { TRPCProvider } from './api/infra/trpc'
10
10
 
11
11
  export interface AppProps {
12
12
  router: ReturnType<typeof createEhRouter>
@@ -1,7 +1,7 @@
1
1
  import { QueryClient } from '@tanstack/react-query'
2
2
  import type { TRPCRouter } from '@igstack/app-catalog-backend-core'
3
3
  import type { TRPCClient } from '@trpc/client'
4
- import type { AcDb } from '~/userDb/AcDb'
4
+ import type { AcDb } from '../../userDb/AcDb'
5
5
 
6
6
  export interface CreateQueryParams {
7
7
  trpcClient: TRPCClient<TRPCRouter>
@@ -4,8 +4,8 @@ import { AcDb } from './userDb/AcDb'
4
4
  import type { TRPCRouter } from '@igstack/app-catalog-backend-core'
5
5
  import type { AppProps } from './App'
6
6
  import type { EhPlugin } from './modules/pluginCore/types'
7
- import { createQueryClient } from '~/api/infra/createQueryClient'
8
- import { createEhRouter } from '~/util/createEhRouter'
7
+ import { createQueryClient } from './api/infra/createQueryClient'
8
+ import { createEhRouter } from './util/createEhRouter'
9
9
 
10
10
  // registerSW();
11
11
  export function appPropsFactory(): AppProps {
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
4
4
  import type { AppForCatalog } from '@igstack/app-catalog-backend-core'
5
5
  import { useAppCatalogContext } from '../../context/AppCatalogContext'
6
6
  import { AppCatalogGrid } from '../grid/AppCatalogGrid'
7
+ import { searchApps } from '../../utils/searchApps'
7
8
 
8
9
  import { Input } from '~/ui/input'
9
10
 
@@ -69,31 +70,17 @@ export function AppCatalogPage() {
69
70
  }, [searchValue, navigate, router.state.location.pathname, search])
70
71
 
71
72
  const filteredApps = useMemo(() => {
72
- const normalizedSearchValue = searchValue.trim().toLowerCase()
73
-
74
- return apps
75
- .filter((app) => {
76
- if (normalizedSearchValue === '') {
77
- return true
78
- }
79
- const name = app.displayName.toLowerCase() || ''
80
- const slug = app.slug.toLowerCase() || ''
81
- const description = app.description?.toLowerCase() || ''
82
- const tags = app.tags?.join(' ').toLowerCase() || ''
83
-
84
- return (
85
- name.includes(normalizedSearchValue) ||
86
- slug.includes(normalizedSearchValue) ||
87
- description.includes(normalizedSearchValue) ||
88
- tags.includes(normalizedSearchValue)
89
- )
90
- })
91
- .filter((app) => {
92
- return (
93
- filterTag === undefined ||
94
- app.tags?.some((tag) => tag.toLowerCase() === filterTag.toLowerCase())
95
- )
96
- })
73
+ // First apply search with smart sorting
74
+ const searchedApps = searchApps(apps, searchValue)
75
+
76
+ // Then apply tag filter if present
77
+ if (filterTag === undefined) {
78
+ return searchedApps
79
+ }
80
+
81
+ return searchedApps.filter((app) =>
82
+ app.tags?.some((tag) => tag.toLowerCase() === filterTag.toLowerCase()),
83
+ )
97
84
  }, [apps, searchValue, filterTag])
98
85
 
99
86
  const handleAppClick = (app: AppForCatalog) => {
@@ -0,0 +1,83 @@
1
+ import type { AppForCatalog } from '@igstack/app-catalog-backend-core'
2
+
3
+ /**
4
+ * Search and sort apps by relevance.
5
+ * Prioritizes prefix matches in display name over other matches.
6
+ *
7
+ * @param apps - Array of apps to search
8
+ * @param searchQuery - Search query string
9
+ * @returns Filtered and sorted array of apps
10
+ */
11
+ export function searchApps(
12
+ apps: Array<AppForCatalog>,
13
+ searchQuery: string,
14
+ ): Array<AppForCatalog> {
15
+ const normalizedQuery = searchQuery.trim().toLowerCase()
16
+
17
+ if (normalizedQuery === '') {
18
+ return apps
19
+ }
20
+
21
+ // Filter and score apps
22
+ const scoredApps = apps
23
+ .map((app) => {
24
+ const name = app.displayName.toLowerCase()
25
+ const slug = app.slug.toLowerCase()
26
+ const description = app.description?.toLowerCase() || ''
27
+ const tags = app.tags?.join(' ').toLowerCase() || ''
28
+
29
+ // Check if any field matches
30
+ const nameMatch = name.includes(normalizedQuery)
31
+ const slugMatch = slug.includes(normalizedQuery)
32
+ const descriptionMatch = description.includes(normalizedQuery)
33
+ const tagsMatch = tags.includes(normalizedQuery)
34
+
35
+ if (!nameMatch && !slugMatch && !descriptionMatch && !tagsMatch) {
36
+ return null
37
+ }
38
+
39
+ // Calculate score (lower is better)
40
+ let score = 0
41
+
42
+ // Highest priority: prefix match in display name
43
+ if (name.startsWith(normalizedQuery)) {
44
+ score = 0
45
+ }
46
+ // Second priority: prefix match in slug
47
+ else if (slug.startsWith(normalizedQuery)) {
48
+ score = 1
49
+ }
50
+ // Third priority: anywhere in display name
51
+ else if (nameMatch) {
52
+ score = 2
53
+ }
54
+ // Fourth priority: anywhere in slug
55
+ else if (slugMatch) {
56
+ score = 3
57
+ }
58
+ // Fifth priority: tags
59
+ else if (tagsMatch) {
60
+ score = 4
61
+ }
62
+ // Lowest priority: description
63
+ else if (descriptionMatch) {
64
+ score = 5
65
+ }
66
+
67
+ return { app, score }
68
+ })
69
+ .filter(
70
+ (item): item is { app: AppForCatalog; score: number } => item !== null,
71
+ )
72
+
73
+ // Sort by score (ascending - lower score = higher priority)
74
+ scoredApps.sort((a, b) => {
75
+ if (a.score !== b.score) {
76
+ return a.score - b.score
77
+ }
78
+ // If same score, sort alphabetically by display name
79
+ return a.app.displayName.localeCompare(b.app.displayName)
80
+ })
81
+
82
+ return scoredApps.map((item) => item.app)
83
+ }
@@ -1,6 +1,6 @@
1
1
  import { createRouter } from '@tanstack/react-router'
2
- import type { EhRouterInitParams } from '~/types/types'
3
- import { routeTree } from '~/routeTree.gen'
2
+ import type { EhRouterInitParams } from '../types/types'
3
+ import { routeTree } from '../routeTree.gen'
4
4
 
5
5
  export function createEhRouter({ context, history }: EhRouterInitParams) {
6
6
  return createRouter({