Compare commits
17 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8a34379f3 | |||
| 4229001572 | |||
| 88e37bfee8 | |||
| a4e6f88e07 | |||
| 39f1bcee46 | |||
| 3d8f2354cb | |||
| f39fe75340 | |||
| 04269ce9cf | |||
| 5e424803ed | |||
| b5bbe26ec2 | |||
| 6757bdb77c | |||
| 4c8cce27da | |||
| 2f079e6fdd | |||
| c049b22723 | |||
| 8f67839f93 | |||
| 06ed2f3625 | |||
| e1ccf0ddc8 |
75 changed files with 12166 additions and 4038 deletions
66
Dockerfile
66
Dockerfile
|
|
@ -1,57 +1,63 @@
|
||||||
################## First Stage - Creating base #########################
|
################## First Stage - Creating base #########################
|
||||||
|
|
||||||
# Created a variable to hold our node base image
|
# Created a variable to hold our node base image
|
||||||
ARG NODE_IMAGE=node:22-bookworm-slim
|
ARG NODE_IMAGE=node:22-trixie-slim
|
||||||
|
|
||||||
FROM $NODE_IMAGE AS base
|
FROM $NODE_IMAGE AS base
|
||||||
|
|
||||||
# Install dumb-init and ClamAV, and perform ClamAV database update
|
# Install dumb-init and ClamAV, and perform ClamAV database update
|
||||||
RUN apt update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y dumb-init clamav clamav-daemon nano \
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
dumb-init \
|
||||||
|
clamav \
|
||||||
|
clamav-daemon \
|
||||||
|
clamdscan \
|
||||||
|
ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
# Creating folders and changing ownerships
|
# Creating folders and changing ownerships
|
||||||
&& mkdir -p /home/node/app && chown node:node /home/node/app \
|
&& mkdir -p /home/node/app \
|
||||||
&& mkdir -p /var/lib/clamav \
|
&& mkdir -p /var/lib/clamav \
|
||||||
&& mkdir /usr/local/share/clamav \
|
&& mkdir /usr/local/share/clamav \
|
||||||
&& chown -R node:clamav /var/lib/clamav /usr/local/share/clamav /etc/clamav \
|
|
||||||
# permissions
|
|
||||||
&& mkdir /var/run/clamav \
|
&& mkdir /var/run/clamav \
|
||||||
&& chown node:clamav /var/run/clamav \
|
&& mkdir -p /var/log/clamav \
|
||||||
&& chmod 750 /var/run/clamav
|
&& mkdir -p /tmp/clamav-logs \
|
||||||
# -----------------------------------------------
|
|
||||||
# --- ClamAV & FeshClam -------------------------
|
|
||||||
# -----------------------------------------------
|
|
||||||
# RUN \
|
|
||||||
# chmod 644 /etc/clamav/freshclam.conf && \
|
|
||||||
# freshclam && \
|
|
||||||
# mkdir /var/run/clamav && \
|
|
||||||
# chown -R clamav:root /var/run/clamav
|
|
||||||
|
|
||||||
# # initial update of av databases
|
# Set ownership and permissions
|
||||||
# RUN freshclam
|
&& chown node:node /home/node/app \
|
||||||
|
# && chown -R node:clamav /var/lib/clamav /usr/local/share/clamav /etc/clamav /var/run/clamav \
|
||||||
|
&& chown -R node:clamav /var/lib/clamav /usr/local/share/clamav /etc/clamav /var/run/clamav /var/log/clamav \
|
||||||
|
&& chown -R node:clamav /etc/clamav \
|
||||||
|
&& chmod 755 /tmp/clamav-logs \
|
||||||
|
&& chmod 750 /var/run/clamav \
|
||||||
|
&& chmod 755 /var/lib/clamav \
|
||||||
|
&& chmod 755 /var/log/clamav \
|
||||||
|
# Add node user to clamav group and allow sudo for clamav commands
|
||||||
|
&& usermod -a -G clamav node
|
||||||
|
# && chmod 666 /var/run/clamav/clamd.socket
|
||||||
|
# Make directories group-writable so node (as member of clamav group) can access them
|
||||||
|
# && chmod 750 /var/run/clamav /var/lib/clamav /var/log/clamav /tmp/clamav-logs
|
||||||
|
|
||||||
# Configure Clam AV...
|
|
||||||
|
# Configure ClamAV - copy config files before switching user
|
||||||
|
# COPY --chown=node:clamav ./*.conf /etc/clamav/
|
||||||
COPY --chown=node:clamav ./*.conf /etc/clamav/
|
COPY --chown=node:clamav ./*.conf /etc/clamav/
|
||||||
|
|
||||||
# # permissions
|
|
||||||
# RUN mkdir /var/run/clamav && \
|
|
||||||
# chown node:clamav /var/run/clamav && \
|
|
||||||
# chmod 750 /var/run/clamav
|
|
||||||
# Setting the working directory
|
# Setting the working directory
|
||||||
WORKDIR /home/node/app
|
WORKDIR /home/node/app
|
||||||
# Changing the current active user to "node"
|
# Changing the current active user to "node"
|
||||||
|
|
||||||
|
# Download initial ClamAV database as root before switching users
|
||||||
USER node
|
USER node
|
||||||
|
RUN freshclam --quiet || echo "Initial database download failed - will retry at runtime"
|
||||||
|
|
||||||
# initial update of av databases
|
# Copy entrypoint script
|
||||||
RUN freshclam
|
|
||||||
|
|
||||||
# VOLUME /var/lib/clamav
|
|
||||||
COPY --chown=node:clamav docker-entrypoint.sh /home/node/app/docker-entrypoint.sh
|
COPY --chown=node:clamav docker-entrypoint.sh /home/node/app/docker-entrypoint.sh
|
||||||
RUN chmod +x /home/node/app/docker-entrypoint.sh
|
RUN chmod +x /home/node/app/docker-entrypoint.sh
|
||||||
ENV TZ="Europe/Vienna"
|
ENV TZ="Europe/Vienna"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
################## Second Stage - Installing dependencies ##########
|
################## Second Stage - Installing dependencies ##########
|
||||||
# In this stage, we will start installing dependencies
|
# In this stage, we will start installing dependencies
|
||||||
FROM base AS dependencies
|
FROM base AS dependencies
|
||||||
|
|
@ -70,7 +76,6 @@ ENV NODE_ENV=production
|
||||||
# We run "node ace build" to build the app (dist folder) for production
|
# We run "node ace build" to build the app (dist folder) for production
|
||||||
RUN node ace build --ignore-ts-errors
|
RUN node ace build --ignore-ts-errors
|
||||||
# RUN node ace build --production
|
# RUN node ace build --production
|
||||||
# RUN node ace build --ignore-ts-errors
|
|
||||||
|
|
||||||
|
|
||||||
################## Final Stage - Production #########################
|
################## Final Stage - Production #########################
|
||||||
|
|
@ -88,6 +93,7 @@ RUN npm ci --omit=dev
|
||||||
# Copy files to the working directory from the build folder the user
|
# Copy files to the working directory from the build folder the user
|
||||||
COPY --chown=node:node --from=build /home/node/app/build .
|
COPY --chown=node:node --from=build /home/node/app/build .
|
||||||
# Expose port
|
# Expose port
|
||||||
|
# EXPOSE 3310
|
||||||
EXPOSE 3333
|
EXPOSE 3333
|
||||||
ENTRYPOINT ["/home/node/app/docker-entrypoint.sh"]
|
ENTRYPOINT ["/home/node/app/docker-entrypoint.sh"]
|
||||||
# Run the command to start the server using "dumb-init"
|
# Run the command to start the server using "dumb-init"
|
||||||
|
|
|
||||||
22
LICENSE
Normal file
22
LICENSE
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Tethys Research Repository
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE
|
||||||
26
adonisrc.ts
26
adonisrc.ts
|
|
@ -13,7 +13,8 @@ export default defineConfig({
|
||||||
commands: [
|
commands: [
|
||||||
() => import('@adonisjs/core/commands'),
|
() => import('@adonisjs/core/commands'),
|
||||||
() => import('@adonisjs/lucid/commands'),
|
() => import('@adonisjs/lucid/commands'),
|
||||||
() => import('@adonisjs/mail/commands')],
|
() => import('@adonisjs/mail/commands')
|
||||||
|
],
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Preloads
|
| Preloads
|
||||||
|
|
@ -26,16 +27,17 @@ export default defineConfig({
|
||||||
() => import('./start/routes.js'),
|
() => import('./start/routes.js'),
|
||||||
() => import('./start/kernel.js'),
|
() => import('./start/kernel.js'),
|
||||||
() => import('#start/validator'),
|
() => import('#start/validator'),
|
||||||
() => import('#start/rules/unique'),
|
// () => import('#start/rules/unique'),
|
||||||
() => import('#start/rules/translated_language'),
|
// () => import('#start/rules/translated_language'),
|
||||||
() => import('#start/rules/unique_person'),
|
// () => import('#start/rules/unique_person'),
|
||||||
() => import('#start/rules/file_length'),
|
// // () => import('#start/rules/file_length'),
|
||||||
() => import('#start/rules/file_scan'),
|
// // () => import('#start/rules/file_scan'),
|
||||||
() => import('#start/rules/allowed_extensions_mimetypes'),
|
// // () => import('#start/rules/allowed_extensions_mimetypes'),
|
||||||
() => import('#start/rules/dependent_array_min_length'),
|
// () => import('#start/rules/dependent_array_min_length'),
|
||||||
() => import('#start/rules/referenceValidation'),
|
// () => import('#start/rules/referenceValidation'),
|
||||||
() => import('#start/rules/valid_mimetype'),
|
// () => import('#start/rules/valid_mimetype'),
|
||||||
() => import('#start/rules/array_contains_types'),
|
// () => import('#start/rules/array_contains_types'),
|
||||||
|
// () => import('#start/rules/orcid'),
|
||||||
],
|
],
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
@ -70,7 +72,7 @@ export default defineConfig({
|
||||||
() => import('#providers/stardust_provider'),
|
() => import('#providers/stardust_provider'),
|
||||||
() => import('#providers/query_builder_provider'),
|
() => import('#providers/query_builder_provider'),
|
||||||
() => import('#providers/token_worker_provider'),
|
() => import('#providers/token_worker_provider'),
|
||||||
// () => import('#providers/validator_provider'),
|
() => import('#providers/rule_provider'),
|
||||||
// () => import('#providers/drive/provider/drive_provider'),
|
// () => import('#providers/drive/provider/drive_provider'),
|
||||||
() => import('@adonisjs/drive/drive_provider'),
|
() => import('@adonisjs/drive/drive_provider'),
|
||||||
// () => import('@adonisjs/core/providers/vinejs_provider'),
|
// () => import('@adonisjs/core/providers/vinejs_provider'),
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,19 @@ import Person from '#models/person';
|
||||||
// node ace make:controller Author
|
// node ace make:controller Author
|
||||||
export default class AuthorsController {
|
export default class AuthorsController {
|
||||||
public async index({}: HttpContext) {
|
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"));
|
|
||||||
const authors = await Person.query()
|
const authors = await Person.query()
|
||||||
|
.select([
|
||||||
|
'id',
|
||||||
|
'academic_title',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'identifier_orcid',
|
||||||
|
'status',
|
||||||
|
'name_type',
|
||||||
|
'created_at'
|
||||||
|
// Note: 'email' is omitted
|
||||||
|
])
|
||||||
.preload('datasets')
|
.preload('datasets')
|
||||||
.where('name_type', 'Personal')
|
.where('name_type', 'Personal')
|
||||||
.whereHas('datasets', (dQuery) => {
|
.whereHas('datasets', (dQuery) => {
|
||||||
|
|
|
||||||
|
|
@ -2,68 +2,132 @@ import type { HttpContext } from '@adonisjs/core/http';
|
||||||
import { StatusCodes } from 'http-status-codes';
|
import { StatusCodes } from 'http-status-codes';
|
||||||
import redis from '@adonisjs/redis/services/main';
|
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 DEFAULT_SIZE = 50;
|
||||||
|
const MIN_SIZE = 16;
|
||||||
|
const MAX_SIZE = 512;
|
||||||
const FONT_SIZE_RATIO = 0.4;
|
const FONT_SIZE_RATIO = 0.4;
|
||||||
const COLOR_LIGHTENING_PERCENT = 60;
|
const COLOR_LIGHTENING_PERCENT = 60;
|
||||||
const COLOR_DARKENING_FACTOR = 0.6;
|
const COLOR_DARKENING_FACTOR = 0.6;
|
||||||
|
const CACHE_TTL = 24 * 60 * 60; // 24 hours instead of 1 hour
|
||||||
|
|
||||||
export default class AvatarController {
|
export default class AvatarController {
|
||||||
public async generateAvatar({ request, response }: HttpContext) {
|
public async generateAvatar({ request, response }: HttpContext) {
|
||||||
try {
|
try {
|
||||||
const { name, size = DEFAULT_SIZE } = request.only(['name', 'size']);
|
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
|
// Build a unique cache key for the given name and size
|
||||||
const cacheKey = `avatar:${name.trim().toLowerCase()}-${size}`;
|
const cacheKey = `avatar:${this.sanitizeName(name)}-${parsedSize.value}`;
|
||||||
|
// const cacheKey = `avatar:${name.trim().toLowerCase()}-${size}`;
|
||||||
|
try {
|
||||||
const cachedSvg = await redis.get(cacheKey);
|
const cachedSvg = await redis.get(cacheKey);
|
||||||
if (cachedSvg) {
|
if (cachedSvg) {
|
||||||
this.setResponseHeaders(response);
|
this.setResponseHeaders(response);
|
||||||
return response.send(cachedSvg);
|
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);
|
const initials = this.getInitials(name);
|
||||||
const colors = this.generateColors(name);
|
const colors = this.generateColors(name);
|
||||||
const svgContent = this.createSvg(size, colors, initials);
|
const svgContent = this.createSvg(size, colors, initials);
|
||||||
|
|
||||||
// // Cache the generated avatar for future use, e.g. 1 hour expiry
|
// // 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);
|
this.setResponseHeaders(response);
|
||||||
return response.send(svgContent);
|
return response.send(svgContent);
|
||||||
} catch (error) {
|
} 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 {
|
private validateSize(size: any): { isValid: boolean; value?: number; error?: string } {
|
||||||
const parts = name
|
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()
|
.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(' ')
|
.split(' ')
|
||||||
.filter((part) => part.length > 0);
|
.filter((part) => part.length > 0)
|
||||||
|
.map((part) => part.trim());
|
||||||
|
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
return 'NA';
|
return 'NA';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parts.length >= 2) {
|
if (parts.length === 1) {
|
||||||
return this.getMultiWordInitials(parts);
|
// 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 {
|
private getMultiWordInitials(parts: string[]): string {
|
||||||
|
// Filter out prefixes and short words
|
||||||
|
const significantParts = parts.filter((part) => !PREFIXES.includes(part.toLowerCase()) && part.length > 1);
|
||||||
|
|
||||||
|
if (significantParts.length === 0) {
|
||||||
|
// Fallback to first and last regardless of prefixes
|
||||||
const firstName = parts[0];
|
const firstName = parts[0];
|
||||||
const lastName = parts[parts.length - 1];
|
const lastName = parts[parts.length - 1];
|
||||||
const firstInitial = firstName.charAt(0).toUpperCase();
|
return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase();
|
||||||
const lastInitial = lastName.charAt(0).toUpperCase();
|
|
||||||
|
|
||||||
if (PREFIXES.includes(lastName.toLowerCase()) && lastName === lastName.toUpperCase()) {
|
|
||||||
return firstInitial + lastName.charAt(1).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 } {
|
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 {
|
private createSvg(size: number, colors: { background: string; text: string }, initials: string): string {
|
||||||
const fontSize = size * FONT_SIZE_RATIO;
|
const fontSize = Math.max(12, Math.floor(size * FONT_SIZE_RATIO)); // Ensure readable font size
|
||||||
return `
|
|
||||||
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
// Escape any potential HTML/XML characters in initials
|
||||||
<rect width="100%" height="100%" fill="#${colors.background}"/>
|
const escapedInitials = this.escapeXml(initials);
|
||||||
<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>
|
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 {
|
private setResponseHeaders(response: HttpContext['response']): void {
|
||||||
response.header('Content-type', 'image/svg+xml');
|
response.header('Content-Type', 'image/svg+xml');
|
||||||
response.header('Cache-Control', 'no-cache');
|
response.header('Cache-Control', 'public, max-age=86400'); // Cache for 1 day
|
||||||
response.header('Pragma', 'no-cache');
|
response.header('ETag', `"${Date.now()}"`); // Simple ETag
|
||||||
response.header('Expires', '0');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getColorFromName(name: string): string {
|
private getColorFromName(name: string): string {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < name.length; i++) {
|
const normalizedName = name.toLowerCase().trim();
|
||||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
|
||||||
|
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 = [];
|
const colorParts = [];
|
||||||
for (let i = 0; i < 3; i++) {
|
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'));
|
colorParts.push(value.toString(16).padStart(2, '0'));
|
||||||
}
|
}
|
||||||
return colorParts.join('');
|
return colorParts.join('');
|
||||||
|
|
@ -110,7 +187,7 @@ export default class AvatarController {
|
||||||
const g = parseInt(hexColor.substring(2, 4), 16);
|
const g = parseInt(hexColor.substring(2, 4), 16);
|
||||||
const b = parseInt(hexColor.substring(4, 6), 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 newR = lightenValue(r);
|
||||||
const newG = lightenValue(g);
|
const newG = lightenValue(g);
|
||||||
|
|
@ -124,7 +201,7 @@ export default class AvatarController {
|
||||||
const g = parseInt(hexColor.slice(2, 4), 16);
|
const g = parseInt(hexColor.slice(2, 4), 16);
|
||||||
const b = parseInt(hexColor.slice(4, 6), 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 darkerR = darkenValue(r);
|
||||||
const darkerG = darkenValue(g);
|
const darkerG = darkenValue(g);
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,36 @@
|
||||||
import type { HttpContext } from '@adonisjs/core/http';
|
import type { HttpContext } from '@adonisjs/core/http';
|
||||||
// import Person from 'App/Models/Person';
|
|
||||||
import Dataset from '#models/dataset';
|
import Dataset from '#models/dataset';
|
||||||
import { StatusCodes } from 'http-status-codes';
|
import { StatusCodes } from 'http-status-codes';
|
||||||
|
import DatasetReference from '#models/dataset_reference';
|
||||||
|
|
||||||
// node ace make:controller Author
|
// node ace make:controller Author
|
||||||
export default class DatasetController {
|
export default class DatasetController {
|
||||||
public async index({}: HttpContext) {
|
/**
|
||||||
// Select datasets with server_state 'published' or 'deleted' and sort by the last published date
|
* GET /api/datasets
|
||||||
|
* Find all published datasets
|
||||||
|
*/
|
||||||
|
public async index({ response }: HttpContext) {
|
||||||
|
try {
|
||||||
const datasets = await Dataset.query()
|
const datasets = await Dataset.query()
|
||||||
.where(function (query) {
|
.where(function (query) {
|
||||||
query.where('server_state', 'published')
|
query.where('server_state', 'published').orWhere('server_state', 'deleted');
|
||||||
.orWhere('server_state', 'deleted');
|
|
||||||
})
|
})
|
||||||
.preload('titles')
|
.preload('titles')
|
||||||
.preload('identifier')
|
.preload('identifier')
|
||||||
.orderBy('server_date_published', 'desc');
|
.orderBy('server_date_published', 'desc');
|
||||||
|
|
||||||
return datasets;
|
return response.status(StatusCodes.OK).json(datasets);
|
||||||
|
} catch (error) {
|
||||||
|
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
|
||||||
|
message: error.message || 'Some error occurred while retrieving datasets.',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/dataset
|
||||||
|
* Find all published datasets
|
||||||
|
*/
|
||||||
public async findAll({ response }: HttpContext) {
|
public async findAll({ response }: HttpContext) {
|
||||||
try {
|
try {
|
||||||
const datasets = await Dataset.query()
|
const datasets = await Dataset.query()
|
||||||
|
|
@ -34,34 +46,279 @@ export default class DatasetController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async findOne({ params }: HttpContext) {
|
/**
|
||||||
const datasets = await Dataset.query()
|
* GET /api/dataset/:publish_id
|
||||||
|
* Find one dataset by publish_id
|
||||||
|
*/
|
||||||
|
public async findOne({ response, params }: HttpContext) {
|
||||||
|
try {
|
||||||
|
const dataset = await Dataset.query()
|
||||||
.where('publish_id', params.publish_id)
|
.where('publish_id', params.publish_id)
|
||||||
.preload('titles')
|
.preload('titles')
|
||||||
.preload('descriptions')
|
.preload('descriptions') // Using 'descriptions' instead of 'abstracts'
|
||||||
.preload('user')
|
.preload('user', (builder) => {
|
||||||
|
builder.select(['id', 'firstName', 'lastName', 'avatar', 'login']);
|
||||||
|
})
|
||||||
.preload('authors', (builder) => {
|
.preload('authors', (builder) => {
|
||||||
builder.orderBy('pivot_sort_order', 'asc');
|
builder
|
||||||
|
.select(['id', 'academic_title', 'first_name', 'last_name', 'identifier_orcid', 'status', 'name_type'])
|
||||||
|
.withCount('datasets', (query) => {
|
||||||
|
query.as('datasets_count');
|
||||||
|
})
|
||||||
|
.pivotColumns(['role', 'sort_order'])
|
||||||
|
.orderBy('pivot_sort_order', 'asc');
|
||||||
})
|
})
|
||||||
.preload('contributors', (builder) => {
|
.preload('contributors', (builder) => {
|
||||||
builder.orderBy('pivot_sort_order', 'asc');
|
builder
|
||||||
|
.select(['id', 'academic_title', 'first_name', 'last_name', 'identifier_orcid', 'status', 'name_type'])
|
||||||
|
.withCount('datasets', (query) => {
|
||||||
|
query.as('datasets_count');
|
||||||
|
})
|
||||||
|
.pivotColumns(['role', 'sort_order', 'contributor_type'])
|
||||||
|
.orderBy('pivot_sort_order', 'asc');
|
||||||
})
|
})
|
||||||
.preload('subjects')
|
.preload('subjects')
|
||||||
.preload('coverage')
|
.preload('coverage')
|
||||||
.preload('licenses')
|
.preload('licenses')
|
||||||
.preload('references')
|
.preload('references')
|
||||||
.preload('project')
|
.preload('project')
|
||||||
.preload('referenced_by', (builder) => {
|
// .preload('referenced_by', (builder) => {
|
||||||
builder.preload('dataset', (builder) => {
|
// builder.preload('dataset', (builder) => {
|
||||||
builder.preload('identifier');
|
// builder.preload('identifier');
|
||||||
});
|
// });
|
||||||
})
|
// })
|
||||||
.preload('files', (builder) => {
|
.preload('files', (builder) => {
|
||||||
builder.preload('hashvalues');
|
builder.preload('hashvalues');
|
||||||
})
|
})
|
||||||
.preload('identifier')
|
.preload('identifier')
|
||||||
.firstOrFail();
|
.first(); // Use first() instead of firstOrFail() to handle not found gracefully
|
||||||
|
|
||||||
return datasets;
|
if (!dataset) {
|
||||||
|
return response.status(StatusCodes.NOT_FOUND).json({
|
||||||
|
message: `Cannot find Dataset with publish_id=${params.publish_id}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the version chain
|
||||||
|
const versionChain = await this.buildVersionChain(dataset);
|
||||||
|
|
||||||
|
// Add version chain to response
|
||||||
|
const responseData = {
|
||||||
|
...dataset.toJSON(),
|
||||||
|
versionChain: versionChain,
|
||||||
|
};
|
||||||
|
|
||||||
|
// return response.status(StatusCodes.OK).json(dataset);
|
||||||
|
return response.status(StatusCodes.OK).json(responseData);
|
||||||
|
} catch (error) {
|
||||||
|
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
|
||||||
|
message: error.message || `Error retrieving Dataset with publish_id=${params.publish_id}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /:prefix/:value
|
||||||
|
* Find dataset by identifier (e.g., https://doi.tethys.at/10.24341/tethys.99.2)
|
||||||
|
*/
|
||||||
|
public async findByIdentifier({ response, params }: HttpContext) {
|
||||||
|
const identifierValue = `${params.prefix}/${params.value}`;
|
||||||
|
|
||||||
|
// Optional: Validate DOI format
|
||||||
|
if (!identifierValue.match(/^10\.\d+\/[a-zA-Z0-9._-]+\.[0-9]+(?:\.[0-9]+)*$/)) {
|
||||||
|
return response.status(StatusCodes.BAD_REQUEST).json({
|
||||||
|
message: `Invalid DOI format: ${identifierValue}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Method 1: Using subquery with whereIn (most similar to your original)
|
||||||
|
const dataset = await Dataset.query()
|
||||||
|
// .whereIn('id', (subQuery) => {
|
||||||
|
// subQuery.select('dataset_id').from('dataset_identifiers').where('value', identifierValue);
|
||||||
|
// })
|
||||||
|
.whereHas('identifier', (builder) => {
|
||||||
|
builder.where('value', identifierValue);
|
||||||
|
})
|
||||||
|
.preload('titles')
|
||||||
|
.preload('descriptions') // Using 'descriptions' instead of 'abstracts'
|
||||||
|
.preload('user', (builder) => {
|
||||||
|
builder.select(['id', 'firstName', 'lastName', 'avatar', 'login']);
|
||||||
|
})
|
||||||
|
.preload('authors', (builder) => {
|
||||||
|
builder
|
||||||
|
.select(['id', 'academic_title', 'first_name', 'last_name', 'identifier_orcid', 'status', 'name_type'])
|
||||||
|
.withCount('datasets', (query) => {
|
||||||
|
query.as('datasets_count');
|
||||||
|
})
|
||||||
|
.pivotColumns(['role', 'sort_order'])
|
||||||
|
.wherePivot('role', 'author')
|
||||||
|
.orderBy('pivot_sort_order', 'asc');
|
||||||
|
})
|
||||||
|
.preload('contributors', (builder) => {
|
||||||
|
builder
|
||||||
|
.select(['id', 'academic_title', 'first_name', 'last_name', 'identifier_orcid', 'status', 'name_type'])
|
||||||
|
.withCount('datasets', (query) => {
|
||||||
|
query.as('datasets_count');
|
||||||
|
})
|
||||||
|
.pivotColumns(['role', 'sort_order', 'contributor_type'])
|
||||||
|
.wherePivot('role', 'contributor')
|
||||||
|
.orderBy('pivot_sort_order', 'asc');
|
||||||
|
})
|
||||||
|
.preload('subjects')
|
||||||
|
.preload('coverage')
|
||||||
|
.preload('licenses')
|
||||||
|
.preload('references')
|
||||||
|
.preload('project')
|
||||||
|
// .preload('referenced_by', (builder) => {
|
||||||
|
// builder.preload('dataset', (builder) => {
|
||||||
|
// builder.preload('identifier');
|
||||||
|
// });
|
||||||
|
// })
|
||||||
|
.preload('files', (builder) => {
|
||||||
|
builder.preload('hashvalues');
|
||||||
|
})
|
||||||
|
.preload('identifier')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!dataset) {
|
||||||
|
return response.status(StatusCodes.NOT_FOUND).json({
|
||||||
|
message: `Cannot find Dataset with identifier=${identifierValue}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Build the version chain
|
||||||
|
const versionChain = await this.buildVersionChain(dataset);
|
||||||
|
|
||||||
|
// Add version chain to response
|
||||||
|
const responseData = {
|
||||||
|
...dataset.toJSON(),
|
||||||
|
versionChain: versionChain,
|
||||||
|
};
|
||||||
|
|
||||||
|
// return response.status(StatusCodes.OK).json(dataset);
|
||||||
|
return response.status(StatusCodes.OK).json(responseData);
|
||||||
|
} catch (error) {
|
||||||
|
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
|
||||||
|
message: error.message || `Error retrieving Dataset with identifier=${identifierValue}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the complete version chain for a dataset
|
||||||
|
* Traverses both backwards (previous versions) and forwards (newer versions)
|
||||||
|
*/
|
||||||
|
private async buildVersionChain(dataset: Dataset) {
|
||||||
|
const versionChain = {
|
||||||
|
current: {
|
||||||
|
id: dataset.id,
|
||||||
|
publish_id: dataset.publish_id,
|
||||||
|
doi: dataset.identifier?.value || null,
|
||||||
|
main_title: dataset.mainTitle || null,
|
||||||
|
server_date_published: dataset.server_date_published,
|
||||||
|
},
|
||||||
|
previousVersions: [] as any[],
|
||||||
|
newerVersions: [] as any[],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all previous versions (going backwards in time)
|
||||||
|
versionChain.previousVersions = await this.getPreviousVersions(dataset.id);
|
||||||
|
|
||||||
|
// Get all newer versions (going forwards in time)
|
||||||
|
versionChain.newerVersions = await this.getNewerVersions(dataset.id);
|
||||||
|
|
||||||
|
return versionChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively get all previous versions
|
||||||
|
*/
|
||||||
|
private async getPreviousVersions(datasetId: number, visited: Set<number> = new Set()): Promise<any[]> {
|
||||||
|
// Prevent infinite loops
|
||||||
|
if (visited.has(datasetId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
visited.add(datasetId);
|
||||||
|
|
||||||
|
const previousVersions: any[] = [];
|
||||||
|
|
||||||
|
// Find references where this dataset "IsNewVersionOf" another dataset
|
||||||
|
const previousRefs = await DatasetReference.query()
|
||||||
|
.where('document_id', datasetId)
|
||||||
|
.where('relation', 'IsNewVersionOf')
|
||||||
|
.whereNotNull('related_document_id');
|
||||||
|
|
||||||
|
for (const ref of previousRefs) {
|
||||||
|
if (!ref.related_document_id) continue;
|
||||||
|
|
||||||
|
const previousDataset = await Dataset.query()
|
||||||
|
.where('id', ref.related_document_id)
|
||||||
|
.preload('identifier')
|
||||||
|
.preload('titles')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (previousDataset) {
|
||||||
|
const versionInfo = {
|
||||||
|
id: previousDataset.id,
|
||||||
|
publish_id: previousDataset.publish_id,
|
||||||
|
doi: previousDataset.identifier?.value || null,
|
||||||
|
main_title: previousDataset.mainTitle || null,
|
||||||
|
server_date_published: previousDataset.server_date_published,
|
||||||
|
relation: 'IsPreviousVersionOf', // From perspective of current dataset
|
||||||
|
};
|
||||||
|
|
||||||
|
previousVersions.push(versionInfo);
|
||||||
|
|
||||||
|
// Recursively get even older versions
|
||||||
|
const olderVersions = await this.getPreviousVersions(previousDataset.id, visited);
|
||||||
|
previousVersions.push(...olderVersions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return previousVersions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively get all newer versions
|
||||||
|
*/
|
||||||
|
private async getNewerVersions(datasetId: number, visited: Set<number> = new Set()): Promise<any[]> {
|
||||||
|
// Prevent infinite loops
|
||||||
|
if (visited.has(datasetId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
visited.add(datasetId);
|
||||||
|
|
||||||
|
const newerVersions: any[] = [];
|
||||||
|
|
||||||
|
// Find references where this dataset "IsPreviousVersionOf" another dataset
|
||||||
|
const newerRefs = await DatasetReference.query()
|
||||||
|
.where('document_id', datasetId)
|
||||||
|
.where('relation', 'IsPreviousVersionOf')
|
||||||
|
.whereNotNull('related_document_id');
|
||||||
|
|
||||||
|
for (const ref of newerRefs) {
|
||||||
|
if (!ref.related_document_id) continue;
|
||||||
|
|
||||||
|
const newerDataset = await Dataset.query().where('id', ref.related_document_id).preload('identifier').preload('titles').first();
|
||||||
|
|
||||||
|
if (newerDataset) {
|
||||||
|
const versionInfo = {
|
||||||
|
id: newerDataset.id,
|
||||||
|
publish_id: newerDataset.publish_id,
|
||||||
|
doi: newerDataset.identifier?.value || null,
|
||||||
|
main_title: newerDataset.mainTitle || null,
|
||||||
|
server_date_published: newerDataset.server_date_published,
|
||||||
|
relation: 'IsNewVersionOf', // From perspective of current dataset
|
||||||
|
};
|
||||||
|
|
||||||
|
newerVersions.push(versionInfo);
|
||||||
|
|
||||||
|
// Recursively get even newer versions
|
||||||
|
const evenNewerVersions = await this.getNewerVersions(newerDataset.id, visited);
|
||||||
|
newerVersions.push(...evenNewerVersions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newerVersions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,33 +2,73 @@ import type { HttpContext } from '@adonisjs/core/http';
|
||||||
import File from '#models/file';
|
import File from '#models/file';
|
||||||
import { StatusCodes } from 'http-status-codes';
|
import { StatusCodes } from 'http-status-codes';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
// node ace make:controller Author
|
// node ace make:controller Author
|
||||||
export default class FileController {
|
export default class FileController {
|
||||||
// @Get("download/:id")
|
// @Get("download/:id")
|
||||||
public async findOne({ response, params }: HttpContext) {
|
public async findOne({ response, params }: HttpContext) {
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
const file = await File.findOrFail(id);
|
// const file = await File.findOrFail(id);
|
||||||
// const file = await File.findOne({
|
// Load file with its related dataset to check embargo
|
||||||
// where: { id: id },
|
const file = await File.query()
|
||||||
// });
|
.where('id', id)
|
||||||
if (file) {
|
.preload('dataset') // or 'dataset' - whatever your relationship is named
|
||||||
|
.firstOrFail();
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return response.status(StatusCodes.NOT_FOUND).send({
|
||||||
|
message: `Cannot find File with id=${id}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proceed with file download
|
||||||
const filePath = '/storage/app/data/' + file.pathName;
|
const filePath = '/storage/app/data/' + file.pathName;
|
||||||
const ext = path.extname(filePath);
|
const fileExt = file.filePath.split('.').pop() || '';
|
||||||
const fileName = file.label + ext;
|
// const fileName = file.label + fileExt;
|
||||||
|
const fileName = file.label.toLowerCase().endsWith(`.${fileExt.toLowerCase()}`) ? file.label : `${file.label}.${fileExt}`;
|
||||||
|
|
||||||
|
// Determine if file can be previewed inline in browser
|
||||||
|
const canPreviewInline = (mimeType: string): boolean => {
|
||||||
|
const type = mimeType.toLowerCase();
|
||||||
|
return (
|
||||||
|
type === 'application/pdf' ||
|
||||||
|
type.startsWith('image/') ||
|
||||||
|
type.startsWith('text/') ||
|
||||||
|
type === 'application/json' ||
|
||||||
|
type === 'application/xml' ||
|
||||||
|
// Uncomment if you want video/audio inline
|
||||||
|
type.startsWith('video/') ||
|
||||||
|
type.startsWith('audio/')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const disposition = canPreviewInline(file.mimeType) ? 'inline' : 'attachment';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.accessSync(filePath, fs.constants.R_OK); //| fs.constants.W_OK);
|
fs.accessSync(filePath, fs.constants.R_OK); //| fs.constants.W_OK);
|
||||||
// console.log("can read/write:", path);
|
// console.log("can read/write:", filePath);
|
||||||
|
|
||||||
response
|
response
|
||||||
.header('Cache-Control', 'no-cache private')
|
.header('Cache-Control', 'no-cache private')
|
||||||
.header('Content-Description', 'File Transfer')
|
.header('Content-Description', 'File Transfer')
|
||||||
.header('Content-Type', file.mimeType)
|
.header('Content-Type', file.mimeType)
|
||||||
.header('Content-Disposition', 'inline; filename=' + fileName)
|
.header('Content-Disposition', `${disposition}; filename="${fileName}"`)
|
||||||
.header('Content-Transfer-Encoding', 'binary')
|
.header('Content-Transfer-Encoding', 'binary')
|
||||||
.header('Access-Control-Allow-Origin', '*')
|
.header('Access-Control-Allow-Origin', '*')
|
||||||
.header('Access-Control-Allow-Methods', 'GET,POST');
|
.header('Access-Control-Allow-Methods', 'GET');
|
||||||
|
|
||||||
response.status(StatusCodes.OK).download(filePath);
|
response.status(StatusCodes.OK).download(filePath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -37,18 +77,28 @@ export default class FileController {
|
||||||
message: `File with id ${id} doesn't exist on file server`,
|
message: `File with id ${id} doesn't exist on file server`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// res.status(StatusCodes.OK).sendFile(filePath, (err) => {
|
/**
|
||||||
// // res.setHeader("Content-Type", "application/json");
|
* Check if the dataset is under embargo
|
||||||
// // res.removeHeader("Content-Disposition");
|
* Compares only dates (ignoring time) for embargo check
|
||||||
// res.status(StatusCodes.NOT_FOUND).send({
|
* @param embargoDate - The embargo date from dataset
|
||||||
// message: `File with id ${id} doesn't exist on file server`,
|
* @returns true if under embargo, false if embargo has passed or no embargo set
|
||||||
// });
|
*/
|
||||||
// });
|
private isUnderEmbargo(embargoDate: DateTime | null): boolean {
|
||||||
} else {
|
// No embargo date set - allow download
|
||||||
response.status(StatusCodes.NOT_FOUND).send({
|
if (!embargoDate) {
|
||||||
message: `Cannot find File with id=${id}.`,
|
return false;
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
// Get current date at start of day (00:00:00)
|
||||||
|
const today = DateTime.now().startOf('day');
|
||||||
|
|
||||||
|
// Get embargo date at start of day (00:00:00)
|
||||||
|
const embargoDateOnly = embargoDate.startOf('day');
|
||||||
|
|
||||||
|
// File is under embargo if embargo date is after today
|
||||||
|
// This means the embargo lifts at the start of the embargo date
|
||||||
|
return embargoDateOnly >= today;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Client } from '@opensearch-project/opensearch';
|
||||||
import User from '#models/user';
|
import User from '#models/user';
|
||||||
import Dataset from '#models/dataset';
|
import Dataset from '#models/dataset';
|
||||||
import DatasetIdentifier from '#models/dataset_identifier';
|
import DatasetIdentifier from '#models/dataset_identifier';
|
||||||
import XmlModel from '#app/Library/XmlModel';
|
import DatasetXmlSerializer from '#app/Library/DatasetXmlSerializer';
|
||||||
import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js';
|
import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js';
|
||||||
import { create } from 'xmlbuilder2';
|
import { create } from 'xmlbuilder2';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
|
|
@ -252,7 +252,6 @@ export default class DatasetsController {
|
||||||
dataset.reject_editor_note = null;
|
dataset.reject_editor_note = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
//save main and additional titles
|
//save main and additional titles
|
||||||
const reviewer_id = request.input('reviewer_id', null);
|
const reviewer_id = request.input('reviewer_id', null);
|
||||||
dataset.reviewer_id = reviewer_id;
|
dataset.reviewer_id = reviewer_id;
|
||||||
|
|
@ -290,8 +289,6 @@ export default class DatasetsController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public async rejectUpdate({ request, response, auth }: HttpContext) {
|
public async rejectUpdate({ request, response, auth }: HttpContext) {
|
||||||
const authUser = auth.user!;
|
const authUser = auth.user!;
|
||||||
|
|
||||||
|
|
@ -402,8 +399,6 @@ export default class DatasetsController {
|
||||||
.back();
|
.back();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return inertia.render('Editor/Dataset/Publish', {
|
return inertia.render('Editor/Dataset/Publish', {
|
||||||
dataset,
|
dataset,
|
||||||
can: {
|
can: {
|
||||||
|
|
@ -555,7 +550,6 @@ export default class DatasetsController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
.flash(
|
.flash(
|
||||||
`You have successfully rejected dataset ${dataset.id} reviewed by ${dataset.reviewer.login}.${emailStatusMessage}`,
|
`You have successfully rejected dataset ${dataset.id} reviewed by ${dataset.reviewer.login}.${emailStatusMessage}`,
|
||||||
|
|
@ -580,51 +574,86 @@ export default class DatasetsController {
|
||||||
|
|
||||||
public async doiStore({ request, response }: HttpContext) {
|
public async doiStore({ request, response }: HttpContext) {
|
||||||
const dataId = request.param('publish_id');
|
const dataId = request.param('publish_id');
|
||||||
const dataset = await Dataset.query()
|
|
||||||
// .preload('xmlCache')
|
// Load dataset with minimal required relationships
|
||||||
.where('publish_id', dataId)
|
const dataset = await Dataset.query().where('publish_id', dataId).firstOrFail();
|
||||||
.firstOrFail();
|
|
||||||
|
const prefix = process.env.DATACITE_PREFIX || '';
|
||||||
|
const base_domain = process.env.BASE_DOMAIN || '';
|
||||||
|
|
||||||
|
// Generate DOI metadata XML
|
||||||
const xmlMeta = (await Index.getDoiRegisterString(dataset)) as string;
|
const xmlMeta = (await Index.getDoiRegisterString(dataset)) as string;
|
||||||
|
|
||||||
let prefix = '';
|
// Prepare DOI registration data
|
||||||
let base_domain = '';
|
const doiValue = `${prefix}/tethys.${dataset.publish_id}`; //'10.21388/tethys.213'
|
||||||
// const datacite_environment = process.env.DATACITE_ENVIRONMENT || 'debug';
|
const landingPageUrl = `https://doi.${getDomain(base_domain)}/${prefix}/tethys.${dataset.publish_id}`; //https://doi.dev.tethys.at/10.21388/tethys.213
|
||||||
prefix = process.env.DATACITE_PREFIX || '';
|
|
||||||
base_domain = process.env.BASE_DOMAIN || '';
|
|
||||||
|
|
||||||
// register DOI:
|
// Register DOI with DataCite
|
||||||
const doiValue = prefix + '/tethys.' + dataset.publish_id; //'10.21388/tethys.213'
|
|
||||||
const landingPageUrl = 'https://doi.' + getDomain(base_domain) + '/' + prefix + '/tethys.' + dataset.publish_id; //https://doi.dev.tethys.at/10.21388/tethys.213
|
|
||||||
const doiClient = new DoiClient();
|
const doiClient = new DoiClient();
|
||||||
const dataciteResponse = await doiClient.registerDoi(doiValue, xmlMeta, landingPageUrl);
|
const dataciteResponse = await doiClient.registerDoi(doiValue, xmlMeta, landingPageUrl);
|
||||||
|
|
||||||
if (dataciteResponse?.status === 201) {
|
if (dataciteResponse?.status !== 201) {
|
||||||
// if response OK 201; save the Identifier value into db
|
const message = `Unexpected DataCite MDS response code ${dataciteResponse?.status}`;
|
||||||
|
throw new DoiClientException(dataciteResponse?.status, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOI registration successful - persist and index
|
||||||
|
try {
|
||||||
|
// Save identifier
|
||||||
|
await this.persistDoiAndIndex(dataset, doiValue);
|
||||||
|
|
||||||
|
return response.toRoute('editor.dataset.list').flash('message', 'You have successfully created a DOI for the dataset!');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`${__filename}: Failed to persist DOI and index dataset ${dataset.id}: ${error.message}`);
|
||||||
|
throw new HttpException(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// return response.toRoute('editor.dataset.list').flash('message', xmlMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist DOI identifier and update search index
|
||||||
|
* Handles cache invalidation to ensure fresh indexing
|
||||||
|
*/
|
||||||
|
private async persistDoiAndIndex(dataset: Dataset, doiValue: string): Promise<void> {
|
||||||
|
// Create DOI identifier
|
||||||
const doiIdentifier = new DatasetIdentifier();
|
const doiIdentifier = new DatasetIdentifier();
|
||||||
doiIdentifier.value = doiValue;
|
doiIdentifier.value = doiValue;
|
||||||
doiIdentifier.dataset_id = dataset.id;
|
doiIdentifier.dataset_id = dataset.id;
|
||||||
doiIdentifier.type = 'doi';
|
doiIdentifier.type = 'doi';
|
||||||
doiIdentifier.status = 'findable';
|
doiIdentifier.status = 'findable';
|
||||||
// save modified date of datset for re-caching model in db an update the search index
|
|
||||||
dataset.server_date_modified = DateTime.now();
|
|
||||||
|
|
||||||
// save updated dataset to db an index to OpenSearch
|
// Save identifier (this will trigger database insert)
|
||||||
try {
|
|
||||||
await dataset.related('identifier').save(doiIdentifier);
|
await dataset.related('identifier').save(doiIdentifier);
|
||||||
const index_name = 'tethys-records';
|
|
||||||
await Index.indexDocument(dataset, index_name);
|
// Update dataset modification timestamp to reflect the change
|
||||||
} catch (error) {
|
dataset.server_date_modified = DateTime.now();
|
||||||
logger.error(`${__filename}: Indexing document ${dataset.id} failed: ${error.message}`);
|
await dataset.save();
|
||||||
// Log the error or handle it as needed
|
|
||||||
throw new HttpException(error.message);
|
// Invalidate stale XML cache
|
||||||
|
await this.invalidateDatasetCache(dataset);
|
||||||
|
|
||||||
|
// Reload dataset with fresh state for indexing
|
||||||
|
const freshDataset = await Dataset.query().where('id', dataset.id).preload('identifier').preload('xmlCache').firstOrFail();
|
||||||
|
|
||||||
|
// Index to OpenSearch with fresh data
|
||||||
|
const index_name = process.env.OPENSEARCH_INDEX || 'tethys-records';
|
||||||
|
await Index.indexDocument(freshDataset, index_name);
|
||||||
|
|
||||||
|
logger.info(`Successfully created DOI ${doiValue} and indexed dataset ${dataset.id}`);
|
||||||
}
|
}
|
||||||
return response.toRoute('editor.dataset.list').flash('message', 'You have successfully created a DOI for the dataset!');
|
|
||||||
} else {
|
/**
|
||||||
const message = `Unexpected DataCite MDS response code ${dataciteResponse?.status}`;
|
* Invalidate XML cache for dataset
|
||||||
// Log the error or handle it as needed
|
* Ensures fresh cache generation on next access
|
||||||
throw new DoiClientException(dataciteResponse?.status, message);
|
*/
|
||||||
|
private async invalidateDatasetCache(dataset: Dataset): Promise<void> {
|
||||||
|
await dataset.load('xmlCache');
|
||||||
|
|
||||||
|
if (dataset.xmlCache) {
|
||||||
|
await dataset.xmlCache.delete();
|
||||||
|
logger.debug(`Invalidated XML cache for dataset ${dataset.id}`);
|
||||||
}
|
}
|
||||||
// return response.toRoute('editor.dataset.list').flash('message', xmlMeta);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async show({}: HttpContext) {}
|
public async show({}: HttpContext) {}
|
||||||
|
|
@ -900,6 +929,7 @@ export default class DatasetsController {
|
||||||
const input = request.only(['project_id', 'embargo_date', 'language', 'type', 'creating_corporation']);
|
const input = request.only(['project_id', 'embargo_date', 'language', 'type', 'creating_corporation']);
|
||||||
// dataset.type = request.input('type');
|
// dataset.type = request.input('type');
|
||||||
dataset.merge(input);
|
dataset.merge(input);
|
||||||
|
dataset.server_date_modified = DateTime.now();
|
||||||
// let test: boolean = dataset.$isDirty;
|
// let test: boolean = dataset.$isDirty;
|
||||||
await dataset.useTransaction(trx).save();
|
await dataset.useTransaction(trx).save();
|
||||||
|
|
||||||
|
|
@ -1121,9 +1151,20 @@ export default class DatasetsController {
|
||||||
// const filePath = await drive.use('local').getUrl('/'+ file.filePath)
|
// const filePath = await drive.use('local').getUrl('/'+ file.filePath)
|
||||||
const filePath = file.filePath;
|
const filePath = file.filePath;
|
||||||
const fileExt = file.filePath.split('.').pop() || '';
|
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
|
// Set the response headers and download the file
|
||||||
response.header('Content-Type', file.mime_type || 'application/octet-stream');
|
response
|
||||||
response.attachment(`${file.label}.${fileExt}`);
|
.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);
|
return response.download(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1136,19 +1177,18 @@ export default class DatasetsController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getDatasetXmlDomNode(dataset: Dataset) {
|
private async getDatasetXmlDomNode(dataset: Dataset): Promise<XMLBuilder | null> {
|
||||||
const xmlModel = new XmlModel(dataset);
|
const serializer = new DatasetXmlSerializer(dataset).enableCaching().excludeEmptyFields();
|
||||||
// xmlModel.setModel(dataset);
|
// xmlModel.setModel(dataset);
|
||||||
xmlModel.excludeEmptyFields();
|
|
||||||
xmlModel.caching = true;
|
// Load existing cache if available
|
||||||
// const cache = dataset.xmlCache ? dataset.xmlCache : null;
|
await dataset.load('xmlCache');
|
||||||
// dataset.load('xmlCache');
|
|
||||||
if (dataset.xmlCache) {
|
if (dataset.xmlCache) {
|
||||||
xmlModel.xmlCache = dataset.xmlCache;
|
serializer.setCache(dataset.xmlCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
// return cache.getDomDocument();
|
// return cache.getDomDocument();
|
||||||
const domDocument: XMLBuilder | null = await xmlModel.getDomDocument();
|
const xmlDocument : XMLBuilder | null = await serializer.toXmlDocument();
|
||||||
return domDocument;
|
return xmlDocument;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { OaiModelException, BadOaiModelException } from '#app/exceptions/OaiMode
|
||||||
import Dataset from '#models/dataset';
|
import Dataset from '#models/dataset';
|
||||||
import Collection from '#models/collection';
|
import Collection from '#models/collection';
|
||||||
import { getDomain, preg_match } from '#app/utils/utility-functions';
|
import { getDomain, preg_match } from '#app/utils/utility-functions';
|
||||||
import XmlModel from '#app/Library/XmlModel';
|
import DatasetXmlSerializer from '#app/Library/DatasetXmlSerializer';
|
||||||
import logger from '@adonisjs/core/services/logger';
|
import logger from '@adonisjs/core/services/logger';
|
||||||
import ResumptionToken from '#app/Library/Oai/ResumptionToken';
|
import ResumptionToken from '#app/Library/Oai/ResumptionToken';
|
||||||
// import Config from '@ioc:Adonis/Core/Config';
|
// import Config from '@ioc:Adonis/Core/Config';
|
||||||
|
|
@ -347,7 +347,7 @@ export default class OaiController {
|
||||||
finder: ModelQueryBuilderContract<typeof Dataset, Dataset>,
|
finder: ModelQueryBuilderContract<typeof Dataset, Dataset>,
|
||||||
paginationParams: PagingParameter,
|
paginationParams: PagingParameter,
|
||||||
oaiRequest: Dictionary,
|
oaiRequest: Dictionary,
|
||||||
maxRecords: number
|
maxRecords: number,
|
||||||
) {
|
) {
|
||||||
const totalResult = await finder
|
const totalResult = await finder
|
||||||
.clone()
|
.clone()
|
||||||
|
|
@ -356,7 +356,11 @@ export default class OaiController {
|
||||||
.then((res) => res?.$extras.total);
|
.then((res) => res?.$extras.total);
|
||||||
paginationParams.totalLength = Number(totalResult);
|
paginationParams.totalLength = Number(totalResult);
|
||||||
|
|
||||||
const combinedRecords: Dataset[] = await finder.select('publish_id').orderBy('publish_id').offset(0).limit(maxRecords*2);
|
const combinedRecords: Dataset[] = await finder
|
||||||
|
.select('publish_id')
|
||||||
|
.orderBy('publish_id')
|
||||||
|
.offset(0)
|
||||||
|
.limit(maxRecords * 2);
|
||||||
|
|
||||||
paginationParams.activeWorkIds = combinedRecords.slice(0, 100).map((dat) => Number(dat.publish_id));
|
paginationParams.activeWorkIds = combinedRecords.slice(0, 100).map((dat) => Number(dat.publish_id));
|
||||||
paginationParams.nextDocIds = combinedRecords.slice(100).map((dat) => Number(dat.publish_id));
|
paginationParams.nextDocIds = combinedRecords.slice(100).map((dat) => Number(dat.publish_id));
|
||||||
|
|
@ -602,19 +606,17 @@ export default class OaiController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getDatasetXmlDomNode(dataset: Dataset) {
|
private async getDatasetXmlDomNode(dataset: Dataset) {
|
||||||
const xmlModel = new XmlModel(dataset);
|
const serializer = new DatasetXmlSerializer(dataset).enableCaching().excludeEmptyFields();
|
||||||
// xmlModel.setModel(dataset);
|
|
||||||
xmlModel.excludeEmptyFields();
|
|
||||||
xmlModel.caching = true;
|
|
||||||
// const cache = dataset.xmlCache ? dataset.xmlCache : null;
|
// const cache = dataset.xmlCache ? dataset.xmlCache : null;
|
||||||
// dataset.load('xmlCache');
|
// dataset.load('xmlCache');
|
||||||
if (dataset.xmlCache) {
|
if (dataset.xmlCache) {
|
||||||
xmlModel.xmlCache = dataset.xmlCache;
|
serializer.setCache(dataset.xmlCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
// return cache.getDomDocument();
|
// return cache.toXmlDocument();
|
||||||
const domDocument: XMLBuilder | null = await xmlModel.getDomDocument();
|
const xmlDocument: XMLBuilder | null = await serializer.toXmlDocument();
|
||||||
return domDocument;
|
return xmlDocument;
|
||||||
}
|
}
|
||||||
|
|
||||||
private addSpecInformation(domNode: XMLBuilder, information: string) {
|
private addSpecInformation(domNode: XMLBuilder, information: string) {
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,6 @@ export default class DatasetsController {
|
||||||
reject: await auth.user?.can(['dataset-review-reject']),
|
reject: await auth.user?.can(['dataset-review-reject']),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async review_old({ request, inertia, response, auth }: HttpContext) {
|
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');
|
.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) {
|
public async download({ params, response }: HttpContext) {
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
// Find the file by 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 = await drive.use('local').getUrl('/'+ file.filePath)
|
||||||
const filePath = file.filePath;
|
const filePath = file.filePath;
|
||||||
const fileExt = file.filePath.split('.').pop() || '';
|
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
|
// Set the response headers and download the file
|
||||||
response.header('Content-Type', file.mime_type || 'application/octet-stream');
|
response
|
||||||
response.attachment(`${file.label}.${fileExt}`);
|
.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);
|
return response.download(filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ export default class DatasetController {
|
||||||
'reviewed',
|
'reviewed',
|
||||||
'rejected_editor',
|
'rejected_editor',
|
||||||
'rejected_reviewer',
|
'rejected_reviewer',
|
||||||
|
'rejected_to_reviewer',
|
||||||
])
|
])
|
||||||
.where('account_id', user.id)
|
.where('account_id', user.id)
|
||||||
.preload('titles')
|
.preload('titles')
|
||||||
|
|
@ -233,8 +234,9 @@ export default class DatasetController {
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
|
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.minLength(1)
|
.minLength(1)
|
||||||
|
|
@ -249,8 +251,9 @@ export default class DatasetController {
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
|
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
|
||||||
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -324,8 +327,9 @@ export default class DatasetController {
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
|
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.minLength(1)
|
.minLength(1)
|
||||||
|
|
@ -340,8 +344,9 @@ export default class DatasetController {
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
|
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
|
||||||
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -983,19 +988,6 @@ export default class DatasetController {
|
||||||
const years = Array.from({ length: currentYear - 1990 + 1 }, (_, index) => 1990 + index);
|
const years = Array.from({ length: currentYear - 1990 + 1 }, (_, index) => 1990 + index);
|
||||||
|
|
||||||
const licenses = await License.query().select('id', 'name_long').where('active', 'true').pluck('name_long', 'id');
|
const licenses = await License.query().select('id', 'name_long').where('active', 'true').pluck('name_long', 'id');
|
||||||
// const userHasRoles = user.roles;
|
|
||||||
// const datasetHasLicenses = await dataset.related('licenses').query().pluck('id');
|
|
||||||
// const checkeds = dataset.licenses.first().id;
|
|
||||||
|
|
||||||
// const doctypes = {
|
|
||||||
// analysisdata: { label: 'Analysis', value: 'analysisdata' },
|
|
||||||
// measurementdata: { label: 'Measurements', value: 'measurementdata' },
|
|
||||||
// monitoring: 'Monitoring',
|
|
||||||
// remotesensing: 'Remote Sensing',
|
|
||||||
// gis: 'GIS',
|
|
||||||
// models: 'Models',
|
|
||||||
// mixedtype: 'Mixed Type',
|
|
||||||
// };
|
|
||||||
|
|
||||||
return inertia.render('Submitter/Dataset/Edit', {
|
return inertia.render('Submitter/Dataset/Edit', {
|
||||||
dataset,
|
dataset,
|
||||||
|
|
@ -1163,42 +1155,93 @@ export default class DatasetController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process all subjects/keywords from the request
|
// ============================================
|
||||||
const subjects = request.input('subjects');
|
// IMPROVED SUBJECTS PROCESSING
|
||||||
|
// ============================================
|
||||||
|
const subjects = request.input('subjects', []);
|
||||||
|
const currentDatasetSubjectIds = new Set<number>();
|
||||||
|
|
||||||
for (const subjectData of subjects) {
|
for (const subjectData of subjects) {
|
||||||
// Case 1: Subject already exists in the database (has an ID)
|
let subjectToRelate: Subject;
|
||||||
|
|
||||||
|
// Case 1: Subject has an ID (existing subject being updated)
|
||||||
if (subjectData.id) {
|
if (subjectData.id) {
|
||||||
// Retrieve the existing subject
|
|
||||||
const existingSubject = await Subject.findOrFail(subjectData.id);
|
const existingSubject = await Subject.findOrFail(subjectData.id);
|
||||||
|
|
||||||
// Update subject properties from the request data
|
// Check if the updated value conflicts with another existing subject
|
||||||
|
const duplicateSubject = await Subject.query()
|
||||||
|
.where('value', subjectData.value)
|
||||||
|
.where('type', subjectData.type)
|
||||||
|
.where('language', subjectData.language || 'en') // Default language if not provided
|
||||||
|
.where('id', '!=', subjectData.id) // Exclude the current subject
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (duplicateSubject) {
|
||||||
|
// A duplicate exists - use the existing duplicate instead
|
||||||
|
subjectToRelate = duplicateSubject;
|
||||||
|
|
||||||
|
// Check if the original subject should be deleted (if it's only used by this dataset)
|
||||||
|
const originalSubjectUsage = await Subject.query()
|
||||||
|
.where('id', existingSubject.id)
|
||||||
|
.withCount('datasets')
|
||||||
|
.firstOrFail();
|
||||||
|
|
||||||
|
if (originalSubjectUsage.$extras.datasets_count <= 1) {
|
||||||
|
// Only used by this dataset, safe to delete after detaching
|
||||||
|
await dataset.useTransaction(trx).related('subjects').detach([existingSubject.id]);
|
||||||
|
await existingSubject.useTransaction(trx).delete();
|
||||||
|
} else {
|
||||||
|
// Used by other datasets, just detach from this one
|
||||||
|
await dataset.useTransaction(trx).related('subjects').detach([existingSubject.id]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No duplicate found, update the existing subject
|
||||||
existingSubject.value = subjectData.value;
|
existingSubject.value = subjectData.value;
|
||||||
existingSubject.type = subjectData.type;
|
existingSubject.type = subjectData.type;
|
||||||
|
existingSubject.language = subjectData.language;
|
||||||
existingSubject.external_key = subjectData.external_key;
|
existingSubject.external_key = subjectData.external_key;
|
||||||
|
|
||||||
// Only save if there are actual changes
|
|
||||||
if (existingSubject.$isDirty) {
|
if (existingSubject.$isDirty) {
|
||||||
await existingSubject.save();
|
await existingSubject.useTransaction(trx).save();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: The relationship between dataset and subject is already established,
|
subjectToRelate = existingSubject;
|
||||||
// so we don't need to attach it again
|
}
|
||||||
}
|
}
|
||||||
// Case 2: New subject being added (no ID)
|
// Case 2: New subject being added (no ID)
|
||||||
else {
|
else {
|
||||||
// Check if a subject with the same value and type already exists in the database
|
// Use firstOrNew to either find existing or create new subject
|
||||||
const subject = await Subject.firstOrNew({ value: subjectData.value, type: subjectData.type }, subjectData);
|
subjectToRelate = await Subject.firstOrNew(
|
||||||
|
{
|
||||||
|
value: subjectData.value,
|
||||||
|
type: subjectData.type,
|
||||||
|
language: subjectData.language || 'en',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: subjectData.value,
|
||||||
|
type: subjectData.type,
|
||||||
|
language: subjectData.language || 'en',
|
||||||
|
external_key: subjectData.external_key,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (subject.$isNew === true) {
|
if (subjectToRelate.$isNew) {
|
||||||
// If it's a completely new subject, create and associate it with the dataset
|
await subjectToRelate.useTransaction(trx).save();
|
||||||
await dataset.useTransaction(trx).related('subjects').save(subject);
|
|
||||||
} else {
|
|
||||||
// If the subject already exists, just create the relationship
|
|
||||||
await dataset.useTransaction(trx).related('subjects').attach([subject.id]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure the relationship exists between dataset and subject
|
||||||
|
const relationshipExists = await dataset.related('subjects').query().where('subject_id', subjectToRelate.id).first();
|
||||||
|
|
||||||
|
if (!relationshipExists) {
|
||||||
|
await dataset.useTransaction(trx).related('subjects').attach([subjectToRelate.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track which subjects should remain associated with this dataset
|
||||||
|
currentDatasetSubjectIds.add(subjectToRelate.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle explicit deletions
|
||||||
const subjectsToDelete = request.input('subjectsToDelete', []);
|
const subjectsToDelete = request.input('subjectsToDelete', []);
|
||||||
for (const subjectData of subjectsToDelete) {
|
for (const subjectData of subjectsToDelete) {
|
||||||
if (subjectData.id) {
|
if (subjectData.id) {
|
||||||
|
|
@ -1211,16 +1254,16 @@ export default class DatasetController {
|
||||||
.withCount('datasets')
|
.withCount('datasets')
|
||||||
.firstOrFail();
|
.firstOrFail();
|
||||||
|
|
||||||
// Check if the subject is used by multiple datasets
|
// Detach the subject from this dataset
|
||||||
if (subject.$extras.datasets_count > 1) {
|
|
||||||
// If used by multiple datasets, just detach it from the current dataset
|
|
||||||
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
|
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
|
||||||
} else {
|
|
||||||
// If only used by this dataset, delete the subject completely
|
|
||||||
|
|
||||||
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
|
// If this was the only dataset using this subject, delete it entirely
|
||||||
|
if (subject.$extras.datasets_count <= 1) {
|
||||||
await subject.useTransaction(trx).delete();
|
await subject.useTransaction(trx).delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove from current set if it was added earlier
|
||||||
|
currentDatasetSubjectIds.delete(subjectData.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
231
app/Library/DatasetXmlSerializer.ts
Normal file
231
app/Library/DatasetXmlSerializer.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
import DocumentXmlCache from '#models/DocumentXmlCache';
|
||||||
|
import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js';
|
||||||
|
import Dataset from '#models/dataset';
|
||||||
|
import Strategy from './Strategy.js';
|
||||||
|
import { builder } from 'xmlbuilder2';
|
||||||
|
import logger from '@adonisjs/core/services/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for XML serialization
|
||||||
|
*
|
||||||
|
* @interface XmlSerializationConfig
|
||||||
|
*/
|
||||||
|
export interface XmlSerializationConfig {
|
||||||
|
/** The dataset model to serialize */
|
||||||
|
model: Dataset;
|
||||||
|
/** DOM representation (if available) */
|
||||||
|
dom?: XMLBuilder;
|
||||||
|
/** Fields to exclude from serialization */
|
||||||
|
excludeFields: Array<string>;
|
||||||
|
/** Whether to exclude empty fields */
|
||||||
|
excludeEmpty: boolean;
|
||||||
|
/** Base URI for xlink:ref elements */
|
||||||
|
baseUri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for controlling serialization behavior
|
||||||
|
*/
|
||||||
|
export interface SerializationOptions {
|
||||||
|
/** Enable XML caching */
|
||||||
|
enableCaching?: boolean;
|
||||||
|
/** Exclude empty fields from output */
|
||||||
|
excludeEmptyFields?: boolean;
|
||||||
|
/** Custom base URI */
|
||||||
|
baseUri?: string;
|
||||||
|
/** Fields to exclude */
|
||||||
|
excludeFields?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DatasetXmlSerializer
|
||||||
|
*
|
||||||
|
* Handles XML serialization of Dataset models with intelligent caching.
|
||||||
|
* Generates XML representations and manages cache lifecycle to optimize performance.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const serializer = new DatasetXmlSerializer(dataset);
|
||||||
|
* serializer.enableCaching();
|
||||||
|
* serializer.excludeEmptyFields();
|
||||||
|
*
|
||||||
|
* const xmlDocument = await serializer.toXmlDocument();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export default class DatasetXmlSerializer {
|
||||||
|
private readonly config: XmlSerializationConfig;
|
||||||
|
private readonly strategy: Strategy;
|
||||||
|
private cache: DocumentXmlCache | null = null;
|
||||||
|
private cachingEnabled = false;
|
||||||
|
|
||||||
|
constructor(dataset: Dataset, options: SerializationOptions = {}) {
|
||||||
|
this.config = {
|
||||||
|
model: dataset,
|
||||||
|
excludeEmpty: options.excludeEmptyFields ?? false,
|
||||||
|
baseUri: options.baseUri ?? '',
|
||||||
|
excludeFields: options.excludeFields ?? [],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.strategy = new Strategy({
|
||||||
|
excludeEmpty: options.excludeEmptyFields ?? false,
|
||||||
|
baseUri: options.baseUri ?? '',
|
||||||
|
excludeFields: options.excludeFields ?? [],
|
||||||
|
model: dataset,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.enableCaching) {
|
||||||
|
this.cachingEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable caching for XML generation
|
||||||
|
* When enabled, generated XML is stored in database for faster retrieval
|
||||||
|
*/
|
||||||
|
public enableCaching(): this {
|
||||||
|
this.cachingEnabled = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable caching for XML generation
|
||||||
|
*/
|
||||||
|
public disableCaching(): this {
|
||||||
|
this.cachingEnabled = false;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
set model(model: Dataset) {
|
||||||
|
this.config.model = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure to exclude empty fields from XML output
|
||||||
|
*/
|
||||||
|
public excludeEmptyFields(): this {
|
||||||
|
this.config.excludeEmpty = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the cache instance directly (useful when preloading)
|
||||||
|
* @param cache - The DocumentXmlCache instance
|
||||||
|
*/
|
||||||
|
public setCache(cache: DocumentXmlCache): this {
|
||||||
|
this.cache = cache;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current cache instance
|
||||||
|
*/
|
||||||
|
public getCache(): DocumentXmlCache | null {
|
||||||
|
return this.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get DOM document with intelligent caching
|
||||||
|
* Returns cached version if valid, otherwise generates new document
|
||||||
|
*/
|
||||||
|
public async toXmlDocument(): Promise<XMLBuilder | null> {
|
||||||
|
const dataset = this.config.model;
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
let cachedDocument: XMLBuilder | null = await this.retrieveFromCache();
|
||||||
|
|
||||||
|
if (cachedDocument) {
|
||||||
|
logger.debug(`Using cached XML for dataset ${dataset.id}`);
|
||||||
|
return cachedDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate fresh document
|
||||||
|
logger.debug(`[DatasetXmlSerializer] Cache miss - generating fresh XML for dataset ${dataset.id}`);
|
||||||
|
const freshDocument = await this.strategy.createDomDocument();
|
||||||
|
|
||||||
|
if (!freshDocument) {
|
||||||
|
logger.error(`[DatasetXmlSerializer] Failed to generate XML for dataset ${dataset.id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache if caching is enabled
|
||||||
|
if (this.cachingEnabled) {
|
||||||
|
await this.persistToCache(freshDocument, dataset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the dataset-specific node
|
||||||
|
return this.extractDatasetNode(freshDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate XML string representation
|
||||||
|
* Convenience method that converts XMLBuilder to string
|
||||||
|
*/
|
||||||
|
public async toXmlString(): Promise<string | null> {
|
||||||
|
const document = await this.toXmlDocument();
|
||||||
|
return document ? document.end({ prettyPrint: false }) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist generated XML document to cache
|
||||||
|
* Non-blocking - failures are logged but don't interrupt the flow
|
||||||
|
*/
|
||||||
|
private async persistToCache(domDocument: XMLBuilder, dataset: Dataset): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.cache = this.cache || new DocumentXmlCache();
|
||||||
|
this.cache.document_id = dataset.id;
|
||||||
|
this.cache.xml_version = 1;
|
||||||
|
this.cache.server_date_modified = dataset.server_date_modified.toFormat('yyyy-MM-dd HH:mm:ss');
|
||||||
|
this.cache.xml_data = domDocument.end();
|
||||||
|
|
||||||
|
await this.cache.save();
|
||||||
|
logger.debug(`Cached XML for dataset ${dataset.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to cache XML for dataset ${dataset.id}: ${error.message}`);
|
||||||
|
// Don't throw - caching failure shouldn't break the flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the Rdr_Dataset node from full document
|
||||||
|
*/
|
||||||
|
private extractDatasetNode(domDocument: XMLBuilder): XMLBuilder | null {
|
||||||
|
const node = domDocument.find((n) => n.node.nodeName === 'Rdr_Dataset', false, true)?.node;
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
return builder({ version: '1.0', encoding: 'UTF-8', standalone: true }, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return domDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to retrieve valid cached XML document
|
||||||
|
* Returns null if cache doesn't exist or is stale
|
||||||
|
*/
|
||||||
|
private async retrieveFromCache(): Promise<XMLBuilder | null> {
|
||||||
|
const dataset: Dataset = this.config.model;
|
||||||
|
if (!this.cache) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cache is still valid
|
||||||
|
const actuallyCached = await DocumentXmlCache.hasValidEntry(dataset.id, dataset.server_date_modified);
|
||||||
|
|
||||||
|
if (!actuallyCached) {
|
||||||
|
logger.debug(`Cache invalid for dataset ${dataset.id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
//cache is actual return cached document
|
||||||
|
try {
|
||||||
|
if (this.cache) {
|
||||||
|
return this.cache.getDomDocument();
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to retrieve cached document for dataset ${dataset.id}: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
// import { Client } from 'guzzle';
|
|
||||||
// import { Log } from '@adonisjs/core/build/standalone';
|
|
||||||
// import { DoiInterface } from './interfaces/DoiInterface';
|
|
||||||
import DoiClientContract from '#app/Library/Doi/DoiClientContract';
|
import DoiClientContract from '#app/Library/Doi/DoiClientContract';
|
||||||
import DoiClientException from '#app/exceptions/DoiClientException';
|
import DoiClientException from '#app/exceptions/DoiClientException';
|
||||||
import { StatusCodes } from 'http-status-codes';
|
import { StatusCodes } from 'http-status-codes';
|
||||||
|
|
@ -12,14 +9,14 @@ export class DoiClient implements DoiClientContract {
|
||||||
public username: string;
|
public username: string;
|
||||||
public password: string;
|
public password: string;
|
||||||
public serviceUrl: string;
|
public serviceUrl: string;
|
||||||
|
public apiUrl: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// const datacite_environment = process.env.DATACITE_ENVIRONMENT || 'debug';
|
// const datacite_environment = process.env.DATACITE_ENVIRONMENT || 'debug';
|
||||||
this.username = process.env.DATACITE_USERNAME || '';
|
this.username = process.env.DATACITE_USERNAME || '';
|
||||||
this.password = process.env.DATACITE_PASSWORD || '';
|
this.password = process.env.DATACITE_PASSWORD || '';
|
||||||
this.serviceUrl = process.env.DATACITE_SERVICE_URL || '';
|
this.serviceUrl = process.env.DATACITE_SERVICE_URL || '';
|
||||||
// this.prefix = process.env.DATACITE_PREFIX || '';
|
this.apiUrl = process.env.DATACITE_API_URL || 'https://api.datacite.org';
|
||||||
// this.base_domain = process.env.BASE_DOMAIN || '';
|
|
||||||
|
|
||||||
if (this.username === '' || this.password === '' || this.serviceUrl === '') {
|
if (this.username === '' || this.password === '' || this.serviceUrl === '') {
|
||||||
const message = 'issing configuration settings to properly initialize DOI client';
|
const message = 'issing configuration settings to properly initialize DOI client';
|
||||||
|
|
@ -90,4 +87,240 @@ export class DoiClient implements DoiClientContract {
|
||||||
throw new DoiClientException(error.response.status, error.response.data);
|
throw new DoiClientException(error.response.status, error.response.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves DOI information from DataCite REST API
|
||||||
|
*
|
||||||
|
* @param doiValue The DOI identifier e.g. '10.5072/tethys.999'
|
||||||
|
* @returns Promise with DOI information or null if not found
|
||||||
|
*/
|
||||||
|
public async getDoiInfo(doiValue: string): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
// Use configurable DataCite REST API URL
|
||||||
|
const dataciteApiUrl = `${this.apiUrl}/dois/${doiValue}`;
|
||||||
|
const response = await axios.get(dataciteApiUrl, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.api+json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 200 && response.data.data) {
|
||||||
|
return {
|
||||||
|
created: response.data.data.attributes.created,
|
||||||
|
registered: response.data.data.attributes.registered,
|
||||||
|
updated: response.data.data.attributes.updated,
|
||||||
|
published: response.data.data.attributes.published,
|
||||||
|
state: response.data.data.attributes.state,
|
||||||
|
url: response.data.data.attributes.url,
|
||||||
|
metadata: response.data.data.attributes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
logger.debug(`DOI ${doiValue} not found in DataCite`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`DataCite REST API failed for ${doiValue}: ${error.message}`);
|
||||||
|
|
||||||
|
// Fallback to MDS API
|
||||||
|
return await this.getDoiInfoFromMds(doiValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback method to get DOI info from MDS API
|
||||||
|
*
|
||||||
|
* @param doiValue The DOI identifier
|
||||||
|
* @returns Promise with basic DOI information or null
|
||||||
|
*/
|
||||||
|
private async getDoiInfoFromMds(doiValue: string): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const auth = {
|
||||||
|
username: this.username,
|
||||||
|
password: this.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get DOI URL
|
||||||
|
const doiResponse = await axios.get(`${this.serviceUrl}/doi/${doiValue}`, { auth });
|
||||||
|
|
||||||
|
if (doiResponse.status === 200) {
|
||||||
|
// Get metadata if available
|
||||||
|
try {
|
||||||
|
const metadataResponse = await axios.get(`${this.serviceUrl}/metadata/${doiValue}`, {
|
||||||
|
auth,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/xml',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: doiResponse.data.trim(),
|
||||||
|
metadata: metadataResponse.data,
|
||||||
|
created: new Date().toISOString(), // MDS doesn't provide creation dates
|
||||||
|
registered: new Date().toISOString(), // Use current time as fallback
|
||||||
|
source: 'mds',
|
||||||
|
};
|
||||||
|
} catch (metadataError) {
|
||||||
|
// Return basic info even if metadata fetch fails
|
||||||
|
return {
|
||||||
|
url: doiResponse.data.trim(),
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
registered: new Date().toISOString(),
|
||||||
|
source: 'mds',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
logger.debug(`DOI ${doiValue} not found in DataCite MDS`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`DataCite MDS API failed for ${doiValue}: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a DOI exists in DataCite
|
||||||
|
*
|
||||||
|
* @param doiValue The DOI identifier
|
||||||
|
* @returns Promise<boolean> True if DOI exists
|
||||||
|
*/
|
||||||
|
public async doiExists(doiValue: string): Promise<boolean> {
|
||||||
|
const doiInfo = await this.getDoiInfo(doiValue);
|
||||||
|
return doiInfo !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the last modification date of a DOI
|
||||||
|
*
|
||||||
|
* @param doiValue The DOI identifier
|
||||||
|
* @returns Promise<Date | null> Last modification date or creation date if never updated, null if not found
|
||||||
|
*/
|
||||||
|
public async getDoiLastModified(doiValue: string): Promise<Date | null> {
|
||||||
|
const doiInfo = await this.getDoiInfo(doiValue);
|
||||||
|
|
||||||
|
if (doiInfo) {
|
||||||
|
// Use updated date if available, otherwise fall back to created/registered date
|
||||||
|
const dateToUse = doiInfo.updated || doiInfo.registered || doiInfo.created;
|
||||||
|
|
||||||
|
if (dateToUse) {
|
||||||
|
logger.debug(
|
||||||
|
`DOI ${doiValue}: Using ${doiInfo.updated ? 'updated' : doiInfo.registered ? 'registered' : 'created'} date: ${dateToUse}`,
|
||||||
|
);
|
||||||
|
return new Date(dateToUse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a DOI unfindable (registered but not discoverable)
|
||||||
|
* Note: DOIs cannot be deleted, only made unfindable
|
||||||
|
* await doiClient.makeDoiUnfindable('10.21388/tethys.231');
|
||||||
|
*
|
||||||
|
* @param doiValue The DOI identifier e.g. '10.5072/tethys.999'
|
||||||
|
* @returns Promise<AxiosResponse<any>> The http response
|
||||||
|
*/
|
||||||
|
public async makeDoiUnfindable(doiValue: string): Promise<AxiosResponse<any>> {
|
||||||
|
const auth = {
|
||||||
|
username: this.username,
|
||||||
|
password: this.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, check if DOI exists
|
||||||
|
const exists = await this.doiExists(doiValue);
|
||||||
|
if (!exists) {
|
||||||
|
throw new DoiClientException(404, `DOI ${doiValue} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the DOI URL mapping to make it unfindable
|
||||||
|
// This removes the URL but keeps the metadata registered
|
||||||
|
const response = await axios.delete(`${this.serviceUrl}/doi/${doiValue}`, { auth });
|
||||||
|
|
||||||
|
// Response Codes for DELETE /doi/{doi}
|
||||||
|
// 200 OK: operation successful
|
||||||
|
// 401 Unauthorized: no login
|
||||||
|
// 403 Forbidden: login problem, quota exceeded
|
||||||
|
// 404 Not Found: DOI does not exist
|
||||||
|
if (response.status !== 200) {
|
||||||
|
const message = `Unexpected DataCite MDS response code ${response.status}`;
|
||||||
|
logger.error(message);
|
||||||
|
throw new DoiClientException(response.status, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`DOI ${doiValue} successfully made unfindable`);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to make DOI ${doiValue} unfindable: ${error.message}`);
|
||||||
|
if (error instanceof DoiClientException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new DoiClientException(error.response?.status || 500, error.response?.data || error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a DOI findable again by re-registering the URL
|
||||||
|
* await doiClient.makeDoiFindable(
|
||||||
|
* '10.21388/tethys.231',
|
||||||
|
* 'https://doi.dev.tethys.at/10.21388/tethys.231'
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* @param doiValue The DOI identifier e.g. '10.5072/tethys.999'
|
||||||
|
* @param landingPageUrl The landing page URL
|
||||||
|
* @returns Promise<AxiosResponse<any>> The http response
|
||||||
|
*/
|
||||||
|
public async makeDoiFindable(doiValue: string, landingPageUrl: string): Promise<AxiosResponse<any>> {
|
||||||
|
const auth = {
|
||||||
|
username: this.username,
|
||||||
|
password: this.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Re-register the DOI with its URL to make it findable again
|
||||||
|
const response = await axios.put(`${this.serviceUrl}/doi/${doiValue}`, `doi=${doiValue}\nurl=${landingPageUrl}`, { auth });
|
||||||
|
|
||||||
|
// Response Codes for PUT /doi/{doi}
|
||||||
|
// 201 Created: operation successful
|
||||||
|
// 400 Bad Request: request body must be exactly two lines: DOI and URL
|
||||||
|
// 401 Unauthorized: no login
|
||||||
|
// 403 Forbidden: login problem, quota exceeded
|
||||||
|
// 412 Precondition failed: metadata must be uploaded first
|
||||||
|
if (response.status !== 201) {
|
||||||
|
const message = `Unexpected DataCite MDS response code ${response.status}`;
|
||||||
|
logger.error(message);
|
||||||
|
throw new DoiClientException(response.status, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`DOI ${doiValue} successfully made findable again`);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to make DOI ${doiValue} findable: ${error.message}`);
|
||||||
|
if (error instanceof DoiClientException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new DoiClientException(error.response?.status || 500, error.response?.data || error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current state of a DOI (draft, registered, findable)
|
||||||
|
* const state = await doiClient.getDoiState('10.21388/tethys.231');
|
||||||
|
* console.log(`Current state: ${state}`); // 'findable'
|
||||||
|
*
|
||||||
|
* @param doiValue The DOI identifier
|
||||||
|
* @returns Promise<string | null> The DOI state or null if not found
|
||||||
|
*/
|
||||||
|
public async getDoiState(doiValue: string): Promise<string | null> {
|
||||||
|
const doiInfo = await this.getDoiInfo(doiValue);
|
||||||
|
return doiInfo?.state || null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import Dataset from '#models/dataset';
|
||||||
import { Client } from '@opensearch-project/opensearch';
|
import { Client } from '@opensearch-project/opensearch';
|
||||||
import { create } from 'xmlbuilder2';
|
import { create } from 'xmlbuilder2';
|
||||||
import SaxonJS from 'saxon-js';
|
import SaxonJS from 'saxon-js';
|
||||||
import XmlModel from '#app/Library/XmlModel';
|
import DatasetXmlSerializer from '#app/Library/DatasetXmlSerializer';
|
||||||
import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js';
|
import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js';
|
||||||
import logger from '@adonisjs/core/services/logger';
|
import logger from '@adonisjs/core/services/logger';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
|
|
@ -72,31 +72,42 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index a dataset document to OpenSearch/Elasticsearch
|
||||||
|
*/
|
||||||
async indexDocument(dataset: Dataset, index_name: string): Promise<void> {
|
async indexDocument(dataset: Dataset, index_name: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const proc = readFileSync('public/assets2/solr.sef.json');
|
// Load XSLT transformation file
|
||||||
const doc: string = await this.getTransformedString(dataset, proc);
|
const xsltProc = readFileSync('public/assets2/solr.sef.json');
|
||||||
|
|
||||||
let document = JSON.parse(doc);
|
// Transform dataset to JSON document
|
||||||
|
const jsonDoc: string = await this.getTransformedString(dataset, xsltProc);
|
||||||
|
|
||||||
|
const document = JSON.parse(jsonDoc);
|
||||||
|
|
||||||
|
// Index document to OpenSearch with doument json body
|
||||||
await this.client.index({
|
await this.client.index({
|
||||||
id: dataset.publish_id?.toString(),
|
id: dataset.publish_id?.toString(),
|
||||||
index: index_name,
|
index: index_name,
|
||||||
body: document,
|
body: document,
|
||||||
refresh: true,
|
refresh: true, // make immediately searchable
|
||||||
});
|
});
|
||||||
logger.info(`dataset with publish_id ${dataset.publish_id} successfully indexed`);
|
logger.info(`Dataset ${dataset.publish_id} successfully indexed to ${index_name}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`An error occurred while indexing datsaet with publish_id ${dataset.publish_id}.`);
|
logger.error(`Failed to index dataset ${dataset.publish_id}: ${error.message}`);
|
||||||
|
throw error; // Re-throw to allow caller to handle
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform dataset XML to JSON using XSLT
|
||||||
|
*/
|
||||||
async getTransformedString(dataset: Dataset, proc: Buffer): Promise<string> {
|
async getTransformedString(dataset: Dataset, proc: Buffer): Promise<string> {
|
||||||
let xml = create({ version: '1.0', encoding: 'UTF-8', standalone: true }, '<root></root>');
|
// Generate XML string from dataset
|
||||||
const datasetNode = xml.root().ele('Dataset');
|
const xmlString = await this.generateDatasetXml(dataset);
|
||||||
await createXmlRecord(dataset, datasetNode);
|
|
||||||
const xmlString = xml.end({ prettyPrint: false });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Apply XSLT transformation
|
||||||
const result = await SaxonJS.transform({
|
const result = await SaxonJS.transform({
|
||||||
stylesheetText: proc,
|
stylesheetText: proc,
|
||||||
destination: 'serialized',
|
destination: 'serialized',
|
||||||
|
|
@ -108,6 +119,18 @@ export default {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate XML string from dataset model
|
||||||
|
*/
|
||||||
|
async generateDatasetXml(dataset: Dataset): Promise<string> {
|
||||||
|
const xml = create({ version: '1.0', encoding: 'UTF-8', standalone: true }, '<root></root>');
|
||||||
|
const datasetNode = xml.root().ele('Dataset');
|
||||||
|
|
||||||
|
await createXmlRecord(dataset, datasetNode);
|
||||||
|
|
||||||
|
return xml.end({ prettyPrint: false });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Return the default global focus trap stack
|
* Return the default global focus trap stack
|
||||||
|
|
@ -115,74 +138,49 @@ export default {
|
||||||
* @return {import('focus-trap').FocusTrap[]}
|
* @return {import('focus-trap').FocusTrap[]}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// export const indexDocument = async (dataset: Dataset, index_name: string, proc: Buffer): Promise<void> => {
|
/**
|
||||||
// try {
|
* Create complete XML record for dataset
|
||||||
// const doc = await getJsonString(dataset, proc);
|
* Handles caching and metadata enrichment
|
||||||
|
*/
|
||||||
// let document = JSON.parse(doc);
|
|
||||||
// await client.index({
|
|
||||||
// id: dataset.publish_id?.toString(),
|
|
||||||
// index: index_name,
|
|
||||||
// body: document,
|
|
||||||
// refresh: true,
|
|
||||||
// });
|
|
||||||
// Logger.info(`dataset with publish_id ${dataset.publish_id} successfully indexed`);
|
|
||||||
// } catch (error) {
|
|
||||||
// Logger.error(`An error occurred while indexing datsaet with publish_id ${dataset.publish_id}.`);
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const getJsonString = async (dataset, proc): Promise<string> => {
|
|
||||||
// let xml = create({ version: '1.0', encoding: 'UTF-8', standalone: true }, '<root></root>');
|
|
||||||
// const datasetNode = xml.root().ele('Dataset');
|
|
||||||
// await createXmlRecord(dataset, datasetNode);
|
|
||||||
// const xmlString = xml.end({ prettyPrint: false });
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// const result = await transform({
|
|
||||||
// stylesheetText: proc,
|
|
||||||
// destination: 'serialized',
|
|
||||||
// sourceText: xmlString,
|
|
||||||
// });
|
|
||||||
// return result.principalResult;
|
|
||||||
// } catch (error) {
|
|
||||||
// Logger.error(`An error occurred while creating the user, error: ${error.message},`);
|
|
||||||
// return '';
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
const createXmlRecord = async (dataset: Dataset, datasetNode: XMLBuilder): Promise<void> => {
|
const createXmlRecord = async (dataset: Dataset, datasetNode: XMLBuilder): Promise<void> => {
|
||||||
const domNode = await getDatasetXmlDomNode(dataset);
|
const domNode = await getDatasetXmlDomNode(dataset);
|
||||||
if (domNode) {
|
|
||||||
// add frontdoor url and data-type
|
if (!domNode) {
|
||||||
dataset.publish_id && addLandingPageAttribute(domNode, dataset.publish_id.toString());
|
throw new Error(`Failed to generate XML DOM node for dataset ${dataset.id}`);
|
||||||
addSpecInformation(domNode, 'data-type:' + dataset.type);
|
}
|
||||||
|
|
||||||
|
// Enrich with landing page URL
|
||||||
|
if (dataset.publish_id) {
|
||||||
|
addLandingPageAttribute(domNode, dataset.publish_id.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add data type specification
|
||||||
|
addSpecInformation(domNode, `data-type:${dataset.type}`);
|
||||||
|
|
||||||
|
// Add collection information
|
||||||
if (dataset.collections) {
|
if (dataset.collections) {
|
||||||
for (const coll of dataset.collections) {
|
for (const coll of dataset.collections) {
|
||||||
const collRole = coll.collectionRole;
|
const collRole = coll.collectionRole;
|
||||||
addSpecInformation(domNode, collRole.oai_name + ':' + coll.number);
|
addSpecInformation(domNode, `${collRole.oai_name}:${coll.number}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
datasetNode.import(domNode);
|
datasetNode.import(domNode);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDatasetXmlDomNode = async (dataset: Dataset): Promise<XMLBuilder | null> => {
|
const getDatasetXmlDomNode = async (dataset: Dataset): Promise<XMLBuilder | null> => {
|
||||||
const xmlModel = new XmlModel(dataset);
|
const serializer = new DatasetXmlSerializer(dataset).enableCaching().excludeEmptyFields();
|
||||||
// xmlModel.setModel(dataset);
|
// xmlModel.setModel(dataset);
|
||||||
xmlModel.excludeEmptyFields();
|
|
||||||
xmlModel.caching = true;
|
// Load cache relationship if not already loaded
|
||||||
// const cache = dataset.xmlCache ? dataset.xmlCache : null;
|
|
||||||
// dataset.load('xmlCache');
|
|
||||||
await dataset.load('xmlCache');
|
await dataset.load('xmlCache');
|
||||||
if (dataset.xmlCache) {
|
if (dataset.xmlCache) {
|
||||||
xmlModel.xmlCache = dataset.xmlCache;
|
serializer.setCache(dataset.xmlCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
// return cache.getDomDocument();
|
// Generate or retrieve cached DOM document
|
||||||
const domDocument: XMLBuilder | null = await xmlModel.getDomDocument();
|
const xmlDocument: XMLBuilder | null = await serializer.toXmlDocument();
|
||||||
return domDocument;
|
return xmlDocument;
|
||||||
};
|
};
|
||||||
|
|
||||||
const addLandingPageAttribute = (domNode: XMLBuilder, dataid: string) => {
|
const addLandingPageAttribute = (domNode: XMLBuilder, dataid: string) => {
|
||||||
|
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
import DocumentXmlCache from '#models/DocumentXmlCache';
|
|
||||||
import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js';
|
|
||||||
import Dataset from '#models/dataset';
|
|
||||||
import Strategy from './Strategy.js';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { builder } from 'xmlbuilder2';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is the description of the interface
|
|
||||||
*
|
|
||||||
* @interface Conf
|
|
||||||
* @member {Model} model holds the current dataset model
|
|
||||||
* @member {XMLBuilder} dom holds the current DOM representation
|
|
||||||
* @member {Array<string>} excludeFields List of fields to skip on serialization.
|
|
||||||
* @member {boolean} excludeEmpty True, if empty fields get excluded from serialization.
|
|
||||||
* @member {string} baseUri Base URI for xlink:ref elements
|
|
||||||
*/
|
|
||||||
export interface Conf {
|
|
||||||
model: Dataset;
|
|
||||||
dom?: XMLBuilder;
|
|
||||||
excludeFields: Array<string>;
|
|
||||||
excludeEmpty: boolean;
|
|
||||||
baseUri: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class XmlModel {
|
|
||||||
private config: Conf;
|
|
||||||
// private strategy = null;
|
|
||||||
private cache: DocumentXmlCache | null = null;
|
|
||||||
private _caching = false;
|
|
||||||
private strategy: Strategy;
|
|
||||||
|
|
||||||
constructor(dataset: Dataset) {
|
|
||||||
// $this->strategy = new Strategy();// Opus_Model_Xml_Version1;
|
|
||||||
// $this->config = new Conf();
|
|
||||||
// $this->strategy->setup($this->config);
|
|
||||||
|
|
||||||
this.config = {
|
|
||||||
excludeEmpty: false,
|
|
||||||
baseUri: '',
|
|
||||||
excludeFields: [],
|
|
||||||
model: dataset,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.strategy = new Strategy({
|
|
||||||
excludeEmpty: true,
|
|
||||||
baseUri: '',
|
|
||||||
excludeFields: [],
|
|
||||||
model: dataset,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
set model(model: Dataset) {
|
|
||||||
this.config.model = model;
|
|
||||||
}
|
|
||||||
|
|
||||||
public excludeEmptyFields(): void {
|
|
||||||
this.config.excludeEmpty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
get xmlCache(): DocumentXmlCache | null {
|
|
||||||
return this.cache;
|
|
||||||
}
|
|
||||||
|
|
||||||
set xmlCache(cache: DocumentXmlCache) {
|
|
||||||
this.cache = cache;
|
|
||||||
}
|
|
||||||
|
|
||||||
get caching(): boolean {
|
|
||||||
return this._caching;
|
|
||||||
}
|
|
||||||
set caching(caching: boolean) {
|
|
||||||
this._caching = caching;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getDomDocument(): Promise<XMLBuilder | null> {
|
|
||||||
const dataset = this.config.model;
|
|
||||||
|
|
||||||
let domDocument: XMLBuilder | null = await this.getDomDocumentFromXmlCache();
|
|
||||||
if (domDocument == null) {
|
|
||||||
domDocument = await this.strategy.createDomDocument();
|
|
||||||
// domDocument = create({ version: '1.0', encoding: 'UTF-8', standalone: true }, '<root></root>');
|
|
||||||
if (this._caching) {
|
|
||||||
// caching is desired:
|
|
||||||
this.cache = this.cache || new DocumentXmlCache();
|
|
||||||
this.cache.document_id = dataset.id;
|
|
||||||
this.cache.xml_version = 1; // (int)$this->strategy->getVersion();
|
|
||||||
this.cache.server_date_modified = dataset.server_date_modified.toFormat('yyyy-MM-dd HH:mm:ss');
|
|
||||||
this.cache.xml_data = domDocument.end();
|
|
||||||
await this.cache.save();
|
|
||||||
}
|
|
||||||
const node = domDocument.find(
|
|
||||||
(n) => {
|
|
||||||
const test = n.node.nodeName == 'Rdr_Dataset';
|
|
||||||
return test;
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
)?.node;
|
|
||||||
if (node != undefined) {
|
|
||||||
domDocument = builder({ version: '1.0', encoding: 'UTF-8', standalone: true }, node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return domDocument;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getDomDocumentFromXmlCache(): Promise<XMLBuilder | null> {
|
|
||||||
const dataset: Dataset = this.config.model;
|
|
||||||
if (!this.cache) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
//.toFormat('YYYY-MM-DD HH:mm:ss');
|
|
||||||
let date: DateTime = dataset.server_date_modified;
|
|
||||||
const actuallyCached: boolean = await DocumentXmlCache.hasValidEntry(dataset.id, date);
|
|
||||||
if (!actuallyCached) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
//cache is actual return it for oai:
|
|
||||||
try {
|
|
||||||
if (this.cache) {
|
|
||||||
return this.cache.getDomDocument();
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
54
app/controllers/projects_controller.ts
Normal file
54
app/controllers/projects_controller.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// app/controllers/projects_controller.ts
|
||||||
|
import Project from '#models/project';
|
||||||
|
import type { HttpContext } from '@adonisjs/core/http';
|
||||||
|
import { createProjectValidator, updateProjectValidator } from '#validators/project';
|
||||||
|
|
||||||
|
export default class ProjectsController {
|
||||||
|
// GET /settings/projects
|
||||||
|
public async index({ inertia, auth }: HttpContext) {
|
||||||
|
const projects = await Project.all();
|
||||||
|
// return inertia.render('Admin/Project/Index', { projects });
|
||||||
|
return inertia.render('Admin/Project/Index', {
|
||||||
|
projects: projects,
|
||||||
|
can: {
|
||||||
|
edit: await auth.user?.can(['settings']),
|
||||||
|
create: await auth.user?.can(['settings']),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /settings/projects/create
|
||||||
|
public async create({ inertia }: HttpContext) {
|
||||||
|
return inertia.render('Admin/Project/Create');
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /settings/projects
|
||||||
|
public async store({ request, response, session }: HttpContext) {
|
||||||
|
// Validate the request data
|
||||||
|
const data = await request.validateUsing(createProjectValidator);
|
||||||
|
|
||||||
|
await Project.create(data);
|
||||||
|
|
||||||
|
session.flash('success', 'Project created successfully');
|
||||||
|
return response.redirect().toRoute('settings.project.index');
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /settings/projects/:id/edit
|
||||||
|
public async edit({ params, inertia }: HttpContext) {
|
||||||
|
const project = await Project.findOrFail(params.id);
|
||||||
|
return inertia.render('Admin/Project/Edit', { project });
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /settings/projects/:id
|
||||||
|
public async update({ params, request, response, session }: HttpContext) {
|
||||||
|
const project = await Project.findOrFail(params.id);
|
||||||
|
|
||||||
|
// Validate the request data
|
||||||
|
const data = await request.validateUsing(updateProjectValidator);
|
||||||
|
|
||||||
|
await project.merge(data).save();
|
||||||
|
|
||||||
|
session.flash('success', 'Project updated successfully');
|
||||||
|
return response.redirect().toRoute('settings.project.index');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,8 @@ import { builder, create } from 'xmlbuilder2';
|
||||||
import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js';
|
import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js';
|
||||||
import db from '@adonisjs/lucid/services/db';
|
import db from '@adonisjs/lucid/services/db';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import type { BelongsTo } from "@adonisjs/lucid/types/relations";
|
import type { BelongsTo } from '@adonisjs/lucid/types/relations';
|
||||||
|
import logger from '@adonisjs/core/services/logger';
|
||||||
|
|
||||||
export default class DocumentXmlCache extends BaseModel {
|
export default class DocumentXmlCache extends BaseModel {
|
||||||
public static namingStrategy = new SnakeCaseNamingStrategy();
|
public static namingStrategy = new SnakeCaseNamingStrategy();
|
||||||
|
|
@ -66,33 +67,38 @@ export default class DocumentXmlCache extends BaseModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a dataset in a specific xml version is already cached or not.
|
* Check if a valid (non-stale) cache entry exists
|
||||||
|
* Cache is valid only if it was created AFTER the dataset's last modification
|
||||||
*
|
*
|
||||||
* @param mixed datasetId
|
* @param datasetId - The dataset ID to check
|
||||||
* @param mixed serverDateModified
|
* @param datasetServerDateModified - The dataset's last modification timestamp
|
||||||
* @returns {Promise<boolean>} Returns true on cached hit else false.
|
* @returns true if valid cache exists, false otherwise
|
||||||
*/
|
*/
|
||||||
// public static async hasValidEntry(datasetId: number, datasetServerDateModified: DateTime): Promise<boolean> {
|
|
||||||
// // const formattedDate = dayjs(datasetServerDateModified).format('YYYY-MM-DD HH:mm:ss');
|
|
||||||
|
|
||||||
// const query = Database.from(this.table)
|
|
||||||
// .where('document_id', datasetId)
|
|
||||||
// .where('server_date_modified', '2023-08-17 16:51:03')
|
|
||||||
// .first();
|
|
||||||
|
|
||||||
// const row = await query;
|
|
||||||
// return !!row;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Assuming 'DocumentXmlCache' has a table with a 'server_date_modified' column in your database
|
|
||||||
public static async hasValidEntry(datasetId: number, datasetServerDateModified: DateTime): Promise<boolean> {
|
public static async hasValidEntry(datasetId: number, datasetServerDateModified: DateTime): Promise<boolean> {
|
||||||
const serverDateModifiedString: string = datasetServerDateModified.toFormat('yyyy-MM-dd HH:mm:ss'); // Convert DateTime to ISO string
|
const serverDateModifiedString: string = datasetServerDateModified.toFormat('yyyy-MM-dd HH:mm:ss'); // Convert DateTime to ISO string
|
||||||
const query = db.from(this.table)
|
|
||||||
|
const row = await db
|
||||||
|
.from(this.table)
|
||||||
.where('document_id', datasetId)
|
.where('document_id', datasetId)
|
||||||
.where('server_date_modified', '>=', serverDateModifiedString) // Check if server_date_modified is newer or equal
|
.where('server_date_modified', '>', serverDateModifiedString) // Check if server_date_modified is newer or equal
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
const row = await query;
|
const isValid = !!row;
|
||||||
return !!row;
|
|
||||||
|
if (isValid) {
|
||||||
|
logger.debug(`Valid cache found for dataset ${datasetId}`);
|
||||||
|
} else {
|
||||||
|
logger.debug(`No valid cache for dataset ${datasetId} (dataset modified: ${serverDateModifiedString})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate (delete) cache entry
|
||||||
|
*/
|
||||||
|
public async invalidate(): Promise<void> {
|
||||||
|
await this.delete();
|
||||||
|
logger.debug(`Invalidated cache for document ${this.document_id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export default class Person extends BaseModel {
|
||||||
@column({})
|
@column({})
|
||||||
public lastName: string;
|
public lastName: string;
|
||||||
|
|
||||||
@column({})
|
@column({ columnName: 'identifier_orcid' })
|
||||||
public identifierOrcid: string;
|
public identifierOrcid: string;
|
||||||
|
|
||||||
@column({})
|
@column({})
|
||||||
|
|
@ -95,4 +95,34 @@ export default class Person extends BaseModel {
|
||||||
pivotColumns: ['role', 'sort_order', 'allow_email_contact'],
|
pivotColumns: ['role', 'sort_order', 'allow_email_contact'],
|
||||||
})
|
})
|
||||||
public datasets: ManyToMany<typeof Dataset>;
|
public datasets: ManyToMany<typeof Dataset>;
|
||||||
|
|
||||||
|
// public toJSON() {
|
||||||
|
// const json = super.toJSON();
|
||||||
|
|
||||||
|
// // Check if this person is loaded through a pivot relationship with sensitive roles
|
||||||
|
// const pivotRole = this.$extras?.pivot_role;
|
||||||
|
// if (pivotRole === 'author' || pivotRole === 'contributor') {
|
||||||
|
// // Remove sensitive information for public-facing roles
|
||||||
|
// delete json.email;
|
||||||
|
// // delete json.identifierOrcid;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return json;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @afterFind()
|
||||||
|
// public static async afterFindHook(person: Person) {
|
||||||
|
// if (person.$extras?.pivot_role === 'author' || person.$extras?.pivot_role === 'contributor') {
|
||||||
|
// person.email = undefined as any;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @afterFetch()
|
||||||
|
// public static async afterFetchHook(persons: Person[]) {
|
||||||
|
// persons.forEach(person => {
|
||||||
|
// if (person.$extras?.pivot_role === 'author' || person.$extras?.pivot_role === 'contributor') {
|
||||||
|
// person.email = undefined as any;
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,24 +89,11 @@ export default class User extends compose(BaseModel, AuthFinder) {
|
||||||
@column({})
|
@column({})
|
||||||
public avatar: string;
|
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 {
|
public get isTwoFactorEnabled(): boolean {
|
||||||
return Boolean(this?.twoFactorSecret && this.state == TotpState.STATE_ENABLED);
|
return Boolean(this?.twoFactorSecret && this.state == TotpState.STATE_ENABLED);
|
||||||
// return Boolean(this.totp_secret?.twoFactorSecret);
|
// return Boolean(this.totp_secret?.twoFactorSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@manyToMany(() => Role, {
|
@manyToMany(() => Role, {
|
||||||
pivotForeignKey: 'account_id',
|
pivotForeignKey: 'account_id',
|
||||||
pivotRelatedForeignKey: 'role_id',
|
pivotRelatedForeignKey: 'role_id',
|
||||||
|
|
@ -142,7 +129,9 @@ export default class User extends compose(BaseModel, AuthFinder) {
|
||||||
@beforeFind()
|
@beforeFind()
|
||||||
@beforeFetch()
|
@beforeFetch()
|
||||||
public static preloadRoles(user: User) {
|
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[]> {
|
public async getBackupCodes(this: User): Promise<BackupCode[]> {
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,9 @@ export const createDatasetValidator = vine.compile(
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
|
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.minLength(1)
|
.minLength(1)
|
||||||
|
|
@ -83,9 +84,10 @@ export const createDatasetValidator = vine.compile(
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
||||||
|
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.distinct('email')
|
.distinct('email')
|
||||||
|
|
@ -158,7 +160,8 @@ export const createDatasetValidator = vine.compile(
|
||||||
.fileScan({ removeInfected: true }),
|
.fileScan({ removeInfected: true }),
|
||||||
)
|
)
|
||||||
.minLength(1),
|
.minLength(1),
|
||||||
}),);
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the dataset's update action
|
* Validates the dataset's update action
|
||||||
|
|
@ -214,8 +217,9 @@ export const updateDatasetValidator = vine.compile(
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
|
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.minLength(1)
|
.minLength(1)
|
||||||
|
|
@ -230,8 +234,9 @@ export const updateDatasetValidator = vine.compile(
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
|
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
|
||||||
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -305,12 +310,13 @@ export const updateDatasetValidator = vine.compile(
|
||||||
.fileScan({ removeInfected: true }),
|
.fileScan({ removeInfected: true }),
|
||||||
)
|
)
|
||||||
.dependentArrayMinLength({ dependentArray: 'fileInputs', min: 1 }),
|
.dependentArrayMinLength({ dependentArray: 'fileInputs', min: 1 }),
|
||||||
fileInputs: vine.array(
|
fileInputs: vine
|
||||||
|
.array(
|
||||||
vine.object({
|
vine.object({
|
||||||
label: vine.string().trim().maxLength(100),
|
label: vine.string().trim().maxLength(100),
|
||||||
//extnames: extensions,
|
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
|
.optional(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -365,8 +371,9 @@ export const updateEditorDatasetValidator = vine.compile(
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
|
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.minLength(1)
|
.minLength(1)
|
||||||
|
|
@ -381,8 +388,9 @@ export const updateEditorDatasetValidator = vine.compile(
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
|
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
|
||||||
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
28
app/validators/project.ts
Normal file
28
app/validators/project.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// app/validators/project.ts
|
||||||
|
import vine from '@vinejs/vine';
|
||||||
|
|
||||||
|
export const createProjectValidator = vine.compile(
|
||||||
|
vine.object({
|
||||||
|
label: vine.string().trim().minLength(1).maxLength(50) .regex(/^[a-z0-9-]+$/),
|
||||||
|
name: vine
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.minLength(3)
|
||||||
|
.maxLength(255)
|
||||||
|
.regex(/^[a-zA-Z0-9äöüßÄÖÜ\s-]+$/),
|
||||||
|
description: vine.string().trim().maxLength(255).minLength(5).optional(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const updateProjectValidator = vine.compile(
|
||||||
|
vine.object({
|
||||||
|
// label is NOT included since it's readonly
|
||||||
|
name: vine
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.minLength(3)
|
||||||
|
.maxLength(255)
|
||||||
|
.regex(/^[a-zA-Z0-9äöüßÄÖÜ\s-]+$/),
|
||||||
|
description: vine.string().trim().maxLength(255).minLength(5).optional(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -8,20 +8,20 @@ export const createRoleValidator = vine.compile(
|
||||||
vine.object({
|
vine.object({
|
||||||
name: vine
|
name: vine
|
||||||
.string()
|
.string()
|
||||||
.isUnique({ table: 'roles', column: 'name' })
|
|
||||||
.trim()
|
.trim()
|
||||||
.minLength(3)
|
.minLength(3)
|
||||||
.maxLength(255)
|
.maxLength(255)
|
||||||
.regex(/^[a-zA-Z0-9]+$/), //Must be alphanumeric with hyphens or underscores
|
.isUnique({ table: 'roles', column: 'name' })
|
||||||
|
.regex(/^[a-zA-Z0-9]+$/), // Must be alphanumeric
|
||||||
display_name: vine
|
display_name: vine
|
||||||
.string()
|
.string()
|
||||||
.isUnique({ table: 'roles', column: 'display_name' })
|
|
||||||
.trim()
|
.trim()
|
||||||
.minLength(3)
|
.minLength(3)
|
||||||
.maxLength(255)
|
.maxLength(255)
|
||||||
|
.isUnique({ table: 'roles', column: 'display_name' })
|
||||||
.regex(/^[a-zA-Z0-9]+$/),
|
.regex(/^[a-zA-Z0-9]+$/),
|
||||||
description: vine.string().trim().escape().minLength(3).maxLength(255).optional(),
|
description: vine.string().trim().escape().minLength(3).maxLength(255).optional(),
|
||||||
permissions: vine.array(vine.number()).minLength(1), // define at least one permission for the new role
|
permissions: vine.array(vine.number()).minLength(1), // At least one permission required
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -29,21 +29,28 @@ export const updateRoleValidator = vine.withMetaData<{ roleId: number }>().compi
|
||||||
vine.object({
|
vine.object({
|
||||||
name: vine
|
name: vine
|
||||||
.string()
|
.string()
|
||||||
// .unique(async (db, value, field) => {
|
.trim()
|
||||||
// const result = await db.from('roles').select('id').whereNot('id', field.meta.roleId).where('name', value).first();
|
.minLength(3)
|
||||||
// return result.length ? false : true;
|
.maxLength(255)
|
||||||
// })
|
|
||||||
.isUnique({
|
.isUnique({
|
||||||
table: 'roles',
|
table: 'roles',
|
||||||
column: 'name',
|
column: 'name',
|
||||||
whereNot: (field) => field.meta.roleId,
|
whereNot: (field) => field.meta.roleId,
|
||||||
})
|
})
|
||||||
|
.regex(/^[a-zA-Z0-9]+$/),
|
||||||
|
display_name: vine
|
||||||
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.minLength(3)
|
.minLength(3)
|
||||||
.maxLength(255),
|
.maxLength(255)
|
||||||
|
.isUnique({
|
||||||
|
table: 'roles',
|
||||||
|
column: 'display_name',
|
||||||
|
whereNot: (field) => field.meta.roleId,
|
||||||
|
})
|
||||||
|
.regex(/^[a-zA-Z0-9]+$/),
|
||||||
description: vine.string().trim().escape().minLength(3).maxLength(255).optional(),
|
description: vine.string().trim().escape().minLength(3).maxLength(255).optional(),
|
||||||
permissions: vine.array(vine.number()).minLength(1), // define at least one permission for the new role
|
permissions: vine.array(vine.number()).minLength(1), // At least one permission required
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
20
clamd.conf
20
clamd.conf
|
|
@ -5,7 +5,23 @@ LogSyslog no
|
||||||
LogVerbose yes
|
LogVerbose yes
|
||||||
DatabaseDirectory /var/lib/clamav
|
DatabaseDirectory /var/lib/clamav
|
||||||
LocalSocket /var/run/clamav/clamd.socket
|
LocalSocket /var/run/clamav/clamd.socket
|
||||||
|
# LocalSocketMode 666
|
||||||
|
# Optional: allow multiple threads
|
||||||
|
MaxThreads 20
|
||||||
|
# Disable TCP socket
|
||||||
|
# TCPSocket 0
|
||||||
|
|
||||||
|
# TCP port address.
|
||||||
|
# Default: no
|
||||||
|
# TCPSocket 3310
|
||||||
|
# TCP address.
|
||||||
|
# By default we bind to INADDR_ANY, probably not wise.
|
||||||
|
# Enable the following to provide some degree of protection
|
||||||
|
# from the outside world.
|
||||||
|
# Default: no
|
||||||
|
# TCPAddr 127.0.0.1
|
||||||
|
|
||||||
Foreground no
|
Foreground no
|
||||||
PidFile /var/run/clamav/clamd.pid
|
PidFile /var/run/clamav/clamd.pid
|
||||||
LocalSocketGroup node
|
# LocalSocketGroup node # Changed from 'clamav'
|
||||||
User node
|
# User node # Changed from 'clamav' - clamd runs as clamav user
|
||||||
482
commands/fix_dataset_cross_references.ts
Normal file
482
commands/fix_dataset_cross_references.ts
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| node ace make:command fix-dataset-cross-references
|
||||||
|
| DONE: create commands/fix_dataset_cross_references.ts
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
import { BaseCommand, flags } from '@adonisjs/core/ace';
|
||||||
|
import type { CommandOptions } from '@adonisjs/core/types/ace';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import Dataset from '#models/dataset';
|
||||||
|
import DatasetReference from '#models/dataset_reference';
|
||||||
|
import AppConfig from '#models/appconfig';
|
||||||
|
// import env from '#start/env';
|
||||||
|
|
||||||
|
interface MissingCrossReference {
|
||||||
|
sourceDatasetId: number;
|
||||||
|
targetDatasetId: number;
|
||||||
|
sourcePublishId: number | null;
|
||||||
|
targetPublishId: number | null;
|
||||||
|
sourceDoi: string | null;
|
||||||
|
targetDoi: string | null;
|
||||||
|
referenceType: string;
|
||||||
|
relation: string;
|
||||||
|
doi: string | null;
|
||||||
|
reverseRelation: string;
|
||||||
|
sourceReferenceLabel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class DetectMissingCrossReferences extends BaseCommand {
|
||||||
|
static commandName = 'detect:missing-cross-references';
|
||||||
|
static description = 'Detect missing bidirectional cross-references between versioned datasets';
|
||||||
|
|
||||||
|
public static needsApplication = true;
|
||||||
|
|
||||||
|
@flags.boolean({ alias: 'f', description: 'Fix missing cross-references automatically' })
|
||||||
|
public fix: boolean = false;
|
||||||
|
|
||||||
|
@flags.boolean({ alias: 'v', description: 'Verbose output' })
|
||||||
|
public verbose: boolean = false;
|
||||||
|
|
||||||
|
@flags.number({ alias: 'p', description: 'Filter by specific publish_id (source or target dataset)' })
|
||||||
|
public publish_id?: number;
|
||||||
|
|
||||||
|
// example: node ace detect:missing-cross-references --verbose -p 227 //if you want to filter by specific publish_id with details
|
||||||
|
// example: node ace detect:missing-cross-references --verbose
|
||||||
|
// example: node ace detect:missing-cross-references --fix -p 227 //if you want to filter by specific publish_id and fix it
|
||||||
|
// example: node ace detect:missing-cross-references
|
||||||
|
|
||||||
|
public static options: CommandOptions = {
|
||||||
|
startApp: true,
|
||||||
|
staysAlive: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define the allowed relations that we want to process
|
||||||
|
private readonly ALLOWED_RELATIONS = [
|
||||||
|
'IsNewVersionOf',
|
||||||
|
'IsPreviousVersionOf',
|
||||||
|
'IsVariantFormOf',
|
||||||
|
'IsOriginalFormOf',
|
||||||
|
'Continues',
|
||||||
|
'IsContinuedBy',
|
||||||
|
'HasPart',
|
||||||
|
'IsPartOf',
|
||||||
|
];
|
||||||
|
// private readonly ALLOWED_RELATIONS = ['IsPreviousVersionOf', 'IsOriginalFormOf'];
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
this.logger.info('🔍 Detecting missing cross-references...');
|
||||||
|
this.logger.info(`📋 Processing only these relations: ${this.ALLOWED_RELATIONS.join(', ')}`);
|
||||||
|
|
||||||
|
if (this.publish_id) {
|
||||||
|
this.logger.info(`Filtering by publish_id: ${this.publish_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const missingReferences = await this.findMissingCrossReferences();
|
||||||
|
|
||||||
|
// Store count in AppConfig if not fixing and count >= 1
|
||||||
|
if (!this.fix && missingReferences.length >= 1) {
|
||||||
|
await this.storeMissingCrossReferencesCount(missingReferences.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingReferences.length === 0) {
|
||||||
|
const filterMsg = this.publish_id ? ` for publish_id ${this.publish_id}` : '';
|
||||||
|
this.logger.success(`All cross-references are properly linked for the specified relations${filterMsg}!`);
|
||||||
|
// Clear the count if no missing references
|
||||||
|
if (!this.fix) {
|
||||||
|
await this.storeMissingCrossReferencesCount(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterMsg = this.publish_id ? ` (filtered by publish_id ${this.publish_id})` : '';
|
||||||
|
this.logger.warning(`Found ${missingReferences.length} missing cross-reference(s)${filterMsg}:`);
|
||||||
|
|
||||||
|
// Show brief list if not verbose mode
|
||||||
|
if (!this.verbose) {
|
||||||
|
for (const missing of missingReferences) {
|
||||||
|
const sourceDoi = missing.sourceDoi ? ` DOI: ${missing.sourceDoi}` : '';
|
||||||
|
const targetDoi = missing.targetDoi ? ` DOI: ${missing.targetDoi}` : '';
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
`Dataset ${missing.sourceDatasetId} (Publish ID: ${missing.sourcePublishId}${sourceDoi}) ${missing.relation} Dataset ${missing.targetDatasetId} (Publish ID: ${missing.targetPublishId}${targetDoi}) → missing reverse: ${missing.reverseRelation}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Verbose mode - show detailed info
|
||||||
|
for (const missing of missingReferences) {
|
||||||
|
this.logger.info(
|
||||||
|
`Dataset ${missing.sourceDatasetId} references ${missing.targetDatasetId}, but reverse reference is missing`,
|
||||||
|
);
|
||||||
|
this.logger.info(` - Reference type: ${missing.referenceType}`);
|
||||||
|
this.logger.info(` - Relation: ${missing.relation}`);
|
||||||
|
this.logger.info(` - DOI: ${missing.doi}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fix) {
|
||||||
|
await this.fixMissingReferences(missingReferences);
|
||||||
|
// Clear the count after fixing
|
||||||
|
await this.storeMissingCrossReferencesCount(0);
|
||||||
|
this.logger.success('All missing cross-references have been fixed!');
|
||||||
|
} else {
|
||||||
|
if (this.verbose) {
|
||||||
|
this.printMissingReferencesList(missingReferences);
|
||||||
|
}
|
||||||
|
this.logger.info('💡 Run with --fix flag to automatically create missing cross-references');
|
||||||
|
if (this.publish_id) {
|
||||||
|
this.logger.info(`🎯 Currently filtering by publish_id: ${this.publish_id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error detecting missing cross-references:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async storeMissingCrossReferencesCount(count: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AppConfig.updateOrCreate(
|
||||||
|
{
|
||||||
|
appid: 'commands',
|
||||||
|
configkey: 'missing_cross_references_count',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
configvalue: count.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.info(`📊 Stored missing cross-references count in database: ${count}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to store missing cross-references count:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findMissingCrossReferences(): Promise<MissingCrossReference[]> {
|
||||||
|
const missingReferences: {
|
||||||
|
sourceDatasetId: number;
|
||||||
|
targetDatasetId: number;
|
||||||
|
sourcePublishId: number | null;
|
||||||
|
targetPublishId: number | null;
|
||||||
|
sourceDoi: string | null;
|
||||||
|
targetDoi: string | null;
|
||||||
|
referenceType: string;
|
||||||
|
relation: string;
|
||||||
|
doi: string | null;
|
||||||
|
reverseRelation: string;
|
||||||
|
sourceReferenceLabel: string | null;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
this.logger.info('📊 Querying dataset references...');
|
||||||
|
|
||||||
|
// Find all references that point to Tethys datasets (DOI or URL containing tethys DOI)
|
||||||
|
// Only from datasets that are published AND only for allowed relations
|
||||||
|
const tethysReferencesQuery = DatasetReference.query()
|
||||||
|
.whereIn('type', ['DOI', 'URL'])
|
||||||
|
.whereIn('relation', this.ALLOWED_RELATIONS) // Only process allowed relations
|
||||||
|
.where((query) => {
|
||||||
|
query.where('value', 'like', '%doi.org/10.24341/tethys.%').orWhere('value', 'like', '%tethys.at/dataset/%');
|
||||||
|
})
|
||||||
|
.preload('dataset', (datasetQuery) => {
|
||||||
|
datasetQuery.preload('identifier');
|
||||||
|
})
|
||||||
|
.whereHas('dataset', (datasetQuery) => {
|
||||||
|
datasetQuery.where('server_state', 'published');
|
||||||
|
});
|
||||||
|
if (typeof this.publish_id === 'number') {
|
||||||
|
tethysReferencesQuery.whereHas('dataset', (datasetQuery) => {
|
||||||
|
datasetQuery.where('publish_id', this.publish_id as number);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tethysReferences = await tethysReferencesQuery.exec();
|
||||||
|
|
||||||
|
this.logger.info(`🔗 Found ${tethysReferences.length} Tethys references from published datasets (allowed relations only)`);
|
||||||
|
|
||||||
|
let processedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
|
||||||
|
for (const reference of tethysReferences) {
|
||||||
|
processedCount++;
|
||||||
|
|
||||||
|
// if (this.verbose && processedCount % 10 === 0) {
|
||||||
|
// this.logger.info(`📈 Processed ${processedCount}/${tethysReferences.length} references...`);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Double-check that this relation is in our allowed list (safety check)
|
||||||
|
if (!this.ALLOWED_RELATIONS.includes(reference.relation)) {
|
||||||
|
skippedCount++;
|
||||||
|
if (this.verbose) {
|
||||||
|
this.logger.info(`⏭️ Skipping relation "${reference.relation}" - not in allowed list`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract dataset publish_id from DOI or URL
|
||||||
|
// const targetDatasetPublish = this.extractDatasetPublishIdFromReference(reference.value);
|
||||||
|
// Extract DOI from reference URL
|
||||||
|
const doi = this.extractDoiFromReference(reference.value);
|
||||||
|
|
||||||
|
// if (!targetDatasetPublish) {
|
||||||
|
// if (this.verbose) {
|
||||||
|
// this.logger.warning(`Could not extract publish ID from: ${reference.value}`);
|
||||||
|
// }
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
if (!doi) {
|
||||||
|
if (this.verbose) {
|
||||||
|
this.logger.warning(`Could not extract DOI from: ${reference.value}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// // Check if target dataset exists and is published
|
||||||
|
// const targetDataset = await Dataset.query()
|
||||||
|
// .where('publish_id', targetDatasetPublish)
|
||||||
|
// .where('server_state', 'published')
|
||||||
|
// .preload('identifier')
|
||||||
|
// .first();
|
||||||
|
// Check if target dataset exists and is published by querying via identifier
|
||||||
|
const targetDataset = await Dataset.query()
|
||||||
|
.where('server_state', 'published')
|
||||||
|
.whereHas('identifier', (query) => {
|
||||||
|
query.where('value', doi);
|
||||||
|
})
|
||||||
|
.preload('identifier')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!targetDataset) {
|
||||||
|
if (this.verbose) {
|
||||||
|
this.logger.warning(`⚠️ Target dataset with publish_id ${doi} not found or not published`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we have a valid source dataset with proper preloading
|
||||||
|
if (!reference.dataset) {
|
||||||
|
this.logger.warning(`⚠️ Source dataset ${reference.document_id} not properly loaded, skipping...`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if reverse reference exists
|
||||||
|
const reverseReferenceExists = await this.checkReverseReferenceExists(
|
||||||
|
targetDataset.id,
|
||||||
|
reference.document_id,
|
||||||
|
reference.relation,
|
||||||
|
reference.dataset.identifier.value
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!reverseReferenceExists) {
|
||||||
|
const reverseRelation = this.getReverseRelation(reference.relation);
|
||||||
|
if (reverseRelation) {
|
||||||
|
// Only add if we have a valid reverse relation
|
||||||
|
missingReferences.push({
|
||||||
|
sourceDatasetId: reference.document_id,
|
||||||
|
targetDatasetId: targetDataset.id,
|
||||||
|
sourcePublishId: reference.dataset.publish_id || null,
|
||||||
|
targetPublishId: targetDataset.publish_id || null,
|
||||||
|
referenceType: reference.type,
|
||||||
|
relation: reference.relation,
|
||||||
|
doi: reference.value,
|
||||||
|
reverseRelation: reverseRelation,
|
||||||
|
sourceDoi: reference.dataset.identifier ? reference.dataset.identifier.value : null,
|
||||||
|
targetDoi: targetDataset.identifier ? targetDataset.identifier.value : null,
|
||||||
|
sourceReferenceLabel: reference.label || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info(`✅ Processed ${processedCount} references (${skippedCount} skipped due to relation filtering)`);
|
||||||
|
return missingReferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractDoiFromReference(reference: string): string | null {
|
||||||
|
// Match DOI pattern, with or without URL prefix
|
||||||
|
const doiPattern = /(?:https?:\/\/)?(?:doi\.org\/)?(.+)/i;
|
||||||
|
const match = reference.match(doiPattern);
|
||||||
|
|
||||||
|
if (match && match[1]) {
|
||||||
|
return match[1]; // Returns just "10.24341/tethys.99.2"
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractDatasetPublishIdFromReference(value: string): number | null {
|
||||||
|
// Extract from DOI: https://doi.org/10.24341/tethys.107 -> 107
|
||||||
|
const doiMatch = value.match(/10\.24341\/tethys\.(\d+)/);
|
||||||
|
if (doiMatch) {
|
||||||
|
return parseInt(doiMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract from URL: https://tethys.at/dataset/107 -> 107
|
||||||
|
const urlMatch = value.match(/tethys\.at\/dataset\/(\d+)/);
|
||||||
|
if (urlMatch) {
|
||||||
|
return parseInt(urlMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkReverseReferenceExists(
|
||||||
|
targetDatasetId: number,
|
||||||
|
sourceDatasetId: number,
|
||||||
|
originalRelation: string,
|
||||||
|
sourceDatasetIdentifier: string | null,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const reverseRelation = this.getReverseRelation(originalRelation);
|
||||||
|
|
||||||
|
if (!reverseRelation) {
|
||||||
|
return true; // If no reverse relation is defined, consider it as "exists" to skip processing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only check for reverse references where the source dataset is also published
|
||||||
|
const reverseReference = await DatasetReference.query()
|
||||||
|
// We don't filter by source document_id here to find any incoming reference from any published dataset
|
||||||
|
.where('document_id', targetDatasetId)
|
||||||
|
// .where('related_document_id', sourceDatasetId) // Ensure it's an incoming reference
|
||||||
|
.where('relation', reverseRelation)
|
||||||
|
.where('value', 'like', `%${sourceDatasetIdentifier}`) // Basic check to ensure it points back to source dataset
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return !!reverseReference;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getReverseRelation(relation: string): string | null {
|
||||||
|
const relationMap: Record<string, string> = {
|
||||||
|
IsNewVersionOf: 'IsPreviousVersionOf',
|
||||||
|
IsPreviousVersionOf: 'IsNewVersionOf',
|
||||||
|
IsVariantFormOf: 'IsOriginalFormOf',
|
||||||
|
IsOriginalFormOf: 'IsVariantFormOf',
|
||||||
|
Continues: 'IsContinuedBy',
|
||||||
|
IsContinuedBy: 'Continues',
|
||||||
|
HasPart: 'IsPartOf',
|
||||||
|
IsPartOf: 'HasPart',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only return reverse relation if it exists in our map, otherwise return null
|
||||||
|
return relationMap[relation] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private printMissingReferencesList(missingReferences: MissingCrossReference[]) {
|
||||||
|
console.log('┌─────────────────────────────────────────────────────────────────────────────────┐');
|
||||||
|
console.log('│ MISSING CROSS-REFERENCES REPORT │');
|
||||||
|
console.log('│ (Published Datasets Only - Filtered Relations) │');
|
||||||
|
console.log('└─────────────────────────────────────────────────────────────────────────────────┘');
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
missingReferences.forEach((missing, index) => {
|
||||||
|
console.log(
|
||||||
|
`${index + 1}. Dataset ${missing.sourceDatasetId} (Publish ID: ${missing.sourcePublishId} Identifier: ${missing.sourceDoi})
|
||||||
|
${missing.relation} Dataset ${missing.targetDatasetId} (Publish ID: ${missing.targetPublishId} Identifier: ${missing.targetDoi})`,
|
||||||
|
);
|
||||||
|
console.log(` ├─ Current relation: "${missing.relation}"`);
|
||||||
|
console.log(` ├─ Missing reverse relation: "${missing.reverseRelation}"`);
|
||||||
|
console.log(` ├─ Reference type: ${missing.referenceType}`);
|
||||||
|
console.log(` └─ DOI/URL: ${missing.doi}`);
|
||||||
|
console.log();
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('┌─────────────────────────────────────────────────────────────────────────────────┐');
|
||||||
|
console.log(`│ SUMMARY: ${missingReferences.length} missing reverse reference(s) detected │`);
|
||||||
|
console.log(`│ Processed relations: ${this.ALLOWED_RELATIONS.join(', ')} │`);
|
||||||
|
console.log('└─────────────────────────────────────────────────────────────────────────────────┘');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fixMissingReferences(missingReferences: MissingCrossReference[]) {
|
||||||
|
this.logger.info('🔧 Creating missing cross-references in database...');
|
||||||
|
|
||||||
|
let fixedCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const [index, missing] of missingReferences.entries()) {
|
||||||
|
try {
|
||||||
|
// Get both source and target datasets
|
||||||
|
const sourceDataset = await Dataset.query()
|
||||||
|
.where('id', missing.sourceDatasetId)
|
||||||
|
.where('server_state', 'published')
|
||||||
|
.preload('identifier')
|
||||||
|
.preload('titles') // Preload titles to get mainTitle
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const targetDataset = await Dataset.query().where('id', missing.targetDatasetId).where('server_state', 'published').first();
|
||||||
|
|
||||||
|
if (!sourceDataset) {
|
||||||
|
this.logger.warning(`⚠️ Source dataset ${missing.sourceDatasetId} not found or not published, skipping...`);
|
||||||
|
errorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetDataset) {
|
||||||
|
this.logger.warning(`⚠️ Target dataset ${missing.targetDatasetId} not found or not published, skipping...`);
|
||||||
|
errorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// **NEW: Update the original reference if related_document_id is missing**
|
||||||
|
const originalReference = await DatasetReference.query()
|
||||||
|
.where('document_id', missing.sourceDatasetId)
|
||||||
|
.where('relation', missing.relation)
|
||||||
|
.where('value', 'like', `%${missing.targetDoi}%`)
|
||||||
|
.first();
|
||||||
|
if (originalReference && !originalReference.related_document_id) {
|
||||||
|
originalReference.related_document_id = missing.targetDatasetId;
|
||||||
|
await originalReference.save();
|
||||||
|
if (this.verbose) {
|
||||||
|
this.logger.info(`🔗 Updated original reference with related_document_id: ${missing.targetDatasetId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the reverse reference using the referenced_by relationship
|
||||||
|
// Example: If Dataset 297 IsNewVersionOf Dataset 144
|
||||||
|
// We create an incoming reference for Dataset 144 that shows Dataset 297 IsPreviousVersionOf it
|
||||||
|
const reverseReference = new DatasetReference();
|
||||||
|
// Don't set document_id - this creates an incoming reference via related_document_id
|
||||||
|
reverseReference.document_id = missing.targetDatasetId; //
|
||||||
|
reverseReference.related_document_id = missing.sourceDatasetId;
|
||||||
|
reverseReference.type = 'DOI';
|
||||||
|
reverseReference.relation = missing.reverseRelation;
|
||||||
|
|
||||||
|
// Use the source dataset's DOI for the value (what's being referenced)
|
||||||
|
if (sourceDataset.identifier?.value) {
|
||||||
|
reverseReference.value = `https://doi.org/${sourceDataset.identifier.value}`;
|
||||||
|
} else {
|
||||||
|
// Fallback to dataset URL if no DOI
|
||||||
|
reverseReference.value = `https://tethys.at/dataset/${sourceDataset.publish_id || missing.sourceDatasetId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the source dataset's main title for the label
|
||||||
|
//reverseReference.label = sourceDataset.mainTitle || `Dataset ${missing.sourceDatasetId}`;
|
||||||
|
// get label of forward reference
|
||||||
|
reverseReference.label = missing.sourceReferenceLabel || sourceDataset.mainTitle || `Dataset ${missing.sourceDatasetId}`;
|
||||||
|
// reverseReference.notes = `Auto-created by detect:missing-cross-references command on ${DateTime.now().toISO()} to fix missing bidirectional reference.`;
|
||||||
|
|
||||||
|
// Save the new reverse reference
|
||||||
|
// Also save 'server_date_modified' on target dataset to trigger any downstream updates (e.g. search index)
|
||||||
|
targetDataset.server_date_modified = DateTime.now();
|
||||||
|
await targetDataset.save();
|
||||||
|
|
||||||
|
await reverseReference.save();
|
||||||
|
fixedCount++;
|
||||||
|
|
||||||
|
if (this.verbose) {
|
||||||
|
this.logger.info(
|
||||||
|
`✅ [${index + 1}/${missingReferences.length}] Created reverse reference: Dataset ${missing.sourceDatasetId} -> ${missing.targetDatasetId} (${missing.reverseRelation})`,
|
||||||
|
);
|
||||||
|
} else if ((index + 1) % 10 === 0) {
|
||||||
|
this.logger.info(`📈 Fixed ${fixedCount}/${missingReferences.length} references...`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`❌ Error creating reverse reference for datasets ${missing.targetDatasetId} -> ${missing.sourceDatasetId}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info(`📊 Fix completed: ${fixedCount} created, ${errorCount} errors`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js';
|
import { XMLBuilder } from 'xmlbuilder2/lib/interfaces.js';
|
||||||
import { create } from 'xmlbuilder2';
|
import { create } from 'xmlbuilder2';
|
||||||
import Dataset from '#models/dataset';
|
import Dataset from '#models/dataset';
|
||||||
import XmlModel from '#app/Library/XmlModel';
|
import XmlModel from '#app/Library/DatasetXmlSerializer';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import SaxonJS from 'saxon-js';
|
import SaxonJS from 'saxon-js';
|
||||||
import { Client } from '@opensearch-project/opensearch';
|
import { Client } from '@opensearch-project/opensearch';
|
||||||
|
|
@ -12,10 +12,8 @@ import { getDomain } from '#app/utils/utility-functions';
|
||||||
import { BaseCommand, flags } from '@adonisjs/core/ace';
|
import { BaseCommand, flags } from '@adonisjs/core/ace';
|
||||||
import { CommandOptions } from '@adonisjs/core/types/ace';
|
import { CommandOptions } from '@adonisjs/core/types/ace';
|
||||||
import env from '#start/env';
|
import env from '#start/env';
|
||||||
// import db from '@adonisjs/lucid/services/db';
|
|
||||||
// import { default as Dataset } from '#models/dataset';
|
|
||||||
import logger from '@adonisjs/core/services/logger';
|
import logger from '@adonisjs/core/services/logger';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
const opensearchNode = env.get('OPENSEARCH_HOST', 'localhost');
|
const opensearchNode = env.get('OPENSEARCH_HOST', 'localhost');
|
||||||
const client = new Client({ node: `${opensearchNode}` }); // replace with your OpenSearch endpoint
|
const client = new Client({ node: `${opensearchNode}` }); // replace with your OpenSearch endpoint
|
||||||
|
|
@ -30,11 +28,10 @@ export default class IndexDatasets extends BaseCommand {
|
||||||
public publish_id: number;
|
public publish_id: number;
|
||||||
|
|
||||||
public static options: CommandOptions = {
|
public static options: CommandOptions = {
|
||||||
startApp: true,
|
startApp: true, // Ensures the IoC container is ready to use
|
||||||
staysAlive: false,
|
staysAlive: false, // Command exits after running
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
async run() {
|
async run() {
|
||||||
logger.debug('Hello world!');
|
logger.debug('Hello world!');
|
||||||
// const { default: Dataset } = await import('#models/dataset');
|
// const { default: Dataset } = await import('#models/dataset');
|
||||||
|
|
@ -44,10 +41,12 @@ export default class IndexDatasets extends BaseCommand {
|
||||||
const index_name = 'tethys-records';
|
const index_name = 'tethys-records';
|
||||||
|
|
||||||
for (var dataset of datasets) {
|
for (var dataset of datasets) {
|
||||||
// Logger.info(`File publish_id ${dataset.publish_id}`);
|
const shouldUpdate = await this.shouldUpdateDataset(dataset, index_name);
|
||||||
// const jsonString = await this.getJsonString(dataset, proc);
|
if (shouldUpdate) {
|
||||||
// console.log(jsonString);
|
|
||||||
await this.indexDocument(dataset, index_name, proc);
|
await this.indexDocument(dataset, index_name, proc);
|
||||||
|
} else {
|
||||||
|
logger.info(`Dataset with publish_id ${dataset.publish_id} is up to date, skipping indexing`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,6 +64,46 @@ export default class IndexDatasets extends BaseCommand {
|
||||||
return await query.exec();
|
return await query.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async shouldUpdateDataset(dataset: Dataset, index_name: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Check if publish_id exists before proceeding
|
||||||
|
if (!dataset.publish_id) {
|
||||||
|
// Return true to update since document doesn't exist in OpenSearch yet
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Get the existing document from OpenSearch
|
||||||
|
const response = await client.get({
|
||||||
|
index: index_name,
|
||||||
|
id: dataset.publish_id?.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingDoc = response.body._source;
|
||||||
|
|
||||||
|
// Compare server_date_modified
|
||||||
|
if (existingDoc && existingDoc.server_date_modified) {
|
||||||
|
// Convert Unix timestamp (seconds) to milliseconds for DateTime.fromMillis()
|
||||||
|
const existingModified = DateTime.fromMillis(Number(existingDoc.server_date_modified) * 1000);
|
||||||
|
const currentModified = dataset.server_date_modified;
|
||||||
|
|
||||||
|
// Only update if the dataset has been modified more recently
|
||||||
|
if (currentModified <= existingModified) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// If document doesn't exist or other error, we should index it
|
||||||
|
if (error.statusCode === 404) {
|
||||||
|
logger.info(`Dataset with publish_id ${dataset.publish_id} not found in index, will create new document`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(`Error checking existing document for publish_id ${dataset.publish_id}: ${error.message}`);
|
||||||
|
return true; // Index anyway if we can't determine the status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async indexDocument(dataset: Dataset, index_name: string, proc: Buffer): Promise<void> {
|
private async indexDocument(dataset: Dataset, index_name: string, proc: Buffer): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const doc = await this.getJsonString(dataset, proc);
|
const doc = await this.getJsonString(dataset, proc);
|
||||||
|
|
@ -78,7 +117,8 @@ export default class IndexDatasets extends BaseCommand {
|
||||||
});
|
});
|
||||||
logger.info(`dataset with publish_id ${dataset.publish_id} successfully indexed`);
|
logger.info(`dataset with publish_id ${dataset.publish_id} successfully indexed`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`An error occurred while indexing dataset with publish_id ${dataset.publish_id}.`);
|
logger.error(`An error occurred while indexing dataset with publish_id ${dataset.publish_id}.
|
||||||
|
Error: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,19 +151,16 @@ export default class IndexDatasets extends BaseCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getDatasetXmlDomNode(dataset: Dataset): Promise<XMLBuilder | null> {
|
private async getDatasetXmlDomNode(dataset: Dataset): Promise<XMLBuilder | null> {
|
||||||
const xmlModel = new XmlModel(dataset);
|
const serializer = new XmlModel(dataset).enableCaching().excludeEmptyFields();
|
||||||
// xmlModel.setModel(dataset);
|
// xmlModel.setModel(dataset);
|
||||||
xmlModel.excludeEmptyFields();
|
|
||||||
xmlModel.caching = true;
|
|
||||||
// const cache = dataset.xmlCache ? dataset.xmlCache : null;
|
|
||||||
// dataset.load('xmlCache');
|
|
||||||
if (dataset.xmlCache) {
|
if (dataset.xmlCache) {
|
||||||
xmlModel.xmlCache = dataset.xmlCache;
|
serializer.setCache(dataset.xmlCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
// return cache.getDomDocument();
|
// return cache.toXmlDocument();
|
||||||
const domDocument: XMLBuilder | null = await xmlModel.getDomDocument();
|
const xmlDocument: XMLBuilder | null = await serializer.toXmlDocument();
|
||||||
return domDocument;
|
return xmlDocument;
|
||||||
}
|
}
|
||||||
|
|
||||||
private addSpecInformation(domNode: XMLBuilder, information: string) {
|
private addSpecInformation(domNode: XMLBuilder, information: string) {
|
||||||
|
|
|
||||||
346
commands/list_updatable_datacite.ts
Normal file
346
commands/list_updatable_datacite.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| node ace make:command list-updateable-datacite
|
||||||
|
| DONE: create commands/list_updeatable_datacite.ts
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
import { BaseCommand, flags } from '@adonisjs/core/ace';
|
||||||
|
import { CommandOptions } from '@adonisjs/core/types/ace';
|
||||||
|
import Dataset from '#models/dataset';
|
||||||
|
import { DoiClient } from '#app/Library/Doi/DoiClient';
|
||||||
|
import env from '#start/env';
|
||||||
|
import logger from '@adonisjs/core/services/logger';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import pLimit from 'p-limit';
|
||||||
|
|
||||||
|
export default class ListUpdateableDatacite extends BaseCommand {
|
||||||
|
static commandName = 'list:updateable-datacite';
|
||||||
|
static description = 'List all datasets that need DataCite DOI updates';
|
||||||
|
|
||||||
|
public static needsApplication = true;
|
||||||
|
|
||||||
|
// private chunkSize = 100; // Set chunk size for pagination
|
||||||
|
|
||||||
|
@flags.boolean({ alias: 'v', description: 'Verbose output showing detailed information' })
|
||||||
|
public verbose: boolean = false;
|
||||||
|
|
||||||
|
@flags.boolean({ alias: 'c', description: 'Show only count of updatable datasets' })
|
||||||
|
public countOnly: boolean = false;
|
||||||
|
|
||||||
|
@flags.boolean({ alias: 'i', description: 'Show only publish IDs (useful for scripting)' })
|
||||||
|
public idsOnly: boolean = false;
|
||||||
|
|
||||||
|
@flags.number({ description: 'Chunk size for processing datasets (default: 50)' })
|
||||||
|
public chunkSize: number = 50;
|
||||||
|
|
||||||
|
//example: node ace list:updateable-datacite
|
||||||
|
//example: node ace list:updateable-datacite --verbose
|
||||||
|
//example: node ace list:updateable-datacite --count-only
|
||||||
|
//example: node ace list:updateable-datacite --ids-only
|
||||||
|
//example: node ace list:updateable-datacite --chunk-size 50
|
||||||
|
|
||||||
|
public static options: CommandOptions = {
|
||||||
|
startApp: true,
|
||||||
|
stayAlive: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
const prefix = env.get('DATACITE_PREFIX', '');
|
||||||
|
const base_domain = env.get('BASE_DOMAIN', '');
|
||||||
|
|
||||||
|
if (!prefix || !base_domain) {
|
||||||
|
logger.error('Missing DATACITE_PREFIX or BASE_DOMAIN environment variables');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent conflicting flags
|
||||||
|
if ((this.verbose && this.countOnly) || (this.verbose && this.idsOnly)) {
|
||||||
|
logger.error('Flags --verbose cannot be combined with --count-only or --ids-only');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkSize = this.chunkSize || 50;
|
||||||
|
let page = 1;
|
||||||
|
let hasMoreDatasets = true;
|
||||||
|
let totalProcessed = 0;
|
||||||
|
const updatableDatasets: Dataset[] = [];
|
||||||
|
|
||||||
|
if (!this.countOnly && !this.idsOnly) {
|
||||||
|
logger.info(`Processing datasets in chunks of ${chunkSize}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (hasMoreDatasets) {
|
||||||
|
const datasets = await this.getDatasets(page, chunkSize);
|
||||||
|
|
||||||
|
if (datasets.length === 0) {
|
||||||
|
hasMoreDatasets = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.countOnly && !this.idsOnly) {
|
||||||
|
logger.info(`Processing chunk ${page} (${datasets.length} datasets)...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkUpdatableDatasets = await this.processChunk(datasets);
|
||||||
|
updatableDatasets.push(...chunkUpdatableDatasets);
|
||||||
|
totalProcessed += datasets.length;
|
||||||
|
|
||||||
|
page += 1;
|
||||||
|
if (datasets.length < chunkSize) {
|
||||||
|
hasMoreDatasets = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.countOnly && !this.idsOnly) {
|
||||||
|
logger.info(`Processed ${totalProcessed} datasets total, found ${updatableDatasets.length} that need updates`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.countOnly) {
|
||||||
|
console.log(updatableDatasets.length);
|
||||||
|
} else if (this.idsOnly) {
|
||||||
|
updatableDatasets.forEach((dataset) => console.log(dataset.publish_id));
|
||||||
|
} else if (this.verbose) {
|
||||||
|
await this.showVerboseOutput(updatableDatasets);
|
||||||
|
} else {
|
||||||
|
this.showSimpleOutput(updatableDatasets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a chunk of datasets to determine which ones need DataCite updates
|
||||||
|
*
|
||||||
|
* This method handles parallel processing of datasets within a chunk, providing
|
||||||
|
* efficient error handling and filtering of results.
|
||||||
|
*
|
||||||
|
* @param datasets - Array of Dataset objects to process
|
||||||
|
* @returns Promise<Dataset[]> - Array of datasets that need updates
|
||||||
|
*/
|
||||||
|
// private async processChunk(datasets: Dataset[]): Promise<Dataset[]> {
|
||||||
|
// // Process datasets in parallel using Promise.allSettled for better error handling
|
||||||
|
// //
|
||||||
|
// // Why Promise.allSettled vs Promise.all?
|
||||||
|
// // - Promise.all fails fast: if ANY promise rejects, the entire operation fails
|
||||||
|
// // - Promise.allSettled waits for ALL promises: some can fail, others succeed
|
||||||
|
// // - This is crucial for batch processing where we don't want one bad dataset
|
||||||
|
// // to stop processing of the entire chunk
|
||||||
|
// const results = await Promise.allSettled(
|
||||||
|
// datasets.map(async (dataset) => {
|
||||||
|
// try {
|
||||||
|
// // Check if this specific dataset needs a DataCite update
|
||||||
|
// const needsUpdate = await this.shouldUpdateDataset(dataset);
|
||||||
|
|
||||||
|
// // Return the dataset if it needs update, null if it doesn't
|
||||||
|
// // This creates a sparse array that we'll filter later
|
||||||
|
// return needsUpdate ? dataset : null;
|
||||||
|
// } catch (error) {
|
||||||
|
// // Error handling for individual dataset checks
|
||||||
|
// //
|
||||||
|
// // Log warnings only if we're not in silent modes (count-only or ids-only)
|
||||||
|
// // This prevents log spam when running automated scripts
|
||||||
|
// if (!this.countOnly && !this.idsOnly) {
|
||||||
|
// logger.warn(`Error checking dataset ${dataset.publish_id}: ${error.message}`);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // IMPORTANT DECISION: Return the dataset anyway if we can't determine status
|
||||||
|
// //
|
||||||
|
// // Why? It's safer to include a dataset that might not need updating
|
||||||
|
// // than to miss one that actually does need updating. This follows the
|
||||||
|
// // "fail-safe" principle - if we're unsure, err on the side of caution
|
||||||
|
// return dataset;
|
||||||
|
// }
|
||||||
|
// }),
|
||||||
|
// );
|
||||||
|
|
||||||
|
// // Filter and extract results from Promise.allSettled response
|
||||||
|
// //
|
||||||
|
// // Promise.allSettled returns an array of objects with this structure:
|
||||||
|
// // - { status: 'fulfilled', value: T } for successful promises
|
||||||
|
// // - { status: 'rejected', reason: Error } for failed promises
|
||||||
|
// //
|
||||||
|
// // We need to:
|
||||||
|
// // 1. Only get fulfilled results (rejected ones are already handled above)
|
||||||
|
// // 2. Filter out null values (datasets that don't need updates)
|
||||||
|
// // 3. Extract the actual Dataset objects from the wrapper
|
||||||
|
// return results
|
||||||
|
// .filter(
|
||||||
|
// (result): result is PromiseFulfilledResult<Dataset | null> =>
|
||||||
|
// // Type guard: only include fulfilled results that have actual values
|
||||||
|
// // This filters out:
|
||||||
|
// // - Rejected promises (shouldn't happen due to try/catch, but safety first)
|
||||||
|
// // - Fulfilled promises that returned null (datasets that don't need updates)
|
||||||
|
// result.status === 'fulfilled' && result.value !== null,
|
||||||
|
// )
|
||||||
|
// .map((result) => result.value!); // Extract the Dataset from the wrapper
|
||||||
|
// // The ! is safe here because we filtered out null values above
|
||||||
|
// }
|
||||||
|
|
||||||
|
private async processChunk(datasets: Dataset[]): Promise<Dataset[]> {
|
||||||
|
// Limit concurrency to avoid API flooding (e.g., max 5 at once)
|
||||||
|
const limit = pLimit(5);
|
||||||
|
|
||||||
|
const tasks = datasets.map((dataset) =>
|
||||||
|
limit(async () => {
|
||||||
|
try {
|
||||||
|
const needsUpdate = await this.shouldUpdateDataset(dataset);
|
||||||
|
return needsUpdate ? dataset : null;
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.countOnly && !this.idsOnly) {
|
||||||
|
logger.warn(
|
||||||
|
`Error checking dataset ${dataset.publish_id}: ${
|
||||||
|
error instanceof Error ? error.message : JSON.stringify(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Fail-safe: include dataset if uncertain
|
||||||
|
return dataset;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(tasks);
|
||||||
|
|
||||||
|
return results
|
||||||
|
.filter((result): result is PromiseFulfilledResult<Dataset | null> => result.status === 'fulfilled' && result.value !== null)
|
||||||
|
.map((result) => result.value!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDatasets(page: number, chunkSize: number): Promise<Dataset[]> {
|
||||||
|
return await Dataset.query()
|
||||||
|
.orderBy('publish_id', 'asc')
|
||||||
|
.preload('identifier')
|
||||||
|
.preload('xmlCache')
|
||||||
|
.preload('titles')
|
||||||
|
.where('server_state', 'published')
|
||||||
|
.whereHas('identifier', (identifierQuery) => {
|
||||||
|
identifierQuery.where('type', 'doi');
|
||||||
|
})
|
||||||
|
.forPage(page, chunkSize); // Get files for the current page
|
||||||
|
}
|
||||||
|
|
||||||
|
private async shouldUpdateDataset(dataset: Dataset): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
let doiIdentifier = dataset.identifier;
|
||||||
|
if (!doiIdentifier) {
|
||||||
|
await dataset.load('identifier');
|
||||||
|
doiIdentifier = dataset.identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doiIdentifier || doiIdentifier.type !== 'doi') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const datasetModified =
|
||||||
|
dataset.server_date_modified instanceof DateTime
|
||||||
|
? dataset.server_date_modified
|
||||||
|
: DateTime.fromJSDate(dataset.server_date_modified);
|
||||||
|
|
||||||
|
if (!datasetModified) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (datasetModified > DateTime.now()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doiClient = new DoiClient();
|
||||||
|
const DOI_CHECK_TIMEOUT = 300; // ms
|
||||||
|
|
||||||
|
const doiLastModified = await Promise.race([
|
||||||
|
doiClient.getDoiLastModified(doiIdentifier.value),
|
||||||
|
this.createTimeoutPromise(DOI_CHECK_TIMEOUT),
|
||||||
|
]).catch(() => null);
|
||||||
|
|
||||||
|
if (!doiLastModified) {
|
||||||
|
// If uncertain, better include dataset for update
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doiModified = DateTime.fromJSDate(doiLastModified);
|
||||||
|
if (datasetModified > doiModified) {
|
||||||
|
const diffInSeconds = Math.abs(datasetModified.diff(doiModified, 'seconds').seconds);
|
||||||
|
const toleranceSeconds = 600;
|
||||||
|
return diffInSeconds > toleranceSeconds;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
return true; // safer: include dataset if unsure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a timeout promise for API calls
|
||||||
|
*/
|
||||||
|
private createTimeoutPromise(timeoutMs: number): Promise<never> {
|
||||||
|
return new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error(`API call timeout after ${timeoutMs}ms`)), timeoutMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private showSimpleOutput(updatableDatasets: Dataset[]): void {
|
||||||
|
if (updatableDatasets.length === 0) {
|
||||||
|
console.log('No datasets need DataCite updates.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nFound ${updatableDatasets.length} dataset(s) that need DataCite updates:\n`);
|
||||||
|
|
||||||
|
updatableDatasets.forEach((dataset) => {
|
||||||
|
console.log(`publish_id ${dataset.publish_id} needs update - ${dataset.mainTitle || 'Untitled'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\nTo update these datasets, run:`);
|
||||||
|
console.log(` node ace update:datacite`);
|
||||||
|
console.log(`\nOr update specific datasets:`);
|
||||||
|
console.log(` node ace update:datacite -p <publish_id>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showVerboseOutput(updatableDatasets: Dataset[]): Promise<void> {
|
||||||
|
if (updatableDatasets.length === 0) {
|
||||||
|
console.log('No datasets need DataCite updates.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nFound ${updatableDatasets.length} dataset(s) that need DataCite updates:\n`);
|
||||||
|
|
||||||
|
for (const dataset of updatableDatasets) {
|
||||||
|
await this.showDatasetDetails(dataset);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nSummary: ${updatableDatasets.length} datasets need updates`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showDatasetDetails(dataset: Dataset): Promise<void> {
|
||||||
|
try {
|
||||||
|
let doiIdentifier = dataset.identifier;
|
||||||
|
|
||||||
|
if (!doiIdentifier) {
|
||||||
|
await dataset.load('identifier');
|
||||||
|
doiIdentifier = dataset.identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doiValue = doiIdentifier?.value || 'N/A';
|
||||||
|
const datasetModified = dataset.server_date_modified;
|
||||||
|
|
||||||
|
// Get DOI info from DataCite
|
||||||
|
const doiClient = new DoiClient();
|
||||||
|
const doiLastModified = await doiClient.getDoiLastModified(doiValue);
|
||||||
|
const doiState = await doiClient.getDoiState(doiValue);
|
||||||
|
|
||||||
|
console.log(`┌─ Dataset ${dataset.publish_id} ───────────────────────────────────────────────────────────────`);
|
||||||
|
console.log(`│ Title: ${dataset.mainTitle || 'Untitled'}`);
|
||||||
|
console.log(`│ DOI: ${doiValue}`);
|
||||||
|
console.log(`│ DOI State: ${doiState || 'Unknown'}`);
|
||||||
|
console.log(`│ Dataset Modified: ${datasetModified ? datasetModified.toISO() : 'N/A'}`);
|
||||||
|
console.log(`│ DOI Modified: ${doiLastModified ? DateTime.fromJSDate(doiLastModified).toISO() : 'N/A'}`);
|
||||||
|
console.log(`│ Status: NEEDS UPDATE`);
|
||||||
|
console.log(`└─────────────────────────────────────────────────────────────────────────────────────────────\n`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`┌─ Dataset ${dataset.publish_id} ───────────────────────────────────────────────────────────────`);
|
||||||
|
console.log(`│ Title: ${dataset.mainTitle || 'Untitled'}`);
|
||||||
|
console.log(`│ DOI: ${dataset.identifier?.value || 'N/A'}`);
|
||||||
|
console.log(`│ Error: ${error.message}`);
|
||||||
|
console.log(`│ Status: NEEDS UPDATE (Error checking)`);
|
||||||
|
console.log(`└─────────────────────────────────────────────────────────────────────────────────────────────\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
266
commands/update_datacite.ts
Normal file
266
commands/update_datacite.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| node ace make:command update-datacite
|
||||||
|
| DONE: create commands/update_datacite.ts
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
import { BaseCommand, flags } from '@adonisjs/core/ace';
|
||||||
|
import { CommandOptions } from '@adonisjs/core/types/ace';
|
||||||
|
import Dataset from '#models/dataset';
|
||||||
|
import { DoiClient } from '#app/Library/Doi/DoiClient';
|
||||||
|
import DoiClientException from '#app/exceptions/DoiClientException';
|
||||||
|
import Index from '#app/Library/Utils/Index';
|
||||||
|
import env from '#start/env';
|
||||||
|
import logger from '@adonisjs/core/services/logger';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { getDomain } from '#app/utils/utility-functions';
|
||||||
|
|
||||||
|
export default class UpdateDatacite extends BaseCommand {
|
||||||
|
static commandName = 'update:datacite';
|
||||||
|
static description = 'Update DataCite DOI records for published datasets';
|
||||||
|
|
||||||
|
public static needsApplication = true;
|
||||||
|
|
||||||
|
@flags.number({ alias: 'p', description: 'Specific publish_id to update' })
|
||||||
|
public publish_id: number;
|
||||||
|
|
||||||
|
@flags.boolean({ alias: 'f', description: 'Force update all records regardless of modification date' })
|
||||||
|
public force: boolean = false;
|
||||||
|
|
||||||
|
@flags.boolean({ alias: 'd', description: 'Dry run - show what would be updated without making changes' })
|
||||||
|
public dryRun: boolean = false;
|
||||||
|
|
||||||
|
@flags.boolean({ alias: 's', description: 'Show detailed stats for each dataset that needs updating' })
|
||||||
|
public stats: boolean = false;
|
||||||
|
|
||||||
|
//example: node ace update:datacite -p 123 --force --dry-run
|
||||||
|
|
||||||
|
public static options: CommandOptions = {
|
||||||
|
startApp: true, // Whether to boot the application before running the command
|
||||||
|
stayAlive: false, // Whether to keep the process alive after the command has executed
|
||||||
|
};
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
logger.info('Starting DataCite update process...');
|
||||||
|
|
||||||
|
const prefix = env.get('DATACITE_PREFIX', '');
|
||||||
|
const base_domain = env.get('BASE_DOMAIN', '');
|
||||||
|
const apiUrl = env.get('DATACITE_API_URL', 'https://api.datacite.org');
|
||||||
|
|
||||||
|
if (!prefix || !base_domain) {
|
||||||
|
logger.error('Missing DATACITE_PREFIX or BASE_DOMAIN environment variables');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Using DataCite API: ${apiUrl}`);
|
||||||
|
|
||||||
|
const datasets = await this.getDatasets();
|
||||||
|
logger.info(`Found ${datasets.length} datasets to process`);
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (const dataset of datasets) {
|
||||||
|
try {
|
||||||
|
const shouldUpdate = this.force || (await this.shouldUpdateDataset(dataset));
|
||||||
|
|
||||||
|
if (this.stats) {
|
||||||
|
// Stats mode: show detailed information for datasets that need updating
|
||||||
|
if (shouldUpdate) {
|
||||||
|
await this.showDatasetStats(dataset);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldUpdate) {
|
||||||
|
logger.info(`Dataset ${dataset.publish_id}: Up to date, skipping`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dryRun) {
|
||||||
|
logger.info(`Dataset ${dataset.publish_id}: Would update DataCite record (dry run)`);
|
||||||
|
updated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.updateDataciteRecord(dataset, prefix, base_domain);
|
||||||
|
logger.info(`Dataset ${dataset.publish_id}: Successfully updated DataCite record`);
|
||||||
|
updated++;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Dataset ${dataset.publish_id}: Failed to update - ${error.message}`);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.stats) {
|
||||||
|
logger.info(`\nDataCite Stats Summary: ${updated} datasets need updating, ${skipped} are up to date`);
|
||||||
|
} else {
|
||||||
|
logger.info(`DataCite update completed. Updated: ${updated}, Skipped: ${skipped}, Errors: ${errors}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDatasets(): Promise<Dataset[]> {
|
||||||
|
const query = Dataset.query()
|
||||||
|
.preload('identifier')
|
||||||
|
.preload('xmlCache')
|
||||||
|
.where('server_state', 'published')
|
||||||
|
.whereHas('identifier', (identifierQuery) => {
|
||||||
|
identifierQuery.where('type', 'doi');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.publish_id) {
|
||||||
|
query.where('publish_id', this.publish_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async shouldUpdateDataset(dataset: Dataset): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
let doiIdentifier = dataset.identifier;
|
||||||
|
|
||||||
|
if (!doiIdentifier) {
|
||||||
|
await dataset.load('identifier');
|
||||||
|
doiIdentifier = dataset.identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doiIdentifier || doiIdentifier.type !== 'doi') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const datasetModified = dataset.server_date_modified;
|
||||||
|
const now = DateTime.now();
|
||||||
|
|
||||||
|
if (!datasetModified) {
|
||||||
|
return true; // Update if modification date is missing
|
||||||
|
}
|
||||||
|
|
||||||
|
if (datasetModified > now) {
|
||||||
|
return false; // Skip invalid future dates
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check DataCite DOI modification date
|
||||||
|
const doiClient = new DoiClient();
|
||||||
|
const doiLastModified = await doiClient.getDoiLastModified(doiIdentifier.value);
|
||||||
|
|
||||||
|
if (!doiLastModified) {
|
||||||
|
return false; // not Update if we can't get DOI info
|
||||||
|
}
|
||||||
|
|
||||||
|
const doiModified = DateTime.fromJSDate(doiLastModified);
|
||||||
|
if (datasetModified > doiModified) {
|
||||||
|
// if dataset was modified after DOI creation
|
||||||
|
// Calculate the difference in seconds
|
||||||
|
const diffInSeconds = Math.abs(datasetModified.diff(doiModified, 'seconds').seconds);
|
||||||
|
|
||||||
|
// Define tolerance threshold (60 seconds = 1 minute)
|
||||||
|
const toleranceSeconds = 60;
|
||||||
|
|
||||||
|
// Only update if the difference is greater than the tolerance
|
||||||
|
// This prevents unnecessary updates for minor timestamp differences
|
||||||
|
return diffInSeconds > toleranceSeconds;
|
||||||
|
} else {
|
||||||
|
return false; // No update needed
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return false; // not update if we can't determine status or other error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateDataciteRecord(dataset: Dataset, prefix: string, base_domain: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Get the DOI identifier (HasOne relationship)
|
||||||
|
let doiIdentifier = dataset.identifier;
|
||||||
|
|
||||||
|
if (!doiIdentifier) {
|
||||||
|
await dataset.load('identifier');
|
||||||
|
doiIdentifier = dataset.identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doiIdentifier || doiIdentifier.type !== 'doi') {
|
||||||
|
throw new Error('No DOI identifier found for dataset');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate XML metadata
|
||||||
|
const xmlMeta = (await Index.getDoiRegisterString(dataset)) as string;
|
||||||
|
if (!xmlMeta) {
|
||||||
|
throw new Error('Failed to generate XML metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct DOI value and landing page URL
|
||||||
|
const doiValue = doiIdentifier.value; // Use existing DOI value
|
||||||
|
const landingPageUrl = `https://doi.${getDomain(base_domain)}/${doiValue}`;
|
||||||
|
|
||||||
|
// Update DataCite record
|
||||||
|
const doiClient = new DoiClient();
|
||||||
|
const dataciteResponse = await doiClient.registerDoi(doiValue, xmlMeta, landingPageUrl);
|
||||||
|
|
||||||
|
if (dataciteResponse?.status === 201) {
|
||||||
|
// // Update dataset modification date
|
||||||
|
// dataset.server_date_modified = DateTime.now();
|
||||||
|
// await dataset.save();
|
||||||
|
|
||||||
|
// // Update search index
|
||||||
|
// const index_name = 'tethys-records';
|
||||||
|
// await Index.indexDocument(dataset, index_name);
|
||||||
|
|
||||||
|
logger.debug(`Dataset ${dataset.publish_id}: DataCite record and search index updated successfully`);
|
||||||
|
} else {
|
||||||
|
throw new DoiClientException(
|
||||||
|
dataciteResponse?.status || 500,
|
||||||
|
`Unexpected DataCite response code: ${dataciteResponse?.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DoiClientException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to update DataCite record: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows detailed statistics for a dataset that needs updating
|
||||||
|
*/
|
||||||
|
private async showDatasetStats(dataset: Dataset): Promise<void> {
|
||||||
|
try {
|
||||||
|
let doiIdentifier = dataset.identifier;
|
||||||
|
|
||||||
|
if (!doiIdentifier) {
|
||||||
|
await dataset.load('identifier');
|
||||||
|
doiIdentifier = dataset.identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doiValue = doiIdentifier?.value || 'N/A';
|
||||||
|
const doiStatus = doiIdentifier?.status || 'N/A';
|
||||||
|
const datasetModified = dataset.server_date_modified;
|
||||||
|
|
||||||
|
// Get DOI info from DataCite
|
||||||
|
const doiClient = new DoiClient();
|
||||||
|
const doiLastModified = await doiClient.getDoiLastModified(doiValue);
|
||||||
|
const doiState = await doiClient.getDoiState(doiValue);
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
┌─ Dataset ${dataset.publish_id} ───────────────────────────────────────────────────────────────
|
||||||
|
│ DOI Value: ${doiValue}
|
||||||
|
│ DOI Status (DB): ${doiStatus}
|
||||||
|
│ DOI State (DataCite): ${doiState || 'Unknown'}
|
||||||
|
│ Dataset Modified: ${datasetModified ? datasetModified.toISO() : 'N/A'}
|
||||||
|
│ DOI Modified: ${doiLastModified ? DateTime.fromJSDate(doiLastModified).toISO() : 'N/A'}
|
||||||
|
│ Needs Update: YES - Dataset newer than DOI
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────────────────────`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`
|
||||||
|
┌─ Dataset ${dataset.publish_id} ───────────────────────────────────────────────────────────────
|
||||||
|
│ DOI Value: ${dataset.identifier?.value || 'N/A'}
|
||||||
|
│ Error: ${error.message}
|
||||||
|
│ Needs Update: YES - Error checking status
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────────────────────`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
components.d.ts
vendored
18
components.d.ts
vendored
|
|
@ -11,3 +11,21 @@ declare module '@vue/runtime-core' {
|
||||||
NInput: (typeof import('naive-ui'))['NInput'];
|
NInput: (typeof import('naive-ui'))['NInput'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// types/leaflet-src-dom-DomEvent.d.ts
|
||||||
|
declare module 'leaflet/src/dom/DomEvent' {
|
||||||
|
export type DomEventHandler = (e?: any) => void;
|
||||||
|
|
||||||
|
// Attach event listeners. `obj` can be any DOM node or object with event handling.
|
||||||
|
export function on(obj: any, types: string, fn: DomEventHandler, context?: any): void;
|
||||||
|
|
||||||
|
// Detach event listeners.
|
||||||
|
export function off(obj: any, types: string, fn?: DomEventHandler, context?: any): void;
|
||||||
|
|
||||||
|
// Prevent default on native events
|
||||||
|
export function preventDefault(ev?: Event | undefined): void;
|
||||||
|
|
||||||
|
// Optional: other helpers you might need later
|
||||||
|
export function stopPropagation(ev?: Event | undefined): void;
|
||||||
|
export function stop(ev?: Event | undefined): void;
|
||||||
|
}
|
||||||
|
|
@ -1,47 +1,74 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# # Run freshclam to update virus definitions
|
|
||||||
# freshclam
|
|
||||||
|
|
||||||
# # Sleep for a few seconds to give ClamAV time to start
|
|
||||||
# sleep 5
|
|
||||||
|
|
||||||
# # Start the ClamAV daemon
|
|
||||||
# /etc/init.d/clamav-daemon start
|
|
||||||
|
|
||||||
# bootstrap clam av service and clam av database updater
|
|
||||||
set -m
|
set -m
|
||||||
|
|
||||||
function process_file() {
|
echo "Starting ClamAV services..."
|
||||||
if [[ ! -z "$1" ]]; then
|
|
||||||
local SETTING_LIST=$(echo "$1" | tr ',' '\n' | grep "^[A-Za-z][A-Za-z]*=.*$")
|
|
||||||
local SETTING
|
|
||||||
|
|
||||||
for SETTING in ${SETTING_LIST}; do
|
|
||||||
# Remove any existing copies of this setting. We do this here so that
|
|
||||||
# settings with multiple values (e.g. ExtraDatabase) can still be added
|
|
||||||
# multiple times below
|
|
||||||
local KEY=${SETTING%%=*}
|
|
||||||
sed -i $2 -e "/^${KEY} /d"
|
|
||||||
done
|
|
||||||
|
|
||||||
for SETTING in ${SETTING_LIST}; do
|
# Try to download database if missing
|
||||||
# Split on first '='
|
# if [ ! "$(ls -A /var/lib/clamav 2>/dev/null)" ]; then
|
||||||
local KEY=${SETTING%%=*}
|
# echo "Downloading ClamAV database (this may take a while)..."
|
||||||
local VALUE=${SETTING#*=}
|
|
||||||
echo "${KEY} ${VALUE}" >> "$2"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# process_file "${CLAMD_SETTINGS_CSV}" /etc/clamav/clamd.conf
|
# # Simple freshclam run without complex config
|
||||||
# process_file "${FRESHCLAM_SETTINGS_CSV}" /etc/clamav/freshclam.conf
|
# if freshclam --datadir=/var/lib/clamav --quiet; then
|
||||||
|
# echo "✓ Database downloaded successfully"
|
||||||
|
# else
|
||||||
|
# echo "⚠ Database download failed - creating minimal setup"
|
||||||
|
# # Create a dummy file so clamd doesn't immediately fail
|
||||||
|
# touch /var/lib/clamav/.dummy
|
||||||
|
# fi
|
||||||
|
# fi
|
||||||
|
|
||||||
|
# Start freshclam daemon for automatic updates
|
||||||
|
echo "Starting freshclam daemon for automatic updates..."
|
||||||
|
# sg clamav -c "freshclam -d" &
|
||||||
|
# Added --daemon-notify to freshclam - This notifies clamd when the database updates
|
||||||
|
freshclam -d --daemon-notify=/etc/clamav/clamd.conf &
|
||||||
|
#freshclam -d &
|
||||||
|
|
||||||
# start in background
|
|
||||||
freshclam -d &
|
|
||||||
# /etc/init.d/clamav-freshclam start &
|
# /etc/init.d/clamav-freshclam start &
|
||||||
clamd
|
# Start clamd in background
|
||||||
|
# Start clamd in foreground (so dumb-init can supervise it)
|
||||||
# /etc/init.d/clamav-daemon start &
|
# /etc/init.d/clamav-daemon start &
|
||||||
|
|
||||||
# change back to CMD of dockerfile
|
# Give freshclam a moment to start
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Start clamd daemon in background using sg
|
||||||
|
echo "Starting ClamAV daemon..."
|
||||||
|
# sg clamav -c "clamd" &
|
||||||
|
# Use sg to run clamd with proper group permissions
|
||||||
|
# sg clamav -c "clamd" &
|
||||||
|
# clamd --config-file=/etc/clamav/clamd.conf &
|
||||||
|
clamd &
|
||||||
|
|
||||||
|
|
||||||
|
# Give services time to start
|
||||||
|
echo "Waiting for services to initialize..."
|
||||||
|
sleep 8
|
||||||
|
|
||||||
|
# simple check
|
||||||
|
if pgrep clamd > /dev/null; then
|
||||||
|
echo "✓ ClamAV daemon is running"
|
||||||
|
else
|
||||||
|
echo "⚠ ClamAV daemon status uncertain, but continuing..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if freshclam daemon is running
|
||||||
|
if pgrep freshclam > /dev/null; then
|
||||||
|
echo "✓ Freshclam daemon is running"
|
||||||
|
else
|
||||||
|
echo "⚠ Freshclam daemon status uncertain, but continuing..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# # Optional: Test socket connectivity
|
||||||
|
# if [ -S /var/run/clamav/clamd.socket ]; then
|
||||||
|
# echo "✓ ClamAV socket exists"
|
||||||
|
# else
|
||||||
|
# echo "⚠ WARNING: ClamAV socket not found - services may still be starting"
|
||||||
|
# fi
|
||||||
|
|
||||||
|
# # change back to CMD of dockerfile
|
||||||
|
echo "✓ ClamAV setup complete"
|
||||||
|
echo "Starting main application..."
|
||||||
|
# exec dumb-init -- "$@"
|
||||||
exec "$@"
|
exec "$@"
|
||||||
278
docs/commands/index-datasets.md
Normal file
278
docs/commands/index-datasets.md
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
# Dataset Indexing Command
|
||||||
|
|
||||||
|
AdonisJS Ace command for indexing and synchronizing published datasets with OpenSearch for search functionality.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `index:datasets` command processes published datasets and creates/updates corresponding search index documents in OpenSearch. It intelligently compares modification timestamps to only re-index datasets when necessary, optimizing performance while maintaining search index accuracy.
|
||||||
|
|
||||||
|
## Command Syntax
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ace index:datasets [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
| Flag | Alias | Description |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| `--publish_id <number>` | `-p` | Index a specific dataset by publish_id |
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Index all published datasets that have been modified since last indexing
|
||||||
|
node ace index:datasets
|
||||||
|
|
||||||
|
# Index a specific dataset by publish_id
|
||||||
|
node ace index:datasets --publish_id 231
|
||||||
|
node ace index:datasets -p 231
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### 1. **Dataset Selection**
|
||||||
|
The command processes datasets that meet these criteria:
|
||||||
|
- `server_state = 'published'` - Only published datasets
|
||||||
|
- Has preloaded `xmlCache` relationship for metadata transformation
|
||||||
|
- Optionally filtered by specific `publish_id`
|
||||||
|
|
||||||
|
### 2. **Smart Update Detection**
|
||||||
|
For each dataset, the command:
|
||||||
|
- Checks if the dataset exists in the OpenSearch index
|
||||||
|
- Compares `server_date_modified` timestamps
|
||||||
|
- Only re-indexes if the dataset is newer than the indexed version
|
||||||
|
|
||||||
|
### 3. **Document Processing**
|
||||||
|
The indexing process involves:
|
||||||
|
1. **XML Generation**: Creates structured XML from dataset metadata
|
||||||
|
2. **XSLT Transformation**: Converts XML to JSON using Saxon-JS processor
|
||||||
|
3. **Index Update**: Updates or creates the document in OpenSearch
|
||||||
|
4. **Logging**: Records success/failure for each operation
|
||||||
|
|
||||||
|
## Index Structure
|
||||||
|
|
||||||
|
### Index Configuration
|
||||||
|
- **Index Name**: `tethys-records`
|
||||||
|
- **Document ID**: Dataset `publish_id`
|
||||||
|
- **Refresh**: `true` (immediate availability)
|
||||||
|
|
||||||
|
### Document Fields
|
||||||
|
The indexed documents contain:
|
||||||
|
- **Metadata Fields**: Title, description, authors, keywords
|
||||||
|
- **Identifiers**: DOI, publish_id, and other identifiers
|
||||||
|
- **Temporal Data**: Publication dates, coverage periods
|
||||||
|
- **Geographic Data**: Spatial coverage information
|
||||||
|
- **Technical Details**: Data formats, access information
|
||||||
|
- **Timestamps**: Creation and modification dates
|
||||||
|
|
||||||
|
## Example Output
|
||||||
|
|
||||||
|
### Successful Run
|
||||||
|
```bash
|
||||||
|
node ace index:datasets
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Found 150 published datasets to process
|
||||||
|
Dataset with publish_id 231 successfully indexed
|
||||||
|
Dataset with publish_id 245 is up to date, skipping indexing
|
||||||
|
Dataset with publish_id 267 successfully indexed
|
||||||
|
An error occurred while indexing dataset with publish_id 289. Error: Invalid XML metadata
|
||||||
|
Processing completed: 148 indexed, 1 skipped, 1 error
|
||||||
|
```
|
||||||
|
|
||||||
|
### Specific Dataset
|
||||||
|
```bash
|
||||||
|
node ace index:datasets --publish_id 231
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Found 1 published dataset to process
|
||||||
|
Dataset with publish_id 231 successfully indexed
|
||||||
|
Processing completed: 1 indexed, 0 skipped, 0 errors
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update Logic
|
||||||
|
|
||||||
|
The command uses intelligent indexing to avoid unnecessary processing:
|
||||||
|
|
||||||
|
| Condition | Action | Reason |
|
||||||
|
|-----------|--------|--------|
|
||||||
|
| Dataset not in index | ✅ Index | New dataset needs indexing |
|
||||||
|
| Dataset newer than indexed version | ✅ Re-index | Dataset has been updated |
|
||||||
|
| Dataset same/older than indexed version | ❌ Skip | Already up to date |
|
||||||
|
| OpenSearch document check fails | ✅ Index | Better safe than sorry |
|
||||||
|
| Invalid XML metadata | ❌ Skip + Log Error | Cannot process invalid data |
|
||||||
|
|
||||||
|
### Timestamp Comparison
|
||||||
|
```typescript
|
||||||
|
// Example comparison logic
|
||||||
|
const existingModified = DateTime.fromMillis(Number(existingDoc.server_date_modified) * 1000);
|
||||||
|
const currentModified = dataset.server_date_modified;
|
||||||
|
|
||||||
|
if (currentModified <= existingModified) {
|
||||||
|
// Skip - already up to date
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Proceed with indexing
|
||||||
|
```
|
||||||
|
|
||||||
|
## XML Transformation Process
|
||||||
|
|
||||||
|
### 1. **XML Generation**
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="true"?>
|
||||||
|
<root>
|
||||||
|
<Dataset>
|
||||||
|
<!-- Dataset metadata fields -->
|
||||||
|
<title>Research Dataset Title</title>
|
||||||
|
<description>Dataset description...</description>
|
||||||
|
<!-- Additional metadata -->
|
||||||
|
</Dataset>
|
||||||
|
</root>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **XSLT Processing**
|
||||||
|
The command uses Saxon-JS with a compiled stylesheet (`solr.sef.json`) to transform XML to JSON:
|
||||||
|
```javascript
|
||||||
|
const result = await SaxonJS.transform({
|
||||||
|
stylesheetText: proc,
|
||||||
|
destination: 'serialized',
|
||||||
|
sourceText: xmlString,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **Final JSON Document**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "231",
|
||||||
|
"title": "Research Dataset Title",
|
||||||
|
"description": "Dataset description...",
|
||||||
|
"authors": ["Author Name"],
|
||||||
|
"server_date_modified": 1634567890,
|
||||||
|
"publish_id": 231
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Requirements
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
```bash
|
||||||
|
# OpenSearch Configuration
|
||||||
|
OPENSEARCH_HOST=localhost:9200
|
||||||
|
|
||||||
|
# For production:
|
||||||
|
# OPENSEARCH_HOST=your-opensearch-cluster:9200
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Files
|
||||||
|
- **XSLT Stylesheet**: `public/assets2/solr.sef.json` - Compiled Saxon-JS stylesheet for XML transformation
|
||||||
|
|
||||||
|
### Database Relationships
|
||||||
|
The command expects these model relationships:
|
||||||
|
```typescript
|
||||||
|
// Dataset model must have:
|
||||||
|
@hasOne(() => XmlCache, { foreignKey: 'dataset_id' })
|
||||||
|
public xmlCache: HasOne<typeof XmlCache>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The command handles various error scenarios gracefully:
|
||||||
|
|
||||||
|
### Common Errors and Solutions
|
||||||
|
|
||||||
|
| Error | Cause | Solution |
|
||||||
|
|-------|-------|----------|
|
||||||
|
| `XSLT transformation failed` | Invalid XML or missing stylesheet | Check XML structure and stylesheet path |
|
||||||
|
| `OpenSearch connection error` | Service unavailable | Verify OpenSearch is running and accessible |
|
||||||
|
| `JSON parse error` | Malformed transformation result | Check XSLT stylesheet output format |
|
||||||
|
| `Missing xmlCache relationship` | Data integrity issue | Ensure xmlCache exists for dataset |
|
||||||
|
|
||||||
|
### Error Logging
|
||||||
|
```bash
|
||||||
|
# Typical error log entry
|
||||||
|
An error occurred while indexing dataset with publish_id 231.
|
||||||
|
Error: XSLT transformation failed: Invalid XML structure at line 15
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Batch Processing
|
||||||
|
- Processes datasets sequentially to avoid overwhelming OpenSearch
|
||||||
|
- Each dataset is committed individually for reliability
|
||||||
|
- Failed indexing of one dataset doesn't stop processing others
|
||||||
|
|
||||||
|
### Resource Usage
|
||||||
|
- **Memory**: XML/JSON transformations require temporary memory
|
||||||
|
- **Network**: OpenSearch API calls for each dataset
|
||||||
|
- **CPU**: XSLT transformations are CPU-intensive
|
||||||
|
|
||||||
|
### Optimization Tips
|
||||||
|
```bash
|
||||||
|
# Index only recently modified datasets (run regularly)
|
||||||
|
node ace index:datasets
|
||||||
|
|
||||||
|
# Index specific datasets when needed
|
||||||
|
node ace index:datasets --publish_id 231
|
||||||
|
|
||||||
|
# Consider running during off-peak hours for large batches
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with Other Systems
|
||||||
|
|
||||||
|
### Search Functionality
|
||||||
|
The indexed documents power:
|
||||||
|
- **Dataset Search**: Full-text search across metadata
|
||||||
|
- **Faceted Browsing**: Filter by authors, keywords, dates
|
||||||
|
- **Geographic Search**: Spatial query capabilities
|
||||||
|
- **Auto-complete**: Suggest dataset titles and keywords
|
||||||
|
|
||||||
|
### Related Commands
|
||||||
|
- [`update:datacite`](update-datacite.md) - Often run after indexing to sync DOI metadata
|
||||||
|
- **Database migrations** - May require re-indexing after schema changes
|
||||||
|
|
||||||
|
### API Integration
|
||||||
|
The indexed data is consumed by:
|
||||||
|
- **Search API**: `/api/search` endpoints
|
||||||
|
- **Browse API**: `/api/datasets` with filtering
|
||||||
|
- **Recommendations**: Related dataset suggestions
|
||||||
|
|
||||||
|
## Monitoring and Maintenance
|
||||||
|
|
||||||
|
### Regular Tasks
|
||||||
|
```bash
|
||||||
|
# Daily indexing (recommended cron job)
|
||||||
|
0 2 * * * cd /path/to/project && node ace index:datasets
|
||||||
|
|
||||||
|
# Weekly full re-index (if needed)
|
||||||
|
0 3 * * 0 cd /path/to/project && node ace index:datasets --force
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
- Monitor OpenSearch cluster health
|
||||||
|
- Check for failed indexing operations in logs
|
||||||
|
- Verify search functionality is working
|
||||||
|
- Compare dataset counts between database and index
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
```bash
|
||||||
|
# Check specific dataset indexing
|
||||||
|
node ace index:datasets --publish_id 231
|
||||||
|
|
||||||
|
# Verify OpenSearch connectivity
|
||||||
|
curl -X GET "localhost:9200/_cluster/health"
|
||||||
|
|
||||||
|
# Check index statistics
|
||||||
|
curl -X GET "localhost:9200/tethys-records/_stats"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Regular Scheduling**: Run the command regularly (daily) to keep the search index current
|
||||||
|
2. **Monitor Logs**: Watch for transformation errors or OpenSearch issues
|
||||||
|
3. **Backup Strategy**: Include OpenSearch indices in backup procedures
|
||||||
|
4. **Resource Management**: Monitor OpenSearch cluster resources during bulk operations
|
||||||
|
5. **Testing**: Verify search functionality after major indexing operations
|
||||||
|
6. **Coordination**: Run indexing before DataCite updates when both are needed
|
||||||
216
docs/commands/update-datacite.md
Normal file
216
docs/commands/update-datacite.md
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
# DataCite Update Command
|
||||||
|
|
||||||
|
AdonisJS Ace command for updating DataCite DOI records for published datasets.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `update:datacite` command synchronizes your local dataset metadata with DataCite DOI records. It intelligently compares modification dates to only update records when necessary, reducing unnecessary API calls and maintaining data consistency.
|
||||||
|
|
||||||
|
## Command Syntax
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ace update:datacite [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
| Flag | Alias | Description |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| `--publish_id <number>` | `-p` | Update a specific dataset by publish_id |
|
||||||
|
| `--force` | `-f` | Force update all records regardless of modification date |
|
||||||
|
| `--dry-run` | `-d` | Preview what would be updated without making changes |
|
||||||
|
| `--stats` | `-s` | Show detailed statistics for datasets that need updating |
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update all datasets that have been modified since their DOI was last updated
|
||||||
|
node ace update:datacite
|
||||||
|
|
||||||
|
# Update a specific dataset
|
||||||
|
node ace update:datacite --publish_id 231
|
||||||
|
node ace update:datacite -p 231
|
||||||
|
|
||||||
|
# Force update all datasets with DOIs (ignores modification dates)
|
||||||
|
node ace update:datacite --force
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preview and Analysis
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Preview what would be updated (dry run)
|
||||||
|
node ace update:datacite --dry-run
|
||||||
|
|
||||||
|
# Show detailed statistics for datasets that need updating
|
||||||
|
node ace update:datacite --stats
|
||||||
|
|
||||||
|
# Show stats for a specific dataset
|
||||||
|
node ace update:datacite --stats --publish_id 231
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combined Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dry run for a specific dataset
|
||||||
|
node ace update:datacite --dry-run --publish_id 231
|
||||||
|
|
||||||
|
# Show stats for all datasets (including up-to-date ones)
|
||||||
|
node ace update:datacite --stats --force
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command Modes
|
||||||
|
|
||||||
|
### 1. **Normal Mode** (Default)
|
||||||
|
Updates DataCite records for datasets that have been modified since their DOI was last updated.
|
||||||
|
|
||||||
|
**Example Output:**
|
||||||
|
```
|
||||||
|
Using DataCite API: https://api.test.datacite.org
|
||||||
|
Found 50 datasets to process
|
||||||
|
Dataset 231: Successfully updated DataCite record
|
||||||
|
Dataset 245: Up to date, skipping
|
||||||
|
Dataset 267: Successfully updated DataCite record
|
||||||
|
DataCite update completed. Updated: 15, Skipped: 35, Errors: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **Dry Run Mode** (`--dry-run`)
|
||||||
|
Shows what would be updated without making any changes to DataCite.
|
||||||
|
|
||||||
|
**Use Case:** Preview updates before running the actual command.
|
||||||
|
|
||||||
|
**Example Output:**
|
||||||
|
```
|
||||||
|
Dataset 231: Would update DataCite record (dry run)
|
||||||
|
Dataset 267: Would update DataCite record (dry run)
|
||||||
|
Dataset 245: Up to date, skipping
|
||||||
|
DataCite update completed. Updated: 2, Skipped: 1, Errors: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **Stats Mode** (`--stats`)
|
||||||
|
Shows detailed information for each dataset that needs updating, including why it needs updating.
|
||||||
|
|
||||||
|
**Use Case:** Debug synchronization issues, monitor dataset/DOI status, generate reports.
|
||||||
|
|
||||||
|
**Example Output:**
|
||||||
|
```
|
||||||
|
┌─ Dataset 231 ─────────────────────────────────────────────────────────
|
||||||
|
│ DOI Value: 10.21388/tethys.231
|
||||||
|
│ DOI Status (DB): findable
|
||||||
|
│ DOI State (DataCite): findable
|
||||||
|
│ Dataset Modified: 2024-09-15T10:30:00.000Z
|
||||||
|
│ DOI Modified: 2024-09-10T08:15:00.000Z
|
||||||
|
│ Needs Update: YES - Dataset newer than DOI
|
||||||
|
└───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
┌─ Dataset 267 ─────────────────────────────────────────────────────────
|
||||||
|
│ DOI Value: 10.21388/tethys.267
|
||||||
|
│ DOI Status (DB): findable
|
||||||
|
│ DOI State (DataCite): findable
|
||||||
|
│ Dataset Modified: 2024-09-18T14:20:00.000Z
|
||||||
|
│ DOI Modified: 2024-09-16T12:45:00.000Z
|
||||||
|
│ Needs Update: YES - Dataset newer than DOI
|
||||||
|
└───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DataCite Stats Summary: 2 datasets need updating, 48 are up to date
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update Logic
|
||||||
|
|
||||||
|
The command uses intelligent update detection:
|
||||||
|
|
||||||
|
1. **Compares modification dates**: Dataset `server_date_modified` vs DOI last modification date from DataCite
|
||||||
|
2. **Validates data integrity**: Checks for missing or future dates
|
||||||
|
3. **Handles API failures gracefully**: Updates anyway if DataCite info can't be retrieved
|
||||||
|
4. **Uses dual API approach**: DataCite REST API (primary) with MDS API fallback
|
||||||
|
|
||||||
|
### When Updates Happen
|
||||||
|
|
||||||
|
| Condition | Action | Reason |
|
||||||
|
|-----------|--------|--------|
|
||||||
|
| Dataset modified > DOI modified | ✅ Update | Dataset has newer changes |
|
||||||
|
| Dataset modified ≤ DOI modified | ❌ Skip | DOI is up to date |
|
||||||
|
| Dataset date in future | ❌ Skip | Invalid data, needs investigation |
|
||||||
|
| Dataset date missing | ✅ Update | Can't determine staleness |
|
||||||
|
| DataCite API error | ✅ Update | Better safe than sorry |
|
||||||
|
| `--force` flag used | ✅ Update | Override all logic |
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
Required environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# DataCite Credentials
|
||||||
|
DATACITE_USERNAME=your_username
|
||||||
|
DATACITE_PASSWORD=your_password
|
||||||
|
|
||||||
|
# API Endpoints (environment-specific)
|
||||||
|
DATACITE_API_URL=https://api.test.datacite.org # Test environment
|
||||||
|
DATACITE_SERVICE_URL=https://mds.test.datacite.org # Test MDS
|
||||||
|
|
||||||
|
DATACITE_API_URL=https://api.datacite.org # Production
|
||||||
|
DATACITE_SERVICE_URL=https://mds.datacite.org # Production MDS
|
||||||
|
|
||||||
|
# Project Configuration
|
||||||
|
DATACITE_PREFIX=10.21388 # Your DOI prefix
|
||||||
|
BASE_DOMAIN=tethys.at # Your domain
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The command handles various error scenarios:
|
||||||
|
|
||||||
|
- **Invalid modification dates**: Logs errors but continues processing other datasets
|
||||||
|
- **DataCite API failures**: Falls back to MDS API, then to safe update
|
||||||
|
- **Missing DOI identifiers**: Skips datasets without DOI identifiers
|
||||||
|
- **Network issues**: Continues with next dataset after logging error
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
The command integrates with:
|
||||||
|
|
||||||
|
- **Dataset Model**: Uses `server_date_modified` for change detection
|
||||||
|
- **DatasetIdentifier Model**: Reads DOI values and status
|
||||||
|
- **OpenSearch Index**: Updates search index after DataCite update
|
||||||
|
- **DoiClient**: Handles all DataCite API interactions
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### Daily Maintenance
|
||||||
|
```bash
|
||||||
|
# Update any datasets modified today
|
||||||
|
node ace update:datacite
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-Deployment Check
|
||||||
|
```bash
|
||||||
|
# Check what would be updated before deployment
|
||||||
|
node ace update:datacite --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debugging Sync Issues
|
||||||
|
```bash
|
||||||
|
# Investigate why specific dataset isn't syncing
|
||||||
|
node ace update:datacite --stats --publish_id 231
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full Resync
|
||||||
|
```bash
|
||||||
|
# Force update all DOI records (use with caution)
|
||||||
|
node ace update:datacite --force
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring Report
|
||||||
|
```bash
|
||||||
|
# Generate sync status report
|
||||||
|
node ace update:datacite --stats > datacite-sync-report.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Regular Updates**: Run daily or after bulk dataset modifications
|
||||||
|
2. **Test First**: Use `--dry-run` or `--stats` before bulk operations
|
||||||
|
3. **Monitor Logs**: Check for data integrity warnings
|
||||||
|
4. **Environment Separation**: Use correct API URLs for test vs production
|
||||||
|
5. **Rate Limiting**: The command handles DataCite rate limits automatically
|
||||||
218
freshclam.conf
218
freshclam.conf
|
|
@ -1,229 +1,47 @@
|
||||||
##
|
##
|
||||||
## Example config file for freshclam
|
## Container-optimized freshclam configuration
|
||||||
## Please read the freshclam.conf(5) manual before editing this file.
|
|
||||||
##
|
##
|
||||||
|
|
||||||
|
# Database directory
|
||||||
# Comment or remove the line below.
|
|
||||||
|
|
||||||
# Path to the database directory.
|
|
||||||
# WARNING: It must match clamd.conf's directive!
|
|
||||||
# Default: hardcoded (depends on installation options)
|
|
||||||
DatabaseDirectory /var/lib/clamav
|
DatabaseDirectory /var/lib/clamav
|
||||||
|
|
||||||
# Path to the log file (make sure it has proper permissions)
|
# Log to stdout for container logging
|
||||||
# Default: disabled
|
|
||||||
# UpdateLogFile /dev/stdout
|
# UpdateLogFile /dev/stdout
|
||||||
|
|
||||||
# Maximum size of the log file.
|
# Basic logging settings
|
||||||
# Value of 0 disables the limit.
|
|
||||||
# You may use 'M' or 'm' for megabytes (1M = 1m = 1048576 bytes)
|
|
||||||
# and 'K' or 'k' for kilobytes (1K = 1k = 1024 bytes).
|
|
||||||
# in bytes just don't use modifiers. If LogFileMaxSize is enabled,
|
|
||||||
# log rotation (the LogRotate option) will always be enabled.
|
|
||||||
# Default: 1M
|
|
||||||
#LogFileMaxSize 2M
|
|
||||||
|
|
||||||
# Log time with each message.
|
|
||||||
# Default: no
|
|
||||||
LogTime yes
|
LogTime yes
|
||||||
|
|
||||||
# Enable verbose logging.
|
|
||||||
# Default: no
|
|
||||||
LogVerbose yes
|
LogVerbose yes
|
||||||
|
|
||||||
# Use system logger (can work together with UpdateLogFile).
|
|
||||||
# Default: no
|
|
||||||
LogSyslog no
|
LogSyslog no
|
||||||
|
|
||||||
# Specify the type of syslog messages - please refer to 'man syslog'
|
# PID file location
|
||||||
# for facility names.
|
|
||||||
# Default: LOG_LOCAL6
|
|
||||||
#LogFacility LOG_MAIL
|
|
||||||
|
|
||||||
# Enable log rotation. Always enabled when LogFileMaxSize is enabled.
|
|
||||||
# Default: no
|
|
||||||
#LogRotate yes
|
|
||||||
|
|
||||||
# This option allows you to save the process identifier of the daemon
|
|
||||||
# Default: disabled
|
|
||||||
#PidFile /var/run/freshclam.pid
|
|
||||||
PidFile /var/run/clamav/freshclam.pid
|
PidFile /var/run/clamav/freshclam.pid
|
||||||
|
|
||||||
# By default when started freshclam drops privileges and switches to the
|
# Database owner
|
||||||
# "clamav" user. This directive allows you to change the database owner.
|
|
||||||
# Default: clamav (may depend on installation options)
|
|
||||||
DatabaseOwner node
|
DatabaseOwner node
|
||||||
|
|
||||||
# Use DNS to verify virus database version. Freshclam uses DNS TXT records
|
# Mirror settings for Austria
|
||||||
# to verify database and software versions. With this directive you can change
|
|
||||||
# the database verification domain.
|
|
||||||
# WARNING: Do not touch it unless you're configuring freshclam to use your
|
|
||||||
# own database verification domain.
|
|
||||||
# Default: current.cvd.clamav.net
|
|
||||||
#DNSDatabaseInfo current.cvd.clamav.net
|
|
||||||
|
|
||||||
# Uncomment the following line and replace XY with your country
|
|
||||||
# code. See http://www.iana.org/cctld/cctld-whois.htm for the full list.
|
|
||||||
# You can use db.XY.ipv6.clamav.net for IPv6 connections.
|
|
||||||
DatabaseMirror db.at.clamav.net
|
DatabaseMirror db.at.clamav.net
|
||||||
|
|
||||||
# database.clamav.net is a round-robin record which points to our most
|
|
||||||
# reliable mirrors. It's used as a fall back in case db.XY.clamav.net is
|
|
||||||
# not working. DO NOT TOUCH the following line unless you know what you
|
|
||||||
# are doing.
|
|
||||||
DatabaseMirror database.clamav.net
|
DatabaseMirror database.clamav.net
|
||||||
|
|
||||||
# How many attempts to make before giving up.
|
|
||||||
# Default: 3 (per mirror)
|
|
||||||
#MaxAttempts 5
|
|
||||||
|
|
||||||
# With this option you can control scripted updates. It's highly recommended
|
# With this option you can control scripted updates. It's highly recommended
|
||||||
# to keep it enabled.
|
# to keep it enabled.
|
||||||
# Default: yes
|
# Default: yes
|
||||||
#ScriptedUpdates yes
|
# Update settings
|
||||||
|
ScriptedUpdates yes
|
||||||
# By default freshclam will keep the local databases (.cld) uncompressed to
|
|
||||||
# make their handling faster. With this option you can enable the compression;
|
|
||||||
# the change will take effect with the next database update.
|
|
||||||
# Default: no
|
|
||||||
#CompressLocalDatabase no
|
|
||||||
|
|
||||||
# With this option you can provide custom sources (http:// or file://) for
|
|
||||||
# database files. This option can be used multiple times.
|
|
||||||
# Default: no custom URLs
|
|
||||||
#DatabaseCustomURL http://myserver.com/mysigs.ndb
|
|
||||||
#DatabaseCustomURL file:///mnt/nfs/local.hdb
|
|
||||||
|
|
||||||
# This option allows you to easily point freshclam to private mirrors.
|
|
||||||
# If PrivateMirror is set, freshclam does not attempt to use DNS
|
|
||||||
# to determine whether its databases are out-of-date, instead it will
|
|
||||||
# use the If-Modified-Since request or directly check the headers of the
|
|
||||||
# remote database files. For each database, freshclam first attempts
|
|
||||||
# to download the CLD file. If that fails, it tries to download the
|
|
||||||
# CVD file. This option overrides DatabaseMirror, DNSDatabaseInfo
|
|
||||||
# and ScriptedUpdates. It can be used multiple times to provide
|
|
||||||
# fall-back mirrors.
|
|
||||||
# Default: disabled
|
|
||||||
#PrivateMirror mirror1.mynetwork.com
|
|
||||||
#PrivateMirror mirror2.mynetwork.com
|
|
||||||
|
|
||||||
# Number of database checks per day.
|
# Number of database checks per day.
|
||||||
# Default: 12 (every two hours)
|
# Default: 12 (every two hours)
|
||||||
#Checks 24
|
Checks 12
|
||||||
|
|
||||||
# Proxy settings
|
# Don't fork (good for containers)
|
||||||
# Default: disabled
|
|
||||||
#HTTPProxyServer myproxy.com
|
|
||||||
#HTTPProxyPort 1234
|
|
||||||
#HTTPProxyUsername myusername
|
|
||||||
#HTTPProxyPassword mypass
|
|
||||||
|
|
||||||
# If your servers are behind a firewall/proxy which applies User-Agent
|
|
||||||
# filtering you can use this option to force the use of a different
|
|
||||||
# User-Agent header.
|
|
||||||
# Default: clamav/version_number
|
|
||||||
#HTTPUserAgent SomeUserAgentIdString
|
|
||||||
|
|
||||||
# Use aaa.bbb.ccc.ddd as client address for downloading databases. Useful for
|
|
||||||
# multi-homed systems.
|
|
||||||
# Default: Use OS'es default outgoing IP address.
|
|
||||||
#LocalIPAddress aaa.bbb.ccc.ddd
|
|
||||||
|
|
||||||
# Send the RELOAD command to clamd.
|
|
||||||
# Default: no
|
|
||||||
#NotifyClamd /path/to/clamd.conf
|
|
||||||
|
|
||||||
# Run command after successful database update.
|
|
||||||
# Default: disabled
|
|
||||||
#OnUpdateExecute command
|
|
||||||
|
|
||||||
# Run command when database update process fails.
|
|
||||||
# Default: disabled
|
|
||||||
#OnErrorExecute command
|
|
||||||
|
|
||||||
# Run command when freshclam reports outdated version.
|
|
||||||
# In the command string %v will be replaced by the new version number.
|
|
||||||
# Default: disabled
|
|
||||||
#OnOutdatedExecute command
|
|
||||||
|
|
||||||
# Don't fork into background.
|
|
||||||
# Default: no
|
|
||||||
Foreground no
|
Foreground no
|
||||||
|
|
||||||
# Enable debug messages in libclamav.
|
# Connection timeouts
|
||||||
# Default: no
|
ConnectTimeout 60
|
||||||
#Debug yes
|
ReceiveTimeout 60
|
||||||
|
|
||||||
# Timeout in seconds when connecting to database server.
|
# Test databases before using them
|
||||||
# Default: 30
|
TestDatabases yes
|
||||||
#ConnectTimeout 60
|
|
||||||
|
|
||||||
# Timeout in seconds when reading from database server.
|
# Enable bytecode signatures
|
||||||
# Default: 30
|
Bytecode yes
|
||||||
#ReceiveTimeout 60
|
|
||||||
|
|
||||||
# With this option enabled, freshclam will attempt to load new
|
|
||||||
# databases into memory to make sure they are properly handled
|
|
||||||
# by libclamav before replacing the old ones.
|
|
||||||
# Default: yes
|
|
||||||
#TestDatabases yes
|
|
||||||
|
|
||||||
# When enabled freshclam will submit statistics to the ClamAV Project about
|
|
||||||
# the latest virus detections in your environment. The ClamAV maintainers
|
|
||||||
# will then use this data to determine what types of malware are the most
|
|
||||||
# detected in the field and in what geographic area they are.
|
|
||||||
# Freshclam will connect to clamd in order to get recent statistics.
|
|
||||||
# Default: no
|
|
||||||
#SubmitDetectionStats /path/to/clamd.conf
|
|
||||||
|
|
||||||
# Country of origin of malware/detection statistics (for statistical
|
|
||||||
# purposes only). The statistics collector at ClamAV.net will look up
|
|
||||||
# your IP address to determine the geographical origin of the malware
|
|
||||||
# reported by your installation. If this installation is mainly used to
|
|
||||||
# scan data which comes from a different location, please enable this
|
|
||||||
# option and enter a two-letter code (see http://www.iana.org/domains/root/db/)
|
|
||||||
# of the country of origin.
|
|
||||||
# Default: disabled
|
|
||||||
#DetectionStatsCountry country-code
|
|
||||||
|
|
||||||
# This option enables support for our "Personal Statistics" service.
|
|
||||||
# When this option is enabled, the information on malware detected by
|
|
||||||
# your clamd installation is made available to you through our website.
|
|
||||||
# To get your HostID, log on http://www.stats.clamav.net and add a new
|
|
||||||
# host to your host list. Once you have the HostID, uncomment this option
|
|
||||||
# and paste the HostID here. As soon as your freshclam starts submitting
|
|
||||||
# information to our stats collecting service, you will be able to view
|
|
||||||
# the statistics of this clamd installation by logging into
|
|
||||||
# http://www.stats.clamav.net with the same credentials you used to
|
|
||||||
# generate the HostID. For more information refer to:
|
|
||||||
# http://www.clamav.net/documentation.html#cctts
|
|
||||||
# This feature requires SubmitDetectionStats to be enabled.
|
|
||||||
# Default: disabled
|
|
||||||
#DetectionStatsHostID unique-id
|
|
||||||
|
|
||||||
# This option enables support for Google Safe Browsing. When activated for
|
|
||||||
# the first time, freshclam will download a new database file (safebrowsing.cvd)
|
|
||||||
# which will be automatically loaded by clamd and clamscan during the next
|
|
||||||
# reload, provided that the heuristic phishing detection is turned on. This
|
|
||||||
# database includes information about websites that may be phishing sites or
|
|
||||||
# possible sources of malware. When using this option, it's mandatory to run
|
|
||||||
# freshclam at least every 30 minutes.
|
|
||||||
# Freshclam uses the ClamAV's mirror infrastructure to distribute the
|
|
||||||
# database and its updates but all the contents are provided under Google's
|
|
||||||
# terms of use. See http://www.google.com/transparencyreport/safebrowsing
|
|
||||||
# and http://www.clamav.net/documentation.html#safebrowsing
|
|
||||||
# for more information.
|
|
||||||
# Default: disabled
|
|
||||||
#SafeBrowsing yes
|
|
||||||
|
|
||||||
# This option enables downloading of bytecode.cvd, which includes additional
|
|
||||||
# detection mechanisms and improvements to the ClamAV engine.
|
|
||||||
# Default: enabled
|
|
||||||
#Bytecode yes
|
|
||||||
|
|
||||||
# Download an additional 3rd party signature database distributed through
|
|
||||||
# the ClamAV mirrors.
|
|
||||||
# This option can be used multiple times.
|
|
||||||
#ExtraDatabase dbname1
|
|
||||||
#ExtraDatabase dbname2
|
|
||||||
3880
package-lock.json
generated
3880
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -77,7 +77,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@adonisjs/auth": "^9.2.4",
|
"@adonisjs/auth": "^9.2.4",
|
||||||
"@adonisjs/bodyparser": "^10.0.1",
|
"@adonisjs/bodyparser": "^10.0.1",
|
||||||
"@adonisjs/core": "^6.17.0",
|
"@adonisjs/core": "6.17.2",
|
||||||
"@adonisjs/cors": "^2.2.1",
|
"@adonisjs/cors": "^2.2.1",
|
||||||
"@adonisjs/drive": "^3.2.0",
|
"@adonisjs/drive": "^3.2.0",
|
||||||
"@adonisjs/inertia": "^2.1.3",
|
"@adonisjs/inertia": "^2.1.3",
|
||||||
|
|
@ -97,7 +97,6 @@
|
||||||
"@phc/format": "^1.0.0",
|
"@phc/format": "^1.0.0",
|
||||||
"@poppinss/manager": "^5.0.2",
|
"@poppinss/manager": "^5.0.2",
|
||||||
"@vinejs/vine": "^3.0.0",
|
"@vinejs/vine": "^3.0.0",
|
||||||
"argon2": "^0.43.0",
|
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
|
|
||||||
34
providers/rule_provider.ts
Normal file
34
providers/rule_provider.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { ApplicationService } from '@adonisjs/core/types';
|
||||||
|
|
||||||
|
export default class RuleProvider {
|
||||||
|
constructor(protected app: ApplicationService) {}
|
||||||
|
|
||||||
|
public register() {
|
||||||
|
// Register your own bindings
|
||||||
|
}
|
||||||
|
|
||||||
|
public async boot() {
|
||||||
|
// IoC container is ready
|
||||||
|
// await import("../src/rules/index.js");
|
||||||
|
|
||||||
|
await import('#start/rules/unique');
|
||||||
|
await import('#start/rules/translated_language');
|
||||||
|
await import('#start/rules/unique_person');
|
||||||
|
// () => import('#start/rules/file_length'),
|
||||||
|
// () => import('#start/rules/file_scan'),
|
||||||
|
// () => import('#start/rules/allowed_extensions_mimetypes'),
|
||||||
|
await import('#start/rules/dependent_array_min_length');
|
||||||
|
await import('#start/rules/referenceValidation');
|
||||||
|
await import('#start/rules/valid_mimetype');
|
||||||
|
await import('#start/rules/array_contains_types');
|
||||||
|
await import('#start/rules/orcid');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ready() {
|
||||||
|
// App is ready
|
||||||
|
}
|
||||||
|
|
||||||
|
public async shutdown() {
|
||||||
|
// Cleanup, since app is going down
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,17 +6,16 @@
|
||||||
import type { ApplicationService } from '@adonisjs/core/types';
|
import type { ApplicationService } from '@adonisjs/core/types';
|
||||||
import vine, { symbols, BaseLiteralType, Vine } from '@vinejs/vine';
|
import vine, { symbols, BaseLiteralType, Vine } from '@vinejs/vine';
|
||||||
import type { FieldContext, FieldOptions } from '@vinejs/vine/types';
|
import type { FieldContext, FieldOptions } from '@vinejs/vine/types';
|
||||||
// import type { MultipartFile, FileValidationOptions } from '@adonisjs/bodyparser/types';
|
|
||||||
import type { MultipartFile } from '@adonisjs/core/bodyparser';
|
import type { MultipartFile } from '@adonisjs/core/bodyparser';
|
||||||
import type { FileValidationOptions } from '@adonisjs/core/types/bodyparser';
|
import type { FileValidationOptions } from '@adonisjs/core/types/bodyparser';
|
||||||
import { Request, RequestValidator } from '@adonisjs/core/http';
|
import { Request, RequestValidator } from '@adonisjs/core/http';
|
||||||
import MimeType from '#models/mime_type';
|
import MimeType from '#models/mime_type';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validation options accepted by the "file" rule
|
* Validation options accepted by the "file" rule
|
||||||
*/
|
*/
|
||||||
export type FileRuleValidationOptions = Partial<FileValidationOptions> | ((field: FieldContext) => Partial<FileValidationOptions>);
|
export type FileRuleValidationOptions = Partial<FileValidationOptions> | ((field: FieldContext) => Partial<FileValidationOptions>);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extend VineJS
|
* Extend VineJS
|
||||||
*/
|
*/
|
||||||
|
|
@ -25,6 +24,7 @@ declare module '@vinejs/vine' {
|
||||||
myfile(options?: FileRuleValidationOptions): VineMultipartFile;
|
myfile(options?: FileRuleValidationOptions): VineMultipartFile;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extend HTTP request class
|
* Extend HTTP request class
|
||||||
*/
|
*/
|
||||||
|
|
@ -36,19 +36,54 @@ declare module '@adonisjs/core/http' {
|
||||||
* Checks if the value is an instance of multipart file
|
* Checks if the value is an instance of multipart file
|
||||||
* from bodyparser.
|
* from bodyparser.
|
||||||
*/
|
*/
|
||||||
export function isBodyParserFile(file: MultipartFile | unknown): boolean {
|
export function isBodyParserFile(file: MultipartFile | unknown): file is MultipartFile {
|
||||||
return !!(file && typeof file === 'object' && 'isMultipartFile' in file);
|
return !!(file && typeof file === 'object' && 'isMultipartFile' in file);
|
||||||
}
|
}
|
||||||
export async function getEnabledExtensions() {
|
|
||||||
const enabledExtensions = await MimeType.query().select('file_extension').where('enabled', true).exec();
|
|
||||||
const extensions = enabledExtensions
|
|
||||||
.map((extension) => {
|
|
||||||
return extension.file_extension.split('|');
|
|
||||||
})
|
|
||||||
.flat();
|
|
||||||
|
|
||||||
return extensions;
|
/**
|
||||||
|
* Cache for enabled extensions to reduce database queries
|
||||||
|
*/
|
||||||
|
let extensionsCache: string[] | null = null;
|
||||||
|
let cacheTimestamp = 0;
|
||||||
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get enabled extensions with caching
|
||||||
|
*/
|
||||||
|
export async function getEnabledExtensions(): Promise<string[]> {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (extensionsCache && now - cacheTimestamp < CACHE_DURATION) {
|
||||||
|
return extensionsCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const enabledExtensions = await MimeType.query().select('file_extension').where('enabled', true).exec();
|
||||||
|
|
||||||
|
const extensions = enabledExtensions
|
||||||
|
.map((extension) => extension.file_extension.split('|'))
|
||||||
|
.flat()
|
||||||
|
.map((ext) => ext.toLowerCase().trim())
|
||||||
|
.filter((ext) => ext.length > 0);
|
||||||
|
|
||||||
|
extensionsCache = [...new Set(extensions)]; // Remove duplicates
|
||||||
|
cacheTimestamp = now;
|
||||||
|
|
||||||
|
return extensionsCache;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching enabled extensions:', error);
|
||||||
|
return extensionsCache || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear extensions cache
|
||||||
|
*/
|
||||||
|
export function clearExtensionsCache(): void {
|
||||||
|
extensionsCache = null;
|
||||||
|
cacheTimestamp = 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VineJS validation rule that validates the file to be an
|
* VineJS validation rule that validates the file to be an
|
||||||
* instance of BodyParser MultipartFile class.
|
* instance of BodyParser MultipartFile class.
|
||||||
|
|
@ -65,6 +100,7 @@ const isMultipartFile = vine.createRule(async (file: MultipartFile | unknown, op
|
||||||
// At this point, you can use type assertion to explicitly tell TypeScript that file is of type MultipartFile
|
// At this point, you can use type assertion to explicitly tell TypeScript that file is of type MultipartFile
|
||||||
const validatedFile = file as MultipartFile;
|
const validatedFile = file as MultipartFile;
|
||||||
const validationOptions = typeof options === 'function' ? options(field) : options;
|
const validationOptions = typeof options === 'function' ? options(field) : options;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set size when it's defined in the options and missing
|
* Set size when it's defined in the options and missing
|
||||||
* on the file instance
|
* on the file instance
|
||||||
|
|
@ -72,30 +108,29 @@ const isMultipartFile = vine.createRule(async (file: MultipartFile | unknown, op
|
||||||
if (validatedFile.sizeLimit === undefined && validationOptions.size) {
|
if (validatedFile.sizeLimit === undefined && validationOptions.size) {
|
||||||
validatedFile.sizeLimit = validationOptions.size;
|
validatedFile.sizeLimit = validationOptions.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set extensions when it's defined in the options and missing
|
* Set extensions when it's defined in the options and missing
|
||||||
* on the file instance
|
* on the file instance
|
||||||
*/
|
*/
|
||||||
// if (validatedFile.allowedExtensions === undefined && validationOptions.extnames) {
|
if (validatedFile.allowedExtensions === undefined) {
|
||||||
// validatedFile.allowedExtensions = validationOptions.extnames;
|
if (validationOptions.extnames !== undefined) {
|
||||||
// }
|
validatedFile.allowedExtensions = validationOptions.extnames;
|
||||||
if (validatedFile.allowedExtensions === undefined && validationOptions.extnames !== undefined) {
|
} else {
|
||||||
validatedFile.allowedExtensions = validationOptions.extnames; // await getEnabledExtensions();
|
|
||||||
} else if (validatedFile.allowedExtensions === undefined && validationOptions.extnames === undefined) {
|
|
||||||
validatedFile.allowedExtensions = await getEnabledExtensions();
|
validatedFile.allowedExtensions = await getEnabledExtensions();
|
||||||
}
|
}
|
||||||
/**
|
}
|
||||||
* wieder löschen
|
|
||||||
* Set extensions when it's defined in the options and missing
|
|
||||||
* on the file instance
|
|
||||||
*/
|
|
||||||
// if (file.clientNameSizeLimit === undefined && validationOptions.clientNameSizeLimit) {
|
|
||||||
// file.clientNameSizeLimit = validationOptions.clientNameSizeLimit;
|
|
||||||
// }
|
|
||||||
/**
|
/**
|
||||||
* Validate file
|
* Validate file
|
||||||
*/
|
*/
|
||||||
|
try {
|
||||||
validatedFile.validate();
|
validatedFile.validate();
|
||||||
|
} catch (error) {
|
||||||
|
field.report(`File validation failed: ${error.message}`, 'file.validation_error', field, validationOptions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Report errors
|
* Report errors
|
||||||
*/
|
*/
|
||||||
|
|
@ -107,36 +142,37 @@ const isMultipartFile = vine.createRule(async (file: MultipartFile | unknown, op
|
||||||
const MULTIPART_FILE: typeof symbols.SUBTYPE = symbols.SUBTYPE;
|
const MULTIPART_FILE: typeof symbols.SUBTYPE = symbols.SUBTYPE;
|
||||||
|
|
||||||
export class VineMultipartFile extends BaseLiteralType<MultipartFile, MultipartFile, MultipartFile> {
|
export class VineMultipartFile extends BaseLiteralType<MultipartFile, MultipartFile, MultipartFile> {
|
||||||
|
|
||||||
[MULTIPART_FILE]: string;
|
[MULTIPART_FILE]: string;
|
||||||
// constructor(validationOptions?: FileRuleValidationOptions, options?: FieldOptions) {
|
public validationOptions?: FileRuleValidationOptions;
|
||||||
// super(options, [isMultipartFile(validationOptions || {})]);
|
|
||||||
// this.validationOptions = validationOptions;
|
|
||||||
// this.#private = true;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// clone(): this {
|
|
||||||
// return new VineMultipartFile(this.validationOptions, this.cloneOptions()) as this;
|
|
||||||
// }
|
|
||||||
// #private;
|
|
||||||
// constructor(validationOptions?: FileRuleValidationOptions, options?: FieldOptions, validations?: Validation<any>[]);
|
|
||||||
// clone(): this;
|
|
||||||
|
|
||||||
public validationOptions;
|
|
||||||
// extnames: (18) ['gpkg', 'htm', 'html', 'csv', 'txt', 'asc', 'c', 'cc', 'h', 'srt', 'tiff', 'pdf', 'png', 'zip', 'jpg', 'jpeg', 'jpe', 'xlsx']
|
// extnames: (18) ['gpkg', 'htm', 'html', 'csv', 'txt', 'asc', 'c', 'cc', 'h', 'srt', 'tiff', 'pdf', 'png', 'zip', 'jpg', 'jpeg', 'jpe', 'xlsx']
|
||||||
// size: '512mb'
|
// size: '512mb'
|
||||||
|
|
||||||
// public constructor(validationOptions?: FileRuleValidationOptions, options?: FieldOptions, validations?: Validation<any>[]) {
|
|
||||||
public constructor(validationOptions?: FileRuleValidationOptions, options?: FieldOptions) {
|
public constructor(validationOptions?: FileRuleValidationOptions, options?: FieldOptions) {
|
||||||
// super(options, validations);
|
|
||||||
super(options, [isMultipartFile(validationOptions || {})]);
|
super(options, [isMultipartFile(validationOptions || {})]);
|
||||||
this.validationOptions = validationOptions;
|
this.validationOptions = validationOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public clone(): any {
|
public clone(): any {
|
||||||
// return new VineMultipartFile(this.validationOptions, this.cloneOptions(), this.cloneValidations());
|
|
||||||
return new VineMultipartFile(this.validationOptions, this.cloneOptions());
|
return new VineMultipartFile(this.validationOptions, this.cloneOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set maximum file size
|
||||||
|
*/
|
||||||
|
public maxSize(size: string | number): this {
|
||||||
|
const newOptions = { ...this.validationOptions, size };
|
||||||
|
return new VineMultipartFile(newOptions, this.cloneOptions()) as this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set allowed extensions
|
||||||
|
*/
|
||||||
|
public extensions(extnames: string[]): this {
|
||||||
|
const newOptions = { ...this.validationOptions, extnames };
|
||||||
|
return new VineMultipartFile(newOptions, this.cloneOptions()) as this;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class VinejsProvider {
|
export default class VinejsProvider {
|
||||||
|
|
@ -155,13 +191,8 @@ export default class VinejsProvider {
|
||||||
/**
|
/**
|
||||||
* The container bindings have booted
|
* The container bindings have booted
|
||||||
*/
|
*/
|
||||||
|
|
||||||
boot(): void {
|
boot(): void {
|
||||||
// VineString.macro('translatedLanguage', function (this: VineString, options: Options) {
|
Vine.macro('myfile', function (this: Vine, options?: FileRuleValidationOptions) {
|
||||||
// return this.use(translatedLanguageRule(options));
|
|
||||||
// });
|
|
||||||
|
|
||||||
Vine.macro('myfile', function (this: Vine, options) {
|
|
||||||
return new VineMultipartFile(options);
|
return new VineMultipartFile(options);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -175,6 +206,41 @@ export default class VinejsProvider {
|
||||||
}
|
}
|
||||||
return new RequestValidator(this.ctx).validateUsing(...args);
|
return new RequestValidator(this.ctx).validateUsing(...args);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ensure MIME validation macros are loaded
|
||||||
|
this.loadMimeValidationMacros();
|
||||||
|
this.loadFileScanMacros();
|
||||||
|
this.loadFileLengthMacros();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load MIME validation macros - called during boot to ensure they're available
|
||||||
|
*/
|
||||||
|
private async loadMimeValidationMacros(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Dynamically import the MIME validation rule to ensure macros are registered
|
||||||
|
await import('#start/rules/allowed_extensions_mimetypes');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not load MIME validation macros:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadFileScanMacros(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Dynamically import the MIME validation rule to ensure macros are registered
|
||||||
|
await import('#start/rules/file_scan');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not load MIME validation macros:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadFileLengthMacros(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Dynamically import the MIME validation rule to ensure macros are registered
|
||||||
|
await import('#start/rules/file_length');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not load MIME validation macros:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -190,5 +256,7 @@ export default class VinejsProvider {
|
||||||
/**
|
/**
|
||||||
* Preparing to shutdown the app
|
* Preparing to shutdown the app
|
||||||
*/
|
*/
|
||||||
async shutdown() {}
|
async shutdown() {
|
||||||
|
clearExtensionsCache();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -111,7 +111,14 @@
|
||||||
<!--5 server_date_modified -->
|
<!--5 server_date_modified -->
|
||||||
<xsl:if test="ServerDateModified/@UnixTimestamp != ''">
|
<xsl:if test="ServerDateModified/@UnixTimestamp != ''">
|
||||||
<xsl:text>"server_date_modified": "</xsl:text>
|
<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:text>",</xsl:text>
|
||||||
</xsl:if>
|
</xsl:if>
|
||||||
|
|
||||||
|
|
@ -200,7 +207,8 @@
|
||||||
|
|
||||||
<!--17 +18 uncontrolled subject (swd) -->
|
<!--17 +18 uncontrolled subject (swd) -->
|
||||||
<xsl:variable name="subjects">
|
<xsl:variable name="subjects">
|
||||||
<xsl:for-each select="Subject[@Type = 'Uncontrolled']">
|
<!-- <xsl:for-each select="Subject[@Type = 'Uncontrolled']"> -->
|
||||||
|
<xsl:for-each select="Subject[@Type = 'Uncontrolled' or @Type = 'Geoera']">
|
||||||
<xsl:text>"</xsl:text>
|
<xsl:text>"</xsl:text>
|
||||||
<xsl:value-of select="fn:escapeQuotes(@Value)"/>
|
<xsl:value-of select="fn:escapeQuotes(@Value)"/>
|
||||||
<xsl:text>"</xsl:text>
|
<xsl:text>"</xsl:text>
|
||||||
|
|
|
||||||
174
readme.md
174
readme.md
|
|
@ -11,6 +11,8 @@ Welcome to the Tethys Research Repository Backend System! This is the backend co
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [Database](#database)
|
- [Database](#database)
|
||||||
- [API Documentation](#api-documentation)
|
- [API Documentation](#api-documentation)
|
||||||
|
- [Commands](#commands)
|
||||||
|
- [Documentation](#documentation)
|
||||||
- [Contributing](#contributing)
|
- [Contributing](#contributing)
|
||||||
- [License](#license)
|
- [License](#license)
|
||||||
|
|
||||||
|
|
@ -29,5 +31,175 @@ Before you begin, ensure you have met the following requirements:
|
||||||
1. Clone this repository:
|
1. Clone this repository:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitea.geologie.ac.at/geolba/tethys.backend.git
|
git clone git clone https://gitea.geologie.ac.at/geolba/tethys.backend.git
|
||||||
|
cd tethys-backend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Configure environment variables (see [Configuration](#configuration))
|
||||||
|
|
||||||
|
4. Run database migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ace migration:run
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Start the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
The Tethys Backend provides RESTful APIs for managing research datasets, user authentication, DOI registration, and search functionality.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Copy the `.env.example` file to `.env` and configure the following variables:
|
||||||
|
|
||||||
|
### Database Configuration
|
||||||
|
```bash
|
||||||
|
DB_CONNECTION=pg
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=your_username
|
||||||
|
DB_PASSWORD=your_password
|
||||||
|
DB_DATABASE=tethys_db
|
||||||
|
```
|
||||||
|
|
||||||
|
### DataCite Configuration
|
||||||
|
```bash
|
||||||
|
# DataCite Credentials
|
||||||
|
DATACITE_USERNAME=your_datacite_username
|
||||||
|
DATACITE_PASSWORD=your_datacite_password
|
||||||
|
DATACITE_PREFIX=10.21388
|
||||||
|
|
||||||
|
# Environment-specific API endpoints
|
||||||
|
DATACITE_API_URL=https://api.test.datacite.org # Test environment
|
||||||
|
DATACITE_SERVICE_URL=https://mds.test.datacite.org # Test MDS
|
||||||
|
|
||||||
|
# For production:
|
||||||
|
# DATACITE_API_URL=https://api.datacite.org
|
||||||
|
# DATACITE_SERVICE_URL=https://mds.datacite.org
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenSearch Configuration
|
||||||
|
```bash
|
||||||
|
OPENSEARCH_HOST=localhost:9200
|
||||||
|
```
|
||||||
|
|
||||||
|
### Application Configuration
|
||||||
|
```bash
|
||||||
|
BASE_DOMAIN=tethys.at
|
||||||
|
APP_KEY=your_app_key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
The system uses PostgreSQL with Lucid ORM. Key models include:
|
||||||
|
|
||||||
|
- **Dataset**: Research dataset metadata
|
||||||
|
- **DatasetIdentifier**: DOI and other identifiers for datasets
|
||||||
|
- **User**: User management and authentication
|
||||||
|
- **XmlCache**: Cached XML metadata
|
||||||
|
|
||||||
|
Run migrations and seeders:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run migrations
|
||||||
|
node ace migration:run
|
||||||
|
|
||||||
|
# Run seeders (if available)
|
||||||
|
node ace db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
API endpoints are available for:
|
||||||
|
|
||||||
|
- Dataset management (`/api/datasets`)
|
||||||
|
- User authentication (`/api/auth`)
|
||||||
|
- DOI registration (`/api/doi`)
|
||||||
|
- Search functionality (`/api/search`)
|
||||||
|
|
||||||
|
*Detailed API documentation can be found in the `/docs/api` directory.*
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
The system includes several Ace commands for maintenance and data management:
|
||||||
|
|
||||||
|
### Dataset Indexing
|
||||||
|
```bash
|
||||||
|
# Index all published datasets to OpenSearch
|
||||||
|
node ace index:datasets
|
||||||
|
|
||||||
|
# Index a specific dataset
|
||||||
|
node ace index:datasets --publish_id 123
|
||||||
|
```
|
||||||
|
|
||||||
|
### DataCite DOI Management
|
||||||
|
```bash
|
||||||
|
# Update DataCite records for modified datasets
|
||||||
|
node ace update:datacite
|
||||||
|
|
||||||
|
# Show detailed statistics for datasets needing updates
|
||||||
|
node ace update:datacite --stats
|
||||||
|
|
||||||
|
# Preview what would be updated (dry run)
|
||||||
|
node ace update:datacite --dry-run
|
||||||
|
|
||||||
|
# Force update all DOI records
|
||||||
|
node ace update:datacite --force
|
||||||
|
|
||||||
|
# Update a specific dataset
|
||||||
|
node ace update:datacite --publish_id 123
|
||||||
|
```
|
||||||
|
|
||||||
|
*For detailed command documentation, see the [Commands Documentation](docs/commands/)*
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Comprehensive documentation is available in the `/docs` directory:
|
||||||
|
|
||||||
|
- **[Commands Documentation](docs/commands/)** - Detailed guides for Ace commands
|
||||||
|
- [DataCite Update Command](docs/commands/update-datacite.md) - DOI synchronization and management
|
||||||
|
- [Dataset Indexing Command](docs/commands/index-datasets.md) - Search index management
|
||||||
|
- **[API Documentation](docs/api/)** - REST API endpoints and usage
|
||||||
|
- **[Deployment Guide](docs/deployment/)** - Production deployment instructions
|
||||||
|
- **[Configuration Guide](docs/configuration/)** - Environment setup and configuration options
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
### Development Guidelines
|
||||||
|
|
||||||
|
- Follow the existing code style and conventions
|
||||||
|
- Write tests for new features
|
||||||
|
- Update documentation for any API changes
|
||||||
|
- Ensure all commands and migrations work properly
|
||||||
|
|
||||||
|
### Testing Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Test specific commands
|
||||||
|
node ace update:datacite --dry-run --publish_id 123
|
||||||
|
node ace index:datasets --publish_id 123
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the [MIT License](LICENSE).
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { computed, PropType } from 'vue';
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
// import { Link } from '@inertiajs/inertia-vue3';
|
// import { Link } from '@inertiajs/inertia-vue3';
|
||||||
import { getButtonColor } from '@/colors';
|
import { getButtonColor } from '@/colors';
|
||||||
|
|
@ -31,7 +31,7 @@ const props = defineProps({
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
color: {
|
color: {
|
||||||
type: String,
|
type: String as PropType<'white' | 'contrast' | 'light' | 'success' | 'danger' | 'warning' | 'info' | 'modern'>,
|
||||||
default: 'white',
|
default: 'white',
|
||||||
},
|
},
|
||||||
as: {
|
as: {
|
||||||
|
|
@ -45,11 +45,18 @@ const props = defineProps({
|
||||||
roundedFull: Boolean,
|
roundedFull: Boolean,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['click']);
|
||||||
|
|
||||||
const is = computed(() => {
|
const is = computed(() => {
|
||||||
if (props.as) {
|
if (props.as) {
|
||||||
return props.as;
|
return props.as;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If disabled, always render as button or span to prevent navigation
|
||||||
|
if (props.disabled) {
|
||||||
|
return props.routeName || props.href ? 'span' : 'button';
|
||||||
|
}
|
||||||
|
|
||||||
if (props.routeName) {
|
if (props.routeName) {
|
||||||
return Link;
|
return Link;
|
||||||
}
|
}
|
||||||
|
|
@ -69,47 +76,105 @@ const computedType = computed(() => {
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Only provide href/routeName when not disabled
|
||||||
|
const computedHref = computed(() => {
|
||||||
|
if (props.disabled) return null;
|
||||||
|
return props.routeName || props.href;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only provide target when not disabled and has href
|
||||||
|
const computedTarget = computed(() => {
|
||||||
|
if (props.disabled || !props.href) return null;
|
||||||
|
return props.target;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only provide disabled attribute for actual button elements
|
||||||
|
const computedDisabled = computed(() => {
|
||||||
|
if (is.value === 'button') {
|
||||||
|
return props.disabled;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
const labelClass = computed(() => (props.small && props.icon ? 'px-1' : 'px-2'));
|
const labelClass = computed(() => (props.small && props.icon ? 'px-1' : 'px-2'));
|
||||||
|
|
||||||
const componentClass = computed(() => {
|
const componentClass = computed(() => {
|
||||||
const base = [
|
const base = [
|
||||||
'inline-flex',
|
'inline-flex',
|
||||||
'cursor-pointer',
|
|
||||||
'justify-center',
|
'justify-center',
|
||||||
'items-center',
|
'items-center',
|
||||||
'whitespace-nowrap',
|
'whitespace-nowrap',
|
||||||
'focus:outline-none',
|
'focus:outline-none',
|
||||||
'transition-colors',
|
'transition-colors',
|
||||||
'focus:ring-2',
|
|
||||||
'duration-150',
|
'duration-150',
|
||||||
'border',
|
'border',
|
||||||
props.roundedFull ? 'rounded-full' : 'rounded',
|
props.roundedFull ? 'rounded-full' : 'rounded',
|
||||||
props.active ? 'ring ring-black dark:ring-white' : 'ring-blue-700',
|
|
||||||
getButtonColor(props.color, props.outline, !props.disabled),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Only add focus ring styles when not disabled
|
||||||
|
if (!props.disabled) {
|
||||||
|
base.push('focus:ring-2');
|
||||||
|
base.push(props.active ? 'ring ring-black dark:ring-white' : 'ring-blue-700');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add button colors
|
||||||
|
// Add button colors - handle both string and array returns
|
||||||
|
// const buttonColors = getButtonColor(props.color, props.outline, !props.disabled);
|
||||||
|
base.push(getButtonColor(props.color, props.outline, !props.disabled));
|
||||||
|
// if (Array.isArray(buttonColors)) {
|
||||||
|
// base.push(...buttonColors);
|
||||||
|
// } else {
|
||||||
|
// base.push(buttonColors);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Add size classes
|
||||||
if (props.small) {
|
if (props.small) {
|
||||||
base.push('text-sm', props.roundedFull ? 'px-3 py-1' : 'p-1');
|
base.push('text-sm', props.roundedFull ? 'px-3 py-1' : 'p-1');
|
||||||
} else {
|
} else {
|
||||||
base.push('py-2', props.roundedFull ? 'px-6' : 'px-3');
|
base.push('py-2', props.roundedFull ? 'px-6' : 'px-3');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add disabled/enabled specific classes
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
base.push('cursor-not-allowed', props.outline ? 'opacity-50' : 'opacity-70');
|
base.push(
|
||||||
|
'cursor-not-allowed',
|
||||||
|
'opacity-60',
|
||||||
|
'pointer-events-none', // This prevents all interactions
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
base.push('cursor-pointer');
|
||||||
|
// Add hover effects only when not disabled
|
||||||
|
if (is.value === 'button' || is.value === 'a' || is.value === Link) {
|
||||||
|
base.push('hover:opacity-80');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle click events with disabled check
|
||||||
|
const handleClick = (event) => {
|
||||||
|
if (props.disabled) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('click', event);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<component
|
<component
|
||||||
:is="is"
|
:is="is"
|
||||||
:class="componentClass"
|
:class="componentClass"
|
||||||
:href="routeName ? routeName : href"
|
:href="computedHref"
|
||||||
|
:to="props.disabled ? null : props.routeName"
|
||||||
:type="computedType"
|
:type="computedType"
|
||||||
:target="target"
|
:target="computedTarget"
|
||||||
:disabled="disabled"
|
:disabled="computedDisabled"
|
||||||
|
:tabindex="props.disabled ? -1 : null"
|
||||||
|
:aria-disabled="props.disabled ? 'true' : null"
|
||||||
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<BaseIcon v-if="icon" :path="icon" />
|
<BaseIcon v-if="icon" :path="icon" />
|
||||||
<span v-if="label" :class="labelClass">{{ label }}</span>
|
<span v-if="label" :class="labelClass">{{ label }}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, watch, ref } from 'vue';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -13,32 +13,138 @@ const props = defineProps<Props>();
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: 'update:modelValue', value: Props['modelValue']): void }>();
|
const emit = defineEmits<{ (e: 'update:modelValue', value: Props['modelValue']): void }>();
|
||||||
|
|
||||||
|
// const computedValue = computed({
|
||||||
|
// get: () => props.modelValue,
|
||||||
|
// set: (value) => {
|
||||||
|
// emit('update:modelValue', props.type === 'radio' ? [value] : value);
|
||||||
|
// },
|
||||||
|
// });
|
||||||
const computedValue = computed({
|
const computedValue = computed({
|
||||||
get: () => props.modelValue,
|
get: () => {
|
||||||
set: (value) => {
|
if (props.type === 'radio') {
|
||||||
emit('update:modelValue', props.type === 'radio' ? [value] : value);
|
// For radio buttons, return boolean indicating if this option is selected
|
||||||
|
if (Array.isArray(props.modelValue)) {
|
||||||
|
return props.modelValue;
|
||||||
|
}
|
||||||
|
return [props.modelValue];
|
||||||
|
} else {
|
||||||
|
// For checkboxes, return boolean indicating if this option is included
|
||||||
|
if (Array.isArray(props.modelValue)) {
|
||||||
|
return props.modelValue.includes(props.inputValue);
|
||||||
|
}
|
||||||
|
return props.modelValue == props.inputValue;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
set: (value: boolean) => {
|
||||||
|
if (props.type === 'radio') {
|
||||||
|
// When radio is selected, emit the new value as array
|
||||||
|
emit('update:modelValue', [value]);
|
||||||
|
} else {
|
||||||
|
// Handle checkboxes
|
||||||
|
let updatedValue = Array.isArray(props.modelValue) ? [...props.modelValue] : [];
|
||||||
|
if (value) {
|
||||||
|
if (!updatedValue.includes(props.inputValue)) {
|
||||||
|
updatedValue.push(props.inputValue);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updatedValue = updatedValue.filter(item => item != props.inputValue);
|
||||||
|
}
|
||||||
|
emit('update:modelValue', updatedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const inputType = computed(() => (props.type === 'radio' ? 'radio' : 'checkbox'));
|
const inputType = computed(() => (props.type === 'radio' ? 'radio' : 'checkbox'));
|
||||||
|
|
||||||
// Define isChecked for radio inputs: it's true when the current modelValue equals the inputValue
|
// Define isChecked for radio inputs: it's true when the current modelValue equals the inputValue
|
||||||
const isChecked = computed(() => {
|
// const isChecked = computed(() => {
|
||||||
if (Array.isArray(computedValue.value) && computedValue.value.length > 0) {
|
// if (Array.isArray(computedValue.value) && computedValue.value.length > 0) {
|
||||||
return props.type === 'radio'
|
// return props.type === 'radio'
|
||||||
? computedValue.value[0] === props.inputValue
|
// ? computedValue.value[0] === props.inputValue
|
||||||
: computedValue.value.includes(props.inputValue);
|
// : computedValue.value.includes(props.inputValue);
|
||||||
|
// }
|
||||||
|
// return computedValue.value === props.inputValue;
|
||||||
|
// });
|
||||||
|
// const isChecked = computed(() => {
|
||||||
|
// return computedValue.value[0] === props.inputValue;
|
||||||
|
// });
|
||||||
|
// Fix the isChecked computation with proper type handling
|
||||||
|
// const isChecked = computed(() => {
|
||||||
|
// if (props.type === 'radio') {
|
||||||
|
// // Use loose equality to handle string/number conversion
|
||||||
|
// return computedValue.value == props.inputValue;
|
||||||
|
// }
|
||||||
|
// return computedValue.value === true;
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const isChecked = computed(() => {
|
||||||
|
// if (props.type === 'radio') {
|
||||||
|
// if (Array.isArray(props.modelValue)) {
|
||||||
|
// return props.modelValue.length > 0 && props.modelValue[0] == props.inputValue;
|
||||||
|
// }
|
||||||
|
// return props.modelValue == props.inputValue;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // For checkboxes
|
||||||
|
// if (Array.isArray(props.modelValue)) {
|
||||||
|
// return props.modelValue.includes(props.inputValue);
|
||||||
|
// }
|
||||||
|
// return props.modelValue == props.inputValue;
|
||||||
|
// });
|
||||||
|
// Use a ref for isChecked and update it with a watcher
|
||||||
|
const isChecked = ref(false);
|
||||||
|
// Calculate initial isChecked value
|
||||||
|
const calculateIsChecked = () => {
|
||||||
|
if (props.type === 'radio') {
|
||||||
|
if (Array.isArray(props.modelValue)) {
|
||||||
|
return props.modelValue.length > 0 && props.modelValue[0] == props.inputValue;
|
||||||
}
|
}
|
||||||
return computedValue.value === props.inputValue;
|
return props.modelValue == props.inputValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For checkboxes
|
||||||
|
if (Array.isArray(props.modelValue)) {
|
||||||
|
return props.modelValue.includes(props.inputValue);
|
||||||
|
}
|
||||||
|
return props.modelValue == props.inputValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
|
isChecked.value = calculateIsChecked();
|
||||||
|
|
||||||
|
// Watch for changes in modelValue and recalculate isChecked
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newValue) => {
|
||||||
|
console.log('modelValue changed:', {
|
||||||
|
newValue,
|
||||||
|
inputValue: props.inputValue,
|
||||||
|
type: props.type
|
||||||
});
|
});
|
||||||
|
isChecked.value = calculateIsChecked();
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also watch inputValue in case it changes
|
||||||
|
watch(
|
||||||
|
() => props.inputValue,
|
||||||
|
() => {
|
||||||
|
isChecked.value = calculateIsChecked();
|
||||||
|
}
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<label v-if="type === 'radio'" :class="[type]"
|
<label v-if="type === 'radio'" :class="[type]"
|
||||||
class="mr-6 mb-3 last:mr-0 inline-flex items-center cursor-pointer relative">
|
class="mr-6 mb-3 last:mr-0 inline-flex items-center cursor-pointer relative">
|
||||||
<input v-model="computedValue" :type="inputType" :name="name" :value="inputValue"
|
<input
|
||||||
class="absolute left-0 opacity-0 -z-1 focus:outline-none focus:ring-0"
|
v-model="computedValue"
|
||||||
:checked="isChecked" />
|
:type="inputType"
|
||||||
|
:name="name"
|
||||||
|
:value="inputValue"
|
||||||
|
class="absolute left-0 opacity-0 -z-1 focus:outline-none focus:ring-0" />
|
||||||
<span class="check border transition-colors duration-200 dark:bg-slate-800 block w-5 h-5 rounded-full" :class="{
|
<span class="check border transition-colors duration-200 dark:bg-slate-800 block w-5 h-5 rounded-full" :class="{
|
||||||
'border-gray-700': !isChecked,
|
'border-gray-700': !isChecked,
|
||||||
'bg-radio-checked bg-no-repeat bg-center bg-lime-600 border-lime-600 border-4': isChecked
|
'bg-radio-checked bg-no-repeat bg-center bg-lime-600 border-lime-600 border-4': isChecked
|
||||||
|
|
|
||||||
|
|
@ -38,32 +38,82 @@ const props = defineProps({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const emit = defineEmits(['update:modelValue']);
|
const emit = defineEmits(['update:modelValue']);
|
||||||
const computedValue = computed({
|
// const computedValue = computed({
|
||||||
// get: () => props.modelValue,
|
// // get: () => props.modelValue,
|
||||||
get: () => {
|
// get: () => {
|
||||||
|
// // const ids = props.modelValue.map((obj) => obj.id);
|
||||||
|
// // return ids;
|
||||||
|
// if (Array.isArray(props.modelValue)) {
|
||||||
|
// if (props.modelValue.every((item) => typeof item === 'number')) {
|
||||||
|
// return props.modelValue;
|
||||||
|
// } else if (props.modelValue.every((item) => hasIdAttribute(item))) {
|
||||||
// const ids = props.modelValue.map((obj) => obj.id);
|
// const ids = props.modelValue.map((obj) => obj.id);
|
||||||
// return ids;
|
// return ids;
|
||||||
if (Array.isArray(props.modelValue)) {
|
// }
|
||||||
if (props.modelValue.every((item) => typeof item === 'number')) {
|
|
||||||
return props.modelValue;
|
|
||||||
} else if (props.modelValue.every((item) => hasIdAttribute(item))) {
|
|
||||||
const ids = props.modelValue.map((obj) => obj.id);
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
return props.modelValue;
|
|
||||||
}
|
|
||||||
// return props.modelValue;
|
// return props.modelValue;
|
||||||
},
|
// }
|
||||||
set: (value) => {
|
// // return props.modelValue;
|
||||||
emit('update:modelValue', value);
|
// },
|
||||||
},
|
// set: (value) => {
|
||||||
});
|
// emit('update:modelValue', value);
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
// Define a type guard to check if an object has an 'id' attribute
|
// Define a type guard to check if an object has an 'id' attribute
|
||||||
// function hasIdAttribute(obj: any): obj is { id: any } {
|
// function hasIdAttribute(obj: any): obj is { id: any } {
|
||||||
// return typeof obj === 'object' && 'id' in obj;
|
// return typeof obj === 'object' && 'id' in obj;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
const computedValue = computed({
|
||||||
|
get: () => {
|
||||||
|
if (!props.modelValue) return props.modelValue;
|
||||||
|
|
||||||
|
if (Array.isArray(props.modelValue)) {
|
||||||
|
// Handle empty array
|
||||||
|
if (props.modelValue.length === 0) return [];
|
||||||
|
|
||||||
|
// If all items are objects with id property
|
||||||
|
if (props.modelValue.every((item) => hasIdAttribute(item))) {
|
||||||
|
return props.modelValue.map((obj) => {
|
||||||
|
// Ensure we return the correct type based on the options keys
|
||||||
|
const id = obj.id;
|
||||||
|
// Check if options keys are numbers or strings
|
||||||
|
const optionKeys = Object.keys(props.options);
|
||||||
|
if (optionKeys.length > 0) {
|
||||||
|
// If option keys are numeric strings, return number
|
||||||
|
if (optionKeys.every(key => !isNaN(Number(key)))) {
|
||||||
|
return Number(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all items are numbers
|
||||||
|
if (props.modelValue.every((item) => typeof item === 'number')) {
|
||||||
|
return props.modelValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all items are strings that represent numbers
|
||||||
|
if (props.modelValue.every((item) => typeof item === 'string' && !isNaN(Number(item)))) {
|
||||||
|
// Convert to numbers if options keys are numeric
|
||||||
|
const optionKeys = Object.keys(props.options);
|
||||||
|
if (optionKeys.length > 0 && optionKeys.every(key => !isNaN(Number(key)))) {
|
||||||
|
return props.modelValue.map(item => Number(item));
|
||||||
|
}
|
||||||
|
return props.modelValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return as-is for other cases
|
||||||
|
return props.modelValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.modelValue;
|
||||||
|
},
|
||||||
|
set: (value) => {
|
||||||
|
emit('update:modelValue', value);
|
||||||
|
},
|
||||||
|
});
|
||||||
const hasIdAttribute = (obj: any): obj is { id: any } => {
|
const hasIdAttribute = (obj: any): obj is { id: any } => {
|
||||||
return typeof obj === 'object' && 'id' in obj;
|
return typeof obj === 'object' && 'id' in obj;
|
||||||
};
|
};
|
||||||
|
|
@ -110,7 +160,7 @@ const inputElClass = computed(() => {
|
||||||
</div>
|
</div>
|
||||||
<!-- <FormCheckRadio v-for="(value, key) in options" :key="key" v-model="computedValue" :type="type"
|
<!-- <FormCheckRadio v-for="(value, key) in options" :key="key" v-model="computedValue" :type="type"
|
||||||
:name="name" :input-value="key" :label="value" :class="componentClass" /> -->
|
:name="name" :input-value="key" :label="value" :class="componentClass" /> -->
|
||||||
<FormCheckRadio v-for="(value, key) in options" :key="key" v-model="computedValue" :type="type"
|
<FormCheckRadio v-for="(value, key) in options" key="`${name}-${key}-${JSON.stringify(computedValue)}`" v-model="computedValue" :type="type"
|
||||||
:name="name" :input-value="isNaN(Number(key)) ? key : Number(key)" :label="value" :class="componentClass" />
|
:name="name" :input-value="isNaN(Number(key)) ? key : Number(key)" :label="value" :class="componentClass" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import type { LatLngBoundsExpression } from 'leaflet/src/geo/LatLngBounds';
|
// import type { LatLngBoundsExpression } from 'leaflet/src/geo/LatLngBounds';
|
||||||
import type { LatLngExpression } from 'leaflet/src/geo/LatLng';
|
// import type { LatLngExpression } from 'leaflet/src/geo/LatLng';
|
||||||
import type { Layer } from 'leaflet/src/layer/Layer';
|
// import type { Layer } from 'leaflet/src/layer/Layer';
|
||||||
import type { CRS } from 'leaflet/src/geo/crs/CRS';
|
// import type { CRS } from 'leaflet/src/geo/crs/CRS';
|
||||||
|
import type { LatLngBoundsExpression } from 'leaflet';
|
||||||
|
import type { LatLngExpression } from 'leaflet';
|
||||||
|
import type { Layer } from 'leaflet';
|
||||||
|
import type { CRS } from 'leaflet';
|
||||||
|
|
||||||
export interface MapOptions {
|
export interface MapOptions {
|
||||||
preferCanvas?: boolean | undefined;
|
preferCanvas?: boolean | undefined;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { svg } from 'leaflet/src/layer/vector/SVG';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { LatLngBoundsExpression } from 'leaflet/src/geo/LatLngBounds';
|
import { LatLngBoundsExpression } from 'leaflet/src/geo/LatLngBounds';
|
||||||
import { tileLayerWMS } from 'leaflet/src/layer/tile/TileLayer.WMS';
|
import { tileLayerWMS } from 'leaflet/src/layer/tile/TileLayer.WMS';
|
||||||
// import { TileLayer } from 'leaflet/src/layer/tile/TileLayer';
|
|
||||||
import { Attribution } from 'leaflet/src/control/Control.Attribution';
|
import { Attribution } from 'leaflet/src/control/Control.Attribution';
|
||||||
import DrawControlComponent from '@/Components/Map/draw.component.vue';
|
import DrawControlComponent from '@/Components/Map/draw.component.vue';
|
||||||
import ZoomControlComponent from '@/Components/Map/zoom.component.vue';
|
import ZoomControlComponent from '@/Components/Map/zoom.component.vue';
|
||||||
|
|
@ -17,14 +16,7 @@ import { LayerGroup } from 'leaflet/src/layer/LayerGroup';
|
||||||
import { OpensearchDocument } from '@/Dataset';
|
import { OpensearchDocument } from '@/Dataset';
|
||||||
|
|
||||||
Map.include({
|
Map.include({
|
||||||
// @namespace Map; @method getRenderer(layer: Path): Renderer
|
|
||||||
// Returns the instance of `Renderer` that should be used to render the given
|
|
||||||
// `Path`. It will ensure that the `renderer` options of the map and paths
|
|
||||||
// are respected, and that the renderers do exist on the map.
|
|
||||||
getRenderer: function (layer) {
|
getRenderer: function (layer) {
|
||||||
// @namespace Path; @option renderer: Renderer
|
|
||||||
// Use this specific instance of `Renderer` for this path. Takes
|
|
||||||
// precedence over the map's [default renderer](#map-renderer).
|
|
||||||
var renderer = layer.options.renderer || this._getPaneRenderer(layer.options.pane) || this.options.renderer || this._renderer;
|
var renderer = layer.options.renderer || this._getPaneRenderer(layer.options.pane) || this.options.renderer || this._renderer;
|
||||||
|
|
||||||
if (!renderer) {
|
if (!renderer) {
|
||||||
|
|
@ -51,21 +43,18 @@ Map.include({
|
||||||
},
|
},
|
||||||
|
|
||||||
_createRenderer: function (options) {
|
_createRenderer: function (options) {
|
||||||
// @namespace Map; @option preferCanvas: Boolean = false
|
|
||||||
// Whether `Path`s should be rendered on a `Canvas` renderer.
|
|
||||||
// By default, all `Path`s are rendered in a `SVG` renderer.
|
|
||||||
return (this.options.preferCanvas && canvas(options)) || svg(options);
|
return (this.options.preferCanvas && canvas(options)) || svg(options);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const DEFAULT_BASE_LAYER_NAME = 'BaseLayer';
|
const DEFAULT_BASE_LAYER_NAME = 'BaseLayer';
|
||||||
const DEFAULT_BASE_LAYER_ATTRIBUTION = '© <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors';
|
const DEFAULT_BASE_LAYER_ATTRIBUTION = '© <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors';
|
||||||
const OPENSEARCH_HOST = 'https://catalog.geosphere.at';
|
const OPENSEARCH_HOST = 'https://catalog.geosphere.at';
|
||||||
// const OPENSEARCH_HOST = `${process.env.OPENSEARCH_HOST}`;
|
|
||||||
// const OPENSEARCH_HOST = `http://${process.env.OPENSEARCH_PUBLIC_HOST}`;
|
|
||||||
let map: Map;
|
let map: Map;
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
dheckable: Boolean,
|
checkable: Boolean,
|
||||||
datasets: {
|
datasets: {
|
||||||
type: Array<OpensearchDocument>,
|
type: Array<OpensearchDocument>,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
|
|
@ -89,10 +78,7 @@ const items = computed({
|
||||||
get() {
|
get() {
|
||||||
return props.datasets;
|
return props.datasets;
|
||||||
},
|
},
|
||||||
// setter
|
|
||||||
set(value) {
|
set(value) {
|
||||||
// Note: we are using destructuring assignment syntax here.
|
|
||||||
|
|
||||||
props.datasets.length = 0;
|
props.datasets.length = 0;
|
||||||
props.datasets.push(...value);
|
props.datasets.push(...value);
|
||||||
},
|
},
|
||||||
|
|
@ -103,15 +89,13 @@ const fitBounds: LatLngBoundsExpression = [
|
||||||
[49.0390742051, 16.9796667823],
|
[49.0390742051, 16.9796667823],
|
||||||
];
|
];
|
||||||
|
|
||||||
// const mapId = 'map';
|
|
||||||
const drawControl: Ref<DrawControlComponent | null> = ref(null);
|
const drawControl: Ref<DrawControlComponent | null> = ref(null);
|
||||||
const southWest = ref(null);
|
const southWest = ref(null);
|
||||||
const northEast = ref(null);
|
const northEast = ref(null);
|
||||||
const mapService = MapService();
|
const mapService = MapService();
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
const filterLayerGroup = new LayerGroup();
|
const filterLayerGroup = new LayerGroup();
|
||||||
// Replace with your actual data
|
|
||||||
// const datasets: Ref<OpensearchDocument[]> = ref([]);
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initMap();
|
initMap();
|
||||||
|
|
@ -122,7 +106,6 @@ onUnmounted(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const initMap = async () => {
|
const initMap = async () => {
|
||||||
// init leaflet map
|
|
||||||
map = new Map('map', props.mapOptions);
|
map = new Map('map', props.mapOptions);
|
||||||
mapService.setMap(props.mapId, map);
|
mapService.setMap(props.mapId, map);
|
||||||
map.scrollWheelZoom.disable();
|
map.scrollWheelZoom.disable();
|
||||||
|
|
@ -140,11 +123,6 @@ const initMap = async () => {
|
||||||
layers: 'OSM-WMS',
|
layers: 'OSM-WMS',
|
||||||
});
|
});
|
||||||
|
|
||||||
// let baseAt = new TileLayer('https://{s}.wien.gv.at/basemap/bmapgrau/normal/google3857/{z}/{y}/{x}.png', {
|
|
||||||
// subdomains: ['maps', 'maps1', 'maps2', 'maps3', 'maps4'],
|
|
||||||
// attribution: DEFAULT_BASE_LAYER_ATTRIBUTION,
|
|
||||||
// });
|
|
||||||
|
|
||||||
let layerOptions = {
|
let layerOptions = {
|
||||||
label: DEFAULT_BASE_LAYER_NAME,
|
label: DEFAULT_BASE_LAYER_NAME,
|
||||||
visible: true,
|
visible: true,
|
||||||
|
|
@ -153,62 +131,15 @@ const initMap = async () => {
|
||||||
layerOptions.layer.addTo(map);
|
layerOptions.layer.addTo(map);
|
||||||
|
|
||||||
map.on('Draw.Event.CREATED', handleDrawEventCreated);
|
map.on('Draw.Event.CREATED', handleDrawEventCreated);
|
||||||
|
|
||||||
// // const query = {
|
|
||||||
// // query: {
|
|
||||||
// // term: {
|
|
||||||
// // id: "103"
|
|
||||||
// // }
|
|
||||||
// // }
|
|
||||||
// // };
|
|
||||||
// // to do : call extra method:
|
|
||||||
// const query = {
|
|
||||||
// // q: 'id:103'
|
|
||||||
// // q: 'author:"Iglseder, Christoph" OR title:"Datensatz"',
|
|
||||||
// // q: 'author:"Iglseder"',
|
|
||||||
// q: '*',
|
|
||||||
// _source: 'author,bbox_xmin,bbox_xmax,bbox_ymin,bbox_ymax,abstract,title',
|
|
||||||
// size: 1000
|
|
||||||
// // qf:"title^3 author^2 subject^1",
|
|
||||||
// }
|
|
||||||
// try {
|
|
||||||
// let response = await axios({
|
|
||||||
// method: 'GET',
|
|
||||||
// url: OPEN_SEARCH_HOST + '/tethys-records/_search',
|
|
||||||
// headers: { 'Content-Type': 'application/json' },
|
|
||||||
// params: query
|
|
||||||
// });
|
|
||||||
// // Loop through the hits in the response
|
|
||||||
// response.data.hits.hits.forEach(hit => {
|
|
||||||
// // Get the geo_location attribute
|
|
||||||
// // var geo_location = hit._source.geo_location;
|
|
||||||
// let xMin = hit._source.bbox_xmin;
|
|
||||||
// let xMax = hit._source.bbox_xmax;
|
|
||||||
// let yMin = hit._source.bbox_ymin;
|
|
||||||
// let yMax = hit._source.bbox_ymax;
|
|
||||||
// var bbox: LatLngBoundsExpression = [[yMin, xMin], [yMax, xMax]];
|
|
||||||
// // Parse the WKT string to get the bounding box coordinates
|
|
||||||
// // var bbox = wktToBbox(geo_location);
|
|
||||||
|
|
||||||
// // // Add the bounding box to the map as a rectangle
|
|
||||||
// new Rectangle(bbox, { color: "#ff7800", weight: 1 }).addTo(map);
|
|
||||||
// // console.log(hit._source);
|
|
||||||
// });
|
|
||||||
// } catch (error) {
|
|
||||||
// console.error(error);
|
|
||||||
// }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrawEventCreated = async (event) => {
|
const handleDrawEventCreated = async (event) => {
|
||||||
|
isLoading.value = true;
|
||||||
filterLayerGroup.clearLayers();
|
filterLayerGroup.clearLayers();
|
||||||
items.value = [];
|
items.value = [];
|
||||||
|
|
||||||
let layer = event.layer;
|
let layer = event.layer;
|
||||||
let bounds = layer.getBounds();
|
let bounds = layer.getBounds();
|
||||||
// coverage.x_min = bounds.getSouthWest().lng;
|
|
||||||
// coverage.y_min = bounds.getSouthWest().lat;
|
|
||||||
// coverage.x_max = bounds.getNorthEast().lng;
|
|
||||||
// coverage.y_max = bounds.getNorthEast().lat;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let response = await axios({
|
let response = await axios({
|
||||||
|
|
@ -225,7 +156,6 @@ const handleDrawEventCreated = async (event) => {
|
||||||
filter: {
|
filter: {
|
||||||
geo_shape: {
|
geo_shape: {
|
||||||
geo_location: {
|
geo_location: {
|
||||||
// replace 'location' with your geo-point field name
|
|
||||||
shape: {
|
shape: {
|
||||||
type: 'envelope',
|
type: 'envelope',
|
||||||
coordinates: [
|
coordinates: [
|
||||||
|
|
@ -237,16 +167,12 @@ const handleDrawEventCreated = async (event) => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// _source: 'author,bbox_xmin,bbox_xmax,bbox_ymin,bbox_ymax,abstract,title',
|
|
||||||
// "size": 1000
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Loop through the hits in the response
|
|
||||||
response.data.hits.hits.forEach((hit) => {
|
response.data.hits.hits.forEach((hit) => {
|
||||||
// Get the geo_location attribute
|
|
||||||
// var geo_location = hit._source.geo_location;
|
|
||||||
let xMin = hit._source.bbox_xmin;
|
let xMin = hit._source.bbox_xmin;
|
||||||
let xMax = hit._source.bbox_xmax;
|
let xMax = hit._source.bbox_xmax;
|
||||||
let yMin = hit._source.bbox_ymin;
|
let yMin = hit._source.bbox_ymin;
|
||||||
|
|
@ -255,46 +181,255 @@ const handleDrawEventCreated = async (event) => {
|
||||||
[yMin, xMin],
|
[yMin, xMin],
|
||||||
[yMax, xMax],
|
[yMax, xMax],
|
||||||
];
|
];
|
||||||
// Parse the WKT string to get the bounding box coordinates
|
|
||||||
// var bbox = wktToBbox(geo_location);
|
|
||||||
|
|
||||||
// // Add the bounding box to the map as a rectangle
|
let rect = new Rectangle(bbox, {
|
||||||
let rect = new Rectangle(bbox, { color: '#ff7800', weight: 1 });
|
color: '#65DC21',
|
||||||
|
weight: 2,
|
||||||
|
fillColor: '#65DC21',
|
||||||
|
fillOpacity: 0.2,
|
||||||
|
className: 'animated-rectangle',
|
||||||
|
});
|
||||||
filterLayerGroup.addLayer(rect);
|
filterLayerGroup.addLayer(rect);
|
||||||
// add to result list
|
|
||||||
items.value.push(hit._source);
|
items.value.push(hit._source);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<div id="map" class="map-container mt-6 mb-6 rounded-2xl py-12 px-6 text-center dark:bg-slate-900 bg-white">
|
<div class="map-container-wrapper">
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<div v-if="isLoading" class="loading-overlay">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<p class="loading-text">Searching datasets...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map Instructions Banner -->
|
||||||
|
<div class="map-instructions">
|
||||||
|
<svg class="instruction-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 16v-4M12 8h.01" />
|
||||||
|
</svg>
|
||||||
|
<p class="instruction-text">
|
||||||
|
<strong>Tip:</strong> Use the drawing tool to select an area on the map and discover datasets
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="map" class="map-container">
|
||||||
<ZoomControlComponent ref="zoomControl" :mapId="mapId"></ZoomControlComponent>
|
<ZoomControlComponent ref="zoomControl" :mapId="mapId"></ZoomControlComponent>
|
||||||
<DrawControlComponent ref="drawControl" :preserve="false" :mapId="mapId" :southWest="southWest"
|
<DrawControlComponent ref="drawControl" :preserve="false" :mapId="mapId" :southWest="southWest" :northEast="northEast">
|
||||||
:northEast="northEast">
|
|
||||||
</DrawControlComponent>
|
</DrawControlComponent>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
<style scoped lang="css">
|
.map-container-wrapper {
|
||||||
/* .leaflet-container {
|
position: relative;
|
||||||
height: 600px;
|
border-radius: 1rem;
|
||||||
width: 100%;
|
overflow: hidden;
|
||||||
background-color: transparent;
|
background: white;
|
||||||
outline-offset: 1px;
|
box-shadow:
|
||||||
} */
|
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
.leaflet-container {
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
height: 600px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-container .leaflet-pane {
|
.dark .map-container-wrapper {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map Instructions Banner */
|
||||||
|
.map-instructions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: linear-gradient(135deg, rgba(101, 220, 33, 0.1) 0%, rgba(53, 124, 6, 0.1) 100%);
|
||||||
|
border-bottom: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .map-instructions {
|
||||||
|
background: linear-gradient(135deg, rgba(101, 220, 33, 0.2) 0%, rgba(53, 124, 6, 0.2) 100%);
|
||||||
|
border-bottom-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-icon {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
color: #65dc21;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #4b5563;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .instruction-text {
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-text strong {
|
||||||
|
color: #65dc21;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Overlay */
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .loading-overlay {
|
||||||
|
background: rgba(31, 41, 55, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border: 4px solid #e5e7eb;
|
||||||
|
border-top-color: #65dc21;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .loading-spinner {
|
||||||
|
border-color: #374151;
|
||||||
|
border-top-color: #65dc21;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #65dc21;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map Container */
|
||||||
|
.map-container {
|
||||||
|
position: relative;
|
||||||
|
height: 600px;
|
||||||
|
width: 100%;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .map-container {
|
||||||
|
background: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leaflet Overrides */
|
||||||
|
:deep(.leaflet-container) {
|
||||||
|
height: 600px;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.leaflet-container .leaflet-pane) {
|
||||||
z-index: 30 !important;
|
z-index: 30 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Enhanced Rectangle Styling */
|
||||||
|
:deep(.animated-rectangle) {
|
||||||
|
animation: pulseRectangle 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseRectangle {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Control Enhancements */
|
||||||
|
:deep(.leaflet-control) {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.leaflet-bar a) {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.leaflet-bar a:hover) {
|
||||||
|
background: #65dc21;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.leaflet-draw-toolbar a) {
|
||||||
|
background: white;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark :deep(.leaflet-draw-toolbar a) {
|
||||||
|
background: #374151;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.leaflet-draw-toolbar a:hover) {
|
||||||
|
background: #65dc21;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Popup Enhancements */
|
||||||
|
:deep(.leaflet-popup-content-wrapper) {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.leaflet-popup-tip) {
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.map-container {
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-instructions {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-text {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.map-container {
|
||||||
|
height: 350px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,14 +1,29 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="drawControl" class="gba-control-draw btn-group-vertical map-control">
|
<div class="draw-control-container">
|
||||||
<!-- <button type="button" class="button is-light is-small" (click)="locateUser()" [ngClass]="isToggled ? 'is-primary': 'is-active'">
|
<button
|
||||||
<fa-icon [icon]="faSearchLocation"></fa-icon>
|
ref="drawButton"
|
||||||
</button> -->
|
class="draw-button"
|
||||||
<!-- -->
|
:class="{ 'is-active': enabled }"
|
||||||
<button ref="inputDraw"
|
type="button"
|
||||||
class="inline-flex cursor-pointer justify-center items-center whitespace-nowrap focus:outline-none transition-colors duration-150 border rounded ring-blue-700 text-black border-teal-50 hover:bg-gray-200 text-sm p-1"
|
@click.stop.prevent="toggleDraw"
|
||||||
type="button" :class="[_enabled ? 'cursor-not-allowed bg-cyan-200' : 'bg-teal-50 is-active']"
|
:aria-label="enabled ? 'Stop drawing' : 'Start drawing'"
|
||||||
@click.prevent="toggleDraw">
|
:aria-pressed="enabled"
|
||||||
<BaseIcon v-if="mdiDrawPen" :path="mdiDrawPen" />
|
>
|
||||||
|
<!-- Icon changes based on state -->
|
||||||
|
<!-- <BaseIcon
|
||||||
|
v-if="enabled"
|
||||||
|
:path="mdiClose"
|
||||||
|
:size="20"
|
||||||
|
/> -->
|
||||||
|
<BaseIcon
|
||||||
|
:path="mdiVectorRectangle"
|
||||||
|
:size="20"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Status indicator -->
|
||||||
|
<!-- <span class="draw-status-badge" :class="{ 'is-active': enabled }">
|
||||||
|
{{ enabled ? 'Active' : 'Draw' }}
|
||||||
|
</span> -->
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -17,16 +32,14 @@
|
||||||
import { Component, Vue, Prop } from 'vue-facing-decorator';
|
import { Component, Vue, Prop } from 'vue-facing-decorator';
|
||||||
|
|
||||||
import BaseIcon from '@/Components/BaseIcon.vue';
|
import BaseIcon from '@/Components/BaseIcon.vue';
|
||||||
import { mdiDrawPen } from '@mdi/js';
|
import { mdiVectorRectangle, mdiClose } from '@mdi/js';
|
||||||
import { MapService } from '@/Stores/map.service';
|
import { MapService } from '@/Stores/map.service';
|
||||||
import { Map } from 'leaflet/src/map/index';
|
import { Map } from 'leaflet';
|
||||||
// import { LayerGroup } from 'leaflet/src/layer/LayerGroup';
|
|
||||||
// import { LatLngBounds, Rectangle } from 'leaflet';
|
|
||||||
import { on, off, preventDefault } from 'leaflet/src/dom/DomEvent';
|
import { on, off, preventDefault } from 'leaflet/src/dom/DomEvent';
|
||||||
import { Rectangle } from 'leaflet/src/layer/vector/Rectangle';
|
|
||||||
import { LatLngBounds } from 'leaflet/src/geo/LatLngBounds';
|
import { Rectangle } from 'leaflet';
|
||||||
|
import { LatLngBounds } from 'leaflet';
|
||||||
import { LatLng } from 'leaflet';
|
import { LatLng } from 'leaflet';
|
||||||
import { LeafletMouseEvent } from 'leaflet';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
name: 'draw-control',
|
name: 'draw-control',
|
||||||
|
|
@ -34,19 +47,19 @@ import { LeafletMouseEvent } from 'leaflet';
|
||||||
BaseIcon,
|
BaseIcon,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class DrawControlComponent extends Vue {
|
export class DrawControlComponent extends Vue {
|
||||||
public TYPE = 'rectangle';
|
public TYPE = 'rectangle';
|
||||||
mdiDrawPen = mdiDrawPen;
|
mdiVectorRectangle = mdiVectorRectangle;
|
||||||
// private featuresLayer;
|
mdiClose = mdiClose;
|
||||||
|
|
||||||
options = {
|
options = {
|
||||||
shapeOptions: {
|
shapeOptions: {
|
||||||
stroke: true,
|
stroke: true,
|
||||||
color: '#22C55E',
|
color: '#65DC21',
|
||||||
weight: 4,
|
weight: 4,
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
fill: true,
|
fill: true,
|
||||||
fillColor: '#22C55E', //same as color by default
|
fillColor: '#65DC21',
|
||||||
fillOpacity: 0.2,
|
fillOpacity: 0.2,
|
||||||
clickable: true,
|
clickable: true,
|
||||||
},
|
},
|
||||||
|
|
@ -56,7 +69,6 @@ export default class DrawControlComponent extends Vue {
|
||||||
};
|
};
|
||||||
|
|
||||||
@Prop() public mapId: string;
|
@Prop() public mapId: string;
|
||||||
// @Prop() public map: Map;
|
|
||||||
@Prop public southWest: LatLng;
|
@Prop public southWest: LatLng;
|
||||||
@Prop public northEast: LatLng;
|
@Prop public northEast: LatLng;
|
||||||
@Prop({
|
@Prop({
|
||||||
|
|
@ -65,13 +77,17 @@ export default class DrawControlComponent extends Vue {
|
||||||
public preserve: boolean;
|
public preserve: boolean;
|
||||||
|
|
||||||
mapService = MapService();
|
mapService = MapService();
|
||||||
public _enabled: boolean;
|
private _enabled: boolean;
|
||||||
private _map: Map;
|
private _map: Map;
|
||||||
private _isDrawing: boolean = false;
|
private _isDrawing: boolean = false;
|
||||||
private _startLatLng: LatLng;
|
private _startLatLng: LatLng;
|
||||||
private _mapDraggable: boolean;
|
private _mapDraggable: boolean;
|
||||||
private _shape: Rectangle | undefined;
|
private _shape: Rectangle | undefined;
|
||||||
|
|
||||||
|
get enabled() {
|
||||||
|
return this._enabled;
|
||||||
|
}
|
||||||
|
|
||||||
enable() {
|
enable() {
|
||||||
if (this._enabled) {
|
if (this._enabled) {
|
||||||
return this;
|
return this;
|
||||||
|
|
@ -93,49 +109,35 @@ export default class DrawControlComponent extends Vue {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
enabled() {
|
// enabled() {
|
||||||
return !!this._enabled;
|
// return !!this._enabled;
|
||||||
}
|
// }
|
||||||
|
|
||||||
// @Ref('inputDraw') private _inputDraw: HTMLElement;
|
|
||||||
|
|
||||||
private addHooks() {
|
private addHooks() {
|
||||||
// L.Draw.Feature.prototype.addHooks.call(this);
|
|
||||||
this._map = this.mapService.getMap(this.mapId);
|
this._map = this.mapService.getMap(this.mapId);
|
||||||
if (this._map) {
|
if (this._map) {
|
||||||
this._mapDraggable = this._map.dragging.enabled();
|
this._mapDraggable = this._map.dragging.enabled();
|
||||||
if (this._mapDraggable) {
|
if (this._mapDraggable) {
|
||||||
this._map.dragging.disable();
|
this._map.dragging.disable();
|
||||||
}
|
}
|
||||||
//TODO refactor: move cursor to styles
|
this._map.getContainer().style.cursor = 'crosshair';
|
||||||
// this._map.domElement.style.cursor = 'crosshair';
|
|
||||||
this._map._container.style.cursor = 'crosshair';
|
|
||||||
// this._tooltip.updateContent({text: this._initialLabelText});
|
|
||||||
|
|
||||||
this._map
|
this._map
|
||||||
.on('mousedown', this._onMouseDown, this)
|
.on('mousedown', this._onMouseDown, this)
|
||||||
.on('mousemove', this._onMouseMove, this)
|
.on('mousemove', this._onMouseMove, this)
|
||||||
.on('touchstart', this._onMouseDown, this)
|
.on('touchstart', this._onMouseDown, this)
|
||||||
.on('touchmove', this._onMouseMove, this);
|
.on('touchmove', this._onMouseMove, this);
|
||||||
// we should prevent default, otherwise default behavior (scrolling) will fire,
|
|
||||||
// and that will cause document.touchend to fire and will stop the drawing
|
|
||||||
// (circle, rectangle) in touch mode.
|
|
||||||
// (update): we have to send passive now to prevent scroll, because by default it is {passive: true} now, which means,
|
|
||||||
// handler can't event.preventDefault
|
|
||||||
// check the news https://developers.google.com/web/updates/2016/06/passive-event-listeners
|
|
||||||
// document.addEventListener('touchstart', preventDefault, { passive: false });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeHooks() {
|
private removeHooks() {
|
||||||
// L.Draw.Feature.prototype.removeHooks.call(this);
|
|
||||||
if (this._map) {
|
if (this._map) {
|
||||||
if (this._mapDraggable) {
|
if (this._mapDraggable) {
|
||||||
this._map.dragging.enable();
|
this._map.dragging.enable();
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO refactor: move cursor to styles
|
this._map.getContainer().style.cursor = '';
|
||||||
this._map._container.style.cursor = '';
|
|
||||||
|
|
||||||
this._map
|
this._map
|
||||||
.off('mousedown', this._onMouseDown, this)
|
.off('mousedown', this._onMouseDown, this)
|
||||||
|
|
@ -146,46 +148,36 @@ export default class DrawControlComponent extends Vue {
|
||||||
off(document, 'mouseup', this._onMouseUp, this);
|
off(document, 'mouseup', this._onMouseUp, this);
|
||||||
off(document, 'touchend', this._onMouseUp, this);
|
off(document, 'touchend', this._onMouseUp, this);
|
||||||
|
|
||||||
// document.removeEventListener('touchstart', preventDefault);
|
|
||||||
|
|
||||||
// If the box element doesn't exist they must not have moved the mouse, so don't need to destroy/return
|
|
||||||
if (this._shape && this.preserve == false) {
|
if (this._shape && this.preserve == false) {
|
||||||
this._map.removeLayer(this._shape);
|
this._map.removeLayer(this._shape);
|
||||||
// delete this._shape;
|
|
||||||
this._shape = undefined;
|
this._shape = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._isDrawing = false;
|
this._isDrawing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onMouseDown(e: LeafletMouseEvent) {
|
// private _onMouseDown(e: LeafletMouseEvent) {
|
||||||
|
private _onMouseDown(e: any) {
|
||||||
this._isDrawing = true;
|
this._isDrawing = true;
|
||||||
this._startLatLng = e.latlng;
|
this._startLatLng = e.latlng;
|
||||||
|
|
||||||
// DomEvent.on(document, 'mouseup', this._onMouseUp, this)
|
|
||||||
// .on(document, 'touchend', this._onMouseUp, this)
|
|
||||||
// .preventDefault(e.originalEvent);
|
|
||||||
on(document, 'mouseup', this._onMouseUp, this);
|
on(document, 'mouseup', this._onMouseUp, this);
|
||||||
on(document, 'touchend', this._onMouseUp, this);
|
on(document, 'touchend', this._onMouseUp, this);
|
||||||
preventDefault(e.originalEvent);
|
preventDefault(e.originalEvent);
|
||||||
}
|
}
|
||||||
|
// private _onMouseMove(e: LeafletMouseEvent) {
|
||||||
private _onMouseMove(e: LeafletMouseEvent) {
|
private _onMouseMove(e: any) {
|
||||||
var latlng = e.latlng;
|
var latlng = e.latlng;
|
||||||
|
|
||||||
// this._tooltip.updatePosition(latlng);
|
|
||||||
if (this._isDrawing) {
|
if (this._isDrawing) {
|
||||||
// this._tooltip.updateContent(this._getTooltipText());
|
|
||||||
this._drawShape(latlng);
|
this._drawShape(latlng);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onMouseUp() {
|
private _onMouseUp() {
|
||||||
if (this._shape) {
|
if (this._shape) {
|
||||||
this._fireCreatedEvent(this._shape);
|
this._fireCreatedEvent(this._shape);
|
||||||
}
|
}
|
||||||
|
|
||||||
// this.removeHooks();
|
|
||||||
this.disable();
|
this.disable();
|
||||||
if (this.options.repeatMode) {
|
if (this.options.repeatMode) {
|
||||||
this.enable();
|
this.enable();
|
||||||
|
|
@ -194,14 +186,12 @@ export default class DrawControlComponent extends Vue {
|
||||||
|
|
||||||
private _fireCreatedEvent(shape: Rectangle) {
|
private _fireCreatedEvent(shape: Rectangle) {
|
||||||
var rectangle = new Rectangle(shape.getBounds(), this.options.shapeOptions);
|
var rectangle = new Rectangle(shape.getBounds(), this.options.shapeOptions);
|
||||||
// L.Draw.SimpleShape.prototype._fireCreatedEvent.call(this, rectangle);
|
|
||||||
this._map.fire('Draw.Event.CREATED', { layer: rectangle, type: this.TYPE });
|
this._map.fire('Draw.Event.CREATED', { layer: rectangle, type: this.TYPE });
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeShape() {
|
public removeShape() {
|
||||||
if (this._shape) {
|
if (this._shape) {
|
||||||
this._map.removeLayer(this._shape);
|
this._map.removeLayer(this._shape);
|
||||||
// delete this._shape;
|
|
||||||
this._shape = undefined;
|
this._shape = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -210,7 +200,6 @@ export default class DrawControlComponent extends Vue {
|
||||||
if (!this._shape) {
|
if (!this._shape) {
|
||||||
const bounds = new LatLngBounds(southWest, northEast);
|
const bounds = new LatLngBounds(southWest, northEast);
|
||||||
this._shape = new Rectangle(bounds, this.options.shapeOptions);
|
this._shape = new Rectangle(bounds, this.options.shapeOptions);
|
||||||
// this._map.addLayer(this._shape);
|
|
||||||
this._map = this.mapService.getMap(this.mapId);
|
this._map = this.mapService.getMap(this.mapId);
|
||||||
this._shape.addTo(this._map);
|
this._shape.addTo(this._map);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -218,12 +207,10 @@ export default class DrawControlComponent extends Vue {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// from Draw Rectangle
|
|
||||||
private _drawShape(latlng: LatLng) {
|
private _drawShape(latlng: LatLng) {
|
||||||
if (!this._shape) {
|
if (!this._shape) {
|
||||||
const bounds = new LatLngBounds(this._startLatLng, latlng);
|
const bounds = new LatLngBounds(this._startLatLng, latlng);
|
||||||
this._shape = new Rectangle(bounds, this.options.shapeOptions);
|
this._shape = new Rectangle(bounds, this.options.shapeOptions);
|
||||||
// this._map.addLayer(this._shape);
|
|
||||||
this._shape.addTo(this._map);
|
this._shape.addTo(this._map);
|
||||||
} else {
|
} else {
|
||||||
this._shape.setBounds(new LatLngBounds(this._startLatLng, latlng));
|
this._shape.setBounds(new LatLngBounds(this._startLatLng, latlng));
|
||||||
|
|
@ -237,44 +224,336 @@ export default class DrawControlComponent extends Vue {
|
||||||
this.enable();
|
this.enable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// private enable() {
|
|
||||||
// //if (this.map.mapTool) this.map.mapTool.on('editable:drawing:start', this.disable.bind(this));
|
|
||||||
// // dom.addClass(this.map.container, 'measure-enabled');
|
|
||||||
// //this.fireAndForward('showmeasure');
|
|
||||||
// this._startMarker(this.southWest, this.options);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// private disable() {
|
|
||||||
// //if (this.map.mapTool) this.map.mapTool.off('editable:drawing:start', this.disable.bind(this));
|
|
||||||
// // dom.removeClass(this.map.container, 'measure-enabled');
|
|
||||||
// // this.featuresLayer.clearLayers();
|
|
||||||
// // //this.fireAndForward('hidemeasure');
|
|
||||||
// // if (this._drawingEditor) {
|
|
||||||
// // this._drawingEditor.cancelDrawing();
|
|
||||||
// // }
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
export default DrawControlComponent;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="css">
|
<style scoped>
|
||||||
.gba-control-draw {
|
.draw-control-container {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
top: 8rem;
|
||||||
|
z-index: 1000;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
-moz-user-select: none;
|
-moz-user-select: none;
|
||||||
-ms-user-select: none;
|
-ms-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
position: absolute;
|
|
||||||
left: 10px;
|
|
||||||
top: 100px;
|
|
||||||
z-index: 40;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-group-vertical button {
|
.draw-button {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0.625rem;
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
color: #374151;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
outline: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
margin-left: 0;
|
.dark .draw-button {
|
||||||
margin-top: 0.5em;
|
background: #1f2937;
|
||||||
|
border-color: #374151;
|
||||||
|
color: #d1d5db;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inactive state hover */
|
||||||
|
.draw-button:not(.is-active):hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-color: #65DC21;
|
||||||
|
color: #357C06;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
/* box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); */
|
||||||
|
width: auto;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .draw-button:not(.is-active):hover {
|
||||||
|
background: #111827;
|
||||||
|
border-color: #65DC21;
|
||||||
|
color: #65DC21;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state */
|
||||||
|
.draw-button.is-active {
|
||||||
|
background: linear-gradient(135deg, #65DC21 0%, #357C06 100%);
|
||||||
|
border-color: #357C06;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(101, 220, 33, 0.4), 0 4px 6px -2px rgba(101, 220, 33, 0.2);
|
||||||
|
width: auto;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .draw-button.is-active {
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(101, 220, 33, 0.5), 0 4px 6px -2px rgba(101, 220, 33, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state hover */
|
||||||
|
.draw-button.is-active:hover {
|
||||||
|
background: linear-gradient(135deg, #429E04 0%, #295B09 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(101, 220, 33, 0.4), 0 10px 10px -5px rgba(101, 220, 33, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state press */
|
||||||
|
.draw-button:active {
|
||||||
|
transform: translateY(0) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus state */
|
||||||
|
.draw-button:focus-visible {
|
||||||
|
outline: 3px solid rgba(101, 220, 33, 0.5);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon styling */
|
||||||
|
.draw-button :deep(svg) {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .draw-button.is-active :deep(svg) {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
} */
|
||||||
|
|
||||||
|
/* Status badge */
|
||||||
|
.draw-status-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
max-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show badge on hover when inactive */
|
||||||
|
.draw-button:not(.is-active):hover .draw-status-badge {
|
||||||
|
max-width: 100px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show badge when active */
|
||||||
|
.draw-button.is-active .draw-status-badge {
|
||||||
|
max-width: 100px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse animation for active state */
|
||||||
|
.draw-button.is-active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
transform: translate(-50%, -50%) scale(0);
|
||||||
|
animation: pulse 2s ease-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* @keyframes pulse {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -50%) scale(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -50%) scale(1.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
} */
|
||||||
|
|
||||||
|
/* Glow effect for active state */
|
||||||
|
.draw-button.is-active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
left: -2px;
|
||||||
|
right: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
background: linear-gradient(135deg, #65DC21, #357C06);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: -1;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-button.is-active:hover::after {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inactive state indicator */
|
||||||
|
.draw-button:not(.is-active) .draw-status-badge {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .draw-button:not(.is-active) .draw-status-badge {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-button:not(.is-active):hover .draw-status-badge {
|
||||||
|
color: #357C06;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .draw-button:not(.is-active):hover .draw-status-badge {
|
||||||
|
color: #65DC21;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state indicator */
|
||||||
|
.draw-button.is-active .draw-status-badge {
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip on hover */
|
||||||
|
.draw-button:hover::after {
|
||||||
|
content: attr(aria-label);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 0.5rem);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #1f2937;
|
||||||
|
color: white;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 1001;
|
||||||
|
animation: fadeInTooltip 0.2s ease 0.5s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* @keyframes fadeInTooltip {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
} */
|
||||||
|
|
||||||
|
/* Ripple effect on click */
|
||||||
|
.draw-button::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: width 0.6s, height 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-button:active::before {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.draw-control-container {
|
||||||
|
right: 0.75rem;
|
||||||
|
top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-button {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-button:not(.is-active):hover,
|
||||||
|
.draw-button.is-active {
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-button :deep(svg) {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-status-badge {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide tooltip on mobile */
|
||||||
|
.draw-button:hover::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* @media (max-width: 640px) {
|
||||||
|
.draw-control-container {
|
||||||
|
right: 0.5rem;
|
||||||
|
top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-button {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-button:not(.is-active):hover,
|
||||||
|
.draw-button.is-active {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-button :deep(svg) {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
} */
|
||||||
|
|
||||||
|
/* Accessibility: reduce motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.draw-button,
|
||||||
|
.draw-button :deep(svg),
|
||||||
|
.draw-status-badge {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-button.is-active::before,
|
||||||
|
.draw-button.is-active::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Global styles for draw mode */
|
||||||
|
.leaflet-container.draw-mode-active {
|
||||||
|
cursor: crosshair !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.draw-mode-active * {
|
||||||
|
cursor: crosshair !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,21 +1,72 @@
|
||||||
<template>
|
<template>
|
||||||
<div style="position: relative">
|
<div class="relative w-full">
|
||||||
<!-- <Map className="h-36" :center="state.center" :zoom="state.zoom"> // map component content </Map> -->
|
<!-- Map Container -->
|
||||||
<div :id="mapId" class="rounded">
|
<div
|
||||||
<div class="dark:bg-slate-900 bg-slate flex flex-col">
|
:id="mapId"
|
||||||
|
class="relative h-[600px] w-full bg-gray-50 dark:bg-gray-900 rounded-xl overflow-hidden"
|
||||||
|
>
|
||||||
|
<div class="relative w-full h-full">
|
||||||
<ZoomControlComponent ref="zoom" :mapId="mapId" />
|
<ZoomControlComponent ref="zoom" :mapId="mapId" />
|
||||||
<DrawControlComponent ref="draw" :mapId="mapId" :southWest="southWest" :northEast="northEast" />
|
<DrawControlComponent ref="draw" :mapId="mapId" :southWest="southWest" :northEast="northEast" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="gba-control-validate btn-group-vertical">
|
|
||||||
|
<!-- Validate Button -->
|
||||||
|
<div class="absolute left-4 top-44 z-[1000] select-none">
|
||||||
<button
|
<button
|
||||||
class="min-w-27 inline-flex cursor-pointer justify-center items-center whitespace-nowrap focus:outline-none transition-colors duration-150 border rounded ring-blue-700 text-black text-sm p-1"
|
class="group flex items-center justify-center relative overflow-visible outline-none font-semibold text-sm transition-all duration-300 ease-in-out
|
||||||
|
w-10 h-10 rounded-xl border-2 shadow-md
|
||||||
|
focus-visible:outline focus-visible:outline-3 focus-visible:outline-offset-2"
|
||||||
|
:class="[
|
||||||
|
validBoundingBox
|
||||||
|
? 'bg-gradient-to-br from-lime-500 to-lime-700 border-lime-700 text-white shadow-lime-500/40 cursor-default gap-2 w-auto px-4 focus-visible:outline-lime-500/50'
|
||||||
|
: 'bg-white dark:bg-gray-800 border-red-500 text-red-600 dark:text-red-400 gap-0 hover:bg-red-50 dark:hover:bg-gray-900 hover:border-red-500 hover:text-red-700 dark:hover:text-red-300 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-red-500/30 hover:w-auto hover:px-4 hover:gap-2 focus-visible:outline-red-500/50'
|
||||||
|
]"
|
||||||
type="button"
|
type="button"
|
||||||
@click.stop.prevent="validateBoundingBox"
|
@click.stop.prevent="validateBoundingBox"
|
||||||
:class="[validBoundingBox ? 'cursor-not-allowed bg-green-500 is-active' : 'bg-red-500 ']"
|
:aria-label="validBoundingBox ? 'Bounding box is valid' : 'Validate bounding box'"
|
||||||
|
>
|
||||||
|
<!-- Icon -->
|
||||||
|
<BaseIcon
|
||||||
|
v-if="mdiMapCheckOutline"
|
||||||
|
:path="mdiMapCheckOutline"
|
||||||
|
:size="20"
|
||||||
|
:class="[
|
||||||
|
'transition-transform duration-300',
|
||||||
|
validBoundingBox && 'animate-[checkPulse_2s_ease-in-out_infinite]'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Status badge -->
|
||||||
|
<span
|
||||||
|
class="text-xs font-bold uppercase tracking-wider whitespace-nowrap transition-all duration-300 overflow-hidden"
|
||||||
|
:class="[
|
||||||
|
validBoundingBox
|
||||||
|
? 'max-w-[100px] opacity-100 text-white drop-shadow'
|
||||||
|
: 'max-w-0 opacity-0 group-hover:max-w-[100px] group-hover:opacity-100'
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<!-- <BaseIcon v-if="mdiMapCheckOutline" :path="mdiMapCheckOutline" /> -->
|
|
||||||
{{ label }}
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Pulse animation for valid state -->
|
||||||
|
<span
|
||||||
|
v-if="validBoundingBox"
|
||||||
|
class="absolute top-1/2 left-1/2 w-full h-full bg-white/30 rounded-xl -translate-x-1/2 -translate-y-1/2 animate-[pulse_2s_ease-out_infinite] pointer-events-none"
|
||||||
|
></span>
|
||||||
|
|
||||||
|
<!-- Ripple effect on click -->
|
||||||
|
<span
|
||||||
|
class="absolute top-1/2 left-1/2 w-0 h-0 rounded-full bg-white/30 -translate-x-1/2 -translate-y-1/2 transition-all duration-[600ms] active:w-[300px] active:h-[300px]"
|
||||||
|
></span>
|
||||||
|
|
||||||
|
<!-- Tooltip -->
|
||||||
|
<span
|
||||||
|
v-if="!validBoundingBox"
|
||||||
|
class="absolute left-[calc(100%+0.5rem)] top-1/2 -translate-y-1/2 px-3 py-1.5 bg-gray-800 text-white text-xs rounded-md whitespace-nowrap opacity-0 pointer-events-none transition-opacity duration-200 z-[1001] group-hover:opacity-100 group-hover:animate-[fadeInTooltip_0.2s_ease_0.5s_forwards]"
|
||||||
|
>
|
||||||
|
Click to validate
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -27,8 +78,7 @@ import { Component, Vue, Prop, Ref } from 'vue-facing-decorator';
|
||||||
import { Map } from 'leaflet/src/map/index';
|
import { Map } from 'leaflet/src/map/index';
|
||||||
import { Control } from 'leaflet/src/control/Control';
|
import { Control } from 'leaflet/src/control/Control';
|
||||||
import { LatLngBoundsExpression, LatLngBounds } from 'leaflet/src/geo/LatLngBounds';
|
import { LatLngBoundsExpression, LatLngBounds } from 'leaflet/src/geo/LatLngBounds';
|
||||||
// import { toLatLng } from 'leaflet/src/geo/LatLng';
|
import { LatLng } from 'leaflet';
|
||||||
import { LatLng } from 'leaflet'; //'leaflet/src/geo/LatLng';
|
|
||||||
import { tileLayerWMS } from 'leaflet/src/layer/tile/TileLayer.WMS';
|
import { tileLayerWMS } from 'leaflet/src/layer/tile/TileLayer.WMS';
|
||||||
import { Attribution } from 'leaflet/src/control/Control.Attribution';
|
import { Attribution } from 'leaflet/src/control/Control.Attribution';
|
||||||
import { mdiMapCheckOutline } from '@mdi/js';
|
import { mdiMapCheckOutline } from '@mdi/js';
|
||||||
|
|
@ -37,22 +87,15 @@ import BaseIcon from '@/Components/BaseIcon.vue';
|
||||||
import { MapOptions } from './MapOptions';
|
import { MapOptions } from './MapOptions';
|
||||||
import { LayerOptions, LayerMap } from './LayerOptions';
|
import { LayerOptions, LayerMap } from './LayerOptions';
|
||||||
import { MapService } from '@/Stores/map.service';
|
import { MapService } from '@/Stores/map.service';
|
||||||
import ZoomControlComponent from './zoom.component.vue';
|
import { ZoomControlComponent } from './zoom.component.vue';
|
||||||
import DrawControlComponent from './draw.component.vue';
|
import { DrawControlComponent } from './draw.component.vue';
|
||||||
import { Coverage } from '@/Dataset';
|
import { Coverage } from '@/Dataset';
|
||||||
import { canvas } from 'leaflet/src/layer/vector/Canvas';
|
import { canvas } from 'leaflet/src/layer/vector/Canvas';
|
||||||
import { svg } from 'leaflet/src/layer/vector/SVG';
|
import { svg } from 'leaflet/src/layer/vector/SVG';
|
||||||
import Notification from '@/utils/toast';
|
import Notification from '@/utils/toast';
|
||||||
|
|
||||||
Map.include({
|
Map.include({
|
||||||
// @namespace Map; @method getRenderer(layer: Path): Renderer
|
|
||||||
// Returns the instance of `Renderer` that should be used to render the given
|
|
||||||
// `Path`. It will ensure that the `renderer` options of the map and paths
|
|
||||||
// are respected, and that the renderers do exist on the map.
|
|
||||||
getRenderer: function (layer) {
|
getRenderer: function (layer) {
|
||||||
// @namespace Path; @option renderer: Renderer
|
|
||||||
// Use this specific instance of `Renderer` for this path. Takes
|
|
||||||
// precedence over the map's [default renderer](#map-renderer).
|
|
||||||
var renderer = layer.options.renderer || this._getPaneRenderer(layer.options.pane) || this.options.renderer || this._renderer;
|
var renderer = layer.options.renderer || this._getPaneRenderer(layer.options.pane) || this.options.renderer || this._renderer;
|
||||||
|
|
||||||
if (!renderer) {
|
if (!renderer) {
|
||||||
|
|
@ -79,15 +122,11 @@ Map.include({
|
||||||
},
|
},
|
||||||
|
|
||||||
_createRenderer: function (options) {
|
_createRenderer: function (options) {
|
||||||
// @namespace Map; @option preferCanvas: Boolean = false
|
|
||||||
// Whether `Path`s should be rendered on a `Canvas` renderer.
|
|
||||||
// By default, all `Path`s are rendered in a `SVG` renderer.
|
|
||||||
return (this.options.preferCanvas && canvas(options)) || svg(options);
|
return (this.options.preferCanvas && canvas(options)) || svg(options);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const DEFAULT_BASE_LAYER_NAME = 'BaseLayer';
|
const DEFAULT_BASE_LAYER_NAME = 'BaseLayer';
|
||||||
// const DEFAULT_BASE_LAYER_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
|
||||||
const DEFAULT_BASE_LAYER_ATTRIBUTION = '© <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors';
|
const DEFAULT_BASE_LAYER_ATTRIBUTION = '© <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|
@ -98,33 +137,19 @@ const DEFAULT_BASE_LAYER_ATTRIBUTION = '© <a target="_blank" href="http://o
|
||||||
BaseIcon,
|
BaseIcon,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class MapComponent extends Vue {
|
export class MapComponent extends Vue {
|
||||||
/**
|
|
||||||
* A map with the given ID is created inside this component.
|
|
||||||
* This ID can be used the get the map instance over the map cache service.
|
|
||||||
*/
|
|
||||||
@Prop()
|
@Prop()
|
||||||
public mapId: string;
|
public mapId: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* The corresponding leaflet map options (see: https://leafletjs.com/reference-1.3.4.html#map-option)
|
|
||||||
*/
|
|
||||||
@Prop()
|
@Prop()
|
||||||
public mapOptions: MapOptions;
|
public mapOptions: MapOptions;
|
||||||
|
|
||||||
@Prop()
|
@Prop()
|
||||||
public coverage: Coverage;
|
public coverage: Coverage;
|
||||||
|
|
||||||
// markerService: MarkerService
|
|
||||||
/**
|
|
||||||
* Bounds for the map
|
|
||||||
*/
|
|
||||||
@Prop({ default: null })
|
@Prop({ default: null })
|
||||||
public fitBounds: LatLngBoundsExpression;
|
public fitBounds: LatLngBoundsExpression;
|
||||||
|
|
||||||
/**
|
|
||||||
* Describes the the zoom control options (see: https://leafletjs.com/reference-1.3.4.html#control-zoom)
|
|
||||||
*/
|
|
||||||
@Prop()
|
@Prop()
|
||||||
public zoomControlOptions: Control.ZoomOptions;
|
public zoomControlOptions: Control.ZoomOptions;
|
||||||
|
|
||||||
|
|
@ -132,7 +157,7 @@ export default class MapComponent extends Vue {
|
||||||
public baseMaps: LayerMap;
|
public baseMaps: LayerMap;
|
||||||
|
|
||||||
get label(): string {
|
get label(): string {
|
||||||
return this.validBoundingBox ? ' valid' : 'invalid';
|
return this.validBoundingBox ? 'Valid' : 'Invalid';
|
||||||
}
|
}
|
||||||
|
|
||||||
get validBoundingBox(): boolean {
|
get validBoundingBox(): boolean {
|
||||||
|
|
@ -144,35 +169,31 @@ export default class MapComponent extends Vue {
|
||||||
|
|
||||||
let isBoundValid = true;
|
let isBoundValid = true;
|
||||||
if (isValidNumber) {
|
if (isValidNumber) {
|
||||||
let _southWest: LatLng = new LatLng(this.coverage.y_min, this.coverage.x_min);
|
let _southWest: LatLng = new LatLng(this.coverage.y_min!, this.coverage.x_min!);
|
||||||
let _northEast: LatLng = new LatLng(this.coverage.y_max, this.coverage.x_max);
|
let _northEast: LatLng = new LatLng(this.coverage.y_max!, this.coverage.x_max!);
|
||||||
const bounds = new LatLngBounds(this.southWest, this.northEast);
|
const bounds = new LatLngBounds(this.southWest, this.northEast);
|
||||||
if (!bounds.isValid() || !(_southWest.lat < _northEast.lat && _southWest.lng < _northEast.lng)) {
|
if (!bounds.isValid() || !(_southWest.lat < _northEast.lat && _southWest.lng < _northEast.lng)) {
|
||||||
// this.draw.removeShape();
|
|
||||||
// Notification.showTemporary('Bounds are not valid.');
|
|
||||||
isBoundValid = false;
|
isBoundValid = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return isValidNumber && isBoundValid;
|
return isValidNumber && isBoundValid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Ref('zoom') private zoom: ZoomControlComponent;
|
@Ref('zoom')
|
||||||
@Ref('draw') private draw: DrawControlComponent;
|
private zoom: ZoomControlComponent;
|
||||||
|
|
||||||
|
@Ref('draw')
|
||||||
|
private draw: DrawControlComponent;
|
||||||
|
|
||||||
// services:
|
|
||||||
mapService = MapService();
|
mapService = MapService();
|
||||||
|
|
||||||
mdiMapCheckOutline = mdiMapCheckOutline;
|
mdiMapCheckOutline = mdiMapCheckOutline;
|
||||||
southWest: LatLng;
|
southWest: LatLng;
|
||||||
northEast: LatLng;
|
northEast: LatLng;
|
||||||
|
|
||||||
/**
|
|
||||||
* Informs when initialization is done with map id.
|
|
||||||
*/
|
|
||||||
public onMapInitializedEvent: EventEmitter<string> = new EventEmitter<string>();
|
public onMapInitializedEvent: EventEmitter<string> = new EventEmitter<string>();
|
||||||
|
|
||||||
public map!: Map;
|
public map!: Map;
|
||||||
// protected drawnItems!: FeatureGroup<any>;
|
|
||||||
|
|
||||||
validateBoundingBox() {
|
validateBoundingBox() {
|
||||||
if (this.validBoundingBox == false) {
|
if (this.validBoundingBox == false) {
|
||||||
|
|
@ -182,53 +203,22 @@ export default class MapComponent extends Vue {
|
||||||
}
|
}
|
||||||
this.map.control && this.map.control.disable();
|
this.map.control && this.map.control.disable();
|
||||||
var _this = this;
|
var _this = this;
|
||||||
// // _this.locationErrors.length = 0;
|
|
||||||
// this.drawnItems.clearLayers();
|
|
||||||
// //var xmin = document.getElementById("xmin").value;
|
|
||||||
// var xmin = (<HTMLInputElement>document.getElementById("xmin")).value;
|
|
||||||
// // var ymin = document.getElementById("ymin").value;
|
|
||||||
// var ymin = (<HTMLInputElement>document.getElementById("ymin")).value;
|
|
||||||
// //var xmax = document.getElementById("xmax").value;
|
|
||||||
// var xmax = (<HTMLInputElement>document.getElementById("xmax")).value;
|
|
||||||
// //var ymax = document.getElementById("ymax").value;
|
|
||||||
// var ymax = (<HTMLInputElement>document.getElementById("ymax")).value;
|
|
||||||
// var bounds = [[ymin, xmin], [ymax, xmax]];
|
|
||||||
|
|
||||||
// let _southWest: LatLng;
|
let _southWest: LatLng = new LatLng(this.coverage.y_min!, this.coverage.x_min!);
|
||||||
// let _northEast: LatLng;
|
let _northEast: LatLng = new LatLng(this.coverage.y_max!, this.coverage.x_max!);
|
||||||
// if (this.coverage.x_min && this.coverage.y_min) {
|
|
||||||
let _southWest: LatLng = new LatLng(this.coverage.y_min, this.coverage.x_min);
|
|
||||||
// }
|
|
||||||
// if (this.coverage.x_max && this.coverage.y_max) {
|
|
||||||
let _northEast: LatLng = new LatLng(this.coverage.y_max, this.coverage.x_max);
|
|
||||||
// }
|
|
||||||
const bounds = new LatLngBounds(this.southWest, this.northEast);
|
const bounds = new LatLngBounds(this.southWest, this.northEast);
|
||||||
if (!bounds.isValid() || !(_southWest.lat < _northEast.lat && _southWest.lng < _northEast.lng)) {
|
if (!bounds.isValid() || !(_southWest.lat < _northEast.lat && _southWest.lng < _northEast.lng)) {
|
||||||
this.draw.removeShape();
|
this.draw.removeShape();
|
||||||
Notification.showTemporary('Bounds are not valid.');
|
Notification.showTemporary('Bounds are not valid.');
|
||||||
} else {
|
} else {
|
||||||
// this.draw.drawShape(_southWest, _northEast);
|
|
||||||
try {
|
try {
|
||||||
this.draw.drawShape(_southWest, _northEast);
|
this.draw.drawShape(_southWest, _northEast);
|
||||||
_this.map.fitBounds(bounds);
|
_this.map.fitBounds(bounds);
|
||||||
|
|
||||||
// var boundingBox = L.rectangle(bounds, { color: "#005F6A", weight: 1 });
|
Notification.showSuccess('Valid bounding box');
|
||||||
// // this.geolocation.xmin = xmin;
|
|
||||||
// // this.geolocation.ymin = ymin;
|
|
||||||
// // this.geolocation.xmax = xmax;
|
|
||||||
// // this.geolocation.ymax = ymax;
|
|
||||||
|
|
||||||
// _this.drawnItems.addLayer(boundingBox);
|
|
||||||
// _this.map.fitBounds(bounds);
|
|
||||||
// this.options.message = "valid bounding box";
|
|
||||||
// this.$toast.success("valid bounding box", this.options);
|
|
||||||
Notification.showSuccess('valid bounding box');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// this.options.message = e.message;
|
|
||||||
// // _this.errors.push(e);
|
|
||||||
// this.$toast.error(e.message, this.options);
|
|
||||||
Notification.showTemporary('An error occurred while drawing bounding box');
|
Notification.showTemporary('An error occurred while drawing bounding box');
|
||||||
// generatingCodes.value = false;
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -242,16 +232,11 @@ export default class MapComponent extends Vue {
|
||||||
this.map.off('zoomend zoomlevelschange');
|
this.map.off('zoomend zoomlevelschange');
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Emit(this.onMapInitializedEvent)
|
|
||||||
protected initMap(): void {
|
protected initMap(): void {
|
||||||
// let map: Map = (this.map = this.mapService.getMap(this.mapId));
|
|
||||||
|
|
||||||
let map: Map = (this.map = new Map(this.mapId, this.mapOptions));
|
let map: Map = (this.map = new Map(this.mapId, this.mapOptions));
|
||||||
this.mapService.setMap(this.mapId, map);
|
this.mapService.setMap(this.mapId, map);
|
||||||
map.scrollWheelZoom.disable();
|
map.scrollWheelZoom.disable();
|
||||||
|
|
||||||
// return this.mapId;
|
|
||||||
// this.$emit("onMapInitializedEvent", this.mapId);
|
|
||||||
this.onMapInitializedEvent.emit(this.mapId);
|
this.onMapInitializedEvent.emit(this.mapId);
|
||||||
this.addBaseMap();
|
this.addBaseMap();
|
||||||
|
|
||||||
|
|
@ -260,45 +245,28 @@ export default class MapComponent extends Vue {
|
||||||
|
|
||||||
map.on(
|
map.on(
|
||||||
'Draw.Event.CREATED',
|
'Draw.Event.CREATED',
|
||||||
function (event) {
|
(event: any) => {
|
||||||
// drawnItems.clearLayers();
|
|
||||||
// var type = event.type;
|
|
||||||
var layer = event.layer;
|
var layer = event.layer;
|
||||||
|
|
||||||
// if (type === "rectancle") {
|
|
||||||
// layer.bindPopup("A popup!" + layer.getBounds().toBBoxString());
|
|
||||||
var bounds = layer.getBounds();
|
var bounds = layer.getBounds();
|
||||||
this.coverage.x_min = bounds.getSouthWest().lng;
|
this.coverage.x_min = bounds.getSouthWest().lng;
|
||||||
this.coverage.y_min = bounds.getSouthWest().lat;
|
this.coverage.y_min = bounds.getSouthWest().lat;
|
||||||
// console.log(this.geolocation.xmin);
|
|
||||||
this.coverage.x_max = bounds.getNorthEast().lng;
|
this.coverage.x_max = bounds.getNorthEast().lng;
|
||||||
this.coverage.y_max = bounds.getNorthEast().lat;
|
this.coverage.y_max = bounds.getNorthEast().lat;
|
||||||
// }
|
|
||||||
|
|
||||||
// drawnItems.addLayer(layer);
|
|
||||||
},
|
},
|
||||||
this,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialise the FeatureGroup to store editable layers
|
|
||||||
// let drawnItems = (this.drawnItems = new FeatureGroup());
|
|
||||||
// map.addLayer(drawnItems);
|
|
||||||
|
|
||||||
this.map.on('zoomend zoomlevelschange', this.zoom.updateDisabled, this.zoom);
|
this.map.on('zoomend zoomlevelschange', this.zoom.updateDisabled, this.zoom);
|
||||||
|
|
||||||
// if (this.fitBounds) {
|
|
||||||
// this.map.fitBounds(this.fitBounds);
|
|
||||||
// }
|
|
||||||
if (this.coverage.x_min && this.coverage.y_min) {
|
if (this.coverage.x_min && this.coverage.y_min) {
|
||||||
this.southWest = new LatLng(this.coverage.y_min, this.coverage.x_min);
|
this.southWest = new LatLng(this.coverage.y_min!, this.coverage.x_min!);
|
||||||
} else {
|
} else {
|
||||||
this.southWest = new LatLng(46.5, 9.9);
|
this.southWest = new LatLng(46.5, 9.9);
|
||||||
}
|
}
|
||||||
if (this.coverage.x_max && this.coverage.y_max) {
|
if (this.coverage.x_max && this.coverage.y_max) {
|
||||||
this.northEast = new LatLng(this.coverage.y_max, this.coverage.x_max);
|
this.northEast = new LatLng(this.coverage.y_max!, this.coverage.x_max!);
|
||||||
} else {
|
} else {
|
||||||
this.northEast = new LatLng(48.9, 16.9);
|
this.northEast = new LatLng(48.9, 16.9);
|
||||||
} // this.northEast = toLatLng(48.9, 16.9);
|
}
|
||||||
const bounds = new LatLngBounds(this.southWest, this.northEast);
|
const bounds = new LatLngBounds(this.southWest, this.northEast);
|
||||||
map.fitBounds(bounds);
|
map.fitBounds(bounds);
|
||||||
|
|
||||||
|
|
@ -318,10 +286,6 @@ export default class MapComponent extends Vue {
|
||||||
private addBaseMap(layerOptions?: LayerOptions): void {
|
private addBaseMap(layerOptions?: LayerOptions): void {
|
||||||
if (this.map) {
|
if (this.map) {
|
||||||
if (!this.baseMaps || this.baseMaps.size === 0) {
|
if (!this.baseMaps || this.baseMaps.size === 0) {
|
||||||
// let bmapgrau = tileLayer('https://{s}.wien.gv.at/basemap/bmapgrau/normal/google3857/{z}/{y}/{x}.png', {
|
|
||||||
// subdomains: ['maps', 'maps1', 'maps2', 'maps3', 'maps4'],
|
|
||||||
// attribution: 'Datenquelle: <a href="http://www.basemap.at/">basemap.at</a>',
|
|
||||||
// });
|
|
||||||
let osmGgray = tileLayerWMS('https://ows.terrestris.de/osm-gray/service', {
|
let osmGgray = tileLayerWMS('https://ows.terrestris.de/osm-gray/service', {
|
||||||
format: 'image/png',
|
format: 'image/png',
|
||||||
attribution: DEFAULT_BASE_LAYER_ATTRIBUTION,
|
attribution: DEFAULT_BASE_LAYER_ATTRIBUTION,
|
||||||
|
|
@ -337,45 +301,61 @@ export default class MapComponent extends Vue {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export default MapComponent;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="css">
|
<style scoped>
|
||||||
/* .leaflet-container {
|
/* Leaflet container - only what can't be done with Tailwind */
|
||||||
|
:deep(.leaflet-container) {
|
||||||
height: 600px;
|
height: 600px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: transparent;
|
background: transparent;
|
||||||
outline-offset: 1px;
|
|
||||||
} */
|
|
||||||
.leaflet-container {
|
|
||||||
height: 600px;
|
|
||||||
width: 100%;
|
|
||||||
background: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.gba-control-validate {
|
:deep(.leaflet-container .leaflet-pane) {
|
||||||
-webkit-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
position: absolute;
|
|
||||||
left: 10px;
|
|
||||||
top: 150px;
|
|
||||||
z-index: 999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-group-vertical button {
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
margin-left: 0;
|
|
||||||
margin-top: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-container .leaflet-pane {
|
|
||||||
z-index: 30 !important;
|
z-index: 30 !important;
|
||||||
}
|
}
|
||||||
/* .leaflet-pane {
|
|
||||||
z-index: 30;
|
/* Custom animations */
|
||||||
} */
|
@keyframes checkPulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -50%) scale(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -50%) scale(1.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInTooltip {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:deep(.leaflet-container) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
:deep(.leaflet-container) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,21 +1,27 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="gba-control-zoom btn-group-vertical">
|
<div class="zoom-control-container">
|
||||||
<button
|
<button
|
||||||
ref="inputPlus"
|
ref="inputPlus"
|
||||||
class="disabled:bg-gray-200 inline-flex cursor-pointer justify-center items-center whitespace-nowrap focus:outline-none transition-colors duration-150 border rounded ring-blue-700 bg-teal-50 text-black border-teal-50 text-sm p-1"
|
class="zoom-button zoom-button-plus"
|
||||||
type="button"
|
type="button"
|
||||||
@click.stop.prevent="zoomIn"
|
@click.stop.prevent="zoomIn"
|
||||||
|
:disabled="isZoomInDisabled"
|
||||||
|
aria-label="Zoom in"
|
||||||
>
|
>
|
||||||
<BaseIcon v-if="mdiPlus" :path="mdiPlus" />
|
<BaseIcon v-if="mdiPlus" :path="mdiPlus" :size="20" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="zoom-separator"></div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
ref="inputMinus"
|
ref="inputMinus"
|
||||||
class="disabled:bg-gray-200 inline-flex cursor-pointer justify-center items-center whitespace-nowrap focus:outline-none transition-colors duration-150 border rounded ring-blue-700 bg-teal-50 text-black border-teal-50 text-sm p-1"
|
class="zoom-button zoom-button-minus"
|
||||||
type="button"
|
type="button"
|
||||||
@click.stop.prevent="zoomOut"
|
@click.stop.prevent="zoomOut"
|
||||||
|
:disabled="isZoomOutDisabled"
|
||||||
|
aria-label="Zoom out"
|
||||||
>
|
>
|
||||||
<BaseIcon v-if="mdiMinus" :path="mdiMinus" />
|
<BaseIcon v-if="mdiMinus" :path="mdiMinus" :size="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -26,6 +32,7 @@ import { MapService } from '@/Stores/map.service';
|
||||||
|
|
||||||
import BaseIcon from '@/Components/BaseIcon.vue';
|
import BaseIcon from '@/Components/BaseIcon.vue';
|
||||||
import { mdiPlus, mdiMinus } from '@mdi/js';
|
import { mdiPlus, mdiMinus } from '@mdi/js';
|
||||||
|
import { Map } from 'leaflet';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
name: 'zoom-control',
|
name: 'zoom-control',
|
||||||
|
|
@ -33,7 +40,7 @@ import { mdiPlus, mdiMinus } from '@mdi/js';
|
||||||
BaseIcon,
|
BaseIcon,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class ZoomControlComponent extends Vue {
|
export class ZoomControlComponent extends Vue {
|
||||||
mdiPlus = mdiPlus;
|
mdiPlus = mdiPlus;
|
||||||
mdiMinus = mdiMinus;
|
mdiMinus = mdiMinus;
|
||||||
|
|
||||||
|
|
@ -46,16 +53,23 @@ export default class ZoomControlComponent extends Vue {
|
||||||
@Ref('inputMinus') inputMinus: HTMLButtonElement;
|
@Ref('inputMinus') inputMinus: HTMLButtonElement;
|
||||||
|
|
||||||
mapService = MapService();
|
mapService = MapService();
|
||||||
map;
|
map: Map | null = null;
|
||||||
|
isZoomInDisabled = false;
|
||||||
|
isZoomOutDisabled = false;
|
||||||
|
|
||||||
// mounted() {
|
mounted() {
|
||||||
// let map = (this.map = this.mapService.getMap(this.mapId));
|
let map = (this.map = this.mapService.getMap(this.mapId));
|
||||||
// map.on('zoomend zoomlevelschange', this.updateDisabled, this);
|
if (map) {
|
||||||
// }
|
map.on('zoomend zoomlevelschange', this.updateDisabled, this);
|
||||||
|
this.updateDisabled();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// unmounted() {
|
unmounted() {
|
||||||
// this.map.off('zoomend zoomlevelschange');
|
if (this.map) {
|
||||||
// }
|
this.map.off('zoomend zoomlevelschange', this.updateDisabled, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public zoomIn() {
|
public zoomIn() {
|
||||||
let map = this.mapService.getMap(this.mapId);
|
let map = this.mapService.getMap(this.mapId);
|
||||||
|
|
@ -69,44 +83,266 @@ export default class ZoomControlComponent extends Vue {
|
||||||
|
|
||||||
public updateDisabled() {
|
public updateDisabled() {
|
||||||
let map = this.mapService.getMap(this.mapId);
|
let map = this.mapService.getMap(this.mapId);
|
||||||
// let className = 'leaflet-disabled';
|
if (!map) return;
|
||||||
|
|
||||||
this.inputPlus.disabled = false;
|
this.isZoomInDisabled = map.getZoom() >= map.getMaxZoom();
|
||||||
this.inputPlus.setAttribute('aria-disabled', 'false');
|
this.isZoomOutDisabled = map.getZoom() <= map.getMinZoom();
|
||||||
|
|
||||||
this.inputMinus.disabled = false;
|
if (this.inputPlus) {
|
||||||
this.inputMinus.setAttribute('aria-disabled', 'false');
|
this.inputPlus.disabled = this.isZoomInDisabled;
|
||||||
|
this.inputPlus.setAttribute('aria-disabled', this.isZoomInDisabled.toString());
|
||||||
|
}
|
||||||
|
|
||||||
if (map.getZoom() === map.getMinZoom()) {
|
if (this.inputMinus) {
|
||||||
this.inputMinus.disabled = true;
|
this.inputMinus.disabled = this.isZoomOutDisabled;
|
||||||
this.inputMinus.setAttribute('aria-disabled', 'true');
|
this.inputMinus.setAttribute('aria-disabled', this.isZoomOutDisabled.toString());
|
||||||
}
|
|
||||||
if (map.getZoom() === map.getMaxZoom()) {
|
|
||||||
this.inputPlus.disabled = true;
|
|
||||||
this.inputPlus.setAttribute('aria-disabled', 'true');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export default ZoomControlComponent;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="css">
|
<style scoped>
|
||||||
.gba-control-zoom {
|
.zoom-control-container {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
top: 1rem;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
-moz-user-select: none;
|
-moz-user-select: none;
|
||||||
-ms-user-select: none;
|
-ms-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: pointer;
|
transition: box-shadow 0.2s ease;
|
||||||
border-radius: 4px;
|
|
||||||
position: absolute;
|
|
||||||
left: 10px;
|
|
||||||
top: 10px;
|
|
||||||
z-index: 40;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-group-vertical button {
|
.zoom-control-container:hover {
|
||||||
display: block;
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
margin-left: 0;
|
.dark .zoom-control-container {
|
||||||
margin-top: 0.5em;
|
background: #1f2937;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .zoom-control-container:hover {
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
padding: 0;
|
||||||
|
background: white;
|
||||||
|
border: none;
|
||||||
|
color: #374151;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .zoom-button {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-button:hover:not(:disabled) {
|
||||||
|
background: #65dc21;
|
||||||
|
color: white;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .zoom-button:hover:not(:disabled) {
|
||||||
|
background: #65dc21;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-button:active:not(:disabled) {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.4;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .zoom-button:disabled {
|
||||||
|
background: #111827;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-button:focus-visible {
|
||||||
|
outline: 2px solid #65dc21;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon sizing */
|
||||||
|
.zoom-button :deep(svg) {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Separator between buttons */
|
||||||
|
.zoom-separator {
|
||||||
|
height: 1px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .zoom-separator {
|
||||||
|
background: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effect for the plus button */
|
||||||
|
.zoom-button-plus::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(101, 220, 33, 0.1) 0%, rgba(53, 124, 6, 0.1) 100%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-button-plus:hover:not(:disabled)::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effect for the minus button */
|
||||||
|
.zoom-button-minus::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(101, 220, 33, 0.1) 0%, rgba(53, 124, 6, 0.1) 100%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-button-minus:hover:not(:disabled)::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.zoom-control-container {
|
||||||
|
left: 0.75rem;
|
||||||
|
top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-button {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-button :deep(svg) {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.zoom-control-container {
|
||||||
|
left: 0.5rem;
|
||||||
|
top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-button {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-button :deep(svg) {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation for button press */
|
||||||
|
@keyframes buttonPress {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-button:active:not(:disabled) {
|
||||||
|
animation: buttonPress 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip-like effect on hover (optional) */
|
||||||
|
.zoom-button-plus:hover:not(:disabled)::before {
|
||||||
|
content: 'Zoom In';
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% + 0.5rem);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: #1f2937;
|
||||||
|
color: white;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeIn 0.2s ease 0.5s forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-button-minus:hover:not(:disabled)::before {
|
||||||
|
content: 'Zoom Out';
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% + 0.5rem);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: #1f2937;
|
||||||
|
color: white;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeIn 0.2s ease 0.5s forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide tooltips on mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.zoom-button-plus:hover:not(:disabled)::before,
|
||||||
|
.zoom-button-minus:hover:not(:disabled)::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,273 +1,571 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
// import { MainService } from '@/Stores/main';
|
|
||||||
// import { StyleService } from '@/Stores/style.service';
|
|
||||||
import { mdiTrashCan } from '@mdi/js';
|
import { mdiTrashCan } from '@mdi/js';
|
||||||
import { mdiDragVariant } from '@mdi/js';
|
import { mdiDragVariant, mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||||
|
import { mdiAccount, mdiDomain } from '@mdi/js';
|
||||||
import BaseIcon from '@/Components/BaseIcon.vue';
|
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 BaseButton from '@/Components/BaseButton.vue';
|
||||||
// import UserAvatar from '@/Components/UserAvatar.vue';
|
|
||||||
// import Person from 'App/Models/Person';
|
|
||||||
import { Person } from '@/Dataset';
|
import { Person } from '@/Dataset';
|
||||||
import Draggable from 'vuedraggable';
|
import Draggable from 'vuedraggable';
|
||||||
import FormControl from '@/Components/FormControl.vue';
|
import FormControl from '@/Components/FormControl.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
interface Props {
|
||||||
checkable: Boolean,
|
checkable?: boolean;
|
||||||
persons: {
|
persons?: Person[];
|
||||||
type: Array<Person>,
|
relation: string;
|
||||||
default: () => [],
|
contributortypes?: Record<string, string>;
|
||||||
},
|
errors?: Record<string, string[]>;
|
||||||
relation: {
|
isLoading?: boolean;
|
||||||
type: String,
|
canDelete?: boolean;
|
||||||
required: true,
|
canEdit?: boolean;
|
||||||
},
|
canReorder?: boolean;
|
||||||
contributortypes: {
|
}
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
},
|
checkable: false,
|
||||||
errors: {
|
persons: () => [],
|
||||||
type: Object,
|
contributortypes: () => ({}),
|
||||||
default: () => ({}),
|
errors: () => ({}),
|
||||||
},
|
isLoading: false,
|
||||||
|
canDelete: true,
|
||||||
|
canEdit: true,
|
||||||
|
canReorder: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// const styleService = StyleService();
|
const emit = defineEmits<{
|
||||||
// const mainService = MainService();
|
'update:persons': [value: Person[]];
|
||||||
// const items = computed(() => props.persons);
|
'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);
|
||||||
|
|
||||||
|
// Name type options
|
||||||
|
const nameTypeOptions = {
|
||||||
|
'Personal': 'Personal',
|
||||||
|
'Organizational': 'Org'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
const items = computed({
|
const items = computed({
|
||||||
get() {
|
get() {
|
||||||
return props.persons;
|
return props.persons;
|
||||||
},
|
},
|
||||||
// setter
|
|
||||||
set(value) {
|
set(value) {
|
||||||
// Note: we are using destructuring assignment syntax here.
|
|
||||||
|
|
||||||
props.persons.length = 0;
|
props.persons.length = 0;
|
||||||
props.persons.push(...value);
|
props.persons.push(...value);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// const isModalActive = ref(false);
|
const itemsPaginated = computed(() => {
|
||||||
// const isModalDangerActive = ref(false);
|
const start = perPage.value * currentPage.value;
|
||||||
const perPage = ref(5);
|
const end = perPage.value * (currentPage.value + 1);
|
||||||
const currentPage = ref(0);
|
return items.value.slice(start, end);
|
||||||
// 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 numPages = computed(() => Math.ceil(items.value.length / perPage.value));
|
const numPages = computed(() => Math.ceil(items.value.length / perPage.value));
|
||||||
|
|
||||||
const currentPageHuman = computed(() => currentPage.value + 1);
|
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 = computed(() => {
|
||||||
const pagesList: Array<number> = [];
|
const pages: number[] = [];
|
||||||
|
const maxVisible = 10;
|
||||||
|
|
||||||
|
if (numPages.value <= maxVisible) {
|
||||||
for (let i = 0; i < numPages.value; i++) {
|
for (let i = 0; i < numPages.value; i++) {
|
||||||
pagesList.push(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) => {
|
// Methods
|
||||||
items.value.splice(key, 1);
|
const removeAuthor = (index: number) => {
|
||||||
|
const actualIndex = perPage.value * currentPage.value + index;
|
||||||
|
const person = items.value[actualIndex];
|
||||||
|
|
||||||
|
const displayName = person.name_type === 'Organizational'
|
||||||
|
? person.last_name || person.email
|
||||||
|
: `${person.first_name || ''} ${person.last_name || person.email}`.trim();
|
||||||
|
|
||||||
|
if (confirm(`Are you sure you want to remove ${displayName}?`)) {
|
||||||
|
items.value.splice(actualIndex, 1);
|
||||||
|
emit('remove-person', actualIndex, person);
|
||||||
|
|
||||||
|
if (itemsPaginated.value.length === 0 && currentPage.value > 0) {
|
||||||
|
currentPage.value--;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// const remove = (arr, cb) => {
|
const updatePerson = (index: number, field: keyof Person, value: any) => {
|
||||||
// const newArr = [];
|
const actualIndex = perPage.value * currentPage.value + index;
|
||||||
|
const person = items.value[actualIndex];
|
||||||
|
|
||||||
// arr.forEach((item) => {
|
// Handle name_type change - clear first_name if switching to Organizational
|
||||||
// if (!cb(item)) {
|
if (field === 'name_type' && value === 'Organizational') {
|
||||||
// newArr.push(item);
|
person.first_name = '';
|
||||||
// }
|
}
|
||||||
// });
|
|
||||||
|
|
||||||
// return newArr;
|
(person as any)[field] = value;
|
||||||
// };
|
emit('person-updated', actualIndex, person);
|
||||||
|
};
|
||||||
|
|
||||||
// const checked = (isChecked, client) => {
|
const goToPage = (page: number) => {
|
||||||
// if (isChecked) {
|
if (page >= 0 && page < numPages.value) {
|
||||||
// checkedRows.value.push(client);
|
currentPage.value = page;
|
||||||
// } else {
|
}
|
||||||
// checkedRows.value = remove(checkedRows.value, (row) => row.id === client.id);
|
};
|
||||||
// }
|
|
||||||
// };
|
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 handleDragEnd = (evt: any) => {
|
||||||
|
if (evt.oldIndex !== evt.newIndex) {
|
||||||
|
emit('reorder', evt.oldIndex, evt.newIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watchers
|
||||||
|
watch(
|
||||||
|
() => props.persons.length,
|
||||||
|
() => {
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- <CardBoxModal v-model="isModalActive" title="Sample modal">
|
<div class="card">
|
||||||
<p>Lorem ipsum dolor sit amet <b>adipiscing elit</b></p>
|
<!-- Table Controls -->
|
||||||
<p>This is sample modal</p>
|
<div v-if="hasMultiplePages" class="flex justify-between items-center px-4 py-2.5 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50">
|
||||||
</CardBoxModal>
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
<CardBoxModal v-model="isModalDangerActive" large-title="Please confirm" button="danger" has-cancel>
|
{{ currentPage * perPage + 1 }}-{{ Math.min((currentPage + 1) * perPage, items.length) }} of {{ items.length }}
|
||||||
<p>Lorem ipsum dolor sit amet <b>adipiscing elit</b></p>
|
|
||||||
<p>This is sample modal</p>
|
|
||||||
</CardBoxModal> -->
|
|
||||||
|
|
||||||
<!-- <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>
|
</span>
|
||||||
</div> -->
|
</div>
|
||||||
|
<select
|
||||||
|
v-model="perPage"
|
||||||
|
@change="currentPage = 0"
|
||||||
|
class="px-2 py-1 text-xs border rounded dark:bg-slate-800 dark:border-slate-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option v-for="option in perPageOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table>
|
<!-- Table -->
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full table-compact">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr class="bg-gray-50 dark:bg-slate-800/50 border-b border-gray-200 dark:border-slate-700">
|
||||||
<!-- <th v-if="checkable" /> -->
|
<th v-if="canReorder" class="w-8 px-2 py-2" />
|
||||||
<th />
|
<th scope="col" class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 w-10">#</th>
|
||||||
<th scope="col">Sort</th>
|
<th class="text-left px-2 py-2 text-[10px] font-semibold text-gray-600 dark:text-gray-300 w-40">Type</th>
|
||||||
<th scope="col">Id</th>
|
<th class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 min-w-[120px]">First Name</th>
|
||||||
<!-- <th class="hidden lg:table-cell"></th> -->
|
<th class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 min-w-[160px]">Last Name / Org</th>
|
||||||
<th>First Name</th>
|
<th class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 min-w-[140px]">ORCID</th>
|
||||||
<th>Last Name</th>
|
<th class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 min-w-[160px]">Email</th>
|
||||||
<th>Email</th>
|
<th v-if="showContributorTypes" scope="col" class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 w-32">Role</th>
|
||||||
<th scope="col" v-if="Object.keys(contributortypes).length">
|
<th v-if="canDelete" class="w-16 px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300">Actions</th>
|
||||||
<span>Type</span>
|
|
||||||
</th>
|
|
||||||
|
|
||||||
<!-- <th>Name Type</th> -->
|
|
||||||
<!-- <th>Progress</th> -->
|
|
||||||
<!-- <th>Created</th> -->
|
|
||||||
<th />
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<!-- <tbody> -->
|
|
||||||
<!-- <tr v-for="(client, index) in itemsPaginated" :key="client.id"> -->
|
<!-- Draggable tbody for non-paginated view -->
|
||||||
<draggable id="galliwasery" tag="tbody" v-model="items" item-key="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 }">
|
<template #item="{ index, element }">
|
||||||
<tr>
|
<tr class="border-b border-gray-100 dark:border-slate-800 hover:bg-blue-50 dark:hover:bg-slate-800/70 transition-colors">
|
||||||
<td class="drag-icon">
|
<td v-if="canReorder" class="px-2 py-2">
|
||||||
<BaseIcon :path="mdiDragVariant" />
|
<div class="drag-handle cursor-move text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
|
<BaseIcon :path="mdiDragVariant" :size="18" />
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td scope="row">{{ index + 1 }}</td>
|
<td class="px-2 py-2 text-xs text-gray-600 dark:text-gray-400">{{ index + 1 }}</td>
|
||||||
<td data-label="Id">{{ element.id }}</td>
|
|
||||||
<!-- <TableCheckboxCell v-if="checkable" @checked="checked($event, client)" /> -->
|
<!-- Name Type Selector -->
|
||||||
<!-- <td v-if="element.name" class="border-b-0 lg:w-6 before:hidden hidden lg:table-cell">
|
<td class="px-2 py-2">
|
||||||
<UserAvatar :username="element.name" class="w-24 h-24 mx-auto lg:w-6 lg:h-6" />
|
<div class="flex items-center gap-1.5">
|
||||||
</td> -->
|
<BaseIcon
|
||||||
<td data-label="First Name">
|
:path="element.name_type === 'Organizational' ? mdiDomain : mdiAccount"
|
||||||
<!-- {{ element.first_name }} -->
|
:size="16"
|
||||||
|
:class="element.name_type === 'Organizational' ? 'text-purple-500' : 'text-blue-500'"
|
||||||
|
:title="element.name_type"
|
||||||
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
required
|
required
|
||||||
v-model="element.first_name"
|
v-model="element.name_type"
|
||||||
type="text" :is-read-only="element.status==true"
|
type="select"
|
||||||
placeholder="[FIRST NAME]"
|
:options="nameTypeOptions"
|
||||||
>
|
:is-read-only="element.status == true"
|
||||||
<div
|
class="text-[8px] compact-select-mini flex-1"
|
||||||
class="text-red-400 text-sm"
|
/>
|
||||||
v-if="errors && Array.isArray(errors[`${relation}.${index}.first_name`])"
|
</div>
|
||||||
>
|
<div class="text-red-500 text-[8px] mt-0.5" v-if="errors && Array.isArray(errors[`${relation}.${index}.name_type`])">
|
||||||
{{ errors[`${relation}.${index}.first_name`].join(', ') }}
|
{{ errors[`${relation}.${index}.name_type`][0] }}
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Last Name">
|
|
||||||
|
<!-- First Name - Only shown for Personal type -->
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
<FormControl
|
||||||
|
v-if="element.name_type !== 'Organizational'"
|
||||||
|
required
|
||||||
|
v-model="element.first_name"
|
||||||
|
type="text"
|
||||||
|
:is-read-only="element.status == true"
|
||||||
|
placeholder="First name"
|
||||||
|
class="text-xs compact-input"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-gray-400 text-xs italic">—</span>
|
||||||
|
<div class="text-red-500 text-xs mt-0.5" v-if="errors && Array.isArray(errors[`${relation}.${index}.first_name`])">
|
||||||
|
{{ errors[`${relation}.${index}.first_name`][0] }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Last Name / Organization Name -->
|
||||||
|
<td class="px-2 py-2">
|
||||||
<FormControl
|
<FormControl
|
||||||
required
|
required
|
||||||
v-model="element.last_name"
|
v-model="element.last_name"
|
||||||
type="text" :is-read-only="element.status==true"
|
type="text"
|
||||||
placeholder="[LAST NAME]"
|
:is-read-only="element.status == true"
|
||||||
>
|
:placeholder="element.name_type === 'Organizational' ? 'Organization' : 'Last name'"
|
||||||
<div
|
class="text-xs compact-input"
|
||||||
class="text-red-400 text-sm"
|
/>
|
||||||
v-if="errors && Array.isArray(errors[`${relation}.${index}.last_name`])"
|
<div class="text-red-500 text-xs mt-0.5" v-if="errors && Array.isArray(errors[`${relation}.${index}.last_name`])">
|
||||||
>
|
{{ errors[`${relation}.${index}.last_name`][0] }}
|
||||||
{{ errors[`${relation}.${index}.last_name`].join(', ') }}
|
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Email">
|
|
||||||
|
<!-- Orcid -->
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
<FormControl
|
||||||
|
v-model="element.identifier_orcid"
|
||||||
|
type="text"
|
||||||
|
:is-read-only="element.status == true"
|
||||||
|
placeholder="0000-0000-0000-0000"
|
||||||
|
class="text-xs compact-input font-mono"
|
||||||
|
/>
|
||||||
|
<div class="text-red-500 text-xs mt-0.5" v-if="errors && Array.isArray(errors[`${relation}.${index}.identifier_orcid`])">
|
||||||
|
{{ errors[`${relation}.${index}.identifier_orcid`][0] }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<td class="px-2 py-2">
|
||||||
<FormControl
|
<FormControl
|
||||||
required
|
required
|
||||||
v-model="element.email"
|
v-model="element.email"
|
||||||
type="text" :is-read-only="element.status==true"
|
type="email"
|
||||||
placeholder="[EMAIL]"
|
:is-read-only="element.status == true"
|
||||||
>
|
placeholder="email@example.com"
|
||||||
<div
|
class="text-xs compact-input"
|
||||||
class="text-red-400 text-sm"
|
/>
|
||||||
v-if="errors && Array.isArray(errors[`${relation}.${index}.email`])"
|
<div class="text-red-500 text-xs mt-0.5" v-if="errors && Array.isArray(errors[`${relation}.${index}.email`])">
|
||||||
>
|
{{ errors[`${relation}.${index}.email`][0] }}
|
||||||
{{ errors[`${relation}.${index}.email`].join(', ') }}
|
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
|
||||||
</td>
|
</td>
|
||||||
<td v-if="Object.keys(contributortypes).length">
|
|
||||||
<!-- <select type="text" v-model="element.pivot.contributor_type">
|
<!-- Contributor Type -->
|
||||||
<option v-for="(option, i) in contributortypes" :value="option" :key="i">
|
<td v-if="Object.keys(contributortypes).length" class="px-2 py-2">
|
||||||
{{ option }}
|
|
||||||
</option>
|
|
||||||
</select> -->
|
|
||||||
<FormControl
|
<FormControl
|
||||||
required
|
required
|
||||||
v-model="element.pivot_contributor_type"
|
v-model="element.pivot_contributor_type"
|
||||||
type="select"
|
type="select"
|
||||||
:options="contributortypes"
|
:options="contributortypes"
|
||||||
placeholder="[relation type]"
|
placeholder="Role"
|
||||||
>
|
class="text-xs compact-select"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
class="text-red-400 text-sm"
|
class="text-red-500 text-xs mt-0.5"
|
||||||
v-if="errors && Array.isArray(errors[`${relation}.${index}.pivot_contributor_type`])"
|
v-if="errors && Array.isArray(errors[`${relation}.${index}.pivot_contributor_type`])"
|
||||||
>
|
>
|
||||||
{{ errors[`${relation}.${index}.pivot_contributor_type`].join(', ') }}
|
{{ errors[`${relation}.${index}.pivot_contributor_type`][0] }}
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
|
||||||
</td>
|
</td>
|
||||||
<!-- <td data-label="Name Type">
|
|
||||||
{{ client.name_type }}
|
<!-- Actions -->
|
||||||
</td> -->
|
<td class="px-2 py-2 whitespace-nowrap">
|
||||||
<!-- <td data-label="Orcid">
|
<BaseButton
|
||||||
{{ client.identifier_orcid }}
|
color="danger"
|
||||||
</td> -->
|
:icon="mdiTrashCan"
|
||||||
<!-- <td data-label="Progress" class="lg:w-32">
|
small
|
||||||
<progress class="flex w-2/5 self-center lg:w-full" max="100" v-bind:value="client.progress">
|
@click.prevent="removeAuthor(index)"
|
||||||
{{ client.progress }}
|
class="compact-button"
|
||||||
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
<!-- </tbody> -->
|
|
||||||
</table>
|
<!-- Non-draggable tbody for paginated view -->
|
||||||
<!-- :class="[ pagesList.length > 1 ? 'block' : 'hidden']" -->
|
<tbody v-else>
|
||||||
<div class="p-3 lg:px-6 border-t border-gray-100 dark:border-slate-800">
|
<tr
|
||||||
<!-- <BaseLevel>
|
v-for="(element, index) in itemsPaginated"
|
||||||
<BaseButtons>
|
:key="element.id || index"
|
||||||
|
class="border-b border-gray-100 dark:border-slate-800 hover:bg-blue-50 dark:hover:bg-slate-800/70 transition-colors"
|
||||||
|
>
|
||||||
|
<td v-if="canReorder" class="px-2 py-2 text-gray-400">
|
||||||
|
<BaseIcon :path="mdiDragVariant" :size="18" />
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-xs text-gray-600 dark:text-gray-400">{{ currentPage * perPage + index + 1 }}</td>
|
||||||
|
|
||||||
|
<!-- Name Type Selector -->
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
<BaseIcon
|
||||||
|
:path="element.name_type === 'Organizational' ? mdiDomain : mdiAccount"
|
||||||
|
:size="16"
|
||||||
|
:class="element.name_type === 'Organizational' ? 'text-purple-500' : 'text-blue-500'"
|
||||||
|
:title="element.name_type"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
required
|
||||||
|
:model-value="element.name_type"
|
||||||
|
@update:model-value="updatePerson(index, 'name_type', $event)"
|
||||||
|
type="select"
|
||||||
|
:options="nameTypeOptions"
|
||||||
|
:is-read-only="element.status || !canEdit"
|
||||||
|
class="text-xs compact-select"
|
||||||
|
:error="getFieldError(index, 'name_type')"
|
||||||
|
/>
|
||||||
|
<div v-if="getFieldError(index, 'name_type')" class="text-red-500 text-xs mt-0.5">
|
||||||
|
{{ getFieldError(index, 'name_type') }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- First Name -->
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
<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"
|
||||||
|
class="text-xs compact-input"
|
||||||
|
:error="getFieldError(index, 'first_name')"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-gray-400 text-xs italic">—</span>
|
||||||
|
<div v-if="getFieldError(index, 'first_name')" class="text-red-500 text-xs mt-0.5">
|
||||||
|
{{ getFieldError(index, 'first_name') }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Last Name / Organization -->
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
<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' : 'Last name'"
|
||||||
|
class="text-xs compact-input"
|
||||||
|
:error="getFieldError(index, 'last_name')"
|
||||||
|
/>
|
||||||
|
<div v-if="getFieldError(index, 'last_name')" class="text-red-500 text-xs mt-0.5">
|
||||||
|
{{ getFieldError(index, 'last_name') }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Orcid -->
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
<FormControl
|
||||||
|
:model-value="element.identifier_orcid"
|
||||||
|
@update:model-value="updatePerson(index, 'identifier_orcid', $event)"
|
||||||
|
type="text"
|
||||||
|
:is-read-only="element.status || !canEdit"
|
||||||
|
placeholder="0000-0000-0000-0000"
|
||||||
|
class="text-xs compact-input font-mono"
|
||||||
|
:error="getFieldError(index, 'identifier_orcid')"
|
||||||
|
/>
|
||||||
|
<div v-if="getFieldError(index, 'identifier_orcid')" class="text-red-500 text-xs mt-0.5">
|
||||||
|
{{ getFieldError(index, 'identifier_orcid') }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
<FormControl
|
||||||
|
required
|
||||||
|
:model-value="element.email"
|
||||||
|
@update:model-value="updatePerson(index, 'email', $event)"
|
||||||
|
type="email"
|
||||||
|
:is-read-only="element.status || !canEdit"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
class="text-xs compact-input"
|
||||||
|
:error="getFieldError(index, 'email')"
|
||||||
|
/>
|
||||||
|
<div v-if="getFieldError(index, 'email')" class="text-red-500 text-xs mt-0.5">
|
||||||
|
{{ getFieldError(index, 'email') }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Contributor Type -->
|
||||||
|
<td v-if="showContributorTypes" class="px-2 py-2">
|
||||||
|
<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="Role"
|
||||||
|
class="text-xs compact-select"
|
||||||
|
:error="getFieldError(index, 'pivot_contributor_type')"
|
||||||
|
/>
|
||||||
|
<div v-if="getFieldError(index, 'pivot_contributor_type')" class="text-red-500 text-xs mt-0.5">
|
||||||
|
{{ getFieldError(index, 'pivot_contributor_type') }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<td v-if="canDelete" class="px-2 py-2 whitespace-nowrap">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-for="page in pagesList"
|
color="danger"
|
||||||
:key="page"
|
:icon="mdiTrashCan"
|
||||||
:active="page === currentPage"
|
|
||||||
:label="page + 1"
|
|
||||||
small
|
small
|
||||||
:outline="styleService.darkMode"
|
@click="removeAuthor(index)"
|
||||||
@click="currentPage = page"
|
:disabled="element.status || !canEdit"
|
||||||
|
title="Remove person"
|
||||||
|
class="compact-button"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<!-- <tr v-if="items.length === 0">
|
||||||
|
<td :colspan="showContributorTypes ? 9 : 8" class="text-center py-12 text-gray-400">
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<BaseIcon :path="mdiBookOpenPageVariant" :size="32" class="text-gray-300" />
|
||||||
|
<span class="text-sm">No persons added yet</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>-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>
|
</BaseButtons>
|
||||||
<small>Page {{ currentPageHuman }} of {{ numPages }}</small>
|
</td>
|
||||||
</BaseLevel> -->
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr v-if="items.length === 0">
|
||||||
|
<td :colspan="showContributorTypes ? 10 : 9" 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
|
||||||
|
:disabled="currentPage >= numPages - 1"
|
||||||
|
@click="goToPage(currentPage + 1)"
|
||||||
|
:icon="mdiChevronRight"
|
||||||
|
small
|
||||||
|
outline
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Page {{ currentPageHuman }} of {{ numPages }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
table {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.5rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
287
resources/js/Components/UnsavedChangesWarning.vue
Normal file
287
resources/js/Components/UnsavedChangesWarning.vue
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
<template>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition ease-out duration-300"
|
||||||
|
enter-from-class="opacity-0 transform -translate-y-2"
|
||||||
|
enter-to-class="opacity-100 transform translate-y-0"
|
||||||
|
leave-active-class="transition ease-in duration-200"
|
||||||
|
leave-from-class="opacity-100 transform translate-y-0"
|
||||||
|
leave-to-class="opacity-0 transform -translate-y-2"
|
||||||
|
>
|
||||||
|
<div v-if="show" class="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg shadow-sm" role="alert" aria-live="polite">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<WarningTriangleIcon class="h-5 w-5 text-amber-500" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-3 flex-1">
|
||||||
|
<h3 class="text-sm font-medium text-amber-800">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<div class="mt-1 text-sm text-amber-700">
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
|
||||||
|
<!-- Optional detailed list of changes -->
|
||||||
|
<div v-if="showDetails && changesSummary.length > 0" class="mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click.stop="toggleDetails"
|
||||||
|
class="text-amber-800 underline hover:text-amber-900 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-amber-50 rounded"
|
||||||
|
>
|
||||||
|
{{ detailsVisible ? 'Hide details' : 'Show details' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0 max-h-0"
|
||||||
|
enter-to-class="opacity-100 max-h-40"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100 max-h-40"
|
||||||
|
leave-to-class="opacity-0 max-h-0"
|
||||||
|
>
|
||||||
|
<div v-if="detailsVisible" class="mt-2 overflow-hidden">
|
||||||
|
<ul class="text-xs text-amber-600 space-y-1">
|
||||||
|
<li v-for="change in changesSummary" :key="change" class="flex items-center">
|
||||||
|
<div class="w-1 h-1 bg-amber-400 rounded-full mr-2"></div>
|
||||||
|
{{ change }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div v-if="showActions" class="ml-4 flex-shrink-0 flex space-x-2">
|
||||||
|
<button
|
||||||
|
v-if="onSave"
|
||||||
|
type="button"
|
||||||
|
@click.stop="handleSave"
|
||||||
|
:disabled="isSaving"
|
||||||
|
class="bg-amber-100 text-amber-800 px-3 py-1 rounded text-sm font-medium hover:bg-amber-200 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-amber-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<span v-if="!isSaving">Save Now</span>
|
||||||
|
<span v-else class="flex items-center">
|
||||||
|
<LoadingSpinner class="w-3 h-3 mr-1" />
|
||||||
|
Saving...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="onDismiss"
|
||||||
|
type="button"
|
||||||
|
@click="handleDismiss"
|
||||||
|
class="text-amber-600 hover:text-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-amber-50 rounded p-1"
|
||||||
|
:title="dismissLabel"
|
||||||
|
>
|
||||||
|
<XMarkIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
|
<span class="sr-only">{{ dismissLabel }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress indicator for auto-save -->
|
||||||
|
<div v-if="showAutoSaveProgress && autoSaveCountdown > 0" class="mt-3">
|
||||||
|
<div class="flex items-center justify-between text-xs text-amber-600">
|
||||||
|
<span>Auto-save in {{ autoSaveCountdown }}s</span>
|
||||||
|
<button @click="cancelAutoSave" class="underline hover:text-amber-700">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 w-full bg-amber-200 rounded-full h-1">
|
||||||
|
<div
|
||||||
|
class="bg-amber-500 h-1 rounded-full transition-all duration-1000 ease-linear"
|
||||||
|
:style="{ width: `${((initialCountdown - autoSaveCountdown) / initialCountdown) * 100}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch, defineComponent } from 'vue';
|
||||||
|
|
||||||
|
// Icons - you can replace these with your preferred icon library
|
||||||
|
const WarningTriangleIcon = defineComponent({
|
||||||
|
template: `
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const XMarkIcon = defineComponent({
|
||||||
|
template: `
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const LoadingSpinner = defineComponent({
|
||||||
|
template: `
|
||||||
|
<svg class="animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// Control visibility
|
||||||
|
show?: boolean;
|
||||||
|
|
||||||
|
// Content
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
changesSummary?: string[];
|
||||||
|
|
||||||
|
// Behavior
|
||||||
|
showDetails?: boolean;
|
||||||
|
showActions?: boolean;
|
||||||
|
showAutoSaveProgress?: boolean;
|
||||||
|
autoSaveDelay?: number; // seconds
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
onSave?: () => Promise<void> | void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
onAutoSave?: () => Promise<void> | void;
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
dismissLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
show: true,
|
||||||
|
title: 'You have unsaved changes',
|
||||||
|
message: 'Your changes will be lost if you leave this page without saving.',
|
||||||
|
changesSummary: () => [],
|
||||||
|
showDetails: false,
|
||||||
|
showActions: true,
|
||||||
|
showAutoSaveProgress: false,
|
||||||
|
autoSaveDelay: 30,
|
||||||
|
dismissLabel: 'Dismiss warning',
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
save: [];
|
||||||
|
dismiss: [];
|
||||||
|
autoSave: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const detailsVisible = ref(false);
|
||||||
|
const isSaving = ref(false);
|
||||||
|
const autoSaveCountdown = ref(0);
|
||||||
|
const initialCountdown = ref(0);
|
||||||
|
let autoSaveTimer: NodeJS.Timeout | null = null;
|
||||||
|
let countdownTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const toggleDetails = () => {
|
||||||
|
detailsVisible.value = !detailsVisible.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (isSaving.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSaving.value = true;
|
||||||
|
await props.onSave?.();
|
||||||
|
emit('save');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save failed:', error);
|
||||||
|
// You might want to emit an error event here
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
props.onDismiss?.();
|
||||||
|
emit('dismiss');
|
||||||
|
stopAutoSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAutoSave = () => {
|
||||||
|
if (!props.onAutoSave || autoSaveTimer) return;
|
||||||
|
|
||||||
|
autoSaveCountdown.value = props.autoSaveDelay;
|
||||||
|
initialCountdown.value = props.autoSaveDelay;
|
||||||
|
|
||||||
|
// Countdown timer
|
||||||
|
countdownTimer = setInterval(() => {
|
||||||
|
autoSaveCountdown.value--;
|
||||||
|
|
||||||
|
if (autoSaveCountdown.value <= 0) {
|
||||||
|
executeAutoSave();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeAutoSave = async () => {
|
||||||
|
stopAutoSave();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await props.onAutoSave?.();
|
||||||
|
emit('autoSave');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto-save failed:', error);
|
||||||
|
// Optionally restart auto-save on failure
|
||||||
|
if (props.show) {
|
||||||
|
startAutoSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelAutoSave = () => {
|
||||||
|
stopAutoSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAutoSave = () => {
|
||||||
|
if (autoSaveTimer) {
|
||||||
|
clearTimeout(autoSaveTimer);
|
||||||
|
autoSaveTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countdownTimer) {
|
||||||
|
clearInterval(countdownTimer);
|
||||||
|
countdownTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoSaveCountdown.value = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watchers
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(newShow) => {
|
||||||
|
if (newShow && props.showAutoSaveProgress && props.onAutoSave) {
|
||||||
|
startAutoSave();
|
||||||
|
} else if (!newShow) {
|
||||||
|
stopAutoSave();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.show && props.showAutoSaveProgress && props.onAutoSave) {
|
||||||
|
startAutoSave();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopAutoSave();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Additional custom styles if needed */
|
||||||
|
.max-h-0 {
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-h-40 {
|
||||||
|
max-height: 10rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -132,13 +132,25 @@ export interface Description {
|
||||||
|
|
||||||
export interface Person {
|
export interface Person {
|
||||||
id?: number;
|
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;
|
email: string;
|
||||||
name_type?: string;
|
name_type?: string;
|
||||||
|
// Additional identifiers
|
||||||
identifier_orcid?: string;
|
identifier_orcid?: string;
|
||||||
datasetCount?: string;
|
|
||||||
|
// Status and metadata
|
||||||
|
status: boolean; // true = read-only/locked, false = editable
|
||||||
created_at?: string;
|
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 {
|
interface IErrorMessage {
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,11 @@ const props = defineProps({
|
||||||
showAsideMenu: {
|
showAsideMenu: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true // Set default value to true
|
default: true // Set default value to true
|
||||||
|
},
|
||||||
|
hasProgressBar: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false // New prop to indicate if progress bar is shown
|
||||||
}
|
}
|
||||||
// user: {
|
|
||||||
// type: Object,
|
|
||||||
// default: () => ({}),
|
|
||||||
// }
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -29,9 +29,18 @@ const props = defineProps({
|
||||||
}">
|
}">
|
||||||
<div :class="{
|
<div :class="{
|
||||||
'ml-60 lg:ml-0': layoutService.isAsideMobileExpanded,
|
'ml-60 lg:ml-0': layoutService.isAsideMobileExpanded,
|
||||||
'xl:pl-60': props.showAsideMenu==true }"
|
'xl:pl-60': props.showAsideMenu==true,
|
||||||
class="pt-14 min-h-screen w-screen transition-position lg:w-auto bg-gray-50 dark:bg-slate-800 dark:text-slate-100">
|
'pt-14': !props.hasProgressBar,
|
||||||
<NavBar :class="{ 'ml-60 lg:ml-0': layoutService.isAsideMobileExpanded }" :showBurger="props.showAsideMenu" />
|
'pt-24': props.hasProgressBar // Increased padding when progress bar is present (pt-14 + height of progress bar)
|
||||||
|
}"
|
||||||
|
class="min-h-screen w-screen transition-position lg:w-auto bg-gray-50 dark:bg-slate-800 dark:text-slate-100">
|
||||||
|
<NavBar
|
||||||
|
:class="{
|
||||||
|
'ml-60 lg:ml-0': layoutService.isAsideMobileExpanded,
|
||||||
|
'top-10': props.hasProgressBar // Push NavBar down when progress bar is present
|
||||||
|
}"
|
||||||
|
:showBurger="props.showAsideMenu"
|
||||||
|
/>
|
||||||
<!-- Conditionally render AsideMenu based on showAsideMenu prop -->
|
<!-- Conditionally render AsideMenu based on showAsideMenu prop -->
|
||||||
<template v-if="showAsideMenu">
|
<template v-if="showAsideMenu">
|
||||||
<AsideMenu />
|
<AsideMenu />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { usePage } from '@inertiajs/vue3';
|
import { Head, usePage } from '@inertiajs/vue3';
|
||||||
import { mdiAccountKey, mdiSquareEditOutline, mdiAlertBoxOutline } from '@mdi/js';
|
import { mdiLicense, mdiCheckCircle, mdiCloseCircle, mdiAlertBoxOutline } from '@mdi/js';
|
||||||
import { computed, ComputedRef } from 'vue';
|
import { computed, ComputedRef } from 'vue';
|
||||||
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
||||||
import SectionMain from '@/Components/SectionMain.vue';
|
import SectionMain from '@/Components/SectionMain.vue';
|
||||||
|
|
@ -9,107 +9,150 @@ import BaseButton from '@/Components/BaseButton.vue';
|
||||||
import CardBox from '@/Components/CardBox.vue';
|
import CardBox from '@/Components/CardBox.vue';
|
||||||
import BaseButtons from '@/Components/BaseButtons.vue';
|
import BaseButtons from '@/Components/BaseButtons.vue';
|
||||||
import NotificationBar from '@/Components/NotificationBar.vue';
|
import NotificationBar from '@/Components/NotificationBar.vue';
|
||||||
// import Pagination from '@/Components/Admin/Pagination.vue';
|
|
||||||
// import Sort from '@/Components/Admin/Sort.vue';
|
|
||||||
import { stardust } from '@eidellev/adonis-stardust/client';
|
import { stardust } from '@eidellev/adonis-stardust/client';
|
||||||
// import CardBoxModal from '@/Components/CardBoxModal.vue';
|
|
||||||
|
|
||||||
// const isModalDangerActive = ref(false);
|
interface License {
|
||||||
// const deleteId = ref();
|
id: number;
|
||||||
|
name: string;
|
||||||
|
sort_order: number;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
licenses: {
|
licenses: {
|
||||||
type: Object,
|
type: Array<License>,
|
||||||
default: () => ({}),
|
default: () => [],
|
||||||
},
|
},
|
||||||
// filters: {
|
|
||||||
// type: Object,
|
|
||||||
// default: () => ({}),
|
|
||||||
// },
|
|
||||||
can: {
|
can: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const flash: ComputedRef<any> = computed(() => {
|
const flash: ComputedRef<any> = computed(() => usePage().props.flash);
|
||||||
// let test = usePage();
|
|
||||||
// console.log(test);
|
|
||||||
return usePage().props.flash;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const licenseCount = computed(() => props.licenses.length);
|
||||||
|
|
||||||
|
const getLicenseColor = (index: number) => {
|
||||||
|
const colors = [
|
||||||
|
'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-300',
|
||||||
|
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
|
||||||
|
'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-300',
|
||||||
|
'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300',
|
||||||
|
'bg-rose-100 text-rose-800 dark:bg-rose-900 dark:text-rose-300',
|
||||||
|
'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-300',
|
||||||
|
];
|
||||||
|
return colors[index % colors.length];
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<LayoutAuthenticated>
|
<LayoutAuthenticated>
|
||||||
|
|
||||||
<Head title="Licenses" />
|
<Head title="Licenses" />
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton :icon="mdiAccountKey" title="Licenses" main>
|
<SectionTitleLineWithButton :icon="mdiLicense" title="Licenses" main>
|
||||||
<!-- <BaseButton
|
<div class="flex items-center gap-3">
|
||||||
v-if="can.create"
|
<span class="text-sm text-gray-500 dark:text-gray-400 font-medium">
|
||||||
:route-name="stardust.route('settings.role.create')"
|
{{ licenseCount }} {{ licenseCount === 1 ? 'license' : 'licenses' }}
|
||||||
:icon="mdiPlus"
|
</span>
|
||||||
label="Add"
|
</div>
|
||||||
color="info"
|
|
||||||
rounded-full
|
|
||||||
small
|
|
||||||
/> -->
|
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
<NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline">
|
<NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline">
|
||||||
{{ flash.message }}
|
{{ flash.message }}
|
||||||
</NotificationBar>
|
</NotificationBar>
|
||||||
|
|
||||||
<CardBox class="mb-6" has-table>
|
<CardBox class="mb-6" has-table>
|
||||||
</CardBox>
|
|
||||||
<CardBox class="mb-6" has-form-data>
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>Name</th>
|
||||||
<!-- <Sort label="Name" attribute="name" /> -->
|
<th>Sort Order</th>
|
||||||
Name
|
<th>Status</th>
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<!-- <Sort label="Sort Order" attribute="sort_order" /> -->
|
|
||||||
Sort Order
|
|
||||||
</th>
|
|
||||||
<th v-if="can.edit">Actions</th>
|
<th v-if="can.edit">Actions</th>
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="license in licenses" :key="license.id">
|
<tr v-if="licenses.length === 0">
|
||||||
|
<td colspan="4" class="text-center py-12">
|
||||||
|
<div class="flex flex-col items-center justify-center text-gray-500 dark:text-gray-400">
|
||||||
|
<p class="text-lg font-medium mb-2">No licenses found</p>
|
||||||
|
<p class="text-sm">Licenses will appear here once configured</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
v-for="(license, index) in licenses"
|
||||||
|
:key="license.id"
|
||||||
|
class="hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
<td data-label="Name">
|
<td data-label="Name">
|
||||||
<!-- <Link
|
<span
|
||||||
:href="stardust.route('settings.role.show', [role.id])"
|
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium transition-all hover:shadow-md"
|
||||||
class="no-underline hover:underline text-cyan-600 dark:text-cyan-400"
|
:class="getLicenseColor(index)"
|
||||||
>
|
>
|
||||||
{{ license.name }}
|
{{ license.name }}
|
||||||
</Link> -->
|
</span>
|
||||||
{{ license.name }}
|
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Description">
|
<td data-label="Sort Order">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-300 font-semibold text-sm"
|
||||||
|
>
|
||||||
{{ license.sort_order }}
|
{{ license.sort_order }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td data-label="Status">
|
||||||
|
<span
|
||||||
|
v-if="license.active"
|
||||||
|
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td v-if="can.edit" class="before:hidden lg:w-1 whitespace-nowrap">
|
<td v-if="can.edit" class="before:hidden lg:w-1 whitespace-nowrap">
|
||||||
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
||||||
<BaseButton v-if="license.active"
|
<BaseButton
|
||||||
|
v-if="license.active"
|
||||||
:route-name="stardust.route('settings.license.down', [license.id])"
|
:route-name="stardust.route('settings.license.down', [license.id])"
|
||||||
color="warning" :icon="mdiSquareEditOutline" label="deactivate" small />
|
color="warning"
|
||||||
<BaseButton v-else :route-name="stardust.route('settings.license.up', [license.id])"
|
:icon="mdiCloseCircle"
|
||||||
color="success" :icon="mdiSquareEditOutline" label="activate" small />
|
label="Deactivate"
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
v-else
|
||||||
|
:route-name="stardust.route('settings.license.up', [license.id])"
|
||||||
|
color="success"
|
||||||
|
:icon="mdiCheckCircle"
|
||||||
|
label="Activate"
|
||||||
|
small
|
||||||
|
/>
|
||||||
</BaseButtons>
|
</BaseButtons>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<!-- <div class="py-4">
|
|
||||||
<Pagination v-bind:data="roles.meta" />
|
|
||||||
</div> -->
|
|
||||||
</CardBox>
|
</CardBox>
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
</LayoutAuthenticated>
|
</LayoutAuthenticated>
|
||||||
|
|
|
||||||
135
resources/js/Pages/Admin/Project/Create.vue
Normal file
135
resources/js/Pages/Admin/Project/Create.vue
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
|
import { mdiFolderPlus, mdiArrowLeftBoldOutline, mdiFormTextarea, mdiContentSave } from '@mdi/js';
|
||||||
|
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
||||||
|
import SectionMain from '@/Components/SectionMain.vue';
|
||||||
|
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
|
||||||
|
import CardBox from '@/Components/CardBox.vue';
|
||||||
|
import FormField from '@/Components/FormField.vue';
|
||||||
|
import FormControl from '@/Components/FormControl.vue';
|
||||||
|
import BaseButton from '@/Components/BaseButton.vue';
|
||||||
|
import BaseButtons from '@/Components/BaseButtons.vue';
|
||||||
|
import { stardust } from '@eidellev/adonis-stardust/client';
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
label: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
await form.post(stardust.route('settings.project.store'));
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LayoutAuthenticated>
|
||||||
|
<Head title="Create New Project" />
|
||||||
|
<SectionMain>
|
||||||
|
<SectionTitleLineWithButton :icon="mdiFolderPlus" title="Create New Project" main>
|
||||||
|
<BaseButton
|
||||||
|
:route-name="stardust.route('settings.project.index')"
|
||||||
|
:icon="mdiArrowLeftBoldOutline"
|
||||||
|
label="Back"
|
||||||
|
color="white"
|
||||||
|
rounded-full
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
<CardBox form @submit.prevent="submit()" class="shadow-lg">
|
||||||
|
<div class="grid grid-cols-1 gap-6">
|
||||||
|
<FormField label="Label" help="Lowercase letters, numbers, and hyphens only" :class="{ 'text-red-400': form.errors.label }">
|
||||||
|
<FormControl
|
||||||
|
v-model="form.label"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., my-awesome-project"
|
||||||
|
required
|
||||||
|
:error="form.errors.label"
|
||||||
|
class="transition-all focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<div class="text-red-400 text-sm mt-1" v-if="form.errors.label">
|
||||||
|
{{ form.errors.label }}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Name"
|
||||||
|
help="Required. Project title shown to users"
|
||||||
|
:class="{ 'text-red-400': form.errors.name }"
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
v-model="form.name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter a descriptive titel..."
|
||||||
|
required
|
||||||
|
:error="form.errors.name"
|
||||||
|
class="font-mono transition-all focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<div class="text-red-400 text-sm mt-1" v-if="form.errors.name">
|
||||||
|
{{ form.errors.name }}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Description"
|
||||||
|
help="Optional. Detailed description of the project"
|
||||||
|
:class="{ 'text-red-400': form.errors.description }"
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
v-model="form.description"
|
||||||
|
:icon="mdiFormTextarea"
|
||||||
|
name="description"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="Describe what this project is about..."
|
||||||
|
:error="form.errors.description"
|
||||||
|
class="transition-all focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<div class="text-red-400 text-sm mt-1" v-if="form.errors.description">
|
||||||
|
{{ form.errors.description }}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<BaseButtons class="justify-between">
|
||||||
|
<BaseButton :route-name="stardust.route('settings.project.index')" label="Cancel" color="white" outline />
|
||||||
|
<BaseButton
|
||||||
|
type="submit"
|
||||||
|
color="info"
|
||||||
|
:icon="mdiContentSave"
|
||||||
|
label="Create Project"
|
||||||
|
:class="{ 'opacity-25': form.processing }"
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="transition-all hover:shadow-lg"
|
||||||
|
/>
|
||||||
|
</BaseButtons>
|
||||||
|
</template>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<!-- Helper Card -->
|
||||||
|
<CardBox
|
||||||
|
class="mt-6 bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-slate-800 dark:to-slate-900 border-l-4 border-blue-500"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-blue-500 dark:bg-blue-600 flex items-center justify-center">
|
||||||
|
<span class="text-white text-lg">💡</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Quick Tips</h3>
|
||||||
|
<ul class="text-sm text-gray-700 dark:text-gray-300 space-y-1">
|
||||||
|
<li>• <strong>Label</strong> is a technical identifier (use lowercase and hyphens) </li>
|
||||||
|
<li>• <strong>Name</strong> is what users will see in the interface - short title</li>
|
||||||
|
<li>• <strong>Description</strong> helps team members understand the project's purpose</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</SectionMain>
|
||||||
|
</LayoutAuthenticated>
|
||||||
|
</template>
|
||||||
155
resources/js/Pages/Admin/Project/Edit.vue
Normal file
155
resources/js/Pages/Admin/Project/Edit.vue
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
|
import { mdiFolderEdit, mdiArrowLeftBoldOutline, mdiFormTextarea, mdiContentSave } from '@mdi/js';
|
||||||
|
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
||||||
|
import SectionMain from '@/Components/SectionMain.vue';
|
||||||
|
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
|
||||||
|
import CardBox from '@/Components/CardBox.vue';
|
||||||
|
import FormField from '@/Components/FormField.vue';
|
||||||
|
import FormControl from '@/Components/FormControl.vue';
|
||||||
|
import BaseButton from '@/Components/BaseButton.vue';
|
||||||
|
import BaseButtons from '@/Components/BaseButtons.vue';
|
||||||
|
import { stardust } from '@eidellev/adonis-stardust/client';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
project: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
label: props.project.label,
|
||||||
|
name: props.project.name,
|
||||||
|
description: props.project.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
await form.put(stardust.route('settings.project.update', [props.project.id]));
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LayoutAuthenticated>
|
||||||
|
<Head :title="`Edit ${project.label}`" />
|
||||||
|
<SectionMain>
|
||||||
|
<SectionTitleLineWithButton :icon="mdiFolderEdit" :title="`Edit: ${project.label}`" main>
|
||||||
|
<BaseButton
|
||||||
|
:route-name="stardust.route('settings.project.index')"
|
||||||
|
:icon="mdiArrowLeftBoldOutline"
|
||||||
|
label="Back"
|
||||||
|
color="white"
|
||||||
|
rounded-full
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
<CardBox form @submit.prevent="submit()" class="shadow-lg">
|
||||||
|
<div class="grid grid-cols-1 gap-6">
|
||||||
|
<FormField
|
||||||
|
label="Label"
|
||||||
|
help="Lowercase letters, numbers, and hyphens only"
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
v-model="form.label"
|
||||||
|
type="text"
|
||||||
|
help="Lowercase letters, numbers, and hyphens only"
|
||||||
|
:is-read-only=true
|
||||||
|
class="bg-gray-100 dark:bg-slate-800 cursor-not-allowed opacity-75"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Name"
|
||||||
|
help="Required. Project title shown to users"
|
||||||
|
:class="{ 'text-red-400': form.errors.name }"
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
v-model="form.name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter Name"
|
||||||
|
required
|
||||||
|
:error="form.errors.name"
|
||||||
|
class="font-mono transition-all focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<div class="text-red-400 text-sm mt-1" v-if="form.errors.name">
|
||||||
|
{{ form.errors.name }}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Description"
|
||||||
|
help="Optional. Detailed description of the project"
|
||||||
|
:class="{ 'text-red-400': form.errors.description }"
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
v-model="form.description"
|
||||||
|
:icon="mdiFormTextarea"
|
||||||
|
name="description"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="Enter project description..."
|
||||||
|
:error="form.errors.description"
|
||||||
|
class="transition-all focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<div class="text-red-400 text-sm mt-1" v-if="form.errors.description">
|
||||||
|
{{ form.errors.description }}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<BaseButtons class="justify-between">
|
||||||
|
<BaseButton
|
||||||
|
:route-name="stardust.route('settings.project.index')"
|
||||||
|
label="Cancel"
|
||||||
|
color="white"
|
||||||
|
outline
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
type="submit"
|
||||||
|
color="info"
|
||||||
|
:icon="mdiContentSave"
|
||||||
|
label="Save Changes"
|
||||||
|
:class="{ 'opacity-25': form.processing }"
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="transition-all hover:shadow-lg"
|
||||||
|
/>
|
||||||
|
</BaseButtons>
|
||||||
|
</template>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<!-- Project Info Card -->
|
||||||
|
<CardBox class="mt-6 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-slate-800 dark:to-slate-900">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-12 h-12 rounded-lg bg-blue-500 dark:bg-blue-600 flex items-center justify-center">
|
||||||
|
<span class="text-white text-xl font-bold">
|
||||||
|
{{ project.label.charAt(0).toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
|
{{ project.label }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm font-mono text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
{{ project.name }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-500">
|
||||||
|
<span>
|
||||||
|
<span class="font-medium">Created:</span>
|
||||||
|
{{ new Date(project.created_at).toLocaleDateString() }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span class="font-medium">Updated:</span>
|
||||||
|
{{ new Date(project.updated_at).toLocaleDateString() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</SectionMain>
|
||||||
|
</LayoutAuthenticated>
|
||||||
|
</template>
|
||||||
182
resources/js/Pages/Admin/Project/Index.vue
Normal file
182
resources/js/Pages/Admin/Project/Index.vue
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
<script setup>
|
||||||
|
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
||||||
|
import { mdiFolderMultiple, mdiPlus, mdiSquareEditOutline, mdiTrashCan, mdiAlertBoxOutline } from '@mdi/js';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
||||||
|
import SectionMain from '@/Components/SectionMain.vue';
|
||||||
|
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
|
||||||
|
import BaseButton from '@/Components/BaseButton.vue';
|
||||||
|
import CardBox from '@/Components/CardBox.vue';
|
||||||
|
import BaseButtons from '@/Components/BaseButtons.vue';
|
||||||
|
import NotificationBar from '@/Components/NotificationBar.vue';
|
||||||
|
import CardBoxModal from '@/Components/CardBoxModal.vue';
|
||||||
|
import { stardust } from '@eidellev/adonis-stardust/client';
|
||||||
|
|
||||||
|
const isModalDangerActive = ref(false);
|
||||||
|
const deleteId = ref();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
projects: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
can: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const flash = computed(() => usePage().props.flash);
|
||||||
|
|
||||||
|
const projectCount = computed(() => props.projects.length);
|
||||||
|
|
||||||
|
const formDelete = useForm({});
|
||||||
|
|
||||||
|
const destroy = (id) => {
|
||||||
|
deleteId.value = id;
|
||||||
|
isModalDangerActive.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConfirm = async (id) => {
|
||||||
|
await formDelete.delete(stardust.route('settings.project.destroy', [id]));
|
||||||
|
deleteId.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
deleteId.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncate = (text, length = 30) => {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.length > length ? text.substring(0, length) + '...' : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProjectColor = (index) => {
|
||||||
|
const colors = [
|
||||||
|
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
|
||||||
|
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
|
||||||
|
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
|
||||||
|
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
|
||||||
|
'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300',
|
||||||
|
'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300',
|
||||||
|
];
|
||||||
|
return colors[index % colors.length];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CardBoxModal
|
||||||
|
v-model="isModalDangerActive"
|
||||||
|
:delete-id="deleteId"
|
||||||
|
large-title="Delete Project"
|
||||||
|
button="danger"
|
||||||
|
button-label="Delete"
|
||||||
|
has-cancel
|
||||||
|
@confirm="onConfirm"
|
||||||
|
@cancel="onCancel"
|
||||||
|
>
|
||||||
|
<p>Are you sure you want to delete this project?</p>
|
||||||
|
<p>This action cannot be undone.</p>
|
||||||
|
</CardBoxModal>
|
||||||
|
|
||||||
|
<LayoutAuthenticated>
|
||||||
|
<Head title="Projects" />
|
||||||
|
<SectionMain>
|
||||||
|
<SectionTitleLineWithButton :icon="mdiFolderMultiple" title="Projects" main>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400 font-medium">
|
||||||
|
{{ projectCount }} {{ projectCount === 1 ? 'project' : 'projects' }}
|
||||||
|
</span>
|
||||||
|
<BaseButton
|
||||||
|
v-if="can.create"
|
||||||
|
:route-name="stardust.route('settings.project.create')"
|
||||||
|
:icon="mdiPlus"
|
||||||
|
label="New Project"
|
||||||
|
color="info"
|
||||||
|
rounded-full
|
||||||
|
small
|
||||||
|
class="shadow-md hover:shadow-lg transition-shadow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
<NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline">
|
||||||
|
{{ flash.message }}
|
||||||
|
</NotificationBar>
|
||||||
|
|
||||||
|
<CardBox class="mb-6" has-table>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th v-if="can.edit || can.delete">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="projects.length === 0">
|
||||||
|
<td colspan="3" class="text-center py-12">
|
||||||
|
<div class="flex flex-col items-center justify-center text-gray-500 dark:text-gray-400">
|
||||||
|
<mdiFolderMultiple class="w-16 h-16 mb-4 opacity-50" />
|
||||||
|
<p class="text-lg font-medium mb-2">No projects yet</p>
|
||||||
|
<p class="text-sm mb-4">Get started by creating your first project</p>
|
||||||
|
<BaseButton
|
||||||
|
v-if="can.create"
|
||||||
|
:route-name="stardust.route('settings.project.create')"
|
||||||
|
:icon="mdiPlus"
|
||||||
|
label="Create Project"
|
||||||
|
color="info"
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="(project, index) in projects" :key="project.id" class="hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors">
|
||||||
|
<td data-label="Label">
|
||||||
|
<!-- <Link
|
||||||
|
:href="stardust.route('settings.project.show', [project.id])"
|
||||||
|
class="no-underline hover:underline"
|
||||||
|
> -->
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium transition-all hover:shadow-md"
|
||||||
|
:class="getProjectColor(index)"
|
||||||
|
:title="project.label"
|
||||||
|
>
|
||||||
|
{{ truncate(project.label, 30) }}
|
||||||
|
</span>
|
||||||
|
<!-- </Link> -->
|
||||||
|
</td>
|
||||||
|
<td data-label="Name">
|
||||||
|
<span
|
||||||
|
class="text-gray-700 dark:text-gray-300 font-mono text-sm"
|
||||||
|
:title="project.name"
|
||||||
|
>
|
||||||
|
{{ truncate(project.name, 40) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td v-if="can.edit || can.delete" class="before:hidden lg:w-1 whitespace-nowrap">
|
||||||
|
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
||||||
|
<BaseButton
|
||||||
|
v-if="can.edit"
|
||||||
|
:route-name="stardust.route('settings.project.edit', [project.id])"
|
||||||
|
color="info"
|
||||||
|
:icon="mdiSquareEditOutline"
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
v-if="can.delete"
|
||||||
|
color="danger"
|
||||||
|
:icon="mdiTrashCan"
|
||||||
|
small
|
||||||
|
@click="destroy(project.id)"
|
||||||
|
/>
|
||||||
|
</BaseButtons>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardBox>
|
||||||
|
</SectionMain>
|
||||||
|
</LayoutAuthenticated>
|
||||||
|
</template>
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
||||||
import { mdiAccountKey, mdiPlus, mdiSquareEditOutline, mdiTrashCan, mdiAlertBoxOutline } from '@mdi/js';
|
import { mdiAccountKey, mdiPlus, mdiSquareEditOutline, mdiTrashCan, mdiAlertBoxOutline } from '@mdi/js';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, ComputedRef } from 'vue';
|
||||||
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
||||||
import SectionMain from '@/Components/SectionMain.vue';
|
import SectionMain from '@/Components/SectionMain.vue';
|
||||||
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
|
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
|
||||||
|
|
@ -9,18 +9,23 @@ import BaseButton from '@/Components/BaseButton.vue';
|
||||||
import CardBox from '@/Components/CardBox.vue';
|
import CardBox from '@/Components/CardBox.vue';
|
||||||
import BaseButtons from '@/Components/BaseButtons.vue';
|
import BaseButtons from '@/Components/BaseButtons.vue';
|
||||||
import NotificationBar from '@/Components/NotificationBar.vue';
|
import NotificationBar from '@/Components/NotificationBar.vue';
|
||||||
import Pagination from '@/Components/Admin/Pagination.vue';
|
import CardBoxModal from '@/Components/CardBoxModal.vue';
|
||||||
import Sort from '@/Components/Admin/Sort.vue';
|
import Sort from '@/Components/Admin/Sort.vue';
|
||||||
import { stardust } from '@eidellev/adonis-stardust/client';
|
import { stardust } from '@eidellev/adonis-stardust/client';
|
||||||
import CardBoxModal from '@/Components/CardBoxModal.vue';
|
|
||||||
|
interface Role {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
const isModalDangerActive = ref(false);
|
const isModalDangerActive = ref(false);
|
||||||
const deleteId = ref();
|
const deleteId = ref();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
roles: {
|
roles: {
|
||||||
type: Object,
|
type: Array<Role>,
|
||||||
default: () => ({}),
|
default: () => [],
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
|
@ -32,88 +37,89 @@ const props = defineProps({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const flash = computed(() => {
|
const flash: ComputedRef<any> = computed(() => usePage().props.flash);
|
||||||
// let test = usePage();
|
|
||||||
// console.log(test);
|
|
||||||
return usePage().props.flash;
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm({
|
// const form = useForm({
|
||||||
search: props.filters.search,
|
// search: props.filters.search,
|
||||||
});
|
// });
|
||||||
|
|
||||||
|
const roleCount = computed(() => props.roles.length);
|
||||||
|
|
||||||
const formDelete = useForm({});
|
const formDelete = useForm({});
|
||||||
const destroy = (id, e) => {
|
|
||||||
// console.log(id);
|
const destroy = (id: number) => {
|
||||||
deleteId.value = id;
|
deleteId.value = id;
|
||||||
isModalDangerActive.value = true;
|
isModalDangerActive.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onConfirm = async (id) => {
|
const onConfirm = async (id: number) => {
|
||||||
// let id = 6;
|
|
||||||
await formDelete.delete(stardust.route('settings.role.destroy', [id]));
|
await formDelete.delete(stardust.route('settings.role.destroy', [id]));
|
||||||
deleteId.value = null;
|
deleteId.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCancel = (id) => {
|
const onCancel = () => {
|
||||||
// console.log('cancel');
|
|
||||||
deleteId.value = null;
|
deleteId.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getRoleColor = (index: number) => {
|
||||||
|
const colors = [
|
||||||
|
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
|
||||||
|
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
|
||||||
|
'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300',
|
||||||
|
'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300',
|
||||||
|
'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300',
|
||||||
|
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
|
||||||
|
];
|
||||||
|
return colors[index % colors.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncate = (text: string, length = 50) => {
|
||||||
|
if (!text) return '-';
|
||||||
|
return text.length > length ? text.substring(0, length) + '...' : text;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CardBoxModal
|
<CardBoxModal
|
||||||
v-model="isModalDangerActive"
|
v-model="isModalDangerActive"
|
||||||
:delete-id="deleteId"
|
:delete-id="deleteId"
|
||||||
large-title="Please confirm"
|
large-title="Delete Role"
|
||||||
button="danger"
|
button="danger"
|
||||||
button-label="Delete"
|
button-label="Delete"
|
||||||
has-cancel
|
has-cancel
|
||||||
v-on:confirm="onConfirm"
|
@confirm="onConfirm"
|
||||||
v-on:cancel="onCancel"
|
@cancel="onCancel"
|
||||||
>
|
>
|
||||||
<p>Lorem ipsum dolor sit amet <b>adipiscing elit</b></p>
|
<p>Are you sure you want to delete this role?</p>
|
||||||
<p>This is sample modal</p>
|
<p>This action cannot be undone.</p>
|
||||||
</CardBoxModal>
|
</CardBoxModal>
|
||||||
|
|
||||||
<LayoutAuthenticated>
|
<LayoutAuthenticated>
|
||||||
<Head title="Roles" />
|
<Head title="Roles" />
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton :icon="mdiAccountKey" title="Roles" main>
|
<SectionTitleLineWithButton :icon="mdiAccountKey" title="Roles" main>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400 font-medium">
|
||||||
|
{{ roleCount }} {{ roleCount === 1 ? 'role' : 'roles' }}
|
||||||
|
</span>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="can.create"
|
v-if="can.create"
|
||||||
:route-name="stardust.route('settings.role.create')"
|
:route-name="stardust.route('settings.role.create')"
|
||||||
:icon="mdiPlus"
|
:icon="mdiPlus"
|
||||||
label="Add"
|
label="New Role"
|
||||||
color="info"
|
color="info"
|
||||||
rounded-full
|
rounded-full
|
||||||
small
|
small
|
||||||
|
class="shadow-md hover:shadow-lg transition-shadow"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
<NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline">
|
<NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline">
|
||||||
{{ flash.message }}
|
{{ flash.message }}
|
||||||
</NotificationBar>
|
</NotificationBar>
|
||||||
|
|
||||||
<CardBox class="mb-6" has-table>
|
<CardBox class="mb-6" has-table>
|
||||||
<!-- <form @submit.prevent="form.get(stardust.route('role.index'))">
|
|
||||||
<div class="py-2 flex">
|
|
||||||
<div class="flex pl-4">
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
v-model="form.search"
|
|
||||||
class="rounded-md shadow-sm border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
|
|
||||||
placeholder="Search"
|
|
||||||
/>
|
|
||||||
<BaseButton
|
|
||||||
label="Search"
|
|
||||||
type="submit"
|
|
||||||
color="info"
|
|
||||||
class="ml-4 inline-flex items-center px-4 py-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form> -->
|
|
||||||
</CardBox>
|
|
||||||
<CardBox class="mb-6" has-form-data>
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -128,17 +134,42 @@ const onCancel = (id) => {
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="role in roles" :key="role.id">
|
<tr v-if="roles.length === 0">
|
||||||
|
<td colspan="3" class="text-center py-12">
|
||||||
|
<div class="flex flex-col items-center justify-center text-gray-500 dark:text-gray-400">
|
||||||
|
<p class="text-lg font-medium mb-2">No roles yet</p>
|
||||||
|
<p class="text-sm mb-4">Get started by creating your first role</p>
|
||||||
|
<BaseButton
|
||||||
|
v-if="can.create"
|
||||||
|
:route-name="stardust.route('settings.role.create')"
|
||||||
|
:icon="mdiPlus"
|
||||||
|
label="Create Role"
|
||||||
|
color="info"
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
v-for="(role, index) in roles"
|
||||||
|
:key="role.id"
|
||||||
|
class="hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
<td data-label="Name">
|
<td data-label="Name">
|
||||||
<Link
|
<Link :href="stardust.route('settings.role.show', [role.id])" class="no-underline hover:underline">
|
||||||
:href="stardust.route('settings.role.show', [role.id])"
|
<span
|
||||||
class="no-underline hover:underline text-cyan-600 dark:text-cyan-400"
|
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium transition-all hover:shadow-md"
|
||||||
|
:class="getRoleColor(index)"
|
||||||
|
:title="role.name"
|
||||||
>
|
>
|
||||||
{{ role.name }}
|
{{ role.name }}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Description">
|
<td data-label="Description">
|
||||||
{{ role.description }}
|
<span class="text-gray-700 dark:text-gray-300 text-sm" :title="role.description">
|
||||||
|
{{ truncate(role.description, 50) }}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td v-if="can.edit || can.delete" class="before:hidden lg:w-1 whitespace-nowrap">
|
<td v-if="can.edit || can.delete" class="before:hidden lg:w-1 whitespace-nowrap">
|
||||||
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
||||||
|
|
@ -149,21 +180,12 @@ const onCancel = (id) => {
|
||||||
:icon="mdiSquareEditOutline"
|
:icon="mdiSquareEditOutline"
|
||||||
small
|
small
|
||||||
/>
|
/>
|
||||||
<!-- <BaseButton
|
<BaseButton v-if="can.delete" color="danger" :icon="mdiTrashCan" small @click="destroy(role.id)" />
|
||||||
v-if="can.delete"
|
|
||||||
color="danger"
|
|
||||||
:icon="mdiTrashCan"
|
|
||||||
small
|
|
||||||
@click="($event) => destroy(role.id, $event)"
|
|
||||||
/> -->
|
|
||||||
</BaseButtons>
|
</BaseButtons>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<!-- <div class="py-4">
|
|
||||||
<Pagination v-bind:data="roles.meta" />
|
|
||||||
</div> -->
|
|
||||||
</CardBox>
|
</CardBox>
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
</LayoutAuthenticated>
|
</LayoutAuthenticated>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, usePage } from '@inertiajs/vue3';
|
import { Head, usePage } from '@inertiajs/vue3';
|
||||||
import { computed } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { MainService } from '@/Stores/main';
|
import { MainService } from '@/Stores/main';
|
||||||
import {
|
import {
|
||||||
mdiAccountMultiple,
|
mdiAccountMultiple,
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
mdiMonitorCellphone,
|
mdiMonitorCellphone,
|
||||||
mdiReload,
|
mdiReload,
|
||||||
mdiChartPie,
|
mdiChartPie,
|
||||||
|
mdiTrendingUp,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import LineChart from '@/Components/Charts/LineChart.vue';
|
import LineChart from '@/Components/Charts/LineChart.vue';
|
||||||
import SectionMain from '@/Components/SectionMain.vue';
|
import SectionMain from '@/Components/SectionMain.vue';
|
||||||
|
|
@ -18,47 +19,37 @@ import CardBox from '@/Components/CardBox.vue';
|
||||||
import TableSampleClients from '@/Components/TableSampleClients.vue';
|
import TableSampleClients from '@/Components/TableSampleClients.vue';
|
||||||
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
||||||
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
|
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
|
||||||
// import SectionBannerStarOnGitHub from '@/Components/SectionBannerStarOnGitea.vue';
|
|
||||||
import CardBoxDataset from '@/Components/CardBoxDataset.vue';
|
import CardBoxDataset from '@/Components/CardBoxDataset.vue';
|
||||||
import type { User } from '@/Dataset';
|
import type { User } from '@/Dataset';
|
||||||
const mainService = MainService()
|
|
||||||
|
|
||||||
// const chartData = ref();
|
const mainService = MainService();
|
||||||
|
|
||||||
|
const isLoadingChart = ref(false);
|
||||||
|
|
||||||
const fillChartData = async () => {
|
const fillChartData = async () => {
|
||||||
|
isLoadingChart.value = true;
|
||||||
|
try {
|
||||||
await mainService.fetchChartData();
|
await mainService.fetchChartData();
|
||||||
// chartData.value = chartConfig.sampleChartData();
|
} finally {
|
||||||
// chartData.value = mainService.graphData;
|
isLoadingChart.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const chartData = computed(() => mainService.graphData);
|
const chartData = computed(() => mainService.graphData);
|
||||||
// onMounted(async () => {
|
const authors = computed(() => mainService.authors);
|
||||||
// await mainService.fetchChartData("2022");
|
const datasets = computed(() => mainService.datasets);
|
||||||
// });
|
const datasetBarItems = computed(() => mainService.datasets.slice(0, 5));
|
||||||
|
const submitters = computed(() => mainService.clients);
|
||||||
// mainService.fetch('clients');
|
const user = computed(() => usePage().props.authUser as User);
|
||||||
// mainService.fetch('history');
|
|
||||||
|
|
||||||
// mainService.fetchApi('authors');
|
|
||||||
// mainService.fetchApi('datasets');
|
|
||||||
|
|
||||||
// const clientBarItems = computed(() => mainService.clients.slice(0, 4));
|
|
||||||
// const transactionBarItems = computed(() => mainService.history);
|
|
||||||
|
|
||||||
|
// Initialize data
|
||||||
mainService.fetchApi('clients');
|
mainService.fetchApi('clients');
|
||||||
mainService.fetchApi('authors');
|
mainService.fetchApi('authors');
|
||||||
mainService.fetchApi('datasets');
|
mainService.fetchApi('datasets');
|
||||||
mainService.fetchChartData();
|
mainService.fetchChartData();
|
||||||
|
|
||||||
// const authorBarItems = computed(() => mainService.authors.slice(0, 5));
|
|
||||||
const authors = computed(() => mainService.authors);
|
|
||||||
const datasets = computed(() => mainService.datasets);
|
|
||||||
const datasetBarItems = computed(() => mainService.datasets.slice(0, 5));
|
|
||||||
const submitters = computed(() => mainService.clients);
|
|
||||||
const user = computed(() => {
|
|
||||||
return usePage().props.authUser as User;
|
|
||||||
});
|
|
||||||
|
|
||||||
const userHasRoles = (roleNames: Array<string>): boolean => {
|
const userHasRoles = (roleNames: Array<string>): boolean => {
|
||||||
return user.value.roles.some(role => roleNames.includes(role.name));
|
return user.value.roles.some((role) => roleNames.includes(role.name));
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -67,18 +58,13 @@ const userHasRoles = (roleNames: Array<string>): boolean => {
|
||||||
<Head title="Dashboard" />
|
<Head title="Dashboard" />
|
||||||
|
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton v-bind:icon="mdiChartTimelineVariant" title="Overview" main>
|
<SectionTitleLineWithButton :icon="mdiChartTimelineVariant" title="Dashboard Overview" main>
|
||||||
<!-- <BaseButton
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
href=""
|
Welcome back, <span class="font-semibold">{{ user.login }}</span>
|
||||||
target="_blank"
|
</div>
|
||||||
:icon="mdiGithub"
|
|
||||||
label="Star on GeoSphere Forgejo"
|
|
||||||
color="contrast"
|
|
||||||
rounded-full
|
|
||||||
small
|
|
||||||
/> -->
|
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6">
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6">
|
||||||
<CardBoxWidget
|
<CardBoxWidget
|
||||||
trend="12%"
|
trend="12%"
|
||||||
|
|
@ -87,6 +73,7 @@ const userHasRoles = (roleNames: Array<string>): boolean => {
|
||||||
:icon="mdiAccountMultiple"
|
:icon="mdiAccountMultiple"
|
||||||
:number="authors.length"
|
:number="authors.length"
|
||||||
label="Authors"
|
label="Authors"
|
||||||
|
class="hover:shadow-lg transition-shadow duration-300"
|
||||||
/>
|
/>
|
||||||
<CardBoxWidget
|
<CardBoxWidget
|
||||||
trend-type="info"
|
trend-type="info"
|
||||||
|
|
@ -94,6 +81,7 @@ const userHasRoles = (roleNames: Array<string>): boolean => {
|
||||||
:icon="mdiDatabaseOutline"
|
:icon="mdiDatabaseOutline"
|
||||||
:number="datasets.length"
|
:number="datasets.length"
|
||||||
label="Publications"
|
label="Publications"
|
||||||
|
class="hover:shadow-lg transition-shadow duration-300"
|
||||||
/>
|
/>
|
||||||
<CardBoxWidget
|
<CardBoxWidget
|
||||||
trend-type="up"
|
trend-type="up"
|
||||||
|
|
@ -101,11 +89,12 @@ const userHasRoles = (roleNames: Array<string>): boolean => {
|
||||||
:icon="mdiChartTimelineVariant"
|
:icon="mdiChartTimelineVariant"
|
||||||
:number="submitters.length"
|
:number="submitters.length"
|
||||||
label="Submitters"
|
label="Submitters"
|
||||||
|
class="hover:shadow-lg transition-shadow duration-300"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
<!-- <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
<!-- <div class="flex flex-col justify-between">
|
<div class="flex flex-col justify-between">
|
||||||
<CardBoxClient
|
<CardBoxClient
|
||||||
v-for="client in authorBarItems"
|
v-for="client in authorBarItems"
|
||||||
:key="client.id"
|
:key="client.id"
|
||||||
|
|
@ -116,7 +105,7 @@ const userHasRoles = (roleNames: Array<string>): boolean => {
|
||||||
:count="client.dataset_count"
|
:count="client.dataset_count"
|
||||||
|
|
||||||
/>
|
/>
|
||||||
</div> -->
|
</div> <!--
|
||||||
<div class="flex flex-col justify-between">
|
<div class="flex flex-col justify-between">
|
||||||
<CardBoxDataset
|
<CardBoxDataset
|
||||||
v-for="(dataset, index) in datasetBarItems"
|
v-for="(dataset, index) in datasetBarItems"
|
||||||
|
|
@ -126,20 +115,61 @@ const userHasRoles = (roleNames: Array<string>): boolean => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Datasets Section -->
|
||||||
|
<div v-if="datasetBarItems.length > 0" class="mb-6">
|
||||||
|
<SectionTitleLineWithButton :icon="mdiTrendingUp" title="Recent Publications">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400"> Latest {{ datasetBarItems.length }} publications </span>
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
<CardBoxDataset
|
||||||
|
v-for="(dataset, index) in datasetBarItems"
|
||||||
|
:key="index"
|
||||||
|
:dataset="dataset"
|
||||||
|
class="hover:shadow-md transition-all duration-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- <SectionBannerStarOnGitHub /> -->
|
<!-- <SectionBannerStarOnGitHub /> -->
|
||||||
|
|
||||||
<SectionTitleLineWithButton :icon="mdiChartPie" title="Trends overview: Publications per month" ></SectionTitleLineWithButton>
|
<!-- Chart Section -->
|
||||||
<CardBox title="Performance" :icon="mdiFinance" :header-icon="mdiReload" class="mb-6" @header-icon-click="fillChartData">
|
<SectionTitleLineWithButton :icon="mdiChartPie" title="Trends Overview" class="mt-8">
|
||||||
<div v-if="chartData">
|
<span class="text-sm text-gray-500 dark:text-gray-400"> Publications per month </span>
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
<CardBox
|
||||||
|
title="Performance"
|
||||||
|
:icon="mdiFinance"
|
||||||
|
:header-icon="mdiReload"
|
||||||
|
class="mb-6 shadow-lg"
|
||||||
|
@header-icon-click="fillChartData"
|
||||||
|
>
|
||||||
|
<div v-if="isLoadingChart" class="flex items-center justify-center h-96">
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading chart data...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="chartData" class="relative">
|
||||||
<line-chart :data="chartData" class="h-96" />
|
<line-chart :data="chartData" class="h-96" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="flex items-center justify-center h-96 text-gray-500 dark:text-gray-400">
|
||||||
|
<p>No chart data available</p>
|
||||||
|
</div>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
|
||||||
<SectionTitleLineWithButton v-if="userHasRoles(['administrator'])" :icon="mdiAccountMultiple" title="Submitters" />
|
<!-- Admin Section -->
|
||||||
<!-- <NotificationBar color="info" :icon="mdiMonitorCellphone"> <b>Responsive table.</b> Collapses on mobile </NotificationBar> -->
|
<template v-if="userHasRoles(['administrator'])">
|
||||||
<CardBox v-if="userHasRoles(['administrator'])" :icon="mdiMonitorCellphone" title="Responsive table" has-table>
|
<SectionTitleLineWithButton :icon="mdiAccountMultiple" title="Submitters Management" class="mt-8">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400"> Administrator view </span>
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
<CardBox :icon="mdiMonitorCellphone" title="All Submitters" has-table class="shadow-lg">
|
||||||
<TableSampleClients />
|
<TableSampleClients />
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
</template>
|
||||||
|
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
</LayoutAuthenticated>
|
</LayoutAuthenticated>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Main Title Language*" help="required: main abstract language"
|
<FormField label="Main Description Language*" help="required: main abstract language"
|
||||||
:class="{ 'text-red-400': form.errors['descriptions.0.language'] }"
|
:class="{ 'text-red-400': form.errors['descriptions.0.language'] }"
|
||||||
class="w-full ml-1 flex-1">
|
class="w-full ml-1 flex-1">
|
||||||
<FormControl required v-model="form.descriptions[0].language" type="text"
|
<FormControl required v-model="form.descriptions[0].language" type="text"
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
// import { Head, Link, useForm, usePage } from '@inertiajs/inertia-vue3';
|
// import { Head, Link, useForm, usePage } from '@inertiajs/inertia-vue3';
|
||||||
import { Head, usePage } from '@inertiajs/vue3';
|
import { Head, usePage } from '@inertiajs/vue3';
|
||||||
import { ComputedRef } from 'vue';
|
import { ComputedRef } from 'vue';
|
||||||
import { mdiSquareEditOutline, mdiAlertBoxOutline, mdiShareVariant, mdiBookEdit, mdiUndo, mdiLibraryShelves } from '@mdi/js';
|
import { mdiSquareEditOutline, mdiAlertBoxOutline, mdiShareVariant, mdiBookEdit, mdiUndo, mdiLibraryShelves, mdiAccountArrowLeft, mdiAccountArrowRight, mdiFingerprint, mdiPublish, mdiChevronDown, mdiChevronUp, mdiTrayArrowDown, mdiCheckDecagram } from '@mdi/js';
|
||||||
import { computed } from 'vue';
|
import { computed, ref, onMounted } from 'vue';
|
||||||
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
||||||
import SectionMain from '@/Components/SectionMain.vue';
|
import SectionMain from '@/Components/SectionMain.vue';
|
||||||
import BaseButton from '@/Components/BaseButton.vue';
|
import BaseButton from '@/Components/BaseButton.vue';
|
||||||
|
import BaseIcon from '@/Components/BaseIcon.vue';
|
||||||
import CardBox from '@/Components/CardBox.vue';
|
import CardBox from '@/Components/CardBox.vue';
|
||||||
import BaseButtons from '@/Components/BaseButtons.vue';
|
import BaseButtons from '@/Components/BaseButtons.vue';
|
||||||
import NotificationBar from '@/Components/NotificationBar.vue';
|
import NotificationBar from '@/Components/NotificationBar.vue';
|
||||||
|
|
@ -26,78 +27,93 @@ const props = defineProps({
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
// user: {
|
|
||||||
// type: Object,
|
|
||||||
// default: () => ({}),
|
|
||||||
// }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const flash: ComputedRef<any> = computed(() => {
|
const flash: ComputedRef<any> = computed(() => {
|
||||||
// let test = usePage();
|
|
||||||
// console.log(test);
|
|
||||||
return usePage().props.flash;
|
return usePage().props.flash;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Legend visibility state with localStorage persistence
|
||||||
|
const showLegend = ref(true);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const savedState = localStorage.getItem('datasetLegendVisible');
|
||||||
|
if (savedState !== null) {
|
||||||
|
showLegend.value = savedState === 'true';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleLegend = () => {
|
||||||
|
showLegend.value = !showLegend.value;
|
||||||
|
localStorage.setItem('datasetLegendVisible', String(showLegend.value));
|
||||||
|
};
|
||||||
|
|
||||||
// const getRowClass = (dataset) => {
|
|
||||||
// // (props.options ? 'select' : props.type)
|
|
||||||
// let rowclass = '';
|
|
||||||
// if (dataset.server_state == 'accepted') {
|
|
||||||
// rowclass = 'bg-accepted';
|
|
||||||
// } else if (dataset.server_state == 'rejected_reviewer') {
|
|
||||||
// rowclass = 'bg-rejected-reviewer';
|
|
||||||
// } else if (dataset.server_state == 'reviewed') {
|
|
||||||
// rowclass = 'bg-reviewed';
|
|
||||||
// } else if (dataset.server_state == 'released') {
|
|
||||||
// rowclass = 'bg-released';
|
|
||||||
// } else if (dataset.server_state == 'published') {
|
|
||||||
// rowclass = 'bg-published';
|
|
||||||
// } else {
|
|
||||||
// rowclass = '';
|
|
||||||
// }
|
|
||||||
// return rowclass;
|
|
||||||
// };
|
|
||||||
const getRowClass = (dataset) => {
|
const getRowClass = (dataset) => {
|
||||||
// (props.options ? 'select' : props.type)
|
// Return Tailwind classes that will be defined in tailwind.config
|
||||||
let rowclass = '';
|
const stateClasses = {
|
||||||
if (dataset.server_state == 'released') {
|
'released': 'bg-released dark:bg-released-dark',
|
||||||
rowclass = 'bg-released';
|
'editor_accepted': 'bg-editor-accepted dark:bg-editor-accepted-dark',
|
||||||
} else if (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer') {
|
'rejected_reviewer': 'bg-rejected-reviewer dark:bg-rejected-reviewer-dark',
|
||||||
rowclass = 'bg-editor-accepted';
|
'reviewed': 'bg-reviewed dark:bg-reviewed-dark',
|
||||||
} else if (dataset.server_state == 'reviewed') {
|
'published': 'bg-published dark:bg-published-dark',
|
||||||
rowclass = 'bg-reviewed';
|
|
||||||
} else if (dataset.server_state == 'published') {
|
|
||||||
rowclass = 'bg-published';
|
|
||||||
} else {
|
|
||||||
rowclass = '';
|
|
||||||
}
|
|
||||||
return rowclass;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// New method to format server state
|
return stateClasses[dataset.server_state] || '';
|
||||||
const formatServerState = (state: string) => {
|
|
||||||
if (state === 'inprogress') {
|
|
||||||
return 'draft';
|
|
||||||
} else if (state === 'released') {
|
|
||||||
return 'submitted';
|
|
||||||
} else if (state === 'approved') {
|
|
||||||
return 'ready for review';
|
|
||||||
} else if (state === 'reviewer_accepted') {
|
|
||||||
return 'in review';
|
|
||||||
}
|
|
||||||
return state; // Return the original state for other cases
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Method to get state badge color
|
||||||
|
const getStateColor = (state: string) => {
|
||||||
|
const stateColors = {
|
||||||
|
'inprogress': 'bg-sky-200 text-sky-900 dark:bg-sky-900 dark:text-sky-200',
|
||||||
|
'released': 'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-200',
|
||||||
|
'editor_accepted': 'bg-teal-200 text-teal-900 dark:bg-teal-900 dark:text-teal-200',
|
||||||
|
'rejected_reviewer': 'bg-amber-200 text-amber-900 dark:bg-amber-900 dark:text-amber-200',
|
||||||
|
'rejected_to_reviewer': 'bg-yellow-200 text-yellow-900 dark:bg-yellow-900 dark:text-yellow-200',
|
||||||
|
'rejected_editor': 'bg-rose-300 text-rose-900 dark:bg-rose-900 dark:text-rose-200',
|
||||||
|
'reviewed': 'bg-yellow-300 text-yellow-900 dark:bg-yellow-900 dark:text-yellow-200',
|
||||||
|
'published': 'bg-green-300 text-green-900 dark:bg-green-900 dark:text-green-200',
|
||||||
|
'approved': 'bg-cyan-200 text-cyan-900 dark:bg-cyan-900 dark:text-cyan-200',
|
||||||
|
'reviewer_accepted': 'bg-lime-200 text-lime-900 dark:bg-lime-900 dark:text-lime-200',
|
||||||
|
};
|
||||||
|
return stateColors[state] || 'bg-sky-200 text-sky-900 dark:bg-sky-900 dark:text-sky-200';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dynamic legend definitions
|
||||||
|
const datasetStates = [
|
||||||
|
{ key: 'released', label: 'Submitted' },
|
||||||
|
{ key: 'editor_accepted', label: 'In Approval' },
|
||||||
|
// { key: 'approved', label: 'Ready for Review' },
|
||||||
|
// { key: 'reviewer_accepted', label: 'In Review' },
|
||||||
|
{ key: 'reviewed', label: 'Reviewed' },
|
||||||
|
{ key: 'published', label: 'Published' },
|
||||||
|
{ key: 'rejected_reviewer', label: 'Rejected by Reviewer' },
|
||||||
|
];
|
||||||
|
const getLabel = (key: string) => {
|
||||||
|
return datasetStates.find(s => s.key === key)?.label || 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableActions = [
|
||||||
|
{ icon: mdiSquareEditOutline, label: 'Edit', color: 'text-blue-500' },
|
||||||
|
{ icon: mdiTrayArrowDown, label: 'Receive', color: 'text-cyan-500' },
|
||||||
|
{ icon: mdiCheckDecagram, label: 'Approve (Send to Reviewer)', color: 'text-teal-600' },
|
||||||
|
{ icon: mdiAccountArrowLeft, label: 'Reject to Submitter', color: 'text-amber-600' },
|
||||||
|
{ icon: mdiAccountArrowRight, label: 'Reject to Reviewer', color: 'text-yellow-600' },
|
||||||
|
{ icon: mdiLibraryShelves, label: 'Classify', color: 'text-blue-500' },
|
||||||
|
{ icon: mdiPublish, label: 'Publish', color: 'text-green-600' },
|
||||||
|
{ icon: mdiFingerprint, label: 'Mint DOI', color: 'text-cyan-600' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const truncateTitle = (text: string, length = 50) => {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.length > length ? text.substring(0, length) + '...' : text;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LayoutAuthenticated>
|
<LayoutAuthenticated>
|
||||||
|
|
||||||
<Head title="Editor Datasets" />
|
<Head title="Editor Datasets" />
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
|
|
||||||
|
|
||||||
<NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline">
|
<NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline">
|
||||||
{{ flash.message }}
|
{{ flash.message }}
|
||||||
</NotificationBar>
|
</NotificationBar>
|
||||||
|
|
@ -108,6 +124,80 @@ const formatServerState = (state: string) => {
|
||||||
{{ flash.error }}
|
{{ flash.error }}
|
||||||
</NotificationBar>
|
</NotificationBar>
|
||||||
|
|
||||||
|
<!-- Legend -->
|
||||||
|
<CardBox class="mb-4">
|
||||||
|
<!-- Legend Header with Toggle -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between cursor-pointer select-none p-4 border-b border-gray-200 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-800/50 transition-colors"
|
||||||
|
@click="toggleLegend"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
Legend - States & Actions
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ showLegend ? 'Click to hide' : 'Click to show' }}
|
||||||
|
</span>
|
||||||
|
<BaseIcon
|
||||||
|
:path="showLegend ? mdiChevronUp : mdiChevronDown"
|
||||||
|
:size="20"
|
||||||
|
class="text-gray-500 dark:text-gray-400 transition-transform"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collapsible Legend Content -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
enter-from-class="max-h-0 opacity-0"
|
||||||
|
enter-to-class="max-h-96 opacity-100"
|
||||||
|
leave-active-class="transition-all duration-300 ease-in"
|
||||||
|
leave-from-class="max-h-96 opacity-100"
|
||||||
|
leave-to-class="max-h-0 opacity-0"
|
||||||
|
>
|
||||||
|
<div v-show="showLegend" class="overflow-hidden">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 p-4">
|
||||||
|
<!-- State Colors Legend -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||||
|
</svg>
|
||||||
|
Dataset States
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div v-for="state in datasetStates" :key="state.key" class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full" :class="getStateColor(state.key)">
|
||||||
|
{{ state.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions Legend -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
Available Actions
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-1.5 text-xs">
|
||||||
|
<div v-for="action in availableActions" :key="action.label" class="flex items-center gap-2">
|
||||||
|
<BaseIcon :path="action.icon" :size="16" :class="action.color" />
|
||||||
|
<span class="text-gray-700 dark:text-gray-300">{{ action.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
<!-- table -->
|
<!-- table -->
|
||||||
<CardBox class="mb-6" has-table>
|
<CardBox class="mb-6" has-table>
|
||||||
|
|
@ -115,172 +205,144 @@ const formatServerState = (state: string) => {
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="py-3 text-left text-xs font-medium uppercase tracking-wider dark:text-white">
|
<th>Title</th>
|
||||||
<!-- <Sort label="Dataset Title" attribute="title" :search="form.search" /> -->
|
<th>Submitter</th>
|
||||||
Title
|
<th>State</th>
|
||||||
</th>
|
<th>Editor</th>
|
||||||
<th scope="col" class="py-3 text-left text-xs font-medium uppercase tracking-wider dark:text-white">
|
<th>Modified</th>
|
||||||
Submitter
|
<th v-if="can.edit || can.delete">Actions</th>
|
||||||
</th>
|
|
||||||
<th scope="col" class="py-3 text-left text-xs font-medium uppercase tracking-wider dark:text-white">
|
|
||||||
State
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="py-3 text-left text-xs font-medium uppercase tracking-wider dark:text-white">
|
|
||||||
Editor
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="py-3 text-left text-xs font-medium uppercase tracking-wider dark:text-white">
|
|
||||||
Date of last modification
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="relative px-6 py-3 dark:text-white" v-if="can.edit || can.delete">
|
|
||||||
<span class="sr-only">Actions</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<tbody>
|
||||||
<tr v-for="dataset in props.datasets.data" :key="dataset.id"
|
<tr v-for="dataset in props.datasets.data" :key="dataset.id"
|
||||||
:class="[getRowClass(dataset)]">
|
:class="['hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors', getRowClass(dataset)]">
|
||||||
<td data-label="Login"
|
<td data-label="Title">
|
||||||
class="py-4 whitespace-nowrap text-gray-700 table-title">
|
<div class="max-w-xs">
|
||||||
<!-- <Link v-bind:href="stardust.route('settings.user.show', [user.id])"
|
<span
|
||||||
class="no-underline hover:underline text-cyan-600 dark:text-cyan-400">
|
class="text-gray-700 dark:text-gray-300 text-sm font-medium"
|
||||||
{{ user.login }}
|
:title="dataset.main_title"
|
||||||
</Link> -->
|
>
|
||||||
<!-- {{ user.id }} -->
|
{{ truncateTitle(dataset.main_title) }}
|
||||||
{{ dataset.main_title }}
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-4 whitespace-nowrap text-gray-700">
|
<td data-label="Submitter">
|
||||||
<div class="text-sm">{{ dataset.user.login }}</div>
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ dataset.user.login }}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-4 whitespace-nowrap text-gray-700">
|
<td data-label="State">
|
||||||
<div class="text-sm"> {{ formatServerState(dataset.server_state) }}</div>
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize"
|
||||||
|
:class="getStateColor(dataset.server_state)">
|
||||||
|
{{ getLabel(dataset.server_state) }}
|
||||||
|
</span>
|
||||||
<div v-if="dataset.server_state === 'rejected_reviewer' && dataset.reject_reviewer_note"
|
<div v-if="dataset.server_state === 'rejected_reviewer' && dataset.reject_reviewer_note"
|
||||||
class="inline-block relative ml-2 group">
|
class="relative group">
|
||||||
<button
|
<button
|
||||||
class="w-5 h-5 rounded-full bg-gray-200 text-gray-600 text-xs flex items-center justify-center focus:outline-none hover:bg-gray-300">
|
class="w-5 h-5 rounded-full bg-gray-200 dark:bg-slate-700 text-gray-600 dark:text-gray-300 text-xs flex items-center justify-center focus:outline-none hover:bg-gray-300 dark:hover:bg-slate-600 transition-colors">
|
||||||
i
|
i
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
class="absolute left-0 top-full mt-1 w-64 bg-white shadow-lg rounded-md p-3 text-xs text-left z-50 transform scale-0 origin-top-left transition-transform duration-100 group-hover:scale-100">
|
class="absolute left-0 top-full mt-1 w-64 bg-white dark:bg-slate-800 shadow-lg rounded-md p-3 text-xs text-left z-50 transform scale-0 origin-top-left transition-transform duration-100 group-hover:scale-100 border border-gray-200 dark:border-slate-700">
|
||||||
<p class="text-gray-700 max-h-40 overflow-y-auto overflow-x-hidden whitespace-normal break-words">
|
<p class="text-gray-700 dark:text-gray-300 max-h-40 overflow-y-auto overflow-x-hidden whitespace-normal break-words">
|
||||||
{{ dataset.reject_reviewer_note }}
|
{{ dataset.reject_reviewer_note }}
|
||||||
</p>
|
</p>
|
||||||
<div class="absolute -top-1 left-1 w-2 h-2 bg-white transform rotate-45">
|
<div class="absolute -top-1 left-1 w-2 h-2 bg-white dark:bg-slate-800 border-l border-t border-gray-200 dark:border-slate-700 transform rotate-45">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="py-4 whitespace-nowrap text-gray-700"
|
<td data-label="Editor" v-if="dataset.server_state === 'released'">
|
||||||
v-if="dataset.server_state === 'released'">
|
<span class="text-sm text-gray-600 dark:text-gray-400 italic" :title="dataset.server_date_modified">
|
||||||
<div class="text-sm" :title="dataset.server_date_modified">
|
Preferred: {{ dataset.preferred_reviewer }}
|
||||||
Preferred reviewer: {{ dataset.preferred_reviewer }}
|
</span>
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="py-4 whitespace-nowrap text-gray-700"
|
<td data-label="Editor"
|
||||||
v-else-if="dataset.server_state === 'editor_accepted' || dataset.server_state === 'rejected_reviewer'">
|
v-else-if="dataset.server_state === 'editor_accepted' || dataset.server_state === 'rejected_reviewer'">
|
||||||
<div class="text-sm" :title="dataset.server_date_modified">
|
<span class="text-sm text-gray-600 dark:text-gray-400 italic" :title="dataset.server_date_modified">
|
||||||
In approval by: {{ dataset.editor?.login }}
|
In approval: {{ dataset.editor?.login }}
|
||||||
</div>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-4 whitespace-nowrap text-gray-700" v-else>
|
<td data-label="Editor" v-else>
|
||||||
<div class="text-sm">{{ dataset.editor?.login }}</div>
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ dataset.editor?.login || '—' }}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td data-label="modified" class="py-4 whitespace-nowrap text-gray-700">
|
<td data-label="Modified">
|
||||||
<div class="text-sm" :title="dataset.server_date_modified">
|
<span class="text-sm text-gray-600 dark:text-gray-400" :title="dataset.server_date_modified">
|
||||||
{{ dataset.server_date_modified }}
|
{{ dataset.server_date_modified }}
|
||||||
</div>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td v-if="can.edit || can.delete" class="before:hidden lg:w-1 whitespace-nowrap">
|
||||||
class="py-4 whitespace-nowrap text-right text-sm font-medium text-gray-700 dark:text-white">
|
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
||||||
<div type="justify-start lg:justify-end" class="grid grid-cols-2 gap-x-2 gap-y-2"
|
|
||||||
no-wrap>
|
|
||||||
|
|
||||||
<BaseButton v-if="can.receive && (dataset.server_state == 'released')"
|
<BaseButton v-if="can.receive && (dataset.server_state == 'released')"
|
||||||
:route-name="stardust.route('editor.dataset.receive', [dataset.id])"
|
:route-name="stardust.route('editor.dataset.receive', [dataset.id])"
|
||||||
color="info" :icon="mdiSquareEditOutline" :label="'Receive edit task'" small
|
color="info" :icon="mdiTrayArrowDown" small
|
||||||
class="col-span-1" />
|
title="Receive edit task" />
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="can.approve && (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer')"
|
v-if="can.approve && (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer')"
|
||||||
:route-name="stardust.route('editor.dataset.approve', [dataset.id])"
|
:route-name="stardust.route('editor.dataset.approve', [dataset.id])"
|
||||||
color="info" :icon="mdiShareVariant" :label="'Approve'" small
|
color="success" :icon="mdiCheckDecagram" small
|
||||||
class="col-span-1" />
|
title="Approve (Send to Reviewer)" />
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="can.approve && (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer')"
|
v-if="can.approve && (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer')"
|
||||||
:route-name="stardust.route('editor.dataset.reject', [dataset.id])"
|
:route-name="stardust.route('editor.dataset.reject', [dataset.id])"
|
||||||
color="info" :icon="mdiUndo" label="Reject" small class="col-span-1">
|
color="danger" :icon="mdiAccountArrowLeft" small
|
||||||
</BaseButton>
|
title="Reject to Submitter" />
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="can.edit && (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer')"
|
v-if="can.edit && (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer')"
|
||||||
:route-name="stardust.route('editor.dataset.edit', [Number(dataset.id)])"
|
:route-name="stardust.route('editor.dataset.edit', [Number(dataset.id)])"
|
||||||
color="info" :icon="mdiSquareEditOutline" :label="'Edit'" small
|
color="info" :icon="mdiSquareEditOutline" small
|
||||||
class="col-span-1">
|
title="Edit" />
|
||||||
</BaseButton>
|
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="can.edit && (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer')"
|
v-if="can.edit && (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer')"
|
||||||
:route-name="stardust.route('editor.dataset.categorize', [dataset.id])"
|
:route-name="stardust.route('editor.dataset.categorize', [dataset.id])"
|
||||||
color="info" :icon="mdiLibraryShelves" :label="'Classify'" small
|
color="info" :icon="mdiLibraryShelves" small
|
||||||
class="col-span-1">
|
title="Classify" />
|
||||||
</BaseButton>
|
|
||||||
|
|
||||||
<BaseButton v-if="can.publish && (dataset.server_state == 'reviewed')"
|
<BaseButton v-if="can.publish && (dataset.server_state == 'reviewed')"
|
||||||
:route-name="stardust.route('editor.dataset.rejectToReviewer', [dataset.id])"
|
:route-name="stardust.route('editor.dataset.rejectToReviewer', [dataset.id])"
|
||||||
color="info" :icon="mdiUndo" :label="'Reject To Reviewer'" small
|
color="warning" :icon="mdiAccountArrowRight" small
|
||||||
class="col-span-1" />
|
title="Reject to Reviewer" />
|
||||||
|
|
||||||
<BaseButton v-if="can.publish && (dataset.server_state == 'reviewed')"
|
<BaseButton v-if="can.publish && (dataset.server_state == 'reviewed')"
|
||||||
:route-name="stardust.route('editor.dataset.publish', [dataset.id])"
|
:route-name="stardust.route('editor.dataset.publish', [dataset.id])"
|
||||||
color="info" :icon="mdiBookEdit" :label="'Publish'" small
|
color="success" :icon="mdiPublish" small
|
||||||
class="col-span-1" />
|
title="Publish" />
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="can.publish && (dataset.server_state == 'published' && !dataset.identifier)"
|
v-if="can.publish && (dataset.server_state == 'published' && !dataset.identifier)"
|
||||||
:route-name="stardust.route('editor.dataset.doi', [dataset.id])"
|
:route-name="stardust.route('editor.dataset.doi', [dataset.id])"
|
||||||
color="info" :icon="mdiBookEdit" :label="'Mint DOI'" small
|
color="info" :icon="mdiFingerprint" small
|
||||||
class="col-span-1 last-in-row" />
|
title="Mint DOI" />
|
||||||
|
</BaseButtons>
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<!-- Show warning message if datasets are not defined or empty -->
|
<div class="text-center py-12">
|
||||||
<div class="bg-yellow-200 text-yellow-800 rounded-md p-4">
|
<div class="flex flex-col items-center justify-center text-gray-500 dark:text-gray-400">
|
||||||
<p>No datasets defined.</p>
|
<p class="text-lg font-medium mb-2">No datasets found</p>
|
||||||
<!-- You can add more descriptive text here -->
|
<p class="text-sm">Datasets will appear here when they are submitted</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- <BaseButton
|
|
||||||
v-if="can.edit && (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer')"
|
|
||||||
:route-name="stardust.route('editor.dataset.edit', [dataset.id])" color="info"
|
|
||||||
:icon="mdiSquareEditOutline" :label="'Edit'" small /> -->
|
|
||||||
<div class="py-4">
|
<div class="py-4">
|
||||||
<Pagination v-bind:data="datasets.meta" />
|
<Pagination v-bind:data="datasets.meta" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
</LayoutAuthenticated>
|
</LayoutAuthenticated>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<style scoped lang="css">
|
|
||||||
.table-title {
|
|
||||||
max-width: 200px;
|
|
||||||
/* set a maximum width */
|
|
||||||
overflow: hidden;
|
|
||||||
/* hide overflow */
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
/* show ellipsis for overflowed text */
|
|
||||||
white-space: nowrap;
|
|
||||||
/* prevent wrapping */
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,40 +1,15 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import { ref, Ref } from 'vue';
|
import { ref, Ref } from 'vue';
|
||||||
import { mdiChartTimelineVariant, mdiGithub } from '@mdi/js';
|
import { mdiChartTimelineVariant, mdiGithub, mdiMapMarker, mdiCalendar, mdiLockOpenVariant } from '@mdi/js';
|
||||||
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
||||||
import SectionMain from '@/Components/SectionMain.vue';
|
import SectionMain from '@/Components/SectionMain.vue';
|
||||||
import BaseButton from '@/Components/BaseButton.vue';
|
import BaseButton from '@/Components/BaseButton.vue';
|
||||||
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
|
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
|
||||||
import { MapOptions } from '@/Components/Map/MapOptions';
|
import { MapOptions } from '@/Components/Map/MapOptions';
|
||||||
// import { stardust } from '@eidellev/adonis-stardust/client';
|
|
||||||
import SearchMap from '@/Components/Map/SearchMap.vue';
|
import SearchMap from '@/Components/Map/SearchMap.vue';
|
||||||
import { OpensearchDocument } from '@/Dataset';
|
import { OpensearchDocument } from '@/Dataset';
|
||||||
|
|
||||||
// const fitBounds: LatLngBoundsExpression = [
|
|
||||||
// [46.4318173285, 9.47996951665],
|
|
||||||
// [49.0390742051, 16.9796667823],
|
|
||||||
// ];
|
|
||||||
|
|
||||||
// const mapId = 'map';
|
|
||||||
|
|
||||||
// const coverage = {
|
|
||||||
// x_min: undefined,
|
|
||||||
// y_min: undefined,
|
|
||||||
// x_max: undefined,
|
|
||||||
// y_max: undefined,
|
|
||||||
// elevation_min: undefined,
|
|
||||||
// elevation_max: undefined,
|
|
||||||
// elevation_absolut: undefined,
|
|
||||||
// depth_min: undefined,
|
|
||||||
// depth_max: undefined,
|
|
||||||
// depth_absolut: undefined,
|
|
||||||
// time_min: undefined,
|
|
||||||
// time_max: undefined,
|
|
||||||
// time_absolut: undefined,
|
|
||||||
// };
|
|
||||||
|
|
||||||
// Replace with your actual data
|
|
||||||
const datasets: Ref<OpensearchDocument[]> = ref([]);
|
const datasets: Ref<OpensearchDocument[]> = ref([]);
|
||||||
const openAccessLicences: Array<string> = ['CC-BY-4.0', 'CC-BY-SA-4.0'];
|
const openAccessLicences: Array<string> = ['CC-BY-4.0', 'CC-BY-SA-4.0'];
|
||||||
|
|
||||||
|
|
@ -48,58 +23,499 @@ const mapOptions: MapOptions = {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LayoutAuthenticated :showAsideMenu="false">
|
<LayoutAuthenticated :showAsideMenu="false">
|
||||||
|
|
||||||
<Head title="Map" />
|
<Head title="Map" />
|
||||||
|
|
||||||
<!-- <section class="p-6" v-bind:class="containerMaxW"> -->
|
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton v-bind:icon="mdiChartTimelineVariant" title="Tethys Map" main>
|
<SectionTitleLineWithButton v-bind:icon="mdiChartTimelineVariant" title="Tethys Map" main>
|
||||||
<BaseButton href="https://gitea.geosphere.at/geolba/tethys" target="_blank" :icon="mdiGithub"
|
<BaseButton
|
||||||
label="Star on GeoSPhere Forgejo" color="contrast" rounded-full small />
|
href="https://gitea.geosphere.at/geolba/tethys"
|
||||||
<!-- <BaseButton :route-name="stardust.route('app.login.show')" label="Login" color="white" rounded-full small /> -->
|
target="_blank"
|
||||||
|
:icon="mdiGithub"
|
||||||
|
label="Star on GeoSphere Forgejo"
|
||||||
|
color="contrast"
|
||||||
|
rounded-full
|
||||||
|
small
|
||||||
|
/>
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
<!-- <SectionBannerStarOnGitea /> -->
|
<!-- Map Component with enhanced styling -->
|
||||||
|
<div class="map-wrapper">
|
||||||
<!-- <CardBox> -->
|
|
||||||
<!-- <div id="map" class="map-container mapDesktop mt-6 mb-6 rounded-2xl py-12 px-6 text-center">
|
|
||||||
<DrawControlComponent ref="draw" :preserve="false" :mapId="mapId" :southWest="southWest"
|
|
||||||
:northEast="northEast">
|
|
||||||
</DrawControlComponent>
|
|
||||||
</div> -->
|
|
||||||
<SearchMap :datasets="datasets" :map-options="mapOptions"></SearchMap>
|
<SearchMap :datasets="datasets" :map-options="mapOptions"></SearchMap>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div d="search-result-list-wrapper" class="flex flex-wrap col-span-24 h-full">
|
<!-- Results Header -->
|
||||||
<div v-for="dataset in datasets" :key="dataset.id" class="w-full sm:w-1/2 md:w-1/3 lg:w-1/4 p-4">
|
<div v-if="datasets.length > 0" class="results-header">
|
||||||
<div class="bg-white rounded shadow p-6">
|
<h2 class="results-title">
|
||||||
<h3 class="text-lg leading-tight text-gray-500 dark:text-slate-400">
|
<span class="results-count">{{ datasets.length }}</span>
|
||||||
|
{{ datasets.length === 1 ? 'Dataset' : 'Datasets' }} Found
|
||||||
|
</h2>
|
||||||
|
<p class="results-subtitle">Click on any card to view details</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enhanced Results Grid -->
|
||||||
|
<div class="results-grid">
|
||||||
|
<div
|
||||||
|
v-for="(dataset, index) in datasets"
|
||||||
|
:key="dataset.id"
|
||||||
|
class="dataset-card"
|
||||||
|
:style="{ animationDelay: `${index * 50}ms` }"
|
||||||
|
>
|
||||||
|
<!-- Card Header with Icon -->
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-icon">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24">
|
||||||
|
<path :d="mdiMapMarker" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="card-type">{{ dataset.doctype }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Content -->
|
||||||
|
<div class="card-content">
|
||||||
|
<h3 class="card-title">
|
||||||
{{ dataset.title_output }}
|
{{ dataset.title_output }}
|
||||||
</h3>
|
</h3>
|
||||||
<!-- <h2 class="text-xl font-bold mb-2">{{ dataset.title_output }}</h2> -->
|
<p class="card-abstract">
|
||||||
<p class="text-gray-700 mb-2">{{ dataset.abstract_output }}</p>
|
{{ dataset.abstract_output }}
|
||||||
<div class="text-sm text-gray-600">
|
|
||||||
<div v-for="author in dataset.author" :key="author" class="mb-1">{{ author }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<span
|
|
||||||
class="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">
|
|
||||||
{{ dataset.year }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">
|
|
||||||
{{ dataset.language }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
<span class="label"><i class="fas fa-file"></i> {{ dataset.doctype }}</span>
|
|
||||||
<!-- <span>Licence: {{ document.licence }}</span> -->
|
|
||||||
<span v-if="openAccessLicences.includes(dataset.licence)" class="label titlecase"><i
|
|
||||||
class="fas fa-lock-open"></i> Open Access</span>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Authors Section -->
|
||||||
|
<div v-if="dataset.author && dataset.author.length > 0" class="card-authors">
|
||||||
|
<div class="author-label">Authors:</div>
|
||||||
|
<div class="author-list">
|
||||||
|
<span v-for="(author, idx) in dataset.author.slice(0, 3)" :key="idx" class="author-tag">
|
||||||
|
{{ author }}
|
||||||
|
</span>
|
||||||
|
<span v-if="dataset.author.length > 3" class="author-more"> +{{ dataset.author.length - 3 }} more </span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Footer with Metadata -->
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="metadata-tags">
|
||||||
|
<span class="tag tag-year">
|
||||||
|
<svg class="tag-icon" viewBox="0 0 24 24">
|
||||||
|
<path :d="mdiCalendar" />
|
||||||
|
</svg>
|
||||||
|
{{ dataset.year }}
|
||||||
|
</span>
|
||||||
|
<span class="tag tag-language">
|
||||||
|
{{ dataset.language?.toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
<span v-if="openAccessLicences.includes(dataset.licence)" class="tag tag-open-access">
|
||||||
|
<svg class="tag-icon" viewBox="0 0 24 24">
|
||||||
|
<path :d="mdiLockOpenVariant" />
|
||||||
|
</svg>
|
||||||
|
Open Access
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hover Effect Overlay -->
|
||||||
|
<div class="card-overlay"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-if="datasets.length === 0" class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||||
|
<circle cx="12" cy="10" r="3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="empty-title">No datasets selected</h3>
|
||||||
|
<p class="empty-description">Draw a rectangle on the map to search for datasets in that area</p>
|
||||||
|
</div>
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
<!-- </section> -->
|
|
||||||
</LayoutAuthenticated>
|
</LayoutAuthenticated>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Map Wrapper */
|
||||||
|
.map-wrapper {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-wrapper:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Results Header */
|
||||||
|
.results-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .results-header {
|
||||||
|
border-bottom-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .results-title {
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-count {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
background: linear-gradient(135deg, #65dc21 0%, #357c06 100%);
|
||||||
|
color: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-subtitle {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .results-subtitle {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Results Grid */
|
||||||
|
.results-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dataset Card */
|
||||||
|
.dataset-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 3px 0 rgba(0, 0, 0, 0.1),
|
||||||
|
0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
animation: fadeInUp 0.5s ease-out forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .dataset-card {
|
||||||
|
background: #1f2937;
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataset-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow:
|
||||||
|
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||||
|
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .dataset-card:hover {
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Header */
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: linear-gradient(135deg, #65dc21 0%, #357c06 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-type {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Content */
|
||||||
|
.card-content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .card-title {
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-abstract {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .card-abstract {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Authors Section */
|
||||||
|
.card-authors {
|
||||||
|
padding: 0 1.5rem 1rem;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .card-authors {
|
||||||
|
border-top-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .author-label {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-tag {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #4b5563;
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .author-tag {
|
||||||
|
color: #d1d5db;
|
||||||
|
background: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-more {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .author-more {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Footer */
|
||||||
|
.card-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .card-footer {
|
||||||
|
background: #111827;
|
||||||
|
border-top-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-icon {
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-year {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tag-year {
|
||||||
|
background: #1e3a8a;
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-language {
|
||||||
|
background: #fce7f3;
|
||||||
|
color: #9f1239;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tag-language {
|
||||||
|
background: #831843;
|
||||||
|
color: #fbcfe8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-open-access {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tag-open-access {
|
||||||
|
background: #064e3b;
|
||||||
|
color: #6ee7b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Overlay */
|
||||||
|
.card-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(101, 220, 33, 0.1) 0%, rgba(53, 124, 6, 0.1) 100%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataset-card:hover .card-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .empty-state {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .empty-icon {
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .empty-title {
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-description {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .empty-description {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.results-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, usePage } from '@inertiajs/vue3';
|
import { Head, usePage } from '@inertiajs/vue3';
|
||||||
import { ComputedRef } from 'vue';
|
import { ComputedRef } from 'vue';
|
||||||
import { mdiAlertBoxOutline, mdiGlasses, mdiReiterate } from '@mdi/js';
|
import { mdiAlertBoxOutline, mdiGlasses, mdiAccountArrowLeft, mdiChevronDown, mdiChevronUp } from '@mdi/js';
|
||||||
import { computed } from 'vue';
|
import { computed, ref, onMounted } from 'vue';
|
||||||
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
||||||
import SectionMain from '@/Components/SectionMain.vue';
|
import SectionMain from '@/Components/SectionMain.vue';
|
||||||
import BaseButton from '@/Components/BaseButton.vue';
|
import BaseButton from '@/Components/BaseButton.vue';
|
||||||
|
import BaseIcon from '@/Components/BaseIcon.vue';
|
||||||
import CardBox from '@/Components/CardBox.vue';
|
import CardBox from '@/Components/CardBox.vue';
|
||||||
import BaseButtons from '@/Components/BaseButtons.vue';
|
import BaseButtons from '@/Components/BaseButtons.vue';
|
||||||
import NotificationBar from '@/Components/NotificationBar.vue';
|
import NotificationBar from '@/Components/NotificationBar.vue';
|
||||||
|
|
@ -25,63 +26,83 @@ const props = defineProps({
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
// user: {
|
|
||||||
// type: Object,
|
|
||||||
// default: () => ({}),
|
|
||||||
// }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const flash: ComputedRef<any> = computed(() => {
|
const flash: ComputedRef<any> = computed(() => {
|
||||||
// let test = usePage();
|
|
||||||
// console.log(test);
|
|
||||||
return usePage().props.flash;
|
return usePage().props.flash;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Legend visibility state with localStorage persistence
|
||||||
|
const showLegend = ref(true);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const savedState = localStorage.getItem('reviewerDatasetLegendVisible');
|
||||||
|
if (savedState !== null) {
|
||||||
|
showLegend.value = savedState === 'true';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleLegend = () => {
|
||||||
|
showLegend.value = !showLegend.value;
|
||||||
|
localStorage.setItem('reviewerDatasetLegendVisible', String(showLegend.value));
|
||||||
|
};
|
||||||
|
|
||||||
const getRowClass = (dataset) => {
|
const getRowClass = (dataset) => {
|
||||||
// (props.options ? 'select' : props.type)
|
// Return Tailwind classes that will be defined in tailwind.config
|
||||||
let rowclass = '';
|
const stateClasses = {
|
||||||
if (dataset.server_state == 'approved') {
|
'approved': 'bg-approved dark:bg-approved-dark',
|
||||||
rowclass = 'bg-approved';
|
'rejected_reviewer': 'bg-rejected-reviewer dark:bg-rejected-reviewer-dark',
|
||||||
} else if (dataset.server_state == 'rejected_reviewer') {
|
'rejected_to_reviewer': 'bg-rejected-reviewer dark:bg-rejected-reviewer-dark',
|
||||||
rowclass = 'bg-rejected-reviewer';
|
'reviewed': 'bg-reviewed dark:bg-reviewed-dark',
|
||||||
} else if (dataset.server_state == 'reviewed') {
|
|
||||||
rowclass = 'bg-reviewed';
|
|
||||||
} else if (dataset.server_state == 'released') {
|
|
||||||
rowclass = 'bg-released';
|
|
||||||
} else if (dataset.server_state == 'published') {
|
|
||||||
rowclass = 'bg-published';
|
|
||||||
} else if (dataset.server_state == 'rejected_to_reviewer') {
|
|
||||||
rowclass = 'bg-rejected-reviewer';
|
|
||||||
} else {
|
|
||||||
rowclass = '';
|
|
||||||
}
|
|
||||||
return rowclass;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// New method to format server state
|
return stateClasses[dataset.server_state] || '';
|
||||||
const formatServerState = (state: string) => {
|
|
||||||
if (state === 'inprogress') {
|
|
||||||
return 'draft';
|
|
||||||
} else if (state === 'released') {
|
|
||||||
return 'submitted';
|
|
||||||
} else if (state === 'approved') {
|
|
||||||
return 'ready for review';
|
|
||||||
} else if (state === 'reviewer_accepted') {
|
|
||||||
return 'in review';
|
|
||||||
}
|
|
||||||
return state; // Return the original state for other cases
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Method to get state badge color
|
||||||
|
const getStateColor = (state: string) => {
|
||||||
|
const stateColors = {
|
||||||
|
'inprogress': 'bg-sky-200 text-sky-900 dark:bg-sky-900 dark:text-sky-200',
|
||||||
|
'released': 'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-200',
|
||||||
|
'editor_accepted': 'bg-teal-200 text-teal-900 dark:bg-teal-900 dark:text-teal-200',
|
||||||
|
'rejected_reviewer': 'bg-amber-200 text-amber-900 dark:bg-amber-900 dark:text-amber-200',
|
||||||
|
'rejected_to_reviewer': 'bg-yellow-200 text-yellow-900 dark:bg-yellow-900 dark:text-yellow-200',
|
||||||
|
'rejected_editor': 'bg-rose-300 text-rose-900 dark:bg-rose-900 dark:text-rose-200',
|
||||||
|
'reviewed': 'bg-yellow-300 text-yellow-900 dark:bg-yellow-900 dark:text-yellow-200',
|
||||||
|
'published': 'bg-green-300 text-green-900 dark:bg-green-900 dark:text-green-200',
|
||||||
|
'approved': 'bg-cyan-200 text-cyan-900 dark:bg-cyan-900 dark:text-cyan-200',
|
||||||
|
'reviewer_accepted': 'bg-lime-200 text-lime-900 dark:bg-lime-900 dark:text-lime-200',
|
||||||
|
};
|
||||||
|
return stateColors[state] || 'bg-sky-200 text-sky-900 dark:bg-sky-900 dark:text-sky-200';
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const truncateTitle = (text: string, length = 50) => {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.length > length ? text.substring(0, length) + '...' : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dynamic legend definitions
|
||||||
|
const datasetStates = [
|
||||||
|
{ key: 'approved', label: 'Ready for Review' },
|
||||||
|
{ key: 'rejected_to_reviewer', label: 'Rejected To Reviewer' }
|
||||||
|
];
|
||||||
|
const getLabel = (key: string) => {
|
||||||
|
return datasetStates.find(s => s.key === key)?.label || 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableActions = [
|
||||||
|
{ icon: mdiGlasses, label: 'View / Review', color: 'text-blue-500' },
|
||||||
|
{ icon: mdiAccountArrowLeft, label: 'Reject to Editor', color: 'text-yellow-600' },
|
||||||
|
];
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LayoutAuthenticated>
|
<LayoutAuthenticated>
|
||||||
|
<Head title="Reviewer Datasets" />
|
||||||
<Head title="Dataset List" />
|
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<!-- <FormValidationErrors v-bind:errors="errors" /> -->
|
|
||||||
<NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline">
|
<NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline">
|
||||||
{{ flash.message }}
|
{{ flash.message }}
|
||||||
</NotificationBar>
|
</NotificationBar>
|
||||||
|
|
@ -92,86 +113,162 @@ const formatServerState = (state: string) => {
|
||||||
{{ flash.error }}
|
{{ flash.error }}
|
||||||
</NotificationBar>
|
</NotificationBar>
|
||||||
|
|
||||||
|
<!-- Legend -->
|
||||||
|
<CardBox class="mb-4">
|
||||||
|
<!-- Legend Header with Toggle -->
|
||||||
|
<!-- <div
|
||||||
|
class="flex items-center justify-between cursor-pointer select-none p-4 border-b border-gray-200 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-800/50 transition-colors"
|
||||||
|
@click="toggleLegend"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
Legend - States & Actions
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ showLegend ? 'Click to hide' : 'Click to show' }}
|
||||||
|
</span>
|
||||||
|
<BaseIcon
|
||||||
|
:path="showLegend ? mdiChevronUp : mdiChevronDown"
|
||||||
|
:size="20"
|
||||||
|
class="text-gray-500 dark:text-gray-400 transition-transform"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<!-- Collapsible Legend Content -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
enter-from-class="max-h-0 opacity-0"
|
||||||
|
enter-to-class="max-h-96 opacity-100"
|
||||||
|
leave-active-class="transition-all duration-300 ease-in"
|
||||||
|
leave-from-class="max-h-96 opacity-100"
|
||||||
|
leave-to-class="max-h-0 opacity-0"
|
||||||
|
>
|
||||||
|
<div v-show="showLegend" class="overflow-hidden">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 p-4">
|
||||||
|
<!-- State Colors Legend -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||||
|
</svg>
|
||||||
|
Dataset States
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div v-for="state in datasetStates" :key="state.key" class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full" :class="getStateColor(state.key)">
|
||||||
|
{{ state.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions Legend -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
Available Actions
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-1.5 text-xs">
|
||||||
|
<div v-for="action in availableActions" :key="action.label" class="flex items-center gap-2">
|
||||||
|
<BaseIcon :path="action.icon" :size="16" :class="action.color" />
|
||||||
|
<span class="text-gray-700 dark:text-gray-300">{{ action.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
<!-- table -->
|
<!-- table -->
|
||||||
<CardBox class="mb-6" has-table>
|
<CardBox class="mb-6" has-table>
|
||||||
<div v-if="props.datasets.data.length > 0">
|
<div v-if="props.datasets.data.length > 0">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="py-3 text-left text-xs font-medium uppercase tracking-wider dark:text-white">
|
<th>Title</th>
|
||||||
<!-- <Sort label="Dataset Title" attribute="title" :search="form.search" /> -->
|
<th>ID</th>
|
||||||
Title
|
<th>State</th>
|
||||||
</th>
|
<th>Editor</th>
|
||||||
<th scope="col" class="py-3 text-left text-xs font-medium uppercase tracking-wider dark:text-white">
|
<th>Remaining Time</th>
|
||||||
ID
|
<th v-if="can.edit || can.delete">Actions</th>
|
||||||
</th>
|
|
||||||
<th scope="col" class="py-3 text-left text-xs font-medium uppercase tracking-wider dark:text-white">
|
|
||||||
<!-- <Sort label="Email" attribute="email" :search="form.search" /> -->
|
|
||||||
State
|
|
||||||
</th>
|
|
||||||
|
|
||||||
<th scope="col" class="py-3 text-left text-xs font-medium uppercase tracking-wider">
|
|
||||||
Editor
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="py-3 text-left text-xs font-medium uppercase tracking-wider dark:text-white">
|
|
||||||
Remaining Time
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="relative px-6 py-3 dark:text-white" v-if="can.edit || can.delete">
|
|
||||||
<span class="sr-only">Actions</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="dataset in props.datasets.data" :key="dataset.id" :class="[getRowClass(dataset)]">
|
<tr v-for="dataset in props.datasets.data" :key="dataset.id"
|
||||||
<td data-label="Login"
|
:class="['hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors', getRowClass(dataset)]">
|
||||||
class="py-4 whitespace-nowrap text-gray-700">
|
<td data-label="Title">
|
||||||
<div class="text-sm table-title">{{ dataset.main_title }}</div>
|
<div class="max-w-xs">
|
||||||
|
<span
|
||||||
|
class="text-gray-700 dark:text-gray-300 text-sm font-medium"
|
||||||
|
:title="dataset.main_title"
|
||||||
|
>
|
||||||
|
{{ truncateTitle(dataset.main_title) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-4 whitespace-nowrap text-gray-700">
|
<td data-label="ID">
|
||||||
<div class="text-sm">{{ dataset.id }}</div>
|
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">
|
||||||
|
{{ dataset.id }}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-4 whitespace-nowrap text-gray-700">
|
<td data-label="State">
|
||||||
<div class="text-sm">{{ formatServerState(dataset.server_state) }}</div>
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize"
|
||||||
|
:class="getStateColor(dataset.server_state)">
|
||||||
|
{{ getLabel(dataset.server_state) }}
|
||||||
|
</span>
|
||||||
<div v-if="dataset.server_state === 'rejected_to_reviewer' && dataset.reject_editor_note"
|
<div v-if="dataset.server_state === 'rejected_to_reviewer' && dataset.reject_editor_note"
|
||||||
class="inline-block relative ml-2 group">
|
class="relative group">
|
||||||
<button
|
<button
|
||||||
class="w-5 h-5 rounded-full bg-gray-200 text-gray-600 text-xs flex items-center justify-center focus:outline-none hover:bg-gray-300">
|
class="w-5 h-5 rounded-full bg-gray-200 dark:bg-slate-700 text-gray-600 dark:text-gray-300 text-xs flex items-center justify-center focus:outline-none hover:bg-gray-300 dark:hover:bg-slate-600 transition-colors">
|
||||||
i
|
i
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
class="absolute left-0 top-full mt-1 w-64 bg-white shadow-lg rounded-md p-3 text-xs text-left z-50 transform scale-0 origin-top-left transition-transform duration-100 group-hover:scale-100">
|
class="absolute left-0 top-full mt-1 w-64 bg-white dark:bg-slate-800 shadow-lg rounded-md p-3 text-xs text-left z-50 transform scale-0 origin-top-left transition-transform duration-100 group-hover:scale-100 border border-gray-200 dark:border-slate-700">
|
||||||
<p class="text-gray-700 max-h-40 overflow-y-auto overflow-x-hidden whitespace-normal break-words">
|
<p class="text-gray-700 dark:text-gray-300 max-h-40 overflow-y-auto overflow-x-hidden whitespace-normal break-words">
|
||||||
{{ dataset.reject_editor_note }}
|
{{ dataset.reject_editor_note }}
|
||||||
</p>
|
</p>
|
||||||
<div class="absolute -top-1 left-1 w-2 h-2 bg-white transform rotate-45">
|
<div class="absolute -top-1 left-1 w-2 h-2 bg-white dark:bg-slate-800 border-l border-t border-gray-200 dark:border-slate-700 transform rotate-45">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<td data-label="Editor">
|
||||||
<td class="py-4 whitespace-nowrap text-gray-700">
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
<div class="text-sm">{{ dataset.editor?.login }}</div>
|
{{ dataset.editor?.login || '—' }}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td data-label="modified" class="py-4 whitespace-nowrap text-gray-700">
|
<td data-label="Remaining Time">
|
||||||
<div class="text-sm" :title="dataset.remaining_time">
|
<span class="text-sm text-gray-600 dark:text-gray-400" :title="dataset.remaining_time">
|
||||||
{{ dataset.remaining_time + ' days' }}
|
{{ dataset.remaining_time }} days
|
||||||
</div>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td v-if="can.reject" class="before:hidden lg:w-1 whitespace-nowrap">
|
||||||
class="py-4 whitespace-nowrap text-right text-sm font-medium text-gray-700">
|
|
||||||
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="can.reject && (dataset.server_state == 'approved' || dataset.server_state == 'rejected_to_reviewer')"
|
v-if="can.reject && (dataset.server_state == 'approved' || dataset.server_state == 'rejected_to_reviewer')"
|
||||||
:route-name="stardust.route('reviewer.dataset.review', [dataset.id])"
|
:route-name="stardust.route('reviewer.dataset.review', [dataset.id])"
|
||||||
color="info" :icon="mdiGlasses" :label="'View'" small />
|
color="info" :icon="mdiGlasses" small
|
||||||
|
title="View / Review" />
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="can.reject && (dataset.server_state == 'approved' || dataset.server_state == 'rejected_to_reviewer')"
|
v-if="can.reject && (dataset.server_state == 'approved' || dataset.server_state == 'rejected_to_reviewer')"
|
||||||
:route-name="stardust.route('reviewer.dataset.reject', [dataset.id])"
|
:route-name="stardust.route('reviewer.dataset.reject', [dataset.id])"
|
||||||
color="info" :icon="mdiReiterate" :label="'Reject'" small />
|
color="warning" :icon="mdiAccountArrowLeft" small
|
||||||
|
title="Reject to Editor" />
|
||||||
</BaseButtons>
|
</BaseButtons>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -179,21 +276,16 @@ const formatServerState = (state: string) => {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<!-- Show warning message if datasets are not defined or empty -->
|
<div class="text-center py-12">
|
||||||
<div class="bg-yellow-200 text-yellow-800 rounded-md p-4">
|
<div class="flex flex-col items-center justify-center text-gray-500 dark:text-gray-400">
|
||||||
<p>No datasets defined.</p>
|
<p class="text-lg font-medium mb-2">No datasets found</p>
|
||||||
<!-- You can add more descriptive text here -->
|
<p class="text-sm">Datasets will appear here when they are ready for review</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- <BaseButton
|
|
||||||
v-if="can.edit && (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer')"
|
|
||||||
:route-name="stardust.route('editor.dataset.edit', [dataset.id])" color="info"
|
|
||||||
:icon="mdiSquareEditOutline" :label="'Edit'" small /> -->
|
|
||||||
<div class="py-4">
|
<div class="py-4">
|
||||||
<Pagination v-bind:data="datasets.meta" />
|
<Pagination v-bind:data="datasets.meta" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
|
|
@ -201,15 +293,24 @@ const formatServerState = (state: string) => {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="css">
|
<style scoped lang="css">
|
||||||
.table-title {
|
/* Background colors are now defined in tailwind.config.js */
|
||||||
max-width: 200px;
|
/* .bg-approved {
|
||||||
/* set a maximum width */
|
@apply bg-approved dark:bg-approved-dark;
|
||||||
overflow: hidden;
|
|
||||||
/* hide overflow */
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
/* show ellipsis for overflowed text */
|
|
||||||
white-space: nowrap;
|
|
||||||
/* prevent wrapping */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-rejected-reviewer {
|
||||||
|
@apply bg-rejected-reviewer dark:bg-rejected-reviewer-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-reviewed {
|
||||||
|
@apply bg-reviewed dark:bg-reviewed-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-released {
|
||||||
|
@apply bg-released dark:bg-released-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-published {
|
||||||
|
@apply bg-published dark:bg-published-dark;
|
||||||
|
} */
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -725,7 +725,7 @@ Removes a selected keyword
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Main Title Language*" help="required: main abstract language"
|
<FormField label="Main Description Language*" help="required: main abstract language"
|
||||||
:class="{ 'text-red-400': form.errors['descriptions.0.language'] }"
|
:class="{ 'text-red-400': form.errors['descriptions.0.language'] }"
|
||||||
class="w-full mx-2 flex-1">
|
class="w-full mx-2 flex-1">
|
||||||
<FormControl required v-model="form.descriptions[0].language" type="text"
|
<FormControl required v-model="form.descriptions[0].language" type="text"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +1,18 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// import { Head, Link, useForm, usePage } from '@inertiajs/inertia-vue3';
|
|
||||||
import { Head, usePage } from '@inertiajs/vue3';
|
import { Head, usePage } from '@inertiajs/vue3';
|
||||||
import { ComputedRef } from 'vue';
|
import { ComputedRef } from 'vue';
|
||||||
import { mdiSquareEditOutline, mdiTrashCan, mdiAlertBoxOutline, mdiLockOpen, mdiLibraryShelves } from '@mdi/js';
|
import { mdiSquareEditOutline, mdiTrashCan, mdiAlertBoxOutline, mdiLockOpen, mdiLibraryShelves, mdiChevronDown, mdiChevronUp } from '@mdi/js';
|
||||||
import { computed } from 'vue';
|
import { computed, ref, onMounted } from 'vue';
|
||||||
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
|
||||||
import SectionMain from '@/Components/SectionMain.vue';
|
import SectionMain from '@/Components/SectionMain.vue';
|
||||||
import BaseButton from '@/Components/BaseButton.vue';
|
import BaseButton from '@/Components/BaseButton.vue';
|
||||||
|
import BaseIcon from '@/Components/BaseIcon.vue';
|
||||||
import CardBox from '@/Components/CardBox.vue';
|
import CardBox from '@/Components/CardBox.vue';
|
||||||
import BaseButtons from '@/Components/BaseButtons.vue';
|
import BaseButtons from '@/Components/BaseButtons.vue';
|
||||||
import NotificationBar from '@/Components/NotificationBar.vue';
|
import NotificationBar from '@/Components/NotificationBar.vue';
|
||||||
import Pagination from '@/Components/Pagination.vue';
|
import Pagination from '@/Components/Pagination.vue';
|
||||||
import { stardust } from '@eidellev/adonis-stardust/client';
|
import { stardust } from '@eidellev/adonis-stardust/client';
|
||||||
|
import Label from '@/Components/unused/Label.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
datasets: {
|
datasets: {
|
||||||
|
|
@ -26,63 +27,120 @@ const props = defineProps({
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
// user: {
|
|
||||||
// type: Object,
|
|
||||||
// default: () => ({}),
|
|
||||||
// }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const flash: ComputedRef<any> = computed(() => {
|
const flash: ComputedRef<any> = computed(() => {
|
||||||
// let test = usePage();
|
|
||||||
// console.log(test);
|
|
||||||
return usePage().props.flash;
|
return usePage().props.flash;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Legend visibility state with localStorage persistence
|
||||||
|
const showLegend = ref(true);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const savedState = localStorage.getItem('submitterDatasetLegendVisible');
|
||||||
|
if (savedState !== null) {
|
||||||
|
showLegend.value = savedState === 'true';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleLegend = () => {
|
||||||
|
showLegend.value = !showLegend.value;
|
||||||
|
localStorage.setItem('submitterDatasetLegendVisible', String(showLegend.value));
|
||||||
|
};
|
||||||
|
|
||||||
const validStates = ['inprogress', 'rejected_editor'];
|
const validStates = ['inprogress', 'rejected_editor'];
|
||||||
|
|
||||||
|
|
||||||
const getRowClass = (dataset) => {
|
const getRowClass = (dataset) => {
|
||||||
// (props.options ? 'select' : props.type)
|
// Return Tailwind classes that will be defined in tailwind.config
|
||||||
let rowclass = '';
|
const stateClasses = {
|
||||||
if (dataset.server_state == 'inprogress') {
|
'inprogress': 'bg-inprogress dark:bg-inprogress-dark',
|
||||||
rowclass = 'bg-inprogress';
|
'released': 'bg-released dark:bg-released-dark',
|
||||||
} else if (dataset.server_state == 'released') {
|
'editor_accepted': 'bg-editor-accepted dark:bg-editor-accepted-dark',
|
||||||
rowclass = 'bg-released';
|
'rejected_reviewer': 'bg-rejected-reviewer dark:bg-rejected-reviewer-dark',
|
||||||
} else if (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer') {
|
'rejected_to_reviewer': 'bg-rejected-reviewer dark:bg-rejected-reviewer-dark',
|
||||||
rowclass = 'bg-editor-accepted';
|
|
||||||
} else if (dataset.server_state == 'approved') {
|
'approved': 'bg-approved dark:bg-approved-dark',
|
||||||
rowclass = 'bg-approved';
|
'reviewed': 'bg-reviewed dark:bg-reviewed-dark',
|
||||||
} else if (dataset.server_state == 'reviewed') {
|
'published': 'bg-published dark:bg-published-dark',
|
||||||
rowclass = 'bg-reviewed';
|
'rejected_editor': 'bg-rejected-editor dark:bg-rejected-editor-dark',
|
||||||
} else if (dataset.server_state == 'rejected_editor') {
|
|
||||||
rowclass = 'bg-rejected-editor';
|
|
||||||
} else {
|
|
||||||
rowclass = '';
|
|
||||||
}
|
|
||||||
return rowclass;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return stateClasses[dataset.server_state] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// New method to format server state
|
// New method to format server state
|
||||||
const formatServerState = (state: string) => {
|
// const formatServerState = (state: string) => {
|
||||||
if (state === 'inprogress') {
|
// if (state === 'inprogress') {
|
||||||
return 'draft';
|
// return 'draft';
|
||||||
} else if (state === 'released') {
|
// } else if (state === 'released') {
|
||||||
return 'submitted';
|
// return 'submitted';
|
||||||
} else if (state === 'approved') {
|
// } else if (state === 'editor_accepted') {
|
||||||
return 'ready for review';
|
// return 'in approval';
|
||||||
} else if (state === 'reviewer_accepted') {
|
// } else if (state === 'approved') {
|
||||||
return 'in review';
|
// return 'ready for review';
|
||||||
}
|
// } else if (state === 'reviewer_accepted') {
|
||||||
return state; // Return the original state for other cases
|
// return 'in review';
|
||||||
|
// } else if (state === 'rejected_editor') {
|
||||||
|
// return 'rejected by editor';
|
||||||
|
// }
|
||||||
|
// return state;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// Method to get state badge color
|
||||||
|
// Method to get state badge color
|
||||||
|
|
||||||
|
const getStateColor = (state: string) => {
|
||||||
|
const stateColors = {
|
||||||
|
'inprogress': 'bg-sky-200 text-sky-900 dark:bg-sky-900 dark:text-sky-200',
|
||||||
|
'released': 'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-200',
|
||||||
|
'editor_accepted': 'bg-teal-200 text-teal-900 dark:bg-teal-900 dark:text-teal-200',
|
||||||
|
'rejected_reviewer': 'bg-amber-200 text-amber-900 dark:bg-amber-900 dark:text-amber-200',
|
||||||
|
'rejected_to_reviewer': 'bg-yellow-200 text-yellow-900 dark:bg-yellow-900 dark:text-yellow-200',
|
||||||
|
'rejected_editor': 'bg-rose-300 text-rose-900 dark:bg-rose-900 dark:text-rose-200',
|
||||||
|
'reviewed': 'bg-yellow-300 text-yellow-900 dark:bg-yellow-900 dark:text-yellow-200',
|
||||||
|
'published': 'bg-green-300 text-green-900 dark:bg-green-900 dark:text-green-200',
|
||||||
|
'approved': 'bg-cyan-200 text-cyan-900 dark:bg-cyan-900 dark:text-cyan-200',
|
||||||
|
'reviewer_accepted': 'bg-lime-200 text-lime-900 dark:bg-lime-900 dark:text-lime-200',
|
||||||
|
};
|
||||||
|
return stateColors[state] || 'bg-sky-200 text-sky-900 dark:bg-sky-900 dark:text-sky-200';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Dynamic legend definitions
|
||||||
|
const datasetStates = [
|
||||||
|
{ key: 'inprogress', label: 'Draft' },
|
||||||
|
{ key: 'released', label: 'Submitted' },
|
||||||
|
{ key: 'editor_accepted', label: 'In Approval' },
|
||||||
|
{ key: 'approved', label: 'Ready for Review' },
|
||||||
|
{ key: 'reviewer_accepted', label: 'In Review' },
|
||||||
|
{ key: 'reviewed', label: 'Reviewed' },
|
||||||
|
// { key: 'published', label: 'Published' },
|
||||||
|
{ key: 'rejected_editor', label: 'Rejected by Editor' },
|
||||||
|
{ key: 'rejected_reviewer', label: 'Rejected by Reviewer' },
|
||||||
|
{ key: 'rejected_to_reviewer', label: 'Rejected To Reviewer' }
|
||||||
|
];
|
||||||
|
const getLabel = (key: string) => {
|
||||||
|
return datasetStates.find(s => s.key === key)?.label || 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableActions = [
|
||||||
|
{ icon: mdiLockOpen, label: 'Release (Submit)', color: 'text-blue-500' },
|
||||||
|
{ icon: mdiSquareEditOutline, label: 'Edit', color: 'text-blue-500' },
|
||||||
|
{ icon: mdiLibraryShelves, label: 'Classify', color: 'text-blue-500' },
|
||||||
|
{ icon: mdiTrashCan, label: 'Delete', color: 'text-red-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const truncateTitle = (text: string, length = 50) => {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.length > length ? text.substring(0, length) + '...' : text;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LayoutAuthenticated>
|
<LayoutAuthenticated>
|
||||||
|
<Head title="My Datasets" />
|
||||||
<Head title="Dataset List" />
|
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<!-- <FormValidationErrors v-bind:errors="errors" /> -->
|
|
||||||
<NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline">
|
<NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline">
|
||||||
{{ flash.message }}
|
{{ flash.message }}
|
||||||
</NotificationBar>
|
</NotificationBar>
|
||||||
|
|
@ -90,86 +148,170 @@ const formatServerState = (state: string) => {
|
||||||
{{ flash.warning }}
|
{{ flash.warning }}
|
||||||
</NotificationBar>
|
</NotificationBar>
|
||||||
|
|
||||||
|
<!-- Legend -->
|
||||||
|
<CardBox class="mb-4">
|
||||||
|
<!-- Legend Header with Toggle -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between cursor-pointer select-none p-4 border-b border-gray-200 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-800/50 transition-colors"
|
||||||
|
@click="toggleLegend"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
Legend - States & Actions
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ showLegend ? 'Click to hide' : 'Click to show' }}
|
||||||
|
</span>
|
||||||
|
<BaseIcon
|
||||||
|
:path="showLegend ? mdiChevronUp : mdiChevronDown"
|
||||||
|
:size="20"
|
||||||
|
class="text-gray-500 dark:text-gray-400 transition-transform"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collapsible Legend Content -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
enter-from-class="max-h-0 opacity-0"
|
||||||
|
enter-to-class="max-h-96 opacity-100"
|
||||||
|
leave-active-class="transition-all duration-300 ease-in"
|
||||||
|
leave-from-class="max-h-96 opacity-100"
|
||||||
|
leave-to-class="max-h-0 opacity-0"
|
||||||
|
>
|
||||||
|
<div v-show="showLegend" class="overflow-hidden">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 p-4">
|
||||||
|
<!-- State Colors Legend -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||||
|
</svg>
|
||||||
|
Dataset States
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div v-for="state in datasetStates" :key="state.key" class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full" :class="getStateColor(state.key)">
|
||||||
|
{{ state.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions Legend -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
Available Actions
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-1.5 text-xs">
|
||||||
|
<div v-for="action in availableActions" :key="action.label" class="flex items-center gap-2">
|
||||||
|
<BaseIcon :path="action.icon" :size="16" :class="action.color" />
|
||||||
|
<span class="text-gray-700 dark:text-gray-300">{{ action.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
<!-- table -->
|
<!-- table -->
|
||||||
<CardBox class="mb-6" has-table>
|
<CardBox class="mb-6" has-table>
|
||||||
<table class="w-full table-fixed">
|
<div v-if="props.datasets.data.length > 0">
|
||||||
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="py-3 text-left text-xs font-medium uppercase tracking-wider dark:text-white">
|
<th>Title</th>
|
||||||
<!-- <Sort label="Dataset Title" attribute="title" :search="form.search" /> -->
|
<th>State</th>
|
||||||
Dataset Title
|
<th>Modified</th>
|
||||||
</th>
|
<th v-if="can.edit || can.delete">Actions</th>
|
||||||
<th scope="col" class="py-3 text-left text-xs font-medium uppercase tracking-wider dark:text-white">
|
|
||||||
<!-- <Sort label="Email" attribute="email" :search="form.search" /> -->
|
|
||||||
Server State
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="py-3 text-left text-xs font-medium uppercase tracking-wider dark:text-white">
|
|
||||||
Date of last modification
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="relative px-6 py-3 dark:text-white" v-if="can.edit || can.delete">
|
|
||||||
<span class="sr-only">Actions</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<tbody>
|
||||||
<tr v-for="dataset in props.datasets.data" :key="dataset.id" :class="getRowClass(dataset)">
|
<tr v-for="dataset in props.datasets.data" :key="dataset.id"
|
||||||
<td data-label="Login"
|
:class="['hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors', getRowClass(dataset)]">
|
||||||
class="py-4 whitespace-nowrap text-gray-700 table-title">
|
<td data-label="Title">
|
||||||
<!-- <Link v-bind:href="stardust.route('settings.user.show', [user.id])"
|
<div class="max-w-xs">
|
||||||
class="no-underline hover:underline text-cyan-600 dark:text-cyan-400">
|
<span
|
||||||
{{ user.login }}
|
class="text-gray-700 dark:text-gray-300 text-sm font-medium"
|
||||||
</Link> -->
|
:title="dataset.main_title"
|
||||||
<!-- {{ user.id }} -->
|
>
|
||||||
{{ dataset.main_title }}
|
{{ truncateTitle(dataset.main_title) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-4 whitespace-nowrap text-gray-700">
|
<td data-label="State">
|
||||||
{{ formatServerState(dataset.server_state) }}
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize"
|
||||||
|
:class="getStateColor(dataset.server_state)">
|
||||||
|
{{ getLabel(dataset.server_state) }}
|
||||||
|
</span>
|
||||||
<div v-if="dataset.server_state === 'rejected_editor' && dataset.reject_editor_note"
|
<div v-if="dataset.server_state === 'rejected_editor' && dataset.reject_editor_note"
|
||||||
class="inline-block relative ml-2 group">
|
class="relative group">
|
||||||
<button
|
<button
|
||||||
class="w-5 h-5 rounded-full bg-gray-200 text-gray-600 text-xs flex items-center justify-center focus:outline-none hover:bg-gray-300">
|
class="w-5 h-5 rounded-full bg-gray-200 dark:bg-slate-700 text-gray-600 dark:text-gray-300 text-xs flex items-center justify-center focus:outline-none hover:bg-gray-300 dark:hover:bg-slate-600 transition-colors">
|
||||||
i
|
i
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
class="absolute left-0 top-full mt-1 w-64 bg-white shadow-lg rounded-md p-3 text-xs text-left z-50 transform scale-0 origin-top-left transition-transform duration-100 group-hover:scale-100">
|
class="absolute left-0 top-full mt-1 w-64 bg-white dark:bg-slate-800 shadow-lg rounded-md p-3 text-xs text-left z-50 transform scale-0 origin-top-left transition-transform duration-100 group-hover:scale-100 border border-gray-200 dark:border-slate-700">
|
||||||
<p
|
<p class="text-gray-700 dark:text-gray-300 max-h-40 overflow-y-auto overflow-x-hidden whitespace-normal break-words">
|
||||||
class="text-gray-700 max-h-40 overflow-y-auto overflow-x-hidden whitespace-normal break-words">
|
|
||||||
{{ dataset.reject_editor_note }}
|
{{ dataset.reject_editor_note }}
|
||||||
</p>
|
</p>
|
||||||
<div class="absolute -top-1 left-1 w-2 h-2 bg-white transform rotate-45">
|
<div class="absolute -top-1 left-1 w-2 h-2 bg-white dark:bg-slate-800 border-l border-t border-gray-200 dark:border-slate-700 transform rotate-45">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td data-label="modified" class="py-4 whitespace-nowrap text-gray-700">
|
<td data-label="Modified">
|
||||||
<div class="text-sm" :title="dataset.server_date_modified">
|
<span class="text-sm text-gray-600 dark:text-gray-400" :title="dataset.server_date_modified">
|
||||||
{{ dataset.server_date_modified }}
|
{{ dataset.server_date_modified }}
|
||||||
</div>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td v-if="can.edit || can.delete" class="before:hidden lg:w-1 whitespace-nowrap">
|
||||||
class="py-4 whitespace-nowrap text-right text-sm font-medium text-gray-700">
|
|
||||||
<BaseButtons v-if="validStates.includes(dataset.server_state)"
|
<BaseButtons v-if="validStates.includes(dataset.server_state)"
|
||||||
type="justify-start lg:justify-end" no-wrap>
|
type="justify-start lg:justify-end" no-wrap>
|
||||||
<!-- release created dataset -->
|
|
||||||
<BaseButton v-if="can.edit"
|
<BaseButton v-if="can.edit"
|
||||||
:route-name="stardust.route('dataset.release', [dataset.id])" color="info"
|
:route-name="stardust.route('dataset.release', [dataset.id])" color="info"
|
||||||
:icon="mdiLockOpen" :label="'Release'" small />
|
:icon="mdiLockOpen" small
|
||||||
|
title="Release (Submit)" />
|
||||||
<BaseButton v-if="can.edit"
|
<BaseButton v-if="can.edit"
|
||||||
:route-name="stardust.route('dataset.edit', [dataset.id])" color="info"
|
:route-name="stardust.route('dataset.edit', [dataset.id])" color="info"
|
||||||
:icon="mdiSquareEditOutline" :label="'Edit'" small />
|
:icon="mdiSquareEditOutline" small
|
||||||
|
title="Edit" />
|
||||||
<BaseButton v-if="can.edit"
|
<BaseButton v-if="can.edit"
|
||||||
:route-name="stardust.route('dataset.categorize', [dataset.id])" color="info"
|
:route-name="stardust.route('dataset.categorize', [dataset.id])" color="info"
|
||||||
:icon="mdiLibraryShelves" :label="'Classify'" small />
|
:icon="mdiLibraryShelves" small
|
||||||
|
title="Classify" />
|
||||||
<BaseButton v-if="can.delete" color="danger"
|
<BaseButton v-if="can.delete" color="danger"
|
||||||
:route-name="stardust.route('dataset.delete', [dataset.id])" :icon="mdiTrashCan"
|
:route-name="stardust.route('dataset.delete', [dataset.id])" :icon="mdiTrashCan"
|
||||||
small />
|
small
|
||||||
|
title="Delete" />
|
||||||
</BaseButtons>
|
</BaseButtons>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="flex flex-col items-center justify-center text-gray-500 dark:text-gray-400">
|
||||||
|
<p class="text-lg font-medium mb-2">No datasets found</p>
|
||||||
|
<p class="text-sm">Create your first dataset to get started</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="py-4">
|
<div class="py-4">
|
||||||
<Pagination v-bind:data="datasets.meta" />
|
<Pagination v-bind:data="datasets.meta" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -179,56 +321,28 @@ const formatServerState = (state: string) => {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="css">
|
<style scoped lang="css">
|
||||||
.table-title {
|
/* Background colors are now defined in tailwind.config.js */
|
||||||
max-width: 200px;
|
/* .bg-inprogress {
|
||||||
/* set a maximum width */
|
@apply bg-inprogress dark:bg-inprogress-dark;
|
||||||
overflow: hidden;
|
|
||||||
/* hide overflow */
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
/* show ellipsis for overflowed text */
|
|
||||||
white-space: nowrap;
|
|
||||||
/* prevent wrapping */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-fixed {
|
.bg-released {
|
||||||
table-layout: fixed;
|
@apply bg-released dark:bg-released-dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .pure-table tr.released {
|
.bg-editor-accepted {
|
||||||
background-color: rgb(52 211 153);
|
@apply bg-editor-accepted dark:bg-editor-accepted-dark;
|
||||||
color: gray;
|
}
|
||||||
} */
|
|
||||||
|
|
||||||
/* .pure-table tr.inprogress {
|
.bg-approved {
|
||||||
padding: 0.8em;
|
@apply bg-approved dark:bg-approved-dark;
|
||||||
background-color: rgb(94 234 212);
|
}
|
||||||
color: gray;
|
|
||||||
} */
|
|
||||||
|
|
||||||
/* .pure-table tr.editor_accepted {
|
.bg-reviewed {
|
||||||
background-color: rgb(125 211 252);
|
@apply bg-reviewed dark:bg-reviewed-dark;
|
||||||
color: gray;
|
}
|
||||||
} */
|
|
||||||
|
|
||||||
/* .pure-table tr.rejected_reviewer {
|
|
||||||
padding: 0.8em;
|
|
||||||
background-color: orange;
|
|
||||||
color: gray;
|
|
||||||
} */
|
|
||||||
|
|
||||||
/* .pure-table tr.rejected_editor {
|
|
||||||
background-color: orange;
|
|
||||||
color: gray;
|
|
||||||
} */
|
|
||||||
|
|
||||||
/* .pure-table tr.reviewed {
|
|
||||||
background-color: yellow;
|
|
||||||
color: gray;
|
|
||||||
} */
|
|
||||||
|
|
||||||
/* .pure-table tr.approved {
|
|
||||||
background-color: rgb(86, 86, 241);
|
|
||||||
color: whitesmoke;
|
|
||||||
|
|
||||||
|
.bg-rejected-editor {
|
||||||
|
@apply bg-rejected-editor dark:bg-rejected-editor-dark;
|
||||||
} */
|
} */
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,21 +1,41 @@
|
||||||
<template>
|
<template>
|
||||||
<NcSettingsSection :name="t('settings', 'Background jobs')" :description="t(
|
<NcSettingsSection
|
||||||
|
:name="t('settings', 'Background jobs')"
|
||||||
|
:description="
|
||||||
|
t(
|
||||||
'settings',
|
'settings',
|
||||||
`For the server to work properly, it\'s important to configure background jobs correctly. Cron is the recommended setting. Please see the documentation for more information.`,
|
`For the server to work properly, it's important to configure background jobs correctly. Cron is the recommended setting. Please see the documentation for more information.`,
|
||||||
)" :doc-url="backgroundJobsDocUrl">
|
)
|
||||||
|
"
|
||||||
<template v-if="lastCron !== 0">
|
:doc-url="backgroundJobsDocUrl"
|
||||||
<NcNoteCard v-if="oldExecution" type="danger">
|
>
|
||||||
{{ t('settings', `Last job execution ran {time}. Something seems wrong.`, {
|
<template v-if="lastCronTimestamp">
|
||||||
|
<NcNoteCard v-if="isExecutionTooOld" type="danger">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'settings',
|
||||||
|
`Last job execution ran {time}. Something seems wrong.
|
||||||
|
Timestamp of last cron: {timestamp} `,
|
||||||
|
{
|
||||||
time: relativeTime,
|
time: relativeTime,
|
||||||
timestamp: lastCron
|
timestamp: lastCronTimestamp,
|
||||||
}) }}
|
},
|
||||||
|
)
|
||||||
|
}}
|
||||||
</NcNoteCard>
|
</NcNoteCard>
|
||||||
|
|
||||||
<!-- <NcNoteCard v-else-if="longExecutionCron" type="warning">
|
<NcNoteCard v-else-if="isLongExecutionCron" type="warning">
|
||||||
{{ t('settings', `Some jobs have not been executed since {maxAgeRelativeTime}. Please consider increasing the execution frequency.`, {maxAgeRelativeTime}) }}
|
{{
|
||||||
</NcNoteCard> -->
|
t(
|
||||||
|
'settings',
|
||||||
|
`Some jobs have not been executed since {maxAgeRelativeTime}. Please consider increasing the execution
|
||||||
|
frequency.`,
|
||||||
|
{
|
||||||
|
maxAgeRelativeTime,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</NcNoteCard>
|
||||||
|
|
||||||
<NcNoteCard v-else type="success">
|
<NcNoteCard v-else type="success">
|
||||||
{{ t('settings', 'Last job ran {relativeTime}.', { relativeTime }) }}
|
{{ t('settings', 'Last job ran {relativeTime}.', { relativeTime }) }}
|
||||||
|
|
@ -23,147 +43,180 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<NcNoteCard v-else type="danger">
|
<NcNoteCard v-else type="danger">
|
||||||
'Background job did not run yet!'
|
{{ t('settings', 'Background job did not run yet!') }}
|
||||||
</NcNoteCard>
|
</NcNoteCard>
|
||||||
|
|
||||||
|
<!-- Missing Cross References Warning -->
|
||||||
|
<NcNoteCard v-if="missingCrossReferencesCount >= 1" type="warning">
|
||||||
|
{{
|
||||||
|
t('settings', 'Found {count} missing dataset cross-reference(s). You can fix this by running: node ace detect:missing-cross-references --fix', {
|
||||||
|
count: missingCrossReferencesCount,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</NcNoteCard>
|
||||||
|
|
||||||
|
<!-- Background Jobs Status Display -->
|
||||||
|
<div class="background-jobs-mode">
|
||||||
|
<h3>{{ t('settings', 'Background Jobs Mode') }}</h3>
|
||||||
|
|
||||||
|
<div class="current-mode">
|
||||||
|
<span class="mode-label">{{ t('settings', 'Current mode:') }}</span>
|
||||||
|
<span class="mode-value">{{ getCurrentModeLabel() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <div class="mode-description" v-if="backgroundJobsMode === 'cron'" v-html="cronLabel"></div> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Refresh Button -->
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="primary" @click="refreshStatus" :disabled="isRefreshing">
|
||||||
|
{{ isRefreshing ? t('settings', 'Refreshing...') : t('settings', 'Refresh Status') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</NcSettingsSection>
|
</NcSettingsSection>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { usePage } from '@inertiajs/vue3';
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { usePage, router } from '@inertiajs/vue3';
|
||||||
import { loadState } from '@/utils/initialState';
|
import { loadState } from '@/utils/initialState';
|
||||||
import { showError } from '@/utils/toast';
|
import { showError, showSuccess } from '@/utils/toast';
|
||||||
// import { generateOcsUrl } from '@nextcloud/router';
|
|
||||||
// import { confirmPassword } from '@nextcloud/password-confirmation';
|
|
||||||
import axios from 'axios';
|
|
||||||
import dayjs from '@/utils/dayjs';
|
import dayjs from '@/utils/dayjs';
|
||||||
|
|
||||||
import NcNoteCard from '@/Components/NCNoteCard.vue';
|
import NcNoteCard from '@/Components/NCNoteCard.vue';
|
||||||
import NcSettingsSection from '@/Components/NcSettingsSection.vue';
|
import NcSettingsSection from '@/Components/NcSettingsSection.vue';
|
||||||
import { translate as t } from '@/utils/tethyscloud-l10n';
|
import { translate as t } from '@/utils/tethyscloud-l10n';
|
||||||
// import { useLocaleStore } from '@/Stores/locale';
|
|
||||||
|
|
||||||
// import '@nextcloud/password-confirmation/dist/style.css';
|
// Props and reactive data
|
||||||
|
const cronMaxAge =ref<number>(loadState('settings', 'cronMaxAge', 1758824778));
|
||||||
|
const backgroundJobsMode = ref<string>(loadState('settings', 'backgroundJobsMode', 'cron'));
|
||||||
|
const cliBasedCronPossible = ref<boolean>(loadState('settings', 'cliBasedCronPossible', true));
|
||||||
|
const cliBasedCronUser = ref<string>(loadState('settings', 'cliBasedCronUser', 'www-data'));
|
||||||
|
const backgroundJobsDocUrl = ref<string>(loadState('settings', 'backgroundJobsDocUrl', ''));
|
||||||
|
const isRefreshing = ref<boolean>(false);
|
||||||
|
|
||||||
// const lastCron: number = 1723807502; //loadState('settings', 'lastCron'); //1723788607
|
// Use reactive page reference
|
||||||
const cronMaxAge: number = 1724046901;//loadState('settings', 'cronMaxAge', 0); //''
|
const page = usePage();
|
||||||
const backgroundJobsMode: string = loadState('settings', 'backgroundJobsMode', 'cron'); //cron
|
|
||||||
const cliBasedCronPossible = loadState('settings', 'cliBasedCronPossible', true); //true
|
|
||||||
const cliBasedCronUser = loadState('settings', 'cliBasedCronUser', 'www-data'); //www-data
|
|
||||||
const backgroundJobsDocUrl: string = loadState('settings', 'backgroundJobsDocUrl'); //https://docs.nextcloud.com/server/29/go.php?to=admin-background-jobs
|
|
||||||
|
|
||||||
// await loadTranslations('settings');
|
// Auto-refresh timer
|
||||||
|
let refreshTimer: NodeJS.Timeout | null = null;
|
||||||
|
const AUTO_REFRESH_INTERVAL = 30000; // 30 seconds
|
||||||
|
|
||||||
export default {
|
// Computed properties
|
||||||
name: 'BackgroundJob',
|
const missingCrossReferencesCount = computed((): number => {
|
||||||
|
const count = page.props.missingCrossReferencesCount as string | number;
|
||||||
|
return typeof count === 'string' ? parseInt(count) || 0 : count || 0;
|
||||||
|
});
|
||||||
|
|
||||||
components: {
|
const lastCronTimestamp = computed((): number => {
|
||||||
NcSettingsSection,
|
const lastCron = page.props.lastCron as string | number;
|
||||||
NcNoteCard,
|
return typeof lastCron === 'string' ? parseInt(lastCron) || 0 : lastCron || 0;
|
||||||
},
|
});
|
||||||
|
|
||||||
data() {
|
const relativeTime = computed((): string => {
|
||||||
return {
|
if (!lastCronTimestamp.value) return '';
|
||||||
// lastCron: 0,
|
// Reference currentTime.value to make this reactive to time changes
|
||||||
cronMaxAge: cronMaxAge,
|
let intermValue = page.props.lastCron as string | number;
|
||||||
backgroundJobsMode: backgroundJobsMode,
|
intermValue = typeof intermValue === 'string' ? parseInt(intermValue) || 0 : intermValue || 0;
|
||||||
cliBasedCronPossible: cliBasedCronPossible,
|
return dayjs.unix(intermValue).fromNow();
|
||||||
cliBasedCronUser: cliBasedCronUser,
|
});
|
||||||
backgroundJobsDocUrl: backgroundJobsDocUrl,
|
|
||||||
// relativeTime: dayjs(this.lastCron * 1000).fromNow(),
|
|
||||||
// maxAgeRelativeTime: dayjs(cronMaxAge * 1000).fromNow(),
|
|
||||||
t: t,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
lastCron(): number {
|
|
||||||
return usePage().props.lastCron as number;
|
|
||||||
|
|
||||||
},
|
const maxAgeRelativeTime = computed((): string => {
|
||||||
relativeTime() {
|
return dayjs.unix(cronMaxAge).fromNow();
|
||||||
return dayjs.unix(this.lastCron).fromNow(); // Calculate relative time for lastCron
|
});
|
||||||
},
|
|
||||||
maxAgeRelativeTime() {
|
const cronLabel = computed((): string => {
|
||||||
return dayjs.unix(this.cronMaxAge).fromNow(); // Calculate relative time for cronMaxAge
|
let desc = t('settings', 'Use system cron service to call the cron.php file every 5 minutes.');
|
||||||
},
|
|
||||||
cronLabel() {
|
if (cliBasedCronPossible.value) {
|
||||||
let desc = 'Use system cron service to call the cron.php file every 5 minutes.';
|
|
||||||
if (this.cliBasedCronPossible) {
|
|
||||||
desc +=
|
desc +=
|
||||||
'<br>' +
|
'<br>' +
|
||||||
'The cron.php needs to be executed by the system account "{user}".', { user: this.cliBasedCronUser };
|
t('settings', 'The cron.php needs to be executed by the system account "{user}".', {
|
||||||
|
user: cliBasedCronUser.value,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
desc +=
|
desc +=
|
||||||
'<br>' +
|
'<br>' +
|
||||||
|
t('settings', 'The PHP POSIX extension is required. See {linkstart}PHP documentation{linkend} for more details.', {
|
||||||
'The PHP POSIX extension is required. See {linkstart}PHP documentation{linkend} for more details.',
|
|
||||||
{
|
|
||||||
linkstart:
|
linkstart:
|
||||||
'<a target="_blank" rel="noreferrer nofollow" class="external" href="https://www.php.net/manual/en/book.posix.php">',
|
'<a target="_blank" rel="noreferrer nofollow" class="external" href="https://www.php.net/manual/en/book.posix.php">',
|
||||||
linkend: '</a>',
|
linkend: '</a>',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return desc;
|
return desc;
|
||||||
},
|
|
||||||
oldExecution() {
|
|
||||||
return (dayjs().unix() - this.lastCron) > 600; // older than 10 minutes
|
|
||||||
},
|
|
||||||
|
|
||||||
longExecutionCron() {
|
|
||||||
//type of cron job and greater than 24h
|
|
||||||
// let test = dayjs.unix(this.cronMaxAge).format('YYYY-MM-DD HH:mm:ss');
|
|
||||||
return (dayjs().unix() - this.cronMaxAge) > 24 * 3600 && this.backgroundJobsMode === 'cron';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async onBackgroundJobModeChanged(backgroundJobsMode: string) {
|
|
||||||
const url = generateOcsUrl('/apps/provisioning_api/api/v1/config/apps/{appId}/{key}', {
|
|
||||||
appId: 'core',
|
|
||||||
key: 'backgroundjobs_mode',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// await confirmPassword();
|
const isExecutionTooOld = computed((): boolean => {
|
||||||
|
if (!lastCronTimestamp.value) return false;
|
||||||
|
return dayjs().unix() - lastCronTimestamp.value > 600; // older than 10 minutes
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
const isLongExecutionCron = computed((): boolean => {
|
||||||
const { data } = await axios.post(url, {
|
return dayjs().unix() - cronMaxAge > 24 * 3600 && backgroundJobsMode.value === 'cron';
|
||||||
value: backgroundJobsMode,
|
|
||||||
});
|
|
||||||
this.handleResponse({
|
|
||||||
status: data.ocs?.meta?.status,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
this.handleResponse({
|
|
||||||
errorMessage: t('settings', 'Unable to update background job mode'),
|
|
||||||
error: e,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const getCurrentModeLabel = (): string => {
|
||||||
|
switch (backgroundJobsMode.value) {
|
||||||
|
case 'cron':
|
||||||
|
return t('settings', 'Cron (Recommended)');
|
||||||
|
case 'webcron':
|
||||||
|
return t('settings', 'Webcron');
|
||||||
|
case 'ajax':
|
||||||
|
return t('settings', 'AJAX');
|
||||||
|
default:
|
||||||
|
return t('settings', 'Unknown');
|
||||||
}
|
}
|
||||||
},
|
|
||||||
async handleResponse({ status, errorMessage, error }) {
|
|
||||||
if (status === 'ok') {
|
|
||||||
await this.deleteError();
|
|
||||||
} else {
|
|
||||||
showError(errorMessage);
|
|
||||||
console.error(errorMessage, error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async deleteError() {
|
|
||||||
// clear cron errors on background job mode change
|
|
||||||
const url = generateOcsUrl('/apps/provisioning_api/api/v1/config/apps/{appId}/{key}', {
|
|
||||||
appId: 'core',
|
|
||||||
key: 'cronErrors',
|
|
||||||
});
|
|
||||||
|
|
||||||
// await confirmPassword();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await axios.delete(url);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshStatus = async (): Promise<void> => {
|
||||||
|
isRefreshing.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use Inertia to refresh the current page data
|
||||||
|
router.reload({
|
||||||
|
only: ['lastCron', 'cronMaxAge', 'missingCrossReferencesCount'], // Also reload missing cross references count
|
||||||
|
onSuccess: () => {
|
||||||
|
showSuccess(t('settings', 'Background job status refreshed'));
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showError(t('settings', 'Failed to refresh status'));
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
isRefreshing.value = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh status:', error);
|
||||||
|
showError(t('settings', 'Failed to refresh status'));
|
||||||
|
isRefreshing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAutoRefresh = (): void => {
|
||||||
|
refreshTimer = setInterval(() => {
|
||||||
|
if (!isRefreshing.value) {
|
||||||
|
router.reload({ only: ['lastCron', 'cronMaxAge', 'missingCrossReferencesCount'] });
|
||||||
|
}
|
||||||
|
}, AUTO_REFRESH_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAutoRefresh = (): void => {
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearInterval(refreshTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle hooks
|
||||||
|
onMounted(() => {
|
||||||
|
startAutoRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopAutoRefresh();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="css" scoped>
|
<style lang="css" scoped>
|
||||||
|
|
@ -185,7 +238,76 @@ export default {
|
||||||
width: initial;
|
width: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ajaxSwitch {
|
.background-jobs-mode {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.background-jobs-mode h3 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-mode {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-main-text);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background-color: var(--color-background-hover);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-description {
|
||||||
|
color: var(--color-text-maxcontrast);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button.primary {
|
||||||
|
background-color: var(--color-primary-element);
|
||||||
|
color: var(--color-primary-element-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button.primary:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-primary-element-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button.primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.current-mode {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -99,5 +99,6 @@ export const getButtonColor = (color: 'white' | 'contrast' | 'light' | 'success'
|
||||||
base.push(isOutlined ? colors.outlineHover[color] : colors.bgHover[color]);
|
base.push(isOutlined ? colors.outlineHover[color] : colors.bgHover[color]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return base;
|
// return base;
|
||||||
|
return base.join(' '); // Join array into single string
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
mdiShieldCrownOutline,
|
mdiShieldCrownOutline,
|
||||||
mdiLicense,
|
mdiLicense,
|
||||||
mdiFileDocument,
|
mdiFileDocument,
|
||||||
mdiLibraryShelves
|
mdiFolderMultiple,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
|
|
@ -92,6 +92,12 @@ export default [
|
||||||
label: 'Licenses',
|
label: 'Licenses',
|
||||||
roles: ['administrator'],
|
roles: ['administrator'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
route: 'settings.project.index',
|
||||||
|
icon: mdiFolderMultiple,
|
||||||
|
label: 'Projects',
|
||||||
|
roles: ['administrator'],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@ import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import 'dayjs/locale/de';
|
import 'dayjs/locale/de';
|
||||||
import 'dayjs/locale/en';
|
import 'dayjs/locale/en';
|
||||||
|
|
||||||
const extendedDayjs = dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
export const setDayjsLocale = (locale: string) => {
|
export const setDayjsLocale = (locale: string) => {
|
||||||
extendedDayjs.locale(locale);
|
dayjs.locale(locale);
|
||||||
};
|
};
|
||||||
|
|
||||||
// // Set a default locale initially
|
// // Set a default locale initially
|
||||||
// setDayjsLocale('en');
|
// setDayjsLocale('en');
|
||||||
|
|
||||||
export default extendedDayjs;
|
export default dayjs;
|
||||||
|
|
@ -160,6 +160,16 @@ export function showError(text: string, options?: ToastOptions): Toast {
|
||||||
return showMessage(text, { ...options, type: ToastType.ERROR })
|
return showMessage(text, { ...options, type: ToastType.ERROR })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a toast message with success styling
|
||||||
|
*
|
||||||
|
* @param text Message to be shown in the toast, any HTML is removed by default
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
export function showSuccess(text: string, options?: ToastOptions): Toast {
|
||||||
|
return showMessage(text, { ...options, type: ToastType.SUCCESS });
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
updatableNotification: null,
|
updatableNotification: null,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import DatasetController from '#app/Controllers/Http/Submitter/DatasetController
|
||||||
import PersonController from '#app/Controllers/Http/Submitter/PersonController';
|
import PersonController from '#app/Controllers/Http/Submitter/PersonController';
|
||||||
import EditorDatasetController from '#app/Controllers/Http/Editor/DatasetController';
|
import EditorDatasetController from '#app/Controllers/Http/Editor/DatasetController';
|
||||||
import ReviewerDatasetController from '#app/Controllers/Http/Reviewer/DatasetController';
|
import ReviewerDatasetController from '#app/Controllers/Http/Reviewer/DatasetController';
|
||||||
|
import ProjectsController from '#app/controllers/projects_controller';
|
||||||
import './routes/api.js';
|
import './routes/api.js';
|
||||||
import { middleware } from './kernel.js';
|
import { middleware } from './kernel.js';
|
||||||
import db from '@adonisjs/lucid/services/db'; // Import the DB service
|
import db from '@adonisjs/lucid/services/db'; // Import the DB service
|
||||||
|
|
@ -127,15 +128,33 @@ router
|
||||||
.group(() => {
|
.group(() => {
|
||||||
router
|
router
|
||||||
.get('/settings', async ({ inertia }: HttpContext) => {
|
.get('/settings', async ({ inertia }: HttpContext) => {
|
||||||
const updatedConfigValue = await db
|
try {
|
||||||
|
const [lastJobConfig, missingCrossReferencesConfig] = await Promise.all([
|
||||||
|
db.from('appconfigs').select('configvalue').where('appid', 'backgroundjob').where('configkey', 'lastjob').first(),
|
||||||
|
db
|
||||||
.from('appconfigs')
|
.from('appconfigs')
|
||||||
.select('configvalue')
|
.select('configvalue')
|
||||||
.where('appid', 'backgroundjob')
|
.where('appid', 'commands')
|
||||||
.where('configkey', 'lastjob')
|
.where('configkey', 'missing_cross_references_count')
|
||||||
.first();
|
.first(),
|
||||||
|
]);
|
||||||
|
|
||||||
return inertia.render('Admin/Settings', {
|
return inertia.render('Admin/Settings', {
|
||||||
lastCron: updatedConfigValue?.configvalue || '',
|
lastCron: lastJobConfig?.configvalue || 0,
|
||||||
|
missingCrossReferencesCount: parseInt(missingCrossReferencesConfig?.configvalue || '0'),
|
||||||
|
// Add timestamp for cache busting
|
||||||
|
lastUpdated: Date.now(),
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load background job settings:', error);
|
||||||
|
return inertia.render('Admin/Settings', {
|
||||||
|
lastCron: 0,
|
||||||
|
cronMaxAge: 0,
|
||||||
|
backgroundJobsMode: 'cron',
|
||||||
|
missingCrossReferencesCount: 0,
|
||||||
|
error: 'Failed to load background job settings',
|
||||||
|
});
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.as('overview');
|
.as('overview');
|
||||||
|
|
||||||
|
|
@ -216,6 +235,19 @@ router
|
||||||
.where('id', router.matchers.number())
|
.where('id', router.matchers.number())
|
||||||
.use(middleware.can(['settings']));
|
.use(middleware.can(['settings']));
|
||||||
|
|
||||||
|
// Project routes
|
||||||
|
// List all projects
|
||||||
|
router.get('/projects', [ProjectsController, 'index']).as('project.index');
|
||||||
|
// Show create form
|
||||||
|
router.get('/projects/create', [ProjectsController, 'create']).as('project.create').use(middleware.can(['settings']));;
|
||||||
|
// Store new project
|
||||||
|
router.post('/projects', [ProjectsController, 'store']).as('project.store').use(middleware.can(['settings']));;
|
||||||
|
// Show edit form
|
||||||
|
router.get('/projects/:id/edit',[ProjectsController, 'edit']).as('project.edit').use(middleware.can(['settings']));;
|
||||||
|
// Update project
|
||||||
|
router.put('/projects/:id',[ProjectsController, 'update']).as('project.update').use(middleware.can(['settings']));;
|
||||||
|
|
||||||
|
|
||||||
// Mimetype routes
|
// Mimetype routes
|
||||||
router.get('/mimetype', [MimetypeController, 'index']).as('mimetype.index');
|
router.get('/mimetype', [MimetypeController, 'index']).as('mimetype.index');
|
||||||
router
|
router
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,24 @@ import AvatarController from '#controllers/Http/Api/AvatarController';
|
||||||
import UserController from '#controllers/Http/Api/UserController';
|
import UserController from '#controllers/Http/Api/UserController';
|
||||||
import CollectionsController from '#controllers/Http/Api/collections_controller';
|
import CollectionsController from '#controllers/Http/Api/collections_controller';
|
||||||
import { middleware } from '../kernel.js';
|
import { middleware } from '../kernel.js';
|
||||||
// API
|
|
||||||
|
// Clean DOI URL routes (no /api prefix)
|
||||||
|
|
||||||
|
// API routes with /api prefix
|
||||||
router
|
router
|
||||||
.group(() => {
|
.group(() => {
|
||||||
router.get('clients', [UserController, 'getSubmitters']).as('client.index').use(middleware.auth());;
|
router.get('clients', [UserController, 'getSubmitters']).as('client.index').use(middleware.auth());
|
||||||
router.get('authors', [AuthorsController, 'index']).as('author.index').use(middleware.auth());;
|
router.get('authors', [AuthorsController, 'index']).as('author.index').use(middleware.auth());
|
||||||
router.get('datasets', [DatasetController, 'index']).as('dataset.index');
|
router.get('datasets', [DatasetController, 'index']).as('dataset.index');
|
||||||
router.get('persons', [AuthorsController, 'persons']).as('author.persons');
|
router.get('persons', [AuthorsController, 'persons']).as('author.persons');
|
||||||
|
|
||||||
|
// This should come BEFORE any other routes that might conflict
|
||||||
|
router
|
||||||
|
.get('/dataset/:prefix/:value', [DatasetController, 'findByIdentifier'])
|
||||||
|
.where('prefix', /^10\.\d+$/) // Match DOI prefix pattern (10.xxxx)
|
||||||
|
.where('value', /^[a-zA-Z0-9._-]+\.[0-9]+(?:\.[0-9]+)*$/) // Match DOI suffix pattern
|
||||||
|
.as('dataset.findByIdentifier');
|
||||||
|
|
||||||
router.get('/dataset', [DatasetController, 'findAll']).as('dataset.findAll');
|
router.get('/dataset', [DatasetController, 'findAll']).as('dataset.findAll');
|
||||||
router.get('/dataset/:publish_id', [DatasetController, 'findOne']).as('dataset.findOne');
|
router.get('/dataset/:publish_id', [DatasetController, 'findOne']).as('dataset.findOne');
|
||||||
router.get('/sitelinks/:year', [HomeController, 'findDocumentsPerYear']);
|
router.get('/sitelinks/:year', [HomeController, 'findDocumentsPerYear']);
|
||||||
|
|
@ -35,7 +45,7 @@ router
|
||||||
.as('apps.twofactor_backupcodes.create')
|
.as('apps.twofactor_backupcodes.create')
|
||||||
.use(middleware.auth());
|
.use(middleware.auth());
|
||||||
|
|
||||||
router.get('collections/:id', [CollectionsController, 'show']).as('collection.show')
|
router.get('collections/:id', [CollectionsController, 'show']).as('collection.show');
|
||||||
})
|
})
|
||||||
// .namespace('App/Controllers/Http/Api')
|
// .namespace('App/Controllers/Http/Api')
|
||||||
.prefix('api');
|
.prefix('api');
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Preloaded File - node ace make:preload rules/dependentArrayMinLength
|
| Preloaded File - node ace make:preload rules/dependentArrayMinLength
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|*/
|
*/
|
||||||
|
|
||||||
import { FieldContext } from '@vinejs/vine/types';
|
import { FieldContext } from '@vinejs/vine/types';
|
||||||
import vine, { VineArray } from '@vinejs/vine';
|
import vine, { VineArray } from '@vinejs/vine';
|
||||||
|
|
@ -17,39 +17,75 @@ type Options = {
|
||||||
};
|
};
|
||||||
|
|
||||||
async function dependentArrayMinLength(value: unknown, options: Options, field: FieldContext) {
|
async function dependentArrayMinLength(value: unknown, options: Options, field: FieldContext) {
|
||||||
const fileInputs = field.data[options.dependentArray]; // Access the dependent array
|
const dependentArrayValue = field.data[options.dependentArray];
|
||||||
const isArrayValue = Array.isArray(value);
|
|
||||||
const isArrayFileInputs = Array.isArray(fileInputs);
|
|
||||||
|
|
||||||
if (isArrayValue && isArrayFileInputs) {
|
// Both values can be null/undefined or arrays, but not other types
|
||||||
if (value.length >= options.min) {
|
const isMainValueValid = value === null || value === undefined || Array.isArray(value);
|
||||||
return true; // Valid if the main array length meets the minimum
|
const isDependentValueValid = dependentArrayValue === null || dependentArrayValue === undefined || Array.isArray(dependentArrayValue);
|
||||||
} else if (value.length === 0 && fileInputs.length >= options.min) {
|
|
||||||
return true; // Valid if the main array is empty and the dependent array meets the minimum
|
if (!isMainValueValid || !isDependentValueValid) {
|
||||||
} else {
|
|
||||||
field.report(
|
field.report(
|
||||||
`At least {{ min }} item for {{field}} field must be defined`,
|
`Invalid file data format. Please contact support if this error persists.`,
|
||||||
'array.dependentArrayMinLength',
|
'array.dependentArrayMinLength',
|
||||||
field,
|
field,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Report if either value or dependentArray is not an array
|
// Convert null/undefined to empty arrays for length checking
|
||||||
|
const mainArray = Array.isArray(value) ? value : [];
|
||||||
|
const dependentArray = Array.isArray(dependentArrayValue) ? dependentArrayValue : [];
|
||||||
|
|
||||||
|
// Calculate total count across both arrays
|
||||||
|
const totalCount = mainArray.length + dependentArray.length;
|
||||||
|
|
||||||
|
// Check if minimum requirement is met
|
||||||
|
if (totalCount >= options.min) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special case: if dependent array has items, main array can be empty/null
|
||||||
|
if (dependentArray.length >= options.min && mainArray.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine appropriate error message based on context
|
||||||
|
const hasExistingFiles = dependentArray.length > 0;
|
||||||
|
const hasNewFiles = mainArray.length > 0;
|
||||||
|
|
||||||
|
if (!hasExistingFiles && !hasNewFiles) {
|
||||||
|
// No files at all
|
||||||
field.report(
|
field.report(
|
||||||
`Both the {{field}} field and dependent array {{dependentArray}} must be arrays.`,
|
`Your dataset must include at least {{ min }} file. Please upload a new file to continue.`,
|
||||||
|
'array.dependentArrayMinLength',
|
||||||
|
field,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
} else if (hasExistingFiles && !hasNewFiles && dependentArray.length < options.min) {
|
||||||
|
// Has existing files but marked for deletion, no new files
|
||||||
|
field.report(
|
||||||
|
`You have marked all existing files for deletion. Please upload at least {{ min }} new file or keep some existing files.`,
|
||||||
|
'array.dependentArrayMinLength',
|
||||||
|
field,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Generic fallback message
|
||||||
|
field.report(
|
||||||
|
`Your dataset must have at least {{ min }} file. You can either upload new files or keep existing ones.`,
|
||||||
'array.dependentArrayMinLength',
|
'array.dependentArrayMinLength',
|
||||||
field,
|
field,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false; // Invalid if none of the conditions are met
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dependentArrayMinLengthRule = vine.createRule(dependentArrayMinLength);
|
export const dependentArrayMinLengthRule = vine.createRule(dependentArrayMinLength);
|
||||||
|
|
||||||
// Extend the VineArray interface with the same type parameters
|
// Extend the VineArray interface
|
||||||
declare module '@vinejs/vine' {
|
declare module '@vinejs/vine' {
|
||||||
interface VineArray<Schema extends SchemaTypes> {
|
interface VineArray<Schema extends SchemaTypes> {
|
||||||
dependentArrayMinLength(options: Options): this;
|
dependentArrayMinLength(options: Options): this;
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,14 @@ async function scanFileForViruses(filePath: string | undefined, options: Options
|
||||||
scanRecursively: true, // If true, deep scan folders recursively
|
scanRecursively: true, // If true, deep scan folders recursively
|
||||||
clamdscan: {
|
clamdscan: {
|
||||||
active: true, // If true, this module will consider using the clamdscan binary
|
active: true, // If true, this module will consider using the clamdscan binary
|
||||||
host: options.host,
|
host: options.host, // IP of host to connect to TCP interface,
|
||||||
port: options.port,
|
port: options.port, // Port of host to use when connecting to TCP interface
|
||||||
|
// socket: '/var/run/clamav/clamd.socket', // Socket file for connecting via socket
|
||||||
|
// localFallback: false, // Use local clamscan binary if socket/tcp fails
|
||||||
|
// port: options.port,
|
||||||
multiscan: true, // Scan using all available cores! Yay!
|
multiscan: true, // Scan using all available cores! Yay!
|
||||||
},
|
},
|
||||||
|
preference: 'clamdscan', // If clamdscan is found and active, it will be used by default over clamscan
|
||||||
};
|
};
|
||||||
|
|
||||||
const clamscan = await new ClamScan().init(opts);
|
const clamscan = await new ClamScan().init(opts);
|
||||||
|
|
|
||||||
175
start/rules/orcid.ts
Normal file
175
start/rules/orcid.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Preloaded File - node ace make:preload rules/orcid
|
||||||
|
| Do you want to register the preload file in .adonisrc.ts file? (y/N) · true
|
||||||
|
| DONE: create start/rules/orcid.ts
|
||||||
|
| DONE: update adonisrc.ts file
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
import vine, { VineString } from '@vinejs/vine';
|
||||||
|
import { FieldContext } from '@vinejs/vine/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ORCID Validator Implementation
|
||||||
|
*
|
||||||
|
* Validates ORCID identifiers using both format validation and checksum verification.
|
||||||
|
* ORCID (Open Researcher and Contributor ID) is a persistent digital identifier
|
||||||
|
* that distinguishes researchers and supports automated linkages between them
|
||||||
|
* and their professional activities.
|
||||||
|
*
|
||||||
|
* Format: 0000-0000-0000-0000 (where the last digit can be X for checksum 10)
|
||||||
|
* Algorithm: MOD-11-2 checksum validation as per ISO/IEC 7064:2003
|
||||||
|
*
|
||||||
|
* @param value - The ORCID value to validate
|
||||||
|
* @param _options - Unused options parameter (required by VineJS signature)
|
||||||
|
* @param field - VineJS field context for error reporting
|
||||||
|
*/
|
||||||
|
async function orcidValidator(value: unknown, _options: undefined, field: FieldContext) {
|
||||||
|
/**
|
||||||
|
* Type guard: We only validate string values
|
||||||
|
* The "string" rule should handle type validation before this rule runs
|
||||||
|
*/
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle optional fields: Skip validation for empty strings
|
||||||
|
* This allows the field to be truly optional when used with .optional()
|
||||||
|
*/
|
||||||
|
if (value.trim() === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize the ORCID value:
|
||||||
|
* - Remove any whitespace characters
|
||||||
|
* - Convert to uppercase (for potential X check digit)
|
||||||
|
*/
|
||||||
|
const cleanOrcid = value.replace(/\s/g, '').toUpperCase();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Validation
|
||||||
|
*
|
||||||
|
* ORCID format regex breakdown:
|
||||||
|
* ^(\d{4}-){3} - Three groups of exactly 4 digits followed by hyphen
|
||||||
|
* \d{3} - Three more digits
|
||||||
|
* [\dX]$ - Final character: either digit or 'X' (for checksum 10)
|
||||||
|
*
|
||||||
|
* Valid examples: 0000-0002-1825-0097, 0000-0002-1825-009X
|
||||||
|
*/
|
||||||
|
const orcidRegex = /^(\d{4}-){3}\d{3}[\dX]$/;
|
||||||
|
|
||||||
|
if (!orcidRegex.test(cleanOrcid)) {
|
||||||
|
field.report('ORCID must be in format: 0000-0000-0000-0000 or 0000-0000-0000-000X', 'orcid', field);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checksum Validation - MOD-11-2 Algorithm
|
||||||
|
*
|
||||||
|
* This implements the official ORCID checksum algorithm based on ISO/IEC 7064:2003
|
||||||
|
* to verify mathematical validity and detect typos or invalid identifiers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Step 1: Extract digits and separate check digit
|
||||||
|
const digits = cleanOrcid.replace(/-/g, ''); // Remove hyphens: "0000000218250097"
|
||||||
|
const baseDigits = digits.slice(0, -1); // First 15 digits: "000000021825009"
|
||||||
|
const checkDigit = digits.slice(-1); // Last character: "7"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 2: Calculate checksum using MOD-11-2 algorithm
|
||||||
|
*
|
||||||
|
* For each digit from left to right:
|
||||||
|
* 1. Add the digit to running total
|
||||||
|
* 2. Multiply result by 2
|
||||||
|
*
|
||||||
|
* Example for "000000021825009":
|
||||||
|
* - Start with total = 0
|
||||||
|
* - Process each digit: total = (total + digit) * 2
|
||||||
|
* - Continue until all 15 digits are processed
|
||||||
|
*/
|
||||||
|
let total = 0;
|
||||||
|
for (const digit of baseDigits) {
|
||||||
|
total = (total + parseInt(digit)) * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3: Calculate expected check digit
|
||||||
|
*
|
||||||
|
* Formula: (12 - (total % 11)) % 11
|
||||||
|
* - Get remainder when total is divided by 11
|
||||||
|
* - Subtract from 12 and take modulo 11 again
|
||||||
|
* - If result is 10, use 'X' (since we need single character)
|
||||||
|
*
|
||||||
|
* Example: total = 1314
|
||||||
|
* - remainder = 1314 % 11 = 5
|
||||||
|
* - result = (12 - 5) % 11 = 7
|
||||||
|
* - expectedCheckDigit = "7"
|
||||||
|
*/
|
||||||
|
const remainder = total % 11;
|
||||||
|
const result = (12 - remainder) % 11;
|
||||||
|
const expectedCheckDigit = result === 10 ? 'X' : result.toString();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 4: Verify checksum matches
|
||||||
|
*
|
||||||
|
* Compare the actual check digit with the calculated expected value.
|
||||||
|
* If they don't match, the ORCID is invalid (likely contains typos or is fabricated).
|
||||||
|
*/
|
||||||
|
if (checkDigit !== expectedCheckDigit) {
|
||||||
|
field.report('Invalid ORCID checksum', 'orcid', field);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach this point, the ORCID is valid (both format and checksum)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the VineJS validation rule
|
||||||
|
*
|
||||||
|
* This creates a reusable rule that can be chained with other VineJS validators
|
||||||
|
*/
|
||||||
|
const orcidRule = vine.createRule(orcidValidator);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TypeScript module declaration
|
||||||
|
*
|
||||||
|
* Extends the VineString interface to include our custom orcid() method.
|
||||||
|
* This enables TypeScript autocompletion and type checking when using the rule.
|
||||||
|
*/
|
||||||
|
declare module '@vinejs/vine' {
|
||||||
|
interface VineString {
|
||||||
|
/**
|
||||||
|
* Validates that a string is a valid ORCID identifier
|
||||||
|
*
|
||||||
|
* Checks both format (0000-0000-0000-0000) and mathematical validity
|
||||||
|
* using the MOD-11-2 checksum algorithm.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Usage in validation schema
|
||||||
|
* identifier_orcid: vine.string().trim().maxLength(255).orcid().optional()
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @returns {this} The VineString instance for method chaining
|
||||||
|
*/
|
||||||
|
orcid(): this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the macro with VineJS
|
||||||
|
*
|
||||||
|
* This adds the .orcid() method to all VineString instances,
|
||||||
|
* allowing it to be used in validation schemas.
|
||||||
|
*
|
||||||
|
* Usage example:
|
||||||
|
* ```typescript
|
||||||
|
* vine.string().orcid().optional()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
VineString.macro('orcid', function (this: VineString) {
|
||||||
|
return this.use(orcidRule());
|
||||||
|
});
|
||||||
|
|
@ -13,8 +13,49 @@ module.exports = {
|
||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
backgroundImage: {
|
backgroundImage: {
|
||||||
'radio-checked': "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23fff' d='M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z' /%3E%3C/svg%3E\")",
|
'radio-checked':
|
||||||
'checkbox-checked': "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E\")",
|
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23fff' d='M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z' /%3E%3C/svg%3E\")",
|
||||||
|
'checkbox-checked':
|
||||||
|
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E\")",
|
||||||
|
},
|
||||||
|
backgroundColor: {
|
||||||
|
// Draft / In Progress - Light blue-gray
|
||||||
|
'draft': 'rgb(224 242 254)', // sky-100
|
||||||
|
'draft-dark': 'rgb(12 74 110 / 0.3)', // sky-900/30
|
||||||
|
'inprogress': 'rgb(224 242 254)', // sky-100
|
||||||
|
'inprogress-dark': 'rgb(12 74 110 / 0.3)', // sky-900/30
|
||||||
|
|
||||||
|
// Released / Submitted - Bright blue
|
||||||
|
'released': 'rgb(191 219 254)', // blue-200
|
||||||
|
'released-dark': 'rgb(30 58 138 / 0.3)', // blue-900/30
|
||||||
|
|
||||||
|
// Editor Accepted - Blue-green (teal)
|
||||||
|
'editor-accepted': 'rgb(204 251 241)', // teal-100
|
||||||
|
'editor-accepted-dark': 'rgb(19 78 74 / 0.3)', // teal-900/30
|
||||||
|
|
||||||
|
// Rejected by Reviewer - Yellow-orange (amber)
|
||||||
|
'rejected-reviewer': 'rgb(254 243 199)', // amber-100
|
||||||
|
'rejected-reviewer-dark': 'rgb(120 53 15 / 0.3)', // amber-900/30
|
||||||
|
|
||||||
|
// Rejected by Editor - Rose/Red (back to submitter)
|
||||||
|
'rejected-editor': 'rgb(254 205 211)', // rose-200
|
||||||
|
'rejected-editor-dark': 'rgb(136 19 55 / 0.3)', // rose-900/30
|
||||||
|
|
||||||
|
// Approved / Ready for Review - Cyan (blue-green)
|
||||||
|
'approved': 'rgb(207 250 254)', // cyan-100
|
||||||
|
'approved-dark': 'rgb(22 78 99 / 0.3)', // cyan-900/30
|
||||||
|
|
||||||
|
// Reviewer Accepted / In Review - Lime yellow-green
|
||||||
|
'reviewer-accepted': 'rgb(236 252 203)', // lime-100
|
||||||
|
'reviewer-accepted-dark': 'rgb(54 83 20 / 0.3)', // lime-900/30
|
||||||
|
|
||||||
|
// Reviewed - Soft yellow
|
||||||
|
'reviewed': 'rgb(254 240 138)', // yellow-200
|
||||||
|
'reviewed-dark': 'rgb(113 63 18 / 0.3)', // yellow-900/30
|
||||||
|
|
||||||
|
// Published - Fresh green
|
||||||
|
'published': 'rgb(187 247 208)', // green-200
|
||||||
|
'published-dark': 'rgb(20 83 45 / 0.3)', // green-900/30
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
'primary': '#22C55E',
|
'primary': '#22C55E',
|
||||||
|
|
@ -115,8 +156,8 @@ module.exports = {
|
||||||
'-o-user-drag': 'none',
|
'-o-user-drag': 'none',
|
||||||
'user-drag': 'none',
|
'user-drag': 'none',
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
addUtilities(newUtilities)
|
addUtilities(newUtilities);
|
||||||
}),
|
}),
|
||||||
// As of Tailwind CSS v3.3, the `@tailwindcss/line-clamp` plugin is now included by default
|
// As of Tailwind CSS v3.3, the `@tailwindcss/line-clamp` plugin is now included by default
|
||||||
// require('@tailwindcss/line-clamp'),
|
// require('@tailwindcss/line-clamp'),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue