feat: Add alternate mimetype support, enhance validation for alternate mimetypes, and improve script loading performance
All checks were successful
CI / container-job (push) Successful in 36s

- mime_type.ts: Added a new column `public alternate_mimetype: string;`
- MimetypeController.ts: Extended validation and storage logic to accommodate the new `alternate_mimetype` attribute
- adonisrc.ts: Integrated new validation rule to validate user-provided mimetypes
- vite.ts: Set `defer: true` for script attributes to improve loading performance
- update_1_to_mime_types.ts: Added migration for the new `alternate_mimetype` column in the database
- UI improvements: Updated components such as AsideMenuLayer.vue, FormCheckRadioGroup.vue, MimeTypeInput.vue, NavBar.vue (lime-green background), NavBarMenu.vue, SectionBannerStarOnGitea.vue, Admin/mimetype/Create.vue, Admin/mimetype/Delete.vue, Admin/mimetype/Index.vue
- allowed_extensions_mimetype.ts: Enhanced rule to also check for alternate mimetypes
- referenceValidation.ts: Improved validation to allow only ISBNs with a '-' delimiter
- package-lock.json: Updated npm dependencie
This commit is contained in:
Kaimbacher 2025-02-13 15:49:09 +01:00
parent 4c5a8f5a42
commit a3031169ca
20 changed files with 719 additions and 704 deletions

View file

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { ref, watch, computed, Ref, reactive } from 'vue';
import { ref, reactive } from 'vue';
import { Head, useForm } from '@inertiajs/vue3';
import { mdiAccountKey, mdiArrowLeftBoldOutline } from '@mdi/js';
import { mdiAccountKey, mdiArrowLeftBoldOutline, mdiTrashCan, mdiImageText, mdiPlus } from '@mdi/js';
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
import SectionMain from '@/Components/SectionMain.vue';
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
@ -16,12 +16,9 @@ import FormControl from '@/Components/FormControl.vue';
import standardTypes from 'mime/types/standard.js';
import otherTypes from 'mime/types/other.js';
import FormCheckRadioGroup from '@/Components/FormCheckRadioGroup.vue';
import MimetypeInput from '@/Components/MimetypeInput.vue';
const props = defineProps({
permissions: {
type: Object,
default: () => ({}),
},
defineProps({
borderless: Boolean,
transparent: Boolean,
ctrlKFocus: Boolean,
@ -36,88 +33,61 @@ const file_extensions = reactive<Record<string, string>>({});
interface FormData {
name: string;
file_extension: string[];
alternate_mimetype: string[];
enabled: boolean;
[key: string]: string | string[] | boolean;
}
const form = useForm<FormData>({
name: '',
file_extension: [],
alternate_mimetype: [],
enabled: true,
});
const filteredMimetypes = ref<string[]>([]); // Stores the filtered MIME types for the dropdown
const showDropdown = ref(false); // Controls the visibility of the autocomplete dropdown
const selectedIndex: Ref<number> = ref(0); // Track selected MIME type in the dropdown
const ul: Ref<HTMLLIElement[] | null> = ref<HTMLLIElement[]>([]);
const newExtension: Ref = ref(''); //reactive([] as Array<string>);
const mimetypeError = ref<string | null>(null);
watch(selectedIndex, (selectedIndex: number) => {
if (selectedIndex != null && ul.value != null) {
const currentElement: HTMLLIElement = ul.value[selectedIndex];
currentElement &&
currentElement?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'start',
});
}
});
// const newExtension = ref<string>('');
// const addFileExtension = () => {
// if (newExtension.value && !file_extensions[newExtension.value]) {
// file_extensions[newExtension.value] = newExtension.value;
// newExtension.value = '';
// }
// };
const addAlternateMimetype = () => {
form.alternate_mimetype.push("");
};
const removeAliasMimetype = (index: number) => {
form.alternate_mimetype.splice(index, 1);
};
// Function to reset the object
function resetFileExtensions() {
// Reset to an empty object
Object.keys(file_extensions).forEach(key => {
delete file_extensions[key];
});
}
const inputElClass = computed(() => {
const base = [
'block p-2.5 w-full z-20 text-sm text-gray-900 bg-gray-50 rounded-r-lg',
'dark:placeholder-gray-400 dark:text-white dark:focus:border-blue-500',
'h-12',
props.borderless ? 'border-0' : 'border',
props.transparent ? 'bg-transparent' : 'bg-white dark:bg-slate-800',
];
// if (props.icon) {
base.push('pl-10');
// }
return base;
});
// Check if the MIME type is valid
const isValidMimeType = (mimeType: string): boolean => {
let extensions = mime.getExtension(mimeType)
return extensions !== null;
};
async function handleInputChange(e: Event) {
const target = <HTMLInputElement>e.target;
newExtension.value = target.value;
const clearInput = () => {
// newExtension.value = '';
// showDropdown.value = false;
form.name = '';
resetFileExtensions();
};
if (newExtension.value.length >= 2) {
showDropdown.value = true;
filteredMimetypes.value = mimeTypes.filter(mimeType =>
mimeType.toLowerCase().includes(newExtension.value.toLowerCase())
);
} else {
// data.results = [];
showDropdown.value = false;
}
}
// Handle MIME type selection from the dropdown
const selectResult = (mimeType: string) => {
form.name = mimeType;
// file_extensions.values = [];
resetFileExtensions();
showDropdown.value = false;
newExtension.value = ''; // Reset the input
selectedIndex.value = -1;
// showDropdown.value = false;
// newExtension.value = '';
// selectedIndex.value = -1;
if (form.name && isValidMimeType(form.name)) {
const extensions = mime.getAllExtensions(form.name) as Set<string>;
// Iterate over each extension and set both key and value to the extension
Array.from(extensions).forEach(extension => {
file_extensions[extension] = extension;
});
@ -126,61 +96,16 @@ const selectResult = (mimeType: string) => {
}
};
function onArrowDown() {
if (filteredMimetypes.value.length > 0) {
selectedIndex.value = selectedIndex.value === filteredMimetypes.value.length - 1 ? 0 : selectedIndex.value + 1;
// const currentElement: HTMLLIElement = ul.value[selectedIndex.value];
}
}
function onArrowUp() {
if (filteredMimetypes.value.length > 0) {
selectedIndex.value = selectedIndex.value == 0 || selectedIndex.value == -1 ? filteredMimetypes.value.length - 1 : selectedIndex.value - 1;
}
}
function onEnter() {
if (Array.isArray(filteredMimetypes.value) && filteredMimetypes.value.length && selectedIndex.value !== -1 && selectedIndex.value < filteredMimetypes.value.length) {
const mimeType = filteredMimetypes.value[selectedIndex.value];
// this.$emit('person', person);
form.name = mimeType;
// reset form file extensions
// file_extensions.values = [];
resetFileExtensions();
showDropdown.value = false;
newExtension.value = ''; // Reset the input
selectedIndex.value = -1;
if (form.name) {
// clear all loaded file extensions
// file_extensions.values = [];
resetFileExtensions();
if (isValidMimeType(form.name)) {
let extensions = mime.getAllExtensions(form.name) as Set<string>;
// Convert the Set to an array of objects
// Convert the Set to an object
Array.from(extensions).forEach(extension => {
file_extensions[extension] = extension;
});
// file_extensions.push(...formattedExtensions);
} else {
mimetypeError.value = 'Invalid MIME type.';
}
}
}
}
// Handle form submission
const submit = async () => {
if (isValidForm()) {
await form.post(stardust.route('settings.mimetype.store'), {
preserveScroll: true,
});
}
};
// Form validation before submission
const isValidForm = (): boolean => {
if (!form.name) {
form.errors.name = 'Name is required.';
@ -190,6 +115,7 @@ const isValidForm = (): boolean => {
}
if (!form.file_extension.length) {
form.errors.file_extension = 'At least one file extension is required.';
return false;
}
return true;
@ -205,59 +131,15 @@ const isValidForm = (): boolean => {
<BaseButton :route-name="stardust.route('settings.mimetype.index')" :icon="mdiArrowLeftBoldOutline"
label="Back" color="white" rounded-full small />
</SectionTitleLineWithButton>
<!-- <CardBox form @submit.prevent="form.post(stardust.route('role.store'))"> -->
<CardBox form @submit.prevent="submit()">
<!-- MIME Type Input Field with Autocomplete -->
<div class="relative mb-4">
<input v-model="newExtension" type="text" placeholder="Enter Mimetype Name"
class="block w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-blue-500 focus:border-blue-500"
:class="inputElClass" @input="handleInputChange" @keydown.down="onArrowDown"
@keydown.up="onArrowUp" @keydown.prevent.enter="onEnter">
</input>
<svg class="w-4 h-4 absolute left-2.5 top-3.5" v-show="newExtension.length < 2"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<svg class="w-4 h-4 absolute left-2.5 top-3.5" v-show="newExtension.length >= 2"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" @click="() => {
newExtension = '';
showDropdown = false;
form.name = '';
resetFileExtensions();
}
">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
<ul v-if="showDropdown && filteredMimetypes.length"
class="bg-white dark:bg-slate-800 w-full mt-2 max-h-28 overflow-y-auto scroll-smooth">
<li v-for="(mimeType, index) in filteredMimetypes" :key="index" @click="selectResult(mimeType)"
class="pl-8 pr-2 py-1 border-b-2 border-gray-100 relative cursor-pointer hover:bg-blue-500 hover:text-white"
:class="{
'bg-blue-500 text-white': selectedIndex === index,
'bg-white text-gray-900': selectedIndex !== index
}" :ref="(el) => {
if (ul) {
ul[index] = el as HTMLLIElement;
}
}">
<svg class="absolute w-4 h-4 left-2 top-2" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
<span>{{ mimeType }}</span>
</li>
</ul>
</div>
<MimetypeInput @on-select-result="selectResult" @on-clear-input="clearInput" :transparent="transparent"
:borderless="borderless" :mimeTypes="mimeTypes" :isValidMimeType="isValidMimeType" />
<div v-if="mimetypeError" class="text-red-400 text-sm mt-1">
{{ mimetypeError }}
</div>
<BaseDivider v-if="form.name" />
<FormField v-if="form.name" label="Mimetype Name" :class="{ 'text-red-400': form.errors.name }">
<FormControl v-model="form.name" name="display_name" :error="form.errors.name"
:is-read-only=isReadOnly>
@ -266,29 +148,88 @@ const isValidForm = (): boolean => {
</div>
</FormControl>
</FormField>
<FormField v-if="form.name" help="Activate mimetype immediately?" wrap-body
<!-- <FormField v-if="form.name" help="Activate mimetype immediately?" wrap-body
class="mt-8 w-full mx-2 flex-1 flex-col">
<label for="rights" class="checkbox mr-6 mb-3 last:mr-0">
<input type="checkbox" id="rights" required v-model="form.enabled" />
<span class="check" />
<a class="pl-2" target="_blank">Enable mimetype immediately </a>
</label>
</FormField>
<FormField label="Extensions" wrap-body>
</FormField> -->
<FormField v-if="Object.keys(file_extensions).length > 0" label="File Extensions" wrap-body>
<!-- <div class="flex items-center mt-2">
<FormControl v-model="newExtension" placeholder="Enter file extension" class="mr-2" />
<BaseButton color="info" @click="addFileExtension" label="Add" />
</div> -->
<FormCheckRadioGroup v-model="form.file_extension" :options="file_extensions" name="file_extensions"
is-column />
is-column allow-manual-adding manual-adding-placeholder="Enter file extension manually"/>
</FormField>
<div class="text-red-400 text-sm"
v-if="form.errors.file_extension && Array.isArray(form.errors.file_extension)">
{{ form.errors.file_extension.join(', ') }}
<div class="text-red-400 text-sm" v-if="form.errors.file_extension">
{{ form.errors.file_extension }}
</div>
<BaseDivider />
<!-- <FormField label="Add File Extension" wrap-body>
<FormControl v-model="newExtension" placeholder="Enter file extension" />
<BaseButton color="info" @click="addFileExtension" label="Add" />
</FormField> -->
<BaseDivider v-if="Object.keys(file_extensions).length > 0" />
<CardBox v-if="form.name" class="mb-6 shadow" has-table :icon="mdiImageText" title="Alternate Mimetypes"
:header-icon="mdiPlus" @header-icon-click="addAlternateMimetype">
<div v-if="form.alternate_mimetype.length === 0" class="text-center py-4">
<p class="text-gray-600">No alternate mimetypes added yet.</p>
<p class="text-gray-400">
Click the plus icon above to add a new alternate mimetype.
<br>
An alternate mimetype is needed to ensure compatibility across different systems and
software.
For example, the GeoPackage standard mimetype is
'application/vnd.opengeospatial.geopackage+sqlite3', but most software stores it as
'application/x-sqlite3'. Therefore, 'application/x-sqlite3' must be added as an alternate
mimetype.
</p>
<!-- <FormField label="Alias Mimetype" wrap-body>
<FormControl v-model="aliasMimetype" name="alias_mimetype" :error="form.errors.alias_mimetype"
:is-read-only=isReadOnly>
<div class="text-red-400 text-sm" v-if="form.errors.alias_mimetype">
{{ form.errors.alias_mimetype }}
</div>
</FormControl>
<BaseButton color="info" :icon="mdiEarthPlus" small @click.prevent="addAliasMimetype" />
</FormField> -->
</div>
<table class="table-fixed border-green-900" v-if="form.alternate_mimetype.length > 0">
<thead>
<tr>
<th class="w-10/12">Alias Mimetype</th>
<th class="w-2/12"></th>
</tr>
</thead>
<tbody>
<tr v-for="(alias, index) in form.alternate_mimetype" :key="index">
<td>
<FormControl required v-model="form.alternate_mimetype[index]"
placeholder="[alternate mimetype]">
<div class="text-red-400 text-sm"
v-if="Array.isArray(form.errors[`alternate_mimetype.${index}`])">
{{ form.errors[`alternate_mimetype.${index}`]?.join(', ') }}
</div>
</FormControl>
</td>
<td class="before:hidden lg:w-1 whitespace-nowrap">
<BaseButton color="danger" :icon="mdiTrashCan" small
@click.prevent="removeAliasMimetype(index)" />
</td>
</tr>
</tbody>
</table>
<div class="text-red-400 text-sm"
v-if="form.errors.alternate_mimetype && Array.isArray(form.errors.alternate_mimetype)">
{{ form.errors.alternate_mimetype.join(', ') }}
</div>
</CardBox>
<template #footer>
<BaseButtons>

View file

@ -32,7 +32,7 @@ const form = useForm({
// isPreferationRequired: false,
});
const handleSubmit = async (e) => {
const handleSubmit = async (e: Event) => {
e.preventDefault();
await form.delete(stardust.route('settings.mimetype.deleteStore', [props.mimetype.id]));
};

View file

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { Head, usePage } from '@inertiajs/vue3';
import { mdiAccountKey, mdiSquareEditOutline, mdiAlertBoxOutline, mdiPlus, mdiTrashCan } from '@mdi/js';
import { mdiAccountKey, mdiSquareEditOutline, mdiAlertBoxOutline, mdiPlus, mdiTrashCan, mdiCheckCircle, mdiCloseCircle } from '@mdi/js';
import { computed, ComputedRef } from 'vue';
import type { PropType } from "vue";
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
@ -16,6 +16,7 @@ interface MimeType {
id: number;
name: string;
file_extension: string;
alternate_mimetype: string;
enabled: boolean;
}
@ -54,24 +55,41 @@ const flash: ComputedRef<any> = computed(() => {
{{ flash.message }}
</NotificationBar>
<CardBox class="mb-6" has-table>
<table>
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th v-if="can.edit">Actions</th>
<th class="px-4 py-2 text-left text-sm font-medium uppercase tracking-wider">Mimetype</th>
<th class="px-4 py-2 text-left text-sm font-medium uppercase tracking-wider">Alternate Mime Types</th>
<th v-if="can.edit" class="px-4 py-2 text-left text-sm font-medium uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
<tbody class="divide-y divide-gray-200">
<tr v-for="mimetype in mimetypes" :key="mimetype.id">
<td data-label="Name">
{{ mimetype.name }} ({{ mimetype.file_extension }})
<td class="px-4 py-2 whitespace-nowrap text-left text-sm" data-label="Name">
<span class="flex items-center">
<svg viewBox="0 0 24 24" v-if="mimetype.enabled" :class="{'text-green-500': mimetype.enabled}" class="w-4 h-4 mr-2">
<path fill="currentColor" :d="mdiCheckCircle" />
</svg>
<svg v-else viewBox="0 0 24 24" :class="{'text-red-500': !mimetype.enabled}" class="w-4 h-4 mr-2">
<path fill="currentColor" :d="mdiCloseCircle" />
</svg>
<br>
<span class="truncate block max-w-xs">{{ mimetype.name }}</span>
</span>
<ul class="list-none pl-0">
<li v-for="ext in mimetype.file_extension?.split('|')" :key="ext" class="flex items-center truncate block max-w-xs">- .{{ ext }}</li>
</ul>
</td>
<td data-label="Status">
<td class="px-4 py-2 whitespace-nowrap text-left text-sm" data-label="Alternate Mime Types">
<ul class="list-none pl-0">
<li v-for="alt in mimetype.alternate_mimetype?.split('|')" :key="alt" class="flex items-center truncate block max-w-xs">- {{ alt }}</li>
</ul>
</td>
<!-- <td class="px-4 py-2 whitespace-nowrap" data-label="Status">
<template v-if="mimetype.enabled">Active</template>
<template v-else>Inactive</template>
</td>
<td v-if="can.edit" class="before:hidden lg:w-1 whitespace-nowrap">
</td> -->
<td v-if="can.edit" class="before:hidden lg:w-1 whitespace-nowrap px-4 py-2 whitespace-nowrap text-left text-sm">
<BaseButtons type="justify-start lg:justify-end" no-wrap>
<BaseButton v-if="mimetype.enabled"
:route-name="stardust.route('settings.mimetype.down', [mimetype.id])"

View file

@ -69,10 +69,10 @@ const datasets = computed(() => mainService.datasets);
<SectionMain>
<SectionTitleLineWithButton v-bind:icon="mdiChartTimelineVariant" title="Overview" main>
<BaseButton
href="https://gitea.geologie.ac.at/geolba/tethys"
href="https://gitea.geosphere.at/geolba/tethys.backend"
target="_blank"
:icon="mdiGithub"
label="Star on Gitea"
label="Star on GeoSphere Forgejo"
color="contrast"
rounded-full
small