unforld 1 not health yet

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-14 17:15:21 +00:00
parent ade055c971
commit cd2c84116b
43 changed files with 3326 additions and 1103 deletions

View File

@@ -1,86 +1,176 @@
# Django Admin Improvement Plan # Django Admin Improvement Plan - Unfold Edition
**Version:** 1.0.0 **Version:** 2.0.0
**Created:** December 13, 2025 **Created:** December 14, 2025
**Status:** Planning Phase **Status:** Implementation Phase
**Priority:** 🔴 High - 4-6 weeks implementation **Priority:** 🔴 High - 3-4 weeks implementation
**Dependencies:** After Plan Management Implementation **Dependencies:** After Plan Management Implementation
**Theme:** Django Unfold (Modern Admin Theme)
---
## 🔧 CRITICAL FIXES APPLIED (December 14, 2025)
### Problem: Multiple Conflicting Admin Systems
The system had **3 conflicting admin systems running simultaneously**:
1. Default Django admin
2. Custom IGNY8 admin modifications
3. Unfold theme (partially installed)
This caused style conflicts, crashes, and inconsistent UI.
### Solution: Clean Unfold-Only Installation
**✅ Fixed Issues:**
1. **Backend Container Crashing** - Missing `unfold` module
- Added `django-unfold==0.73.1` to requirements.txt
- Rebuilt Docker image: `igny8-backend:latest`
- All containers now use new image (backend, celery_worker, celery_beat, flower)
2. **Admin Apps Configuration Error** - `AttributeError: module 'django.contrib.admin' has no attribute 'apps'`
- Fixed import in `admin/apps.py`: `from django.contrib.admin.apps import AdminConfig`
- Import Unfold AFTER apps are ready in `ready()` method
3. **Admin Site Inheritance** - Mixed admin systems
- Changed `Igny8AdminSite` from `admin.AdminSite``UnfoldAdminSite`
- All admin classes now inherit from Unfold's `ModelAdmin`
4. **Celery Admin Filters** - Using wrong filter classes
- Changed from `DateRangeFilter``RangeDateFilter` (Unfold version)
- Updated `CeleryTaskResultAdmin` to use `ModelAdmin` from Unfold
5. **Middleware Configuration**
- Added `simple_history.middleware.HistoryRequestMiddleware`
6. **INSTALLED_APPS Order**
- Unfold apps MUST be before `django.contrib.admin`
- Configured properly in settings.py
**✅ Result: Single Clean Admin System**
- **ONLY Unfold** - No more conflicts
- Modern, responsive UI with Tailwind CSS
- Dark mode support
- Advanced filters and bulk operations built-in
- All containers running healthy
--- ---
## Executive Summary ## Executive Summary
This document outlines a comprehensive improvement plan for the IGNY8 Django Admin interface to transform it from a basic management tool into a powerful operational command center. The plan addresses UI/UX deficiencies, operational inefficiencies, missing monitoring capabilities, and organizational improvements. This document outlines a comprehensive improvement plan for the IGNY8 Django Admin interface using **Django Unfold**, a modern, feature-rich admin theme. Unfold provides a beautiful UI based on Tailwind CSS, advanced filtering, bulk operations, dark mode, and extensive customization options - eliminating the need for custom CSS/JS while providing enterprise-grade functionality out of the box.
**Key Objectives:** **Key Objectives:**
- Enhance UI styling and user experience - **COMPLETED:** Install and configure Unfold theme
- Reorganize sidebar menu for logical grouping - Leverage Unfold's built-in features for UI/UX excellence
- Reorganize sidebar menu using Unfold's navigation system
- Remove unused/empty models - Remove unused/empty models
- Add bulk operations and data export - Implement bulk operations with Unfold's action system
- Implement Celery task monitoring - Add Celery task monitoring with Unfold integration
- Create operational dashboards - Create operational dashboards using Unfold's dashboard tools
- Improve search and filtering capabilities - Implement advanced filtering with Unfold's filter extensions
**What Makes This Different:**
- **Modern UI Out-of-the-Box:** No custom CSS needed - Unfold provides beautiful, responsive design
- **Built-in Features:** Bulk operations, advanced filters, charts, dark mode included
- **Django Integration:** Works seamlessly with django-import-export, django-simple-history, django-celery-results
- **Extensible:** Easy to customize with Unfold's configuration system
--- ---
## Current State Analysis ## Current State Analysis
### ✅ Completed (Phase 0) - December 14, 2025
1.**Unfold Installation** - django-unfold==0.73.1 installed in requirements.txt
2.**Docker Image Rebuilt** - igny8-backend:latest rebuilt with all dependencies
3.**Settings Configuration** - UNFOLD settings configured in settings.py
4.**Admin Site Update** - Igny8AdminSite now inherits from UnfoldAdminSite
5.**Admin Apps Fixed** - Igny8AdminConfig properly imports Unfold after apps ready
6.**Celery Admin Updated** - CeleryTaskResultAdmin uses Unfold ModelAdmin & filters
7.**Supporting Packages** - django-simple-history, django-import-export, django-celery-results installed
8.**Middleware Updated** - simple_history.middleware.HistoryRequestMiddleware added
9.**Static Files** - Unfold assets collected via collectstatic
10.**All Containers Running** - Backend, Celery Worker, Celery Beat, Flower all healthy
11.**Conflicts Resolved** - No more mixed admin systems (default + custom + Unfold)
12.**Single Admin System** - **Unfold ONLY** - clean, modern, no conflicts
### ✅ Strengths ### ✅ Strengths
1. **Custom Admin Site** - Igny8AdminSite with logical grouping 1. **Modern UI Theme** - Unfold provides beautiful, responsive Tailwind-based design
2. **Multi-Tenancy Support** - AccountAdminMixin and SiteSectorAdminMixin 2. **Custom Admin Site** - Igny8AdminSite with logical grouping (maintained)
3. **Payment Approval Workflow** - Comprehensive payment approval system 3. **Multi-Tenancy Support** - AccountAdminMixin and SiteSectorAdminMixin
4. **Custom Actions** - API key generation, payment approval/rejection 4. **Payment Approval Workflow** - Comprehensive payment approval system
5. **Field Customization** - Custom fieldsets and readonly fields 5. **Custom Actions** - API key generation, payment approval/rejection
6. **Field Customization** - Custom fieldsets and readonly fields
### ⚠️ Issues Identified ### ⚠️ Issues Remaining
#### 1. **UI/UX Problems** #### 1. **Sidebar Menu Organization**
- No custom styling - uses default Django admin theme - ✅ Current get_app_list() structure works but can be enhanced with Unfold features
- Basic, dated appearance - Need to add icons to models for better visual recognition
- Poor mobile responsiveness
- No dashboard widgets or charts
- Limited visual feedback for actions
- No color coding for statuses
#### 2. **Sidebar Menu Organization**
- Some groups have unclear purpose
- "Payments" group separated from "Billing & Tenancy"
- Missing PlanLimitUsage model (needs to be added) - Missing PlanLimitUsage model (needs to be added)
- Some empty groups appearing - Some empty groups appearing
- Inconsistent naming conventions
#### 3. **Unused/Empty Models** #### 2. **Unused/Empty Models** (Same as before)
- **site_building** models referenced but don't exist: - **site_building** models referenced but don't exist
- BusinessType - Duplicate model registrations need cleanup
- AudienceProfile
- BrandPersonality
- HeroImageryDirection
- Duplicate model registrations:
- Invoice registered in both `business/billing/admin.py` and `modules/billing/admin.py`
- Payment registered twice (with comment noting one shouldn't be used)
- CreditPackage registered twice
- CreditCostConfig registered twice
#### 4. **Missing Features** #### 3. **Missing Features** (Now easier with Unfold)
- No CSV/Excel export functionality - CSV/Excel export - Can use Unfold's import_export integration
- Limited bulk operations (only 3 actions total) - Bulk operations - Use Unfold's enhanced action system
- No Celery task monitoring interface - Celery monitoring - Use Unfold's contrib package for better UI
- No admin dashboard with metrics - Admin dashboard - Use Unfold's dashboard widgets
- No data quality indicators - Advanced filtering - Use Unfold's filter contrib package
- No audit trail beyond basic LogEntry
- No advanced filtering (date ranges, etc.)
#### 5. **Operational Gaps**
- No at-risk account alerts
- No low credit warnings
- No failed automation alerts
- No WordPress sync status dashboard
- No performance metrics tracking
--- ---
## Phase 1: Critical Fixes & UI Foundation (Week 1-2) ## Unfold Features Available
### Built-in Features We'll Use:
1. **Visual Interface**
- Modern Tailwind CSS-based design
- Dark mode support (automatic)
- Responsive layout (mobile-friendly)
- Beautiful forms and tables
2. **Advanced Filtering**
- `unfold.contrib.filters` - Enhanced filter UI
- Date range filters with calendar
- Autocomplete filters for foreign keys
- Numeric range filters
3. **Import/Export**
- `unfold.contrib.import_export` - Styled import/export UI
- Works seamlessly with django-import-export
- Beautiful file upload/download interface
4. **History/Audit Trail**
- `unfold.contrib.simple_history` - Enhanced history UI
- Timeline view of changes
- User attribution and timestamps
5. **Actions**
- Enhanced bulk actions UI
- Custom action styling
- Progress indicators
6. **Dashboard Components**
- Cards for metrics
- Charts (Chart.js integration)
- Custom widgets
- Activity feeds
7. **Other Features**
- Inline tabs for grouping
- Conditional field visibility
- WYSIWYG editor (Trix)
- Sortable inlines
- Command palette (Cmd+K search)
---
## Phase 1: Critical Fixes & Model Updates (Week 1)
### 1.1 Remove Unused Models from Admin Site ### 1.1 Remove Unused Models from Admin Site
@@ -111,295 +201,224 @@ This document outlines a comprehensive improvement plan for the IGNY8 Django Adm
**Files to modify:** **Files to modify:**
- `/data/app/igny8/backend/igny8_core/business/billing/admin.py` - `/data/app/igny8/backend/igny8_core/business/billing/admin.py`
### 1.3 Reorganize Sidebar Menu Groups ### 1.3 Update Admin Classes to Use Unfold
**Current Groups:** **Current State:** Most admin classes inherit from `admin.ModelAdmin`
1. Billing & Tenancy
2. Sites & Users
3. Global Reference Data
4. Planner
5. Writer Module
6. Thinker Module
7. System Configuration
8. Payments (separate!)
9. Integrations & Sync
10. Publishing
11. Optimization
12. Django Internals
**Proposed Reorganization:** **Action:** Update all admin classes to inherit from Unfold's ModelAdmin:
```python ```python
'💰 Billing & Accounts': { from unfold.admin import ModelAdmin
'models': [ from unfold.contrib.filters.admin import RangeDateFilter
('igny8_core_auth', 'Plan'), from unfold.contrib.import_export.forms import ExportForm, ImportForm
('billing', 'PlanLimitUsage'), # ADD THIS
('igny8_core_auth', 'Account'), class TasksAdmin(SiteSectorAdminMixin, ModelAdmin):
('igny8_core_auth', 'Subscription'), # Unfold-specific features
('billing', 'Invoice'), compressed_fields = True # Compact form layout
('billing', 'Payment'), warn_unsaved_form = True # Warn before leaving unsaved form
('billing', 'CreditTransaction'),
('billing', 'CreditUsageLog'), # Standard Django admin
('billing', 'CreditPackage'), list_display = ['title', 'status', 'cluster', 'created_at']
('billing', 'PaymentMethodConfig'), list_filter = [
('billing', 'AccountPaymentMethod'), ('created_at', RangeDateFilter), # Unfold date range filter
('billing', 'CreditCostConfig'), 'status',
], ]
}, search_fields = ['title', 'description']
'👥 Sites & Users': { actions_detail = ['generate_content', 'assign_cluster'] # Actions in detail view
'models': [
('igny8_core_auth', 'Site'),
('igny8_core_auth', 'Sector'),
('igny8_core_auth', 'User'),
('igny8_core_auth', 'SiteUserAccess'),
('igny8_core_auth', 'PasswordResetToken'),
],
},
'📚 Content Management': {
'models': [
('writer', 'Content'),
('writer', 'Tasks'),
('writer', 'Images'),
('writer', 'ContentTaxonomy'),
('writer', 'ContentAttribute'),
('writer', 'ContentTaxonomyRelation'),
('writer', 'ContentClusterMap'),
],
},
'🎯 Planning & Strategy': {
'models': [
('planner', 'Clusters'),
('planner', 'Keywords'),
('planner', 'ContentIdeas'),
('system', 'Strategy'),
],
},
'🔗 Integrations & Publishing': {
'models': [
('integration', 'SiteIntegration'),
('integration', 'SyncEvent'),
('publishing', 'PublishingRecord'),
('publishing', 'DeploymentRecord'),
],
},
'🤖 AI & Automation': {
'models': [
('ai', 'AITaskLog'),
('system', 'AIPrompt'),
('automation', 'AutomationConfig'),
('automation', 'AutomationRun'),
('optimization', 'OptimizationTask'),
],
},
'🌍 Global Reference Data': {
'models': [
('igny8_core_auth', 'Industry'),
('igny8_core_auth', 'IndustrySector'),
('igny8_core_auth', 'SeedKeyword'),
],
},
'⚙️ System Configuration': {
'models': [
('system', 'IntegrationSettings'),
('system', 'AuthorProfile'),
('system', 'SystemSettings'),
('system', 'AccountSettings'),
('system', 'UserSettings'),
('system', 'ModuleSettings'),
('system', 'AISettings'),
('system', 'ModuleEnableSettings'),
('system', 'SystemLog'),
('system', 'SystemStatus'),
],
},
'🔧 Django System': {
'models': [
('admin', 'LogEntry'),
('auth', 'Group'),
('auth', 'Permission'),
('contenttypes', 'ContentType'),
('sessions', 'Session'),
],
},
``` ```
**Benefits:** **Key Files to Update:**
- Logical grouping by functionality - `/data/app/igny8/backend/igny8_core/modules/writer/admin.py`
- Emoji icons for quick visual recognition - `/data/app/igny8/backend/igny8_core/modules/planner/admin.py`
- Combined Billing & Payments into one group - `/data/app/igny8/backend/igny8_core/modules/billing/admin.py`
- Separated Content Management from Planning - `/data/app/igny8/backend/igny8_core/business/automation/admin.py`
- Grouped AI, Automation, and Optimization together - `/data/app/igny8/backend/igny8_core/business/integration/admin.py`
- Clearer hierarchy and easier navigation - All other admin files
### 1.4 Install Django Admin Enhancement Packages ### 1.4 Add Model Icons for Visual Navigation
**Packages to install:** **Action:** Add icon declarations to each admin class using Unfold's icon system (Material Symbols):
```bash ```python
# UI Enhancement class AccountAdmin(AccountAdminMixin, ModelAdmin):
pip install django-admin-interface # Modern, customizable theme # Add icon for sidebar
# OR icon = "business" # Material symbol name
pip install django-grappelli # Alternative mature theme
class ContentAdmin(SiteSectorAdminMixin, ModelAdmin):
# Functionality icon = "article"
pip install django-import-export # CSV/Excel import/export
pip install django-admin-rangefilter # Date range filters class TasksAdmin(SiteSectorAdminMixin, ModelAdmin):
pip install django-advanced-filters # Save filter combinations icon = "task"
pip install django-admin-autocomplete-filter # Better autocomplete
``` ```
**Configuration needed:** **Icon Mapping:**
- Add to INSTALLED_APPS
- Configure static files
- Run collectstatic
- Apply migrations if needed
### 1.5 Basic UI Styling Improvements | Model | Icon | Material Symbol |
|-------|------|----------------|
| Account | business | Business/company icon |
| Plan | card_membership | Membership card |
| Site | language | Globe/website icon |
| User | person | Person icon |
| Content | article | Article/document |
| Tasks | task | Checkbox list |
| Clusters | bubble_chart | Cluster diagram |
| Keywords | vpn_key | Key icon |
| Payment | payment | Payment icon |
| Invoice | receipt | Receipt icon |
| Automation | settings_suggest | Automation gear |
| Integration | integration_instructions | Integration icon |
**Without packages (quick wins):** ### 1.5 Configure Unfold Colors and Branding
Create custom CSS file: `backend/igny8_core/static/admin/css/igny8_admin.css` **Already Done:** Basic UNFOLD configuration in settings.py
```css **Additional Customization:**
/* Status badges */
.status-active { color: #28a745; font-weight: bold; }
.status-inactive { color: #dc3545; }
.status-pending { color: #ffc107; }
/* Credit indicators */ ```python
.credits-low { color: #dc3545; font-weight: bold; } # In settings.py UNFOLD dict
.credits-medium { color: #ffc107; } "SITE_FAVICONS": [
.credits-high { color: #28a745; } {
"rel": "icon",
/* Quick action buttons */ "sizes": "32x32",
.admin-action-button { "type": "image/png",
padding: 5px 15px; "href": lambda request: static("favicons/favicon-32x32.png"),
border-radius: 4px; },
cursor: pointer; ],
text-decoration: none; "SIDEBAR": {
display: inline-block; "show_search": True,
margin: 2px; "show_all_applications": True,
} "navigation": None, # Use get_app_list() from Igny8AdminSite
},
/* List view enhancements */ "THEME": "light", # or "dark" or "auto"
.django-admin-index tr:hover {
background-color: #f8f9fa;
}
```
Add custom template: `backend/igny8_core/templates/admin/base_site.html`
```html
{% extends "admin/base.html" %}
{% load static %}
{% block title %}{{ title }} | IGNY8 Admin{% endblock %}
{% block branding %}
<h1 id="site-name">
<a href="{% url 'admin:index' %}">
IGNY8 Administration
</a>
</h1>
{% endblock %}
{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" href="{% static 'admin/css/igny8_admin.css' %}">
{% endblock %}
``` ```
--- ---
## Phase 2: Operational Features (Week 3-4) ## Phase 2: Bulk Operations & Export (Week 2)
### 2.1 Add Bulk Operations ### 2.1 Implement CSV/Excel Export with Unfold
**Priority Models for Bulk Actions:** **Install Already Complete:** django-import-export and unfold.contrib.import_export
**Action:** Add ExportMixin and ImportMixin to admin classes:
```python
from import_export.admin import ImportExportModelAdmin
from unfold.contrib.import_export.forms import ExportForm, ImportForm, SelectableFieldsExportForm
class TasksAdmin(SiteSectorAdminMixin, ImportExportModelAdmin, ModelAdmin):
import_form_class = ImportForm
export_form_class = ExportForm
# Define what fields to export
class TaskResource(resources.ModelResource):
class Meta:
model = Tasks
fields = ('id', 'title', 'status', 'cluster__name', 'site__name', 'created_at')
export_order = fields
resource_class = TaskResource
```
**Priority Models for Export:**
- ✅ Tasks
- ✅ Content
- ✅ Keywords
- ✅ Payments
- ✅ CreditTransactions
- ✅ Clusters
### 2.2 Add Bulk Operations with Unfold Actions
**Unfold provides enhanced action UI automatically**
**Action:** Add actions to admin classes:
```python
from django.contrib import admin
from unfold.decorators import action
class TasksAdmin(SiteSectorAdminMixin, ModelAdmin):
actions = ['bulk_set_in_progress', 'bulk_set_completed', 'bulk_assign_cluster']
@action(description="Mark as In Progress")
def bulk_set_in_progress(self, request, queryset):
updated = queryset.update(status='in_progress')
self.message_user(request, f'{updated} tasks marked as in progress', 'SUCCESS')
@action(description="Mark as Completed")
def bulk_set_completed(self, request, queryset):
updated = queryset.update(status='completed')
self.message_user(request, f'{updated} tasks completed', 'SUCCESS')
@action(description="Assign to Cluster", form_class=BulkAssignClusterForm)
def bulk_assign_cluster(self, request, queryset):
# Unfold will show form with cluster selection
if 'cluster' in request.POST:
cluster_id = request.POST['cluster']
queryset.update(cluster_id=cluster_id)
self.message_user(request, f'Assigned to cluster', 'SUCCESS')
```
**Bulk Actions to Add:**
#### Tasks Admin #### Tasks Admin
```python - Mark as Draft
actions = [ - Mark as In Progress
'bulk_set_status_draft', - Mark as Completed
'bulk_set_status_in_progress', - Assign to Cluster
'bulk_set_status_completed', - Generate Content (trigger AI)
'bulk_assign_cluster',
'bulk_generate_content',
'bulk_export_csv',
]
```
#### Content Admin #### Content Admin
```python - Publish to WordPress
actions = [ - Change Status
'bulk_publish_to_wordpress', - Add Taxonomy
'bulk_set_status', - Update SEO Settings
'bulk_add_taxonomy',
'bulk_export_with_seo',
]
```
#### Keywords Admin #### Keywords Admin
```python - Assign to Cluster
actions = [ - Set Priority
'bulk_assign_cluster', - Mark for Research
'bulk_set_priority',
'bulk_export_csv',
]
```
#### Payments Admin (already has some) #### Payments Admin
```python - Approve Payments (already exists)
actions = [ - Reject Payments (already exists)
'approve_payments', - Export Transactions (add)
'reject_payments',
'export_transactions', # ADD
]
```
### 2.2 Implement CSV Export ### 2.3 Advanced Filtering with Unfold
**Install django-import-export:** **Unfold provides beautiful filter UI out of the box**
Add to each admin class: **Action:** Use Unfold's filter contrib for enhanced filtering:
```python ```python
from import_export.admin import ExportMixin from unfold.contrib.filters.admin import (
RangeDateFilter,
RangeDateTimeFilter,
RangeNumericFilter,
SingleNumericFilter,
SliderNumericFilter,
)
from unfold.contrib.filters.admin import RelatedDropdownFilter, ChoicesDropdownFilter
class TasksAdmin(ExportMixin, SiteSectorAdminMixin, admin.ModelAdmin): class CreditTransactionAdmin(AccountAdminMixin, ModelAdmin):
resource_class = TaskResource # Define export fields
# ... existing code
```
**Create Resource classes for key models:**
- TaskResource
- ContentResource
- KeywordsResource
- CreditTransactionResource
- PaymentResource
### 2.3 Advanced Filtering
**Add date range filters:**
```python
from rangefilter.filters import DateRangeFilter
class CreditTransactionAdmin(AccountAdminMixin, admin.ModelAdmin):
list_filter = [ list_filter = [
'transaction_type', ('created_at', RangeDateFilter), # Beautiful date range picker
('created_at', DateRangeFilter), ('amount', RangeNumericFilter), # Numeric range with slider
'account', ('account', RelatedDropdownFilter), # Dropdown with search
('transaction_type', ChoicesDropdownFilter), # Enhanced dropdown
] ]
``` ```
**Add autocomplete filters for large datasets:** **Filter Types to Implement:**
```python | Model | Filters |
class TasksAdmin(SiteSectorAdminMixin, admin.ModelAdmin): |-------|---------|
autocomplete_fields = ['site', 'sector', 'cluster'] | CreditTransaction | Date range, Amount range, Account dropdown, Type |
search_fields = ['title', 'description'] | Payment | Date range, Status, Amount range, Method |
``` | Content | Date range, Status, Site, Sector, Word count range |
| Tasks | Date range, Status, Cluster, Priority |
| AutomationRun | Date range, Status, Site, Success/Fail |
--- ---

View File

@@ -28,6 +28,9 @@ class Igny8AdminConfig(AdminConfig):
def ready(self): def ready(self):
super().ready() super().ready()
# Import Unfold AFTER apps are ready
from unfold.admin import ModelAdmin as UnfoldModelAdmin
# Register Django internals in admin (read-only where appropriate) # Register Django internals in admin (read-only where appropriate)
from django.contrib.admin.models import LogEntry from django.contrib.admin.models import LogEntry
from django.contrib.auth.models import Group, Permission from django.contrib.auth.models import Group, Permission
@@ -35,8 +38,8 @@ class Igny8AdminConfig(AdminConfig):
from django.contrib.sessions.models import Session from django.contrib.sessions.models import Session
_safe_register(LogEntry, ReadOnlyAdmin) _safe_register(LogEntry, ReadOnlyAdmin)
_safe_register(Permission, admin.ModelAdmin) _safe_register(Permission, UnfoldModelAdmin)
_safe_register(Group, admin.ModelAdmin) _safe_register(Group, UnfoldModelAdmin)
_safe_register(ContentType, ReadOnlyAdmin) _safe_register(ContentType, ReadOnlyAdmin)
_safe_register(Session, ReadOnlyAdmin) _safe_register(Session, ReadOnlyAdmin)

View File

@@ -1,15 +1,16 @@
""" """
Celery Task Monitoring Admin Celery Task Monitoring Admin - Unfold Style
""" """
from django.contrib import admin from django.contrib import admin
from django.utils.html import format_html from django.utils.html import format_html
from django.contrib import messages from django.contrib import messages
from django_celery_results.models import TaskResult from django_celery_results.models import TaskResult
from rangefilter.filters import DateRangeFilter from unfold.admin import ModelAdmin
from unfold.contrib.filters.admin import RangeDateFilter
class CeleryTaskResultAdmin(admin.ModelAdmin): class CeleryTaskResultAdmin(ModelAdmin):
"""Admin interface for monitoring Celery tasks""" """Admin interface for monitoring Celery tasks with Unfold styling"""
list_display = [ list_display = [
'task_id', 'task_id',
@@ -22,8 +23,8 @@ class CeleryTaskResultAdmin(admin.ModelAdmin):
list_filter = [ list_filter = [
'status', 'status',
'task_name', 'task_name',
('date_created', DateRangeFilter), ('date_created', RangeDateFilter),
('date_done', DateRangeFilter), ('date_done', RangeDateFilter),
] ]
search_fields = ['task_id', 'task_name', 'task_args'] search_fields = ['task_id', 'task_name', 'task_args']
readonly_fields = [ readonly_fields = [

View File

@@ -1,23 +1,18 @@
""" """
Custom AdminSite for IGNY8 to organize models into proper groups Custom AdminSite for IGNY8 to organize models into proper groups using Unfold
""" """
from django.contrib import admin from django.contrib import admin
from django.contrib.admin.apps import AdminConfig from django.contrib.admin.apps import AdminConfig
from django.apps import apps from django.apps import apps
from django.urls import path from django.urls import path, reverse_lazy
from django.shortcuts import redirect from django.shortcuts import redirect
from unfold.admin import ModelAdmin as UnfoldModelAdmin
from unfold.sites import UnfoldAdminSite
class Igny8AdminSite(admin.AdminSite): class Igny8AdminSite(UnfoldAdminSite):
""" """
Custom AdminSite that organizes models into the planned groups: Custom AdminSite based on Unfold that organizes models into the planned groups
1. Billing & Tenancy
2. Sites & Users
3. Global Reference Data
4. Planner
5. Writer Module
6. Thinker Module
7. System Configuration
""" """
site_header = 'IGNY8 Administration' site_header = 'IGNY8 Administration'
site_title = 'IGNY8 Admin' site_title = 'IGNY8 Admin'

View File

@@ -36,6 +36,11 @@ ALLOWED_HOSTS = [
] ]
INSTALLED_APPS = [ INSTALLED_APPS = [
# Django Unfold admin theme - MUST be before django.contrib.admin
'unfold',
'unfold.contrib.filters',
'unfold.contrib.import_export',
'unfold.contrib.simple_history',
# Core Django apps - Custom admin with IGNY8 branding # Core Django apps - Custom admin with IGNY8 branding
'igny8_core.admin.apps.Igny8AdminConfig', # Custom admin config 'igny8_core.admin.apps.Igny8AdminConfig', # Custom admin config
'django.contrib.auth', 'django.contrib.auth',
@@ -51,6 +56,7 @@ INSTALLED_APPS = [
'import_export', 'import_export',
'rangefilter', 'rangefilter',
'django_celery_results', 'django_celery_results',
'simple_history',
# IGNY8 apps # IGNY8 apps
'igny8_core.auth.apps.Igny8CoreAuthConfig', # Use app config with custom label 'igny8_core.auth.apps.Igny8CoreAuthConfig', # Use app config with custom label
'igny8_core.ai.apps.AIConfig', # AI Framework 'igny8_core.ai.apps.AIConfig', # AI Framework
@@ -108,6 +114,7 @@ MIDDLEWARE = [
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'simple_history.middleware.HistoryRequestMiddleware', # Audit trail
'igny8_core.middleware.request_id.RequestIDMiddleware', # Request ID tracking (must be early) 'igny8_core.middleware.request_id.RequestIDMiddleware', # Request ID tracking (must be early)
'igny8_core.auth.middleware.AccountContextMiddleware', # Multi-account support 'igny8_core.auth.middleware.AccountContextMiddleware', # Multi-account support
# AccountContextMiddleware sets request.account from JWT # AccountContextMiddleware sets request.account from JWT
@@ -607,6 +614,40 @@ CELERY_CACHE_BACKEND = 'django-cache'
# Import/Export Settings # Import/Export Settings
IMPORT_EXPORT_USE_TRANSACTIONS = True IMPORT_EXPORT_USE_TRANSACTIONS = True
# ==============================================================================
# UNFOLD ADMIN CONFIGURATION
# ==============================================================================
# Modern Django admin theme with advanced features
# Documentation: https://unfoldadmin.com/
UNFOLD = {
"SITE_TITLE": "IGNY8 Administration",
"SITE_HEADER": "IGNY8 Admin",
"SITE_URL": "/",
"SITE_SYMBOL": "speed", # Symbol from Material icons
"SHOW_HISTORY": True, # Show history for models with simple_history
"SHOW_VIEW_ON_SITE": True, # Show "View on site" button
"COLORS": {
"primary": {
"50": "248 250 252",
"100": "241 245 249",
"200": "226 232 240",
"300": "203 213 225",
"400": "148 163 184",
"500": "100 116 139",
"600": "71 85 105",
"700": "51 65 85",
"800": "30 41 59",
"900": "15 23 42",
"950": "2 6 23",
},
},
"SIDEBAR": {
"show_search": True, # Show search in sidebar
"show_all_applications": True, # Show all apps (we'll organize via custom get_app_list)
},
}
# Billing / Payments configuration # Billing / Payments configuration
STRIPE_PUBLIC_KEY = os.getenv('STRIPE_PUBLIC_KEY', '') STRIPE_PUBLIC_KEY = os.getenv('STRIPE_PUBLIC_KEY', '')
STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY', '') STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY', '')

View File

@@ -17,7 +17,8 @@ drf-spectacular>=0.27.0
stripe>=7.10.0 stripe>=7.10.0
# Django Admin Enhancements # Django Admin Enhancements
django-admin-interface==0.26.0 django-unfold==0.73.1
django-import-export==3.3.1 django-import-export==3.3.1
django-admin-rangefilter==0.11.1 django-admin-rangefilter==0.11.1
django-celery-results==2.5.1 django-celery-results==2.5.1
django-simple-history==3.4.0

View File

@@ -1,204 +1,246 @@
/*global gettext, interpolate, ngettext, Actions*/ /*global gettext, interpolate, ngettext, Actions*/
'use strict'; "use strict";
{ {
function show(selector) { function show(options, selector) {
document.querySelectorAll(selector).forEach(function(el) { options.parent.querySelectorAll(selector).forEach(function (el) {
el.classList.remove('hidden'); el.classList.remove("hidden");
});
}
function hide(selector) {
document.querySelectorAll(selector).forEach(function(el) {
el.classList.add('hidden');
});
}
function showQuestion(options) {
hide(options.acrossClears);
show(options.acrossQuestions);
hide(options.allContainer);
}
function showClear(options) {
show(options.acrossClears);
hide(options.acrossQuestions);
document.querySelector(options.actionContainer).classList.remove(options.selectedClass);
show(options.allContainer);
hide(options.counterContainer);
}
function reset(options) {
hide(options.acrossClears);
hide(options.acrossQuestions);
hide(options.allContainer);
show(options.counterContainer);
}
function clearAcross(options) {
reset(options);
const acrossInputs = document.querySelectorAll(options.acrossInput);
acrossInputs.forEach(function(acrossInput) {
acrossInput.value = 0;
});
document.querySelector(options.actionContainer).classList.remove(options.selectedClass);
}
function checker(actionCheckboxes, options, checked) {
if (checked) {
showQuestion(options);
} else {
reset(options);
}
actionCheckboxes.forEach(function(el) {
el.checked = checked;
el.closest('tr').classList.toggle(options.selectedClass, checked);
});
}
function updateCounter(actionCheckboxes, options) {
const sel = Array.from(actionCheckboxes).filter(function(el) {
return el.checked;
}).length;
const counter = document.querySelector(options.counterContainer);
// data-actions-icnt is defined in the generated HTML
// and contains the total amount of objects in the queryset
const actions_icnt = Number(counter.dataset.actionsIcnt);
counter.textContent = interpolate(
ngettext('%(sel)s of %(cnt)s selected', '%(sel)s of %(cnt)s selected', sel), {
sel: sel,
cnt: actions_icnt
}, true);
const allToggle = document.getElementById(options.allToggleId);
allToggle.checked = sel === actionCheckboxes.length;
if (allToggle.checked) {
showQuestion(options);
} else {
clearAcross(options);
}
}
const defaults = {
actionContainer: "div.actions",
counterContainer: "span.action-counter",
allContainer: "div.actions span.all",
acrossInput: "div.actions input.select-across",
acrossQuestions: "div.actions span.question",
acrossClears: "div.actions span.clear",
allToggleId: "action-toggle",
selectedClass: "selected"
};
window.Actions = function(actionCheckboxes, options) {
options = Object.assign({}, defaults, options);
let list_editable_changed = false;
let lastChecked = null;
let shiftPressed = false;
document.addEventListener('keydown', (event) => {
shiftPressed = event.shiftKey;
});
document.addEventListener('keyup', (event) => {
shiftPressed = event.shiftKey;
});
document.getElementById(options.allToggleId).addEventListener('click', function(event) {
checker(actionCheckboxes, options, this.checked);
updateCounter(actionCheckboxes, options);
});
document.querySelectorAll(options.acrossQuestions + " a").forEach(function(el) {
el.addEventListener('click', function(event) {
event.preventDefault();
const acrossInputs = document.querySelectorAll(options.acrossInput);
acrossInputs.forEach(function(acrossInput) {
acrossInput.value = 1;
});
showClear(options);
});
});
document.querySelectorAll(options.acrossClears + " a").forEach(function(el) {
el.addEventListener('click', function(event) {
event.preventDefault();
document.getElementById(options.allToggleId).checked = false;
clearAcross(options);
checker(actionCheckboxes, options, false);
updateCounter(actionCheckboxes, options);
});
});
function affectedCheckboxes(target, withModifier) {
const multiSelect = (lastChecked && withModifier && lastChecked !== target);
if (!multiSelect) {
return [target];
}
const checkboxes = Array.from(actionCheckboxes);
const targetIndex = checkboxes.findIndex(el => el === target);
const lastCheckedIndex = checkboxes.findIndex(el => el === lastChecked);
const startIndex = Math.min(targetIndex, lastCheckedIndex);
const endIndex = Math.max(targetIndex, lastCheckedIndex);
const filtered = checkboxes.filter((el, index) => (startIndex <= index) && (index <= endIndex));
return filtered;
};
Array.from(document.getElementById('result_list').tBodies).forEach(function(el) {
el.addEventListener('change', function(event) {
const target = event.target;
if (target.classList.contains('action-select')) {
const checkboxes = affectedCheckboxes(target, shiftPressed);
checker(checkboxes, options, target.checked);
updateCounter(actionCheckboxes, options);
lastChecked = target;
} else {
list_editable_changed = true;
}
});
});
document.querySelector('#changelist-form button[name=index]').addEventListener('click', function(event) {
if (list_editable_changed) {
const confirmed = confirm(gettext("You have unsaved changes on individual editable fields. If you run an action, your unsaved changes will be lost."));
if (!confirmed) {
event.preventDefault();
}
}
});
const el = document.querySelector('#changelist-form input[name=_save]');
// The button does not exist if no fields are editable.
if (el) {
el.addEventListener('click', function(event) {
if (document.querySelector('[name=action]').value) {
const text = list_editable_changed
? gettext("You have selected an action, but you havent saved your changes to individual fields yet. Please click OK to save. Youll need to re-run the action.")
: gettext("You have selected an action, and you havent made any changes on individual fields. Youre probably looking for the Go button rather than the Save button.");
if (!confirm(text)) {
event.preventDefault();
}
}
});
}
// Sync counter when navigating to the page, such as through the back
// button.
window.addEventListener('pageshow', (event) => updateCounter(actionCheckboxes, options));
};
// Call function fn when the DOM is loaded and ready. If it is already
// loaded, call the function now.
// http://youmightnotneedjquery.com/#ready
function ready(fn) {
if (document.readyState !== 'loading') {
fn();
} else {
document.addEventListener('DOMContentLoaded', fn);
}
}
ready(function() {
const actionsEls = document.querySelectorAll('tr input.action-select');
if (actionsEls.length > 0) {
Actions(actionsEls);
}
}); });
}
function hide(options, selector) {
options.parent.querySelectorAll(selector).forEach(function (el) {
el.classList.add("hidden");
});
}
function showQuestion(options) {
hide(options, options.acrossClears);
show(options, options.acrossQuestions);
hide(options, options.allContainer);
}
function showClear(options) {
show(options, options.acrossClears);
hide(options, options.acrossQuestions);
options.parent
.querySelector(options.actionContainer)
.classList.remove(options.selectedClass);
show(options, options.allContainer);
hide(options, options.counterContainer);
}
function reset(options) {
hide(options, options.acrossClears);
hide(options, options.acrossQuestions);
hide(options, options.allContainer);
show(options, options.counterContainer);
}
function clearAcross(options) {
reset(options);
const acrossInputs = options.parent.querySelectorAll(options.acrossInput);
acrossInputs.forEach(function (acrossInput) {
acrossInput.value = 0;
acrossInput.dispatchEvent(new Event("input"));
});
options.parent
.querySelector(options.actionContainer)
.classList.remove(options.selectedClass);
}
function checker(actionCheckboxes, options, checked) {
if (checked) {
showQuestion(options);
} else {
reset(options);
}
actionCheckboxes.forEach(function (el) {
el.checked = checked;
el.closest("tr").classList.toggle(options.selectedClass, checked);
});
}
function updateCounter(actionCheckboxes, options) {
const sel = Array.from(actionCheckboxes).filter(function (el) {
return el.checked;
}).length;
const counter = options.parent.querySelector(options.counterContainer);
// data-actions-icnt is defined in the generated HTML
// and contains the total amount of objects in the queryset
const actions_icnt = Number(counter.dataset.actionsIcnt);
counter.textContent = interpolate(
ngettext(
"%(sel)s of %(cnt)s selected",
"%(sel)s of %(cnt)s selected",
sel
),
{
sel: sel,
cnt: actions_icnt,
},
true
);
const allToggle = options.parent.querySelector(".action-toggle");
allToggle.checked = sel === actionCheckboxes.length;
if (allToggle.checked) {
showQuestion(options);
} else {
clearAcross(options);
}
}
const defaults = {
actionContainer: "div.actions",
counterContainer: "span.action-counter",
allContainer: "div.actions span.all",
acrossInput: "div.actions input.select-across",
acrossQuestions: "div.actions span.question",
acrossClears: "div.actions span.clear",
allToggleId: "action-toggle",
selectedClass: "selected",
};
window.Actions = function (actionCheckboxes, options) {
options = Object.assign({}, defaults, options);
let list_editable_changed = false;
let lastChecked = null;
let shiftPressed = false;
document.addEventListener("keydown", (event) => {
shiftPressed = event.shiftKey;
});
document.addEventListener("keyup", (event) => {
shiftPressed = event.shiftKey;
});
const allToggle = options.parent.querySelector(".action-toggle");
allToggle.addEventListener("click", function (event) {
checker(actionCheckboxes, options, this.checked);
updateCounter(actionCheckboxes, options);
});
options.parent
.querySelectorAll(options.acrossQuestions + " a")
.forEach(function (el) {
el.addEventListener("click", function (event) {
event.preventDefault();
const acrossInputs = options.parent.querySelectorAll(
options.acrossInput
);
acrossInputs.forEach(function (acrossInput) {
acrossInput.value = 1;
acrossInput.dispatchEvent(new Event("input"));
});
showClear(options);
});
});
options.parent
.querySelectorAll(options.acrossClears + " a")
.forEach(function (el) {
el.addEventListener("click", function (event) {
event.preventDefault();
options.parent.querySelector(".action-toggle").checked = false;
clearAcross(options);
checker(actionCheckboxes, options, false);
updateCounter(actionCheckboxes, options);
});
});
function affectedCheckboxes(target, withModifier) {
const multiSelect = lastChecked && withModifier && lastChecked !== target;
if (!multiSelect) {
return [target];
}
const checkboxes = Array.from(actionCheckboxes);
const targetIndex = checkboxes.findIndex((el) => el === target);
const lastCheckedIndex = checkboxes.findIndex((el) => el === lastChecked);
const startIndex = Math.min(targetIndex, lastCheckedIndex);
const endIndex = Math.max(targetIndex, lastCheckedIndex);
const filtered = checkboxes.filter(
(el, index) => startIndex <= index && index <= endIndex
);
return filtered;
}
const resultList = options.parent.querySelector(".result-list").tBodies;
Array.from(resultList).forEach(function (el) {
el.addEventListener("change", function (event) {
const target = event.target;
if (target.classList.contains("action-select")) {
const checkboxes = affectedCheckboxes(target, shiftPressed);
checker(checkboxes, options, target.checked);
updateCounter(actionCheckboxes, options);
lastChecked = target;
} else {
list_editable_changed = true;
}
});
});
options.parent
.querySelector("button[name=index]")
.addEventListener("click", function (event) {
if (list_editable_changed) {
const confirmed = confirm(
gettext(
"You have unsaved changes on individual editable fields. If you run an action, your unsaved changes will be lost."
)
);
if (!confirmed) {
event.preventDefault();
}
}
});
const el = options.parent.querySelector("input[name=_save]");
// The button does not exist if no fields are editable.
if (el) {
el.addEventListener("click", function (event) {
if (document.querySelector("[name=action]").value) {
const text = list_editable_changed
? gettext(
"You have selected an action, but you havent saved your changes to individual fields yet. Please click OK to save. Youll need to re-run the action."
)
: gettext(
"You have selected an action, and you havent made any changes on individual fields. Youre probably looking for the Go button rather than the Save button."
);
if (!confirm(text)) {
event.preventDefault();
}
}
});
}
// Sync counter when navigating to the page, such as through the back
// button.
window.addEventListener("pageshow", (event) =>
updateCounter(actionCheckboxes, options)
);
};
// Call function fn when the DOM is loaded and ready. If it is already
// loaded, call the function now.
// http://youmightnotneedjquery.com/#ready
function ready(fn) {
if (document.readyState !== "loading") {
fn();
} else {
document.addEventListener("DOMContentLoaded", fn);
}
}
ready(function () {
document.querySelectorAll(".result-list-wrapper").forEach(function (el) {
const actionsEls = el.querySelectorAll("tr input.action-select");
if (actionsEls.length > 0) {
Actions(actionsEls, {
parent: el,
});
}
});
});
} }

View File

@@ -1,252 +1,301 @@
/*global SelectBox, interpolate*/ /*global SelectBox, interpolate*/
// Handles related-objects functionality: lookup link for raw_id_fields // Handles related-objects functionality: lookup link for raw_id_fields
// and Add Another links. // and Add Another links.
'use strict'; "use strict";
{ {
const $ = django.jQuery; const $ = django.jQuery;
let popupIndex = 0; let popupIndex = 0;
const relatedWindows = []; const relatedWindows = [];
function dismissChildPopups() { function dismissChildPopups() {
relatedWindows.forEach(function(win) { relatedWindows.forEach(function (win) {
if(!win.closed) { if (!win.closed) {
win.dismissChildPopups(); win.dismissChildPopups();
win.close();
}
});
}
function setPopupIndex() {
if(document.getElementsByName("_popup").length > 0) {
const index = window.name.lastIndexOf("__") + 2;
popupIndex = parseInt(window.name.substring(index));
} else {
popupIndex = 0;
}
}
function addPopupIndex(name) {
return name + "__" + (popupIndex + 1);
}
function removePopupIndex(name) {
return name.replace(new RegExp("__" + (popupIndex + 1) + "$"), '');
}
function showAdminPopup(triggeringLink, name_regexp, add_popup) {
const name = addPopupIndex(triggeringLink.id.replace(name_regexp, ''));
const href = new URL(triggeringLink.href);
if (add_popup) {
href.searchParams.set('_popup', 1);
}
const win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
relatedWindows.push(win);
win.focus();
return false;
}
function showRelatedObjectLookupPopup(triggeringLink) {
return showAdminPopup(triggeringLink, /^lookup_/, true);
}
function dismissRelatedLookupPopup(win, chosenId) {
const name = removePopupIndex(win.name);
const elem = document.getElementById(name);
if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) {
elem.value += ',' + chosenId;
} else {
elem.value = chosenId;
}
$(elem).trigger('change');
const index = relatedWindows.indexOf(win);
if (index > -1) {
relatedWindows.splice(index, 1);
}
win.close(); win.close();
} }
function showRelatedObjectPopup(triggeringLink) {
return showAdminPopup(triggeringLink, /^(change|add|delete)_/, false);
}
function updateRelatedObjectLinks(triggeringLink) {
const $this = $(triggeringLink);
const siblings = $this.nextAll('.view-related, .change-related, .delete-related');
if (!siblings.length) {
return;
}
const value = $this.val();
if (value) {
siblings.each(function() {
const elm = $(this);
elm.attr('href', elm.attr('data-href-template').replace('__fk__', value));
elm.removeAttr('aria-disabled');
});
} else {
siblings.removeAttr('href');
siblings.attr('aria-disabled', true);
}
}
function updateRelatedSelectsOptions(currentSelect, win, objId, newRepr, newId, skipIds = []) {
// After create/edit a model from the options next to the current
// select (+ or :pencil:) update ForeignKey PK of the rest of selects
// in the page.
const path = win.location.pathname;
// Extract the model from the popup url '.../<model>/add/' or
// '.../<model>/<id>/change/' depending the action (add or change).
const modelName = path.split('/')[path.split('/').length - (objId ? 4 : 3)];
// Select elements with a specific model reference and context of "available-source".
const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] [data-context="available-source"]`);
selectsRelated.forEach(function(select) {
if (currentSelect === select || skipIds && skipIds.includes(select.id)) {
return;
}
let option = select.querySelector(`option[value="${objId}"]`);
if (!option) {
option = new Option(newRepr, newId);
select.options.add(option);
// Update SelectBox cache for related fields.
if (window.SelectBox !== undefined && !SelectBox.cache[currentSelect.id]) {
SelectBox.add_to_cache(select.id, option);
SelectBox.redisplay(select.id);
}
return;
}
option.textContent = newRepr;
option.value = newId;
});
}
function dismissAddRelatedObjectPopup(win, newId, newRepr) {
const name = removePopupIndex(win.name);
const elem = document.getElementById(name);
if (elem) {
const elemName = elem.nodeName.toUpperCase();
if (elemName === 'SELECT') {
elem.options[elem.options.length] = new Option(newRepr, newId, true, true);
updateRelatedSelectsOptions(elem, win, null, newRepr, newId);
} else if (elemName === 'INPUT') {
if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) {
elem.value += ',' + newId;
} else {
elem.value = newId;
}
}
// Trigger a change event to update related links if required.
$(elem).trigger('change');
} else {
const toId = name + "_to";
const toElem = document.getElementById(toId);
const o = new Option(newRepr, newId);
SelectBox.add_to_cache(toId, o);
SelectBox.redisplay(toId);
if (toElem && toElem.nodeName.toUpperCase() === 'SELECT') {
const skipIds = [name + "_from"];
updateRelatedSelectsOptions(toElem, win, null, newRepr, newId, skipIds);
}
}
const index = relatedWindows.indexOf(win);
if (index > -1) {
relatedWindows.splice(index, 1);
}
win.close();
}
function dismissChangeRelatedObjectPopup(win, objId, newRepr, newId) {
const id = removePopupIndex(win.name.replace(/^edit_/, ''));
const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]);
const selects = $(selectsSelector);
selects.find('option').each(function() {
if (this.value === objId) {
this.textContent = newRepr;
this.value = newId;
}
}).trigger('change');
updateRelatedSelectsOptions(selects[0], win, objId, newRepr, newId);
selects.next().find('.select2-selection__rendered').each(function() {
// The element can have a clear button as a child.
// Use the lastChild to modify only the displayed value.
this.lastChild.textContent = newRepr;
this.title = newRepr;
});
const index = relatedWindows.indexOf(win);
if (index > -1) {
relatedWindows.splice(index, 1);
}
win.close();
}
function dismissDeleteRelatedObjectPopup(win, objId) {
const id = removePopupIndex(win.name.replace(/^delete_/, ''));
const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]);
const selects = $(selectsSelector);
selects.find('option').each(function() {
if (this.value === objId) {
$(this).remove();
}
}).trigger('change');
const index = relatedWindows.indexOf(win);
if (index > -1) {
relatedWindows.splice(index, 1);
}
win.close();
}
window.showRelatedObjectLookupPopup = showRelatedObjectLookupPopup;
window.dismissRelatedLookupPopup = dismissRelatedLookupPopup;
window.showRelatedObjectPopup = showRelatedObjectPopup;
window.updateRelatedObjectLinks = updateRelatedObjectLinks;
window.dismissAddRelatedObjectPopup = dismissAddRelatedObjectPopup;
window.dismissChangeRelatedObjectPopup = dismissChangeRelatedObjectPopup;
window.dismissDeleteRelatedObjectPopup = dismissDeleteRelatedObjectPopup;
window.dismissChildPopups = dismissChildPopups;
window.relatedWindows = relatedWindows;
// Kept for backward compatibility
window.showAddAnotherPopup = showRelatedObjectPopup;
window.dismissAddAnotherPopup = dismissAddRelatedObjectPopup;
window.addEventListener('unload', function(evt) {
window.dismissChildPopups();
}); });
}
$(document).ready(function() { function setPopupIndex() {
setPopupIndex(); if (document.getElementsByName("_popup").length > 0) {
$("a[data-popup-opener]").on('click', function(event) { const index = window.name.lastIndexOf("__") + 2;
event.preventDefault(); popupIndex = parseInt(window.name.substring(index));
opener.dismissRelatedLookupPopup(window, $(this).data("popup-opener")); } else {
}); popupIndex = 0;
$('body').on('click', '.related-widget-wrapper-link[data-popup="yes"]', function(e) { }
e.preventDefault(); }
if (this.href) {
const event = $.Event('django:show-related', {href: this.href}); function addPopupIndex(name) {
$(this).trigger(event); return name + "__" + (popupIndex + 1);
if (!event.isDefaultPrevented()) { }
showRelatedObjectPopup(this);
} function removePopupIndex(name) {
} return name.replace(new RegExp("__" + (popupIndex + 1) + "$"), "");
}); }
$('body').on('change', '.related-widget-wrapper select', function(e) {
const event = $.Event('django:update-related'); function showAdminPopup(triggeringLink, name_regexp, add_popup) {
$(this).trigger(event); const name = addPopupIndex(triggeringLink.id.replace(name_regexp, ""));
if (!event.isDefaultPrevented()) { const href = new URL(triggeringLink.href);
updateRelatedObjectLinks(this); if (add_popup) {
} href.searchParams.set("_popup", 1);
}); }
$('.related-widget-wrapper select').trigger('change'); const win = window.open(
$('body').on('click', '.related-lookup', function(e) { href,
e.preventDefault(); name,
const event = $.Event('django:lookup-related'); "height=768,width=1024,resizable=yes,scrollbars=yes"
$(this).trigger(event); );
if (!event.isDefaultPrevented()) { relatedWindows.push(win);
showRelatedObjectLookupPopup(this); win.focus();
} return false;
}); }
function showRelatedObjectLookupPopup(triggeringLink) {
return showAdminPopup(triggeringLink, /^lookup_/, true);
}
function dismissRelatedLookupPopup(win, chosenId) {
const name = removePopupIndex(win.name);
const elem = document.getElementById(name);
if (elem.classList.contains("vManyToManyRawIdAdminField") && elem.value) {
elem.value += "," + chosenId;
} else {
document.getElementById(name).value = chosenId;
}
const index = relatedWindows.indexOf(win);
if (index > -1) {
relatedWindows.splice(index, 1);
}
win.close();
}
function showRelatedObjectPopup(triggeringLink) {
return showAdminPopup(triggeringLink, /^(change|add|delete)_/, false);
}
function updateRelatedObjectLinks(triggeringLink) {
const $this = $(triggeringLink);
// !CHANGED from original
// const siblings = $this.nextAll(
// ".view-related, .change-related, .delete-related"
// );
const siblings = $this
.closest(".related-widget-wrapper")
.find(".view-related, .change-related, .delete-related");
if (!siblings.length) {
return;
}
const value = $this.val();
if (value) {
siblings.each(function () {
const elm = $(this);
elm.attr(
"href",
elm.attr("data-href-template").replace("__fk__", value)
);
elm.removeAttr("aria-disabled");
});
} else {
siblings.removeAttr("href");
siblings.attr("aria-disabled", true);
}
}
function updateRelatedSelectsOptions(
currentSelect,
win,
objId,
newRepr,
newId,
skipIds = []
) {
// After create/edit a model from the options next to the current
// select (+ or :pencil:) update ForeignKey PK of the rest of selects
// in the page.
const path = win.location.pathname;
// Extract the model from the popup url '.../<model>/add/' or
// '.../<model>/<id>/change/' depending the action (add or change).
const modelName = path.split("/")[path.split("/").length - (objId ? 4 : 3)];
// Select elements with a specific model reference and context of "available-source".
const selectsRelated = document.querySelectorAll(
`[data-model-ref="${modelName}"] [data-context="available-source"]`
);
selectsRelated.forEach(function (select) {
if (
currentSelect === select ||
(skipIds && skipIds.includes(select.id))
) {
return;
}
let option = select.querySelector(`option[value="${objId}"]`);
if (!option) {
option = new Option(newRepr, newId);
select.options.add(option);
// Update SelectBox cache for related fields.
if (
window.SelectBox !== undefined &&
!SelectBox.cache[currentSelect.id]
) {
SelectBox.add_to_cache(select.id, option);
SelectBox.redisplay(select.id);
}
return;
}
option.textContent = newRepr;
option.value = newId;
}); });
}
function dismissAddRelatedObjectPopup(win, newId, newRepr) {
const name = removePopupIndex(win.name);
const elem = document.getElementById(name);
if (elem) {
const elemName = elem.nodeName.toUpperCase();
if (elemName === "SELECT") {
elem.options[elem.options.length] = new Option(
newRepr,
newId,
true,
true
);
updateRelatedSelectsOptions(elem, win, null, newRepr, newId);
} else if (elemName === "INPUT") {
if (
elem.classList.contains("vManyToManyRawIdAdminField") &&
elem.value
) {
elem.value += "," + newId;
} else {
elem.value = newId;
}
}
// Trigger a change event to update related links if required.
$(elem).trigger("change");
} else {
const toId = name + "_to";
const toElem = document.getElementById(toId);
const o = new Option(newRepr, newId);
SelectBox.add_to_cache(toId, o);
SelectBox.redisplay(toId);
if (toElem && toElem.nodeName.toUpperCase() === "SELECT") {
const skipIds = [name + "_from"];
updateRelatedSelectsOptions(toElem, win, null, newRepr, newId, skipIds);
}
}
const index = relatedWindows.indexOf(win);
if (index > -1) {
relatedWindows.splice(index, 1);
}
win.close();
}
function dismissChangeRelatedObjectPopup(win, objId, newRepr, newId) {
const id = removePopupIndex(win.name.replace(/^edit_/, ""));
const selectsSelector = interpolate("#%s, #%s_from, #%s_to", [id, id, id]);
const selects = $(selectsSelector);
selects
.find("option")
.each(function () {
if (this.value === objId) {
this.textContent = newRepr;
this.value = newId;
}
})
.trigger("change");
updateRelatedSelectsOptions(selects[0], win, objId, newRepr, newId);
selects
.next()
.find(".select2-selection__rendered")
.each(function () {
// The element can have a clear button as a child.
// Use the lastChild to modify only the displayed value.
this.lastChild.textContent = newRepr;
this.title = newRepr;
});
const index = relatedWindows.indexOf(win);
if (index > -1) {
relatedWindows.splice(index, 1);
}
win.close();
}
function dismissDeleteRelatedObjectPopup(win, objId) {
const id = removePopupIndex(win.name.replace(/^delete_/, ""));
const selectsSelector = interpolate("#%s, #%s_from, #%s_to", [id, id, id]);
const selects = $(selectsSelector);
selects
.find("option")
.each(function () {
if (this.value === objId) {
$(this).remove();
}
})
.trigger("change");
const index = relatedWindows.indexOf(win);
if (index > -1) {
relatedWindows.splice(index, 1);
}
win.close();
}
window.showRelatedObjectLookupPopup = showRelatedObjectLookupPopup;
window.dismissRelatedLookupPopup = dismissRelatedLookupPopup;
window.showRelatedObjectPopup = showRelatedObjectPopup;
window.updateRelatedObjectLinks = updateRelatedObjectLinks;
window.dismissAddRelatedObjectPopup = dismissAddRelatedObjectPopup;
window.dismissChangeRelatedObjectPopup = dismissChangeRelatedObjectPopup;
window.dismissDeleteRelatedObjectPopup = dismissDeleteRelatedObjectPopup;
window.dismissChildPopups = dismissChildPopups;
// Kept for backward compatibility
window.showAddAnotherPopup = showRelatedObjectPopup;
window.dismissAddAnotherPopup = dismissAddRelatedObjectPopup;
window.addEventListener("unload", function (evt) {
window.dismissChildPopups();
});
$(document).ready(function () {
setPopupIndex();
$("a[data-popup-opener]").on("click", function (event) {
event.preventDefault();
opener.dismissRelatedLookupPopup(window, $(this).data("popup-opener"));
});
$("body").on(
"click",
'.related-widget-wrapper-link[data-popup="yes"]',
function (e) {
e.preventDefault();
if (this.href) {
const event = $.Event("django:show-related", { href: this.href });
$(this).trigger(event);
if (!event.isDefaultPrevented()) {
showRelatedObjectPopup(this);
}
}
}
);
$("body").on("change", ".related-widget-wrapper select", function (e) {
const event = $.Event("django:update-related");
$(this).trigger(event);
if (!event.isDefaultPrevented()) {
updateRelatedObjectLinks(this);
}
});
$(".related-widget-wrapper select").trigger("change");
$("body").on("click", ".related-lookup", function (e) {
e.preventDefault();
const event = $.Event("django:lookup-related");
$(this).trigger(event);
if (!event.isDefaultPrevented()) {
showRelatedObjectLookupPopup(this);
}
});
});
} }

View File

@@ -15,345 +15,485 @@
* Licensed under the New BSD License * Licensed under the New BSD License
* See: https://opensource.org/licenses/bsd-license.php * See: https://opensource.org/licenses/bsd-license.php
*/ */
'use strict'; "use strict";
{ {
const $ = django.jQuery; const $ = django.jQuery;
$.fn.formset = function(opts) { $.fn.formset = function (opts) {
const options = $.extend({}, $.fn.formset.defaults, opts); const options = $.extend({}, $.fn.formset.defaults, opts);
const $this = $(this); const $this = $(this);
const $parent = $this.parent(); const $parent = $this.parent();
const updateElementIndex = function(el, prefix, ndx) { const updateElementIndex = function (el, prefix, ndx) {
const id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))"); const id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))");
const replacement = prefix + "-" + ndx; const replacement = prefix + "-" + ndx;
if ($(el).prop("for")) { if ($(el).prop("for")) {
$(el).prop("for", $(el).prop("for").replace(id_regex, replacement)); $(el).prop("for", $(el).prop("for").replace(id_regex, replacement));
} }
if (el.id) { if (el.id) {
el.id = el.id.replace(id_regex, replacement); el.id = el.id.replace(id_regex, replacement);
} }
if (el.name) { if (el.name) {
el.name = el.name.replace(id_regex, replacement); // !CHANGED from original
} // el.name = el.name.replace(id_regex, replacement);
}; el.setAttribute("name", el.name.replace(id_regex, replacement));
const totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS").prop("autocomplete", "off"); }
let nextIndex = parseInt(totalForms.val(), 10); };
const maxForms = $("#id_" + options.prefix + "-MAX_NUM_FORMS").prop("autocomplete", "off"); const totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS").prop(
const minForms = $("#id_" + options.prefix + "-MIN_NUM_FORMS").prop("autocomplete", "off"); "autocomplete",
let addButton; "off"
);
let nextIndex = parseInt(totalForms.val(), 10);
const maxForms = $("#id_" + options.prefix + "-MAX_NUM_FORMS").prop(
"autocomplete",
"off"
);
const minForms = $("#id_" + options.prefix + "-MIN_NUM_FORMS").prop(
"autocomplete",
"off"
);
let addButton;
/** /**
* The "Add another MyModel" button below the inline forms. * The "Add another MyModel" button below the inline forms.
*/ */
const addInlineAddButton = function() { const addInlineAddButton = function () {
if (addButton === null) { if (addButton === null) {
if ($this.prop("tagName") === "TR") { if ($this.prop("tagName") === "TR") {
// If forms are laid out as table rows, insert the // If forms are laid out as table rows, insert the
// "add" button in a new table row: // "add" button in a new table row:
const numCols = $this.eq(-1).children().length; const numCols = $this.eq(-1).children().length;
$parent.append('<tr class="' + options.addCssClass + '"><td colspan="' + numCols + '"><a role="button" class="addlink" href="#">' + options.addText + "</a></tr>"); $parent.append(
addButton = $parent.find("tr:last a"); '<tr class="' +
} else { options.addCssClass +
// Otherwise, insert it immediately after the last form: '"><td colspan="' +
$this.filter(":last").after('<div class="' + options.addCssClass + '"><a role="button" class="addlink" href="#">' + options.addText + "</a></div>"); numCols +
addButton = $this.filter(":last").next().find("a"); '"><a href="#">' +
} options.addText +
} "</a></tr>"
addButton.on('click', addInlineClickHandler); );
}; addButton = $parent.find("tr:last a");
const addInlineClickHandler = function(e) {
e.preventDefault();
const template = $("#" + options.prefix + "-empty");
const row = template.clone(true);
row.removeClass(options.emptyCssClass)
.addClass(options.formCssClass)
.attr("id", options.prefix + "-" + nextIndex);
addInlineDeleteButton(row);
row.find("*").each(function() {
updateElementIndex(this, options.prefix, totalForms.val());
});
// Insert the new form when it has been fully edited.
row.insertBefore($(template));
// Update number of total forms.
$(totalForms).val(parseInt(totalForms.val(), 10) + 1);
nextIndex += 1;
// Hide the add button if there's a limit and it's been reached.
if ((maxForms.val() !== '') && (maxForms.val() - totalForms.val()) <= 0) {
addButton.parent().hide();
}
// Show the remove buttons if there are more than min_num.
toggleDeleteButtonVisibility(row.closest('.inline-group'));
// Pass the new form to the post-add callback, if provided.
if (options.added) {
options.added(row);
}
row.get(0).dispatchEvent(new CustomEvent("formset:added", {
bubbles: true,
detail: {
formsetName: options.prefix
}
}));
};
/**
* The "X" button that is part of every unsaved inline.
* (When saved, it is replaced with a "Delete" checkbox.)
*/
const addInlineDeleteButton = function(row) {
if (row.is("tr")) {
// If the forms are laid out in table rows, insert
// the remove button into the last table cell:
row.children(":last").append('<div><a role="button" class="' + options.deleteCssClass + '" href="#">' + options.deleteText + "</a></div>");
} else if (row.is("ul") || row.is("ol")) {
// If they're laid out as an ordered/unordered list,
// insert an <li> after the last list item:
row.append('<li><a role="button" class="' + options.deleteCssClass + '" href="#">' + options.deleteText + "</a></li>");
} else {
// Otherwise, just insert the remove button as the
// last child element of the form's container:
row.children(":first").append('<span><a role="button" class="' + options.deleteCssClass + '" href="#">' + options.deleteText + "</a></span>");
}
// Add delete handler for each row.
row.find("a." + options.deleteCssClass).on('click', inlineDeleteHandler.bind(this));
};
const inlineDeleteHandler = function(e1) {
e1.preventDefault();
const deleteButton = $(e1.target);
const row = deleteButton.closest('.' + options.formCssClass);
const inlineGroup = row.closest('.inline-group');
// Remove the parent form containing this button,
// and also remove the relevant row with non-field errors:
const prevRow = row.prev();
if (prevRow.length && prevRow.hasClass('row-form-errors')) {
prevRow.remove();
}
row.remove();
nextIndex -= 1;
// Pass the deleted form to the post-delete callback, if provided.
if (options.removed) {
options.removed(row);
}
document.dispatchEvent(new CustomEvent("formset:removed", {
detail: {
formsetName: options.prefix
}
}));
// Update the TOTAL_FORMS form count.
const forms = $("." + options.formCssClass);
$("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length);
// Show add button again once below maximum number.
if ((maxForms.val() === '') || (maxForms.val() - forms.length) > 0) {
addButton.parent().show();
}
// Hide the remove buttons if at min_num.
toggleDeleteButtonVisibility(inlineGroup);
// Also, update names and ids for all remaining form controls so
// they remain in sequence:
let i, formCount;
const updateElementCallback = function() {
updateElementIndex(this, options.prefix, i);
};
for (i = 0, formCount = forms.length; i < formCount; i++) {
updateElementIndex($(forms).get(i), options.prefix, i);
$(forms.get(i)).find("*").each(updateElementCallback);
}
};
const toggleDeleteButtonVisibility = function(inlineGroup) {
if ((minForms.val() !== '') && (minForms.val() - totalForms.val()) >= 0) {
inlineGroup.find('.inline-deletelink').hide();
} else {
inlineGroup.find('.inline-deletelink').show();
}
};
$this.each(function(i) {
$(this).not("." + options.emptyCssClass).addClass(options.formCssClass);
});
// Create the delete buttons for all unsaved inlines:
$this.filter('.' + options.formCssClass + ':not(.has_original):not(.' + options.emptyCssClass + ')').each(function() {
addInlineDeleteButton($(this));
});
toggleDeleteButtonVisibility($this);
// Create the add button, initially hidden.
addButton = options.addButton;
addInlineAddButton();
// Show the add button if allowed to add more items.
// Note that max_num = None translates to a blank string.
const showAddButton = maxForms.val() === '' || (maxForms.val() - totalForms.val()) > 0;
if ($this.length && showAddButton) {
addButton.parent().show();
} else { } else {
addButton.parent().hide(); // Otherwise, insert it immediately after the last form:
$this
.filter(":last")
.after(
'<div class="' +
options.addCssClass +
'"><a href="#">' +
options.addText +
"</a></div>"
);
addButton = $this.filter(":last").next().find("a");
}
}
addButton.on("click", addInlineClickHandler);
};
const addInlineClickHandler = function (e) {
e.preventDefault();
const template = $("#" + options.prefix + "-empty");
const row = template.clone(true);
row
.removeClass(options.emptyCssClass)
.addClass(options.formCssClass)
.attr("id", options.prefix + "-" + nextIndex);
addInlineDeleteButton(row);
row.find("*").each(function () {
updateElementIndex(this, options.prefix, totalForms.val());
});
// Insert the new form when it has been fully edited.
// !CHANGED from original
if ($(template).parent().is("tbody")) {
row
.wrap('<tbody class="template"></tbody>')
.parent()
.insertBefore($(template).parent());
} else {
row.insertBefore($(template));
}
// Update number of total forms.
$(totalForms).val(parseInt(totalForms.val(), 10) + 1);
nextIndex += 1;
// Hide the add button if there's a limit and it's been reached.
if (maxForms.val() !== "" && maxForms.val() - totalForms.val() <= 0) {
addButton.parent().hide();
}
// Show the remove buttons if there are more than min_num.
toggleDeleteButtonVisibility(row.closest(".inline-group"));
// Pass the new form to the post-add callback, if provided.
if (options.added) {
options.added(row);
}
row.get(0).dispatchEvent(
new CustomEvent("formset:added", {
bubbles: true,
detail: {
formsetName: options.prefix,
},
})
);
};
/**
* The "X" button that is part of every unsaved inline.
* (When saved, it is replaced with a "Delete" checkbox.)
*/
const addInlineDeleteButton = function (row) {
if (row.is("tr")) {
// If the forms are laid out in table rows, insert
// the remove button into the last table cell:
row
.children(":last")
.append(
'<div><a class="' +
options.deleteCssClass +
'" href="#">' +
options.deleteText +
"</a></div>"
);
} else if (row.is("ul") || row.is("ol")) {
// If they're laid out as an ordered/unordered list,
// insert an <li> after the last list item:
row.append(
'<li><a class="' +
options.deleteCssClass +
'" href="#">' +
options.deleteText +
"</a></li>"
);
} else {
// Otherwise, just insert the remove button as the
// last child element of the form's container:
row
.children(":first")
.append(
'<span><a class="' +
options.deleteCssClass +
'" href="#">' +
options.deleteText +
"</a></span>"
);
}
// Add delete handler for each row.
row
.find("a." + options.deleteCssClass)
.on("click", inlineDeleteHandler.bind(this));
};
const inlineDeleteHandler = function (e1) {
e1.preventDefault();
const deleteButton = $(e1.target);
const row = deleteButton.closest("." + options.formCssClass);
const inlineGroup = row.closest(".inline-group");
// Remove the parent form containing this button,
// and also remove the relevant row with non-field errors:
const prevRow = row.prev();
if (prevRow.length && prevRow.hasClass("row-form-errors")) {
prevRow.remove();
}
// !CHANGED from original
if (deleteButton.parent().parent().parent().parent().is("tbody")) {
row.parent().remove();
} else {
row.remove();
}
nextIndex -= 1;
// Pass the deleted form to the post-delete callback, if provided.
if (options.removed) {
options.removed(row);
}
document.dispatchEvent(
new CustomEvent("formset:removed", {
detail: {
formsetName: options.prefix,
},
})
);
// Update the TOTAL_FORMS form count.
const forms = $("." + options.formCssClass);
$("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length);
// Show add button again once below maximum number.
if (maxForms.val() === "" || maxForms.val() - forms.length > 0) {
addButton.parent().show();
}
// Hide the remove buttons if at min_num.
toggleDeleteButtonVisibility(inlineGroup);
// Also, update names and ids for all remaining form controls so
// they remain in sequence:
let i, formCount;
const updateElementCallback = function () {
updateElementIndex(this, options.prefix, i);
};
for (i = 0, formCount = forms.length; i < formCount; i++) {
updateElementIndex($(forms).get(i), options.prefix, i);
$(forms.get(i)).find("*").each(updateElementCallback);
}
};
const toggleDeleteButtonVisibility = function (inlineGroup) {
if (minForms.val() !== "" && minForms.val() - totalForms.val() >= 0) {
inlineGroup.find(".inline-deletelink").hide();
} else {
inlineGroup.find(".inline-deletelink").show();
}
};
// !CHANGED from original. Business logic for tabular inlines is different.
if ($this.parent().is("tbody")) {
$this
.parent()
.parent()
.find("tr.form-row")
.each(function (i) {
$(this)
.not("." + options.emptyCssClass)
.addClass(options.formCssClass);
});
} else {
$this.each(function (i) {
$(this)
.not("." + options.emptyCssClass)
.addClass(options.formCssClass);
});
}
// Create the delete buttons for all unsaved inlines:
// !CHANGED from original, added parent() and used find() instead of filter()
$this
.parent()
.parent()
.find(
"." +
options.formCssClass +
":not(.has_original):not(." +
options.emptyCssClass +
")"
)
.each(function () {
addInlineDeleteButton($(this));
});
toggleDeleteButtonVisibility($this);
// Create the add button, initially hidden.
addButton = options.addButton;
addInlineAddButton();
// Show the add button if allowed to add more items.
// Note that max_num = None translates to a blank string.
const showAddButton =
maxForms.val() === "" || maxForms.val() - totalForms.val() > 0;
if ($this.length && showAddButton) {
addButton.parent().show();
} else {
addButton.parent().hide();
}
return this;
};
/* Setup plugin defaults */
$.fn.formset.defaults = {
prefix: "form", // The form prefix for your django formset
addText: "add another", // Text for the add link
deleteText: "remove", // Text for the delete link
addCssClass: "add-row", // CSS class applied to the add link
deleteCssClass: "delete-row", // CSS class applied to the delete link
emptyCssClass: "empty-row", // CSS class applied to the empty row
formCssClass: "dynamic-form", // CSS class applied to each form in a formset
added: null, // Function called each time a new form is added
removed: null, // Function called each time a form is deleted
addButton: null, // Existing add button to use
};
// Tabular inlines ---------------------------------------------------------
$.fn.tabularFormset = function (selector, options, callback = null) {
const $rows = $(this);
const reinitDateTimeShortCuts = function () {
// Reinitialize the calendar and clock widgets by force
if (typeof DateTimeShortcuts !== "undefined") {
$(".datetimeshortcuts").remove();
DateTimeShortcuts.init();
}
};
const updateSelectFilter = function () {
// If any SelectFilter widgets are a part of the new form,
// instantiate a new SelectFilter instance for it.
if (typeof SelectFilter !== "undefined") {
$(".selectfilter").each(function (index, value) {
SelectFilter.init(value.id, this.dataset.fieldName, false);
});
$(".selectfilterstacked").each(function (index, value) {
SelectFilter.init(value.id, this.dataset.fieldName, true);
});
}
};
const initPrepopulatedFields = function (row) {
row.find(".prepopulated_field").each(function () {
const field = $(this),
input = field.find("input, select, textarea"),
dependency_list = input.data("dependency_list") || [],
dependencies = [];
$.each(dependency_list, function (i, field_name) {
dependencies.push(
"#" +
row
.find(".field-" + field_name)
.find("input, select, textarea")
.attr("id")
);
});
if (dependencies.length) {
input.prepopulate(dependencies, input.attr("maxlength"));
}
});
};
$rows.formset({
prefix: options.prefix,
addText: options.addText,
formCssClass: "dynamic-" + options.prefix,
deleteCssClass: "inline-deletelink",
deleteText: options.deleteText,
emptyCssClass: "empty-form",
added: function (row) {
initPrepopulatedFields(row);
reinitDateTimeShortCuts();
updateSelectFilter();
},
addButton: options.addButton,
});
if (typeof callback === "function") {
callback();
}
return $rows;
};
// Stacked inlines ---------------------------------------------------------
$.fn.stackedFormset = function (selector, options, callback = null) {
const $rows = $(this);
const updateInlineLabel = function (row) {
$(selector)
.find(".inline_label")
.each(function (i) {
const count = i + 1;
$(this).html(
$(this)
.html()
.replace(/(#\d+)/g, "#" + count)
);
});
};
const reinitDateTimeShortCuts = function () {
// Reinitialize the calendar and clock widgets by force, yuck.
if (typeof DateTimeShortcuts !== "undefined") {
$(".datetimeshortcuts").remove();
DateTimeShortcuts.init();
}
};
const updateSelectFilter = function () {
// If any SelectFilter widgets were added, instantiate a new instance.
if (typeof SelectFilter !== "undefined") {
$(".selectfilter").each(function (index, value) {
SelectFilter.init(value.id, this.dataset.fieldName, false);
});
$(".selectfilterstacked").each(function (index, value) {
SelectFilter.init(value.id, this.dataset.fieldName, true);
});
}
};
const initPrepopulatedFields = function (row) {
row.find(".prepopulated_field").each(function () {
const field = $(this),
input = field.find("input, select, textarea"),
dependency_list = input.data("dependency_list") || [],
dependencies = [];
$.each(dependency_list, function (i, field_name) {
// Dependency in a fieldset.
let field_element = row.find(".form-row .field-" + field_name);
// Dependency without a fieldset.
if (!field_element.length) {
field_element = row.find(".form-row.field-" + field_name);
}
dependencies.push(
"#" + field_element.find("input, select, textarea").attr("id")
);
});
if (dependencies.length) {
input.prepopulate(dependencies, input.attr("maxlength"));
}
});
};
$rows.formset({
prefix: options.prefix,
addText: options.addText,
formCssClass: "dynamic-" + options.prefix,
deleteCssClass: "inline-deletelink",
deleteText: options.deleteText,
emptyCssClass: "empty-form",
removed: updateInlineLabel,
added: function (row) {
initPrepopulatedFields(row);
reinitDateTimeShortCuts();
updateSelectFilter();
updateInlineLabel(row);
},
addButton: options.addButton,
});
if (typeof callback === "function") {
callback();
}
return $rows;
};
$(window).on("htmx:afterSettle", function (event) {
if (event.target.classList.contains("js-inline-admin-formset")) {
initInlines($(event.target), function () {
if (typeof DateTimeShortcuts !== "undefined") {
$(".datetimeshortcuts").remove();
DateTimeShortcuts.init();
} }
return this; $(event.target).find(".admin-autocomplete").djangoAdminSelect2();
}; });
}
});
/* Setup plugin defaults */ $(document).ready(function () {
$.fn.formset.defaults = { $(".js-inline-admin-formset").each(function () {
prefix: "form", // The form prefix for your django formset initInlines(this);
addText: "add another", // Text for the add link
deleteText: "remove", // Text for the delete link
addCssClass: "add-row", // CSS class applied to the add link
deleteCssClass: "delete-row", // CSS class applied to the delete link
emptyCssClass: "empty-row", // CSS class applied to the empty row
formCssClass: "dynamic-form", // CSS class applied to each form in a formset
added: null, // Function called each time a new form is added
removed: null, // Function called each time a form is deleted
addButton: null // Existing add button to use
};
// Tabular inlines ---------------------------------------------------------
$.fn.tabularFormset = function(selector, options) {
const $rows = $(this);
const reinitDateTimeShortCuts = function() {
// Reinitialize the calendar and clock widgets by force
if (typeof DateTimeShortcuts !== "undefined") {
$(".datetimeshortcuts").remove();
DateTimeShortcuts.init();
}
};
const updateSelectFilter = function() {
// If any SelectFilter widgets are a part of the new form,
// instantiate a new SelectFilter instance for it.
if (typeof SelectFilter !== 'undefined') {
$('.selectfilter').each(function(index, value) {
SelectFilter.init(value.id, this.dataset.fieldName, false);
});
$('.selectfilterstacked').each(function(index, value) {
SelectFilter.init(value.id, this.dataset.fieldName, true);
});
}
};
const initPrepopulatedFields = function(row) {
row.find('.prepopulated_field').each(function() {
const field = $(this),
input = field.find('input, select, textarea'),
dependency_list = input.data('dependency_list') || [],
dependencies = [];
$.each(dependency_list, function(i, field_name) {
dependencies.push('#' + row.find('.field-' + field_name).find('input, select, textarea').attr('id'));
});
if (dependencies.length) {
input.prepopulate(dependencies, input.attr('maxlength'));
}
});
};
$rows.formset({
prefix: options.prefix,
addText: options.addText,
formCssClass: "dynamic-" + options.prefix,
deleteCssClass: "inline-deletelink",
deleteText: options.deleteText,
emptyCssClass: "empty-form",
added: function(row) {
initPrepopulatedFields(row);
reinitDateTimeShortCuts();
updateSelectFilter();
},
addButton: options.addButton
});
return $rows;
};
// Stacked inlines ---------------------------------------------------------
$.fn.stackedFormset = function(selector, options) {
const $rows = $(this);
const updateInlineLabel = function(row) {
$(selector).find(".inline_label").each(function(i) {
const count = i + 1;
$(this).html($(this).html().replace(/(#\d+)/g, "#" + count));
});
};
const reinitDateTimeShortCuts = function() {
// Reinitialize the calendar and clock widgets by force, yuck.
if (typeof DateTimeShortcuts !== "undefined") {
$(".datetimeshortcuts").remove();
DateTimeShortcuts.init();
}
};
const updateSelectFilter = function() {
// If any SelectFilter widgets were added, instantiate a new instance.
if (typeof SelectFilter !== "undefined") {
$(".selectfilter").each(function(index, value) {
SelectFilter.init(value.id, this.dataset.fieldName, false);
});
$(".selectfilterstacked").each(function(index, value) {
SelectFilter.init(value.id, this.dataset.fieldName, true);
});
}
};
const initPrepopulatedFields = function(row) {
row.find('.prepopulated_field').each(function() {
const field = $(this),
input = field.find('input, select, textarea'),
dependency_list = input.data('dependency_list') || [],
dependencies = [];
$.each(dependency_list, function(i, field_name) {
// Dependency in a fieldset.
let field_element = row.find('.form-row .field-' + field_name);
// Dependency without a fieldset.
if (!field_element.length) {
field_element = row.find('.form-row.field-' + field_name);
}
dependencies.push('#' + field_element.find('input, select, textarea').attr('id'));
});
if (dependencies.length) {
input.prepopulate(dependencies, input.attr('maxlength'));
}
});
};
$rows.formset({
prefix: options.prefix,
addText: options.addText,
formCssClass: "dynamic-" + options.prefix,
deleteCssClass: "inline-deletelink",
deleteText: options.deleteText,
emptyCssClass: "empty-form",
removed: updateInlineLabel,
added: function(row) {
initPrepopulatedFields(row);
reinitDateTimeShortCuts();
updateSelectFilter();
updateInlineLabel(row);
},
addButton: options.addButton
});
return $rows;
};
$(document).ready(function() {
$(".js-inline-admin-formset").each(function() {
const data = $(this).data(),
inlineOptions = data.inlineFormset;
let selector;
switch(data.inlineType) {
case "stacked":
selector = inlineOptions.name + "-group .inline-related";
$(selector).stackedFormset(selector, inlineOptions.options);
break;
case "tabular":
selector = inlineOptions.name + "-group .tabular.inline-related tbody:first > tr.form-row";
$(selector).tabularFormset(selector, inlineOptions.options);
break;
}
});
}); });
});
function initInlines(el, callback = null) {
const data = $(el).data(),
inlineOptions = data.inlineFormset;
let selector;
switch (data.inlineType) {
case "stacked":
selector = inlineOptions.name + "-group .inline-related";
$(selector).stackedFormset(selector, inlineOptions.options, callback);
break;
case "tabular":
selector =
inlineOptions.name +
"-group .tabular.inline-related tbody:last > tr.form-row";
$(selector).tabularFormset(selector, inlineOptions.options, callback);
break;
}
}
} }

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015 Jonathan Nicol
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,230 @@
[data-simplebar] {
position: relative;
flex-direction: column;
flex-wrap: wrap;
justify-content: flex-start;
align-content: flex-start;
align-items: flex-start;
}
.simplebar-wrapper {
overflow: hidden;
width: inherit;
height: inherit;
max-width: inherit;
max-height: inherit;
}
.simplebar-mask {
direction: inherit;
position: absolute;
overflow: hidden;
padding: 0;
margin: 0;
left: 0;
top: 0;
bottom: 0;
right: 0;
width: auto !important;
height: auto !important;
z-index: 0;
}
.simplebar-offset {
direction: inherit !important;
box-sizing: inherit !important;
resize: none !important;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
padding: 0;
margin: 0;
-webkit-overflow-scrolling: touch;
}
.simplebar-content-wrapper {
direction: inherit;
box-sizing: border-box !important;
position: relative;
display: block;
height: 100%; /* Required for horizontal native scrollbar to not appear if parent is taller than natural height */
width: auto;
max-width: 100%; /* Not required for horizontal scroll to trigger */
max-height: 100%; /* Needed for vertical scroll to trigger */
overflow: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.simplebar-content-wrapper::-webkit-scrollbar,
.simplebar-hide-scrollbar::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.simplebar-content:before,
.simplebar-content:after {
content: ' ';
display: table;
}
.simplebar-placeholder {
max-height: 100%;
max-width: 100%;
width: 100%;
pointer-events: none;
}
.simplebar-height-auto-observer-wrapper {
box-sizing: inherit !important;
height: 100%;
width: 100%;
max-width: 1px;
position: relative;
float: left;
max-height: 1px;
overflow: hidden;
z-index: -1;
padding: 0;
margin: 0;
pointer-events: none;
flex-grow: inherit;
flex-shrink: 0;
flex-basis: 0;
}
.simplebar-height-auto-observer {
box-sizing: inherit;
display: block;
opacity: 0;
position: absolute;
top: 0;
left: 0;
height: 1000%;
width: 1000%;
min-height: 1px;
min-width: 1px;
overflow: hidden;
pointer-events: none;
z-index: -1;
}
.simplebar-track {
z-index: 1;
position: absolute;
right: 0;
bottom: 0;
pointer-events: none;
overflow: hidden;
}
[data-simplebar].simplebar-dragging {
pointer-events: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
[data-simplebar].simplebar-dragging .simplebar-content {
pointer-events: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
[data-simplebar].simplebar-dragging .simplebar-track {
pointer-events: all;
}
.simplebar-scrollbar {
position: absolute;
left: 0;
right: 0;
min-height: 10px;
}
.simplebar-scrollbar:before {
position: absolute;
content: '';
background: black;
border-radius: 7px;
left: 2px;
right: 2px;
opacity: 0;
transition: opacity 0.2s 0.5s linear;
}
.simplebar-scrollbar.simplebar-visible:before {
opacity: 0.5;
transition-delay: 0s;
transition-duration: 0s;
}
.simplebar-track.simplebar-vertical {
top: 0;
width: 11px;
}
.simplebar-scrollbar:before {
top: 2px;
bottom: 2px;
left: 2px;
right: 2px;
}
.simplebar-track.simplebar-horizontal {
left: 0;
height: 11px;
}
.simplebar-track.simplebar-horizontal .simplebar-scrollbar {
right: auto;
left: 0;
top: 0;
bottom: 0;
min-height: 0;
min-width: 10px;
width: auto;
}
/* Rtl support */
[data-simplebar-direction='rtl'] .simplebar-track.simplebar-vertical {
right: auto;
left: 0;
}
.simplebar-dummy-scrollbar-size {
direction: rtl;
position: fixed;
opacity: 0;
visibility: hidden;
height: 500px;
width: 500px;
overflow-y: hidden;
overflow-x: scroll;
-ms-overflow-style: scrollbar !important;
}
.simplebar-dummy-scrollbar-size > div {
width: 200%;
height: 200%;
margin: 10px 0;
}
.simplebar-hide-scrollbar {
position: fixed;
left: 0;
visibility: hidden;
overflow-y: scroll;
scrollbar-width: none;
-ms-overflow-style: none;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Léon Gersen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1 @@
.noUi-target,.noUi-target *{-webkit-touch-callout:none;-webkit-tap-highlight-color:transparent;-webkit-user-select:none;-ms-touch-action:none;touch-action:none;-ms-user-select:none;-moz-user-select:none;user-select:none;-moz-box-sizing:border-box;box-sizing:border-box}.noUi-target{position:relative}.noUi-base,.noUi-connects{width:100%;height:100%;position:relative;z-index:1}.noUi-connects{overflow:hidden;z-index:0}.noUi-connect,.noUi-origin{will-change:transform;position:absolute;z-index:1;top:0;right:0;height:100%;width:100%;-ms-transform-origin:0 0;-webkit-transform-origin:0 0;-webkit-transform-style:preserve-3d;transform-origin:0 0;transform-style:flat}.noUi-txt-dir-rtl.noUi-horizontal .noUi-origin{left:0;right:auto}.noUi-vertical .noUi-origin{top:-100%;width:0}.noUi-horizontal .noUi-origin{height:0}.noUi-handle{-webkit-backface-visibility:hidden;backface-visibility:hidden;position:absolute}.noUi-touch-area{height:100%;width:100%}.noUi-state-tap .noUi-connect,.noUi-state-tap .noUi-origin{-webkit-transition:transform .3s;transition:transform .3s}.noUi-state-drag *{cursor:inherit!important}.noUi-horizontal{height:18px}.noUi-horizontal .noUi-handle{width:34px;height:28px;right:-17px;top:-6px}.noUi-vertical{width:18px}.noUi-vertical .noUi-handle{width:28px;height:34px;right:-6px;bottom:-17px}.noUi-txt-dir-rtl.noUi-horizontal .noUi-handle{left:-17px;right:auto}.noUi-target{background:#FAFAFA;border-radius:4px;border:1px solid #D3D3D3;box-shadow:inset 0 1px 1px #F0F0F0,0 3px 6px -5px #BBB}.noUi-connects{border-radius:3px}.noUi-connect{background:#3FB8AF}.noUi-draggable{cursor:ew-resize}.noUi-vertical .noUi-draggable{cursor:ns-resize}.noUi-handle{border:1px solid #D9D9D9;border-radius:3px;background:#FFF;cursor:default;box-shadow:inset 0 0 1px #FFF,inset 0 1px 7px #EBEBEB,0 3px 6px -3px #BBB}.noUi-active{box-shadow:inset 0 0 1px #FFF,inset 0 1px 7px #DDD,0 3px 6px -3px #BBB}.noUi-handle:after,.noUi-handle:before{content:"";display:block;position:absolute;height:14px;width:1px;background:#E8E7E6;left:14px;top:6px}.noUi-handle:after{left:17px}.noUi-vertical .noUi-handle:after,.noUi-vertical .noUi-handle:before{width:14px;height:1px;left:6px;top:14px}.noUi-vertical .noUi-handle:after{top:17px}[disabled] .noUi-connect{background:#B8B8B8}[disabled] .noUi-handle,[disabled].noUi-handle,[disabled].noUi-target{cursor:not-allowed}.noUi-pips,.noUi-pips *{-moz-box-sizing:border-box;box-sizing:border-box}.noUi-pips{position:absolute;color:#999}.noUi-value{position:absolute;white-space:nowrap;text-align:center}.noUi-value-sub{color:#ccc;font-size:10px}.noUi-marker{position:absolute;background:#CCC}.noUi-marker-sub{background:#AAA}.noUi-marker-large{background:#AAA}.noUi-pips-horizontal{padding:10px 0;height:80px;top:100%;left:0;width:100%}.noUi-value-horizontal{-webkit-transform:translate(-50%,50%);transform:translate(-50%,50%)}.noUi-rtl .noUi-value-horizontal{-webkit-transform:translate(50%,50%);transform:translate(50%,50%)}.noUi-marker-horizontal.noUi-marker{margin-left:-1px;width:2px;height:5px}.noUi-marker-horizontal.noUi-marker-sub{height:10px}.noUi-marker-horizontal.noUi-marker-large{height:15px}.noUi-pips-vertical{padding:0 10px;height:100%;top:0;left:100%}.noUi-value-vertical{-webkit-transform:translate(0,-50%);transform:translate(0,-50%);padding-left:25px}.noUi-rtl .noUi-value-vertical{-webkit-transform:translate(0,50%);transform:translate(0,50%)}.noUi-marker-vertical.noUi-marker{width:5px;height:2px;margin-top:-1px}.noUi-marker-vertical.noUi-marker-sub{width:10px}.noUi-marker-vertical.noUi-marker-large{width:15px}.noUi-tooltip{display:block;position:absolute;border:1px solid #D9D9D9;border-radius:3px;background:#fff;color:#000;padding:5px;text-align:center;white-space:nowrap}.noUi-horizontal .noUi-tooltip{-webkit-transform:translate(-50%,0);transform:translate(-50%,0);left:50%;bottom:120%}.noUi-vertical .noUi-tooltip{-webkit-transform:translate(0,-50%);transform:translate(0,-50%);top:50%;right:120%}.noUi-horizontal .noUi-origin>.noUi-tooltip{-webkit-transform:translate(50%,0);transform:translate(50%,0);left:auto;bottom:10px}.noUi-vertical .noUi-origin>.noUi-tooltip{-webkit-transform:translate(0,-18px);transform:translate(0,-18px);top:auto;right:28px}

View File

@@ -0,0 +1,408 @@
/*global Calendar, findPosX, findPosY, get_format, gettext, gettext_noop, interpolate, ngettext, quickElement*/
// Inserts shortcut buttons after all of the following:
// <input type="text" class="vDateField">
// <input type="text" class="vTimeField">
'use strict';
{
const DateTimeShortcuts = {
calendars: [],
calendarInputs: [],
clockInputs: [],
clockHours: {
default_: [
[gettext_noop('Now'), -1],
[gettext_noop('Midnight'), 0],
[gettext_noop('6 a.m.'), 6],
[gettext_noop('Noon'), 12],
[gettext_noop('6 p.m.'), 18]
]
},
dismissClockFunc: [],
dismissCalendarFunc: [],
calendarDivName1: 'calendarbox', // name of calendar <div> that gets toggled
calendarDivName2: 'calendarin', // name of <div> that contains calendar
calendarLinkName: 'calendarlink', // name of the link that is used to toggle
clockDivName: 'clockbox', // name of clock <div> that gets toggled
clockLinkName: 'clocklink', // name of the link that is used to toggle
shortCutsClass: 'datetimeshortcuts', // class of the clock and cal shortcuts
timezoneWarningClass: 'timezonewarning', // class of the warning for timezone mismatch
timezoneOffset: 0,
init: function() {
const serverOffset = document.body.dataset.adminUtcOffset;
if (serverOffset) {
const localOffset = new Date().getTimezoneOffset() * -60;
DateTimeShortcuts.timezoneOffset = localOffset - serverOffset;
}
for (const inp of document.getElementsByTagName('input')) {
if (inp.type === 'text' && inp.classList.contains('vCustomTimeField')) {
DateTimeShortcuts.addClock(inp);
DateTimeShortcuts.addTimezoneWarning(inp);
}
else if (inp.type === 'text' && inp.classList.contains('vCustomDateField')) {
DateTimeShortcuts.addCalendar(inp);
DateTimeShortcuts.addTimezoneWarning(inp);
}
}
},
// Return the current time while accounting for the server timezone.
now: function() {
const serverOffset = document.body.dataset.adminUtcOffset;
if (serverOffset) {
const localNow = new Date();
const localOffset = localNow.getTimezoneOffset() * -60;
localNow.setTime(localNow.getTime() + 1000 * (serverOffset - localOffset));
return localNow;
} else {
return new Date();
}
},
// Add a warning when the time zone in the browser and backend do not match.
addTimezoneWarning: function(inp) {
const warningClass = DateTimeShortcuts.timezoneWarningClass;
let timezoneOffset = DateTimeShortcuts.timezoneOffset / 3600;
// Only warn if there is a time zone mismatch.
if (!timezoneOffset) {
return;
}
// Check if warning is already there.
if (inp.parentNode.querySelectorAll('.' + warningClass).length) {
return;
}
let message;
if (timezoneOffset > 0) {
message = ngettext(
'Note: You are %s hour ahead of server time.',
'Note: You are %s hours ahead of server time.',
timezoneOffset
);
}
else {
timezoneOffset *= -1;
message = ngettext(
'Note: You are %s hour behind server time.',
'Note: You are %s hours behind server time.',
timezoneOffset
);
}
message = interpolate(message, [timezoneOffset]);
const warning = document.createElement('div');
warning.classList.add('help', warningClass);
warning.textContent = message;
inp.parentNode.appendChild(warning);
},
// Add clock widget to a given field
addClock: function(inp) {
const num = DateTimeShortcuts.clockInputs.length;
DateTimeShortcuts.clockInputs[num] = inp;
DateTimeShortcuts.dismissClockFunc[num] = function() { DateTimeShortcuts.dismissClock(num); return true; };
// Shortcut links (clock icon and "Now" link)
const shortcuts_span = document.createElement('span');
shortcuts_span.className = DateTimeShortcuts.shortCutsClass;
inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling);
const now_link = document.createElement('a');
now_link.href = "#";
now_link.textContent = gettext('Now');
now_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleClockQuicklink(num, -1);
});
const clock_link = document.createElement('a');
clock_link.href = '#';
clock_link.id = DateTimeShortcuts.clockLinkName + num;
clock_link.addEventListener('click', function(e) {
e.preventDefault();
// avoid triggering the document click handler to dismiss the clock
e.stopPropagation();
DateTimeShortcuts.openClock(num);
});
quickElement(
'span', clock_link, '',
'class', 'clock-icon',
'title', gettext('Choose a Time')
);
shortcuts_span.appendChild(document.createTextNode('\u00A0'));
shortcuts_span.appendChild(now_link);
shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0'));
shortcuts_span.appendChild(clock_link);
// Create clock link div
//
// Markup looks like:
// <div id="clockbox1" class="clockbox module">
// <h2>Choose a time</h2>
// <ul class="timelist">
// <li><a href="#">Now</a></li>
// <li><a href="#">Midnight</a></li>
// <li><a href="#">6 a.m.</a></li>
// <li><a href="#">Noon</a></li>
// <li><a href="#">6 p.m.</a></li>
// </ul>
// <p class="calendar-cancel"><a href="#">Cancel</a></p>
// </div>
const clock_box = document.createElement('div');
clock_box.style.display = 'none';
clock_box.style.position = 'absolute';
clock_box.className = 'clockbox module';
clock_box.id = DateTimeShortcuts.clockDivName + num;
document.body.appendChild(clock_box);
clock_box.addEventListener('click', function(e) { e.stopPropagation(); });
quickElement('h2', clock_box, gettext('Choose a time'));
const time_list = quickElement('ul', clock_box);
time_list.className = 'timelist';
// The list of choices can be overridden in JavaScript like this:
// DateTimeShortcuts.clockHours.name = [['3 a.m.', 3]];
// where name is the name attribute of the <input>.
const name = typeof DateTimeShortcuts.clockHours[inp.name] === 'undefined' ? 'default_' : inp.name;
DateTimeShortcuts.clockHours[name].forEach(function(element) {
const time_link = quickElement('a', quickElement('li', time_list), gettext(element[0]), 'href', '#');
time_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleClockQuicklink(num, element[1]);
});
});
const cancel_p = quickElement('p', clock_box);
cancel_p.className = 'calendar-cancel';
const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#');
cancel_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.dismissClock(num);
});
document.addEventListener('keyup', function(event) {
if (event.which === 27) {
// ESC key closes popup
DateTimeShortcuts.dismissClock(num);
event.preventDefault();
}
});
},
openClock: function(num) {
const clock_box = document.getElementById(DateTimeShortcuts.clockDivName + num);
const clock_link = document.getElementById(DateTimeShortcuts.clockLinkName + num);
// Recalculate the clockbox position
// is it left-to-right or right-to-left layout ?
if (window.getComputedStyle(document.body).direction !== 'rtl') {
clock_box.style.left = findPosX(clock_link) + 17 + 'px';
}
else {
// since style's width is in em, it'd be tough to calculate
// px value of it. let's use an estimated px for now
clock_box.style.left = findPosX(clock_link) - 110 + 'px';
}
clock_box.style.top = Math.max(0, findPosY(clock_link) - 30) + 'px';
// Show the clock box
clock_box.style.display = 'block';
document.addEventListener('click', DateTimeShortcuts.dismissClockFunc[num]);
},
dismissClock: function(num) {
document.getElementById(DateTimeShortcuts.clockDivName + num).style.display = 'none';
document.removeEventListener('click', DateTimeShortcuts.dismissClockFunc[num]);
},
handleClockQuicklink: function(num, val) {
let d;
if (val === -1) {
d = DateTimeShortcuts.now();
}
else {
d = new Date(1970, 1, 1, val, 0, 0, 0);
}
DateTimeShortcuts.clockInputs[num].value = d.strftime(get_format('TIME_INPUT_FORMATS')[0]);
DateTimeShortcuts.clockInputs[num].focus();
DateTimeShortcuts.dismissClock(num);
},
// Add calendar widget to a given field.
addCalendar: function(inp) {
const num = DateTimeShortcuts.calendars.length;
DateTimeShortcuts.calendarInputs[num] = inp;
DateTimeShortcuts.dismissCalendarFunc[num] = function() { DateTimeShortcuts.dismissCalendar(num); return true; };
// Shortcut links (calendar icon and "Today" link)
const shortcuts_span = document.createElement('span');
shortcuts_span.className = DateTimeShortcuts.shortCutsClass;
inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling);
const today_link = document.createElement('a');
today_link.href = '#';
today_link.appendChild(document.createTextNode(gettext('Today')));
today_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleCalendarQuickLink(num, 0);
});
const cal_link = document.createElement('a');
cal_link.href = '#';
cal_link.id = DateTimeShortcuts.calendarLinkName + num;
cal_link.addEventListener('click', function(e) {
e.preventDefault();
// avoid triggering the document click handler to dismiss the calendar
e.stopPropagation();
DateTimeShortcuts.openCalendar(num);
});
quickElement(
'span', cal_link, '',
'class', 'date-icon',
'title', gettext('Choose a Date')
);
shortcuts_span.appendChild(document.createTextNode('\u00A0'));
shortcuts_span.appendChild(today_link);
shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0'));
shortcuts_span.appendChild(cal_link);
// Create calendarbox div.
//
// Markup looks like:
//
// <div id="calendarbox3" class="calendarbox module">
// <h2>
// <a href="#" class="link-previous">&lsaquo;</a>
// <a href="#" class="link-next">&rsaquo;</a> February 2003
// </h2>
// <div class="calendar" id="calendarin3">
// <!-- (cal) -->
// </div>
// <div class="calendar-shortcuts">
// <a href="#">Yesterday</a> | <a href="#">Today</a> | <a href="#">Tomorrow</a>
// </div>
// <p class="calendar-cancel"><a href="#">Cancel</a></p>
// </div>
const cal_box = document.createElement('div');
cal_box.style.display = 'none';
cal_box.style.position = 'absolute';
cal_box.className = 'calendarbox module';
cal_box.id = DateTimeShortcuts.calendarDivName1 + num;
document.body.appendChild(cal_box);
cal_box.addEventListener('click', function(e) { e.stopPropagation(); });
// next-prev links
const cal_nav = quickElement('div', cal_box);
const cal_nav_prev = quickElement('a', cal_nav, '<', 'href', '#');
cal_nav_prev.className = 'calendarnav-previous';
cal_nav_prev.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.drawPrev(num);
});
const cal_nav_next = quickElement('a', cal_nav, '>', 'href', '#');
cal_nav_next.className = 'calendarnav-next';
cal_nav_next.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.drawNext(num);
});
// main box
const cal_main = quickElement('div', cal_box, '', 'id', DateTimeShortcuts.calendarDivName2 + num);
cal_main.className = 'calendar';
DateTimeShortcuts.calendars[num] = new Calendar(DateTimeShortcuts.calendarDivName2 + num, DateTimeShortcuts.handleCalendarCallback(num));
DateTimeShortcuts.calendars[num].drawCurrent();
// calendar shortcuts
const shortcuts = quickElement('div', cal_box);
shortcuts.className = 'calendar-shortcuts';
let day_link = quickElement('a', shortcuts, gettext('Yesterday'), 'href', '#');
day_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleCalendarQuickLink(num, -1);
});
shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0'));
day_link = quickElement('a', shortcuts, gettext('Today'), 'href', '#');
day_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleCalendarQuickLink(num, 0);
});
shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0'));
day_link = quickElement('a', shortcuts, gettext('Tomorrow'), 'href', '#');
day_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.handleCalendarQuickLink(num, +1);
});
// cancel bar
const cancel_p = quickElement('p', cal_box);
cancel_p.className = 'calendar-cancel';
const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#');
cancel_link.addEventListener('click', function(e) {
e.preventDefault();
DateTimeShortcuts.dismissCalendar(num);
});
document.addEventListener('keyup', function(event) {
if (event.which === 27) {
// ESC key closes popup
DateTimeShortcuts.dismissCalendar(num);
event.preventDefault();
}
});
},
openCalendar: function(num) {
const cal_box = document.getElementById(DateTimeShortcuts.calendarDivName1 + num);
const cal_link = document.getElementById(DateTimeShortcuts.calendarLinkName + num);
const inp = DateTimeShortcuts.calendarInputs[num];
// Determine if the current value in the input has a valid date.
// If so, draw the calendar with that date's year and month.
if (inp.value) {
const format = get_format('DATE_INPUT_FORMATS')[0];
const selected = inp.value.strptime(format);
const year = selected.getUTCFullYear();
const month = selected.getUTCMonth() + 1;
const re = /\d{4}/;
if (re.test(year.toString()) && month >= 1 && month <= 12) {
DateTimeShortcuts.calendars[num].drawDate(month, year, selected);
}
}
// Recalculate the clockbox position
// is it left-to-right or right-to-left layout ?
if (window.getComputedStyle(document.body).direction !== 'rtl') {
cal_box.style.left = findPosX(cal_link) - 228 + 'px';
}
else {
// since style's width is in em, it'd be tough to calculate
// px value of it. let's use an estimated px for now
cal_box.style.left = findPosX(cal_link) - 180 + 'px';
}
cal_box.style.top = Math.max(0, findPosY(cal_link) + 44) + 'px';
cal_box.style.display = 'block';
document.addEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]);
},
dismissCalendar: function(num) {
document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none';
document.removeEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]);
},
drawPrev: function(num) {
DateTimeShortcuts.calendars[num].drawPreviousMonth();
},
drawNext: function(num) {
DateTimeShortcuts.calendars[num].drawNextMonth();
},
handleCalendarCallback: function(num) {
const format = get_format('DATE_INPUT_FORMATS')[0];
return function(y, m, d) {
DateTimeShortcuts.calendarInputs[num].value = new Date(y, m - 1, d).strftime(format);
DateTimeShortcuts.calendarInputs[num].focus();
document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none';
};
},
handleCalendarQuickLink: function(num, offset) {
const d = DateTimeShortcuts.now();
d.setDate(d.getDate() + offset);
DateTimeShortcuts.calendarInputs[num].value = d.strftime(get_format('DATE_INPUT_FORMATS')[0]);
DateTimeShortcuts.calendarInputs[num].focus();
DateTimeShortcuts.dismissCalendar(num);
}
};
window.addEventListener('load', DateTimeShortcuts.init);
window.DateTimeShortcuts = DateTimeShortcuts;
}

View File

@@ -0,0 +1,69 @@
document.addEventListener("DOMContentLoaded", function () {
Array.from(
document.getElementsByClassName("admin-numeric-filter-slider")
).forEach(function (slider) {
if (Array.from(slider.classList).includes("noUi-target")) {
return;
}
const fromInput = slider
.closest(".admin-numeric-filter-wrapper")
.querySelectorAll(".admin-numeric-filter-wrapper-group input")[0];
const toInput = slider
.closest(".admin-numeric-filter-wrapper")
.querySelectorAll(".admin-numeric-filter-wrapper-group input")[1];
noUiSlider.create(slider, {
start: [parseFloat(fromInput.value), parseFloat(toInput.value)],
step: parseFloat(slider.getAttribute("data-step")),
connect: true,
format: wNumb({
decimals: parseFloat(slider.getAttribute("data-decimals")),
}),
range: {
min: parseFloat(slider.getAttribute("data-min")),
max: parseFloat(slider.getAttribute("data-max")),
},
});
/*************************************************************
* Update slider when input values change
*************************************************************/
fromInput.addEventListener("keyup", function () {
clearTimeout(this._sliderUpdateTimeout);
this._sliderUpdateTimeout = setTimeout(() => {
slider.noUiSlider.set([
parseFloat(this.value),
parseFloat(toInput.value),
]);
}, 500);
});
toInput.addEventListener("keyup", function () {
clearTimeout(this._sliderUpdateTimeout);
this._sliderUpdateTimeout = setTimeout(() => {
slider.noUiSlider.set([
parseFloat(fromInput.value),
parseFloat(this.value),
]);
}, 500);
});
/*************************************************************
* Updated inputs when slider is moved
*************************************************************/
slider.noUiSlider.on("update", function (values, handle) {
const parent = this.target.closest(".admin-numeric-filter-wrapper");
const from = parent.querySelectorAll(
".admin-numeric-filter-wrapper-group input"
)[0];
const to = parent.querySelectorAll(
".admin-numeric-filter-wrapper-group input"
)[1];
from.value = values[0];
to.value = values[1];
});
});
});

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Léon Gersen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2019 Léon Gersen
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1 @@
!function(e){"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?module.exports=e():window.wNumb=e()}(function(){"use strict";var o=["decimals","thousand","mark","prefix","suffix","encoder","decoder","negativeBefore","negative","edit","undo"];function w(e){return e.split("").reverse().join("")}function h(e,t){return e.substring(0,t.length)===t}function f(e,t,n){if((e[t]||e[n])&&e[t]===e[n])throw new Error(t)}function x(e){return"number"==typeof e&&isFinite(e)}function n(e,t,n,r,i,o,f,u,s,c,a,p){var d,l,h,g=p,v="",m="";return o&&(p=o(p)),!!x(p)&&(!1!==e&&0===parseFloat(p.toFixed(e))&&(p=0),p<0&&(d=!0,p=Math.abs(p)),!1!==e&&(p=function(e,t){return e=e.toString().split("e"),(+((e=(e=Math.round(+(e[0]+"e"+(e[1]?+e[1]+t:t)))).toString().split("e"))[0]+"e"+(e[1]?e[1]-t:-t))).toFixed(t)}(p,e)),-1!==(p=p.toString()).indexOf(".")?(h=(l=p.split("."))[0],n&&(v=n+l[1])):h=p,t&&(h=w((h=w(h).match(/.{1,3}/g)).join(w(t)))),d&&u&&(m+=u),r&&(m+=r),d&&s&&(m+=s),m+=h,m+=v,i&&(m+=i),c&&(m=c(m,g)),m)}function r(e,t,n,r,i,o,f,u,s,c,a,p){var d,l="";return a&&(p=a(p)),!(!p||"string"!=typeof p)&&(u&&h(p,u)&&(p=p.replace(u,""),d=!0),r&&h(p,r)&&(p=p.replace(r,"")),s&&h(p,s)&&(p=p.replace(s,""),d=!0),i&&function(e,t){return e.slice(-1*t.length)===t}(p,i)&&(p=p.slice(0,-1*i.length)),t&&(p=p.split(t).join("")),n&&(p=p.replace(n,".")),d&&(l+="-"),""!==(l=(l+=p).replace(/[^0-9\.\-.]/g,""))&&(l=Number(l),f&&(l=f(l)),!!x(l)&&l))}function i(e,t,n){var r,i=[];for(r=0;r<o.length;r+=1)i.push(e[o[r]]);return i.push(n),t.apply("",i)}return function e(t){if(!(this instanceof e))return new e(t);"object"==typeof t&&(t=function(e){var t,n,r,i={};for(void 0===e.suffix&&(e.suffix=e.postfix),t=0;t<o.length;t+=1)if(void 0===(r=e[n=o[t]]))"negative"!==n||i.negativeBefore?"mark"===n&&"."!==i.thousand?i[n]=".":i[n]=!1:i[n]="-";else if("decimals"===n){if(!(0<=r&&r<8))throw new Error(n);i[n]=r}else if("encoder"===n||"decoder"===n||"edit"===n||"undo"===n){if("function"!=typeof r)throw new Error(n);i[n]=r}else{if("string"!=typeof r)throw new Error(n);i[n]=r}return f(i,"mark","thousand"),f(i,"prefix","negative"),f(i,"prefix","negativeBefore"),i}(t),this.to=function(e){return i(t,n,e)},this.from=function(e){return i(t,r,e)})}});

View File

@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,31 @@
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(Inter-Regular.woff2) format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(Inter-Medium.woff2) format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(Inter-SemiBold.woff2) format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(Inter-Bold.woff2) format("woff2");
}

View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,6 @@
@font-face {
font-family: "Material Symbols Outlined";
font-style: normal;
font-weight: 400;
src: url("Material-Symbols-Outlined.woff2") format("woff2");
}

View File

@@ -0,0 +1,21 @@
# MIT License
Copyright © 2019-2021 Caleb Porzio and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
(()=>{function d(t){let n=()=>{let r,a;try{a=localStorage}catch(i){console.error(i),console.warn("Alpine: $persist is using temporary storage since localStorage is unavailable.");let e=new Map;a={getItem:e.get.bind(e),setItem:e.set.bind(e)}}return t.interceptor((i,e,l,s,f)=>{let o=r||`_x_${s}`,u=g(o,a)?p(o,a):i;return l(u),t.effect(()=>{let c=e();m(o,c,a),l(c)}),u},i=>{i.as=e=>(r=e,i),i.using=e=>(a=e,i)})};Object.defineProperty(t,"$persist",{get:()=>n()}),t.magic("persist",n),t.persist=(r,{get:a,set:i},e=localStorage)=>{let l=g(r,e)?p(r,e):a();i(l),t.effect(()=>{let s=a();m(r,s,e),i(s)})}}function g(t,n){return n.getItem(t)!==null}function p(t,n){let r=n.getItem(t,n);if(r!==void 0)return JSON.parse(r)}function m(t,n,r){r.setItem(t,JSON.stringify(n))}document.addEventListener("alpine:init",()=>{window.Alpine.plugin(d)});})();

View File

@@ -0,0 +1 @@
(()=>{function u(e){e.directive("resize",e.skipDuringClone((t,{value:i,expression:n,modifiers:o},{evaluateLater:r,cleanup:h})=>{let f=r(n),s=(z,m)=>{f(()=>{},{scope:{$width:z,$height:m}})},v=o.includes("document")?b(s):a(t,s);h(()=>v())}))}function a(e,t){let i=new ResizeObserver(n=>{let[o,r]=c(n);t(o,r)});return i.observe(e),()=>i.disconnect()}var d,l=new Set;function b(e){return l.add(e),d||(d=new ResizeObserver(t=>{let[i,n]=c(t);l.forEach(o=>o(i,n))}),d.observe(document.documentElement)),()=>{l.delete(e)}}function c(e){let t,i;for(let n of e)t=n.borderBoxSize[0].inlineSize,i=n.borderBoxSize[0].blockSize;return[t,i]}document.addEventListener("alpine:init",()=>{window.Alpine.plugin(u)});})();

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,687 @@
window.addEventListener("load", (e) => {
fileInputUpdatePath();
dateTimeShortcutsOverlay();
renderCharts();
filterForm();
warnWithoutSaving();
tabNavigation();
});
/*************************************************************
* Move not visible tab items to dropdown
*************************************************************/
function tabNavigation() {
const itemsDropdown = document.getElementById("tabs-dropdown");
const itemsList = document.getElementById("tabs-items");
const widths = [];
if (!itemsDropdown || !itemsList) {
return;
}
handleTabNavigationResize();
window.addEventListener("resize", function () {
handleTabNavigationResize();
});
function handleTabNavigationResize() {
const contentWidth = document.getElementById("content").offsetWidth;
const tabsWidth = document.getElementById("tabs-wrapper").scrollWidth;
const availableWidth =
itemsList.parentElement.offsetWidth - itemsList.offsetWidth - 48;
if (tabsWidth > contentWidth) {
const lastTabItem = itemsList ? itemsList.lastElementChild : null;
if (lastTabItem) {
widths.push(lastTabItem.offsetWidth);
itemsList.removeChild(lastTabItem);
itemsDropdown.appendChild(lastTabItem);
// If there is still not enough space, move the last item to the dropdown again
if (
document.getElementById("content").offsetWidth <
document.getElementById("tabs-wrapper").scrollWidth
) {
handleTabNavigationResize();
}
}
} else if (
widths.length > 0 &&
widths[widths.length - 1] < availableWidth
) {
const lastTabItem = itemsDropdown ? itemsDropdown.lastElementChild : null;
if (lastTabItem) {
itemsDropdown.removeChild(lastTabItem);
itemsList.appendChild(lastTabItem);
widths.pop();
}
}
// Show/hide dropdown based on the number of items in dropdown
if (itemsDropdown.childElementCount === 0) {
itemsDropdown.parentElement.classList.add("hidden");
} else {
itemsDropdown.parentElement.classList.remove("hidden");
}
}
}
/*************************************************************
* Alpine.sort.js callback after sorting
*************************************************************/
const sortRecords = (e) => {
const orderingField = e.from.dataset.orderingField;
const weightInputs = Array.from(
e.from.querySelectorAll(
`.has_original input[name$=-${orderingField}], td.field-${orderingField} input[name$=-${orderingField}]`
)
);
weightInputs.forEach((input, index) => {
input.value = index;
});
};
/*************************************************************
* Search form
*************************************************************/
function searchForm() {
return {
applyShortcut(event) {
if (
event.key === "/" &&
document.activeElement.tagName.toLowerCase() !== "input" &&
document.activeElement.tagName.toLowerCase() !== "textarea" &&
!document.activeElement.isContentEditable
) {
event.preventDefault();
this.$refs.searchInput.focus();
}
},
};
}
/*************************************************************
* Search dropdown
*************************************************************/
function searchDropdown() {
return {
openSearchResults: false,
currentIndex: 0,
applyShortcut(event) {
if (
event.key === "t" &&
document.activeElement.tagName.toLowerCase() !== "input" &&
document.activeElement.tagName.toLowerCase() !== "textarea" &&
!document.activeElement.isContentEditable
) {
event.preventDefault();
this.$refs.searchInput.focus();
}
},
nextItem() {
if (this.currentIndex < this.maxItem()) {
this.currentIndex++;
}
},
prevItem() {
if (this.currentIndex > 1) {
this.currentIndex--;
}
},
maxItem() {
return document.getElementById("search-results").querySelectorAll("li")
.length;
},
selectItem() {
const href = this.items[this.currentIndex - 1].querySelector("a").href;
window.location = href;
},
};
}
/*************************************************************
* Search command
*************************************************************/
function searchCommand() {
return {
el: document.getElementById("command-results"),
items: undefined,
hasResults: false,
openCommandResults: false,
currentIndex: 0,
totalItems: 0,
commandHistory: JSON.parse(localStorage.getItem("commandHistory") || "[]"),
handleOpen() {
this.openCommandResults = true;
this.toggleBodyOverflow();
setTimeout(() => {
this.$refs.searchInputCommand.focus();
}, 20);
this.items = document.querySelectorAll("#command-history li");
this.totalItems = this.items.length;
},
handleShortcut(event) {
if (
event.key === "k" &&
(event.metaKey || event.ctrlKey) &&
document.activeElement.tagName.toLowerCase() !== "input" &&
document.activeElement.tagName.toLowerCase() !== "textarea" &&
!document.activeElement.isContentEditable
) {
event.preventDefault();
this.handleOpen();
}
},
handleEscape() {
if (this.$refs.searchInputCommand.value === "") {
this.toggleBodyOverflow();
this.openCommandResults = false;
this.el.innerHTML = "";
this.items = undefined;
this.totalItems = 0;
this.currentIndex = 0;
} else {
this.$refs.searchInputCommand.value = "";
}
},
handleContentLoaded(event) {
if (
event.target.id !== "command-results" &&
event.target.id !== "command-results-list"
) {
return;
}
const commandResultsList = document.getElementById(
"command-results-list"
);
if (commandResultsList) {
this.items = commandResultsList.querySelectorAll("li");
this.totalItems = this.items.length;
} else {
this.items = undefined;
this.totalItems = 0;
}
if (event.target.id === "command-results") {
this.currentIndex = 0;
if (this.items) {
this.totalItems = this.items.length;
} else {
this.totalItems = 0;
}
}
this.hasResults = this.totalItems > 0;
if (!this.hasResults) {
this.items = document.querySelectorAll("#command-history li");
}
},
handleOutsideClick() {
this.$refs.searchInputCommand.value = "";
this.openCommandResults = false;
this.toggleBodyOverflow();
},
toggleBodyOverflow() {
document
.getElementsByTagName("body")[0]
.classList.toggle("overflow-hidden");
},
scrollToActiveItem() {
const item = this.items[this.currentIndex - 1];
if (item) {
item.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
},
nextItem() {
if (this.currentIndex < this.totalItems) {
this.currentIndex++;
this.scrollToActiveItem();
}
},
prevItem() {
if (this.currentIndex > 1) {
this.currentIndex--;
this.scrollToActiveItem();
}
},
selectItem(addHistory) {
const link = this.items[this.currentIndex - 1].querySelector("a");
const data = {
title: link.dataset.title,
description: link.dataset.description,
link: link.href,
favorite: false,
};
if (addHistory) {
this.addToHistory(data);
}
window.location = link.href;
},
addToHistory(data) {
let commandHistory = JSON.parse(
localStorage.getItem("commandHistory") || "[]"
);
for (const [index, item] of commandHistory.entries()) {
if (item.link === data.link) {
commandHistory.splice(index, 1);
}
}
commandHistory.unshift(data);
commandHistory = commandHistory.slice(0, 10);
this.commandHistory = commandHistory;
localStorage.setItem("commandHistory", JSON.stringify(commandHistory));
},
removeFromHistory(event, index) {
event.preventDefault();
const commandHistory = JSON.parse(
localStorage.getItem("commandHistory") || "[]"
);
commandHistory.splice(index, 1);
this.commandHistory = commandHistory;
localStorage.setItem("commandHistory", JSON.stringify(commandHistory));
},
toggleFavorite(event, index) {
event.preventDefault();
const commandHistory = JSON.parse(
localStorage.getItem("commandHistory") || "[]"
);
commandHistory[index].favorite = !commandHistory[index].favorite;
this.commandHistory = commandHistory.sort(
(a, b) => Number(b.favorite) - Number(a.favorite)
);
localStorage.setItem("commandHistory", JSON.stringify(commandHistory));
},
};
}
/*************************************************************
* Warn without saving
*************************************************************/
const warnWithoutSaving = () => {
let formChanged = false;
const form = document.querySelector("form.warn-unsaved-form");
const checkFormChanged = () => {
const elements = document.querySelectorAll(
"form.warn-unsaved-form input, form.warn-unsaved-form select, form.warn-unsaved-form textarea"
);
for (const field of elements) {
field.addEventListener("input", () => {
formChanged = true;
});
}
};
if (!form) {
return;
}
new MutationObserver((mutationsList, observer) => {
checkFormChanged();
}).observe(form, { attributes: true, childList: true, subtree: true });
checkFormChanged();
preventLeaving = (e) => {
if (formChanged) {
e.preventDefault();
}
};
form.addEventListener("submit", (e) => {
window.removeEventListener("beforeunload", preventLeaving);
});
window.addEventListener("beforeunload", preventLeaving);
};
/*************************************************************
* Filter form
*************************************************************/
const filterForm = () => {
const filterForm = document.getElementById("filter-form");
if (!filterForm) {
return;
}
filterForm.addEventListener("formdata", (event) => {
Array.from(event.formData.entries()).forEach(([key, value]) => {
if (value === "") event.formData.delete(key);
});
});
};
/*************************************************************
* Class watcher
*************************************************************/
const watchClassChanges = (selector, callback) => {
const body = document.querySelector(selector);
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (
mutation.type === "attributes" &&
mutation.attributeName === "class"
) {
callback();
}
}
});
observer.observe(body, { attributes: true, attributeFilter: ["class"] });
};
/*************************************************************
* Calendar & clock
*************************************************************/
const dateTimeShortcutsOverlay = () => {
const observer = new MutationObserver((mutations) => {
for (const mutationRecord of mutations) {
const display = mutationRecord.target.style.display;
const overlay = document.getElementById("modal-overlay");
if (display === "block") {
overlay.style.display = "block";
} else {
overlay.style.display = "none";
}
}
});
const targets = document.querySelectorAll(".calendarbox, .clockbox");
for (const target of targets) {
observer.observe(target, {
attributes: true,
attributeFilter: ["style"],
});
}
};
/*************************************************************
* File upload path
*************************************************************/
const fileInputUpdatePath = () => {
const checkInputChanged = () => {
for (const input of document.querySelectorAll("input[type=file]")) {
if (input.hasChangeListener) {
continue;
}
input.addEventListener("change", (e) => {
const parts = e.target.value.split("\\");
const placeholder =
input.parentNode.parentNode.parentNode.querySelector(
"input[type=text]"
);
placeholder.setAttribute("value", parts[parts.length - 1]);
});
input.hasChangeListener = true;
}
};
new MutationObserver(() => {
checkInputChanged();
}).observe(document.body, {
childList: true,
subtree: true,
});
checkInputChanged();
};
/*************************************************************
* Chart
*************************************************************/
const DEFAULT_CHART_OPTIONS = {
animation: false,
barPercentage: 1,
base: 0,
grouped: false,
maxBarThickness: 4,
responsive: true,
maintainAspectRatio: false,
datasets: {
bar: {
borderRadius: 12,
border: {
width: 0,
},
borderSkipped: "middle",
},
line: {
borderWidth: 2,
pointBorderWidth: 0,
pointStyle: false,
},
pie: {
borderWidth: 0,
},
doughnut: {
borderWidth: 0,
},
},
plugins: {
legend: {
align: "end",
display: false,
position: "top",
labels: {
boxHeight: 5,
boxWidth: 5,
color: "#9ca3af",
pointStyle: "circle",
usePointStyle: true,
},
},
tooltip: {
enabled: true,
},
},
scales: {
x: {
display: function (context) {
if (["pie", "doughnut", "radar"].includes(context.chart.config.type)) {
return false;
}
return true;
},
border: {
dash: [5, 5],
dashOffset: 2,
width: 0,
},
ticks: {
color: "#9ca3af",
display: true,
maxTicksLimit: function (context) {
return context.chart.data.datasets.find(
(dataset) => dataset.maxTicksXLimit
)?.maxTicksXLimit;
},
},
grid: {
display: true,
tickWidth: 0,
},
},
y: {
display: function (context) {
if (["pie", "doughnut", "radar"].includes(context.chart.config.type)) {
return false;
}
return true;
},
border: {
dash: [5, 5],
dashOffset: 5,
width: 0,
},
ticks: {
color: "#9ca3af",
display: function (context) {
return context.chart.data.datasets.some((dataset) => {
return (
dataset.hasOwnProperty("displayYAxis") && dataset.displayYAxis
);
});
},
callback: function (value) {
const suffix = this.chart.data.datasets.find(
(dataset) => dataset.suffixYAxis
)?.suffixYAxis;
if (suffix) {
return `${value} ${suffix}`;
}
return value;
},
},
grid: {
lineWidth: (context) => {
if (context.tick.value === 0) {
return 1;
}
return 0;
},
tickWidth: 0,
},
},
},
};
const renderCharts = () => {
const charts = [];
const changeDarkModeSettings = () => {
const hasDarkClass = document
.querySelector("html")
.classList.contains("dark");
const baseColorDark = getComputedStyle(document.documentElement)
.getPropertyValue("--color-base-700")
.trim();
const baseColorLight = getComputedStyle(document.documentElement)
.getPropertyValue("--color-base-300")
.trim();
const borderColor = hasDarkClass ? baseColorDark : baseColorLight;
for (const chart of charts) {
if (chart.options.scales.x) {
chart.options.scales.x.grid.color = borderColor;
}
if (chart.options.scales.y) {
chart.options.scales.y.grid.color = borderColor;
}
if (chart.options.scales.r) {
chart.options.scales.r.grid.color = borderColor;
}
chart.update();
}
};
for (const chart of document.querySelectorAll(".chart")) {
const ctx = chart.getContext("2d");
const data = chart.dataset.value;
const type = chart.dataset.type;
const options = chart.dataset.options;
if (!data) {
continue;
}
const parsedData = JSON.parse(chart.dataset.value);
for (const key in parsedData.datasets) {
const dataset = parsedData.datasets[key];
const processColor = (colorProp) => {
if (Array.isArray(dataset?.[colorProp])) {
for (const [index, prop] of dataset?.[colorProp].entries()) {
if (prop.startsWith("var(")) {
const cssVar = prop.match(/var\((.*?)\)/)[1];
const color = getComputedStyle(document.documentElement)
.getPropertyValue(cssVar)
.trim();
dataset[colorProp][index] = color;
}
}
} else if (dataset?.[colorProp]?.startsWith("var(")) {
const cssVar = dataset[colorProp].match(/var\((.*?)\)/)[1];
const color = getComputedStyle(document.documentElement)
.getPropertyValue(cssVar)
.trim();
dataset[colorProp] = color;
}
};
processColor("borderColor");
processColor("backgroundColor");
}
CHART_OPTIONS = { ...DEFAULT_CHART_OPTIONS };
if (type === "radar") {
CHART_OPTIONS.scales = {
r: {
ticks: {
backdropColor: "transparent",
},
pointLabels: {
color: "#9ca3af",
font: {
size: 12,
},
},
},
};
}
Chart.defaults.font.family = "Inter";
Chart.defaults.font.size = 12;
charts.push(
new Chart(ctx, {
type: type || "bar",
data: parsedData,
options: options ? JSON.parse(options) : { ...CHART_OPTIONS },
})
);
}
changeDarkModeSettings();
watchClassChanges("html", () => {
changeDarkModeSettings();
});
};

View File

@@ -0,0 +1,9 @@
The MIT License (MIT)
Copyright (c) 2014-2024 Chart.js Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,13 @@
Zero-Clause BSD
=============
Permission to use, copy, modify, and/or distribute this software for
any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,45 @@
"use strict";
{
const $ = django.jQuery;
$.fn.djangoCustomSelect2 = function () {
$.each(this, function (i, element) {
if (element.id.match(/__prefix__/)) {
return;
}
if ($(element).hasClass("select2-hidden-accessible")) {
return;
}
$(element).select2();
});
return this;
};
$.fn.djangoFilterSelect2 = function () {
$.each(this, function (i, element) {
$(element).select2({
ajax: {
data: (params) => {
return {
term: params.term,
page: params.page,
app_label: element.dataset.appLabel,
model_name: element.dataset.modelName,
field_name: element.dataset.fieldName,
};
},
},
});
});
return this;
};
$(function () {
$(".unfold-admin-autocomplete").djangoCustomSelect2();
$(".unfold-filter-autocomplete").djangoFilterSelect2();
});
}

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015 Jonathan Nicol
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

File diff suppressed because one or more lines are too long