quasar stuff

This commit is contained in:
Gauthier Roebroeck 2025-06-05 12:50:45 +08:00
parent e3a7342ea6
commit 66892c9232
23 changed files with 1167 additions and 307 deletions

View file

@ -12,6 +12,8 @@
"@pinia/colada": "^0.16.1",
"@pinia/colada-plugin-auto-refetch": "^0.1.0",
"@quasar/extras": "^1.16.4",
"@vueuse/core": "^13.3.0",
"marked": "^15.0.12",
"openapi-fetch": "^0.14.0",
"pinia": "^3.0.1",
"pinia-plugin-persistedstate": "^4.3.0",
@ -1928,6 +1930,12 @@
"@types/send": "*"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.33.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.0.tgz",
@ -2461,6 +2469,44 @@
"integrity": "sha512-c/0fWy3Jw6Z8L9FmTyYfkpM5zklnqqa9+a6dz3DvONRKW2NEbh46BP0FHuLFSWi2TnQEtp91Z6zOWNrU6QiyPg==",
"license": "MIT"
},
"node_modules/@vueuse/core": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.3.0.tgz",
"integrity": "sha512-uYRz5oEfebHCoRhK4moXFM3NSCd5vu2XMLOq/Riz5FdqZMy2RvBtazdtL3gEcmDyqkztDe9ZP/zymObMIbiYSg==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "13.3.0",
"@vueuse/shared": "13.3.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.3.0.tgz",
"integrity": "sha512-42IzJIOYCKIb0Yjv1JfaKpx8JlCiTmtCWrPxt7Ja6Wzoq0h79+YVXmBV03N966KEmDEESTbp5R/qO3AB5BDnGw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.3.0.tgz",
"integrity": "sha512-L1QKsF0Eg9tiZSFXTgodYnu0Rsa2P0En2LuLrIs/jgrkyiDuJSsPZK+tx+wU0mMsYHUYEjNsuE41uqqkuR8VhA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@ -5590,6 +5636,18 @@
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/marked": {
"version": "15.0.12",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View file

@ -19,6 +19,8 @@
"@pinia/colada": "^0.16.1",
"@pinia/colada-plugin-auto-refetch": "^0.1.0",
"@quasar/extras": "^1.16.4",
"@vueuse/core": "^13.3.0",
"marked": "^15.0.12",
"openapi-fetch": "^0.14.0",
"pinia": "^3.0.1",
"pinia-plugin-persistedstate": "^4.3.0",

View file

@ -3,7 +3,11 @@
import { defineConfig } from '#q-app/wrappers'
import { VueRouterAutoImports } from 'unplugin-vue-router'
import { QuasarResolver } from 'unplugin-vue-components/resolvers'
import {
QuasarResolver,
VueUseComponentsResolver,
VueUseDirectiveResolver,
} from 'unplugin-vue-components/resolvers'
import { fileURLToPath } from 'node:url'
export default defineConfig((ctx) => {
@ -91,11 +95,7 @@ export default defineConfig((ctx) => {
dts: 'src/components.d.ts',
directoryAsNamespace: true,
collapseSamePrefixes: true,
resolvers: [
QuasarResolver(),
// VueUseComponentsResolver(),
// VueUseDirectiveResolver()
],
resolvers: [QuasarResolver()],
},
],
[
@ -149,7 +149,7 @@ export default defineConfig((ctx) => {
// directives: [],
// Quasar plugins
plugins: ['Notify'],
plugins: ['Notify', 'Dialog'],
},
// animations: 'all', // --- includes all animations

View file

@ -3,7 +3,14 @@
</template>
<script setup lang="ts">
//
import { useAppStore } from 'stores/app'
import { useQuasar } from 'quasar'
const appStore = useAppStore()
const $q = useQuasar()
// initialize theme on startup
$q.dark.set(appStore.theme)
</script>
<style lang="scss">

View file

@ -17,12 +17,16 @@ declare module 'vue' {
AppDrawerMenuImport: typeof import('./components/app/drawer/menu/Import.vue')['default']
AppDrawerMenuLogout: typeof import('./components/app/drawer/menu/Logout.vue')['default']
AppDrawerMenuMedia: typeof import('./components/app/drawer/menu/Media.vue')['default']
AppDrawerMenuMenu: typeof import('./components/app/drawer/menu/Menu.vue')['default']
AppDrawerMenuServer: typeof import('./components/app/drawer/menu/Server.vue')['default']
EmptyState: typeof import('./components/EmptyState.vue')['default']
EssentialLink: typeof import('./components/EssentialLink.vue')['default']
ExampleComponent: typeof import('./components/ExampleComponent.vue')['default']
FormUserChangePassword: typeof import('./components/form/user/ChangePassword.vue')['default']
FormUserEdit: typeof import('./components/form/user/Edit.vue')['default']
KBanner: typeof import('./components/KBanner.vue')['default']
KEmptyState: typeof import('./components/KEmptyState.vue')['default']
QTable: typeof import('quasar')['QTable']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ThemeSelector: typeof import('./components/ThemeSelector.vue')['default']
}
}

View file

@ -0,0 +1,94 @@
<template>
<div :class="type">
<q-banner
rounded
class="k-banner"
>
<div class="row items-center">
<q-icon
:name="icon"
class="k-banner-icon"
size="md"
left
/>
<span class="text-subtitle1 k-banner-text">
<slot />
</span>
</div>
</q-banner>
</div>
</template>
<script setup lang="ts">
interface Props {
type?: 'positive' | 'warning'
tonal?: boolean
}
const { type } = defineProps<Props>()
const icon = computed(() => {
switch (type) {
case 'positive':
return 'mdi-check-circle'
case 'warning':
return 'mdi-alert-circle'
}
})
</script>
<script lang="ts"></script>
<style scoped lang="scss">
@use 'quasar/src/css/variables' as q;
.body--light {
.positive {
.k-banner {
background: q.$green-1;
}
.k-banner-icon {
color: q.$green;
}
.k-banner-text {
color: q.$green;
}
}
.warning {
.k-banner {
background: q.$orange-1;
}
.k-banner-icon {
color: q.$orange;
}
.k-banner-text {
color: q.$orange;
}
}
}
.body--dark {
.positive {
.k-banner {
background: #1a291b;
}
.k-banner-icon {
color: q.$green;
}
.k-banner-text {
color: q.$green;
}
}
.warning {
.k-banner {
background: #372413;
}
.k-banner-icon {
color: q.$orange;
}
.k-banner-text {
color: q.$orange;
}
}
}
</style>

View file

@ -0,0 +1,47 @@
<template>
<q-btn
:icon="themeIcon"
round
flat
:color="$q.dark.isActive ? 'white' : 'black'"
@click="cycleTheme()"
/>
</template>
<script setup lang="ts">
import { useAppStore } from 'stores/app'
import { useQuasar } from 'quasar'
const appStore = useAppStore()
const $q = useQuasar()
const themes = [
{
value: false,
icon: 'mdi-weather-sunny',
},
{
value: true,
icon: 'mdi-weather-night',
},
{
value: 'auto',
icon: 'mdi-theme-light-dark',
},
]
const themeIcon = computed(
() => themes.find((x) => x.value === appStore.theme)?.icon || 'mdi-theme-light-dark',
)
function cycleTheme() {
const index = themes.findIndex((x) => x.value === appStore.theme)
const newIndex = (index + 1) % themes.length
appStore.theme = themes[newIndex]!.value as 'auto' | boolean
$q.dark.set(appStore.theme)
}
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -1,11 +1,16 @@
<template>
<q-header elevated>
<q-header
:elevated="!$q.dark.isActive"
:class="$q.dark.isActive ? 'bg-dark' : 'bg-white'"
>
<q-toolbar>
<q-btn
flat
dense
round
icon="mdi-menu"
:color="$q.dark.isActive ? 'white' : 'dark'"
class="q-mr-md"
aria-label="Menu"
@click="appStore.drawer = !appStore.drawer"
/>
@ -16,9 +21,11 @@
</q-avatar>
</RouterLink>
<q-toolbar-title>Komga</q-toolbar-title>
<q-toolbar-title :class="$q.dark.isActive ? 'text-white' : 'text-dark'"
>Komga</q-toolbar-title
>
<div>Quasar v{{ $q.version }}</div>
<ThemeSelector />
</q-toolbar>
</q-header>
</template>

View file

@ -33,21 +33,12 @@
}}
</q-item-section>
</template>
<!-- <template #prepend>-->
<!-- <v-badge-->
<!-- :model-value="unreadCount > 0 && !($refs as any).group.isOpen"-->
<!-- dot-->
<!-- floating-->
<!-- color="info"-->
<!-- >-->
<!-- <v-icon>mdi-cog</v-icon>-->
<!-- </v-badge>-->
<!-- </template>-->
<q-item
to="/server/users"
clickable
:inset-level="1"
active-class="drawer-menu-active"
>
<q-item-section>
{{
@ -64,6 +55,7 @@
to="/server/announcements"
clickable
:inset-level="1"
active-class="drawer-menu-active"
>
<q-item-section>
<q-item-label
@ -92,6 +84,7 @@
to="/server/updates"
clickable
:inset-level="1"
active-class="drawer-menu-active"
>
<q-item-section>{{
$formatMessage({
@ -176,4 +169,5 @@ const expanded = ref<boolean>(false)
<style scoped lang="scss">
@use 'styles/transitions/fab';
@use 'styles/drawer';
</style>

View file

@ -0,0 +1 @@
Simple forms that can be wrapped by a `v-form`, or used within a `DialogEditConfirm`.

View file

@ -0,0 +1,128 @@
<template>
<q-dialog
ref="dialogRef"
@hide="onDialogHide"
>
<q-card class="q-dialog-plugin q-pa-xs">
<q-card-section v-if="title || subtitle">
<div class="text-h6">{{ props.title }}</div>
<div class="text-subtitle1 text-weight-light">{{ props.subtitle }}</div>
</q-card-section>
<q-form
greedy
@submit="onDialogOK(newPassword)"
>
<q-card-section>
<q-input
v-model="newPassword"
:rules="[required()]"
lazy-rules
:label="
$formatMessage({
description: 'User password change dialog: New Password field label',
defaultMessage: 'New password',
id: 'WhasCZ',
})
"
autocomplete="off"
autofocus
outlined
:type="showPassword ? 'text' : 'password'"
>
<template #append>
<q-btn
flat
round
:icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click="showPassword = !showPassword"
:ripple="false"
/>
</template>
</q-input>
<q-input
v-model="confirmPassword"
class="q-mt-sm"
:rules="[
sameAs(
newPassword,
$formatMessage({
description: 'User password change dialog: Error message if passwords differ',
defaultMessage: 'Passwords must be identical',
id: 'LaxrEO',
}),
),
]"
lazy-rules
:label="
$formatMessage({
description: 'User password change dialog: Confirm Password field label',
defaultMessage: 'Confirm password',
id: 'nJiYF7',
})
"
autocomplete="off"
outlined
:type="showPassword ? 'text' : 'password'"
>
<template #append>
<q-btn
flat
round
:icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click="showPassword = !showPassword"
:ripple="false"
/>
</template>
</q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn
label="Cancel"
flat
rounded
color="primary"
@click="onDialogCancel"
/>
<q-btn
label="Save"
flat
rounded
color="primary"
type="submit"
/>
</q-card-actions>
</q-form>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { useDialogPluginComponent } from 'quasar'
import { required, sameAs } from 'utils/rules'
const props = defineProps<{
title?: string
subtitle?: string
}>()
defineEmits([
// REQUIRED; need to specify some events that your
// component will emit through useDialogPluginComponent()
...useDialogPluginComponent.emits,
])
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
// dialogRef - Vue ref to be applied to QDialog
// onDialogHide - Function to be used as handler for @hide on QDialog
// onDialogOK - Function to call to settle dialog with "ok" outcome
// example: onDialogOK() - no payload
// example: onDialogOK({ /*...*/ }) - with payload
// onDialogCancel - Function to call to settle dialog with "cancel" outcome
const newPassword = ref<string>()
const confirmPassword = ref<string>()
const showPassword = ref<boolean>(false)
</script>

View file

@ -0,0 +1,373 @@
<template>
<q-dialog
ref="dialogRef"
@hide="onDialogHide"
>
<q-card
class="q-dialog-plugin q-pa-xs"
style="width: 600px"
>
<q-card-section v-if="title || subtitle">
<div class="text-h6">{{ title }}</div>
<div class="text-subtitle1 text-weight-light">{{ subtitle }}</div>
</q-card-section>
<q-form
greedy
@submit="onDialogOK(userEdit)"
>
<q-card-section>
<template v-if="!userEdit.id">
<q-input
v-model="userEdit!.email"
autofocus
:rules="[required(), (x, r) => r.email(x) || 'Must be a valid email address']"
:label="
$formatMessage({
description: 'User creation dialog: Email field',
defaultMessage: 'Email',
id: 'ToD0+o',
})
"
icon="mdi-account"
outlined
/>
<q-input
v-model="userEdit.password"
:rules="[required()]"
:label="
$formatMessage({
description: 'User creation dialog: Password field',
defaultMessage: 'Password',
id: 'o+A10T',
})
"
autocomplete="off"
outlined
:type="showPassword ? 'text' : 'password'"
>
<template #append>
<q-btn
flat
round
:icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click="showPassword = !showPassword"
:ripple="false"
/>
</template>
</q-input>
</template>
<!-- Roles -->
<q-select
v-model="userEdit.roles"
multiple
outlined
:label="
$formatMessage({
description: 'User creation/edit dialog: Roles field',
defaultMessage: 'Roles',
id: 'CUxhzL',
})
"
:options="userRoles"
emit-value
use-chips
>
<template v-slot:before>
<q-icon name="mdi-key-chain" />
</template>
</q-select>
<!-- Shared libraries -->
<!-- <v-select-->
<!-- v-model="user.sharedLibraries!.libraryIds"-->
<!-- multiple-->
<!-- :label="-->
<!-- $formatMessage({-->
<!-- description: 'User creation/edit dialog: Shared Libraries field',-->
<!-- defaultMessage: 'Shared Libraries',-->
<!-- id: 'UvhIIT',-->
<!-- })-->
<!-- "-->
<!-- :items="libraries"-->
<!-- item-title="name"-->
<!-- item-value="id"-->
<!-- prepend-icon="mdi-book-multiple"-->
<!-- >-->
<!-- &lt;!&ndash; Workaround for the lack of a slot to override the whole selection &ndash;&gt;-->
<!-- <template #prepend-inner>-->
<!-- &lt;!&ndash; Show an All Libraries chip instead of the selection &ndash;&gt;-->
<!-- <v-chip-->
<!-- v-if="user.sharedLibraries?.all"-->
<!-- :text="-->
<!-- $formatMessage({-->
<!-- description:-->
<!-- 'User creation/edit dialog: Shared Libraries field, value shown when user has access to all libraries',-->
<!-- defaultMessage: 'All libraries',-->
<!-- id: 'app.user-create-dialog.all_libraries',-->
<!-- })-->
<!-- "-->
<!-- size="small"-->
<!-- />-->
<!-- </template>-->
<!-- <template #selection="{ item }">-->
<!-- &lt;!&ndash; Show the selection only if 'all' is false &ndash;&gt;-->
<!-- <v-chip-->
<!-- v-if="!user.sharedLibraries?.all"-->
<!-- size="small"-->
<!-- :text="item.title"-->
<!-- />-->
<!-- </template>-->
<!-- <template #prepend-item>-->
<!-- <v-list-item-->
<!-- :title="-->
<!-- $formatMessage({-->
<!-- description:-->
<!-- 'User creation/edit dialog: Shared Libraries field, value shown when user has access to all libraries',-->
<!-- defaultMessage: 'All libraries',-->
<!-- id: 'app.user-create-dialog.all_libraries',-->
<!-- })-->
<!-- "-->
<!-- @click="selectAllLibraries"-->
<!-- >-->
<!-- <template #prepend>-->
<!-- <v-checkbox-btn :model-value="user.sharedLibraries?.all" />-->
<!-- </template>-->
<!-- </v-list-item>-->
<!-- </template>-->
<!-- <template #item="{ props: itemProps }">-->
<!-- <v-list-item-->
<!-- :disabled="user.sharedLibraries?.all"-->
<!-- v-bind="itemProps"-->
<!-- >-->
<!-- <template #prepend="{ isSelected }">-->
<!-- <v-checkbox-btn :model-value="isSelected" />-->
<!-- </template>-->
<!-- </v-list-item>-->
<!-- </template>-->
<!-- </v-select>-->
<!-- Age restriction -->
<!-- <v-row>-->
<!-- <v-col>-->
<!-- <v-select-->
<!-- v-model="user.ageRestriction!.restriction"-->
<!-- :label="-->
<!-- $formatMessage({-->
<!-- description: 'User creation/edit dialog: Age restriction field label',-->
<!-- defaultMessage: 'Age restriction',-->
<!-- id: 'hEOGa9',-->
<!-- })-->
<!-- "-->
<!-- :items="ageRestrictions"-->
<!-- prepend-icon="mdi-folder-lock"-->
<!-- />-->
<!-- </v-col>-->
<!-- <v-col>-->
<!-- <v-number-input-->
<!-- v-model="user.ageRestriction!.age"-->
<!-- :disabled="user.ageRestriction?.restriction?.toString() === 'NONE'"-->
<!-- :label="-->
<!-- $formatMessage({-->
<!-- description: 'User creation/edit dialog: Age Restriction > Age field label',-->
<!-- defaultMessage: 'Age',-->
<!-- id: 'jywpqq',-->
<!-- })-->
<!-- "-->
<!-- :min="0"-->
<!-- :rules="[rules.required()]"-->
<!-- />-->
<!-- </v-col>-->
<!-- </v-row>-->
<!-- Allow labels -->
<!-- <v-combobox-->
<!-- v-model="user.labelsAllow"-->
<!-- :label="-->
<!-- $formatMessage({-->
<!-- description: 'User creation/edit dialog: Allow only labels field label',-->
<!-- defaultMessage: 'Allow only labels',-->
<!-- id: 'Sj0HXz',-->
<!-- })-->
<!-- "-->
<!-- chips-->
<!-- closable-chips-->
<!-- multiple-->
<!-- :items="sharingLabels"-->
<!-- prepend-icon="mdi-none"-->
<!-- >-->
<!-- <template #prepend-item>-->
<!-- <v-list-item>-->
<!-- <span class="font-weight-medium">-->
<!-- {{-->
<!-- $formatMessage({-->
<!-- description: 'User creation/edit dialog: Allow only labels field selection',-->
<!-- defaultMessage: 'Select an item or create one',-->
<!-- id: 'app.user-create-dialog.select_create_one',-->
<!-- })-->
<!-- }}-->
<!-- </span>-->
<!-- </v-list-item>-->
<!-- </template>-->
<!-- </v-combobox>-->
<!-- Exclude labels -->
<!-- <v-combobox-->
<!-- v-model="user.labelsExclude"-->
<!-- :label="-->
<!-- $formatMessage({-->
<!-- description: 'User creation/edit dialog: Exclude labels field label',-->
<!-- defaultMessage: 'Exclude labels',-->
<!-- id: '3W0jUi',-->
<!-- })-->
<!-- "-->
<!-- chips-->
<!-- closable-chips-->
<!-- multiple-->
<!-- :items="sharingLabels"-->
<!-- prepend-icon="mdi-none"-->
<!-- >-->
<!-- <template #prepend-item>-->
<!-- <v-list-item>-->
<!-- <span class="font-weight-medium">-->
<!-- {{-->
<!-- $formatMessage({-->
<!-- description: 'User creation/edit dialog: Exclude labels field selection',-->
<!-- defaultMessage: 'Select an item or create one',-->
<!-- id: 'app.user-create-dialog.select_create_one',-->
<!-- })-->
<!-- }}-->
<!-- </span>-->
<!-- </v-list-item>-->
<!-- </template>-->
<!-- </v-combobox>-->
</q-card-section>
<q-card-actions align="right">
<q-btn
label="Cancel"
flat
rounded
color="primary"
@click="onDialogCancel"
/>
<q-btn
label="Save"
flat
rounded
color="primary"
type="submit"
/>
</q-card-actions>
</q-form>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { useDialogPluginComponent, extend } from 'quasar'
import { UserRoles } from 'types/UserRoles'
import type { components } from 'openapi/komga'
import { useLibraries } from 'colada/queries/libraries'
import { useSharingLabels } from 'colada/queries/referential'
import { useIntl } from 'vue-intl'
import { required } from 'utils/rules'
const { data: libraries } = useLibraries()
const { data: sharingLabels } = useSharingLabels()
interface Props {
title?: string
subtitle?: string
user?: UserUpdate | UserCreation
}
const {
subtitle,
title,
user = {
email: '',
password: '',
roles: [UserRoles.PAGE_STREAMING, UserRoles.FILE_DOWNLOAD],
sharedLibraries: {
all: true,
libraryIds: [],
},
ageRestriction: {
age: 0,
restriction: 'NONE',
},
} as UserCreation,
} = defineProps<Props>()
const userEdit = reactive<UserCreation | UserUpdate>(extend(true, {}, user))
defineEmits([
// REQUIRED; need to specify some events that your
// component will emit through useDialogPluginComponent()
...useDialogPluginComponent.emits,
])
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
// dialogRef - Vue ref to be applied to QDialog
// onDialogHide - Function to be used as handler for @hide on QDialog
// onDialogOK - Function to call to settle dialog with "ok" outcome
// example: onDialogOK() - no payload
// example: onDialogOK({ /*...*/ }) - with payload
// onDialogCancel - Function to call to settle dialog with "cancel" outcome
const intl = useIntl()
interface UserExtend {
id?: string
email: string
password?: string
}
type UserCreation = components['schemas']['UserCreationDto'] & UserExtend
type UserUpdate = components['schemas']['UserUpdateDto'] & UserExtend
const showPassword = ref<boolean>(false)
function selectAllLibraries() {
user.sharedLibraries!.all = !user.sharedLibraries?.all
user.sharedLibraries!.libraryIds = libraries.value?.map((x) => x.id) || []
}
const userRoles = computed(() =>
Object.keys(UserRoles).map((x) => ({
label: x,
value: x,
})),
)
const ageRestrictions = [
{
title: intl.formatMessage({
description: 'User creation/edit dialog: Age restriction field possible option',
defaultMessage: 'No restriction',
id: 'AeA9Ka',
}),
value: 'NONE',
},
{
title: intl.formatMessage({
description: 'User creation/edit dialog: Age restriction field possible option',
defaultMessage: 'Allow only under',
id: '/bathK',
}),
value: 'ALLOW_ONLY',
},
{
title: intl.formatMessage({
description: 'User creation/edit dialog: Age restriction field possible option',
defaultMessage: 'Exclude over',
id: 'wmGcF+',
}),
value: 'EXCLUDE',
},
]
</script>

View file

@ -12,14 +12,26 @@
// to match your app's branding.
// Tip: Use the "Theme Builder" on Quasar's documentation website.
$primary: #1976d2;
$secondary: #26a69a;
$accent: #9c27b0;
//$primary: #1976d2;
//$secondary: #26a69a;
//$accent: #9c27b0;
//
//$dark: #1d1d1d;
//$dark-page: #121212;
//
//$positive: #21ba45;
//$negative: #c10015;
//$info: #31ccec;
//$warning: #f2c037;
$dark: #1d1d1d;
$dark-page: #121212;
$primary : #005ed3;
$secondary : #fec000;
$accent : #ff0335;
$positive: #21ba45;
$negative: #c10015;
$info: #31ccec;
$warning: #f2c037;
$dark : #1d1d1d;
$dark-page : #121212;
$positive : #4caf50;
$negative : #b00020;
$info : #2196f3;
$warning : #fb8c00;

View file

@ -21,10 +21,7 @@
autofocus
:disable="isLoading"
lazy-rules
:rules="[
(x) => !!x || 'Required',
(x, r) => r.email(x) || 'Must be a valid email address',
]"
:rules="[required(), (x, r) => r.email(x) || 'Must be a valid email address']"
/>
</div>
</div>
@ -37,7 +34,7 @@
type="password"
:disable="isLoading"
lazy-rules
:rules="[(x) => !!x || 'Required']"
:rules="[required()]"
/>
</div>
</div>
@ -73,6 +70,7 @@ import type { ErrorCause } from 'api/komga-client'
import { komgaClient } from 'api/komga-client'
import { useAppStore } from 'stores/app'
import { Notify } from 'quasar'
import { required } from 'utils/rules'
const username = ref('')
const password = ref('')

View file

@ -20,7 +20,7 @@
</q-card-actions>
</q-card>
<EmptyState
<KEmptyState
v-else-if="error"
:title="$formatMessage(commonMessages.somethingWentWrongTitle)"
:sub-title="$formatMessage(commonMessages.somethingWentWrongSubTitle)"
@ -122,6 +122,7 @@ function markRead(id: string) {
<style lang="scss">
.announcement h2 {
font-size: 24px;
line-height: 1.5rem;
}
</style>

View file

@ -1,141 +1,136 @@
<template>
<v-skeleton-loader
v-if="isLoading"
type="heading, text, paragraph@3"
/>
<q-card v-if="isLoading">
<q-card-section>
<q-skeleton type="text" />
<q-skeleton
type="text"
width="200px"
/>
</q-card-section>
<v-empty-state
<q-card-section>
<q-skeleton
height="300px"
square
/>
</q-card-section>
</q-card>
<KEmptyState
v-else-if="error"
icon="mdi-connection"
:title="$formatMessage(commonMessages.somethingWentWrongTitle)"
:text="$formatMessage(commonMessages.somethingWentWrongSubTitle)"
:sub-title="$formatMessage(commonMessages.somethingWentWrongSubTitle)"
icon="mdi-connection"
icon-size="250px"
avatar-color="grey-4"
/>
<template v-else-if="releases">
<v-row>
<v-col>
<div>
<div>
<div v-if="isLatestVersion == true">
<v-alert
type="success"
variant="tonal"
>
<KBanner type="positive">
{{
$formatMessage({
description: 'Updates view: banner shown at the top',
defaultMessage: 'The latest version of Komga is already installed',
id: 'WNY0pu'
id: 'WNY0pu',
})
}}
</v-alert>
</KBanner>
</div>
<div v-if="isLatestVersion == false">
<v-alert
type="warning"
variant="tonal"
>
<KBanner type="warning">
{{
$formatMessage({
description: 'Updates view: banner shown at the top',
defaultMessage: 'Updates are available',
id: 'n1Ik+L'
id: 'n1Ik+L',
})
}}
</v-alert>
</KBanner>
</div>
</v-col>
</v-row>
</div>
</div>
<div
v-for="(release, index) in releases"
:key="index"
class="q-py-sm"
>
<v-row
justify="space-between"
align="center"
>
<v-col cols="auto">
<div>
<q-card>
<q-card-section>
<div class="text-h3">
<a
:href="release.url"
target="_blank"
class="text-h4 font-weight-medium link-underline me-2"
>{{
release.version
}}</a>
<v-chip
class="link-underline q-mr-sm"
>{{ release.version }}</a
>
<q-chip
v-if="release.version == currentVersion"
class="mx-2 mt-n3"
size="small"
rounded
color="info"
class="chip-info"
:ripple="false"
>
{{
$formatMessage({
description: 'Updates view: badge showing next to the currently installed release number',
description:
'Updates view: badge showing next to the currently installed release number',
defaultMessage: 'Currently installed',
id: '3jrAF6'
id: '3jrAF6',
})
}}
</v-chip>
<v-chip
</q-chip>
<q-chip
v-if="release.version == latest?.version"
class="mx-2 mt-n3"
size="small"
rounded
:ripple="false"
>
{{
$formatMessage({
description: 'Updates view: badge showing next to the latest release number',
defaultMessage: 'Latest',
id: '2Bh8F2'
id: '2Bh8F2',
})
}}
</v-chip>
</q-chip>
</div>
<div class="mt-2 subtitle-1">
{{ $formatDate(release.releaseDate, {dateStyle: 'long'}) }}
<div class="text-subtitle1">
{{ $formatDate(release.releaseDate, { dateStyle: 'long' }) }}
</div>
</v-col>
</v-row>
</q-card-section>
<v-row>
<v-col cols="12">
<q-card-section>
<!-- eslint-disable vue/no-v-html -->
<div
class="release"
v-html="marked(release.description)"
/>
<!-- eslint-enable vue/no-v-html -->
</v-col>
</v-row>
<v-divider
v-if="index != releases.length - 1"
class="my-8"
/>
</q-card-section>
</q-card>
</div>
</template>
</template>
<script lang="ts" setup>
import {useAppReleases} from '@/colada/queries/app-releases.ts'
import {marked} from 'marked'
import {commonMessages} from '@/utils/common-messages.ts'
import { useAppReleases } from 'colada/queries/app-releases'
import { marked } from 'marked'
import { commonMessages } from 'utils/i18n/common-messages'
const {data: releases, error, buildVersion: currentVersion, isLatestVersion, latestRelease: latest, isLoading} = useAppReleases()
const {
data: releases,
error,
buildVersion: currentVersion,
isLatestVersion,
latestRelease: latest,
isLoading,
} = useAppReleases()
</script>
<style lang="scss">
.release p {
margin-bottom: 16px;
}
.release ul {
padding-left: 24px;
}
.release a {
color: var(--v-anchor-base);
.release h2 {
font-size: 24px;
line-height: 1.5rem;
}
</style>

View file

@ -1,185 +1,223 @@
<template>
<v-empty-state
<KEmptyState
v-if="error"
icon="mdi-connection"
icon-size="250px"
avatar-color="grey-4"
:title="$formatMessage(commonMessages.somethingWentWrongTitle)"
:text="$formatMessage(commonMessages.somethingWentWrongSubTitle)"
/>
<template v-else>
<v-data-table
<QTable
:columns="columns"
:rows="users ? users : []"
:loading="isLoading"
:items="users"
:headers="headers"
:hide-default-footer="hideFooter"
:rows-per-page-options="[10]"
:hide-bottom="hideFooter"
>
<template #top>
<v-toolbar flat>
<v-toolbar-title>
<v-icon
color="medium-emphasis"
icon="mdi-account-multiple"
size="x-small"
start
<q-toolbar>
<q-toolbar-title>
<q-icon
name="mdi-account-multiple"
left
/>
Users
</v-toolbar-title>
</q-toolbar-title>
<v-btn
class="me-2"
prepend-icon="mdi-plus"
rounded="lg"
text="Add a User"
border
@click="showDialog(ACTION.ADD)"
@mouseenter="activator = $event.currentTarget"
<q-btn
icon="mdi-plus"
label="Add a user"
@click="addUser()"
/>
</v-toolbar>
</q-toolbar>
</template>
<template #[`item.roles`]="{ value }">
<div class="d-flex ga-1">
<v-chip
v-for="role in value"
<template #[`body-cell-roles`]="props">
<q-td :props="props">
<q-chip
v-for="role in props.value"
:key="role"
:color="getRoleColor(role)"
:text="role"
size="x-small"
rounded
:class="getRoleClass(role)"
:label="role"
size="sm"
/>
</div>
</q-td>
</template>
<template #[`item.actions`]="{ item : user }">
<div class="d-flex ga-1 justify-end">
<v-icon-btn
v-tooltip:bottom="'Change password'"
<template #[`body-cell-actions`]="props">
<q-td :props="props">
<q-btn
icon="mdi-lock-reset"
@click="showDialog(ACTION.PASSWORD, user)"
@mouseenter="activator = $event.currentTarget"
/>
<v-icon-btn
v-tooltip:bottom="'Edit user'"
round
flat
@click="changePassword(props.row)"
>
<q-tooltip class="text-body2">Change password</q-tooltip>
</q-btn>
<q-btn
icon="mdi-pencil"
:disabled="me?.id == user.id"
@click="showDialog(ACTION.EDIT, user)"
@mouseenter="activator = $event.currentTarget"
/>
<v-icon-btn
v-tooltip:bottom="'Delete user'"
round
flat
:disable="me?.id == props.row.id"
:color="me?.id == props.row.id ? 'grey' : undefined"
@click="editUser(props.row)"
>
<q-tooltip
v-if="me?.id !== props.row.id"
class="text-body2"
>Edit user</q-tooltip
>
</q-btn>
<q-btn
icon="mdi-delete"
:disabled="me?.id == user.id"
@click="showDialog(ACTION.DELETE, user)"
@mouseenter="activatorDelete = $event.currentTarget"
/>
</div>
round
flat
:disable="me?.id == props.row.id"
:color="me?.id == props.row.id ? 'grey' : undefined"
@click="showDialog(ACTION.DELETE, props.row)"
>
<q-tooltip
v-if="me?.id !== props.row.id"
class="text-body2"
>Delete user</q-tooltip
>
</q-btn>
</q-td>
</template>
</v-data-table>
</QTable>
<DialogConfirmEdit
v-model:record="dialogRecord"
:activator="activator"
:title="dialogTitle"
:subtitle="userRecord?.email"
:max-width="currentAction === ACTION.PASSWORD ? 400 : 600"
@update:record="handleDialogConfirmation()"
>
<template #text="{proxyModel}">
<component
:is="dialogComponent"
v-model="proxyModel.value"
/>
</template>
</DialogConfirmEdit>
<!-- <DialogConfirmEdit-->
<!-- v-model:record="dialogRecord"-->
<!-- :activator="activator"-->
<!-- :title="dialogTitle"-->
<!-- :subtitle="userRecord?.email"-->
<!-- :max-width="currentAction === ACTION.PASSWORD ? 400 : 600"-->
<!-- @update:record="handleDialogConfirmation()"-->
<!-- >-->
<!-- <template #text="{ proxyModel }">-->
<!-- <component-->
<!-- :is="dialogComponent"-->
<!-- v-model="proxyModel.value"-->
<!-- />-->
<!-- </template>-->
<!-- </DialogConfirmEdit>-->
<DialogConfirm
:activator="activatorDelete"
:title="dialogTitle"
:subtitle="userRecord?.email"
ok-text="Delete"
:validate-text="userRecord?.email"
max-width="600"
@confirm="handleDialogConfirmation()"
>
<template #warning>
<v-alert
type="warning"
variant="tonal"
class="mb-4"
>
<div>The user account will be deleted from this server.</div>
<ul class="ps-8">
<li>The read progress for this user account will be permanently deleted.</li>
<li>Authentication activity for this user will be permanently deleted.</li>
</ul>
<div class="font-weight-bold mt-4">
This action cannot be undone.
</div>
</v-alert>
</template>
</DialogConfirm>
<!-- <DialogConfirm-->
<!-- :activator="activatorDelete"-->
<!-- :title="dialogTitle"-->
<!-- :subtitle="userRecord?.email"-->
<!-- ok-text="Delete"-->
<!-- :validate-text="userRecord?.email"-->
<!-- max-width="600"-->
<!-- @confirm="handleDialogConfirmation()"-->
<!-- >-->
<!-- <template #warning>-->
<!-- <v-alert-->
<!-- type="warning"-->
<!-- variant="tonal"-->
<!-- class="mb-4"-->
<!-- >-->
<!-- <div>The user account will be deleted from this server.</div>-->
<!-- <ul class="ps-8">-->
<!-- <li>The read progress for this user account will be permanently deleted.</li>-->
<!-- <li>Authentication activity for this user will be permanently deleted.</li>-->
<!-- </ul>-->
<!-- <div class="font-weight-bold mt-4">This action cannot be undone.</div>-->
<!-- </v-alert>-->
<!-- </template>-->
<!-- </DialogConfirm>-->
</template>
</template>
<script lang="ts" setup>
import {useUsers} from '@/colada/queries/users.ts'
import {komgaClient} from '@/api/komga-client.ts'
import type {components} from '@/generated/openapi/komga'
import {useCurrentUser} from '@/colada/queries/current-user.ts'
import {UserRoles} from '@/types/UserRoles.ts'
import {useCreateUser, useDeleteUser, useUpdateUser, useUpdateUserPassword} from '@/colada/mutations/update-user.ts'
import FormUserChangePassword from '@/components/forms/user/FormUserChangePassword.vue'
import FormUserEdit from '@/components/forms/user/FormUserEdit.vue'
import type {Component} from 'vue'
import {useLibraries} from '@/colada/queries/libraries.ts'
import {commonMessages} from '@/utils/common-messages.ts'
import { useUsers } from 'colada/queries/users'
import { komgaClient } from 'api/komga-client'
import type { components } from 'openapi/komga'
import { useCurrentUser } from 'colada/queries/current-user'
import { UserRoles } from 'types/UserRoles'
import {
useCreateUser,
useDeleteUser,
useUpdateUser,
useUpdateUserPassword,
} from 'colada/mutations/update-user'
import { useLibraries } from 'colada/queries/libraries'
import { commonMessages } from 'utils/i18n/common-messages'
import { useQuasar } from 'quasar'
import FormUserChangePassword from 'components/form/user/ChangePassword.vue'
import FormUserEdit from 'components/form/user/Edit.vue'
// API data
const {data: users, error, isLoading, refetch: refetchUsers} = useUsers()
const {data: me} = useCurrentUser()
const { data: users, error, isLoading, refetch: refetchUsers } = useUsers()
const { data: me } = useCurrentUser()
// Table
const hideFooter = computed(() => users.value && users.value.length < 11)
const headers = [
{title: 'Email', key: 'email'},
{title: 'Latest Activity', key: 'activity', value: (item: components["schemas"]["UserDto"]) => latestActivity[item.id]},
{title: 'Roles', value: 'roles', sortable: false},
{title: 'Actions', key: 'actions', align: 'end', sortable: false},
] as const // workaround for https://github.com/vuetifyjs/vuetify/issues/18901
const columns = [
{
name: 'email',
label: 'Email',
field: 'email',
align: 'left',
sortable: true,
},
{
name: 'activity',
label: 'Latest Activity',
field: (item: components['schemas']['UserDto']) => latestActivity[item.id],
align: 'left',
sortable: true,
},
{
name: 'roles',
label: 'Roles',
field: 'roles',
align: 'left',
sortable: false,
},
{
name: 'actions',
label: 'Actions',
field: () => '',
align: 'right',
sortable: false,
},
]
function getRoleColor(role: UserRoles) {
if(role === UserRoles.ADMIN) return 'error'
function getRoleClass(role: UserRoles) {
if (role === UserRoles.ADMIN) return 'chip-negative'
}
// store each user's latest activity in a map
// when the 'users' change, we call the API for each user
const latestActivity: Record<string, Date | undefined> = reactive({})
function getLatestActivity(userId: string) {
komgaClient.GET('/api/v2/users/{id}/authentication-activity/latest', {
params: {
path: { id: userId }
}
})
komgaClient
.GET('/api/v2/users/{id}/authentication-activity/latest', {
params: {
path: { id: userId },
},
})
// unwrap the openapi-fetch structure on success
.then((res) => latestActivity[userId] = res.data?.dateTime)
.then((res) => (latestActivity[userId] = res.data?.dateTime))
.catch(() => {})
}
watch(users, (users) => {
if(users) for (const user of users) {
getLatestActivity(user.id)
}
if (users)
for (const user of users) {
getLatestActivity(user.id)
}
})
onMounted(() => refetchUsers())
// Dialogs handling
// stores the user being actioned upon
const userRecord = ref<components["schemas"]["UserDto"]>()
const userRecord = ref<components['schemas']['UserDto']>()
// stores the ongoing action, so we can handle the action when the dialog is closed with changes
const currentAction = ref<ACTION>()
// the record passed to the dialog's form's model
@ -190,89 +228,146 @@ const dialogTitle = ref<string>()
// dynamic component for the dialog's inner form
const dialogComponent = shallowRef<Component>()
const {mutate: mutateCreateUser} = useCreateUser()
const {mutate: mutateUser} = useUpdateUser()
const {mutate: mutateUserPassword} = useUpdateUserPassword()
const {mutate: mutateDeleteUser} = useDeleteUser()
const {data: libraries} = useLibraries()
const { mutate: mutateCreateUser } = useCreateUser()
const { mutate: mutateUser } = useUpdateUser()
const { mutate: mutateUserPassword } = useUpdateUserPassword()
const { mutate: mutateDeleteUser } = useDeleteUser()
const { data: libraries } = useLibraries()
enum ACTION {
ADD, EDIT, DELETE, PASSWORD
ADD,
EDIT,
DELETE,
PASSWORD,
}
function showDialog(action: ACTION, user?: components["schemas"]["UserDto"]) {
currentAction.value = action
switch (action) {
case ACTION.ADD:
dialogTitle.value = 'Add User'
dialogComponent.value = FormUserEdit
dialogRecord.value = {
email: '',
password: '',
roles: [UserRoles.PAGE_STREAMING, UserRoles.FILE_DOWNLOAD],
sharedLibraries: {
all: true,
// we fill the array with all libraries for a nicer display in the edit dialog
libraryIds: libraries.value?.map(x => x.id) || [],
},
ageRestriction: {
age: 0,
restriction: 'NONE',
}
} as components["schemas"]["UserCreationDto"]
break;
case ACTION.EDIT:
dialogTitle.value = 'Edit User'
dialogComponent.value = FormUserEdit
dialogRecord.value = {
const $q = useQuasar()
function changePassword(user: components['schemas']['UserDto']) {
$q.dialog({
component: FormUserChangePassword,
componentProps: {
title: 'Change Password',
subtitle: user.email,
},
}).onOk((newPassword: string) => {
console.log('new password:', newPassword)
})
}
function addUser() {
$q.dialog({
component: FormUserEdit,
componentProps: {
title: 'Add User',
},
}).onOk((user: string) => {
console.log('add user:', user)
})
}
function editUser(user: components['schemas']['UserDto']) {
$q.dialog({
component: FormUserEdit,
componentProps: {
title: 'Edit User',
subtitle: user.email,
user: {
...user,
roles: user?.roles.filter(x => x !== 'USER'),
roles: user?.roles.filter((x) => x !== 'USER'),
sharedLibraries: {
all: user?.sharedAllLibraries,
// we fill the array with all libraries for a nicer display in the edit dialog
libraryIds: user?.sharedAllLibraries ? libraries.value?.map(x => x.id) || [] : user?.sharedLibrariesIds,
libraryIds: user?.sharedAllLibraries
? libraries.value?.map((x) => x.id) || []
: user?.sharedLibrariesIds,
},
ageRestriction: user?.ageRestriction || {
age: 0,
restriction: 'NONE',
}
} as components["schemas"]["UserUpdateDto"]
break;
case ACTION.DELETE:
dialogTitle.value = 'Delete User'
dialogComponent.value = FormUserEdit
dialogRecord.value = user
break;
case ACTION.PASSWORD:
dialogTitle.value = 'Change Password'
dialogComponent.value = FormUserChangePassword
// password change initiated with an empty string
dialogRecord.value = ''
}
},
} as components['schemas']['UserUpdateDto'],
},
}).onOk((editedUser: string) => {
console.log('edited user:', editedUser)
})
}
function showDialog(action: ACTION, user?: components['schemas']['UserDto']) {
currentAction.value = action
// switch (action) {
// case ACTION.ADD:
// dialogTitle.value = 'Add User'
// dialogComponent.value = FormUserEdit
// dialogRecord.value = {
// email: '',
// password: '',
// roles: [UserRoles.PAGE_STREAMING, UserRoles.FILE_DOWNLOAD],
// sharedLibraries: {
// all: true,
// // we fill the array with all libraries for a nicer display in the edit dialog
// libraryIds: libraries.value?.map((x) => x.id) || [],
// },
// ageRestriction: {
// age: 0,
// restriction: 'NONE',
// },
// } as components['schemas']['UserCreationDto']
// break
// case ACTION.EDIT:
// dialogTitle.value = 'Edit User'
// dialogComponent.value = FormUserEdit
// dialogRecord.value = {
// ...user,
// roles: user?.roles.filter((x) => x !== 'USER'),
// sharedLibraries: {
// all: user?.sharedAllLibraries,
// // we fill the array with all libraries for a nicer display in the edit dialog
// libraryIds: user?.sharedAllLibraries
// ? libraries.value?.map((x) => x.id) || []
// : user?.sharedLibrariesIds,
// },
// ageRestriction: user?.ageRestriction || {
// age: 0,
// restriction: 'NONE',
// },
// } as components['schemas']['UserUpdateDto']
// break
// case ACTION.DELETE:
// dialogTitle.value = 'Delete User'
// dialogComponent.value = FormUserEdit
// dialogRecord.value = user
// break
// case ACTION.PASSWORD:
// dialogTitle.value = 'Change Password'
// dialogComponent.value = FormUserChangePassword
// // password change initiated with an empty string
// dialogRecord.value = ''
// }
userRecord.value = user
}
function handleDialogConfirmation() {
switch (currentAction.value) {
case ACTION.ADD:
mutateCreateUser(dialogRecord.value as components["schemas"]["UserCreationDto"])
break;
case ACTION.EDIT:
mutateUser(dialogRecord.value as components["schemas"]["UserDto"])
break;
case ACTION.DELETE:
mutateDeleteUser(userRecord.value!.id)
break;
case ACTION.PASSWORD:
mutateUserPassword({
userId: userRecord.value!.id,
newPassword: dialogRecord.value as string,
})
break;
}
}
//
// function handleDialogConfirmation() {
// switch (currentAction.value) {
// case ACTION.ADD:
// mutateCreateUser(dialogRecord.value as components['schemas']['UserCreationDto'])
// break
// case ACTION.EDIT:
// mutateUser(dialogRecord.value as components['schemas']['UserDto'])
// break
// case ACTION.DELETE:
// mutateDeleteUser(userRecord.value!.id)
// break
// case ACTION.PASSWORD:
// mutateUserPassword({
// userId: userRecord.value!.id,
// newPassword: dialogRecord.value as string,
// })
// break
// }
// }
</script>
<style scoped lang="scss"></style>
<route lang="yaml">
meta:
requiresRole: ADMIN

View file

@ -5,6 +5,7 @@ export const useAppStore = defineStore('app', {
state: () => ({
loginRememberMe: false,
drawer: !useQuasar().platform.is.mobile.valueOf(),
theme: 'auto' as 'auto' | boolean,
}),
persist: true,
})

View file

@ -0,0 +1,26 @@
@use "quasar/src/css/variables" as q;
.body--light {
.chip-negative {
background: q.$red-1;
color: q.$red-10;
}
.chip-info {
background: q.$blue-1;
color: q.$blue;
}
}
.body--dark {
.q-chip {
background: #3A3A3A;
color: #D8D8D8;
}
.chip-negative {
background: #36292C;
color: #AB5866;
}
.chip-info {
background: #172535;
color: #218CE3;
}
}

View file

@ -0,0 +1,8 @@
@use "quasar/src/css/variables" as q;
.body--light .drawer-menu-active {
background: q.$grey-3;
}
.body--dark .drawer-menu-active {
background: q.$grey-9;
}

View file

@ -1,3 +1,5 @@
@use './chip';
.link-none {
text-decoration: none;
}

View file

@ -0,0 +1,7 @@
export function required(err?: string) {
return (v: unknown) => !!v || err || 'Required'
}
export function sameAs(other?: string, err?: string) {
return (v: unknown) => other === v || err || 'Field must have the same value'
}