@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 +17 -0
- package/CONTRIBUTING.md +37 -0
- package/LICENSE +15 -0
- package/README.md +118 -0
- package/assets/css/app-shell.css +36 -0
- package/assets/css/tokens.css +81 -0
- package/components/AlertMessages.vue +77 -0
- package/components/AppNavigation.vue +59 -0
- package/components/Loading.vue +67 -0
- package/components/OfflineBanner.vue +20 -0
- package/composables/EventBus.ts +43 -0
- package/composables/useAppHeight.ts +19 -0
- package/composables/useOfflineStatus.ts +22 -0
- package/composables/useTheme.ts +56 -0
- package/nuxt.config.ts +8 -0
- package/package.json +58 -0
- package/services/AuthService.ts +58 -0
- package/services/Config.ts +9 -0
- package/services/PreferencesService.ts +13 -0
- package/services/RefreshIntervalService.ts +12 -0
- package/services/Timeout.ts +7 -0
- package/services/UserService.ts +12 -0
- package/stores/AuthenticationStore.ts +25 -0
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.
|
package/CONTRIBUTING.md
ADDED
|
@@ -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
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,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,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
|
+
}
|