@igstack/app-catalog-frontend-core 0.7.0 → 0.7.1
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/__tests__/modules/gallery/Gallery.zoom-toggle.test.d.ts +0 -0
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +1 -0
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +1 -0
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +1 -0
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +1 -0
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
- package/dist/esm/modules/gallery/Gallery.js +5 -2
- package/dist/esm/modules/gallery/Gallery.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/modules/gallery/Gallery.zoom-toggle.test.tsx +53 -0
- package/src/modules/gallery/Gallery.tsx +4 -1
|
File without changes
|
|
@@ -9,6 +9,7 @@ import "clsx";
|
|
|
9
9
|
import "tailwind-merge";
|
|
10
10
|
import "@tanstack/react-query-devtools";
|
|
11
11
|
import "next-themes";
|
|
12
|
+
import "../../auth/AuthModalContext.js";
|
|
12
13
|
import "@radix-ui/react-dialog";
|
|
13
14
|
import "lucide-react";
|
|
14
15
|
import "../../config/GlobalConfigContext.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AppCatalogContext.js","sources":["../../../../../src/modules/appCatalog/context/AppCatalogContext.tsx"],"sourcesContent":["import type {\n AppApprovalMethod,\n AppVersionInfo,\n Group,\n GroupingTagDefinition,\n Person,\n Resource,\n} from '@igstack/app-catalog-backend-core'\nimport { useQuery } from '@tanstack/react-query'\nimport type { ReactNode } from 'react'\nimport { createContext, use, useEffect, useMemo } from 'react'\nimport { ApiQueryMagazineAppCatalog } from '~/modules/appCatalog'\nimport { useUiSettings } from '~/context/UiSettingsContext'\nimport { mergeFrontendBuildId } from '~/modules/appCatalog/context/mergeFrontendBuildId'\n\nexport interface AppCatalogContextIface {\n resources: Resource[]\n isLoadingApps: boolean\n tagsDefinitions: GroupingTagDefinition[]\n approvalMethods: AppApprovalMethod[]\n persons: Person[]\n groups: Group[]\n versions?: AppVersionInfo\n}\n\nexport const AppCatalogContext = createContext<\n AppCatalogContextIface | undefined\n>(undefined)\n\ninterface AppCatalogProviderProps {\n children: ReactNode\n}\n\nexport function AppCatalogProvider({ children }: AppCatalogProviderProps) {\n const { data, isLoading: isLoadingApps } = useQuery(\n ApiQueryMagazineAppCatalog.getAppCatalog(),\n )\n const uiSettings = useUiSettings()\n\n const contextValue = useMemo<AppCatalogContextIface>(\n () => ({\n resources: data?.resources ?? [],\n isLoadingApps,\n tagsDefinitions: data?.tagsDefinitions ?? [],\n approvalMethods: data?.approvalMethods ?? [],\n persons: data?.persons ?? [],\n groups: data?.groups ?? [],\n versions: mergeFrontendBuildId(\n data?.versions ?? {},\n uiSettings.frontendBuildId,\n ),\n }),\n [\n data?.approvalMethods,\n data?.resources,\n data?.tagsDefinitions,\n data?.persons,\n data?.groups,\n data?.versions,\n uiSettings.frontendBuildId,\n isLoadingApps,\n ],\n )\n\n // Update document title based on backend version\n useEffect(() => {\n if (data?.versions?.backend?.displayName === 'local') {\n document.title = 'Local'\n } else {\n document.title = 'App Catalog'\n }\n }, [data?.versions?.backend?.displayName])\n\n return <AppCatalogContext value={contextValue}>{children}</AppCatalogContext>\n}\n\nexport function useAppCatalogContext(): AppCatalogContextIface {\n const context = use(AppCatalogContext)\n if (!context) {\n throw new Error(\n 'useAppCatalogContext must be used within AppCatalogProvider',\n )\n }\n return context\n}\n"],"names":["_b","_a"],"mappings":"
|
|
1
|
+
{"version":3,"file":"AppCatalogContext.js","sources":["../../../../../src/modules/appCatalog/context/AppCatalogContext.tsx"],"sourcesContent":["import type {\n AppApprovalMethod,\n AppVersionInfo,\n Group,\n GroupingTagDefinition,\n Person,\n Resource,\n} from '@igstack/app-catalog-backend-core'\nimport { useQuery } from '@tanstack/react-query'\nimport type { ReactNode } from 'react'\nimport { createContext, use, useEffect, useMemo } from 'react'\nimport { ApiQueryMagazineAppCatalog } from '~/modules/appCatalog'\nimport { useUiSettings } from '~/context/UiSettingsContext'\nimport { mergeFrontendBuildId } from '~/modules/appCatalog/context/mergeFrontendBuildId'\n\nexport interface AppCatalogContextIface {\n resources: Resource[]\n isLoadingApps: boolean\n tagsDefinitions: GroupingTagDefinition[]\n approvalMethods: AppApprovalMethod[]\n persons: Person[]\n groups: Group[]\n versions?: AppVersionInfo\n}\n\nexport const AppCatalogContext = createContext<\n AppCatalogContextIface | undefined\n>(undefined)\n\ninterface AppCatalogProviderProps {\n children: ReactNode\n}\n\nexport function AppCatalogProvider({ children }: AppCatalogProviderProps) {\n const { data, isLoading: isLoadingApps } = useQuery(\n ApiQueryMagazineAppCatalog.getAppCatalog(),\n )\n const uiSettings = useUiSettings()\n\n const contextValue = useMemo<AppCatalogContextIface>(\n () => ({\n resources: data?.resources ?? [],\n isLoadingApps,\n tagsDefinitions: data?.tagsDefinitions ?? [],\n approvalMethods: data?.approvalMethods ?? [],\n persons: data?.persons ?? [],\n groups: data?.groups ?? [],\n versions: mergeFrontendBuildId(\n data?.versions ?? {},\n uiSettings.frontendBuildId,\n ),\n }),\n [\n data?.approvalMethods,\n data?.resources,\n data?.tagsDefinitions,\n data?.persons,\n data?.groups,\n data?.versions,\n uiSettings.frontendBuildId,\n isLoadingApps,\n ],\n )\n\n // Update document title based on backend version\n useEffect(() => {\n if (data?.versions?.backend?.displayName === 'local') {\n document.title = 'Local'\n } else {\n document.title = 'App Catalog'\n }\n }, [data?.versions?.backend?.displayName])\n\n return <AppCatalogContext value={contextValue}>{children}</AppCatalogContext>\n}\n\nexport function useAppCatalogContext(): AppCatalogContextIface {\n const context = use(AppCatalogContext)\n if (!context) {\n throw new Error(\n 'useAppCatalogContext must be used within AppCatalogProvider',\n )\n }\n return context\n}\n"],"names":["_b","_a"],"mappings":";;;;;;;;;;;;;;;;;;;;AAyBO,MAAM,oBAAoB,cAE/B,MAAS;AAMJ,SAAS,mBAAmB,EAAE,YAAqC;;AACxE,QAAM,EAAE,MAAM,WAAW,cAAA,IAAkB;AAAA,IACzC,2BAA2B,cAAA;AAAA,EAAc;AAE3C,QAAM,aAAa,cAAA;AAEnB,QAAM,eAAe;AAAA,IACnB,OAAO;AAAA,MACL,YAAW,6BAAM,cAAa,CAAA;AAAA,MAC9B;AAAA,MACA,kBAAiB,6BAAM,oBAAmB,CAAA;AAAA,MAC1C,kBAAiB,6BAAM,oBAAmB,CAAA;AAAA,MAC1C,UAAS,6BAAM,YAAW,CAAA;AAAA,MAC1B,SAAQ,6BAAM,WAAU,CAAA;AAAA,MACxB,UAAU;AAAA,SACR,6BAAM,aAAY,CAAA;AAAA,QAClB,WAAW;AAAA,MAAA;AAAA,IACb;AAAA,IAEF;AAAA,MACE,6BAAM;AAAA,MACN,6BAAM;AAAA,MACN,6BAAM;AAAA,MACN,6BAAM;AAAA,MACN,6BAAM;AAAA,MACN,6BAAM;AAAA,MACN,WAAW;AAAA,MACX;AAAA,IAAA;AAAA,EACF;AAIF,YAAU,MAAM;;AACd,UAAIA,OAAAC,MAAA,6BAAM,aAAN,gBAAAA,IAAgB,YAAhB,gBAAAD,IAAyB,iBAAgB,SAAS;AACpD,eAAS,QAAQ;AAAA,IACnB,OAAO;AACL,eAAS,QAAQ;AAAA,IACnB;AAAA,EACF,GAAG,EAAC,wCAAM,aAAN,mBAAgB,YAAhB,mBAAyB,WAAW,CAAC;AAEzC,SAAO,oBAAC,mBAAA,EAAkB,OAAO,cAAe,SAAA,CAAS;AAC3D;AAEO,SAAS,uBAA+C;AAC7D,QAAM,UAAU,IAAI,iBAAiB;AACrC,MAAI,CAAC,SAAS;AACZ,UAAM,IAAI;AAAA,MACR;AAAA,IAAA;AAAA,EAEJ;AACA,SAAO;AACT;"}
|
|
@@ -9,6 +9,7 @@ import "clsx";
|
|
|
9
9
|
import "tailwind-merge";
|
|
10
10
|
import "@tanstack/react-query-devtools";
|
|
11
11
|
import "next-themes";
|
|
12
|
+
import "../../../auth/AuthModalContext.js";
|
|
12
13
|
import "@radix-ui/react-dialog";
|
|
13
14
|
import "../../../config/GlobalConfigContext.js";
|
|
14
15
|
import "../../../pluginCore/PluginManagerContext.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PersonBadge.js","sources":["../../../../../../src/modules/appCatalog/ui/components/PersonBadge.tsx"],"sourcesContent":["import { Check, Copy, User } from 'lucide-react'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { Badge } from '~/ui/badge'\nimport { useAppCatalogContext } from '~/modules/appCatalog'\nimport { getPersonBySlug } from '~/modules/appCatalog/utils/resolveHelpers'\n\ninterface PersonBadgeProps {\n slug: string\n}\n\nexport function PersonBadge({ slug }: PersonBadgeProps) {\n const { persons } = useAppCatalogContext()\n const person = getPersonBySlug(persons, slug)\n\n const displayName = person\n ? `${person.firstName} ${person.lastName}`.trim() || slug\n : slug\n\n const email = person?.email\n\n return (\n <Badge\n variant=\"outline\"\n className=\"font-normal inline-flex items-center gap-1\"\n title={email ? `${displayName} (${email})` : displayName}\n >\n <User className=\"size-3\" />\n {displayName}\n {email && <CopyEmailButton email={email} />}\n </Badge>\n )\n}\n\nfunction CopyEmailButton({ email }: { email: string }) {\n const [copied, setCopied] = useState(false)\n const timeoutRef = useRef<NodeJS.Timeout | null>(null)\n\n useEffect(() => {\n return () => {\n if (timeoutRef.current) clearTimeout(timeoutRef.current)\n }\n }, [])\n\n const handleCopy = useCallback(\n (e: React.MouseEvent) => {\n e.stopPropagation()\n navigator.clipboard.writeText(email)\n setCopied(true)\n if (timeoutRef.current) clearTimeout(timeoutRef.current)\n timeoutRef.current = setTimeout(() => setCopied(false), 2000)\n },\n [email],\n )\n\n return (\n <button\n onClick={handleCopy}\n className=\"ml-0.5 hover:text-primary transition-colors\"\n title={copied ? 'Copied!' : `Copy ${email}`}\n type=\"button\"\n >\n {copied ? (\n <Check className=\"size-3 text-green-600\" />\n ) : (\n <Copy className=\"size-3\" />\n )}\n </button>\n )\n}\n"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"PersonBadge.js","sources":["../../../../../../src/modules/appCatalog/ui/components/PersonBadge.tsx"],"sourcesContent":["import { Check, Copy, User } from 'lucide-react'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { Badge } from '~/ui/badge'\nimport { useAppCatalogContext } from '~/modules/appCatalog'\nimport { getPersonBySlug } from '~/modules/appCatalog/utils/resolveHelpers'\n\ninterface PersonBadgeProps {\n slug: string\n}\n\nexport function PersonBadge({ slug }: PersonBadgeProps) {\n const { persons } = useAppCatalogContext()\n const person = getPersonBySlug(persons, slug)\n\n const displayName = person\n ? `${person.firstName} ${person.lastName}`.trim() || slug\n : slug\n\n const email = person?.email\n\n return (\n <Badge\n variant=\"outline\"\n className=\"font-normal inline-flex items-center gap-1\"\n title={email ? `${displayName} (${email})` : displayName}\n >\n <User className=\"size-3\" />\n {displayName}\n {email && <CopyEmailButton email={email} />}\n </Badge>\n )\n}\n\nfunction CopyEmailButton({ email }: { email: string }) {\n const [copied, setCopied] = useState(false)\n const timeoutRef = useRef<NodeJS.Timeout | null>(null)\n\n useEffect(() => {\n return () => {\n if (timeoutRef.current) clearTimeout(timeoutRef.current)\n }\n }, [])\n\n const handleCopy = useCallback(\n (e: React.MouseEvent) => {\n e.stopPropagation()\n navigator.clipboard.writeText(email)\n setCopied(true)\n if (timeoutRef.current) clearTimeout(timeoutRef.current)\n timeoutRef.current = setTimeout(() => setCopied(false), 2000)\n },\n [email],\n )\n\n return (\n <button\n onClick={handleCopy}\n className=\"ml-0.5 hover:text-primary transition-colors\"\n title={copied ? 'Copied!' : `Copy ${email}`}\n type=\"button\"\n >\n {copied ? (\n <Check className=\"size-3 text-green-600\" />\n ) : (\n <Copy className=\"size-3\" />\n )}\n </button>\n )\n}\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;AAUO,SAAS,YAAY,EAAE,QAA0B;AACtD,QAAM,EAAE,QAAA,IAAY,qBAAA;AACpB,QAAM,SAAS,gBAAgB,SAAS,IAAI;AAE5C,QAAM,cAAc,SAChB,GAAG,OAAO,SAAS,IAAI,OAAO,QAAQ,GAAG,KAAA,KAAU,OACnD;AAEJ,QAAM,QAAQ,iCAAQ;AAEtB,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,SAAQ;AAAA,MACR,WAAU;AAAA,MACV,OAAO,QAAQ,GAAG,WAAW,KAAK,KAAK,MAAM;AAAA,MAE7C,UAAA;AAAA,QAAA,oBAAC,MAAA,EAAK,WAAU,SAAA,CAAS;AAAA,QACxB;AAAA,QACA,SAAS,oBAAC,iBAAA,EAAgB,MAAA,CAAc;AAAA,MAAA;AAAA,IAAA;AAAA,EAAA;AAG/C;AAEA,SAAS,gBAAgB,EAAE,SAA4B;AACrD,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,KAAK;AAC1C,QAAM,aAAa,OAA8B,IAAI;AAErD,YAAU,MAAM;AACd,WAAO,MAAM;AACX,UAAI,WAAW,QAAS,cAAa,WAAW,OAAO;AAAA,IACzD;AAAA,EACF,GAAG,CAAA,CAAE;AAEL,QAAM,aAAa;AAAA,IACjB,CAAC,MAAwB;AACvB,QAAE,gBAAA;AACF,gBAAU,UAAU,UAAU,KAAK;AACnC,gBAAU,IAAI;AACd,UAAI,WAAW,QAAS,cAAa,WAAW,OAAO;AACvD,iBAAW,UAAU,WAAW,MAAM,UAAU,KAAK,GAAG,GAAI;AAAA,IAC9D;AAAA,IACA,CAAC,KAAK;AAAA,EAAA;AAGR,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,SAAS;AAAA,MACT,WAAU;AAAA,MACV,OAAO,SAAS,YAAY,QAAQ,KAAK;AAAA,MACzC,MAAK;AAAA,MAEJ,UAAA,6BACE,OAAA,EAAM,WAAU,yBAAwB,IAEzC,oBAAC,MAAA,EAAK,WAAU,SAAA,CAAS;AAAA,IAAA;AAAA,EAAA;AAIjC;"}
|
|
@@ -13,6 +13,7 @@ import "clsx";
|
|
|
13
13
|
import "tailwind-merge";
|
|
14
14
|
import "@tanstack/react-query-devtools";
|
|
15
15
|
import "next-themes";
|
|
16
|
+
import "../../../auth/AuthModalContext.js";
|
|
16
17
|
import "@radix-ui/react-dialog";
|
|
17
18
|
import "../../../config/GlobalConfigContext.js";
|
|
18
19
|
import "../../../pluginCore/PluginManagerContext.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SubResourcesSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/SubResourcesSection.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { Search } from 'lucide-react'\nimport { useMemo, useState } from 'react'\nimport { Badge } from '~/ui/badge'\nimport { Input } from '~/ui/input'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { PersonBadge } from './PersonBadge'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '~/ui/select'\nimport { useAppCatalogContext } from '~/modules/appCatalog'\nimport { getGroupBySlug } from '~/modules/appCatalog/utils/resolveHelpers'\n\ninterface SubResourcesSectionProps {\n subResources: Resource[]\n}\n\nfunction getTierBadgeVariant(\n tierSlug: string,\n): 'default' | 'secondary' | 'destructive' | 'outline' {\n if (tierSlug === 'prod' || tierSlug === 'production') return 'destructive'\n if (tierSlug === 'dev' || tierSlug === 'staging') return 'secondary'\n if (tierSlug === 'preprod') return 'outline'\n if (tierSlug === 'sandbox') return 'outline'\n return 'outline'\n}\n\nfunction getTierBadgeClassName(tierSlug: string): string {\n if (tierSlug === 'preprod')\n return 'border-amber-400 bg-amber-100 text-amber-800 hover:bg-amber-200'\n if (tierSlug === 'sandbox')\n return 'border-gray-400 bg-gray-100 text-gray-700 hover:bg-gray-200'\n return ''\n}\n\nfunction getTierDisplayLabel(tierSlug: string): string {\n if (tierSlug === 'preprod') return 'Pre-Prod'\n if (tierSlug === 'sandbox') return 'Sandbox'\n if (tierSlug === 'prod' || tierSlug === 'production') return 'Prod'\n if (tierSlug === 'dev') return 'Dev'\n if (tierSlug === 'staging') return 'Staging'\n return tierSlug\n}\n\nexport function SubResourcesSection({\n subResources,\n}: SubResourcesSectionProps) {\n const { groups } = useAppCatalogContext()\n const [search, setSearch] = useState('')\n const [tierFilter, setTierFilter] = useState<string>('all')\n\n const uniqueTiers = useMemo(() => {\n const tiers = new Set<string>()\n for (const sr of subResources) {\n if (sr.tier) tiers.add(sr.tier)\n }\n return [...tiers].sort()\n }, [subResources])\n\n const filtered = useMemo(() => {\n let result = subResources\n\n if (tierFilter !== 'all') {\n result = result.filter((sr) => sr.tier === tierFilter)\n }\n\n if (search.trim()) {\n const q = search.trim().toLowerCase()\n result = result.filter(\n (sr) =>\n sr.displayName.toLowerCase().includes(q) ||\n (sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)) ||\n (sr.description?.toLowerCase().includes(q) ?? false),\n )\n }\n\n return result\n }, [subResources, search, tierFilter])\n\n if (subResources.length === 0) return null\n\n return (\n <div className=\"space-y-3\">\n <div className=\"flex items-center justify-between\">\n <div className=\"text-sm font-medium\">\n Sub-Resources ({filtered.length} of {subResources.length})\n </div>\n </div>\n\n {/* Filters */}\n <div className=\"flex gap-2\">\n <div className=\"relative flex-1\">\n <Search className=\"absolute left-2.5 top-2.5 size-4 text-muted-foreground\" />\n <Input\n placeholder=\"Search resources by name or alias...\"\n value={search}\n onChange={(e) => setSearch(e.target.value)}\n className=\"pl-9 h-9\"\n />\n </div>\n {uniqueTiers.length > 1 && (\n <Select value={tierFilter} onValueChange={setTierFilter}>\n <SelectTrigger className=\"w-[130px] h-9\">\n <SelectValue placeholder=\"All tiers\" />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"all\">All tiers</SelectItem>\n {uniqueTiers.map((tier) => (\n <SelectItem key={tier} value={tier}>\n {tier}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n )}\n </div>\n\n {/* Table */}\n <div className=\"rounded-lg border max-h-[400px] overflow-auto\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>Name</TableHead>\n <TableHead className=\"w-[80px]\">Tier</TableHead>\n <TableHead>Owner</TableHead>\n <TableHead>Access Contacts</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.length === 0 ? (\n <TableRow>\n <TableCell\n colSpan={4}\n className=\"text-center text-muted-foreground py-8\"\n >\n No resources match your filters\n </TableCell>\n </TableRow>\n ) : (\n filtered.map((sr) => {\n // Resolve maintainer group members\n const maintainerMembers = (\n sr.accessMaintainerGroupSlugs ?? []\n ).flatMap((groupSlug) => {\n const group = getGroupBySlug(groups, groupSlug)\n return group?.memberSlugs ?? []\n })\n // Deduplicate\n const uniqueMaintainers = [...new Set(maintainerMembers)]\n\n return (\n <TableRow key={sr.slug}>\n <TableCell>\n <div className=\"font-medium text-sm\">\n {sr.displayName}\n </div>\n {(sr.aliases ?? []).length > 0 && (\n <div className=\"text-xs text-muted-foreground mt-0.5\">\n {(sr.aliases ?? []).join(', ')}\n </div>\n )}\n {sr.description && (\n <div className=\"text-xs text-muted-foreground mt-0.5\">\n {sr.description}\n </div>\n )}\n </TableCell>\n <TableCell>\n {sr.tier && (\n <Badge\n variant={getTierBadgeVariant(sr.tier)}\n className={`text-xs ${getTierBadgeClassName(sr.tier)}`}\n >\n {getTierDisplayLabel(sr.tier)}\n </Badge>\n )}\n </TableCell>\n <TableCell>\n {sr.ownerPersonSlug && (\n <PersonBadge slug={sr.ownerPersonSlug} />\n )}\n </TableCell>\n <TableCell>\n {uniqueMaintainers.length > 0 ? (\n <div className=\"flex flex-wrap gap-1\">\n {uniqueMaintainers.map((personSlug) => (\n <PersonBadge key={personSlug} slug={personSlug} />\n ))}\n </div>\n ) : (\n <span className=\"text-muted-foreground\">-</span>\n )}\n </TableCell>\n </TableRow>\n )\n })\n )}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"SubResourcesSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/SubResourcesSection.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { Search } from 'lucide-react'\nimport { useMemo, useState } from 'react'\nimport { Badge } from '~/ui/badge'\nimport { Input } from '~/ui/input'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { PersonBadge } from './PersonBadge'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '~/ui/select'\nimport { useAppCatalogContext } from '~/modules/appCatalog'\nimport { getGroupBySlug } from '~/modules/appCatalog/utils/resolveHelpers'\n\ninterface SubResourcesSectionProps {\n subResources: Resource[]\n}\n\nfunction getTierBadgeVariant(\n tierSlug: string,\n): 'default' | 'secondary' | 'destructive' | 'outline' {\n if (tierSlug === 'prod' || tierSlug === 'production') return 'destructive'\n if (tierSlug === 'dev' || tierSlug === 'staging') return 'secondary'\n if (tierSlug === 'preprod') return 'outline'\n if (tierSlug === 'sandbox') return 'outline'\n return 'outline'\n}\n\nfunction getTierBadgeClassName(tierSlug: string): string {\n if (tierSlug === 'preprod')\n return 'border-amber-400 bg-amber-100 text-amber-800 hover:bg-amber-200'\n if (tierSlug === 'sandbox')\n return 'border-gray-400 bg-gray-100 text-gray-700 hover:bg-gray-200'\n return ''\n}\n\nfunction getTierDisplayLabel(tierSlug: string): string {\n if (tierSlug === 'preprod') return 'Pre-Prod'\n if (tierSlug === 'sandbox') return 'Sandbox'\n if (tierSlug === 'prod' || tierSlug === 'production') return 'Prod'\n if (tierSlug === 'dev') return 'Dev'\n if (tierSlug === 'staging') return 'Staging'\n return tierSlug\n}\n\nexport function SubResourcesSection({\n subResources,\n}: SubResourcesSectionProps) {\n const { groups } = useAppCatalogContext()\n const [search, setSearch] = useState('')\n const [tierFilter, setTierFilter] = useState<string>('all')\n\n const uniqueTiers = useMemo(() => {\n const tiers = new Set<string>()\n for (const sr of subResources) {\n if (sr.tier) tiers.add(sr.tier)\n }\n return [...tiers].sort()\n }, [subResources])\n\n const filtered = useMemo(() => {\n let result = subResources\n\n if (tierFilter !== 'all') {\n result = result.filter((sr) => sr.tier === tierFilter)\n }\n\n if (search.trim()) {\n const q = search.trim().toLowerCase()\n result = result.filter(\n (sr) =>\n sr.displayName.toLowerCase().includes(q) ||\n (sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)) ||\n (sr.description?.toLowerCase().includes(q) ?? false),\n )\n }\n\n return result\n }, [subResources, search, tierFilter])\n\n if (subResources.length === 0) return null\n\n return (\n <div className=\"space-y-3\">\n <div className=\"flex items-center justify-between\">\n <div className=\"text-sm font-medium\">\n Sub-Resources ({filtered.length} of {subResources.length})\n </div>\n </div>\n\n {/* Filters */}\n <div className=\"flex gap-2\">\n <div className=\"relative flex-1\">\n <Search className=\"absolute left-2.5 top-2.5 size-4 text-muted-foreground\" />\n <Input\n placeholder=\"Search resources by name or alias...\"\n value={search}\n onChange={(e) => setSearch(e.target.value)}\n className=\"pl-9 h-9\"\n />\n </div>\n {uniqueTiers.length > 1 && (\n <Select value={tierFilter} onValueChange={setTierFilter}>\n <SelectTrigger className=\"w-[130px] h-9\">\n <SelectValue placeholder=\"All tiers\" />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"all\">All tiers</SelectItem>\n {uniqueTiers.map((tier) => (\n <SelectItem key={tier} value={tier}>\n {tier}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n )}\n </div>\n\n {/* Table */}\n <div className=\"rounded-lg border max-h-[400px] overflow-auto\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>Name</TableHead>\n <TableHead className=\"w-[80px]\">Tier</TableHead>\n <TableHead>Owner</TableHead>\n <TableHead>Access Contacts</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.length === 0 ? (\n <TableRow>\n <TableCell\n colSpan={4}\n className=\"text-center text-muted-foreground py-8\"\n >\n No resources match your filters\n </TableCell>\n </TableRow>\n ) : (\n filtered.map((sr) => {\n // Resolve maintainer group members\n const maintainerMembers = (\n sr.accessMaintainerGroupSlugs ?? []\n ).flatMap((groupSlug) => {\n const group = getGroupBySlug(groups, groupSlug)\n return group?.memberSlugs ?? []\n })\n // Deduplicate\n const uniqueMaintainers = [...new Set(maintainerMembers)]\n\n return (\n <TableRow key={sr.slug}>\n <TableCell>\n <div className=\"font-medium text-sm\">\n {sr.displayName}\n </div>\n {(sr.aliases ?? []).length > 0 && (\n <div className=\"text-xs text-muted-foreground mt-0.5\">\n {(sr.aliases ?? []).join(', ')}\n </div>\n )}\n {sr.description && (\n <div className=\"text-xs text-muted-foreground mt-0.5\">\n {sr.description}\n </div>\n )}\n </TableCell>\n <TableCell>\n {sr.tier && (\n <Badge\n variant={getTierBadgeVariant(sr.tier)}\n className={`text-xs ${getTierBadgeClassName(sr.tier)}`}\n >\n {getTierDisplayLabel(sr.tier)}\n </Badge>\n )}\n </TableCell>\n <TableCell>\n {sr.ownerPersonSlug && (\n <PersonBadge slug={sr.ownerPersonSlug} />\n )}\n </TableCell>\n <TableCell>\n {uniqueMaintainers.length > 0 ? (\n <div className=\"flex flex-wrap gap-1\">\n {uniqueMaintainers.map((personSlug) => (\n <PersonBadge key={personSlug} slug={personSlug} />\n ))}\n </div>\n ) : (\n <span className=\"text-muted-foreground\">-</span>\n )}\n </TableCell>\n </TableRow>\n )\n })\n )}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -11,6 +11,7 @@ import "clsx";
|
|
|
11
11
|
import "tailwind-merge";
|
|
12
12
|
import "@tanstack/react-query-devtools";
|
|
13
13
|
import "next-themes";
|
|
14
|
+
import "../../../auth/AuthModalContext.js";
|
|
14
15
|
import "@radix-ui/react-dialog";
|
|
15
16
|
import "../../../config/GlobalConfigContext.js";
|
|
16
17
|
import "../../../pluginCore/PluginManagerContext.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TierVariantsSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/TierVariantsSection.tsx"],"sourcesContent":["import type {\n AppAccessRequest,\n AppApprovalMethod,\n TierVariant,\n} from '@igstack/app-catalog-backend-core'\nimport { Bot, ExternalLinkIcon, Settings, Users } from 'lucide-react'\nimport { useState } from 'react'\nimport ReactMarkdown from 'react-markdown'\nimport { Badge } from '~/ui/badge'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { useAppCatalogContext } from '~/modules/appCatalog'\nimport { PersonBadge } from './PersonBadge'\n\ninterface TierVariantsSectionProps {\n tiers: TierVariant[]\n}\n\nfunction getTierBadgeVariant(\n tierSlug: string,\n): 'default' | 'secondary' | 'destructive' | 'outline' {\n if (tierSlug === 'prod' || tierSlug === 'production') return 'destructive'\n if (tierSlug === 'dev' || tierSlug === 'staging') return 'secondary'\n if (tierSlug === 'preprod') return 'outline'\n if (tierSlug === 'sandbox') return 'outline'\n return 'outline'\n}\n\nfunction getTierBadgeClassName(tierSlug: string): string {\n if (tierSlug === 'preprod')\n return 'border-amber-400 bg-amber-100 text-amber-800 hover:bg-amber-200'\n if (tierSlug === 'sandbox')\n return 'border-gray-400 bg-gray-100 text-gray-700 hover:bg-gray-200'\n return ''\n}\n\nfunction getTierDisplayLabel(tierSlug: string): string {\n if (tierSlug === 'preprod') return 'Pre-Prod'\n if (tierSlug === 'sandbox') return 'Sandbox'\n if (tierSlug === 'prod' || tierSlug === 'production') return 'Prod'\n if (tierSlug === 'dev') return 'Dev'\n if (tierSlug === 'staging') return 'Staging'\n return tierSlug\n}\n\nfunction getAccessIcon(type: string): React.ReactNode {\n switch (type) {\n case 'service':\n return <Bot className=\"size-4 text-primary shrink-0\" />\n case 'personTeam':\n return <Users className=\"size-4 text-primary shrink-0\" />\n default:\n return <Settings className=\"size-4 text-primary shrink-0\" />\n }\n}\n\n/** Compact inline access detail for a tier row */\nfunction TierAccessDetail({\n accessRequest,\n methods,\n}: {\n accessRequest: AppAccessRequest\n methods: AppApprovalMethod[]\n}) {\n const [expanded, setExpanded] = useState(false)\n const method = methods.find(\n (m) => m.slug === accessRequest.approvalMethodSlug,\n )\n\n const hasExtra =\n accessRequest.comments ||\n accessRequest.urls?.length ||\n accessRequest.approverPersonSlugs?.length ||\n accessRequest.postApprovalInstructions\n\n return (\n <div className=\"space-y-1\">\n <div className=\"flex items-center gap-1.5\">\n {method && getAccessIcon(method.type)}\n {method?.type === 'service' && method.config.url ? (\n <a\n href={method.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-sm text-primary hover:underline inline-flex items-center gap-1\"\n >\n {method.displayName}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ) : (\n <span className=\"text-sm\">\n {method?.displayName ?? accessRequest.approvalMethodSlug}\n </span>\n )}\n {hasExtra && (\n <button\n type=\"button\"\n onClick={() => setExpanded(!expanded)}\n className=\"text-xs text-muted-foreground hover:text-primary ml-1\"\n >\n {expanded ? 'less' : 'more...'}\n </button>\n )}\n </div>\n {expanded && (\n <div className=\"pl-5 space-y-1.5 text-xs\">\n {accessRequest.comments && (\n <div className=\"text-muted-foreground prose prose-xs max-w-none\">\n <ReactMarkdown>{accessRequest.comments}</ReactMarkdown>\n </div>\n )}\n {accessRequest.urls && accessRequest.urls.length > 0 && (\n <div className=\"flex flex-col gap-0.5\">\n {accessRequest.urls.map((urlObj, idx) => (\n <a\n key={`${urlObj.url}-${idx}`}\n href={urlObj.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-primary hover:underline inline-flex items-center gap-1\"\n >\n {urlObj.label || urlObj.url.replace(/^https?:\\/\\//, '')}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ))}\n </div>\n )}\n {accessRequest.approverPersonSlugs &&\n accessRequest.approverPersonSlugs.length > 0 && (\n <div className=\"flex flex-wrap gap-1\">\n {accessRequest.approverPersonSlugs.map((slug) => (\n <PersonBadge key={slug} slug={slug} />\n ))}\n </div>\n )}\n </div>\n )}\n </div>\n )\n}\n\nexport function TierVariantsSection({ tiers }: TierVariantsSectionProps) {\n const { approvalMethods } = useAppCatalogContext()\n\n if (tiers.length === 0) return null\n\n return (\n <div className=\"space-y-2\">\n <div className=\"text-sm font-medium\">Environment Tiers</div>\n <div className=\"rounded-lg border\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead className=\"w-[100px]\">Tier</TableHead>\n <TableHead>Name</TableHead>\n <TableHead>URL</TableHead>\n <TableHead>Access</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {tiers.map((tier) => (\n <TableRow key={tier.tierSlug}>\n <TableCell>\n <Badge\n variant={getTierBadgeVariant(tier.tierSlug)}\n className={getTierBadgeClassName(tier.tierSlug)}\n >\n {getTierDisplayLabel(tier.tierSlug)}\n </Badge>\n </TableCell>\n <TableCell className=\"font-medium\">\n {tier.displayName ?? tier.tierSlug}\n </TableCell>\n <TableCell>\n {tier.appUrl ? (\n <a\n href={tier.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-sm text-primary hover:underline inline-flex items-center gap-1\"\n >\n {tier.appUrl.replace(/^https?:\\/\\//, '')}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">-</span>\n )}\n </TableCell>\n <TableCell>\n {tier.accessRequest ? (\n <TierAccessDetail\n accessRequest={tier.accessRequest}\n methods={approvalMethods}\n />\n ) : (\n <span className=\"text-muted-foreground\">Same as app</span>\n )}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AAwBA,SAAS,oBACP,UACqD;AACrD,MAAI,aAAa,UAAU,aAAa,aAAc,QAAO;AAC7D,MAAI,aAAa,SAAS,aAAa,UAAW,QAAO;AACzD,MAAI,aAAa,UAAW,QAAO;AACnC,MAAI,aAAa,UAAW,QAAO;AACnC,SAAO;AACT;AAEA,SAAS,sBAAsB,UAA0B;AACvD,MAAI,aAAa;AACf,WAAO;AACT,MAAI,aAAa;AACf,WAAO;AACT,SAAO;AACT;AAEA,SAAS,oBAAoB,UAA0B;AACrD,MAAI,aAAa,UAAW,QAAO;AACnC,MAAI,aAAa,UAAW,QAAO;AACnC,MAAI,aAAa,UAAU,aAAa,aAAc,QAAO;AAC7D,MAAI,aAAa,MAAO,QAAO;AAC/B,MAAI,aAAa,UAAW,QAAO;AACnC,SAAO;AACT;AAEA,SAAS,cAAc,MAA+B;AACpD,UAAQ,MAAA;AAAA,IACN,KAAK;AACH,aAAO,oBAAC,KAAA,EAAI,WAAU,+BAAA,CAA+B;AAAA,IACvD,KAAK;AACH,aAAO,oBAAC,OAAA,EAAM,WAAU,+BAAA,CAA+B;AAAA,IACzD;AACE,aAAO,oBAAC,UAAA,EAAS,WAAU,+BAAA,CAA+B;AAAA,EAAA;AAEhE;AAGA,SAAS,iBAAiB;AAAA,EACxB;AAAA,EACA;AACF,GAGG;;AACD,QAAM,CAAC,UAAU,WAAW,IAAI,SAAS,KAAK;AAC9C,QAAM,SAAS,QAAQ;AAAA,IACrB,CAAC,MAAM,EAAE,SAAS,cAAc;AAAA,EAAA;AAGlC,QAAM,WACJ,cAAc,cACd,mBAAc,SAAd,mBAAoB,aACpB,mBAAc,wBAAd,mBAAmC,WACnC,cAAc;AAEhB,SACE,qBAAC,OAAA,EAAI,WAAU,aACb,UAAA;AAAA,IAAA,qBAAC,OAAA,EAAI,WAAU,6BACZ,UAAA;AAAA,MAAA,UAAU,cAAc,OAAO,IAAI;AAAA,OACnC,iCAAQ,UAAS,aAAa,OAAO,OAAO,MAC3C;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,MAAM,OAAO,OAAO;AAAA,UACpB,QAAO;AAAA,UACP,KAAI;AAAA,UACJ,WAAU;AAAA,UAET,UAAA;AAAA,YAAA,OAAO;AAAA,YACR,oBAAC,kBAAA,EAAiB,WAAU,SAAA,CAAS;AAAA,UAAA;AAAA,QAAA;AAAA,MAAA,wBAGtC,QAAA,EAAK,WAAU,WACb,WAAA,iCAAQ,gBAAe,cAAc,oBACxC;AAAA,MAED,YACC;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,MAAK;AAAA,UACL,SAAS,MAAM,YAAY,CAAC,QAAQ;AAAA,UACpC,WAAU;AAAA,UAET,qBAAW,SAAS;AAAA,QAAA;AAAA,MAAA;AAAA,IACvB,GAEJ;AAAA,IACC,YACC,qBAAC,OAAA,EAAI,WAAU,4BACZ,UAAA;AAAA,MAAA,cAAc,gCACZ,OAAA,EAAI,WAAU,mDACb,UAAA,oBAAC,eAAA,EAAe,UAAA,cAAc,SAAA,CAAS,EAAA,CACzC;AAAA,MAED,cAAc,QAAQ,cAAc,KAAK,SAAS,KACjD,oBAAC,OAAA,EAAI,WAAU,yBACZ,UAAA,cAAc,KAAK,IAAI,CAAC,QAAQ,QAC/B;AAAA,QAAC;AAAA,QAAA;AAAA,UAEC,MAAM,OAAO;AAAA,UACb,QAAO;AAAA,UACP,KAAI;AAAA,UACJ,WAAU;AAAA,UAET,UAAA;AAAA,YAAA,OAAO,SAAS,OAAO,IAAI,QAAQ,gBAAgB,EAAE;AAAA,YACtD,oBAAC,kBAAA,EAAiB,WAAU,SAAA,CAAS;AAAA,UAAA;AAAA,QAAA;AAAA,QAPhC,GAAG,OAAO,GAAG,IAAI,GAAG;AAAA,MAAA,CAS5B,GACH;AAAA,MAED,cAAc,uBACb,cAAc,oBAAoB,SAAS,KACzC,oBAAC,SAAI,WAAU,wBACZ,wBAAc,oBAAoB,IAAI,CAAC,SACtC,oBAAC,eAAuB,KAAA,GAAN,IAAkB,CACrC,EAAA,CACH;AAAA,IAAA,EAAA,CAEN;AAAA,EAAA,GAEJ;AAEJ;AAEO,SAAS,oBAAoB,EAAE,SAAmC;AACvE,QAAM,EAAE,gBAAA,IAAoB,qBAAA;AAE5B,MAAI,MAAM,WAAW,EAAG,QAAO;AAE/B,SACE,qBAAC,OAAA,EAAI,WAAU,aACb,UAAA;AAAA,IAAA,oBAAC,OAAA,EAAI,WAAU,uBAAsB,UAAA,qBAAiB;AAAA,IACtD,oBAAC,OAAA,EAAI,WAAU,qBACb,+BAAC,OAAA,EACC,UAAA;AAAA,MAAA,oBAAC,aAAA,EACC,+BAAC,UAAA,EACC,UAAA;AAAA,QAAA,oBAAC,WAAA,EAAU,WAAU,aAAY,UAAA,QAAI;AAAA,QACrC,oBAAC,aAAU,UAAA,OAAA,CAAI;AAAA,QACf,oBAAC,aAAU,UAAA,MAAA,CAAG;AAAA,QACd,oBAAC,aAAU,UAAA,SAAA,CAAM;AAAA,MAAA,EAAA,CACnB,EAAA,CACF;AAAA,0BACC,WAAA,EACE,UAAA,MAAM,IAAI,CAAC,8BACT,UAAA,EACC,UAAA;AAAA,QAAA,oBAAC,WAAA,EACC,UAAA;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,SAAS,oBAAoB,KAAK,QAAQ;AAAA,YAC1C,WAAW,sBAAsB,KAAK,QAAQ;AAAA,YAE7C,UAAA,oBAAoB,KAAK,QAAQ;AAAA,UAAA;AAAA,QAAA,GAEtC;AAAA,4BACC,WAAA,EAAU,WAAU,eAClB,UAAA,KAAK,eAAe,KAAK,UAC5B;AAAA,QACA,oBAAC,WAAA,EACE,UAAA,KAAK,SACJ;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,MAAM,KAAK;AAAA,YACX,QAAO;AAAA,YACP,KAAI;AAAA,YACJ,WAAU;AAAA,YAET,UAAA;AAAA,cAAA,KAAK,OAAO,QAAQ,gBAAgB,EAAE;AAAA,cACvC,oBAAC,kBAAA,EAAiB,WAAU,SAAA,CAAS;AAAA,YAAA;AAAA,UAAA;AAAA,QAAA,IAGvC,oBAAC,QAAA,EAAK,WAAU,yBAAwB,eAAC,GAE7C;AAAA,QACA,oBAAC,WAAA,EACE,UAAA,KAAK,gBACJ;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,eAAe,KAAK;AAAA,YACpB,SAAS;AAAA,UAAA;AAAA,QAAA,IAGX,oBAAC,QAAA,EAAK,WAAU,yBAAwB,yBAAW,EAAA,CAEvD;AAAA,MAAA,KApCa,KAAK,QAqCpB,CACD,EAAA,CACH;AAAA,IAAA,EAAA,CACF,EAAA,CACF;AAAA,EAAA,GACF;AAEJ;"}
|
|
1
|
+
{"version":3,"file":"TierVariantsSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/TierVariantsSection.tsx"],"sourcesContent":["import type {\n AppAccessRequest,\n AppApprovalMethod,\n TierVariant,\n} from '@igstack/app-catalog-backend-core'\nimport { Bot, ExternalLinkIcon, Settings, Users } from 'lucide-react'\nimport { useState } from 'react'\nimport ReactMarkdown from 'react-markdown'\nimport { Badge } from '~/ui/badge'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { useAppCatalogContext } from '~/modules/appCatalog'\nimport { PersonBadge } from './PersonBadge'\n\ninterface TierVariantsSectionProps {\n tiers: TierVariant[]\n}\n\nfunction getTierBadgeVariant(\n tierSlug: string,\n): 'default' | 'secondary' | 'destructive' | 'outline' {\n if (tierSlug === 'prod' || tierSlug === 'production') return 'destructive'\n if (tierSlug === 'dev' || tierSlug === 'staging') return 'secondary'\n if (tierSlug === 'preprod') return 'outline'\n if (tierSlug === 'sandbox') return 'outline'\n return 'outline'\n}\n\nfunction getTierBadgeClassName(tierSlug: string): string {\n if (tierSlug === 'preprod')\n return 'border-amber-400 bg-amber-100 text-amber-800 hover:bg-amber-200'\n if (tierSlug === 'sandbox')\n return 'border-gray-400 bg-gray-100 text-gray-700 hover:bg-gray-200'\n return ''\n}\n\nfunction getTierDisplayLabel(tierSlug: string): string {\n if (tierSlug === 'preprod') return 'Pre-Prod'\n if (tierSlug === 'sandbox') return 'Sandbox'\n if (tierSlug === 'prod' || tierSlug === 'production') return 'Prod'\n if (tierSlug === 'dev') return 'Dev'\n if (tierSlug === 'staging') return 'Staging'\n return tierSlug\n}\n\nfunction getAccessIcon(type: string): React.ReactNode {\n switch (type) {\n case 'service':\n return <Bot className=\"size-4 text-primary shrink-0\" />\n case 'personTeam':\n return <Users className=\"size-4 text-primary shrink-0\" />\n default:\n return <Settings className=\"size-4 text-primary shrink-0\" />\n }\n}\n\n/** Compact inline access detail for a tier row */\nfunction TierAccessDetail({\n accessRequest,\n methods,\n}: {\n accessRequest: AppAccessRequest\n methods: AppApprovalMethod[]\n}) {\n const [expanded, setExpanded] = useState(false)\n const method = methods.find(\n (m) => m.slug === accessRequest.approvalMethodSlug,\n )\n\n const hasExtra =\n accessRequest.comments ||\n accessRequest.urls?.length ||\n accessRequest.approverPersonSlugs?.length ||\n accessRequest.postApprovalInstructions\n\n return (\n <div className=\"space-y-1\">\n <div className=\"flex items-center gap-1.5\">\n {method && getAccessIcon(method.type)}\n {method?.type === 'service' && method.config.url ? (\n <a\n href={method.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-sm text-primary hover:underline inline-flex items-center gap-1\"\n >\n {method.displayName}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ) : (\n <span className=\"text-sm\">\n {method?.displayName ?? accessRequest.approvalMethodSlug}\n </span>\n )}\n {hasExtra && (\n <button\n type=\"button\"\n onClick={() => setExpanded(!expanded)}\n className=\"text-xs text-muted-foreground hover:text-primary ml-1\"\n >\n {expanded ? 'less' : 'more...'}\n </button>\n )}\n </div>\n {expanded && (\n <div className=\"pl-5 space-y-1.5 text-xs\">\n {accessRequest.comments && (\n <div className=\"text-muted-foreground prose prose-xs max-w-none\">\n <ReactMarkdown>{accessRequest.comments}</ReactMarkdown>\n </div>\n )}\n {accessRequest.urls && accessRequest.urls.length > 0 && (\n <div className=\"flex flex-col gap-0.5\">\n {accessRequest.urls.map((urlObj, idx) => (\n <a\n key={`${urlObj.url}-${idx}`}\n href={urlObj.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-primary hover:underline inline-flex items-center gap-1\"\n >\n {urlObj.label || urlObj.url.replace(/^https?:\\/\\//, '')}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ))}\n </div>\n )}\n {accessRequest.approverPersonSlugs &&\n accessRequest.approverPersonSlugs.length > 0 && (\n <div className=\"flex flex-wrap gap-1\">\n {accessRequest.approverPersonSlugs.map((slug) => (\n <PersonBadge key={slug} slug={slug} />\n ))}\n </div>\n )}\n </div>\n )}\n </div>\n )\n}\n\nexport function TierVariantsSection({ tiers }: TierVariantsSectionProps) {\n const { approvalMethods } = useAppCatalogContext()\n\n if (tiers.length === 0) return null\n\n return (\n <div className=\"space-y-2\">\n <div className=\"text-sm font-medium\">Environment Tiers</div>\n <div className=\"rounded-lg border\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead className=\"w-[100px]\">Tier</TableHead>\n <TableHead>Name</TableHead>\n <TableHead>URL</TableHead>\n <TableHead>Access</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {tiers.map((tier) => (\n <TableRow key={tier.tierSlug}>\n <TableCell>\n <Badge\n variant={getTierBadgeVariant(tier.tierSlug)}\n className={getTierBadgeClassName(tier.tierSlug)}\n >\n {getTierDisplayLabel(tier.tierSlug)}\n </Badge>\n </TableCell>\n <TableCell className=\"font-medium\">\n {tier.displayName ?? tier.tierSlug}\n </TableCell>\n <TableCell>\n {tier.appUrl ? (\n <a\n href={tier.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-sm text-primary hover:underline inline-flex items-center gap-1\"\n >\n {tier.appUrl.replace(/^https?:\\/\\//, '')}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">-</span>\n )}\n </TableCell>\n <TableCell>\n {tier.accessRequest ? (\n <TierAccessDetail\n accessRequest={tier.accessRequest}\n methods={approvalMethods}\n />\n ) : (\n <span className=\"text-muted-foreground\">Same as app</span>\n )}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -160,10 +160,13 @@ function Gallery({
|
|
|
160
160
|
{
|
|
161
161
|
src: currentImage.url,
|
|
162
162
|
alt: currentImage.alt,
|
|
163
|
-
className: "max-w-full h-auto",
|
|
163
|
+
className: "max-w-full h-auto cursor-zoom-out",
|
|
164
|
+
onClick: () => setFullscreen(false),
|
|
164
165
|
onLoad: () => handleImageLoad(currentIndex),
|
|
165
166
|
onError: () => handleImageError(currentIndex),
|
|
166
|
-
style: { opacity: imageState === "loaded" ? 1 : 0 }
|
|
167
|
+
style: { opacity: imageState === "loaded" ? 1 : 0 },
|
|
168
|
+
role: "button",
|
|
169
|
+
"aria-label": "Zoom out"
|
|
167
170
|
}
|
|
168
171
|
)
|
|
169
172
|
] }),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Gallery.js","sources":["../../../../src/modules/gallery/Gallery.tsx"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react'\nimport useEmblaCarousel from 'embla-carousel-react'\nimport { ChevronLeft, ChevronRight, ImageOff, X } from 'lucide-react'\nimport { clamp } from 'radashi'\nimport { useHotkeys } from 'react-hotkeys-hook'\n\nimport { Button } from '~/ui/button'\nimport { Spinner } from '~/ui/spinner'\nimport { cn } from '~/lib/utils'\n\nexport interface GalleryImage {\n url: string\n alt: string\n}\n\nexport interface GalleryProps {\n images: GalleryImage[]\n initialIndex?: number\n onIndexChange?: (index: number) => void\n onFullscreenChange?: (isFullscreen: boolean) => void\n className?: string\n title?: string\n}\n\nexport function Gallery({\n images,\n initialIndex = 0,\n onIndexChange,\n onFullscreenChange,\n className,\n title,\n}: GalleryProps) {\n const [emblaRef, emblaApi] = useEmblaCarousel({\n loop: false,\n align: 'center',\n })\n // Use lazy initialization for clamp() call\n const [currentIndex, setCurrentIndex] = useState(() =>\n clamp(initialIndex, 0, images.length - 1),\n )\n const [isFullscreen, setIsFullscreen] = useState(false)\n const [imageStates, setImageStates] = useState<\n Record<number, 'loading' | 'loaded' | 'error'>\n >(() => Object.fromEntries(images.map((_, i) => [i, 'loading'])))\n\n // Reset image states when images array changes\n useEffect(() => {\n setImageStates(Object.fromEntries(images.map((_, i) => [i, 'loading'])))\n }, [images])\n\n // Stabilize onFullscreenChange callback with ref\n const onFullscreenChangeRef = useRef(onFullscreenChange)\n useEffect(() => {\n onFullscreenChangeRef.current = onFullscreenChange\n }, [onFullscreenChange])\n\n const setFullscreen = useCallback((value: boolean) => {\n setIsFullscreen(value)\n onFullscreenChangeRef.current?.(value)\n }, [])\n\n // Stabilize onIndexChange callback with ref\n const onIndexChangeRef = useRef(onIndexChange)\n useEffect(() => {\n onIndexChangeRef.current = onIndexChange\n }, [onIndexChange])\n\n const scrollPrev = useCallback(() => {\n if (emblaApi) emblaApi.scrollPrev()\n }, [emblaApi])\n\n const scrollNext = useCallback(() => {\n if (emblaApi) emblaApi.scrollNext()\n }, [emblaApi])\n\n // Sync current index with embla using event handler\n const onSelect = useCallback(() => {\n if (!emblaApi) return\n const index = emblaApi.selectedScrollSnap()\n setCurrentIndex(index)\n onIndexChangeRef.current?.(index)\n }, [emblaApi])\n\n // Subscribe to embla select events and initialize\n useEffect(() => {\n if (!emblaApi) return\n\n // Initialize current index\n onSelect()\n\n // Subscribe to future changes\n emblaApi.on('select', onSelect)\n return () => {\n emblaApi.off('select', onSelect)\n }\n }, [emblaApi, onSelect])\n\n // Scroll to initial index on mount only\n useEffect(() => {\n if (emblaApi && typeof initialIndex === 'number') {\n emblaApi.scrollTo(initialIndex)\n }\n }, [emblaApi, initialIndex])\n\n // Keyboard navigation\n useHotkeys(\n 'left',\n (e) => {\n e.preventDefault()\n scrollPrev()\n },\n {\n enabled: images.length > 1,\n enableOnFormTags: false,\n preventDefault: true,\n },\n [scrollPrev, images.length],\n )\n useHotkeys(\n 'right',\n (e) => {\n e.preventDefault()\n scrollNext()\n },\n {\n enabled: images.length > 1,\n enableOnFormTags: false,\n preventDefault: true,\n },\n [scrollNext, images.length],\n )\n // Intercept Escape in fullscreen — must stopPropagation before Radix Dialog sees it\n useEffect(() => {\n if (!isFullscreen) return\n\n const handleEscape = (e: KeyboardEvent) => {\n if (e.key === 'Escape') {\n e.stopPropagation()\n e.preventDefault()\n setFullscreen(false)\n }\n }\n\n document.addEventListener('keydown', handleEscape, true)\n return () => {\n document.removeEventListener('keydown', handleEscape, true)\n }\n }, [isFullscreen, setFullscreen])\n\n // Mouse wheel navigation with debouncing\n useEffect(() => {\n if (!emblaApi || images.length <= 1) return\n\n const emblaNode = emblaApi.rootNode()\n\n let isScrolling = false\n let scrollTimeout: NodeJS.Timeout\n\n const handleWheel = (event: WheelEvent) => {\n // Prevent page scroll when wheeling over gallery\n event.preventDefault()\n\n // Skip if already scrolling (debounce)\n if (isScrolling) return\n\n // Determine scroll direction\n const deltaY = event.deltaY\n const deltaX = event.deltaX\n\n // Use vertical scroll primarily, fall back to horizontal\n const delta = Math.abs(deltaY) > Math.abs(deltaX) ? deltaY : deltaX\n\n if (delta > 0) {\n // Scroll down/right -> next slide\n scrollNext()\n } else if (delta < 0) {\n // Scroll up/left -> previous slide\n scrollPrev()\n }\n\n // Set cooldown period\n isScrolling = true\n clearTimeout(scrollTimeout)\n scrollTimeout = setTimeout(() => {\n isScrolling = false\n }, 300) // 300ms cooldown\n }\n\n emblaNode.addEventListener('wheel', handleWheel, { passive: false })\n\n return () => {\n emblaNode.removeEventListener('wheel', handleWheel)\n clearTimeout(scrollTimeout)\n }\n }, [emblaApi, scrollNext, scrollPrev, images.length])\n\n const currentImage = images[currentIndex]\n\n const handleImageLoad = (index: number) => {\n setImageStates((prev) => ({ ...prev, [index]: 'loaded' }))\n }\n\n const handleImageError = (index: number) => {\n setImageStates((prev) => ({ ...prev, [index]: 'error' }))\n }\n\n // Fullscreen mode\n if (isFullscreen && currentImage) {\n const imageState = imageStates[currentIndex]\n return (\n <div\n className=\"fixed inset-0 z-50 bg-background\"\n role=\"dialog\"\n aria-label=\"Fullscreen view\"\n >\n {imageState === 'error' ? (\n <div className=\"flex h-screen w-screen items-center justify-center\">\n <div className=\"flex flex-col items-center gap-2 text-muted-foreground\">\n <ImageOff className=\"size-12\" />\n <p className=\"text-sm\">Failed to load image</p>\n </div>\n </div>\n ) : (\n <div className=\"h-full w-full overflow-auto p-2\">\n {imageState !== 'loaded' && (\n <div className=\"absolute inset-0 flex items-center justify-center\">\n <Spinner className=\"size-8\" />\n </div>\n )}\n <img\n src={currentImage.url}\n alt={currentImage.alt}\n className=\"max-w-full h-auto\"\n onLoad={() => handleImageLoad(currentIndex)}\n onError={() => handleImageError(currentIndex)}\n style={{ opacity: imageState === 'loaded' ? 1 : 0 }}\n />\n </div>\n )}\n <Button\n variant=\"outline\"\n size=\"icon\"\n onClick={() => setFullscreen(false)}\n className=\"absolute top-4 right-4 z-10\"\n aria-label=\"Exit fullscreen\"\n >\n <X className=\"size-4\" />\n </Button>\n </div>\n )\n }\n\n return (\n <div className={cn('flex h-full flex-col min-h-0', className)}>\n {/* Title Header */}\n {title && (\n <div className=\"px-6 py-4\">\n <h2 className=\"text-lg font-semibold\">{title}</h2>\n </div>\n )}\n\n {/* Carousel Viewport with Peek Effect */}\n <div className=\"relative flex-1 min-h-0 overflow-hidden px-4\">\n <div ref={emblaRef} className=\"h-full overflow-hidden\">\n <div className=\"flex h-full gap-4\">\n {images.map((image, index) => {\n const imageState = imageStates[index]\n // Use 100% width for single image, 85% for multiple (peek effect)\n const slideWidth = images.length === 1 ? '100%' : '85%'\n return (\n <div\n key={image.url}\n className={cn(\n 'relative flex min-w-0 items-center justify-center transition-opacity duration-300 h-full',\n index !== currentIndex && 'opacity-40',\n )}\n style={{ flexBasis: slideWidth, flexShrink: 0, flexGrow: 0 }}\n >\n {imageState === 'error' ? (\n <div className=\"flex flex-col items-center gap-2 text-muted-foreground\">\n <ImageOff className=\"size-12\" />\n <p className=\"text-xs\">Failed to load</p>\n </div>\n ) : (\n <>\n {imageState !== 'loaded' && (\n <div className=\"absolute inset-0 flex items-center justify-center\">\n <Spinner className=\"size-8\" />\n </div>\n )}\n <img\n src={image.url}\n alt={image.alt}\n className=\"max-w-full max-h-full w-auto h-auto object-contain cursor-pointer border border-border/30 rounded\"\n onClick={() => {\n if (index !== currentIndex) {\n emblaApi?.scrollTo(index)\n } else {\n setFullscreen(true)\n }\n }}\n onLoad={() => handleImageLoad(index)}\n onError={() => handleImageError(index)}\n style={{ opacity: imageState === 'loaded' ? 1 : 0 }}\n role=\"button\"\n aria-label={`Navigate to ${image.alt}`}\n />\n </>\n )}\n </div>\n )\n })}\n </div>\n </div>\n </div>\n\n {/* Bottom Controls */}\n <div className=\"flex items-center justify-center gap-4 py-4\">\n <Button\n variant=\"outline\"\n size=\"icon\"\n onClick={scrollPrev}\n disabled={images.length <= 1}\n aria-label=\"Previous screenshot\"\n className=\"h-12 w-12\"\n >\n <ChevronLeft className=\"h-12 w-12\" />\n </Button>\n\n <div className=\"text-sm text-muted-foreground\" aria-live=\"polite\">\n {currentIndex + 1} / {images.length}\n </div>\n\n <Button\n variant=\"outline\"\n size=\"icon\"\n onClick={scrollNext}\n disabled={images.length <= 1}\n aria-label=\"Next screenshot\"\n className=\"h-12 w-12\"\n >\n <ChevronRight className=\"h-12 w-12\" />\n </Button>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"Gallery.js","sources":["../../../../src/modules/gallery/Gallery.tsx"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react'\nimport useEmblaCarousel from 'embla-carousel-react'\nimport { ChevronLeft, ChevronRight, ImageOff, X } from 'lucide-react'\nimport { clamp } from 'radashi'\nimport { useHotkeys } from 'react-hotkeys-hook'\n\nimport { Button } from '~/ui/button'\nimport { Spinner } from '~/ui/spinner'\nimport { cn } from '~/lib/utils'\n\nexport interface GalleryImage {\n url: string\n alt: string\n}\n\nexport interface GalleryProps {\n images: GalleryImage[]\n initialIndex?: number\n onIndexChange?: (index: number) => void\n onFullscreenChange?: (isFullscreen: boolean) => void\n className?: string\n title?: string\n}\n\nexport function Gallery({\n images,\n initialIndex = 0,\n onIndexChange,\n onFullscreenChange,\n className,\n title,\n}: GalleryProps) {\n const [emblaRef, emblaApi] = useEmblaCarousel({\n loop: false,\n align: 'center',\n })\n // Use lazy initialization for clamp() call\n const [currentIndex, setCurrentIndex] = useState(() =>\n clamp(initialIndex, 0, images.length - 1),\n )\n const [isFullscreen, setIsFullscreen] = useState(false)\n const [imageStates, setImageStates] = useState<\n Record<number, 'loading' | 'loaded' | 'error'>\n >(() => Object.fromEntries(images.map((_, i) => [i, 'loading'])))\n\n // Reset image states when images array changes\n useEffect(() => {\n setImageStates(Object.fromEntries(images.map((_, i) => [i, 'loading'])))\n }, [images])\n\n // Stabilize onFullscreenChange callback with ref\n const onFullscreenChangeRef = useRef(onFullscreenChange)\n useEffect(() => {\n onFullscreenChangeRef.current = onFullscreenChange\n }, [onFullscreenChange])\n\n const setFullscreen = useCallback((value: boolean) => {\n setIsFullscreen(value)\n onFullscreenChangeRef.current?.(value)\n }, [])\n\n // Stabilize onIndexChange callback with ref\n const onIndexChangeRef = useRef(onIndexChange)\n useEffect(() => {\n onIndexChangeRef.current = onIndexChange\n }, [onIndexChange])\n\n const scrollPrev = useCallback(() => {\n if (emblaApi) emblaApi.scrollPrev()\n }, [emblaApi])\n\n const scrollNext = useCallback(() => {\n if (emblaApi) emblaApi.scrollNext()\n }, [emblaApi])\n\n // Sync current index with embla using event handler\n const onSelect = useCallback(() => {\n if (!emblaApi) return\n const index = emblaApi.selectedScrollSnap()\n setCurrentIndex(index)\n onIndexChangeRef.current?.(index)\n }, [emblaApi])\n\n // Subscribe to embla select events and initialize\n useEffect(() => {\n if (!emblaApi) return\n\n // Initialize current index\n onSelect()\n\n // Subscribe to future changes\n emblaApi.on('select', onSelect)\n return () => {\n emblaApi.off('select', onSelect)\n }\n }, [emblaApi, onSelect])\n\n // Scroll to initial index on mount only\n useEffect(() => {\n if (emblaApi && typeof initialIndex === 'number') {\n emblaApi.scrollTo(initialIndex)\n }\n }, [emblaApi, initialIndex])\n\n // Keyboard navigation\n useHotkeys(\n 'left',\n (e) => {\n e.preventDefault()\n scrollPrev()\n },\n {\n enabled: images.length > 1,\n enableOnFormTags: false,\n preventDefault: true,\n },\n [scrollPrev, images.length],\n )\n useHotkeys(\n 'right',\n (e) => {\n e.preventDefault()\n scrollNext()\n },\n {\n enabled: images.length > 1,\n enableOnFormTags: false,\n preventDefault: true,\n },\n [scrollNext, images.length],\n )\n // Intercept Escape in fullscreen — must stopPropagation before Radix Dialog sees it\n useEffect(() => {\n if (!isFullscreen) return\n\n const handleEscape = (e: KeyboardEvent) => {\n if (e.key === 'Escape') {\n e.stopPropagation()\n e.preventDefault()\n setFullscreen(false)\n }\n }\n\n document.addEventListener('keydown', handleEscape, true)\n return () => {\n document.removeEventListener('keydown', handleEscape, true)\n }\n }, [isFullscreen, setFullscreen])\n\n // Mouse wheel navigation with debouncing\n useEffect(() => {\n if (!emblaApi || images.length <= 1) return\n\n const emblaNode = emblaApi.rootNode()\n\n let isScrolling = false\n let scrollTimeout: NodeJS.Timeout\n\n const handleWheel = (event: WheelEvent) => {\n // Prevent page scroll when wheeling over gallery\n event.preventDefault()\n\n // Skip if already scrolling (debounce)\n if (isScrolling) return\n\n // Determine scroll direction\n const deltaY = event.deltaY\n const deltaX = event.deltaX\n\n // Use vertical scroll primarily, fall back to horizontal\n const delta = Math.abs(deltaY) > Math.abs(deltaX) ? deltaY : deltaX\n\n if (delta > 0) {\n // Scroll down/right -> next slide\n scrollNext()\n } else if (delta < 0) {\n // Scroll up/left -> previous slide\n scrollPrev()\n }\n\n // Set cooldown period\n isScrolling = true\n clearTimeout(scrollTimeout)\n scrollTimeout = setTimeout(() => {\n isScrolling = false\n }, 300) // 300ms cooldown\n }\n\n emblaNode.addEventListener('wheel', handleWheel, { passive: false })\n\n return () => {\n emblaNode.removeEventListener('wheel', handleWheel)\n clearTimeout(scrollTimeout)\n }\n }, [emblaApi, scrollNext, scrollPrev, images.length])\n\n const currentImage = images[currentIndex]\n\n const handleImageLoad = (index: number) => {\n setImageStates((prev) => ({ ...prev, [index]: 'loaded' }))\n }\n\n const handleImageError = (index: number) => {\n setImageStates((prev) => ({ ...prev, [index]: 'error' }))\n }\n\n // Fullscreen mode\n if (isFullscreen && currentImage) {\n const imageState = imageStates[currentIndex]\n return (\n <div\n className=\"fixed inset-0 z-50 bg-background\"\n role=\"dialog\"\n aria-label=\"Fullscreen view\"\n >\n {imageState === 'error' ? (\n <div className=\"flex h-screen w-screen items-center justify-center\">\n <div className=\"flex flex-col items-center gap-2 text-muted-foreground\">\n <ImageOff className=\"size-12\" />\n <p className=\"text-sm\">Failed to load image</p>\n </div>\n </div>\n ) : (\n <div className=\"h-full w-full overflow-auto p-2\">\n {imageState !== 'loaded' && (\n <div className=\"absolute inset-0 flex items-center justify-center\">\n <Spinner className=\"size-8\" />\n </div>\n )}\n <img\n src={currentImage.url}\n alt={currentImage.alt}\n className=\"max-w-full h-auto cursor-zoom-out\"\n onClick={() => setFullscreen(false)}\n onLoad={() => handleImageLoad(currentIndex)}\n onError={() => handleImageError(currentIndex)}\n style={{ opacity: imageState === 'loaded' ? 1 : 0 }}\n role=\"button\"\n aria-label=\"Zoom out\"\n />\n </div>\n )}\n <Button\n variant=\"outline\"\n size=\"icon\"\n onClick={() => setFullscreen(false)}\n className=\"absolute top-4 right-4 z-10\"\n aria-label=\"Exit fullscreen\"\n >\n <X className=\"size-4\" />\n </Button>\n </div>\n )\n }\n\n return (\n <div className={cn('flex h-full flex-col min-h-0', className)}>\n {/* Title Header */}\n {title && (\n <div className=\"px-6 py-4\">\n <h2 className=\"text-lg font-semibold\">{title}</h2>\n </div>\n )}\n\n {/* Carousel Viewport with Peek Effect */}\n <div className=\"relative flex-1 min-h-0 overflow-hidden px-4\">\n <div ref={emblaRef} className=\"h-full overflow-hidden\">\n <div className=\"flex h-full gap-4\">\n {images.map((image, index) => {\n const imageState = imageStates[index]\n // Use 100% width for single image, 85% for multiple (peek effect)\n const slideWidth = images.length === 1 ? '100%' : '85%'\n return (\n <div\n key={image.url}\n className={cn(\n 'relative flex min-w-0 items-center justify-center transition-opacity duration-300 h-full',\n index !== currentIndex && 'opacity-40',\n )}\n style={{ flexBasis: slideWidth, flexShrink: 0, flexGrow: 0 }}\n >\n {imageState === 'error' ? (\n <div className=\"flex flex-col items-center gap-2 text-muted-foreground\">\n <ImageOff className=\"size-12\" />\n <p className=\"text-xs\">Failed to load</p>\n </div>\n ) : (\n <>\n {imageState !== 'loaded' && (\n <div className=\"absolute inset-0 flex items-center justify-center\">\n <Spinner className=\"size-8\" />\n </div>\n )}\n <img\n src={image.url}\n alt={image.alt}\n className=\"max-w-full max-h-full w-auto h-auto object-contain cursor-pointer border border-border/30 rounded\"\n onClick={() => {\n if (index !== currentIndex) {\n emblaApi?.scrollTo(index)\n } else {\n setFullscreen(true)\n }\n }}\n onLoad={() => handleImageLoad(index)}\n onError={() => handleImageError(index)}\n style={{ opacity: imageState === 'loaded' ? 1 : 0 }}\n role=\"button\"\n aria-label={`Navigate to ${image.alt}`}\n />\n </>\n )}\n </div>\n )\n })}\n </div>\n </div>\n </div>\n\n {/* Bottom Controls */}\n <div className=\"flex items-center justify-center gap-4 py-4\">\n <Button\n variant=\"outline\"\n size=\"icon\"\n onClick={scrollPrev}\n disabled={images.length <= 1}\n aria-label=\"Previous screenshot\"\n className=\"h-12 w-12\"\n >\n <ChevronLeft className=\"h-12 w-12\" />\n </Button>\n\n <div className=\"text-sm text-muted-foreground\" aria-live=\"polite\">\n {currentIndex + 1} / {images.length}\n </div>\n\n <Button\n variant=\"outline\"\n size=\"icon\"\n onClick={scrollNext}\n disabled={images.length <= 1}\n aria-label=\"Next screenshot\"\n className=\"h-12 w-12\"\n >\n <ChevronRight className=\"h-12 w-12\" />\n </Button>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":";;;;;;;;;AAwBO,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA,eAAe;AAAA,EACf;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAiB;AACf,QAAM,CAAC,UAAU,QAAQ,IAAI,iBAAiB;AAAA,IAC5C,MAAM;AAAA,IACN,OAAO;AAAA,EAAA,CACR;AAED,QAAM,CAAC,cAAc,eAAe,IAAI;AAAA,IAAS,MAC/C,MAAM,cAAc,GAAG,OAAO,SAAS,CAAC;AAAA,EAAA;AAE1C,QAAM,CAAC,cAAc,eAAe,IAAI,SAAS,KAAK;AACtD,QAAM,CAAC,aAAa,cAAc,IAAI,SAEpC,MAAM,OAAO,YAAY,OAAO,IAAI,CAAC,GAAG,MAAM,CAAC,GAAG,SAAS,CAAC,CAAC,CAAC;AAGhE,YAAU,MAAM;AACd,mBAAe,OAAO,YAAY,OAAO,IAAI,CAAC,GAAG,MAAM,CAAC,GAAG,SAAS,CAAC,CAAC,CAAC;AAAA,EACzE,GAAG,CAAC,MAAM,CAAC;AAGX,QAAM,wBAAwB,OAAO,kBAAkB;AACvD,YAAU,MAAM;AACd,0BAAsB,UAAU;AAAA,EAClC,GAAG,CAAC,kBAAkB,CAAC;AAEvB,QAAM,gBAAgB,YAAY,CAAC,UAAmB;;AACpD,oBAAgB,KAAK;AACrB,gCAAsB,YAAtB,+CAAgC;AAAA,EAClC,GAAG,CAAA,CAAE;AAGL,QAAM,mBAAmB,OAAO,aAAa;AAC7C,YAAU,MAAM;AACd,qBAAiB,UAAU;AAAA,EAC7B,GAAG,CAAC,aAAa,CAAC;AAElB,QAAM,aAAa,YAAY,MAAM;AACnC,QAAI,mBAAmB,WAAA;AAAA,EACzB,GAAG,CAAC,QAAQ,CAAC;AAEb,QAAM,aAAa,YAAY,MAAM;AACnC,QAAI,mBAAmB,WAAA;AAAA,EACzB,GAAG,CAAC,QAAQ,CAAC;AAGb,QAAM,WAAW,YAAY,MAAM;;AACjC,QAAI,CAAC,SAAU;AACf,UAAM,QAAQ,SAAS,mBAAA;AACvB,oBAAgB,KAAK;AACrB,2BAAiB,YAAjB,0CAA2B;AAAA,EAC7B,GAAG,CAAC,QAAQ,CAAC;AAGb,YAAU,MAAM;AACd,QAAI,CAAC,SAAU;AAGf,aAAA;AAGA,aAAS,GAAG,UAAU,QAAQ;AAC9B,WAAO,MAAM;AACX,eAAS,IAAI,UAAU,QAAQ;AAAA,IACjC;AAAA,EACF,GAAG,CAAC,UAAU,QAAQ,CAAC;AAGvB,YAAU,MAAM;AACd,QAAI,YAAY,OAAO,iBAAiB,UAAU;AAChD,eAAS,SAAS,YAAY;AAAA,IAChC;AAAA,EACF,GAAG,CAAC,UAAU,YAAY,CAAC;AAG3B;AAAA,IACE;AAAA,IACA,CAAC,MAAM;AACL,QAAE,eAAA;AACF,iBAAA;AAAA,IACF;AAAA,IACA;AAAA,MACE,SAAS,OAAO,SAAS;AAAA,MACzB,kBAAkB;AAAA,MAClB,gBAAgB;AAAA,IAAA;AAAA,IAElB,CAAC,YAAY,OAAO,MAAM;AAAA,EAAA;AAE5B;AAAA,IACE;AAAA,IACA,CAAC,MAAM;AACL,QAAE,eAAA;AACF,iBAAA;AAAA,IACF;AAAA,IACA;AAAA,MACE,SAAS,OAAO,SAAS;AAAA,MACzB,kBAAkB;AAAA,MAClB,gBAAgB;AAAA,IAAA;AAAA,IAElB,CAAC,YAAY,OAAO,MAAM;AAAA,EAAA;AAG5B,YAAU,MAAM;AACd,QAAI,CAAC,aAAc;AAEnB,UAAM,eAAe,CAAC,MAAqB;AACzC,UAAI,EAAE,QAAQ,UAAU;AACtB,UAAE,gBAAA;AACF,UAAE,eAAA;AACF,sBAAc,KAAK;AAAA,MACrB;AAAA,IACF;AAEA,aAAS,iBAAiB,WAAW,cAAc,IAAI;AACvD,WAAO,MAAM;AACX,eAAS,oBAAoB,WAAW,cAAc,IAAI;AAAA,IAC5D;AAAA,EACF,GAAG,CAAC,cAAc,aAAa,CAAC;AAGhC,YAAU,MAAM;AACd,QAAI,CAAC,YAAY,OAAO,UAAU,EAAG;AAErC,UAAM,YAAY,SAAS,SAAA;AAE3B,QAAI,cAAc;AAClB,QAAI;AAEJ,UAAM,cAAc,CAAC,UAAsB;AAEzC,YAAM,eAAA;AAGN,UAAI,YAAa;AAGjB,YAAM,SAAS,MAAM;AACrB,YAAM,SAAS,MAAM;AAGrB,YAAM,QAAQ,KAAK,IAAI,MAAM,IAAI,KAAK,IAAI,MAAM,IAAI,SAAS;AAE7D,UAAI,QAAQ,GAAG;AAEb,mBAAA;AAAA,MACF,WAAW,QAAQ,GAAG;AAEpB,mBAAA;AAAA,MACF;AAGA,oBAAc;AACd,mBAAa,aAAa;AAC1B,sBAAgB,WAAW,MAAM;AAC/B,sBAAc;AAAA,MAChB,GAAG,GAAG;AAAA,IACR;AAEA,cAAU,iBAAiB,SAAS,aAAa,EAAE,SAAS,OAAO;AAEnE,WAAO,MAAM;AACX,gBAAU,oBAAoB,SAAS,WAAW;AAClD,mBAAa,aAAa;AAAA,IAC5B;AAAA,EACF,GAAG,CAAC,UAAU,YAAY,YAAY,OAAO,MAAM,CAAC;AAEpD,QAAM,eAAe,OAAO,YAAY;AAExC,QAAM,kBAAkB,CAAC,UAAkB;AACzC,mBAAe,CAAC,UAAU,EAAE,GAAG,MAAM,CAAC,KAAK,GAAG,SAAA,EAAW;AAAA,EAC3D;AAEA,QAAM,mBAAmB,CAAC,UAAkB;AAC1C,mBAAe,CAAC,UAAU,EAAE,GAAG,MAAM,CAAC,KAAK,GAAG,QAAA,EAAU;AAAA,EAC1D;AAGA,MAAI,gBAAgB,cAAc;AAChC,UAAM,aAAa,YAAY,YAAY;AAC3C,WACE;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,WAAU;AAAA,QACV,MAAK;AAAA,QACL,cAAW;AAAA,QAEV,UAAA;AAAA,UAAA,eAAe,8BACb,OAAA,EAAI,WAAU,sDACb,UAAA,qBAAC,OAAA,EAAI,WAAU,0DACb,UAAA;AAAA,YAAA,oBAAC,UAAA,EAAS,WAAU,UAAA,CAAU;AAAA,YAC9B,oBAAC,KAAA,EAAE,WAAU,WAAU,UAAA,uBAAA,CAAoB;AAAA,UAAA,EAAA,CAC7C,EAAA,CACF,IAEA,qBAAC,OAAA,EAAI,WAAU,mCACZ,UAAA;AAAA,YAAA,eAAe,gCACb,OAAA,EAAI,WAAU,qDACb,UAAA,oBAAC,SAAA,EAAQ,WAAU,SAAA,CAAS,EAAA,CAC9B;AAAA,YAEF;AAAA,cAAC;AAAA,cAAA;AAAA,gBACC,KAAK,aAAa;AAAA,gBAClB,KAAK,aAAa;AAAA,gBAClB,WAAU;AAAA,gBACV,SAAS,MAAM,cAAc,KAAK;AAAA,gBAClC,QAAQ,MAAM,gBAAgB,YAAY;AAAA,gBAC1C,SAAS,MAAM,iBAAiB,YAAY;AAAA,gBAC5C,OAAO,EAAE,SAAS,eAAe,WAAW,IAAI,EAAA;AAAA,gBAChD,MAAK;AAAA,gBACL,cAAW;AAAA,cAAA;AAAA,YAAA;AAAA,UACb,GACF;AAAA,UAEF;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,SAAQ;AAAA,cACR,MAAK;AAAA,cACL,SAAS,MAAM,cAAc,KAAK;AAAA,cAClC,WAAU;AAAA,cACV,cAAW;AAAA,cAEX,UAAA,oBAAC,GAAA,EAAE,WAAU,SAAA,CAAS;AAAA,YAAA;AAAA,UAAA;AAAA,QACxB;AAAA,MAAA;AAAA,IAAA;AAAA,EAGN;AAEA,8BACG,OAAA,EAAI,WAAW,GAAG,gCAAgC,SAAS,GAEzD,UAAA;AAAA,IAAA,SACC,oBAAC,SAAI,WAAU,aACb,8BAAC,MAAA,EAAG,WAAU,yBAAyB,UAAA,MAAA,CAAM,EAAA,CAC/C;AAAA,wBAID,OAAA,EAAI,WAAU,gDACb,UAAA,oBAAC,OAAA,EAAI,KAAK,UAAU,WAAU,0BAC5B,UAAA,oBAAC,SAAI,WAAU,qBACZ,iBAAO,IAAI,CAAC,OAAO,UAAU;AAC5B,YAAM,aAAa,YAAY,KAAK;AAEpC,YAAM,aAAa,OAAO,WAAW,IAAI,SAAS;AAClD,aACE;AAAA,QAAC;AAAA,QAAA;AAAA,UAEC,WAAW;AAAA,YACT;AAAA,YACA,UAAU,gBAAgB;AAAA,UAAA;AAAA,UAE5B,OAAO,EAAE,WAAW,YAAY,YAAY,GAAG,UAAU,EAAA;AAAA,UAExD,UAAA,eAAe,UACd,qBAAC,OAAA,EAAI,WAAU,0DACb,UAAA;AAAA,YAAA,oBAAC,UAAA,EAAS,WAAU,UAAA,CAAU;AAAA,YAC9B,oBAAC,KAAA,EAAE,WAAU,WAAU,UAAA,iBAAA,CAAc;AAAA,UAAA,EAAA,CACvC,IAEA,qBAAA,UAAA,EACG,UAAA;AAAA,YAAA,eAAe,gCACb,OAAA,EAAI,WAAU,qDACb,UAAA,oBAAC,SAAA,EAAQ,WAAU,SAAA,CAAS,EAAA,CAC9B;AAAA,YAEF;AAAA,cAAC;AAAA,cAAA;AAAA,gBACC,KAAK,MAAM;AAAA,gBACX,KAAK,MAAM;AAAA,gBACX,WAAU;AAAA,gBACV,SAAS,MAAM;AACb,sBAAI,UAAU,cAAc;AAC1B,yDAAU,SAAS;AAAA,kBACrB,OAAO;AACL,kCAAc,IAAI;AAAA,kBACpB;AAAA,gBACF;AAAA,gBACA,QAAQ,MAAM,gBAAgB,KAAK;AAAA,gBACnC,SAAS,MAAM,iBAAiB,KAAK;AAAA,gBACrC,OAAO,EAAE,SAAS,eAAe,WAAW,IAAI,EAAA;AAAA,gBAChD,MAAK;AAAA,gBACL,cAAY,eAAe,MAAM,GAAG;AAAA,cAAA;AAAA,YAAA;AAAA,UACtC,EAAA,CACF;AAAA,QAAA;AAAA,QApCG,MAAM;AAAA,MAAA;AAAA,IAwCjB,CAAC,EAAA,CACH,EAAA,CACF,GACF;AAAA,IAGA,qBAAC,OAAA,EAAI,WAAU,+CACb,UAAA;AAAA,MAAA;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,SAAQ;AAAA,UACR,MAAK;AAAA,UACL,SAAS;AAAA,UACT,UAAU,OAAO,UAAU;AAAA,UAC3B,cAAW;AAAA,UACX,WAAU;AAAA,UAEV,UAAA,oBAAC,aAAA,EAAY,WAAU,YAAA,CAAY;AAAA,QAAA;AAAA,MAAA;AAAA,MAGrC,qBAAC,OAAA,EAAI,WAAU,iCAAgC,aAAU,UACtD,UAAA;AAAA,QAAA,eAAe;AAAA,QAAE;AAAA,QAAI,OAAO;AAAA,MAAA,GAC/B;AAAA,MAEA;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,SAAQ;AAAA,UACR,MAAK;AAAA,UACL,SAAS;AAAA,UACT,UAAU,OAAO,UAAU;AAAA,UAC3B,cAAW;AAAA,UACX,WAAU;AAAA,UAEV,UAAA,oBAAC,cAAA,EAAa,WAAU,YAAA,CAAY;AAAA,QAAA;AAAA,MAAA;AAAA,IACtC,EAAA,CACF;AAAA,EAAA,GACF;AAEJ;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-frontend-core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Frontend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -134,8 +134,8 @@
|
|
|
134
134
|
"vite-plugin-static-copy": "^3.1.4",
|
|
135
135
|
"vite-plugin-svgr": "^4.2.0",
|
|
136
136
|
"vitest": "^4.1.2",
|
|
137
|
-
"@igstack/app-catalog-
|
|
138
|
-
"@igstack/app-catalog-
|
|
137
|
+
"@igstack/app-catalog-shared-core": "0.7.1",
|
|
138
|
+
"@igstack/app-catalog-backend-core": "0.7.1"
|
|
139
139
|
},
|
|
140
140
|
"peerDependencies": {
|
|
141
141
|
"react": "19.1.2",
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
"vite": "^6.3.5",
|
|
145
145
|
"vite-plugin-svgr": "^4.2.0"
|
|
146
146
|
},
|
|
147
|
-
"gitHead": "
|
|
147
|
+
"gitHead": "e3a5db87f9d07a62c8fae26949b55e54a45e2ca9",
|
|
148
148
|
"scripts": {
|
|
149
149
|
"build": "vite build",
|
|
150
150
|
"build:lenient": "vite build --mode lenient",
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen } from '@testing-library/react'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import '@testing-library/jest-dom/vitest'
|
|
4
|
+
|
|
5
|
+
import { Gallery } from '~/modules/gallery/Gallery'
|
|
6
|
+
import type { GalleryImage } from '~/modules/gallery/Gallery'
|
|
7
|
+
|
|
8
|
+
const images: GalleryImage[] = [
|
|
9
|
+
{ url: '/api/screenshots/img-1', alt: 'Screenshot 1' },
|
|
10
|
+
{ url: '/api/screenshots/img-2', alt: 'Screenshot 2' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
// Issue #14 — clicking the fullscreen image should exit fullscreen,
|
|
14
|
+
// making zoom-in / zoom-out a reversible click toggle.
|
|
15
|
+
describe('Gallery — click image to exit fullscreen', () => {
|
|
16
|
+
it('exits fullscreen when the fullscreen image is clicked', async () => {
|
|
17
|
+
render(<Gallery images={images} />)
|
|
18
|
+
|
|
19
|
+
// Enter fullscreen by clicking the active slide image
|
|
20
|
+
const activeImg = screen.getByAltText('Screenshot 1')
|
|
21
|
+
await act(async () => {
|
|
22
|
+
fireEvent.click(activeImg)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// Fullscreen overlay should now be visible
|
|
26
|
+
const dialog = screen.getByRole('dialog', { name: /fullscreen view/i })
|
|
27
|
+
expect(dialog).toBeInTheDocument()
|
|
28
|
+
|
|
29
|
+
// Click the image inside the fullscreen overlay — should exit fullscreen
|
|
30
|
+
const fullscreenImg = screen.getByAltText('Screenshot 1')
|
|
31
|
+
await act(async () => {
|
|
32
|
+
fireEvent.click(fullscreenImg)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Fullscreen overlay should be gone (back to the carousel)
|
|
36
|
+
expect(
|
|
37
|
+
screen.queryByRole('dialog', { name: /fullscreen view/i }),
|
|
38
|
+
).not.toBeInTheDocument()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('exposes the fullscreen image as an accessible exit control', async () => {
|
|
42
|
+
render(<Gallery images={images} />)
|
|
43
|
+
|
|
44
|
+
const activeImg = screen.getByAltText('Screenshot 1')
|
|
45
|
+
await act(async () => {
|
|
46
|
+
fireEvent.click(activeImg)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// The fullscreen image should itself be operable as an exit affordance
|
|
50
|
+
const imageControl = screen.getByRole('button', { name: /zoom out/i })
|
|
51
|
+
expect(imageControl).toBeInTheDocument()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -230,10 +230,13 @@ export function Gallery({
|
|
|
230
230
|
<img
|
|
231
231
|
src={currentImage.url}
|
|
232
232
|
alt={currentImage.alt}
|
|
233
|
-
className="max-w-full h-auto"
|
|
233
|
+
className="max-w-full h-auto cursor-zoom-out"
|
|
234
|
+
onClick={() => setFullscreen(false)}
|
|
234
235
|
onLoad={() => handleImageLoad(currentIndex)}
|
|
235
236
|
onError={() => handleImageError(currentIndex)}
|
|
236
237
|
style={{ opacity: imageState === 'loaded' ? 1 : 0 }}
|
|
238
|
+
role="button"
|
|
239
|
+
aria-label="Zoom out"
|
|
237
240
|
/>
|
|
238
241
|
</div>
|
|
239
242
|
)}
|