@nospt/plugin-dev-ai-hub 1.1.3 → 1.2.0-rc2
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/README.md +93 -2
- package/config.schema.json +15 -0
- package/dist/api/DevAiHubResourceClient.esm.js +42 -2
- package/dist/api/DevAiHubResourceClient.esm.js.map +1 -1
- package/dist/components/AiResourcesPage/AiResourcesPage.esm.js +98 -20
- package/dist/components/AiResourcesPage/AiResourcesPage.esm.js.map +1 -1
- package/dist/components/AiResourcesPage/AiResourcesPage.module.css.esm.js +2 -2
- package/dist/components/AiResourcesPage/ResourceCard.esm.js +13 -4
- package/dist/components/AiResourcesPage/ResourceCard.esm.js.map +1 -1
- package/dist/components/AiResourcesPage/ResourceCard.module.css.esm.js +2 -2
- package/dist/components/AiResourcesPage/ResourceDetailPanel.esm.js +55 -9
- package/dist/components/AiResourcesPage/ResourceDetailPanel.esm.js.map +1 -1
- package/dist/components/AiResourcesPage/ResourceFilters.esm.js +57 -3
- package/dist/components/AiResourcesPage/ResourceFilters.esm.js.map +1 -1
- package/dist/components/AiResourcesPage/ResourceInstallDialog.esm.js +4 -2
- package/dist/components/AiResourcesPage/ResourceInstallDialog.esm.js.map +1 -1
- package/dist/components/AiResourcesPage/SortControl.esm.js +139 -0
- package/dist/components/AiResourcesPage/SortControl.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/SortControl.module.css.esm.js +8 -0
- package/dist/components/AiResourcesPage/SortControl.module.css.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/ownerLabel.esm.js +6 -0
- package/dist/components/AiResourcesPage/ownerLabel.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/typeMeta.esm.js +1 -12
- package/dist/components/AiResourcesPage/typeMeta.esm.js.map +1 -1
- package/dist/components/Backoffice/Backoffice.module.css.esm.js +8 -0
- package/dist/components/Backoffice/Backoffice.module.css.esm.js.map +1 -0
- package/dist/components/Backoffice/BackofficeCuration.esm.js +191 -0
- package/dist/components/Backoffice/BackofficeCuration.esm.js.map +1 -0
- package/dist/components/Backoffice/BackofficeExclusion.esm.js +185 -0
- package/dist/components/Backoffice/BackofficeExclusion.esm.js.map +1 -0
- package/dist/components/Backoffice/BackofficeGate.esm.js +25 -0
- package/dist/components/Backoffice/BackofficeGate.esm.js.map +1 -0
- package/dist/components/Backoffice/BackofficeHeaderAction.esm.js +43 -0
- package/dist/components/Backoffice/BackofficeHeaderAction.esm.js.map +1 -0
- package/dist/components/Backoffice/BackofficeOverview.esm.js +517 -0
- package/dist/components/Backoffice/BackofficeOverview.esm.js.map +1 -0
- package/dist/components/Backoffice/BackofficeSection.esm.js +37 -0
- package/dist/components/Backoffice/BackofficeSection.esm.js.map +1 -0
- package/dist/components/Backoffice/TypeDistributionBar.esm.js +44 -0
- package/dist/components/Backoffice/TypeDistributionBar.esm.js.map +1 -0
- package/dist/components/Backoffice/rowEntrance.esm.js +11 -0
- package/dist/components/Backoffice/rowEntrance.esm.js.map +1 -0
- package/dist/components/Backoffice/useCurationAccess.esm.js +12 -0
- package/dist/components/Backoffice/useCurationAccess.esm.js.map +1 -0
- package/dist/components/RecommendedBadge/RecommendedBadgeIcon.esm.js +25 -0
- package/dist/components/RecommendedBadge/RecommendedBadgeIcon.esm.js.map +1 -0
- package/dist/components/RecommendedBadge/recommended-badge.svg +9 -0
- package/dist/components/ToolIcon/ToolIcon.esm.js +41 -6
- package/dist/components/ToolIcon/ToolIcon.esm.js.map +1 -1
- package/dist/hooks/useDefaultSortMode.esm.js +37 -0
- package/dist/hooks/useDefaultSortMode.esm.js.map +1 -0
- package/dist/hooks/useFrameworkLabel.esm.js +42 -0
- package/dist/hooks/useFrameworkLabel.esm.js.map +1 -0
- package/dist/hooks/useResources.esm.js +25 -14
- package/dist/hooks/useResources.esm.js.map +1 -1
- package/dist/hooks/useTelemetryCounts.esm.js +16 -7
- package/dist/hooks/useTelemetryCounts.esm.js.map +1 -1
- package/dist/hooks/useTelemetrySummary.esm.js +46 -0
- package/dist/hooks/useTelemetrySummary.esm.js.map +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.esm.js +1 -1
- package/dist/plugin.esm.js +90 -3
- package/dist/plugin.esm.js.map +1 -1
- package/dist/routes.esm.js +2 -1
- package/dist/routes.esm.js.map +1 -1
- package/dist/theme/typeTokens.module.css.esm.js +5 -0
- package/dist/theme/typeTokens.module.css.esm.js.map +1 -0
- package/package.json +10 -4
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
2
|
+
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
3
|
+
import { useApi } from '@backstage/core-plugin-api';
|
|
4
|
+
import { Text, Switch, Button } from '@backstage/ui';
|
|
5
|
+
import { RESOURCE_TYPES, RECOMMENDED_TAG } from '@nospt/plugin-dev-ai-hub-common';
|
|
6
|
+
import { devAiHubResourceApiRef } from '../../api/DevAiHubResourceClient.esm.js';
|
|
7
|
+
import { useResources } from '../../hooks/useResources.esm.js';
|
|
8
|
+
import { getTypeMeta } from '../AiResourcesPage/typeMeta.esm.js';
|
|
9
|
+
import { ResourceFilters } from '../AiResourcesPage/ResourceFilters.esm.js';
|
|
10
|
+
import { RecommendedBadgeIcon } from '../RecommendedBadge/RecommendedBadgeIcon.esm.js';
|
|
11
|
+
import { BackofficeSection } from './BackofficeSection.esm.js';
|
|
12
|
+
import { rowEntrance } from './rowEntrance.esm.js';
|
|
13
|
+
import { useCurationAccess } from './useCurationAccess.esm.js';
|
|
14
|
+
import styles from './Backoffice.module.css.esm.js';
|
|
15
|
+
|
|
16
|
+
const ENTRANCE_DURATION_MS = 700;
|
|
17
|
+
const EMPTY_FILTERS = {
|
|
18
|
+
search: "",
|
|
19
|
+
framework: void 0,
|
|
20
|
+
tags: []
|
|
21
|
+
};
|
|
22
|
+
function matchesSearch(resource, query) {
|
|
23
|
+
const q = query.trim().toLowerCase();
|
|
24
|
+
if (!q) return true;
|
|
25
|
+
return resource.name.toLowerCase().includes(q) || (resource.title ?? "").toLowerCase().includes(q) || (resource.description ?? "").toLowerCase().includes(q) || resource.tags.some((tag) => tag.toLowerCase().includes(q));
|
|
26
|
+
}
|
|
27
|
+
function BackofficeCuration() {
|
|
28
|
+
const { items, error, loading, refresh } = useResources();
|
|
29
|
+
const { allowed: canCurate, loading: accessLoading } = useCurationAccess();
|
|
30
|
+
const api = useApi(devAiHubResourceApiRef);
|
|
31
|
+
const [pendingRef, setPendingRef] = useState();
|
|
32
|
+
const [saveError, setSaveError] = useState();
|
|
33
|
+
const [filters, setFilters] = useState(EMPTY_FILTERS);
|
|
34
|
+
const [playEntrance, setPlayEntrance] = useState(true);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (loading || !playEntrance) return void 0;
|
|
37
|
+
const timer = setTimeout(
|
|
38
|
+
() => setPlayEntrance(false),
|
|
39
|
+
ENTRANCE_DURATION_MS
|
|
40
|
+
);
|
|
41
|
+
return () => clearTimeout(timer);
|
|
42
|
+
}, [loading, playEntrance]);
|
|
43
|
+
const save = useCallback(
|
|
44
|
+
async (resource, recommended2) => {
|
|
45
|
+
setSaveError(void 0);
|
|
46
|
+
setPendingRef(resource.entityRef);
|
|
47
|
+
try {
|
|
48
|
+
await api.setRecommendation(resource.entityRef, { recommended: recommended2 });
|
|
49
|
+
await refresh();
|
|
50
|
+
} catch (e) {
|
|
51
|
+
setSaveError(e instanceof Error ? e : new Error(String(e)));
|
|
52
|
+
} finally {
|
|
53
|
+
setPendingRef(void 0);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
[api, refresh]
|
|
57
|
+
);
|
|
58
|
+
const total = items?.length ?? 0;
|
|
59
|
+
const recommended = (items ?? []).filter((resource) => resource.recommended);
|
|
60
|
+
const others = (items ?? []).filter((resource) => !resource.recommended);
|
|
61
|
+
const availableFrameworks = useMemo(
|
|
62
|
+
() => Array.from(
|
|
63
|
+
new Set(others.flatMap((resource) => resource.frameworks))
|
|
64
|
+
).sort(),
|
|
65
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
66
|
+
[items]
|
|
67
|
+
);
|
|
68
|
+
const availableTags = useMemo(
|
|
69
|
+
() => Array.from(new Set(others.flatMap((resource) => resource.tags))).sort(),
|
|
70
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
71
|
+
[items]
|
|
72
|
+
);
|
|
73
|
+
const availableTypes = useMemo(
|
|
74
|
+
() => RESOURCE_TYPES.filter((type) => others.some((r) => r.type === type)),
|
|
75
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
76
|
+
[items]
|
|
77
|
+
);
|
|
78
|
+
const hasFilter = filters.search.trim() !== "" || !!filters.framework || !!filters.type || filters.tags.length > 0;
|
|
79
|
+
const matches = others.filter(
|
|
80
|
+
(resource) => (!filters.type || resource.type === filters.type) && (!filters.framework || resource.frameworks.includes(filters.framework)) && (filters.tags.length === 0 || filters.tags.every((tag) => resource.tags.includes(tag))) && matchesSearch(resource, filters.search)
|
|
81
|
+
);
|
|
82
|
+
const renderRow = (resource, index) => {
|
|
83
|
+
const pending = pendingRef === resource.entityRef;
|
|
84
|
+
const meta = getTypeMeta(resource.type);
|
|
85
|
+
const entrance = rowEntrance(playEntrance, index);
|
|
86
|
+
const rowStyle = {
|
|
87
|
+
"--card-accent": meta.color,
|
|
88
|
+
"--card-accent-bg": meta.colorBg,
|
|
89
|
+
...entrance.style
|
|
90
|
+
};
|
|
91
|
+
return /* @__PURE__ */ jsxs(
|
|
92
|
+
"li",
|
|
93
|
+
{
|
|
94
|
+
className: entrance.className ? `${styles.curationRow} ${entrance.className}` : styles.curationRow,
|
|
95
|
+
style: rowStyle,
|
|
96
|
+
children: [
|
|
97
|
+
/* @__PURE__ */ jsxs("div", { className: styles.curationRowMain, children: [
|
|
98
|
+
/* @__PURE__ */ jsxs("span", { className: styles.curationRowTitle, children: [
|
|
99
|
+
/* @__PURE__ */ jsx("span", { className: styles.curationRowIcon, "aria-hidden": true, children: /* @__PURE__ */ jsx(meta.Icon, { size: 14 }) }),
|
|
100
|
+
/* @__PURE__ */ jsx(Text, { variant: "body-medium", weight: "bold", as: "span", children: resource.title ?? resource.name }),
|
|
101
|
+
resource.recommended && /* @__PURE__ */ jsx(
|
|
102
|
+
RecommendedBadgeIcon,
|
|
103
|
+
{
|
|
104
|
+
size: 16,
|
|
105
|
+
className: styles.curationRowBadge
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
] }),
|
|
109
|
+
/* @__PURE__ */ jsx(
|
|
110
|
+
Switch,
|
|
111
|
+
{
|
|
112
|
+
isSelected: !!resource.recommended,
|
|
113
|
+
isDisabled: !canCurate || pending || !!resource.recommendedByTag,
|
|
114
|
+
onChange: (checked) => save(resource, checked),
|
|
115
|
+
children: "Recommended"
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
] }),
|
|
119
|
+
resource.recommendedByTag && /* @__PURE__ */ jsxs(Text, { variant: "body-x-small", color: "secondary", as: "p", children: [
|
|
120
|
+
"Set by the \u201C",
|
|
121
|
+
RECOMMENDED_TAG,
|
|
122
|
+
"\u201D tag in the catalog \u2014 remove the tag from the entity to un-recommend it."
|
|
123
|
+
] }),
|
|
124
|
+
resource.recommendedByTag && resource.recommendedByCuration && canCurate && /* @__PURE__ */ jsx(
|
|
125
|
+
Button,
|
|
126
|
+
{
|
|
127
|
+
size: "small",
|
|
128
|
+
variant: "tertiary",
|
|
129
|
+
isDisabled: pending,
|
|
130
|
+
onPress: () => save(resource, false),
|
|
131
|
+
children: "Clear backoffice recommendation"
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
]
|
|
135
|
+
},
|
|
136
|
+
resource.entityRef
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
return /* @__PURE__ */ jsx("div", { className: styles.surface, children: /* @__PURE__ */ jsxs(
|
|
140
|
+
BackofficeSection,
|
|
141
|
+
{
|
|
142
|
+
title: "Recommendations",
|
|
143
|
+
description: canCurate ? "Mark resources as recommended so they stand out to everyone browsing the catalog (#68)." : "What the team currently recommends. Ask for dev-ai-hub.backoffice.curate to change it.",
|
|
144
|
+
loading: loading || accessLoading,
|
|
145
|
+
error,
|
|
146
|
+
isEmpty: !loading && !error && total === 0,
|
|
147
|
+
emptyMessage: "No AI resources are visible to you yet.",
|
|
148
|
+
children: [
|
|
149
|
+
saveError && /* @__PURE__ */ jsxs(Text, { variant: "body-small", color: "danger", as: "p", role: "alert", children: [
|
|
150
|
+
"Could not save: ",
|
|
151
|
+
saveError.message
|
|
152
|
+
] }),
|
|
153
|
+
/* @__PURE__ */ jsx(
|
|
154
|
+
Text,
|
|
155
|
+
{
|
|
156
|
+
variant: "body-small",
|
|
157
|
+
weight: "bold",
|
|
158
|
+
as: "h3",
|
|
159
|
+
className: styles.subsectionHeading,
|
|
160
|
+
children: recommended.length > 0 ? `Currently recommended (${recommended.length})` : "Currently recommended"
|
|
161
|
+
}
|
|
162
|
+
),
|
|
163
|
+
recommended.length > 0 ? /* @__PURE__ */ jsx("ul", { className: styles.curationList, children: recommended.map((resource, index) => renderRow(resource, index)) }) : /* @__PURE__ */ jsx(Text, { variant: "body-small", color: "secondary", as: "p", children: canCurate ? "Nothing is recommended yet \u2014 find a resource below to add the first one." : "Nothing is recommended yet." }),
|
|
164
|
+
/* @__PURE__ */ jsx(
|
|
165
|
+
Text,
|
|
166
|
+
{
|
|
167
|
+
variant: "body-small",
|
|
168
|
+
weight: "bold",
|
|
169
|
+
as: "h3",
|
|
170
|
+
className: styles.subsectionHeading,
|
|
171
|
+
children: canCurate ? "Add a recommendation" : "Other resources"
|
|
172
|
+
}
|
|
173
|
+
),
|
|
174
|
+
canCurate && /* @__PURE__ */ jsx(
|
|
175
|
+
ResourceFilters,
|
|
176
|
+
{
|
|
177
|
+
value: filters,
|
|
178
|
+
onChange: setFilters,
|
|
179
|
+
availableFrameworks,
|
|
180
|
+
availableTags,
|
|
181
|
+
availableTypes
|
|
182
|
+
}
|
|
183
|
+
),
|
|
184
|
+
matches.length > 0 ? /* @__PURE__ */ jsx("ul", { className: styles.curationList, children: matches.map((resource, index) => renderRow(resource, index)) }) : /* @__PURE__ */ jsx(Text, { variant: "body-small", color: "secondary", as: "p", children: hasFilter ? "No resources match your filters." : "Every visible resource is already recommended." })
|
|
185
|
+
]
|
|
186
|
+
}
|
|
187
|
+
) });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export { BackofficeCuration };
|
|
191
|
+
//# sourceMappingURL=BackofficeCuration.esm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BackofficeCuration.esm.js","sources":["../../../src/components/Backoffice/BackofficeCuration.tsx"],"sourcesContent":["import { useCallback, useEffect, useMemo, useState } from 'react';\nimport { useApi } from '@backstage/core-plugin-api';\nimport { Button, Switch, Text } from '@backstage/ui';\nimport type { ResourceSummary } from '@nospt/plugin-dev-ai-hub-common';\nimport {\n RECOMMENDED_TAG,\n RESOURCE_TYPES,\n} from '@nospt/plugin-dev-ai-hub-common';\nimport { devAiHubResourceApiRef } from '../../api/DevAiHubResourceClient';\nimport { useResources } from '../../hooks/useResources';\nimport { getTypeMeta } from '../AiResourcesPage/typeMeta';\nimport {\n ResourceFilters,\n type ResourceFiltersValue,\n} from '../AiResourcesPage/ResourceFilters';\nimport { RecommendedBadgeIcon } from '../RecommendedBadge';\nimport { BackofficeSection } from './BackofficeSection';\nimport { rowEntrance } from './rowEntrance';\nimport { useCurationAccess } from './useCurationAccess';\nimport styles from './Backoffice.module.css';\n\n/** How long the initial-render entrance animation plays for (#83), matching\n * AiResourcesPage's playEntrance window. */\nconst ENTRANCE_DURATION_MS = 700;\n\nconst EMPTY_FILTERS: ResourceFiltersValue = {\n search: '',\n framework: undefined,\n tags: [],\n};\n\n/** Same search predicate the browse page uses (name/title/description/tags),\n * so the \"add a recommendation\" picker filters identically to the main page. */\nfunction matchesSearch(resource: ResourceSummary, query: string): boolean {\n const q = query.trim().toLowerCase();\n if (!q) return true;\n return (\n resource.name.toLowerCase().includes(q) ||\n (resource.title ?? '').toLowerCase().includes(q) ||\n (resource.description ?? '').toLowerCase().includes(q) ||\n resource.tags.some(tag => tag.toLowerCase().includes(q))\n );\n}\n\n/**\n * The curation section (#68, ADR-0017): every visible resource, with a\n * switch to promote it to \"Recommended\".\n *\n * Visible to anyone who can see the backoffice, writable only to those who\n * also hold `dev-ai-hub.backoffice.curate` — a user with read-only backoffice\n * access sees today's recommendations with disabled controls, not a missing\n * section, so \"I could ask for this to change\" stays discoverable.\n *\n * It reuses `useResources` (the same list the browse page renders) rather than\n * a curation-specific endpoint, and calls `refresh()` after a write so the list\n * reflects the server's state rather than trusting the local optimistic one.\n */\nexport function BackofficeCuration() {\n const { items, error, loading, refresh } = useResources();\n const { allowed: canCurate, loading: accessLoading } = useCurationAccess();\n const api = useApi(devAiHubResourceApiRef);\n const [pendingRef, setPendingRef] = useState<string | undefined>();\n const [saveError, setSaveError] = useState<Error | undefined>();\n // The \"add a recommendation\" picker reuses the browse page's filter bar\n // (#68): full-width search plus the Tags and AI Tool dropdowns. The old flat\n // list forced a curator to eyeball the whole catalog; filtering by\n // name/tag/tool is what makes promoting a resource a couple of clicks rather\n // than a scroll.\n const [filters, setFilters] = useState<ResourceFiltersValue>(EMPTY_FILTERS);\n\n // Staggered row entrance plays once, on the initial successful load only\n // (same trade as AiResourcesPage's playEntrance) — a save-triggered\n // `refresh()` must not replay it, which is why this only watches `loading`\n // and not `items`.\n const [playEntrance, setPlayEntrance] = useState(true);\n useEffect(() => {\n if (loading || !playEntrance) return undefined;\n const timer = setTimeout(\n () => setPlayEntrance(false),\n ENTRANCE_DURATION_MS,\n );\n return () => clearTimeout(timer);\n }, [loading, playEntrance]);\n\n const save = useCallback(\n async (resource: ResourceSummary, recommended: boolean) => {\n setSaveError(undefined);\n setPendingRef(resource.entityRef);\n try {\n await api.setRecommendation(resource.entityRef, { recommended });\n await refresh();\n } catch (e) {\n setSaveError(e instanceof Error ? e : new Error(String(e)));\n } finally {\n setPendingRef(undefined);\n }\n },\n [api, refresh],\n );\n\n const total = items?.length ?? 0;\n\n // Split the catalog into what is already recommended (the payoff, pinned at\n // the top so a curator sees the current set first) and everything else (the\n // pool to promote from). Keeping them apart is what the old single list\n // lacked — a recommendation was buried among every other resource.\n const recommended = (items ?? []).filter(resource => resource.recommended);\n const others = (items ?? []).filter(resource => !resource.recommended);\n\n // The dropdowns only offer values that exist in the pool a curator can\n // actually promote from, so picking a tag or tool never yields an empty list\n // for a reason the UI can't explain.\n const availableFrameworks = useMemo(\n () =>\n Array.from(\n new Set(others.flatMap(resource => resource.frameworks)),\n ).sort(),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [items],\n );\n const availableTags = useMemo(\n () => Array.from(new Set(others.flatMap(resource => resource.tags))).sort(),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [items],\n );\n // Canonical order (Skills, Agents, …) rather than alphabetical, matching the\n // browse page's stat tiles, and limited to types actually present in the\n // promotable pool.\n const availableTypes = useMemo(\n () => RESOURCE_TYPES.filter(type => others.some(r => r.type === type)),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [items],\n );\n\n const hasFilter =\n filters.search.trim() !== '' ||\n !!filters.framework ||\n !!filters.type ||\n filters.tags.length > 0;\n const matches = others.filter(\n resource =>\n (!filters.type || resource.type === filters.type) &&\n (!filters.framework || resource.frameworks.includes(filters.framework)) &&\n (filters.tags.length === 0 ||\n filters.tags.every(tag => resource.tags.includes(tag))) &&\n matchesSearch(resource, filters.search),\n );\n\n const renderRow = (resource: ResourceSummary, index: number) => {\n const pending = pendingRef === resource.entityRef;\n const meta = getTypeMeta(resource.type);\n const entrance = rowEntrance(playEntrance, index);\n const rowStyle = {\n '--card-accent': meta.color,\n '--card-accent-bg': meta.colorBg,\n ...entrance.style,\n } as React.CSSProperties;\n return (\n <li\n key={resource.entityRef}\n className={\n entrance.className\n ? `${styles.curationRow} ${entrance.className}`\n : styles.curationRow\n }\n style={rowStyle}\n >\n <div className={styles.curationRowMain}>\n <span className={styles.curationRowTitle}>\n {/* Same type identity as the browse-page card (ADR-0008) — an\n admin scanning this list gets the type at a glance instead of\n reading the title to guess it. */}\n <span className={styles.curationRowIcon} aria-hidden>\n <meta.Icon size={14} />\n </span>\n <Text variant=\"body-medium\" weight=\"bold\" as=\"span\">\n {resource.title ?? resource.name}\n </Text>\n {resource.recommended && (\n <RecommendedBadgeIcon\n size={16}\n className={styles.curationRowBadge}\n />\n )}\n </span>\n <Switch\n isSelected={!!resource.recommended}\n // A tag-declared recommendation belongs to the resource's author\n // (ADR-0020); the backend refuses to clear it, so don't offer to.\n isDisabled={!canCurate || pending || !!resource.recommendedByTag}\n onChange={checked => save(resource, checked)}\n >\n Recommended\n </Switch>\n </div>\n {resource.recommendedByTag && (\n <Text variant=\"body-x-small\" color=\"secondary\" as=\"p\">\n Set by the “{RECOMMENDED_TAG}” tag in the catalog — remove the tag\n from the entity to un-recommend it.\n </Text>\n )}\n {/* A backoffice recommendation made before the tag was added would\n outlive the tag (ADR-0020), so it is offered for clearing here. */}\n {resource.recommendedByTag &&\n resource.recommendedByCuration &&\n canCurate && (\n <Button\n size=\"small\"\n variant=\"tertiary\"\n isDisabled={pending}\n onPress={() => save(resource, false)}\n >\n Clear backoffice recommendation\n </Button>\n )}\n </li>\n );\n };\n\n return (\n <div className={styles.surface}>\n <BackofficeSection\n title=\"Recommendations\"\n description={\n canCurate\n ? 'Mark resources as recommended so they stand out to everyone browsing the catalog (#68).'\n : 'What the team currently recommends. Ask for dev-ai-hub.backoffice.curate to change it.'\n }\n loading={loading || accessLoading}\n error={error}\n isEmpty={!loading && !error && total === 0}\n emptyMessage=\"No AI resources are visible to you yet.\"\n >\n {saveError && (\n <Text variant=\"body-small\" color=\"danger\" as=\"p\" role=\"alert\">\n Could not save: {saveError.message}\n </Text>\n )}\n\n <Text\n variant=\"body-small\"\n weight=\"bold\"\n as=\"h3\"\n className={styles.subsectionHeading}\n >\n {recommended.length > 0\n ? `Currently recommended (${recommended.length})`\n : 'Currently recommended'}\n </Text>\n {recommended.length > 0 ? (\n <ul className={styles.curationList}>\n {recommended.map((resource, index) => renderRow(resource, index))}\n </ul>\n ) : (\n <Text variant=\"body-small\" color=\"secondary\" as=\"p\">\n {canCurate\n ? 'Nothing is recommended yet — find a resource below to add the first one.'\n : 'Nothing is recommended yet.'}\n </Text>\n )}\n\n <Text\n variant=\"body-small\"\n weight=\"bold\"\n as=\"h3\"\n className={styles.subsectionHeading}\n >\n {canCurate ? 'Add a recommendation' : 'Other resources'}\n </Text>\n {canCurate && (\n <ResourceFilters\n value={filters}\n onChange={setFilters}\n availableFrameworks={availableFrameworks}\n availableTags={availableTags}\n availableTypes={availableTypes}\n />\n )}\n {matches.length > 0 ? (\n <ul className={styles.curationList}>\n {matches.map((resource, index) => renderRow(resource, index))}\n </ul>\n ) : (\n <Text variant=\"body-small\" color=\"secondary\" as=\"p\">\n {hasFilter\n ? 'No resources match your filters.'\n : 'Every visible resource is already recommended.'}\n </Text>\n )}\n </BackofficeSection>\n </div>\n );\n}\n"],"names":["recommended"],"mappings":"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"}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
2
|
+
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
3
|
+
import { useApi } from '@backstage/core-plugin-api';
|
|
4
|
+
import { Text, Switch } from '@backstage/ui';
|
|
5
|
+
import { RiEyeOffLine } from '@remixicon/react';
|
|
6
|
+
import { RESOURCE_TYPES } from '@nospt/plugin-dev-ai-hub-common';
|
|
7
|
+
import { devAiHubResourceApiRef } from '../../api/DevAiHubResourceClient.esm.js';
|
|
8
|
+
import { useResources } from '../../hooks/useResources.esm.js';
|
|
9
|
+
import { getTypeMeta } from '../AiResourcesPage/typeMeta.esm.js';
|
|
10
|
+
import { ResourceFilters } from '../AiResourcesPage/ResourceFilters.esm.js';
|
|
11
|
+
import { BackofficeSection } from './BackofficeSection.esm.js';
|
|
12
|
+
import { rowEntrance } from './rowEntrance.esm.js';
|
|
13
|
+
import { useCurationAccess } from './useCurationAccess.esm.js';
|
|
14
|
+
import styles from './Backoffice.module.css.esm.js';
|
|
15
|
+
|
|
16
|
+
const ENTRANCE_DURATION_MS = 700;
|
|
17
|
+
const EMPTY_FILTERS = {
|
|
18
|
+
search: "",
|
|
19
|
+
framework: void 0,
|
|
20
|
+
tags: []
|
|
21
|
+
};
|
|
22
|
+
function matchesSearch(resource, query) {
|
|
23
|
+
const q = query.trim().toLowerCase();
|
|
24
|
+
if (!q) return true;
|
|
25
|
+
return resource.name.toLowerCase().includes(q) || (resource.title ?? "").toLowerCase().includes(q) || (resource.description ?? "").toLowerCase().includes(q) || resource.tags.some((tag) => tag.toLowerCase().includes(q));
|
|
26
|
+
}
|
|
27
|
+
function BackofficeExclusion() {
|
|
28
|
+
const { items, error, loading, refresh } = useResources({
|
|
29
|
+
includeExcluded: true
|
|
30
|
+
});
|
|
31
|
+
const { allowed: canCurate, loading: accessLoading } = useCurationAccess();
|
|
32
|
+
const api = useApi(devAiHubResourceApiRef);
|
|
33
|
+
const [pendingRef, setPendingRef] = useState();
|
|
34
|
+
const [saveError, setSaveError] = useState();
|
|
35
|
+
const [filters, setFilters] = useState(EMPTY_FILTERS);
|
|
36
|
+
const [playEntrance, setPlayEntrance] = useState(true);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (loading || !playEntrance) return void 0;
|
|
39
|
+
const timer = setTimeout(
|
|
40
|
+
() => setPlayEntrance(false),
|
|
41
|
+
ENTRANCE_DURATION_MS
|
|
42
|
+
);
|
|
43
|
+
return () => clearTimeout(timer);
|
|
44
|
+
}, [loading, playEntrance]);
|
|
45
|
+
const save = useCallback(
|
|
46
|
+
async (resource, excluded2) => {
|
|
47
|
+
setSaveError(void 0);
|
|
48
|
+
setPendingRef(resource.entityRef);
|
|
49
|
+
try {
|
|
50
|
+
await api.setExclusion(resource.entityRef, { excluded: excluded2 });
|
|
51
|
+
await refresh();
|
|
52
|
+
} catch (e) {
|
|
53
|
+
setSaveError(e instanceof Error ? e : new Error(String(e)));
|
|
54
|
+
} finally {
|
|
55
|
+
setPendingRef(void 0);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
[api, refresh]
|
|
59
|
+
);
|
|
60
|
+
const total = items?.length ?? 0;
|
|
61
|
+
const excluded = (items ?? []).filter((resource) => resource.excluded);
|
|
62
|
+
const others = (items ?? []).filter((resource) => !resource.excluded);
|
|
63
|
+
const availableFrameworks = useMemo(
|
|
64
|
+
() => Array.from(
|
|
65
|
+
new Set(others.flatMap((resource) => resource.frameworks))
|
|
66
|
+
).sort(),
|
|
67
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
68
|
+
[items]
|
|
69
|
+
);
|
|
70
|
+
const availableTags = useMemo(
|
|
71
|
+
() => Array.from(new Set(others.flatMap((resource) => resource.tags))).sort(),
|
|
72
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
73
|
+
[items]
|
|
74
|
+
);
|
|
75
|
+
const availableTypes = useMemo(
|
|
76
|
+
() => RESOURCE_TYPES.filter((type) => others.some((r) => r.type === type)),
|
|
77
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
78
|
+
[items]
|
|
79
|
+
);
|
|
80
|
+
const hasFilter = filters.search.trim() !== "" || !!filters.framework || !!filters.type || filters.tags.length > 0;
|
|
81
|
+
const matches = others.filter(
|
|
82
|
+
(resource) => (!filters.type || resource.type === filters.type) && (!filters.framework || resource.frameworks.includes(filters.framework)) && (filters.tags.length === 0 || filters.tags.every((tag) => resource.tags.includes(tag))) && matchesSearch(resource, filters.search)
|
|
83
|
+
);
|
|
84
|
+
const renderRow = (resource, index) => {
|
|
85
|
+
const pending = pendingRef === resource.entityRef;
|
|
86
|
+
const meta = getTypeMeta(resource.type);
|
|
87
|
+
const entrance = rowEntrance(playEntrance, index);
|
|
88
|
+
const rowStyle = {
|
|
89
|
+
"--card-accent": meta.color,
|
|
90
|
+
"--card-accent-bg": meta.colorBg,
|
|
91
|
+
...entrance.style
|
|
92
|
+
};
|
|
93
|
+
return /* @__PURE__ */ jsxs(
|
|
94
|
+
"li",
|
|
95
|
+
{
|
|
96
|
+
className: entrance.className ? `${styles.curationRow} ${entrance.className}` : styles.curationRow,
|
|
97
|
+
style: rowStyle,
|
|
98
|
+
children: [
|
|
99
|
+
/* @__PURE__ */ jsxs("div", { className: styles.curationRowMain, children: [
|
|
100
|
+
/* @__PURE__ */ jsxs("span", { className: styles.curationRowTitle, children: [
|
|
101
|
+
/* @__PURE__ */ jsx("span", { className: styles.curationRowIcon, "aria-hidden": true, children: /* @__PURE__ */ jsx(meta.Icon, { size: 14 }) }),
|
|
102
|
+
/* @__PURE__ */ jsx(Text, { variant: "body-medium", weight: "bold", as: "span", children: resource.title ?? resource.name }),
|
|
103
|
+
resource.excluded && /* @__PURE__ */ jsx(RiEyeOffLine, { size: 16, className: styles.curationRowBadge })
|
|
104
|
+
] }),
|
|
105
|
+
/* @__PURE__ */ jsx(
|
|
106
|
+
Switch,
|
|
107
|
+
{
|
|
108
|
+
isSelected: !!resource.excluded,
|
|
109
|
+
isDisabled: !canCurate || pending,
|
|
110
|
+
onChange: (checked) => save(resource, checked),
|
|
111
|
+
children: "Excluded"
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
] }),
|
|
115
|
+
resource.excluded && resource.excludedBy && /* @__PURE__ */ jsxs(
|
|
116
|
+
Text,
|
|
117
|
+
{
|
|
118
|
+
variant: "body-small",
|
|
119
|
+
color: "secondary",
|
|
120
|
+
as: "p",
|
|
121
|
+
className: styles.curationRowMeta,
|
|
122
|
+
children: [
|
|
123
|
+
"Excluded by ",
|
|
124
|
+
resource.excludedBy
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
]
|
|
129
|
+
},
|
|
130
|
+
resource.entityRef
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
return /* @__PURE__ */ jsx("div", { className: styles.surface, children: /* @__PURE__ */ jsxs(
|
|
134
|
+
BackofficeSection,
|
|
135
|
+
{
|
|
136
|
+
title: "Excluded resources",
|
|
137
|
+
description: canCurate ? "Exclude a resource to take it off the Dev AI Hub entirely. It disappears from browsing, search and the stats \u2014 but it stays in the catalog, so this is curation, not access control." : "Resources currently excluded from the Dev AI Hub. Ask for dev-ai-hub.backoffice.curate to change it.",
|
|
138
|
+
loading: loading || accessLoading,
|
|
139
|
+
error,
|
|
140
|
+
isEmpty: !loading && !error && total === 0,
|
|
141
|
+
emptyMessage: "No AI resources are visible to you yet.",
|
|
142
|
+
children: [
|
|
143
|
+
saveError && /* @__PURE__ */ jsxs(Text, { variant: "body-small", color: "danger", as: "p", role: "alert", children: [
|
|
144
|
+
"Could not save: ",
|
|
145
|
+
saveError.message
|
|
146
|
+
] }),
|
|
147
|
+
/* @__PURE__ */ jsx(
|
|
148
|
+
Text,
|
|
149
|
+
{
|
|
150
|
+
variant: "body-small",
|
|
151
|
+
weight: "bold",
|
|
152
|
+
as: "h3",
|
|
153
|
+
className: styles.subsectionHeading,
|
|
154
|
+
children: excluded.length > 0 ? `Currently excluded (${excluded.length})` : "Currently excluded"
|
|
155
|
+
}
|
|
156
|
+
),
|
|
157
|
+
excluded.length > 0 ? /* @__PURE__ */ jsx("ul", { className: styles.curationList, children: excluded.map((resource, index) => renderRow(resource, index)) }) : /* @__PURE__ */ jsx(Text, { variant: "body-small", color: "secondary", as: "p", children: "Nothing is excluded \u2014 everything in the catalog is on the hub." }),
|
|
158
|
+
/* @__PURE__ */ jsx(
|
|
159
|
+
Text,
|
|
160
|
+
{
|
|
161
|
+
variant: "body-small",
|
|
162
|
+
weight: "bold",
|
|
163
|
+
as: "h3",
|
|
164
|
+
className: styles.subsectionHeading,
|
|
165
|
+
children: canCurate ? "Exclude a resource" : "Other resources"
|
|
166
|
+
}
|
|
167
|
+
),
|
|
168
|
+
canCurate && /* @__PURE__ */ jsx(
|
|
169
|
+
ResourceFilters,
|
|
170
|
+
{
|
|
171
|
+
value: filters,
|
|
172
|
+
onChange: setFilters,
|
|
173
|
+
availableFrameworks,
|
|
174
|
+
availableTags,
|
|
175
|
+
availableTypes
|
|
176
|
+
}
|
|
177
|
+
),
|
|
178
|
+
matches.length > 0 ? /* @__PURE__ */ jsx("ul", { className: styles.curationList, children: matches.map((resource, index) => renderRow(resource, index)) }) : /* @__PURE__ */ jsx(Text, { variant: "body-small", color: "secondary", as: "p", children: hasFilter ? "No resources match your filters." : "Every visible resource is already excluded." })
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
) });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export { BackofficeExclusion };
|
|
185
|
+
//# sourceMappingURL=BackofficeExclusion.esm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BackofficeExclusion.esm.js","sources":["../../../src/components/Backoffice/BackofficeExclusion.tsx"],"sourcesContent":["import { useCallback, useEffect, useMemo, useState } from 'react';\nimport { useApi } from '@backstage/core-plugin-api';\nimport { Switch, Text } from '@backstage/ui';\nimport { RiEyeOffLine } from '@remixicon/react';\nimport type { ResourceSummary } from '@nospt/plugin-dev-ai-hub-common';\nimport { RESOURCE_TYPES } from '@nospt/plugin-dev-ai-hub-common';\nimport { devAiHubResourceApiRef } from '../../api/DevAiHubResourceClient';\nimport { useResources } from '../../hooks/useResources';\nimport { getTypeMeta } from '../AiResourcesPage/typeMeta';\nimport {\n ResourceFilters,\n type ResourceFiltersValue,\n} from '../AiResourcesPage/ResourceFilters';\nimport { BackofficeSection } from './BackofficeSection';\nimport { rowEntrance } from './rowEntrance';\nimport { useCurationAccess } from './useCurationAccess';\nimport styles from './Backoffice.module.css';\n\n/** How long the initial-render entrance animation plays for (#83), matching\n * AiResourcesPage's playEntrance window. */\nconst ENTRANCE_DURATION_MS = 700;\n\nconst EMPTY_FILTERS: ResourceFiltersValue = {\n search: '',\n framework: undefined,\n tags: [],\n};\n\n/** Same search predicate the browse page uses (name/title/description/tags),\n * so the \"exclude a resource\" picker filters identically to the main page. */\nfunction matchesSearch(resource: ResourceSummary, query: string): boolean {\n const q = query.trim().toLowerCase();\n if (!q) return true;\n return (\n resource.name.toLowerCase().includes(q) ||\n (resource.title ?? '').toLowerCase().includes(q) ||\n (resource.description ?? '').toLowerCase().includes(q) ||\n resource.tags.some(tag => tag.toLowerCase().includes(q))\n );\n}\n\n/**\n * The exclusion section (ADR-0019): the mirror of `BackofficeCuration`, but\n * for taking a resource *off* the hub rather than promoting it.\n *\n * This is the only surface in the product that can see an excluded resource —\n * it is the sole caller of `useResources({ includeExcluded: true })`, which the\n * backend gates on `dev-ai-hub.backoffice.read`. Everywhere else the resource\n * is simply absent from the payload, which is what makes the stat tiles,\n * filters and the recommendations pool exclude it without any code of their\n * own.\n *\n * Writes need `dev-ai-hub.backoffice.curate` — the same permission that gates\n * recommending — so a read-only admin sees today's exclusions with disabled\n * controls rather than a missing section.\n */\nexport function BackofficeExclusion() {\n const { items, error, loading, refresh } = useResources({\n includeExcluded: true,\n });\n const { allowed: canCurate, loading: accessLoading } = useCurationAccess();\n const api = useApi(devAiHubResourceApiRef);\n const [pendingRef, setPendingRef] = useState<string | undefined>();\n const [saveError, setSaveError] = useState<Error | undefined>();\n const [filters, setFilters] = useState<ResourceFiltersValue>(EMPTY_FILTERS);\n\n // Plays once on the initial load only, so a save-triggered `refresh()` does\n // not replay the stagger. Same trade as BackofficeCuration.\n const [playEntrance, setPlayEntrance] = useState(true);\n useEffect(() => {\n if (loading || !playEntrance) return undefined;\n const timer = setTimeout(\n () => setPlayEntrance(false),\n ENTRANCE_DURATION_MS,\n );\n return () => clearTimeout(timer);\n }, [loading, playEntrance]);\n\n const save = useCallback(\n async (resource: ResourceSummary, excluded: boolean) => {\n setSaveError(undefined);\n setPendingRef(resource.entityRef);\n try {\n await api.setExclusion(resource.entityRef, { excluded });\n await refresh();\n } catch (e) {\n setSaveError(e instanceof Error ? e : new Error(String(e)));\n } finally {\n setPendingRef(undefined);\n }\n },\n [api, refresh],\n );\n\n const total = items?.length ?? 0;\n\n const excluded = (items ?? []).filter(resource => resource.excluded);\n const others = (items ?? []).filter(resource => !resource.excluded);\n\n const availableFrameworks = useMemo(\n () =>\n Array.from(\n new Set(others.flatMap(resource => resource.frameworks)),\n ).sort(),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [items],\n );\n const availableTags = useMemo(\n () => Array.from(new Set(others.flatMap(resource => resource.tags))).sort(),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [items],\n );\n const availableTypes = useMemo(\n () => RESOURCE_TYPES.filter(type => others.some(r => r.type === type)),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [items],\n );\n\n const hasFilter =\n filters.search.trim() !== '' ||\n !!filters.framework ||\n !!filters.type ||\n filters.tags.length > 0;\n const matches = others.filter(\n resource =>\n (!filters.type || resource.type === filters.type) &&\n (!filters.framework || resource.frameworks.includes(filters.framework)) &&\n (filters.tags.length === 0 ||\n filters.tags.every(tag => resource.tags.includes(tag))) &&\n matchesSearch(resource, filters.search),\n );\n\n const renderRow = (resource: ResourceSummary, index: number) => {\n const pending = pendingRef === resource.entityRef;\n const meta = getTypeMeta(resource.type);\n const entrance = rowEntrance(playEntrance, index);\n const rowStyle = {\n '--card-accent': meta.color,\n '--card-accent-bg': meta.colorBg,\n ...entrance.style,\n } as React.CSSProperties;\n return (\n <li\n key={resource.entityRef}\n className={\n entrance.className\n ? `${styles.curationRow} ${entrance.className}`\n : styles.curationRow\n }\n style={rowStyle}\n >\n <div className={styles.curationRowMain}>\n <span className={styles.curationRowTitle}>\n <span className={styles.curationRowIcon} aria-hidden>\n <meta.Icon size={14} />\n </span>\n <Text variant=\"body-medium\" weight=\"bold\" as=\"span\">\n {resource.title ?? resource.name}\n </Text>\n {resource.excluded && (\n <RiEyeOffLine size={16} className={styles.curationRowBadge} />\n )}\n </span>\n <Switch\n isSelected={!!resource.excluded}\n isDisabled={!canCurate || pending}\n onChange={checked => save(resource, checked)}\n >\n Excluded\n </Switch>\n </div>\n {/* An exclusion nobody can attribute is one nobody will undo, so the\n person who made it is always on the row while it stands. */}\n {resource.excluded && resource.excludedBy && (\n <Text\n variant=\"body-small\"\n color=\"secondary\"\n as=\"p\"\n className={styles.curationRowMeta}\n >\n Excluded by {resource.excludedBy}\n </Text>\n )}\n </li>\n );\n };\n\n return (\n <div className={styles.surface}>\n <BackofficeSection\n title=\"Excluded resources\"\n description={\n canCurate\n ? 'Exclude a resource to take it off the Dev AI Hub entirely. It disappears from browsing, search and the stats — but it stays in the catalog, so this is curation, not access control.'\n : 'Resources currently excluded from the Dev AI Hub. Ask for dev-ai-hub.backoffice.curate to change it.'\n }\n loading={loading || accessLoading}\n error={error}\n isEmpty={!loading && !error && total === 0}\n emptyMessage=\"No AI resources are visible to you yet.\"\n >\n {saveError && (\n <Text variant=\"body-small\" color=\"danger\" as=\"p\" role=\"alert\">\n Could not save: {saveError.message}\n </Text>\n )}\n\n <Text\n variant=\"body-small\"\n weight=\"bold\"\n as=\"h3\"\n className={styles.subsectionHeading}\n >\n {excluded.length > 0\n ? `Currently excluded (${excluded.length})`\n : 'Currently excluded'}\n </Text>\n {excluded.length > 0 ? (\n <ul className={styles.curationList}>\n {excluded.map((resource, index) => renderRow(resource, index))}\n </ul>\n ) : (\n <Text variant=\"body-small\" color=\"secondary\" as=\"p\">\n Nothing is excluded — everything in the catalog is on the hub.\n </Text>\n )}\n\n <Text\n variant=\"body-small\"\n weight=\"bold\"\n as=\"h3\"\n className={styles.subsectionHeading}\n >\n {canCurate ? 'Exclude a resource' : 'Other resources'}\n </Text>\n {canCurate && (\n <ResourceFilters\n value={filters}\n onChange={setFilters}\n availableFrameworks={availableFrameworks}\n availableTags={availableTags}\n availableTypes={availableTypes}\n />\n )}\n {matches.length > 0 ? (\n <ul className={styles.curationList}>\n {matches.map((resource, index) => renderRow(resource, index))}\n </ul>\n ) : (\n <Text variant=\"body-small\" color=\"secondary\" as=\"p\">\n {hasFilter\n ? 'No resources match your filters.'\n : 'Every visible resource is already excluded.'}\n </Text>\n )}\n </BackofficeSection>\n </div>\n );\n}\n"],"names":["excluded"],"mappings":"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"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx, Fragment } from 'react/jsx-runtime';
|
|
2
|
+
import { Suspense } from 'react';
|
|
3
|
+
import { NotFoundErrorPage } from '@backstage/frontend-plugin-api';
|
|
4
|
+
import { usePermission } from '@backstage/plugin-permission-react';
|
|
5
|
+
import { devAiHubBackofficeReadPermission } from '@nospt/plugin-dev-ai-hub-common';
|
|
6
|
+
|
|
7
|
+
function useBackofficeAccess() {
|
|
8
|
+
const { loading, allowed } = usePermission({
|
|
9
|
+
permission: devAiHubBackofficeReadPermission
|
|
10
|
+
});
|
|
11
|
+
return { loading, allowed };
|
|
12
|
+
}
|
|
13
|
+
function BackofficeGate({ children }) {
|
|
14
|
+
const { loading, allowed } = useBackofficeAccess();
|
|
15
|
+
if (loading) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
if (!allowed) {
|
|
19
|
+
return /* @__PURE__ */ jsx(Suspense, { fallback: null, children: /* @__PURE__ */ jsx(NotFoundErrorPage, {}) });
|
|
20
|
+
}
|
|
21
|
+
return /* @__PURE__ */ jsx(Fragment, { children });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { BackofficeGate, useBackofficeAccess };
|
|
25
|
+
//# sourceMappingURL=BackofficeGate.esm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BackofficeGate.esm.js","sources":["../../../src/components/Backoffice/BackofficeGate.tsx"],"sourcesContent":["import { Suspense, type ReactNode } from 'react';\nimport { NotFoundErrorPage } from '@backstage/frontend-plugin-api';\nimport { usePermission } from '@backstage/plugin-permission-react';\nimport { devAiHubBackofficeReadPermission } from '@nospt/plugin-dev-ai-hub-common';\n\n/**\n * Whether the current user may open the backoffice.\n *\n * One hook, one permission, one answer — the entry point and the surface must\n * never disagree about who is allowed in, and sharing this is what guarantees\n * it. When the host app runs with the permission framework disabled the\n * permission API answers ALLOW for everything, so the backoffice is visible;\n * that is standard Backstage behaviour and a deliberate choice (ADR-0016).\n */\nexport function useBackofficeAccess(): { loading: boolean; allowed: boolean } {\n const { loading, allowed } = usePermission({\n permission: devAiHubBackofficeReadPermission,\n });\n return { loading, allowed };\n}\n\n/**\n * Wraps the backoffice page so an unauthorised user gets the app's not-found\n * page rather than the surface — the route is indistinguishable from one that\n * does not exist.\n *\n * This is presentation, not enforcement. Anything the backoffice eventually\n * *writes* has to be authorised server-side as well; hiding a route protects\n * nothing on its own (ADR-0016).\n */\nexport function BackofficeGate({ children }: { children: ReactNode }) {\n const { loading, allowed } = useBackofficeAccess();\n\n // Render nothing while the answer is in flight. Showing the surface first\n // and retracting it would flash administrative UI at everyone.\n if (loading) {\n return null;\n }\n\n if (!allowed) {\n // The app's not-found page is a swappable component and loads lazily, so\n // it needs a boundary of its own — without one it suspends the whole page\n // mid-update the moment the permission answer flips to denied.\n return (\n <Suspense fallback={null}>\n <NotFoundErrorPage />\n </Suspense>\n );\n }\n\n return <>{children}</>;\n}\n"],"names":[],"mappings":";;;;;;AAcO,SAAS,mBAAA,GAA8D;AAC5E,EAAA,MAAM,EAAE,OAAA,EAAS,OAAA,EAAQ,GAAI,aAAA,CAAc;AAAA,IACzC,UAAA,EAAY;AAAA,GACb,CAAA;AACD,EAAA,OAAO,EAAE,SAAS,OAAA,EAAQ;AAC5B;AAWO,SAAS,cAAA,CAAe,EAAE,QAAA,EAAS,EAA4B;AACpE,EAAA,MAAM,EAAE,OAAA,EAAS,OAAA,EAAQ,GAAI,mBAAA,EAAoB;AAIjD,EAAA,IAAI,OAAA,EAAS;AACX,IAAA,OAAO,IAAA;AAAA,EACT;AAEA,EAAA,IAAI,CAAC,OAAA,EAAS;AAIZ,IAAA,2BACG,QAAA,EAAA,EAAS,QAAA,EAAU,IAAA,EAClB,QAAA,kBAAA,GAAA,CAAC,qBAAkB,CAAA,EACrB,CAAA;AAAA,EAEJ;AAEA,EAAA,uCAAU,QAAA,EAAS,CAAA;AACrB;;;;"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { jsx } from 'react/jsx-runtime';
|
|
2
|
+
import { ButtonLink } from '@backstage/ui';
|
|
3
|
+
import { useRouteRef } from '@backstage/frontend-plugin-api';
|
|
4
|
+
import { useLocation } from 'react-router-dom';
|
|
5
|
+
import { RiRobot3Fill, RiSettings3Line } from '@remixicon/react';
|
|
6
|
+
import { backofficeRouteRef, rootRouteRef } from '../../routes.esm.js';
|
|
7
|
+
import { useBackofficeAccess } from './BackofficeGate.esm.js';
|
|
8
|
+
|
|
9
|
+
function BackofficeHeaderAction() {
|
|
10
|
+
const { loading, allowed } = useBackofficeAccess();
|
|
11
|
+
const backofficeLink = useRouteRef(backofficeRouteRef);
|
|
12
|
+
const rootLink = useRouteRef(rootRouteRef);
|
|
13
|
+
const { pathname } = useLocation();
|
|
14
|
+
if (loading || !allowed || !backofficeLink) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const onBackoffice = pathname.startsWith(backofficeLink());
|
|
18
|
+
if (onBackoffice && rootLink) {
|
|
19
|
+
return /* @__PURE__ */ jsx(
|
|
20
|
+
ButtonLink,
|
|
21
|
+
{
|
|
22
|
+
href: rootLink(),
|
|
23
|
+
size: "small",
|
|
24
|
+
variant: "secondary",
|
|
25
|
+
iconStart: /* @__PURE__ */ jsx(RiRobot3Fill, {}),
|
|
26
|
+
children: "AI Hub"
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return /* @__PURE__ */ jsx(
|
|
31
|
+
ButtonLink,
|
|
32
|
+
{
|
|
33
|
+
href: backofficeLink(),
|
|
34
|
+
size: "small",
|
|
35
|
+
variant: "secondary",
|
|
36
|
+
iconStart: /* @__PURE__ */ jsx(RiSettings3Line, {}),
|
|
37
|
+
children: "Admin Panel"
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export { BackofficeHeaderAction };
|
|
43
|
+
//# sourceMappingURL=BackofficeHeaderAction.esm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BackofficeHeaderAction.esm.js","sources":["../../../src/components/Backoffice/BackofficeHeaderAction.tsx"],"sourcesContent":["import { ButtonLink } from '@backstage/ui';\nimport { useRouteRef } from '@backstage/frontend-plugin-api';\nimport { useLocation } from 'react-router-dom';\nimport { RiSettings3Line, RiRobot3Fill } from '@remixicon/react';\nimport { backofficeRouteRef, rootRouteRef } from '../../routes';\nimport { useBackofficeAccess } from './BackofficeGate';\n\n/**\n * The backoffice entry point, rendered in the AI Hub plugin header (#83).\n *\n * A header action rather than a sub-page tab, deliberately: `PageBlueprint`\n * derives its tabs from static extension data at app wiring time, before any\n * user exists, so a tab cannot be withheld per user. Header actions are\n * ordinary components resolved at render time and may return `null`, which is\n * what lets an unauthorised user see no entry point at all (ADR-0016).\n *\n * The action is context-aware. As the header action lives on every page of the\n * plugin and the backoffice has no sidebar entry, it doubles as the way in and\n * the way out: on the browse surface it reads \"Admin Panel\" and links into the\n * backoffice, and once inside it flips to \"AI Hub\" and links back to the main\n * page — so the only rendered control is never a link to the page you are on.\n */\nexport function BackofficeHeaderAction() {\n const { loading, allowed } = useBackofficeAccess();\n const backofficeLink = useRouteRef(backofficeRouteRef);\n const rootLink = useRouteRef(rootRouteRef);\n const { pathname } = useLocation();\n\n if (loading || !allowed || !backofficeLink) {\n return null;\n }\n\n const onBackoffice = pathname.startsWith(backofficeLink());\n\n if (onBackoffice && rootLink) {\n return (\n <ButtonLink\n href={rootLink()}\n size=\"small\"\n variant=\"secondary\"\n iconStart={<RiRobot3Fill />}\n >\n AI Hub\n </ButtonLink>\n );\n }\n\n return (\n <ButtonLink\n href={backofficeLink()}\n size=\"small\"\n variant=\"secondary\"\n iconStart={<RiSettings3Line />}\n >\n Admin Panel\n </ButtonLink>\n );\n}\n"],"names":[],"mappings":";;;;;;;;AAsBO,SAAS,sBAAA,GAAyB;AACvC,EAAA,MAAM,EAAE,OAAA,EAAS,OAAA,EAAQ,GAAI,mBAAA,EAAoB;AACjD,EAAA,MAAM,cAAA,GAAiB,YAAY,kBAAkB,CAAA;AACrD,EAAA,MAAM,QAAA,GAAW,YAAY,YAAY,CAAA;AACzC,EAAA,MAAM,EAAE,QAAA,EAAS,GAAI,WAAA,EAAY;AAEjC,EAAA,IAAI,OAAA,IAAW,CAAC,OAAA,IAAW,CAAC,cAAA,EAAgB;AAC1C,IAAA,OAAO,IAAA;AAAA,EACT;AAEA,EAAA,MAAM,YAAA,GAAe,QAAA,CAAS,UAAA,CAAW,cAAA,EAAgB,CAAA;AAEzD,EAAA,IAAI,gBAAgB,QAAA,EAAU;AAC5B,IAAA,uBACE,GAAA;AAAA,MAAC,UAAA;AAAA,MAAA;AAAA,QACC,MAAM,QAAA,EAAS;AAAA,QACf,IAAA,EAAK,OAAA;AAAA,QACL,OAAA,EAAQ,WAAA;AAAA,QACR,SAAA,sBAAY,YAAA,EAAA,EAAa,CAAA;AAAA,QAC1B,QAAA,EAAA;AAAA;AAAA,KAED;AAAA,EAEJ;AAEA,EAAA,uBACE,GAAA;AAAA,IAAC,UAAA;AAAA,IAAA;AAAA,MACC,MAAM,cAAA,EAAe;AAAA,MACrB,IAAA,EAAK,OAAA;AAAA,MACL,OAAA,EAAQ,WAAA;AAAA,MACR,SAAA,sBAAY,eAAA,EAAA,EAAgB,CAAA;AAAA,MAC7B,QAAA,EAAA;AAAA;AAAA,GAED;AAEJ;;;;"}
|