hotfix: enhance editor dataset management and UI improvements

- Implemented dataset editing functionality for editor roles, including fetching, updating, and categorizing datasets.
- Added routes and controller actions for editing, updating, and categorizing datasets within the editor interface.
- Integrated UI components for managing dataset metadata, subjects, references, and files.
- Enhanced keyword management with features for adding, editing, and deleting keywords, including handling keywords used by multiple datasets.
- Improved reference management with features for adding, editing, and deleting dataset references.
- Added validation for dataset updates using the `updateEditorDatasetValidator`.
- Updated the dataset edit form to include components for managing titles, descriptions, authors, contributors, licenses, coverage, subjects, references, and files.
- Implemented transaction management for dataset updates to ensure data consistency.
- Added a download route for files associated with datasets.
- Improved the UI for displaying and interacting with datasets in the editor index view, including adding edit and categorize buttons.
- Fixed an issue where the file size was not correctly calculated.
- Added a tooltip to the keyword value column in the TableKeywords component to explain the editability of keywords.
- Added a section to display keywords that are marked for deletion.
- Added a section to display references that are marked for deletion.
- Added a restore button to the references to delete section to restore references.
- Updated the SearchCategoryAutocomplete component to support read-only mode.
- Updated the FormControl component to support read-only mode.
- Added icons and styling improvements to various components.
- Added a default value for subjectsToDelete and referencesToDelete in the dataset model.
- Updated the FooterBar component to use the JustboilLogo component.
- Updated the app.ts file to fetch chart data without a year parameter.
- Updated the Login.vue file to invert the logo in dark mode.
- Updated the AccountInfo.vue file to add a Head component.
This commit is contained in:
Kaimbacher 2025-04-08 14:16:35 +02:00
parent 10d159a57a
commit f04c1f6327
30 changed files with 2284 additions and 539 deletions

View file

@ -18,9 +18,32 @@ import { HttpException } from 'node-exceptions';
import { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model';
import vine, { SimpleMessagesProvider } from '@vinejs/vine';
import mail from '@adonisjs/mail/services/main';
// import { resolveMx } from 'dns/promises';
// import * as net from 'net';
import { validate } from 'deep-email-validator';
import {
TitleTypes,
DescriptionTypes,
ContributorTypes,
PersonNameTypes,
ReferenceIdentifierTypes,
RelationTypes,
SubjectTypes,
} from '#contracts/enums';
import { TransactionClientContract } from '@adonisjs/lucid/types/database';
import db from '@adonisjs/lucid/services/db';
import Project from '#models/project';
import License from '#models/license';
import Language from '#models/language';
import File from '#models/file';
import Coverage from '#models/coverage';
import Title from '#models/title';
import Description from '#models/description';
import Subject from '#models/subject';
import DatasetReference from '#models/dataset_reference';
import Collection from '#models/collection';
import CollectionRole from '#models/collection_role';
import { updateEditorDatasetValidator } from '#validators/dataset';
import { savePersons } from '#app/utils/utility-functions';
// Create a new instance of the client
const client = new Client({ node: 'http://localhost:9200' }); // replace with your OpenSearch endpoint
@ -255,71 +278,6 @@ export default class DatasetsController {
});
}
// private async checkEmailDomain(email: string): Promise<boolean> {
// const domain = email.split('@')[1];
// try {
// // Step 1: Check MX records for the domain
// const mxRecords = await resolveMx(domain);
// if (mxRecords.length === 0) {
// return false; // No MX records, can't send email
// }
// // Sort MX records by priority
// mxRecords.sort((a, b) => a.priority - b.priority);
// // Step 2: Attempt SMTP connection to the first available mail server
// const smtpServer = mxRecords[0].exchange;
// return await this.checkMailboxExists(smtpServer, email);
// } catch (error) {
// console.error('Error during MX lookup or SMTP validation:', error);
// return false;
// }
// }
//// Helper function to check if the mailbox exists using SMTP
// private async checkMailboxExists(smtpServer: string, email: string): Promise<boolean> {
// return new Promise((resolve, reject) => {
// const socket = net.createConnection(25, smtpServer);
// socket.on('connect', () => {
// socket.write(`HELO ${smtpServer}\r\n`);
// socket.write(`MAIL FROM: <test@example.com>\r\n`);
// socket.write(`RCPT TO: <${email}>\r\n`);
// });
// socket.on('data', (data) => {
// const response = data.toString();
// if (response.includes('250')) {
// // 250 is an SMTP success code
// socket.end();
// resolve(true); // Email exists
// } else if (response.includes('550')) {
// // 550 means the mailbox doesn't exist
// socket.end();
// resolve(false); // Email doesn't exist
// }
// });
// socket.on('error', (error) => {
// console.error('SMTP connection error:', error);
// socket.end();
// resolve(false);
// });
// socket.on('end', () => {
// // SMTP connection closed
// });
// socket.setTimeout(5000, () => {
// // Timeout after 5 seconds
// socket.end();
// resolve(false); // Assume email doesn't exist if no response
// });
// });
// }
public async rejectUpdate({ request, response, auth }: HttpContext) {
const authUser = auth.user!;
@ -353,7 +311,7 @@ export default class DatasetsController {
return response
.flash(
`Invalid server state. Dataset with id ${id} cannot be rejected. Datset has server state ${dataset.server_state}.`,
'warning'
'warning',
)
.redirect()
.toRoute('editor.dataset.list');
@ -388,7 +346,9 @@ export default class DatasetsController {
emailStatusMessage = ` A rejection email was successfully sent to ${dataset.user.email}.`;
} catch (error) {
logger.error(error);
return response.flash('Dataset has not been rejected due to an email error: ' + error.message, 'error').toRoute('editor.dataset.list');
return response
.flash('Dataset has not been rejected due to an email error: ' + error.message, 'error')
.toRoute('editor.dataset.list');
}
} else {
emailStatusMessage = ` However, the email could not be sent because the submitter's email address (${dataset.user.email}) is not valid.`;
@ -536,10 +496,375 @@ export default class DatasetsController {
public async show({}: HttpContext) {}
public async edit({}: HttpContext) {}
public async edit({ request, inertia, response }: HttpContext) {
const id = request.param('id');
const datasetQuery = Dataset.query().where('id', id);
datasetQuery
.preload('titles', (query) => query.orderBy('id', 'asc'))
.preload('descriptions', (query) => query.orderBy('id', 'asc'))
.preload('coverage')
.preload('licenses')
.preload('authors')
.preload('contributors')
// .preload('subjects')
.preload('subjects', (builder) => {
builder.orderBy('id', 'asc').withCount('datasets');
})
.preload('references')
.preload('files', (query) => {
query.orderBy('sort_order', 'asc'); // Sort by sort_order column
});
const dataset = await datasetQuery.firstOrFail();
const validStates = ['editor_accepted', 'rejected_reviewer'];
if (!validStates.includes(dataset.server_state)) {
// session.flash('errors', 'Invalid server state!');
return response
.flash(
`Invalid server state. Dataset with id ${id} cannot be edited. Datset has server state ${dataset.server_state}.`,
'warning',
)
.toRoute('editor.dataset.list');
}
const titleTypes = Object.entries(TitleTypes)
.filter(([value]) => value !== 'Main')
.map(([key, value]) => ({ value: key, label: value }));
const descriptionTypes = Object.entries(DescriptionTypes)
.filter(([value]) => value !== 'Abstract')
.map(([key, value]) => ({ value: key, label: value }));
const languages = await Language.query().where('active', true).pluck('part1', 'part1');
// const contributorTypes = Config.get('enums.contributor_types');
const contributorTypes = Object.entries(ContributorTypes).map(([key, value]) => ({ value: key, label: value }));
// const nameTypes = Config.get('enums.name_types');
const nameTypes = Object.entries(PersonNameTypes).map(([key, value]) => ({ value: key, label: value }));
// const messages = await Database.table('messages')
// .pluck('help_text', 'metadata_element');
const projects = await Project.query().pluck('label', 'id');
const currentDate = new Date();
const currentYear = currentDate.getFullYear();
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 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('Editor/Dataset/Edit', {
dataset,
titletypes: titleTypes,
descriptiontypes: descriptionTypes,
contributorTypes,
nameTypes,
languages,
// messages,
projects,
licenses,
// datasetHasLicenses: Object.keys(datasetHasLicenses).map((key) => datasetHasLicenses[key]), //convert object to array with license ids
// checkeds,
years,
// languages,
subjectTypes: SubjectTypes,
referenceIdentifierTypes: Object.entries(ReferenceIdentifierTypes).map(([key, value]) => ({ value: key, label: value })),
relationTypes: Object.entries(RelationTypes).map(([key, value]) => ({ value: key, label: value })),
doctypes,
});
}
public async update({ request, response, session }: HttpContext) {
// Get the dataset id from the route parameter
const datasetId = request.param('id');
// Retrieve the dataset and load its existing files
const dataset = await Dataset.findOrFail(datasetId);
await dataset.load('files');
let trx: TransactionClientContract | null = null;
try {
await request.validateUsing(updateEditorDatasetValidator);
trx = await db.transaction();
// const user = (await User.find(auth.user?.id)) as User;
// await this.createDatasetAndAssociations(user, request, trx);
const dataset = await Dataset.findOrFail(datasetId);
// save the licenses
const licenses: number[] = request.input('licenses', []);
// await dataset.useTransaction(trx).related('licenses').sync(licenses);
await dataset.useTransaction(trx).related('licenses').sync(licenses);
// save authors and contributors
await dataset.useTransaction(trx).related('authors').sync([]);
await dataset.useTransaction(trx).related('contributors').sync([]);
await savePersons(dataset, request.input('authors', []), 'author', trx);
await savePersons(dataset, request.input('contributors', []), 'contributor', trx);
//save the titles:
const titles = request.input('titles', []);
// const savedTitles:Array<Title> = [];
for (const titleData of titles) {
if (titleData.id) {
const title = await Title.findOrFail(titleData.id);
title.value = titleData.value;
title.language = titleData.language;
title.type = titleData.type;
if (title.$isDirty) {
await title.useTransaction(trx).save();
// await dataset.useTransaction(trx).related('titles').save(title);
// savedTitles.push(title);
}
} else {
const title = new Title();
title.fill(titleData);
// savedTitles.push(title);
await dataset.useTransaction(trx).related('titles').save(title);
}
}
// save the abstracts
const descriptions = request.input('descriptions', []);
// const savedTitles:Array<Title> = [];
for (const descriptionData of descriptions) {
if (descriptionData.id) {
const description = await Description.findOrFail(descriptionData.id);
description.value = descriptionData.value;
description.language = descriptionData.language;
description.type = descriptionData.type;
if (description.$isDirty) {
await description.useTransaction(trx).save();
// await dataset.useTransaction(trx).related('titles').save(title);
// savedTitles.push(title);
}
} else {
const description = new Description();
description.fill(descriptionData);
// savedTitles.push(title);
await dataset.useTransaction(trx).related('descriptions').save(description);
}
}
// Process all subjects/keywords from the request
const subjects = request.input('subjects');
for (const subjectData of subjects) {
// Case 1: Subject already exists in the database (has an ID)
if (subjectData.id) {
// Retrieve the existing subject
const existingSubject = await Subject.findOrFail(subjectData.id);
// Update subject properties from the request data
existingSubject.value = subjectData.value;
existingSubject.type = subjectData.type;
existingSubject.external_key = subjectData.external_key;
// Only save if there are actual changes
if (existingSubject.$isDirty) {
await existingSubject.save();
}
// 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)
else {
// Check if a subject with the same value and type already exists in the database
const subject = await Subject.firstOrNew({ value: subjectData.value, type: subjectData.type }, subjectData);
if (subject.$isNew === true) {
// If it's a completely new subject, create and associate it with the dataset
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]);
}
}
}
const subjectsToDelete = request.input('subjectsToDelete', []);
for (const subjectData of subjectsToDelete) {
if (subjectData.id) {
// const subject = await Subject.findOrFail(subjectData.id);
const subject = await Subject.query()
.where('id', subjectData.id)
.preload('datasets', (builder) => {
builder.orderBy('id', 'asc');
})
.withCount('datasets')
.firstOrFail();
// Check if the subject is used by multiple datasets
if (subject.$extras.datasets_count > 1) {
// If used by multiple datasets, just detach it from the current dataset
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
} else {
// If only used by this dataset, delete the subject completely
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
await subject.useTransaction(trx).delete();
}
}
}
// Process references
const references = request.input('references', []);
// First, get existing references to determine which ones to update vs. create
const existingReferences = await dataset.related('references').query();
const existingReferencesMap: Map<number, DatasetReference> = new Map(existingReferences.map((ref) => [ref.id, ref]));
for (const referenceData of references) {
if (existingReferencesMap.has(referenceData.id) && referenceData.id) {
// Update existing reference
const reference = existingReferencesMap.get(referenceData.id);
if (reference) {
reference.merge(referenceData);
if (reference.$isDirty) {
await reference.useTransaction(trx).save();
}
}
} else {
// Create new reference
const dataReference = new DatasetReference();
dataReference.fill(referenceData);
await dataset.useTransaction(trx).related('references').save(dataReference);
}
}
// Handle references to delete if provided
const referencesToDelete = request.input('referencesToDelete', []);
for (const referenceData of referencesToDelete) {
if (referenceData.id) {
const reference = await DatasetReference.findOrFail(referenceData.id);
await reference.useTransaction(trx).delete();
}
}
// save coverage
const coverageData = request.input('coverage');
if (coverageData) {
if (coverageData.id) {
const coverage = await Coverage.findOrFail(coverageData.id);
coverage.merge(coverageData);
if (coverage.$isDirty) {
await coverage.useTransaction(trx).save();
}
}
}
const input = request.only(['project_id', 'embargo_date', 'language', 'type', 'creating_corporation']);
// dataset.type = request.input('type');
dataset.merge(input);
// let test: boolean = dataset.$isDirty;
await dataset.useTransaction(trx).save();
await trx.commit();
// console.log('Dataset has been updated successfully');
session.flash('message', 'Dataset has been updated successfully');
// return response.redirect().toRoute('user.index');
return response.redirect().toRoute('editor.dataset.edit', [dataset.id]);
} catch (error) {
if (trx !== null) {
await trx.rollback();
}
console.error('Failed to update dataset and related models:', error);
// throw new ValidationException(true, { 'upload error': `failed to create dataset and related models. ${error}` });
throw error;
}
}
public async categorize({ inertia, request, response }: HttpContext) {
const id = request.param('id');
// Preload dataset and its "collections" relation
const dataset = await Dataset.query().where('id', id).preload('collections').firstOrFail();
const validStates = ['editor_accepted', 'rejected_reviewer'];
if (!validStates.includes(dataset.server_state)) {
// session.flash('errors', 'Invalid server state!');
return response
.flash(
'warning',
`Invalid server state. Dataset with id ${id} cannot be edited. Datset has server state ${dataset.server_state}.`,
)
.redirect()
.toRoute('editor.dataset.list');
}
const collectionRoles = await CollectionRole.query()
.preload('collections', (coll: Collection) => {
// preloa only top level collection with noparent_id
coll.whereNull('parent_id').orderBy('number', 'asc');
})
.exec();
return inertia.render('Editor/Dataset/Category', {
collectionRoles: collectionRoles,
dataset: dataset,
relatedCollections: dataset.collections,
});
}
public async categorizeUpdate({ request, response, session }: HttpContext) {
// Get the dataset id from the route parameter
const id = request.param('id');
const dataset = await Dataset.query().preload('files').where('id', id).firstOrFail();
const validStates = ['editor_accepted', 'rejected_reviewer'];
if (!validStates.includes(dataset.server_state)) {
return response
.flash(
'warning',
`Invalid server state. Dataset with id ${id} cannot be categorized. Dataset has server state ${dataset.server_state}.`,
)
.redirect()
.toRoute('editor.dataset.list');
}
let trx: TransactionClientContract | null = null;
try {
trx = await db.transaction();
// const user = (await User.find(auth.user?.id)) as User;
// await this.createDatasetAndAssociations(user, request, trx);
// Retrieve the selected collections from the request.
// This should be an array of collection ids.
const collections: number[] = request.input('collections', []);
// Synchronize the dataset collections using the transaction.
await dataset.useTransaction(trx).related('collections').sync(collections);
// Commit the transaction.await trx.commit()
await trx.commit();
// Redirect with a success flash message.
// return response.flash('success', 'Dataset collections updated successfully!').redirect().toRoute('dataset.list');
session.flash('message', 'Dataset collections updated successfully!');
return response.redirect().toRoute('editor.dataset.list');
} catch (error) {
if (trx !== null) {
await trx.rollback();
}
console.error('Failed tocatgorize dataset collections:', error);
// throw new ValidationException(true, { 'upload error': `failed to create dataset and related models. ${error}` });
throw error;
}
}
// public async update({}: HttpContextContract) {}
public async update({ response }: HttpContext) {
public async updateOpensearch({ response }: HttpContext) {
const id = 273; //request.param('id');
const dataset = await Dataset.query().preload('xmlCache').where('id', id).firstOrFail();
// add xml elements
@ -655,6 +980,19 @@ export default class DatasetsController {
}
}
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 destroy({}: HttpContext) {}
private async createXmlRecord(dataset: Dataset, datasetNode: XMLBuilder) {

View file

@ -29,12 +29,8 @@ import {
} from '#contracts/enums';
import { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model';
import DatasetReference from '#models/dataset_reference';
import { cuid } from '@adonisjs/core/helpers';
import File from '#models/file';
import ClamScan from 'clamscan';
// import { ValidationException } from '@adonisjs/validator';
// import Drive from '@ioc:Adonis/Core/Drive';
// import drive from '#services/drive';
import drive from '@adonisjs/drive/services/main';
import path from 'path';
import { Exception } from '@adonisjs/core/exceptions';
@ -945,10 +941,9 @@ export default class DatasetController {
// session.flash('errors', 'Invalid server state!');
return response
.flash(
'warning',
`Invalid server state. Dataset with id ${id} cannot be edited. Datset has server state ${dataset.server_state}.`,
'warning',
)
.redirect()
.toRoute('dataset.list');
}
@ -1020,7 +1015,11 @@ export default class DatasetController {
const dataset = await Dataset.findOrFail(datasetId);
await dataset.load('files');
// Accumulate the size of the already related files
const preExistingFileSize = dataset.files.reduce((acc, file) => acc + file.fileSize, 0);
// const preExistingFileSize = dataset.files.reduce((acc, file) => acc + file.fileSize, 0);
let preExistingFileSize = 0;
for (const file of dataset.files) {
preExistingFileSize += Number(file.fileSize);
}
const uploadedTmpFiles: string[] = [];
// Only process multipart if the request has a multipart content type
@ -1150,22 +1149,97 @@ export default class DatasetController {
}
}
// await dataset.useTransaction(trx).related('subjects').sync([]);
const keywords = request.input('subjects');
for (const keywordData of keywords) {
if (keywordData.id) {
const subject = await Subject.findOrFail(keywordData.id);
// await dataset.useTransaction(trx).related('subjects').attach([keywordData.id]);
subject.value = keywordData.value;
subject.type = keywordData.type;
subject.external_key = keywordData.external_key;
if (subject.$isDirty) {
await subject.save();
// Process all subjects/keywords from the request
const subjects = request.input('subjects');
for (const subjectData of subjects) {
// Case 1: Subject already exists in the database (has an ID)
if (subjectData.id) {
// Retrieve the existing subject
const existingSubject = await Subject.findOrFail(subjectData.id);
// Update subject properties from the request data
existingSubject.value = subjectData.value;
existingSubject.type = subjectData.type;
existingSubject.external_key = subjectData.external_key;
// Only save if there are actual changes
if (existingSubject.$isDirty) {
await existingSubject.save();
}
// 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)
else {
// Check if a subject with the same value and type already exists in the database
const subject = await Subject.firstOrNew({ value: subjectData.value, type: subjectData.type }, subjectData);
if (subject.$isNew === true) {
// If it's a completely new subject, create and associate it with the dataset
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]);
}
}
}
const subjectsToDelete = request.input('subjectsToDelete', []);
for (const subjectData of subjectsToDelete) {
if (subjectData.id) {
// const subject = await Subject.findOrFail(subjectData.id);
const subject = await Subject.query()
.where('id', subjectData.id)
.preload('datasets', (builder) => {
builder.orderBy('id', 'asc');
})
.withCount('datasets')
.firstOrFail();
// Check if the subject is used by multiple datasets
if (subject.$extras.datasets_count > 1) {
// If used by multiple datasets, just detach it from the current dataset
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
} else {
// If only used by this dataset, delete the subject completely
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
await subject.useTransaction(trx).delete();
}
}
}
// Process references
const references = request.input('references', []);
// First, get existing references to determine which ones to update vs. create
const existingReferences = await dataset.related('references').query();
const existingReferencesMap: Map<number, DatasetReference> = new Map(existingReferences.map((ref) => [ref.id, ref]));
for (const referenceData of references) {
if (existingReferencesMap.has(referenceData.id) && referenceData.id) {
// Update existing reference
const reference = existingReferencesMap.get(referenceData.id);
if (reference) {
reference.merge(referenceData);
if (reference.$isDirty) {
await reference.useTransaction(trx).save();
}
}
} else {
const keyword = new Subject();
keyword.fill(keywordData);
await dataset.useTransaction(trx).related('subjects').save(keyword, false);
// Create new reference
const dataReference = new DatasetReference();
dataReference.fill(referenceData);
await dataset.useTransaction(trx).related('references').save(dataReference);
}
}
// Handle references to delete if provided
const referencesToDelete = request.input('referencesToDelete', []);
for (const referenceData of referencesToDelete) {
if (referenceData.id) {
const reference = await DatasetReference.findOrFail(referenceData.id);
await reference.useTransaction(trx).delete();
}
}
@ -1269,7 +1343,7 @@ export default class DatasetController {
await dataset.useTransaction(trx).save();
await trx.commit();
console.log('Dataset and related models created successfully');
console.log('Dataset has been updated successfully');
session.flash('message', 'Dataset has been updated successfully');
// return response.redirect().toRoute('user.index');

View file

@ -3,6 +3,13 @@ import type { BodyParserConfig } from '#models/types';
import { createId } from '@paralleldrive/cuid2';
import { tmpdir } from 'node:os';
import config from '@adonisjs/core/services/config';
import Dataset from '#models/dataset';
import { TransactionClientContract } from '@adonisjs/lucid/types/database';
import Person from '#models/person';
interface Dictionary {
[index: string]: string;
}
export function sum(a: number, b: number): number {
return a + b;
@ -78,3 +85,40 @@ export function formatBytes(bytes: number): string {
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
export async function savePersons(dataset: Dataset, persons: any[], role: string, trx: TransactionClientContract) {
for (const [key, person] of persons.entries()) {
const pivotData = {
role: role,
sort_order: key + 1,
allow_email_contact: false,
...extractPivotAttributes(person), // Merge pivot attributes here
};
if (person.id !== undefined) {
await dataset
.useTransaction(trx)
.related('persons')
.attach({
[person.id]: pivotData,
});
} else {
const dataPerson = new Person();
dataPerson.fill(person);
await dataset.useTransaction(trx).related('persons').save(dataPerson, false, pivotData);
}
}
}
// Helper function to extract pivot attributes from a person object
function extractPivotAttributes(person: any) {
const pivotAttributes: Dictionary = {};
for (const key in person) {
if (key.startsWith('pivot_')) {
// pivotAttributes[key] = person[key];
const cleanKey = key.replace('pivot_', ''); // Remove 'pivot_' prefix
pivotAttributes[cleanKey] = person[key];
}
}
return pivotAttributes;
}

View file

@ -314,12 +314,137 @@ export const updateDatasetValidator = vine.compile(
}),
);
// files: schema.array([rules.minLength(1)]).members(
// schema.file({
// size: '512mb',
// extnames: ['jpg', 'gif', 'png', 'tif', 'pdf', 'zip', 'fgb', 'nc', 'qml', 'ovr', 'gpkg', 'gml', 'gpx', 'kml', 'kmz', 'json'],
// }),
// ),
export const updateEditorDatasetValidator = vine.compile(
vine.object({
// first step
language: vine
.string()
.trim()
.regex(/^[a-zA-Z0-9]+$/),
licenses: vine.array(vine.number()).minLength(1), // define at least one license for the new dataset
rights: vine.string().in(['true']),
// second step
type: vine.string().trim().minLength(3).maxLength(255),
creating_corporation: vine.string().trim().minLength(3).maxLength(255),
titles: vine
.array(
vine.object({
value: vine.string().trim().minLength(3).maxLength(255),
type: vine.enum(Object.values(TitleTypes)),
language: vine
.string()
.trim()
.minLength(2)
.maxLength(255)
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}),
)
// .minLength(2)
.arrayContainsTypes({ typeA: 'main', typeB: 'translated' }),
descriptions: vine
.array(
vine.object({
value: vine.string().trim().minLength(3).maxLength(2500),
type: vine.enum(Object.values(DescriptionTypes)),
language: vine
.string()
.trim()
.minLength(2)
.maxLength(255)
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}),
)
.arrayContainsTypes({ typeA: 'abstract', typeB: 'translated' }),
authors: vine
.array(
vine.object({
email: vine
.string()
.trim()
.maxLength(255)
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_name: vine.string().trim().minLength(3).maxLength(255),
last_name: vine.string().trim().minLength(3).maxLength(255),
}),
)
.minLength(1)
.distinct('email'),
contributors: vine
.array(
vine.object({
email: vine
.string()
.trim()
.maxLength(255)
.email()
.normalizeEmail()
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
first_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)),
}),
)
.distinct('email')
.optional(),
// third step
project_id: vine.number().optional(),
// embargo_date: schema.date.optional({ format: 'yyyy-MM-dd' }, [rules.after(10, 'days')]),
embargo_date: vine
.date({
formats: ['YYYY-MM-DD'],
})
.afterOrEqual((_field) => {
return dayjs().add(10, 'day').format('YYYY-MM-DD');
})
.optional(),
coverage: vine.object({
x_min: vine.number(),
x_max: vine.number(),
y_min: vine.number(),
y_max: vine.number(),
elevation_absolut: vine.number().positive().optional(),
elevation_min: vine.number().positive().optional().requiredIfExists('elevation_max'),
elevation_max: vine.number().positive().optional().requiredIfExists('elevation_min'),
// type: vine.enum(Object.values(DescriptionTypes)),
depth_absolut: vine.number().negative().optional(),
depth_min: vine.number().negative().optional().requiredIfExists('depth_max'),
depth_max: vine.number().negative().optional().requiredIfExists('depth_min'),
time_abolute: vine.date({ formats: { utc: true } }).optional(),
time_min: vine
.date({ formats: { utc: true } })
.beforeField('time_max')
.optional()
.requiredIfExists('time_max'),
time_max: vine
.date({ formats: { utc: true } })
.afterField('time_min')
.optional()
.requiredIfExists('time_min'),
}),
references: vine
.array(
vine.object({
value: vine.string().trim().minLength(3).maxLength(255).validateReference({ typeField: 'type' }),
type: vine.enum(Object.values(ReferenceIdentifierTypes)),
relation: vine.enum(Object.values(RelationTypes)),
label: vine.string().trim().minLength(2).maxLength(255),
}),
)
.optional(),
subjects: vine
.array(
vine.object({
value: vine.string().trim().minLength(3).maxLength(255),
// pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
language: vine.string().trim().minLength(2).maxLength(255),
}),
)
.minLength(3)
.distinct('value'),
}),
);
let messagesProvider = new SimpleMessagesProvider({
'minLength': '{{ field }} must be at least {{ min }} characters long',

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

9
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 952 KiB

1
public/site.webmanifest Normal file
View file

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View file

@ -15,9 +15,10 @@ const year = computed(() => new Date().getFullYear());
<!-- Get more with <a href="https://tailwind-vue.justboil.me/" target="_blank" class="text-blue-600">Premium
version</a> -->
</div>
<div class="md:py-3">
<div class="md:py-1">
<a href="https://www.tethys.at" target="_blank">
<JustboilLogo class="w-auto h-8 md:h-6" />
<!-- <JustboilLogo class="w-auto h-8 md:h-6" /> -->
<JustboilLogo class="w-auto h-12 md:h-10 dark:invert" />
</a>
</div>
</BaseLevel>

File diff suppressed because one or more lines are too long

View file

@ -169,13 +169,10 @@ const showAbout = async () => {
</NavBarItem>
<NavBarItem v-if="userHasRoles(['reviewer'])" :route-name="'reviewer.dataset.list'">
<NavBarItemLabel :icon="mdiGlasses" label="Reviewer Menu" />
</NavBarItem>
<!-- <NavBarItem>
<NavBarItemLabel :icon="mdiEmail" label="Messages" />
</NavBarItem> -->
<NavBarItem @click="showAbout">
</NavBarItem>
<!-- <NavBarItem @click="showAbout">
<NavBarItemLabel :icon="mdiInformationVariant" label="About" />
</NavBarItem>
</NavBarItem> -->
<BaseDivider nav-bar />
<NavBarItem @click="logout">
<NavBarItemLabel :icon="mdiLogout" label="Log Out" />

View file

@ -5,7 +5,7 @@
<div class="relative" data-te-dropdown-ref>
<button id="states-button" data-dropdown-toggle="dropdown-states"
class="whitespace-nowrap h-12 z-10 inline-flex items-center py-2.5 px-4 text-sm font-medium text-center text-gray-500 bg-gray-100 border border-gray-300 rounded-l-lg hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 dark:focus:ring-gray-700 dark:text-white dark:border-gray-600"
type="button" @click.prevent="showStates">
type="button" :disabled="isReadOnly" @click.prevent="showStates">
<!-- <svg aria-hidden="true" class="h-3 mr-2" viewBox="0 0 15 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" width="14" height="12" rx="2" fill="white" />
<mask id="mask0_12694_49953" style="mask-type: alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="15" height="12">
@ -65,7 +65,7 @@
</svg> -->
<!-- eng -->
{{ language }}
<svg aria-hidden="true" class="w-4 h-4 ml-1" fill="currentColor" viewBox="0 0 20 20"
<svg aria-hidden="true" class="w-4 h-4 ml-1" fill="currentColor" viewBox="0 0 20 20" v-if="!isReadOnly"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
@ -93,7 +93,7 @@
<!-- :class="inputElClass" -->
<!-- class="block p-2.5 w-full z-20 text-sm text-gray-900 bg-gray-50 rounded-r-lg border-l-gray-50 border-l-2 border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-l-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:border-blue-500" -->
<input v-model="computedValue" type="text" :name="props.name" autocomplete="off" :class="inputElClass"
placeholder="Search Keywords..." required @input="handleInput" />
placeholder="Search Keywords..." required @input="handleInput" :readonly="isReadOnly" />
<!-- v-model="data.search" -->
<svg class="w-4 h-4 absolute left-2.5 top-3.5" v-show="computedValue.length < 2"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -101,12 +101,12 @@
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<svg class="w-4 h-4 absolute left-2.5 top-3.5" v-show="computedValue.length >= 2"
<svg class="w-4 h-4 absolute left-2.5 top-3.5" v-show="computedValue.length >= 2 && !isReadOnly"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" @click="() => {
computedValue = '';
data.isOpen = false;
}
">
">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
@ -166,6 +166,10 @@ let props = defineProps({
type: String,
default: '',
},
isReadOnly: {
type: Boolean,
default: false,
},
required: Boolean,
borderless: Boolean,
transparent: Boolean,
@ -190,11 +194,18 @@ const inputElClass = computed(() => {
'dark:placeholder-gray-400 dark:text-white dark:focus:border-blue-500',
'h-12',
props.borderless ? 'border-0' : 'border',
props.transparent ? 'bg-transparent' : 'bg-white dark:bg-slate-800',
props.transparent && 'bg-transparent',
// props.isReadOnly ? 'bg-gray-50 dark:bg-slate-600' : 'bg-white dark:bg-slate-800',
];
// if (props.icon) {
base.push('pl-10');
if (props.isReadOnly) {
// Read-only: no focus ring, grayed-out text and border, and disabled cursor.
base.push('bg-gray-50', 'dark:bg-slate-600', 'border', 'border-gray-300', 'dark:border-slate-600', 'text-gray-500', 'cursor-not-allowed', 'focus:outline-none', 'focus:ring-0', 'focus:border-gray-300');
} else {
// Actionable field: focus ring, white/dark background, and darker border.
base.push('bg-white dark:bg-slate-800', 'focus:ring focus:outline-none', 'border', 'border-gray-700');
}
// }
return base;
});

View file

@ -12,6 +12,7 @@ import { Subject } from '@/Dataset';
// import FormField from '@/Components/FormField.vue';
import FormControl from '@/Components/FormControl.vue';
import SearchCategoryAutocomplete from '@/Components/SearchCategoryAutocomplete.vue';
import { mdiRefresh } from '@mdi/js';
const props = defineProps({
checkable: Boolean,
@ -27,6 +28,22 @@ const props = defineProps({
type: Object,
default: () => ({}),
},
subjectsToDelete: {
type: Array<Subject>,
default: [],
}
});
const emit = defineEmits(['update:subjectsToDelete']);
// Create a computed property for subjectsToDelete with getter and setter
const deletetSubjects = computed({
get: () => props.subjectsToDelete,
set: (values: Array<Subject>) => {
props.subjectsToDelete.length = 0;
props.subjectsToDelete.push(...values);
emit('update:subjectsToDelete', values);
}
});
const styleService = StyleService();
@ -58,21 +75,45 @@ const pagesList = computed(() => {
});
const removeItem = (key: number) => {
// items.value.splice(key, 1);
const item = items.value[key];
// If the item has an ID, add it to the delete list
if (item.id) {
addToDeleteList(item);
}
// Remove from the visible list
items.value.splice(key, 1);
};
// Helper function to add a subject to the delete list
const addToDeleteList = (subject: Subject) => {
if (subject.id) {
const newList = [...props.subjectsToDelete, subject];
deletetSubjects.value = newList;
}
};
// Helper function to reactivate a subject (remove from delete list)
const reactivateSubject = (index: number) => {
const newList = [...props.subjectsToDelete];
const removedSubject = newList.splice(index, 1)[0];
deletetSubjects.value = newList;
// Add the subject back to the keywords list if it's not already there
if (removedSubject && !props.keywords.some(k => k.id === removedSubject.id)) {
props.keywords.push(removedSubject);
}
};
const isKeywordReadOnly = (item: Subject) => {
return (item.dataset_count ?? 0) > 1 || item.type !== 'uncontrolled';
};
</script>
<template>
<!-- <CardBoxModal v-model="isModalActive" title="Sample modal">
<p>Lorem ipsum dolor sit amet <b>adipiscing elit</b></p>
<p>This is sample modal</p>
</CardBoxModal>
<CardBoxModal v-model="isModalDangerActive" large-title="Please confirm" button="danger" has-cancel>
<p>Lorem ipsum dolor sit amet <b>adipiscing elit</b></p>
<p>This is sample modal</p>
</CardBoxModal> -->
<!-- <div v-if="checkedRows.length" class="p-3 bg-gray-100/50 dark:bg-slate-800">
<span v-for="checkedRow in checkedRows" :key="checkedRow.id"
@ -87,17 +128,34 @@ const removeItem = (key: number) => {
<!-- <th v-if="checkable" /> -->
<!-- <th class="hidden lg:table-cell"></th> -->
<th scope="col">Type</th>
<th scope="col">Value</th>
<th scope="col" class="relative">
Value
<div class="inline-block relative ml-1 group">
<button
class="w-4 h-4 rounded-full bg-gray-200 text-gray-600 text-xs flex items-center justify-center focus:outline-none hover:bg-gray-300">
i
</button>
<div
class="absolute left-0 top-full mt-1 w-64 bg-white shadow-lg rounded-md p-3 text-xs text-left z-50 transform scale-0 origin-top-left transition-transform duration-100 group-hover:scale-100">
<p class="text-gray-700">
Keywords are only editable if they are used by a single dataset (Usage Count = 1)".
</p>
<div class="absolute -top-1 left-1 w-2 h-2 bg-white transform rotate-45"></div>
</div>
</div>
</th>
<th scope="col">Language</th>
<th scope="col">Usage Count</th>
<th scope="col" />
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in itemsPaginated" :key="index">
<td data-label="Type" scope="row">
<FormControl required v-model="item.type" @update:modelValue="() => {item.external_key = undefined; item.value= '';}" :type="'select'" placeholder="[Enter Language]" :options="props.subjectTypes">
<FormControl required v-model="item.type"
@update:modelValue="() => { item.external_key = undefined; item.value = ''; }" :type="'select'"
placeholder="[Enter Language]" :options="props.subjectTypes">
<div class="text-red-400 text-sm" v-if="errors[`subjects.${index}.type`]">
{{ errors[`subjects.${index}.type`].join(', ') }}
</div>
@ -105,22 +163,19 @@ const removeItem = (key: number) => {
</td>
<td data-label="Value" scope="row">
<SearchCategoryAutocomplete
v-if="item.type !== 'uncontrolled'"
v-model="item.value"
@subject="
(result) => {
item.language = result.language;
item.external_key = result.uri;
}
"
>
<SearchCategoryAutocomplete v-if="item.type !== 'uncontrolled'" v-model="item.value" @subject="
(result) => {
item.language = result.language;
item.external_key = result.uri;
}
" :is-read-only="item.dataset_count > 1">
<div class="text-red-400 text-sm" v-if="errors[`subjects.${index}.value`]">
{{ errors[`subjects.${index}.value`].join(', ') }}
</div>
</SearchCategoryAutocomplete>
<FormControl v-else required v-model="item.value" type="text" placeholder="[enter keyword value]" :borderless="true">
<FormControl v-else required v-model="item.value" type="text" placeholder="[enter keyword value]"
:borderless="true" :is-read-only="item.dataset_count > 1">
<div class="text-red-400 text-sm" v-if="errors[`subjects.${index}.value`]">
{{ errors[`subjects.${index}.value`].join(', ') }}
</div>
@ -128,23 +183,24 @@ const removeItem = (key: number) => {
</td>
<td data-label="Language" scope="row">
<FormControl
required
v-model="item.language"
:type="'select'"
placeholder="[Enter Lang]"
:options="{ de: 'de', en: 'en' }"
:is-read-only="item.type != 'uncontrolled'"
>
<FormControl required v-model="item.language" :type="'select'" placeholder="[Enter Lang]"
:options="{ de: 'de', en: 'en' }" :is-read-only="isKeywordReadOnly(item)">
<div class="text-red-400 text-sm" v-if="errors[`subjects.${index}.language`]">
{{ errors[`subjects.${index}.language`].join(', ') }}
</div>
</FormControl>
</td>
<td data-label="Usage Count" scope="row">
<div class="text-center">
{{ item.dataset_count || 1 }}
</div>
</td>
<td class="before:hidden lg:w-1 whitespace-nowrap" scope="row">
<BaseButtons type="justify-start lg:justify-end" no-wrap>
<!-- <BaseButton color="info" :icon="mdiEye" small @click="isModalActive = true" /> -->
<BaseButton v-if="index > 2" color="danger" :icon="mdiTrashCan" small @click.prevent="removeItem(index)" />
<BaseButton color="danger" :icon="mdiTrashCan" small @click.prevent="removeItem(index)" />
</BaseButtons>
</td>
</tr>
@ -155,15 +211,8 @@ const removeItem = (key: number) => {
<div class="p-3 lg:px-6 border-t border-gray-100 dark:border-slate-800">
<BaseLevel>
<BaseButtons>
<BaseButton
v-for="page in pagesList"
:key="page"
:active="page === currentPage"
:label="page + 1"
small
:outline="styleService.darkMode"
@click="currentPage = page"
/>
<BaseButton v-for="page in pagesList" :key="page" :active="page === currentPage" :label="page + 1" small
:outline="styleService.darkMode" @click="currentPage = page" />
</BaseButtons>
<small>Page {{ currentPageHuman }} of {{ numPages }}</small>
</BaseLevel>
@ -172,6 +221,47 @@ const removeItem = (key: number) => {
<div class="text-red-400 text-sm" v-if="errors.subjects && Array.isArray(errors.subjects)">
{{ errors.subjects.join(', ') }}
</div>
<!-- Subjects to delete section -->
<div v-if="deletetSubjects.length > 0" class="mt-8">
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-900">Keywords To Delete</h1>
<ul id="deleteSubjects" tag="ul" class="flex flex-1 flex-wrap -m-1">
<li v-for="(element, index) in deletetSubjects" :key="index"
class="block p-1 w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/6 xl:w-1/8 h-32">
<article tabindex="0"
class="bg-red-100 group w-full h-full rounded-md cursor-pointer relative shadow-sm overflow-hidden">
<section
class="flex flex-col rounded-md text-xs break-words w-full h-full z-20 absolute top-0 py-2 px-3">
<h1 class="flex-1 text-gray-700 group-hover:text-blue-800 font-medium text-sm mb-1">{{
element.value }}</h1>
<div class="flex items-center justify-between mt-auto">
<div class="flex flex-col">
<p class="p-1 size text-xs text-gray-700">
<span class="font-semibold">Type:</span> {{ element.type }}
</p>
<p class="p-1 size text-xs text-gray-700" v-if="element.dataset_count">
<span class="font-semibold">Used by:</span>
<span
class="inline-flex items-center justify-center bg-gray-200 text-gray-800 rounded-full w-5 h-5 text-xs">
{{ element.dataset_count }}
</span> datasets
</p>
</div>
<button
class="delete ml-auto focus:outline-none hover:bg-gray-300 p-1 rounded-md text-gray-800"
@click.prevent="reactivateSubject(index)">
<svg viewBox="0 0 24 24" class="w-5 h-5">
<path fill="currentColor" :d="mdiRefresh"></path>
</svg>
</button>
</div>
</section>
</article>
</li>
</ul>
</div>
</template>
<style scoped>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
// import { Head, Link, useForm } from '@inertiajs/inertia-vue3';
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import { useForm, Head } from '@inertiajs/vue3';
// import { ref } from 'vue';
// import { reactive } from 'vue';
import {
@ -126,6 +126,7 @@ const flash: Ref<any> = computed(() => {
<template>
<LayoutAuthenticated>
<Head title="Profile Security"></Head>
<SectionMain>
<SectionTitleLineWithButton :icon="mdiAccount" title="Profile" main>
<BaseButton :route-name="stardust.route('dashboard')" :icon="mdiArrowLeftBoldOutline" label="Back"

View file

@ -6,7 +6,7 @@
<!-- <SectionFullScreen v-slot="{ cardClass }" :bg="'greenBlue'"> -->
<SectionFullScreen v-slot="{ cardClass }">
<a class="text-2xl font-semibold flex justify-center items-center mb-8 lg:mb-10">
<img src="../../logo.svg" class="h-10 mr-4" alt="Windster Logo" />
<img src="../../logo.svg" class="h-10 mr-4 dark:invert" alt="Windster Logo" />
<!-- <span class="self-center text-2xl font-bold whitespace-nowrap">Tethys</span> -->
</a>

View file

@ -0,0 +1,371 @@
<template>
<LayoutAuthenticated>
<Head title="Collections"></Head>
<SectionMain>
<SectionTitleLineWithButton :icon="mdiLibraryShelves" title="Library Classification" main>
<div class="bg-lime-100 shadow rounded-lg p-6 mb-6 flex items-center justify-between">
<div>
<label for="role-select" class="block text-lg font-medium text-gray-700 mb-1">
Select Classification Role <span class="text-red-500">*</span>
</label>
<select id="role-select" v-model="selectedCollectionRole"
class="w-full border border-gray-300 rounded-md p-2 text-gray-700 focus:ring-2 focus:ring-indigo-500"
required>
<!-- <option value="" disabled selected>Please select a role</option> -->
<option v-for="collRole in collectionRoles" :key="collRole.id" :value="collRole">
{{ collRole.name }}
</option>
</select>
</div>
<div class="ml-4 hidden md:block">
<span class="text-sm text-gray-600 italic">* required</span>
</div>
</div>
</SectionTitleLineWithButton>
<!-- Available TopLevel Collections -->
<CardBox class="mb-4 rounded-lg p-4">
<h2 class="text-lg font-bold text-gray-800 dark:text-slate-400 mb-2">Available Toplevel-Collections
<span v-if="selectedCollectionRole && !selectedToplevelCollection"
class="text-sm text-red-500 italic">(click to
select)</span>
</h2>
<ul class="flex flex-wrap gap-2">
<li v-for="col in collections" :key="col.id" :class="{
'cursor-pointer p-2 border border-gray-200 rounded hover:bg-sky-50 text-sky-700 text-sm': true,
'bg-sky-100 border-sky-500': selectedToplevelCollection && selectedToplevelCollection.id === col.id
}" @click="onToplevelCollectionSelected(col)">
{{ `${col.name} (${col.number})` }}
</li>
<li v-if="collections.length === 0" class="text-gray-800 dark:text-slate-400">
No collections available.
</li>
</ul>
</CardBox>
<!-- Collections Listing -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<!-- Broader Collection (Parent) -->
<CardBox v-if="selectedCollection" class="rounded-lg p-4" has-form-data>
<h2 class="text-lg font-bold text-gray-800 dark:text-slate-400 mb-2">Broader Collection</h2>
<draggable v-if="broaderCollections.length > 0" v-model="broaderCollections"
:group="{ name: 'collections' }" tag="ul" class="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
<template #item="{ element: parent }">
<li :key="parent.id" :draggable="!parent.inUse" :class="getChildClasses(parent)"
@click="onCollectionSelected(parent)">
{{ `${parent.name} (${parent.number})` }}
</li>
</template>
</draggable>
<ul v-else class="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
<li class="text-gray-500 text-sm">
No broader collections available.
</li>
</ul>
</CardBox>
<!-- Selected Collection Details -->
<CardBox v-if="selectedCollection" class="rounded-lg p-4" has-form-data>
<h3 class="text-xl font-bold text-gray-800 dark:text-slate-400 mb-2">Selected Collection</h3>
<!-- <p :class="[
'cursor-pointer p-2 border border-gray-200 rounded text-sm',
selectedCollection.inUse ? 'bg-gray-200 text-gray-500 drag-none' : 'bg-green-50 text-green-700 hover:bg-green-100 hover:underline cursor-move'
]"></p> -->
<draggable v-model="selectedCollectionArray" :group="{ name: 'collections', pull: 'clone', put: false }" tag="ul" class="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
<template #item="{ element }">
<li :key="element.id" :class="[
'p-2 border border-gray-200 rounded text-sm',
element.inUse ? 'bg-gray-200 text-gray-500 drag-none' : 'bg-green-50 text-green-700 hover:bg-green-100 hover:underline cursor-move'
]">
{{ `${element.name} (${element.number})` }}
</li>
</template>
</draggable>
</CardBox>
<!-- Narrower Collections (Children) -->
<CardBox v-if="selectedCollection" class="rounded-lg p-4" has-form-data>
<h2 class="text-lg font-bold text-gray-800 dark:text-slate-400 mb-2">Narrower Collections</h2>
<draggable v-if="narrowerCollections.length > 0" v-model="narrowerCollections"
:group="{ name: 'collections' }" tag="ul" class="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
<template #item="{ element: child }">
<li :key="child.id" :draggable="!child.inUse" :class="getChildClasses(child)"
@click="onCollectionSelected(child)">
{{ `${child.name} (${child.number})` }}
</li>
</template>
</draggable>
<ul v-else class="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
<li class="text-gray-500 text-sm">
No sub-collections available.
</li>
</ul>
</CardBox>
</div>
<div class="mb-4 rounded-lg">
<div v-if="selectedCollection || selectedCollectionList.length > 0" class="bg-gray-100 shadow rounded-lg p-6 mb-6" :class="{ 'opacity-50': selectedCollection && selectedCollectionList.length === 0 }">
<p class="mb-4 text-gray-700">Please drag your collections here to classify your previously created
dataset
according to library classification standards.</p>
<draggable v-model="selectedCollectionList" :group="{ name: 'collections' }"
class="min-h-36 border-dashed border-2 border-gray-400 p-4 text-sm flex flex-wrap gap-2 max-h-60 overflow-y-auto"
tag="ul"
:disabled="selectedCollection === null && selectedCollectionList.length > 0"
:style="{ opacity: (selectedCollection === null && selectedCollectionList.length > 0) ? 0.5 : 1, pointerEvents: (selectedCollection === null && selectedCollectionList.length > 0) ? 'none' : 'auto' }">
<template #item="{ element }">
<div :key="element.id"
class="p-2 m-1 bg-sky-200 text-sky-800 rounded flex items-center gap-2 h-7">
<span>{{ element.name }} ({{ element.number }})</span>
<button
@click="selectedCollectionList = selectedCollectionList.filter(item => item.id !== element.id)"
class="hover:text-sky-600 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
</button>
</div>
</template>
</draggable>
</div>
</div>
<div class="p-6 border-t border-gray-100 dark:border-slate-800">
<BaseButtons>
<BaseButton @click.stop="syncDatasetCollections" label="Save" color="info" small
:disabled="isSaveDisabled" :style="{ opacity: isSaveDisabled ? 0.5 : 1 }">
</BaseButton>
</BaseButtons>
</div>
</SectionMain>
</LayoutAuthenticated>
</template>
<script setup lang="ts">
import { ref, Ref, watch, computed } from 'vue';
import { useForm } from '@inertiajs/vue3';
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
import SectionMain from '@/Components/SectionMain.vue';
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
import axios from 'axios';
import { mdiLibraryShelves } from '@mdi/js';
import draggable from 'vuedraggable';
import BaseButton from '@/Components/BaseButton.vue';
import BaseButtons from '@/Components/BaseButtons.vue';
import CardBox from '@/Components/CardBox.vue';
import { stardust } from '@eidellev/adonis-stardust/client';
import { CollectionRole, Collection } from '@/types/models';
// import CollectionRoleSelector from '@/Components/Collection/CollectionRoleSelector.vue';
// import ToplevelCollections from '@/Components/Collection/ToplevelCollections.vue';
// import CollectionHierarchy from '@/Components/Collection/CollectionHierarchy.vue';
// import CollectionDropZone from '@/Components/Collection/CollectionDropZone.vue';
/* --------------------------------------------------------------------------
Props & Reactive State
-------------------------------------------------------------------------- */
const props = defineProps({
collectionRoles: {
type: Array,
required: true,
default: () => []
},
dataset: {
type: Object,
default: () => ({}),
},
relatedCollections: {
type: Array as () => Collection[],
default: () => [] as const
}
});
const collectionRoles: Ref<CollectionRole[]> = ref(props.collectionRoles as CollectionRole[]);
const collections: Ref<Collection[]> = ref<Collection[]>([]);
const selectedCollectionRole = ref<CollectionRole | null>(null);
const selectedToplevelCollection = ref<Collection | null>(null);
const selectedCollection = ref<Collection | null>(null);
const narrowerCollections = ref<Collection[]>([]);
const broaderCollections = ref<Collection[]>([]);
// Reactive list that holds collections dropped by the user
const selectedCollectionList: Ref<Collection[]> = ref<Collection[]>([]);
// Wrap selectedCollection in an array for draggable (always expects an array)
const selectedCollectionArray = computed({
get: () => (selectedCollection.value ? [selectedCollection.value] : []),
set: (value: Collection[]) => {
selectedCollection.value = value.length ? value[0] : null
}
})
const form = useForm({
collections: [] as number[],
});
// Watch for changes in dropCollections
watch(
() => selectedCollectionList.value,
() => {
if (selectedCollection.value) {
fetchCollections(selectedCollection.value.id);
}
},
{ deep: true }
);
/* --------------------------------------------------------------------------
Watchers and Initial Setup
-------------------------------------------------------------------------- */
// If the collectionRoles prop might load asynchronously (or change), you can watch for it:
watch(
() => props.collectionRoles as CollectionRole[],
(newCollectionRoles: CollectionRole[]) => {
collectionRoles.value = newCollectionRoles;
// Preselect the role with name "ccs" if it exists
const found: CollectionRole | undefined = collectionRoles.value.find(
role => role.name.toLowerCase() === 'ccs'
);
if (found?.name === 'ccs') {
selectedCollectionRole.value = found;
}
},
{ immediate: true }
);
// When collection role changes, update available collections and clear dependent state.
watch(
() => selectedCollectionRole.value as CollectionRole,
(newSelectedCollectionRole: CollectionRole | null) => {
if (newSelectedCollectionRole != null) {
collections.value = newSelectedCollectionRole.collections || []
} else {
selectedToplevelCollection.value = null;
selectedCollection.value = null;
collections.value = []
}
// Reset dependent variables when the role changes
selectedCollection.value = null
narrowerCollections.value = []
broaderCollections.value = []
},
{ immediate: true }
);
/* --------------------------------------------------------------------------
Methods
-------------------------------------------------------------------------- */
const onToplevelCollectionSelected = (collection: Collection) => {
selectedToplevelCollection.value = collection;
selectedCollection.value = collection;
// call the API endpoint to get both.
fetchCollections(collection.id);
};
const onCollectionSelected = (collection: Collection) => {
selectedCollection.value = collection;
// call the API endpoint to get both.
fetchCollections(collection.id);
};
/**
* fetchCollections: Retrieves broader and narrower collections.
* Marks any narrower collection as inUse if it appears in selectedCollectionList.
*/
const fetchCollections = async (collectionId: number) => {
try {
const response = await axios.get(`/api/collections/${collectionId}`);
const data = response.data;
// Map each returned narrower collection
narrowerCollections.value = data.narrowerCollections.map((collection: Collection) => {
// If found, mark it as inUse.
const alreadyDropped = selectedCollectionList.value.find(dc => dc.id === collection.id);
return alreadyDropped ? { ...collection, inUse: true } : { ...collection, inUse: false };
});
broaderCollections.value = data.broaderCollection.map((collection: Collection) => {
const alreadyDropped = selectedCollectionList.value.find(dc => dc.id === collection.id);
return alreadyDropped ? { ...collection, inUse: true } : { ...collection, inUse: false };
});
// Check if selected collection is in the selected list
if (selectedCollection.value && selectedCollectionList.value.find(dc => dc.id === selectedCollection.value.id)) {
selectedCollection.value = { ...selectedCollection.value, inUse: true };
} else if (selectedCollection.value) {
selectedCollection.value = { ...selectedCollection.value, inUse: false };
}
} catch (error) {
console.error('Error in fetchCollections:', error);
}
};
const syncDatasetCollections = async () => {
// Extract the ids from the dropCollections list
form.collections = selectedCollectionList.value.map((item: Collection) => item.id);
form.put(stardust.route('editor.dataset.categorizeUpdate', [props.dataset.id]), {
preserveState: true,
onSuccess: () => {
console.log('Dataset collections synced successfully');
},
onError: (errors) => {
console.error('Error syncing dataset collections:', errors);
},
});
};
/**
* getChildClasses returns the Tailwind CSS classes to apply to each collection list item.
*/
const getChildClasses = (child: Collection) => {
return child.inUse
? 'p-2 border border-gray-200 rounded bg-gray-200 text-gray-500 cursor-pointer drag-none'
: 'p-2 border border-gray-200 rounded bg-green-50 text-green-700 cursor-move hover:bg-green-100 hover:underline'
}
// If there are related collections passed in, fill dropCollections with these.
if (props.relatedCollections && props.relatedCollections.length > 0) {
selectedCollectionList.value = props.relatedCollections;
}
// Add a computed property for the disabled state based on dropCollections length
const isSaveDisabled = computed(() => selectedCollectionList.value.length === 0);
</script>
<style scoped>
.btn-primary {
background-color: #4f46e5;
color: white;
border-radius: 0.25rem;
}
.btn-primary:hover {
background-color: #4338ca;
}
.btn-primary:focus {
outline: none;
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #4f46e5;
}
.btn-secondary {
background-color: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
}
.btn-secondary:hover {
background-color: #f9fafb;
}
.btn-secondary:focus {
outline: none;
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #6366f1;
}
</style>

View file

@ -0,0 +1,853 @@
<template>
<LayoutAuthenticated>
<Head title="Edit dataset" />
<SectionMain>
<SectionTitleLineWithButton :icon="mdiImageText" title="Update dataset" main>
<BaseButton :route-name="stardust.route('editor.dataset.list')" :icon="mdiArrowLeftBoldOutline"
label="Back" color="white" rounded-full small />
</SectionTitleLineWithButton>
<NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline">
{{ flash.message }}
</NotificationBar>
<FormValidationErrors v-bind:errors="errors" />
<CardBox :form="true">
<!-- <FormValidationErrors v-bind:errors="errors" /> -->
<div class="mb-4">
<div class="flex flex-col md:flex-row">
<!-- (1) language field -->
<FormField label="Language *" help="required: select dataset main language"
:class="{ 'text-red-400': form.errors.language }" class="w-full flex-1">
<FormControl required v-model="form.language" :type="'select'"
placeholder="[Enter Language]" :errors="form.errors.language"
:options="{ de: 'de', en: 'en' }">
<div class="text-red-400 text-sm" v-if="form.errors.language">
{{ form.errors.language.join(', ') }}
</div>
</FormControl>
</FormField>
</div>
<!-- (2) licenses -->
<FormField label="Licenses" wrap-body :class="{ 'text-red-400': form.errors.licenses }"
class="mt-8 w-full mx-2 flex-1">
<FormCheckRadioGroup type="radio" v-model="form.licenses" name="licenses" is-column
:options="licenses" />
</FormField>
<div class="flex flex-col md:flex-row">
<!-- (3) dataset_type -->
<FormField label="Dataset Type *" help="required: dataset type"
:class="{ 'text-red-400': form.errors.type }" class="w-full mx-2 flex-1">
<FormControl required v-model="form.type" :type="'select'" placeholder="-- select type --"
:errors="form.errors.type" :options="doctypes">
<div class="text-red-400 text-sm"
v-if="form.errors.type && Array.isArray(form.errors.type)">
{{ form.errors.type.join(', ') }}
</div>
</FormControl>
</FormField>
<!-- (4) creating_corporation -->
<FormField label="Creating Corporation *"
:class="{ 'text-red-400': form.errors.creating_corporation }" class="w-full mx-2 flex-1">
<FormControl required v-model="form.creating_corporation" type="text"
placeholder="[enter creating corporation]" :is-read-only="true">
<div class="text-red-400 text-sm"
v-if="form.errors.creating_corporation && Array.isArray(form.errors.creating_corporation)">
{{ form.errors.creating_corporation.join(', ') }}
</div>
</FormControl>
</FormField>
</div>
<BaseDivider />
<!-- (5) titles -->
<CardBox class="mb-6 shadow" :has-form-data="false" title="Titles" :icon="mdiFinance"
:header-icon="mdiPlusCircle" v-on:header-icon-click="addTitle()">
<div class="flex flex-col md:flex-row">
<FormField label="Main Title *" help="required: main title"
:class="{ 'text-red-400': form.errors['titles.0.value'] }" class="w-full mr-1 flex-1">
<FormControl required v-model="form.titles[0].value" type="text"
placeholder="[enter main title]" :show-char-count="true" :max-input-length="255">
<div class="text-red-400 text-sm"
v-if="form.errors['titles.0.value'] && Array.isArray(form.errors['titles.0.value'])">
{{ form.errors['titles.0.value'].join(', ') }}
</div>
</FormControl>
</FormField>
<FormField label="Main Title Language*" help="required: main title language"
:class="{ 'text-red-400': form.errors['titles.0.language'] }"
class="w-full ml-1 flex-1">
<FormControl required v-model="form.titles[0].language" type="text"
:is-read-only="true">
<div class="text-red-400 text-sm"
v-if="form.errors['titles.0.language'] && Array.isArray(form.errors['titles.0.language'])">
{{ form.errors['titles.0.language'].join(', ') }}
</div>
</FormControl>
</FormField>
</div>
<label v-if="form.titles.length > 1">additional titles </label>
<!-- <BaseButton :icon="mdiPlusCircle" @click.prevent="addTitle()" color="modern" rounded-full small /> -->
<table>
<thead>
<tr>
<!-- <th v-if="checkable" /> -->
<th>Title Value</th>
<th>Title Type</th>
<th>Title Language</th>
<th />
</tr>
</thead>
<tbody>
<template v-for="(title, index) in form.titles" :key="index">
<tr v-if="title.type != 'Main'">
<!-- <td scope="row">{{ index + 1 }}</td> -->
<td data-label="Title Value">
<FormControl required v-model="form.titles[index].value" type="text"
placeholder="[enter main title]">
<div class="text-red-400 text-sm"
v-if="form.errors[`titles.${index}.value`]">
{{ form.errors[`titles.${index}.value`].join(', ') }}
</div>
</FormControl>
</td>
<td data-label="Title Type">
<FormControl required v-model="form.titles[index].type" type="select"
:options="titletypes" placeholder="[select title type]">
<div class="text-red-400 text-sm"
v-if="Array.isArray(form.errors[`titles.${index}.type`])">
{{ form.errors[`titles.${index}.type`].join(', ') }}
</div>
</FormControl>
</td>
<td data-label="Title Language">
<FormControl required v-model="form.titles[index].language" type="select"
:options="{ de: 'de', en: 'en' }" placeholder="[select title language]">
<div class="text-red-400 text-sm"
v-if="form.errors[`titles.${index}.language`]">
{{ form.errors[`titles.${index}.language`].join(', ') }}
</div>
</FormControl>
</td>
<td class="before:hidden lg:w-1 whitespace-nowrap">
<BaseButtons type="justify-start lg:justify-end" no-wrap>
<!-- <BaseButton color="info" :icon="mdiEye" small @click="isModalActive = true" /> -->
<BaseButton color="danger" :icon="mdiTrashCan" small
v-if="title.id == undefined" @click.prevent="removeTitle(index)" />
</BaseButtons>
</td>
</tr>
</template>
</tbody>
</table>
</CardBox>
<!-- (6) descriptions -->
<CardBox class="mb-6 shadow" :has-form-data="false" title="Descriptions" :icon="mdiFinance"
:header-icon="mdiPlusCircle" v-on:header-icon-click="addDescription()">
<div class="flex flex-col md:flex-row">
<FormField label="Main Abstract *" help="required: main abstract"
:class="{ 'text-red-400': form.errors['descriptions.0.value'] }"
class="w-full mr-1 flex-1">
<FormControl required v-model="form.descriptions[0].value" type="textarea"
placeholder="[enter main abstract]" :show-char-count="true"
:max-input-length="2500">
<div class="text-red-400 text-sm"
v-if="form.errors['descriptions.0.value'] && Array.isArray(form.errors['descriptions.0.value'])">
{{ form.errors['descriptions.0.value'].join(', ') }}
</div>
</FormControl>
</FormField>
<FormField label="Main Title Language*" help="required: main abstract language"
:class="{ 'text-red-400': form.errors['descriptions.0.language'] }"
class="w-full ml-1 flex-1">
<FormControl required v-model="form.descriptions[0].language" type="text"
:is-read-only="true">
<div class="text-red-400 text-sm" v-if="form.errors['descriptions.0.value'] && Array.isArray(form.errors['descriptions.0.language'])
">
{{ form.errors['descriptions.0.language'].join(', ') }}
</div>
</FormControl>
</FormField>
</div>
<table>
<thead>
<tr>
<!-- <th v-if="checkable" /> -->
<th>Title Value</th>
<th>Title Type</th>
<th>Title Language</th>
<th />
</tr>
</thead>
<tbody>
<template v-for="(item, index) in form.descriptions" :key="index">
<tr v-if="item.type != 'Abstract'">
<!-- <td scope="row">{{ index + 1 }}</td> -->
<td data-label="Description Value">
<FormControl required v-model="form.descriptions[index].value" type="text"
placeholder="[enter main title]">
<div class="text-red-400 text-sm"
v-if="form.errors[`descriptions.${index}.value`]">
{{ form.errors[`descriptions.${index}.value`].join(', ') }}
</div>
</FormControl>
</td>
<td data-label="Description Type">
<FormControl required v-model="form.descriptions[index].type" type="select"
:options="descriptiontypes" placeholder="[select title type]">
<div class="text-red-400 text-sm"
v-if="Array.isArray(form.errors[`descriptions.${index}.type`])">
{{ form.errors[`descriptions.${index}.type`].join(', ') }}
</div>
</FormControl>
</td>
<td data-label="Description Language">
<FormControl required v-model="form.descriptions[index].language"
type="select" :options="{ de: 'de', en: 'en' }"
placeholder="[select title language]">
<div class="text-red-400 text-sm"
v-if="form.errors[`descriptions.${index}.language`]">
{{ form.errors[`descriptions.${index}.language`].join(', ') }}
</div>
</FormControl>
</td>
<td class="before:hidden lg:w-1 whitespace-nowrap">
<BaseButtons type="justify-start lg:justify-end" no-wrap>
<!-- <BaseButton color="info" :icon="mdiEye" small @click="isModalActive = true" /> -->
<BaseButton color="danger" :icon="mdiTrashCan" small
v-if="item.id == undefined"
@click.prevent="removeDescription(index)" />
</BaseButtons>
</td>
</tr>
</template>
</tbody>
</table>
</CardBox>
<!-- (7) authors -->
<CardBox class="mb-6 shadow" has-table title="Creators" :icon="mdiBookOpenPageVariant">
<SearchAutocomplete source="/api/persons" :response-property="'first_name'"
placeholder="search in person table...." v-on:person="onAddAuthor"></SearchAutocomplete>
<TablePersons :persons="form.authors" v-if="form.authors.length > 0" :relation="'authors'" />
<div class="text-red-400 text-sm"
v-if="form.errors.authors && Array.isArray(form.errors.authors)">
{{ form.errors.authors.join(', ') }}
</div>
</CardBox>
<!-- (8) contributors -->
<CardBox class="mb-6 shadow" has-table title="Contributors" :icon="mdiBookOpenPageVariant">
<SearchAutocomplete source="/api/persons" :response-property="'first_name'"
placeholder="search in person table...." v-on:person="onAddContributor">
</SearchAutocomplete>
<TablePersons :persons="form.contributors" v-if="form.contributors.length > 0"
:contributortypes="contributorTypes" :errors="form.errors" :relation="'contributors'" />
<div class="text-red-400 text-sm"
v-if="form.errors.contributors && Array.isArray(form.errors.contributors)">
{{ form.errors.contributors.join(', ') }}
</div>
</CardBox>
<div class="flex flex-col md:flex-row">
<!-- (9) project_id -->
<FormField label="Project.." help="project is optional"
:class="{ 'text-red-400': form.errors.project_id }" class="w-full mx-2 flex-1">
<FormControl required v-model="form.project_id" :type="'select'"
placeholder="[Select Project]" :errors="form.errors.project_id" :options="projects">
<div class="text-red-400 text-sm" v-if="form.errors.project_id">
{{ form.errors.project_id.join(', ') }}
</div>
</FormControl>
</FormField>
<!-- (10) embargo_date -->
<FormField label="Embargo Date.." help="embargo date is optional"
:class="{ 'text-red-400': form.errors.embargo_date }" class="w-full mx-2 flex-1">
<FormControl v-model="form.embargo_date" :type="'date'" placeholder="date('y-m-d')"
:errors="form.errors.embargo_date">
<div class="text-red-400 text-sm" v-if="form.errors.embargo_date">
{{ form.errors.embargo_date.join(', ') }}
</div>
</FormControl>
</FormField>
</div>
<BaseDivider />
<MapComponent v-if="form.coverage" :mapOptions="mapOptions" :baseMaps="baseMaps"
:fitBounds="fitBounds" :coverage="form.coverage" :mapId="mapId"
v-bind-event:onMapInitializedEvent="onMapInitialized">
</MapComponent>
<div class="flex flex-col md:flex-row">
<!-- x min and max -->
<FormField label="Coverage X Min" :class="{ 'text-red-400': form.errors['coverage.x_min'] }"
class="w-full mx-2 flex-1">
<FormControl required v-model="form.coverage.x_min" type="text" placeholder="[enter x_min]">
<div class="text-red-400 text-sm"
v-if="form.errors['coverage.x_min'] && Array.isArray(form.errors['coverage.x_min'])">
{{ form.errors['coverage.x_min'].join(', ') }}
</div>
</FormControl>
</FormField>
<FormField label="Coverage X Max" :class="{ 'text-red-400': form.errors['coverage.x_max'] }"
class="w-full mx-2 flex-1">
<FormControl required v-model="form.coverage.x_max" type="text" placeholder="[enter x_max]">
<div class="text-red-400 text-sm"
v-if="form.errors['coverage.x_max'] && Array.isArray(form.errors['coverage.x_max'])">
{{ form.errors['coverage.x_max'].join(', ') }}
</div>
</FormControl>
</FormField>
<!-- y min and max -->
<FormField label="Coverage Y Min" :class="{ 'text-red-400': form.errors['coverage.y_min'] }"
class="w-full mx-2 flex-1">
<FormControl required v-model="form.coverage.y_min" type="text" placeholder="[enter y_min]">
<div class="text-red-400 text-sm"
v-if="form.errors['coverage.y_min'] && Array.isArray(form.errors['coverage.y_min'])">
{{ form.errors['coverage.y_min'].join(', ') }}
</div>
</FormControl>
</FormField>
<FormField label="Coverage Y Max" :class="{ 'text-red-400': form.errors['coverage.y_max'] }"
class="w-full mx-2 flex-1">
<FormControl required v-model="form.coverage.y_max" type="text" placeholder="[enter y_max]">
<div class="text-red-400 text-sm"
v-if="form.errors['coverage.y_max'] && Array.isArray(form.errors['coverage.y_max'])">
{{ form.errors['coverage.y_max'].join(', ') }}
</div>
</FormControl>
</FormField>
</div>
<CardBox class="mb-6 shadow" has-table title="Dataset References" :icon="mdiEarthPlus"
:header-icon="mdiPlusCircle" v-on:header-icon-click="addReference()">
<!-- Message when no references exist -->
<div v-if="form.references.length === 0" class="text-center py-4">
<p class="text-gray-600">No references added yet.</p>
<p class="text-gray-400">Click the plus icon above to add a new reference.</p>
</div>
<!-- Reference form -->
<table class="table-fixed border-green-900" v-if="form.references.length">
<thead>
<tr>
<th class="w-4/12">Value</th>
<th class="w-2/12">Type</th>
<th class="w-3/12">Relation</th>
<th class="w-2/12">Label</th>
<th class="w-1/12"></th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in form.references">
<td data-label="Reference Value">
<!-- <input name="Reference Value" class="form-control"
placeholder="[VALUE]" v-model="item.value" /> -->
<FormControl required v-model="item.value" :type="'text'" placeholder="[VALUE]"
:errors="form.errors.embargo_date">
<div class="text-red-400 text-sm"
v-if="form.errors[`references.${index}.value`] && Array.isArray(form.errors[`references.${index}.value`])">
{{ form.errors[`references.${index}.value`].join(', ') }}
</div>
</FormControl>
</td>
<td>
<FormControl required v-model="form.references[index].type" type="select"
:options="referenceIdentifierTypes" placeholder="[type]">
<div class="text-red-400 text-sm"
v-if="Array.isArray(form.errors[`references.${index}.type`])">
{{ form.errors[`references.${index}.type`].join(', ') }}
</div>
</FormControl>
</td>
<td>
<!-- {!! Form::select('Reference[Relation]', $relationTypes, null,
['placeholder' => '[relationType]', 'v-model' => 'item.relation',
'data-vv-scope' => 'step-2'])
!!} -->
<FormControl required v-model="form.references[index].relation" type="select"
:options="relationTypes" placeholder="[relation type]">
<div class="text-red-400 text-sm"
v-if="Array.isArray(form.errors[`references.${index}.relation`])">
{{ form.errors[`references.${index}.relation`].join(', ') }}
</div>
</FormControl>
</td>
<td data-label="Reference Label">
<!-- <input name="Reference Label" class="form-control" v-model="item.label" /> -->
<FormControl required v-model="form.references[index].label" type="text"
placeholder="[reference label]">
<div class="text-red-400 text-sm"
v-if="form.errors[`references.${index}.label`]">
{{ form.errors[`references.${index}.label`].join(', ') }}
</div>
</FormControl>
</td>
<td class="before:hidden lg:w-1 whitespace-nowrap">
<!-- <BaseButton color="info" :icon="mdiEye" small @click="isModalActive = true" /> -->
<BaseButton color="danger" :icon="mdiTrashCan" small
@click.prevent="removeReference(index)" />
</td>
</tr>
</tbody>
</table>
<!-- References to delete section -->
<div v-if="form.referencesToDelete && form.referencesToDelete.length > 0" class="mt-8">
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-900">References To Delete</h1>
<ul class="flex flex-1 flex-wrap -m-1">
<li v-for="(element, index) in form.referencesToDelete" :key="index"
class="block p-1 w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/6 xl:w-1/8 h-40">
<article tabindex="0"
class="bg-red-100 group w-full h-full rounded-md cursor-pointer relative shadow-sm overflow-hidden">
<section
class="flex flex-col rounded-md text-xs break-words w-full h-full z-20 absolute top-0 py-2 px-3">
<h1 class="flex-1 text-gray-700 group-hover:text-blue-800 font-medium text-sm mb-1 truncate overflow-hidden whitespace-nowrap">
{{ element.value }}
</h1>
<div class="flex flex-col mt-auto">
<p class="p-1 size text-xs text-gray-700">
<span class="font-semibold">Type:</span> {{ element.type }}
</p>
<p class="p-1 size text-xs text-gray-700">
<span class="font-semibold">Relation:</span> {{ element.relation }}
</p>
<div class="flex justify-end mt-1">
<button
class="restore ml-auto focus:outline-none hover:bg-gray-300 p-1 rounded-md text-gray-800"
@click.prevent="restoreReference(index)">
<svg viewBox="0 0 24 24" class="w-5 h-5">
<path fill="currentColor" :d="mdiRestore"></path>
</svg>
</button>
</div>
</div>
</section>
</article>
</li>
</ul>
</div>
</CardBox>
<BaseDivider />
<CardBox class="mb-6 shadow" has-table title="Dataset Keywords" :icon="mdiEarthPlus"
:header-icon="mdiPlusCircle" v-on:header-icon-click="addKeyword()">
<!-- <ul>
<li v-for="(subject, index) in form.subjects" :key="index">
{{ subject.value }} <BaseButton color="danger" :icon="mdiTrashCan" small @click.prevent="removeKeyword(index)" />
</li>
</ul> -->
<TableKeywords :keywords="form.subjects" :errors="form.errors" :subjectTypes="subjectTypes" v-model:subjects-to-delete="form.subjectsToDelete"
v-if="form.subjects.length > 0" />
</CardBox>
</div>
<!-- download file list -->
<div class="mb-6">
<h3 class="text-lg font-medium text-gray-700 mb-2 flex items-center">
Files
<div class="inline-block relative ml-2 group">
<button
class="w-5 h-5 rounded-full bg-gray-200 text-gray-600 text-xs flex items-center justify-center focus:outline-none hover:bg-gray-300">
i
</button>
<div
class="absolute left-0 top-full mt-1 w-64 bg-white shadow-lg rounded-md p-3 text-xs text-left z-50 transform scale-0 origin-top-left transition-transform duration-100 group-hover:scale-100">
<p class="text-gray-700">
As a research data repository editor, you can only download the submitted files.
Files cannot be
edited or replaced at this stage.
</p>
<div class="absolute -top-1 left-1 w-2 h-2 bg-white transform rotate-45"></div>
</div>
</div>
</h3>
<div v-if="form.files && form.files.length > 0" class="bg-white rounded-lg shadow overflow-hidden">
<ul class="divide-y divide-gray-200">
<li v-for="file in form.files" :key="file.id"
class="px-4 py-3 flex items-center justify-between hover:bg-gray-50">
<div class="flex items-center space-x-3 flex-1">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-gray-400" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 truncate">
{{ file.label }}
</p>
<p class="text-xs text-gray-500 truncate">
{{ getFileSize(file) }}
</p>
</div>
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<a v-if="file.id != undefined"
:href="stardust.route('editor.file.download', [file.id])"
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-blue-700 bg-blue-100 hover:bg-blue-200">
Download
</a>
</div>
</li>
</ul>
</div>
</div>
<template #footer>
<BaseButtons>
<BaseButton @click.stop="submit" :disabled="form.processing" label="Save" color="info"
:class="{ 'opacity-25': form.processing }" small>
</BaseButton>
<!-- <button :disabled="form.processing" :class="{ 'opacity-25': form.processing }"
class="text-base hover:scale-110 focus:outline-none flex justify-center px-4 py-2 rounded font-bold cursor-pointer hover:bg-teal-200 bg-teal-100 text-teal-700 border duration-200 ease-in-out border-teal-600 transition"
@click.stop="submit">
Save
</button> -->
</BaseButtons>
</template>
</CardBox>
<!-- Loading Spinner -->
<div v-if="form.processing"
class="fixed inset-0 flex items-center justify-center bg-gray-500 bg-opacity-50 z-50">
<svg class="animate-spin h-12 w-12 text-white" xmlns="http://www.w3.org/2000/svg" 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="M12 2a10 10 0 0110 10h-4a6 6 0 00-6-6V2z"></path>
</svg>
</div>
</SectionMain>
</LayoutAuthenticated>
</template>
<script setup lang="ts">
// import EditComponent from "./../EditComponent";
// export default EditComponent;
// import { Component, Vue, Prop, Setup, toNative } from 'vue-facing-decorator';
// import AuthLayout from '@/Layouts/Auth.vue';
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
import { useForm, Head, usePage } from '@inertiajs/vue3';
import { computed, ComputedRef } from 'vue';
import { Dataset, Title, Subject, Person, License } from '@/Dataset';
import { stardust } from '@eidellev/adonis-stardust/client';
import FormField from '@/Components/FormField.vue';
import FormControl from '@/Components/FormControl.vue';
import SectionMain from '@/Components/SectionMain.vue';
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
import FormCheckRadioGroup from '@/Components/FormCheckRadioGroup.vue';
import BaseButton from '@/Components/BaseButton.vue';
import BaseButtons from '@/Components/BaseButtons.vue';
import BaseDivider from '@/Components/BaseDivider.vue';
import CardBox from '@/Components/CardBox.vue';
import MapComponent from '@/Components/Map/map.component.vue';
import SearchAutocomplete from '@/Components/SearchAutocomplete.vue';
import TablePersons from '@/Components/TablePersons.vue';
import TableKeywords from '@/Components/TableKeywords.vue';
import FormValidationErrors from '@/Components/FormValidationErrors.vue';
import { MapOptions } from '@/Components/Map/MapOptions';
import { LatLngBoundsExpression } from 'leaflet';
import { LayerOptions } from '@/Components/Map/LayerOptions';
import {
mdiImageText,
mdiArrowLeftBoldOutline,
mdiPlusCircle,
mdiFinance,
mdiTrashCan,
mdiBookOpenPageVariant,
mdiEarthPlus,
mdiAlertBoxOutline,
mdiRestore
} from '@mdi/js';
import { notify } from '@/notiwind';
import NotificationBar from '@/Components/NotificationBar.vue';
const props = defineProps({
// errors: {
// type: Object,
// default: () => ({}),
// },
licenses: {
type: Object,
default: () => ({}),
},
languages: {
type: Object,
default: () => ({}),
},
doctypes: {
type: Object,
default: () => ({}),
},
titletypes: {
type: Object,
default: () => ({}),
},
projects: {
type: Object,
default: () => ({}),
},
descriptiontypes: {
type: Object,
default: () => ({}),
},
contributorTypes: {
type: Object,
default: () => ({}),
},
subjectTypes: {
type: Object,
default: () => ({}),
},
referenceIdentifierTypes: {
type: Object,
default: () => ({}),
},
relationTypes: {
type: Object,
default: () => ({}),
},
dataset: {
type: Object,
default: () => ({}),
},
});
const flash: ComputedRef<any> = computed(() => {
return usePage().props.flash;
});
const errors: ComputedRef<any> = computed(() => {
return usePage().props.errors;
});
const mapOptions: MapOptions = {
center: [48.208174, 16.373819],
zoom: 3,
zoomControl: false,
attributionControl: false,
};
const baseMaps: Map<string, LayerOptions> = new Map<string, LayerOptions>();
const fitBounds: LatLngBoundsExpression = [
[46.4318173285, 9.47996951665],
[49.0390742051, 16.9796667823],
];
const mapId = 'test';
// const downloadFile = async (id: string): Promise<string> => {
// const response = await axios.get<Blob>(`/api/download/${id}`, {
// responseType: 'blob',
// });
// const url = URL.createObjectURL(response.data);
// setTimeout(() => {
// URL.revokeObjectURL(url);
// }, 1000);
// return url;
// };
// for (const file of props.dataset.files) {
// // console.log(`${file.name} path is ${file.filePath} here.`);
// file.fileSrc = ref("");
// // downloadFile(file.id).then((value: string) => {
// // file.fileSrc = ref(value);
// // form = useForm<Dataset>(props.dataset as Dataset);
// // });
// }
// props.dataset.filesToDelete = [];
props.dataset.subjectsToDelete = [];
props.dataset.referencesToDelete = [];
let form = useForm<Dataset>(props.dataset as Dataset);
// const mainService = MainService();
// mainService.fetchfiles(props.dataset);
const submit = async (): Promise<void> => {
let route = stardust.route('editor.dataset.update', [props.dataset.id]);
// await Inertia.post('/app/register', this.form);
// await router.post('/app/register', this.form);
let licenses = form.licenses.map((obj) => {
if (hasIdAttribute(obj)) {
return obj.id.toString()
} else {
return obj;
}
});
await form
.transform((data) => ({
...data,
licenses: licenses,
rights: 'true',
}))
// .put(route);
.put(route, {
onSuccess: () => {
// console.log(form.data());
// mainService.setDataset(form.data());
// formStep.value++;
// form.filesToDelete = [];
// Clear the array using splice
form.subjectsToDelete?.splice(0, form.subjectsToDelete.length);
form.referencesToDelete?.splice(0, form.referencesToDelete.length);
},
});
};
const hasIdAttribute = (obj: License | number): obj is License => {
return typeof obj === 'object' && 'id' in obj;
};
const addTitle = () => {
let newTitle: Title = { value: '', language: '', type: '' };
form.titles.push(newTitle);
};
const removeTitle = (key: any) => {
form.titles.splice(key, 1);
};
const addDescription = () => {
let newDescription = { value: '', language: '', type: '' };
form.descriptions.push(newDescription);
};
const removeDescription = (key: any) => {
form.descriptions.splice(key, 1);
};
const onAddAuthor = (person: Person) => {
if (form.authors.filter((e) => e.id === person.id).length > 0) {
notify({ type: 'warning', title: 'Warning', text: 'person is already defined as author' }, 4000);
} else if (form.contributors.filter((e) => e.id === person.id).length > 0) {
notify({ type: 'warning', title: 'Warning', text: 'person is already defined as contributor' });
} else {
form.authors.push(person);
notify({ type: 'info', text: 'person has been successfully added as author' });
}
};
const onAddContributor = (person: Person) => {
if (form.contributors.filter((e) => e.id === person.id).length > 0) {
notify({ type: 'warning', title: 'Warning', text: 'person is already defined as contributor' }, 4000);
} else if (form.authors.filter((e) => e.id === person.id).length > 0) {
notify({ type: 'warning', title: 'Warning', text: 'person is already defined as author' }, 4000);
} else {
// person.pivot = { contributor_type: '' };
// // person.pivot = { name_type: '', contributor_type: '' };
form.contributors.push(person);
notify({ type: 'info', text: 'person has been successfully added as contributor' }, 4000);
}
};
const addKeyword = () => {
let newSubject: Subject = { value: '', language: '', type: 'uncontrolled' };
//this.dataset.files.push(uploadedFiles[i]);
form.subjects.push(newSubject);
};
const addReference = () => {
let newReference = { value: '', label: '', relation: '', type: '' };
//this.dataset.files.push(uploadedFiles[i]);
form.references.push(newReference);
};
const removeReference = (key: any) => {
const reference = form.references[key];
// If the reference has an ID, it exists in the database
// and should be added to referencesToDelete
if (reference.id) {
// Initialize referencesToDelete array if it doesn't exist
if (!form.referencesToDelete) {
form.referencesToDelete = [];
}
// Add to referencesToDelete
form.referencesToDelete.push(reference);
}
// Remove from form.references array
form.references.splice(key, 1);
};
const restoreReference = (index: number) => {
// Get the reference from referencesToDelete
const reference = form.referencesToDelete[index];
// Add it back to form.references
form.references.push(reference);
// Remove it from referencesToDelete
form.referencesToDelete.splice(index, 1);
};
const onMapInitialized = (newItem: any) => {
console.log(newItem);
};
const getFileSize = (file: File) => {
if (file.size > 1024) {
if (file.size > 1048576) {
return Math.round(file.size / 1048576) + 'mb';
} else {
return Math.round(file.size / 1024) + 'kb';
}
} else {
return file.size + 'b';
}
}
</script>
<style scoped>
.max-w-2xl {
max-width: 2xl;
}
.text-2xl {
font-size: 2xl;
}
.font-bold {
font-weight: bold;
}
.mb-4 {
margin-bottom: 1rem;
}
.block {
display: block;
}
.text-gray-700 {
color: #4b5563;
}
.shadow {
box-shadow:
0 0 0 1px rgba(66, 72, 78, 0.05),
0 1px 2px 0 rgba(66, 72, 78, 0.08),
0 2px 4px 0 rgba(66, 72, 78, 0.12),
0 4px 8px 0 rgba(66, 72, 78, 0.16);
}
</style>

View file

@ -2,7 +2,7 @@
// import { Head, Link, useForm, usePage } from '@inertiajs/inertia-vue3';
import { Head, usePage } from '@inertiajs/vue3';
import { ComputedRef } from 'vue';
import { mdiSquareEditOutline, mdiAlertBoxOutline, mdiShareVariant, mdiBookEdit, mdiUndo } from '@mdi/js';
import { mdiSquareEditOutline, mdiAlertBoxOutline, mdiShareVariant, mdiBookEdit, mdiUndo, mdiLibraryShelves } from '@mdi/js';
import { computed } from 'vue';
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
import SectionMain from '@/Components/SectionMain.vue';
@ -96,8 +96,8 @@ const formatServerState = (state: string) => {
<Head title="Editor Datasets" />
<SectionMain>
<NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline">
{{ flash.message }}
</NotificationBar>
@ -108,6 +108,7 @@ const formatServerState = (state: string) => {
{{ flash.error }}
</NotificationBar>
<!-- table -->
<CardBox class="mb-6" has-table>
<div v-if="props.datasets.data.length > 0">
@ -141,12 +142,14 @@ const formatServerState = (state: string) => {
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="dataset in props.datasets.data" :key="dataset.id"
:class="[getRowClass(dataset)]">
<td data-label="Login" class="py-4 whitespace-nowrap text-gray-700 dark:text-white">
<!-- <Link v-bind:href="stardust.route('user.show', [user.id])"
<td data-label="Login"
class="py-4 whitespace-nowrap text-gray-700 dark:text-white table-title">
<!-- <Link v-bind:href="stardust.route('settings.user.show', [user.id])"
class="no-underline hover:underline text-cyan-600 dark:text-cyan-400">
{{ user.login }}
</Link> -->
<div class="text-sm font-medium">{{ dataset.main_title }}</div>
<!-- {{ user.id }} -->
{{ dataset.main_title }}
</td>
<td class="py-4 whitespace-nowrap text-gray-700 dark:text-white">
<div class="text-sm">{{ dataset.user.login }}</div>
@ -178,33 +181,46 @@ const formatServerState = (state: string) => {
</td>
<td
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>
<BaseButton v-if="can.receive && (dataset.server_state == 'released')"
:route-name="stardust.route('editor.dataset.receive', [dataset.id])"
color="info" :icon="mdiSquareEditOutline" :label="'Receive edit task'"
small />
<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')"
:route-name="stardust.route('editor.dataset.receive', [dataset.id])"
color="info" :icon="mdiSquareEditOutline" :label="'Receive edit task'"
small class="col-span-1"/>
<BaseButton
v-if="can.approve && (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer')"
:route-name="stardust.route('editor.dataset.approve', [dataset.id])"
color="info" :icon="mdiShareVariant" :label="'Approve'" small />
<BaseButton
v-if="can.approve && (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer')"
:route-name="stardust.route('editor.dataset.approve', [dataset.id])"
color="info" :icon="mdiShareVariant" :label="'Approve'" small class="col-span-1"/>
<BaseButton
v-if="can.approve && (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer')"
:route-name="stardust.route('editor.dataset.reject', [dataset.id])"
color="info" :icon="mdiUndo" label="Reject" small>
</BaseButton>
<BaseButton
v-if="can.approve && (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer')"
:route-name="stardust.route('editor.dataset.reject', [dataset.id])"
color="info" :icon="mdiUndo" label="'Reject'" small class="col-span-1">
</BaseButton>
<BaseButton v-if="can.publish && (dataset.server_state == 'reviewed')"
:route-name="stardust.route('editor.dataset.publish', [dataset.id])"
color="info" :icon="mdiBookEdit" :label="'Publish'" small />
<BaseButton
v-if="can.edit && (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer')"
:route-name="stardust.route('editor.dataset.edit', [Number(dataset.id)])"
color="info" :icon="mdiSquareEditOutline" :label="'Edit'" small class="col-span-1">
</BaseButton>
<BaseButton
v-if="can.publish && (dataset.server_state == 'published' && !dataset.identifier)"
:route-name="stardust.route('editor.dataset.doi', [dataset.id])"
color="info" :icon="mdiBookEdit" :label="'Mint DOI'" small />
<BaseButton
v-if="can.edit && (dataset.server_state == 'editor_accepted' || dataset.server_state == 'rejected_reviewer')"
:route-name="stardust.route('editor.dataset.categorize', [dataset.id])"
color="info" :icon="mdiLibraryShelves" :label="'Sets'" small class="col-span-1">
</BaseButton>
</BaseButtons>
<BaseButton v-if="can.publish && (dataset.server_state == 'reviewed')"
:route-name="stardust.route('editor.dataset.publish', [dataset.id])"
color="info" :icon="mdiBookEdit" :label="'Publish'" small class="col-span-1"/>
<BaseButton
v-if="can.publish && (dataset.server_state == 'published' && !dataset.identifier)"
:route-name="stardust.route('editor.dataset.doi', [dataset.id])"
color="info" :icon="mdiBookEdit" :label="'Mint DOI'" small class="col-span-1 last-in-row"/>
</div>
</td>
</tr>
</tbody>
@ -231,3 +247,18 @@ const formatServerState = (state: string) => {
</SectionMain>
</LayoutAuthenticated>
</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

@ -295,7 +295,7 @@ const fetchCollections = async (collectionId: number) => {
return alreadyDropped ? { ...collection, inUse: true } : { ...collection, inUse: false };
});
// Check if selected collection is in the selected list
if (selectedCollection.value && selectedCollectionList.value.find(dc => dc.id === selectedCollection.value.id)) {
if (selectedCollection.value && selectedCollectionList.value.find(dc => dc.id === selectedCollection.value?.id)) {
selectedCollection.value = { ...selectedCollection.value, inUse: true };
} else if (selectedCollection.value) {
selectedCollection.value = { ...selectedCollection.value, inUse: false };

View file

@ -544,15 +544,15 @@ Removes a selected keyword
<div class="flex items-center">
<!-- <label>{{ form.titles[0].language }}</label>
<label>{{ form.language }}</label> -->
<icon-wizard :is-current="formStep == 1" :is-checked="formStep > 1" :label="'Language'">
<icon-wizard :is-current="formStep == 1" :is-checked="formStep > 1" :label="'Step 1'">
<icon-language></icon-language>
</icon-wizard>
<icon-wizard :is-current="formStep == 2" :is-checked="formStep > 2" :label="'Mandatory'">
<icon-wizard :is-current="formStep == 2" :is-checked="formStep > 2" :label="'Step 2'">
<icon-mandatory></icon-mandatory>
</icon-wizard>
<icon-wizard :is-current="formStep == 3" :is-checked="formStep > 3" :label="'Recommended'">
<icon-wizard :is-current="formStep == 3" :is-checked="formStep > 3" :label="'Step 3'">
<icon-recommendet></icon-recommendet>
</icon-wizard>
@ -588,7 +588,7 @@ Removes a selected keyword
<input class="form-checkbox" name="rights" id="rights" type="checkbox" v-model="dataset.rights" />
terms and conditions
</label> -->
<FormField label="Rights" help="You must agree to continue" wrap-body
<FormField label="Rights" help="You must agree that you have read the Terms and Conditions. Please click on the 'i' icon to find a read the policy" wrap-body
:class="{ 'text-red-400': form.errors.rights }" class="mt-8 w-full mx-2 flex-1 flex-col">
<label for="rights" class="checkbox mr-6 mb-3 last:mr-0">
<input type="checkbox" id="rights" required v-model="form.rights" />

View file

@ -42,7 +42,8 @@
<!-- (2) licenses -->
<FormField label="Licenses" wrap-body :class="{ 'text-red-400': form.errors.licenses }"
class="mt-8 w-full mx-2 flex-1">
<FormCheckRadioGroup type="radio" v-model="form.licenses" name="licenses" is-column :options="licenses" />
<FormCheckRadioGroup type="radio" v-model="form.licenses" name="licenses" is-column
:options="licenses" />
</FormField>
<div class="flex flex-col md:flex-row">
@ -163,7 +164,8 @@
:class="{ 'text-red-400': form.errors['descriptions.0.value'] }"
class="w-full mr-1 flex-1">
<FormControl required v-model="form.descriptions[0].value" type="textarea"
placeholder="[enter main abstract]" :show-char-count="true" :max-input-length="2500">
placeholder="[enter main abstract]" :show-char-count="true"
:max-input-length="2500">
<div class="text-red-400 text-sm"
v-if="form.errors['descriptions.0.value'] && Array.isArray(form.errors['descriptions.0.value'])">
{{ form.errors['descriptions.0.value'].join(', ') }}
@ -176,7 +178,7 @@
<FormControl required v-model="form.descriptions[0].language" type="text"
:is-read-only="true">
<div class="text-red-400 text-sm" v-if="form.errors['descriptions.0.value'] && Array.isArray(form.errors['descriptions.0.language'])
">
">
{{ form.errors['descriptions.0.language'].join(', ') }}
</div>
</FormControl>
@ -243,8 +245,9 @@
<SearchAutocomplete source="/api/persons" :response-property="'first_name'"
placeholder="search in person table...." v-on:person="onAddAuthor"></SearchAutocomplete>
<TablePersons :persons="form.authors" v-if="form.authors.length > 0" :relation="'authors'"/>
<div class="text-red-400 text-sm" v-if="form.errors.authors && Array.isArray(form.errors.authors)">
<TablePersons :persons="form.authors" v-if="form.authors.length > 0" :relation="'authors'" />
<div class="text-red-400 text-sm"
v-if="form.errors.authors && Array.isArray(form.errors.authors)">
{{ form.errors.authors.join(', ') }}
</div>
</CardBox>
@ -334,8 +337,8 @@
</FormField>
</div>
<CardBox class="mb-6 shadow" has-table title="Dataset References" :icon="mdiEarthPlus" :header-icon="mdiPlusCircle"
v-on:header-icon-click="addReference">
<CardBox class="mb-6 shadow" has-table title="Dataset References" :icon="mdiEarthPlus"
:header-icon="mdiPlusCircle" v-on:header-icon-click="addReference">
<!-- Message when no references exist -->
<div v-if="form.references.length === 0" class="text-center py-4">
<p class="text-gray-600">No references added yet.</p>
@ -408,6 +411,42 @@
</tr>
</tbody>
</table>
<!-- References to delete section -->
<div v-if="form.referencesToDelete && form.referencesToDelete.length > 0" class="mt-8">
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-900">References To Delete</h1>
<ul class="flex flex-1 flex-wrap -m-1">
<li v-for="(element, index) in form.referencesToDelete" :key="index"
class="block p-1 w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/6 xl:w-1/8 h-40">
<article tabindex="0"
class="bg-red-100 group w-full h-full rounded-md cursor-pointer relative shadow-sm overflow-hidden">
<section
class="flex flex-col rounded-md text-xs break-words w-full h-full z-20 absolute top-0 py-2 px-3">
<h1 class="flex-1 text-gray-700 group-hover:text-blue-800 font-medium text-sm mb-1 truncate overflow-hidden whitespace-nowrap">
{{ element.value }}
</h1>
<div class="flex flex-col mt-auto">
<p class="p-1 size text-xs text-gray-700">
<span class="font-semibold">Type:</span> {{ element.type }}
</p>
<p class="p-1 size text-xs text-gray-700">
<span class="font-semibold">Relation:</span> {{ element.relation }}
</p>
<div class="flex justify-end mt-1">
<button
class="restore ml-auto focus:outline-none hover:bg-gray-300 p-1 rounded-md text-gray-800"
@click.prevent="restoreReference(index)">
<svg viewBox="0 0 24 24" class="w-5 h-5">
<path fill="currentColor" :d="mdiRestore"></path>
</svg>
</button>
</div>
</div>
</section>
</article>
</li>
</ul>
</div>
</CardBox>
<BaseDivider />
@ -420,7 +459,7 @@
</li>
</ul> -->
<TableKeywords :keywords="form.subjects" :errors="form.errors" :subjectTypes="subjectTypes"
v-if="form.subjects.length > 0" />
v-model:subjects-to-delete="form.subjectsToDelete" v-if="form.subjects.length > 0" />
</CardBox>
</div>
@ -447,7 +486,9 @@
</select> -->
</div>
<FileUploadComponent v-model:files="form.files" v-model:filesToDelete="form.filesToDelete" :showClearButton="false"></FileUploadComponent>
<FileUploadComponent v-model:files="form.files" v-model:filesToDelete="form.filesToDelete"
:showClearButton="false">
</FileUploadComponent>
<div class="text-red-400 text-sm" v-if="form.errors['file'] && Array.isArray(form.errors['files'])">
{{ form.errors['files'].join(', ') }}
@ -475,8 +516,8 @@
</BaseButtons>
</template>
</CardBox>
<!-- Loading Spinner -->
<div v-if="form.processing"
<!-- Loading Spinner -->
<div v-if="form.processing"
class="fixed inset-0 flex items-center justify-center bg-gray-500 bg-opacity-50 z-50">
<svg class="animate-spin h-12 w-12 text-white" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
@ -527,6 +568,7 @@ import {
mdiBookOpenPageVariant,
mdiEarthPlus,
mdiAlertBoxOutline,
mdiRestore
} from '@mdi/js';
import { notify } from '@/notiwind';
import NotificationBar from '@/Components/NotificationBar.vue';
@ -633,6 +675,8 @@ const mapId = 'test';
// }
props.dataset.filesToDelete = [];
props.dataset.subjectsToDelete = [];
props.dataset.referencesToDelete = [];
let form = useForm<Dataset>(props.dataset as Dataset);
// const mainService = MainService();
@ -701,13 +745,13 @@ const submit = async (): Promise<void> => {
// const metadata = JSON.stringify({ sort_order: obj.sort_order });
// const metadataBlob = new Blob([metadata + '\n'], { type: 'application/json' });
const file = new File([obj.blob], `${obj.label}?sortorder=${obj.sort_order}`, options,);
// const file = new File([obj.blob], `${obj.label}`, options);
// fileUploads[obj.sort_order] = file;
fileUploads.push(file);
} else {
} else {
// return normal request input
fileInputs.push(obj);
}
@ -744,7 +788,9 @@ const submit = async (): Promise<void> => {
// formStep.value++;
// form.filesToDelete = [];
// Clear the array using splice
form.filesToDelete?.splice(0, form.filesToDelete.length);
form.filesToDelete?.splice(0, form.filesToDelete.length);
form.subjectsToDelete?.splice(0, form.subjectsToDelete.length);
form.referencesToDelete?.splice(0, form.referencesToDelete.length);
},
});
};
@ -794,7 +840,7 @@ const onAddContributor = (person: Person) => {
};
const addKeyword = () => {
let newSubject: Subject = { value: 'test', language: '', type: 'uncontrolled', dataset_count: 0 };
let newSubject: Subject = { value: '', language: '', type: 'uncontrolled' };
//this.dataset.files.push(uploadedFiles[i]);
form.subjects.push(newSubject);
};
@ -806,9 +852,35 @@ const addReference = () => {
};
const removeReference = (key: any) => {
const reference = form.references[key];
// If the reference has an ID, it exists in the database
// and should be added to referencesToDelete
if (reference.id) {
// Initialize referencesToDelete array if it doesn't exist
if (!form.referencesToDelete) {
form.referencesToDelete = [];
}
// Add to referencesToDelete
form.referencesToDelete.push(reference);
}
// Remove from form.references array
form.references.splice(key, 1);
};
const restoreReference = (index: number) => {
// Get the reference from referencesToDelete
const reference = form.referencesToDelete[index];
// Add it back to form.references
form.references.push(reference);
// Remove it from referencesToDelete
form.referencesToDelete.splice(index, 1);
};
const onMapInitialized = (newItem: any) => {
console.log(newItem);
};

View file

@ -96,7 +96,7 @@ if ((!localStorage[darkModeKey] && window.matchMedia('(prefers-color-scheme: dar
mainService.fetchApi('clients');
mainService.fetchApi('authors');
mainService.fetchApi('datasets');
mainService.fetchChartData("2022");
mainService.fetchChartData();
/* Collapse mobile aside menu on route change */
Inertia.on('navigate', () => {

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 287 KiB

View file

@ -7,6 +7,12 @@
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<!-- <link rel="icon" href="/apps/theming/favicon/settings?v=ad28c447"> -->
<input type="hidden" id="initial-state-firstrunwizard-desktop"
value="Imh0dHBzOi8vZ2l0ZWEuZ2VvbG9naWUuYWMuYXQvZ2VvbGJhL3RldGh5cy5iYWNrZW5kIg==">

View file

@ -106,7 +106,7 @@ router
// Auth routes
router
.get('/app/login', async({ inertia }: HttpContext) => {
.get('/app/login', async ({ inertia }: HttpContext) => {
try {
await db.connection().rawQuery('SELECT 1');
} catch (error) {
@ -364,6 +364,35 @@ router
.as('editor.dataset.rejectUpdate')
.where('id', router.matchers.number())
.use([middleware.auth(), middleware.can(['dataset-editor-reject'])]);
router
.get('/dataset/:id/edit', [EditorDatasetController, 'edit'])
.as('editor.dataset.edit')
// .where('id', router.matchers.number())
.use([middleware.auth(), middleware.can(['dataset-editor-update'])]);
router
.put('/dataset/:id/update', [EditorDatasetController, 'update'])
.as('editor.dataset.update')
.where('id', router.matchers.number())
.use([middleware.auth(), middleware.can(['dataset-editor-update'])]);
router
.get('/dataset/:id/categorize', [EditorDatasetController, 'categorize'])
.as('editor.dataset.categorize')
.where('id', router.matchers.number())
.use([middleware.auth(), middleware.can(['dataset-editor-update'])]);
router
.put('/dataset/:id/categorizeUpdate', [EditorDatasetController, 'categorizeUpdate'])
.as('editor.dataset.categorizeUpdate')
.where('id', router.matchers.number())
.use([middleware.auth(), middleware.can(['dataset-editor-update'])]);
router
.get('/file/download/:id', [EditorDatasetController, 'download'])
.as('editor.file.download')
.where('id', router.matchers.number())
.use([middleware.auth(), middleware.can(['dataset-editor-update'])]);
router
.get('dataset/:id/publish', [EditorDatasetController, 'publish'])
.as('editor.dataset.publish')
@ -384,10 +413,10 @@ router
.as('editor.dataset.doiStore')
.where('id', router.matchers.number())
.use([middleware.auth(), middleware.can(['dataset-publish'])]);
router
.put('/dataset/:id/update', [EditorDatasetController, 'update'])
.as('editor.dataset.update')
.use([middleware.auth(), middleware.can(['dataset-editor-edit'])]);
// router
// .put('/dataset/:id/update', [EditorDatasetController, 'update'])
// .as('editor.dataset.update')
// .use([middleware.auth(), middleware.can(['dataset-editor-edit'])]);
})
.prefix('editor');