@igstack/app-catalog-frontend-core 4.0.0-alpha-20260919175302 → 4.0.0-alpha-20260920000636
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/modules/appCatalog/ui/components/QuickJumpBar.d.ts +15 -5
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js +148 -143
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +4 -3
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/package.json +4 -4
- package/src/modules/appCatalog/ui/components/QuickJumpBar.tsx +179 -147
- package/src/modules/appCatalog/ui/detail/AppDetails.tsx +4 -3
|
@@ -4,16 +4,26 @@ import { Resource } from '@igstack/app-catalog-backend-core';
|
|
|
4
4
|
* and crouches on `:active`. Deliberately NOT an on-click animation — these
|
|
5
5
|
* links open a new tab, which takes focus the same instant, so a keyframe fired
|
|
6
6
|
* on click runs in a backgrounded tab and the user only sees it on the way back.
|
|
7
|
-
*
|
|
7
|
+
*
|
|
8
|
+
* The transition has to name `translate` and `scale`: in Tailwind v4 those
|
|
9
|
+
* utilities set those separate properties, which a `transform` transition never
|
|
10
|
+
* sees.
|
|
8
11
|
*/
|
|
9
|
-
export declare const LEAP = "origin-bottom transition-
|
|
12
|
+
export declare const LEAP = "origin-bottom transition-[background-color,translate,scale] duration-100 ease-out motion-safe:hover:-translate-y-[1.5px] motion-safe:active:translate-y-px motion-safe:active:scale-y-[0.92]";
|
|
13
|
+
/**
|
|
14
|
+
* The brand fill for the two buttons that leave for the app. Darkened, because
|
|
15
|
+
* `--primary` under `--primary-foreground` measures 3.8:1 — below WCAG AA, and
|
|
16
|
+
* the conventional `bg-primary/90` hover made it worse by fading the fill
|
|
17
|
+
* toward the card instead of darkening it.
|
|
18
|
+
*
|
|
19
|
+
* A token-level fix would be better and is a brand decision, not this
|
|
20
|
+
* component's: every filled button in the app inherits the same ratio.
|
|
21
|
+
*/
|
|
22
|
+
export declare const LEAVE_FILL: string;
|
|
10
23
|
/**
|
|
11
24
|
* Quick Jump: `[ destination ▾ | id | Jump ]`, one control sitting next to the
|
|
12
25
|
* app's own open button. The picker only names and swaps the destination; Jump
|
|
13
26
|
* is the sole thing that navigates. Renders nothing for an app with no jumps.
|
|
14
|
-
*
|
|
15
|
-
* Returns a fragment — a hairline separator plus the control — so it drops into
|
|
16
|
-
* the header's flex row beside the open button.
|
|
17
27
|
*/
|
|
18
28
|
export declare function QuickJumpBar({ app }: {
|
|
19
29
|
app: Resource;
|
|
@@ -1,13 +1,26 @@
|
|
|
1
|
-
import { jsxs,
|
|
1
|
+
import { jsxs, jsx } from "react/jsx-runtime";
|
|
2
2
|
import { ChevronDown, ExternalLink } from "lucide-react";
|
|
3
3
|
import React__default from "react";
|
|
4
4
|
import { useUrlSyncedState } from "../../hooks/useUrlSyncedState.js";
|
|
5
5
|
import { groupQuickJumps, quickJumpSlug, buildQuickJumpUrl, quickJumpShortTitle, quickJumpFieldName } from "../../utils/quickJump.js";
|
|
6
6
|
import { cn } from "../../../../lib/utils.js";
|
|
7
7
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuLabel, DropdownMenuRadioItem } from "../../../../ui/dropdown-menu.js";
|
|
8
|
-
const PICKER =
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
const PICKER = [
|
|
9
|
+
"inline-flex w-full shrink-0 items-center gap-1.5 rounded-t-lg border-b border-border bg-muted px-2",
|
|
10
|
+
"text-left text-[0.8125rem] font-semibold whitespace-nowrap",
|
|
11
|
+
"sm:w-44 sm:rounded-t-none sm:rounded-l-lg sm:border-b-0"
|
|
12
|
+
].join(" ");
|
|
13
|
+
const SEGMENT_FOCUS = "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring";
|
|
14
|
+
const JUMP = [
|
|
15
|
+
"inline-flex shrink-0 items-center gap-1.5 rounded-br-lg border-l border-border px-3",
|
|
16
|
+
"text-[0.8125rem] font-bold whitespace-nowrap sm:rounded-br-none sm:rounded-r-lg",
|
|
17
|
+
SEGMENT_FOCUS
|
|
18
|
+
].join(" ");
|
|
19
|
+
const LEAP = "origin-bottom transition-[background-color,translate,scale] duration-100 ease-out motion-safe:hover:-translate-y-[1.5px] motion-safe:active:translate-y-px motion-safe:active:scale-y-[0.92]";
|
|
20
|
+
const LEAVE_FILL = [
|
|
21
|
+
"bg-[color-mix(in_oklab,var(--color-primary)_90%,black)] text-primary-foreground",
|
|
22
|
+
"hover:bg-[color-mix(in_oklab,var(--color-primary)_80%,black)]"
|
|
23
|
+
].join(" ");
|
|
11
24
|
function QuickJumpBar({ app }) {
|
|
12
25
|
const identities = React__default.useMemo(() => groupQuickJumps(app), [app]);
|
|
13
26
|
const jumps = React__default.useMemo(
|
|
@@ -31,158 +44,150 @@ function QuickJumpBar({ app }) {
|
|
|
31
44
|
const trimmed = value.trim();
|
|
32
45
|
const href = (trimmed ? buildQuickJumpUrl(selected, trimmed, app.appUrl) : null) ?? void 0;
|
|
33
46
|
const fieldChars = Math.max(...jumps.map((jump) => jump.identity.length), 8) + 8;
|
|
34
|
-
return /* @__PURE__ */ jsxs(
|
|
35
|
-
/* @__PURE__ */
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// derived: Tailwind only compiles class names it can read.
|
|
48
|
-
"origin-bottom transition-transform duration-100 ease-out",
|
|
49
|
-
"motion-safe:has-[.qj-armed:hover]:-translate-y-[1.5px]",
|
|
50
|
-
"motion-safe:has-[.qj-armed:active]:translate-y-px",
|
|
51
|
-
"motion-safe:has-[.qj-armed:active]:scale-y-[0.92]"
|
|
52
|
-
),
|
|
53
|
-
children: [
|
|
54
|
-
jumps.length > 1 ? /* @__PURE__ */ jsxs(DropdownMenu, { children: [
|
|
55
|
-
/* @__PURE__ */ jsxs(
|
|
56
|
-
DropdownMenuTrigger,
|
|
57
|
-
{
|
|
58
|
-
title: `${selected.title} — choose a destination`,
|
|
59
|
-
className: cn(
|
|
60
|
-
PICKER,
|
|
61
|
-
PICKER_TEXT,
|
|
62
|
-
"cursor-pointer transition-colors hover:bg-accent"
|
|
63
|
-
),
|
|
64
|
-
children: [
|
|
65
|
-
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate", children: quickJumpShortTitle(selected) }),
|
|
66
|
-
/* @__PURE__ */ jsx(ChevronDown, { className: "size-4 shrink-0 text-muted-foreground" })
|
|
67
|
-
]
|
|
68
|
-
}
|
|
69
|
-
),
|
|
70
|
-
/* @__PURE__ */ jsx(
|
|
71
|
-
DropdownMenuContent,
|
|
72
|
-
{
|
|
73
|
-
align: "start",
|
|
74
|
-
className: "max-w-[min(90vw,34rem)]",
|
|
75
|
-
children: /* @__PURE__ */ jsx(
|
|
76
|
-
DropdownMenuRadioGroup,
|
|
77
|
-
{
|
|
78
|
-
value: quickJumpSlug(selected),
|
|
79
|
-
onValueChange: setSlug,
|
|
80
|
-
children: identities.map((group) => /* @__PURE__ */ jsxs(React__default.Fragment, { children: [
|
|
81
|
-
/* @__PURE__ */ jsx(DropdownMenuLabel, { className: "text-[0.65rem] font-extrabold tracking-wider text-muted-foreground uppercase", children: group.identity }),
|
|
82
|
-
group.jumps.map((jump) => /* @__PURE__ */ jsx(
|
|
83
|
-
DropdownMenuRadioItem,
|
|
84
|
-
{
|
|
85
|
-
value: quickJumpSlug(jump),
|
|
86
|
-
className: "whitespace-nowrap",
|
|
87
|
-
children: jump.title
|
|
88
|
-
},
|
|
89
|
-
quickJumpSlug(jump)
|
|
90
|
-
))
|
|
91
|
-
] }, group.identity))
|
|
92
|
-
}
|
|
93
|
-
)
|
|
94
|
-
}
|
|
95
|
-
)
|
|
96
|
-
] }) : /* @__PURE__ */ jsx(
|
|
97
|
-
"span",
|
|
98
|
-
{
|
|
99
|
-
title: selected.title,
|
|
100
|
-
className: cn(PICKER, PICKER_TEXT, "truncate"),
|
|
101
|
-
children: quickJumpShortTitle(selected)
|
|
102
|
-
}
|
|
103
|
-
),
|
|
104
|
-
/* @__PURE__ */ jsx(
|
|
105
|
-
"input",
|
|
106
|
-
{
|
|
107
|
-
ref: inputRef,
|
|
108
|
-
type: "text",
|
|
109
|
-
size: fieldChars,
|
|
110
|
-
name: quickJumpFieldName(selected.identity),
|
|
111
|
-
autoComplete: "on",
|
|
112
|
-
"aria-label": selected.identity,
|
|
113
|
-
placeholder: `Enter ${selected.identity}`,
|
|
114
|
-
value,
|
|
115
|
-
onChange: (e) => setValue(e.target.value),
|
|
116
|
-
onFocus: (e) => e.currentTarget.select(),
|
|
117
|
-
onKeyDown: (e) => {
|
|
118
|
-
if (e.key !== "Enter" || !href) return;
|
|
119
|
-
window.open(href, "_blank", "noopener,noreferrer");
|
|
120
|
-
},
|
|
121
|
-
className: cn(
|
|
122
|
-
"shrink-0 border-l border-border bg-transparent px-2.5 py-1.5 font-mono text-[0.8125rem] font-medium outline-none placeholder:font-sans placeholder:text-muted-foreground",
|
|
123
|
-
// Hovering the dormant Jump lights up the field it is waiting on.
|
|
124
|
-
"transition-[box-shadow,background-color] duration-150",
|
|
125
|
-
"group-has-[.qj-dormant:hover]:bg-ring/10 group-has-[.qj-dormant:hover]:shadow-[inset_0_0_0_2px_var(--color-ring)]",
|
|
126
|
-
"group-has-[.qj-dormant:hover]:placeholder:text-foreground"
|
|
127
|
-
)
|
|
128
|
-
}
|
|
129
|
-
),
|
|
47
|
+
return /* @__PURE__ */ jsxs("div", { className: "relative flex w-full sm:w-auto", children: [
|
|
48
|
+
/* @__PURE__ */ jsxs(
|
|
49
|
+
"div",
|
|
50
|
+
{
|
|
51
|
+
className: cn(
|
|
52
|
+
"group peer flex w-full flex-wrap items-stretch rounded-lg border border-input bg-card shadow-[inset_0_1px_2px_rgb(0_0_0/0.09)]",
|
|
53
|
+
// The ring is the FIELD's, not the shell's: the picker and Jump are
|
|
54
|
+
// focusable too and ringing the shell for all three says nothing.
|
|
55
|
+
"has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-ring/25",
|
|
56
|
+
"sm:w-auto sm:flex-nowrap"
|
|
57
|
+
),
|
|
58
|
+
children: [
|
|
59
|
+
jumps.length > 1 ? /* @__PURE__ */ jsxs(DropdownMenu, { children: [
|
|
130
60
|
/* @__PURE__ */ jsxs(
|
|
131
|
-
|
|
61
|
+
DropdownMenuTrigger,
|
|
132
62
|
{
|
|
133
|
-
|
|
134
|
-
target: "_blank",
|
|
135
|
-
rel: "noopener noreferrer",
|
|
136
|
-
"aria-disabled": href ? void 0 : true,
|
|
137
|
-
title: href ? `${selected.title} → ${href}` : `Enter a ${selected.identity} first`,
|
|
138
|
-
onClick: (e) => {
|
|
139
|
-
var _a;
|
|
140
|
-
if (!href) {
|
|
141
|
-
e.preventDefault();
|
|
142
|
-
(_a = inputRef.current) == null ? void 0 : _a.focus();
|
|
143
|
-
}
|
|
144
|
-
},
|
|
63
|
+
title: `${selected.title} — choose a destination`,
|
|
145
64
|
className: cn(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
// attribute selector cannot be in a Tailwind variant.
|
|
150
|
-
href ? "qj-armed animate-button-pop bg-primary text-primary-foreground hover:bg-primary/90" : "qj-dormant cursor-text bg-muted text-muted-foreground"
|
|
65
|
+
PICKER,
|
|
66
|
+
SEGMENT_FOCUS,
|
|
67
|
+
"cursor-pointer transition-colors hover:bg-accent"
|
|
151
68
|
),
|
|
152
69
|
children: [
|
|
153
|
-
"
|
|
154
|
-
/* @__PURE__ */ jsx(
|
|
70
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 truncate", children: quickJumpShortTitle(selected) }),
|
|
71
|
+
/* @__PURE__ */ jsx(ChevronDown, { className: "size-4 shrink-0 text-muted-foreground" })
|
|
155
72
|
]
|
|
156
73
|
}
|
|
74
|
+
),
|
|
75
|
+
/* @__PURE__ */ jsx(
|
|
76
|
+
DropdownMenuContent,
|
|
77
|
+
{
|
|
78
|
+
align: "start",
|
|
79
|
+
className: "max-w-[min(90vw,34rem)]",
|
|
80
|
+
children: /* @__PURE__ */ jsx(
|
|
81
|
+
DropdownMenuRadioGroup,
|
|
82
|
+
{
|
|
83
|
+
value: quickJumpSlug(selected),
|
|
84
|
+
onValueChange: setSlug,
|
|
85
|
+
children: identities.map((group) => /* @__PURE__ */ jsxs(React__default.Fragment, { children: [
|
|
86
|
+
/* @__PURE__ */ jsx(DropdownMenuLabel, { className: "text-[0.65rem] font-extrabold tracking-wider text-muted-foreground uppercase", children: group.identity }),
|
|
87
|
+
group.jumps.map((jump) => /* @__PURE__ */ jsx(
|
|
88
|
+
DropdownMenuRadioItem,
|
|
89
|
+
{
|
|
90
|
+
value: quickJumpSlug(jump),
|
|
91
|
+
className: "whitespace-nowrap",
|
|
92
|
+
children: jump.title
|
|
93
|
+
},
|
|
94
|
+
quickJumpSlug(jump)
|
|
95
|
+
))
|
|
96
|
+
] }, group.identity))
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
}
|
|
157
100
|
)
|
|
158
|
-
]
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
101
|
+
] }) : /* @__PURE__ */ jsx("span", { title: selected.title, className: cn(PICKER, "truncate"), children: quickJumpShortTitle(selected) }),
|
|
102
|
+
/* @__PURE__ */ jsx(
|
|
103
|
+
"input",
|
|
104
|
+
{
|
|
105
|
+
ref: inputRef,
|
|
106
|
+
type: "text",
|
|
107
|
+
size: fieldChars,
|
|
108
|
+
name: quickJumpFieldName(selected.identity),
|
|
109
|
+
autoComplete: "on",
|
|
110
|
+
"aria-label": selected.identity,
|
|
111
|
+
placeholder: `Enter ${selected.identity}`,
|
|
112
|
+
value,
|
|
113
|
+
onChange: (e) => setValue(e.target.value),
|
|
114
|
+
onFocus: (e) => e.currentTarget.select(),
|
|
115
|
+
onKeyDown: (e) => {
|
|
116
|
+
if (e.key !== "Enter" || !href) return;
|
|
117
|
+
window.open(href, "_blank", "noopener,noreferrer");
|
|
118
|
+
},
|
|
119
|
+
className: cn(
|
|
120
|
+
// Grows into the row's leftovers below `sm`, where it shares a line
|
|
121
|
+
// with Jump; its own fixed `size` from there up.
|
|
122
|
+
"min-w-0 flex-1 border-border bg-transparent px-2.5 py-1.5 font-mono text-[0.8125rem] font-medium outline-none placeholder:font-sans placeholder:text-muted-foreground",
|
|
123
|
+
"sm:flex-none sm:border-l",
|
|
124
|
+
// Hovering the dormant Jump lights up the field it is waiting on.
|
|
125
|
+
"transition-[box-shadow,background-color] duration-150",
|
|
126
|
+
"group-has-[.qj-dormant:hover]:bg-ring/10 group-has-[.qj-dormant:hover]:shadow-[inset_0_0_0_2px_var(--color-ring)]",
|
|
127
|
+
"group-has-[.qj-dormant:hover]:placeholder:text-foreground"
|
|
128
|
+
)
|
|
129
|
+
}
|
|
173
130
|
),
|
|
174
|
-
|
|
175
|
-
"
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
131
|
+
href ? /* @__PURE__ */ jsxs(
|
|
132
|
+
"a",
|
|
133
|
+
{
|
|
134
|
+
href,
|
|
135
|
+
target: "_blank",
|
|
136
|
+
rel: "noopener noreferrer",
|
|
137
|
+
title: `Jump to ${selected.title}`,
|
|
138
|
+
className: cn(JUMP, LEAP, LEAVE_FILL, "animate-button-pop"),
|
|
139
|
+
children: [
|
|
140
|
+
"Jump",
|
|
141
|
+
/* @__PURE__ */ jsx(ExternalLink, { className: "size-3.5 shrink-0" })
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
) : /* @__PURE__ */ jsxs(
|
|
145
|
+
"button",
|
|
146
|
+
{
|
|
147
|
+
type: "button",
|
|
148
|
+
title: `Enter a ${selected.identity} first`,
|
|
149
|
+
onClick: () => {
|
|
150
|
+
var _a;
|
|
151
|
+
return (_a = inputRef.current) == null ? void 0 : _a.focus();
|
|
152
|
+
},
|
|
153
|
+
className: cn(
|
|
154
|
+
JUMP,
|
|
155
|
+
"qj-dormant cursor-text bg-primary/10 text-muted-foreground"
|
|
156
|
+
),
|
|
157
|
+
children: [
|
|
158
|
+
"Jump",
|
|
159
|
+
/* @__PURE__ */ jsx(ExternalLink, { className: "size-3.5 shrink-0" })
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
]
|
|
164
|
+
}
|
|
165
|
+
),
|
|
166
|
+
/* @__PURE__ */ jsxs(
|
|
167
|
+
"p",
|
|
168
|
+
{
|
|
169
|
+
"aria-hidden": true,
|
|
170
|
+
className: cn(
|
|
171
|
+
"pointer-events-none absolute top-[calc(100%+0.5rem)] left-0 z-30 m-0 flex items-center rounded-md bg-foreground px-2 py-1 text-xs font-semibold whitespace-nowrap text-background sm:left-44",
|
|
172
|
+
"before:absolute before:-top-[3px] before:left-[0.9rem] before:size-2 before:rotate-45 before:rounded-[1px] before:bg-foreground",
|
|
173
|
+
// `translate`, not `transform`: v4 utilities set the separate
|
|
174
|
+
// `translate` property, which a transform transition never sees.
|
|
175
|
+
"-translate-y-[3px] opacity-0 transition-[opacity,translate] duration-150",
|
|
176
|
+
"peer-has-[.qj-dormant:hover]:translate-y-0 peer-has-[.qj-dormant:hover]:opacity-100",
|
|
177
|
+
"peer-has-[.qj-dormant:focus-visible]:translate-y-0 peer-has-[.qj-dormant:focus-visible]:opacity-100"
|
|
178
|
+
),
|
|
179
|
+
children: [
|
|
180
|
+
"Type a ",
|
|
181
|
+
selected.identity,
|
|
182
|
+
" here first"
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
)
|
|
182
186
|
] });
|
|
183
187
|
}
|
|
184
188
|
export {
|
|
185
189
|
LEAP,
|
|
190
|
+
LEAVE_FILL,
|
|
186
191
|
QuickJumpBar
|
|
187
192
|
};
|
|
188
193
|
//# sourceMappingURL=QuickJumpBar.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"QuickJumpBar.js","sources":["../../../../../../src/modules/appCatalog/ui/components/QuickJumpBar.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ChevronDown, ExternalLink } from 'lucide-react'\nimport React from 'react'\n\nimport { useUrlSyncedState } from '~/modules/appCatalog/hooks/useUrlSyncedState'\nimport {\n buildQuickJumpUrl,\n groupQuickJumps,\n quickJumpFieldName,\n quickJumpShortTitle,\n quickJumpSlug,\n} from '~/modules/appCatalog/utils/quickJump'\nimport { cn } from '~/lib/utils'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n} from '~/ui/dropdown-menu'\n\n/** Fixed, so swapping destination cannot slide the field and Jump sideways. The\n picker carries the short title; the full one is in the menu, where you are\n choosing between systems. Longer shorts still ellipsis here. */\nconst PICKER = 'inline-flex w-44 shrink-0 items-center gap-1.5 bg-muted px-2'\nconst PICKER_TEXT = 'text-left text-xs font-semibold whitespace-nowrap'\n\n/**\n * The leap, as env-hopper does it: the button lifts onto its toes on `:hover`\n * and crouches on `:active`. Deliberately NOT an on-click animation — these\n * links open a new tab, which takes focus the same instant, so a keyframe fired\n * on click runs in a backgrounded tab and the user only sees it on the way back.\n * Shared with the app's own \"Go to <host>\" button: both are leaving for the app.\n */\nexport const LEAP =\n 'origin-bottom transition-transform duration-100 ease-out motion-safe:hover:-translate-y-[1.5px] motion-safe:active:translate-y-px motion-safe:active:scale-y-[0.92]'\n\n/**\n * Quick Jump: `[ destination ▾ | id | Jump ]`, one control sitting next to the\n * app's own open button. The picker only names and swaps the destination; Jump\n * is the sole thing that navigates. Renders nothing for an app with no jumps.\n *\n * Returns a fragment — a hairline separator plus the control — so it drops into\n * the header's flex row beside the open button.\n */\nexport function QuickJumpBar({ app }: { app: Resource }) {\n const identities = React.useMemo(() => groupQuickJumps(app), [app])\n const jumps = React.useMemo(\n () => identities.flatMap((group) => group.jumps),\n [identities],\n )\n // The chosen destination lives in the url (`?qj=tracker.view-case`), so a\n // \"use this one\" link is shareable. Empty = the app's first jump.\n const [slug, setSlug] = useUrlSyncedState<string>({\n key: 'qj',\n defaultValue: '',\n encode: (value) => value || undefined,\n })\n const [value, setValue] = React.useState('')\n const inputRef = React.useRef<HTMLInputElement>(null)\n\n // A typed id belongs to the app it was typed on.\n React.useEffect(() => setValue(''), [jumps])\n\n // A `?qj=` naming another app's jump would silently resolve to this app's\n // first one, leaving the url claiming something untrue.\n React.useEffect(() => {\n if (slug && !jumps.some((jump) => quickJumpSlug(jump) === slug)) setSlug('')\n }, [jumps, slug, setSlug])\n\n if (jumps.length === 0) return null\n\n const selected =\n jumps.find((jump) => quickJumpSlug(jump) === slug) ?? jumps[0]\n if (!selected) return null\n\n const trimmed = value.trim()\n // Never null in practice: groupQuickJumps already dropped the jumps this app\n // cannot build a url for.\n const href =\n (trimmed ? buildQuickJumpUrl(selected, trimmed, app.appUrl) : null) ??\n undefined\n\n // Sized once from the longest identifier this app asks for, not per\n // selection: the field must not resize when the destination changes. `size`\n // is in characters of the input's own font, so it survives a type-scale change.\n const fieldChars =\n Math.max(...jumps.map((jump) => jump.identity.length), 8) + 8\n\n return (\n <>\n <span aria-hidden className=\"mx-1 w-px self-stretch bg-border\" />\n <div className=\"relative flex\">\n <div\n className={cn(\n 'group peer flex items-stretch overflow-hidden rounded-lg border border-input bg-card shadow-[inset_0_1px_2px_rgb(0_0_0/0.09)]',\n 'focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/25',\n // Jump is a segment inside this clipping shell, so the SHELL leaps\n // -- a transform on the segment alone would be cut off, and the\n // field is going with it anyway. Same motion as LEAP, hung off the\n // armed Jump instead of off this element. Spelled out rather than\n // derived: Tailwind only compiles class names it can read.\n 'origin-bottom transition-transform duration-100 ease-out',\n 'motion-safe:has-[.qj-armed:hover]:-translate-y-[1.5px]',\n 'motion-safe:has-[.qj-armed:active]:translate-y-px',\n 'motion-safe:has-[.qj-armed:active]:scale-y-[0.92]',\n )}\n >\n {jumps.length > 1 ? (\n <DropdownMenu>\n <DropdownMenuTrigger\n title={`${selected.title} — choose a destination`}\n className={cn(\n PICKER,\n PICKER_TEXT,\n 'cursor-pointer transition-colors hover:bg-accent',\n )}\n >\n <span className=\"min-w-0 flex-1 truncate\">\n {quickJumpShortTitle(selected)}\n </span>\n <ChevronDown className=\"size-4 shrink-0 text-muted-foreground\" />\n </DropdownMenuTrigger>\n {/* Portalled and sized to its content: the trigger ellipsises, the\n menu never does. */}\n <DropdownMenuContent\n align=\"start\"\n className=\"max-w-[min(90vw,34rem)]\"\n >\n <DropdownMenuRadioGroup\n value={quickJumpSlug(selected)}\n onValueChange={setSlug}\n >\n {identities.map((group) => (\n <React.Fragment key={group.identity}>\n <DropdownMenuLabel className=\"text-[0.65rem] font-extrabold tracking-wider text-muted-foreground uppercase\">\n {group.identity}\n </DropdownMenuLabel>\n {group.jumps.map((jump) => (\n <DropdownMenuRadioItem\n key={quickJumpSlug(jump)}\n value={quickJumpSlug(jump)}\n className=\"whitespace-nowrap\"\n >\n {jump.title}\n </DropdownMenuRadioItem>\n ))}\n </React.Fragment>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n ) : (\n <span\n title={selected.title}\n className={cn(PICKER, PICKER_TEXT, 'truncate')}\n >\n {quickJumpShortTitle(selected)}\n </span>\n )}\n\n <input\n ref={inputRef}\n type=\"text\"\n size={fieldChars}\n // Namespaced per identity: the browser remembers values typed for this\n // identifier and offers them on every app that asks for it.\n name={quickJumpFieldName(selected.identity)}\n autoComplete=\"on\"\n aria-label={selected.identity}\n placeholder={`Enter ${selected.identity}`}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n // Selecting on focus rather than on click, or a second click inside\n // could never place a caret.\n onFocus={(e) => e.currentTarget.select()}\n onKeyDown={(e) => {\n if (e.key !== 'Enter' || !href) return\n window.open(href, '_blank', 'noopener,noreferrer')\n }}\n className={cn(\n 'shrink-0 border-l border-border bg-transparent px-2.5 py-1.5 font-mono text-[0.8125rem] font-medium outline-none placeholder:font-sans placeholder:text-muted-foreground',\n // Hovering the dormant Jump lights up the field it is waiting on.\n 'transition-[box-shadow,background-color] duration-150',\n 'group-has-[.qj-dormant:hover]:bg-ring/10 group-has-[.qj-dormant:hover]:shadow-[inset_0_0_0_2px_var(--color-ring)]',\n 'group-has-[.qj-dormant:hover]:placeholder:text-foreground',\n )}\n />\n\n <a\n href={href}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n aria-disabled={href ? undefined : true}\n title={\n href\n ? `${selected.title} → ${href}`\n : `Enter a ${selected.identity} first`\n }\n onClick={(e) => {\n // Dormant, but still clickable: pressing it says \"type here\".\n if (!href) {\n e.preventDefault()\n inputRef.current?.focus()\n }\n }}\n className={cn(\n 'inline-flex shrink-0 items-center gap-1.5 border-l border-border px-3 text-xs font-bold whitespace-nowrap transition-colors',\n // `qj-armed`/`qj-dormant` carry no styles: they are the hooks the\n // shell and the field hang their `has-*` variants off, which an\n // attribute selector cannot be in a Tailwind variant.\n href\n ? 'qj-armed animate-button-pop bg-primary text-primary-foreground hover:bg-primary/90'\n : 'qj-dormant cursor-text bg-muted text-muted-foreground',\n )}\n >\n Jump\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </a>\n </div>\n\n {/* Hovering a button that does nothing is someone asking why. The answer\n points at the FIELD, not at the button under the cursor: the tail\n lands just inside the field's left edge (`left-44` = the picker's\n `w-44`) and the field lights up at the same time. */}\n <p\n aria-hidden\n className={cn(\n 'pointer-events-none absolute top-[calc(100%+0.5rem)] left-44 z-30 m-0 flex items-center rounded-md bg-foreground px-2 py-1 text-xs font-semibold whitespace-nowrap text-background',\n 'before:absolute before:-top-[3px] before:left-[0.9rem] before:size-2 before:rotate-45 before:rounded-[1px] before:bg-foreground',\n // `translate`, not `transform`: v4 utilities set the separate\n // `translate` property, which a transform transition never sees.\n '-translate-y-[3px] opacity-0 transition-[opacity,translate] duration-150',\n 'peer-has-[.qj-dormant:hover]:translate-y-0 peer-has-[.qj-dormant:hover]:opacity-100',\n 'peer-has-[.qj-dormant:focus-visible]:translate-y-0 peer-has-[.qj-dormant:focus-visible]:opacity-100',\n )}\n >\n Type a {selected.identity} here first\n </p>\n </div>\n </>\n )\n}\n"],"names":["React","value"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"QuickJumpBar.js","sources":["../../../../../../src/modules/appCatalog/ui/components/QuickJumpBar.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ChevronDown, ExternalLink } from 'lucide-react'\nimport React from 'react'\n\nimport { useUrlSyncedState } from '~/modules/appCatalog/hooks/useUrlSyncedState'\nimport {\n buildQuickJumpUrl,\n groupQuickJumps,\n quickJumpFieldName,\n quickJumpShortTitle,\n quickJumpSlug,\n} from '~/modules/appCatalog/utils/quickJump'\nimport { cn } from '~/lib/utils'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n} from '~/ui/dropdown-menu'\n\n/**\n * The destination picker. Fixed width on a desktop row, so swapping destination\n * cannot slide the field and Jump sideways; its own full-width line below `sm`,\n * where the three segments together do not fit in a phone's panel at all. It\n * carries the short title — the full one is in the menu, where you are choosing\n * between systems. Longer shorts still ellipsis here.\n */\nconst PICKER = [\n 'inline-flex w-full shrink-0 items-center gap-1.5 rounded-t-lg border-b border-border bg-muted px-2',\n 'text-left text-[0.8125rem] font-semibold whitespace-nowrap',\n 'sm:w-44 sm:rounded-t-none sm:rounded-l-lg sm:border-b-0',\n].join(' ')\n\n/** One focus ring per focusable segment, or tabbing the row never says where\n you are: the shell's ring belongs to the field (below), these to the ends. */\nconst SEGMENT_FOCUS =\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring'\n\n/** Shared by Jump and by the app's own \"Go to <host>\" button. */\nconst JUMP = [\n 'inline-flex shrink-0 items-center gap-1.5 rounded-br-lg border-l border-border px-3',\n 'text-[0.8125rem] font-bold whitespace-nowrap sm:rounded-br-none sm:rounded-r-lg',\n SEGMENT_FOCUS,\n].join(' ')\n\n/**\n * The leap, as env-hopper does it: the button lifts onto its toes on `:hover`\n * and crouches on `:active`. Deliberately NOT an on-click animation — these\n * links open a new tab, which takes focus the same instant, so a keyframe fired\n * on click runs in a backgrounded tab and the user only sees it on the way back.\n *\n * The transition has to name `translate` and `scale`: in Tailwind v4 those\n * utilities set those separate properties, which a `transform` transition never\n * sees.\n */\nexport const LEAP =\n 'origin-bottom transition-[background-color,translate,scale] duration-100 ease-out motion-safe:hover:-translate-y-[1.5px] motion-safe:active:translate-y-px motion-safe:active:scale-y-[0.92]'\n\n/**\n * The brand fill for the two buttons that leave for the app. Darkened, because\n * `--primary` under `--primary-foreground` measures 3.8:1 — below WCAG AA, and\n * the conventional `bg-primary/90` hover made it worse by fading the fill\n * toward the card instead of darkening it.\n *\n * A token-level fix would be better and is a brand decision, not this\n * component's: every filled button in the app inherits the same ratio.\n */\nexport const LEAVE_FILL = [\n 'bg-[color-mix(in_oklab,var(--color-primary)_90%,black)] text-primary-foreground',\n 'hover:bg-[color-mix(in_oklab,var(--color-primary)_80%,black)]',\n].join(' ')\n\n/**\n * Quick Jump: `[ destination ▾ | id | Jump ]`, one control sitting next to the\n * app's own open button. The picker only names and swaps the destination; Jump\n * is the sole thing that navigates. Renders nothing for an app with no jumps.\n */\nexport function QuickJumpBar({ app }: { app: Resource }) {\n const identities = React.useMemo(() => groupQuickJumps(app), [app])\n const jumps = React.useMemo(\n () => identities.flatMap((group) => group.jumps),\n [identities],\n )\n // The chosen destination lives in the url (`?qj=tracker.view-case`), so a\n // \"use this one\" link is shareable. Empty = the app's first jump.\n const [slug, setSlug] = useUrlSyncedState<string>({\n key: 'qj',\n defaultValue: '',\n encode: (value) => value || undefined,\n })\n const [value, setValue] = React.useState('')\n const inputRef = React.useRef<HTMLInputElement>(null)\n\n // A typed id belongs to the app it was typed on.\n React.useEffect(() => setValue(''), [jumps])\n\n // A `?qj=` naming another app's jump would silently resolve to this app's\n // first one, leaving the url claiming something untrue.\n React.useEffect(() => {\n if (slug && !jumps.some((jump) => quickJumpSlug(jump) === slug)) setSlug('')\n }, [jumps, slug, setSlug])\n\n if (jumps.length === 0) return null\n\n const selected =\n jumps.find((jump) => quickJumpSlug(jump) === slug) ?? jumps[0]\n if (!selected) return null\n\n const trimmed = value.trim()\n // Never null in practice: groupQuickJumps already dropped the jumps this app\n // cannot build a url for.\n const href =\n (trimmed ? buildQuickJumpUrl(selected, trimmed, app.appUrl) : null) ??\n undefined\n\n // Sized once from the longest identifier this app asks for, not per\n // selection: the field must not resize when the destination changes. `size`\n // is in characters of the input's own font, so it survives a type-scale change.\n const fieldChars =\n Math.max(...jumps.map((jump) => jump.identity.length), 8) + 8\n\n return (\n <div className=\"relative flex w-full sm:w-auto\">\n <div\n className={cn(\n 'group peer flex w-full flex-wrap items-stretch rounded-lg border border-input bg-card shadow-[inset_0_1px_2px_rgb(0_0_0/0.09)]',\n // The ring is the FIELD's, not the shell's: the picker and Jump are\n // focusable too and ringing the shell for all three says nothing.\n 'has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-ring/25',\n 'sm:w-auto sm:flex-nowrap',\n )}\n >\n {jumps.length > 1 ? (\n <DropdownMenu>\n <DropdownMenuTrigger\n title={`${selected.title} — choose a destination`}\n className={cn(\n PICKER,\n SEGMENT_FOCUS,\n 'cursor-pointer transition-colors hover:bg-accent',\n )}\n >\n {/* Caret immediately after the label, not shoved to the far edge:\n out there it reads as the field's boundary and leaves the\n picker looking like a wide empty select. */}\n <span className=\"min-w-0 truncate\">\n {quickJumpShortTitle(selected)}\n </span>\n <ChevronDown className=\"size-4 shrink-0 text-muted-foreground\" />\n </DropdownMenuTrigger>\n {/* Portalled and sized to its content: the trigger ellipsises, the\n menu never does. */}\n <DropdownMenuContent\n align=\"start\"\n className=\"max-w-[min(90vw,34rem)]\"\n >\n <DropdownMenuRadioGroup\n value={quickJumpSlug(selected)}\n onValueChange={setSlug}\n >\n {identities.map((group) => (\n <React.Fragment key={group.identity}>\n <DropdownMenuLabel className=\"text-[0.65rem] font-extrabold tracking-wider text-muted-foreground uppercase\">\n {group.identity}\n </DropdownMenuLabel>\n {group.jumps.map((jump) => (\n <DropdownMenuRadioItem\n key={quickJumpSlug(jump)}\n value={quickJumpSlug(jump)}\n className=\"whitespace-nowrap\"\n >\n {jump.title}\n </DropdownMenuRadioItem>\n ))}\n </React.Fragment>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n ) : (\n <span title={selected.title} className={cn(PICKER, 'truncate')}>\n {quickJumpShortTitle(selected)}\n </span>\n )}\n\n <input\n ref={inputRef}\n type=\"text\"\n size={fieldChars}\n // Namespaced per identity: the browser remembers values typed for this\n // identifier and offers them on every app that asks for it.\n name={quickJumpFieldName(selected.identity)}\n autoComplete=\"on\"\n aria-label={selected.identity}\n placeholder={`Enter ${selected.identity}`}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n // Selecting on focus rather than on click, or a second click inside\n // could never place a caret.\n onFocus={(e) => e.currentTarget.select()}\n onKeyDown={(e) => {\n if (e.key !== 'Enter' || !href) return\n window.open(href, '_blank', 'noopener,noreferrer')\n }}\n className={cn(\n // Grows into the row's leftovers below `sm`, where it shares a line\n // with Jump; its own fixed `size` from there up.\n 'min-w-0 flex-1 border-border bg-transparent px-2.5 py-1.5 font-mono text-[0.8125rem] font-medium outline-none placeholder:font-sans placeholder:text-muted-foreground',\n 'sm:flex-none sm:border-l',\n // Hovering the dormant Jump lights up the field it is waiting on.\n 'transition-[box-shadow,background-color] duration-150',\n 'group-has-[.qj-dormant:hover]:bg-ring/10 group-has-[.qj-dormant:hover]:shadow-[inset_0_0_0_2px_var(--color-ring)]',\n 'group-has-[.qj-dormant:hover]:placeholder:text-foreground',\n )}\n />\n\n {/* A real <button> while dormant. An <a> without href is not focusable,\n so the whole \"press it and it tells you to type\" behaviour — and the\n hint's :focus-visible branch — would have been mouse-only. */}\n {href ? (\n <a\n href={href}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n // Title, not the url: the browser already puts the url in the\n // status bar, and some of these are 250 characters long. It doubles\n // as the full text when the picker has ellipsised it.\n title={`Jump to ${selected.title}`}\n className={cn(JUMP, LEAP, LEAVE_FILL, 'animate-button-pop')}\n >\n Jump\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </a>\n ) : (\n <button\n type=\"button\"\n title={`Enter a ${selected.identity} first`}\n onClick={() => inputRef.current?.focus()}\n // Tinted toward the action rather than greyed: on `bg-muted` it was\n // the picker's twin, so the row read as two dropdowns around a\n // field and nothing in it looked raised.\n className={cn(\n JUMP,\n 'qj-dormant cursor-text bg-primary/10 text-muted-foreground',\n )}\n >\n Jump\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </button>\n )}\n </div>\n\n {/* Hovering a button that does nothing is someone asking why. The answer\n points at the FIELD, not at the button under the cursor: the tail\n lands just inside the field's left edge (`left-44` = the picker's\n `w-44`; below `sm` the field starts the line) and the field lights up\n at the same time. */}\n <p\n aria-hidden\n className={cn(\n 'pointer-events-none absolute top-[calc(100%+0.5rem)] left-0 z-30 m-0 flex items-center rounded-md bg-foreground px-2 py-1 text-xs font-semibold whitespace-nowrap text-background sm:left-44',\n 'before:absolute before:-top-[3px] before:left-[0.9rem] before:size-2 before:rotate-45 before:rounded-[1px] before:bg-foreground',\n // `translate`, not `transform`: v4 utilities set the separate\n // `translate` property, which a transform transition never sees.\n '-translate-y-[3px] opacity-0 transition-[opacity,translate] duration-150',\n 'peer-has-[.qj-dormant:hover]:translate-y-0 peer-has-[.qj-dormant:hover]:opacity-100',\n 'peer-has-[.qj-dormant:focus-visible]:translate-y-0 peer-has-[.qj-dormant:focus-visible]:opacity-100',\n )}\n >\n Type a {selected.identity} here first\n </p>\n </div>\n )\n}\n"],"names":["React","value"],"mappings":"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"}
|
|
@@ -8,7 +8,7 @@ import { Button } from "../../../../ui/button.js";
|
|
|
8
8
|
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "../../../../ui/accordion.js";
|
|
9
9
|
import { AccessRequestSection } from "../components/AccessRequestSection.js";
|
|
10
10
|
import { AccessPrerequisiteChain } from "../components/AccessPrerequisiteChain.js";
|
|
11
|
-
import { LEAP, QuickJumpBar } from "../components/QuickJumpBar.js";
|
|
11
|
+
import { LEAP, LEAVE_FILL, QuickJumpBar } from "../components/QuickJumpBar.js";
|
|
12
12
|
import { useAppCatalogFilters } from "../context/AppCatalogFiltersContext.js";
|
|
13
13
|
import { PersonBadge } from "../components/PersonBadge.js";
|
|
14
14
|
import { useUser } from "../../../auth/AuthContext.js";
|
|
@@ -171,7 +171,7 @@ function AppDetails({
|
|
|
171
171
|
}
|
|
172
172
|
)
|
|
173
173
|
] }),
|
|
174
|
-
/* @__PURE__ */ jsxs("div", { className: "mt-3 flex flex-wrap items-stretch gap-
|
|
174
|
+
/* @__PURE__ */ jsxs("div", { className: "mt-3 flex flex-wrap items-stretch gap-3 px-3", children: [
|
|
175
175
|
isAdmin ? /* @__PURE__ */ jsx(
|
|
176
176
|
InlineEditableField,
|
|
177
177
|
{
|
|
@@ -201,7 +201,8 @@ function AppDetails({
|
|
|
201
201
|
rel: "noopener noreferrer",
|
|
202
202
|
onClick: () => recordClick(app.slug),
|
|
203
203
|
className: cn(
|
|
204
|
-
"inline-flex items-center gap-2 rounded-md
|
|
204
|
+
"inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium shadow-sm",
|
|
205
|
+
LEAVE_FILL,
|
|
205
206
|
LEAP
|
|
206
207
|
),
|
|
207
208
|
title: displayUrl(app.appUrl),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AppDetails.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/AppDetails.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport {\n AppWindow,\n CalendarPlus,\n Clock,\n ExternalLink,\n Plus,\n Trash2,\n} from 'lucide-react'\nimport React from 'react'\nimport { useHotkeys } from 'react-hotkeys-hook'\nimport { cn } from '~/lib/utils'\nimport { Badge } from '~/ui/badge'\nimport { Button } from '~/ui/button'\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from '~/ui/accordion'\nimport { AccessRequestSection } from '../components/AccessRequestSection'\nimport { AccessPrerequisiteChain } from '../components/AccessPrerequisiteChain'\nimport { LEAP, QuickJumpBar } from '../components/QuickJumpBar'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { PersonBadge } from '../components/PersonBadge'\nimport { useUser } from '~/modules/auth'\nimport { InlineEditableField } from '../components/InlineEditableField'\nimport { MarkdownText } from '../components/MarkdownText'\nimport { ScreenshotGallery } from '../components/ScreenshotGallery'\nimport { useUpdateApp } from '../../hooks/useUpdateApp'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\nimport { TierVariantsSection } from '../components/TierVariantsSection'\nimport { SubResourcesSection } from '../components/SubResourcesSection'\nimport { getChildResources } from '../../utils/resolveHelpers'\nimport { CommentsSection } from './CommentsSection'\nimport { displayUrl } from '~/modules/appCatalog/utils/displayUrl'\n\nfunction getIconUrl(iconName: string): string {\n return `/api/icons/${iconName}`\n}\n\nfunction AppIcon({ app, className }: { app: Resource; className?: string }) {\n const [imageError, setImageError] = React.useState(false)\n\n // Use iconName from backend if available\n if (app.iconName && !imageError) {\n return (\n <div className={cn('size-12 shrink-0', className)}>\n <img\n src={getIconUrl(app.iconName)}\n alt={`${app.abbreviation || app.displayName} icon`}\n className=\"size-12 rounded-lg object-contain\"\n onError={() => setImageError(true)}\n />\n </div>\n )\n }\n\n // Fallback icon\n return (\n <div\n className={cn(\n 'flex items-center justify-center rounded-lg bg-primary/10 text-primary size-12 shrink-0',\n className,\n )}\n >\n <AppWindow className=\"size-6\" />\n </div>\n )\n}\n\nfunction AppScreenshot({ app }: { app: Resource }) {\n const [imageError, setImageError] = React.useState(false)\n const [isLoadingImage, setIsLoadingImage] = React.useState(true)\n\n // Check if app has screenshots\n const screenshotId = app.screenshotIds?.[0]\n if (!screenshotId) {\n return (\n <div className=\"w-full bg-muted/50 rounded-lg overflow-hidden flex items-center justify-center min-h-64\">\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n No screenshot available\n </div>\n </div>\n )\n }\n\n const screenshotImageUrl = `/api/screenshots/${screenshotId}?size=512`\n\n return (\n <div className=\"w-full flex justify-center\">\n <div className=\"rounded-lg overflow-hidden inline-flex items-center justify-center min-h-64\">\n {!imageError ? (\n <img\n src={screenshotImageUrl}\n alt={`${app.abbreviation || app.displayName} screenshot`}\n className=\"h-64 object-contain\"\n onError={() => {\n setImageError(true)\n setIsLoadingImage(false)\n }}\n onLoad={() => setIsLoadingImage(false)}\n />\n ) : null}\n {(imageError || isLoadingImage) && (\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n {isLoadingImage\n ? 'Loading screenshot...'\n : 'No screenshot available'}\n </div>\n )}\n </div>\n </div>\n )\n}\n\nfunction TiersAndSubResourcesPanel({ app }: { app: Resource }) {\n const { resources } = useAppCatalogContext()\n // #38 item C: the active catalog search term. When the user reached this app\n // by searching something that matched a child, seed the sub-resource filter\n // with it so the matched child is revealed.\n const { state: filterState } = useAppCatalogFilters()\n const appSubResources = React.useMemo(\n () => getChildResources(resources, app.slug),\n [resources, app.slug],\n )\n\n return (\n <>\n {app.tiers && app.tiers.length > 0 && (\n <div className=\"mt-6\">\n <TierVariantsSection tiers={app.tiers} />\n </div>\n )}\n {appSubResources.length > 0 && (\n <div className=\"mt-6\">\n <SubResourcesSection\n subResources={appSubResources}\n parentSlug={app.slug}\n initialSearch={filterState.searchValue}\n />\n </div>\n )}\n </>\n )\n}\n\nexport function AppDetails({\n app,\n onAppClick,\n onClosePanel,\n}: {\n app: Resource\n onAppClick?: (app: Resource) => void\n onClosePanel: () => void\n}) {\n const [isGalleryOpen, setIsGalleryOpen] = React.useState(false)\n const [galleryInitialIndex, setGalleryInitialIndex] = React.useState(0)\n const { approvalMethods, resources: allResources } = useAppCatalogContext()\n const { recordClick } = useAppClickHistory()\n const updateApp = useUpdateApp()\n const [draftSource, setDraftSource] = React.useState<string | null>(null)\n const user = useUser()\n const isAdmin = user?.isAdmin ?? false\n\n const sourceUrls: string[] =\n app.sources?.map((s) => (typeof s === 'string' ? s : s.url)) ?? []\n const displaySources =\n draftSource !== null ? [...sourceUrls, draftSource] : sourceUrls\n\n // Enter: open screenshot gallery\n useHotkeys(\n 'enter',\n () => {\n const tag = document.activeElement?.tagName\n if (\n tag === 'BUTTON' ||\n tag === 'A' ||\n tag === 'INPUT' ||\n tag === 'SELECT' ||\n tag === 'TEXTAREA'\n )\n return\n\n if (app.screenshotIds && app.screenshotIds.length > 0) {\n setGalleryInitialIndex(0)\n setIsGalleryOpen(true)\n }\n },\n { enabled: !isGalleryOpen },\n [app, isGalleryOpen],\n )\n\n // Esc: close the details panel (only when gallery is NOT open)\n useHotkeys(\n 'escape',\n () => {\n onClosePanel()\n },\n { enabled: !isGalleryOpen },\n [isGalleryOpen, onClosePanel],\n )\n\n const handleScreenshotClick = (index: number) => {\n setGalleryInitialIndex(index)\n setIsGalleryOpen(true)\n }\n\n // Find replacement app if deprecated\n const replacementApp = app.deprecated?.replacementSlug\n ? allResources.find((a) => a.slug === app.deprecated?.replacementSlug)\n : null\n\n return (\n <>\n <div className=\"flex h-full flex-col p-6\">\n {/* Icon and Title — always first: a pinned Quick Jump takes order -1,\n so the header claims -2 rather than being pushed below it. */}\n <div className=\"-order-2 border-b pb-6\">\n <div className=\"flex items-center gap-3\">\n <AppIcon app={app} className=\"size-16\" />\n <div className=\"-mx-3 flex-1 min-w-0\">\n <div className=\"flex items-center gap-2 px-3\">\n <div className=\"font-serif text-2xl font-semibold min-w-0\">\n {app.abbreviation\n ? `${app.displayName} (${app.abbreviation})`\n : app.displayName}\n </div>\n {app.deprecated && (\n <Badge\n variant={\n app.deprecated.type === 'discouraged'\n ? 'secondary'\n : 'destructive'\n }\n >\n {app.deprecated.type === 'discouraged'\n ? 'Discouraged'\n : 'Deprecated'}\n </Badge>\n )}\n </div>\n {isAdmin && (\n <div className=\"mt-1 px-3\">\n <span className=\"text-xs text-muted-foreground mr-2\">\n Slug:\n </span>\n <InlineEditableField\n value={app.slug}\n onSave={(slug) =>\n updateApp.mutate({ id: app.id, data: { slug } })\n }\n className=\"text-sm\"\n />\n </div>\n )}\n {/* Open button, then Quick Jump: the host you reach by pressing\n it, and the deep link you reach by pasting an id. */}\n <div className=\"mt-3 flex flex-wrap items-stretch gap-2 px-3\">\n {isAdmin ? (\n <InlineEditableField\n value={app.appUrl ?? ''}\n onSave={(appUrl) =>\n updateApp.mutate({ id: app.id, data: { appUrl } })\n }\n placeholder=\"App URL\"\n renderView={(url) =>\n url ? (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n onClick={() => recordClick(app.slug)}\n className=\"inline-flex items-center gap-1 rounded-md py-1 text-sm text-blue-600 hover:bg-accent/30 hover:underline dark:text-blue-400 transition-all\"\n >\n {displayUrl(url)}\n <ExternalLink className=\"size-3.5 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\n }\n />\n ) : app.appUrl ? (\n <a\n href={app.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n onClick={() => recordClick(app.slug)}\n className={cn(\n 'inline-flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90',\n LEAP,\n )}\n title={displayUrl(app.appUrl)}\n aria-label={`Open ${app.displayName} in a new tab (${displayUrl(app.appUrl)})`}\n >\n {/* \"Go to <host>\": the verb makes it a thing you press and\n the host says where to. The glyph adds \"in a new tab\". */}\n <span className=\"max-w-[280px] truncate whitespace-nowrap\">\n Go to {displayUrl(app.appUrl)}\n </span>\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\n <QuickJumpBar app={app} />\n </div>\n {/* Updated/Added metadata moved to consolidated section before Sources (#55) */}\n </div>\n </div>\n </div>\n\n {/* Deprecation/Discouraged Warning */}\n {app.deprecated &&\n (() => {\n const deprecationType = app.deprecated.type || 'deprecated'\n const isDiscouraged = deprecationType === 'discouraged'\n return (\n <div\n className={\n isDiscouraged\n ? 'mt-6 p-4 border border-yellow-500/50 rounded-lg bg-yellow-50 dark:bg-yellow-950/20'\n : 'mt-6 p-4 border border-destructive/50 rounded-lg bg-destructive/10'\n }\n >\n <h3\n className={\n isDiscouraged\n ? 'text-sm font-semibold text-yellow-700 dark:text-yellow-500 mb-2'\n : 'text-sm font-semibold text-destructive mb-2'\n }\n >\n {isDiscouraged\n ? 'Usage discouraged'\n : 'This application is deprecated'}\n </h3>\n <p className=\"text-sm text-muted-foreground mb-3\">\n {app.deprecated.comment}\n </p>\n {replacementApp && (\n <button\n type=\"button\"\n onClick={() => onAppClick?.(replacementApp)}\n className=\"text-sm font-medium text-primary hover:underline inline-flex items-center gap-1\"\n >\n View replacement: {replacementApp.displayName}\n <ExternalLink className=\"size-3\" />\n </button>\n )}\n </div>\n )\n })()}\n\n {/* Description */}\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Description</h3>\n {isAdmin ? (\n <InlineEditableField\n value={app.description ?? ''}\n onSave={(description) =>\n updateApp.mutate({ id: app.id, data: { description } })\n }\n multiline\n placeholder=\"Description\"\n className=\"min-h-[4rem] resize-y text-sm text-muted-foreground\"\n />\n ) : app.description ? (\n <MarkdownText className=\"prose prose-sm max-w-none text-sm text-muted-foreground [&_p]:m-0\">\n {app.description}\n </MarkdownText>\n ) : (\n <p className=\"text-sm text-muted-foreground\">—</p>\n )}\n </div>\n\n {/* Two-step access (#38 D): for a nested resource, show the parent-first\n prerequisite chain before this resource's own access instructions. */}\n <AccessPrerequisiteChain resource={app} onOpenParent={onAppClick} />\n\n {/* Access Request Section — what the app is comes first, then how to\n get into it. */}\n <AccessRequestSection app={app} approvalMethods={approvalMethods} />\n\n {/* Screenshots - Clickable preview */}\n {app.screenshotIds && app.screenshotIds.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">\n Screenshots ({app.screenshotIds.length})\n </h3>\n {/* A button, not a clickable div: the gallery was unreachable by\n keyboard, and a role + label gives it a stable handle. */}\n <button\n type=\"button\"\n aria-label={`View screenshots of ${app.displayName}`}\n className=\"block w-full cursor-pointer text-left hover:opacity-80 transition-opacity\"\n onClick={() => handleScreenshotClick(0)}\n >\n <AppScreenshot app={app} />\n {app.screenshotIds.length > 1 && (\n <p className=\"text-xs text-muted-foreground mt-2 text-center\">\n Click to view all {app.screenshotIds.length} screenshots\n </p>\n )}\n </button>\n </div>\n )}\n\n {/* Owner — who is responsible for this resource. Distinct from the\n access approver (who decides access requests); see domain model. */}\n {app.ownerPersonSlug && (\n <div className=\"mt-6\">\n <h3 className=\"mb-1 text-sm font-medium\">Owner</h3>\n <p className=\"mb-2 text-xs text-muted-foreground\">\n Who is responsible for this resource\n </p>\n <PersonBadge slug={app.ownerPersonSlug} />\n </div>\n )}\n\n {/* Tier Variants and Sub-Resources */}\n <TiersAndSubResourcesPanel app={app} />\n\n {/* Links */}\n {app.links && app.links.length > 0 && (\n <div className=\"mt-4\">\n <h3 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n Links\n </h3>\n <div className=\"space-y-0.5\">\n {app.links.map((link) => (\n <a\n key={link.url}\n href={link.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-1 text-xs text-muted-foreground hover:text-primary truncate\"\n >\n <ExternalLink className=\"size-3 shrink-0\" />\n {link.title || displayUrl(link.url)}\n </a>\n ))}\n </div>\n </div>\n )}\n\n {/* Tags */}\n {app.tags && app.tags.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Tags</h3>\n <div className=\"flex flex-wrap gap-2\">\n {app.tags.map((tag) => (\n <Badge key={tag} variant=\"secondary\" className=\"text-xs\">\n {tag}\n </Badge>\n ))}\n </div>\n </div>\n )}\n\n {/* Teams */}\n {app.teams && app.teams.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Teams</h3>\n <div className=\"flex flex-wrap gap-2\">\n {app.teams.map((team) => (\n <Badge key={team} variant=\"outline\" className=\"text-xs\">\n {team}\n </Badge>\n ))}\n </div>\n </div>\n )}\n\n {/* Technical information: AI-facing fields, de-emphasized/collapsed */}\n {(app.aiPrompt || app.aiMemory) && (\n <Accordion type=\"single\" collapsible className=\"mt-6\">\n <AccordionItem\n value=\"technical-info\"\n className=\"border rounded-lg px-4\"\n >\n <AccordionTrigger className=\"text-sm hover:no-underline py-3\">\n Technical information\n </AccordionTrigger>\n <AccordionContent className=\"pb-3 space-y-3\">\n {app.aiPrompt && (\n <div>\n <h4 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n AI Prompt\n </h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiPrompt}\n </p>\n </div>\n )}\n {app.aiMemory && (\n <div>\n <h4 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n AI Memory\n </h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiMemory}\n </p>\n </div>\n )}\n </AccordionContent>\n </AccordionItem>\n </Accordion>\n )}\n\n {/* Metadata: Added / Updated */}\n {(app.createdAt || app.freshness?.lastCheckedAt) && (\n <div className=\"mt-6 flex flex-wrap gap-x-5 gap-y-1\">\n {app.createdAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={app.createdAt}\n >\n <CalendarPlus className=\"size-3\" />\n Added {formatRelativeTime(app.createdAt)}\n </span>\n )}\n {app.freshness?.lastCheckedAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={\n app.freshness.lastContentChangeAt\n ? `Content last changed ${app.freshness.lastContentChangeAt} · last checked ${app.freshness.lastCheckedAt}`\n : `Last checked ${app.freshness.lastCheckedAt}`\n }\n >\n <Clock className=\"size-3\" />\n {/* \"Updated\" means the content changed, not that a check ran -\n entries predating the field have none, so fall back. */}\n Updated{' '}\n {formatRelativeTime(\n app.freshness.lastContentChangeAt ??\n app.freshness.lastCheckedAt,\n )}\n {app.freshness.isStale && (\n <span className=\"italic opacity-75\">\n {' '}\n · may be out of date\n </span>\n )}\n </span>\n )}\n </div>\n )}\n\n {/* Sources */}\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Sources</h3>\n {isAdmin ? (\n <>\n <ul className=\"space-y-2\">\n {displaySources.map((url, index) => {\n const isDraft =\n draftSource !== null && index === sourceUrls.length\n return (\n <li\n key={isDraft ? 'draft' : `${index}-${url}`}\n className=\"flex items-center gap-2 text-xs\"\n >\n <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n {index + 1}.\n </span>\n <InlineEditableField\n value={url}\n initialEditMode={isDraft}\n onCancel={\n isDraft ? () => setDraftSource(null) : undefined\n }\n onSave={(newUrl) => {\n if (isDraft) {\n setDraftSource(null)\n if (newUrl) {\n updateApp.mutate({\n id: app.id,\n data: { sources: [...sourceUrls, newUrl] },\n })\n }\n } else {\n const next = [...sourceUrls]\n next[index] = newUrl\n updateApp.mutate({\n id: app.id,\n data: { sources: next.filter(Boolean) },\n })\n }\n }}\n placeholder=\"https://...\"\n viewClassName=\"flex-1 min-w-0\"\n renderView={(val) =>\n val ? (\n <a\n href={val}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex items-center gap-1 truncate\"\n >\n {displayUrl(val)}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\n }\n />\n {!isDraft && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label=\"Remove source\"\n className=\"shrink-0 text-muted-foreground hover:text-destructive\"\n onClick={() => {\n const next = sourceUrls.filter(\n (_, i) => i !== index,\n )\n updateApp.mutate({\n id: app.id,\n data: { sources: next },\n })\n }}\n >\n <Trash2 className=\"size-3.5\" />\n </Button>\n )}\n </li>\n )\n })}\n </ul>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"mt-2 gap-1 text-muted-foreground\"\n onClick={() => setDraftSource('')}\n >\n <Plus className=\"size-3.5\" />\n Add source\n </Button>\n </>\n ) : (\n <ul className=\"space-y-2\">\n {sourceUrls.map((url, index) => (\n <li key={index} className=\"flex items-center gap-2 text-xs\">\n <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n {index + 1}.\n </span>\n {url ? (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex items-center gap-1 truncate\"\n >\n {displayUrl(url)}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\n </li>\n ))}\n </ul>\n )}\n </div>\n\n {/* Freshness now shown near the header (see \"Updated …\" above). */}\n\n <CommentsSection appSlug={app.slug} />\n </div>\n\n {/* Screenshot Gallery Dialog */}\n <ScreenshotGallery\n app={app}\n screenshotIds={app.screenshotIds || []}\n open={isGalleryOpen}\n onOpenChange={setIsGalleryOpen}\n initialIndex={galleryInitialIndex}\n title={`${app.abbreviation || app.displayName} - Screenshots`}\n />\n </>\n )\n}\n"],"names":["React","_a"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AppDetails.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/AppDetails.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport {\n AppWindow,\n CalendarPlus,\n Clock,\n ExternalLink,\n Plus,\n Trash2,\n} from 'lucide-react'\nimport React from 'react'\nimport { useHotkeys } from 'react-hotkeys-hook'\nimport { cn } from '~/lib/utils'\nimport { Badge } from '~/ui/badge'\nimport { Button } from '~/ui/button'\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from '~/ui/accordion'\nimport { AccessRequestSection } from '../components/AccessRequestSection'\nimport { AccessPrerequisiteChain } from '../components/AccessPrerequisiteChain'\nimport { LEAP, LEAVE_FILL, QuickJumpBar } from '../components/QuickJumpBar'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { PersonBadge } from '../components/PersonBadge'\nimport { useUser } from '~/modules/auth'\nimport { InlineEditableField } from '../components/InlineEditableField'\nimport { MarkdownText } from '../components/MarkdownText'\nimport { ScreenshotGallery } from '../components/ScreenshotGallery'\nimport { useUpdateApp } from '../../hooks/useUpdateApp'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\nimport { TierVariantsSection } from '../components/TierVariantsSection'\nimport { SubResourcesSection } from '../components/SubResourcesSection'\nimport { getChildResources } from '../../utils/resolveHelpers'\nimport { CommentsSection } from './CommentsSection'\nimport { displayUrl } from '~/modules/appCatalog/utils/displayUrl'\n\nfunction getIconUrl(iconName: string): string {\n return `/api/icons/${iconName}`\n}\n\nfunction AppIcon({ app, className }: { app: Resource; className?: string }) {\n const [imageError, setImageError] = React.useState(false)\n\n // Use iconName from backend if available\n if (app.iconName && !imageError) {\n return (\n <div className={cn('size-12 shrink-0', className)}>\n <img\n src={getIconUrl(app.iconName)}\n alt={`${app.abbreviation || app.displayName} icon`}\n className=\"size-12 rounded-lg object-contain\"\n onError={() => setImageError(true)}\n />\n </div>\n )\n }\n\n // Fallback icon\n return (\n <div\n className={cn(\n 'flex items-center justify-center rounded-lg bg-primary/10 text-primary size-12 shrink-0',\n className,\n )}\n >\n <AppWindow className=\"size-6\" />\n </div>\n )\n}\n\nfunction AppScreenshot({ app }: { app: Resource }) {\n const [imageError, setImageError] = React.useState(false)\n const [isLoadingImage, setIsLoadingImage] = React.useState(true)\n\n // Check if app has screenshots\n const screenshotId = app.screenshotIds?.[0]\n if (!screenshotId) {\n return (\n <div className=\"w-full bg-muted/50 rounded-lg overflow-hidden flex items-center justify-center min-h-64\">\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n No screenshot available\n </div>\n </div>\n )\n }\n\n const screenshotImageUrl = `/api/screenshots/${screenshotId}?size=512`\n\n return (\n <div className=\"w-full flex justify-center\">\n <div className=\"rounded-lg overflow-hidden inline-flex items-center justify-center min-h-64\">\n {!imageError ? (\n <img\n src={screenshotImageUrl}\n alt={`${app.abbreviation || app.displayName} screenshot`}\n className=\"h-64 object-contain\"\n onError={() => {\n setImageError(true)\n setIsLoadingImage(false)\n }}\n onLoad={() => setIsLoadingImage(false)}\n />\n ) : null}\n {(imageError || isLoadingImage) && (\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n {isLoadingImage\n ? 'Loading screenshot...'\n : 'No screenshot available'}\n </div>\n )}\n </div>\n </div>\n )\n}\n\nfunction TiersAndSubResourcesPanel({ app }: { app: Resource }) {\n const { resources } = useAppCatalogContext()\n // #38 item C: the active catalog search term. When the user reached this app\n // by searching something that matched a child, seed the sub-resource filter\n // with it so the matched child is revealed.\n const { state: filterState } = useAppCatalogFilters()\n const appSubResources = React.useMemo(\n () => getChildResources(resources, app.slug),\n [resources, app.slug],\n )\n\n return (\n <>\n {app.tiers && app.tiers.length > 0 && (\n <div className=\"mt-6\">\n <TierVariantsSection tiers={app.tiers} />\n </div>\n )}\n {appSubResources.length > 0 && (\n <div className=\"mt-6\">\n <SubResourcesSection\n subResources={appSubResources}\n parentSlug={app.slug}\n initialSearch={filterState.searchValue}\n />\n </div>\n )}\n </>\n )\n}\n\nexport function AppDetails({\n app,\n onAppClick,\n onClosePanel,\n}: {\n app: Resource\n onAppClick?: (app: Resource) => void\n onClosePanel: () => void\n}) {\n const [isGalleryOpen, setIsGalleryOpen] = React.useState(false)\n const [galleryInitialIndex, setGalleryInitialIndex] = React.useState(0)\n const { approvalMethods, resources: allResources } = useAppCatalogContext()\n const { recordClick } = useAppClickHistory()\n const updateApp = useUpdateApp()\n const [draftSource, setDraftSource] = React.useState<string | null>(null)\n const user = useUser()\n const isAdmin = user?.isAdmin ?? false\n\n const sourceUrls: string[] =\n app.sources?.map((s) => (typeof s === 'string' ? s : s.url)) ?? []\n const displaySources =\n draftSource !== null ? [...sourceUrls, draftSource] : sourceUrls\n\n // Enter: open screenshot gallery\n useHotkeys(\n 'enter',\n () => {\n const tag = document.activeElement?.tagName\n if (\n tag === 'BUTTON' ||\n tag === 'A' ||\n tag === 'INPUT' ||\n tag === 'SELECT' ||\n tag === 'TEXTAREA'\n )\n return\n\n if (app.screenshotIds && app.screenshotIds.length > 0) {\n setGalleryInitialIndex(0)\n setIsGalleryOpen(true)\n }\n },\n { enabled: !isGalleryOpen },\n [app, isGalleryOpen],\n )\n\n // Esc: close the details panel (only when gallery is NOT open)\n useHotkeys(\n 'escape',\n () => {\n onClosePanel()\n },\n { enabled: !isGalleryOpen },\n [isGalleryOpen, onClosePanel],\n )\n\n const handleScreenshotClick = (index: number) => {\n setGalleryInitialIndex(index)\n setIsGalleryOpen(true)\n }\n\n // Find replacement app if deprecated\n const replacementApp = app.deprecated?.replacementSlug\n ? allResources.find((a) => a.slug === app.deprecated?.replacementSlug)\n : null\n\n return (\n <>\n <div className=\"flex h-full flex-col p-6\">\n {/* Icon and Title — always first: a pinned Quick Jump takes order -1,\n so the header claims -2 rather than being pushed below it. */}\n <div className=\"-order-2 border-b pb-6\">\n <div className=\"flex items-center gap-3\">\n <AppIcon app={app} className=\"size-16\" />\n <div className=\"-mx-3 flex-1 min-w-0\">\n <div className=\"flex items-center gap-2 px-3\">\n <div className=\"font-serif text-2xl font-semibold min-w-0\">\n {app.abbreviation\n ? `${app.displayName} (${app.abbreviation})`\n : app.displayName}\n </div>\n {app.deprecated && (\n <Badge\n variant={\n app.deprecated.type === 'discouraged'\n ? 'secondary'\n : 'destructive'\n }\n >\n {app.deprecated.type === 'discouraged'\n ? 'Discouraged'\n : 'Deprecated'}\n </Badge>\n )}\n </div>\n {isAdmin && (\n <div className=\"mt-1 px-3\">\n <span className=\"text-xs text-muted-foreground mr-2\">\n Slug:\n </span>\n <InlineEditableField\n value={app.slug}\n onSave={(slug) =>\n updateApp.mutate({ id: app.id, data: { slug } })\n }\n className=\"text-sm\"\n />\n </div>\n )}\n {/* Open button, then Quick Jump: the host you reach by pressing\n it, and the deep link you reach by pasting an id. */}\n <div className=\"mt-3 flex flex-wrap items-stretch gap-3 px-3\">\n {isAdmin ? (\n <InlineEditableField\n value={app.appUrl ?? ''}\n onSave={(appUrl) =>\n updateApp.mutate({ id: app.id, data: { appUrl } })\n }\n placeholder=\"App URL\"\n renderView={(url) =>\n url ? (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n onClick={() => recordClick(app.slug)}\n className=\"inline-flex items-center gap-1 rounded-md py-1 text-sm text-blue-600 hover:bg-accent/30 hover:underline dark:text-blue-400 transition-all\"\n >\n {displayUrl(url)}\n <ExternalLink className=\"size-3.5 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\n }\n />\n ) : app.appUrl ? (\n <a\n href={app.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n onClick={() => recordClick(app.slug)}\n className={cn(\n 'inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium shadow-sm',\n LEAVE_FILL,\n LEAP,\n )}\n title={displayUrl(app.appUrl)}\n aria-label={`Open ${app.displayName} in a new tab (${displayUrl(app.appUrl)})`}\n >\n {/* \"Go to <host>\": the verb makes it a thing you press and\n the host says where to. The glyph adds \"in a new tab\". */}\n <span className=\"max-w-[280px] truncate whitespace-nowrap\">\n Go to {displayUrl(app.appUrl)}\n </span>\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\n <QuickJumpBar app={app} />\n </div>\n {/* Updated/Added metadata moved to consolidated section before Sources (#55) */}\n </div>\n </div>\n </div>\n\n {/* Deprecation/Discouraged Warning */}\n {app.deprecated &&\n (() => {\n const deprecationType = app.deprecated.type || 'deprecated'\n const isDiscouraged = deprecationType === 'discouraged'\n return (\n <div\n className={\n isDiscouraged\n ? 'mt-6 p-4 border border-yellow-500/50 rounded-lg bg-yellow-50 dark:bg-yellow-950/20'\n : 'mt-6 p-4 border border-destructive/50 rounded-lg bg-destructive/10'\n }\n >\n <h3\n className={\n isDiscouraged\n ? 'text-sm font-semibold text-yellow-700 dark:text-yellow-500 mb-2'\n : 'text-sm font-semibold text-destructive mb-2'\n }\n >\n {isDiscouraged\n ? 'Usage discouraged'\n : 'This application is deprecated'}\n </h3>\n <p className=\"text-sm text-muted-foreground mb-3\">\n {app.deprecated.comment}\n </p>\n {replacementApp && (\n <button\n type=\"button\"\n onClick={() => onAppClick?.(replacementApp)}\n className=\"text-sm font-medium text-primary hover:underline inline-flex items-center gap-1\"\n >\n View replacement: {replacementApp.displayName}\n <ExternalLink className=\"size-3\" />\n </button>\n )}\n </div>\n )\n })()}\n\n {/* Description */}\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Description</h3>\n {isAdmin ? (\n <InlineEditableField\n value={app.description ?? ''}\n onSave={(description) =>\n updateApp.mutate({ id: app.id, data: { description } })\n }\n multiline\n placeholder=\"Description\"\n className=\"min-h-[4rem] resize-y text-sm text-muted-foreground\"\n />\n ) : app.description ? (\n <MarkdownText className=\"prose prose-sm max-w-none text-sm text-muted-foreground [&_p]:m-0\">\n {app.description}\n </MarkdownText>\n ) : (\n <p className=\"text-sm text-muted-foreground\">—</p>\n )}\n </div>\n\n {/* Two-step access (#38 D): for a nested resource, show the parent-first\n prerequisite chain before this resource's own access instructions. */}\n <AccessPrerequisiteChain resource={app} onOpenParent={onAppClick} />\n\n {/* Access Request Section — what the app is comes first, then how to\n get into it. */}\n <AccessRequestSection app={app} approvalMethods={approvalMethods} />\n\n {/* Screenshots - Clickable preview */}\n {app.screenshotIds && app.screenshotIds.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">\n Screenshots ({app.screenshotIds.length})\n </h3>\n {/* A button, not a clickable div: the gallery was unreachable by\n keyboard, and a role + label gives it a stable handle. */}\n <button\n type=\"button\"\n aria-label={`View screenshots of ${app.displayName}`}\n className=\"block w-full cursor-pointer text-left hover:opacity-80 transition-opacity\"\n onClick={() => handleScreenshotClick(0)}\n >\n <AppScreenshot app={app} />\n {app.screenshotIds.length > 1 && (\n <p className=\"text-xs text-muted-foreground mt-2 text-center\">\n Click to view all {app.screenshotIds.length} screenshots\n </p>\n )}\n </button>\n </div>\n )}\n\n {/* Owner — who is responsible for this resource. Distinct from the\n access approver (who decides access requests); see domain model. */}\n {app.ownerPersonSlug && (\n <div className=\"mt-6\">\n <h3 className=\"mb-1 text-sm font-medium\">Owner</h3>\n <p className=\"mb-2 text-xs text-muted-foreground\">\n Who is responsible for this resource\n </p>\n <PersonBadge slug={app.ownerPersonSlug} />\n </div>\n )}\n\n {/* Tier Variants and Sub-Resources */}\n <TiersAndSubResourcesPanel app={app} />\n\n {/* Links */}\n {app.links && app.links.length > 0 && (\n <div className=\"mt-4\">\n <h3 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n Links\n </h3>\n <div className=\"space-y-0.5\">\n {app.links.map((link) => (\n <a\n key={link.url}\n href={link.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-1 text-xs text-muted-foreground hover:text-primary truncate\"\n >\n <ExternalLink className=\"size-3 shrink-0\" />\n {link.title || displayUrl(link.url)}\n </a>\n ))}\n </div>\n </div>\n )}\n\n {/* Tags */}\n {app.tags && app.tags.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Tags</h3>\n <div className=\"flex flex-wrap gap-2\">\n {app.tags.map((tag) => (\n <Badge key={tag} variant=\"secondary\" className=\"text-xs\">\n {tag}\n </Badge>\n ))}\n </div>\n </div>\n )}\n\n {/* Teams */}\n {app.teams && app.teams.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Teams</h3>\n <div className=\"flex flex-wrap gap-2\">\n {app.teams.map((team) => (\n <Badge key={team} variant=\"outline\" className=\"text-xs\">\n {team}\n </Badge>\n ))}\n </div>\n </div>\n )}\n\n {/* Technical information: AI-facing fields, de-emphasized/collapsed */}\n {(app.aiPrompt || app.aiMemory) && (\n <Accordion type=\"single\" collapsible className=\"mt-6\">\n <AccordionItem\n value=\"technical-info\"\n className=\"border rounded-lg px-4\"\n >\n <AccordionTrigger className=\"text-sm hover:no-underline py-3\">\n Technical information\n </AccordionTrigger>\n <AccordionContent className=\"pb-3 space-y-3\">\n {app.aiPrompt && (\n <div>\n <h4 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n AI Prompt\n </h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiPrompt}\n </p>\n </div>\n )}\n {app.aiMemory && (\n <div>\n <h4 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n AI Memory\n </h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiMemory}\n </p>\n </div>\n )}\n </AccordionContent>\n </AccordionItem>\n </Accordion>\n )}\n\n {/* Metadata: Added / Updated */}\n {(app.createdAt || app.freshness?.lastCheckedAt) && (\n <div className=\"mt-6 flex flex-wrap gap-x-5 gap-y-1\">\n {app.createdAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={app.createdAt}\n >\n <CalendarPlus className=\"size-3\" />\n Added {formatRelativeTime(app.createdAt)}\n </span>\n )}\n {app.freshness?.lastCheckedAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={\n app.freshness.lastContentChangeAt\n ? `Content last changed ${app.freshness.lastContentChangeAt} · last checked ${app.freshness.lastCheckedAt}`\n : `Last checked ${app.freshness.lastCheckedAt}`\n }\n >\n <Clock className=\"size-3\" />\n {/* \"Updated\" means the content changed, not that a check ran -\n entries predating the field have none, so fall back. */}\n Updated{' '}\n {formatRelativeTime(\n app.freshness.lastContentChangeAt ??\n app.freshness.lastCheckedAt,\n )}\n {app.freshness.isStale && (\n <span className=\"italic opacity-75\">\n {' '}\n · may be out of date\n </span>\n )}\n </span>\n )}\n </div>\n )}\n\n {/* Sources */}\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Sources</h3>\n {isAdmin ? (\n <>\n <ul className=\"space-y-2\">\n {displaySources.map((url, index) => {\n const isDraft =\n draftSource !== null && index === sourceUrls.length\n return (\n <li\n key={isDraft ? 'draft' : `${index}-${url}`}\n className=\"flex items-center gap-2 text-xs\"\n >\n <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n {index + 1}.\n </span>\n <InlineEditableField\n value={url}\n initialEditMode={isDraft}\n onCancel={\n isDraft ? () => setDraftSource(null) : undefined\n }\n onSave={(newUrl) => {\n if (isDraft) {\n setDraftSource(null)\n if (newUrl) {\n updateApp.mutate({\n id: app.id,\n data: { sources: [...sourceUrls, newUrl] },\n })\n }\n } else {\n const next = [...sourceUrls]\n next[index] = newUrl\n updateApp.mutate({\n id: app.id,\n data: { sources: next.filter(Boolean) },\n })\n }\n }}\n placeholder=\"https://...\"\n viewClassName=\"flex-1 min-w-0\"\n renderView={(val) =>\n val ? (\n <a\n href={val}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex items-center gap-1 truncate\"\n >\n {displayUrl(val)}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\n }\n />\n {!isDraft && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label=\"Remove source\"\n className=\"shrink-0 text-muted-foreground hover:text-destructive\"\n onClick={() => {\n const next = sourceUrls.filter(\n (_, i) => i !== index,\n )\n updateApp.mutate({\n id: app.id,\n data: { sources: next },\n })\n }}\n >\n <Trash2 className=\"size-3.5\" />\n </Button>\n )}\n </li>\n )\n })}\n </ul>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"mt-2 gap-1 text-muted-foreground\"\n onClick={() => setDraftSource('')}\n >\n <Plus className=\"size-3.5\" />\n Add source\n </Button>\n </>\n ) : (\n <ul className=\"space-y-2\">\n {sourceUrls.map((url, index) => (\n <li key={index} className=\"flex items-center gap-2 text-xs\">\n <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n {index + 1}.\n </span>\n {url ? (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex items-center gap-1 truncate\"\n >\n {displayUrl(url)}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\n </li>\n ))}\n </ul>\n )}\n </div>\n\n {/* Freshness now shown near the header (see \"Updated …\" above). */}\n\n <CommentsSection appSlug={app.slug} />\n </div>\n\n {/* Screenshot Gallery Dialog */}\n <ScreenshotGallery\n app={app}\n screenshotIds={app.screenshotIds || []}\n open={isGalleryOpen}\n onOpenChange={setIsGalleryOpen}\n initialIndex={galleryInitialIndex}\n title={`${app.abbreviation || app.displayName} - Screenshots`}\n />\n </>\n )\n}\n"],"names":["React","_a"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-frontend-core",
|
|
3
|
-
"version": "4.0.0-alpha-
|
|
3
|
+
"version": "4.0.0-alpha-20260920000636",
|
|
4
4
|
"description": "Frontend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
"tailwindcss": "^4.1.7",
|
|
114
114
|
"tiptap-markdown": "^0.8.0",
|
|
115
115
|
"tslib": "^2.3.0",
|
|
116
|
-
"@igstack/app-catalog-shared-core": "4.0.0-alpha-
|
|
116
|
+
"@igstack/app-catalog-shared-core": "4.0.0-alpha-20260920000636"
|
|
117
117
|
},
|
|
118
118
|
"devDependencies": {
|
|
119
119
|
"@tailwindcss/vite": "^4.0.5",
|
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
"vite-plugin-static-copy": "^3.1.4",
|
|
141
141
|
"vite-plugin-svgr": "^4.2.0",
|
|
142
142
|
"vitest": "^4.1.2",
|
|
143
|
-
"@igstack/app-catalog-backend-core": "4.0.0-alpha-
|
|
143
|
+
"@igstack/app-catalog-backend-core": "4.0.0-alpha-20260920000636"
|
|
144
144
|
},
|
|
145
145
|
"peerDependencies": {
|
|
146
146
|
"react": "19.1.2",
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
"vite": "^6.3.5",
|
|
150
150
|
"vite-plugin-svgr": "^4.2.0"
|
|
151
151
|
},
|
|
152
|
-
"gitHead": "
|
|
152
|
+
"gitHead": "dbf82b07f2c0888cbe605f24eac4dc29c47d060e",
|
|
153
153
|
"scripts": {
|
|
154
154
|
"build": "vite build",
|
|
155
155
|
"build:lenient": "vite build --mode lenient",
|
|
@@ -20,29 +20,62 @@ import {
|
|
|
20
20
|
DropdownMenuTrigger,
|
|
21
21
|
} from '~/ui/dropdown-menu'
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The destination picker. Fixed width on a desktop row, so swapping destination
|
|
25
|
+
* cannot slide the field and Jump sideways; its own full-width line below `sm`,
|
|
26
|
+
* where the three segments together do not fit in a phone's panel at all. It
|
|
27
|
+
* carries the short title — the full one is in the menu, where you are choosing
|
|
28
|
+
* between systems. Longer shorts still ellipsis here.
|
|
29
|
+
*/
|
|
30
|
+
const PICKER = [
|
|
31
|
+
'inline-flex w-full shrink-0 items-center gap-1.5 rounded-t-lg border-b border-border bg-muted px-2',
|
|
32
|
+
'text-left text-[0.8125rem] font-semibold whitespace-nowrap',
|
|
33
|
+
'sm:w-44 sm:rounded-t-none sm:rounded-l-lg sm:border-b-0',
|
|
34
|
+
].join(' ')
|
|
35
|
+
|
|
36
|
+
/** One focus ring per focusable segment, or tabbing the row never says where
|
|
37
|
+
you are: the shell's ring belongs to the field (below), these to the ends. */
|
|
38
|
+
const SEGMENT_FOCUS =
|
|
39
|
+
'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring'
|
|
40
|
+
|
|
41
|
+
/** Shared by Jump and by the app's own "Go to <host>" button. */
|
|
42
|
+
const JUMP = [
|
|
43
|
+
'inline-flex shrink-0 items-center gap-1.5 rounded-br-lg border-l border-border px-3',
|
|
44
|
+
'text-[0.8125rem] font-bold whitespace-nowrap sm:rounded-br-none sm:rounded-r-lg',
|
|
45
|
+
SEGMENT_FOCUS,
|
|
46
|
+
].join(' ')
|
|
28
47
|
|
|
29
48
|
/**
|
|
30
49
|
* The leap, as env-hopper does it: the button lifts onto its toes on `:hover`
|
|
31
50
|
* and crouches on `:active`. Deliberately NOT an on-click animation — these
|
|
32
51
|
* links open a new tab, which takes focus the same instant, so a keyframe fired
|
|
33
52
|
* on click runs in a backgrounded tab and the user only sees it on the way back.
|
|
34
|
-
*
|
|
53
|
+
*
|
|
54
|
+
* The transition has to name `translate` and `scale`: in Tailwind v4 those
|
|
55
|
+
* utilities set those separate properties, which a `transform` transition never
|
|
56
|
+
* sees.
|
|
35
57
|
*/
|
|
36
58
|
export const LEAP =
|
|
37
|
-
'origin-bottom transition-
|
|
59
|
+
'origin-bottom transition-[background-color,translate,scale] duration-100 ease-out motion-safe:hover:-translate-y-[1.5px] motion-safe:active:translate-y-px motion-safe:active:scale-y-[0.92]'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The brand fill for the two buttons that leave for the app. Darkened, because
|
|
63
|
+
* `--primary` under `--primary-foreground` measures 3.8:1 — below WCAG AA, and
|
|
64
|
+
* the conventional `bg-primary/90` hover made it worse by fading the fill
|
|
65
|
+
* toward the card instead of darkening it.
|
|
66
|
+
*
|
|
67
|
+
* A token-level fix would be better and is a brand decision, not this
|
|
68
|
+
* component's: every filled button in the app inherits the same ratio.
|
|
69
|
+
*/
|
|
70
|
+
export const LEAVE_FILL = [
|
|
71
|
+
'bg-[color-mix(in_oklab,var(--color-primary)_90%,black)] text-primary-foreground',
|
|
72
|
+
'hover:bg-[color-mix(in_oklab,var(--color-primary)_80%,black)]',
|
|
73
|
+
].join(' ')
|
|
38
74
|
|
|
39
75
|
/**
|
|
40
76
|
* Quick Jump: `[ destination ▾ | id | Jump ]`, one control sitting next to the
|
|
41
77
|
* app's own open button. The picker only names and swaps the destination; Jump
|
|
42
78
|
* is the sole thing that navigates. Renders nothing for an app with no jumps.
|
|
43
|
-
*
|
|
44
|
-
* Returns a fragment — a hairline separator plus the control — so it drops into
|
|
45
|
-
* the header's flex row beside the open button.
|
|
46
79
|
*/
|
|
47
80
|
export function QuickJumpBar({ app }: { app: Resource }) {
|
|
48
81
|
const identities = React.useMemo(() => groupQuickJumps(app), [app])
|
|
@@ -89,156 +122,155 @@ export function QuickJumpBar({ app }: { app: Resource }) {
|
|
|
89
122
|
Math.max(...jumps.map((jump) => jump.identity.length), 8) + 8
|
|
90
123
|
|
|
91
124
|
return (
|
|
92
|
-
|
|
93
|
-
<
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
{quickJumpShortTitle(selected)}
|
|
122
|
-
</span>
|
|
123
|
-
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
|
124
|
-
</DropdownMenuTrigger>
|
|
125
|
-
{/* Portalled and sized to its content: the trigger ellipsises, the
|
|
125
|
+
<div className="relative flex w-full sm:w-auto">
|
|
126
|
+
<div
|
|
127
|
+
className={cn(
|
|
128
|
+
'group peer flex w-full flex-wrap items-stretch rounded-lg border border-input bg-card shadow-[inset_0_1px_2px_rgb(0_0_0/0.09)]',
|
|
129
|
+
// The ring is the FIELD's, not the shell's: the picker and Jump are
|
|
130
|
+
// focusable too and ringing the shell for all three says nothing.
|
|
131
|
+
'has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-ring/25',
|
|
132
|
+
'sm:w-auto sm:flex-nowrap',
|
|
133
|
+
)}
|
|
134
|
+
>
|
|
135
|
+
{jumps.length > 1 ? (
|
|
136
|
+
<DropdownMenu>
|
|
137
|
+
<DropdownMenuTrigger
|
|
138
|
+
title={`${selected.title} — choose a destination`}
|
|
139
|
+
className={cn(
|
|
140
|
+
PICKER,
|
|
141
|
+
SEGMENT_FOCUS,
|
|
142
|
+
'cursor-pointer transition-colors hover:bg-accent',
|
|
143
|
+
)}
|
|
144
|
+
>
|
|
145
|
+
{/* Caret immediately after the label, not shoved to the far edge:
|
|
146
|
+
out there it reads as the field's boundary and leaves the
|
|
147
|
+
picker looking like a wide empty select. */}
|
|
148
|
+
<span className="min-w-0 truncate">
|
|
149
|
+
{quickJumpShortTitle(selected)}
|
|
150
|
+
</span>
|
|
151
|
+
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
|
152
|
+
</DropdownMenuTrigger>
|
|
153
|
+
{/* Portalled and sized to its content: the trigger ellipsises, the
|
|
126
154
|
menu never does. */}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
>
|
|
131
|
-
<DropdownMenuRadioGroup
|
|
132
|
-
value={quickJumpSlug(selected)}
|
|
133
|
-
onValueChange={setSlug}
|
|
134
|
-
>
|
|
135
|
-
{identities.map((group) => (
|
|
136
|
-
<React.Fragment key={group.identity}>
|
|
137
|
-
<DropdownMenuLabel className="text-[0.65rem] font-extrabold tracking-wider text-muted-foreground uppercase">
|
|
138
|
-
{group.identity}
|
|
139
|
-
</DropdownMenuLabel>
|
|
140
|
-
{group.jumps.map((jump) => (
|
|
141
|
-
<DropdownMenuRadioItem
|
|
142
|
-
key={quickJumpSlug(jump)}
|
|
143
|
-
value={quickJumpSlug(jump)}
|
|
144
|
-
className="whitespace-nowrap"
|
|
145
|
-
>
|
|
146
|
-
{jump.title}
|
|
147
|
-
</DropdownMenuRadioItem>
|
|
148
|
-
))}
|
|
149
|
-
</React.Fragment>
|
|
150
|
-
))}
|
|
151
|
-
</DropdownMenuRadioGroup>
|
|
152
|
-
</DropdownMenuContent>
|
|
153
|
-
</DropdownMenu>
|
|
154
|
-
) : (
|
|
155
|
-
<span
|
|
156
|
-
title={selected.title}
|
|
157
|
-
className={cn(PICKER, PICKER_TEXT, 'truncate')}
|
|
155
|
+
<DropdownMenuContent
|
|
156
|
+
align="start"
|
|
157
|
+
className="max-w-[min(90vw,34rem)]"
|
|
158
158
|
>
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
<DropdownMenuRadioGroup
|
|
160
|
+
value={quickJumpSlug(selected)}
|
|
161
|
+
onValueChange={setSlug}
|
|
162
|
+
>
|
|
163
|
+
{identities.map((group) => (
|
|
164
|
+
<React.Fragment key={group.identity}>
|
|
165
|
+
<DropdownMenuLabel className="text-[0.65rem] font-extrabold tracking-wider text-muted-foreground uppercase">
|
|
166
|
+
{group.identity}
|
|
167
|
+
</DropdownMenuLabel>
|
|
168
|
+
{group.jumps.map((jump) => (
|
|
169
|
+
<DropdownMenuRadioItem
|
|
170
|
+
key={quickJumpSlug(jump)}
|
|
171
|
+
value={quickJumpSlug(jump)}
|
|
172
|
+
className="whitespace-nowrap"
|
|
173
|
+
>
|
|
174
|
+
{jump.title}
|
|
175
|
+
</DropdownMenuRadioItem>
|
|
176
|
+
))}
|
|
177
|
+
</React.Fragment>
|
|
178
|
+
))}
|
|
179
|
+
</DropdownMenuRadioGroup>
|
|
180
|
+
</DropdownMenuContent>
|
|
181
|
+
</DropdownMenu>
|
|
182
|
+
) : (
|
|
183
|
+
<span title={selected.title} className={cn(PICKER, 'truncate')}>
|
|
184
|
+
{quickJumpShortTitle(selected)}
|
|
185
|
+
</span>
|
|
186
|
+
)}
|
|
162
187
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
188
|
+
<input
|
|
189
|
+
ref={inputRef}
|
|
190
|
+
type="text"
|
|
191
|
+
size={fieldChars}
|
|
192
|
+
// Namespaced per identity: the browser remembers values typed for this
|
|
193
|
+
// identifier and offers them on every app that asks for it.
|
|
194
|
+
name={quickJumpFieldName(selected.identity)}
|
|
195
|
+
autoComplete="on"
|
|
196
|
+
aria-label={selected.identity}
|
|
197
|
+
placeholder={`Enter ${selected.identity}`}
|
|
198
|
+
value={value}
|
|
199
|
+
onChange={(e) => setValue(e.target.value)}
|
|
200
|
+
// Selecting on focus rather than on click, or a second click inside
|
|
201
|
+
// could never place a caret.
|
|
202
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
203
|
+
onKeyDown={(e) => {
|
|
204
|
+
if (e.key !== 'Enter' || !href) return
|
|
205
|
+
window.open(href, '_blank', 'noopener,noreferrer')
|
|
206
|
+
}}
|
|
207
|
+
className={cn(
|
|
208
|
+
// Grows into the row's leftovers below `sm`, where it shares a line
|
|
209
|
+
// with Jump; its own fixed `size` from there up.
|
|
210
|
+
'min-w-0 flex-1 border-border bg-transparent px-2.5 py-1.5 font-mono text-[0.8125rem] font-medium outline-none placeholder:font-sans placeholder:text-muted-foreground',
|
|
211
|
+
'sm:flex-none sm:border-l',
|
|
212
|
+
// Hovering the dormant Jump lights up the field it is waiting on.
|
|
213
|
+
'transition-[box-shadow,background-color] duration-150',
|
|
214
|
+
'group-has-[.qj-dormant:hover]:bg-ring/10 group-has-[.qj-dormant:hover]:shadow-[inset_0_0_0_2px_var(--color-ring)]',
|
|
215
|
+
'group-has-[.qj-dormant:hover]:placeholder:text-foreground',
|
|
216
|
+
)}
|
|
217
|
+
/>
|
|
190
218
|
|
|
219
|
+
{/* A real <button> while dormant. An <a> without href is not focusable,
|
|
220
|
+
so the whole "press it and it tells you to type" behaviour — and the
|
|
221
|
+
hint's :focus-visible branch — would have been mouse-only. */}
|
|
222
|
+
{href ? (
|
|
191
223
|
<a
|
|
192
224
|
href={href}
|
|
193
225
|
target="_blank"
|
|
194
226
|
rel="noopener noreferrer"
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}}
|
|
227
|
+
// Title, not the url: the browser already puts the url in the
|
|
228
|
+
// status bar, and some of these are 250 characters long. It doubles
|
|
229
|
+
// as the full text when the picker has ellipsised it.
|
|
230
|
+
title={`Jump to ${selected.title}`}
|
|
231
|
+
className={cn(JUMP, LEAP, LEAVE_FILL, 'animate-button-pop')}
|
|
232
|
+
>
|
|
233
|
+
Jump
|
|
234
|
+
<ExternalLink className="size-3.5 shrink-0" />
|
|
235
|
+
</a>
|
|
236
|
+
) : (
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
title={`Enter a ${selected.identity} first`}
|
|
240
|
+
onClick={() => inputRef.current?.focus()}
|
|
241
|
+
// Tinted toward the action rather than greyed: on `bg-muted` it was
|
|
242
|
+
// the picker's twin, so the row read as two dropdowns around a
|
|
243
|
+
// field and nothing in it looked raised.
|
|
208
244
|
className={cn(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
// shell and the field hang their `has-*` variants off, which an
|
|
212
|
-
// attribute selector cannot be in a Tailwind variant.
|
|
213
|
-
href
|
|
214
|
-
? 'qj-armed animate-button-pop bg-primary text-primary-foreground hover:bg-primary/90'
|
|
215
|
-
: 'qj-dormant cursor-text bg-muted text-muted-foreground',
|
|
245
|
+
JUMP,
|
|
246
|
+
'qj-dormant cursor-text bg-primary/10 text-muted-foreground',
|
|
216
247
|
)}
|
|
217
248
|
>
|
|
218
249
|
Jump
|
|
219
250
|
<ExternalLink className="size-3.5 shrink-0" />
|
|
220
|
-
</
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
{/* Hovering a button that does nothing is someone asking why. The answer
|
|
224
|
-
points at the FIELD, not at the button under the cursor: the tail
|
|
225
|
-
lands just inside the field's left edge (`left-44` = the picker's
|
|
226
|
-
`w-44`) and the field lights up at the same time. */}
|
|
227
|
-
<p
|
|
228
|
-
aria-hidden
|
|
229
|
-
className={cn(
|
|
230
|
-
'pointer-events-none absolute top-[calc(100%+0.5rem)] left-44 z-30 m-0 flex items-center rounded-md bg-foreground px-2 py-1 text-xs font-semibold whitespace-nowrap text-background',
|
|
231
|
-
'before:absolute before:-top-[3px] before:left-[0.9rem] before:size-2 before:rotate-45 before:rounded-[1px] before:bg-foreground',
|
|
232
|
-
// `translate`, not `transform`: v4 utilities set the separate
|
|
233
|
-
// `translate` property, which a transform transition never sees.
|
|
234
|
-
'-translate-y-[3px] opacity-0 transition-[opacity,translate] duration-150',
|
|
235
|
-
'peer-has-[.qj-dormant:hover]:translate-y-0 peer-has-[.qj-dormant:hover]:opacity-100',
|
|
236
|
-
'peer-has-[.qj-dormant:focus-visible]:translate-y-0 peer-has-[.qj-dormant:focus-visible]:opacity-100',
|
|
237
|
-
)}
|
|
238
|
-
>
|
|
239
|
-
Type a {selected.identity} here first
|
|
240
|
-
</p>
|
|
251
|
+
</button>
|
|
252
|
+
)}
|
|
241
253
|
</div>
|
|
242
|
-
|
|
254
|
+
|
|
255
|
+
{/* Hovering a button that does nothing is someone asking why. The answer
|
|
256
|
+
points at the FIELD, not at the button under the cursor: the tail
|
|
257
|
+
lands just inside the field's left edge (`left-44` = the picker's
|
|
258
|
+
`w-44`; below `sm` the field starts the line) and the field lights up
|
|
259
|
+
at the same time. */}
|
|
260
|
+
<p
|
|
261
|
+
aria-hidden
|
|
262
|
+
className={cn(
|
|
263
|
+
'pointer-events-none absolute top-[calc(100%+0.5rem)] left-0 z-30 m-0 flex items-center rounded-md bg-foreground px-2 py-1 text-xs font-semibold whitespace-nowrap text-background sm:left-44',
|
|
264
|
+
'before:absolute before:-top-[3px] before:left-[0.9rem] before:size-2 before:rotate-45 before:rounded-[1px] before:bg-foreground',
|
|
265
|
+
// `translate`, not `transform`: v4 utilities set the separate
|
|
266
|
+
// `translate` property, which a transform transition never sees.
|
|
267
|
+
'-translate-y-[3px] opacity-0 transition-[opacity,translate] duration-150',
|
|
268
|
+
'peer-has-[.qj-dormant:hover]:translate-y-0 peer-has-[.qj-dormant:hover]:opacity-100',
|
|
269
|
+
'peer-has-[.qj-dormant:focus-visible]:translate-y-0 peer-has-[.qj-dormant:focus-visible]:opacity-100',
|
|
270
|
+
)}
|
|
271
|
+
>
|
|
272
|
+
Type a {selected.identity} here first
|
|
273
|
+
</p>
|
|
274
|
+
</div>
|
|
243
275
|
)
|
|
244
276
|
}
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from '~/ui/accordion'
|
|
21
21
|
import { AccessRequestSection } from '../components/AccessRequestSection'
|
|
22
22
|
import { AccessPrerequisiteChain } from '../components/AccessPrerequisiteChain'
|
|
23
|
-
import { LEAP, QuickJumpBar } from '../components/QuickJumpBar'
|
|
23
|
+
import { LEAP, LEAVE_FILL, QuickJumpBar } from '../components/QuickJumpBar'
|
|
24
24
|
import { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'
|
|
25
25
|
import { PersonBadge } from '../components/PersonBadge'
|
|
26
26
|
import { useUser } from '~/modules/auth'
|
|
@@ -258,7 +258,7 @@ export function AppDetails({
|
|
|
258
258
|
)}
|
|
259
259
|
{/* Open button, then Quick Jump: the host you reach by pressing
|
|
260
260
|
it, and the deep link you reach by pasting an id. */}
|
|
261
|
-
<div className="mt-3 flex flex-wrap items-stretch gap-
|
|
261
|
+
<div className="mt-3 flex flex-wrap items-stretch gap-3 px-3">
|
|
262
262
|
{isAdmin ? (
|
|
263
263
|
<InlineEditableField
|
|
264
264
|
value={app.appUrl ?? ''}
|
|
@@ -290,7 +290,8 @@ export function AppDetails({
|
|
|
290
290
|
rel="noopener noreferrer"
|
|
291
291
|
onClick={() => recordClick(app.slug)}
|
|
292
292
|
className={cn(
|
|
293
|
-
'inline-flex items-center gap-2 rounded-md
|
|
293
|
+
'inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium shadow-sm',
|
|
294
|
+
LEAVE_FILL,
|
|
294
295
|
LEAP,
|
|
295
296
|
)}
|
|
296
297
|
title={displayUrl(app.appUrl)}
|