From bb17300f3cf227fb5c9f8095e5ebc596825a7416 Mon Sep 17 00:00:00 2001 From: Tom Searle Date: Tue, 20 Jan 2026 12:27:11 +0000 Subject: [PATCH 01/25] feat(medcat-trainer): first pass at project admin initial page not using the django admin... --- medcat-trainer/webapp/api/api/permissions.py | 25 + medcat-trainer/webapp/api/api/views.py | 139 +++- medcat-trainer/webapp/api/core/urls.py | 4 + medcat-trainer/webapp/frontend/src/App.vue | 1 + .../webapp/frontend/src/router/index.ts | 6 + .../webapp/frontend/src/views/Home.vue | 26 +- .../frontend/src/views/ProjectAdmin.vue | 673 ++++++++++++++++++ 7 files changed, 868 insertions(+), 6 deletions(-) create mode 100644 medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue diff --git a/medcat-trainer/webapp/api/api/permissions.py b/medcat-trainer/webapp/api/api/permissions.py index e8995c01f..2a2f04ff3 100644 --- a/medcat-trainer/webapp/api/api/permissions.py +++ b/medcat-trainer/webapp/api/api/permissions.py @@ -1,4 +1,7 @@ from rest_framework import permissions +from rest_framework.exceptions import PermissionDenied +from .models import ProjectAnnotateEntities, ProjectGroup + class IsReadOnly(permissions.BasePermission): """ @@ -9,3 +12,25 @@ def has_permission(self, request, view): # Read permissions are allowed to any request, # so we'll always allow GET, HEAD or OPTIONS requests. return request.method in permissions.SAFE_METHODS + + +def is_project_admin(user, project): + """ + Check if a user is an admin of a project. + A user is a project admin if: + 1. They are a member of the project, OR + 2. They are an administrator of the project's group (if the project has a group) + 3. They are a superuser/staff + """ + if user.is_superuser or user.is_staff: + return True + + # Check if user is a member of the project + if project.members.filter(id=user.id).exists(): + return True + + # Check if user is an administrator of the project's group + if project.group and project.group.administrators.filter(id=user.id).exists(): + return True + + return False diff --git a/medcat-trainer/webapp/api/api/views.py b/medcat-trainer/webapp/api/api/views.py index ea8b75d69..08955334c 100644 --- a/medcat-trainer/webapp/api/api/views.py +++ b/medcat-trainer/webapp/api/api/views.py @@ -15,7 +15,7 @@ from django_filters import rest_framework as drf from rest_framework import viewsets -from rest_framework.decorators import api_view +from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response from medcat.components.ner.trf.deid import DeIdModel from medcat.utils.cdb_utils import ch2pt_from_pt2ch, get_all_ch, snomed_ct_concept_path @@ -1003,3 +1003,140 @@ def project_progress(request): out[p] = {'validated_count': val_docs, 'dataset_count': ds_doc_count} return Response(out) + + +@api_view(http_method_names=['GET']) +@permission_classes([permissions.IsAuthenticated]) +def project_admin_projects(request): + """ + Get all projects where the user is a project admin. + """ + user = request.user + projects = ProjectAnnotateEntities.objects.filter(members=user.id) + + # Also include projects where user is admin of the project's group + group_admin_projects = ProjectAnnotateEntities.objects.filter( + group__administrators=user.id + ) + projects = (projects | group_admin_projects).distinct() + + serializer = ProjectAnnotateEntitiesSerializer(projects, many=True) + return Response(serializer.data) + + +@api_view(http_method_names=['GET', 'PUT', 'DELETE']) +@permission_classes([permissions.IsAuthenticated]) +def project_admin_detail(request, project_id): + """ + Get, update, or delete a project (only if user is project admin). + """ + try: + project = ProjectAnnotateEntities.objects.get(id=project_id) + except ProjectAnnotateEntities.DoesNotExist: + return Response({'error': 'Project not found'}, status=404) + + # Check if user is project admin + from .permissions import is_project_admin + if not is_project_admin(request.user, project): + return Response({'error': 'You do not have permission to access this project'}, status=403) + + if request.method == 'GET': + serializer = ProjectAnnotateEntitiesSerializer(project) + return Response(serializer.data) + + elif request.method == 'PUT': + # Handle both JSON and FormData + data = request.data.copy() if hasattr(request.data, 'copy') else dict(request.data) + + # Convert many-to-many fields from lists to proper format + if 'cdb_search_filter' in data and isinstance(data['cdb_search_filter'], list): + # Already a list, keep it + pass + elif 'cdb_search_filter' in request.data: + # FormData sends as multiple values with same key + data['cdb_search_filter'] = request.data.getlist('cdb_search_filter') + + if 'members' in request.data: + if isinstance(request.data.get('members'), list): + data['members'] = request.data['members'] + else: + data['members'] = request.data.getlist('members') + + serializer = ProjectAnnotateEntitiesSerializer(project, data=data, partial=True) + if serializer.is_valid(): + project = serializer.save() + # Handle many-to-many fields manually if needed + if 'cdb_search_filter' in data: + project.cdb_search_filter.set(data['cdb_search_filter']) + if 'members' in data: + project.members.set(data['members']) + return Response(ProjectAnnotateEntitiesSerializer(project).data) + return Response(serializer.errors, status=400) + + elif request.method == 'DELETE': + project.delete() + return Response({'message': 'Project deleted successfully'}, status=200) + + +@api_view(http_method_names=['POST']) +@permission_classes([permissions.IsAuthenticated]) +def project_admin_create(request): + """ + Create a new project (user must be authenticated). + """ + # Handle both JSON and FormData + data = request.data.copy() if hasattr(request.data, 'copy') else dict(request.data) + + # Convert many-to-many fields from FormData format + if 'cdb_search_filter' in request.data: + if isinstance(request.data.get('cdb_search_filter'), list): + data['cdb_search_filter'] = request.data['cdb_search_filter'] + else: + data['cdb_search_filter'] = request.data.getlist('cdb_search_filter') + + if 'members' in request.data: + if isinstance(request.data.get('members'), list): + data['members'] = request.data['members'] + else: + data['members'] = request.data.getlist('members') + + serializer = ProjectAnnotateEntitiesSerializer(data=data) + if serializer.is_valid(): + project = serializer.save() + # Handle many-to-many fields manually + if 'cdb_search_filter' in data: + project.cdb_search_filter.set(data['cdb_search_filter']) + if 'members' in data: + project.members.set(data['members']) + # Add the creator as a member if not already included + if request.user not in project.members.all(): + project.members.add(request.user) + return Response(ProjectAnnotateEntitiesSerializer(project).data, status=201) + return Response(serializer.errors, status=400) + + +@api_view(http_method_names=['POST']) +@permission_classes([permissions.IsAuthenticated]) +def project_admin_reset(request, project_id): + """ + Reset a project (clear all annotations) - only if user is project admin. + This is equivalent to the reset_project admin action. + """ + try: + project = ProjectAnnotateEntities.objects.get(id=project_id) + except ProjectAnnotateEntities.DoesNotExist: + return Response({'error': 'Project not found'}, status=404) + + # Check if user is project admin + from .permissions import is_project_admin + if not is_project_admin(request.user, project): + return Response({'error': 'You do not have permission to reset this project'}, status=403) + + # Remove all annotations and cascade to meta anns + AnnotatedEntity.objects.filter(project=project).delete() + + # Clear validated_documents and prepared_documents + project.validated_documents.clear() + project.prepared_documents.clear() + + return Response({'message': 'Project reset successfully'}, status=200) diff --git a/medcat-trainer/webapp/api/core/urls.py b/medcat-trainer/webapp/api/core/urls.py index e4165a52f..6614244d4 100644 --- a/medcat-trainer/webapp/api/core/urls.py +++ b/medcat-trainer/webapp/api/core/urls.py @@ -60,6 +60,10 @@ path('api/generate-concept-filter-json/', api.views.generate_concept_filter_flat_json), path('api/generate-concept-filter/', api.views.generate_concept_filter), path('api/cuis-to-concepts/', api.views.cuis_to_concepts), + path('api/project-admin/projects/', api.views.project_admin_projects), + path('api/project-admin/projects//', api.views.project_admin_detail), + path('api/project-admin/projects//reset/', api.views.project_admin_reset), + path('api/project-admin/projects/create/', api.views.project_admin_create), path('reset_password/', api.views.ResetPasswordView.as_view(), name='reset_password'), path('reset_password_sent/', pw_views.PasswordResetDoneView.as_view(), name='password_reset_done'), path('reset//', pw_views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'), diff --git a/medcat-trainer/webapp/frontend/src/App.vue b/medcat-trainer/webapp/frontend/src/App.vue index f58626418..f6a9bc177 100644 --- a/medcat-trainer/webapp/frontend/src/App.vue +++ b/medcat-trainer/webapp/frontend/src/App.vue @@ -17,6 +17,7 @@ @@ -472,7 +475,7 @@

Are you sure you want to delete the dataset {{ datasetToDelete.name }}?

This action cannot be undone.

- +
@@ -1971,7 +1974,6 @@ export default { } :deep(.modal-header) { - background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); padding: 20px 24px; margin: 0; border-bottom: 1px solid var(--color-border); From 5cba6955d6bd313e089f501d8bcd9979441bb6a5 Mon Sep 17 00:00:00 2001 From: Tom Searle Date: Fri, 6 Feb 2026 12:56:44 +0000 Subject: [PATCH 24/25] fix(medcat-trainer): fix styling for admin forms not showing action buttons. Refactor common styles to _admin.scss --- .../webapp/frontend/package-lock.json | 29 +- .../src/components/admin/DatasetForm.vue | 184 +------- .../src/components/admin/DatasetsList.vue | 89 +--- .../src/components/admin/ModelPackForm.vue | 229 ++------- .../src/components/admin/ModelPacksList.vue | 89 +--- .../src/components/admin/ProjectsList.vue | 57 +-- .../src/components/admin/UserForm.vue | 198 +------- .../src/components/admin/UsersList.vue | 85 +--- .../webapp/frontend/src/styles/_admin.scss | 445 ++++++++++++++++++ .../frontend/src/views/ProjectAdmin.vue | 5 +- 10 files changed, 518 insertions(+), 892 deletions(-) create mode 100644 medcat-trainer/webapp/frontend/src/styles/_admin.scss diff --git a/medcat-trainer/webapp/frontend/package-lock.json b/medcat-trainer/webapp/frontend/package-lock.json index 3990464d3..2689d9df5 100644 --- a/medcat-trainer/webapp/frontend/package-lock.json +++ b/medcat-trainer/webapp/frontend/package-lock.json @@ -4633,10 +4633,12 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, + "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -5160,9 +5162,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -5339,10 +5341,11 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -6866,9 +6869,9 @@ } }, "node_modules/vite": { - "version": "6.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", - "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/medcat-trainer/webapp/frontend/src/components/admin/DatasetForm.vue b/medcat-trainer/webapp/frontend/src/components/admin/DatasetForm.vue index 8af7d49a5..72bf3d3a6 100644 --- a/medcat-trainer/webapp/frontend/src/components/admin/DatasetForm.vue +++ b/medcat-trainer/webapp/frontend/src/components/admin/DatasetForm.vue @@ -122,153 +122,18 @@ export default { diff --git a/medcat-trainer/webapp/frontend/src/components/admin/DatasetsList.vue b/medcat-trainer/webapp/frontend/src/components/admin/DatasetsList.vue index e60b4c5ce..8f469d4fc 100644 --- a/medcat-trainer/webapp/frontend/src/components/admin/DatasetsList.vue +++ b/medcat-trainer/webapp/frontend/src/components/admin/DatasetsList.vue @@ -62,92 +62,5 @@ export default { diff --git a/medcat-trainer/webapp/frontend/src/components/admin/ModelPackForm.vue b/medcat-trainer/webapp/frontend/src/components/admin/ModelPackForm.vue index 597006be6..1fae94eb2 100644 --- a/medcat-trainer/webapp/frontend/src/components/admin/ModelPackForm.vue +++ b/medcat-trainer/webapp/frontend/src/components/admin/ModelPackForm.vue @@ -13,25 +13,31 @@
- +
- - + + Upload a .zip file containing the model pack
+
+ +
-
+
-
- @@ -78,6 +84,7 @@ export default { emits: ['close', 'save'], data() { return { + showLegacyFields: false, formData: { name: '', model_pack: null, @@ -97,10 +104,27 @@ export default { concept_db: newVal.concept_db || null, vocab: newVal.vocab || null } + // Show legacy fields if concept_db or vocab are set + this.showLegacyFields = !!(newVal.concept_db || newVal.vocab) } else { this.resetForm() } } + }, + showLegacyFields(newVal) { + if (newVal) { + // When legacy mode is enabled, clear the model pack file + this.formData.model_pack = null + // Clear the file input element if it exists + const fileInput = this.$el?.querySelector('input[type="file"]') + if (fileInput) { + fileInput.value = '' + } + } else { + // When legacy mode is disabled, clear legacy fields + this.formData.concept_db = null + this.formData.vocab = null + } } }, methods: { @@ -111,6 +135,7 @@ export default { } }, resetForm() { + this.showLegacyFields = false this.formData = { name: '', model_pack: null, @@ -123,193 +148,23 @@ export default { diff --git a/medcat-trainer/webapp/frontend/src/components/admin/ModelPacksList.vue b/medcat-trainer/webapp/frontend/src/components/admin/ModelPacksList.vue index dabe84bee..c4946fe05 100644 --- a/medcat-trainer/webapp/frontend/src/components/admin/ModelPacksList.vue +++ b/medcat-trainer/webapp/frontend/src/components/admin/ModelPacksList.vue @@ -87,92 +87,5 @@ export default { diff --git a/medcat-trainer/webapp/frontend/src/components/admin/ProjectsList.vue b/medcat-trainer/webapp/frontend/src/components/admin/ProjectsList.vue index 91333e1af..e77e499da 100644 --- a/medcat-trainer/webapp/frontend/src/components/admin/ProjectsList.vue +++ b/medcat-trainer/webapp/frontend/src/components/admin/ProjectsList.vue @@ -124,6 +124,9 @@ export default { diff --git a/medcat-trainer/webapp/frontend/src/components/admin/UserForm.vue b/medcat-trainer/webapp/frontend/src/components/admin/UserForm.vue index 9a9e0bc79..e8a0c93ce 100644 --- a/medcat-trainer/webapp/frontend/src/components/admin/UserForm.vue +++ b/medcat-trainer/webapp/frontend/src/components/admin/UserForm.vue @@ -118,202 +118,10 @@ export default { diff --git a/medcat-trainer/webapp/frontend/src/components/admin/UsersList.vue b/medcat-trainer/webapp/frontend/src/components/admin/UsersList.vue index bd34df26c..63d0e4017 100644 --- a/medcat-trainer/webapp/frontend/src/components/admin/UsersList.vue +++ b/medcat-trainer/webapp/frontend/src/components/admin/UsersList.vue @@ -72,88 +72,5 @@ export default { diff --git a/medcat-trainer/webapp/frontend/src/styles/_admin.scss b/medcat-trainer/webapp/frontend/src/styles/_admin.scss new file mode 100644 index 000000000..1383e7e66 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/styles/_admin.scss @@ -0,0 +1,445 @@ +// Shared admin component styles +@import './variables.scss'; + +// ============================================ +// Form Container Styles +// ============================================ + +.form-section { + background: white; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + overflow: hidden; + display: flex; + flex-direction: column; + max-height: calc(100vh - 270px); + min-height: auto; +} + +.form-header { + padding: 12px 20px; + border-bottom: 1px solid var(--color-border); + background: linear-gradient(135deg, $primary 0%, darken($primary, 10%) 100%); + color: white; + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + border-radius: 12px 12px 0 0; + + .btn-back { + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.3); + } + } + + h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + } +} + +.form-content { + flex: 1; + overflow: hidden; + padding: 16px 20px; + display: flex; + flex-direction: column; +} + +.admin-form { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + + .form-sections-wrapper { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; + padding: 20px; + background: #f8f9fa; + } + + .form-actions { + margin-top: auto; + flex-shrink: 0; + padding: 20px; + border-top: 1px solid var(--color-border); + display: flex; + gap: 12px; + justify-content: flex-end; + background: var(--color-background-light); + } +} + +// ============================================ +// Form Section Styles +// ============================================ + +.form-section { + margin-bottom: 24px; + padding: 20px; + background: white; + border: 1px solid #e0e0e0; + border-radius: 12px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + flex-shrink: 0; + + h4 { + margin-bottom: 16px; + margin-top: 0; + color: var(--color-heading); + font-size: 1.05rem; + font-weight: 600; + padding-bottom: 12px; + border-bottom: 1px solid #f0f0f0; + } + + &.form-section-horizontal { + // Only .form-row elements should be horizontal, not the entire section + // This allows h4 headers to remain full width (ProjectAdmin pattern) + // Components that need the section itself horizontal (like ModelPackForm) can override + + .form-row { + display: flex; + gap: 20px; + align-items: flex-end; + flex-wrap: wrap; + margin-bottom: 16px; + + &:last-child { + margin-bottom: 0; + } + + .form-group { + margin-bottom: 0; + flex: 1; + min-width: 200px; + } + } + } +} + +// ============================================ +// Form Group & Control Styles +// ============================================ + +.form-group { + margin-bottom: 16px; + flex: 1; + min-width: 200px; + + label { + display: block; + margin-bottom: 6px; + font-weight: 500; + color: var(--color-heading); + font-size: 0.9rem; + transition: color 0.2s ease; + } + + &:has(.form-control:disabled) label, + &:has(input:disabled) label { + color: #6c757d; + opacity: 0.7; + } + + .form-control { + width: 100%; + padding: 8px 12px; + border: 1px solid #d0d0d0; + border-radius: 8px; + font-size: 0.9rem; + transition: all 0.2s ease; + background: white; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.02); + + &:hover:not(:disabled) { + border-color: #b0b0b0; + } + + &:focus:not(:disabled) { + outline: none; + border-color: $primary; + box-shadow: 0 0 0 3px rgba(0, 114, 206, 0.1), inset 0 1px 2px rgba(0, 0, 0, 0.02); + } + + &:disabled { + background-color: #f5f5f5; + border-color: #d0d0d0; + color: #6c757d; + opacity: 0.6; + cursor: not-allowed; + box-shadow: none; + } + + &::placeholder { + color: #999; + opacity: 0.7; + } + } + + // Select dropdown arrow + select.form-control { + cursor: pointer; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 8px center; + background-size: 16px 12px; + padding-right: 32px; + } + + // Textarea specific styles + textarea.form-control { + resize: vertical; + min-height: 80px; + font-family: inherit; + line-height: 1.5; + border-radius: 8px; + } + + // Multiple select styles + select[multiple].form-control { + min-height: 120px; + padding: 8px; + border-radius: 8px; + + option { + padding: 6px 8px; + } + } + + .form-text { + display: block; + margin-top: 6px; + font-size: 0.85rem; + color: var(--color-text); + opacity: 0.7; + transition: opacity 0.2s ease; + } + + &:has(.form-control:disabled) .form-text, + &:has(input:disabled) .form-text { + opacity: 0.5; + } +} + +// ============================================ +// File Input Styles +// ============================================ + +input[type="file"].form-control, +.file-input { + padding: 8px; + cursor: pointer; + border: 1px solid #d0d0d0; + border-radius: 8px; + background: white; + display: block; + width: 100%; + min-height: 38px; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + border-color: #b0b0b0; + } + + &:disabled { + background-color: #f5f5f5; + border-color: #d0d0d0; + color: #6c757d; + opacity: 0.6; + cursor: not-allowed; + box-shadow: none; + } + + &::file-selector-button { + padding: 6px 14px; + margin-right: 12px; + border: 1px solid #d0d0d0; + border-radius: 6px; + background: #f8f9fa; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.85rem; + display: inline-block; + visibility: visible; + opacity: 1; + + &:hover { + background: #e9ecef; + border-color: #b0b0b0; + } + } + + &:disabled::file-selector-button { + background: #e9ecef; + border-color: #d0d0d0; + color: #6c757d; + opacity: 0.6; + cursor: not-allowed; + } +} + +// ============================================ +// Checkbox Styles +// ============================================ + +.checkbox-group { + margin-bottom: 16px; + + .checkbox-label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + padding: 8px 0; + transition: all 0.2s ease; + margin-bottom: 0; + min-height: 36px; + + &:hover { + opacity: 0.8; + } + + .checkbox-input { + margin: 0; + width: 18px; + height: 18px; + cursor: pointer; + accent-color: $primary; + flex-shrink: 0; + border: 1px solid #d0d0d0; + border-radius: 3px; + } + + .checkbox-text { + flex: 1; + font-weight: 400; + color: var(--color-text); + font-size: 0.9rem; + line-height: 1.4; + } + } +} + +.checkbox-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 8px; +} + +// ============================================ +// List Section Styles +// ============================================ + +.list-section { + .section-header { + margin-bottom: 20px; + + h3 { + font-size: 1.5rem; + font-weight: 600; + color: var(--color-heading); + margin: 0; + } + + .item-count { + font-weight: 400; + color: var(--color-text-secondary); + font-size: 1rem; + } + } + + .table-container { + background: white; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + overflow: hidden; + } + + .action-buttons { + display: flex; + gap: 6px; + justify-content: flex-start; + } + + .btn-action { + padding: 4px 8px; + border: none; + background: transparent; + cursor: pointer; + transition: all 0.2s ease; + border-radius: 4px; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &.btn-edit { + color: #0d6efd; + } + + &.btn-clone { + color: #0d6efd; + } + + &.btn-reset { + color: #ffc107; + } + + &.btn-delete { + color: #dc3545; + } + } + + .empty-state { + text-align: center; + padding: 60px 20px; + background: white; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + + h4 { + font-size: 1.25rem; + color: var(--color-heading); + margin-bottom: 8px; + } + + p { + color: var(--color-text-secondary); + margin-bottom: 20px; + } + + .btn-create-empty { + margin-top: 10px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + font-weight: 500; + border-radius: 6px; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 114, 206, 0.2); + + &:hover { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 114, 206, 0.3); + } + } + } +} \ No newline at end of file diff --git a/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue b/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue index 262b69c46..c3625b84c 100644 --- a/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue +++ b/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue @@ -1131,6 +1131,7 @@ export default { diff --git a/medcat-trainer/webapp/frontend/vite.config.ts b/medcat-trainer/webapp/frontend/vite.config.ts index fba0599d6..5698c38f2 100644 --- a/medcat-trainer/webapp/frontend/vite.config.ts +++ b/medcat-trainer/webapp/frontend/vite.config.ts @@ -18,7 +18,17 @@ export default defineConfig({ }, build: { sourcemap: true, - assetsDir: 'static' + assetsDir: 'static', + chunkSizeWarningLimit: 1000, + rollupOptions: { + output: { + manualChunks: { + 'vue-vendor': ['vue', 'vue-router'], + 'vuetify-vendor': ['vuetify'], + 'plotly-vendor': ['plotly.js-dist'] + } + } + } }, server: { host: '127.0.0.1',