@celestia-island/hikari 0.43.18 → 0.44.0
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/package.json
CHANGED
|
@@ -1,39 +1,66 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createApp, h } from "vue";
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
// HkMenu renders its levels through HkSelectPanel, which teleports to
|
|
5
|
+
// body — replace just HkMenu (imported by the component from
|
|
6
|
+
// ./HkMenu) with an inline passthrough that renders the header slot
|
|
7
|
+
// plus one flattened row per item (children included) while open,
|
|
8
|
+
// wiring row clicks to the select handler; the rest of hikari stays
|
|
9
|
+
// real. The select handler reaches the stub through attrs (it is not
|
|
10
|
+
// re-declared as an emit, which would strip it from props).
|
|
11
|
+
vi.mock("./HkMenu", async () => {
|
|
9
12
|
const { defineComponent, h } = await import("vue");
|
|
10
|
-
const
|
|
11
|
-
name: "
|
|
13
|
+
const HMenuStub = defineComponent({
|
|
14
|
+
name: "HMenu",
|
|
12
15
|
props: {
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
open: { type: Boolean, default: false },
|
|
17
|
+
items: { type: Array, default: () => [] },
|
|
18
|
+
title: { type: String, default: "" },
|
|
15
19
|
},
|
|
16
|
-
setup(props, { slots }) {
|
|
17
|
-
return () =>
|
|
18
|
-
props.
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
setup(props, { slots, attrs }) {
|
|
21
|
+
return () => {
|
|
22
|
+
if (!props.open) return null;
|
|
23
|
+
const rows: ReturnType<typeof h>[] = [];
|
|
24
|
+
const flatten = (items: any[]) => {
|
|
25
|
+
for (const item of items ?? []) {
|
|
26
|
+
rows.push(
|
|
27
|
+
h(
|
|
28
|
+
"button",
|
|
29
|
+
{
|
|
30
|
+
class: "menu-stub-row",
|
|
31
|
+
"data-key": item.key,
|
|
32
|
+
"data-checked": item.checked || undefined,
|
|
33
|
+
"data-danger": item.danger || undefined,
|
|
34
|
+
onClick: () => (attrs as any).onSelect?.(item.key),
|
|
35
|
+
},
|
|
36
|
+
item.label,
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
if (item.children?.length) flatten(item.children);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
flatten(props.items as any[]);
|
|
43
|
+
return h("div", { class: "menu-stub" }, [...(slots.header?.() ?? []), ...rows]);
|
|
44
|
+
};
|
|
21
45
|
},
|
|
22
46
|
});
|
|
23
|
-
return {
|
|
47
|
+
return { default: HMenuStub };
|
|
24
48
|
});
|
|
25
49
|
|
|
26
50
|
import { HkAdminHeader } from "./HkAdminHeader";
|
|
27
51
|
|
|
28
52
|
/**
|
|
29
|
-
* HkAdminHeader contract tests for the
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* -
|
|
36
|
-
*
|
|
53
|
+
* HkAdminHeader contract tests for the unified user dropdown:
|
|
54
|
+
* - the bar beside the avatar carries the PAGE TITLE, never the
|
|
55
|
+
* nickname;
|
|
56
|
+
* - the avatar trigger toggles an HkMenu dropdown whose header slot is
|
|
57
|
+
* the profile identity block (avatar chip + nickname + email) and
|
|
58
|
+
* whose rows end in a danger logout;
|
|
59
|
+
* - avatarAction "drawer" emits avatarClick instead (bar text hidden);
|
|
60
|
+
* - an empty identity renders the signing-in placeholder + the
|
|
61
|
+
* force-sign-out escape row;
|
|
62
|
+
* - Language children carry checked state and emit localeSelect;
|
|
63
|
+
* - the goToFrontend row sits directly above logout.
|
|
37
64
|
*
|
|
38
65
|
* (Repo test convention: raw createApp + container queries, no
|
|
39
66
|
* @vue/test-utils dependency.)
|
|
@@ -66,6 +93,9 @@ function headerNode(props: HeaderProps = {}, slots?: Record<string, unknown>) {
|
|
|
66
93
|
const avatarButton = (c: HTMLElement) =>
|
|
67
94
|
c.querySelector('button[aria-haspopup]') as HTMLButtonElement | null;
|
|
68
95
|
|
|
96
|
+
const menuRows = (c: HTMLElement) =>
|
|
97
|
+
[...c.querySelectorAll(".menu-stub-row")] as HTMLButtonElement[];
|
|
98
|
+
|
|
69
99
|
async function click(el: Element | null) {
|
|
70
100
|
el?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
71
101
|
await Promise.resolve();
|
|
@@ -73,27 +103,51 @@ async function click(el: Element | null) {
|
|
|
73
103
|
}
|
|
74
104
|
|
|
75
105
|
describe("HkAdminHeader", () => {
|
|
76
|
-
it("
|
|
77
|
-
const c = mount(headerNode({ logoutLabel: "Log out" }));
|
|
106
|
+
it("shows the page title beside the avatar, never the nickname", () => {
|
|
107
|
+
const c = mount(headerNode({ title: "Providers", logoutLabel: "Log out" }));
|
|
108
|
+
const barText = c.querySelector("header > div")?.textContent ?? "";
|
|
109
|
+
expect(barText).toContain("Providers");
|
|
110
|
+
// The nickname must not render as bar text — identity lives in the
|
|
111
|
+
// dropdown's identity block only.
|
|
112
|
+
expect(barText).not.toContain("alice");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("hides the title node entirely when it is empty", () => {
|
|
116
|
+
const withTitle = mount(headerNode({ title: "Dashboard" }));
|
|
117
|
+
expect(withTitle.textContent).toContain("Dashboard");
|
|
118
|
+
|
|
119
|
+
const withoutTitle = mount(headerNode());
|
|
120
|
+
expect(withoutTitle.querySelector("header > div")?.textContent).not.toContain("Dashboard");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("toggles the user dropdown with the profile identity header in menu mode", async () => {
|
|
124
|
+
const c = mount(headerNode({
|
|
125
|
+
userEmail: "alice@example.com",
|
|
126
|
+
logoutLabel: "Log out",
|
|
127
|
+
}));
|
|
78
128
|
const btn = avatarButton(c);
|
|
79
129
|
expect(btn?.getAttribute("aria-haspopup")).toBe("menu");
|
|
80
130
|
|
|
81
131
|
await click(btn);
|
|
82
|
-
const menu = c.querySelector(".
|
|
83
|
-
// The dropdown leads with the identity block
|
|
132
|
+
const menu = c.querySelector(".menu-stub");
|
|
133
|
+
// The dropdown leads with the identity block — avatar chip,
|
|
134
|
+
// nickname, email — and ends with a danger logout row.
|
|
135
|
+
expect(menu?.querySelector(".s-user-header--profile")).toBeTruthy();
|
|
136
|
+
expect(menu?.querySelector(".s-user-avatar-chip")).toBeTruthy();
|
|
84
137
|
expect(menu?.textContent).toContain("alice");
|
|
85
|
-
expect(
|
|
86
|
-
expect(
|
|
138
|
+
expect(menu?.textContent).toContain("alice@example.com");
|
|
139
|
+
expect(c.querySelector(".menu-stub-row[data-key='logout'][data-danger]")).toBeTruthy();
|
|
87
140
|
|
|
88
141
|
await click(btn);
|
|
89
|
-
expect(c.querySelector(".
|
|
142
|
+
expect(c.querySelector(".menu-stub")).toBeNull();
|
|
90
143
|
});
|
|
91
144
|
|
|
92
145
|
it("emits avatarClick instead of the dropdown in drawer mode", async () => {
|
|
93
146
|
const clicks: number[] = [];
|
|
94
147
|
const c = mount(headerNode({
|
|
95
148
|
avatarAction: "drawer",
|
|
96
|
-
|
|
149
|
+
title: "Providers",
|
|
150
|
+
userEmail: "alice@example.com",
|
|
97
151
|
onAvatarClick: () => clicks.push(1),
|
|
98
152
|
}));
|
|
99
153
|
const btn = avatarButton(c);
|
|
@@ -101,9 +155,10 @@ describe("HkAdminHeader", () => {
|
|
|
101
155
|
|
|
102
156
|
await click(btn);
|
|
103
157
|
expect(clicks).toHaveLength(1);
|
|
104
|
-
// The dropdown must NOT open in drawer mode, and the
|
|
105
|
-
//
|
|
106
|
-
expect(c.querySelector(".
|
|
158
|
+
// The dropdown must NOT open in drawer mode, and neither the title
|
|
159
|
+
// nor the nickname renders — both live in the drawer footer.
|
|
160
|
+
expect(c.querySelector(".menu-stub")).toBeNull();
|
|
161
|
+
expect(c.textContent).not.toContain("Providers");
|
|
107
162
|
expect(c.textContent).not.toContain("alice");
|
|
108
163
|
});
|
|
109
164
|
|
|
@@ -123,63 +178,64 @@ describe("HkAdminHeader", () => {
|
|
|
123
178
|
// No action items above an absent identity.
|
|
124
179
|
expect(c.textContent).not.toContain("Avatar");
|
|
125
180
|
|
|
126
|
-
await click(c.querySelector(".
|
|
181
|
+
await click(c.querySelector(".menu-stub-row"));
|
|
127
182
|
expect(onForceSignOut).toHaveBeenCalledTimes(1);
|
|
128
183
|
});
|
|
129
184
|
|
|
130
|
-
it("hides the context title entirely when it is empty", () => {
|
|
131
|
-
const withTitle = mount(headerNode({ title: "Dashboard" }));
|
|
132
|
-
expect(withTitle.querySelector("h2")?.textContent).toBe("Dashboard");
|
|
133
|
-
|
|
134
|
-
const withoutTitle = mount(headerNode());
|
|
135
|
-
expect(withoutTitle.querySelector("h2")).toBeNull();
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("renders no emergency-stop control by default", () => {
|
|
139
|
-
const c = mount(headerNode({ username: "alice" }));
|
|
140
|
-
expect(c.textContent.toLowerCase()).not.toContain("emergency");
|
|
141
|
-
});
|
|
142
|
-
|
|
143
185
|
it("renders the goToFrontend row above logout and emits goToFrontend", async () => {
|
|
144
186
|
const onGoToFrontend = vi.fn();
|
|
145
187
|
const c = mount(headerNode({
|
|
188
|
+
userEmail: "alice@example.com",
|
|
146
189
|
logoutLabel: "Log out",
|
|
147
190
|
goToFrontendLabel: "Go to Frontend",
|
|
148
191
|
onGoToFrontend,
|
|
149
192
|
}));
|
|
150
193
|
// Opt-in: absent without the label prop…
|
|
151
|
-
const bare = mount(headerNode({ logoutLabel: "Log out" }));
|
|
194
|
+
const bare = mount(headerNode({ userEmail: "alice@example.com", logoutLabel: "Log out" }));
|
|
152
195
|
await click(avatarButton(bare));
|
|
153
|
-
expect(bare.
|
|
196
|
+
expect(bare.querySelector(".menu-stub-row[data-key='go-to-frontend']")).toBeNull();
|
|
154
197
|
|
|
155
198
|
// …and present (directly above logout, mirroring the drawer user
|
|
156
199
|
// panel's row order) with the label prop.
|
|
157
200
|
await click(avatarButton(c));
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
expect(
|
|
161
|
-
expect(labels.indexOf("Log out")).toBe(labels.indexOf("Go to Frontend") + 1);
|
|
201
|
+
const keys = menuRows(c).map((r) => r.dataset.key);
|
|
202
|
+
expect(keys.indexOf("go-to-frontend")).toBeGreaterThan(-1);
|
|
203
|
+
expect(keys.indexOf("logout")).toBe(keys.indexOf("go-to-frontend") + 1);
|
|
162
204
|
|
|
163
|
-
await click(
|
|
205
|
+
await click(menuRows(c)[keys.indexOf("go-to-frontend")]);
|
|
164
206
|
expect(onGoToFrontend).toHaveBeenCalledTimes(1);
|
|
165
207
|
});
|
|
166
208
|
|
|
167
|
-
it("
|
|
168
|
-
|
|
169
|
-
const c = mount(headerNode(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
));
|
|
209
|
+
it("offers locales as a checked cascade and emits localeSelect with the code", async () => {
|
|
210
|
+
const onLocaleSelect = vi.fn();
|
|
211
|
+
const c = mount(headerNode({
|
|
212
|
+
userEmail: "alice@example.com",
|
|
213
|
+
localeOptions: [
|
|
214
|
+
{ code: "en", label: "English" },
|
|
215
|
+
{ code: "zh-Hans", label: "简体中文" },
|
|
216
|
+
],
|
|
217
|
+
currentLocale: "zh-Hans",
|
|
218
|
+
onLocaleSelect,
|
|
219
|
+
}));
|
|
178
220
|
await click(avatarButton(c));
|
|
179
|
-
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
221
|
+
|
|
222
|
+
// Children flatten after the parent row with checked state on the
|
|
223
|
+
// active locale only.
|
|
224
|
+
const rows = menuRows(c);
|
|
225
|
+
const localeIdx = rows.findIndex((r) => r.dataset.key === "locale");
|
|
226
|
+
const en = rows[localeIdx + 1];
|
|
227
|
+
const zh = rows[localeIdx + 2];
|
|
228
|
+
expect(en.dataset.key).toBe("locale:en");
|
|
229
|
+
expect(zh.dataset.key).toBe("locale:zh-Hans");
|
|
230
|
+
expect(zh.dataset.checked).toBe("true");
|
|
231
|
+
expect(en.dataset.checked).toBeUndefined();
|
|
232
|
+
|
|
233
|
+
await click(en);
|
|
234
|
+
expect(onLocaleSelect).toHaveBeenCalledWith("en");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("renders no emergency-stop control by default", () => {
|
|
238
|
+
const c = mount(headerNode({ username: "alice" }));
|
|
239
|
+
expect(c.textContent.toLowerCase()).not.toContain("emergency");
|
|
184
240
|
});
|
|
185
241
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { defineComponent, ref, type PropType, type VNode } from "vue";
|
|
1
|
+
import { computed, defineComponent, ref, type PropType, type VNode } from "vue";
|
|
2
2
|
import { Camera, ExternalLink, Languages, LogOut, Menu } from "lucide-vue-next";
|
|
3
|
-
import { HBadge, HButton,
|
|
3
|
+
import { HBadge, HButton, HSpinner } from "@celestia-island/hikari";
|
|
4
4
|
import { useI18n } from "../i18n/context";
|
|
5
|
+
import HkMenu, { type HkMenuItem } from "./HkMenu";
|
|
5
6
|
|
|
6
7
|
export interface LocaleOption {
|
|
7
8
|
code: string;
|
|
@@ -11,25 +12,30 @@ export interface LocaleOption {
|
|
|
11
12
|
export const HkAdminHeader = defineComponent({
|
|
12
13
|
name: "HkAdminHeader",
|
|
13
14
|
props: {
|
|
14
|
-
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
/** Page title rendered beside the avatar — the view the console is
|
|
16
|
+
* currently ON (dashboard, providers, …). The signed-in nickname
|
|
17
|
+
* deliberately does NOT go here: identity lives in the dropdown's
|
|
18
|
+
* identity block, the bar labels WHERE you are. Empty hides the
|
|
19
|
+
* node; long titles truncate. */
|
|
17
20
|
title: { type: String, default: "" },
|
|
18
21
|
showHamburger: { type: Boolean, default: false },
|
|
19
22
|
compact: { type: Boolean, default: false },
|
|
20
23
|
actions: { type: Array as PropType<VNode[]>, default: () => [] },
|
|
24
|
+
/** Signed-in identity. Drives the avatar fallback letter, the
|
|
25
|
+
* dropdown identity block and the pending-state branch — it is
|
|
26
|
+
* never rendered as bar text (the title slot above owns that). */
|
|
21
27
|
username: { type: String, default: "" },
|
|
22
28
|
avatarUrl: { type: String, default: "" },
|
|
23
29
|
userEmail: { type: String, default: "" },
|
|
24
30
|
userGroups: { type: Array as PropType<{ id: string; name: string }[]>, default: () => [] },
|
|
25
31
|
/** What the avatar trigger does:
|
|
26
|
-
* - "menu" (default, desktop): toggle the user dropdown
|
|
32
|
+
* - "menu" (default, desktop): toggle the user dropdown
|
|
27
33
|
* (identity, avatar edit, language, logout).
|
|
28
34
|
* - "drawer" (mobile): emit `avatarClick` so the shell opens its nav
|
|
29
35
|
* drawer, whose `userPanel` footer carries the same user content.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
36
|
+
* Both the page title and the identity stay hidden in this mode —
|
|
37
|
+
* they live in the drawer, so header copies read as duplicated
|
|
38
|
+
* stray controls. */
|
|
33
39
|
avatarAction: { type: String as PropType<"menu" | "drawer">, default: "menu" },
|
|
34
40
|
/** Placeholder row shown while the identity is still loading (a
|
|
35
41
|
* fetchUser race on hard refresh) — the action items stay hidden
|
|
@@ -51,6 +57,13 @@ export const HkAdminHeader = defineComponent({
|
|
|
51
57
|
/** Accessible label for the avatar trigger button. */
|
|
52
58
|
avatarTriggerLabel: { type: String, default: undefined },
|
|
53
59
|
localeMenuLabel: { type: String, default: undefined },
|
|
60
|
+
/** Offered locales for the Language cascade (the same shape the chat
|
|
61
|
+
* frontend feeds its user menu). An empty list hides the row —
|
|
62
|
+
* hosts without a locale concept simply omit it. Selecting a child
|
|
63
|
+
* emits `localeSelect` with the code. */
|
|
64
|
+
localeOptions: { type: Array as PropType<LocaleOption[]>, default: () => [] },
|
|
65
|
+
/** Currently active locale code (the Language cascade's check). */
|
|
66
|
+
currentLocale: { type: String, default: undefined },
|
|
54
67
|
logoutLabel: { type: String, default: undefined },
|
|
55
68
|
adminGroupLabel: { type: String, default: undefined },
|
|
56
69
|
/** "Go to frontend" menu row (external-face link, rendered directly
|
|
@@ -65,6 +78,7 @@ export const HkAdminHeader = defineComponent({
|
|
|
65
78
|
hamburger: () => true,
|
|
66
79
|
avatarClick: () => true,
|
|
67
80
|
emergencyStop: () => true,
|
|
81
|
+
localeSelect: (_code: string) => true,
|
|
68
82
|
},
|
|
69
83
|
setup(props, { emit, slots }) {
|
|
70
84
|
const { t } = useI18n();
|
|
@@ -72,8 +86,6 @@ export const HkAdminHeader = defineComponent({
|
|
|
72
86
|
const userTriggerRef = ref<HTMLElement>();
|
|
73
87
|
const avatarModalOpen = ref(false);
|
|
74
88
|
const avatarFailed = ref(false);
|
|
75
|
-
const localeMenuOpen = ref(false);
|
|
76
|
-
const localeTriggerRef = ref<HTMLElement | null>(null);
|
|
77
89
|
|
|
78
90
|
function onAvatarClick(e: MouseEvent) {
|
|
79
91
|
e.stopPropagation();
|
|
@@ -84,6 +96,72 @@ export const HkAdminHeader = defineComponent({
|
|
|
84
96
|
userMenuOpen.value = !userMenuOpen.value;
|
|
85
97
|
}
|
|
86
98
|
|
|
99
|
+
/** The dropdown is the SAME engine as the chat frontend's user menu
|
|
100
|
+
* (HkMenu): identity header via the header slot, cascading Language
|
|
101
|
+
* children, danger logout row — every account surface (chat header,
|
|
102
|
+
* console header, mobile drawer) reads identically. */
|
|
103
|
+
const userMenuItems = computed<HkMenuItem[]>(() => {
|
|
104
|
+
// Empty identity (fetchUser race on a hard refresh): a single
|
|
105
|
+
// force-sign-out escape row — action items floating above no
|
|
106
|
+
// identity read as a broken menu.
|
|
107
|
+
if (!props.username && !props.userEmail) {
|
|
108
|
+
return [
|
|
109
|
+
{
|
|
110
|
+
key: "force-signout",
|
|
111
|
+
label: props.forceSignOutLabel ?? t("hikari::adminHeader.forceSignOut", "Sign out"),
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
const items: HkMenuItem[] = [
|
|
116
|
+
{
|
|
117
|
+
key: "avatar",
|
|
118
|
+
label: props.avatarMenuLabel ?? t("hikari::adminHeader.avatar", "Avatar"),
|
|
119
|
+
icon: Camera,
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
if (props.localeOptions.length > 0) {
|
|
123
|
+
items.push({
|
|
124
|
+
key: "locale",
|
|
125
|
+
label: props.localeMenuLabel ?? t("hikari::adminHeader.language", "Language"),
|
|
126
|
+
icon: Languages,
|
|
127
|
+
children: props.localeOptions.map((o) => ({
|
|
128
|
+
key: `locale:${o.code}`,
|
|
129
|
+
label: o.label,
|
|
130
|
+
checked: o.code === props.currentLocale,
|
|
131
|
+
})),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
// Frontend link — mirrors the drawer user panel's "go to frontend"
|
|
135
|
+
// row (same position: directly above logout). Opt-in via the label
|
|
136
|
+
// prop so admin-only panels keep the menu at avatar/language/logout.
|
|
137
|
+
if (props.goToFrontendLabel) {
|
|
138
|
+
items.push({ key: "go-to-frontend", label: props.goToFrontendLabel, icon: ExternalLink });
|
|
139
|
+
}
|
|
140
|
+
items.push({
|
|
141
|
+
key: "logout",
|
|
142
|
+
label: props.logoutLabel ?? t("hikari::adminHeader.logout", "Logout"),
|
|
143
|
+
icon: LogOut,
|
|
144
|
+
danger: true,
|
|
145
|
+
});
|
|
146
|
+
return items;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/** Leaf selection IS the action; HkMenu closes the menu around the
|
|
150
|
+
* emit, so each branch only fires its own side effect. */
|
|
151
|
+
function onUserMenuSelect(key: string) {
|
|
152
|
+
if (key === "avatar") {
|
|
153
|
+
avatarModalOpen.value = true;
|
|
154
|
+
} else if (key.startsWith("locale:")) {
|
|
155
|
+
emit("localeSelect", key.slice("locale:".length));
|
|
156
|
+
} else if (key === "go-to-frontend") {
|
|
157
|
+
emit("goToFrontend");
|
|
158
|
+
} else if (key === "logout") {
|
|
159
|
+
emit("logout");
|
|
160
|
+
} else if (key === "force-signout") {
|
|
161
|
+
props.onForceSignOut?.();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
87
165
|
return () => (
|
|
88
166
|
<header
|
|
89
167
|
class={[
|
|
@@ -132,142 +210,85 @@ export const HkAdminHeader = defineComponent({
|
|
|
132
210
|
</div>
|
|
133
211
|
)}
|
|
134
212
|
</button>
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
213
|
+
{/* WHERE am I — the open view's title, not the nickname. The
|
|
214
|
+
explicit 1.5 line-height keeps descenders (g, y, p) inside
|
|
215
|
+
the truncate clip box: the default text-sm box is exactly
|
|
216
|
+
the em advance, so zoom/subpixel rounding in a scaled root
|
|
217
|
+
shaves the ink off at the bottom (user report 2026-09-12:
|
|
218
|
+
the "g" tail of the nickname was visibly cut). */}
|
|
219
|
+
{props.avatarAction === "menu" && props.title && (
|
|
220
|
+
<span
|
|
221
|
+
class="text-sm font-semibold text-text truncate max-w-[8rem]"
|
|
222
|
+
style={{ lineHeight: "1.5" }}
|
|
223
|
+
>
|
|
224
|
+
{props.title}
|
|
138
225
|
</span>
|
|
139
226
|
)}
|
|
140
227
|
</div>
|
|
141
228
|
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
onUpdate:
|
|
145
|
-
userMenuOpen.value = v;
|
|
146
|
-
// Closing the outer menu must also close the locale submenu,
|
|
147
|
-
// or it stays orphaned for the next open.
|
|
148
|
-
if (!v) localeMenuOpen.value = false;
|
|
149
|
-
}}
|
|
150
|
-
placement="bottom-start"
|
|
229
|
+
<HkMenu
|
|
230
|
+
open={userMenuOpen.value}
|
|
231
|
+
onUpdate:open={(v: boolean) => { userMenuOpen.value = v; }}
|
|
151
232
|
anchorRef={userTriggerRef.value ?? null}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
233
|
+
placement="bottom-start"
|
|
234
|
+
title={props.avatarTriggerLabel ?? t("hikari::adminHeader.avatarTrigger", "Account menu")}
|
|
235
|
+
items={userMenuItems.value}
|
|
236
|
+
onSelect={(key: string) => onUserMenuSelect(key)}
|
|
155
237
|
>
|
|
156
|
-
{
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
>
|
|
190
|
-
{g
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
238
|
+
{{
|
|
239
|
+
header: () => (
|
|
240
|
+
!props.username && !props.userEmail ? (
|
|
241
|
+
// Pending identity: a spinner beside the label reads as
|
|
242
|
+
// "actively working", not a frozen panel.
|
|
243
|
+
<div class="s-user-header s-user-header--pending">
|
|
244
|
+
<HSpinner size="sm" />
|
|
245
|
+
<div class="s-user-header-email">{props.signingInLabel ?? t("hikari::adminHeader.signingIn", "Signing in…")}</div>
|
|
246
|
+
</div>
|
|
247
|
+
) : (
|
|
248
|
+
// Identity block FIRST — avatar, nickname, login email,
|
|
249
|
+
// permission badges — the same grammar the chat
|
|
250
|
+
// frontend's user-menu header uses (the shared
|
|
251
|
+
// s-user-header profile variant).
|
|
252
|
+
<div class="s-user-header s-user-header--profile">
|
|
253
|
+
<span class="s-user-avatar-chip" aria-hidden="true">
|
|
254
|
+
{props.avatarUrl && !avatarFailed.value ? (
|
|
255
|
+
<img
|
|
256
|
+
src={props.avatarUrl}
|
|
257
|
+
alt=""
|
|
258
|
+
class="s-user-avatar-img"
|
|
259
|
+
onError={() => { avatarFailed.value = true; }}
|
|
260
|
+
/>
|
|
261
|
+
) : (
|
|
262
|
+
<span class="s-user-avatar">
|
|
263
|
+
{props.username?.charAt(0).toUpperCase() || "?"}
|
|
264
|
+
</span>
|
|
265
|
+
)}
|
|
266
|
+
</span>
|
|
267
|
+
<div class="s-user-header-body">
|
|
268
|
+
{props.username && <div class="s-user-header-name">{props.username}</div>}
|
|
269
|
+
{props.userEmail && <div class="s-user-header-email">{props.userEmail}</div>}
|
|
270
|
+
{props.userGroups && props.userGroups.length > 0 && (
|
|
271
|
+
<div class="s-user-header-groups">
|
|
272
|
+
{props.userGroups.map((g: { id: string; name: string }) => (
|
|
273
|
+
<HBadge
|
|
274
|
+
key={g.id}
|
|
275
|
+
variant={g.name === "Administrators" ? "error" : "primary"}
|
|
276
|
+
size="sm"
|
|
277
|
+
>
|
|
278
|
+
{g.name === "Administrators"
|
|
279
|
+
? (props.adminGroupLabel ?? t("hikari::adminHeader.adminGroup", "Administrators"))
|
|
280
|
+
: g.name}
|
|
281
|
+
</HBadge>
|
|
282
|
+
))}
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
195
285
|
</div>
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
userMenuOpen.value = false;
|
|
202
|
-
avatarModalOpen.value = true;
|
|
203
|
-
}}
|
|
204
|
-
>
|
|
205
|
-
<span class="hk-menu-item-icon"><Camera size={14} /></span>
|
|
206
|
-
{props.avatarMenuLabel ?? t("hikari::adminHeader.avatar", "Avatar")}
|
|
207
|
-
</button>
|
|
208
|
-
{/* The language trigger owns the locale anchor: the ref sits
|
|
209
|
-
on the BUTTON itself (not a wrapper div) so the picker
|
|
210
|
-
popup anchors to it instead of falling back to inline
|
|
211
|
-
rendering. The ref OBJECT is passed through the slot —
|
|
212
|
-
reading .value here would freeze null from the first
|
|
213
|
-
render before the ref attached. */}
|
|
214
|
-
<button
|
|
215
|
-
ref={localeTriggerRef}
|
|
216
|
-
class="s-popup-menu-item"
|
|
217
|
-
onClick={() => (localeMenuOpen.value = !localeMenuOpen.value)}
|
|
218
|
-
>
|
|
219
|
-
<span class="hk-menu-item-icon"><Languages size={14} /></span>
|
|
220
|
-
{props.localeMenuLabel ?? t("hikari::adminHeader.language", "Language")}
|
|
221
|
-
</button>
|
|
222
|
-
{slots["locale-picker"]?.({
|
|
223
|
-
open: localeMenuOpen.value,
|
|
224
|
-
onUpdateOpen: (v: boolean) => {
|
|
225
|
-
localeMenuOpen.value = v;
|
|
226
|
-
if (!v) userMenuOpen.value = false;
|
|
227
|
-
},
|
|
228
|
-
triggerRef: localeTriggerRef,
|
|
229
|
-
})}
|
|
230
|
-
{slots["user-menu-extra"]?.()}
|
|
231
|
-
{/* Frontend link — mirrors the drawer user panel's
|
|
232
|
-
"go to frontend" row (same icon/position: directly
|
|
233
|
-
above logout). Opt-in via the label prop so admin-only
|
|
234
|
-
panels keep the menu at avatar/language/logout. */}
|
|
235
|
-
{props.goToFrontendLabel ? (
|
|
236
|
-
<button
|
|
237
|
-
class="s-popup-menu-item"
|
|
238
|
-
onClick={() => {
|
|
239
|
-
userMenuOpen.value = false;
|
|
240
|
-
emit("goToFrontend");
|
|
241
|
-
}}
|
|
242
|
-
>
|
|
243
|
-
<span class="hk-menu-item-icon"><ExternalLink size={14} /></span>
|
|
244
|
-
{props.goToFrontendLabel}
|
|
245
|
-
</button>
|
|
246
|
-
) : null}
|
|
247
|
-
<button
|
|
248
|
-
class="s-popup-menu-item"
|
|
249
|
-
onClick={() => {
|
|
250
|
-
userMenuOpen.value = false;
|
|
251
|
-
emit("logout");
|
|
252
|
-
}}
|
|
253
|
-
>
|
|
254
|
-
<span class="hk-menu-item-icon"><LogOut size={14} /></span>
|
|
255
|
-
{props.logoutLabel ?? t("hikari::adminHeader.logout", "Logout")}
|
|
256
|
-
</button>
|
|
257
|
-
</div>
|
|
258
|
-
)}
|
|
259
|
-
</HPopover>
|
|
286
|
+
</div>
|
|
287
|
+
)
|
|
288
|
+
),
|
|
289
|
+
}}
|
|
290
|
+
</HkMenu>
|
|
260
291
|
|
|
261
|
-
{/* The page title lives INSIDE each page in the HPageHeader
|
|
262
|
-
convention (big title left, tool buttons right). The bar
|
|
263
|
-
renders an optional context title only when one is given —
|
|
264
|
-
empty hides the node, keeping the bar to identity + theme
|
|
265
|
-
controls. */}
|
|
266
|
-
{props.title && (
|
|
267
|
-
<h2 class="text-sm font-semibold text-text truncate min-w-0">
|
|
268
|
-
{props.title}
|
|
269
|
-
</h2>
|
|
270
|
-
)}
|
|
271
292
|
<div class="ml-auto flex items-center gap-1.5 shrink-0">
|
|
272
293
|
{props.showEmergencyStop && (
|
|
273
294
|
<button
|
|
@@ -364,6 +364,55 @@
|
|
|
364
364
|
margin-bottom: var(--space-4);
|
|
365
365
|
}
|
|
366
366
|
|
|
367
|
+
/* Profile variant — avatar chip beside the identity column, the ONE
|
|
368
|
+
identity grammar the chat frontend's user menu and the admin
|
|
369
|
+
header's dropdown share (moved up from chest's ChatLayout so both
|
|
370
|
+
surfaces render from the same sheet). */
|
|
371
|
+
.s-user-header--profile {
|
|
372
|
+
flex-direction: row;
|
|
373
|
+
align-items: center;
|
|
374
|
+
gap: var(--space-10);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/* Avatar primitives shared by the trigger button and the identity
|
|
378
|
+
chip: the letter fallback fills its box, images cover. */
|
|
379
|
+
.s-user-avatar {
|
|
380
|
+
display: flex;
|
|
381
|
+
align-items: center;
|
|
382
|
+
justify-content: center;
|
|
383
|
+
width: 100%;
|
|
384
|
+
height: 100%;
|
|
385
|
+
border-radius: 50%;
|
|
386
|
+
background: var(--c-primary-medium);
|
|
387
|
+
color: rgb(var(--color-primary));
|
|
388
|
+
font-size: var(--text-xs);
|
|
389
|
+
font-weight: 700;
|
|
390
|
+
letter-spacing: 0.02em;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.s-user-avatar-img {
|
|
394
|
+
width: 100%;
|
|
395
|
+
height: 100%;
|
|
396
|
+
border-radius: 50%;
|
|
397
|
+
object-fit: cover;
|
|
398
|
+
display: block;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.s-user-avatar-chip {
|
|
402
|
+
flex-shrink: 0;
|
|
403
|
+
width: 32px;
|
|
404
|
+
height: 32px;
|
|
405
|
+
border-radius: 50%;
|
|
406
|
+
overflow: hidden;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
.s-user-header-body {
|
|
410
|
+
min-width: 0;
|
|
411
|
+
display: flex;
|
|
412
|
+
flex-direction: column;
|
|
413
|
+
gap: 2px;
|
|
414
|
+
}
|
|
415
|
+
|
|
367
416
|
/* Pending variant (identity not yet hydrated): spinner beside the
|
|
368
417
|
label reads as "actively working", not a frozen panel. */
|
|
369
418
|
.s-user-header--pending {
|