mirror of https://github.com/requarks/wiki
parent
ee7a15fbd6
commit
6e8fe2b558
@ -0,0 +1,194 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
/**
|
||||
* Blocks API Routes
|
||||
*/
|
||||
async function routes(app: FastifyInstance) {
|
||||
/**
|
||||
* LIST SITE BLOCKS
|
||||
*/
|
||||
app.get<{ Params: { siteId: string } }>(
|
||||
'/sites/:siteId/blocks',
|
||||
{
|
||||
config: {
|
||||
permissions: ['read:sites', 'manage:sites']
|
||||
},
|
||||
schema: {
|
||||
summary: 'List the blocks available to a site',
|
||||
description:
|
||||
'Built-in blocks are registered from the compiled block manifest, so the list reflects what is actually installed.',
|
||||
tags: ['Blocks'],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
}
|
||||
},
|
||||
required: ['siteId']
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'List of site blocks',
|
||||
type: 'array',
|
||||
items: { $ref: 'Block#' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
|
||||
if (!site) {
|
||||
return reply.notFound('Site does not exist.')
|
||||
}
|
||||
return WIKI.models.blocks.getSiteBlocks(req.params.siteId)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* SET SITE BLOCKS STATE
|
||||
*/
|
||||
app.put<{
|
||||
Params: { siteId: string }
|
||||
Body: { states: { id: string; isEnabled: boolean }[] }
|
||||
}>(
|
||||
'/sites/:siteId/blocks',
|
||||
{
|
||||
config: {
|
||||
permissions: ['manage:sites']
|
||||
},
|
||||
schema: {
|
||||
summary: 'Enable or disable site blocks',
|
||||
description: 'Only the blocks listed are affected; any others keep their current state.',
|
||||
tags: ['Blocks'],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
}
|
||||
},
|
||||
required: ['siteId']
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['states'],
|
||||
properties: {
|
||||
states: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['id', 'isEnabled'],
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
},
|
||||
isEnabled: {
|
||||
type: 'boolean'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Blocks state updated successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: {
|
||||
type: 'boolean'
|
||||
},
|
||||
message: {
|
||||
type: 'string'
|
||||
},
|
||||
updated: {
|
||||
type: 'integer',
|
||||
description:
|
||||
'How many block rows were written. A block already in the requested state still counts.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
|
||||
if (!site) {
|
||||
return reply.notFound('Site does not exist.')
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await WIKI.models.blocks.setBlocksState(req.params.siteId, req.body.states)
|
||||
return {
|
||||
ok: true,
|
||||
message: 'Blocks state updated successfully.',
|
||||
updated
|
||||
}
|
||||
} catch (err: any) {
|
||||
WIKI.logger.warn(err)
|
||||
return reply.internalServerError()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE CUSTOM BLOCK
|
||||
*/
|
||||
app.delete<{ Params: { siteId: string; blockId: string } }>(
|
||||
'/sites/:siteId/blocks/:blockId',
|
||||
{
|
||||
config: {
|
||||
permissions: ['manage:sites']
|
||||
},
|
||||
schema: {
|
||||
summary: 'Delete a custom block',
|
||||
description:
|
||||
'Only custom blocks can be deleted. Built-in blocks are registered from disk and would reappear on the next sync.',
|
||||
tags: ['Blocks'],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
},
|
||||
blockId: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
}
|
||||
},
|
||||
required: ['siteId', 'blockId']
|
||||
},
|
||||
response: {
|
||||
204: {
|
||||
description: 'Block deleted successfully'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
|
||||
if (!site) {
|
||||
return reply.notFound('Site does not exist.')
|
||||
}
|
||||
|
||||
const siteBlocks = await WIKI.models.blocks.getSiteBlocks(req.params.siteId)
|
||||
const block = siteBlocks.find((b) => b.id === req.params.blockId)
|
||||
if (!block) {
|
||||
return reply.notFound('Block does not exist.')
|
||||
}
|
||||
if (!block.isCustom) {
|
||||
return reply.conflict('Cannot delete a built-in block.')
|
||||
}
|
||||
|
||||
await WIKI.models.blocks.deleteCustomBlock(req.params.siteId, req.params.blockId)
|
||||
return reply.code(204).send()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export default routes
|
||||
@ -0,0 +1,144 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
/**
|
||||
* Placeholder sent to the client in place of the stored SMTP password. Sending it back unchanged
|
||||
* leaves the stored password alone.
|
||||
*/
|
||||
const PASSWORD_MASK = '********'
|
||||
|
||||
/**
|
||||
* Mail settings, stored as the `mail` key of the settings table.
|
||||
*/
|
||||
const MAIL_CONFIG_KEYS = [
|
||||
'senderName',
|
||||
'senderEmail',
|
||||
'defaultBaseURL',
|
||||
'host',
|
||||
'port',
|
||||
'name',
|
||||
'secure',
|
||||
'verifySSL',
|
||||
'user',
|
||||
'pass',
|
||||
'useDKIM',
|
||||
'dkimDomainName',
|
||||
'dkimKeySelector',
|
||||
'dkimPrivateKey'
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Mail API Routes
|
||||
*/
|
||||
async function routes(app: FastifyInstance) {
|
||||
/**
|
||||
* GET MAIL CONFIG
|
||||
*/
|
||||
app.get(
|
||||
'/config',
|
||||
{
|
||||
config: {
|
||||
permissions: ['manage:system']
|
||||
},
|
||||
schema: {
|
||||
summary: 'Get mail configuration',
|
||||
tags: ['Mail'],
|
||||
response: {
|
||||
200: {
|
||||
description: 'Mail configuration',
|
||||
type: 'object',
|
||||
$ref: 'MailConfig#'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
return {
|
||||
...WIKI.config.mail,
|
||||
pass: WIKI.config.mail?.pass?.length > 0 ? PASSWORD_MASK : ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* UPDATE MAIL CONFIG
|
||||
*/
|
||||
app.put<{
|
||||
Body: {
|
||||
senderName?: string
|
||||
senderEmail?: string
|
||||
defaultBaseURL?: string
|
||||
host?: string
|
||||
port?: number
|
||||
name?: string
|
||||
secure?: boolean
|
||||
verifySSL?: boolean
|
||||
user?: string
|
||||
pass?: string
|
||||
useDKIM?: boolean
|
||||
dkimDomainName?: string
|
||||
dkimKeySelector?: string
|
||||
dkimPrivateKey?: string
|
||||
}
|
||||
}>(
|
||||
'/config',
|
||||
{
|
||||
config: {
|
||||
permissions: ['manage:system']
|
||||
},
|
||||
schema: {
|
||||
summary: 'Update mail configuration',
|
||||
tags: ['Mail'],
|
||||
body: {
|
||||
$ref: 'MailConfig#'
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Mail configuration updated successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: {
|
||||
type: 'boolean'
|
||||
},
|
||||
message: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const patch: Record<string, any> = {}
|
||||
for (const key of MAIL_CONFIG_KEYS) {
|
||||
if (req.body[key] !== undefined) {
|
||||
patch[key] = req.body[key]
|
||||
}
|
||||
}
|
||||
|
||||
// -> Base URLs are used to build links in emails, always without a trailing slash
|
||||
if (typeof patch.defaultBaseURL === 'string') {
|
||||
patch.defaultBaseURL = patch.defaultBaseURL.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
// -> The client only ever receives a masked password, so an unchanged one must not be stored
|
||||
if (patch.pass === PASSWORD_MASK) {
|
||||
delete patch.pass
|
||||
}
|
||||
|
||||
const previousConfig = WIKI.config.mail
|
||||
WIKI.config.mail = { ...previousConfig, ...patch }
|
||||
|
||||
if (!(await WIKI.configSvc.saveToDb(['mail']))) {
|
||||
WIKI.config.mail = previousConfig
|
||||
return reply.internalServerError('Failed to save mail configuration.')
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: 'Mail configuration updated successfully.'
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export default routes
|
||||
@ -0,0 +1,42 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
/**
|
||||
* BLOCK
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'Block',
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
},
|
||||
block: {
|
||||
type: 'string',
|
||||
description: 'Element suffix — the block renders as `<block-{block}>`.'
|
||||
},
|
||||
name: {
|
||||
type: 'string'
|
||||
},
|
||||
description: {
|
||||
type: 'string'
|
||||
},
|
||||
icon: {
|
||||
type: 'string',
|
||||
description: 'Blueprint icon name, resolved as `/_assets/icons/ultraviolet-{icon}.svg`.'
|
||||
},
|
||||
isEnabled: {
|
||||
type: 'boolean'
|
||||
},
|
||||
isCustom: {
|
||||
type: 'boolean',
|
||||
description: 'False for blocks registered from the compiled block manifest.'
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
additionalProperties: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
/**
|
||||
* MAIL CONFIG
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'MailConfig',
|
||||
type: 'object',
|
||||
properties: {
|
||||
senderName: {
|
||||
type: 'string',
|
||||
maxLength: 255
|
||||
},
|
||||
senderEmail: {
|
||||
type: 'string',
|
||||
maxLength: 255
|
||||
},
|
||||
defaultBaseURL: {
|
||||
type: 'string',
|
||||
maxLength: 255
|
||||
},
|
||||
host: {
|
||||
type: 'string',
|
||||
maxLength: 255
|
||||
},
|
||||
port: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 65535
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
maxLength: 255
|
||||
},
|
||||
secure: {
|
||||
type: 'boolean'
|
||||
},
|
||||
verifySSL: {
|
||||
type: 'boolean'
|
||||
},
|
||||
user: {
|
||||
type: 'string',
|
||||
maxLength: 255
|
||||
},
|
||||
pass: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Returned masked as `********` when a password is stored. Send the masked value back unchanged to keep the stored password.',
|
||||
maxLength: 255
|
||||
},
|
||||
useDKIM: {
|
||||
type: 'boolean'
|
||||
},
|
||||
dkimDomainName: {
|
||||
type: 'string',
|
||||
maxLength: 255
|
||||
},
|
||||
dkimKeySelector: {
|
||||
type: 'string',
|
||||
maxLength: 255
|
||||
},
|
||||
dkimPrivateKey: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,191 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { blocks as blocksTable, sites as sitesTable } from '../db/schema.ts'
|
||||
|
||||
/** A block as declared by its component's `static definition`. */
|
||||
export interface BlockDefinition {
|
||||
block: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
/** A block row as exposed by the API. */
|
||||
export interface SiteBlock {
|
||||
id: string
|
||||
block: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
isEnabled: boolean
|
||||
isCustom: boolean
|
||||
config: Record<string, any>
|
||||
}
|
||||
|
||||
const blockSelection = {
|
||||
id: blocksTable.id,
|
||||
block: blocksTable.block,
|
||||
name: blocksTable.name,
|
||||
description: blocksTable.description,
|
||||
icon: blocksTable.icon,
|
||||
isEnabled: blocksTable.isEnabled,
|
||||
isCustom: blocksTable.isCustom,
|
||||
config: blocksTable.config
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks model
|
||||
*
|
||||
* Built-in blocks live in the `blocks/` workspace, one directory per block. Their metadata is
|
||||
* declared as a `static definition` on each Lit component and collected into
|
||||
* `blocks/compiled/blocks.manifest.json` by the rollup build, which is what this model reads —
|
||||
* the components themselves cannot be imported outside a browser.
|
||||
*/
|
||||
class Blocks {
|
||||
/** Definitions read from the compiled manifest, refreshed by `refreshFromDisk()`. */
|
||||
definitions: BlockDefinition[] = []
|
||||
|
||||
/**
|
||||
* Load the built-in block definitions from the compiled manifest.
|
||||
*
|
||||
* A missing manifest is not fatal: it just means `blocks` has not been built yet, in which case
|
||||
* only custom blocks are available.
|
||||
*/
|
||||
async refreshFromDisk(): Promise<void> {
|
||||
const manifestPath = path.join(WIKI.ROOTPATH, 'blocks/compiled/blocks.manifest.json')
|
||||
try {
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
if (!Array.isArray(manifest)) {
|
||||
throw new TypeError('Manifest is not an array.')
|
||||
}
|
||||
this.definitions = manifest
|
||||
WIKI.logger.info(`Found ${this.definitions.length} blocks [ OK ]`)
|
||||
} catch (err: any) {
|
||||
this.definitions = []
|
||||
WIKI.logger.warn(
|
||||
`Could not read the blocks manifest at ${manifestPath} — run "npm run build" in blocks/. [ SKIPPED ]`
|
||||
)
|
||||
WIKI.logger.warn(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register any built-in block missing from a site, and drop rows for built-ins that no longer
|
||||
* exist on disk. Existing rows are updated in place so that `isEnabled` and `config` survive.
|
||||
*
|
||||
* Custom blocks are never touched — they have no on-disk counterpart to compare against.
|
||||
*/
|
||||
async syncSite(siteId: string): Promise<void> {
|
||||
const existing = await WIKI.db
|
||||
.select({ id: blocksTable.id, block: blocksTable.block })
|
||||
.from(blocksTable)
|
||||
.where(and(eq(blocksTable.siteId, siteId), eq(blocksTable.isCustom, false)))
|
||||
const existingKeys = existing.map((b: any) => b.block)
|
||||
const definedKeys = this.definitions.map((d) => d.block)
|
||||
|
||||
for (const definition of this.definitions) {
|
||||
if (existingKeys.includes(definition.block)) {
|
||||
// -> Metadata may have changed on disk; state and config belong to the site
|
||||
await WIKI.db
|
||||
.update(blocksTable)
|
||||
.set({
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
icon: definition.icon
|
||||
})
|
||||
.where(and(eq(blocksTable.siteId, siteId), eq(blocksTable.block, definition.block)))
|
||||
} else {
|
||||
await WIKI.db.insert(blocksTable).values({
|
||||
siteId,
|
||||
block: definition.block,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
icon: definition.icon,
|
||||
isEnabled: true,
|
||||
isCustom: false,
|
||||
config: {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -> A built-in that has been removed from disk should not linger in the admin list
|
||||
const orphaned = existingKeys.filter((key: string) => !definedKeys.includes(key))
|
||||
if (orphaned.length > 0) {
|
||||
await WIKI.db
|
||||
.delete(blocksTable)
|
||||
.where(
|
||||
and(
|
||||
eq(blocksTable.siteId, siteId),
|
||||
eq(blocksTable.isCustom, false),
|
||||
inArray(blocksTable.block, orphaned)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the built-in blocks for every site. Called at boot, after the sites cache is loaded.
|
||||
*/
|
||||
async syncAllSites(): Promise<void> {
|
||||
WIKI.logger.info('Registering blocks for all sites...')
|
||||
const sites = await WIKI.db.select({ id: sitesTable.id }).from(sitesTable)
|
||||
for (const site of sites) {
|
||||
await WIKI.models.blocks.syncSite(site.id)
|
||||
}
|
||||
WIKI.logger.info(`Registered blocks for ${sites.length} sites [ OK ]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the blocks available to a site, built-in first, then by name
|
||||
*/
|
||||
async getSiteBlocks(siteId: string): Promise<SiteBlock[]> {
|
||||
const results = await WIKI.db
|
||||
.select(blockSelection)
|
||||
.from(blocksTable)
|
||||
.where(eq(blocksTable.siteId, siteId))
|
||||
.orderBy(blocksTable.isCustom, blocksTable.name)
|
||||
return results as SiteBlock[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable blocks in bulk.
|
||||
*
|
||||
* @param states Block IDs with their desired state
|
||||
* @returns The number of block rows written — a block already in the requested state still counts
|
||||
*/
|
||||
async setBlocksState(
|
||||
siteId: string,
|
||||
states: { id: string; isEnabled: boolean }[]
|
||||
): Promise<number> {
|
||||
let changed = 0
|
||||
for (const isEnabled of [true, false]) {
|
||||
const ids = states.filter((s) => s.isEnabled === isEnabled).map((s) => s.id)
|
||||
if (ids.length < 1) {
|
||||
continue
|
||||
}
|
||||
const result = await WIKI.db
|
||||
.update(blocksTable)
|
||||
.set({ isEnabled })
|
||||
.where(and(eq(blocksTable.siteId, siteId), inArray(blocksTable.id, ids)))
|
||||
changed += result.rowCount ?? 0
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a custom block. Built-in blocks are rejected, since the next sync would recreate them.
|
||||
*
|
||||
* @returns Whether a block was deleted
|
||||
*/
|
||||
async deleteCustomBlock(siteId: string, id: string): Promise<boolean> {
|
||||
const result = await WIKI.db
|
||||
.delete(blocksTable)
|
||||
.where(
|
||||
and(eq(blocksTable.siteId, siteId), eq(blocksTable.id, id), eq(blocksTable.isCustom, true))
|
||||
)
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
export const blocks = new Blocks()
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"editorconfig.editorconfig",
|
||||
"johnsoncodehk.volar",
|
||||
"wayou.vscode-todo-highlight"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
"octref.vetur",
|
||||
"hookyqr.beautify",
|
||||
"dbaeumer.jshint",
|
||||
"ms-vscode.vscode-typescript-tslint-plugin",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Installs a `Temporal` polyfill on browsers that don't implement it natively yet (Safari, as of
|
||||
* mid-2026). The import is dynamic and guarded, so browsers with native support never download it.
|
||||
*
|
||||
* Must run before anything that touches `Temporal` — it is awaited first in `main.js`.
|
||||
*/
|
||||
export async function initializeTemporal () {
|
||||
if (typeof globalThis.Temporal !== 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
// -> Patches globalThis.Temporal, Intl.DateTimeFormat and Date.prototype.toTemporalInstant
|
||||
await import('temporal-polyfill/global')
|
||||
}
|
||||
@ -0,0 +1,208 @@
|
||||
<template lang="pug">
|
||||
q-dialog(ref='dialogRef', @hide='onDialogHide')
|
||||
q-card.user-search-dialog(style='width: 600px; max-width: 90vw;')
|
||||
q-card-section.card-header
|
||||
q-icon(name='img:/_assets/icons/fluent-account.svg', left, size='sm')
|
||||
span {{ props.title || t('admin.users.selectUsers') }}
|
||||
q-card-section.q-py-sm
|
||||
q-input(
|
||||
outlined
|
||||
dense
|
||||
v-model='state.search'
|
||||
:placeholder='t(`admin.users.searchUsers`)'
|
||||
:aria-label='t(`admin.users.searchUsers`)'
|
||||
clearable
|
||||
hide-bottom-space
|
||||
autofocus
|
||||
)
|
||||
template(#prepend)
|
||||
q-icon(name='las la-search')
|
||||
q-separator
|
||||
.user-search-dialog-list
|
||||
q-inner-loading(:showing='state.loading > 0')
|
||||
.flex.flex-center.full-height.q-pa-md(v-if='state.users.length < 1 && state.loading < 1')
|
||||
.text-grey {{ t('admin.users.searchNoResults') }}
|
||||
q-list(v-else, separator)
|
||||
q-item(
|
||||
v-for='usr of state.users'
|
||||
:key='usr.id'
|
||||
clickable
|
||||
v-ripple
|
||||
@click='toggle(usr)'
|
||||
)
|
||||
q-item-section(side)
|
||||
//- .stop keeps the click from also reaching the item handler, which would toggle twice
|
||||
q-checkbox(
|
||||
:model-value='isSelected(usr.id)'
|
||||
@update:model-value='toggle(usr)'
|
||||
@click.stop
|
||||
:aria-label='usr.name'
|
||||
dense
|
||||
)
|
||||
q-item-section(avatar)
|
||||
q-avatar(v-if='usr.hasAvatar', size='md')
|
||||
img(:src='`/_user/` + usr.id + `/avatar`')
|
||||
q-avatar(v-else, size='md', color='primary', text-color='white', icon='las la-user')
|
||||
q-item-section
|
||||
q-item-label {{ usr.name }}
|
||||
q-item-label(caption) {{ usr.email }}
|
||||
q-item-section(side)
|
||||
.flex.items-center
|
||||
q-icon.q-ml-sm(v-if='usr.isSystem', name='las la-lock', color='pink')
|
||||
q-tooltip {{ t('admin.users.systemUser') }}
|
||||
q-icon.q-ml-sm(v-if='!usr.isActive', name='las la-ban', color='pink')
|
||||
q-tooltip {{ t('admin.users.inactive') }}
|
||||
q-icon.q-ml-sm(v-if='!usr.isVerified', name='las la-envelope', color='orange')
|
||||
q-tooltip {{ t('admin.users.unverified') }}
|
||||
q-separator
|
||||
.flex.flex-center.q-py-sm(v-if='totalPages > 1')
|
||||
q-pagination(
|
||||
v-model='state.currentPage'
|
||||
:max='totalPages'
|
||||
:max-pages='7'
|
||||
boundary-numbers
|
||||
direction-links
|
||||
)
|
||||
q-card-actions.card-actions
|
||||
.text-caption.text-grey.q-ml-sm(v-if='state.selected.length > 0')
|
||||
| {{ t('admin.users.selectedCount', { count: state.selected.length }) }}
|
||||
q-space
|
||||
q-btn.acrylic-btn(
|
||||
flat
|
||||
:label='t(`common.actions.cancel`)'
|
||||
color='grey'
|
||||
padding='xs md'
|
||||
@click='onDialogCancel'
|
||||
)
|
||||
q-btn(
|
||||
unelevated
|
||||
:label='t(`common.actions.select`)'
|
||||
color='primary'
|
||||
padding='xs md'
|
||||
:disable='state.selected.length < 1'
|
||||
@click='confirm'
|
||||
)
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { debounce } from 'es-toolkit/function'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDialogPluginComponent, useQuasar } from 'quasar'
|
||||
import { computed, onMounted, reactive, watch } from 'vue'
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
/** Dialog title. Defaults to a generic "Select Users". */
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
/**
|
||||
* Offer only the users that may be assigned to this group. Filtering happens server-side, as
|
||||
* group membership can span more pages than are displayed.
|
||||
*/
|
||||
assignableToGroupId: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
// EMITS
|
||||
|
||||
defineEmits([...useDialogPluginComponent.emits])
|
||||
|
||||
// QUASAR
|
||||
|
||||
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
|
||||
const $q = useQuasar()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
users: [],
|
||||
selected: [],
|
||||
search: '',
|
||||
loading: 0,
|
||||
total: 0,
|
||||
currentPage: 1,
|
||||
pageSize: 10
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
const totalPages = computed(() => Math.ceil(state.total / state.pageSize))
|
||||
|
||||
// WATCHERS
|
||||
|
||||
watch(
|
||||
() => state.search,
|
||||
debounce(() => {
|
||||
state.currentPage = 1
|
||||
load()
|
||||
}, 400)
|
||||
)
|
||||
|
||||
watch(() => state.currentPage, load)
|
||||
|
||||
// METHODS
|
||||
|
||||
async function load() {
|
||||
state.loading++
|
||||
try {
|
||||
const resp = await API_CLIENT.get('users', {
|
||||
searchParams: {
|
||||
...(state.search ? { filter: state.search } : {}),
|
||||
...(props.assignableToGroupId ? { assignableToGroupId: props.assignableToGroupId } : {}),
|
||||
page: state.currentPage,
|
||||
limit: state.pageSize
|
||||
}
|
||||
}).json()
|
||||
state.total = resp?.total ?? 0
|
||||
state.users = resp?.users ?? []
|
||||
} catch (err) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: t('admin.users.loadFailed'),
|
||||
caption: err.message
|
||||
})
|
||||
}
|
||||
state.loading--
|
||||
}
|
||||
|
||||
function isSelected(id) {
|
||||
return state.selected.some((usr) => usr.id === id)
|
||||
}
|
||||
|
||||
/** Selection survives filtering and paging, so users from several pages can be picked at once. */
|
||||
function toggle(usr) {
|
||||
state.selected = isSelected(usr.id)
|
||||
? state.selected.filter((sel) => sel.id !== usr.id)
|
||||
: [...state.selected, usr]
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
onDialogOK(state.selected)
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.user-search-dialog {
|
||||
&-list {
|
||||
position: relative;
|
||||
height: 360px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in new issue