feat: Enhance Person data structure and improve TablePersons component
- Updated Person interface to include first_name and last_name fields for better clarity and organization handling. - Modified TablePersons.vue to support new fields, including improved pagination and drag-and-drop functionality. - Added loading states and error handling for form controls within the table. - Enhanced the visual layout of the table with responsive design adjustments. - Updated solr.xslt to correctly reference ServerDateModified and EmbargoDate attributes. - updated AvatarController - improved download method for editor, and reviewer - improved security for officlial download file file API: filterd by server_state
This commit is contained in:
parent
e1ccf0ddc8
commit
06ed2f3625
12 changed files with 3143 additions and 1387 deletions
|
@ -4,20 +4,29 @@ import Person from '#models/person';
|
|||
|
||||
// node ace make:controller Author
|
||||
export default class AuthorsController {
|
||||
public async index({}: HttpContext) {
|
||||
// select * from gba.persons
|
||||
// where exists (select * from gba.documents inner join gba.link_documents_persons on "documents"."id" = "link_documents_persons"."document_id"
|
||||
// where ("link_documents_persons"."role" = 'author') and ("persons"."id" = "link_documents_persons"."person_id"));
|
||||
public async index({}: HttpContext) {
|
||||
|
||||
const authors = await Person.query()
|
||||
.preload('datasets')
|
||||
.where('name_type', 'Personal')
|
||||
.whereHas('datasets', (dQuery) => {
|
||||
dQuery.wherePivot('role', 'author');
|
||||
})
|
||||
.withCount('datasets', (query) => {
|
||||
query.as('datasets_count');
|
||||
})
|
||||
.orderBy('datasets_count', 'desc');
|
||||
.select([
|
||||
'id',
|
||||
'academic_title',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'identifier_orcid',
|
||||
'status',
|
||||
'name_type',
|
||||
'created_at'
|
||||
// Note: 'email' is omitted
|
||||
])
|
||||
.preload('datasets')
|
||||
.where('name_type', 'Personal')
|
||||
.whereHas('datasets', (dQuery) => {
|
||||
dQuery.wherePivot('role', 'author');
|
||||
})
|
||||
.withCount('datasets', (query) => {
|
||||
query.as('datasets_count');
|
||||
})
|
||||
.orderBy('datasets_count', 'desc');
|
||||
|
||||
return authors;
|
||||
}
|
||||
|
|
|
@ -2,26 +2,46 @@ import type { HttpContext } from '@adonisjs/core/http';
|
|||
import { StatusCodes } from 'http-status-codes';
|
||||
import redis from '@adonisjs/redis/services/main';
|
||||
|
||||
const PREFIXES = ['von', 'van'];
|
||||
const PREFIXES = ['von', 'van', 'de', 'del', 'della', 'di', 'da', 'dos', 'du', 'le', 'la'];
|
||||
const DEFAULT_SIZE = 50;
|
||||
const MIN_SIZE = 16;
|
||||
const MAX_SIZE = 512;
|
||||
const FONT_SIZE_RATIO = 0.4;
|
||||
const COLOR_LIGHTENING_PERCENT = 60;
|
||||
const COLOR_DARKENING_FACTOR = 0.6;
|
||||
const CACHE_TTL = 24 * 60 * 60; // 24 hours instead of 1 hour
|
||||
|
||||
export default class AvatarController {
|
||||
public async generateAvatar({ request, response }: HttpContext) {
|
||||
try {
|
||||
const { name, size = DEFAULT_SIZE } = request.only(['name', 'size']);
|
||||
if (!name) {
|
||||
return response.status(StatusCodes.BAD_REQUEST).json({ error: 'Name is required' });
|
||||
|
||||
// Enhanced validation
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return response.status(StatusCodes.BAD_REQUEST).json({
|
||||
error: 'Name is required and must be a non-empty string',
|
||||
});
|
||||
}
|
||||
|
||||
const parsedSize = this.validateSize(size);
|
||||
if (!parsedSize.isValid) {
|
||||
return response.status(StatusCodes.BAD_REQUEST).json({
|
||||
error: parsedSize.error,
|
||||
});
|
||||
}
|
||||
|
||||
// Build a unique cache key for the given name and size
|
||||
const cacheKey = `avatar:${name.trim().toLowerCase()}-${size}`;
|
||||
const cachedSvg = await redis.get(cacheKey);
|
||||
if (cachedSvg) {
|
||||
this.setResponseHeaders(response);
|
||||
return response.send(cachedSvg);
|
||||
const cacheKey = `avatar:${this.sanitizeName(name)}-${parsedSize.value}`;
|
||||
// const cacheKey = `avatar:${name.trim().toLowerCase()}-${size}`;
|
||||
try {
|
||||
const cachedSvg = await redis.get(cacheKey);
|
||||
if (cachedSvg) {
|
||||
this.setResponseHeaders(response);
|
||||
return response.send(cachedSvg);
|
||||
}
|
||||
} catch (redisError) {
|
||||
// Log redis error but continue without cache
|
||||
console.warn('Redis cache read failed:', redisError);
|
||||
}
|
||||
|
||||
const initials = this.getInitials(name);
|
||||
|
@ -29,41 +49,85 @@ export default class AvatarController {
|
|||
const svgContent = this.createSvg(size, colors, initials);
|
||||
|
||||
// // Cache the generated avatar for future use, e.g. 1 hour expiry
|
||||
await redis.setex(cacheKey, 3600, svgContent);
|
||||
try {
|
||||
await redis.setex(cacheKey, CACHE_TTL, svgContent);
|
||||
} catch (redisError) {
|
||||
// Log but don't fail the request
|
||||
console.warn('Redis cache write failed:', redisError);
|
||||
}
|
||||
|
||||
this.setResponseHeaders(response);
|
||||
return response.send(svgContent);
|
||||
} catch (error) {
|
||||
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ error: error.message });
|
||||
console.error('Avatar generation error:', error);
|
||||
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
|
||||
error: 'Failed to generate avatar',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private getInitials(name: string): string {
|
||||
const parts = name
|
||||
private validateSize(size: any): { isValid: boolean; value?: number; error?: string } {
|
||||
const numSize = Number(size);
|
||||
|
||||
if (isNaN(numSize)) {
|
||||
return { isValid: false, error: 'Size must be a valid number' };
|
||||
}
|
||||
|
||||
if (numSize < MIN_SIZE || numSize > MAX_SIZE) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Size must be between ${MIN_SIZE} and ${MAX_SIZE}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true, value: Math.floor(numSize) };
|
||||
}
|
||||
|
||||
private sanitizeName(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/gi, '');
|
||||
}
|
||||
|
||||
private getInitials(name: string): string {
|
||||
const sanitized = name.trim().replace(/\s+/g, ' '); // normalize whitespace
|
||||
const parts = sanitized
|
||||
.split(' ')
|
||||
.filter((part) => part.length > 0);
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => part.trim());
|
||||
|
||||
if (parts.length === 0) {
|
||||
return 'NA';
|
||||
}
|
||||
|
||||
if (parts.length >= 2) {
|
||||
return this.getMultiWordInitials(parts);
|
||||
if (parts.length === 1) {
|
||||
// For single word, take first 2 characters or first char if only 1 char
|
||||
return parts[0].substring(0, Math.min(2, parts[0].length)).toUpperCase();
|
||||
}
|
||||
return parts[0].substring(0, 2).toUpperCase();
|
||||
|
||||
return this.getMultiWordInitials(parts);
|
||||
}
|
||||
|
||||
private getMultiWordInitials(parts: string[]): string {
|
||||
const firstName = parts[0];
|
||||
const lastName = parts[parts.length - 1];
|
||||
const firstInitial = firstName.charAt(0).toUpperCase();
|
||||
const lastInitial = lastName.charAt(0).toUpperCase();
|
||||
// Filter out prefixes and short words
|
||||
const significantParts = parts.filter((part) => !PREFIXES.includes(part.toLowerCase()) && part.length > 1);
|
||||
|
||||
if (PREFIXES.includes(lastName.toLowerCase()) && lastName === lastName.toUpperCase()) {
|
||||
return firstInitial + lastName.charAt(1).toUpperCase();
|
||||
if (significantParts.length === 0) {
|
||||
// Fallback to first and last regardless of prefixes
|
||||
const firstName = parts[0];
|
||||
const lastName = parts[parts.length - 1];
|
||||
return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase();
|
||||
}
|
||||
return firstInitial + lastInitial;
|
||||
|
||||
if (significantParts.length === 1) {
|
||||
return significantParts[0].substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
// Take first and last significant parts
|
||||
const firstName = significantParts[0];
|
||||
const lastName = significantParts[significantParts.length - 1];
|
||||
return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase();
|
||||
}
|
||||
|
||||
private generateColors(name: string): { background: string; text: string } {
|
||||
|
@ -75,31 +139,44 @@ export default class AvatarController {
|
|||
}
|
||||
|
||||
private createSvg(size: number, colors: { background: string; text: string }, initials: string): string {
|
||||
const fontSize = size * FONT_SIZE_RATIO;
|
||||
return `
|
||||
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="#${colors.background}"/>
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-weight="bold" font-family="Arial, sans-serif" font-size="${fontSize}" fill="#${colors.text}">${initials}</text>
|
||||
</svg>
|
||||
`;
|
||||
const fontSize = Math.max(12, Math.floor(size * FONT_SIZE_RATIO)); // Ensure readable font size
|
||||
|
||||
// Escape any potential HTML/XML characters in initials
|
||||
const escapedInitials = this.escapeXml(initials);
|
||||
|
||||
return `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}">
|
||||
<rect width="100%" height="100%" fill="#${colors.background}" rx="${size * 0.1}"/>
|
||||
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle"
|
||||
font-weight="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
|
||||
font-size="${fontSize}" fill="#${colors.text}">${escapedInitials}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
private escapeXml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
private setResponseHeaders(response: HttpContext['response']): void {
|
||||
response.header('Content-type', 'image/svg+xml');
|
||||
response.header('Cache-Control', 'no-cache');
|
||||
response.header('Pragma', 'no-cache');
|
||||
response.header('Expires', '0');
|
||||
response.header('Content-Type', 'image/svg+xml');
|
||||
response.header('Cache-Control', 'public, max-age=86400'); // Cache for 1 day
|
||||
response.header('ETag', `"${Date.now()}"`); // Simple ETag
|
||||
}
|
||||
|
||||
private getColorFromName(name: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
const normalizedName = name.toLowerCase().trim();
|
||||
|
||||
for (let i = 0; i < normalizedName.length; i++) {
|
||||
hash = normalizedName.charCodeAt(i) + ((hash << 5) - hash);
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
|
||||
// Ensure we get vibrant colors by constraining the color space
|
||||
const colorParts = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
let value = (hash >> (i * 8)) & 0xff;
|
||||
// Ensure minimum color intensity for better contrast
|
||||
value = Math.max(50, value);
|
||||
colorParts.push(value.toString(16).padStart(2, '0'));
|
||||
}
|
||||
return colorParts.join('');
|
||||
|
@ -110,7 +187,7 @@ export default class AvatarController {
|
|||
const g = parseInt(hexColor.substring(2, 4), 16);
|
||||
const b = parseInt(hexColor.substring(4, 6), 16);
|
||||
|
||||
const lightenValue = (value: number) => Math.min(255, Math.floor((value * (100 + percent)) / 100));
|
||||
const lightenValue = (value: number) => Math.min(255, Math.floor(value + (255 - value) * (percent / 100)));
|
||||
|
||||
const newR = lightenValue(r);
|
||||
const newG = lightenValue(g);
|
||||
|
@ -124,7 +201,7 @@ export default class AvatarController {
|
|||
const g = parseInt(hexColor.slice(2, 4), 16);
|
||||
const b = parseInt(hexColor.slice(4, 6), 16);
|
||||
|
||||
const darkenValue = (value: number) => Math.round(value * COLOR_DARKENING_FACTOR);
|
||||
const darkenValue = (value: number) => Math.max(0, Math.floor(value * COLOR_DARKENING_FACTOR));
|
||||
|
||||
const darkerR = darkenValue(r);
|
||||
const darkerG = darkenValue(g);
|
||||
|
|
|
@ -9,8 +9,7 @@ export default class DatasetController {
|
|||
// Select datasets with server_state 'published' or 'deleted' and sort by the last published date
|
||||
const datasets = await Dataset.query()
|
||||
.where(function (query) {
|
||||
query.where('server_state', 'published')
|
||||
.orWhere('server_state', 'deleted');
|
||||
query.where('server_state', 'published').orWhere('server_state', 'deleted');
|
||||
})
|
||||
.preload('titles')
|
||||
.preload('identifier')
|
||||
|
@ -39,7 +38,9 @@ export default class DatasetController {
|
|||
.where('publish_id', params.publish_id)
|
||||
.preload('titles')
|
||||
.preload('descriptions')
|
||||
.preload('user')
|
||||
.preload('user', (builder) => {
|
||||
builder.select(['id', 'firstName', 'lastName', 'avatar', 'login']);
|
||||
})
|
||||
.preload('authors', (builder) => {
|
||||
builder.orderBy('pivot_sort_order', 'asc');
|
||||
})
|
||||
|
|
|
@ -2,7 +2,6 @@ import type { HttpContext } from '@adonisjs/core/http';
|
|||
import File from '#models/file';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
// node ace make:controller Author
|
||||
|
@ -23,8 +22,13 @@ export default class FileController {
|
|||
});
|
||||
}
|
||||
|
||||
// Check embargo date
|
||||
const dataset = file.dataset; // or file.dataset
|
||||
const dataset = file.dataset;
|
||||
// Files from unpublished datasets are now blocked
|
||||
if (dataset.server_state !== 'published') {
|
||||
return response.status(StatusCodes.FORBIDDEN).send({
|
||||
message: `File access denied: Dataset is not published.`,
|
||||
});
|
||||
}
|
||||
if (dataset && this.isUnderEmbargo(dataset.embargo_date)) {
|
||||
return response.status(StatusCodes.FORBIDDEN).send({
|
||||
message: `File is under embargo until ${dataset.embargo_date?.toFormat('yyyy-MM-dd')}`,
|
||||
|
@ -32,13 +36,16 @@ export default class FileController {
|
|||
}
|
||||
|
||||
// Proceed with file download
|
||||
const filePath = '/storage/app/data/' + file.pathName;
|
||||
const ext = path.extname(filePath);
|
||||
const fileName = file.label + ext;
|
||||
const filePath = '/storage/app/data/' + file.pathName;
|
||||
const fileExt = file.filePath.split('.').pop() || '';
|
||||
// const fileName = file.label + fileExt;
|
||||
const fileName = file.label.toLowerCase().endsWith(`.${fileExt.toLowerCase()}`)
|
||||
? file.label
|
||||
: `${file.label}.${fileExt}`;
|
||||
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.R_OK); //| fs.constants.W_OK);
|
||||
// console.log("can read/write:", path);
|
||||
// console.log("can read/write:", filePath);
|
||||
|
||||
response
|
||||
.header('Cache-Control', 'no-cache private')
|
||||
|
@ -47,7 +54,7 @@ export default class FileController {
|
|||
.header('Content-Disposition', 'inline; filename=' + fileName)
|
||||
.header('Content-Transfer-Encoding', 'binary')
|
||||
.header('Access-Control-Allow-Origin', '*')
|
||||
.header('Access-Control-Allow-Methods', 'GET,POST');
|
||||
.header('Access-Control-Allow-Methods', 'GET');
|
||||
|
||||
response.status(StatusCodes.OK).download(filePath);
|
||||
} catch (err) {
|
||||
|
|
|
@ -252,7 +252,6 @@ export default class DatasetsController {
|
|||
dataset.reject_editor_note = null;
|
||||
}
|
||||
|
||||
|
||||
//save main and additional titles
|
||||
const reviewer_id = request.input('reviewer_id', null);
|
||||
dataset.reviewer_id = reviewer_id;
|
||||
|
@ -290,8 +289,6 @@ export default class DatasetsController {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async rejectUpdate({ request, response, auth }: HttpContext) {
|
||||
const authUser = auth.user!;
|
||||
|
||||
|
@ -402,12 +399,10 @@ export default class DatasetsController {
|
|||
.back();
|
||||
}
|
||||
|
||||
|
||||
|
||||
return inertia.render('Editor/Dataset/Publish', {
|
||||
dataset,
|
||||
can: {
|
||||
reject: await auth.user?.can(['dataset-editor-reject']),
|
||||
can: {
|
||||
reject: await auth.user?.can(['dataset-editor-reject']),
|
||||
publish: await auth.user?.can(['dataset-publish']),
|
||||
},
|
||||
});
|
||||
|
@ -454,7 +449,7 @@ export default class DatasetsController {
|
|||
public async rejectToReviewer({ request, inertia, response }: HttpContext) {
|
||||
const id = request.param('id');
|
||||
const dataset = await Dataset.query()
|
||||
.where('id', id)
|
||||
.where('id', id)
|
||||
.preload('reviewer', (builder) => {
|
||||
builder.select('id', 'login', 'email');
|
||||
})
|
||||
|
@ -555,7 +550,6 @@ export default class DatasetsController {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
return response
|
||||
.flash(
|
||||
`You have successfully rejected dataset ${dataset.id} reviewed by ${dataset.reviewer.login}.${emailStatusMessage}`,
|
||||
|
@ -605,11 +599,10 @@ export default class DatasetsController {
|
|||
doiIdentifier.dataset_id = dataset.id;
|
||||
doiIdentifier.type = 'doi';
|
||||
doiIdentifier.status = 'findable';
|
||||
|
||||
|
||||
// save updated dataset to db an index to OpenSearch
|
||||
try {
|
||||
// save modified date of datset for re-caching model in db an update the search index
|
||||
// save modified date of datset for re-caching model in db an update the search index
|
||||
dataset.server_date_modified = DateTime.now();
|
||||
// autoUpdate: true only triggers when dataset.save() is called, not when saving a related model like below
|
||||
await dataset.save();
|
||||
|
@ -1125,9 +1118,20 @@ export default class DatasetsController {
|
|||
// const filePath = await drive.use('local').getUrl('/'+ file.filePath)
|
||||
const filePath = file.filePath;
|
||||
const fileExt = file.filePath.split('.').pop() || '';
|
||||
|
||||
// Check if label already includes the extension
|
||||
const fileName = file.label.toLowerCase().endsWith(`.${fileExt.toLowerCase()}`) ? file.label : `${file.label}.${fileExt}`;
|
||||
|
||||
// Set the response headers and download the file
|
||||
response.header('Content-Type', file.mime_type || 'application/octet-stream');
|
||||
response.attachment(`${file.label}.${fileExt}`);
|
||||
response
|
||||
.header('Cache-Control', 'no-cache private')
|
||||
.header('Content-Description', 'File Transfer')
|
||||
.header('Content-Type', file.mime_type || 'application/octet-stream')
|
||||
// .header('Content-Disposition', 'inline; filename=' + fileName)
|
||||
.header('Content-Transfer-Encoding', 'binary')
|
||||
.header('Access-Control-Allow-Origin', '*')
|
||||
.header('Access-Control-Allow-Methods', 'GET');
|
||||
response.attachment(fileName);
|
||||
return response.download(filePath);
|
||||
}
|
||||
|
||||
|
|
|
@ -107,13 +107,12 @@ export default class DatasetsController {
|
|||
}
|
||||
|
||||
return inertia.render('Reviewer/Dataset/Review', {
|
||||
dataset,
|
||||
dataset,
|
||||
can: {
|
||||
review: await auth.user?.can(['dataset-review']),
|
||||
reject: await auth.user?.can(['dataset-review-reject']),
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public async review_old({ request, inertia, response, auth }: HttpContext) {
|
||||
|
@ -370,6 +369,19 @@ export default class DatasetsController {
|
|||
.flash(`You have rejected dataset ${dataset.id}! to editor ${dataset.editor.login}`, 'message');
|
||||
}
|
||||
|
||||
// public async download({ params, response }: HttpContext) {
|
||||
// const id = params.id;
|
||||
// // Find the file by ID
|
||||
// const file = await File.findOrFail(id);
|
||||
// // const filePath = await drive.use('local').getUrl('/'+ file.filePath)
|
||||
// const filePath = file.filePath;
|
||||
// const fileExt = file.filePath.split('.').pop() || '';
|
||||
// // Set the response headers and download the file
|
||||
// response.header('Content-Type', file.mime_type || 'application/octet-stream');
|
||||
// response.attachment(`${file.label}.${fileExt}`);
|
||||
// return response.download(filePath);
|
||||
// }
|
||||
|
||||
public async download({ params, response }: HttpContext) {
|
||||
const id = params.id;
|
||||
// Find the file by ID
|
||||
|
@ -377,9 +389,20 @@ export default class DatasetsController {
|
|||
// const filePath = await drive.use('local').getUrl('/'+ file.filePath)
|
||||
const filePath = file.filePath;
|
||||
const fileExt = file.filePath.split('.').pop() || '';
|
||||
|
||||
// Check if label already includes the extension
|
||||
const fileName = file.label.toLowerCase().endsWith(`.${fileExt.toLowerCase()}`) ? file.label : `${file.label}.${fileExt}`;
|
||||
|
||||
// Set the response headers and download the file
|
||||
response.header('Content-Type', file.mime_type || 'application/octet-stream');
|
||||
response.attachment(`${file.label}.${fileExt}`);
|
||||
response
|
||||
.header('Cache-Control', 'no-cache private')
|
||||
.header('Content-Description', 'File Transfer')
|
||||
.header('Content-Type', file.mime_type || 'application/octet-stream')
|
||||
// .header('Content-Disposition', 'inline; filename=' + fileName)
|
||||
.header('Content-Transfer-Encoding', 'binary')
|
||||
.header('Access-Control-Allow-Origin', '*')
|
||||
.header('Access-Control-Allow-Methods', 'GET');
|
||||
response.attachment(fileName);
|
||||
return response.download(filePath);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -89,24 +89,11 @@ export default class User extends compose(BaseModel, AuthFinder) {
|
|||
@column({})
|
||||
public avatar: string;
|
||||
|
||||
// @hasOne(() => TotpSecret, {
|
||||
// foreignKey: 'user_id',
|
||||
// })
|
||||
// public totp_secret: HasOne<typeof TotpSecret>;
|
||||
|
||||
// @beforeSave()
|
||||
// public static async hashPassword(user: User) {
|
||||
// if (user.$dirty.password) {
|
||||
// user.password = await hash.use('laravel').make(user.password);
|
||||
// }
|
||||
// }
|
||||
|
||||
public get isTwoFactorEnabled(): boolean {
|
||||
return Boolean(this?.twoFactorSecret && this.state == TotpState.STATE_ENABLED);
|
||||
// return Boolean(this.totp_secret?.twoFactorSecret);
|
||||
}
|
||||
|
||||
|
||||
@manyToMany(() => Role, {
|
||||
pivotForeignKey: 'account_id',
|
||||
pivotRelatedForeignKey: 'role_id',
|
||||
|
@ -142,7 +129,9 @@ export default class User extends compose(BaseModel, AuthFinder) {
|
|||
@beforeFind()
|
||||
@beforeFetch()
|
||||
public static preloadRoles(user: User) {
|
||||
user.preload('roles')
|
||||
user.preload('roles', (builder) => {
|
||||
builder.select(['id', 'name', 'display_name', 'description']);
|
||||
});
|
||||
}
|
||||
|
||||
public async getBackupCodes(this: User): Promise<BackupCode[]> {
|
||||
|
|
3573
package-lock.json
generated
3573
package-lock.json
generated
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
|
@ -111,7 +111,14 @@
|
|||
<!--5 server_date_modified -->
|
||||
<xsl:if test="ServerDateModified/@UnixTimestamp != ''">
|
||||
<xsl:text>"server_date_modified": "</xsl:text>
|
||||
<xsl:value-of select="/ServerDateModified/@UnixTimestamp" />
|
||||
<xsl:value-of select="ServerDateModified/@UnixTimestamp" />
|
||||
<xsl:text>",</xsl:text>
|
||||
</xsl:if>
|
||||
|
||||
<!--5 embargo_date -->
|
||||
<xsl:if test="EmbargoDate/@UnixTimestamp != ''">
|
||||
<xsl:text>"embargo_date": "</xsl:text>
|
||||
<xsl:value-of select="EmbargoDate/@UnixTimestamp" />
|
||||
<xsl:text>",</xsl:text>
|
||||
</xsl:if>
|
||||
|
||||
|
|
|
@ -1,45 +1,69 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
// import { MainService } from '@/Stores/main';
|
||||
// import { StyleService } from '@/Stores/style.service';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { mdiTrashCan } from '@mdi/js';
|
||||
import { mdiDragVariant } from '@mdi/js';
|
||||
import { mdiDragVariant, mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||
import BaseIcon from '@/Components/BaseIcon.vue';
|
||||
// import CardBoxModal from '@/Components/CardBoxModal.vue';
|
||||
// import TableCheckboxCell from '@/Components/TableCheckboxCell.vue';
|
||||
// import BaseLevel from '@/Components/BaseLevel.vue';
|
||||
import BaseButtons from '@/Components/BaseButtons.vue';
|
||||
import BaseButton from '@/Components/BaseButton.vue';
|
||||
// import UserAvatar from '@/Components/UserAvatar.vue';
|
||||
// import Person from 'App/Models/Person';
|
||||
import { Person } from '@/Dataset';
|
||||
import Draggable from 'vuedraggable';
|
||||
import FormControl from '@/Components/FormControl.vue';
|
||||
|
||||
const props = defineProps({
|
||||
checkable: Boolean,
|
||||
persons: {
|
||||
type: Array<Person>,
|
||||
default: () => [],
|
||||
},
|
||||
relation: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
contributortypes: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
errors: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
interface Props {
|
||||
checkable?: boolean;
|
||||
persons?: Person[];
|
||||
relation: string;
|
||||
contributortypes?: Record<string, string>;
|
||||
errors?: Record<string, string[]>;
|
||||
isLoading?: boolean;
|
||||
canDelete?: boolean;
|
||||
canEdit?: boolean;
|
||||
canReorder?: boolean;
|
||||
}
|
||||
|
||||
// const props = defineProps({
|
||||
// checkable: Boolean,
|
||||
// persons: {
|
||||
// type: Array<Person>,
|
||||
// default: () => [],
|
||||
// },
|
||||
// relation: {
|
||||
// type: String,
|
||||
// required: true,
|
||||
// },
|
||||
// contributortypes: {
|
||||
// type: Object,
|
||||
// default: () => ({}),
|
||||
// },
|
||||
// errors: {
|
||||
// type: Object,
|
||||
// default: () => ({}),
|
||||
// },
|
||||
// });
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
checkable: false,
|
||||
persons: () => [],
|
||||
contributortypes: () => ({}),
|
||||
errors: () => ({}),
|
||||
isLoading: false,
|
||||
canDelete: true,
|
||||
canEdit: true,
|
||||
canReorder: true,
|
||||
});
|
||||
|
||||
// const styleService = StyleService();
|
||||
// const mainService = MainService();
|
||||
// const items = computed(() => props.persons);
|
||||
const emit = defineEmits<{
|
||||
'update:persons': [value: Person[]];
|
||||
'remove-person': [index: number, person: Person];
|
||||
'person-updated': [index: number, person: Person];
|
||||
'reorder': [oldIndex: number, newIndex: number];
|
||||
}>();
|
||||
|
||||
// Local state
|
||||
const perPage = ref(5);
|
||||
const currentPage = ref(0);
|
||||
const dragEnabled = ref(props.canReorder);
|
||||
|
||||
// Computed properties
|
||||
const items = computed({
|
||||
get() {
|
||||
return props.persons;
|
||||
|
@ -53,221 +77,393 @@ const items = computed({
|
|||
},
|
||||
});
|
||||
|
||||
// const isModalActive = ref(false);
|
||||
// const isModalDangerActive = ref(false);
|
||||
const perPage = ref(5);
|
||||
const currentPage = ref(0);
|
||||
// const checkedRows = ref([]);
|
||||
|
||||
const itemsPaginated = computed({
|
||||
get() {
|
||||
return items.value.slice(perPage.value * currentPage.value, perPage.value * (currentPage.value + 1));
|
||||
},
|
||||
// setter
|
||||
set(value) {
|
||||
// Note: we are using destructuring assignment syntax here.
|
||||
|
||||
props.persons.length = 0;
|
||||
props.persons.push(...value);
|
||||
},
|
||||
const itemsPaginated = computed(() => {
|
||||
const start = perPage.value * currentPage.value;
|
||||
const end = perPage.value * (currentPage.value + 1);
|
||||
return items.value.slice(start, end);
|
||||
});
|
||||
|
||||
const numPages = computed(() => Math.ceil(items.value.length / perPage.value));
|
||||
|
||||
const currentPageHuman = computed(() => currentPage.value + 1);
|
||||
const hasMultiplePages = computed(() => numPages.value > 1);
|
||||
const showContributorTypes = computed(() => Object.keys(props.contributortypes).length > 0);
|
||||
|
||||
const pagesList = computed(() => {
|
||||
const pagesList: Array<number> = [];
|
||||
const pages: number[] = [];
|
||||
const maxVisible = 10;
|
||||
|
||||
for (let i = 0; i < numPages.value; i++) {
|
||||
pagesList.push(i);
|
||||
if (numPages.value <= maxVisible) {
|
||||
for (let i = 0; i < numPages.value; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
// Smart pagination with ellipsis
|
||||
if (currentPage.value <= 2) {
|
||||
for (let i = 0; i < 4; i++) pages.push(i);
|
||||
pages.push(-1); // Ellipsis marker
|
||||
pages.push(numPages.value - 1);
|
||||
} else if (currentPage.value >= numPages.value - 3) {
|
||||
pages.push(0);
|
||||
pages.push(-1);
|
||||
for (let i = numPages.value - 4; i < numPages.value; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
pages.push(0);
|
||||
pages.push(-1);
|
||||
for (let i = currentPage.value - 1; i <= currentPage.value + 1; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
pages.push(-1);
|
||||
pages.push(numPages.value - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return pagesList;
|
||||
return pages;
|
||||
});
|
||||
|
||||
const removeAuthor = (key: number) => {
|
||||
items.value.splice(key, 1);
|
||||
// const removeAuthor = (key: number) => {
|
||||
// items.value.splice(key, 1);
|
||||
// };
|
||||
// Methods
|
||||
const removeAuthor = (index: number) => {
|
||||
const actualIndex = perPage.value * currentPage.value + index;
|
||||
const person = items.value[actualIndex];
|
||||
|
||||
if (confirm(`Are you sure you want to remove ${person.first_name || ''} ${person.last_name || person.email}?`)) {
|
||||
items.value.splice(actualIndex, 1);
|
||||
emit('remove-person', actualIndex, person);
|
||||
|
||||
// Adjust current page if needed
|
||||
if (itemsPaginated.value.length === 0 && currentPage.value > 0) {
|
||||
currentPage.value--;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// const remove = (arr, cb) => {
|
||||
// const newArr = [];
|
||||
const updatePerson = (index: number, field: keyof Person, value: any) => {
|
||||
const actualIndex = perPage.value * currentPage.value + index;
|
||||
const person = items.value[actualIndex];
|
||||
(person as any)[field] = value;
|
||||
emit('person-updated', actualIndex, person);
|
||||
};
|
||||
|
||||
// arr.forEach((item) => {
|
||||
// if (!cb(item)) {
|
||||
// newArr.push(item);
|
||||
// }
|
||||
// });
|
||||
const goToPage = (page: number) => {
|
||||
if (page >= 0 && page < numPages.value) {
|
||||
currentPage.value = page;
|
||||
}
|
||||
};
|
||||
|
||||
// return newArr;
|
||||
// };
|
||||
const getFieldError = (index: number, field: string): string => {
|
||||
const actualIndex = perPage.value * currentPage.value + index;
|
||||
const errorKey = `${props.relation}.${actualIndex}.${field}`;
|
||||
return props.errors[errorKey]?.join(', ') || '';
|
||||
};
|
||||
|
||||
// const checked = (isChecked, client) => {
|
||||
// if (isChecked) {
|
||||
// checkedRows.value.push(client);
|
||||
// } else {
|
||||
// checkedRows.value = remove(checkedRows.value, (row) => row.id === client.id);
|
||||
// }
|
||||
// };
|
||||
const handleDragEnd = (evt: any) => {
|
||||
if (evt.oldIndex !== evt.newIndex) {
|
||||
emit('reorder', evt.oldIndex, evt.newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch(
|
||||
() => props.persons.length,
|
||||
() => {
|
||||
// Reset to first page if current page is out of bounds
|
||||
if (currentPage.value >= numPages.value && numPages.value > 0) {
|
||||
currentPage.value = numPages.value - 1;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Pagination helper
|
||||
const perPageOptions = [
|
||||
{ value: 5, label: '5 per page' },
|
||||
{ value: 10, label: '10 per page' },
|
||||
{ value: 20, label: '20 per page' },
|
||||
{ value: 50, label: '50 per page' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- <CardBoxModal v-model="isModalActive" title="Sample modal">
|
||||
<p>Lorem ipsum dolor sit amet <b>adipiscing elit</b></p>
|
||||
<p>This is sample modal</p>
|
||||
</CardBoxModal>
|
||||
<div class="card">
|
||||
<!-- Table Controls -->
|
||||
<div v-if="hasMultiplePages" class="flex justify-between items-center p-3 border-b border-gray-200 dark:border-slate-700">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing {{ currentPage * perPage + 1 }} to
|
||||
{{ Math.min((currentPage + 1) * perPage, items.length) }}
|
||||
of {{ items.length }} entries
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
v-model="perPage"
|
||||
@change="currentPage = 0"
|
||||
class="px-3 py-1 text-sm border rounded-md dark:bg-slate-800 dark:border-slate-600"
|
||||
>
|
||||
<option v-for="option in perPageOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<CardBoxModal v-model="isModalDangerActive" large-title="Please confirm" button="danger" has-cancel>
|
||||
<p>Lorem ipsum dolor sit amet <b>adipiscing elit</b></p>
|
||||
<p>This is sample modal</p>
|
||||
</CardBoxModal> -->
|
||||
<!-- Table -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700">
|
||||
<th v-if="canReorder" class="w-10 p-3" />
|
||||
<th scope="col" class="text-left p-3">#</th>
|
||||
<th scope="col">Id</th>
|
||||
<th>First Name</th>
|
||||
<th>Last Name / Organization</th>
|
||||
<th>Email</th>
|
||||
<th v-if="showContributorTypes" scope="col" class="text-left p-3">Type</th>
|
||||
<th v-if="canDelete" class="w-20 p-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<!-- <tbody> -->
|
||||
<!-- <tr v-for="(client, index) in itemsPaginated" :key="client.id"> -->
|
||||
<draggable
|
||||
v-if="canReorder && !hasMultiplePages"
|
||||
tag="tbody"
|
||||
v-model="items"
|
||||
item-key="id"
|
||||
:disabled="!dragEnabled || isLoading"
|
||||
@end="handleDragEnd"
|
||||
handle=".drag-handle"
|
||||
>
|
||||
<template #item="{ index, element }">
|
||||
<tr class="border-b border-gray-100 dark:border-slate-800 hover:bg-gray-50 dark:hover:bg-slate-800">
|
||||
<td class="p-3">
|
||||
<div class="drag-handle cursor-move text-gray-400 hover:text-gray-600">
|
||||
<BaseIcon :path="mdiDragVariant" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-3">{{ index + 1 }}</td>
|
||||
<td data-label="Id" class="p-3 text-sm text-gray-600">{{ element.id || '-' }}</td>
|
||||
|
||||
<!-- <div v-if="checkedRows.length" class="p-3 bg-gray-100/50 dark:bg-slate-800">
|
||||
<span v-for="checkedRow in checkedRows" :key="checkedRow.id"
|
||||
class="inline-block px-2 py-1 rounded-sm mr-2 text-sm bg-gray-100 dark:bg-slate-700">
|
||||
{{ checkedRow.name }}
|
||||
</span>
|
||||
</div> -->
|
||||
<!-- First Name - Hidden for Organizational -->
|
||||
<td class="p-3" data-label="First Name" v-if="element.name_type !== 'Organizational'">
|
||||
<FormControl
|
||||
required
|
||||
v-model="element.first_name"
|
||||
type="text"
|
||||
:is-read-only="element.status == true"
|
||||
placeholder="[FIRST NAME]"
|
||||
/>
|
||||
<div class="text-red-400 text-sm" v-if="errors && Array.isArray(errors[`${relation}.${index}.first_name`])">
|
||||
{{ errors[`${relation}.${index}.first_name`].join(', ') }}
|
||||
</div>
|
||||
</td>
|
||||
<td v-else></td>
|
||||
<!-- Empty cell for organizational entries -->
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<!-- <th v-if="checkable" /> -->
|
||||
<th />
|
||||
<th scope="col">Sort</th>
|
||||
<th scope="col">Id</th>
|
||||
<!-- <th class="hidden lg:table-cell"></th> -->
|
||||
<th>First Name</th>
|
||||
<th>Last Name</th>
|
||||
<th>Email</th>
|
||||
<th scope="col" v-if="Object.keys(contributortypes).length">
|
||||
<span>Type</span>
|
||||
</th>
|
||||
<!-- Last Name / Organization Name -->
|
||||
<td :data-label="element.name_type === 'Organizational' ? 'Organization Name' : 'Last Name'">
|
||||
<FormControl
|
||||
required
|
||||
v-model="element.last_name"
|
||||
type="text"
|
||||
:is-read-only="element.status == true"
|
||||
:placeholder="element.name_type === 'Organizational' ? '[ORGANIZATION NAME]' : '[LAST NAME]'"
|
||||
/>
|
||||
<div class="text-red-400 text-sm" v-if="errors && Array.isArray(errors[`${relation}.${index}.last_name`])">
|
||||
{{ errors[`${relation}.${index}.last_name`].join(', ') }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- <th>Name Type</th> -->
|
||||
<!-- <th>Progress</th> -->
|
||||
<!-- <th>Created</th> -->
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<!-- <tbody> -->
|
||||
<!-- <tr v-for="(client, index) in itemsPaginated" :key="client.id"> -->
|
||||
<draggable id="galliwasery" tag="tbody" v-model="items" item-key="id">
|
||||
<template #item="{ index, element }">
|
||||
<tr>
|
||||
<td class="drag-icon">
|
||||
<BaseIcon :path="mdiDragVariant" />
|
||||
</td>
|
||||
<td scope="row">{{ index + 1 }}</td>
|
||||
<td data-label="Id">{{ element.id }}</td>
|
||||
<!-- <TableCheckboxCell v-if="checkable" @checked="checked($event, client)" /> -->
|
||||
<!-- <td v-if="element.name" class="border-b-0 lg:w-6 before:hidden hidden lg:table-cell">
|
||||
<UserAvatar :username="element.name" class="w-24 h-24 mx-auto lg:w-6 lg:h-6" />
|
||||
</td> -->
|
||||
<td data-label="First Name">
|
||||
<!-- {{ element.first_name }} -->
|
||||
<FormControl
|
||||
required
|
||||
v-model="element.first_name"
|
||||
type="text" :is-read-only="element.status==true"
|
||||
placeholder="[FIRST NAME]"
|
||||
>
|
||||
<div
|
||||
class="text-red-400 text-sm"
|
||||
v-if="errors && Array.isArray(errors[`${relation}.${index}.first_name`])"
|
||||
>
|
||||
{{ errors[`${relation}.${index}.first_name`].join(', ') }}
|
||||
<!-- Email -->
|
||||
<td data-label="Email">
|
||||
<FormControl
|
||||
required
|
||||
v-model="element.email"
|
||||
type="text"
|
||||
:is-read-only="element.status == true"
|
||||
placeholder="[EMAIL]"
|
||||
/>
|
||||
<div class="text-red-400 text-sm" v-if="errors && Array.isArray(errors[`${relation}.${index}.email`])">
|
||||
{{ errors[`${relation}.${index}.email`].join(', ') }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Contributor Type -->
|
||||
<td v-if="Object.keys(contributortypes).length">
|
||||
<FormControl
|
||||
required
|
||||
v-model="element.pivot_contributor_type"
|
||||
type="select"
|
||||
:options="contributortypes"
|
||||
placeholder="[relation type]"
|
||||
>
|
||||
<div
|
||||
class="text-red-400 text-sm"
|
||||
v-if="errors && Array.isArray(errors[`${relation}.${index}.pivot_contributor_type`])"
|
||||
>
|
||||
{{ errors[`${relation}.${index}.pivot_contributor_type`].join(', ') }}
|
||||
</div>
|
||||
</FormControl>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="before:hidden lg:w-1 whitespace-nowrap">
|
||||
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
||||
<BaseButton color="danger" :icon="mdiTrashCan" small @click.prevent="removeAuthor(index)" />
|
||||
</BaseButtons>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</draggable>
|
||||
<!-- </tbody> -->
|
||||
<!-- Non-draggable tbody for paginated view -->
|
||||
<tbody v-else>
|
||||
<tr
|
||||
v-for="(element, index) in itemsPaginated"
|
||||
:key="element.id || index"
|
||||
class="border-b border-gray-100 dark:border-slate-800 hover:bg-gray-50 dark:hover:bg-slate-800"
|
||||
>
|
||||
<td v-if="canReorder" class="p-3 text-gray-400">
|
||||
<BaseIcon :path="mdiDragVariant" />
|
||||
</td>
|
||||
<td class="p-3">{{ currentPage * perPage + index + 1 }}</td>
|
||||
<td class="p-3 text-sm text-gray-600">{{ element.id || '-' }}</td>
|
||||
|
||||
<!-- Same field structure as draggable version -->
|
||||
<td class="p-3">
|
||||
<FormControl
|
||||
v-if="element.name_type !== 'Organizational'"
|
||||
required
|
||||
:model-value="element.first_name"
|
||||
@update:model-value="updatePerson(index, 'first_name', $event)"
|
||||
type="text"
|
||||
:is-read-only="element.status || !canEdit"
|
||||
placeholder="[FIRST NAME]"
|
||||
:error="getFieldError(index, 'first_name')"
|
||||
/>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
<div v-if="getFieldError(index, 'first_name')" class="text-red-400 text-sm">
|
||||
{{ getFieldError(index, 'first_name') }}
|
||||
</div>
|
||||
</FormControl>
|
||||
</td>
|
||||
<td data-label="Last Name">
|
||||
<FormControl
|
||||
required
|
||||
v-model="element.last_name"
|
||||
type="text" :is-read-only="element.status==true"
|
||||
placeholder="[LAST NAME]"
|
||||
>
|
||||
<div
|
||||
class="text-red-400 text-sm"
|
||||
v-if="errors && Array.isArray(errors[`${relation}.${index}.last_name`])"
|
||||
>
|
||||
{{ errors[`${relation}.${index}.last_name`].join(', ') }}
|
||||
</td>
|
||||
|
||||
<td class="p-3">
|
||||
<FormControl
|
||||
required
|
||||
:model-value="element.last_name"
|
||||
@update:model-value="updatePerson(index, 'last_name', $event)"
|
||||
type="text"
|
||||
:is-read-only="element.status || !canEdit"
|
||||
:placeholder="element.name_type === 'Organizational' ? '[ORGANIZATION NAME]' : '[LAST NAME]'"
|
||||
:error="getFieldError(index, 'last_name')"
|
||||
/>
|
||||
<div v-if="getFieldError(index, 'last_name')" class="text-red-400 text-sm">
|
||||
{{ getFieldError(index, 'last_name') }}
|
||||
</div>
|
||||
</FormControl>
|
||||
</td>
|
||||
<td data-label="Email">
|
||||
<FormControl
|
||||
required
|
||||
v-model="element.email"
|
||||
type="text" :is-read-only="element.status==true"
|
||||
placeholder="[EMAIL]"
|
||||
>
|
||||
<div
|
||||
class="text-red-400 text-sm"
|
||||
v-if="errors && Array.isArray(errors[`${relation}.${index}.email`])"
|
||||
>
|
||||
{{ errors[`${relation}.${index}.email`].join(', ') }}
|
||||
</td>
|
||||
|
||||
<td class="p-3">
|
||||
<FormControl
|
||||
required
|
||||
:model-value="element.email"
|
||||
@update:model-value="updatePerson(index, 'email', $event)"
|
||||
type="email"
|
||||
:is-read-only="element.status || !canEdit"
|
||||
placeholder="[EMAIL]"
|
||||
:error="getFieldError(index, 'email')"
|
||||
/>
|
||||
<div v-if="getFieldError(index, 'email')" class="text-red-400 text-sm">
|
||||
{{ getFieldError(index, 'email') }}
|
||||
</div>
|
||||
</FormControl>
|
||||
</td>
|
||||
<td v-if="Object.keys(contributortypes).length">
|
||||
<!-- <select type="text" v-model="element.pivot.contributor_type">
|
||||
<option v-for="(option, i) in contributortypes" :value="option" :key="i">
|
||||
{{ option }}
|
||||
</option>
|
||||
</select> -->
|
||||
<FormControl
|
||||
required
|
||||
v-model="element.pivot_contributor_type"
|
||||
type="select"
|
||||
:options="contributortypes"
|
||||
placeholder="[relation type]"
|
||||
>
|
||||
<div
|
||||
class="text-red-400 text-sm"
|
||||
v-if="errors && Array.isArray(errors[`${relation}.${index}.pivot_contributor_type`])"
|
||||
>
|
||||
{{ errors[`${relation}.${index}.pivot_contributor_type`].join(', ') }}
|
||||
</td>
|
||||
|
||||
<td v-if="showContributorTypes" class="p-3">
|
||||
<FormControl
|
||||
required
|
||||
:model-value="element.pivot_contributor_type"
|
||||
@update:model-value="updatePerson(index, 'pivot_contributor_type', $event)"
|
||||
type="select"
|
||||
:options="contributortypes"
|
||||
:is-read-only="element.status || !canEdit"
|
||||
placeholder="[Select type]"
|
||||
:error="getFieldError(index, 'pivot_contributor_type')"
|
||||
/>
|
||||
<div v-if="getFieldError(index, 'pivot_contributor_type')" class="text-red-400 text-sm">
|
||||
{{ getFieldError(index, 'pivot_contributor_type') }}
|
||||
</div>
|
||||
</FormControl>
|
||||
</td>
|
||||
<!-- <td data-label="Name Type">
|
||||
{{ client.name_type }}
|
||||
</td> -->
|
||||
<!-- <td data-label="Orcid">
|
||||
{{ client.identifier_orcid }}
|
||||
</td> -->
|
||||
<!-- <td data-label="Progress" class="lg:w-32">
|
||||
<progress class="flex w-2/5 self-center lg:w-full" max="100" v-bind:value="client.progress">
|
||||
{{ client.progress }}
|
||||
</progress>
|
||||
</td> -->
|
||||
<td class="before:hidden lg:w-1 whitespace-nowrap">
|
||||
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
||||
<!-- <BaseButton color="info" :icon="mdiEye" small @click="isModalActive = true" /> -->
|
||||
<BaseButton color="danger" :icon="mdiTrashCan" small @click.prevent="removeAuthor(index)" />
|
||||
</BaseButtons>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</draggable>
|
||||
<!-- </tbody> -->
|
||||
</table>
|
||||
<!-- :class="[ pagesList.length > 1 ? 'block' : 'hidden']" -->
|
||||
<div class="p-3 lg:px-6 border-t border-gray-100 dark:border-slate-800">
|
||||
<!-- <BaseLevel>
|
||||
<BaseButtons>
|
||||
</td>
|
||||
|
||||
<td v-if="canDelete" class="p-3">
|
||||
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
||||
<BaseButton
|
||||
color="danger"
|
||||
:icon="mdiTrashCan"
|
||||
small
|
||||
@click="removeAuthor(index)"
|
||||
:disabled="element.status || !canEdit"
|
||||
title="Remove person"
|
||||
/>
|
||||
</BaseButtons>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Empty State -->
|
||||
<tr v-if="items.length === 0">
|
||||
<td :colspan="canReorder ? 8 : 7" class="text-center p-8 text-gray-500">No persons added yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="hasMultiplePages" class="flex justify-between items-center p-3 border-t border-gray-200 dark:border-slate-700">
|
||||
<div class="flex gap-1">
|
||||
<BaseButton :disabled="currentPage === 0" @click="goToPage(currentPage - 1)" :icon="mdiChevronLeft" small outline />
|
||||
|
||||
<template v-for="(page, i) in pagesList" :key="i">
|
||||
<span v-if="page === -1" class="px-3 py-1">...</span>
|
||||
<BaseButton
|
||||
v-else
|
||||
@click="goToPage(page)"
|
||||
:label="String(page + 1)"
|
||||
:color="page === currentPage ? 'info' : 'whiteDark'"
|
||||
small
|
||||
:outline="page !== currentPage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<BaseButton
|
||||
v-for="page in pagesList"
|
||||
:key="page"
|
||||
:active="page === currentPage"
|
||||
:label="page + 1"
|
||||
:disabled="currentPage >= numPages - 1"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
:icon="mdiChevronRight"
|
||||
small
|
||||
:outline="styleService.darkMode"
|
||||
@click="currentPage = page"
|
||||
outline
|
||||
/>
|
||||
</BaseButtons>
|
||||
<small>Page {{ currentPageHuman }} of {{ numPages }}</small>
|
||||
</BaseLevel> -->
|
||||
</div>
|
||||
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400"> Page {{ currentPageHuman }} of {{ numPages }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.drag-handle {
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white dark:bg-slate-900 rounded-lg shadow-sm;
|
||||
}
|
||||
|
||||
/* Improve table responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -132,13 +132,25 @@ export interface Description {
|
|||
|
||||
export interface Person {
|
||||
id?: number;
|
||||
name?: string;
|
||||
// Name fields
|
||||
first_name?: string;
|
||||
last_name?: string; // Also used for organization name
|
||||
name?: string; // Alternative full name field
|
||||
email: string;
|
||||
name_type?: string;
|
||||
// Additional identifiers
|
||||
identifier_orcid?: string;
|
||||
datasetCount?: string;
|
||||
|
||||
// Status and metadata
|
||||
status: boolean; // true = read-only/locked, false = editable
|
||||
created_at?: string;
|
||||
status: boolean;
|
||||
updated_at?: string;
|
||||
|
||||
// Statistics
|
||||
datasetCount?: string;
|
||||
|
||||
// Relationship data (for many-to-many relationships)
|
||||
pivot_contributor_type?: string; // Type of contribution (e.g., 'Author', 'Editor', 'Contributor')
|
||||
}
|
||||
|
||||
interface IErrorMessage {
|
||||
|
|
Loading…
Add table
editor.link_modal.header
Reference in a new issue