unforld 1 not health yet
This commit is contained in:
@@ -1,86 +1,176 @@
|
||||
# Django Admin Improvement Plan
|
||||
# Django Admin Improvement Plan - Unfold Edition
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Created:** December 13, 2025
|
||||
**Status:** Planning Phase
|
||||
**Priority:** 🔴 High - 4-6 weeks implementation
|
||||
**Version:** 2.0.0
|
||||
**Created:** December 14, 2025
|
||||
**Status:** Implementation Phase
|
||||
**Priority:** 🔴 High - 3-4 weeks 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
|
||||
|
||||
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:**
|
||||
- Enhance UI styling and user experience
|
||||
- Reorganize sidebar menu for logical grouping
|
||||
- ✅ **COMPLETED:** Install and configure Unfold theme
|
||||
- Leverage Unfold's built-in features for UI/UX excellence
|
||||
- Reorganize sidebar menu using Unfold's navigation system
|
||||
- Remove unused/empty models
|
||||
- Add bulk operations and data export
|
||||
- Implement Celery task monitoring
|
||||
- Create operational dashboards
|
||||
- Improve search and filtering capabilities
|
||||
- Implement bulk operations with Unfold's action system
|
||||
- Add Celery task monitoring with Unfold integration
|
||||
- Create operational dashboards using Unfold's dashboard tools
|
||||
- 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
|
||||
|
||||
### ✅ 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
|
||||
|
||||
1. **Custom Admin Site** - Igny8AdminSite with logical grouping
|
||||
2. **Multi-Tenancy Support** - AccountAdminMixin and SiteSectorAdminMixin
|
||||
3. **Payment Approval Workflow** - Comprehensive payment approval system
|
||||
4. **Custom Actions** - API key generation, payment approval/rejection
|
||||
5. **Field Customization** - Custom fieldsets and readonly fields
|
||||
1. **Modern UI Theme** - Unfold provides beautiful, responsive Tailwind-based design
|
||||
2. **Custom Admin Site** - Igny8AdminSite with logical grouping (maintained)
|
||||
3. **Multi-Tenancy Support** - AccountAdminMixin and SiteSectorAdminMixin
|
||||
4. **Payment Approval Workflow** - Comprehensive payment approval system
|
||||
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**
|
||||
- No custom styling - uses default Django admin theme
|
||||
- Basic, dated appearance
|
||||
- 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"
|
||||
#### 1. **Sidebar Menu Organization**
|
||||
- ✅ Current get_app_list() structure works but can be enhanced with Unfold features
|
||||
- Need to add icons to models for better visual recognition
|
||||
- Missing PlanLimitUsage model (needs to be added)
|
||||
- Some empty groups appearing
|
||||
- Inconsistent naming conventions
|
||||
|
||||
#### 3. **Unused/Empty Models**
|
||||
- **site_building** models referenced but don't exist:
|
||||
- BusinessType
|
||||
- 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
|
||||
#### 2. **Unused/Empty Models** (Same as before)
|
||||
- **site_building** models referenced but don't exist
|
||||
- Duplicate model registrations need cleanup
|
||||
|
||||
#### 4. **Missing Features**
|
||||
- No CSV/Excel export functionality
|
||||
- Limited bulk operations (only 3 actions total)
|
||||
- No Celery task monitoring interface
|
||||
- No admin dashboard with metrics
|
||||
- No data quality indicators
|
||||
- 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
|
||||
#### 3. **Missing Features** (Now easier with Unfold)
|
||||
- CSV/Excel export - Can use Unfold's import_export integration
|
||||
- Bulk operations - Use Unfold's enhanced action system
|
||||
- Celery monitoring - Use Unfold's contrib package for better UI
|
||||
- Admin dashboard - Use Unfold's dashboard widgets
|
||||
- Advanced filtering - Use Unfold's filter contrib package
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
@@ -111,295 +201,224 @@ This document outlines a comprehensive improvement plan for the IGNY8 Django Adm
|
||||
**Files to modify:**
|
||||
- `/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:**
|
||||
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
|
||||
**Current State:** Most admin classes inherit from `admin.ModelAdmin`
|
||||
|
||||
**Proposed Reorganization:**
|
||||
**Action:** Update all admin classes to inherit from Unfold's ModelAdmin:
|
||||
|
||||
```python
|
||||
'💰 Billing & Accounts': {
|
||||
'models': [
|
||||
('igny8_core_auth', 'Plan'),
|
||||
('billing', 'PlanLimitUsage'), # ADD THIS
|
||||
('igny8_core_auth', 'Account'),
|
||||
('igny8_core_auth', 'Subscription'),
|
||||
('billing', 'Invoice'),
|
||||
('billing', 'Payment'),
|
||||
('billing', 'CreditTransaction'),
|
||||
('billing', 'CreditUsageLog'),
|
||||
('billing', 'CreditPackage'),
|
||||
('billing', 'PaymentMethodConfig'),
|
||||
('billing', 'AccountPaymentMethod'),
|
||||
('billing', 'CreditCostConfig'),
|
||||
],
|
||||
},
|
||||
'👥 Sites & Users': {
|
||||
'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'),
|
||||
],
|
||||
},
|
||||
from unfold.admin import ModelAdmin
|
||||
from unfold.contrib.filters.admin import RangeDateFilter
|
||||
from unfold.contrib.import_export.forms import ExportForm, ImportForm
|
||||
|
||||
class TasksAdmin(SiteSectorAdminMixin, ModelAdmin):
|
||||
# Unfold-specific features
|
||||
compressed_fields = True # Compact form layout
|
||||
warn_unsaved_form = True # Warn before leaving unsaved form
|
||||
|
||||
# Standard Django admin
|
||||
list_display = ['title', 'status', 'cluster', 'created_at']
|
||||
list_filter = [
|
||||
('created_at', RangeDateFilter), # Unfold date range filter
|
||||
'status',
|
||||
]
|
||||
search_fields = ['title', 'description']
|
||||
actions_detail = ['generate_content', 'assign_cluster'] # Actions in detail view
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Logical grouping by functionality
|
||||
- Emoji icons for quick visual recognition
|
||||
- Combined Billing & Payments into one group
|
||||
- Separated Content Management from Planning
|
||||
- Grouped AI, Automation, and Optimization together
|
||||
- Clearer hierarchy and easier navigation
|
||||
**Key Files to Update:**
|
||||
- `/data/app/igny8/backend/igny8_core/modules/writer/admin.py`
|
||||
- `/data/app/igny8/backend/igny8_core/modules/planner/admin.py`
|
||||
- `/data/app/igny8/backend/igny8_core/modules/billing/admin.py`
|
||||
- `/data/app/igny8/backend/igny8_core/business/automation/admin.py`
|
||||
- `/data/app/igny8/backend/igny8_core/business/integration/admin.py`
|
||||
- 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
|
||||
# UI Enhancement
|
||||
pip install django-admin-interface # Modern, customizable theme
|
||||
# OR
|
||||
pip install django-grappelli # Alternative mature theme
|
||||
```python
|
||||
class AccountAdmin(AccountAdminMixin, ModelAdmin):
|
||||
# Add icon for sidebar
|
||||
icon = "business" # Material symbol name
|
||||
|
||||
# Functionality
|
||||
pip install django-import-export # CSV/Excel import/export
|
||||
pip install django-admin-rangefilter # Date range filters
|
||||
pip install django-advanced-filters # Save filter combinations
|
||||
pip install django-admin-autocomplete-filter # Better autocomplete
|
||||
class ContentAdmin(SiteSectorAdminMixin, ModelAdmin):
|
||||
icon = "article"
|
||||
|
||||
class TasksAdmin(SiteSectorAdminMixin, ModelAdmin):
|
||||
icon = "task"
|
||||
```
|
||||
|
||||
**Configuration needed:**
|
||||
- Add to INSTALLED_APPS
|
||||
- Configure static files
|
||||
- Run collectstatic
|
||||
- Apply migrations if needed
|
||||
**Icon Mapping:**
|
||||
|
||||
### 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
|
||||
/* Status badges */
|
||||
.status-active { color: #28a745; font-weight: bold; }
|
||||
.status-inactive { color: #dc3545; }
|
||||
.status-pending { color: #ffc107; }
|
||||
**Additional Customization:**
|
||||
|
||||
/* Credit indicators */
|
||||
.credits-low { color: #dc3545; font-weight: bold; }
|
||||
.credits-medium { color: #ffc107; }
|
||||
.credits-high { color: #28a745; }
|
||||
|
||||
/* Quick action buttons */
|
||||
.admin-action-button {
|
||||
padding: 5px 15px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
/* List view enhancements */
|
||||
.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 %}
|
||||
```python
|
||||
# In settings.py UNFOLD dict
|
||||
"SITE_FAVICONS": [
|
||||
{
|
||||
"rel": "icon",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png",
|
||||
"href": lambda request: static("favicons/favicon-32x32.png"),
|
||||
},
|
||||
],
|
||||
"SIDEBAR": {
|
||||
"show_search": True,
|
||||
"show_all_applications": True,
|
||||
"navigation": None, # Use get_app_list() from Igny8AdminSite
|
||||
},
|
||||
"THEME": "light", # or "dark" or "auto"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```python
|
||||
actions = [
|
||||
'bulk_set_status_draft',
|
||||
'bulk_set_status_in_progress',
|
||||
'bulk_set_status_completed',
|
||||
'bulk_assign_cluster',
|
||||
'bulk_generate_content',
|
||||
'bulk_export_csv',
|
||||
]
|
||||
```
|
||||
- Mark as Draft
|
||||
- Mark as In Progress
|
||||
- Mark as Completed
|
||||
- Assign to Cluster
|
||||
- Generate Content (trigger AI)
|
||||
|
||||
#### Content Admin
|
||||
```python
|
||||
actions = [
|
||||
'bulk_publish_to_wordpress',
|
||||
'bulk_set_status',
|
||||
'bulk_add_taxonomy',
|
||||
'bulk_export_with_seo',
|
||||
]
|
||||
```
|
||||
- Publish to WordPress
|
||||
- Change Status
|
||||
- Add Taxonomy
|
||||
- Update SEO Settings
|
||||
|
||||
#### Keywords Admin
|
||||
```python
|
||||
actions = [
|
||||
'bulk_assign_cluster',
|
||||
'bulk_set_priority',
|
||||
'bulk_export_csv',
|
||||
]
|
||||
```
|
||||
- Assign to Cluster
|
||||
- Set Priority
|
||||
- Mark for Research
|
||||
|
||||
#### Payments Admin (already has some)
|
||||
```python
|
||||
actions = [
|
||||
'approve_payments',
|
||||
'reject_payments',
|
||||
'export_transactions', # ADD
|
||||
]
|
||||
```
|
||||
#### Payments Admin
|
||||
- Approve Payments (already exists)
|
||||
- Reject Payments (already exists)
|
||||
- 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
|
||||
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):
|
||||
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):
|
||||
class CreditTransactionAdmin(AccountAdminMixin, ModelAdmin):
|
||||
list_filter = [
|
||||
'transaction_type',
|
||||
('created_at', DateRangeFilter),
|
||||
'account',
|
||||
('created_at', RangeDateFilter), # Beautiful date range picker
|
||||
('amount', RangeNumericFilter), # Numeric range with slider
|
||||
('account', RelatedDropdownFilter), # Dropdown with search
|
||||
('transaction_type', ChoicesDropdownFilter), # Enhanced dropdown
|
||||
]
|
||||
```
|
||||
|
||||
**Add autocomplete filters for large datasets:**
|
||||
**Filter Types to Implement:**
|
||||
|
||||
```python
|
||||
class TasksAdmin(SiteSectorAdminMixin, admin.ModelAdmin):
|
||||
autocomplete_fields = ['site', 'sector', 'cluster']
|
||||
search_fields = ['title', 'description']
|
||||
```
|
||||
| Model | Filters |
|
||||
|-------|---------|
|
||||
| CreditTransaction | Date range, Amount range, Account dropdown, Type |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ class Igny8AdminConfig(AdminConfig):
|
||||
|
||||
def ready(self):
|
||||
super().ready()
|
||||
# Import Unfold AFTER apps are ready
|
||||
from unfold.admin import ModelAdmin as UnfoldModelAdmin
|
||||
|
||||
# Register Django internals in admin (read-only where appropriate)
|
||||
from django.contrib.admin.models import LogEntry
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
@@ -35,8 +38,8 @@ class Igny8AdminConfig(AdminConfig):
|
||||
from django.contrib.sessions.models import Session
|
||||
|
||||
_safe_register(LogEntry, ReadOnlyAdmin)
|
||||
_safe_register(Permission, admin.ModelAdmin)
|
||||
_safe_register(Group, admin.ModelAdmin)
|
||||
_safe_register(Permission, UnfoldModelAdmin)
|
||||
_safe_register(Group, UnfoldModelAdmin)
|
||||
_safe_register(ContentType, ReadOnlyAdmin)
|
||||
_safe_register(Session, ReadOnlyAdmin)
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"""
|
||||
Celery Task Monitoring Admin
|
||||
Celery Task Monitoring Admin - Unfold Style
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.contrib import messages
|
||||
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):
|
||||
"""Admin interface for monitoring Celery tasks"""
|
||||
class CeleryTaskResultAdmin(ModelAdmin):
|
||||
"""Admin interface for monitoring Celery tasks with Unfold styling"""
|
||||
|
||||
list_display = [
|
||||
'task_id',
|
||||
@@ -22,8 +23,8 @@ class CeleryTaskResultAdmin(admin.ModelAdmin):
|
||||
list_filter = [
|
||||
'status',
|
||||
'task_name',
|
||||
('date_created', DateRangeFilter),
|
||||
('date_done', DateRangeFilter),
|
||||
('date_created', RangeDateFilter),
|
||||
('date_done', RangeDateFilter),
|
||||
]
|
||||
search_fields = ['task_id', 'task_name', 'task_args']
|
||||
readonly_fields = [
|
||||
|
||||
@@ -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.admin.apps import AdminConfig
|
||||
from django.apps import apps
|
||||
from django.urls import path
|
||||
from django.urls import path, reverse_lazy
|
||||
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:
|
||||
1. Billing & Tenancy
|
||||
2. Sites & Users
|
||||
3. Global Reference Data
|
||||
4. Planner
|
||||
5. Writer Module
|
||||
6. Thinker Module
|
||||
7. System Configuration
|
||||
Custom AdminSite based on Unfold that organizes models into the planned groups
|
||||
"""
|
||||
site_header = 'IGNY8 Administration'
|
||||
site_title = 'IGNY8 Admin'
|
||||
|
||||
@@ -36,6 +36,11 @@ ALLOWED_HOSTS = [
|
||||
]
|
||||
|
||||
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
|
||||
'igny8_core.admin.apps.Igny8AdminConfig', # Custom admin config
|
||||
'django.contrib.auth',
|
||||
@@ -51,6 +56,7 @@ INSTALLED_APPS = [
|
||||
'import_export',
|
||||
'rangefilter',
|
||||
'django_celery_results',
|
||||
'simple_history',
|
||||
# IGNY8 apps
|
||||
'igny8_core.auth.apps.Igny8CoreAuthConfig', # Use app config with custom label
|
||||
'igny8_core.ai.apps.AIConfig', # AI Framework
|
||||
@@ -108,6 +114,7 @@ MIDDLEWARE = [
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'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.auth.middleware.AccountContextMiddleware', # Multi-account support
|
||||
# AccountContextMiddleware sets request.account from JWT
|
||||
@@ -607,6 +614,40 @@ CELERY_CACHE_BACKEND = 'django-cache'
|
||||
# Import/Export Settings
|
||||
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
|
||||
STRIPE_PUBLIC_KEY = os.getenv('STRIPE_PUBLIC_KEY', '')
|
||||
STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY', '')
|
||||
|
||||
@@ -17,7 +17,8 @@ drf-spectacular>=0.27.0
|
||||
stripe>=7.10.0
|
||||
|
||||
# Django Admin Enhancements
|
||||
django-admin-interface==0.26.0
|
||||
django-unfold==0.73.1
|
||||
django-import-export==3.3.1
|
||||
django-admin-rangefilter==0.11.1
|
||||
django-celery-results==2.5.1
|
||||
django-simple-history==3.4.0
|
||||
|
||||
@@ -1,46 +1,51 @@
|
||||
/*global gettext, interpolate, ngettext, Actions*/
|
||||
'use strict';
|
||||
"use strict";
|
||||
{
|
||||
function show(selector) {
|
||||
document.querySelectorAll(selector).forEach(function(el) {
|
||||
el.classList.remove('hidden');
|
||||
function show(options, selector) {
|
||||
options.parent.querySelectorAll(selector).forEach(function (el) {
|
||||
el.classList.remove("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
function hide(selector) {
|
||||
document.querySelectorAll(selector).forEach(function(el) {
|
||||
el.classList.add('hidden');
|
||||
function hide(options, selector) {
|
||||
options.parent.querySelectorAll(selector).forEach(function (el) {
|
||||
el.classList.add("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
function showQuestion(options) {
|
||||
hide(options.acrossClears);
|
||||
show(options.acrossQuestions);
|
||||
hide(options.allContainer);
|
||||
hide(options, options.acrossClears);
|
||||
show(options, options.acrossQuestions);
|
||||
hide(options, 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);
|
||||
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.acrossClears);
|
||||
hide(options.acrossQuestions);
|
||||
hide(options.allContainer);
|
||||
show(options.counterContainer);
|
||||
hide(options, options.acrossClears);
|
||||
hide(options, options.acrossQuestions);
|
||||
hide(options, options.allContainer);
|
||||
show(options, options.counterContainer);
|
||||
}
|
||||
|
||||
function clearAcross(options) {
|
||||
reset(options);
|
||||
const acrossInputs = document.querySelectorAll(options.acrossInput);
|
||||
acrossInputs.forEach(function(acrossInput) {
|
||||
const acrossInputs = options.parent.querySelectorAll(options.acrossInput);
|
||||
acrossInputs.forEach(function (acrossInput) {
|
||||
acrossInput.value = 0;
|
||||
acrossInput.dispatchEvent(new Event("input"));
|
||||
});
|
||||
document.querySelector(options.actionContainer).classList.remove(options.selectedClass);
|
||||
options.parent
|
||||
.querySelector(options.actionContainer)
|
||||
.classList.remove(options.selectedClass);
|
||||
}
|
||||
|
||||
function checker(actionCheckboxes, options, checked) {
|
||||
@@ -49,26 +54,33 @@
|
||||
} else {
|
||||
reset(options);
|
||||
}
|
||||
actionCheckboxes.forEach(function(el) {
|
||||
actionCheckboxes.forEach(function (el) {
|
||||
el.checked = checked;
|
||||
el.closest('tr').classList.toggle(options.selectedClass, checked);
|
||||
el.closest("tr").classList.toggle(options.selectedClass, checked);
|
||||
});
|
||||
}
|
||||
|
||||
function updateCounter(actionCheckboxes, options) {
|
||||
const sel = Array.from(actionCheckboxes).filter(function(el) {
|
||||
const sel = Array.from(actionCheckboxes).filter(function (el) {
|
||||
return el.checked;
|
||||
}).length;
|
||||
const counter = document.querySelector(options.counterContainer);
|
||||
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), {
|
||||
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);
|
||||
cnt: actions_icnt,
|
||||
},
|
||||
true
|
||||
);
|
||||
const allToggle = options.parent.querySelector(".action-toggle");
|
||||
allToggle.checked = sel === actionCheckboxes.length;
|
||||
if (allToggle.checked) {
|
||||
showQuestion(options);
|
||||
@@ -85,43 +97,51 @@
|
||||
acrossQuestions: "div.actions span.question",
|
||||
acrossClears: "div.actions span.clear",
|
||||
allToggleId: "action-toggle",
|
||||
selectedClass: "selected"
|
||||
selectedClass: "selected",
|
||||
};
|
||||
|
||||
window.Actions = function(actionCheckboxes, options) {
|
||||
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) => {
|
||||
document.addEventListener("keydown", (event) => {
|
||||
shiftPressed = event.shiftKey;
|
||||
});
|
||||
|
||||
document.addEventListener('keyup', (event) => {
|
||||
document.addEventListener("keyup", (event) => {
|
||||
shiftPressed = event.shiftKey;
|
||||
});
|
||||
|
||||
document.getElementById(options.allToggleId).addEventListener('click', function(event) {
|
||||
const allToggle = options.parent.querySelector(".action-toggle");
|
||||
allToggle.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) {
|
||||
options.parent
|
||||
.querySelectorAll(options.acrossQuestions + " a")
|
||||
.forEach(function (el) {
|
||||
el.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
const acrossInputs = document.querySelectorAll(options.acrossInput);
|
||||
acrossInputs.forEach(function(acrossInput) {
|
||||
const acrossInputs = options.parent.querySelectorAll(
|
||||
options.acrossInput
|
||||
);
|
||||
acrossInputs.forEach(function (acrossInput) {
|
||||
acrossInput.value = 1;
|
||||
acrossInput.dispatchEvent(new Event("input"));
|
||||
});
|
||||
showClear(options);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(options.acrossClears + " a").forEach(function(el) {
|
||||
el.addEventListener('click', function(event) {
|
||||
options.parent
|
||||
.querySelectorAll(options.acrossClears + " a")
|
||||
.forEach(function (el) {
|
||||
el.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
document.getElementById(options.allToggleId).checked = false;
|
||||
options.parent.querySelector(".action-toggle").checked = false;
|
||||
clearAcross(options);
|
||||
checker(actionCheckboxes, options, false);
|
||||
updateCounter(actionCheckboxes, options);
|
||||
@@ -129,23 +149,26 @@
|
||||
});
|
||||
|
||||
function affectedCheckboxes(target, withModifier) {
|
||||
const multiSelect = (lastChecked && withModifier && lastChecked !== target);
|
||||
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 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));
|
||||
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 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')) {
|
||||
if (target.classList.contains("action-select")) {
|
||||
const checkboxes = affectedCheckboxes(target, shiftPressed);
|
||||
checker(checkboxes, options, target.checked);
|
||||
updateCounter(actionCheckboxes, options);
|
||||
@@ -156,49 +179,68 @@
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector('#changelist-form button[name=index]').addEventListener('click', function(event) {
|
||||
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."));
|
||||
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]');
|
||||
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) {
|
||||
el.addEventListener("click", function (event) {
|
||||
if (document.querySelector("[name=action]").value) {
|
||||
const text = list_editable_changed
|
||||
? gettext("You have selected an action, but you haven’t saved your changes to individual fields yet. Please click OK to save. You’ll need to re-run the action.")
|
||||
: gettext("You have selected an action, and you haven’t made any changes on individual fields. You’re probably looking for the Go button rather than the Save button.");
|
||||
? gettext(
|
||||
"You have selected an action, but you haven’t saved your changes to individual fields yet. Please click OK to save. You’ll need to re-run the action."
|
||||
)
|
||||
: gettext(
|
||||
"You have selected an action, and you haven’t made any changes on individual fields. You’re 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));
|
||||
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') {
|
||||
if (document.readyState !== "loading") {
|
||||
fn();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', fn);
|
||||
document.addEventListener("DOMContentLoaded", fn);
|
||||
}
|
||||
}
|
||||
|
||||
ready(function() {
|
||||
const actionsEls = document.querySelectorAll('tr input.action-select');
|
||||
ready(function () {
|
||||
document.querySelectorAll(".result-list-wrapper").forEach(function (el) {
|
||||
const actionsEls = el.querySelectorAll("tr input.action-select");
|
||||
|
||||
if (actionsEls.length > 0) {
|
||||
Actions(actionsEls);
|
||||
Actions(actionsEls, {
|
||||
parent: el,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/*global SelectBox, interpolate*/
|
||||
// Handles related-objects functionality: lookup link for raw_id_fields
|
||||
// and Add Another links.
|
||||
'use strict';
|
||||
"use strict";
|
||||
{
|
||||
const $ = django.jQuery;
|
||||
let popupIndex = 0;
|
||||
const relatedWindows = [];
|
||||
|
||||
function dismissChildPopups() {
|
||||
relatedWindows.forEach(function(win) {
|
||||
if(!win.closed) {
|
||||
relatedWindows.forEach(function (win) {
|
||||
if (!win.closed) {
|
||||
win.dismissChildPopups();
|
||||
win.close();
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
function setPopupIndex() {
|
||||
if(document.getElementsByName("_popup").length > 0) {
|
||||
if (document.getElementsByName("_popup").length > 0) {
|
||||
const index = window.name.lastIndexOf("__") + 2;
|
||||
popupIndex = parseInt(window.name.substring(index));
|
||||
} else {
|
||||
@@ -30,16 +30,20 @@
|
||||
}
|
||||
|
||||
function removePopupIndex(name) {
|
||||
return name.replace(new RegExp("__" + (popupIndex + 1) + "$"), '');
|
||||
return name.replace(new RegExp("__" + (popupIndex + 1) + "$"), "");
|
||||
}
|
||||
|
||||
function showAdminPopup(triggeringLink, name_regexp, add_popup) {
|
||||
const name = addPopupIndex(triggeringLink.id.replace(name_regexp, ''));
|
||||
const name = addPopupIndex(triggeringLink.id.replace(name_regexp, ""));
|
||||
const href = new URL(triggeringLink.href);
|
||||
if (add_popup) {
|
||||
href.searchParams.set('_popup', 1);
|
||||
href.searchParams.set("_popup", 1);
|
||||
}
|
||||
const win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
|
||||
const win = window.open(
|
||||
href,
|
||||
name,
|
||||
"height=768,width=1024,resizable=yes,scrollbars=yes"
|
||||
);
|
||||
relatedWindows.push(win);
|
||||
win.focus();
|
||||
return false;
|
||||
@@ -52,12 +56,11 @@
|
||||
function dismissRelatedLookupPopup(win, chosenId) {
|
||||
const name = removePopupIndex(win.name);
|
||||
const elem = document.getElementById(name);
|
||||
if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) {
|
||||
elem.value += ',' + chosenId;
|
||||
if (elem.classList.contains("vManyToManyRawIdAdminField") && elem.value) {
|
||||
elem.value += "," + chosenId;
|
||||
} else {
|
||||
elem.value = chosenId;
|
||||
document.getElementById(name).value = chosenId;
|
||||
}
|
||||
$(elem).trigger('change');
|
||||
const index = relatedWindows.indexOf(win);
|
||||
if (index > -1) {
|
||||
relatedWindows.splice(index, 1);
|
||||
@@ -71,24 +74,42 @@
|
||||
|
||||
function updateRelatedObjectLinks(triggeringLink) {
|
||||
const $this = $(triggeringLink);
|
||||
const siblings = $this.nextAll('.view-related, .change-related, .delete-related');
|
||||
// !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() {
|
||||
siblings.each(function () {
|
||||
const elm = $(this);
|
||||
elm.attr('href', elm.attr('data-href-template').replace('__fk__', value));
|
||||
elm.removeAttr('aria-disabled');
|
||||
elm.attr(
|
||||
"href",
|
||||
elm.attr("data-href-template").replace("__fk__", value)
|
||||
);
|
||||
elm.removeAttr("aria-disabled");
|
||||
});
|
||||
} else {
|
||||
siblings.removeAttr('href');
|
||||
siblings.attr('aria-disabled', true);
|
||||
siblings.removeAttr("href");
|
||||
siblings.attr("aria-disabled", true);
|
||||
}
|
||||
}
|
||||
|
||||
function updateRelatedSelectsOptions(currentSelect, win, objId, newRepr, newId, skipIds = []) {
|
||||
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.
|
||||
@@ -96,12 +117,17 @@
|
||||
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)];
|
||||
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"]`);
|
||||
const selectsRelated = document.querySelectorAll(
|
||||
`[data-model-ref="${modelName}"] [data-context="available-source"]`
|
||||
);
|
||||
|
||||
selectsRelated.forEach(function(select) {
|
||||
if (currentSelect === select || skipIds && skipIds.includes(select.id)) {
|
||||
selectsRelated.forEach(function (select) {
|
||||
if (
|
||||
currentSelect === select ||
|
||||
(skipIds && skipIds.includes(select.id))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -111,7 +137,10 @@
|
||||
option = new Option(newRepr, newId);
|
||||
select.options.add(option);
|
||||
// Update SelectBox cache for related fields.
|
||||
if (window.SelectBox !== undefined && !SelectBox.cache[currentSelect.id]) {
|
||||
if (
|
||||
window.SelectBox !== undefined &&
|
||||
!SelectBox.cache[currentSelect.id]
|
||||
) {
|
||||
SelectBox.add_to_cache(select.id, option);
|
||||
SelectBox.redisplay(select.id);
|
||||
}
|
||||
@@ -128,25 +157,33 @@
|
||||
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);
|
||||
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 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');
|
||||
$(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') {
|
||||
if (toElem && toElem.nodeName.toUpperCase() === "SELECT") {
|
||||
const skipIds = [name + "_from"];
|
||||
updateRelatedSelectsOptions(toElem, win, null, newRepr, newId, skipIds);
|
||||
}
|
||||
@@ -159,17 +196,23 @@
|
||||
}
|
||||
|
||||
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 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() {
|
||||
selects
|
||||
.find("option")
|
||||
.each(function () {
|
||||
if (this.value === objId) {
|
||||
this.textContent = newRepr;
|
||||
this.value = newId;
|
||||
}
|
||||
}).trigger('change');
|
||||
})
|
||||
.trigger("change");
|
||||
updateRelatedSelectsOptions(selects[0], win, objId, newRepr, newId);
|
||||
selects.next().find('.select2-selection__rendered').each(function() {
|
||||
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;
|
||||
@@ -183,14 +226,17 @@
|
||||
}
|
||||
|
||||
function dismissDeleteRelatedObjectPopup(win, objId) {
|
||||
const id = removePopupIndex(win.name.replace(/^delete_/, ''));
|
||||
const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]);
|
||||
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() {
|
||||
selects
|
||||
.find("option")
|
||||
.each(function () {
|
||||
if (this.value === objId) {
|
||||
$(this).remove();
|
||||
}
|
||||
}).trigger('change');
|
||||
})
|
||||
.trigger("change");
|
||||
const index = relatedWindows.indexOf(win);
|
||||
if (index > -1) {
|
||||
relatedWindows.splice(index, 1);
|
||||
@@ -206,43 +252,46 @@
|
||||
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.addEventListener("unload", function (evt) {
|
||||
window.dismissChildPopups();
|
||||
});
|
||||
|
||||
$(document).ready(function() {
|
||||
$(document).ready(function () {
|
||||
setPopupIndex();
|
||||
$("a[data-popup-opener]").on('click', function(event) {
|
||||
$("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) {
|
||||
$("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});
|
||||
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');
|
||||
}
|
||||
);
|
||||
$("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) {
|
||||
$(".related-widget-wrapper select").trigger("change");
|
||||
$("body").on("click", ".related-lookup", function (e) {
|
||||
e.preventDefault();
|
||||
const event = $.Event('django:lookup-related');
|
||||
const event = $.Event("django:lookup-related");
|
||||
$(this).trigger(event);
|
||||
if (!event.isDefaultPrevented()) {
|
||||
showRelatedObjectLookupPopup(this);
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
* Licensed under the New BSD License
|
||||
* See: https://opensource.org/licenses/bsd-license.php
|
||||
*/
|
||||
'use strict';
|
||||
"use strict";
|
||||
{
|
||||
const $ = django.jQuery;
|
||||
$.fn.formset = function(opts) {
|
||||
$.fn.formset = function (opts) {
|
||||
const options = $.extend({}, $.fn.formset.defaults, opts);
|
||||
const $this = $(this);
|
||||
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 replacement = prefix + "-" + ndx;
|
||||
if ($(el).prop("for")) {
|
||||
@@ -32,119 +32,192 @@
|
||||
el.id = el.id.replace(id_regex, replacement);
|
||||
}
|
||||
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");
|
||||
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 minForms = $("#id_" + options.prefix + "-MIN_NUM_FORMS").prop("autocomplete", "off");
|
||||
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.
|
||||
*/
|
||||
const addInlineAddButton = function() {
|
||||
const addInlineAddButton = function () {
|
||||
if (addButton === null) {
|
||||
if ($this.prop("tagName") === "TR") {
|
||||
// If forms are laid out as table rows, insert the
|
||||
// "add" button in a new table row:
|
||||
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(
|
||||
'<tr class="' +
|
||||
options.addCssClass +
|
||||
'"><td colspan="' +
|
||||
numCols +
|
||||
'"><a href="#">' +
|
||||
options.addText +
|
||||
"</a></tr>"
|
||||
);
|
||||
addButton = $parent.find("tr:last a");
|
||||
} else {
|
||||
// Otherwise, insert it immediately after the last form:
|
||||
$this.filter(":last").after('<div class="' + options.addCssClass + '"><a role="button" class="addlink" href="#">' + options.addText + "</a></div>");
|
||||
$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);
|
||||
addButton.on("click", addInlineClickHandler);
|
||||
};
|
||||
|
||||
const addInlineClickHandler = function(e) {
|
||||
const addInlineClickHandler = function (e) {
|
||||
e.preventDefault();
|
||||
const template = $("#" + options.prefix + "-empty");
|
||||
const row = template.clone(true);
|
||||
row.removeClass(options.emptyCssClass)
|
||||
row
|
||||
.removeClass(options.emptyCssClass)
|
||||
.addClass(options.formCssClass)
|
||||
.attr("id", options.prefix + "-" + nextIndex);
|
||||
|
||||
addInlineDeleteButton(row);
|
||||
row.find("*").each(function() {
|
||||
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) {
|
||||
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'));
|
||||
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", {
|
||||
row.get(0).dispatchEvent(
|
||||
new CustomEvent("formset:added", {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
formsetName: options.prefix
|
||||
}
|
||||
}));
|
||||
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) {
|
||||
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>");
|
||||
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 role="button" class="' + options.deleteCssClass + '" href="#">' + options.deleteText + "</a></li>");
|
||||
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 role="button" class="' + options.deleteCssClass + '" href="#">' + options.deleteText + "</a></span>");
|
||||
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));
|
||||
row
|
||||
.find("a." + options.deleteCssClass)
|
||||
.on("click", inlineDeleteHandler.bind(this));
|
||||
};
|
||||
|
||||
const inlineDeleteHandler = function(e1) {
|
||||
const inlineDeleteHandler = function (e1) {
|
||||
e1.preventDefault();
|
||||
const deleteButton = $(e1.target);
|
||||
const row = deleteButton.closest('.' + options.formCssClass);
|
||||
const inlineGroup = row.closest('.inline-group');
|
||||
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')) {
|
||||
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", {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("formset:removed", {
|
||||
detail: {
|
||||
formsetName: options.prefix
|
||||
}
|
||||
}));
|
||||
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) {
|
||||
if (maxForms.val() === "" || maxForms.val() - forms.length > 0) {
|
||||
addButton.parent().show();
|
||||
}
|
||||
// Hide the remove buttons if at min_num.
|
||||
@@ -152,7 +225,7 @@
|
||||
// Also, update names and ids for all remaining form controls so
|
||||
// they remain in sequence:
|
||||
let i, formCount;
|
||||
const updateElementCallback = function() {
|
||||
const updateElementCallback = function () {
|
||||
updateElementIndex(this, options.prefix, i);
|
||||
};
|
||||
for (i = 0, formCount = forms.length; i < formCount; i++) {
|
||||
@@ -161,20 +234,46 @@
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDeleteButtonVisibility = function(inlineGroup) {
|
||||
if ((minForms.val() !== '') && (minForms.val() - totalForms.val()) >= 0) {
|
||||
inlineGroup.find('.inline-deletelink').hide();
|
||||
const toggleDeleteButtonVisibility = function (inlineGroup) {
|
||||
if (minForms.val() !== "" && minForms.val() - totalForms.val() >= 0) {
|
||||
inlineGroup.find(".inline-deletelink").hide();
|
||||
} else {
|
||||
inlineGroup.find('.inline-deletelink').show();
|
||||
inlineGroup.find(".inline-deletelink").show();
|
||||
}
|
||||
};
|
||||
|
||||
$this.each(function(i) {
|
||||
$(this).not("." + options.emptyCssClass).addClass(options.formCssClass);
|
||||
// !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:
|
||||
$this.filter('.' + options.formCssClass + ':not(.has_original):not(.' + options.emptyCssClass + ')').each(function() {
|
||||
// !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);
|
||||
@@ -185,7 +284,8 @@
|
||||
|
||||
// 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;
|
||||
const showAddButton =
|
||||
maxForms.val() === "" || maxForms.val() - totalForms.val() > 0;
|
||||
if ($this.length && showAddButton) {
|
||||
addButton.parent().show();
|
||||
} else {
|
||||
@@ -206,15 +306,14 @@
|
||||
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
|
||||
addButton: null, // Existing add button to use
|
||||
};
|
||||
|
||||
|
||||
// Tabular inlines ---------------------------------------------------------
|
||||
$.fn.tabularFormset = function(selector, options) {
|
||||
$.fn.tabularFormset = function (selector, options, callback = null) {
|
||||
const $rows = $(this);
|
||||
|
||||
const reinitDateTimeShortCuts = function() {
|
||||
const reinitDateTimeShortCuts = function () {
|
||||
// Reinitialize the calendar and clock widgets by force
|
||||
if (typeof DateTimeShortcuts !== "undefined") {
|
||||
$(".datetimeshortcuts").remove();
|
||||
@@ -222,30 +321,36 @@
|
||||
}
|
||||
};
|
||||
|
||||
const updateSelectFilter = function() {
|
||||
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) {
|
||||
if (typeof SelectFilter !== "undefined") {
|
||||
$(".selectfilter").each(function (index, value) {
|
||||
SelectFilter.init(value.id, this.dataset.fieldName, false);
|
||||
});
|
||||
$('.selectfilterstacked').each(function(index, value) {
|
||||
$(".selectfilterstacked").each(function (index, value) {
|
||||
SelectFilter.init(value.id, this.dataset.fieldName, true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const initPrepopulatedFields = function(row) {
|
||||
row.find('.prepopulated_field').each(function() {
|
||||
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') || [],
|
||||
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'));
|
||||
$.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'));
|
||||
input.prepopulate(dependencies, input.attr("maxlength"));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -257,28 +362,38 @@
|
||||
deleteCssClass: "inline-deletelink",
|
||||
deleteText: options.deleteText,
|
||||
emptyCssClass: "empty-form",
|
||||
added: function(row) {
|
||||
added: function (row) {
|
||||
initPrepopulatedFields(row);
|
||||
reinitDateTimeShortCuts();
|
||||
updateSelectFilter();
|
||||
},
|
||||
addButton: options.addButton
|
||||
addButton: options.addButton,
|
||||
});
|
||||
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
|
||||
return $rows;
|
||||
};
|
||||
|
||||
// Stacked inlines ---------------------------------------------------------
|
||||
$.fn.stackedFormset = function(selector, options) {
|
||||
$.fn.stackedFormset = function (selector, options, callback = null) {
|
||||
const $rows = $(this);
|
||||
const updateInlineLabel = function(row) {
|
||||
$(selector).find(".inline_label").each(function(i) {
|
||||
const updateInlineLabel = function (row) {
|
||||
$(selector)
|
||||
.find(".inline_label")
|
||||
.each(function (i) {
|
||||
const count = i + 1;
|
||||
$(this).html($(this).html().replace(/(#\d+)/g, "#" + count));
|
||||
$(this).html(
|
||||
$(this)
|
||||
.html()
|
||||
.replace(/(#\d+)/g, "#" + count)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const reinitDateTimeShortCuts = function() {
|
||||
const reinitDateTimeShortCuts = function () {
|
||||
// Reinitialize the calendar and clock widgets by force, yuck.
|
||||
if (typeof DateTimeShortcuts !== "undefined") {
|
||||
$(".datetimeshortcuts").remove();
|
||||
@@ -286,35 +401,37 @@
|
||||
}
|
||||
};
|
||||
|
||||
const updateSelectFilter = function() {
|
||||
const updateSelectFilter = function () {
|
||||
// If any SelectFilter widgets were added, instantiate a new instance.
|
||||
if (typeof SelectFilter !== "undefined") {
|
||||
$(".selectfilter").each(function(index, value) {
|
||||
$(".selectfilter").each(function (index, value) {
|
||||
SelectFilter.init(value.id, this.dataset.fieldName, false);
|
||||
});
|
||||
$(".selectfilterstacked").each(function(index, value) {
|
||||
$(".selectfilterstacked").each(function (index, value) {
|
||||
SelectFilter.init(value.id, this.dataset.fieldName, true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const initPrepopulatedFields = function(row) {
|
||||
row.find('.prepopulated_field').each(function() {
|
||||
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') || [],
|
||||
input = field.find("input, select, textarea"),
|
||||
dependency_list = input.data("dependency_list") || [],
|
||||
dependencies = [];
|
||||
$.each(dependency_list, function(i, field_name) {
|
||||
$.each(dependency_list, function (i, field_name) {
|
||||
// Dependency in a fieldset.
|
||||
let field_element = row.find('.form-row .field-' + field_name);
|
||||
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);
|
||||
field_element = row.find(".form-row.field-" + field_name);
|
||||
}
|
||||
dependencies.push('#' + field_element.find('input, select, textarea').attr('id'));
|
||||
dependencies.push(
|
||||
"#" + field_element.find("input, select, textarea").attr("id")
|
||||
);
|
||||
});
|
||||
if (dependencies.length) {
|
||||
input.prepopulate(dependencies, input.attr('maxlength'));
|
||||
input.prepopulate(dependencies, input.attr("maxlength"));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -327,33 +444,56 @@
|
||||
deleteText: options.deleteText,
|
||||
emptyCssClass: "empty-form",
|
||||
removed: updateInlineLabel,
|
||||
added: function(row) {
|
||||
added: function (row) {
|
||||
initPrepopulatedFields(row);
|
||||
reinitDateTimeShortCuts();
|
||||
updateSelectFilter();
|
||||
updateInlineLabel(row);
|
||||
},
|
||||
addButton: options.addButton
|
||||
addButton: options.addButton,
|
||||
});
|
||||
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
|
||||
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;
|
||||
$(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();
|
||||
}
|
||||
|
||||
$(event.target).find(".admin-autocomplete").djangoAdminSelect2();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$(document).ready(function () {
|
||||
$(".js-inline-admin-formset").each(function () {
|
||||
initInlines(this);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
backend/staticfiles/unfold/css/simplebar/LICENSE
Normal file
21
backend/staticfiles/unfold/css/simplebar/LICENSE
Normal 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.
|
||||
230
backend/staticfiles/unfold/css/simplebar/simplebar.css
Normal file
230
backend/staticfiles/unfold/css/simplebar/simplebar.css
Normal 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;
|
||||
}
|
||||
2
backend/staticfiles/unfold/css/styles.css
Normal file
2
backend/staticfiles/unfold/css/styles.css
Normal file
File diff suppressed because one or more lines are too long
21
backend/staticfiles/unfold/filters/css/nouislider/LICENSE
Normal file
21
backend/staticfiles/unfold/filters/css/nouislider/LICENSE
Normal 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.
|
||||
1
backend/staticfiles/unfold/filters/css/nouislider/nouislider.min.css
vendored
Normal file
1
backend/staticfiles/unfold/filters/css/nouislider/nouislider.min.css
vendored
Normal 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}
|
||||
408
backend/staticfiles/unfold/filters/js/DateTimeShortcuts.js
Normal file
408
backend/staticfiles/unfold/filters/js/DateTimeShortcuts.js
Normal 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">‹</a>
|
||||
// <a href="#" class="link-next">›</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;
|
||||
}
|
||||
@@ -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];
|
||||
});
|
||||
});
|
||||
});
|
||||
21
backend/staticfiles/unfold/filters/js/nouislider/LICENSE
Normal file
21
backend/staticfiles/unfold/filters/js/nouislider/LICENSE
Normal 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.
|
||||
1
backend/staticfiles/unfold/filters/js/nouislider/nouislider.min.js
vendored
Normal file
1
backend/staticfiles/unfold/filters/js/nouislider/nouislider.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
9
backend/staticfiles/unfold/filters/js/wnumb/LICENSE
Normal file
9
backend/staticfiles/unfold/filters/js/wnumb/LICENSE
Normal 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.
|
||||
1
backend/staticfiles/unfold/filters/js/wnumb/wNumb.min.js
vendored
Normal file
1
backend/staticfiles/unfold/filters/js/wnumb/wNumb.min.js
vendored
Normal 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)})}});
|
||||
BIN
backend/staticfiles/unfold/fonts/inter/Inter-Bold.woff2
Normal file
BIN
backend/staticfiles/unfold/fonts/inter/Inter-Bold.woff2
Normal file
Binary file not shown.
BIN
backend/staticfiles/unfold/fonts/inter/Inter-Medium.woff2
Normal file
BIN
backend/staticfiles/unfold/fonts/inter/Inter-Medium.woff2
Normal file
Binary file not shown.
BIN
backend/staticfiles/unfold/fonts/inter/Inter-Regular.woff2
Normal file
BIN
backend/staticfiles/unfold/fonts/inter/Inter-Regular.woff2
Normal file
Binary file not shown.
BIN
backend/staticfiles/unfold/fonts/inter/Inter-SemiBold.woff2
Normal file
BIN
backend/staticfiles/unfold/fonts/inter/Inter-SemiBold.woff2
Normal file
Binary file not shown.
92
backend/staticfiles/unfold/fonts/inter/LICENSE
Normal file
92
backend/staticfiles/unfold/fonts/inter/LICENSE
Normal 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.
|
||||
31
backend/staticfiles/unfold/fonts/inter/styles.css
Normal file
31
backend/staticfiles/unfold/fonts/inter/styles.css
Normal 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");
|
||||
}
|
||||
202
backend/staticfiles/unfold/fonts/material-symbols/LICENSE
Normal file
202
backend/staticfiles/unfold/fonts/material-symbols/LICENSE
Normal 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.
|
||||
Binary file not shown.
@@ -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");
|
||||
}
|
||||
21
backend/staticfiles/unfold/js/alpine/LICENSE
Normal file
21
backend/staticfiles/unfold/js/alpine/LICENSE
Normal 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.
|
||||
1
backend/staticfiles/unfold/js/alpine/alpine.anchor.js
Normal file
1
backend/staticfiles/unfold/js/alpine/alpine.anchor.js
Normal file
File diff suppressed because one or more lines are too long
5
backend/staticfiles/unfold/js/alpine/alpine.js
Normal file
5
backend/staticfiles/unfold/js/alpine/alpine.js
Normal file
File diff suppressed because one or more lines are too long
1
backend/staticfiles/unfold/js/alpine/alpine.persist.js
Normal file
1
backend/staticfiles/unfold/js/alpine/alpine.persist.js
Normal 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)});})();
|
||||
1
backend/staticfiles/unfold/js/alpine/alpine.resize.js
Normal file
1
backend/staticfiles/unfold/js/alpine/alpine.resize.js
Normal 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)});})();
|
||||
1
backend/staticfiles/unfold/js/alpine/alpine.sort.js
Normal file
1
backend/staticfiles/unfold/js/alpine/alpine.sort.js
Normal file
File diff suppressed because one or more lines are too long
687
backend/staticfiles/unfold/js/app.js
Normal file
687
backend/staticfiles/unfold/js/app.js
Normal 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();
|
||||
});
|
||||
};
|
||||
9
backend/staticfiles/unfold/js/chart/LICENSE
Normal file
9
backend/staticfiles/unfold/js/chart/LICENSE
Normal 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.
|
||||
1
backend/staticfiles/unfold/js/chart/chart.js
Normal file
1
backend/staticfiles/unfold/js/chart/chart.js
Normal file
File diff suppressed because one or more lines are too long
13
backend/staticfiles/unfold/js/htmx/LICENSE
Normal file
13
backend/staticfiles/unfold/js/htmx/LICENSE
Normal 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.
|
||||
1
backend/staticfiles/unfold/js/htmx/htmx.js
Normal file
1
backend/staticfiles/unfold/js/htmx/htmx.js
Normal file
File diff suppressed because one or more lines are too long
45
backend/staticfiles/unfold/js/select2.init.js
Normal file
45
backend/staticfiles/unfold/js/select2.init.js
Normal 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();
|
||||
});
|
||||
}
|
||||
21
backend/staticfiles/unfold/js/simplebar/LICENSE
Normal file
21
backend/staticfiles/unfold/js/simplebar/LICENSE
Normal 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.
|
||||
10
backend/staticfiles/unfold/js/simplebar/simplebar.js
Normal file
10
backend/staticfiles/unfold/js/simplebar/simplebar.js
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user