@octabits-io/nuxt-ui-kit 0.2.1 → 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 -297
- package/dist/index.js +54 -321
- 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/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: "" });
|
|
@@ -470,6 +156,53 @@ function createApiErrorMessenger(options) {
|
|
|
470
156
|
};
|
|
471
157
|
}
|
|
472
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
|
|
473
206
|
//#region src/composables/useDirtyTracking.ts
|
|
474
207
|
function deepClone(obj) {
|
|
475
208
|
return JSON.parse(JSON.stringify(obj));
|
|
@@ -535,4 +268,4 @@ function usePagination(options = {}) {
|
|
|
535
268
|
};
|
|
536
269
|
}
|
|
537
270
|
//#endregion
|
|
538
|
-
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 };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { computed, inject, provide, ref, toValue, watchEffect } from "vue";
|
|
2
|
+
import { baseLocaleOf, resolveLocale } from "@octabits-io/framework/utils";
|
|
3
|
+
//#region src/locale/pruneLocaleMap.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
|
+
function pruneLocaleMap(map) {
|
|
11
|
+
return Object.fromEntries(Object.entries(map ?? {}).filter(([, v]) => !!v));
|
|
12
|
+
}
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/locale/index.ts
|
|
15
|
+
const LOCALE_FIELD_CONTEXT = Symbol("nuxt-ui-kit:locale-field-context");
|
|
16
|
+
/** Provide the locale-field context (call once, near the app root). */
|
|
17
|
+
function provideLocaleFieldContext(context) {
|
|
18
|
+
provide(LOCALE_FIELD_CONTEXT, context);
|
|
19
|
+
}
|
|
20
|
+
/** Resolve the locale-field context inside a component (throws when absent). */
|
|
21
|
+
function useLocaleFieldContext() {
|
|
22
|
+
const context = inject(LOCALE_FIELD_CONTEXT, null);
|
|
23
|
+
if (!context) throw new Error("[nuxt-ui-kit] LocaleInput/LocaleTextarea need provideLocaleFieldContext() near the app root.");
|
|
24
|
+
return context;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Tab plumbing shared by every per-locale field editor: one tab per content
|
|
28
|
+
* locale, an active-tab ref, and the completeness indicator. Value access
|
|
29
|
+
* stays with the caller — pass `hasValue` so the indicator works for any
|
|
30
|
+
* value type (strings, rich-text documents, …).
|
|
31
|
+
*
|
|
32
|
+
* **Register variants** (e.g. `de-formal`, whose base `de` is also supported)
|
|
33
|
+
* are a tone/overlay axis, not a real language: a label like "Hotel" is
|
|
34
|
+
* identical in both. So variant tabs are **hidden by default** and only shown
|
|
35
|
+
* when `registerOverride` is true (reader-addressing prose — descriptions,
|
|
36
|
+
* body copy). When shown, a blank variant value *inherits* its base locale
|
|
37
|
+
* (neutral hint, not a "missing" warning), and clearing the field should
|
|
38
|
+
* **delete the key** so the resolver falls through to the base
|
|
39
|
+
* (`de-formal → de`).
|
|
40
|
+
*/
|
|
41
|
+
function useLocaleTabs(hasValue, source, registerOverride = false) {
|
|
42
|
+
const supportedLocales = computed(() => toValue(source.locales));
|
|
43
|
+
const defaultLocale = computed(() => toValue(source.defaultLocale));
|
|
44
|
+
/** A locale whose base language is itself also supported (e.g. `de-formal` ⊂ `de`). */
|
|
45
|
+
const isVariant = (loc) => baseLocaleOf(loc) !== loc && supportedLocales.value.includes(baseLocaleOf(loc));
|
|
46
|
+
const visibleLocales = computed(() => {
|
|
47
|
+
const filtered = toValue(registerOverride) ? supportedLocales.value : supportedLocales.value.filter((loc) => !isVariant(loc));
|
|
48
|
+
return filtered.includes(defaultLocale.value) ? filtered : [...filtered, defaultLocale.value];
|
|
49
|
+
});
|
|
50
|
+
const active = ref("");
|
|
51
|
+
watchEffect(() => {
|
|
52
|
+
if (!visibleLocales.value.includes(active.value)) active.value = visibleLocales.value.includes(defaultLocale.value) ? defaultLocale.value : visibleLocales.value[0] ?? "";
|
|
53
|
+
});
|
|
54
|
+
/**
|
|
55
|
+
* A register variant carries a lowercase BCP-47 *variant* subtag (e.g.
|
|
56
|
+
* `de-formal`) — as opposed to a region (`de-AT`) or script (`zh-Hans`).
|
|
57
|
+
* Formality is a tenant-wide choice, so the tab reads as the plain language
|
|
58
|
+
* ("DE"), not the internal `DE-FORMAL` token.
|
|
59
|
+
*/
|
|
60
|
+
const REGISTER_VARIANT = /-[a-z]{4,8}$/;
|
|
61
|
+
const tabLabel = (loc) => (REGISTER_VARIANT.test(loc) ? baseLocaleOf(loc) : loc).toUpperCase();
|
|
62
|
+
const items = computed(() => visibleLocales.value.map((loc) => ({
|
|
63
|
+
label: tabLabel(loc),
|
|
64
|
+
value: loc
|
|
65
|
+
})));
|
|
66
|
+
const indicatorOf = (loc) => {
|
|
67
|
+
if (hasValue(loc)) return null;
|
|
68
|
+
if (isVariant(loc)) return { kind: "inherits" };
|
|
69
|
+
return loc === defaultLocale.value ? { kind: "error" } : { kind: "warning" };
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Source locale for quick translate: the active tab when it has a value, so
|
|
73
|
+
* freshly edited text wins; otherwise the default locale, otherwise the
|
|
74
|
+
* first filled visible locale. `null` when every tab is empty. The fallback
|
|
75
|
+
* matters because the operator typically sits on the tab they want FILLED —
|
|
76
|
+
* translate must not go dead just because the active tab is the empty one.
|
|
77
|
+
*/
|
|
78
|
+
const translateSource = computed(() => {
|
|
79
|
+
if (hasValue(active.value)) return active.value;
|
|
80
|
+
if (hasValue(defaultLocale.value)) return defaultLocale.value;
|
|
81
|
+
return visibleLocales.value.find(hasValue) ?? null;
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
items,
|
|
85
|
+
active,
|
|
86
|
+
indicatorOf,
|
|
87
|
+
isVariant,
|
|
88
|
+
defaultLocale,
|
|
89
|
+
visibleLocales,
|
|
90
|
+
translateSource,
|
|
91
|
+
translateTargets: computed(() => visibleLocales.value.filter((loc) => loc !== translateSource.value && !hasValue(loc) && (!isVariant(loc) || loc === defaultLocale.value)))
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* String-valued locale field over a single `LocaleMap<string>` model — the
|
|
96
|
+
* composable behind `LocaleInput` / `LocaleTextarea`. See
|
|
97
|
+
* {@link useLocaleTabs} for the tab/indicator semantics.
|
|
98
|
+
*/
|
|
99
|
+
function useLocaleField(model, source, registerOverride = false) {
|
|
100
|
+
const hasValue = (loc) => {
|
|
101
|
+
const v = model.value?.[loc];
|
|
102
|
+
return typeof v === "string" && v.length > 0;
|
|
103
|
+
};
|
|
104
|
+
const { items, active, indicatorOf, isVariant, defaultLocale, translateSource, translateTargets } = useLocaleTabs(hasValue, source, registerOverride);
|
|
105
|
+
return {
|
|
106
|
+
items,
|
|
107
|
+
active,
|
|
108
|
+
activeValue: computed({
|
|
109
|
+
get: () => model.value?.[active.value] ?? "",
|
|
110
|
+
set: (val) => {
|
|
111
|
+
const next = { ...model.value ?? {} };
|
|
112
|
+
if (val === "" && isVariant(active.value)) delete next[active.value];
|
|
113
|
+
else next[active.value] = val;
|
|
114
|
+
model.value = next;
|
|
115
|
+
}
|
|
116
|
+
}),
|
|
117
|
+
indicatorOf,
|
|
118
|
+
defaultLocale,
|
|
119
|
+
translateSource,
|
|
120
|
+
translateTargets
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Resolve a `LocaleMap<string>` to a single display string for list / detail
|
|
125
|
+
* surfaces, using the **default content locale** — deliberately decoupled
|
|
126
|
+
* from the app's own UI language, which is unrelated chrome and would
|
|
127
|
+
* otherwise select content arbitrarily. Lists therefore always show the
|
|
128
|
+
* canonical value, consistently for every user; the per-locale values stay
|
|
129
|
+
* fully editable via `LocaleInput` / `LocaleTextarea`.
|
|
130
|
+
*/
|
|
131
|
+
function createLocaleDisplay(source) {
|
|
132
|
+
/** Resolve a LocaleMap to its default-locale string, falling back when empty. */
|
|
133
|
+
function display(map, fallback = "") {
|
|
134
|
+
const defaultLocale = toValue(source.defaultLocale);
|
|
135
|
+
return resolveLocale(map, defaultLocale, defaultLocale) ?? fallback;
|
|
136
|
+
}
|
|
137
|
+
return { display };
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
export { LOCALE_FIELD_CONTEXT, createLocaleDisplay, provideLocaleFieldContext, pruneLocaleMap, useLocaleField, useLocaleFieldContext, useLocaleTabs };
|