add storybook

This commit is contained in:
Gauthier Roebroeck 2025-06-12 16:30:00 +08:00
parent 143637dda5
commit 336a2fb87f
28 changed files with 2679 additions and 45 deletions

3
next-ui/.gitignore vendored
View file

@ -24,3 +24,6 @@ pnpm-debug.log*
# FormatJS compiled translation files
!/src/i18n/README.md
src/i18n
*storybook.log
storybook-static

View file

@ -0,0 +1,17 @@
<template>
<v-theme-provider
with-background
:theme="themeName"
class="pa-2"
>
<slot name="story"></slot>
</v-theme-provider>
</template>
<script lang="ts">
export default {
props: {
themeName: String,
},
}
</script>

View file

@ -0,0 +1,19 @@
import type { StorybookConfig } from '@storybook/vue3-vite'
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@chromatic-com/storybook',
'@storybook/addon-docs',
'@storybook/addon-onboarding',
'@storybook/addon-a11y',
'@storybook/addon-vitest',
'@storybook/addon-themes',
],
framework: {
name: '@storybook/vue3-vite',
options: {},
},
staticDirs: ['../public-msw'],
}
export default config

View file

@ -0,0 +1,6 @@
import { addons } from 'storybook/manager-api'
import theme from './theme'
addons.setConfig({
theme: theme,
})

View file

@ -0,0 +1,62 @@
import type { Preview } from '@storybook/vue3-vite'
import { setup } from '@storybook/vue3'
import { withVuetifyTheme } from './withVuetifyTheme.decorator'
import { initialize, mswLoader } from 'msw-storybook-addon'
import { handlers } from '@/mocks/api/handlers'
import { vuetify, vuetifyRulesPlugin } from '@/plugins/vuetify'
import { createPinia } from 'pinia'
import { PiniaColada } from '@pinia/colada'
import { PiniaColadaAutoRefetch } from '@pinia/colada-plugin-auto-refetch'
import { vueIntl } from '@/plugins/vue-intl'
initialize(
{
onUnhandledRequest: 'bypass',
},
handlers,
)
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo',
},
},
loaders: [mswLoader],
}
export default preview
setup((app) => {
// Registers your app's plugins into Storybook
app.use(vuetify)
app.use(vuetifyRulesPlugin)
app.use(vueIntl)
app.use(createPinia())
app.use(PiniaColada, {
plugins: [PiniaColadaAutoRefetch()],
})
})
export const decorators = [
withVuetifyTheme({
// These keys are the labels that will be displayed in the toolbar theme switcher
// The values must match the theme keys from your VuetifyOptions
themes: {
light: 'light',
dark: 'dark',
},
defaultTheme: 'light', // The key of your default theme
}),
]

0
next-ui/.storybook/storybook.d.ts vendored Normal file
View file

View file

@ -0,0 +1,8 @@
import { create } from 'storybook/theming'
export default create({
base: 'light',
brandTitle: 'Komga Storybook',
brandUrl: 'https://komga.org',
brandImage: '../src/assets/komga.svg',
})

View file

@ -0,0 +1,7 @@
import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'
import { setProjectAnnotations } from '@storybook/vue3-vite'
import * as projectAnnotations from './preview'
// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations])

View file

@ -0,0 +1,36 @@
import { h } from 'vue'
import { DecoratorHelpers } from '@storybook/addon-themes'
import StoryWrapper from './StoryWrapper.vue'
import type { StoryContext } from '@storybook/vue3-vite'
const { initializeThemeState, pluckThemeFromContext } = DecoratorHelpers
export const withVuetifyTheme = ({
themes,
defaultTheme,
}: {
themes: object
defaultTheme: string
}) => {
initializeThemeState(Object.keys(themes), defaultTheme)
return (storyFn: () => Component, context: StoryContext) => {
const selectedTheme = pluckThemeFromContext(context)
const { themeOverride } = context.parameters.themes ?? {}
const selected = themeOverride || selectedTheme || defaultTheme
const story = storyFn()
return () => {
return h(
StoryWrapper,
{ themeName: selected }, // Props for StoryWrapper
{
// Puts your story into StoryWrapper's "story" slot with your story args
story: () => h(story, { ...context.args }),
},
)
}
}
}

View file

@ -4,7 +4,10 @@
// noinspection JSUnusedGlobalSymbols
// Auto generated by vite-plugin-dir2json
declare module "*i18n?dir2json&ext=.json&1" {
const json: {};
const json: {
"en": string;
"fr": string;
};
export default json;
}

View file

@ -1,3 +1,5 @@
import storybook from 'eslint-plugin-storybook'
/**
* .eslint.js
*
@ -23,6 +25,8 @@ export default defineConfigWithVueTs(
'**/coverage/**',
'openapi-generator.mts',
'**/generated/openapi/komga.d.ts',
'public-msw/**/*',
'eslint.config.ts',
],
},
@ -66,4 +70,15 @@ export default defineConfigWithVueTs(
},
eslintConfigPrettier,
...storybook.configs['flat/recommended'],
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
)

View file

@ -35,6 +35,10 @@
"defaultMessage": "No restriction",
"description": "User creation/edit dialog: Age restriction field possible option"
},
"AjWlka": {
"defaultMessage": "Invalid login or password",
"description": "Login screen: error message displayed when login failed"
},
"CUxhzL": {
"defaultMessage": "Roles",
"description": "User creation/edit dialog: Roles field"
@ -75,6 +79,10 @@
"defaultMessage": "Users",
"description": "Drawer menu for Server > Users"
},
"JbF1nK": {
"defaultMessage": "Password changed for user: {email}",
"description": "Snackbar notification shown upon successful user's password modification"
},
"LaxrEO": {
"defaultMessage": "Passwords must be identical",
"description": "User password change dialog: Error message if passwords differ"
@ -111,6 +119,10 @@
"defaultMessage": "Shared Libraries",
"description": "User creation/edit dialog: Shared Libraries field"
},
"V/OYJE": {
"defaultMessage": "User deleted: {email}",
"description": "Snackbar notification shown upon successful user deletion"
},
"WNY0pu": {
"defaultMessage": "The latest version of Komga is already installed",
"description": "Updates view: banner shown at the top"
@ -119,6 +131,10 @@
"defaultMessage": "New password",
"description": "User password change dialog: New Password field label"
},
"Xz+JXU": {
"defaultMessage": "error",
"description": "Common message: an unkown error happened"
},
"Y6VlM9": {
"defaultMessage": "Read List",
"description": "Drawer menu for Import > Read List"
@ -127,6 +143,10 @@
"defaultMessage": "User Interface",
"description": "Drawer menu for Server > User Interface"
},
"Z/EY89": {
"defaultMessage": "Network error",
"description": "Common message: a network error happened when communicating with the server"
},
"app.locale-name": {
"defaultMessage": "English",
"description": "The name of the locale, shown in the language selection menu. Must be translated to the language's name"
@ -163,6 +183,10 @@
"defaultMessage": "Duplicate Files",
"description": "Drawer menu for Media > Duplicate Files"
},
"egrxd6": {
"defaultMessage": "User created: {email}",
"description": "Snackbar notification shown upon successful user creation"
},
"fQIepD": {
"defaultMessage": "Books",
"description": "Drawer menu for Import > Books"
@ -183,6 +207,10 @@
"defaultMessage": "Age",
"description": "User creation/edit dialog: Age Restriction > Age field label"
},
"kvbi4j": {
"defaultMessage": "User updated: {email}",
"description": "Snackbar notification shown upon successful user update"
},
"l/To3S": {
"defaultMessage": "History",
"description": "Drawer menu for History"
@ -219,6 +247,10 @@
"defaultMessage": "Unknown",
"description": "Drawer menu for Media > Duplicate Pages > Unknown"
},
"r6JNfI": {
"defaultMessage": "Forgot your password?",
"description": "Login screen: Forgot your password link"
},
"rw/Dkw": {
"defaultMessage": "User Interface",
"description": "Drawer menu for My Account > User Interface"

1750
next-ui/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,9 @@
"preview": "vite preview",
"build-only": "vite build",
"test": "vitest run",
"test:watch": "vitest watch",
"test:unit": "vitest run --project unit",
"test:unit:watch": "vitest watch --project unit",
"test:storybook": "vitest run --project storybook",
"type-check": "vue-tsc --build --force",
"lint": "eslint .",
"lint:fix": "npm run lint -- --fix",
@ -18,7 +20,9 @@
"openapi-generate": "npx tsx ./openapi-generator.mts",
"i18n:extract": "formatjs extract \"src/**/*.{ts,tsx,vue}\" --ignore=\"**/*.d.ts\" --out-file i18n/en.json",
"i18n:compile": "formatjs compile-folder i18n src/i18n",
"i18n:verify": "formatjs verify --missing-keys --source-locale=en \"i18n/*.json\""
"i18n:verify": "formatjs verify --missing-keys --source-locale=en \"i18n/*.json\"",
"storybook:dev": "storybook dev -p 6006",
"storybook:build": "storybook build"
},
"dependencies": {
"@pinia/colada": "^0.17.0",
@ -34,27 +38,40 @@
"vuetify": "^3.8.5"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.0.0",
"@eslint/js": "^9.28.0",
"@formatjs/cli": "^6.7.1",
"@iconify-json/mdi": "^1.2.3",
"@storybook/addon-a11y": "^9.0.8",
"@storybook/addon-docs": "^9.0.8",
"@storybook/addon-onboarding": "^9.0.8",
"@storybook/addon-themes": "^9.0.8",
"@storybook/addon-vitest": "^9.0.8",
"@storybook/vue3-vite": "^9.0.8",
"@testing-library/vue": "^8.1.0",
"@tsconfig/node22": "^22.0.2",
"@types/node": "^22.15.29",
"@vitejs/plugin-vue": "^5.1.4",
"@vitejs/plugin-vue": "^5.2.4",
"@vitest/browser": "^3.2.3",
"@vitest/coverage-v8": "^3.2.3",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.1.3",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-formatjs": "^5.3.1",
"eslint-plugin-storybook": "^9.0.8",
"eslint-plugin-vue": "^10.1.0",
"happy-dom": "^18.0.1",
"msw": "^2.10.2",
"msw-storybook-addon": "^2.0.5",
"npm-run-all2": "^8.0.4",
"openapi-typescript": "^7.8.0",
"playwright": "^1.53.0",
"prettier": "^3.5.3",
"sass": "^1.88.0",
"sass-embedded": "^1.88.0",
"storybook": "^9.0.8",
"typescript": "^5.8.3",
"unplugin-auto-import": "^19.3.0",
"unplugin-fonts": "^1.1.1",
@ -68,5 +85,10 @@
"vitest": "^3.2.3",
"vue-router": "^4.4.0",
"vue-tsc": "^2.1.10"
},
"msw": {
"workerDirectory": [
"public-msw"
]
}
}

View file

@ -0,0 +1,336 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.10.2'
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
*/
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter((value) => value !== 'msw/passthrough')
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}

View file

@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
height="512pt"
viewBox="0 0 1978.5619 512"
width="1978.5619pt"
version="1.1"
id="svg4586"
sodipodi:docname="komga.svg"
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata
id="metadata4592">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs4590">
<linearGradient
id="linearGradient6082"
inkscape:swatch="solid">
<stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop6080" />
</linearGradient>
<linearGradient
id="linearGradient6076"
inkscape:swatch="solid">
<stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop6074" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient6082"
id="linearGradient6084"
x1="77.866814"
y1="386.00677"
x2="217.20259"
y2="386.00677"
gradientUnits="userSpaceOnUse" />
</defs>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1512"
inkscape:window-height="916"
id="namedview4588"
showgrid="false"
inkscape:zoom="0.19440657"
inkscape:cx="1646.0349"
inkscape:cy="-444.94381"
inkscape:window-x="0"
inkscape:window-y="748"
inkscape:window-maximized="0"
inkscape:current-layer="svg4586"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="pt" />
<path
d="M 512,256 C 512,397.38672 397.38672,512 256,512 114.61328,512 0,397.38672 0,256 0,114.61328 114.61328,0 256,0 397.38672,0 512,114.61328 512,256 Z m 0,0"
fill="#005ed3"
id="path4556" />
<path
d="m 512,256 c 0,-11.71094 -0.80469,-23.23047 -2.32422,-34.52344 L 382.48047,94.28125 320.52344,121.85938 256,56.933594 212.69531,131.30469 129.51953,94.28125 141.86719,178.42187 49.949219,193.81641 114.32031,256 l -64.371091,62.18359 82.121091,82.16016 -2.55078,17.375 91.95703,91.95703 C 232.76953,511.19531 244.28906,512 256,512 397.38672,512 512,397.38672 512,256 Z"
id="path4558"
inkscape:connector-curvature="0"
style="fill:#00459f"
sodipodi:nodetypes="scccccccccccccss" />
<path
d="m 256,86.742188 37.10937,63.738282 70.57422,-31.41406 -10.52734,71.71875 77.07812,12.91015 L 376.08984,256 l 54.14453,52.30469 -77.07812,12.91015 10.52734,71.71875 L 293.10937,361.51953 256,425.25781 218.89062,361.51953 148.31641,392.93359 158.84375,321.21484 81.765625,308.30469 135.91016,256 81.765625,203.69531 l 77.078125,-12.91015 -10.52734,-71.71875 70.57421,31.41406 z m 0,0"
fill="#ff0335"
id="path4560" />
<path
d="m 430.23047,308.30078 -77.07031,12.91016 10.51953,71.71875 L 293.10938,361.51953 256,425.26172 V 86.738281 l 37.10938,63.742189 70.57031,-31.41016 -6.75781,46.10156 -3.76172,25.61719 58.80078,9.85156 18.26953,3.0586 -13.39063,12.92969 -40.75,39.37109 11.37891,10.98828 z m 0,0"
fill="#c2001b"
id="path4562" />
<path
d="m 256,455.06641 -43.30469,-74.3711 -83.17578,37.02344 12.34766,-84.14063 L 49.949219,318.18359 114.32031,256 49.949219,193.81641 141.86719,178.42187 129.51953,94.28125 212.69922,131.30469 256,56.933594 299.30469,131.30469 382.48047,94.28125 370.13281,178.42187 462.05078,193.81641 397.67969,256 l 64.37109,62.18359 -91.91797,15.39844 12.34766,84.13672 -83.17578,-37.02344 z M 225.08203,342.34375 256,395.44531 l 30.91797,-53.10156 57.96484,25.80078 -8.70312,-59.29297 62.23828,-10.42578 L 354.5,256 398.41797,213.57422 336.17969,203.14844 344.88281,143.85547 286.91797,169.65625 256,116.55469 l -30.91797,53.10156 -57.96484,-25.80078 8.70312,59.29297 -62.23828,10.42578 L 157.5,256 l -43.91797,42.42578 62.23828,10.42578 -8.70312,59.29297 z m 0,0"
fill="#ffdf47"
id="path4564" />
<path
d="M 403.30859,261.44141 397.67969,256 l 25.16015,-24.30078 39.21094,-37.87891 -55.75,-9.33984 -36.17187,-6.0586 2.80078,-19.09375 9.55078,-65.04687 -83.17969,37.01953 L 256,56.929688 v 59.621092 l 30.92188,53.10938 57.95703,-25.8086 -3.91016,26.66797 -2.54687,17.37891 -2.24219,15.25 2.48047,0.42187 59.76172,10.00781 L 354.5,256 l 16.96875,16.39062 26.95313,26.03125 -62.24219,10.42969 8.69922,59.29688 -57.95703,-25.8086 L 256,395.44922 v 59.62109 l 43.30078,-74.37109 83.17969,37.01953 -12.35156,-84.14063 91.92187,-15.39843 z m 0,0"
fill="#fec000"
id="path4566" />
<g
aria-label="K"
transform="matrix(1.1590846,-0.34467221,0.22789693,0.794981,0,0)"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:296.56px;line-height:125%;font-family:Impact;-inkscape-font-specification:Impact;letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.54529;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="text4596">
<path
d="m 220.91497,266.9035 -34.89789,105.85211 38.2284,128.58643 H 161.2555 L 136.63873,400.84769 V 501.34204 H 75.676021 V 266.9035 h 60.962709 v 91.08205 l 27.07845,-91.08205 z"
style="font-size:296.56px;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.54529;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path824" />
</g>
<path
style="font-weight:bold;font-size:354.967px;font-family:Sans;-inkscape-font-specification:'Sans, Bold';stroke-width:5.54637"
d="m 902.04822,379.80621 h -81.63548 l -77.12906,-103.30094 -15.59914,18.89228 v 84.40866 H 661.12823 V 121.72717 h 66.55631 v 116.8202 l 92.20823,-116.8202 h 77.12906 L 796.14742,241.14722 Z M 1130.3156,282.5716 q 0,48.01067 -28.0785,75.74247 -27.9051,27.55847 -78.5156,27.55847 -50.61054,0 -78.68898,-27.55847 -27.90512,-27.7318 -27.90512,-75.74247 0,-48.35732 28.07844,-75.9158 28.25177,-27.55847 78.51566,-27.55847 50.9572,0 78.689,27.7318 27.9051,27.73179 27.9051,75.74247 z m -76.7824,47.31738 q 6.0663,-7.45292 9.0128,-17.85235 3.1198,-10.57275 3.1198,-29.11838 0,-17.15905 -3.1198,-28.77174 -3.1198,-11.6127 -8.6662,-18.54564 -5.5463,-7.10628 -13.3459,-10.05278 -7.7996,-2.9465 -16.8124,-2.9465 -9.0128,0 -16.1191,2.42653 -6.933,2.42653 -13.34594,9.70613 -5.71968,6.75962 -9.18616,18.54564 -3.29315,11.78601 -3.29315,29.63836 0,15.94578 2.9465,27.73179 2.94651,11.61269 8.66619,18.71897 5.54636,6.75962 13.17256,9.87945 7.7996,3.11983 17.6791,3.11983 8.4928,0 16.1191,-2.77318 7.7995,-2.94651 13.1726,-9.70613 z m 311.8094,-88.04846 v 137.96569 h -62.7432 V 282.5716 q 0,-14.21255 -0.6933,-24.092 -0.6933,-10.05278 -3.8132,-16.29243 -3.1198,-6.23966 -9.5328,-9.01284 -6.2396,-2.9465 -17.679,-2.9465 -9.1861,0 -17.8523,3.81312 -8.6662,3.6398 -15.5992,7.79957 v 137.96569 h -62.3965 V 185.16366 h 62.3965 v 21.49214 q 16.1191,-12.65263 30.8517,-19.7589 14.7325,-7.10628 32.5848,-7.10628 19.239,0 33.9715,8.66619 14.7325,8.49286 23.052,25.13194 18.719,-15.77246 36.398,-24.78529 17.679,-9.01284 34.6648,-9.01284 31.5449,0 47.8373,18.89229 16.4658,18.89229 16.4658,54.42365 v 126.69965 h -62.7432 V 282.5716 q 0,-14.38587 -0.6933,-24.26533 -0.52,-9.87945 -3.6398,-16.1191 -2.9465,-6.23966 -9.3595,-9.01284 -6.413,-2.9465 -18.0257,-2.9465 -7.7995,0 -15.2525,2.77318 -7.4529,2.59986 -18.1989,8.83951 z m 374.5525,115.60693 q 0,27.38515 -7.7995,46.10412 -7.7996,18.71896 -21.8388,29.29171 -14.0392,10.74607 -33.9715,15.42581 -19.7589,4.85306 -44.7175,4.85306 -20.2789,0 -40.0378,-2.42653 -19.5856,-2.42653 -33.9714,-5.89301 v -48.70397 h 7.6262 q 11.4394,4.50642 27.9051,8.14622 16.4658,3.81312 29.4651,3.81312 17.3323,0 28.0784,-3.29315 10.9194,-3.11983 16.6391,-9.01283 5.373,-5.54636 7.7996,-14.21255 2.4265,-8.66619 2.4265,-20.79885 v -3.6398 q -11.266,9.18616 -24.9586,14.5592 -13.6926,5.37303 -30.505,5.37303 -40.9044,0 -63.0898,-24.61197 -22.1855,-24.61197 -22.1855,-74.87585 0,-24.092 6.7596,-41.5977 6.7597,-17.50569 19.0657,-30.50497 11.4393,-12.13267 28.0784,-18.89229 16.8124,-6.75963 34.3181,-6.75963 15.7725,0 28.5984,3.81312 12.9993,3.6398 23.572,10.22611 l 2.2533,-8.66619 h 60.4899 z m -62.3965,-38.99784 v -88.22178 q -5.373,-2.25321 -13.1726,-3.46648 -7.7996,-1.38659 -14.0392,-1.38659 -24.612,0 -36.918,14.21255 -12.306,14.03922 -12.306,39.34449 0,28.07844 10.3994,39.17116 10.5728,11.09272 31.1983,11.09272 9.3595,0 18.3723,-2.9465 9.0129,-2.9465 16.4658,-7.79957 z m 239.1867,10.57275 V 288.4646 q -12.6526,1.03995 -27.3851,2.94651 -14.7325,1.73324 -22.3588,4.15977 -9.3595,2.9465 -14.3858,8.66618 -4.8531,5.54636 -4.8531,14.73252 0,6.06633 1.0399,9.87946 1.04,3.81312 5.1998,7.27959 3.9864,3.46648 9.5328,5.19971 5.5463,1.55992 17.3323,1.55992 9.3595,0 18.8923,-3.81312 9.7061,-3.81313 16.9857,-10.05278 z m 0,30.15833 q -5.0263,3.81312 -12.4793,9.18616 -7.4529,5.37303 -14.0392,8.49286 -9.1861,4.15977 -19.0656,6.06633 -9.8795,2.07989 -21.6655,2.07989 -27.7318,0 -46.4507,-17.15905 -18.719,-17.15905 -18.719,-43.85091 0,-21.31882 9.5328,-34.83807 9.5328,-13.51925 27.0385,-21.31882 17.3324,-7.79957 42.9843,-11.09272 25.6519,-3.29315 53.2104,-4.85306 v -1.03995 q 0,-16.1191 -13.1726,-22.18543 -13.1726,-6.23966 -38.8245,-6.23966 -15.4258,0 -32.9315,5.54636 -17.5057,5.37304 -25.132,8.31954 h -5.7197 v -46.97073 q 9.8795,-2.59986 32.0649,-6.06633 22.3588,-3.6398 44.7176,-3.6398 53.2103,0 76.7824,16.46575 23.7453,16.29243 23.7453,51.30383 v 132.41933 h -61.8766 z"
id="text1"
aria-label="Komga" />
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -19,8 +19,8 @@ export const useAppReleases = defineQuery(() => {
const latestRelease = computed(() => data.value?.find((x) => x.latest))
const isLatestVersion = computed(() => {
if (buildVersion.value && latestRelease.value)
return buildVersion.value == latestRelease.value?.version
if (buildVersion.value && data.value)
return data.value?.some((x) => x.latest && x.version == buildVersion.value)
else return undefined
})

View file

@ -0,0 +1,49 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import BuildCommit from './BuildCommit.vue'
import { http, HttpResponse, delay } from 'msw'
import { baseUrl, response401Unauthorized } from '@/mocks/api/handlers/base'
import { actuatorResponseOk } from '@/mocks/api/handlers/actuator'
const meta = {
component: BuildCommit,
render: (args: object) => ({
components: { BuildCommit },
setup() {
return { args }
},
template: '<BuildCommit />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof BuildCommit>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get(baseUrl + 'actuator/info', async () => {
await delay(5_000)
return HttpResponse.json(actuatorResponseOk)
}),
],
},
},
}
export const Error: Story = {
parameters: {
msw: {
handlers: [http.get(baseUrl + 'actuator/info', response401Unauthorized)],
},
},
}

View file

@ -1,22 +1,22 @@
<template>
<template v-if="commitId">
<v-btn
:prepend-icon="mdiSourceCommit"
variant="text"
color="grey"
size="small"
class="text-caption"
:href="'https://github.com/gotson/komga/commits/' + commitId"
target="_blank"
>
{{ commitId }}
</v-btn>
</template>
<v-btn
:loading="isLoading"
:prepend-icon="mdiSourceCommit"
variant="text"
color="grey"
size="small"
class="text-caption"
:href="'https://github.com/gotson/komga/commits/' + commitId"
target="_blank"
>
{{ commitId || $formatMessage(commonMessages.error) }}
</v-btn>
</template>
<script setup lang="ts">
import mdiSourceCommit from '~icons/mdi/source-commit'
import { useActuatorInfo } from '@/colada/queries/actuator-info'
import { commonMessages } from '@/utils/i18n/common-messages'
const { commitId } = useActuatorInfo()
const { commitId, isLoading } = useActuatorInfo()
</script>

View file

@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { http, HttpResponse, delay } from 'msw'
import { baseUrl, response401Unauthorized } from '@/mocks/api/handlers/base'
import BuildVersion from './BuildVersion.vue'
import { releasesResponseOk, releasesResponseOkNotLatest } from '@/mocks/api/handlers/releases'
const meta = {
component: BuildVersion,
render: (args: object) => ({
components: { BuildVersion },
setup() {
return { args }
},
template: '<BuildVersion />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof BuildVersion>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const OutdatedVersion: Story = {
parameters: {
msw: {
handlers: [
http.get(baseUrl + 'api/v1/releases', () => HttpResponse.json(releasesResponseOkNotLatest)),
],
},
},
}
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get(baseUrl + 'api/v1/releases', async () => {
await delay(5_000)
return HttpResponse.json(releasesResponseOk)
}),
],
},
},
}
export const Error: Story = {
parameters: {
msw: {
handlers: [
http.get(baseUrl + 'actuator/info', response401Unauthorized),
http.get(baseUrl + 'api/v1/releases', response401Unauthorized),
],
},
},
}

View file

@ -1,27 +1,27 @@
<template>
<template v-if="buildVersion">
<v-badge
dot
color="warning"
:model-value="isLatestVersion == false"
<v-badge
dot
color="warning"
:model-value="isLatestVersion == false"
>
<v-btn
:loading="isLoading"
:prepend-icon="mdiTagOutline"
variant="text"
color="grey"
size="small"
class="text-caption"
to="/server/updates"
>
<v-btn
:prepend-icon="mdiTagOutline"
variant="text"
color="grey"
size="small"
class="text-caption"
to="/server/updates"
>
{{ buildVersion }}
</v-btn>
</v-badge>
</template>
{{ buildVersion || $formatMessage(commonMessages.error) }}
</v-btn>
</v-badge>
</template>
<script setup lang="ts">
import mdiTagOutline from '~icons/mdi/tag-outline'
import { useAppReleases } from '@/colada/queries/app-releases'
import { commonMessages } from '@/utils/i18n/common-messages'
const { buildVersion, isLatestVersion } = useAppReleases()
const { buildVersion, isLatestVersion, isLoading } = useAppReleases()
</script>

View file

@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import login from './login.vue'
const meta = {
component: login,
render: (args: object) => ({
components: { login },
setup() {
return { args }
},
template: '<login />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof login>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}

View file

@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import users from './users.vue'
const meta = {
component: users,
render: (args: object) => ({
components: { users },
setup() {
return { args }
},
template: '<users />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
// This component will have an automatically generated docsPage entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'],
} satisfies Meta<typeof users>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}

View file

@ -16,4 +16,9 @@ export const commonMessages = {
defaultMessage: 'Network error',
id: 'Z/EY89',
}),
error: defineMessage({
description: 'Common message: an unkown error happened',
defaultMessage: 'error',
id: 'Xz+JXU',
}),
}

View file

@ -1,5 +1,5 @@
import { defineMessage } from 'vue-intl'
import localeMessages from '../i18n?dir2json&ext=.json&1'
import localeMessages from '@/i18n?dir2json&ext=.json&1'
export const defaultLocale = 'en'

View file

@ -1,6 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "./dir2json.d.ts"],
"include": [
"env.d.ts",
"src/**/*",
"src/**/*.vue",
"./dir2json.d.ts",
"./.storybook/**/*",
"vitest.shims.d.ts"
],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",

View file

@ -16,6 +16,7 @@ import dir2json from 'vite-plugin-dir2json'
// Utilities
import { defineConfig } from 'vite'
import { fileURLToPath, URL } from 'node:url'
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'
// https://vitejs.dev/config/
export default defineConfig({
@ -84,7 +85,32 @@ export default defineConfig({
},
},
test: {
environment: 'happy-dom',
restoreMocks: true,
projects: [
{
extends: true,
test: {
name: 'unit',
environment: 'happy-dom',
restoreMocks: true,
},
},
{
test: {
name: 'storybook',
browser: {
enabled: true,
headless: true,
provider: 'playwright',
instances: [{ browser: 'chromium' }],
},
setupFiles: ['.storybook/vitest.setup.ts'],
},
plugins: [
// The plugin will run tests for the stories defined in your Storybook config
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
storybookTest(),
],
},
],
},
})

1
next-ui/vitest.shims.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="@vitest/browser/providers/playwright" />