Compare commits

..

17 commits

Author SHA1 Message Date
e8a34379f3 Merge branch 'fix/datset-listing-ux' into develop
Some checks failed
build.yaml / Merge branch 'fix/datset-listing-ux' into develop (push) Failing after 0s
2025-11-05 13:22:07 +01:00
4229001572 Enhance Map Zoom Control and Improve Map Page Layout
Some checks failed
build.yaml / Enhance Map Zoom Control and Improve Map Page Layout (push) Failing after 0s
- Refactored zoom control component for better accessibility and styling.
- Added hover effects and improved button states for zoom in/out buttons.
- Updated map page layout with enhanced dataset card design and responsive styles.
- Introduced empty state for no datasets found and improved results header.
- Added icons for dataset cards and improved author display.
2025-11-05 13:15:23 +01:00
88e37bfee8 feat: Enhance Dataset Index with Dynamic Legend and Improved State Management for submitter, editor and reviewer
Some checks failed
build.yaml / feat: Enhance Dataset Index with Dynamic Legend and Improved State Management for submitter, editor and reviewer (push) Failing after 0s
- Added a collapsible legend to display dataset states and available actions.
- Implemented localStorage persistence for legend visibility.
- Refactored dataset state handling with dynamic classes and labels.
- Improved table layout and styling for better user experience.
- Updated Tailwind CSS configuration to define new background colors for dataset states.
2025-10-30 14:42:36 +01:00
a4e6f88e07 fix: Update TablePersons component with improved layout and name type handling also for organizations
Some checks failed
build.yaml / fix: Update TablePersons component with improved layout and name type handling also for organizations (push) Failing after 0s
2025-10-29 14:36:05 +01:00
39f1bcee46 Merge branch 'feat/create-projects' into develop 2025-10-29 11:25:12 +01:00
3d8f2354cb feat: Enhance Dataset Edit Page with Unsaved Changes Indicator and Improved Structure
Some checks failed
build.yaml / feat: Enhance Dataset Edit Page with Unsaved Changes Indicator and Improved Structure (push) Failing after 0s
- Added a progress indicator for unsaved changes at the top of the dataset edit page.
- Enhanced the title section with a dataset status badge and improved layout.
- Introduced collapsible sections for better organization of form fields.
- Improved notifications for success/error messages.
- Refactored form fields into distinct sections: Basic Information, Licenses, Titles, Descriptions, Creators & Contributors, Additional Metadata, Geographic Coverage, and Files.
- Enhanced loading spinner with a more visually appealing overlay.
- Added new project validation logic in the backend with create and update validators.
2025-10-29 11:20:27 +01:00
f39fe75340 feat: Implement project management functionality with CRUD operations and UI integration
Some checks failed
build.yaml / feat: Implement project management functionality with CRUD operations and UI integration (push) Failing after 0s
feat: Implement project management functionality with CRUD operations and UI integration
- added projects_controller.ts for crud operations-
added views Edit-vue , Index.vue and Create.vue
- small adaptions in menu.ts
additional routes is start/routes.ts for projects
2025-10-16 15:37:55 +02:00
04269ce9cf - fix: Update TablePersons component for improved UI and functionality;
Some checks failed
build.yaml / - fix: Update TablePersons component for improved UI and functionality; (push) Failing after 0s
- refactor file scan options
2025-10-16 12:04:46 +02:00
5e424803ed Merge branch 'feat/admin-diagnose-overview' into develop
Some checks failed
build.yaml / Merge branch 'feat/admin-diagnose-overview' into develop (push) Failing after 0s
2025-10-14 12:30:15 +02:00
b5bbe26ec2 feat: Enhance background job settings UI and functionality
Some checks failed
build.yaml / feat: Enhance background job settings UI and functionality (push) Failing after 0s
- Updated BackgroundJob.vue to improve the display of background job statuses, including missing cross-references and current job mode.
- Added auto-refresh functionality for background job status.
- Introduced success toast notifications for successful status refreshes.
- Modified the XML serialization process in DatasetXmlSerializer for better caching and performance.
- Implemented a new RuleProvider for managing custom validation rules.
- Improved error handling in routes for loading background job settings.
- Enhanced ClamScan configuration with socket support for virus scanning.
- Refactored dayjs utility to streamline locale management.
2025-10-14 12:19:09 +02:00
6757bdb77c feat: Enhance ClamAV Docker entrypoint and configuration
- Updated docker-entrypoint.sh to improve ClamAV service initialization and logging.
- Added checks for ClamAV and freshclam daemon status.
- Optimized freshclam configuration for container usage, including logging to stdout and setting database directory.
- Introduced caching mechanism for enabled file extensions in vinejs_provider.ts to reduce database queries.
- Implemented a new command to list datasets needing DataCite DOI updates, with options for verbose output, count only, and IDs only.
- Updated package dependencies to include p-limit and pino-pretty.
- finalized ace command 'detect:missing-cross-references'
2025-09-26 12:19:35 +02:00
4c8cce27da feat: Update form field labels from "Main Title Language*" to "Main Description Language*" for clarity 2025-09-19 17:21:21 +02:00
2f079e6fdd feat: Add optional ORCID identifier to dataset validation also for the subitter
npm updates
2025-09-19 16:26:01 +02:00
c049b22723 - feat: Enhance README with setup instructions, usage, and command documentation
- fix: Update API routes to include DOI URL handling and improve route organization

- chore: Add ORCID preload rule file and ensure proper registration

- docs: Add MIT License to the project for open-source compliance

- feat: Implement command to detect and fix missing dataset cross-references

- feat: Create command for updating DataCite DOI records with detailed logging and error handling

- docs: Add comprehensive documentation for dataset indexing command

- docs: Create detailed documentation for DataCite update command with usage examples and error handling
2025-09-19 14:35:23 +02:00
8f67839f93 hot-fix: Add ORCID validation and improve dataset editing UX
### Major Features
- Add comprehensive ORCID validation with checksum verification
- Implement unsaved changes detection and auto-save functionality
- Enhanced form component reactivity and state management

### ORCID Implementation
- Create custom VineJS ORCID validation rule with MOD-11-2 algorithm
- Add ORCID fields to Person model and TablePersons component
- Update dataset validators to include ORCID validation
- Add descriptive placeholder text for ORCID input fields

### UI/UX Improvements
- Add UnsavedChangesWarning component with detailed change tracking
- Improve FormCheckRadio and FormCheckRadioGroup reactivity
- Enhanced BaseButton with proper disabled state handling
- Better error handling and user feedback in file validation

### Data Management
- Implement sophisticated change detection for all dataset fields
- Add proper handling of array ordering for authors/contributors
- Improve license selection with better state management
- Enhanced subject/keyword processing with duplicate detection

### Technical Improvements
- Optimize search indexing with conditional updates based on modification dates
- Update person model column mapping for ORCID
- Improve validation error messages and user guidance
- Better handling of file uploads and deletion tracking

### Dependencies
- Update various npm packages (AWS SDK, Babel, Vite, etc.)
- Add baseline-browser-mapping for better browser compatibility

### Bug Fixes
- Fix form reactivity issues with checkbox/radio groups
- Improve error handling in file validation rules
- Better handling of edge cases in change detection
2025-09-15 14:07:59 +02:00
06ed2f3625 feat: Enhance Person data structure and improve TablePersons component
- Updated Person interface to include first_name and last_name fields for better clarity and organization handling.
- Modified TablePersons.vue to support new fields, including improved pagination and drag-and-drop functionality.
- Added loading states and error handling for form controls within the table.
- Enhanced the visual layout of the table with responsive design adjustments.
- Updated solr.xslt to correctly reference ServerDateModified and EmbargoDate attributes.
- updated AvatarController
- improved download method for editor, and reviewer
- improved security for officlial download file file API: filterd by server_state
2025-09-08 12:28:26 +02:00
e1ccf0ddc8 hotfix(dataset): enhance file download with embargo validation and improve API data handling
- Add embargo date validation to file download process with date-only comparison
- Require first_name for authors/contributors only when name_type is 'Personal'
- Remove sensitive personal data from dataset API responses
- Improve dataset validation logic for better data integrity
2025-09-03 12:48:44 +02:00
75 changed files with 12166 additions and 4038 deletions

View file

@ -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
View 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

View file

@ -11,9 +11,10 @@ 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'),

View file

@ -5,19 +5,28 @@ 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()
.preload('datasets') .select([
.where('name_type', 'Personal') 'id',
.whereHas('datasets', (dQuery) => { 'academic_title',
dQuery.wherePivot('role', 'author'); 'first_name',
}) 'last_name',
.withCount('datasets', (query) => { 'identifier_orcid',
query.as('datasets_count'); 'status',
}) 'name_type',
.orderBy('datasets_count', 'desc'); 'created_at'
// Note: 'email' is omitted
])
.preload('datasets')
.where('name_type', 'Personal')
.whereHas('datasets', (dQuery) => {
dQuery.wherePivot('role', 'author');
})
.withCount('datasets', (query) => {
query.as('datasets_count');
})
.orderBy('datasets_count', 'desc');
return authors; return authors;
} }

View file

@ -2,26 +2,46 @@ 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 cachedSvg = await redis.get(cacheKey); // const cacheKey = `avatar:${name.trim().toLowerCase()}-${size}`;
if (cachedSvg) { try {
this.setResponseHeaders(response); const cachedSvg = await redis.get(cacheKey);
return response.send(cachedSvg); if (cachedSvg) {
this.setResponseHeaders(response);
return response.send(cachedSvg);
}
} catch (redisError) {
// Log redis error but continue without cache
console.warn('Redis cache read failed:', redisError);
} }
const initials = this.getInitials(name); const initials = this.getInitials(name);
@ -29,41 +49,85 @@ export default class AvatarController {
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 {
const firstName = parts[0]; // Filter out prefixes and short words
const lastName = parts[parts.length - 1]; const significantParts = parts.filter((part) => !PREFIXES.includes(part.toLowerCase()) && part.length > 1);
const firstInitial = firstName.charAt(0).toUpperCase();
const lastInitial = lastName.charAt(0).toUpperCase();
if (PREFIXES.includes(lastName.toLowerCase()) && lastName === lastName.toUpperCase()) { if (significantParts.length === 0) {
return firstInitial + lastName.charAt(1).toUpperCase(); // Fallback to first and last regardless of prefixes
const firstName = parts[0];
const lastName = parts[parts.length - 1];
return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase();
} }
return firstInitial + lastInitial;
if (significantParts.length === 1) {
return significantParts[0].substring(0, 2).toUpperCase();
}
// Take first and last significant parts
const firstName = significantParts[0];
const lastName = significantParts[significantParts.length - 1];
return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase();
} }
private generateColors(name: string): { background: string; text: string } { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
} }
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);

View file

@ -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
const datasets = await Dataset.query() * Find all published datasets
.where(function (query) { */
query.where('server_state', 'published') public async index({ response }: HttpContext) {
.orWhere('server_state', 'deleted'); try {
}) const datasets = await Dataset.query()
.preload('titles') .where(function (query) {
.preload('identifier') query.where('server_state', 'published').orWhere('server_state', 'deleted');
.orderBy('server_date_published', 'desc'); })
.preload('titles')
.preload('identifier')
.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
.where('publish_id', params.publish_id) * Find one dataset by publish_id
.preload('titles') */
.preload('descriptions') public async findOne({ response, params }: HttpContext) {
.preload('user') try {
.preload('authors', (builder) => { const dataset = await Dataset.query()
builder.orderBy('pivot_sort_order', 'asc'); .where('publish_id', params.publish_id)
}) .preload('titles')
.preload('contributors', (builder) => { .preload('descriptions') // Using 'descriptions' instead of 'abstracts'
builder.orderBy('pivot_sort_order', 'asc'); .preload('user', (builder) => {
}) builder.select(['id', 'firstName', 'lastName', 'avatar', 'login']);
.preload('subjects') })
.preload('coverage') .preload('authors', (builder) => {
.preload('licenses') builder
.preload('references') .select(['id', 'academic_title', 'first_name', 'last_name', 'identifier_orcid', 'status', 'name_type'])
.preload('project') .withCount('datasets', (query) => {
.preload('referenced_by', (builder) => { query.as('datasets_count');
builder.preload('dataset', (builder) => { })
builder.preload('identifier'); .pivotColumns(['role', 'sort_order'])
}); .orderBy('pivot_sort_order', 'asc');
}) })
.preload('files', (builder) => { .preload('contributors', (builder) => {
builder.preload('hashvalues'); builder
}) .select(['id', 'academic_title', 'first_name', 'last_name', 'identifier_orcid', 'status', 'name_type'])
.preload('identifier') .withCount('datasets', (query) => {
.firstOrFail(); query.as('datasets_count');
})
.pivotColumns(['role', 'sort_order', 'contributor_type'])
.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(); // 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;
} }
} }

View file

@ -2,53 +2,103 @@ 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
const filePath = '/storage/app/data/' + file.pathName; .firstOrFail();
const ext = path.extname(filePath);
const fileName = file.label + ext;
try {
fs.accessSync(filePath, fs.constants.R_OK); //| fs.constants.W_OK);
// console.log("can read/write:", path);
response if (!file) {
.header('Cache-Control', 'no-cache private') return response.status(StatusCodes.NOT_FOUND).send({
.header('Content-Description', 'File Transfer')
.header('Content-Type', file.mimeType)
.header('Content-Disposition', 'inline; filename=' + fileName)
.header('Content-Transfer-Encoding', 'binary')
.header('Access-Control-Allow-Origin', '*')
.header('Access-Control-Allow-Methods', 'GET,POST');
response.status(StatusCodes.OK).download(filePath);
} catch (err) {
// console.log("no access:", path);
response.status(StatusCodes.NOT_FOUND).send({
message: `File with id ${id} doesn't exist on file server`,
});
}
// res.status(StatusCodes.OK).sendFile(filePath, (err) => {
// // res.setHeader("Content-Type", "application/json");
// // res.removeHeader("Content-Disposition");
// res.status(StatusCodes.NOT_FOUND).send({
// message: `File with id ${id} doesn't exist on file server`,
// });
// });
} else {
response.status(StatusCodes.NOT_FOUND).send({
message: `Cannot find File with id=${id}.`, 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 fileExt = file.filePath.split('.').pop() || '';
// 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 {
fs.accessSync(filePath, fs.constants.R_OK); //| fs.constants.W_OK);
// console.log("can read/write:", filePath);
response
.header('Cache-Control', 'no-cache private')
.header('Content-Description', 'File Transfer')
.header('Content-Type', file.mimeType)
.header('Content-Disposition', `${disposition}; filename="${fileName}"`)
.header('Content-Transfer-Encoding', 'binary')
.header('Access-Control-Allow-Origin', '*')
.header('Access-Control-Allow-Methods', 'GET');
response.status(StatusCodes.OK).download(filePath);
} catch (err) {
// console.log("no access:", path);
response.status(StatusCodes.NOT_FOUND).send({
message: `File with id ${id} doesn't exist on file server`,
});
}
}
/**
* Check if the dataset is under embargo
* Compares only dates (ignoring time) for embargo check
* @param embargoDate - The embargo date from dataset
* @returns true if under embargo, false if embargo has passed or no embargo set
*/
private isUnderEmbargo(embargoDate: DateTime | null): boolean {
// No embargo date set - allow download
if (!embargoDate) {
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;
} }
} }

View file

@ -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,53 +574,88 @@ 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 doiIdentifier = new DatasetIdentifier();
doiIdentifier.value = doiValue;
doiIdentifier.dataset_id = dataset.id;
doiIdentifier.type = 'doi';
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
try {
await dataset.related('identifier').save(doiIdentifier);
const index_name = 'tethys-records';
await Index.indexDocument(dataset, index_name);
} catch (error) {
logger.error(`${__filename}: Indexing document ${dataset.id} failed: ${error.message}`);
// Log the error or handle it as needed
throw new HttpException(error.message);
}
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}`; const message = `Unexpected DataCite MDS response code ${dataciteResponse?.status}`;
// Log the error or handle it as needed
throw new DoiClientException(dataciteResponse?.status, message); 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); // 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();
doiIdentifier.value = doiValue;
doiIdentifier.dataset_id = dataset.id;
doiIdentifier.type = 'doi';
doiIdentifier.status = 'findable';
// Save identifier (this will trigger database insert)
await dataset.related('identifier').save(doiIdentifier);
// Update dataset modification timestamp to reflect the change
dataset.server_date_modified = DateTime.now();
await dataset.save();
// 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}`);
}
/**
* Invalidate XML cache for dataset
* Ensures fresh cache generation on next access
*/
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}`);
}
}
public async show({}: HttpContext) {} public async show({}: HttpContext) {}
public async edit({ request, inertia, response }: HttpContext) { public async edit({ request, inertia, response }: 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;
} }
} }

View file

@ -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';
@ -292,7 +292,7 @@ export default class OaiController {
this.xsltParameter['repIdentifier'] = repIdentifier; this.xsltParameter['repIdentifier'] = repIdentifier;
const datasetNode = this.xml.root().ele('Datasets'); const datasetNode = this.xml.root().ele('Datasets');
const paginationParams: PagingParameter ={ const paginationParams: PagingParameter = {
cursor: 0, cursor: 0,
totalLength: 0, totalLength: 0,
start: maxRecords + 1, start: maxRecords + 1,
@ -347,16 +347,20 @@ 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()
.count('* as total') .count('* as total')
.first() .first()
.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) {

View file

@ -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);
} }
} }

View file

@ -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
existingSubject.value = subjectData.value; const duplicateSubject = await Subject.query()
existingSubject.type = subjectData.type; .where('value', subjectData.value)
existingSubject.external_key = subjectData.external_key; .where('type', subjectData.type)
.where('language', subjectData.language || 'en') // Default language if not provided
.where('id', '!=', subjectData.id) // Exclude the current subject
.first();
// Only save if there are actual changes if (duplicateSubject) {
if (existingSubject.$isDirty) { // A duplicate exists - use the existing duplicate instead
await existingSubject.save(); 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.type = subjectData.type;
existingSubject.language = subjectData.language;
existingSubject.external_key = subjectData.external_key;
if (existingSubject.$isDirty) {
await existingSubject.useTransaction(trx).save();
}
subjectToRelate = existingSubject;
} }
// Note: The relationship between dataset and subject is already established,
// 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) { await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
// If used by multiple datasets, just detach it from the current dataset
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);
} }
} }

View 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;
}
}
}

View file

@ -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;
}
} }

View file

@ -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
dataset.publish_id && addLandingPageAttribute(domNode, dataset.publish_id.toString());
addSpecInformation(domNode, 'data-type:' + dataset.type);
if (dataset.collections) {
for (const coll of dataset.collections) {
const collRole = coll.collectionRole;
addSpecInformation(domNode, collRole.oai_name + ':' + coll.number);
}
}
datasetNode.import(domNode); if (!domNode) {
throw new Error(`Failed to generate XML DOM node for dataset ${dataset.id}`);
} }
// 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) {
for (const coll of dataset.collections) {
const collRole = coll.collectionRole;
addSpecInformation(domNode, `${collRole.oai_name}:${coll.number}`);
}
}
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) => {
@ -192,6 +190,6 @@ const addLandingPageAttribute = (domNode: XMLBuilder, dataid: string) => {
domNode.att('landingpage', url); domNode.att('landingpage', url);
}; };
const addSpecInformation= (domNode: XMLBuilder, information: string) => { const addSpecInformation = (domNode: XMLBuilder, information: string) => {
domNode.ele('SetSpec').att('Value', information); domNode.ele('SetSpec').att('Value', information);
}; };

View file

@ -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;
}
}
}

View 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');
}
}

View file

@ -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}`);
} }
} }

View file

@ -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;
// }
// });
// }
} }

View file

@ -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[]> {

View file

@ -55,8 +55,8 @@ export const createDatasetValidator = vine.compile(
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }), .translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}), }),
) )
// .minLength(1), // .minLength(1),
.arrayContainsTypes({ typeA: 'abstract', typeB: 'translated' }), .arrayContainsTypes({ typeA: 'abstract', typeB: 'translated' }),
authors: vine authors: vine
.array( .array(
vine.object({ vine.object({
@ -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
vine.object({ .array(
label: vine.string().trim().maxLength(100), vine.object({
//extnames: extensions, label: vine.string().trim().maxLength(100),
}), }),
), )
.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)),
}), }),
) )
@ -496,7 +504,7 @@ let messagesProvider = new SimpleMessagesProvider({
'files.array.minLength': 'At least {{ min }} file upload is required.', 'files.array.minLength': 'At least {{ min }} file upload is required.',
'files.*.size': 'file size is to big', 'files.*.size': 'file size is to big',
'files.*.extnames': 'file extension is not supported', 'files.*.extnames': 'file extension is not supported',
'embargo_date.date.afterOrEqual': `Embargo date must be on or after ${dayjs().add(10, 'day').format('DD.MM.YYYY')}`, 'embargo_date.date.afterOrEqual': `Embargo date must be on or after ${dayjs().add(10, 'day').format('DD.MM.YYYY')}`,
}); });
createDatasetValidator.messagesProvider = messagesProvider; createDatasetValidator.messagesProvider = messagesProvider;

28
app/validators/project.ts Normal file
View 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(),
}),
);

View file

@ -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
}), }),
); );

View file

@ -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

View 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`);
}
}

View file

@ -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) {

View 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
View 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
View file

@ -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;
}

View file

@ -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 "$@"

View 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

View 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

View file

@ -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

3882
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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",

View 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
}
}

View file

@ -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(); validatedFile.allowedExtensions = await getEnabledExtensions();
} else if (validatedFile.allowedExtensions === undefined && validationOptions.extnames === undefined) { }
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
*/ */
validatedFile.validate(); try {
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

View file

@ -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
View file

@ -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).

View file

@ -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';
@ -30,8 +30,8 @@ const props = defineProps({
type: String, type: String,
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>

View file

@ -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 props.modelValue == props.inputValue;
} }
return computedValue.value === 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

View file

@ -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); // // const ids = props.modelValue.map((obj) => obj.id);
// return ids; // // return ids;
if (Array.isArray(props.modelValue)) { // if (Array.isArray(props.modelValue)) {
if (props.modelValue.every((item) => typeof item === 'number')) { // if (props.modelValue.every((item) => typeof item === 'number')) {
return props.modelValue; // return props.modelValue;
} else if (props.modelValue.every((item) => hasIdAttribute(item))) { // } 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;
} // }
return props.modelValue; // return props.modelValue;
} // }
// return props.modelValue; // // return props.modelValue;
}, // },
set: (value) => { // set: (value) => {
emit('update:modelValue', 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>

View file

@ -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;

View file

@ -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 = '&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors'; const DEFAULT_BASE_LAYER_ATTRIBUTION = '&copy; <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 {
z-index: 30!important; 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;
}
/* 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>

View file

@ -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>

View file

@ -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'"
> >
<!-- <BaseIcon v-if="mdiMapCheckOutline" :path="mdiMapCheckOutline" /> --> <!-- Icon -->
{{ label }} <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'
]"
>
{{ 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 = '&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors'; const DEFAULT_BASE_LAYER_ATTRIBUTION = '&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors';
@Component({ @Component({
@ -98,33 +137,19 @@ const DEFAULT_BASE_LAYER_ATTRIBUTION = '&copy; <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; z-index: 30 !important;
-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 { /* Custom animations */
display: block; @keyframes checkPulse {
0%, 100% {
margin-left: 0; transform: scale(1);
margin-top: 0.5em; }
50% {
transform: scale(1.1);
}
} }
.leaflet-container .leaflet-pane { @keyframes pulse {
z-index: 30!important; 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%;
}
} }
/* .leaflet-pane {
z-index: 30;
} */
</style> </style>

View file

@ -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()) {
this.inputMinus.disabled = true;
this.inputMinus.setAttribute('aria-disabled', 'true');
} }
if (map.getZoom() === map.getMaxZoom()) {
this.inputPlus.disabled = true; if (this.inputMinus) {
this.inputPlus.setAttribute('aria-disabled', 'true'); this.inputMinus.disabled = this.isZoomOutDisabled;
this.inputMinus.setAttribute('aria-disabled', this.isZoomOutDisabled.toString());
} }
} }
} }
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>

View file

@ -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;
for (let i = 0; i < numPages.value; i++) { if (numPages.value <= maxVisible) {
pagesList.push(i); for (let i = 0; i < numPages.value; i++) {
pages.push(i);
}
} else {
// Smart pagination with ellipsis
if (currentPage.value <= 2) {
for (let i = 0; i < 4; i++) pages.push(i);
pages.push(-1); // Ellipsis marker
pages.push(numPages.value - 1);
} else if (currentPage.value >= numPages.value - 3) {
pages.push(0);
pages.push(-1);
for (let i = numPages.value - 4; i < numPages.value; i++) {
pages.push(i);
}
} else {
pages.push(0);
pages.push(-1);
for (let i = currentPage.value - 1; i <= currentPage.value + 1; i++) {
pages.push(i);
}
pages.push(-1);
pages.push(numPages.value - 1);
}
} }
return pagesList; return pages;
}); });
const removeAuthor = (key: number) => { // 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">
{{ currentPage * perPage + 1 }}-{{ Math.min((currentPage + 1) * perPage, items.length) }} of {{ items.length }}
</span>
</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>
<CardBoxModal v-model="isModalDangerActive" large-title="Please confirm" button="danger" has-cancel> <!-- Table -->
<p>Lorem ipsum dolor sit amet <b>adipiscing elit</b></p> <div class="overflow-x-auto">
<p>This is sample modal</p> <table class="w-full table-compact">
</CardBoxModal> --> <thead>
<tr class="bg-gray-50 dark:bg-slate-800/50 border-b border-gray-200 dark:border-slate-700">
<th v-if="canReorder" class="w-8 px-2 py-2" />
<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 class="text-left px-2 py-2 text-[10px] font-semibold text-gray-600 dark:text-gray-300 w-40">Type</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="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 class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 min-w-[140px]">ORCID</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 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 v-if="canDelete" class="w-16 px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300">Actions</th>
</tr>
</thead>
<!-- <div v-if="checkedRows.length" class="p-3 bg-gray-100/50 dark:bg-slate-800"> <!-- Draggable tbody for non-paginated view -->
<span v-for="checkedRow in checkedRows" :key="checkedRow.id" <draggable
class="inline-block px-2 py-1 rounded-sm mr-2 text-sm bg-gray-100 dark:bg-slate-700"> v-if="canReorder && !hasMultiplePages"
{{ checkedRow.name }} tag="tbody"
</span> v-model="items"
</div> --> item-key="id"
:disabled="!dragEnabled || isLoading"
@end="handleDragEnd"
handle=".drag-handle"
>
<template #item="{ index, element }">
<tr class="border-b border-gray-100 dark:border-slate-800 hover:bg-blue-50 dark:hover:bg-slate-800/70 transition-colors">
<td v-if="canReorder" class="px-2 py-2">
<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 class="px-2 py-2 text-xs text-gray-600 dark:text-gray-400">{{ index + 1 }}</td>
<table> <!-- Name Type Selector -->
<thead> <td class="px-2 py-2">
<tr> <div class="flex items-center gap-1.5">
<!-- <th v-if="checkable" /> --> <BaseIcon
<th /> :path="element.name_type === 'Organizational' ? mdiDomain : mdiAccount"
<th scope="col">Sort</th> :size="16"
<th scope="col">Id</th> :class="element.name_type === 'Organizational' ? 'text-purple-500' : 'text-blue-500'"
<!-- <th class="hidden lg:table-cell"></th> --> :title="element.name_type"
<th>First Name</th> />
<th>Last Name</th> <FormControl
<th>Email</th> required
<th scope="col" v-if="Object.keys(contributortypes).length"> v-model="element.name_type"
<span>Type</span> type="select"
</th> :options="nameTypeOptions"
:is-read-only="element.status == true"
class="text-[8px] compact-select-mini flex-1"
/>
</div>
<div class="text-red-500 text-[8px] mt-0.5" v-if="errors && Array.isArray(errors[`${relation}.${index}.name_type`])">
{{ errors[`${relation}.${index}.name_type`][0] }}
</div>
</td>
<!-- <th>Name Type</th> --> <!-- First Name - Only shown for Personal type -->
<!-- <th>Progress</th> --> <td class="px-2 py-2">
<!-- <th>Created</th> --> <FormControl
<th /> v-if="element.name_type !== 'Organizational'"
</tr> required
</thead> v-model="element.first_name"
<!-- <tbody> --> type="text"
<!-- <tr v-for="(client, index) in itemsPaginated" :key="client.id"> --> :is-read-only="element.status == true"
<draggable id="galliwasery" tag="tbody" v-model="items" item-key="id"> placeholder="First name"
<template #item="{ index, element }"> class="text-xs compact-input"
<tr> />
<td class="drag-icon"> <span v-else class="text-gray-400 text-xs italic"></span>
<BaseIcon :path="mdiDragVariant" /> <div class="text-red-500 text-xs mt-0.5" v-if="errors && Array.isArray(errors[`${relation}.${index}.first_name`])">
</td> {{ errors[`${relation}.${index}.first_name`][0] }}
<td scope="row">{{ index + 1 }}</td> </div>
<td data-label="Id">{{ element.id }}</td> </td>
<!-- <TableCheckboxCell v-if="checkable" @checked="checked($event, client)" /> -->
<!-- <td v-if="element.name" class="border-b-0 lg:w-6 before:hidden hidden lg:table-cell"> <!-- Last Name / Organization Name -->
<UserAvatar :username="element.name" class="w-24 h-24 mx-auto lg:w-6 lg:h-6" /> <td class="px-2 py-2">
</td> --> <FormControl
<td data-label="First Name"> required
<!-- {{ element.first_name }} --> v-model="element.last_name"
<FormControl type="text"
required :is-read-only="element.status == true"
v-model="element.first_name" :placeholder="element.name_type === 'Organizational' ? 'Organization' : 'Last name'"
type="text" :is-read-only="element.status==true" class="text-xs compact-input"
placeholder="[FIRST NAME]" />
> <div class="text-red-500 text-xs mt-0.5" v-if="errors && Array.isArray(errors[`${relation}.${index}.last_name`])">
<div {{ errors[`${relation}.${index}.last_name`][0] }}
class="text-red-400 text-sm" </div>
v-if="errors && Array.isArray(errors[`${relation}.${index}.first_name`])" </td>
>
{{ errors[`${relation}.${index}.first_name`].join(', ') }} <!-- 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
required
v-model="element.email"
type="email"
:is-read-only="element.status == true"
placeholder="email@example.com"
class="text-xs compact-input"
/>
<div class="text-red-500 text-xs mt-0.5" v-if="errors && Array.isArray(errors[`${relation}.${index}.email`])">
{{ errors[`${relation}.${index}.email`][0] }}
</div>
</td>
<!-- Contributor Type -->
<td v-if="Object.keys(contributortypes).length" class="px-2 py-2">
<FormControl
required
v-model="element.pivot_contributor_type"
type="select"
:options="contributortypes"
placeholder="Role"
class="text-xs compact-select"
/>
<div
class="text-red-500 text-xs mt-0.5"
v-if="errors && Array.isArray(errors[`${relation}.${index}.pivot_contributor_type`])"
>
{{ errors[`${relation}.${index}.pivot_contributor_type`][0] }}
</div>
</td>
<!-- Actions -->
<td class="px-2 py-2 whitespace-nowrap">
<BaseButton
color="danger"
:icon="mdiTrashCan"
small
@click.prevent="removeAuthor(index)"
class="compact-button"
/>
</td>
</tr>
</template>
</draggable>
<!-- Non-draggable tbody for paginated view -->
<tbody v-else>
<tr
v-for="(element, index) in itemsPaginated"
:key="element.id || index"
class="border-b border-gray-100 dark:border-slate-800 hover:bg-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> </div>
</FormControl> </td>
</td>
<td data-label="Last Name"> <!-- First Name -->
<FormControl <td class="px-2 py-2">
required <FormControl
v-model="element.last_name" v-if="element.name_type !== 'Organizational'"
type="text" :is-read-only="element.status==true" required
placeholder="[LAST NAME]" :model-value="element.first_name"
> @update:model-value="updatePerson(index, 'first_name', $event)"
<div type="text"
class="text-red-400 text-sm" :is-read-only="element.status || !canEdit"
v-if="errors && Array.isArray(errors[`${relation}.${index}.last_name`])" placeholder="First name"
> class="text-xs compact-input"
{{ errors[`${relation}.${index}.last_name`].join(', ') }} :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> </div>
</FormControl> </td>
</td>
<td data-label="Email"> <!-- Last Name / Organization -->
<FormControl <td class="px-2 py-2">
required <FormControl
v-model="element.email" required
type="text" :is-read-only="element.status==true" :model-value="element.last_name"
placeholder="[EMAIL]" @update:model-value="updatePerson(index, 'last_name', $event)"
> type="text"
<div :is-read-only="element.status || !canEdit"
class="text-red-400 text-sm" :placeholder="element.name_type === 'Organizational' ? 'Organization' : 'Last name'"
v-if="errors && Array.isArray(errors[`${relation}.${index}.email`])" class="text-xs compact-input"
> :error="getFieldError(index, 'last_name')"
{{ errors[`${relation}.${index}.email`].join(', ') }} />
<div v-if="getFieldError(index, 'last_name')" class="text-red-500 text-xs mt-0.5">
{{ getFieldError(index, 'last_name') }}
</div> </div>
</FormControl> </td>
</td>
<td v-if="Object.keys(contributortypes).length"> <!-- Orcid -->
<!-- <select type="text" v-model="element.pivot.contributor_type"> <td class="px-2 py-2">
<option v-for="(option, i) in contributortypes" :value="option" :key="i"> <FormControl
{{ option }} :model-value="element.identifier_orcid"
</option> @update:model-value="updatePerson(index, 'identifier_orcid', $event)"
</select> --> type="text"
<FormControl :is-read-only="element.status || !canEdit"
required placeholder="0000-0000-0000-0000"
v-model="element.pivot_contributor_type" class="text-xs compact-input font-mono"
type="select" :error="getFieldError(index, 'identifier_orcid')"
:options="contributortypes" />
placeholder="[relation type]" <div v-if="getFieldError(index, 'identifier_orcid')" class="text-red-500 text-xs mt-0.5">
> {{ getFieldError(index, 'identifier_orcid') }}
<div
class="text-red-400 text-sm"
v-if="errors && Array.isArray(errors[`${relation}.${index}.pivot_contributor_type`])"
>
{{ errors[`${relation}.${index}.pivot_contributor_type`].join(', ') }}
</div> </div>
</FormControl> </td>
</td>
<!-- <td data-label="Name Type"> <!-- Email -->
{{ client.name_type }} <td class="px-2 py-2">
</td> --> <FormControl
<!-- <td data-label="Orcid"> required
{{ client.identifier_orcid }} :model-value="element.email"
</td> --> @update:model-value="updatePerson(index, 'email', $event)"
<!-- <td data-label="Progress" class="lg:w-32"> type="email"
<progress class="flex w-2/5 self-center lg:w-full" max="100" v-bind:value="client.progress"> :is-read-only="element.status || !canEdit"
{{ client.progress }} placeholder="email@example.com"
</progress> class="text-xs compact-input"
</td> --> :error="getFieldError(index, 'email')"
<td class="before:hidden lg:w-1 whitespace-nowrap"> />
<BaseButtons type="justify-start lg:justify-end" no-wrap> <div v-if="getFieldError(index, 'email')" class="text-red-500 text-xs mt-0.5">
<!-- <BaseButton color="info" :icon="mdiEye" small @click="isModalActive = true" /> --> {{ getFieldError(index, 'email') }}
<BaseButton color="danger" :icon="mdiTrashCan" small @click.prevent="removeAuthor(index)" /> </div>
</BaseButtons> </td>
</td>
</tr> <!-- Contributor Type -->
</template> <td v-if="showContributorTypes" class="px-2 py-2">
</draggable> <FormControl
<!-- </tbody> --> required
</table> :model-value="element.pivot_contributor_type"
<!-- :class="[ pagesList.length > 1 ? 'block' : 'hidden']" --> @update:model-value="updatePerson(index, 'pivot_contributor_type', $event)"
<div class="p-3 lg:px-6 border-t border-gray-100 dark:border-slate-800"> type="select"
<!-- <BaseLevel> :options="contributortypes"
<BaseButtons> :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
color="danger"
:icon="mdiTrashCan"
small
@click="removeAuthor(index)"
: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>
</td>
</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 <BaseButton
v-for="page in pagesList" :disabled="currentPage >= numPages - 1"
:key="page" @click="goToPage(currentPage + 1)"
:active="page === currentPage" :icon="mdiChevronRight"
:label="page + 1"
small small
:outline="styleService.darkMode" outline
@click="currentPage = page"
/> />
</BaseButtons> </div>
<small>Page {{ currentPageHuman }} of {{ numPages }}</small>
</BaseLevel> --> <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>

View 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>

View file

@ -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 {

View file

@ -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 />

View file

@ -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">
{{ license.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 }}
</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>

View 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>

View 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>

View 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>

View file

@ -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>
<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')" {{ roleCount }} {{ roleCount === 1 ? 'role' : 'roles' }}
:icon="mdiPlus" </span>
label="Add" <BaseButton
color="info" v-if="can.create"
rounded-full :route-name="stardust.route('settings.role.create')"
small :icon="mdiPlus"
/> label="New Role"
color="info"
rounded-full
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)"
{{ role.name }} :title="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>

View file

@ -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 () => {
await mainService.fetchChartData(); isLoadingChart.value = true;
// chartData.value = chartConfig.sampleChartData(); try {
// chartData.value = mainService.graphData; await mainService.fetchChartData();
} finally {
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">
<TableSampleClients /> <span class="text-sm text-gray-500 dark:text-gray-400"> Administrator view </span>
</CardBox> </SectionTitleLineWithButton>
<CardBox :icon="mdiMonitorCellphone" title="All Submitters" has-table class="shadow-lg">
<TableSampleClients />
</CardBox>
</template>
</SectionMain> </SectionMain>
</LayoutAuthenticated> </LayoutAuthenticated>
</template> </template>

View file

@ -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"

View file

@ -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">
<div v-if="dataset.server_state === 'rejected_reviewer' && dataset.reject_reviewer_note" <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize"
class="inline-block relative ml-2 group"> :class="getStateColor(dataset.server_state)">
<button {{ getLabel(dataset.server_state) }}
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"> </span>
i <div v-if="dataset.server_state === 'rejected_reviewer' && dataset.reject_reviewer_note"
</button> class="relative group">
<div <button
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="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">
<p class="text-gray-700 max-h-40 overflow-y-auto overflow-x-hidden whitespace-normal break-words"> i
{{ dataset.reject_reviewer_note }} </button>
</p> <div
<div class="absolute -top-1 left-1 w-2 h-2 bg-white transform rotate-45"> 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 dark:text-gray-300 max-h-40 overflow-y-auto overflow-x-hidden whitespace-normal break-words">
{{ dataset.reject_reviewer_note }}
</p>
<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>

View file

@ -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">
<SearchMap :datasets="datasets" :map-options="mapOptions"></SearchMap>
</div>
<!-- <CardBox> --> <!-- Results Header -->
<!-- <div id="map" class="map-container mapDesktop mt-6 mb-6 rounded-2xl py-12 px-6 text-center"> <div v-if="datasets.length > 0" class="results-header">
<DrawControlComponent ref="draw" :preserve="false" :mapId="mapId" :southWest="southWest" <h2 class="results-title">
:northEast="northEast"> <span class="results-count">{{ datasets.length }}</span>
</DrawControlComponent> {{ datasets.length === 1 ? 'Dataset' : 'Datasets' }} Found
</div> --> </h2>
<SearchMap :datasets="datasets" :map-options="mapOptions"></SearchMap> <p class="results-subtitle">Click on any card to view details</p>
</div>
<div d="search-result-list-wrapper" class="flex flex-wrap col-span-24 h-full"> <!-- Enhanced Results Grid -->
<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 class="results-grid">
<div class="bg-white rounded shadow p-6"> <div
<h3 class="text-lg leading-tight text-gray-500 dark:text-slate-400"> 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>
<!-- 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>
</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>

View file

@ -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') { return stateClasses[dataset.server_state] || '';
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 // Method to get state badge color
const formatServerState = (state: string) => { const getStateColor = (state: string) => {
if (state === 'inprogress') { const stateColors = {
return 'draft'; 'inprogress': 'bg-sky-200 text-sky-900 dark:bg-sky-900 dark:text-sky-200',
} else if (state === 'released') { 'released': 'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-200',
return 'submitted'; 'editor_accepted': 'bg-teal-200 text-teal-900 dark:bg-teal-900 dark:text-teal-200',
} else if (state === 'approved') { 'rejected_reviewer': 'bg-amber-200 text-amber-900 dark:bg-amber-900 dark:text-amber-200',
return 'ready for review'; 'rejected_to_reviewer': 'bg-yellow-200 text-yellow-900 dark:bg-yellow-900 dark:text-yellow-200',
} else if (state === 'reviewer_accepted') { 'rejected_editor': 'bg-rose-300 text-rose-900 dark:bg-rose-900 dark:text-rose-200',
return 'in review'; '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',
return state; // Return the original state for other cases '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">
<div v-if="dataset.server_state === 'rejected_to_reviewer' && dataset.reject_editor_note" <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize"
class="inline-block relative ml-2 group"> :class="getStateColor(dataset.server_state)">
<button {{ getLabel(dataset.server_state) }}
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"> </span>
i <div v-if="dataset.server_state === 'rejected_to_reviewer' && dataset.reject_editor_note"
</button> class="relative group">
<div <button
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="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">
<p class="text-gray-700 max-h-40 overflow-y-auto overflow-x-hidden whitespace-normal break-words"> i
{{ dataset.reject_editor_note }} </button>
</p> <div
<div class="absolute -top-1 left-1 w-2 h-2 bg-white transform rotate-45"> 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 dark:text-gray-300 max-h-40 overflow-y-auto overflow-x-hidden whitespace-normal break-words">
{{ dataset.reject_editor_note }}
</p>
<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>

View file

@ -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

View file

@ -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 { return stateClasses[dataset.server_state] || '';
rowclass = '';
}
return rowclass;
}; };
// 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>
<!-- table --> <!-- Legend -->
<CardBox class="mb-6" has-table> <CardBox class="mb-4">
<table class="w-full table-fixed"> <!-- Legend Header with Toggle -->
<thead> <div
<tr> 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"
<th scope="col" class="py-3 text-left text-xs font-medium uppercase tracking-wider dark:text-white"> @click="toggleLegend"
<!-- <Sort label="Dataset Title" attribute="title" :search="form.search" /> --> >
Dataset Title <div class="flex items-center gap-2">
</th> <svg class="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<th scope="col" class="py-3 text-left text-xs font-medium uppercase tracking-wider dark:text-white"> <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" />
<!-- <Sort label="Email" attribute="email" :search="form.search" /> --> </svg>
Server State <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">
</th> Legend - States & Actions
<th scope="col" class="py-3 text-left text-xs font-medium uppercase tracking-wider dark:text-white"> </h3>
Date of last modification </div>
</th> <div class="flex items-center gap-2">
<th scope="col" class="relative px-6 py-3 dark:text-white" v-if="can.edit || can.delete"> <span class="text-xs text-gray-500 dark:text-gray-400">
<span class="sr-only">Actions</span> {{ showLegend ? 'Click to hide' : 'Click to show' }}
</th> </span>
</tr> <BaseIcon
</thead> :path="showLegend ? mdiChevronUp : mdiChevronDown"
:size="20"
class="text-gray-500 dark:text-gray-400 transition-transform"
/>
</div>
</div>
<tbody class="bg-white divide-y divide-gray-200"> <!-- Collapsible Legend Content -->
<tr v-for="dataset in props.datasets.data" :key="dataset.id" :class="getRowClass(dataset)"> <transition
<td data-label="Login" enter-active-class="transition-all duration-300 ease-out"
class="py-4 whitespace-nowrap text-gray-700 table-title"> enter-from-class="max-h-0 opacity-0"
<!-- <Link v-bind:href="stardust.route('settings.user.show', [user.id])" enter-to-class="max-h-96 opacity-100"
class="no-underline hover:underline text-cyan-600 dark:text-cyan-400"> leave-active-class="transition-all duration-300 ease-in"
{{ user.login }} leave-from-class="max-h-96 opacity-100"
</Link> --> leave-to-class="max-h-0 opacity-0"
<!-- {{ user.id }} --> >
{{ dataset.main_title }} <div v-show="showLegend" class="overflow-hidden">
</td> <div class="grid grid-cols-1 md:grid-cols-2 gap-6 p-4">
<td class="py-4 whitespace-nowrap text-gray-700"> <!-- State Colors Legend -->
{{ formatServerState(dataset.server_state) }} <div>
<div v-if="dataset.server_state === 'rejected_editor' && dataset.reject_editor_note" <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
class="inline-block relative ml-2 group"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button <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" />
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"> </svg>
i Dataset States
</button> </h3>
<div <div class="grid grid-cols-2 gap-2 text-xs">
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"> <div v-for="state in datasetStates" :key="state.key" class="flex items-center gap-2">
<p <span class="inline-flex items-center px-2 py-0.5 rounded-full" :class="getStateColor(state.key)">
class="text-gray-700 max-h-40 overflow-y-auto overflow-x-hidden whitespace-normal break-words"> {{ state.label }}
{{ dataset.reject_editor_note }} </span>
</p>
<div class="absolute -top-1 left-1 w-2 h-2 bg-white transform rotate-45">
</div>
</div> </div>
</div> </div>
</td> </div>
<td data-label="modified" class="py-4 whitespace-nowrap text-gray-700"> <!-- Actions Legend -->
<div class="text-sm" :title="dataset.server_date_modified"> <div>
{{ dataset.server_date_modified }} <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>
</td> </div>
<td </div>
class="py-4 whitespace-nowrap text-right text-sm font-medium text-gray-700"> </div>
<BaseButtons v-if="validStates.includes(dataset.server_state)" </transition>
type="justify-start lg:justify-end" no-wrap> </CardBox>
<!-- release created dataset -->
<BaseButton v-if="can.edit" <!-- table -->
:route-name="stardust.route('dataset.release', [dataset.id])" color="info" <CardBox class="mb-6" has-table>
:icon="mdiLockOpen" :label="'Release'" small /> <div v-if="props.datasets.data.length > 0">
<BaseButton v-if="can.edit" <table>
:route-name="stardust.route('dataset.edit', [dataset.id])" color="info" <thead>
:icon="mdiSquareEditOutline" :label="'Edit'" small /> <tr>
<BaseButton v-if="can.edit" <th>Title</th>
:route-name="stardust.route('dataset.categorize', [dataset.id])" color="info" <th>State</th>
:icon="mdiLibraryShelves" :label="'Classify'" small /> <th>Modified</th>
<BaseButton v-if="can.delete" color="danger" <th v-if="can.edit || can.delete">Actions</th>
:route-name="stardust.route('dataset.delete', [dataset.id])" :icon="mdiTrashCan" </tr>
small /> </thead>
</BaseButtons>
</td> <tbody>
</tr> <tr v-for="dataset in props.datasets.data" :key="dataset.id"
</tbody> :class="['hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors', getRowClass(dataset)]">
</table> <td data-label="Title">
<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 data-label="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"
class="relative group">
<button
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
</button>
<div
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 dark:text-gray-300 max-h-40 overflow-y-auto overflow-x-hidden whitespace-normal break-words">
{{ dataset.reject_editor_note }}
</p>
<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>
</td>
<td data-label="Modified">
<span class="text-sm text-gray-600 dark:text-gray-400" :title="dataset.server_date_modified">
{{ dataset.server_date_modified }}
</span>
</td>
<td v-if="can.edit || can.delete" class="before:hidden lg:w-1 whitespace-nowrap">
<BaseButtons v-if="validStates.includes(dataset.server_state)"
type="justify-start lg:justify-end" no-wrap>
<BaseButton v-if="can.edit"
:route-name="stardust.route('dataset.release', [dataset.id])" color="info"
:icon="mdiLockOpen" small
title="Release (Submit)" />
<BaseButton v-if="can.edit"
:route-name="stardust.route('dataset.edit', [dataset.id])" color="info"
:icon="mdiSquareEditOutline" small
title="Edit" />
<BaseButton v-if="can.edit"
:route-name="stardust.route('dataset.categorize', [dataset.id])" color="info"
:icon="mdiLibraryShelves" small
title="Classify" />
<BaseButton v-if="can.delete" color="danger"
:route-name="stardust.route('dataset.delete', [dataset.id])" :icon="mdiTrashCan"
small
title="Delete" />
</BaseButtons>
</td>
</tr>
</tbody>
</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; }
.bg-approved {
@apply bg-approved dark:bg-approved-dark;
}
.bg-reviewed {
@apply bg-reviewed dark:bg-reviewed-dark;
}
.bg-rejected-editor {
@apply bg-rejected-editor dark:bg-rejected-editor-dark;
} */ } */
/* .pure-table tr.inprogress {
padding: 0.8em;
background-color: rgb(94 234 212);
color: gray;
} */
/* .pure-table tr.editor_accepted {
background-color: rgb(125 211 252);
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;
}*/
</style> </style>

View file

@ -1,21 +1,41 @@
<template> <template>
<NcSettingsSection :name="t('settings', 'Background jobs')" :description="t( <NcSettingsSection
'settings', :name="t('settings', 'Background jobs')"
`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.`, :description="
)" :doc-url="backgroundJobsDocUrl"> t(
'settings',
<template v-if="lastCron !== 0"> `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.`,
<NcNoteCard v-if="oldExecution" type="danger"> )
{{ t('settings', `Last job execution ran {time}. Something seems wrong.`, { "
time: relativeTime, :doc-url="backgroundJobsDocUrl"
timestamp: lastCron >
}) }} <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,
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() {
return dayjs.unix(this.cronMaxAge).fromNow(); // Calculate relative time for cronMaxAge
},
cronLabel() {
let desc = 'Use system cron service to call the cron.php file every 5 minutes.';
if (this.cliBasedCronPossible) {
desc +=
'<br>' +
'The cron.php needs to be executed by the system account "{user}".', { user: this.cliBasedCronUser };
} else {
desc +=
'<br>' +
'The PHP POSIX extension is required. See {linkstart}PHP documentation{linkend} for more details.', const cronLabel = computed((): string => {
{ let desc = t('settings', 'Use system cron service to call the cron.php file every 5 minutes.');
linkstart:
'<a target="_blank" rel="noreferrer nofollow" class="external" href="https://www.php.net/manual/en/book.posix.php">',
linkend: '</a>',
}
}
return desc;
},
oldExecution() {
return (dayjs().unix() - this.lastCron) > 600; // older than 10 minutes
},
longExecutionCron() { if (cliBasedCronPossible.value) {
//type of cron job and greater than 24h desc +=
// let test = dayjs.unix(this.cronMaxAge).format('YYYY-MM-DD HH:mm:ss'); '<br>' +
return (dayjs().unix() - this.cronMaxAge) > 24 * 3600 && this.backgroundJobsMode === 'cron'; t('settings', 'The cron.php needs to be executed by the system account "{user}".', {
}, user: cliBasedCronUser.value,
},
methods: {
async onBackgroundJobModeChanged(backgroundJobsMode: string) {
const url = generateOcsUrl('/apps/provisioning_api/api/v1/config/apps/{appId}/{key}', {
appId: 'core',
key: 'backgroundjobs_mode',
}); });
} else {
// await confirmPassword(); desc +=
'<br>' +
try { t('settings', 'The PHP POSIX extension is required. See {linkstart}PHP documentation{linkend} for more details.', {
const { data } = await axios.post(url, { linkstart:
value: backgroundJobsMode, '<a target="_blank" rel="noreferrer nofollow" class="external" href="https://www.php.net/manual/en/book.posix.php">',
}); linkend: '</a>',
this.handleResponse({
status: data.ocs?.meta?.status,
});
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update background job mode'),
error: e,
});
}
},
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(); return desc;
});
try { const isExecutionTooOld = computed((): boolean => {
await axios.delete(url); if (!lastCronTimestamp.value) return false;
} catch (error) { return dayjs().unix() - lastCronTimestamp.value > 600; // older than 10 minutes
console.error(error); });
}
}, const isLongExecutionCron = computed((): boolean => {
}, return dayjs().unix() - cronMaxAge > 24 * 3600 && backgroundJobsMode.value === 'cron';
});
// 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');
}
}; };
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>

View file

@ -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
}; };

View file

@ -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'],
},
], ],
}, },

View file

@ -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;

View file

@ -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,

View file

@ -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 {
.from('appconfigs') const [lastJobConfig, missingCrossReferencesConfig] = await Promise.all([
.select('configvalue') db.from('appconfigs').select('configvalue').where('appid', 'backgroundjob').where('configkey', 'lastjob').first(),
.where('appid', 'backgroundjob') db
.where('configkey', 'lastjob') .from('appconfigs')
.first(); .select('configvalue')
return inertia.render('Admin/Settings', { .where('appid', 'commands')
lastCron: updatedConfigValue?.configvalue || '', .where('configkey', 'missing_cross_references_count')
}); .first(),
]);
return inertia.render('Admin/Settings', {
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

View file

@ -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');

View file

@ -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(
`At least {{ min }} item for {{field}} field must be defined`,
'array.dependentArrayMinLength',
field,
options,
);
}
} else {
// Report if either value or dependentArray is not an array
field.report( field.report(
`Both the {{field}} field and dependent array {{dependentArray}} must be arrays.`, `Invalid file data format. Please contact support if this error persists.`,
'array.dependentArrayMinLength',
field,
options,
);
return false;
}
// 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(
`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;

View file

@ -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
View 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());
});

View file

@ -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',
@ -30,7 +71,7 @@ module.exports = {
'lime': { 'lime': {
DEFAULT: '#BFCE40', DEFAULT: '#BFCE40',
dark: 'rgba(5,46,55,0.7)', dark: 'rgba(5,46,55,0.7)',
50: '#FBFCF7', 50: '#FBFCF7',
100: '#F8FBE1', 100: '#F8FBE1',
200: '#EEF69E', 200: '#EEF69E',
300: '#DCEC53', 300: '#DCEC53',
@ -40,7 +81,7 @@ module.exports = {
700: '#357C06', 700: '#357C06',
800: '#295B09', 800: '#295B09',
900: '#20450A', 900: '#20450A',
}, },
}, },
fontFamily: { fontFamily: {
sans: ['Inter', ...defaultTheme.fontFamily.sans], sans: ['Inter', ...defaultTheme.fontFamily.sans],
@ -106,7 +147,7 @@ module.exports = {
{ values: theme('asideScrollbars') }, { values: theme('asideScrollbars') },
); );
}), }),
plugin(function({ addUtilities }) { plugin(function ({ addUtilities }) {
const newUtilities = { const newUtilities = {
'.drag-none': { '.drag-none': {
'-webkit-user-drag': 'none', '-webkit-user-drag': 'none',
@ -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'),