mirror of
https://github.com/gotson/komga.git
synced 2026-05-08 21:00:16 +02:00
quasar stuff
This commit is contained in:
parent
e3a7342ea6
commit
66892c9232
23 changed files with 1167 additions and 307 deletions
58
tsugini/package-lock.json
generated
58
tsugini/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
8
tsugini/src/components.d.ts
vendored
8
tsugini/src/components.d.ts
vendored
|
|
@ -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']
|
||||
}
|
||||
}
|
||||
|
|
|
|||
94
tsugini/src/components/KBanner.vue
Normal file
94
tsugini/src/components/KBanner.vue
Normal 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>
|
||||
47
tsugini/src/components/ThemeSelector.vue
Normal file
47
tsugini/src/components/ThemeSelector.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
1
tsugini/src/components/form/README.md
Normal file
1
tsugini/src/components/form/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Simple forms that can be wrapped by a `v-form`, or used within a `DialogEditConfirm`.
|
||||
128
tsugini/src/components/form/user/ChangePassword.vue
Normal file
128
tsugini/src/components/form/user/ChangePassword.vue
Normal 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>
|
||||
373
tsugini/src/components/form/user/Edit.vue
Normal file
373
tsugini/src/components/form/user/Edit.vue
Normal 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"-->
|
||||
<!-- >-->
|
||||
<!-- <!– Workaround for the lack of a slot to override the whole selection –>-->
|
||||
<!-- <template #prepend-inner>-->
|
||||
<!-- <!– Show an All Libraries chip instead of the selection –>-->
|
||||
<!-- <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 }">-->
|
||||
<!-- <!– Show the selection only if 'all' is false –>-->
|
||||
<!-- <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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
26
tsugini/src/styles/_chip.scss
Normal file
26
tsugini/src/styles/_chip.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
8
tsugini/src/styles/_drawer.scss
Normal file
8
tsugini/src/styles/_drawer.scss
Normal 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;
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
@use './chip';
|
||||
|
||||
.link-none {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
|
|
|||
7
tsugini/src/utils/rules.ts
Normal file
7
tsugini/src/utils/rules.ts
Normal 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'
|
||||
}
|
||||
Loading…
Reference in a new issue