@octabits-io/nuxt-ui-kit 0.2.0 → 0.3.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/dist/api/index.d.ts +65 -0
- package/dist/api/index.js +51 -0
- package/dist/auth/index.d.ts +240 -0
- package/dist/auth/index.js +273 -0
- package/dist/i18n/index.d.ts +49 -0
- package/dist/i18n/index.js +80 -0
- package/dist/index.d.ts +65 -310
- package/dist/index.js +70 -325
- package/dist/locale/index.d.ts +140 -0
- package/dist/locale/index.js +140 -0
- package/dist/zod/index.d.ts +23 -1
- package/dist/zod/index.js +44 -1
- package/package.json +33 -2
- package/src/components/LocaleInput.vue +132 -0
- package/src/components/LocaleTab.vue +44 -0
- package/src/components/LocaleTextarea.vue +131 -0
- package/src/components/PageAction.vue +88 -0
- package/src/components/PageActionMenu.vue +34 -0
- package/src/components/PageHeader.vue +110 -0
- package/src/components/PageUtilityActions.vue +31 -0
- package/src/components/SubSidebar.vue +10 -1
- package/src/components/TranslationBadge.vue +65 -0
package/dist/index.js
CHANGED
|
@@ -1,323 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { computed, ref, watch } from "vue";
|
|
3
|
-
import { treaty } from "@elysiajs/eden";
|
|
4
|
-
//#region src/auth/oidc.ts
|
|
5
|
-
/**
|
|
6
|
-
* Lazily-created `UserManager` singleton bound to `window.localStorage`.
|
|
7
|
-
* Call the returned getter from plugins/stores/composables — the manager is
|
|
8
|
-
* constructed on first call (client-side only; requires `window`).
|
|
9
|
-
*/
|
|
10
|
-
function createUserManagerFactory(options) {
|
|
11
|
-
let userManager = null;
|
|
12
|
-
return function getUserManager() {
|
|
13
|
-
if (userManager) return userManager;
|
|
14
|
-
const { issuerUrl, clientId } = options.getConfig();
|
|
15
|
-
if (!issuerUrl || !clientId) (options.onMissingConfig ?? console.error)("Missing OIDC issuer URL or client id in runtime config");
|
|
16
|
-
userManager = new UserManager({
|
|
17
|
-
authority: issuerUrl,
|
|
18
|
-
client_id: clientId,
|
|
19
|
-
redirect_uri: `${window.location.origin}${options.redirectPath ?? "/auth/callback"}`,
|
|
20
|
-
post_logout_redirect_uri: `${window.location.origin}${options.postLogoutRedirectPath ?? "/login"}`,
|
|
21
|
-
response_type: "code",
|
|
22
|
-
scope: options.scope,
|
|
23
|
-
automaticSilentRenew: options.automaticSilentRenew ?? true,
|
|
24
|
-
...options.refreshTokenAllowedScope ? { refreshTokenAllowedScope: options.refreshTokenAllowedScope } : {},
|
|
25
|
-
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
|
26
|
-
...options.settings
|
|
27
|
-
});
|
|
28
|
-
return userManager;
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* Remove any `oidc.user:` storage keys that don't belong to the current
|
|
33
|
-
* authority+clientId — leftovers from environment switches would otherwise
|
|
34
|
-
* shadow or bloat the session storage.
|
|
35
|
-
*/
|
|
36
|
-
function removeStaleOidcKeys(authority, clientId, storage = globalThis.localStorage) {
|
|
37
|
-
const currentKey = `oidc.user:${authority}:${clientId}`;
|
|
38
|
-
for (let i = storage.length - 1; i >= 0; i--) {
|
|
39
|
-
const key = storage.key(i);
|
|
40
|
-
if (key && key.startsWith("oidc.user:") && key !== currentKey) storage.removeItem(key);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
/** Refresh tokens fail unrecoverably with these OIDC error codes — user must re-auth. */
|
|
44
|
-
function isUnrecoverableRenewError(message) {
|
|
45
|
-
return message.includes("login_required") || message.includes("invalid_grant") || message.includes("interaction_required") || message.includes("consent_required");
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Build a `redirectToLogin()` that starts an OIDC signin redirect carrying the
|
|
49
|
-
* current path as returnUrl state, with a plain `/login?redirect=` navigation
|
|
50
|
-
* fallback when the IdP redirect cannot even be started. No-ops on auth routes.
|
|
51
|
-
*/
|
|
52
|
-
function createLoginRedirector(options) {
|
|
53
|
-
const loginPath = options.loginPath ?? "/login";
|
|
54
|
-
const isAuthRoute = options.isAuthRoute ?? ((path) => path === loginPath || path.startsWith("/auth/"));
|
|
55
|
-
return async function redirectToLogin() {
|
|
56
|
-
const path = window.location.pathname;
|
|
57
|
-
if (isAuthRoute(path)) return;
|
|
58
|
-
const returnUrl = path + window.location.search;
|
|
59
|
-
try {
|
|
60
|
-
await options.getUserManager().signinRedirect({ state: returnUrl });
|
|
61
|
-
} catch (err) {
|
|
62
|
-
(options.log ?? console.error)("[oidc] signinRedirect failed, falling back to login navigation", err);
|
|
63
|
-
window.location.href = `${loginPath}?redirect=${encodeURIComponent(returnUrl)}`;
|
|
64
|
-
}
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* Wire oidc-client-ts session events to app callbacks:
|
|
69
|
-
*
|
|
70
|
-
* - silent-renew error → `notify` (`renew-failed`, or `session-expired` when the
|
|
71
|
-
* error is unrecoverable — see {@link isUnrecoverableRenewError}); an
|
|
72
|
-
* unrecoverable error also triggers the login redirect
|
|
73
|
-
* - access token expired without renewal → `notify(session-expired)` +
|
|
74
|
-
* `onSessionLost` + login redirect
|
|
75
|
-
* - back-channel signout at the IdP → `onSessionLost` + login redirect (no notice
|
|
76
|
-
* — the user initiated it elsewhere)
|
|
77
|
-
*
|
|
78
|
-
* Returns a detach function.
|
|
79
|
-
*/
|
|
80
|
-
function attachSessionLifecycleHandlers(userManager, handlers) {
|
|
81
|
-
const log = handlers.log ?? console.warn;
|
|
82
|
-
const onSilentRenewError = (error) => {
|
|
83
|
-
log("[oidc] silent token renew failed:", error);
|
|
84
|
-
const unrecoverable = isUnrecoverableRenewError(error.message);
|
|
85
|
-
handlers.notify?.(unrecoverable ? {
|
|
86
|
-
kind: "session-expired",
|
|
87
|
-
error
|
|
88
|
-
} : {
|
|
89
|
-
kind: "renew-failed",
|
|
90
|
-
error
|
|
91
|
-
});
|
|
92
|
-
if (unrecoverable) handlers.redirectToLogin();
|
|
93
|
-
};
|
|
94
|
-
const onAccessTokenExpired = () => {
|
|
95
|
-
log("[oidc] access token expired without silent renewal");
|
|
96
|
-
handlers.notify?.({ kind: "session-expired" });
|
|
97
|
-
handlers.onSessionLost?.();
|
|
98
|
-
handlers.redirectToLogin();
|
|
99
|
-
};
|
|
100
|
-
const onUserSignedOut = () => {
|
|
101
|
-
log("[oidc] user signed out at IdP (back-channel)");
|
|
102
|
-
handlers.onSessionLost?.();
|
|
103
|
-
handlers.redirectToLogin();
|
|
104
|
-
};
|
|
105
|
-
userManager.events.addSilentRenewError(onSilentRenewError);
|
|
106
|
-
userManager.events.addAccessTokenExpired(onAccessTokenExpired);
|
|
107
|
-
userManager.events.addUserSignedOut(onUserSignedOut);
|
|
108
|
-
return () => {
|
|
109
|
-
userManager.events.removeSilentRenewError(onSilentRenewError);
|
|
110
|
-
userManager.events.removeAccessTokenExpired(onAccessTokenExpired);
|
|
111
|
-
userManager.events.removeUserSignedOut(onUserSignedOut);
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
//#endregion
|
|
115
|
-
//#region src/auth/zitadel.ts
|
|
116
|
-
/**
|
|
117
|
-
* Zitadel scope presets for {@link createUserManagerFactory}.
|
|
118
|
-
*
|
|
119
|
-
* The URN scopes request the resource-owner (organization) claim and the
|
|
120
|
-
* project role grants; `offline_access` requests a refresh token.
|
|
121
|
-
*/
|
|
122
|
-
const ZITADEL_ORG_PROJECT_SCOPE = "openid profile email urn:zitadel:iam:user:resourceowner urn:zitadel:iam:org:project:roles offline_access";
|
|
123
|
-
/**
|
|
124
|
-
* Zitadel only accepts standard OIDC scopes on the refresh-token grant —
|
|
125
|
-
* sending `offline_access` or the `urn:zitadel:*` scopes returns
|
|
126
|
-
* `invalid_scope` even though they were granted at the initial auth. The
|
|
127
|
-
* URN-based claims still need to land in the refreshed access token; that
|
|
128
|
-
* depends on "Assert Roles on Authentication" being enabled at the Zitadel
|
|
129
|
-
* project level.
|
|
130
|
-
*/
|
|
131
|
-
const ZITADEL_REFRESH_TOKEN_ALLOWED_SCOPE = "openid profile email";
|
|
132
|
-
//#endregion
|
|
133
|
-
//#region src/auth/bypass.ts
|
|
134
|
-
/**
|
|
135
|
-
* Dev/E2E auth bypass: seed storage with a fake oidc-client-ts user whose
|
|
136
|
-
* `access_token` is the bypass secret, so the app considers the session
|
|
137
|
-
* authenticated and the API client sends the secret as Bearer.
|
|
138
|
-
*
|
|
139
|
-
* Call from a plugin that runs before the OIDC plugin. Skips seeding when a
|
|
140
|
-
* valid (non-expired) session already exists; overwrites corrupt entries.
|
|
141
|
-
* Returns whether a session was seeded.
|
|
142
|
-
*/
|
|
143
|
-
function seedAuthBypassSession(options) {
|
|
144
|
-
if (options.isProductionBuild) return false;
|
|
145
|
-
if (!options.bypassSecret) return false;
|
|
146
|
-
const storage = options.storage ?? globalThis.localStorage;
|
|
147
|
-
const storageKey = `oidc.user:${options.issuerUrl}:${options.clientId}`;
|
|
148
|
-
const existing = storage.getItem(storageKey);
|
|
149
|
-
if (existing) try {
|
|
150
|
-
if ((JSON.parse(existing).expires_at ?? 0) > Date.now() / 1e3) return false;
|
|
151
|
-
} catch {}
|
|
152
|
-
(options.warn ?? console.warn)("[auth-bypass] Seeding storage with bypass token for dev/E2E testing");
|
|
153
|
-
storage.setItem(storageKey, JSON.stringify({
|
|
154
|
-
access_token: options.bypassSecret,
|
|
155
|
-
token_type: "Bearer",
|
|
156
|
-
expires_at: Math.floor(Date.now() / 1e3) + (options.sessionTtlSeconds ?? 86400),
|
|
157
|
-
profile: options.profile ?? {
|
|
158
|
-
sub: "e2e-test-user",
|
|
159
|
-
email: "e2e@example.test",
|
|
160
|
-
name: "E2E Test User"
|
|
161
|
-
},
|
|
162
|
-
scope: "openid profile email"
|
|
163
|
-
}));
|
|
164
|
-
return true;
|
|
165
|
-
}
|
|
166
|
-
//#endregion
|
|
167
|
-
//#region src/auth/session.ts
|
|
168
|
-
function defaultAuthUserMapper(profile) {
|
|
169
|
-
return {
|
|
170
|
-
id: profile.sub,
|
|
171
|
-
email: profile.email ?? "",
|
|
172
|
-
name: profile.name ?? null,
|
|
173
|
-
picture: profile.picture ?? null
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
/**
|
|
177
|
-
* Reactive OIDC session state + actions — the setup body of an auth store.
|
|
178
|
-
* Wrap it in the app's own store so naming and registration stay app-owned:
|
|
179
|
-
*
|
|
180
|
-
* ```ts
|
|
181
|
-
* export const useAuthStore = defineStore('auth', () =>
|
|
182
|
-
* createAuthSessionCore({ getUserManager, mapUser: defaultAuthUserMapper }),
|
|
183
|
-
* )
|
|
184
|
-
* ```
|
|
185
|
-
*/
|
|
186
|
-
function createAuthSessionCore(options) {
|
|
187
|
-
const log = options.log ?? console.warn;
|
|
188
|
-
const user = ref(null);
|
|
189
|
-
const initialized = ref(false);
|
|
190
|
-
const loading = ref(false);
|
|
191
|
-
const isAuthenticated = computed(() => !!user.value);
|
|
192
|
-
async function checkAuth() {
|
|
193
|
-
loading.value = true;
|
|
194
|
-
try {
|
|
195
|
-
const um = options.getUserManager();
|
|
196
|
-
let oidcUser = await um.getUser();
|
|
197
|
-
if (oidcUser && oidcUser.expired && oidcUser.refresh_token) try {
|
|
198
|
-
oidcUser = await um.signinSilent();
|
|
199
|
-
} catch (err) {
|
|
200
|
-
log("[auth] signinSilent failed during checkAuth", err);
|
|
201
|
-
oidcUser = null;
|
|
202
|
-
}
|
|
203
|
-
if (oidcUser && !oidcUser.expired) user.value = options.mapUser(oidcUser.profile);
|
|
204
|
-
else user.value = null;
|
|
205
|
-
} catch {
|
|
206
|
-
user.value = null;
|
|
207
|
-
} finally {
|
|
208
|
-
initialized.value = true;
|
|
209
|
-
loading.value = false;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
async function login(returnUrl) {
|
|
213
|
-
await options.getUserManager().signinRedirect({ state: returnUrl ?? "/" });
|
|
214
|
-
}
|
|
215
|
-
async function handleCallback() {
|
|
216
|
-
loading.value = true;
|
|
217
|
-
try {
|
|
218
|
-
const oidcUser = await options.getUserManager().signinRedirectCallback();
|
|
219
|
-
user.value = options.mapUser(oidcUser.profile);
|
|
220
|
-
return oidcUser.state || "/";
|
|
221
|
-
} finally {
|
|
222
|
-
initialized.value = true;
|
|
223
|
-
loading.value = false;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
async function logout() {
|
|
227
|
-
loading.value = true;
|
|
228
|
-
try {
|
|
229
|
-
const manager = options.getUserManager();
|
|
230
|
-
const idTokenHint = (await manager.getUser())?.id_token;
|
|
231
|
-
await manager.removeUser();
|
|
232
|
-
user.value = null;
|
|
233
|
-
await manager.signoutRedirect(idTokenHint ? { id_token_hint: idTokenHint } : void 0);
|
|
234
|
-
} finally {
|
|
235
|
-
loading.value = false;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
return {
|
|
239
|
-
user,
|
|
240
|
-
initialized,
|
|
241
|
-
loading,
|
|
242
|
-
isAuthenticated,
|
|
243
|
-
checkAuth,
|
|
244
|
-
login,
|
|
245
|
-
handleCallback,
|
|
246
|
-
logout
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
//#endregion
|
|
250
|
-
//#region src/auth/guard.ts
|
|
251
|
-
/**
|
|
252
|
-
* Build the body of a global auth route-middleware. The returned handler
|
|
253
|
-
* yields a redirect target path or `undefined` to allow navigation; the app's
|
|
254
|
-
* middleware maps that onto its router:
|
|
255
|
-
*
|
|
256
|
-
* ```ts
|
|
257
|
-
* export default defineNuxtRouteMiddleware(async (to) => {
|
|
258
|
-
* const target = await guard(to)
|
|
259
|
-
* if (target) return navigateTo(target)
|
|
260
|
-
* })
|
|
261
|
-
* ```
|
|
262
|
-
*/
|
|
263
|
-
function createAuthGuard(options) {
|
|
264
|
-
const isPublicRoute = options.isPublicRoute ?? ((to) => to.path === "/login" || to.path.startsWith("/auth/"));
|
|
265
|
-
const loginRedirect = options.loginRedirect ?? ((returnTo) => `/login?redirect=${encodeURIComponent(returnTo)}`);
|
|
266
|
-
return async function guard(to) {
|
|
267
|
-
if (isPublicRoute(to)) return void 0;
|
|
268
|
-
if (!await options.ensureAuthenticated()) return loginRedirect(to.fullPath);
|
|
269
|
-
const target = await options.afterAuthenticated?.(to);
|
|
270
|
-
return typeof target === "string" ? target : void 0;
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
//#endregion
|
|
274
|
-
//#region src/api/client.ts
|
|
275
|
-
/**
|
|
276
|
-
* Resolve the API base URL: configured value, else the page origin in
|
|
277
|
-
* production builds (same-host ingress), else a localhost dev port.
|
|
278
|
-
*/
|
|
279
|
-
function resolveApiBaseUrl(options) {
|
|
280
|
-
if (options.configuredUrl) return options.configuredUrl;
|
|
281
|
-
return options.isProductionBuild ? options.origin ?? window.location.origin : `http://localhost:${options.devFallbackPort}`;
|
|
282
|
-
}
|
|
283
|
-
/**
|
|
284
|
-
* Bearer-token provider backed by the OIDC session: resolves to the current
|
|
285
|
-
* access token, or `null` when there is no non-expired session.
|
|
286
|
-
*/
|
|
287
|
-
function createAccessTokenProvider(getUserManager) {
|
|
288
|
-
return async function getAccessToken() {
|
|
289
|
-
const user = await getUserManager().getUser();
|
|
290
|
-
if (!user || user.expired) return null;
|
|
291
|
-
return user.access_token;
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
/**
|
|
295
|
-
* Lazily-created Eden Treaty client singleton with OIDC bearer injection.
|
|
296
|
-
*
|
|
297
|
-
* ```ts
|
|
298
|
-
* const getClient = createTreatyClientFactory<App>({ getBaseUrl, getAccessToken })
|
|
299
|
-
* export function useApi() {
|
|
300
|
-
* const client = getClient()
|
|
301
|
-
* return { api: client.api, client }
|
|
302
|
-
* }
|
|
303
|
-
* ```
|
|
304
|
-
*/
|
|
305
|
-
function createTreatyClientFactory(options) {
|
|
306
|
-
let client = null;
|
|
307
|
-
return function getClient() {
|
|
308
|
-
if (client) return client;
|
|
309
|
-
client = treaty(options.getBaseUrl(), {
|
|
310
|
-
parseDate: options.parseDate ?? false,
|
|
311
|
-
headers: async () => {
|
|
312
|
-
const token = await options.getAccessToken();
|
|
313
|
-
if (token) return { authorization: `Bearer ${token}` };
|
|
314
|
-
},
|
|
315
|
-
...options.treatyConfig
|
|
316
|
-
});
|
|
317
|
-
return client;
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
|
-
//#endregion
|
|
1
|
+
import { computed, reactive, ref, watch } from "vue";
|
|
321
2
|
//#region src/org/orgStore.ts
|
|
322
3
|
/**
|
|
323
4
|
* Reactive granted-organizations state + switching — the setup body of an
|
|
@@ -379,6 +60,11 @@ function createOrgStoreCore(options) {
|
|
|
379
60
|
};
|
|
380
61
|
}
|
|
381
62
|
//#endregion
|
|
63
|
+
//#region src/runtimeConfig.ts
|
|
64
|
+
function resolveRuntimeConfigValue(appConfigKey, fallback) {
|
|
65
|
+
return (typeof window === "undefined" ? void 0 : window.__APP_CONFIG__)?.[appConfigKey] || fallback;
|
|
66
|
+
}
|
|
67
|
+
//#endregion
|
|
382
68
|
//#region src/composables/useConfirm.ts
|
|
383
69
|
const isOpen = ref(false);
|
|
384
70
|
const currentOptions = ref({ title: "" });
|
|
@@ -421,13 +107,25 @@ function useConfirmState() {
|
|
|
421
107
|
*
|
|
422
108
|
* - `errors.<key>` — one entry per API error key (fallback: the raw
|
|
423
109
|
* server `message`, and `errors.internal_server_error` for non-API errors)
|
|
424
|
-
* - `validation.fields.<
|
|
425
|
-
* - `validation.messages.<
|
|
110
|
+
* - `validation.fields.<slug>` — display names for validated fields
|
|
111
|
+
* - `validation.messages.<slug>` — validation message texts
|
|
112
|
+
*
|
|
113
|
+
* `<slug>` is the path/message lowercased with every non-alphanumeric run
|
|
114
|
+
* collapsed to a single `_` (e.g. path `items.0.name` → `items_0_name`,
|
|
115
|
+
* message `Expected string to match 'email'` →
|
|
116
|
+
* `expected_string_to_match_email`) — so every derivable key is a flat,
|
|
117
|
+
* definable vue-i18n key. Raw paths/messages with dots or punctuation are
|
|
118
|
+
* not definable (dots nest in vue-i18n), which previously made this branch
|
|
119
|
+
* unimplementable for most real messages.
|
|
426
120
|
*
|
|
427
121
|
* Framework-free: pass `t`/`te` from your i18n instance (the app-side
|
|
428
122
|
* composable is typically `const { t, te } = useI18n()` + this factory).
|
|
429
123
|
* Eden Treaty error envelopes (`{ value }`) are unwrapped automatically.
|
|
430
124
|
*/
|
|
125
|
+
/** Lowercase + collapse every non-alphanumeric run to `_` (trimmed) — a flat, definable vue-i18n key segment. */
|
|
126
|
+
function i18nSlug(value) {
|
|
127
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
128
|
+
}
|
|
431
129
|
function createApiErrorMessenger(options) {
|
|
432
130
|
const { t, te } = options;
|
|
433
131
|
const log = options.log ?? ((message, error) => console.error(message, error));
|
|
@@ -442,9 +140,9 @@ function createApiErrorMessenger(options) {
|
|
|
442
140
|
let actualError = error;
|
|
443
141
|
if (typeof error === "object" && error !== null && "value" in error) actualError = error.value;
|
|
444
142
|
if (isValidationError(actualError)) return actualError.fields.map((f) => {
|
|
445
|
-
const fieldKey = `validation.fields.${f.path}`;
|
|
143
|
+
const fieldKey = `validation.fields.${i18nSlug(f.path)}`;
|
|
446
144
|
const fieldName = te(fieldKey) ? t(fieldKey) : f.path;
|
|
447
|
-
const messageKey = `validation.messages.${f.message
|
|
145
|
+
const messageKey = `validation.messages.${i18nSlug(f.message)}`;
|
|
448
146
|
return `${fieldName}: ${te(messageKey) ? t(messageKey) : f.message}`;
|
|
449
147
|
}).join(", ") || t("errors.validation_error");
|
|
450
148
|
if (!isApiError(actualError)) return t("errors.internal_server_error");
|
|
@@ -458,6 +156,53 @@ function createApiErrorMessenger(options) {
|
|
|
458
156
|
};
|
|
459
157
|
}
|
|
460
158
|
//#endregion
|
|
159
|
+
//#region src/composables/useHelpPanel.ts
|
|
160
|
+
const HELP_PANEL_KEY = Symbol("help-panel");
|
|
161
|
+
/**
|
|
162
|
+
* Provide/inject registry for a per-tab contextual help panel: pages register
|
|
163
|
+
* help actions keyed by tab, `PageUtilityActions` renders the toggle, and a
|
|
164
|
+
* panel component renders `currentActions`. Open state persists to
|
|
165
|
+
* localStorage; switching to a tab without actions auto-closes the panel.
|
|
166
|
+
*
|
|
167
|
+
* Provide it per page: `provide(HELP_PANEL_KEY, useHelpPanel())`.
|
|
168
|
+
*/
|
|
169
|
+
function useHelpPanel(options = {}) {
|
|
170
|
+
const storageKey = options.storageKey ?? "help-panel-open";
|
|
171
|
+
const storage = options.storage ?? globalThis.localStorage;
|
|
172
|
+
const isOpen = ref(storage?.getItem(storageKey) === "true");
|
|
173
|
+
watch(isOpen, (open) => storage?.setItem(storageKey, String(open)));
|
|
174
|
+
const registrations = reactive(/* @__PURE__ */ new Map());
|
|
175
|
+
const activeTabValue = ref("");
|
|
176
|
+
const currentActions = computed(() => {
|
|
177
|
+
return registrations.get(activeTabValue.value)?.actions ?? [];
|
|
178
|
+
});
|
|
179
|
+
const hasActions = computed(() => currentActions.value.length > 0);
|
|
180
|
+
function register(tabValue, actions) {
|
|
181
|
+
registrations.set(tabValue, { actions });
|
|
182
|
+
}
|
|
183
|
+
function unregister(tabValue) {
|
|
184
|
+
registrations.delete(tabValue);
|
|
185
|
+
}
|
|
186
|
+
function setActiveTab(tabValue) {
|
|
187
|
+
activeTabValue.value = tabValue;
|
|
188
|
+
if (!registrations.has(tabValue)) isOpen.value = false;
|
|
189
|
+
}
|
|
190
|
+
function toggle() {
|
|
191
|
+
isOpen.value = !isOpen.value;
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
registrations,
|
|
195
|
+
isOpen,
|
|
196
|
+
activeTabValue,
|
|
197
|
+
currentActions,
|
|
198
|
+
hasActions,
|
|
199
|
+
register,
|
|
200
|
+
unregister,
|
|
201
|
+
setActiveTab,
|
|
202
|
+
toggle
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
//#endregion
|
|
461
206
|
//#region src/composables/useDirtyTracking.ts
|
|
462
207
|
function deepClone(obj) {
|
|
463
208
|
return JSON.parse(JSON.stringify(obj));
|
|
@@ -523,4 +268,4 @@ function usePagination(options = {}) {
|
|
|
523
268
|
};
|
|
524
269
|
}
|
|
525
270
|
//#endregion
|
|
526
|
-
export {
|
|
271
|
+
export { HELP_PANEL_KEY, createApiErrorMessenger, createOrgStoreCore, resolveRuntimeConfigValue, useConfirm, useConfirmState, useDirtyTracking, useHelpPanel, usePagination };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { ComputedRef, InjectionKey, MaybeRefOrGetter, ModelRef, Ref } from "vue";
|
|
2
|
+
import { LocaleMap } from "@octabits-io/framework/utils";
|
|
3
|
+
//#region src/locale/pruneLocaleMap.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Drop empty-string leaves from a `LocaleMap<string>` so cleared tabs fall
|
|
6
|
+
* back to the default locale instead of shadowing it with `''`. Returns a new
|
|
7
|
+
* map; use `Object.keys(result).length` to decide between map and `null` when
|
|
8
|
+
* the API expects `null` for "unset".
|
|
9
|
+
*/
|
|
10
|
+
declare function pruneLocaleMap(map: LocaleMap<string> | null | undefined): LocaleMap<string>;
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/locale/index.d.ts
|
|
13
|
+
/** Reactive source of the content-locale set a locale field edits against. */
|
|
14
|
+
interface LocaleFieldSource {
|
|
15
|
+
/** Supported content locales (BCP-47 tags). */
|
|
16
|
+
locales: MaybeRefOrGetter<string[]>;
|
|
17
|
+
/** The locale whose value is required (completeness "error" dot). */
|
|
18
|
+
defaultLocale: MaybeRefOrGetter<string>;
|
|
19
|
+
}
|
|
20
|
+
type LocaleTabIndicator = {
|
|
21
|
+
kind: 'error';
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'warning';
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'inherits';
|
|
26
|
+
} | null;
|
|
27
|
+
/**
|
|
28
|
+
* Scope the locale field editors expose through their `#ai` slot, so a page
|
|
29
|
+
* can replace the default translate button with a combined AI menu without
|
|
30
|
+
* re-implementing the translate machinery.
|
|
31
|
+
*/
|
|
32
|
+
interface LocaleFieldTranslateScope {
|
|
33
|
+
/** Translate exists for this field at all (multiple locales, not `no-translate`). */
|
|
34
|
+
available: boolean;
|
|
35
|
+
/** Translate is currently actionable (some tab has text, empty targets exist). */
|
|
36
|
+
canTranslate: boolean;
|
|
37
|
+
translating: boolean;
|
|
38
|
+
translate: () => void;
|
|
39
|
+
}
|
|
40
|
+
/** What a quick-translate provider returns — drives the sparkle button. */
|
|
41
|
+
interface LocaleFieldTranslator {
|
|
42
|
+
translating: Ref<boolean>;
|
|
43
|
+
canTranslate: Ref<boolean> | ComputedRef<boolean>;
|
|
44
|
+
translate: () => void;
|
|
45
|
+
}
|
|
46
|
+
interface UseTranslateOptions {
|
|
47
|
+
model: ModelRef<LocaleMap<string> | undefined>;
|
|
48
|
+
/** What the field is about (e.g. "Listing title") — passed as AI context. */
|
|
49
|
+
context: MaybeRefOrGetter<string | undefined>;
|
|
50
|
+
/** Source locale (from `useLocaleField().translateSource`). */
|
|
51
|
+
source: ComputedRef<string | null>;
|
|
52
|
+
/** Empty visible locales to fill (from `useLocaleField().translateTargets`). */
|
|
53
|
+
targetLocales: ComputedRef<string[]>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* App context the locale field components resolve at setup time. Both members
|
|
57
|
+
* are *factories invoked during the component's own setup*, so they may call
|
|
58
|
+
* composables (state, route, API clients) without executing at provide time.
|
|
59
|
+
*/
|
|
60
|
+
interface LocaleFieldContext {
|
|
61
|
+
/** Resolve the app's content-locale source. */
|
|
62
|
+
useSource: () => LocaleFieldSource;
|
|
63
|
+
/**
|
|
64
|
+
* Optional quick-translate provider; the AI-translate button renders only
|
|
65
|
+
* when this is present.
|
|
66
|
+
*/
|
|
67
|
+
useTranslate?: (options: UseTranslateOptions) => LocaleFieldTranslator;
|
|
68
|
+
}
|
|
69
|
+
declare const LOCALE_FIELD_CONTEXT: InjectionKey<LocaleFieldContext>;
|
|
70
|
+
/** Provide the locale-field context (call once, near the app root). */
|
|
71
|
+
declare function provideLocaleFieldContext(context: LocaleFieldContext): void;
|
|
72
|
+
/** Resolve the locale-field context inside a component (throws when absent). */
|
|
73
|
+
declare function useLocaleFieldContext(): LocaleFieldContext;
|
|
74
|
+
/**
|
|
75
|
+
* Tab plumbing shared by every per-locale field editor: one tab per content
|
|
76
|
+
* locale, an active-tab ref, and the completeness indicator. Value access
|
|
77
|
+
* stays with the caller — pass `hasValue` so the indicator works for any
|
|
78
|
+
* value type (strings, rich-text documents, …).
|
|
79
|
+
*
|
|
80
|
+
* **Register variants** (e.g. `de-formal`, whose base `de` is also supported)
|
|
81
|
+
* are a tone/overlay axis, not a real language: a label like "Hotel" is
|
|
82
|
+
* identical in both. So variant tabs are **hidden by default** and only shown
|
|
83
|
+
* when `registerOverride` is true (reader-addressing prose — descriptions,
|
|
84
|
+
* body copy). When shown, a blank variant value *inherits* its base locale
|
|
85
|
+
* (neutral hint, not a "missing" warning), and clearing the field should
|
|
86
|
+
* **delete the key** so the resolver falls through to the base
|
|
87
|
+
* (`de-formal → de`).
|
|
88
|
+
*/
|
|
89
|
+
declare function useLocaleTabs(hasValue: (locale: string) => boolean, source: LocaleFieldSource, registerOverride?: MaybeRefOrGetter<boolean>): {
|
|
90
|
+
items: ComputedRef<{
|
|
91
|
+
label: string;
|
|
92
|
+
value: string;
|
|
93
|
+
}[]>;
|
|
94
|
+
active: Ref<string, string>;
|
|
95
|
+
indicatorOf: (loc: string) => LocaleTabIndicator;
|
|
96
|
+
isVariant: (loc: string) => boolean;
|
|
97
|
+
defaultLocale: ComputedRef<string>;
|
|
98
|
+
visibleLocales: ComputedRef<string[]>;
|
|
99
|
+
translateSource: ComputedRef<string | null>;
|
|
100
|
+
translateTargets: ComputedRef<string[]>;
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* String-valued locale field over a single `LocaleMap<string>` model — the
|
|
104
|
+
* composable behind `LocaleInput` / `LocaleTextarea`. See
|
|
105
|
+
* {@link useLocaleTabs} for the tab/indicator semantics.
|
|
106
|
+
*/
|
|
107
|
+
declare function useLocaleField(model: ModelRef<LocaleMap<string> | undefined>, source: LocaleFieldSource, registerOverride?: MaybeRefOrGetter<boolean>): {
|
|
108
|
+
items: ComputedRef<{
|
|
109
|
+
label: string;
|
|
110
|
+
value: string;
|
|
111
|
+
}[]>;
|
|
112
|
+
active: Ref<string, string>;
|
|
113
|
+
activeValue: import("vue").WritableComputedRef<string, string>;
|
|
114
|
+
indicatorOf: (loc: string) => LocaleTabIndicator;
|
|
115
|
+
defaultLocale: ComputedRef<string>;
|
|
116
|
+
translateSource: ComputedRef<string | null>;
|
|
117
|
+
translateTargets: ComputedRef<string[]>;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Resolve a `LocaleMap<string>` to a single display string for list / detail
|
|
121
|
+
* surfaces, using the **default content locale** — deliberately decoupled
|
|
122
|
+
* from the app's own UI language, which is unrelated chrome and would
|
|
123
|
+
* otherwise select content arbitrarily. Lists therefore always show the
|
|
124
|
+
* canonical value, consistently for every user; the per-locale values stay
|
|
125
|
+
* fully editable via `LocaleInput` / `LocaleTextarea`.
|
|
126
|
+
*/
|
|
127
|
+
declare function createLocaleDisplay(source: Pick<LocaleFieldSource, 'defaultLocale'>): {
|
|
128
|
+
display: (map: LocaleMap<string> | null | undefined, fallback?: string) => string;
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Translation-completeness summary rendered by `TranslationBadge`:
|
|
132
|
+
* `complete` when every in-use translatable leaf covers all supported
|
|
133
|
+
* locales; `missing` counts absent leaves per locale otherwise.
|
|
134
|
+
*/
|
|
135
|
+
interface TranslationStatus {
|
|
136
|
+
complete: boolean;
|
|
137
|
+
missing: Record<string, number>;
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
export { LOCALE_FIELD_CONTEXT, LocaleFieldContext, LocaleFieldSource, LocaleFieldTranslateScope, LocaleFieldTranslator, LocaleTabIndicator, TranslationStatus, UseTranslateOptions, createLocaleDisplay, provideLocaleFieldContext, pruneLocaleMap, useLocaleField, useLocaleFieldContext, useLocaleTabs };
|