@devopsplaybook.io/common-web 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
+ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-09-27
9
+
10
+ ### Added
11
+
12
+ - Initial Nuxt 4 layer package with shared design tokens and app-shell styles.
13
+ - Loading, alert, navigation, and offline-banner components.
14
+ - Theme, app-height, and offline-status composables.
15
+ - Shared browser services for authentication, configuration, preferences,
16
+ refresh intervals, user initialization, and timeouts.
17
+ - Vitest coverage against the packed package and a Nuxt playground.
@@ -0,0 +1,37 @@
1
+ # Contributing
2
+
3
+ ## Local checks
4
+
5
+ Use Node.js 22 and run the repository checks before opening a pull request:
6
+
7
+ ```sh
8
+ npm ci
9
+ npm run lint
10
+ npm test
11
+ npm run build
12
+ ```
13
+
14
+ The tests consume the generated npm tarball, not only the working tree.
15
+ `npm run build` checks the publish file list and builds the Nuxt playground.
16
+
17
+ ## Scope and compatibility
18
+
19
+ Keep the layer limited to UI and browser behavior that is shared by multiple
20
+ applications. Do not move app-domain components into this package. Components
21
+ must retain their accessibility semantics, and Vue APIs used by raw library
22
+ sources must be explicitly imported.
23
+
24
+ The supported framework peer dependency is Nuxt 4. A Nuxt major upgrade needs
25
+ a playground build and packed-tarball validation before the peer range changes.
26
+
27
+ ## Pull requests and releases
28
+
29
+ Use conventional commit titles and include a summary and test plan in the PR.
30
+ Keep releases semantic and update `package.json` and `CHANGELOG.md` together.
31
+ The `Main Build` workflow publishes a version to public npm only when that
32
+ version does not already exist. Do not publish manually from a developer
33
+ machine.
34
+
35
+ The first package PR uses equivalent local checks because the shared PR
36
+ workflow expects `package.json` to exist on `main`; subsequent PRs use the
37
+ shared npm PR workflow.
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026, DevOpsPlaybook
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # @devopsplaybook.io/common-web
2
+
3
+ A small, no-build Nuxt 4 layer containing shared design tokens, UI components,
4
+ composables, and browser services for DevOpsPlaybook web applications.
5
+
6
+ ## Quick start
7
+
8
+ ```sh
9
+ npm install @devopsplaybook.io/common-web
10
+ ```
11
+
12
+ Extend the layer from your Nuxt configuration:
13
+
14
+ ```ts
15
+ export default defineNuxtConfig({
16
+ extends: ["@devopsplaybook.io/common-web"],
17
+ });
18
+ ```
19
+
20
+ Layer components and composables are auto-imported by Nuxt. The package ships
21
+ raw Vue and TypeScript sources; your app's Nuxt build compiles them. Nuxt 4 is
22
+ required. The `playground/` directory demonstrates a consuming app.
23
+
24
+ ## Components
25
+
26
+ | Component | Purpose |
27
+ | --- | --- |
28
+ | `<Loading size="small" />` | Accessible loading indicator; `size` is `small` or `large`. |
29
+ | `<AlertMessages />` | Accessible live region displaying messages emitted on `EventBus`. |
30
+ | `<AppNavigation :links="links" />` | Navigation shell with a named `brand` slot and configurable links (`label`, `to`, optional `icon` and `active`). |
31
+ | `<OfflineBanner />` | Announces offline status and accepts replacement content through its default slot. |
32
+
33
+ `AlertMessages` listens for `EventTypes.ALERT_MESSAGE` events. Its payload is
34
+ `{ text, type?, durationMs? }`; the default display duration is five seconds.
35
+ `handleError(error)` logs the error and emits a corresponding error alert.
36
+
37
+ ## Composables and services
38
+
39
+ - `useTheme()` persists `light`/`dark` preferences in `localStorage` and applies
40
+ `data-theme` to the document root. Use `setTheme("system")` to return to the
41
+ operating-system preference.
42
+ - `useAppHeight()` updates `--app-height` from `visualViewport` when available
43
+ and cleans up resize listeners on unmount.
44
+ - `useOfflineStatus()` returns a reactive `isOnline` ref.
45
+ - Browser services are available through package deep imports, for example
46
+ `@devopsplaybook.io/common-web/services/AuthService`.
47
+ - `createAuthenticationStore()` returns the Pinia store definition; `Pinia`
48
+ must be available in the consuming app.
49
+
50
+ The canonical auth storage key is `auth_token`. During this compatibility
51
+ release, `AUTH_TOKEN` and `AUTH_TOKEN_KEY` are read and migrated to the
52
+ canonical key. Tokens remain in `localStorage`, matching existing applications.
53
+
54
+ ## Tokens and app-shell styles
55
+
56
+ The layer loads `assets/css/tokens.css` and `assets/css/app-shell.css`.
57
+ Tokens include `--color-*`, `--space-*`, `--text-*`, `--radius-*`,
58
+ `--shadow-*`, `--header-height`, and `--app-height`. Dark mode can be selected
59
+ with `data-theme="dark"` or comes from `prefers-color-scheme` when no explicit
60
+ light preference is set.
61
+
62
+ Load app-specific CSS after the layer and redefine only the values you need:
63
+
64
+ ```css
65
+ :root {
66
+ --color-primary: #145da0;
67
+ --header-height: 4.5rem;
68
+ }
69
+ ```
70
+
71
+ ## Overriding a component
72
+
73
+ Nuxt gives app-local components priority over layer components. Add a component
74
+ with the same name under your app's `components/` directory, for example
75
+ `components/Loading.vue`, and it replaces the layer's `<Loading />` without
76
+ forking or modifying this package. App-specific navigation items are passed
77
+ through the `links` prop rather than embedded in the layer.
78
+
79
+ Deep imports are also available for tooling and tests that do not run inside a
80
+ Nuxt app:
81
+
82
+ ```ts
83
+ import Loading from "@devopsplaybook.io/common-web/components/Loading.vue";
84
+ ```
85
+
86
+ ## Compatibility and release policy
87
+
88
+ This release supports Nuxt `^4.0.0` and Pinia `^2.0.0 || ^3.0.0`. Nuxt 3
89
+ applications (including Telepathy) are not supported.
90
+
91
+ For a Nuxt major upgrade, update the peer dependency, build the playground with
92
+ the new major, test all exported components/composables from the packed
93
+ tarball, and verify a scratch consumer can extend the layer before releasing.
94
+ Keep releases semantic: patch for fixes, minor for backwards-compatible
95
+ features, and major for breaking API or framework changes. The merge workflow
96
+ publishes a new public npm version when the package version is not already
97
+ published; bump `package.json` manually on the release PR.
98
+
99
+ The initial bootstrap PR uses a repository-local workflow that runs install,
100
+ audit, lint, packed-package tests, and the playground build. This is necessary
101
+ because the shared PR workflow compares against `origin/main:package.json`,
102
+ which does not exist before this first package change. Once the manifest is on
103
+ `main`, PRs use the shared npm PR workflow; main builds always use the shared
104
+ publish workflow.
105
+
106
+ ## Development
107
+
108
+ ```sh
109
+ npm ci
110
+ npm run lint
111
+ npm test
112
+ npm run build
113
+ npm run dev
114
+ ```
115
+
116
+ `npm test` packs the library and runs component and service tests against that
117
+ archive. `npm run build` validates its contents and builds the playground.
118
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution and release details.
@@ -0,0 +1,36 @@
1
+ #page-layout {
2
+ display: grid;
3
+ grid-template-rows: auto minmax(0, 1fr);
4
+ min-height: var(--app-height, 100dvh);
5
+ }
6
+
7
+ #page-alert-messages {
8
+ position: fixed;
9
+ right: var(--space-xl, 2rem);
10
+ bottom: var(--space-xl, 2rem);
11
+ z-index: 1000;
12
+ max-width: min(80vw, 36rem);
13
+ }
14
+
15
+ @keyframes dpb-fade {
16
+ from {
17
+ opacity: 0;
18
+ }
19
+ to {
20
+ opacity: 1;
21
+ }
22
+ }
23
+
24
+ @keyframes dpb-blink {
25
+ 50% {
26
+ opacity: 0.35;
27
+ }
28
+ }
29
+
30
+ .dpb-fade-in {
31
+ animation: dpb-fade 180ms ease-out;
32
+ }
33
+
34
+ .dpb-blink {
35
+ animation: dpb-blink 1s ease-in-out infinite;
36
+ }
@@ -0,0 +1,81 @@
1
+ :root {
2
+ color-scheme: light;
3
+ --color-primary: #c62828;
4
+ --color-primary-hover: #a61f1f;
5
+ --color-text: #171717;
6
+ --color-text-muted: #5f6368;
7
+ --color-text-inverse: #fff;
8
+ --color-surface: #fff;
9
+ --color-surface-raised: #f5f5f5;
10
+ --color-surface-message: #fff9c4;
11
+ --color-surface-message-info: #c7e5c8;
12
+ --color-border: #d6d6d6;
13
+ --color-success: #2e7d32;
14
+ --color-error: #c62828;
15
+ --color-warning: #8a5a00;
16
+
17
+ --space-xs: 0.25rem;
18
+ --space-sm: 0.5rem;
19
+ --space-base: 1rem;
20
+ --space-md: 1.25rem;
21
+ --space-lg: 1.5rem;
22
+ --space-xl: 2rem;
23
+ --space-2xl: 3rem;
24
+
25
+ --text-xs: 0.75rem;
26
+ --text-sm: 0.875rem;
27
+ --text-base: 1rem;
28
+ --text-lg: 1.125rem;
29
+ --text-xl: 1.25rem;
30
+ --text-2xl: 1.5rem;
31
+
32
+ --radius-sm: 0.25rem;
33
+ --radius-md: 0.375rem;
34
+ --radius-lg: 0.5rem;
35
+ --radius-full: 9999px;
36
+
37
+ --shadow-sm: 0 1px 2px rgb(0 0 0 / 10%);
38
+ --shadow-md: 0 4px 12px rgb(0 0 0 / 15%);
39
+ --shadow-lg: 0 8px 24px rgb(0 0 0 / 20%);
40
+
41
+ --header-height: 4rem;
42
+ --app-height: 100dvh;
43
+ }
44
+
45
+ :root[data-theme="dark"] {
46
+ color-scheme: dark;
47
+ --color-text: #e0e0e0;
48
+ --color-text-muted: #b0b0b0;
49
+ --color-text-inverse: #fff;
50
+ --color-surface: #1a1a2e;
51
+ --color-surface-raised: #25253a;
52
+ --color-surface-message: #3a3520;
53
+ --color-surface-message-info: #1c3a1d;
54
+ --color-border: #44445a;
55
+ --color-success: #43a047;
56
+ --color-error: #e53935;
57
+ --color-warning: #f0b429;
58
+ --shadow-sm: 0 1px 2px rgb(0 0 0 / 30%);
59
+ --shadow-md: 0 4px 12px rgb(0 0 0 / 40%);
60
+ --shadow-lg: 0 8px 24px rgb(0 0 0 / 50%);
61
+ }
62
+
63
+ @media (prefers-color-scheme: dark) {
64
+ :root:not([data-theme="light"]) {
65
+ color-scheme: dark;
66
+ --color-text: #e0e0e0;
67
+ --color-text-muted: #b0b0b0;
68
+ --color-text-inverse: #fff;
69
+ --color-surface: #1a1a2e;
70
+ --color-surface-raised: #25253a;
71
+ --color-surface-message: #3a3520;
72
+ --color-surface-message-info: #1c3a1d;
73
+ --color-border: #44445a;
74
+ --color-success: #43a047;
75
+ --color-error: #e53935;
76
+ --color-warning: #f0b429;
77
+ --shadow-sm: 0 1px 2px rgb(0 0 0 / 30%);
78
+ --shadow-md: 0 4px 12px rgb(0 0 0 / 40%);
79
+ --shadow-lg: 0 8px 24px rgb(0 0 0 / 50%);
80
+ }
81
+ }
@@ -0,0 +1,77 @@
1
+ <template>
2
+ <div role="status" aria-live="polite" class="alert-messages">
3
+ <div v-for="message in messages" :key="message.id">
4
+ <div
5
+ :class="message.type ? `message message-${message.type}` : 'message'"
6
+ >
7
+ <span class="message-text">
8
+ <b v-if="message.type">{{ message.type }}</b>
9
+ {{ message.text }}
10
+ </span>
11
+ </div>
12
+ </div>
13
+ </div>
14
+ </template>
15
+
16
+ <script setup lang="ts">
17
+ import { onMounted, onUnmounted, ref } from "vue";
18
+ import { EventBus, EventTypes, type AlertMessage } from "../composables/EventBus";
19
+
20
+ interface DisplayMessage extends AlertMessage {
21
+ id: number;
22
+ }
23
+
24
+ const messages = ref<DisplayMessage[]>([]);
25
+ const timers = new Set<ReturnType<typeof setTimeout>>();
26
+ let nextId = 1;
27
+
28
+ function dismiss(id: number): void {
29
+ const index = messages.value.findIndex((message) => message.id === id);
30
+ if (index !== -1) messages.value.splice(index, 1);
31
+ }
32
+
33
+ function handleAlert(message: AlertMessage): void {
34
+ const entry = { ...message, id: nextId++ };
35
+ messages.value.push(entry);
36
+ const timer = setTimeout(() => {
37
+ timers.delete(timer);
38
+ dismiss(entry.id);
39
+ }, message.durationMs || 5000);
40
+ timers.add(timer);
41
+ }
42
+
43
+ onMounted(() => EventBus.on(EventTypes.ALERT_MESSAGE, handleAlert));
44
+ onUnmounted(() => {
45
+ EventBus.off(EventTypes.ALERT_MESSAGE, handleAlert);
46
+ for (const timer of timers) clearTimeout(timer);
47
+ timers.clear();
48
+ });
49
+ </script>
50
+
51
+ <style>
52
+ .message {
53
+ display: flex;
54
+ align-items: center;
55
+ padding: var(--space-base, 1rem);
56
+ margin: var(--space-base, 1rem);
57
+ color: var(--color-text-inverse, #fff);
58
+ background-color: #546e7a;
59
+ }
60
+
61
+ .message-text {
62
+ flex: 1;
63
+ }
64
+
65
+ .message-info,
66
+ .message-success {
67
+ background-color: var(--color-success, #2e7d32);
68
+ }
69
+
70
+ .message-warning {
71
+ background-color: var(--color-warning, #8a5a00);
72
+ }
73
+
74
+ .message-error {
75
+ background-color: var(--color-error, #c62828);
76
+ }
77
+ </style>
@@ -0,0 +1,59 @@
1
+ <template>
2
+ <nav class="app-navigation" aria-label="Main navigation">
3
+ <div class="app-navigation-brand">
4
+ <slot name="brand" />
5
+ </div>
6
+ <ul class="app-navigation-links">
7
+ <li v-for="link in links" :key="link.to">
8
+ <a :href="link.to" :aria-current="link.active ? 'page' : undefined">
9
+ <span v-if="link.icon" :class="link.icon" aria-hidden="true" />
10
+ {{ link.label }}
11
+ </a>
12
+ </li>
13
+ </ul>
14
+ <slot />
15
+ </nav>
16
+ </template>
17
+
18
+ <script setup lang="ts">
19
+ interface AppNavigationLink {
20
+ label: string;
21
+ to: string;
22
+ icon?: string;
23
+ active?: boolean;
24
+ }
25
+
26
+ defineProps<{ links: AppNavigationLink[] }>();
27
+ </script>
28
+
29
+ <style>
30
+ .app-navigation {
31
+ display: flex;
32
+ align-items: center;
33
+ gap: var(--space-base, 1rem);
34
+ min-height: var(--header-height, 4rem);
35
+ padding-inline: var(--space-base, 1rem);
36
+ color: var(--color-text, inherit);
37
+ background: var(--color-surface, transparent);
38
+ }
39
+
40
+ .app-navigation-brand {
41
+ display: flex;
42
+ align-items: center;
43
+ flex-shrink: 0;
44
+ }
45
+
46
+ .app-navigation-links {
47
+ display: flex;
48
+ align-items: center;
49
+ gap: var(--space-sm, 0.5rem);
50
+ padding: 0;
51
+ margin: 0;
52
+ list-style: none;
53
+ }
54
+
55
+ .app-navigation a {
56
+ color: inherit;
57
+ text-decoration: none;
58
+ }
59
+ </style>
@@ -0,0 +1,67 @@
1
+ <template>
2
+ <div
3
+ class="loading-indicator"
4
+ :class="{ 'loading-indicator-small': size === 'small' }"
5
+ role="status"
6
+ aria-live="polite"
7
+ :aria-label="label"
8
+ />
9
+ </template>
10
+
11
+ <script setup lang="ts">
12
+ withDefaults(defineProps<{ size?: "small" | "large"; label?: string }>(), {
13
+ size: "large",
14
+ label: "Loading",
15
+ });
16
+ </script>
17
+
18
+ <style>
19
+ .loading-indicator {
20
+ --loading-color: currentColor;
21
+ --loading-gradient: no-repeat linear-gradient(var(--loading-color) 0 0);
22
+ width: 15%;
23
+ min-width: 2rem;
24
+ margin: 20% auto;
25
+ aspect-ratio: 1;
26
+ background:
27
+ var(--loading-gradient) 0% 50%,
28
+ var(--loading-gradient) 50% 50%,
29
+ var(--loading-gradient) 100% 50%;
30
+ background-size: 20% 100%;
31
+ animation: dpb-loading 2s infinite linear;
32
+ }
33
+
34
+ .loading-indicator-small {
35
+ width: 3rem;
36
+ margin-top: 0.5rem;
37
+ margin-bottom: 0.5rem;
38
+ }
39
+
40
+ @keyframes dpb-loading {
41
+ 0%,
42
+ 100% {
43
+ background-size:
44
+ 20% 100%,
45
+ 20% 100%,
46
+ 20% 100%;
47
+ }
48
+ 33% {
49
+ background-size:
50
+ 20% 10%,
51
+ 20% 100%,
52
+ 20% 100%;
53
+ }
54
+ 50% {
55
+ background-size:
56
+ 20% 100%,
57
+ 20% 10%,
58
+ 20% 100%;
59
+ }
60
+ 66% {
61
+ background-size:
62
+ 20% 100%,
63
+ 20% 100%,
64
+ 20% 10%;
65
+ }
66
+ }
67
+ </style>
@@ -0,0 +1,20 @@
1
+ <template>
2
+ <div v-if="!isOnline" class="offline-banner" role="status" aria-live="polite">
3
+ <slot>You are offline — showing cached data</slot>
4
+ </div>
5
+ </template>
6
+
7
+ <script setup lang="ts">
8
+ import { useOfflineStatus } from "../composables/useOfflineStatus";
9
+
10
+ const { isOnline } = useOfflineStatus();
11
+ </script>
12
+
13
+ <style>
14
+ .offline-banner {
15
+ padding: var(--space-sm, 0.5rem) var(--space-base, 1rem);
16
+ color: var(--color-text-inverse, #fff);
17
+ background: var(--color-warning, #8a5a00);
18
+ text-align: center;
19
+ }
20
+ </style>
@@ -0,0 +1,43 @@
1
+ import mitt from "mitt";
2
+
3
+ export interface AlertMessage {
4
+ type?: "info" | "success" | "warning" | "error" | string;
5
+ text: string;
6
+ durationMs?: number;
7
+ }
8
+
9
+ interface AppEvents {
10
+ ALERT_MESSAGE: AlertMessage;
11
+ AUTH_UPDATED: void;
12
+ }
13
+
14
+ export const EventTypes = {
15
+ ALERT_MESSAGE: "ALERT_MESSAGE",
16
+ AUTH_UPDATED: "AUTH_UPDATED",
17
+ } as const;
18
+
19
+ export type EventType = (typeof EventTypes)[keyof typeof EventTypes];
20
+
21
+ export const EventBus = mitt<AppEvents>();
22
+
23
+ export function handleError(error: unknown): void {
24
+ console.error(error);
25
+ let text = error instanceof Error ? error.message : String(error);
26
+
27
+ if (typeof error === "object" && error !== null && "response" in error) {
28
+ const response = error.response;
29
+ if (
30
+ typeof response === "object" &&
31
+ response !== null &&
32
+ "data" in response &&
33
+ typeof response.data === "object" &&
34
+ response.data !== null &&
35
+ "error" in response.data &&
36
+ typeof response.data.error === "string"
37
+ ) {
38
+ text = response.data.error;
39
+ }
40
+ }
41
+
42
+ EventBus.emit(EventTypes.ALERT_MESSAGE, { type: "error", text });
43
+ }
@@ -0,0 +1,19 @@
1
+ import { onMounted, onUnmounted } from "vue";
2
+
3
+ export function useAppHeight(): void {
4
+ function updateAppHeight(): void {
5
+ const height = window.visualViewport?.height ?? window.innerHeight;
6
+ document.documentElement.style.setProperty("--app-height", `${height}px`);
7
+ }
8
+
9
+ onMounted(() => {
10
+ updateAppHeight();
11
+ window.addEventListener("resize", updateAppHeight);
12
+ window.visualViewport?.addEventListener("resize", updateAppHeight);
13
+ });
14
+
15
+ onUnmounted(() => {
16
+ window.removeEventListener("resize", updateAppHeight);
17
+ window.visualViewport?.removeEventListener("resize", updateAppHeight);
18
+ });
19
+ }
@@ -0,0 +1,22 @@
1
+ import { onMounted, onUnmounted, ref } from "vue";
2
+
3
+ export function useOfflineStatus() {
4
+ const isOnline = ref(true);
5
+
6
+ function updateStatus(): void {
7
+ isOnline.value = navigator.onLine;
8
+ }
9
+
10
+ onMounted(() => {
11
+ updateStatus();
12
+ window.addEventListener("online", updateStatus);
13
+ window.addEventListener("offline", updateStatus);
14
+ });
15
+
16
+ onUnmounted(() => {
17
+ window.removeEventListener("online", updateStatus);
18
+ window.removeEventListener("offline", updateStatus);
19
+ });
20
+
21
+ return { isOnline };
22
+ }
@@ -0,0 +1,56 @@
1
+ import { computed, onMounted, onUnmounted, ref } from "vue";
2
+
3
+ export type ThemePreference = "light" | "dark" | "system";
4
+
5
+ const THEME_KEY = "UI_THEME";
6
+
7
+ export function useTheme() {
8
+ const preference = ref<ThemePreference>("system");
9
+ const isDark = computed(() => preference.value === "dark");
10
+ let mediaQuery: MediaQueryList | undefined;
11
+
12
+ function applyTheme(): void {
13
+ if (typeof document === "undefined") return;
14
+
15
+ const prefersDark = mediaQuery?.matches ?? false;
16
+ const theme =
17
+ preference.value === "system"
18
+ ? prefersDark
19
+ ? "dark"
20
+ : "light"
21
+ : preference.value;
22
+ document.documentElement.setAttribute("data-theme", theme);
23
+ }
24
+
25
+ function setTheme(theme: ThemePreference): void {
26
+ preference.value = theme;
27
+ if (typeof localStorage !== "undefined") {
28
+ if (theme === "system") localStorage.removeItem(THEME_KEY);
29
+ else localStorage.setItem(THEME_KEY, theme);
30
+ }
31
+ applyTheme();
32
+ }
33
+
34
+ function toggleTheme(): void {
35
+ setTheme(isDark.value ? "light" : "dark");
36
+ }
37
+
38
+ function handleSystemThemeChange(): void {
39
+ if (preference.value === "system") applyTheme();
40
+ }
41
+
42
+ onMounted(() => {
43
+ const storedTheme = localStorage.getItem(THEME_KEY);
44
+ preference.value =
45
+ storedTheme === "light" || storedTheme === "dark" ? storedTheme : "system";
46
+ mediaQuery = window.matchMedia?.("(prefers-color-scheme: dark)");
47
+ mediaQuery?.addEventListener?.("change", handleSystemThemeChange);
48
+ applyTheme();
49
+ });
50
+
51
+ onUnmounted(() => {
52
+ mediaQuery?.removeEventListener?.("change", handleSystemThemeChange);
53
+ });
54
+
55
+ return { preference, isDark, setTheme, toggleTheme };
56
+ }
package/nuxt.config.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ export default defineNuxtConfig({
4
+ css: [
5
+ fileURLToPath(new URL("./assets/css/tokens.css", import.meta.url)),
6
+ fileURLToPath(new URL("./assets/css/app-shell.css", import.meta.url)),
7
+ ],
8
+ });
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@devopsplaybook.io/common-web",
3
+ "version": "0.1.0",
4
+ "description": "Shared Nuxt layer for DevOpsPlaybook web applications",
5
+ "license": "ISC",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "type": "module",
10
+ "main": "./nuxt.config.ts",
11
+ "exports": {
12
+ ".": "./nuxt.config.ts",
13
+ "./components/*": "./components/*",
14
+ "./composables/*": "./composables/*",
15
+ "./services/*": "./services/*",
16
+ "./stores/*": "./stores/*"
17
+ },
18
+ "files": [
19
+ "nuxt.config.ts",
20
+ "assets",
21
+ "components",
22
+ "composables",
23
+ "services",
24
+ "stores",
25
+ "README.md",
26
+ "CHANGELOG.md",
27
+ "CONTRIBUTING.md",
28
+ "LICENSE"
29
+ ],
30
+ "scripts": {
31
+ "dev": "nuxi dev playground",
32
+ "lint": "oxlint components composables services stores tests scripts playground vitest.config.ts nuxt.config.ts",
33
+ "test": "node scripts/test-packed.mjs",
34
+ "build": "node scripts/build-package.mjs"
35
+ },
36
+ "peerDependencies": {
37
+ "nuxt": "^4.0.0",
38
+ "pinia": "^2.0.0 || ^3.0.0"
39
+ },
40
+ "dependencies": {
41
+ "axios": "^1.12.2",
42
+ "jwt-decode": "^4.0.0",
43
+ "mitt": "^3.0.1"
44
+ },
45
+ "devDependencies": {
46
+ "@vitejs/plugin-vue": "^6.0.9",
47
+ "@vitest/coverage-v8": "^5.0.2",
48
+ "@vue/test-utils": "^2.5.1",
49
+ "jsdom": "^30.1.1",
50
+ "nuxt": "4.5.2",
51
+ "oxlint": "^1.85.0",
52
+ "pinia": "^3.0.4",
53
+ "vitest": "^5.0.2"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ }
58
+ }
@@ -0,0 +1,58 @@
1
+ import { jwtDecode } from "jwt-decode";
2
+
3
+ export const AUTH_TOKEN_KEY = "auth_token";
4
+ const LEGACY_TOKEN_KEYS = ["AUTH_TOKEN", "AUTH_TOKEN_KEY"] as const;
5
+
6
+ export interface AuthHeaders {
7
+ headers?: {
8
+ Authorization: string;
9
+ };
10
+ }
11
+
12
+ export class AuthService {
13
+ static async isAuthenticated(): Promise<boolean> {
14
+ return Boolean(await AuthService.getToken());
15
+ }
16
+
17
+ static async saveToken(token: string): Promise<void> {
18
+ localStorage.setItem(AUTH_TOKEN_KEY, token);
19
+ for (const key of LEGACY_TOKEN_KEYS) localStorage.removeItem(key);
20
+ }
21
+
22
+ static async removeToken(_token?: string): Promise<void> {
23
+ localStorage.removeItem(AUTH_TOKEN_KEY);
24
+ for (const key of LEGACY_TOKEN_KEYS) localStorage.removeItem(key);
25
+ }
26
+
27
+ static async getToken(): Promise<string | null> {
28
+ let token = localStorage.getItem(AUTH_TOKEN_KEY);
29
+
30
+ if (!token) {
31
+ for (const legacyKey of LEGACY_TOKEN_KEYS) {
32
+ token = localStorage.getItem(legacyKey);
33
+ if (token) {
34
+ localStorage.setItem(AUTH_TOKEN_KEY, token);
35
+ localStorage.removeItem(legacyKey);
36
+ break;
37
+ }
38
+ }
39
+ }
40
+
41
+ if (!token) return null;
42
+
43
+ const decoded = jwtDecode<{ exp?: number }>(token);
44
+ if (typeof decoded.exp === "number" && decoded.exp <= Date.now() / 1000) {
45
+ await AuthService.removeToken();
46
+ return null;
47
+ }
48
+
49
+ return token;
50
+ }
51
+
52
+ static async getAuthHeader(): Promise<AuthHeaders> {
53
+ const token = await AuthService.getToken();
54
+ return token
55
+ ? { headers: { Authorization: `Bearer ${token}` } }
56
+ : {};
57
+ }
58
+ }
@@ -0,0 +1,9 @@
1
+ export interface AppConfig {
2
+ SERVER_URL: string;
3
+ }
4
+
5
+ export default class Config {
6
+ static async get(): Promise<AppConfig> {
7
+ return { SERVER_URL: "/api" };
8
+ }
9
+ }
@@ -0,0 +1,13 @@
1
+ export const PreferencesService = {
2
+ get(key: string): string | null {
3
+ return localStorage.getItem(key);
4
+ },
5
+
6
+ set(key: string, value: string): void {
7
+ localStorage.setItem(key, value);
8
+ },
9
+
10
+ remove(key: string): void {
11
+ localStorage.removeItem(key);
12
+ },
13
+ };
@@ -0,0 +1,12 @@
1
+ export class RefreshIntervalService {
2
+ static readonly KEY = "REFRESH_INTERVAL";
3
+ static readonly DEFAULT = "10000";
4
+
5
+ static get(): string {
6
+ return localStorage.getItem(RefreshIntervalService.KEY) ?? RefreshIntervalService.DEFAULT;
7
+ }
8
+
9
+ static set(value: string): void {
10
+ localStorage.setItem(RefreshIntervalService.KEY, value);
11
+ }
12
+ }
@@ -0,0 +1,7 @@
1
+ export class Timeout {
2
+ static wait(duration: number): Promise<void> {
3
+ return new Promise((resolve) => {
4
+ setTimeout(resolve, duration);
5
+ });
6
+ }
7
+ }
@@ -0,0 +1,12 @@
1
+ import axios from "axios";
2
+ import Config from "./Config";
3
+
4
+ export class UserService {
5
+ static async isInitialized(): Promise<boolean> {
6
+ const config = await Config.get();
7
+ const response = await axios.get<{ initialized: boolean }>(
8
+ `${config.SERVER_URL}/users/status/initialization`,
9
+ );
10
+ return response.data.initialized;
11
+ }
12
+ }
@@ -0,0 +1,25 @@
1
+ import { acceptHMRUpdate, defineStore } from "pinia";
2
+ import { AuthService } from "../services/AuthService";
3
+
4
+ export function createAuthenticationStore() {
5
+ return defineStore("AuthenticationStore", {
6
+ state: () => ({
7
+ isAuthenticated: false,
8
+ }),
9
+
10
+ actions: {
11
+ async ensureAuthenticated(): Promise<boolean> {
12
+ this.isAuthenticated = await AuthService.isAuthenticated();
13
+ return this.isAuthenticated;
14
+ },
15
+ },
16
+ });
17
+ }
18
+
19
+ export const AuthenticationStore = createAuthenticationStore();
20
+
21
+ if (import.meta.hot) {
22
+ import.meta.hot.accept(
23
+ acceptHMRUpdate(AuthenticationStore, import.meta.hot),
24
+ );
25
+ }