Squashed commit of the following:
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 40s

commit 579f0878e5240dc17db69be1e0b0c0f5af7ef9fe
Author: Arno Kaimbacher <arno.kaimbacher@geosphere.at>
Date:   Tue Jun 9 09:25:44 2026 +0200

    feat: Refactor error handling in Dataset Edit form and improve validation messages

    - Updated error handling in the Dataset Edit form to use a centralized formatError function for displaying validation messages.
    - Enhanced user feedback by ensuring that error messages are displayed consistently across various fields.
    - Modified the validation rule for arrayContainsTypes to provide clearer error messages for missing main and translated titles/abstracts.
    - Introduced a new ValidationService to manage manual construction of validation errors.
    - Updated Vite configuration to streamline asset loading and improve performance.
    - Adjusted Inertia setup to utilize dynamic imports for page-specific assets.
    - Cleaned up unnecessary comments and code in various files for better readability.

commit 5efddc2a58c0e164fef585cc7344c06155dbc2c1
Author: Arno Kaimbacher <arno.kaimbacher@geosphere.at>
Date:   Mon Jan 12 17:02:47 2026 +0100

    feat: add dataset change detection and form submission composables

    - Implemented `useDatasetChangeDetection` for tracking unsaved changes in dataset forms, including comparisons for licenses, basic properties, files, coverage, and more.
    - Added `useDatasetFormSubmission` for handling dataset form submissions with validation, success/error handling, and auto-save functionality.
This commit is contained in:
Kaimbacher 2026-06-09 09:35:15 +02:00
commit 9368a0dd8d
38 changed files with 5588 additions and 6181 deletions

View file

@ -1,7 +1,7 @@
PORT=3333 PORT=3333
HOST=0.0.0.0 HOST=0.0.0.0
NODE_ENV=development NODE_ENV=development
APP_KEY=pvmU1vuAZDkSwarb7yh9pgZ-RxaX4zS7 APP_KEY=pvmU1vuAZDkSwarb7yh9pgZ-xxxxxx007
DRIVE_DISK=local DRIVE_DISK=local
SESSION_DRIVER=cookie SESSION_DRIVER=cookie
CACHE_VIEWS=false CACHE_VIEWS=false

View file

@ -62,7 +62,8 @@ export default defineConfig({
// () => import('@eidellev/inertia-adonisjs'), // () => import('@eidellev/inertia-adonisjs'),
// () => import('@adonisjs/inertia/inertia_provider'), // () => import('@adonisjs/inertia/inertia_provider'),
() => import('#providers/app_provider'), () => import('#providers/app_provider'),
() => import('#providers/inertia_provider'), // () => import('#providers/inertia_provider'),
() => import('@adonisjs/inertia/inertia_provider'),
() => import('@adonisjs/lucid/database_provider'), () => import('@adonisjs/lucid/database_provider'),
() => import('@adonisjs/auth/auth_provider'), () => import('@adonisjs/auth/auth_provider'),
// () => import('@eidellev/adonis-stardust'), // () => import('@eidellev/adonis-stardust'),

View file

@ -42,7 +42,7 @@ export default class RoleController {
can: { can: {
create: await auth.user?.can(['user-create']), create: await auth.user?.can(['user-create']),
edit: await auth.user?.can(['user-edit']), edit: await auth.user?.can(['user-edit']),
delete: await auth.user?.can(['user-delete']), delete: false, //await auth.user?.can(['user-delete']),
}, },
}); });
} }
@ -124,7 +124,7 @@ export default class RoleController {
// password is optional // password is optional
const input = request.only(['name', 'description']); const input = request.only(['name', 'display_name', 'description']);
await role.merge(input).save(); await role.merge(input).save();
// await user.save(); // await user.save();

View file

@ -821,6 +821,10 @@ export default class DatasetsController {
referenceIdentifierTypes: Object.entries(ReferenceIdentifierTypes).map(([key, value]) => ({ value: key, label: value })), referenceIdentifierTypes: Object.entries(ReferenceIdentifierTypes).map(([key, value]) => ({ value: key, label: value })),
relationTypes: Object.entries(RelationTypes).map(([key, value]) => ({ value: key, label: value })), relationTypes: Object.entries(RelationTypes).map(([key, value]) => ({ value: key, label: value })),
doctypes: DatasetTypes, doctypes: DatasetTypes,
can: {
edit: await auth.user?.can(['dataset-editor-update']),
// delete: await auth.user?.can(['dataset-delete']),
},
}); });
} }

View file

@ -14,9 +14,7 @@ import Person from '#models/person';
import db from '@adonisjs/lucid/services/db'; import db from '@adonisjs/lucid/services/db';
import { TransactionClientContract } from '@adonisjs/lucid/types/database'; import { TransactionClientContract } from '@adonisjs/lucid/types/database';
import Subject from '#models/subject'; import Subject from '#models/subject';
// import CreateDatasetValidator from '#validators/create_dataset_validator';
import { createDatasetValidator, updateDatasetValidator } from '#validators/dataset'; import { createDatasetValidator, updateDatasetValidator } from '#validators/dataset';
// import UpdateDatasetValidator from '#validators/update_dataset_validator';
import { import {
TitleTypes, TitleTypes,
DescriptionTypes, DescriptionTypes,
@ -40,7 +38,8 @@ import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs'; import { createWriteStream } from 'node:fs';
import type { Multipart } from '@adonisjs/bodyparser'; import type { Multipart } from '@adonisjs/bodyparser';
import * as fs from 'fs'; import * as fs from 'fs';
import { parseBytesSize, getConfigFor, getTmpPath, formatBytes } from '#app/utils/utility-functions'; import { parseBytesSize, getConfigFor, getTmpPath, formatBytes, errorMessage } from '#app/utils/utility-functions';
import validation from '#services/validation_service';
interface Dictionary { interface Dictionary {
[index: string]: string; [index: string]: string;
@ -207,6 +206,7 @@ export default class DatasetController {
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }), .translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}), }),
) )
.minLength(1) // Ensure at least the main title exists
// .minLength(2) // .minLength(2)
.arrayContainsTypes({ typeA: 'main', typeB: 'translated' }), .arrayContainsTypes({ typeA: 'main', typeB: 'translated' }),
descriptions: vine descriptions: vine
@ -426,37 +426,31 @@ export default class DatasetController {
} }
public async store({ auth, request, response, session }: HttpContext) { public async store({ auth, request, response, session }: HttpContext) {
// At the top of the store() method, declare an array to hold temporary file paths
const uploadedTmpFiles: string[] = []; const uploadedTmpFiles: string[] = [];
// Aggregated limit example (adjust as needed)
const multipartConfig = getConfigFor('multipart');
const aggregatedLimit = multipartConfig.limit ? parseBytesSize(multipartConfig.limit) : 100 * 1024 * 1024;
// const aggregatedLimit = 200 * 1024 * 1024;
let totalUploadedSize = 0;
// // Helper function to format bytes as human-readable text
// function formatBytes(bytes: number): string {
// if (bytes === 0) return '0 Bytes';
// const k = 1024;
// const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
// const i = Math.floor(Math.log(bytes) / Math.log(k));
// return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
// }
// const enabledExtensions = await this.getEnabledExtensions();
const multipart: Multipart = request.multipart; const multipart: Multipart = request.multipart;
// NULL GUARD: kein multipart-Body → sauberer Validierungsfehler statt Crash
if (!multipart) {
validation.throw('files', 'Please select at least one file.', 'required');
}
// Configuration for limits
const multipartConfig = getConfigFor('multipart');
const aggregatedLimit = multipartConfig.limit ? parseBytesSize(multipartConfig.limit) : 100 * 1024 * 1024;
let totalUploadedSize = 0;
let filesCount = 0; // Tracker: ensure at least one file is processed
multipart.onFile('files', { deferValidations: true }, async (part) => { multipart.onFile('files', { deferValidations: true }, async (part) => {
// Attach an individual file size accumulator if needed filesCount++;
let fileUploadedSize = 0; let fileUploadedSize = 0;
// Simply accumulate the size in on('data') without performing the expensive check per chunk // Accumulate the size per chunk (cheap), defer the limit check to 'end'
part.on('data', (chunk) => { part.on('data', (chunk) => {
// reporter(chunk);
// Increase counters using the chunk length
fileUploadedSize += chunk.length; fileUploadedSize += chunk.length;
}); });
// After the file is completely read, update the global counter and check aggregated limit // After the file is fully read, update the global counter and check the aggregated limit
part.on('end', () => { part.on('end', () => {
totalUploadedSize += fileUploadedSize; totalUploadedSize += fileUploadedSize;
part.file.size = fileUploadedSize; part.file.size = fileUploadedSize;
@ -465,6 +459,7 @@ export default class DatasetController {
uploadedTmpFiles.push(part.file.tmpPath); uploadedTmpFiles.push(part.file.tmpPath);
} }
// AGGREGATED LIMIT CHECK: abort immediately if too big
if (totalUploadedSize > aggregatedLimit) { if (totalUploadedSize > aggregatedLimit) {
// Clean up all temporary files if aggregate limit is exceeded // Clean up all temporary files if aggregate limit is exceeded
uploadedTmpFiles.forEach((tmpPath) => { uploadedTmpFiles.forEach((tmpPath) => {
@ -474,48 +469,44 @@ export default class DatasetController {
console.error('Error cleaning up temporary file:', cleanupError); console.error('Error cleaning up temporary file:', cleanupError);
} }
}); });
const error = new errors.E_VALIDATION_ERROR({ request.multipart.abort(
'upload error': `Aggregated upload limit of ${formatBytes(aggregatedLimit)} exceeded. The total size of files being uploaded would exceed the limit.`, validation.make('files', `Upload limit of ${formatBytes(aggregatedLimit)} exceeded.`, 'limit')
}); );
request.multipart.abort(error);
} }
}); });
part.on('error', (error) => {
// fileUploadError = error;
request.multipart.abort(error);
});
// await pipeline(part, createWriteStream(filePath));
// return { filePath };
// Process file with error handling // Process file with error handling
try { try {
// Extract extension from the client file name, e.g. "Tethys 5 - Ampflwang_dataset.zip" // Extract extension from the client file name, e.g. "Tethys 5 - Ampflwang_dataset.zip"
const ext = path.extname(part.file.clientName).replace('.', ''); const ext = path.extname(part.file.clientName).replace('.', '');
// Attach the extracted extension to the file object for later use
part.file.extname = ext; part.file.extname = ext;
// part.file.sortOrder = part.file.sortOrder;
const tmpPath = getTmpPath(multipartConfig); const tmpPath = getTmpPath(multipartConfig);
(part.file as any).tmpPath = tmpPath; (part.file as any).tmpPath = tmpPath;
const writeStream = createWriteStream(tmpPath); const writeStream = createWriteStream(tmpPath);
await pipeline(part, writeStream); await pipeline(part, writeStream);
} catch (error) { } catch (error) {
request.multipart.abort(new errors.E_VALIDATION_ERROR({ 'upload error': error.message })); request.multipart.abort(validation.make('files', error.message, 'upload'));
} }
}); });
try { try {
await multipart.process(); await multipart.process();
// // Instead of letting an error abort the controller, check if any error occurred
// EMPTY FIELD CHECK: triggered if process finishes but onFile never ran
if (filesCount === 0) {
validation.throw('files', 'Please select at least one file.', 'required');
}
} catch (error) { } catch (error) {
// This is where you'd expect to catch any errors. // If it's already a validation error, let it bubble up unchanged
session.flash('errors', error.messages); if (error instanceof errors.E_VALIDATION_ERROR) throw error;
return response.redirect().back();
// Wrap any other stream/size errors
validation.throw('files', errorMessage(error) || 'Upload failed.');
} }
// Proceed to transaction and createDatasetAndAssociations
let trx: TransactionClientContract | null = null; let trx: TransactionClientContract | null = null;
try { try {
await request.validateUsing(createDatasetValidator); await request.validateUsing(createDatasetValidator);
@ -539,13 +530,11 @@ export default class DatasetController {
await trx.rollback(); await trx.rollback();
} }
console.error('Failed to create dataset and related models:', error); console.error('Failed to create dataset and related models:', error);
// throw new ValidationException(true, { 'upload error': `failed to create dataset and related models. ${error}` });
throw error; throw error;
} }
session.flash('message', 'Dataset has been created successfully'); session.flash('message', 'Dataset has been created successfully');
return response.redirect().toRoute('dataset.list'); return response.redirect().toRoute('dataset.list');
// return response.redirect().back();
} }
private async createDatasetAndAssociations( private async createDatasetAndAssociations(
user: User, user: User,
@ -1106,7 +1095,7 @@ export default class DatasetController {
} }
}); });
const error = new errors.E_VALIDATION_ERROR({ const error = new errors.E_VALIDATION_ERROR({
'upload error': `Aggregated upload limit of ${formatBytes(aggregatedLimit)} exceeded. The total size of files being uploaded would exceed the limit.`, 'files': `Aggregated upload limit of ${formatBytes(aggregatedLimit)} exceeded. The total size of files being uploaded would exceed the limit.`,
}); });
request.multipart.abort(error); request.multipart.abort(error);
} }
@ -1132,7 +1121,7 @@ export default class DatasetController {
try { try {
await multipart.process(); await multipart.process();
} catch (error) { } catch (error) {
session.flash('errors', error.messages); // session.flash('errors', error.messages);
return response.redirect().back(); return response.redirect().back();
} }
} }

View file

@ -1,8 +1,8 @@
import ResumptionToken from './ResumptionToken.js'; import ResumptionToken from './ResumptionToken.js';
import { createClient, RedisClientType } from 'redis'; import { createClient, RedisClientType } from 'redis';
import InternalServerErrorException from '#app/exceptions/InternalServerException'; import InternalServerErrorException from '#app/exceptions/InternalServerException';
import { sprintf } from 'sprintf-js'; // import { sprintf } from 'sprintf-js';
import dayjs from 'dayjs'; // import dayjs from 'dayjs';
import TokenWorkerContract from './TokenWorkerContract.js'; import TokenWorkerContract from './TokenWorkerContract.js';
export default class TokenWorkerService implements TokenWorkerContract { export default class TokenWorkerService implements TokenWorkerContract {
@ -98,21 +98,21 @@ export default class TokenWorkerService implements TokenWorkerContract {
// return uniqueName; // return uniqueName;
// } // }
private async generateUniqueName(): Promise<string> { // private async generateUniqueName(): Promise<string> {
let fc = 0; // let fc = 0;
const uniqueId = dayjs().unix().toString(); // const uniqueId = dayjs().unix().toString();
let uniqueName: string; // let uniqueName: string;
let cacheKeyExists: boolean; // let cacheKeyExists: boolean;
do { // do {
// format values // // format values
// %s - String // // %s - String
// %d - Signed decimal number (negative, zero or positive) // // %d - Signed decimal number (negative, zero or positive)
// [0-9] (Specifies the minimum width held of to the variable value) // // [0-9] (Specifies the minimum width held of to the variable value)
uniqueName = sprintf('%s%05d', uniqueId, fc++); // uniqueName = sprintf('%s%05d', uniqueId, fc++);
cacheKeyExists = await this.has(uniqueName); // cacheKeyExists = await this.has(uniqueName);
} while (cacheKeyExists); // } while (cacheKeyExists);
return uniqueName; // return uniqueName;
} // }
public async get(key: string): Promise<ResumptionToken | null> { public async get(key: string): Promise<ResumptionToken | null> {
if (!this.cache) { if (!this.cache) {

View file

@ -1,214 +1,53 @@
/* import app from '@adonisjs/core/services/app'
|-------------------------------------------------------------------------- import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
| Http Exception Handler import type { StatusPageRange, StatusPageRenderer } from '@adonisjs/core/types/http'
|--------------------------------------------------------------------------
|
| AdonisJs will forward all exceptions occurred during an HTTP request to
| the following class. You can learn more about exception handling by
| reading docs.
|
| The exception handler extends a base `HttpExceptionHandler` which is not
| mandatory, however it can do lot of heavy lifting to handle the errors
| properly.
|
*/
import app from '@adonisjs/core/services/app';
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http';
// import logger from '@adonisjs/core/services/logger';
import type { StatusPageRange, StatusPageRenderer } from '@adonisjs/core/types/http';
export default class HttpExceptionHandler extends ExceptionHandler { export default class HttpExceptionHandler extends ExceptionHandler {
/** protected debug = !app.inProduction
* In debug mode, the exception handler will display verbose errors protected renderStatusPages = true
* with pretty printed stack traces.
*/
protected debug = !app.inProduction;
/**
* Status pages are used to display a custom HTML pages for certain error
* codes. You might want to enable them in production only, but feel
* free to enable them in development as well.
*/
protected renderStatusPages = true; //app.inProduction;
/**
* Status pages is a collection of error code range and a callback
* to return the HTML contents to send as a response.
*/
// protected statusPages: Record<StatusPageRange, StatusPageRenderer> = {
// '401..403': (error, { view }) => {
// return view.render('./errors/unauthorized', { error });
// },
// '404': (error, { view }) => {
// return view.render('./errors/not-found', { error });
// },
// '500..599': (error, { view }) => {
// return view.render('./errors/server-error', { error });
// },
// };
protected statusPages: Record<StatusPageRange, StatusPageRenderer> = { protected statusPages: Record<StatusPageRange, StatusPageRenderer> = {
'404': (error, { inertia }) => { '404': (error, ctx) =>
return inertia.render('Errors/ServerError', { ctx.inertia
error: error.message, ? ctx.inertia.render('Errors/ServerError', { error: error.message, code: error.status })
code: error.status, : ctx.response.status(error.status).send(error.message),
}); '401..403': (error, ctx) => {
if (ctx.inertia) {
return ctx.inertia.render('Errors/ServerError', { error: error.message, code: error.status });
}
return ctx.response.status(error.status).send(error.message);
}, },
'401..403': async (error, { inertia }) => { '500..599': (error, ctx) => {
// session.flash('errors', error.message); const isDbError =
return inertia.render('Errors/ServerError', { error.code === 'ECONNREFUSED' &&
error: error.message, (error.errors?.some((e: any) => e.port === 5432) ?? error.message?.includes('5432'));
code: error.status,
});
},
// '500': (error, { inertia }) => {
// return inertia.render('Errors/postgres_error', {
// status: 'error',
// message: 'PostgreSQL database connection failed. Please ensure the database service is running.',
// details: {
// code: error.code,
// type: error.status,
// ports: error.errors.map((err: any) => ({
// port: err.port,
// address: err.address,
// })),
// },
// });
// },
'500..599': (error, { inertia }) => {
if (error.code === 'ECONNREFUSED') {
const dbErrors = error.errors.some((err: any) => err.code === 'ECONNREFUSED' && err.port === 5432);
if (dbErrors) { if (isDbError && ctx.inertia) {
return inertia.render('Errors/postgres_error', { return ctx.inertia.render('Errors/postgres_error', {
status: 'error', status: 'error',
message: 'PostgreSQL database connection failed. Please ensure the database service is running.', message: 'PostgreSQL database connection failed.',
details: { details: {
code: error.code, code: error.code,
type: error.status, type: error.status
ports: error.errors.map((err: any) => ({ // Entferne das .map() auf error.errors, da es oft undefined ist
port: err.port, }
address: err.address,
})),
},
}); });
} }
} else {
return inertia.render('Errors/ServerError', { if (ctx.inertia) {
error: error.message, return ctx.inertia.render('Errors/ServerError', { error: error.message, code: 500 });
code: error.status, }
}); return ctx.response.status(500).send(error.message);
} }
},
}; };
// constructor() {
// super(logger);
// }
public async handle(error: any, ctx: HttpContext) { public async handle(error: any, ctx: HttpContext) {
const { response, request, session, inertia } = ctx;
/** /**
* Handle failed authentication attempt * WICHTIG: Validierungsfehler (422) NICHT manuell abfangen!
* AdonisJS 6 + VineJS + Inertia machen das automatisch.
* Wenn du es hier manuell machst, überschreibst du den Standard-Flow.
*/ */
// if (['E_INVALID_AUTH_PASSWORD', 'E_INVALID_AUTH_UID'].includes(error.code)) {
// session.flash('errors', { login: error.message });
// return response.redirect('/login');
// }
// if ([401].includes(error.status)) {
// session.flash('errors', { login: error.message });
// return response.redirect('/dashboard');
// }
// Handle Axios errors return super.handle(error, ctx)
if (error.code === 'ECONNREFUSED') {
const dbErrors = error.errors.some((err: any) => err.code === 'ECONNREFUSED' && err.port === 5432);
if (dbErrors) {
// return ctx.response.status(503).json({
// status: 'error',
// message: 'PostgreSQL database connection failed. Please ensure the database service is running.',
// details: {
// code: error.code,
// type: error.status,
// ports: error.errors.map((err: any) => ({
// port: err.port,
// address: err.address,
// })),
// },
// });
// return inertia.render('Errors/postgres_error', {
// status: 'error',
// message: 'PostgreSQL database connection failed. Please ensure the database service is running.',
// details: {
// code: error.code,
// type: error.status,
// ports: error.errors.map((err: any) => ({
// port: err.port,
// address: err.address,
// })),
// },
// });
}
}
// Handle simple ECONNREFUSED errors
// if (error.code === 'ECONNREFUSED') {
// return ctx.response.status(503).json({
// status: 'error',
// message: 'Database connection failed. Please ensure PostgreSQL is running.',
// code: error.code,
// });
// }
// https://github.com/inertiajs/inertia-laravel/issues/56
// let test = response.getStatus(); //200
// let header = request.header('X-Inertia'); // true
// if (request.header('X-Inertia') && [500, 503, 404, 403, 401, 200].includes(response.getStatus())) {
if (request.header('X-Inertia') && [422].includes(error.status)) {
// session.flash('errors', error.messages.errors);
session.flash('errors', error.messages);
return response.redirect().back();
// return inertia.render('errors/server_error', {
// return inertia.render('errors/server_error', {
// // status: response.getStatus(),
// error: error,
// });
// ->toResponse($request)
// ->setStatusCode($response->status());
}
// Handle simple ECONNREFUSED errors
// if (error.code === 'ECONNREFUSED') {
// return ctx.response.status(503).json({
// status: 'error',
// message: 'Database connection failed. Please ensure PostgreSQL is running.',
// code: error.code,
// });
// }
// Dynamically change the error templates based on the absence of X-Inertia header
// if (!ctx.request.header('X-Inertia')) {
// this.statusPages = {
// '401..403': (error, { inertia }) => inertia.render('Errors/ServerError', { error: error.message, code: error.status }),
// '404': (error, { inertia }) => inertia.render('Errors/ServerError', { error: error.message, code: error.status }),
// '500..599': (error, { inertia }) => inertia.render('Errors/ServerError', { error: error.message, code: error.status }),
// };
// }
/**
* Forward rest of the exceptions to the parent class
*/
return super.handle(error, ctx);
}
/**
* The method is used to report error to the logging service or
* the a third party error monitoring service.
*
* @note You should not attempt to send a response from this method.
*/
async report(error: unknown, ctx: HttpContext) {
return super.report(error, ctx);
} }
} }

View file

@ -0,0 +1,54 @@
import app from '@adonisjs/core/services/app'
import { errors } from '@vinejs/vine'
/**
* The ValidationService handles manual construction of validation errors
* that are compatible with the VanillaErrorReporter and AdonisJS Session.
*/
export class ValidationService {
/**
* Builds a validation error in the array-of-objects format without throwing it.
* Use this when you need the error object itself, e.g. multipart.abort(error).
*/
make(field: string, message: string, rule: string = 'manual') {
return new errors.E_VALIDATION_ERROR([
{
field,
message,
rule,
},
])
}
/**
* Throws a manual validation error in the array-of-objects format
* which prevents the ".reduce is not a function" error in the session.
*/
throw(field: string, message: string, rule: string = 'manual') {
throw this.make(field, message, rule)
}
/**
* Throws multiple manual validation errors at once.
*/
throwMany(errorObjects: Array<{ field: string; message: string; rule?: string }>) {
throw new errors.E_VALIDATION_ERROR(
errorObjects.map((err) => ({
field: err.field,
message: err.message,
rule: err.rule || 'manual',
}))
)
}
}
/**
* Initialize and export the singleton instance
*/
let validation: ValidationService
await app.booted(async () => {
validation = await app.container.make(ValidationService)
})
export { validation as default }

View file

@ -122,3 +122,8 @@ function extractPivotAttributes(person: any) {
} }
return pivotAttributes; return pivotAttributes;
} }
// in #app/utils/utility-functions
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -1,203 +1,50 @@
// import { ValidationError } from '../errors/validation_error.js'; import { errors } from '@vinejs/vine'
import { errors } from '@vinejs/vine'; import type { ErrorReporterContract, FieldContext } from '@vinejs/vine/types'
import type { ErrorReporterContract, FieldContext } from '@vinejs/vine/types';
import string from '@poppinss/utils/string';
/** /**
* Shape of the Vanilla error node * Der VanillaErrorReporter sammelt Validierungsfehler im Standardformat,
*/ * damit die AdonisJS Session-Middleware sie korrekt verarbeiten (reducen) kann.
export type VanillaErrorNode = {
[field: string]: string[];
};
export interface MessagesBagContract {
get(pointer: string, rule: string, message: string, arrayExpressionPointer?: string, args?: any): string;
}
/**
* Message bag exposes the API to pull the most appropriate message for a
* given validation failure.
*/
export class MessagesBag implements MessagesBagContract {
messages: Message;
wildCardCallback;
constructor(messages: string[]) {
this.messages = messages;
this.wildCardCallback = typeof this.messages['*'] === 'function' ? this.messages['*'] : undefined;
}
/**
* Transform message by replace placeholders with runtime values
*/
transform(message: any, rule: string, pointer: string, args: any) {
/**
* No interpolation required
*/
if (!message.includes('{{')) {
return message;
}
return string.interpolate(message, { rule, field: pointer, options: args || {} });
}
/**
* Returns the most appropriate message for the validation failure.
*/
get(pointer: string, rule: string, message: string, arrayExpressionPointer: string, args: any) {
let validationMessage = this.messages[`${pointer}.${rule}`];
/**
* Fetch message for the array expression pointer if it exists
*/
if (!validationMessage && arrayExpressionPointer) {
validationMessage = this.messages[`${arrayExpressionPointer}.${rule}`];
}
/**
* Fallback to the message for the rule
*/
if (!validationMessage) {
validationMessage = this.messages[rule];
}
/**
* Transform and return message. The wildcard callback is invoked when custom message
* is not defined
*/
return validationMessage
? this.transform(validationMessage, rule, pointer, args)
: this.wildCardCallback
? this.wildCardCallback(pointer, rule, arrayExpressionPointer, args)
: message;
}
}
/**
* Shape of the error message collected by the SimpleErrorReporter
*/
type SimpleError = {
message: string;
field: string;
rule: string;
index?: number;
meta?: Record<string, any>;
};
export interface Message {
[key: string]: any;
}
/**
* Simple error reporter collects error messages as an array of object.
* Each object has following properties.
*
* - message: string
* - field: string
* - rule: string
* - index?: number (in case of an array member)
* - args?: Record<string, any>
*/ */
export class VanillaErrorReporter implements ErrorReporterContract { export class VanillaErrorReporter implements ErrorReporterContract {
// private messages;
// private bail;
/** /**
* Boolean to know one or more errors have been reported * Boolean, um zu prüfen, ob Fehler vorliegen
*/
hasErrors: boolean = false;
/**
* Collection of errors
*/
// errors: SimpleError[] = [];
errors: Message = {};
/**
* Report an error.
*/ */
hasErrors: boolean = false
// constructor(messages: MessagesBagContract) { /**
// this.messages = messages; * Sammlung der Fehler als Array (erforderlich für AdonisJS 6 Session)
// } */
errors: any[] = []
report(message: string, rule: string, field: FieldContext, meta?: Record<string, any> | undefined): void { /**
// const error: SimpleError = { * Diese Methode wird von VineJS für jeden Validierungsfehler aufgerufen
// message, */
// rule, report(
// field: field.getFieldPath() message: string,
// }; rule: string,
// if (meta) { field: FieldContext,
// error.meta = meta; meta?: Record<string, any>
// } ): void {
// if (field.isArrayMember) { this.hasErrors = true
// error.index = field.name as number;
// } /**
// this.errors.push(error); * Wir pushen das Objekt in das Array.
this.hasErrors = true; * Das Feld 'field' erhält den vollständigen Pfad (z.B. "user.email").
// if (this.errors[field.getFieldPath()]) { */
// this.errors[field.getFieldPath()]?.push(message); this.errors.push({
// } else {
// this.errors[field.getFieldPath()] = [message];
// }
const error: SimpleError = {
message, message,
rule, rule,
field: field.getFieldPath(), // ?field.wildCardPath.split('.')[0] : field.getFieldPath(), field: field.getFieldPath(),
}; ...meta,
// field: 'titles.0.value' });
// message: 'Main Title is required'
// rule: 'required' "required"
if (meta) {
error.meta = meta;
} }
// if (field.isArrayMember) {
// error.index = field.name;
// }
this.hasErrors = true;
// var test = field.getFieldPath();
// this.errors.push(error);
// if (this.errors[error.field]) {
// this.errors[error.field]?.push(message);
// }
if (field.isArrayMember) {
// Check if the field has wildCardPath and if the error field already exists
if (this.errors[error.field]) {
// Do nothing, as we don't want to push further messages
} else {
// If the error field already exists, push the message
if (this.errors[error.field]) {
this.errors[error.field].push(message);
} else {
this.errors[error.field] = [message];
}
}
} else {
if (this.errors[error.field]) {
this.errors[error.field]?.push(message);
} else {
this.errors[error.field] = [message];
}
}
// } else {
// // normal field
// this.errors[field.field] = [message];
// }
/** /**
* Collecting errors as per the JSONAPI spec * Erstellt die eigentliche Exception.
*/ * Da 'this.errors' nun ein Array ist, funktioniert .reduce()
// this.errors.push({ * in der Session-Middleware reibungslos.
// code: rule,
// detail: message,
// source: {
// pointer: field.wildCardPath,
// },
// ...(meta ? { meta } : {}),
// });
// let pointer: string = field.wildCardPath as string; //'display_name'
// // if (field.isArrayMember) {
// // this.errors[pointer] = field.name;
// // }
// this.errors[pointer] = this.errors[pointer] || [];
// // this.errors[pointer].push(message);
// this.errors[pointer].push(this.messages.get(pointer, rule, message, arrayExpressionPointer, args));
}
/**
* Returns an instance of the validation error
*/ */
createError() { createError() {
return new errors.E_VALIDATION_ERROR(this.errors); return new errors.E_VALIDATION_ERROR(this.errors);
} }
} }
export {};

View file

@ -31,15 +31,15 @@ const inertiaConfig = defineConfig({
// params: ({ params }) => params, // params: ({ params }) => params,
authUser: async ({ auth }: HttpContext) => { authUser: async ({ auth }: HttpContext) => {
if (auth?.user) { if (!auth?.user) return null
await auth.user.load('roles'); await auth.user.load('roles') // sicherstellen, dass geladen ist
return auth.user; return {
// { id: auth.user.id,
// 'id': auth.user.id, login: auth.user.login,
// 'login': auth.user.login, email: auth.user.email,
// }; first_name: auth.user.first_name,
} else { last_name: auth.user.last_name,
return null; roles: auth.user.roles.map((role) => role.name),
} }
}, },
}, },
@ -56,8 +56,8 @@ const inertiaConfig = defineConfig({
export default inertiaConfig export default inertiaConfig
declare module '@adonisjs/inertia/types' { declare module '@adonisjs/inertia/types' {
export interface SharedProps extends InferSharedProps<typeof inertiaConfig> {} export interface SharedProps extends InferSharedProps<typeof inertiaConfig> { }
} }
// import { InertiaConfig } from '@ioc:EidelLev/Inertia'; // import { InertiaConfig } from '@ioc:EidelLev/Inertia';

View file

@ -96,7 +96,7 @@ const sessionConfig = defineConfig({
* variable in order to infer the store name without any * variable in order to infer the store name without any
* errors. * errors.
*/ */
store: env.get('SESSION_DRIVER'), store: 'file', //env.get('SESSION_DRIVER'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Configuration for the file driver | Configuration for the file driver
@ -122,6 +122,9 @@ const sessionConfig = defineConfig({
// redisConnection: 'local', // redisConnection: 'local',
stores: { stores: {
cookie: stores.cookie(), cookie: stores.cookie(),
file: stores.file({
location: './tmp/sessions', // Where the data will live
}),
}, },
}); });
export default sessionConfig; export default sessionConfig;

6479
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -39,7 +39,7 @@
"@types/clamscan": "^2.0.4", "@types/clamscan": "^2.0.4",
"@types/escape-html": "^1.0.4", "@types/escape-html": "^1.0.4",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/leaflet": "^1.9.16", "@types/leaflet": "^1.9.21",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"@types/proxy-addr": "^2.0.0", "@types/proxy-addr": "^2.0.0",
@ -56,7 +56,6 @@
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-adonis": "^2.1.1", "eslint-plugin-adonis": "^2.1.1",
"eslint-plugin-prettier": "^5.0.0-alpha.2", "eslint-plugin-prettier": "^5.0.0-alpha.2",
"hot-hook": "^0.4.0",
"numeral": "^2.0.6", "numeral": "^2.0.6",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
@ -66,10 +65,10 @@
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"ts-loader": "^9.4.2", "ts-loader": "^9.4.2",
"ts-node-maintained": "^10.9.5", "ts-node-maintained": "^10.9.5",
"typescript": "~5.7", "typescript": "^5.9.3",
"vite": "^6.0.11", "vite": "^6.0.11",
"vue": "^3.4.26", "vue": "^3.4.26",
"vue-facing-decorator": "^3.0.0", "vue-facing-decorator": "^4.0.1",
"vue-loader": "^17.0.1", "vue-loader": "^17.0.1",
"webpack-dev-server": "^5.1.0", "webpack-dev-server": "^5.1.0",
"xslt3": "^2.5.0" "xslt3": "^2.5.0"
@ -77,10 +76,10 @@
"dependencies": { "dependencies": {
"@adonisjs/auth": "^9.2.4", "@adonisjs/auth": "^9.2.4",
"@adonisjs/bodyparser": "^10.0.1", "@adonisjs/bodyparser": "^10.0.1",
"@adonisjs/core": "6.17.2", "@adonisjs/core": "^6.21.0",
"@adonisjs/cors": "^2.2.1", "@adonisjs/cors": "^2.2.1",
"@adonisjs/drive": "^3.2.0", "@adonisjs/drive": "^3.2.0",
"@adonisjs/inertia": "^2.1.3", "@adonisjs/inertia": "^3.1.1",
"@adonisjs/lucid": "^21.5.1", "@adonisjs/lucid": "^21.5.1",
"@adonisjs/mail": "^9.2.2", "@adonisjs/mail": "^9.2.2",
"@adonisjs/redis": "^9.1.0", "@adonisjs/redis": "^9.1.0",
@ -92,7 +91,7 @@
"@fontsource/archivo-black": "^5.0.1", "@fontsource/archivo-black": "^5.0.1",
"@fontsource/inter": "^5.0.1", "@fontsource/inter": "^5.0.1",
"@inertiajs/inertia": "^0.11.1", "@inertiajs/inertia": "^0.11.1",
"@inertiajs/vue3": "^2.0.3", "@inertiajs/vue3": "^2.3.25",
"@opensearch-project/opensearch": "^3.2.0", "@opensearch-project/opensearch": "^3.2.0",
"@phc/format": "^1.0.0", "@phc/format": "^1.0.0",
"@poppinss/manager": "^5.0.2", "@poppinss/manager": "^5.0.2",
@ -115,13 +114,14 @@
"node-exceptions": "^4.0.1", "node-exceptions": "^4.0.1",
"notiwind": "^2.0.0", "notiwind": "^2.0.0",
"pg": "^8.9.0", "pg": "^8.9.0",
"phc-argon2": "^1.1.4",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"redis": "^5.0.0", "redis": "^6.0.0",
"reflect-metadata": "^0.2.1", "reflect-metadata": "^0.2.1",
"saxon-js": "^2.5.0", "saxon-js": "^2.5.0",
"toastify-js": "^1.12.0", "toastify-js": "^1.12.0",
"vuedraggable": "^4.1.0", "vuedraggable": "^4.1.0",
"xmlbuilder2": "^3.1.1" "xmlbuilder2": "^4.0.3"
}, },
"hotHook": { "hotHook": {
"boundaries": [ "boundaries": [

View file

@ -102,7 +102,11 @@ const activeStyle = computed(() => {
const hasRoles = computed(() => { const hasRoles = computed(() => {
if (props.item.roles) { if (props.item.roles) {
return user.value.roles.some(role => props.item.roles?.includes(role.name)); // Normalize user roles to strings in case roles are objects
const userRoles = (user.value.roles || []).map(r =>
typeof r === 'string' ? r : (r as any).name ?? String(r)
);
return userRoles.some(role => props.item.roles?.includes(role));
// return test; // return test;
} }
return true return true

View file

@ -1,17 +1,29 @@
<template> <template>
<section aria-label="File Upload Modal" <section
aria-label="File Upload Modal"
class="relative h-full flex flex-col bg-white dark:bg-slate-900/70 shadow-xl rounded-md" class="relative h-full flex flex-col bg-white dark:bg-slate-900/70 shadow-xl rounded-md"
v-on:dragenter="dragEnterHandler" v-on:dragleave="dragLeaveHandler" v-on:dragover="dragOverHandler" v-on:dragenter="dragEnterHandler"
v-on:drop="dropHandler"> v-on:dragleave="dragLeaveHandler"
v-on:dragover="dragOverHandler"
v-on:drop="dropHandler"
>
<!-- overlay --> <!-- overlay -->
<div id="overlay" ref="overlay" <div
class="w-full h-full absolute top-0 left-0 pointer-events-none z-50 flex flex-col items-center justify-center rounded-md"> id="overlay"
ref="overlay"
class="w-full h-full absolute top-0 left-0 pointer-events-none z-50 flex flex-col items-center justify-center rounded-md"
>
<i> <i>
<svg class="fill-current w-12 h-12 mb-3 text-blue-700" xmlns="http://www.w3.org/2000/svg" width="24" <svg
height="24" viewBox="0 0 24 24"> class="fill-current w-12 h-12 mb-3 text-blue-700"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path <path
d="M19.479 10.092c-.212-3.951-3.473-7.092-7.479-7.092-4.005 0-7.267 3.141-7.479 7.092-2.57.463-4.521 2.706-4.521 5.408 0 3.037 2.463 5.5 5.5 5.5h13c3.037 0 5.5-2.463 5.5-5.5 0-2.702-1.951-4.945-4.521-5.408zm-7.479-1.092l4 4h-3v4h-2v-4h-3l4-4z" /> d="M19.479 10.092c-.212-3.951-3.473-7.092-7.479-7.092-4.005 0-7.267 3.141-7.479 7.092-2.57.463-4.521 2.706-4.521 5.408 0 3.037 2.463 5.5 5.5 5.5h13c3.037 0 5.5-2.463 5.5-5.5 0-2.702-1.951-4.945-4.521-5.408zm-7.479-1.092l4 4h-3v4h-2v-4h-3l4-4z"
/>
</svg> </svg>
</i> </i>
<p class="text-lg text-blue-700">Drop files to upload</p> <p class="text-lg text-blue-700">Drop files to upload</p>
@ -19,8 +31,7 @@
<!-- Loading Spinner when processing big files --> <!-- Loading Spinner when processing big files -->
<div v-if="isLoading" class="absolute inset-0 z-60 flex items-center justify-center bg-gray-500 bg-opacity-50"> <div v-if="isLoading" class="absolute inset-0 z-60 flex items-center justify-center bg-gray-500 bg-opacity-50">
<svg class="animate-spin h-12 w-12 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" <svg class="animate-spin h-12 w-12 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <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> <path class="opacity-75" fill="currentColor" d="M12 2a10 10 0 0110 10h-4a6 6 0 00-6-6V2z"></path>
</svg> </svg>
@ -29,21 +40,39 @@
<!-- scroll area --> <!-- scroll area -->
<div class="h-full p-8 w-full h-full flex flex-col"> <div class="h-full p-8 w-full h-full flex flex-col">
<header class="flex items-center justify-center w-full"> <header class="flex items-center justify-center w-full">
<label for="dropzone-file" <label
class="flex flex-col items-center justify-center w-full h-64 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:bg-gray-600"> for="dropzone-file"
class="flex flex-col items-center justify-center w-full h-64 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:bg-gray-600"
>
<div class="flex flex-col items-center justify-center pt-5 pb-6"> <div class="flex flex-col items-center justify-center pt-5 pb-6">
<svg aria-hidden="true" class="w-10 h-10 mb-3 text-gray-400" fill="none" stroke="currentColor" <svg
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> aria-hidden="true"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="w-10 h-10 mb-3 text-gray-400"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"> fill="none"
</path> stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
></path>
</svg> </svg>
<p class="mb-2 text-sm text-gray-500 dark:text-gray-400"> <p class="mb-2 text-sm text-gray-500 dark:text-gray-400">
<span class="font-semibold">Click to upload</span> or drag and drop <span class="font-semibold">Click to upload</span> or drag and drop
</p> </p>
</div> </div>
<input id="dropzone-file" type="file" class="hidden" @click="showSpinner" @change="onChangeFile" <input
@cancel="cancelSpinner" multiple="true" /> id="dropzone-file"
type="file"
class="hidden"
@click="showSpinner"
@change="onChangeFile"
@cancel="cancelSpinner"
multiple="true"
/>
</label> </label>
</header> </header>
@ -107,17 +136,16 @@
</section> </section>
</article> --> </article> -->
<!-- :class="errors && errors[`files.${index}`] ? 'bg-red-400' : 'bg-gray-100'" --> <!-- :class="errors && errors[`files.${index}`] ? 'bg-red-400' : 'bg-gray-100'" -->
<article tabindex="0" <article tabindex="0" class="bg-gray-100 group w-full h-full rounded-md cursor-pointer relative shadow-sm">
class="bg-gray-100 group w-full h-full rounded-md cursor-pointer relative shadow-sm"> <section class="flex flex-col rounded-md text-xs break-words w-full h-full z-20 absolute top-0 py-2 px-3">
<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">{{ element.name }}</h1> <h1 class="flex-1 text-gray-700 group-hover:text-blue-800">{{ element.name }}</h1>
<div class="flex"> <div class="flex">
<p class="p-1 size text-xs text-gray-700">{{ getFileSize(element) }}</p> <p class="p-1 size text-xs text-gray-700">{{ getFileSize(element) }}</p>
<p class="p-1 size text-xs text-gray-700">sort: {{ element.sort_order }}</p> <p class="p-1 size text-xs text-gray-700">sort: {{ element.sort_order }}</p>
<button <button
class="delete ml-auto focus:outline-none hover:bg-gray-300 p-1 rounded-md text-gray-800" class="delete ml-auto focus:outline-none hover:bg-gray-300 p-1 rounded-md text-gray-800"
@click.prevent="removeFile(index)"> @click.prevent="removeFile(index)"
>
<DeleteIcon></DeleteIcon> <DeleteIcon></DeleteIcon>
</button> </button>
</div> </div>
@ -131,11 +159,13 @@
<!--<ul id="deletetFiles"></ul> --> <!--<ul id="deletetFiles"></ul> -->
<div> <div>
<h1 v-if="deletetFiles.length > 0" class="pt-8 pb-3 font-semibold sm:text-lg text-gray-900">Files To <h1 v-if="deletetFiles.length > 0" class="pt-8 pb-3 font-semibold sm:text-lg text-gray-900">Files To Delete</h1>
Delete</h1>
<ul id="deletetFiles" tag="ul" class="flex flex-1 flex-wrap -m-1"> <ul id="deletetFiles" tag="ul" class="flex flex-1 flex-wrap -m-1">
<li v-for="(element, index) in deletetFiles" :key="index" <li
class="block p-1 w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/6 xl:w-1/8 h-24"> v-for="(element, index) in deletetFiles"
: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-24"
>
<!-- <article <!-- <article
v-if="element.type.match('image.*')" v-if="element.type.match('image.*')"
tabindex="0" tabindex="0"
@ -160,17 +190,16 @@
</div> </div>
</section> </section>
</article> --> </article> -->
<article tabindex="0" <article tabindex="0" class="bg-red-100 group w-full h-full rounded-md cursor-pointer relative shadow-sm">
class="bg-red-100 group w-full h-full rounded-md cursor-pointer relative shadow-sm"> <section class="flex flex-col rounded-md text-xs break-words w-full h-full z-20 absolute top-0 py-2 px-3">
<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">{{ element.name }}</h1> <h1 class="flex-1 text-gray-700 group-hover:text-blue-800">{{ element.name }}</h1>
<div class="flex"> <div class="flex">
<!-- <p class="p-1 size text-xs text-gray-700">{{ getFileSize(element) }}</p> --> <!-- <p class="p-1 size text-xs text-gray-700">{{ getFileSize(element) }}</p> -->
<p class="p-1 size text-xs text-gray-700">{{ element.sort_order }}</p> <p class="p-1 size text-xs text-gray-700">{{ element.sort_order }}</p>
<button <button
class="delete ml-auto focus:outline-none hover:bg-gray-300 p-1 rounded-md text-gray-800" class="delete ml-auto focus:outline-none hover:bg-gray-300 p-1 rounded-md text-gray-800"
@click.prevent="reactivateFile(index)"> @click.prevent="reactivateFile(index)"
>
<RefreshIcon></RefreshIcon> <RefreshIcon></RefreshIcon>
</button> </button>
</div> </div>
@ -183,17 +212,19 @@
<div v-if="fileErrors" class="flex flex-col mt-6 animate-fade-in" v-for="fileError in fileErrors"> <div v-if="fileErrors" class="flex flex-col mt-6 animate-fade-in" v-for="fileError in fileErrors">
<div class="bg-yellow-500 border-l-4 border-orange-400 text-white p-4" role="alert"> <div class="bg-yellow-500 border-l-4 border-orange-400 text-white p-4" role="alert">
<p class="font-bold">Be Warned</p> <p class="font-bold">Be Warned</p>
<p>{{ fileError.join(', ') }}</p> <p>{{ formatError(fileError) }}</p>
</div> </div>
</div> </div>
</div> </div>
<!-- sticky footer --> <!-- sticky footer -->
<footer class="flex justify-end px-8 pb-8 pt-4"> <footer class="flex justify-end px-8 pb-8 pt-4">
<button v-if="showClearButton" id="cancel" <button
v-if="showClearButton"
id="cancel"
class="ml-3 rounded-sm px-3 py-1 hover:bg-gray-300 focus:shadow-outline focus:outline-none" class="ml-3 rounded-sm px-3 py-1 hover:bg-gray-300 focus:shadow-outline focus:outline-none"
@click="clearAllFiles"> @click="clearAllFiles"
>
Clear Clear
</button> </button>
</footer> </footer>
@ -247,10 +278,8 @@ interface InteriaPage {
}, },
}) })
class FileUploadComponent extends Vue { class FileUploadComponent extends Vue {
@Ref('overlay') overlay: HTMLDivElement; @Ref('overlay') overlay: HTMLDivElement;
public isLoading: boolean = false; public isLoading: boolean = false;
private counter: number = 0; private counter: number = 0;
// @Prop() files: Array<TestFile>; // @Prop() files: Array<TestFile>;
@ -261,7 +290,6 @@ class FileUploadComponent extends Vue {
}) })
files: Array<TethysFile | File>; files: Array<TethysFile | File>;
@Prop({ @Prop({
type: Array<File>, type: Array<File>,
default: [], default: [],
@ -335,6 +363,11 @@ class FileUploadComponent extends Vue {
++this.counter && this.overlay.classList.add('draggedover'); ++this.counter && this.overlay.classList.add('draggedover');
} }
public formatError(error: string | string[] | undefined) {
if (!error) return '';
return Array.isArray(error) ? error.join(', ') : error;
}
public dragLeaveHandler() { public dragLeaveHandler() {
1 > --this.counter && this.overlay.classList.remove('draggedover'); 1 > --this.counter && this.overlay.classList.remove('draggedover');
} }
@ -425,7 +458,6 @@ class FileUploadComponent extends Vue {
// this.isLoading = true; // this.isLoading = true;
// } // }
this._addFile(file); this._addFile(file);
} }
} }
// if (bigFileFound) { // if (bigFileFound) {

View file

@ -1,38 +1,32 @@
<script setup> <script setup>
import { computed, useSlots } from 'vue'; import { computed, useSlots } from 'vue';
const props = defineProps({ const props = defineProps({
label: { label: { type: String, default: null },
type: String, labelFor: { type: String, default: null },
default: null, help: { type: String, default: null },
}, // Handles Inertia.js string errors or standard array errors
labelFor: { errors: { type: [String, Array], default: null },
type: String,
default: null,
},
help: {
type: String,
default: null,
},
// class: {
// type: Object,
// default: {},
// },
}); });
const slots = useSlots(); const slots = useSlots();
// Normalize errors to an array for consistent rendering
const errorList = computed(() => {
if (!props.errors) return [];
return Array.isArray(props.errors) ? props.errors : [props.errors];
});
const hasErrors = computed(() => errorList.value.length > 0);
const wrapperClass = computed(() => { const wrapperClass = computed(() => {
const base = []; const base = [];
const slotsLength = slots.default().length; const children = slots.default?.().filter(node => node.type.toString() !== 'Symbol(v-cmt)') || [];
if (slotsLength > 1) { // Apply grid logic only if there are multiple child controls
if (children.length > 1) {
base.push('grid grid-cols-1 gap-3'); base.push('grid grid-cols-1 gap-3');
} if (children.length === 2) base.push('md:grid-cols-2');
if (slotsLength === 2) {
base.push('md:grid-cols-2');
} }
return base; return base;
@ -40,14 +34,42 @@ const wrapperClass = computed(() => {
</script> </script>
<template> <template>
<div :class="['last:mb-0', 'mb-6']"> <div class="mb-6 last:mb-0">
<!-- <label v-if="label" :for="labelFor" class="block font-bold mb-2">{{ label }}</label> --> <label
<label v-if="label" :for="labelFor" class="font-bold h-6 mt-3 text-xs leading-8 uppercase">{{ label }}</label> v-if="label"
<div v-bind:class="wrapperClass"> :for="labelFor"
class="block font-bold text-xs uppercase tracking-wide mb-2 transition-colors duration-200"
:class="hasErrors ? 'text-red-600 dark:text-red-400' : 'text-gray-700 dark:text-slate-300'"
>
{{ label }}
</label>
<div :class="wrapperClass">
<slot /> <slot />
</div> </div>
<div v-if="help" class="text-xs text-gray-500 dark:text-slate-400 mt-1">
<div class="mt-1 min-h-[1.25rem]">
<transition-group
enter-active-class="transition duration-150 ease-out"
enter-from-class="transform -translate-y-1 opacity-0"
enter-to-class="transform translate-y-0 opacity-100"
>
<p
v-for="(error, index) in errorList"
:key="`err-${index}`"
class="text-xs text-red-600 dark:text-red-400 italic font-medium"
>
{{ error }}
</p>
<p
v-if="!hasErrors && help"
key="help-text"
class="text-xs text-gray-500 dark:text-slate-400"
>
{{ help }} {{ help }}
</p>
</transition-group>
</div> </div>
</div> </div>
</template> </template>

View file

@ -1,23 +1,45 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref, Ref, defineProps, computed } from 'vue'; import { onMounted, onUnmounted, ref, Ref, computed } from 'vue';
import SectionMain from '@/Components/SectionMain.vue'; import SectionMain from '@/Components/SectionMain.vue';
import { Map } from 'leaflet/src/map/index'; import L, {
import { Rectangle } from 'leaflet'; Map as LeafletMap,
import { canvas } from 'leaflet/src/layer/vector/Canvas'; Rectangle,
import { svg } from 'leaflet/src/layer/vector/SVG'; LayerGroup,
import axios from 'axios'; Control,
import { LatLngBoundsExpression } from 'leaflet/src/geo/LatLngBounds'; type Layer,
type LatLng,
type LatLngBounds,
type LatLngBoundsExpression,
type MapOptions,
type Renderer,
type RendererOptions,
type LeafletEvent,
} from 'leaflet';
import { tileLayerWMS } from 'leaflet/src/layer/tile/TileLayer.WMS'; import { tileLayerWMS } from 'leaflet/src/layer/tile/TileLayer.WMS';
import { Attribution } from 'leaflet/src/control/Control.Attribution'; import axios from 'axios';
import DrawControlComponent from '@/Components/Map/draw.component.vue'; import DrawControlComponent from '@/Components/Map/draw.component.vue';
import ZoomControlComponent from '@/Components/Map/zoom.component.vue'; import ZoomControlComponent from '@/Components/Map/zoom.component.vue';
import { MapService } from '@/Stores/map.service'; import { MapService } from '@/Stores/map.service';
import { LayerGroup } from 'leaflet/src/layer/LayerGroup';
import { OpensearchDocument } from '@/Dataset'; import { OpensearchDocument } from '@/Dataset';
Map.include({ /**
getRenderer: function (layer) { * Leaflet's internal renderer machinery is not part of the public @types/leaflet
var renderer = layer.options.renderer || this._getPaneRenderer(layer.options.pane) || this.options.renderer || this._renderer; * surface, so we describe the bits we touch here. This keeps the mixin body typed
* instead of falling back to `any`.
*/
interface RendererCapableMap extends LeafletMap {
options: MapOptions & { renderer?: Renderer; preferCanvas?: boolean };
_renderer?: Renderer;
_paneRenderers: Record<string, Renderer | undefined>;
_getPaneRenderer(name?: string): Renderer | false;
_createRenderer(options?: RendererOptions): Renderer;
}
LeafletMap.include({
getRenderer(this: RendererCapableMap, layer: Layer): Renderer {
const layerOptions = layer.options as { renderer?: Renderer; pane?: string };
let renderer: Renderer | false | undefined =
layerOptions.renderer || this._getPaneRenderer(layerOptions.pane) || this.options.renderer || this._renderer;
if (!renderer) { if (!renderer) {
renderer = this._renderer = this._createRenderer(); renderer = this._renderer = this._createRenderer();
@ -29,12 +51,12 @@ Map.include({
return renderer; return renderer;
}, },
_getPaneRenderer: function (name) { _getPaneRenderer(this: RendererCapableMap, name?: string): Renderer | false {
if (name === 'overlayPane' || name === undefined) { if (name === 'overlayPane' || name === undefined) {
return false; return false;
} }
var renderer = this._paneRenderers[name]; let renderer = this._paneRenderers[name];
if (renderer === undefined) { if (renderer === undefined) {
renderer = this._createRenderer({ pane: name }); renderer = this._createRenderer({ pane: name });
this._paneRenderers[name] = renderer; this._paneRenderers[name] = renderer;
@ -42,16 +64,15 @@ Map.include({
return renderer; return renderer;
}, },
_createRenderer: function (options) { _createRenderer(this: RendererCapableMap, options?: RendererOptions): Renderer {
return (this.options.preferCanvas && canvas(options)) || svg(options); return (this.options.preferCanvas && L.canvas(options)) || L.svg(options);
}, },
}); });
const DEFAULT_BASE_LAYER_NAME = 'BaseLayer'; const DEFAULT_BASE_LAYER_NAME = 'BaseLayer';
const DEFAULT_BASE_LAYER_ATTRIBUTION = '&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors'; const DEFAULT_BASE_LAYER_ATTRIBUTION = '&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors';
const OPENSEARCH_HOST = 'https://catalog.geosphere.at';
let map: Map; let map: LeafletMap;
const props = defineProps({ const props = defineProps({
checkable: Boolean, checkable: Boolean,
@ -63,6 +84,12 @@ const props = defineProps({
type: String, type: String,
default: 'map', default: 'map',
}, },
// OpenSearch host is provided by the server (prop / shared data), never imported
// from server-only modules into client code.
opensearchHost: {
type: String,
default: 'localhost',
},
mapOptions: { mapOptions: {
type: Object, type: Object,
default: () => ({ default: () => ({
@ -74,7 +101,9 @@ const props = defineProps({
}, },
}); });
const items = computed({ const OPENSEARCH_HOST = computed(() => `${window.location.protocol}//${props.opensearchHost}:9200`);
const items = computed<OpensearchDocument[]>({
get() { get() {
return props.datasets; return props.datasets;
}, },
@ -84,19 +113,43 @@ const items = computed({
}, },
}); });
const resultCount = computed(() => items.value.length);
const fitBounds: LatLngBoundsExpression = [ const fitBounds: LatLngBoundsExpression = [
[46.4318173285, 9.47996951665], [46.4318173285, 9.47996951665],
[49.0390742051, 16.9796667823], [49.0390742051, 16.9796667823],
]; ];
const drawControl: Ref<DrawControlComponent | null> = ref(null); const drawControl = ref<InstanceType<typeof DrawControlComponent> | null>(null);
const southWest = ref(null); const southWest = ref<LatLng | null>(null);
const northEast = ref(null); const northEast = ref<LatLng | null>(null);
const mapService = MapService(); const mapService = MapService();
const isLoading = ref(false); const isLoading = ref(false);
const hasSearched = ref(false);
const filterLayerGroup = new LayerGroup(); const filterLayerGroup = new LayerGroup();
/** Minimal shape of the leaflet-draw "created" event (no @types/leaflet-draw needed). */
interface DrawCreatedEvent extends LeafletEvent {
layer: Layer & { getBounds(): LatLngBounds };
layerType: string;
}
/** Shape of the OpenSearch _search response we rely on. */
interface OpenSearchHit {
_source: OpensearchDocument & {
bbox_xmin: number;
bbox_xmax: number;
bbox_ymin: number;
bbox_ymax: number;
};
}
interface OpenSearchSearchResponse {
hits: {
hits: OpenSearchHit[];
};
}
onMounted(() => { onMounted(() => {
initMap(); initMap();
}); });
@ -105,8 +158,8 @@ onUnmounted(() => {
map.off('zoomend zoomlevelschange'); map.off('zoomend zoomlevelschange');
}); });
const initMap = async () => { const initMap = async (): Promise<void> => {
map = new Map('map', props.mapOptions); map = new LeafletMap('map', props.mapOptions as MapOptions);
mapService.setMap(props.mapId, map); mapService.setMap(props.mapId, map);
map.scrollWheelZoom.disable(); map.scrollWheelZoom.disable();
map.fitBounds(fitBounds); map.fitBounds(fitBounds);
@ -114,37 +167,39 @@ const initMap = async () => {
map.addLayer(filterLayerGroup); map.addLayer(filterLayerGroup);
const attributionControl = new Attribution().addTo(map); const attributionControl = new Control.Attribution().addTo(map);
attributionControl.setPrefix(false); attributionControl.setPrefix(false);
let osmGgray = tileLayerWMS('https://ows.terrestris.de/osm-gray/service', { const osmGray = tileLayerWMS('https://ows.terrestris.de/osm-gray/service', {
format: 'image/png', format: 'image/png',
attribution: DEFAULT_BASE_LAYER_ATTRIBUTION, attribution: DEFAULT_BASE_LAYER_ATTRIBUTION,
layers: 'OSM-WMS', layers: 'OSM-WMS',
}); });
let layerOptions = { const layerOptions = {
label: DEFAULT_BASE_LAYER_NAME, label: DEFAULT_BASE_LAYER_NAME,
visible: true, visible: true,
layer: osmGgray, layer: osmGray,
}; };
layerOptions.layer.addTo(map); layerOptions.layer.addTo(map);
map.on('Draw.Event.CREATED', handleDrawEventCreated); map.on('Draw.Event.CREATED', handleDrawEventCreated as L.LeafletEventHandlerFn);
}; };
const handleDrawEventCreated = async (event) => { const handleDrawEventCreated = async (event: DrawCreatedEvent): Promise<void> => {
isLoading.value = true; isLoading.value = true;
hasSearched.value = true;
filterLayerGroup.clearLayers(); filterLayerGroup.clearLayers();
items.value = []; items.value = [];
let layer = event.layer; const bounds: LatLngBounds = event.layer.getBounds();
let bounds = layer.getBounds();
try { try {
let response = await axios({ // NOTE: OpenSearch _search with a query body must be POST browsers/XHR
// drop the request body on GET, which would send an empty query.
const response = await axios<OpenSearchSearchResponse>({
method: 'POST', method: 'POST',
url: OPENSEARCH_HOST + '/tethys-records/_search', url: OPENSEARCH_HOST.value + '/tethys-records/_search',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
data: { data: {
size: 1000, size: 1000,
@ -172,17 +227,17 @@ const handleDrawEventCreated = async (event) => {
}, },
}); });
response.data.hits.hits.forEach((hit) => { response.data.hits.hits.forEach((hit: OpenSearchHit) => {
let xMin = hit._source.bbox_xmin; const xMin = hit._source.bbox_xmin;
let xMax = hit._source.bbox_xmax; const xMax = hit._source.bbox_xmax;
let yMin = hit._source.bbox_ymin; const yMin = hit._source.bbox_ymin;
let yMax = hit._source.bbox_ymax; const yMax = hit._source.bbox_ymax;
var bbox: LatLngBoundsExpression = [ const bbox: LatLngBoundsExpression = [
[yMin, xMin], [yMin, xMin],
[yMax, xMax], [yMax, xMax],
]; ];
let rect = new Rectangle(bbox, { const rect = new Rectangle(bbox, {
color: '#65DC21', color: '#65DC21',
weight: 2, weight: 2,
fillColor: '#65DC21', fillColor: '#65DC21',
@ -193,7 +248,14 @@ const handleDrawEventCreated = async (event) => {
items.value.push(hit._source); items.value.push(hit._source);
}); });
} catch (error) { } catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 400) {
// Mapping in diesem Index unterstützt keine geo_shape-Suche
console.warn('Geo search unavailable for this index mapping');
// optional: ein dezentes Hinweis-Flag setzen, z. B. searchUnavailable.value = true
} else {
console.error(error); console.error(error);
}
items.value = [];
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
@ -202,74 +264,177 @@ const handleDrawEventCreated = async (event) => {
<template> <template>
<SectionMain> <SectionMain>
<div class="map-container-wrapper"> <div class="map-shell">
<!-- Loading Overlay --> <!-- Header -->
<div v-if="isLoading" class="loading-overlay"> <div class="map-header">
<div class="loading-spinner"></div> <div class="map-header-title">
<p class="loading-text">Searching datasets...</p> <span class="map-dot"></span>
<h2>Geospatial Discovery</h2>
</div>
<transition name="badge">
<div v-if="hasSearched && !isLoading" class="result-badge" :class="{ 'is-empty': resultCount === 0 }">
{{ resultCount }} {{ resultCount === 1 ? 'dataset' : 'datasets' }}
</div>
</transition>
</div> </div>
<!-- Map Instructions Banner --> <div class="map-container-wrapper">
<!-- Loading Overlay -->
<transition name="fade">
<div v-if="isLoading" class="loading-overlay">
<div class="loading-spinner"></div>
<p class="loading-text">Searching datasets</p>
</div>
</transition>
<!-- Floating instruction chip -->
<div class="map-instructions"> <div class="map-instructions">
<svg class="instruction-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="instruction-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" /> <path d="M12 16v-4M12 8h.01" />
</svg> </svg>
<p class="instruction-text"> <p class="instruction-text"><strong>Tip:</strong> draw an area to discover datasets within it</p>
<strong>Tip:</strong> Use the drawing tool to select an area on the map and discover datasets
</p>
</div> </div>
<!-- Floating "no results" hint -->
<transition name="fade">
<div v-if="hasSearched && !isLoading && resultCount === 0" class="empty-hint">
No datasets in the selected area try a larger region.
</div>
</transition>
<div id="map" class="map-container"> <div id="map" class="map-container">
<ZoomControlComponent ref="zoomControl" :mapId="mapId"></ZoomControlComponent> <ZoomControlComponent ref="zoomControl" :mapId="mapId"></ZoomControlComponent>
<DrawControlComponent ref="drawControl" :preserve="false" :mapId="mapId" :southWest="southWest" :northEast="northEast"> <DrawControlComponent ref="drawControl" :preserve="false" :mapId="mapId" :southWest="southWest" :northEast="northEast">
</DrawControlComponent> </DrawControlComponent>
</div> </div>
</div> </div>
</div>
</SectionMain> </SectionMain>
</template> </template>
<style scoped> <style scoped>
.map-container-wrapper { .map-shell {
position: relative; border-radius: 1.25rem;
border-radius: 1rem;
overflow: hidden; overflow: hidden;
background: white; background: white;
box-shadow: box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 10px 30px -12px rgba(0, 0, 0, 0.25),
0 2px 4px -1px rgba(0, 0, 0, 0.06); 0 4px 8px -4px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(0, 0, 0, 0.05);
}
.dark .map-shell {
background: #1f2937;
border-color: rgba(255, 255, 255, 0.06);
}
/* Header */
.map-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: white;
}
.map-header-title {
display: flex;
align-items: center;
gap: 0.65rem;
}
.map-header-title h2 {
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.01em;
margin: 0;
}
.map-dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 9999px;
background: #65dc21;
box-shadow: 0 0 0 0 rgba(101, 220, 33, 0.6);
animation: pulseDot 2s infinite;
}
@keyframes pulseDot {
0% {
box-shadow: 0 0 0 0 rgba(101, 220, 33, 0.6);
}
70% {
box-shadow: 0 0 0 0.5rem rgba(101, 220, 33, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(101, 220, 33, 0);
}
}
.result-badge {
font-size: 0.8rem;
font-weight: 600;
padding: 0.35rem 0.85rem;
border-radius: 9999px;
background: rgba(101, 220, 33, 0.15);
color: #a3f57c;
border: 1px solid rgba(101, 220, 33, 0.4);
}
.result-badge.is-empty {
background: rgba(148, 163, 184, 0.15);
color: #cbd5e1;
border-color: rgba(148, 163, 184, 0.35);
}
/* Map Container */
.map-container-wrapper {
position: relative;
background: #f9fafb;
} }
.dark .map-container-wrapper { .dark .map-container-wrapper {
background: #1f2937; background: #111827;
} }
/* Map Instructions Banner */ /* Floating instruction chip */
.map-instructions { .map-instructions {
position: absolute;
top: 1rem;
left: 1rem;
z-index: 500;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.6rem;
padding: 1rem 1.5rem; padding: 0.65rem 1rem;
background: linear-gradient(135deg, rgba(101, 220, 33, 0.1) 0%, rgba(53, 124, 6, 0.1) 100%); border-radius: 9999px;
border-bottom: 2px solid #e5e7eb; background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 6px 16px -6px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.6);
max-width: calc(100% - 2rem);
} }
.dark .map-instructions { .dark .map-instructions {
background: linear-gradient(135deg, rgba(101, 220, 33, 0.2) 0%, rgba(53, 124, 6, 0.2) 100%); background: rgba(31, 41, 55, 0.8);
border-bottom-color: #374151; border-color: rgba(255, 255, 255, 0.08);
} }
.instruction-icon { .instruction-icon {
width: 1.5rem; width: 1.15rem;
height: 1.5rem; height: 1.15rem;
color: #65dc21; color: #65dc21;
flex-shrink: 0; flex-shrink: 0;
} }
.instruction-text { .instruction-text {
font-size: 0.875rem; font-size: 0.8125rem;
color: #4b5563; color: #4b5563;
margin: 0; margin: 0;
white-space: nowrap;
} }
.dark .instruction-text { .dark .instruction-text {
@ -277,28 +442,53 @@ const handleDrawEventCreated = async (event) => {
} }
.instruction-text strong { .instruction-text strong {
color: #4d9e1a;
font-weight: 700;
}
.dark .instruction-text strong {
color: #65dc21; color: #65dc21;
font-weight: 600; }
/* Floating empty hint */
.empty-hint {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 500;
padding: 0.55rem 1.1rem;
border-radius: 9999px;
font-size: 0.8125rem;
font-weight: 500;
color: #475569;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 6px 16px -6px rgba(0, 0, 0, 0.25);
}
.dark .empty-hint {
background: rgba(31, 41, 55, 0.85);
color: #cbd5e1;
} }
/* Loading Overlay */ /* Loading Overlay */
.loading-overlay { .loading-overlay {
position: absolute; position: absolute;
top: 0; inset: 0;
left: 0; background: rgba(255, 255, 255, 0.9);
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.95);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: 1000;
backdrop-filter: blur(4px); backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
} }
.dark .loading-overlay { .dark .loading-overlay {
background: rgba(31, 41, 55, 0.95); background: rgba(17, 24, 39, 0.9);
} }
.loading-spinner { .loading-spinner {
@ -325,6 +515,10 @@ const handleDrawEventCreated = async (event) => {
margin-top: 1rem; margin-top: 1rem;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: #4d9e1a;
}
.dark .loading-text {
color: #65dc21; color: #65dc21;
} }
@ -369,15 +563,14 @@ const handleDrawEventCreated = async (event) => {
/* Control Enhancements */ /* Control Enhancements */
:deep(.leaflet-control) { :deep(.leaflet-control) {
border-radius: 0.5rem; border-radius: 0.65rem;
box-shadow: box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.2);
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
border: none; border: none;
overflow: hidden;
} }
:deep(.leaflet-bar a) { :deep(.leaflet-bar a) {
border-radius: 0.5rem; border-radius: 0;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
@ -402,34 +595,50 @@ const handleDrawEventCreated = async (event) => {
/* Popup Enhancements */ /* Popup Enhancements */
:deep(.leaflet-popup-content-wrapper) { :deep(.leaflet-popup-content-wrapper) {
border-radius: 0.75rem; border-radius: 0.85rem;
box-shadow: box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.2);
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
} }
:deep(.leaflet-popup-tip) { :deep(.leaflet-popup-tip) {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
/* Transitions */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.badge-enter-active,
.badge-leave-active {
transition: all 0.25s ease;
}
.badge-enter-from,
.badge-leave-to {
opacity: 0;
transform: scale(0.85);
}
/* Responsive Design */ /* Responsive Design */
@media (max-width: 768px) { @media (max-width: 768px) {
.map-container { .map-container,
height: 400px; :deep(.leaflet-container) {
} height: 420px;
.map-instructions {
padding: 0.75rem 1rem;
} }
.instruction-text { .instruction-text {
font-size: 0.8125rem; white-space: normal;
} }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.map-container { .map-container,
height: 350px; :deep(.leaflet-container) {
height: 360px;
} }
} }
</style> </style>

View file

@ -72,7 +72,7 @@ const menuOpenLg = () => {
layoutStore.isAsideLgActive = true; layoutStore.isAsideLgActive = true;
}; };
const userHasRoles = (roleNames: Array<string>): boolean => { const userHasRoles = (roleNames: Array<string>): boolean => {
return user.value.roles.some(role => roleNames.includes(role.name)); return user.value.roles.some(role => roleNames.includes(role));
}; };
// const logout = () => { // const logout = () => {

View file

@ -1,15 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
// import { MainService } from '@/Stores/main';
import { StyleService } from '@/Stores/style.service'; import { StyleService } from '@/Stores/style.service';
import { mdiTrashCan } from '@mdi/js'; import { mdiTrashCan } from '@mdi/js';
// import CardBoxModal from '@/Components/CardBoxModal.vue';
// import TableCheckboxCell from '@/Components/TableCheckboxCell.vue';
import BaseLevel from '@/Components/BaseLevel.vue'; import BaseLevel from '@/Components/BaseLevel.vue';
import BaseButtons from '@/Components/BaseButtons.vue'; import BaseButtons from '@/Components/BaseButtons.vue';
import BaseButton from '@/Components/BaseButton.vue'; import BaseButton from '@/Components/BaseButton.vue';
import { Subject } from '@/Dataset'; import { Subject } from '@/Dataset';
// import FormField from '@/Components/FormField.vue';
import FormControl from '@/Components/FormControl.vue'; import FormControl from '@/Components/FormControl.vue';
import SearchCategoryAutocomplete from '@/Components/SearchCategoryAutocomplete.vue'; import SearchCategoryAutocomplete from '@/Components/SearchCategoryAutocomplete.vue';
import { mdiRefresh } from '@mdi/js'; import { mdiRefresh } from '@mdi/js';
@ -47,14 +43,10 @@ const deletetSubjects = computed({
}); });
const styleService = StyleService(); const styleService = StyleService();
// const mainService = MainService();
const items = computed(() => props.keywords); const items = computed(() => props.keywords);
// const isModalActive = ref(false);
// const isModalDangerActive = ref(false);
const perPage = ref(5); const perPage = ref(5);
const currentPage = ref(0); const currentPage = ref(0);
// const checkedRows = ref([]);
const itemsPaginated = computed(() => { const itemsPaginated = computed(() => {
return items.value.slice(perPage.value * currentPage.value, perPage.value * (currentPage.value + 1)); return items.value.slice(perPage.value * currentPage.value, perPage.value * (currentPage.value + 1));
@ -75,7 +67,6 @@ const pagesList = computed(() => {
}); });
const removeItem = (key: number) => { const removeItem = (key: number) => {
// items.value.splice(key, 1);
const item = items.value[key]; const item = items.value[key];
// If the item has an ID, add it to the delete list // If the item has an ID, add it to the delete list
@ -95,7 +86,6 @@ const addToDeleteList = (subject: Subject) => {
} }
}; };
// Helper function to reactivate a subject (remove from delete list) // Helper function to reactivate a subject (remove from delete list)
const reactivateSubject = (index: number) => { const reactivateSubject = (index: number) => {
const newList = [...props.subjectsToDelete]; const newList = [...props.subjectsToDelete];
@ -111,22 +101,18 @@ const reactivateSubject = (index: number) => {
const isKeywordReadOnly = (item: Subject) => { const isKeywordReadOnly = (item: Subject) => {
return (item.dataset_count ?? 0) > 1 || item.type !== 'uncontrolled'; return (item.dataset_count ?? 0) > 1 || item.type !== 'uncontrolled';
}; };
const formatError = (error: string | string[] | undefined | null): string => {
if (!error) return '';
return Array.isArray(error) ? error.join(', ') : error;
};
</script> </script>
<template> <template>
<!-- <div v-if="checkedRows.length" class="p-3 bg-gray-100/50 dark:bg-slate-800">
<span v-for="checkedRow in checkedRows" :key="checkedRow.id"
class="inline-block px-2 py-1 rounded-sm mr-2 text-sm bg-gray-100 dark:bg-slate-700">
{{ checkedRow.name }}
</span>
</div> -->
<table> <table>
<thead> <thead>
<tr> <tr>
<!-- <th v-if="checkable" /> -->
<!-- <th class="hidden lg:table-cell"></th> -->
<th scope="col">Type</th> <th scope="col">Type</th>
<th scope="col" class="relative"> <th scope="col" class="relative">
Value Value
@ -150,14 +136,14 @@ const isKeywordReadOnly = (item: Subject) => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(item, index) in itemsPaginated" :key="index"> <tr v-for="(item, index) in itemsPaginated" :key="item.id ?? index">
<td data-label="Type" scope="row"> <td data-label="Type" scope="row">
<FormControl required v-model="item.type" <FormControl required v-model="item.type"
@update:modelValue="() => { item.value = ''; }" :type="'select'" @update:modelValue="() => { item.value = ''; }" :type="'select'"
placeholder="[Enter Language]" :options="props.subjectTypes"> placeholder="[Enter Language]" :options="props.subjectTypes">
<div class="text-red-400 text-sm" v-if="errors[`subjects.${index}.type`]"> <div class="text-red-400 text-sm" v-if="errors[`subjects.${index}.type`]">
{{ errors[`subjects.${index}.type`].join(', ') }} {{ formatError(errors[`subjects.${index}.type`]) }}
</div> </div>
</FormControl> </FormControl>
</td> </td>
@ -170,14 +156,14 @@ const isKeywordReadOnly = (item: Subject) => {
} }
" :is-read-only="item.dataset_count > 1"> " :is-read-only="item.dataset_count > 1">
<div class="text-red-400 text-sm" v-if="errors[`subjects.${index}.value`]"> <div class="text-red-400 text-sm" v-if="errors[`subjects.${index}.value`]">
{{ errors[`subjects.${index}.value`].join(', ') }} {{ formatError(errors[`subjects.${index}.value`]) }}
</div> </div>
</SearchCategoryAutocomplete> </SearchCategoryAutocomplete>
<FormControl v-else required v-model="item.value" type="text" placeholder="[enter keyword value]" <FormControl v-else required v-model="item.value" type="text" placeholder="[enter keyword value]"
:borderless="true" :is-read-only="item.dataset_count > 1"> :borderless="true" :is-read-only="item.dataset_count > 1">
<div class="text-red-400 text-sm" v-if="errors[`subjects.${index}.value`]"> <div class="text-red-400 text-sm" v-if="errors[`subjects.${index}.value`]">
{{ errors[`subjects.${index}.value`].join(', ') }} {{ formatError(errors[`subjects.${index}.value`]) }}
</div> </div>
</FormControl> </FormControl>
</td> </td>
@ -186,7 +172,7 @@ const isKeywordReadOnly = (item: Subject) => {
<FormControl required v-model="item.language" :type="'select'" placeholder="[Enter Lang]" <FormControl required v-model="item.language" :type="'select'" placeholder="[Enter Lang]"
:options="{ de: 'de', en: 'en' }" :is-read-only="isKeywordReadOnly(item)"> :options="{ de: 'de', en: 'en' }" :is-read-only="isKeywordReadOnly(item)">
<div class="text-red-400 text-sm" v-if="errors[`subjects.${index}.language`]"> <div class="text-red-400 text-sm" v-if="errors[`subjects.${index}.language`]">
{{ errors[`subjects.${index}.language`].join(', ') }} {{ formatError(errors[`subjects.${index}.language`]) }}
</div> </div>
</FormControl> </FormControl>
</td> </td>
@ -199,7 +185,6 @@ const isKeywordReadOnly = (item: Subject) => {
<td class="before:hidden lg:w-1 whitespace-nowrap" scope="row"> <td class="before:hidden lg:w-1 whitespace-nowrap" scope="row">
<BaseButtons type="justify-start lg:justify-end" no-wrap> <BaseButtons type="justify-start lg:justify-end" no-wrap>
<!-- <BaseButton color="info" :icon="mdiEye" small @click="isModalActive = true" /> -->
<BaseButton color="danger" :icon="mdiTrashCan" small @click.prevent="removeItem(index)" /> <BaseButton color="danger" :icon="mdiTrashCan" small @click.prevent="removeItem(index)" />
</BaseButtons> </BaseButtons>
</td> </td>
@ -207,7 +192,6 @@ const isKeywordReadOnly = (item: Subject) => {
</tbody> </tbody>
</table> </table>
<!-- :class="[ pagesList.length > 1 ? 'block' : 'hidden']" -->
<div class="p-3 lg:px-6 border-t border-gray-100 dark:border-slate-800"> <div class="p-3 lg:px-6 border-t border-gray-100 dark:border-slate-800">
<BaseLevel> <BaseLevel>
<BaseButtons> <BaseButtons>
@ -218,8 +202,9 @@ const isKeywordReadOnly = (item: Subject) => {
</BaseLevel> </BaseLevel>
</div> </div>
<div class="text-red-400 text-sm" v-if="errors.subjects && Array.isArray(errors.subjects)"> <!-- Aggregate error for the whole subjects collection, e.g. "at least 3 keywords must be defined" -->
{{ errors.subjects.join(', ') }} <div class="text-red-400 text-sm" v-if="errors.subjects">
{{ formatError(errors.subjects) }}
</div> </div>
<!-- Subjects to delete section --> <!-- Subjects to delete section -->

View file

@ -1,13 +1,22 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Head, usePage } from '@inertiajs/vue3'; import { Head, usePage } from '@inertiajs/vue3';
import { mdiLicense, mdiCheckCircle, mdiCloseCircle, mdiAlertBoxOutline } from '@mdi/js'; import {
mdiLicense,
mdiCheckCircle,
mdiCloseCircle,
mdiAlertBoxOutline,
mdiFileDocumentOutline,
mdiCheckCircleOutline,
mdiPauseCircleOutline,
} from '@mdi/js';
import { computed, ComputedRef } from 'vue'; import { computed, ComputedRef } from 'vue';
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue'; import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
import SectionMain from '@/Components/SectionMain.vue'; import SectionMain from '@/Components/SectionMain.vue';
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue'; import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
import BaseButton from '@/Components/BaseButton.vue'; import BaseButton from '@/Components/BaseButton.vue';
import CardBox from '@/Components/CardBox.vue';
import BaseButtons from '@/Components/BaseButtons.vue'; import BaseButtons from '@/Components/BaseButtons.vue';
import BaseIcon from '@/Components/BaseIcon.vue';
import CardBox from '@/Components/CardBox.vue';
import NotificationBar from '@/Components/NotificationBar.vue'; import NotificationBar from '@/Components/NotificationBar.vue';
import { stardust } from '@eidellev/adonis-stardust/client'; import { stardust } from '@eidellev/adonis-stardust/client';
@ -32,6 +41,8 @@ const props = defineProps({
const flash: ComputedRef<any> = computed(() => usePage().props.flash); const flash: ComputedRef<any> = computed(() => usePage().props.flash);
const licenseCount = computed(() => props.licenses.length); const licenseCount = computed(() => props.licenses.length);
const activeCount = computed(() => props.licenses.filter((l) => l.active).length);
const inactiveCount = computed(() => licenseCount.value - activeCount.value);
const getLicenseColor = (index: number) => { const getLicenseColor = (index: number) => {
const colors = [ const colors = [
@ -51,18 +62,47 @@ const getLicenseColor = (index: number) => {
<Head title="Licenses" /> <Head title="Licenses" />
<SectionMain> <SectionMain>
<SectionTitleLineWithButton :icon="mdiLicense" title="Licenses" main> <SectionTitleLineWithButton :icon="mdiLicense" title="Licenses" main>
<div class="flex items-center gap-3">
<span class="text-sm text-gray-500 dark:text-gray-400 font-medium"> <span class="text-sm text-gray-500 dark:text-gray-400 font-medium">
{{ licenseCount }} {{ licenseCount === 1 ? 'license' : 'licenses' }} {{ licenseCount }} {{ licenseCount === 1 ? 'license' : 'licenses' }}
</span> </span>
</div>
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline"> <NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline">
{{ flash.message }} {{ flash.message }}
</NotificationBar> </NotificationBar>
<CardBox class="mb-6" has-table> <!-- Summary stats -->
<div class="reveal grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div class="flex items-center gap-4 rounded-xl border-l-4 border-blue-500 bg-white dark:bg-slate-900/40 shadow-sm p-4">
<div class="flex items-center justify-center w-11 h-11 rounded-lg bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-300">
<BaseIcon :path="mdiFileDocumentOutline" size="22" />
</div>
<div>
<p class="text-2xl font-bold text-gray-900 dark:text-white leading-none">{{ licenseCount }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Total</p>
</div>
</div>
<div class="flex items-center gap-4 rounded-xl border-l-4 border-emerald-500 bg-white dark:bg-slate-900/40 shadow-sm p-4">
<div class="flex items-center justify-center w-11 h-11 rounded-lg bg-emerald-100 dark:bg-emerald-900/40 text-emerald-600 dark:text-emerald-300">
<BaseIcon :path="mdiCheckCircleOutline" size="22" />
</div>
<div>
<p class="text-2xl font-bold text-gray-900 dark:text-white leading-none">{{ activeCount }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Active</p>
</div>
</div>
<div class="flex items-center gap-4 rounded-xl border-l-4 border-gray-400 bg-white dark:bg-slate-900/40 shadow-sm p-4">
<div class="flex items-center justify-center w-11 h-11 rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-300">
<BaseIcon :path="mdiPauseCircleOutline" size="22" />
</div>
<div>
<p class="text-2xl font-bold text-gray-900 dark:text-white leading-none">{{ inactiveCount }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Inactive</p>
</div>
</div>
</div>
<CardBox class="reveal reveal-1 mb-6" has-table>
<table> <table>
<thead> <thead>
<tr> <tr>
@ -75,9 +115,12 @@ const getLicenseColor = (index: number) => {
<tbody> <tbody>
<tr v-if="licenses.length === 0"> <tr v-if="licenses.length === 0">
<td colspan="4" class="text-center py-12"> <td :colspan="can.edit ? 4 : 3" class="text-center py-14">
<div class="flex flex-col items-center justify-center text-gray-500 dark:text-gray-400"> <div class="flex flex-col items-center justify-center text-gray-500 dark:text-gray-400">
<p class="text-lg font-medium mb-2">No licenses found</p> <div class="flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-slate-800 mb-4">
<BaseIcon :path="mdiLicense" size="32" class="text-gray-400" />
</div>
<p class="text-lg font-medium mb-1">No licenses found</p>
<p class="text-sm">Licenses will appear here once configured</p> <p class="text-sm">Licenses will appear here once configured</p>
</div> </div>
</td> </td>
@ -157,3 +200,29 @@ const getLicenseColor = (index: number) => {
</SectionMain> </SectionMain>
</LayoutAuthenticated> </LayoutAuthenticated>
</template> </template>
<style scoped>
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.reveal {
animation: fade-up 0.5s ease-out both;
}
.reveal-1 {
animation-delay: 0.1s;
}
@media (prefers-reduced-motion: reduce) {
.reveal {
animation: none;
}
}
</style>

View file

@ -1,6 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Head, useForm } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { mdiFolderPlus, mdiArrowLeftBoldOutline, mdiFormTextarea, mdiContentSave } from '@mdi/js'; import {
mdiFolderPlus,
mdiArrowLeftBoldOutline,
mdiFormTextarea,
mdiContentSave,
mdiTagOutline,
mdiText,
} from '@mdi/js';
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue'; import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
import SectionMain from '@/Components/SectionMain.vue'; import SectionMain from '@/Components/SectionMain.vue';
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue'; import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
@ -37,46 +44,48 @@ const submit = async () => {
/> />
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox form @submit.prevent="submit()" class="shadow-lg"> <CardBox form @submit.prevent="submit()" class="relative overflow-hidden shadow-lg">
<div class="grid grid-cols-1 gap-6"> <!-- Subtle accent bar -->
<FormField label="Label" help="Lowercase letters, numbers, and hyphens only" :class="{ 'text-red-400': form.errors.label }"> <div class="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-blue-500 via-indigo-500 to-blue-500"></div>
<div class="grid grid-cols-1 gap-7 pt-2">
<!-- Label -->
<FormField
label="Label"
help="Lowercase letters, numbers, and hyphens only"
:errors="form.errors.label"
>
<FormControl <FormControl
v-model="form.label" v-model="form.label"
:icon="mdiTagOutline"
type="text" type="text"
placeholder="e.g., my-awesome-project" placeholder="e.g., my-awesome-project"
required required
:error="form.errors.label" class="font-mono transition-all duration-200 focus-within:ring-2 focus-within:ring-blue-500/40"
class="transition-all focus:ring-2 focus:ring-blue-500" />
>
<div class="text-red-400 text-sm mt-1" v-if="form.errors.label">
{{ form.errors.label }}
</div>
</FormControl>
</FormField> </FormField>
<!-- Name -->
<FormField <FormField
label="Name" label="Name"
help="Required. Project title shown to users" help="Required. Project title shown to users"
:class="{ 'text-red-400': form.errors.name }" :errors="form.errors.name"
> >
<FormControl <FormControl
v-model="form.name" v-model="form.name"
:icon="mdiText"
type="text" type="text"
placeholder="Enter a descriptive titel..." placeholder="Enter a descriptive title..."
required required
:error="form.errors.name" class="transition-all duration-200 focus-within:ring-2 focus-within:ring-blue-500/40"
class="font-mono transition-all focus:ring-2 focus:ring-blue-500" />
>
<div class="text-red-400 text-sm mt-1" v-if="form.errors.name">
{{ form.errors.name }}
</div>
</FormControl>
</FormField> </FormField>
<!-- Description -->
<FormField <FormField
label="Description" label="Description"
help="Optional. Detailed description of the project" help="Optional. Detailed description of the project"
:class="{ 'text-red-400': form.errors.description }" :errors="form.errors.description"
> >
<FormControl <FormControl
v-model="form.description" v-model="form.description"
@ -84,19 +93,19 @@ const submit = async () => {
name="description" name="description"
type="textarea" type="textarea"
placeholder="Describe what this project is about..." placeholder="Describe what this project is about..."
:error="form.errors.description" class="transition-all duration-200 focus-within:ring-2 focus-within:ring-blue-500/40"
class="transition-all focus:ring-2 focus:ring-blue-500" />
>
<div class="text-red-400 text-sm mt-1" v-if="form.errors.description">
{{ form.errors.description }}
</div>
</FormControl>
</FormField> </FormField>
</div> </div>
<template #footer> <template #footer>
<BaseButtons class="justify-between"> <BaseButtons class="justify-between">
<BaseButton :route-name="stardust.route('settings.project.index')" label="Cancel" color="white" outline /> <BaseButton
:route-name="stardust.route('settings.project.index')"
label="Cancel"
color="white"
outline
/>
<BaseButton <BaseButton
type="submit" type="submit"
color="info" color="info"
@ -104,7 +113,7 @@ const submit = async () => {
label="Create Project" label="Create Project"
:class="{ 'opacity-25': form.processing }" :class="{ 'opacity-25': form.processing }"
:disabled="form.processing" :disabled="form.processing"
class="transition-all hover:shadow-lg" class="transition-all hover:shadow-lg hover:-translate-y-0.5"
/> />
</BaseButtons> </BaseButtons>
</template> </template>
@ -116,15 +125,17 @@ const submit = async () => {
> >
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-10 h-10 rounded-full bg-blue-500 dark:bg-blue-600 flex items-center justify-center"> <div
class="w-10 h-10 rounded-full bg-blue-500 dark:bg-blue-600 flex items-center justify-center shadow-md shadow-blue-500/30"
>
<span class="text-white text-lg">💡</span> <span class="text-white text-lg">💡</span>
</div> </div>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Quick Tips</h3> <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Quick Tips</h3>
<ul class="text-sm text-gray-700 dark:text-gray-300 space-y-1"> <ul class="text-sm text-gray-700 dark:text-gray-300 space-y-1">
<li> <strong>Label</strong> is a technical identifier (use lowercase and hyphens) </li> <li> <strong>Label</strong> is a technical identifier (use lowercase and hyphens)</li>
<li> <strong>Name</strong> is what users will see in the interface - short title</li> <li> <strong>Name</strong> is what users will see in the interface short title</li>
<li> <strong>Description</strong> helps team members understand the project's purpose</li> <li> <strong>Description</strong> helps team members understand the project's purpose</li>
</ul> </ul>
</div> </div>

View file

@ -1,6 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Head, useForm } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { mdiFolderEdit, mdiArrowLeftBoldOutline, mdiFormTextarea, mdiContentSave } from '@mdi/js'; import {
mdiFolderEdit,
mdiArrowLeftBoldOutline,
mdiFormTextarea,
mdiContentSave,
mdiTagOutline,
mdiText,
} from '@mdi/js';
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue'; import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
import SectionMain from '@/Components/SectionMain.vue'; import SectionMain from '@/Components/SectionMain.vue';
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue'; import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
@ -44,44 +51,39 @@ const submit = async () => {
/> />
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox form @submit.prevent="submit()" class="shadow-lg"> <CardBox form @submit.prevent="submit()" class="relative overflow-hidden shadow-lg">
<div class="grid grid-cols-1 gap-6"> <!-- Subtle accent bar -->
<FormField <div class="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-blue-500 via-indigo-500 to-blue-500"></div>
label="Label"
help="Lowercase letters, numbers, and hyphens only" <div class="grid grid-cols-1 gap-7 pt-2">
> <!-- Label (read-only) -->
<FormField label="Label" help="Lowercase letters, numbers, and hyphens only">
<FormControl <FormControl
v-model="form.label" v-model="form.label"
:icon="mdiTagOutline"
type="text" type="text"
help="Lowercase letters, numbers, and hyphens only" :is-read-only="true"
:is-read-only=true class="font-mono bg-gray-100 dark:bg-slate-800 cursor-not-allowed opacity-75"
class="bg-gray-100 dark:bg-slate-800 cursor-not-allowed opacity-75"
/> />
</FormField> </FormField>
<FormField <!-- Name -->
label="Name" <FormField label="Name" help="Required. Project title shown to users" :errors="form.errors.name">
help="Required. Project title shown to users"
:class="{ 'text-red-400': form.errors.name }"
>
<FormControl <FormControl
v-model="form.name" v-model="form.name"
:icon="mdiText"
type="text" type="text"
placeholder="Enter Name" placeholder="Enter Name"
required required
:error="form.errors.name" class="transition-all duration-200 focus-within:ring-2 focus-within:ring-blue-500/40"
class="font-mono transition-all focus:ring-2 focus:ring-blue-500" />
>
<div class="text-red-400 text-sm mt-1" v-if="form.errors.name">
{{ form.errors.name }}
</div>
</FormControl>
</FormField> </FormField>
<!-- Description -->
<FormField <FormField
label="Description" label="Description"
help="Optional. Detailed description of the project" help="Optional. Detailed description of the project"
:class="{ 'text-red-400': form.errors.description }" :errors="form.errors.description"
> >
<FormControl <FormControl
v-model="form.description" v-model="form.description"
@ -89,13 +91,8 @@ const submit = async () => {
name="description" name="description"
type="textarea" type="textarea"
placeholder="Enter project description..." placeholder="Enter project description..."
:error="form.errors.description" class="transition-all duration-200 focus-within:ring-2 focus-within:ring-blue-500/40"
class="transition-all focus:ring-2 focus:ring-blue-500" />
>
<div class="text-red-400 text-sm mt-1" v-if="form.errors.description">
{{ form.errors.description }}
</div>
</FormControl>
</FormField> </FormField>
</div> </div>
@ -114,17 +111,19 @@ const submit = async () => {
label="Save Changes" label="Save Changes"
:class="{ 'opacity-25': form.processing }" :class="{ 'opacity-25': form.processing }"
:disabled="form.processing" :disabled="form.processing"
class="transition-all hover:shadow-lg" class="transition-all hover:shadow-lg hover:-translate-y-0.5"
/> />
</BaseButtons> </BaseButtons>
</template> </template>
</CardBox> </CardBox>
<!-- Project Info Card --> <!-- Project Info Card -->
<CardBox class="mt-6 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-slate-800 dark:to-slate-900"> <CardBox class="mt-6 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-slate-800 dark:to-slate-900 border-l-4 border-blue-500">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-12 h-12 rounded-lg bg-blue-500 dark:bg-blue-600 flex items-center justify-center"> <div
class="w-12 h-12 rounded-lg bg-blue-500 dark:bg-blue-600 flex items-center justify-center shadow-md shadow-blue-500/30"
>
<span class="text-white text-xl font-bold"> <span class="text-white text-xl font-bold">
{{ project.label.charAt(0).toUpperCase() }} {{ project.label.charAt(0).toUpperCase() }}
</span> </span>

View file

@ -27,9 +27,20 @@ const form = useForm({
permissions: [], permissions: [],
}); });
const submit = async () => { const submit = () => {
await form.post(stardust.route('settings.role.store')); form.post(stardust.route('settings.role.store'), {
preserveScroll: true,
onSuccess: () => form.reset(),
});
}; };
/**
* Sicherer Helper für die Fehleranzeige
*/
// const formatError = (error: string | string[] | undefined) => {
// if (!error) return '';
// return Array.isArray(error) ? error.join(', ') : error;
// };
</script> </script>
<template> <template>
@ -46,58 +57,48 @@ const submit = async () => {
small small
/> />
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<!-- <CardBox form @submit.prevent="form.post(stardust.route('role.store'))"> -->
<CardBox form @submit.prevent="submit()"> <CardBox form @submit.prevent="submit">
<FormField label="Name" help="Required. Role name" :class="{ 'text-red-400': form.errors.name }"> <FormField label="Name" help="Required. Technical role name" :errors="form.errors.name">
<FormControl v-model="form.name" type="text" placeholder="Enter Name" required :error="form.errors.name"> <FormControl v-model="form.name" type="text" placeholder="e.g. manager" :error="form.errors.name"> </FormControl>
<div class="text-red-400 text-sm" v-if="form.errors.name"> </FormField>
{{ form.errors.name }}
</div> <FormField label="Display Name" help="Optional. Readable name" :errors="form.errors.display_name">
<FormControl v-model="form.display_name" placeholder="e.g. Project Manager" :error="form.errors.display_name">
</FormControl> </FormControl>
</FormField> </FormField>
<FormField label="Display Name" help="Optional. Display name" :class="{ 'text-red-400': form.errors.display_name }"> <FormField label="Description" help="Optional. What does this role do?" :errors="form.errors.description">
<FormControl v-model="form.display_name" name="display_name" :error="form.errors.display_name">
<div class="text-red-400 text-sm" v-if="form.errors.display_name">
{{ form.errors.display_name }}
</div>
</FormControl>
</FormField>
<FormField
label="Description"
help="Optional. Description of new role"
:class="{ 'text-red-400': form.errors.description }"
>
<FormControl <FormControl
v-model="form.description" v-model="form.description"
v-bind:icon="mdiFormTextarea" :icon="mdiFormTextarea"
name="display_name" type="textarea"
:type="'textarea'" placeholder="Role description..."
:error="form.errors.description" :error="form.errors.description"
> >
<div class="text-red-400 text-sm" v-if="form.errors.description">
{{ form.errors.description }}
</div>
</FormControl> </FormControl>
</FormField> </FormField>
<BaseDivider /> <BaseDivider />
<FormField label="Permissions" wrap-body> <FormField
label="Permissions"
wrap-body
:class="{ 'text-red-400': form.errors.permissions }"
:errors="form.errors.permissions"
>
<FormCheckRadioGroup v-model="form.permissions" name="permissions" is-column :options="props.permissions" /> <FormCheckRadioGroup v-model="form.permissions" name="permissions" is-column :options="props.permissions" />
<!-- <div class="text-red-400 text-sm mt-1" v-if="form.errors.permissions">
{{ formatError(form.errors.permissions) }}
</div> -->
</FormField> </FormField>
<div class="text-red-400 text-sm" v-if="form.errors.permissions && Array.isArray(form.errors.permissions)">
<!-- {{ errors.password_confirmation }} -->
{{ form.errors.permissions.join(', ') }}
</div>
<template #footer> <template #footer>
<BaseButtons> <BaseButtons>
<BaseButton <BaseButton
type="submit" type="submit"
color="info" color="info"
label="Submit" label="Create Role"
:class="{ 'opacity-25': form.processing }" :class="{ 'opacity-25': form.processing }"
:disabled="form.processing" :disabled="form.processing"
/> />

View file

@ -14,31 +14,45 @@ import BaseButtons from '@/Components/BaseButtons.vue';
import { stardust } from '@eidellev/adonis-stardust/client'; import { stardust } from '@eidellev/adonis-stardust/client';
const props = defineProps({ const props = defineProps({
role: { role: { type: Object, default: () => ({}) },
type: Object, permissions: { type: Object, default: () => ({}) },
default: () => ({}), roleHasPermissions: { type: Object, default: () => ({}) },
},
permissions: {
type: Object,
default: () => ({}),
},
roleHasPermissions: {
type: Object,
default: () => ({}),
},
}); });
const form = useForm({ const form = useForm({
_method: 'put',
name: props.role.name, name: props.role.name,
description: props.role.description, display_name: props.role.display_name || '', // Neu hinzugefügt
permissions: props.roleHasPermissions, description: props.role.description || '',
permissions: props.roleHasPermissions || [],
}); });
const submit = async () => { const submit = () => {
// await Inertia.post(stardust.route('user.store'), form); old form.put(stardust.route('settings.role.update', [props.role.id]), {
await form.put(stardust.route('settings.role.update', [props.role.id])); preserveScroll: true,
// await router.put(stardust.route('settings.role.update', [props.role.id]), form); });
}; };
/**
* Sicherer Helper für die Fehleranzeige
*/
// const formatError = (error: string | string[] | undefined) => {
// if (!error) return '';
// return Array.isArray(error) ? error.join(', ') : error;
// };
/**
* AUTOMATISCHER ERROR-CLEANER
* Löscht Fehlermeldungen sofort, wenn der User mit der Korrektur beginnt.
*/
// watch(() => ({ ...form.data() }), (newData, oldData) => {
// for (const key in newData) {
// // Vergleich via JSON.stringify für Arrays (permissions)
// if (JSON.stringify(newData[key]) !== JSON.stringify(oldData[key]) && form.errors[key]) {
// form.clearErrors(key as any);
// }
// }
// }, { deep: true });
</script> </script>
<template> <template>
@ -55,50 +69,38 @@ const submit = async () => {
small small
/> />
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<!-- <CardBox form @submit.prevent="form.put(stardust.route('role.update', [props.role.id]))"> -->
<CardBox form @submit.prevent="submit()"> <CardBox form @submit.prevent="submit">
<FormField label="Name" :class="{ 'text-red-400': form.errors.name }"> <FormField label="System Name" help="Technical identifier, cannot be changed." :errors="form.errors.name">
<FormControl v-model="form.name" type="text" placeholder="Enter Name" required :error="form.errors.name" :is-read-only=true> <FormControl v-model="form.name" :error="form.errors.name" :is-read-only="true" />
<div class="text-red-400 text-sm" v-if="form.errors.name">
{{ form.errors.name }}
</div>
</FormControl>
</FormField> </FormField>
<FormField <FormField label="Display Name" help="User-friendly name for this role." :errors="form.errors.display_name">
label="Description" <FormControl v-model="form.display_name" placeholder="e.g. Administrator" :error="form.errors.display_name" />
help="Optional. Description of new role" </FormField>
:class="{ 'text-red-400': form.errors.description }"
> <FormField label="Description" :errors="form.errors.description">
<FormControl <FormControl
v-model="form.description" v-model="form.description"
v-bind:icon="mdiFormTextarea" :icon="mdiFormTextarea"
name="display_name" type="textarea"
:type="'textarea'" placeholder="Role description..."
:error="form.errors.description" :error="form.errors.description"
> />
<div class="text-red-400 text-sm" v-if="form.errors.description">
{{ form.errors.description }}
</div>
</FormControl>
</FormField> </FormField>
<BaseDivider /> <BaseDivider />
<FormField label="Permissions" wrap-body> <FormField label="Permissions" :errors="form.errors.permissions">
<FormCheckRadioGroup v-model="form.permissions" name="permissions" is-column :options="props.permissions" /> <FormCheckRadioGroup v-model="form.permissions" name="permissions" is-column :options="props.permissions" />
</FormField> </FormField>
<div class="text-red-400 text-sm" v-if="form.errors.permissions && Array.isArray(form.errors.permissions)">
<!-- {{ errors.password_confirmation }} -->
{{ form.errors.permissions.join(', ') }}
</div>
<template #footer> <template #footer>
<BaseButtons> <BaseButtons>
<BaseButton <BaseButton
type="submit" type="submit"
color="info" color="info"
label="Submit" label="Update Role"
:class="{ 'opacity-25': form.processing }" :class="{ 'opacity-25': form.processing }"
:disabled="form.processing" :disabled="form.processing"
/> />

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { Head, useForm, router } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { mdiAccountKey, mdiArrowLeftBoldOutline } from '@mdi/js'; import { mdiAccountKey, mdiArrowLeftBoldOutline } from '@mdi/js';
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue'; import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
import SectionMain from '@/Components/SectionMain.vue'; import SectionMain from '@/Components/SectionMain.vue';
@ -13,29 +13,27 @@ import BaseDivider from '@/Components/BaseDivider.vue';
import BaseButton from '@/Components/BaseButton.vue'; import BaseButton from '@/Components/BaseButton.vue';
import BaseButtons from '@/Components/BaseButtons.vue'; import BaseButtons from '@/Components/BaseButtons.vue';
import { stardust } from '@eidellev/adonis-stardust/client'; import { stardust } from '@eidellev/adonis-stardust/client';
// import { Inertia } from '@inertiajs/inertia';
import PasswordMeter from '@/Components/SimplePasswordMeter/password-meter.vue'; import PasswordMeter from '@/Components/SimplePasswordMeter/password-meter.vue';
const enabled = ref(false);
const handleScore = (score: number) => {
if (score >= 4){
enabled.value = true;
} else {
enabled.value = false;
}
};
const props = defineProps({ const props = defineProps({
roles: { roles: {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
// Globale Errors als Fallback, falls nicht über useForm gearbeitet wird
errors: { errors: {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
}); });
const enabled = ref(false);
const handleScore = (score: number) => {
// Passwort muss stark genug sein (Score >= 4)
enabled.value = score >= 4;
};
const form = useForm({ const form = useForm({
login: '', login: '',
first_name: '', first_name: '',
@ -46,24 +44,30 @@ const form = useForm({
roles: [], roles: [],
}); });
const submit = async () => { const submit = () => {
// await router.post(stardust.route('settings.user.store'), form); // await router.post(stardust.route('settings.user.store'), form);
await form.post(stardust.route('settings.user.store'), { form.post(stardust.route('settings.user.store'), {
preserveScroll: true, preserveScroll: true,
onSuccess: () => { onSuccess: () => {
form.reset(); form.reset();
enabled.value = false;
}, },
onError: () => { onError: () => {
if (form.errors.new_password) { if (form.errors.new_password) {
form.reset('new_password'); form.reset('new_password', 'password_confirmation');
enabled.value = false; enabled.value = false;
// newPasswordInput.value.focus();
// newPasswordInput.value?.focus();
} }
}, },
}); });
}; };
/**
* Helper um Fehler sicher anzuzeigen (String oder Array)
*/
const formatError = (error: string | string[] | undefined) => {
if (!error) return '';
return Array.isArray(error) ? error.join(', ') : error;
};
</script> </script>
<template> <template>
@ -80,78 +84,68 @@ const submit = async () => {
small small
/> />
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<!-- @submit.prevent="form.post(stardust.route('settings.user.store'))" -->
<CardBox form @submit.prevent="submit()"> <CardBox form @submit.prevent="submit">
<FormField label="Login" :class="{ 'text-red-400': errors.login }"> <FormField label="Login" :class="{ 'text-red-400': form.errors.login }">
<FormControl v-model="form.login" type="text" placeholder="Enter Login" :errors="errors.login"> <FormControl v-model="form.login" type="text" placeholder="Enter Login" :errors="form.errors.login">
<div class="text-red-400 text-sm" v-if="errors.login && Array.isArray(errors.login)"> <div class="text-red-400 text-sm mt-1" v-if="form.errors.login">
<!-- {{ errors.login }} --> {{ formatError(form.errors.login) }}
{{ errors.login.join(', ') }}
</div> </div>
</FormControl> </FormControl>
</FormField> </FormField>
<FormField label="First Name" :class="{ 'text-red-400': errors.first_name }"> <FormField label="First Name" :class="{ 'text-red-400': form.errors.first_name }">
<FormControl v-model="form.first_name" type="text" placeholder="Enter First Name" :errors="errors.first_name"> <FormControl v-model="form.first_name" type="text" placeholder="Enter First Name" :errors="form.errors.first_name">
<div class="text-red-400 text-sm" v-if="errors.first_name && Array.isArray(errors.first_name)"> <div class="text-red-400 text-sm mt-1" v-if="form.errors.first_name">
{{ errors.first_name.join(', ') }} {{ formatError(form.errors.first_name) }}
</div> </div>
</FormControl> </FormControl>
</FormField> </FormField>
<FormField label="Last Name" :class="{ 'text-red-400': errors.last_name }"> <FormField label="Last Name" :class="{ 'text-red-400': form.errors.last_name }">
<FormControl v-model="form.last_name" type="text" placeholder="Enter Last Name" :errors="errors.last_name"> <FormControl v-model="form.last_name" type="text" placeholder="Enter Last Name" :errors="form.errors.last_name">
<div class="text-red-400 text-sm" v-if="errors.last_name && Array.isArray(errors.last_name)"> <div class="text-red-400 text-sm mt-1" v-if="form.errors.last_name">
{{ errors.last_name.join(', ') }} {{ formatError(form.errors.last_name) }}
</div> </div>
</FormControl> </FormControl>
</FormField> </FormField>
<FormField label="Email" :class="{ 'text-red-400': errors.email }"> <FormField label="Email" :class="{ 'text-red-400': form.errors.email }">
<FormControl v-model="form.email" type="text" placeholder="Enter Email" :errors="errors.email"> <FormControl v-model="form.email" type="text" placeholder="Enter Email" :errors="form.errors.email">
<div class="text-red-400 text-sm" v-if="errors.email && Array.isArray(errors.email)"> <div class="text-red-400 text-sm mt-1" v-if="form.errors.email">
<!-- {{ errors.email }} --> {{ formatError(form.errors.email) }}
{{ errors.email.join(', ') }}
</div> </div>
</FormControl> </FormControl>
</FormField> </FormField>
<!-- <FormField label="Password" :class="{ 'text-red-400': errors.password }"> <!-- <FormField label="Password" :class="{ 'text-red-400': form.errors.new_password }">
<FormControl v-model="form.password" type="password" placeholder="Enter Password" :errors="errors.password"> <div class="text-red-400 text-sm mt-1" v-if="form.errors.new_password">
<div class="text-red-400 text-sm" v-if="errors.password && Array.isArray(errors.password)"> {{ formatError(form.errors.new_password) }}
{{ errors.password.join(', ') }}
</div> </div>
</FormControl> </FormField> -->
</FormField>
<password-meter :password="form.password" @score="handleScore" /> -->
<PasswordMeter v-model="form.new_password" :errors="form.errors" @score="handleScore" /> <PasswordMeter v-model="form.new_password" :errors="form.errors" @score="handleScore" />
<FormField label="Password Confirmation" :class="{ 'text-red-400': errors.password_confirmation }"> <FormField label="Password Confirmation" :class="{ 'text-red-400': form.errors.password_confirmation }">
<FormControl <FormControl
v-model="form.password_confirmation" v-model="form.password_confirmation"
type="password" type="password"
placeholder="Enter Password Confirmation" placeholder="Confirm Password"
:errors="errors.password" :errors="form.errors.password_confirmation"
> >
<div <div class="text-red-400 text-sm mt-1" v-if="form.errors.password_confirmation">
class="text-red-400 text-sm" {{ formatError(form.errors.password_confirmation) }}
v-if="errors.password_confirmation && Array.isArray(errors.password_confirmation)"
>
<!-- {{ errors.password_confirmation }} -->
{{ errors.password_confirmation.join(', ') }}
</div> </div>
</FormControl> </FormControl>
</FormField> </FormField>
<BaseDivider /> <BaseDivider />
<FormField label="Roles" wrap-body :class="{ 'text-red-400': errors.roles }"> <FormField label="Roles" wrap-body :class="{ 'text-red-400': form.errors.roles }">
<FormCheckRadioGroup v-model="form.roles" name="roles" is-column :options="props.roles" /> <FormCheckRadioGroup v-model="form.roles" name="roles" is-column :options="props.roles" />
</FormField> <div class="text-red-400 text-sm mt-1" v-if="form.errors.roles">
<div class="text-red-400 text-sm" v-if="errors.roles && Array.isArray(errors.roles)"> {{ formatError(form.errors.roles) }}
<!-- {{ errors.password_confirmation }} -->
{{ errors.roles.join(', ') }}
</div> </div>
</FormField>
<template #footer> <template #footer>
<BaseButtons> <BaseButtons>
@ -160,7 +154,7 @@ const submit = async () => {
color="info" color="info"
label="Submit" label="Submit"
:class="{ 'opacity-25': form.processing }" :class="{ 'opacity-25': form.processing }"
:disabled="form.processing == true || enabled == false" :disabled="form.processing || !enabled"
/> />
</BaseButtons> </BaseButtons>
</template> </template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { Head, useForm, router } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { mdiAccountKey, mdiArrowLeftBoldOutline } from '@mdi/js'; import { mdiAccountKey, mdiArrowLeftBoldOutline } from '@mdi/js';
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue'; import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
import SectionMain from '@/Components/SectionMain.vue'; import SectionMain from '@/Components/SectionMain.vue';
@ -13,29 +13,18 @@ import BaseDivider from '@/Components/BaseDivider.vue';
import BaseButton from '@/Components/BaseButton.vue'; import BaseButton from '@/Components/BaseButton.vue';
import BaseButtons from '@/Components/BaseButtons.vue'; import BaseButtons from '@/Components/BaseButtons.vue';
import { stardust } from '@eidellev/adonis-stardust/client'; import { stardust } from '@eidellev/adonis-stardust/client';
// import { Inertia } from '@inertiajs/inertia';
import PasswordMeter from '@/Components/SimplePasswordMeter/password-meter.vue'; import PasswordMeter from '@/Components/SimplePasswordMeter/password-meter.vue';
const enabled = ref(false);
const props = defineProps({ const props = defineProps({
user: { user: { type: Object, default: () => ({}) },
type: Object, roles: { type: Object, default: () => ({}) },
default: () => ({}), userHasRoles: { type: Object, default: () => ({}) },
}, errors: { type: Object, default: () => ({}) }, // Fallback
roles: {
type: Object,
default: () => ({}),
},
userHasRoles: {
type: Object,
default: () => ({}),
},
errors: {
type: Object,
default: () => ({}),
},
}); });
// enabled ist true, solange kein (schwaches) Passwort eingegeben wird
const enabled = ref(true);
const form = useForm({ const form = useForm({
_method: 'put', _method: 'put',
login: props.user.login, login: props.user.login,
@ -47,30 +36,34 @@ const form = useForm({
roles: props.userHasRoles, // fill actual user roles from db roles: props.userHasRoles, // fill actual user roles from db
}); });
const submit = async () => { const submit = () => {
// await Inertia.post(stardust.route('user.store'), form); form.put(stardust.route('settings.user.update', [props.user.id]), {
// await router.put(stardust.route('settings.user.update', [props.user.id]), form);
await form.put(stardust.route('settings.user.update', [props.user.id]), {
preserveScroll: true, preserveScroll: true,
onSuccess: () => { onSuccess: () => {
form.reset(); // Bei Erfolg Passwortfelder leeren
form.reset('new_password', 'password_confirmation');
}, },
onError: () => { onError: () => {
if (form.errors.new_password) { if (form.errors.new_password) {
form.reset('new_password'); form.reset('new_password', 'password_confirmation');
enabled.value = false; enabled.value = false;
// newPasswordInput.value.focus();
// newPasswordInput.value?.focus();
} }
}, },
}); });
}; };
const formatError = (error: string | string[] | undefined) => {
if (!error) return '';
return Array.isArray(error) ? error.join(', ') : error;
};
const handleScore = (score: number) => { const handleScore = (score: number) => {
if (score >= 4){ // Wenn das Feld leer ist, ist der Status egal (Passwort wird nicht geändert)
// Wenn etwas drin steht, muss der Score >= 4 sein
if (form.new_password === '') {
enabled.value = true; enabled.value = true;
} else { } else {
enabled.value = false; enabled.value = score >= 4;
} }
}; };
</script> </script>
@ -89,85 +82,83 @@ const handleScore = (score: number) => {
small small
/> />
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<!-- <CardBox form @submit.prevent="form.put(stardust.route('user.update', [props.user.id]))"> -->
<CardBox form @submit.prevent="submit()"> <CardBox form @submit.prevent="submit">
<FormField label="Enter Login" :class="{ 'text-red-400': errors.name }"> <FormField label="Login" :class="{ 'text-red-400': form.errors.login }">
<FormControl v-model="form.login" type="text" placeholder="Name" :errors="errors.login"> <FormControl v-model="form.login" type="text" placeholder="Login" :errors="form.errors.login">
<div class="text-red-400 text-sm" v-if="errors.login"> <div class="text-red-400 text-sm mt-1" v-if="form.errors.login">
{{ errors.login }} {{ formatError(form.errors.login) }}
</div> </div>
</FormControl> </FormControl>
</FormField> </FormField>
<FormField label="First Name" :class="{ 'text-red-400': errors.first_name }"> <FormField label="First Name" :class="{ 'text-red-400': form.errors.first_name }">
<FormControl v-model="form.first_name" type="text" placeholder="Enter First Name" :errors="errors.first_name"> <FormControl v-model="form.first_name" type="text" placeholder="First Name" :errors="form.errors.first_name">
<div class="text-red-400 text-sm" v-if="errors.first_name && Array.isArray(errors.first_name)"> <div class="text-red-400 text-sm mt-1" v-if="form.errors.first_name">
{{ errors.first_name.join(', ') }} {{ formatError(form.errors.first_name) }}
</div> </div>
</FormControl> </FormControl>
</FormField> </FormField>
<FormField label="Last Name" :class="{ 'text-red-400': errors.last_name }"> <FormField label="Last Name" :class="{ 'text-red-400': form.errors.last_name }">
<FormControl v-model="form.last_name" type="text" placeholder="Enter Last Name" :errors="errors.last_name"> <FormControl v-model="form.last_name" type="text" placeholder="Last Name" :errors="form.errors.last_name">
<div class="text-red-400 text-sm" v-if="errors.last_name && Array.isArray(errors.last_name)"> <div class="text-red-400 text-sm mt-1" v-if="form.errors.last_name">
{{ errors.last_name.join(', ') }} {{ formatError(form.errors.last_name) }}
</div> </div>
</FormControl> </FormControl>
</FormField> </FormField>
<FormField label="Enter Email" :class="{ 'text-red-400': errors.email }"> <FormField label="Email" :class="{ 'text-red-400': form.errors.email }">
<FormControl v-model="form.email" type="text" placeholder="Email" :errors="errors.email"> <FormControl v-model="form.email" type="text" placeholder="Email" :errors="form.errors.email">
<div class="text-red-400 text-sm" v-if="errors.email && Array.isArray(errors.email)"> <div class="text-red-400 text-sm mt-1" v-if="form.errors.email">
{{ errors.email.join(', ') }} {{ formatError(form.errors.email) }}
</div> </div>
</FormControl> </FormControl>
</FormField> </FormField>
<!-- <FormField label="Password" :class="{ 'text-red-400': errors.password }"> <div class="py-4">
<FormControl v-model="form.password" type="password" placeholder="Enter Password" :errors="errors.password"> <PasswordMeter
<div class="text-red-400 text-sm" v-if="errors.password"> field-label="Reset User Password (leave blank to keep current)"
{{ errors.password }} :show-required-message="false"
v-model="form.new_password"
:errors="form.errors"
@score="handleScore"
/>
<!-- <div class="text-red-400 text-sm mt-1" v-if="form.errors.new_password">
{{ formatError(form.errors.new_password) }}
</div> -->
</div> </div>
</FormControl>
</FormField> -->
<PasswordMeter field-label="Reset User Password" :show-required-message="false" ref="newPasswordInput" v-model="form.new_password" :errors="form.errors" @score="handleScore" /> <FormField label="Password Confirmation" :class="{ 'text-red-400': form.errors.password_confirmation }">
<FormField label="Password Confirmation" :class="{ 'text-red-400': errors.password_confirmation }">
<FormControl <FormControl
v-model="form.password_confirmation" v-model="form.password_confirmation"
type="password" type="password"
placeholder="Enter Password Confirmation" placeholder="Confirm New Password"
:errors="errors.password" :errors="form.errors.password_confirmation"
> >
<div <div class="text-red-400 text-sm mt-1" v-if="form.errors.password_confirmation">
class="text-red-400 text-sm" {{ formatError(form.errors.password_confirmation) }}
v-if="errors.password_confirmation && Array.isArray(errors.password_confirmation)"
>
{{ errors.password_confirmation.join(', ') }}
</div> </div>
</FormControl> </FormControl>
</FormField> </FormField>
<BaseDivider /> <BaseDivider />
<FormField label="Roles" wrap-body :class="{ 'text-red-400': errors.roles }"> <FormField label="Roles" wrap-body :class="{ 'text-red-400': form.errors.roles }">
<FormCheckRadioGroup v-model="form.roles" name="roles" is-column :options="props.roles" /> <FormCheckRadioGroup v-model="form.roles" name="roles" is-column :options="props.roles" />
</FormField> <div class="text-red-400 text-sm mt-1" v-if="form.errors.roles">
<div class="text-red-400 text-sm" v-if="errors.roles && Array.isArray(errors.roles)"> {{ formatError(form.errors.roles) }}
<!-- {{ errors.password_confirmation }} -->
{{ errors.roles.join(', ') }}
</div> </div>
</FormField>
<template #footer> <template #footer>
<BaseButtons> <BaseButtons>
<BaseButton <BaseButton
type="submit" type="submit"
color="info" color="info"
label="Submit" label="Update User"
:class="{ 'opacity-25': form.processing }" :class="{ 'opacity-25': form.processing }"
:disabled="form.processing == true|| (form.new_password != '' && enabled == false)" :disabled="form.processing || !enabled"
/> />
</BaseButtons> </BaseButtons>
</template> </template>

View file

@ -48,9 +48,22 @@ mainService.fetchApi('authors');
mainService.fetchApi('datasets'); mainService.fetchApi('datasets');
mainService.fetchChartData(); mainService.fetchChartData();
// Safe role check: authUser or its roles may be absent depending on auth state
const userHasRoles = (roleNames: Array<string>): boolean => { const userHasRoles = (roleNames: Array<string>): boolean => {
return user.value.roles.some((role) => roleNames.includes(role.name)); return user.value?.roles?.some((role) => roleNames.includes(role)) ?? false;
}; };
// Greeting that adapts to the time of day
const greeting = computed(() => {
const h = new Date().getHours();
if (h < 12) return 'Good morning';
if (h < 18) return 'Good afternoon';
return 'Good evening';
});
const today = computed(() =>
new Date().toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
);
</script> </script>
<template> <template>
@ -58,14 +71,29 @@ const userHasRoles = (roleNames: Array<string>): boolean => {
<Head title="Dashboard" /> <Head title="Dashboard" />
<SectionMain> <SectionMain>
<SectionTitleLineWithButton :icon="mdiChartTimelineVariant" title="Dashboard Overview" main> <!-- Greeting hero -->
<div class="text-sm text-gray-500 dark:text-gray-400"> <div
Welcome back, <span class="font-semibold">{{ user.login }}</span> class="reveal relative overflow-hidden rounded-2xl mb-8 p-6 md:p-8 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white shadow-xl ring-1 ring-white/5"
>
<!-- grüner Akzent-Glow statt weißer Kreise -->
<div class="absolute -right-10 -top-10 w-48 h-48 rounded-full bg-emerald-500/15 blur-3xl"></div>
<div class="absolute -left-8 -bottom-12 w-40 h-40 rounded-full bg-green-400/10 blur-3xl"></div>
<!-- schmale Akzentkante links -->
<div class="absolute inset-y-0 left-0 w-1 bg-gradient-to-b from-green-400 to-emerald-600"></div>
<div class="relative">
<p class="text-sm font-medium text-slate-400">{{ today }}</p>
<h1 class="mt-1 text-2xl md:text-3xl font-bold tracking-tight">
{{ greeting }}, <span class="text-green-400">{{ user?.login ?? 'there' }}</span>
</h1>
<p class="mt-2 text-sm text-slate-400 max-w-xl">
Here's an overview of authors, publications and submitters across the repository.
</p>
</div> </div>
</SectionTitleLineWithButton> </div>
<!-- Stats Grid --> <!-- Stats Grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6"> <div class="reveal reveal-1 grid grid-cols-1 gap-6 lg:grid-cols-3 mb-8">
<div class="rounded-xl border-l-4 border-emerald-500 bg-white dark:bg-slate-900/40 shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-300">
<CardBoxWidget <CardBoxWidget
trend="12%" trend="12%"
trend-type="up" trend-type="up"
@ -73,52 +101,32 @@ const userHasRoles = (roleNames: Array<string>): boolean => {
:icon="mdiAccountMultiple" :icon="mdiAccountMultiple"
:number="authors.length" :number="authors.length"
label="Authors" label="Authors"
class="hover:shadow-lg transition-shadow duration-300"
/> />
</div>
<div class="rounded-xl border-l-4 border-blue-500 bg-white dark:bg-slate-900/40 shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-300">
<CardBoxWidget <CardBoxWidget
trend-type="info" trend-type="info"
color="text-blue-500" color="text-blue-500"
:icon="mdiDatabaseOutline" :icon="mdiDatabaseOutline"
:number="datasets.length" :number="datasets.length"
label="Publications" label="Publications"
class="hover:shadow-lg transition-shadow duration-300"
/> />
</div>
<div class="rounded-xl border-l-4 border-purple-500 bg-white dark:bg-slate-900/40 shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-300">
<CardBoxWidget <CardBoxWidget
trend-type="up" trend-type="up"
color="text-purple-500" color="text-purple-500"
:icon="mdiChartTimelineVariant" :icon="mdiChartTimelineVariant"
:number="submitters.length" :number="submitters.length"
label="Submitters" label="Submitters"
class="hover:shadow-lg transition-shadow duration-300"
/>
</div>
<!-- <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="flex flex-col justify-between">
<CardBoxClient
v-for="client in authorBarItems"
:key="client.id"
:name="client.name"
:email="client.email"
:date="client.created_at"
:text="client.identifier_orcid"
:count="client.dataset_count"
/>
</div> <!--
<div class="flex flex-col justify-between">
<CardBoxDataset
v-for="(dataset, index) in datasetBarItems"
:key="index"
:dataset="dataset"
/> />
</div> </div>
</div> </div>
<!-- Recent Datasets Section --> <!-- Recent Datasets Section -->
<div v-if="datasetBarItems.length > 0" class="mb-6"> <div v-if="datasetBarItems.length > 0" class="reveal reveal-2 mb-8">
<SectionTitleLineWithButton :icon="mdiTrendingUp" title="Recent Publications"> <SectionTitleLineWithButton :icon="mdiTrendingUp" title="Recent Publications">
<span class="text-sm text-gray-500 dark:text-gray-400"> Latest {{ datasetBarItems.length }} publications </span> <span class="text-sm text-gray-500 dark:text-gray-400">Latest {{ datasetBarItems.length }} publications</span>
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<div class="grid grid-cols-1 gap-4"> <div class="grid grid-cols-1 gap-4">
@ -131,18 +139,16 @@ const userHasRoles = (roleNames: Array<string>): boolean => {
</div> </div>
</div> </div>
<!-- <SectionBannerStarOnGitHub /> -->
<!-- Chart Section --> <!-- Chart Section -->
<SectionTitleLineWithButton :icon="mdiChartPie" title="Trends Overview" class="mt-8"> <SectionTitleLineWithButton :icon="mdiChartPie" title="Trends Overview" class="reveal reveal-3 mt-8">
<span class="text-sm text-gray-500 dark:text-gray-400"> Publications per month </span> <span class="text-sm text-gray-500 dark:text-gray-400">Publications per month</span>
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox <CardBox
title="Performance" title="Performance"
:icon="mdiFinance" :icon="mdiFinance"
:header-icon="mdiReload" :header-icon="mdiReload"
class="mb-6 shadow-lg" class="reveal reveal-3 mb-6 shadow-lg"
@header-icon-click="fillChartData" @header-icon-click="fillChartData"
> >
<div v-if="isLoadingChart" class="flex items-center justify-center h-96"> <div v-if="isLoadingChart" class="flex items-center justify-center h-96">
@ -162,14 +168,46 @@ const userHasRoles = (roleNames: Array<string>): boolean => {
<!-- Admin Section --> <!-- Admin Section -->
<template v-if="userHasRoles(['administrator'])"> <template v-if="userHasRoles(['administrator'])">
<SectionTitleLineWithButton :icon="mdiAccountMultiple" title="Submitters Management" class="mt-8"> <SectionTitleLineWithButton :icon="mdiAccountMultiple" title="Submitters Management" class="mt-8">
<span class="text-sm text-gray-500 dark:text-gray-400"> Administrator view </span> <span class="text-sm text-gray-500 dark:text-gray-400">Administrator view</span>
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox :icon="mdiMonitorCellphone" title="All Submitters" has-table class="shadow-lg"> <CardBox :icon="mdiMonitorCellphone" title="All Submitters" has-table class="shadow-lg">
<TableSampleClients /> <TableSampleClients />
</CardBox> </CardBox>
</template> </template>
</SectionMain> </SectionMain>
</LayoutAuthenticated> </LayoutAuthenticated>
</template> </template>
<style scoped>
/* Staggered entrance reveal */
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.reveal {
animation: fade-up 0.5s ease-out both;
}
.reveal-1 {
animation-delay: 0.08s;
}
.reveal-2 {
animation-delay: 0.16s;
}
.reveal-3 {
animation-delay: 0.24s;
}
@media (prefers-reduced-motion: reduce) {
.reveal {
animation: none;
}
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -38,7 +38,6 @@ import { MainService } from '@/Stores/main';
import { notify } from '@/notiwind'; import { notify } from '@/notiwind';
import MapComponent from '@/Components/Map/map.component.vue'; import MapComponent from '@/Components/Map/map.component.vue';
import { MapOptions } from '@/Components/Map/MapOptions'; import { MapOptions } from '@/Components/Map/MapOptions';
// import { LatLngBoundsExpression } from 'leaflet/src/geo/LatLngBounds';
import { LatLngBoundsExpression } from 'leaflet'; import { LatLngBoundsExpression } from 'leaflet';
import { LayerOptions } from '@/Components/Map/LayerOptions'; import { LayerOptions } from '@/Components/Map/LayerOptions';
import TableKeywords from '@/Components/TableKeywords.vue'; import TableKeywords from '@/Components/TableKeywords.vue';
@ -90,14 +89,11 @@ const props = defineProps({
}); });
const flash: ComputedRef<any> = computed(() => { const flash: ComputedRef<any> = computed(() => {
// let test = usePage();
// console.log(test);
return usePage().props.flash; return usePage().props.flash;
}); });
// Computed property to determine the placeholder based on the selected option // Computed property to determine the placeholder based on the selected option
const getPlaceholder = computed(() => (type: string) => { const getPlaceholder = computed(() => (type: string) => {
switch (type) { switch (type) {
case 'DOI': case 'DOI':
return 'https://doi.org/10.24341/tethys.236'; return 'https://doi.org/10.24341/tethys.236';
@ -118,19 +114,9 @@ const getPlaceholder = computed(() => (type: string) => {
const mainService = MainService(); const mainService = MainService();
// let serrors = reactive([]);
// const form = useForm({
// language: '',
// licenses: [],
// type: '',
// titles: [{ value: '', type: 'Main', language: Dataset.language }],
// });
// let language: (string | Ref<string>) = ref('');
let language = ref(''); let language = ref('');
let dataset: Dataset; let dataset: Dataset;
if (Object.keys(mainService.dataset).length == 0) { if (Object.keys(mainService.dataset).length == 0) {
// language = ref('');
dataset = { dataset = {
language: language.value, language: language.value,
licenses: [], licenses: [],
@ -158,7 +144,6 @@ if (Object.keys(mainService.dataset).length == 0) {
time_max: undefined, time_max: undefined,
time_absolut: undefined, time_absolut: undefined,
}, },
// errors: undefined,
subjects: [ subjects: [
{ value: '', type: 'uncontrolled', language: language.value }, { value: '', type: 'uncontrolled', language: language.value },
{ value: '', type: 'uncontrolled', language: language.value }, { value: '', type: 'uncontrolled', language: language.value },
@ -166,15 +151,10 @@ if (Object.keys(mainService.dataset).length == 0) {
], ],
references: [], references: [],
files: [], files: [],
// upload: { label: 'test', sorting: 0 },
}; };
// Set the form's current values as the new defaults...
// mainService.setDataset(dataset, language);
} else { } else {
// console.log(mainService.dataset);
language.value = mainService.dataset.language; language.value = mainService.dataset.language;
// dataset = mainService.dataset;
dataset = { dataset = {
language: mainService.dataset.language, language: mainService.dataset.language,
licenses: mainService.dataset.licenses, licenses: mainService.dataset.licenses,
@ -191,25 +171,9 @@ if (Object.keys(mainService.dataset).length == 0) {
subjects: mainService.dataset.subjects, subjects: mainService.dataset.subjects,
references: mainService.dataset.references, references: mainService.dataset.references,
files: mainService.dataset.files, files: mainService.dataset.files,
// upload: mainService.dataset.upload,
}; };
// for (let index in mainService.dataset.titles) {
// let title: Title = mainService.dataset.titles[index];
// if (title.type == 'Main') {
// title.language = language;
// }
// }
// for (let index in mainService.dataset.descriptions) {
// let description: Description = mainService.dataset.descriptions[index];
// if (description.type == 'Abstract') {
// description.language = language;
// }
// }
} }
// const form = useForm<Dataset>({ // const form = useForm<Dataset>({
// language: language, // language: language,
// licenses: [], // licenses: [],
@ -262,7 +226,6 @@ watch(depth, (currentValue) => {
form.coverage.depth_max = undefined; form.coverage.depth_max = undefined;
} }
}); });
// let time= "no_time";
let time = ref('no_time'); let time = ref('no_time');
watch(time, (currentValue) => { watch(time, (currentValue) => {
if (currentValue == 'absolut') { if (currentValue == 'absolut') {
@ -293,14 +256,6 @@ const fitBounds: LatLngBoundsExpression = [
]; ];
const mapId = 'test'; const mapId = 'test';
// const submit = async () => {
// await router.post(stardust.route('user.store'), form, {
// onSuccess: () => {
// form.reset(), (formStep.value = 1);
// },
// });
// };
const nextStep = async () => { const nextStep = async () => {
let route = ""; let route = "";
if (formStep.value == 1) { if (formStep.value == 1) {
@ -313,9 +268,7 @@ const nextStep = async () => {
// When posting in steps 1-3, remove any file uploads from the data. // When posting in steps 1-3, remove any file uploads from the data.
await form await form
.transform((data: Dataset) => { .transform((data: Dataset) => {
// Create payload and set rights (transforming to a string if needed)
const payload: any = { ...data, rights: data.rights ? 'true' : 'false' }; const payload: any = { ...data, rights: data.rights ? 'true' : 'false' };
// Remove the files property so that the partial update is done without files
if (payload.files) { if (payload.files) {
delete payload.files; delete payload.files;
} }
@ -323,7 +276,6 @@ const nextStep = async () => {
}) })
.post(route, { .post(route, {
onSuccess: () => { onSuccess: () => {
// console.log(form.data());
mainService.setDataset(form.data()); mainService.setDataset(form.data());
formStep.value++; formStep.value++;
}, },
@ -340,7 +292,6 @@ const submit = async () => {
return new File([obj.blob], obj.label, { type: obj.type, lastModified: obj.lastModified, sort_order: obj.sort_order }); return new File([obj.blob], obj.label, { type: obj.type, lastModified: obj.lastModified, sort_order: obj.sort_order });
}); });
// formStep.value++;
await form await form
.transform((data) => ({ .transform((data) => ({
...data, ...data,
@ -348,14 +299,7 @@ const submit = async () => {
rights: form.rights && form.rights == true ? 'true' : 'false', rights: form.rights && form.rights == true ? 'true' : 'false',
})) }))
.post(route, { .post(route, {
// forceFormData: true,
onSuccess: () => { onSuccess: () => {
// console.log(form.data());
// mainService.clearDataset();
// mainService.setDataset(form.data());
// formStep.value++;
// form.reset();
language.value = ''; language.value = '';
formStep.value = 1; formStep.value = 1;
let dataset = { let dataset = {
@ -385,7 +329,6 @@ const submit = async () => {
time_max: undefined, time_max: undefined,
time_absolut: undefined, time_absolut: undefined,
}, },
// errors: undefined,
subjects: [ subjects: [
{ value: '', type: 'uncontrolled', language: language.value }, { value: '', type: 'uncontrolled', language: language.value },
{ value: '', type: 'uncontrolled', language: language.value }, { value: '', type: 'uncontrolled', language: language.value },
@ -408,7 +351,6 @@ const addNewAuthor = () => {
const addTitle = () => { const addTitle = () => {
let newTitle: Title = { value: '', language: '', type: '' }; let newTitle: Title = { value: '', language: '', type: '' };
//this.dataset.files.push(uploadedFiles[i]);
form.titles.push(newTitle); form.titles.push(newTitle);
}; };
const removeTitle = (key: number) => { const removeTitle = (key: number) => {
@ -417,7 +359,6 @@ const removeTitle = (key: number) => {
const addDescription = () => { const addDescription = () => {
let newDescription = { value: '', language: '', type: '' }; let newDescription = { value: '', language: '', type: '' };
//this.dataset.files.push(uploadedFiles[i]);
form.descriptions.push(newDescription); form.descriptions.push(newDescription);
}; };
const removeDescription = (key: number) => { const removeDescription = (key: number) => {
@ -446,55 +387,39 @@ const onAddContributor = (person: Person) => {
} else if (form.authors.filter((e) => e.id === person.id).length > 0) { } 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); notify({ type: 'warning', title: 'Warning', text: 'person is already defined as author' }, 4000);
} else { } else {
// person.pivot = { contributor_type: '' };
// // person.pivot = { name_type: '', contributor_type: '' };
form.contributors.push(person); form.contributors.push(person);
notify({ type: 'info', text: 'person has been successfully added as contributor' }, 4000); notify({ type: 'info', text: 'person has been successfully added as contributor' }, 4000);
} }
}; };
// const onMapInitializedEvent = "onMapInitializedEvent";
const onMapInitialized = (newItem: any) => { const onMapInitialized = (newItem: any) => {
// notify({ type: 'info', text: message });
console.log(newItem); console.log(newItem);
}; };
/* /*
adds a new Keyword * adds a new Keyword
*/ */
const addKeyword = () => { const addKeyword = () => {
let newSubject: Subject = { value: '', language: '', type: 'uncontrolled' }; let newSubject: Subject = { value: '', language: '', type: 'uncontrolled' };
//this.dataset.files.push(uploadedFiles[i]);
form.subjects.push(newSubject); form.subjects.push(newSubject);
}; };
const addReference = () => { const addReference = () => {
let newReference = { value: '', label: '', relation: '', type: '' }; let newReference = { value: '', label: '', relation: '', type: '' };
//this.dataset.files.push(uploadedFiles[i]);
form.references.push(newReference); form.references.push(newReference);
}; };
/* /*
Removes a selected reference * Removes a selected reference
*/ */
const removeReference = (key: number) => { const removeReference = (key: number) => {
form.references.splice(key, 1); form.references.splice(key, 1);
}; };
/*
// const onChangeFile = (event) => { const formatError = (error: string | string[] | undefined | null): string => {
// // let uploadedFile = event.target.files[0]; if (!error) return '';
return Array.isArray(error) ? error.join(', ') : error;
// let fileName = String(event.target.files[0].name.replace(/\.[^/.]+$/, '')); };
// form.file = event.target.files[0];
// form.upload.label = fileName;
// // form.upload = file;
// // console.log(file.file);
// };
/*
Removes a selected keyword
*/
// const removeKeyword = (key) => {
// form.subjects.splice(key, 1);
// };
</script> </script>
<template> <template>
@ -526,8 +451,6 @@ Removes a selected keyword
<Head title="Submit Dataset" /> <Head title="Submit Dataset" />
<SectionMain> <SectionMain>
<SectionTitleLineWithButton :icon="mdiDatabasePlus" title="Submit dataset" main> <SectionTitleLineWithButton :icon="mdiDatabasePlus" title="Submit dataset" main>
<!-- <BaseButton :route-name="stardust.route('user.index')" :icon="mdiArrowLeftBoldOutline" label="Back"
color="white" rounded-full small /> -->
{{ formStep }} {{ formStep }}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline"> <NotificationBar v-if="flash.message" color="success" :icon="mdiAlertBoxOutline">
@ -538,8 +461,6 @@ Removes a selected keyword
<CardBox> <CardBox>
<div class="mx-4 p-4"> <div class="mx-4 p-4">
<div class="flex items-center"> <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="'Step 1'"> <icon-wizard :is-current="formStep == 1" :is-checked="formStep > 1" :label="'Step 1'">
<icon-language></icon-language> <icon-language></icon-language>
</icon-wizard> </icon-wizard>
@ -559,31 +480,23 @@ Removes a selected keyword
</div> </div>
</div> </div>
<!-- mt-8: margin-top: 2rem; /* 32px */ 4 p-4: spacing 1rem 16px-->
<div class="mt-8 p-4"> <div class="mt-8 p-4">
<div v-if="formStep == 1"> <div v-if="formStep == 1">
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row">
<FormField label="Language *" help="required: select dataset main language" <FormField label="Language *" help="required: select dataset main language"
:class="{ 'text-red-400': errors.language }" class="w-full mx-2 flex-1"> :errors="form.errors.language" class="w-full mx-2 flex-1">
<FormControl required v-model="language" :type="'select'" placeholder="[Enter Language]" <FormControl required v-model="language" :type="'select'" placeholder="[Enter Language]"
:errors="form.errors.language" :options="{ de: 'de', en: 'en' }"> :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> </FormControl>
</FormField> </FormField>
</div> </div>
<FormField label="Licenses" wrap-body :class="{ 'text-red-400': form.errors.licenses }" <FormField label="Licenses" wrap-body :errors="form.errors.licenses"
class="mt-8 w-full mx-2 flex-1"> class="mt-8 w-full mx-2 flex-1">
<FormCheckRadioGroup type="radio" v-model="form.licenses" name="licenses" is-column <FormCheckRadioGroup type="radio" v-model="form.licenses" name="licenses" is-column
:options="props.licenses" /> :options="props.licenses" />
</FormField> </FormField>
<!-- <label for="rights">
<input class="form-checkbox" name="rights" id="rights" type="checkbox" v-model="dataset.rights" />
terms and conditions
</label> -->
<FormField label="Rights" <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" 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 }" wrap-body :class="{ 'text-red-400': form.errors.rights }"
@ -592,77 +505,55 @@ Removes a selected keyword
<input type="checkbox" id="rights" required v-model="form.rights" /> <input type="checkbox" id="rights" required v-model="form.rights" />
<span class="check" /> <span class="check" />
<a class="pl-2" target="_blank">terms and conditions </a> <a class="pl-2" target="_blank">terms and conditions </a>
<!-- <BaseButton color="modern" :icon="mdiInformationOutline" small @click="isModalActive = true" /> -->
<BaseIcon v-if="mdiInformationOutline" :path="mdiInformationOutline" <BaseIcon v-if="mdiInformationOutline" :path="mdiInformationOutline"
@click.prevent="isModalActive = true" /> @click.prevent="isModalActive = true" />
</label> </label>
</FormField> </FormField>
<div class="text-red-400 text-sm" v-if="errors.rights && Array.isArray(errors.rights)"> <div class="text-red-400 text-sm" v-if="form.errors.rights">
<!-- {{ errors.password_confirmation }} --> {{ formatError(form.errors.rights) }}
{{ errors.rights.join(', ') }}
</div> </div>
</div> </div>
<div v-if="formStep == 2"> <div v-if="formStep == 2">
<!-- <CardBox title="Performance" :icon="mdiFinance" :header-icon="mdiReload" class="mb-6"> -->
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row">
<FormField label="Dataset Type *" help="required: dataset type" <FormField label="Dataset Type *" help="required: dataset type" :errors="form.errors.type"
:class="{ 'text-red-400': form.errors.type }" class="w-full mx-2 flex-1"> class="w-full mx-2 flex-1">
<FormControl required v-model="form.type" :type="'select'" <FormControl required v-model="form.type" :type="'select'"
placeholder="-- select type --" :errors="errors.type" :options="doctypes"> 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> </FormControl>
</FormField> </FormField>
<!-- <div class="w-full mx-2 flex-1 svelte-1l8159u"></div> -->
<!-- Creating Corporation --> <!-- Creating Corporation -->
<FormField label="Creating Corporation *" <FormField label="Creating Corporation *" :errors="form.errors.creating_corporation"
:class="{ 'text-red-400': form.errors.creating_corporation }"
class="w-full mx-2 flex-1"> class="w-full mx-2 flex-1">
<FormControl required v-model="form.creating_corporation" type="text" <FormControl required v-model="form.creating_corporation" type="text"
placeholder="[enter creating corporation]" :is-read-only="true"> 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> </FormControl>
</FormField> </FormField>
</div> </div>
<!-- <BaseDivider /> -->
<!-- titles --> <!-- titles -->
<CardBox class="mb-6 shadow" :has-form-data="true" title="Titles" :icon="mdiFinance" <CardBox class="mb-6 shadow" :has-form-data="true" title="Titles" :icon="mdiFinance"
:header-icon="mdiPlusCircle" v-on:header-icon-click="addTitle()"> :header-icon="mdiPlusCircle" v-on:header-icon-click="addTitle()">
<!-- <div class="py-6 border-t border-gray-100 dark:border-slate-800"> --> <div v-if="form.errors.titles"
class="mx-2 mb-4 p-3 bg-red-100 border-l-4 border-red-500 text-red-700 text-sm">
{{ formatError(form.errors.titles) }}
</div>
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row">
<FormField label="Main Title *" help="required: main title" <FormField label="Main Title *" help="required: main title"
:class="{ 'text-red-400': form.errors['titles.0.value'] }" :errors="form.errors['titles.0.value']" class="w-full mx-2 flex-1">
class="w-full mx-2 flex-1">
<FormControl required v-model="form.titles[0].value" type="textarea" <FormControl required v-model="form.titles[0].value" type="textarea"
placeholder="[enter main title]" :show-char-count="true" placeholder="[enter main title]" :show-char-count="true"
:max-input-length="255"> :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> </FormControl>
</FormField> </FormField>
<FormField label="Main Title Language*" help="required: main title language" <FormField label="Main Title Language*" help="required: main title language"
:class="{ 'text-red-400': form.errors['titles.0.language'] }" :errors="form.errors['titles.0.language']" class="w-full mx-2 flex-1">
class="w-full mx-2 flex-1">
<FormControl required v-model="form.titles[0].language" type="text" <FormControl required v-model="form.titles[0].language" type="text"
:is-read-only="true"> :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> </FormControl>
</FormField> </FormField>
</div> </div>
<label v-if="form.titles.length > 1">additional titles </label> <label v-if="form.titles.length > 1">additional titles </label>
<!-- <BaseButton :icon="mdiPlusCircle" @click.prevent="addTitle()" color="modern" rounded-full small /> -->
<div v-if="form.titles.length > 1"> <div v-if="form.titles.length > 1">
<div v-for="(item, index) in form.titles"> <div v-for="(item, index) in form.titles">
<div class="flex flex-col md:flex-row" v-if="item.type != 'Main'"> <div class="flex flex-col md:flex-row" v-if="item.type != 'Main'">
@ -670,75 +561,53 @@ Removes a selected keyword
<BaseButton :icon="mdiMinusCircle" class="mt-1" <BaseButton :icon="mdiMinusCircle" class="mt-1"
@click.prevent="removeTitle(index)" color="modern" small /> @click.prevent="removeTitle(index)" color="modern" small />
</FormField> </FormField>
<FormField label="Title Value *" <FormField label="Title Value *" :errors="form.errors[`titles.${index}.value`]"
:class="{ 'text-red-400': form.errors[`titles.${index}.value`] }"
class="w-full mx-2 flex-1"> class="w-full mx-2 flex-1">
<FormControl required v-model="form.titles[index].value" type="textarea" <FormControl required v-model="form.titles[index].value" type="textarea"
placeholder="[enter main title]"> 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> </FormControl>
</FormField> </FormField>
<FormField label="Title Type*" <FormField label="Title Type*" :errors="form.errors[`titles.${index}.type`]"
:class="{ 'text-red-400': form.errors[`titles.${index}.type`] }"
class="w-full mx-2 flex-1"> class="w-full mx-2 flex-1">
<FormControl required v-model="form.titles[index].type" type="select" <FormControl required v-model="form.titles[index].type" type="select"
:options="titletypes" placeholder="[select title type]"> :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> </FormControl>
</FormField> </FormField>
<FormField label="Title Language*" <FormField label="Title Language*"
:class="{ 'text-red-400': form.errors[`titles.${index}.language`] }" :errors="form.errors[`titles.${index}.language`]"
class="w-full mx-2 flex-1"> class="w-full mx-2 flex-1">
<FormControl required v-model="form.titles[index].language" type="select" <FormControl required v-model="form.titles[index].language" type="select"
:options="{ de: 'de', en: 'en' }" placeholder="[select title language]"> :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> </FormControl>
</FormField> </FormField>
</div> </div>
</div> </div>
</div> </div>
<!-- </div> -->
</CardBox> </CardBox>
<!-- Descriptions --> <!-- Descriptions -->
<CardBox :icon="mdiImageText" class="mb-6 shadow" :has-form-data="true" title="Descriptions" <CardBox :icon="mdiImageText" class="mb-6 shadow" :has-form-data="true" title="Descriptions"
:header-icon="mdiPlusCircle" v-on:header-icon-click="addDescription()"> :header-icon="mdiPlusCircle" v-on:header-icon-click="addDescription()">
<div v-if="form.errors.descriptions"
class="mx-2 mb-4 p-3 bg-red-100 border-l-4 border-red-500 text-red-700 text-sm">
{{ formatError(form.errors.descriptions) }}
</div>
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row">
<FormField label="Main Abstract *" help="required: main abstract" <FormField label="Main Abstract *" help="required: main abstract"
:class="{ 'text-red-400': form.errors['descriptions.0.value'] }" :errors="form.errors['descriptions.0.value']" class="w-full mx-2 flex-1">
class="w-full mx-2 flex-1">
<FormControl required v-model="form.descriptions[0].value" type="textarea" <FormControl required v-model="form.descriptions[0].value" type="textarea"
placeholder="[enter main abstract]" :show-char-count="true" placeholder="[enter main abstract]" :show-char-count="true"
:max-input-length="2500"> :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> </FormControl>
</FormField> </FormField>
<FormField label="Main Description Language*" help="required: main abstract language" <FormField label="Main Description Language*" help="required: main abstract language"
:class="{ 'text-red-400': form.errors['descriptions.0.language'] }" :errors="form.errors['descriptions.0.language']" class="w-full mx-2 flex-1">
class="w-full mx-2 flex-1">
<FormControl required v-model="form.descriptions[0].language" type="text" <FormControl required v-model="form.descriptions[0].language" type="text"
:is-read-only="true"> :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> </FormControl>
</FormField> </FormField>
</div> </div>
<label v-if="form.descriptions.length > 1">additional descriptions: </label> <label v-if="form.descriptions.length > 1">additional descriptions: </label>
<!-- <BaseButton :icon="mdiPlusCircle" @click.prevent="addTitle()" color="modern" rounded-full small /> -->
<div v-if="form.descriptions.length > 1"> <div v-if="form.descriptions.length > 1">
<div v-for="(item, index) in form.descriptions"> <div v-for="(item, index) in form.descriptions">
<div class="flex flex-col md:flex-row" v-if="item.type != 'Abstract'"> <div class="flex flex-col md:flex-row" v-if="item.type != 'Abstract'">
@ -747,40 +616,26 @@ Removes a selected keyword
@click.prevent="removeDescription(index)" color="modern" small /> @click.prevent="removeDescription(index)" color="modern" small />
</FormField> </FormField>
<FormField label="Description Value *" <FormField label="Description Value *"
:class="{ 'text-red-400': form.errors[`descriptions.${index}.value`] }" :errors="form.errors[`descriptions.${index}.value`]"
class="w-full mx-2 flex-1"> class="w-full mx-2 flex-1">
<FormControl required v-model="form.descriptions[index].value" type="textarea" <FormControl required v-model="form.descriptions[index].value"
placeholder="[enter additional description]" :show-char-count="true" type="textarea" placeholder="[enter additional description]"
:max-input-length="2500"> :show-char-count="true" :max-input-length="2500">
<div class="text-red-400 text-sm" v-if="form.errors[`descriptions.${index}.value`] &&
Array.isArray(form.errors[`descriptions.${index}.value`])
">
{{ form.errors[`descriptions.${index}.value`].join(', ') }}
</div>
</FormControl> </FormControl>
</FormField> </FormField>
<FormField label="Description Type *" <FormField label="Description Type *"
:class="{ 'text-red-400': form.errors[`descriptions.${index}.type`] }" :errors="form.errors[`descriptions.${index}.type`]"
class="w-full mx-2 flex-1"> class="w-full mx-2 flex-1">
<FormControl required v-model="form.descriptions[index].type" type="select" <FormControl required v-model="form.descriptions[index].type" type="select"
:options="descriptiontypes" placeholder="[select description type]"> :options="descriptiontypes" placeholder="[select description type]">
<div class="text-red-400 text-sm" v-if="form.errors[`descriptions.${index}.type`] &&
Array.isArray(form.errors[`descriptions.${index}.type`])
">
{{ form.errors[`descriptions.${index}.type`].join(', ') }}
</div>
</FormControl> </FormControl>
</FormField> </FormField>
<FormField label="Description Language*" <FormField label="Description Language*"
:class="{ 'text-red-400': form.errors[`titdescriptionsles.${index}.language`] }" :errors="form.errors[`descriptions.${index}.language`]"
class="w-full mx-2 flex-1"> class="w-full mx-2 flex-1">
<FormControl required v-model="form.descriptions[index].language" <FormControl required v-model="form.descriptions[index].language"
type="select" :options="{ de: 'de', en: 'en' }" type="select" :options="{ de: 'de', en: 'en' }"
placeholder="[select title language]"> placeholder="[select title language]">
<div class="text-red-400 text-sm"
v-if="form.errors && Array.isArray(form.errors[`descriptions.${index}.language`])">
{{ form.errors[`descriptions.${index}.language`].join(', ') }}
</div>
</FormControl> </FormControl>
</FormField> </FormField>
</div> </div>
@ -789,15 +644,17 @@ Removes a selected keyword
</CardBox> </CardBox>
<!-- authors --> <!-- authors -->
<CardBox class="mb-6 shadow" has-table title="Creators" :icon="mdiBookOpenPageVariant" :show-header-icon="false"> <CardBox class="mb-6 shadow" has-table title="Creators" :icon="mdiBookOpenPageVariant"
:show-header-icon="false">
<div v-if="form.errors.authors"
class="mx-2 mb-4 p-3 bg-red-100 border-l-4 border-red-500 text-red-700 text-sm">
{{ formatError(form.errors.authors) }}
</div>
<SearchAutocomplete source="/api/persons" :response-property="'first_name'" <SearchAutocomplete source="/api/persons" :response-property="'first_name'"
placeholder="search in person table...." v-on:person="onAddAuthor"></SearchAutocomplete> placeholder="search in person table...." v-on:person="onAddAuthor"></SearchAutocomplete>
<TablePersons :errors="form.errors" :persons="form.authors" :relation="'authors'" <TablePersons :errors="form.errors" :persons="form.authors" :relation="'authors'"
v-if="form.authors.length > 0" /> v-if="form.authors.length > 0" />
<div class="text-red-400 text-sm" v-if="form.errors.authors && Array.isArray(form.errors.authors)">
{{ form.errors.authors.join(', ') }}
</div>
<div class="w-full md:w-1/2"> <div class="w-full md:w-1/2">
<label class="block" for="additionalCreators">Add additional creator(s) if creator is <label class="block" for="additionalCreators">Add additional creator(s) if creator is
not in database</label> not in database</label>
@ -807,7 +664,12 @@ Removes a selected keyword
</CardBox> </CardBox>
<!-- contributors --> <!-- contributors -->
<CardBox class="mb-6 shadow" has-table title="Contributors" :icon="mdiBookOpenPageVariant" :show-header-icon="false"> <CardBox class="mb-6 shadow" has-table title="Contributors" :icon="mdiBookOpenPageVariant"
:show-header-icon="false">
<div v-if="form.errors.contributors"
class="mx-2 mb-4 p-3 bg-red-100 border-l-4 border-red-500 text-red-700 text-sm">
{{ formatError(form.errors.contributors) }}
</div>
<SearchAutocomplete source="/api/persons" :response-property="'first_name'" <SearchAutocomplete source="/api/persons" :response-property="'first_name'"
placeholder="search in person table...." v-on:person="onAddContributor"> placeholder="search in person table...." v-on:person="onAddContributor">
</SearchAutocomplete> </SearchAutocomplete>
@ -815,10 +677,6 @@ Removes a selected keyword
<TablePersons :persons="form.contributors" :relation="'contributors'" <TablePersons :persons="form.contributors" :relation="'contributors'"
v-if="form.contributors.length > 0" :contributortypes="contributorTypes" v-if="form.contributors.length > 0" :contributortypes="contributorTypes"
:errors="form.errors" /> :errors="form.errors" />
<div class="text-red-400 text-sm"
v-if="form.errors.contributors && Array.isArray(form.errors.contributors)">
{{ form.errors.contributors.join(', ') }}
</div>
<div class="w-full md:w-1/2"> <div class="w-full md:w-1/2">
<label class="block" for="additionalCreators">Add additional contributor(s) if <label class="block" for="additionalCreators">Add additional contributor(s) if
contributor is not in database</label> contributor is not in database</label>
@ -827,89 +685,65 @@ Removes a selected keyword
</div> </div>
</CardBox> </CardBox>
</div> </div>
<!-- <label>To Do: Recommendet</label> --> <!-- <label>To Do: Recommendet</label> -->
<div v-if="formStep == 3"> <div v-if="formStep == 3">
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row">
<FormField label="Project.." help="project is optional" <FormField label="Project.." help="project is optional"
:class="{ 'text-red-400': errors.project_id }" class="w-full mx-2 flex-1"> :errors="form.errors.project_id" class="w-full mx-2 flex-1">
<FormControl required v-model="form.project_id" :type="'select'" <FormControl required v-model="form.project_id" :type="'select'"
placeholder="[Select Project]" :errors="form.errors.project_id" :options="projects"> 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> </FormControl>
</FormField> </FormField>
<FormField label="Embargo Date.." help="embargo date is optional" <FormField label="Embargo Date.." help="embargo date is optional"
:class="{ 'text-red-400': errors.embargo_date }" class="w-full mx-2 flex-1"> :errors="form.errors.embargo_date" class="w-full mx-2 flex-1">
<FormControl required v-model="form.embargo_date" :type="'date'" <FormControl required v-model="form.embargo_date" :type="'date'"
placeholder="date('y-m-d')" :errors="form.errors.embargo_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> </FormControl>
</FormField> </FormField>
</div> </div>
<CardBox class="mb-6 shadow" has-table title="Geo Location" :icon="mdiEarthPlus" :show-header-icon="false"> <CardBox class="mb-6 shadow" has-table title="Geo Location" :icon="mdiEarthPlus"
<!-- @onMapInitialized="onMapInitialized" --> :show-header-icon="false">
<!-- v-bind-event="{ mapId, name: mapId }" -->
<MapComponent :mapOptions="mapOptions" :baseMaps="baseMaps" :fitBounds="fitBounds" <MapComponent :mapOptions="mapOptions" :baseMaps="baseMaps" :fitBounds="fitBounds"
:coverage="form.coverage" :mapId="mapId" :coverage="form.coverage" :mapId="mapId"
v-bind-event:onMapInitializedEvent="onMapInitialized"></MapComponent> v-bind-event:onMapInitializedEvent="onMapInitialized"></MapComponent>
<!-- <label v-bind-event="{ for: mapId }" /> -->
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row">
<!-- x min and max --> <!-- x min and max -->
<FormField label="Coverage X Min" <FormField label="Coverage X Min"
:class="{ 'text-red-400': form.errors['coverage.x_min'] }" :errors="form.errors['coverage.x_min']"
class="w-full mx-2 flex-1"> class="w-full mx-2 flex-1">
<FormControl required v-model="form.coverage.x_min" type="text" inputmode="numeric" <FormControl required v-model="form.coverage.x_min" type="text" inputmode="numeric"
pattern="\d*" placeholder="[enter x_min]"> pattern="\d*" 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> </FormControl>
</FormField> </FormField>
<FormField label="Coverage X Max" <FormField label="Coverage X Max"
:class="{ 'text-red-400': form.errors['coverage.x_max'] }" :errors="form.errors['coverage.x_max']"
class="w-full mx-2 flex-1"> class="w-full mx-2 flex-1">
<FormControl required v-model="form.coverage.x_max" type="text" <FormControl required v-model="form.coverage.x_max" type="text"
placeholder="[enter x_max]"> 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> </FormControl>
</FormField> </FormField>
<!-- y min and max --> <!-- y min and max -->
<FormField label="Coverage Y Min" <FormField label="Coverage Y Min"
:class="{ 'text-red-400': form.errors['coverage.y_min'] }" :errors="form.errors['coverage.y_min']"
class="w-full mx-2 flex-1"> class="w-full mx-2 flex-1">
<FormControl required v-model="form.coverage.y_min" type="text" <FormControl required v-model="form.coverage.y_min" type="text"
placeholder="[enter y_min]"> 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> </FormControl>
</FormField> </FormField>
<FormField label="Coverage Y Max" <FormField label="Coverage Y Max"
:class="{ 'text-red-400': form.errors['coverage.y_max'] }" :errors="form.errors['coverage.y_max']"
class="w-full mx-2 flex-1"> class="w-full mx-2 flex-1">
<FormControl required v-model="form.coverage.y_max" type="text" <FormControl required v-model="form.coverage.y_max" type="text"
placeholder="[enter y_max]"> 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> </FormControl>
</FormField> </FormField>
</div> </div>
</CardBox> </CardBox>
<CardBox class="mb-6 shadow" has-table title="Coverage Information" :icon="mdiEarthPlus" :show-header-icon="false"> <CardBox class="mb-6 shadow" has-table title="Coverage Information" :icon="mdiEarthPlus"
:show-header-icon="false">
<!-- elevation menu --> <!-- elevation menu -->
<div class="flex flex-col md:flex-row mb-3 space-y-2 md:space-y-0 md:space-x-4"> <div class="flex flex-col md:flex-row mb-3 space-y-2 md:space-y-0 md:space-x-4">
<label for="elevation-option-one" class="pure-radio mb-2 md:mb-0"> <label for="elevation-option-one" class="pure-radio mb-2 md:mb-0">
@ -928,36 +762,21 @@ Removes a selected keyword
</div> </div>
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row">
<FormField v-if="elevation === 'absolut'" label="elevation absolut" <FormField v-if="elevation === 'absolut'" label="elevation absolut"
:class="{ 'text-red-400': form.errors['coverage.elevation_absolut'] }" :errors="form.errors['coverage.elevation_absolut']" class="w-full mx-2 flex-1">
class="w-full mx-2 flex-1">
<FormControl required v-model="form.coverage.elevation_absolut" type="text" <FormControl required v-model="form.coverage.elevation_absolut" type="text"
placeholder="[enter elevation_absolut]"> placeholder="[enter elevation_absolut]">
<div class="text-red-400 text-sm"
v-if="Array.isArray(form.errors['coverage.elevation_absolut'])">
{{ form.errors['coverage.elevation_absolut'].join(', ') }}
</div>
</FormControl> </FormControl>
</FormField> </FormField>
<FormField v-if="elevation === 'range'" label="elevation min" <FormField v-if="elevation === 'range'" label="elevation min"
:class="{ 'text-red-400': form.errors['coverage.elevation_min'] }" :errors="form.errors['coverage.elevation_min']" class="w-full mx-2 flex-1">
class="w-full mx-2 flex-1">
<FormControl required v-model="form.coverage.elevation_min" type="text" <FormControl required v-model="form.coverage.elevation_min" type="text"
placeholder="[enter elevation_min]"> placeholder="[enter elevation_min]">
<div class="text-red-400 text-sm"
v-if="Array.isArray(form.errors['coverage.elevation_min'])">
{{ form.errors['coverage.elevation_min'].join(', ') }}
</div>
</FormControl> </FormControl>
</FormField> </FormField>
<FormField v-if="elevation === 'range'" label="elevation max" <FormField v-if="elevation === 'range'" label="elevation max"
:class="{ 'text-red-400': form.errors['coverage.elevation_max'] }" :errors="form.errors['coverage.elevation_max']" class="w-full mx-2 flex-1">
class="w-full mx-2 flex-1">
<FormControl required v-model="form.coverage.elevation_max" type="text" <FormControl required v-model="form.coverage.elevation_max" type="text"
placeholder="[enter elevation_max]"> placeholder="[enter elevation_max]">
<div class="text-red-400 text-sm"
v-if="Array.isArray(form.errors['coverage.elevation_max'])">
{{ form.errors['coverage.elevation_max'].join(', ') }}
</div>
</FormControl> </FormControl>
</FormField> </FormField>
</div> </div>
@ -979,36 +798,21 @@ Removes a selected keyword
</div> </div>
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row">
<FormField v-if="depth === 'absolut'" label="depth absolut" <FormField v-if="depth === 'absolut'" label="depth absolut"
:class="{ 'text-red-400': form.errors['coverage.depth_absolut'] }" :errors="form.errors['coverage.depth_absolut']" class="w-full mx-2 flex-1">
class="w-full mx-2 flex-1">
<FormControl required v-model="form.coverage.depth_absolut" type="text" <FormControl required v-model="form.coverage.depth_absolut" type="text"
placeholder="[enter depth_absolut]"> placeholder="[enter depth_absolut]">
<div class="text-red-400 text-sm"
v-if="Array.isArray(form.errors['coverage.depth_absolut'])">
{{ form.errors['coverage.depth_absolut'].join(', ') }}
</div>
</FormControl> </FormControl>
</FormField> </FormField>
<FormField v-if="depth === 'range'" label="depth min" <FormField v-if="depth === 'range'" label="depth min"
:class="{ 'text-red-400': form.errors['coverage.depth_min'] }" :errors="form.errors['coverage.depth_min']" class="w-full mx-2 flex-1">
class="w-full mx-2 flex-1">
<FormControl required v-model="form.coverage.depth_min" type="text" <FormControl required v-model="form.coverage.depth_min" type="text"
placeholder="[enter depth_min]"> placeholder="[enter depth_min]">
<div class="text-red-400 text-sm"
v-if="Array.isArray(form.errors['coverage.depth_min'])">
{{ form.errors['coverage.depth_min'].join(', ') }}
</div>
</FormControl> </FormControl>
</FormField> </FormField>
<FormField v-if="depth === 'range'" label="depth max" <FormField v-if="depth === 'range'" label="depth max"
:class="{ 'text-red-400': form.errors['coverage.depth_max'] }" :errors="form.errors['coverage.depth_max']" class="w-full mx-2 flex-1">
class="w-full mx-2 flex-1">
<FormControl required v-model="form.coverage.depth_max" type="text" <FormControl required v-model="form.coverage.depth_max" type="text"
placeholder="[enter depth_max]"> placeholder="[enter depth_max]">
<div class="text-red-400 text-sm"
v-if="Array.isArray(form.errors['coverage.depth_max'])">
{{ form.errors['coverage.depth_max'].join(', ') }}
</div>
</FormControl> </FormControl>
</FormField> </FormField>
</div> </div>
@ -1030,36 +834,24 @@ Removes a selected keyword
</div> </div>
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row">
<FormField v-if="time === 'absolut'" label="time absolut" <FormField v-if="time === 'absolut'" label="time absolut"
:class="{ 'text-red-400': form.errors['coverage.time_absolut'] }" :errors="form.errors['coverage.time_absolut']"
class="w-full mx-2 flex-1"> class="w-full mx-2 flex-1">
<FormControl required v-model="form.coverage.time_absolut" type="datetime-local" <FormControl required v-model="form.coverage.time_absolut" type="datetime-local"
placeholder="[enter time_absolut]"> placeholder="[enter time_absolut]">
<div class="text-red-400 text-sm"
v-if="Array.isArray(form.errors['coverage.time_absolut'])">
{{ form.errors['coverage.time_absolut'].join(', ') }}
</div>
</FormControl> </FormControl>
</FormField> </FormField>
<FormField v-if="time === 'range'" label="time min" <FormField v-if="time === 'range'" label="time min"
:class="{ 'text-red-400': form.errors['coverage.time_min'] }" :errors="form.errors['coverage.time_min']"
class="w-full mx-2 flex-1"> class="w-full mx-2 flex-1">
<FormControl required v-model="form.coverage.time_min" type="datetime-local" <FormControl required v-model="form.coverage.time_min" type="datetime-local"
placeholder="[enter time_min]"> placeholder="[enter time_min]">
<div class="text-red-400 text-sm"
v-if="Array.isArray(form.errors['coverage.time_min'])">
{{ form.errors['coverage.time_min'].join(', ') }}
</div>
</FormControl> </FormControl>
</FormField> </FormField>
<FormField v-if="time === 'range'" label="time max" <FormField v-if="time === 'range'" label="time max"
:class="{ 'text-red-400': form.errors['coverage.time_max'] }" :errors="form.errors['coverage.time_max']"
class="w-full mx-2 flex-1"> class="w-full mx-2 flex-1">
<FormControl required v-model="form.coverage.time_max" type="datetime-local" <FormControl required v-model="form.coverage.time_max" type="datetime-local"
placeholder="[enter time_max]"> placeholder="[enter time_max]">
<div class="text-red-400 text-sm"
v-if="Array.isArray(form.errors['coverage.time_max'])">
{{ form.errors['coverage.time_max'].join(', ') }}
</div>
</FormControl> </FormControl>
</FormField> </FormField>
</div> </div>
@ -1085,53 +877,44 @@ Removes a selected keyword
<tbody> <tbody>
<tr v-for="(item, index) in form.references"> <tr v-for="(item, index) in form.references">
<td data-label="Reference Value"> <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'" <FormControl required v-model="item.value" :type="'text'"
:placeholder="getPlaceholder(form.references[index].type)" :placeholder="getPlaceholder(form.references[index].type)"
:errors="form.errors.embargo_date"> :errors="form.errors[`references.${index}.value`]">
<div class="text-red-400 text-sm" <div class="text-red-400 text-sm"
v-if="form.errors[`references.${index}.value`] && Array.isArray(form.errors[`references.${index}.value`])"> v-if="form.errors[`references.${index}.value`]">
{{ form.errors[`references.${index}.value`].join(', ') }} {{ formatError(form.errors[`references.${index}.value`]) }}
</div> </div>
</FormControl> </FormControl>
</td> </td>
<td> <td>
<FormControl required v-model="form.references[index].type" type="select" <FormControl required v-model="form.references[index].type" type="select"
:options="referenceIdentifierTypes" placeholder="[type]"> :options="referenceIdentifierTypes" placeholder="[type]">
<div class="text-red-400 text-sm" <div class="text-red-400 text-sm"
v-if="Array.isArray(form.errors[`references.${index}.type`])"> v-if="form.errors[`references.${index}.type`]">
{{ form.errors[`references.${index}.type`].join(', ') }} {{ formatError(form.errors[`references.${index}.type`]) }}
</div> </div>
</FormControl> </FormControl>
</td> </td>
<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" <FormControl required v-model="form.references[index].relation"
type="select" :options="relationTypes" placeholder="[relation type]"> type="select" :options="relationTypes" placeholder="[relation type]">
<div class="text-red-400 text-sm" <div class="text-red-400 text-sm"
v-if="Array.isArray(form.errors[`references.${index}.relation`])"> v-if="form.errors[`references.${index}.relation`]">
{{ form.errors[`references.${index}.relation`].join(', ') }} {{ formatError(form.errors[`references.${index}.relation`]) }}
</div> </div>
</FormControl> </FormControl>
</td> </td>
<td data-label="Reference Label"> <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" <FormControl required v-model="form.references[index].label" type="text"
placeholder="[reference label]"> placeholder="[reference label]">
<div class="text-red-400 text-sm" <div class="text-red-400 text-sm"
v-if="form.errors[`references.${index}.label`] && Array.isArray(form.errors[`references.${index}.label`])"> v-if="form.errors[`references.${index}.label`]">
{{ form.errors[`references.${index}.label`].join(', ') }} {{ formatError(form.errors[`references.${index}.label`]) }}
</div> </div>
</FormControl> </FormControl>
</td> </td>
<td class="before:hidden lg:w-1 whitespace-nowrap"> <td class="before:hidden lg:w-1 whitespace-nowrap">
<!-- <BaseButton color="info" :icon="mdiEye" small @click="isModalActive = true" /> -->
<BaseButton color="danger" :icon="mdiTrashCan" small <BaseButton color="danger" :icon="mdiTrashCan" small
@click.prevent="removeReference(index)" /> @click.prevent="removeReference(index)" />
</td> </td>
@ -1143,40 +926,26 @@ Removes a selected keyword
<CardBox class="mb-6 shadow" has-table title="Dataset Keywords" :icon="mdiEarthPlus" <CardBox class="mb-6 shadow" has-table title="Dataset Keywords" :icon="mdiEarthPlus"
:header-icon="mdiPlusCircle" v-on:header-icon-click="addKeyword"> :header-icon="mdiPlusCircle" v-on:header-icon-click="addKeyword">
<!-- <ul> <div v-if="form.errors.subjects"
<li v-for="(subject, index) in form.subjects" :key="index"> class="mx-2 mb-4 p-3 bg-red-100 border-l-4 border-red-500 text-red-700 text-sm">
{{ subject.value }} <BaseButton color="danger" :icon="mdiTrashCan" small @click.prevent="removeKeyword(index)" /> {{ formatError(form.errors.subjects) }}
</li> </div>
</ul> -->
<TableKeywords :keywords="form.subjects" :errors="form.errors" :subjectTypes="subjectTypes" <TableKeywords :keywords="form.subjects" :errors="form.errors" :subjectTypes="subjectTypes"
v-if="form.subjects.length > 0" /> v-if="form.subjects.length > 0" />
</CardBox> </CardBox>
</div> </div>
<div v-if="formStep == 4"> <div v-if="formStep == 4">
<!-- <progress v-if="form.progress" :value="form.progress.percentage" max="100">
{{ form.progress.percentage }}%
</progress> -->
<!-- <p v-if="isSaving">Uploading @{{ fileCount }} files...</p> -->
<!-- <div class="dropbox">
<input type="file" multiple name="files" @change="onChangeFile" class="input-file" />
<p>
Drag your file(s) here to begin<br />
or click to browse
</p>
</div> -->
<FileUploadComponent :files="form.files"></FileUploadComponent> <FileUploadComponent :files="form.files"></FileUploadComponent>
<div class="text-red-400 text-sm" <div class="text-red-400 text-sm" v-if="form.errors['file']">
v-if="form.errors['file'] && Array.isArray(form.errors['file'])"> {{ formatError(form.errors['file']) }}
{{ form.errors['file'].join(', ') }}
</div> </div>
<div class="text-red-400 text-sm" <div class="text-red-400 text-sm" v-if="form.errors['upload.label']">
v-if="form.errors['upload.label'] && Array.isArray(form.errors['upload.label'])"> {{ formatError(form.errors['upload.label']) }}
{{ form.errors['upload.label'].join(', ') }}
</div> </div>
<!-- <label v-if="form.upload">{{ form.upload?.label }}</label> -->
</div> </div>
</div> </div>
@ -1216,15 +985,6 @@ Removes a selected keyword
<path class="opacity-75" fill="currentColor" d="M12 2a10 10 0 0110 10h-4a6 6 0 00-6-6V2z"></path> <path class="opacity-75" fill="currentColor" d="M12 2a10 10 0 0110 10h-4a6 6 0 00-6-6V2z"></path>
</svg> </svg>
</div> </div>
<!-- <div
class="fixed inset-0 flex items-center justify-center bg-gray-500 bg-opacity-50">
<svg class="animate-spin h-10 w-10 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="M4 12a8 8 0 0116 0 8 8 0 01-16 0zm2 0a6 6 0 0112 0 6 6 0 01-12 0z"></path>
</svg>
</div> -->
</SectionMain> </SectionMain>
</LayoutAuthenticated> </LayoutAuthenticated>

View file

@ -72,7 +72,7 @@
<FormField <FormField
label="Language *" label="Language *"
help="required: select dataset main language" help="required: select dataset main language"
:class="{ 'text-red-400': form.errors.language }" :errors="form.errors.language"
> >
<FormControl <FormControl
required required
@ -82,14 +82,11 @@
:errors="form.errors.language" :errors="form.errors.language"
:options="{ de: 'de', en: 'en' }" :options="{ de: 'de', en: 'en' }"
> >
<div class="text-red-400 text-sm" v-if="form.errors.language">
{{ form.errors.language.join(', ') }}
</div>
</FormControl> </FormControl>
</FormField> </FormField>
<!-- (3) dataset_type --> <!-- (3) dataset_type -->
<FormField label="Dataset Type *" help="required: dataset type" :class="{ 'text-red-400': form.errors.type }"> <FormField label="Dataset Type *" help="required: dataset type" :errors="form.errors.type">
<FormControl <FormControl
required required
v-model="form.type" v-model="form.type"
@ -98,15 +95,12 @@
:errors="form.errors.type" :errors="form.errors.type"
:options="doctypes" :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> </FormControl>
</FormField> </FormField>
<!-- (4) creating_corporation --> <!-- (4) creating_corporation -->
<FormField <FormField
label="Creating Corporation *" label="Creating Corporation *"
:class="{ 'text-red-400': form.errors.creating_corporation }" :errors="form.errors.creating_corporation"
class="w-full mx-2 flex-1" class="w-full mx-2 flex-1"
> >
<FormControl <FormControl
@ -116,12 +110,6 @@
placeholder="[enter creating corporation]" placeholder="[enter creating corporation]"
:is-read-only="true" :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> </FormControl>
</FormField> </FormField>
</div> </div>
@ -152,6 +140,7 @@
<span class="w-8 h-8 rounded-full bg-purple-500 text-white flex items-center justify-center text-sm">3</span> <span class="w-8 h-8 rounded-full bg-purple-500 text-white flex items-center justify-center text-sm">3</span>
Titles Titles
</h2> </h2>
<!-- <pre class="text-xs text-red-500">{{ form.errors }}</pre> -->
<!-- (5) titles --> <!-- (5) titles -->
<CardBox <CardBox
class="ml-10 shadow-md" class="ml-10 shadow-md"
@ -164,8 +153,8 @@
<FormField <FormField
label="Main Title *" label="Main Title *"
help="required: main title" help="required: main title"
:class="{ 'text-red-400': form.errors['titles.0.value'] }"
class="w-full mr-1 flex-1" class="w-full mr-1 flex-1"
:errors="form.errors[`titles.0.value`]"
> >
<FormControl <FormControl
required required
@ -175,27 +164,15 @@
:show-char-count="true" :show-char-count="true"
:max-input-length="255" :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> </FormControl>
</FormField> </FormField>
<FormField <FormField
label="Main Title Language*" label="Main Title Language*"
help="required: main title language" help="required: main title language"
:class="{ 'text-red-400': form.errors['titles.0.language'] }" :errors="form.errors['titles.0.language']"
class="w-full ml-1 flex-1" class="w-full ml-1 flex-1"
> >
<FormControl required v-model="form.titles[0].language" type="text" :is-read-only="true"> <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> </FormControl>
</FormField> </FormField>
</div> </div>
@ -224,7 +201,7 @@
placeholder="[enter main title]" placeholder="[enter main title]"
> >
<div class="text-red-400 text-sm" v-if="form.errors[`titles.${index}.value`]"> <div class="text-red-400 text-sm" v-if="form.errors[`titles.${index}.value`]">
{{ form.errors[`titles.${index}.value`].join(', ') }} {{ formatError(form.errors[`titles.${index}.value`]) }}
</div> </div>
</FormControl> </FormControl>
</td> </td>
@ -236,8 +213,8 @@
:options="titletypes" :options="titletypes"
placeholder="[select title type]" placeholder="[select title type]"
> >
<div class="text-red-400 text-sm" v-if="Array.isArray(form.errors[`titles.${index}.type`])"> <div class="text-red-400 text-sm" v-if="form.errors[`titles.${index}.type`]">
{{ form.errors[`titles.${index}.type`].join(', ') }} {{ formatError(form.errors[`titles.${index}.type`]) }}
</div> </div>
</FormControl> </FormControl>
</td> </td>
@ -250,7 +227,7 @@
placeholder="[select title language]" placeholder="[select title language]"
> >
<div class="text-red-400 text-sm" v-if="form.errors[`titles.${index}.language`]"> <div class="text-red-400 text-sm" v-if="form.errors[`titles.${index}.language`]">
{{ form.errors[`titles.${index}.language`].join(', ') }} {{ formatError(form.errors[`titles.${index}.language`]) }}
</div> </div>
</FormControl> </FormControl>
</td> </td>
@ -293,7 +270,7 @@
<FormField <FormField
label="Main Abstract *" label="Main Abstract *"
help="required: main abstract" help="required: main abstract"
:class="{ 'text-red-400': form.errors['descriptions.0.value'] }" :errors="form.errors['descriptions.0.value']"
class="w-full mr-1 flex-1" class="w-full mr-1 flex-1"
> >
<FormControl <FormControl
@ -304,27 +281,15 @@
:show-char-count="true" :show-char-count="true"
:max-input-length="2500" :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> </FormControl>
</FormField> </FormField>
<FormField <FormField
label="Main Description Language*" label="Main Description Language*"
help="required: main abstract language" help="required: main abstract language"
:class="{ 'text-red-400': form.errors['descriptions.0.language'] }" :errors="form.errors['descriptions.0.language']"
class="w-full ml-1 flex-1" class="w-full ml-1 flex-1"
> >
<FormControl required v-model="form.descriptions[0].language" type="text" :is-read-only="true"> <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> </FormControl>
</FormField> </FormField>
</div> </div>
@ -350,7 +315,7 @@
placeholder="[enter main title]" placeholder="[enter main title]"
> >
<div class="text-red-400 text-sm" v-if="form.errors[`descriptions.${index}.value`]"> <div class="text-red-400 text-sm" v-if="form.errors[`descriptions.${index}.value`]">
{{ form.errors[`descriptions.${index}.value`].join(', ') }} {{ formatError(form.errors[`descriptions.${index}.value`]) }}
</div> </div>
</FormControl> </FormControl>
</td> </td>
@ -364,9 +329,9 @@
> >
<div <div
class="text-red-400 text-sm" class="text-red-400 text-sm"
v-if="Array.isArray(form.errors[`descriptions.${index}.type`])" v-if="form.errors[`descriptions.${index}.type`]"
> >
{{ form.errors[`descriptions.${index}.type`].join(', ') }} {{ formatError(form.errors[`descriptions.${index}.type`]) }}
</div> </div>
</FormControl> </FormControl>
</td> </td>
@ -379,7 +344,7 @@
placeholder="[select title language]" placeholder="[select title language]"
> >
<div class="text-red-400 text-sm" v-if="form.errors[`descriptions.${index}.language`]"> <div class="text-red-400 text-sm" v-if="form.errors[`descriptions.${index}.language`]">
{{ form.errors[`descriptions.${index}.language`].join(', ') }} {{ formatError(form.errors[`descriptions.${index}.language`]) }}
</div> </div>
</FormControl> </FormControl>
</td> </td>
@ -462,8 +427,8 @@
:errors="form.errors" :errors="form.errors"
:relation="'authors'" :relation="'authors'"
/> />
<div class="text-red-400 text-sm" v-if="form.errors.authors && Array.isArray(form.errors.authors)"> <div class="text-red-400 text-sm" v-if="form.errors.authors">
{{ form.errors.authors.join(', ') }} {{ formatError(form.errors.authors) }}
</div> </div>
</CardBox> </CardBox>
@ -499,8 +464,8 @@
:errors="form.errors" :errors="form.errors"
:relation="'contributors'" :relation="'contributors'"
/> />
<div class="text-red-400 text-sm" v-if="form.errors.contributors && Array.isArray(form.errors.contributors)"> <div class="text-red-400 text-sm" v-if="form.errors.contributors">
{{ form.errors.contributors.join(', ') }} {{ formatError(form.errors.contributors) }}
</div> </div>
</CardBox> </CardBox>
</div> </div>
@ -522,7 +487,7 @@
<FormField <FormField
label="Project.." label="Project.."
help="project is optional" help="project is optional"
:class="{ 'text-red-400': form.errors.project_id }" :errors="form.errors.project_id"
class="w-full mx-2 flex-1" class="w-full mx-2 flex-1"
> >
<FormControl <FormControl
@ -533,9 +498,9 @@
:errors="form.errors.project_id" :errors="form.errors.project_id"
:options="projects" :options="projects"
> >
<div class="text-red-400 text-sm" v-if="form.errors.project_id"> <!-- <div class="text-red-400 text-sm" v-if="form.errors.project_id">
{{ form.errors.project_id.join(', ') }} {{ form.errors.project_id.join(', ') }}
</div> </div> -->
</FormControl> </FormControl>
</FormField> </FormField>
@ -543,7 +508,7 @@
<FormField <FormField
label="Embargo Date.." label="Embargo Date.."
help="embargo date is optional" help="embargo date is optional"
:class="{ 'text-red-400': form.errors.embargo_date }" :errors="form.errors.embargo_date"
class="w-full mx-2 flex-1" class="w-full mx-2 flex-1"
> >
<FormControl <FormControl
@ -605,23 +570,15 @@
<tbody> <tbody>
<tr v-for="(item, index) in form.references"> <tr v-for="(item, index) in form.references">
<td data-label="Reference Value"> <td data-label="Reference Value">
<!-- <input name="Reference Value" class="form-control"
placeholder="[VALUE]" v-model="item.value" /> -->
<FormControl <FormControl
required required
v-model="item.value" v-model="item.value"
:type="'text'" :type="'text'"
placeholder="[VALUE]" placeholder="[VALUE]"
:errors="form.errors.embargo_date" :errors="form.errors[`references.${index}.value`]"
> >
<div <div class="text-red-400 text-sm" v-if="form.errors[`references.${index}.value`]">
class="text-red-400 text-sm" {{ formatError(form.errors[`references.${index}.value`]) }}
v-if="
form.errors[`references.${index}.value`] &&
Array.isArray(form.errors[`references.${index}.value`])
"
>
{{ form.errors[`references.${index}.value`].join(', ') }}
</div> </div>
</FormControl> </FormControl>
</td> </td>
@ -633,11 +590,8 @@
:options="referenceIdentifierTypes" :options="referenceIdentifierTypes"
placeholder="[type]" placeholder="[type]"
> >
<div <div class="text-red-400 text-sm" v-if="form.errors[`references.${index}.type`]">
class="text-red-400 text-sm" {{ formatError(form.errors[`references.${index}.type`]) }}
v-if="Array.isArray(form.errors[`references.${index}.type`])"
>
{{ form.errors[`references.${index}.type`].join(', ') }}
</div> </div>
</FormControl> </FormControl>
</td> </td>
@ -650,16 +604,12 @@
:options="relationTypes" :options="relationTypes"
placeholder="[relation type]" placeholder="[relation type]"
> >
<div <div class="text-red-400 text-sm" v-if="form.errors[`references.${index}.relation`]">
class="text-red-400 text-sm" {{ formatError(form.errors[`references.${index}.relation`]) }}
v-if="Array.isArray(form.errors[`references.${index}.relation`])"
>
{{ form.errors[`references.${index}.relation`].join(', ') }}
</div> </div>
</FormControl> </FormControl>
</td> </td>
<td data-label="Reference Label"> <td data-label="Reference Label">
<!-- <input name="Reference Label" class="form-control" v-model="item.label" /> -->
<FormControl <FormControl
required required
v-model="form.references[index].label" v-model="form.references[index].label"
@ -667,12 +617,11 @@
placeholder="[reference label]" placeholder="[reference label]"
> >
<div class="text-red-400 text-sm" v-if="form.errors[`references.${index}.label`]"> <div class="text-red-400 text-sm" v-if="form.errors[`references.${index}.label`]">
{{ form.errors[`references.${index}.label`].join(', ') }} {{ formatError(form.errors[`references.${index}.label`]) }}
</div> </div>
</FormControl> </FormControl>
</td> </td>
<td class="before:hidden lg:w-1 whitespace-nowrap"> <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)" /> <BaseButton color="danger" :icon="mdiTrashCan" small @click.prevent="removeReference(index)" />
</td> </td>
</tr> </tr>
@ -749,62 +698,18 @@
</div> </div>
<div class="flex flex-col md:flex-row ml-10"> <div class="flex flex-col md:flex-row ml-10">
<!-- x min and max --> <!-- x min and max -->
<FormField <FormField label="Coverage X Min" :errors="form.errors['coverage.x_min']" class="w-full mx-2 flex-1">
label="Coverage X Min" <FormControl required v-model="form.coverage.x_min" type="text" placeholder="[enter 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>
<FormField <FormField label="Coverage X Max" :errors="form.errors['coverage.x_max']" class="w-full mx-2 flex-1">
label="Coverage X Max" <FormControl required v-model="form.coverage.x_max" type="text" placeholder="[enter 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> </FormField>
<!-- y min and max --> <!-- y min and max -->
<FormField <FormField label="Coverage Y Min" :errors="form.errors['coverage.y_min']" class="w-full mx-2 flex-1">
label="Coverage Y Min" <FormControl required v-model="form.coverage.y_min" type="text" placeholder="[enter 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>
<FormField <FormField label="Coverage Y Max" :errors="form.errors['coverage.y_max']" class="w-full mx-2 flex-1">
label="Coverage Y Max" <FormControl required v-model="form.coverage.y_max" type="text" placeholder="[enter 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> </FormField>
</div> </div>
</div> </div>
@ -1028,6 +933,7 @@ const mapId = 'test';
props.dataset.filesToDelete = []; props.dataset.filesToDelete = [];
props.dataset.subjectsToDelete = []; props.dataset.subjectsToDelete = [];
props.dataset.referencesToDelete = []; props.dataset.referencesToDelete = [];
let form = useForm<Dataset>(props.dataset as Dataset); let form = useForm<Dataset>(props.dataset as Dataset);
// Add this computed property to the script section // Add this computed property to the script section
@ -1446,6 +1352,11 @@ const onMapInitialized = (newItem: any) => {
console.log(newItem); console.log(newItem);
}; };
const formatError = (error: string | string[] | undefined) => {
if (!error) return '';
return Array.isArray(error) ? error.join(', ') : error;
};
// Add this method to generate change summaries // Add this method to generate change summaries
const getChangesSummary = () => { const getChangesSummary = () => {
const changes = []; const changes = [];

View file

@ -1,6 +1,7 @@
import '../css/app.css'; import '../css/app.css';
import { createApp, h } from 'vue'; import { createApp, h } from 'vue';
import { Inertia } from '@inertiajs/inertia'; // import { Inertia } from '@inertiajs/inertia';
import { router } from '@inertiajs/vue3';
import { Head, Link, createInertiaApp } from '@inertiajs/vue3'; import { Head, Link, createInertiaApp } from '@inertiajs/vue3';
// import DefaultLayout from '@/Layouts/Default.vue'; // import DefaultLayout from '@/Layouts/Default.vue';
@ -8,7 +9,7 @@ import { createPinia } from 'pinia';
import { StyleService } from '@/Stores/style.service'; import { StyleService } from '@/Stores/style.service';
import { LayoutService } from '@/Stores/layout'; import { LayoutService } from '@/Stores/layout';
import { LocaleStore } from '@/Stores/locale'; import { LocaleStore } from '@/Stores/locale';
import { MainService } from './Stores/main';
import { darkModeKey, styleKey } from '@/config'; import { darkModeKey, styleKey } from '@/config';
import type { DefineComponent } from 'vue'; import type { DefineComponent } from 'vue';
import { resolvePageComponent } from '@adonisjs/inertia/helpers'; import { resolvePageComponent } from '@adonisjs/inertia/helpers';
@ -98,7 +99,7 @@ if ((!localStorage[darkModeKey] && window.matchMedia('(prefers-color-scheme: dar
// mainService.fetchChartData(); // mainService.fetchChartData();
/* Collapse mobile aside menu on route change */ /* Collapse mobile aside menu on route change */
Inertia.on('navigate', () => { router.on('navigate', () => {
layoutService.isAsideMobileExpanded = false; layoutService.isAsideMobileExpanded = false;
layoutService.isAsideLgActive = false; layoutService.isAsideLgActive = false;
}); });

View file

@ -0,0 +1,475 @@
// ====================================================================
// FILE: composables/useDatasetChangeDetection.ts
// ====================================================================
import { computed, Ref } from 'vue';
import type { Dataset } from '@/Dataset';
import { InertiaForm } from '@inertiajs/vue3';
interface ComparisonOptions {
orderSensitive?: boolean;
compareKey?: string;
}
export function useDatasetChangeDetection(
form: InertiaForm<Dataset>,
originalDataset: Ref<Dataset>
) {
/**
* Compare arrays with order sensitivity
*/
const compareArraysWithOrder = (
current: any[],
original: any[],
compareKey?: string
): boolean => {
if (current.length !== original.length) return true;
for (let i = 0; i < current.length; i++) {
const currentItem = current[i];
const originalItem = original[i];
if (compareKey && currentItem[compareKey] !== originalItem[compareKey]) {
return true;
}
if (JSON.stringify(currentItem) !== JSON.stringify(originalItem)) {
return true;
}
}
return false;
};
/**
* Compare arrays without order sensitivity (content-based)
*/
const compareArraysContent = (current: any[], original: any[]): boolean => {
if (current.length !== original.length) return true;
const normalizedCurrent = current
.map((item) => JSON.stringify(item))
.sort();
const normalizedOriginal = original
.map((item) => JSON.stringify(item))
.sort();
return (
JSON.stringify(normalizedCurrent) !== JSON.stringify(normalizedOriginal)
);
};
/**
* Check if licenses have changed
*/
const hasLicenseChanges = (): boolean => {
const originalLicenses = Array.isArray(originalDataset.value.licenses)
? originalDataset.value.licenses
.map((l) => (typeof l === 'object' ? l.id.toString() : String(l)))
.sort()
: [];
const currentLicenses = Array.isArray(form.licenses)
? form.licenses
.map((l) => (typeof l === 'object' ? l.id.toString() : String(l)))
.sort()
: [];
return JSON.stringify(currentLicenses) !== JSON.stringify(originalLicenses);
};
/**
* Check if basic properties have changed
*/
const hasBasicPropertyChanges = (): boolean => {
const original = originalDataset.value;
return (
form.language !== original.language ||
form.type !== original.type ||
form.creating_corporation !== original.creating_corporation ||
Number(form.project_id) !== Number(original.project_id) ||
form.embargo_date !== original.embargo_date
);
};
/**
* Check if there are items marked for deletion
*/
const hasDeletionChanges = (): boolean => {
return (
(form.subjectsToDelete?.length ?? 0) > 0 ||
(form.referencesToDelete?.length ?? 0) > 0
);
};
/**
* Check if files have changed
*/
const hasFileChanges = (): boolean => {
const currentFiles = form.files || [];
const originalFiles = originalDataset.value.files || [];
// Check for new files
const newFiles = currentFiles.filter((f) => !f.id);
if (newFiles.length > 0) return true;
// Check for deleted files
const originalFileIds = originalFiles.map((f) => f.id).filter(Boolean);
const currentFileIds = currentFiles.map((f) => f.id).filter(Boolean);
if (!originalFileIds.every((id) => currentFileIds.includes(id))) {
return true;
}
// Check for file order changes
return compareArraysWithOrder(currentFiles, originalFiles, 'sort_order');
};
/**
* Check if coverage has changed
*/
const hasCoverageChanges = (): boolean => {
const currentCoverage = form.coverage || {};
const originalCoverage = originalDataset.value.coverage || {};
return (
Number(currentCoverage.x_min) !== Number(originalCoverage.x_min) ||
Number(currentCoverage.x_max) !== Number(originalCoverage.x_max) ||
Number(currentCoverage.y_min) !== Number(originalCoverage.y_min) ||
Number(currentCoverage.y_max) !== Number(originalCoverage.y_max)
);
};
/**
* Main change detection computed property
*/
const hasUnsavedChanges = computed(() => {
// Check if form is processing
if (form.processing) return true;
const original = originalDataset.value;
// Check basic properties
if (hasBasicPropertyChanges()) return true;
// Check deletion arrays
if (hasDeletionChanges()) return true;
// Check licenses
if (hasLicenseChanges()) return true;
// Check files (order-sensitive)
if (hasFileChanges()) return true;
// Check authors (order-sensitive)
if (
compareArraysWithOrder(
form.authors || [],
original.authors || []
)
) {
return true;
}
// Check contributors (order-sensitive)
if (
compareArraysWithOrder(
form.contributors || [],
original.contributors || []
)
) {
return true;
}
// Check titles (order-sensitive)
if (compareArraysWithOrder(form.titles, original.titles)) {
return true;
}
// Check descriptions (order-sensitive)
if (compareArraysWithOrder(form.descriptions, original.descriptions)) {
return true;
}
// Check subjects/keywords (order-insensitive)
if (
compareArraysContent(
form.subjects || [],
original.subjects || []
)
) {
return true;
}
// Check references (order-insensitive)
if (
compareArraysContent(
form.references || [],
original.references || []
)
) {
return true;
}
// Check coverage
if (hasCoverageChanges()) return true;
return false;
});
/**
* Analyze array changes with detailed information
*/
const analyzeArrayChanges = (
current: any[],
original: any[],
itemName: string
): string[] => {
const changes: string[] = [];
// Check for count changes
if (current.length !== original.length) {
const diff = current.length - original.length;
if (diff > 0) {
changes.push(`${diff} ${itemName}(s) added`);
} else {
changes.push(`${Math.abs(diff)} ${itemName}(s) removed`);
}
}
// Check for order changes (only if same count)
if (current.length === original.length && current.length > 1) {
const currentIds = current.map((item) => item.id).filter(Boolean);
const originalIds = original.map((item) => item.id).filter(Boolean);
if (currentIds.length === originalIds.length && currentIds.length > 0) {
const orderChanged = currentIds.some(
(id, index) => id !== originalIds[index]
);
if (orderChanged) {
changes.push(`${itemName} order changed`);
}
}
}
// Check for content changes
if (current.length === original.length) {
const contentChanged =
JSON.stringify(current) !== JSON.stringify(original);
const orderChanged = changes.some((change) =>
change.includes('order changed')
);
if (contentChanged && !orderChanged) {
changes.push(`${itemName} content modified`);
}
}
return changes;
};
/**
* Generate detailed changes summary
*/
const getChangesSummary = (): string[] => {
const changes: string[] = [];
const original = originalDataset.value;
// Basic property changes
if (form.language !== original.language) {
changes.push('Language changed');
}
if (form.type !== original.type) {
changes.push('Dataset type changed');
}
if (form.creating_corporation !== original.creating_corporation) {
changes.push('Creating corporation changed');
}
if (Number(form.project_id) !== Number(original.project_id)) {
changes.push('Project changed');
}
if (form.embargo_date !== original.embargo_date) {
changes.push('Embargo date changed');
}
// Deletion tracking
if ((form.subjectsToDelete?.length ?? 0) > 0) {
changes.push(
`${form.subjectsToDelete.length} keyword(s) marked for deletion`
);
}
if ((form.referencesToDelete?.length ?? 0) > 0) {
changes.push(
`${form.referencesToDelete.length} reference(s) marked for deletion`
);
}
// License changes
if (hasLicenseChanges()) {
changes.push('Licenses modified');
}
// Files analysis
const currentFiles = form.files || [];
const originalFiles = original.files || [];
const newFiles = currentFiles.filter((f) => !f.id);
if (newFiles.length > 0) {
changes.push(`${newFiles.length} new file(s) added`);
}
const existingCurrentFiles = currentFiles.filter((f) => f.id);
const existingOriginalFiles = originalFiles.filter((f) => f.id);
if (
existingCurrentFiles.length === existingOriginalFiles.length &&
existingCurrentFiles.length > 1
) {
const currentOrder = existingCurrentFiles.map((f) => f.id);
const originalOrder = existingOriginalFiles.map((f) => f.id);
const orderChanged = currentOrder.some(
(id, index) => id !== originalOrder[index]
);
if (orderChanged) {
changes.push('File order changed');
}
}
// Authors and contributors
changes.push(
...analyzeArrayChanges(
form.authors || [],
original.authors || [],
'author'
)
);
changes.push(
...analyzeArrayChanges(
form.contributors || [],
original.contributors || [],
'contributor'
)
);
// Titles analysis
if (JSON.stringify(form.titles) !== JSON.stringify(original.titles)) {
if (form.titles.length !== original.titles.length) {
const diff = form.titles.length - original.titles.length;
changes.push(
diff > 0
? `${diff} title(s) added`
: `${Math.abs(diff)} title(s) removed`
);
} else if (form.titles.length > 0) {
if (form.titles[0]?.value !== original.titles[0]?.value) {
changes.push('Main title changed');
}
const otherTitlesChanged = form.titles
.slice(1)
.some(
(title, index) =>
JSON.stringify(title) !== JSON.stringify(original.titles[index + 1])
);
if (otherTitlesChanged) {
changes.push('Additional titles modified');
}
}
}
// Descriptions analysis
if (
JSON.stringify(form.descriptions) !==
JSON.stringify(original.descriptions)
) {
if (form.descriptions.length !== original.descriptions.length) {
const diff = form.descriptions.length - original.descriptions.length;
changes.push(
diff > 0
? `${diff} description(s) added`
: `${Math.abs(diff)} description(s) removed`
);
} else if (form.descriptions.length > 0) {
if (
form.descriptions[0]?.value !== original.descriptions[0]?.value
) {
changes.push('Main abstract changed');
}
const otherDescChanged = form.descriptions
.slice(1)
.some(
(desc, index) =>
JSON.stringify(desc) !==
JSON.stringify(original.descriptions[index + 1])
);
if (otherDescChanged) {
changes.push('Additional descriptions modified');
}
}
}
// Subjects/Keywords analysis
const currentSubjects = form.subjects || [];
const originalSubjects = original.subjects || [];
if (currentSubjects.length !== originalSubjects.length) {
const diff = currentSubjects.length - originalSubjects.length;
changes.push(
diff > 0
? `${diff} keyword(s) added`
: `${Math.abs(diff)} keyword(s) removed`
);
} else if (currentSubjects.length > 0) {
const currentSubjectsNormalized = currentSubjects
.map((s) => JSON.stringify(s))
.sort();
const originalSubjectsNormalized = originalSubjects
.map((s) => JSON.stringify(s))
.sort();
if (
JSON.stringify(currentSubjectsNormalized) !==
JSON.stringify(originalSubjectsNormalized)
) {
changes.push('Keywords modified');
}
}
// References analysis
const currentRefs = form.references || [];
const originalRefs = original.references || [];
if (currentRefs.length !== originalRefs.length) {
const diff = currentRefs.length - originalRefs.length;
changes.push(
diff > 0
? `${diff} reference(s) added`
: `${Math.abs(diff)} reference(s) removed`
);
} else if (currentRefs.length > 0) {
const currentRefsNormalized = currentRefs
.map((r) => JSON.stringify(r))
.sort();
const originalRefsNormalized = originalRefs
.map((r) => JSON.stringify(r))
.sort();
if (
JSON.stringify(currentRefsNormalized) !==
JSON.stringify(originalRefsNormalized)
) {
changes.push('References modified');
}
}
// Coverage changes
if (hasCoverageChanges()) {
changes.push('Geographic coverage changed');
}
return changes;
};
return {
hasUnsavedChanges,
getChangesSummary,
compareArraysWithOrder,
compareArraysContent,
};
}

View file

@ -0,0 +1,217 @@
// ====================================================================
// FILE: composables/useDatasetFormSubmission.ts
// ====================================================================
import { Ref } from 'vue';
import type { Dataset, License } from '@/Dataset';
import { InertiaForm } from '@inertiajs/vue3';
import { stardust } from '@eidellev/adonis-stardust/client';
import { notify } from '@/notiwind';
interface SubmissionOptions {
onSuccess?: (updatedDataset: Dataset) => void;
onError?: (errors: any) => void;
showNotification?: boolean;
}
export function useDatasetFormSubmission(
form: InertiaForm<Dataset>,
originalDataset: Ref<Dataset>
) {
/**
* Check if object has id attribute (type guard)
*/
const hasIdAttribute = (obj: License | number): obj is License => {
return typeof obj === 'object' && 'id' in obj;
};
/**
* Transform licenses for submission
*/
const transformLicenses = (): string[] => {
return form.licenses.map((obj) => {
if (hasIdAttribute(obj)) {
return obj.id.toString();
}
return String(obj);
});
};
/**
* Validate form before submission
*/
const validateForm = (): { valid: boolean; errors: string[] } => {
const errors: string[] = [];
// Required field validations
if (!form.language) {
errors.push('Language is required');
}
if (!form.type) {
errors.push('Dataset type is required');
}
if (!form.creating_corporation) {
errors.push('Creating corporation is required');
}
if (!form.titles || !form.titles[0]?.value) {
errors.push('Main title is required');
}
if (!form.descriptions || !form.descriptions[0]?.value) {
errors.push('Main abstract is required');
}
return {
valid: errors.length === 0,
errors,
};
};
/**
* Handle successful submission
*/
const handleSubmitSuccess = (
updatedDataset: Dataset,
showNotification: boolean = true
) => {
// Clear deletion arrays
if (updatedDataset.subjectsToDelete) {
updatedDataset.subjectsToDelete = [];
}
if (updatedDataset.referencesToDelete) {
updatedDataset.referencesToDelete = [];
}
// Update form with fresh data from server
Object.keys(updatedDataset).forEach((key) => {
if (key !== 'licenses' && key in form) {
form[key] = updatedDataset[key];
}
});
// Clear form errors
form.clearErrors();
// Update original dataset reference
originalDataset.value = JSON.parse(JSON.stringify(updatedDataset));
// Show success notification
if (showNotification) {
notify(
{
type: 'success',
title: 'Success',
text: 'Dataset updated successfully',
},
4000,
);
}
};
/**
* Handle submission errors
*/
const handleSubmitError = (errors: any) => {
console.error('Submission errors:', errors);
notify(
{
type: 'error',
title: 'Error',
text: 'Failed to update dataset. Please check the form for errors.',
},
5000,
);
};
/**
* Submit form with auto-save behavior
*/
const submitWithAutoSave = async (
options: SubmissionOptions = {}
): Promise<void> => {
try {
const route = stardust.route('editor.dataset.update', [form.id]);
const licenses = transformLicenses();
await form
.transform((data) => ({
...data,
licenses,
rights: 'true',
}))
.put(route, {
onSuccess: (page) => {
const updatedDataset = page.props.dataset || form.data();
handleSubmitSuccess(
updatedDataset,
options.showNotification ?? true
);
if (options.onSuccess) {
options.onSuccess(updatedDataset);
}
},
onError: (errors) => {
handleSubmitError(errors);
if (options.onError) {
options.onError(errors);
}
},
});
} catch (error) {
console.error('Unexpected error during submission:', error);
notify(
{
type: 'error',
title: 'Error',
text: 'An unexpected error occurred. Please try again.',
},
5000,
);
}
};
/**
* Standard submit with validation
*/
const submit = async (
options: SubmissionOptions = {}
): Promise<void> => {
// Validate form first
const validation = validateForm();
if (!validation.valid) {
notify(
{
type: 'error',
title: 'Validation Error',
text: validation.errors.join(', '),
},
5000,
);
return;
}
await submitWithAutoSave({
...options,
showNotification: true,
});
};
/**
* Silent submit without notification (for auto-save)
*/
const submitSilently = async (): Promise<void> => {
await submitWithAutoSave({
showNotification: false,
});
};
return {
submit,
submitWithAutoSave,
submitSilently,
validateForm,
transformLicenses,
};
}

View file

@ -28,7 +28,12 @@
<input type="hidden" id="initial-state-settings-cronErrors" value="IiI="> <input type="hidden" id="initial-state-settings-cronErrors" value="IiI=">
<input type="hidden" id="initial-state-settings-cliBasedCronPossible" value="dHJ1ZQ=="> <input type="hidden" id="initial-state-settings-cliBasedCronPossible" value="dHJ1ZQ==">
<input type="hidden" id="initial-state-settings-cliBasedCronUser" value="Ind3dy1kYXRhIg=="> <input type="hidden" id="initial-state-settings-cliBasedCronUser" value="Ind3dy1kYXRhIg==">
@vite(['resources/js/app.ts'])
{{--
WICHTIG: In Inertia v3 nutzen wir den dynamischen Vite-Import.
Das sorgt dafür, dass nur das JS/CSS der aktuellen Seite geladen wird.
--}}
@vite(['resources/js/app.ts', `resources/js/Pages/${page.component}.vue`])
@routes('test') @routes('test')
@inertiaHead @inertiaHead

View file

@ -23,7 +23,7 @@ server.use([
() => import('#middleware/container_bindings_middleware'), () => import('#middleware/container_bindings_middleware'),
() => import('@adonisjs/static/static_middleware'), () => import('@adonisjs/static/static_middleware'),
// () => import('@adonisjs/cors/cors_middleware'), // () => import('@adonisjs/cors/cors_middleware'),
() => import('@adonisjs/inertia/inertia_middleware'), // () => import('@adonisjs/inertia/inertia_middleware'),
() => import('@adonisjs/vite/vite_middleware'), () => import('@adonisjs/vite/vite_middleware'),
]); ]);
/** /**
@ -34,6 +34,8 @@ router.use([
() => import('@adonisjs/core/bodyparser_middleware'), () => import('@adonisjs/core/bodyparser_middleware'),
() => import('@adonisjs/session/session_middleware'), () => import('@adonisjs/session/session_middleware'),
() => import('@adonisjs/shield/shield_middleware'), () => import('@adonisjs/shield/shield_middleware'),
// HIER EINFÜGEN
() => import('@adonisjs/inertia/inertia_middleware'), //
// () => import('@adonisjs/inertia/inertia_middleware'), // () => import('@adonisjs/inertia/inertia_middleware'),
() => import('@adonisjs/auth/initialize_auth_middleware'), () => import('@adonisjs/auth/initialize_auth_middleware'),
() => import('#middleware/stardust_middleware'), () => import('#middleware/stardust_middleware'),

View file

@ -15,56 +15,40 @@ type Options = {
* where each object has a "type" property. * where each object has a "type" property.
*/ */
async function arrayContainsTypes(value: unknown, options: Options, field: FieldContext) { async function arrayContainsTypes(value: unknown, options: Options, field: FieldContext) {
if (!Array.isArray(value)) { if (!Array.isArray(value)) return;
field.report(`The {{field}} must be an array of titles.`, 'array.titlesContainsMainAndTranslated', field);
return false; const typeAExpected = options.typeA.toLowerCase(); // 'main' or 'abstract'
const typeBExpected = options.typeB.toLowerCase(); // 'translated'
// 1. Check the very first element (The "Main" entry)
const firstItem = value[0];
const isFirstItemMain = firstItem &&
typeof firstItem === 'object' &&
String(firstItem.type).toLowerCase() === typeAExpected;
// 2. Check the rest of the array for the "Translated" entry
// We look for typeB anywhere in the array
const hasTranslatedEntry = value.some((item: any) =>
item && typeof item === 'object' && String(item.type).toLowerCase() === typeBExpected
);
// 3. Conditional Error Messaging based on Field Path
const path = field.getFieldPath();
const isTitles = path === 'titles';
if (!isFirstItemMain) {
const msg = isTitles
? 'The first entry must be the Main Title.'
: 'The first entry must be the Main Abstract.';
field.report(msg, 'array.first_item_invalid', field);
} }
const typeAExpected = options.typeA.toLowerCase(); if (!hasTranslatedEntry) {
const typeBExpected = options.typeB.toLowerCase(); const msg = isTitles
? 'At least one Translated Title is required.'
// const hasMain = value.some((title: any) => { : 'At least one Translated Abstract is required.';
// return typeof title === 'object' && title !== null && String(title.type).toLowerCase() === 'main'; field.report(msg, 'array.missing_translated', field);
// });
// const hasTranslated = value.some((title: any) => {
// return typeof title === 'object' && title !== null && String(title.type).toLowerCase() === 'translated';
// });
const hasTypeA = value.some((item: any) => {
return typeof item === 'object' && item !== null && String(item.type).toLowerCase() === typeAExpected;
});
const hasTypeB = value.some((item: any) => {
return typeof item === 'object' && item !== null && String(item.type).toLowerCase() === typeBExpected;
});
if (!hasTypeA || !hasTypeB) {
let errorMessage = `The ${field.getFieldPath()} array must have at least one '${options.typeA}' item and one '${options.typeB}' item.`;
// Check for specific field names to produce a more readable message.
if (field.getFieldPath() === 'titles') {
// For titles we expect one main and minimum one translated title.
if (!hasTypeA && !hasTypeB) {
errorMessage = 'For titles, define at least one main title and at least one Translated title as MAIN TITLE.';
} else if (!hasTypeA) {
errorMessage = 'For titles, define at least one main title.';
} else if (!hasTypeB) {
errorMessage = 'For Titles, define at least one Translated title as MAIN TITLE.';
} }
} else if (field.getFieldPath() === 'descriptions') {
// For descriptions we expect one abstracts description and minimum one translated description.
if (!hasTypeA && !hasTypeB) {
errorMessage = 'For descriptions, define at least one abstract and at least one Translated description as MAIN ABSTRACT.';
} else if (!hasTypeA) {
errorMessage = 'For descriptions, define at least one abstract.';
} else if (!hasTypeB) {
errorMessage = 'For Descriptions, define at least one Translated description as MAIN ABSTRACT.';
}
}
field.report(errorMessage, 'array.containsTypes', field, options);
return false;
}
return true;
} }
export const arrayContainsMainAndTranslatedRule = vine.createRule(arrayContainsTypes); export const arrayContainsMainAndTranslatedRule = vine.createRule(arrayContainsTypes);

View file

@ -1,78 +1,38 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import adonisjs from '@adonisjs/vite/client'; import adonisjs from '@adonisjs/vite/client';
// import { getDirname } from '@adonisjs/core/helpers';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import path from 'path'; import path from 'path';
// import tailwind from '@tailwindcss/postcss';
// import autoprefixer from 'autoprefixer';
// import postcssNesting from 'postcss-nesting';
import inertia from '@adonisjs/inertia/client'; import inertia from '@adonisjs/inertia/client';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
inertia(), /**
* Das inertia-Plugin muss VOR adonisjs stehen.
* Es hilft dabei, dass SSR und Client-Komponenten richtig erkannt werden.
*/
inertia({ ssr: { enabled: false } }),
vue(), vue(),
adonisjs({ adonisjs({
/** entrypoints: ['resources/js/app.ts'], // CSS wird meist in app.ts importiert
* Entrypoints of your application. Each entrypoint will reload: ['resources/views/**/*.edge', 'resources/js/**/*.vue'],
* result in a separate bundle.
*/
entrypoints: ['resources/js/app.ts', 'resources/css/app.css'],
/**
* Paths to watch and reload the browser on file change
*/
reload: ['resources/views/**/*.edge'],
}), }),
], ],
server: {
port: 5173,
// host: '127.0.0.1'
},
// css: {
// postcss: {
// plugins: [
// postcssNesting(),
// tailwind(),
// autoprefixer(),
// ],
// },
// },
/**
* Define aliases for importing modules from
* your frontend code
*/
resolve: { resolve: {
alias: { alias: {
'@': path.resolve('./resources/js/'), // Stelle sicher, dass @ exakt auf resources/js zeigt
'@': path.resolve(__dirname, './resources/js'),
'~': path.resolve(__dirname, 'node_modules/'), '~': path.resolve(__dirname, 'node_modules/'),
}, },
}, },
// optimizeDeps: { /**
// esbuildOptions: { * WICHTIG: Entferne die manuelle build.rollupOptions.input Konfiguration,
// target: 'esnext' * wenn du das adonisjs() Plugin nutzt. Das Plugin verwaltet die
// }, * Entrypoints automatisch über die obige Konfiguration.
// include: ['resources/js/**/*.{vue,js,jsx,ts,tsx}'], */
// exclude: ['node_modules', 'app'],
// },
build: { build: {
sourcemap: true, sourcemap: true,
outDir: 'public/assets', // outDir wird normalerweise automatisch von Adonis verwaltet (public/assets)
emptyOutDir: true,
manifest: true,
rollupOptions: {
input: 'resources/js/app.ts',
}, },
},
// build: {
// outDir: 'public/assets',
// emptyOutDir: true,
// manifest: true,
// rollupOptions: {
// input: path.resolve(__dirname, 'resources/js/app.ts'),
// },
// },
}); });