commit 20d6df3b274b1663571ec6ac7a0056f41e3e7a9f Author: mohamad Date: Sun Jun 21 17:54:19 2026 +0330 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d94e83b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore +.env +.env.local +.venv +venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +htmlcov/ +.coverage +media/ +staticfiles/ +README.md +docs/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..932ab38 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +DJANGO_SETTINGS_MODULE=config.settings.production +DJANGO_SECRET_KEY=uqgb2wi9@1dr8alhhx$rp_tx!%_en$k7w6yjbu7wz-qr3$&3-w +DEBUG=False +ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,tecvico.com,www.tecvico.com +CSRF_TRUSTED_ORIGINS=localhost,127.0.0.1,0.0.0.0,tecvico.com,www.tecvico.com + +POSTGRES_DB=tecvico +POSTGRES_USER=tecvico_user +POSTGRES_PASSWORD=eS4_WYJH97gywyoHjP6v +POSTGRES_HOST=db +POSTGRES_PORT=5432 + +SECURE_SSL_REDIRECT=False + +DJANGO_SUPERUSER_USERNAME=admin +DJANGO_SUPERUSER_EMAIL=admin@yourdomain.com +DJANGO_SUPERUSER_PASSWORD=bS7_U_uRTmivj7W-lCR5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf508e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +dist/ +*.egg-info/ +.installed.cfg +*.egg + +pip-log.txt +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +.pytest_cache/ +nosetests.xml +coverage.xml +*.cover + +*.log +local_settings.py +db.sqlite3 + +instance/ + +.scrapy +docs/_build/ +__pypackages__/ +celerybeat-schedule +celerybeat.pid + +.env +.env.local +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +.mypy_cache/ +.dmypy.json + +media/ +private_uploads/ +staticfiles/ +*.DS_Store \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..982baa0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONFAULTHANDLER=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + DJANGO_SETTINGS_MODULE=config.settings.production + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libpq-dev \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d267de1 --- /dev/null +++ b/README.md @@ -0,0 +1,278 @@ +# Tecvico Website + +Django MVT informational website for **Tecvico**, showcasing products and solutions from [tecvico.com](https://tecvico.com/). + +## Tech Stack + +| Layer | Technology | +|---|---| +| Backend | Django 5.1 (MVT) | +| Database | PostgreSQL 16 | +| Static files | WhiteNoise (with Brotli compression) | +| Application server | Gunicorn | +| Containerization | Docker + Docker Compose | +| Frontend | Vanilla HTML/CSS/JS (no framework) | + +## Project Structure + +``` +tecvico_website/ +├── config/ # Django project configuration +│ └── settings/ +│ ├── base.py # Shared settings +│ ├── development.py # Dev settings (DEBUG=True, dotenv) +│ └── production.py # Production settings (security headers) +├── apps/ +│ ├── core/ # Context processors, management commands +│ │ └── management/commands/seed_content.py +│ ├── products/ # MainProduct, SubProduct, Article, ArticleSection +│ └── pages/ # FAQEntry, DownloadItem; static pages +├── templates/ # Global templates +│ ├── base.html +│ ├── partials/ +│ └── pages/ & products/ +├── static/ +│ ├── css/main.css # Full design system (Tecvico light theme) +│ └── js/main.js # Navbar, FAQ accordion, scroll effects +├── Dockerfile +├── docker-compose.yml # Production compose +├── docker-compose.override.yml # Development compose overrides +└── entrypoint.sh # DB wait + migrate on container start +``` + +## URL Map + +| URL | View | Description | +|---|---|---| +| `/` | `HomeView` | Landing page | +| `/about/` | `AboutView` | About Tecvico | +| `/products/` | `ProductOverviewView` | All main products | +| `/products//` | `MainProductDetailView` | Main product + sub-products | +| `/products///` | `SubProductDetailView` | Sub-product + articles | +| `/downloads/` | `DownloadsView` | Download items by platform | +| `/faq/` | `FAQView` | FAQ entries | +| `/contact/` | `ContactView` | Contact info | +| `/admin/` | Django Admin | Admin panel | + +## Data Model + +``` +MainProduct + └── SubProduct (FK → MainProduct) + └── Article (FK → SubProduct) + └── ArticleSection (FK → Article) ← key/value metadata + +FAQEntry ← admin-managed FAQ items +DownloadItem ← admin-managed download links per platform +``` + +--- + +## Local Development (with Docker) + +### Prerequisites + +- Docker Desktop (or Docker Engine + Compose plugin) + +### 1. Clone & configure + +```bash +git clone tecvico_website +cd tecvico_website +cp .env.example .env +``` + +Edit `.env` and set at minimum: + +``` +DJANGO_SECRET_KEY=your-very-secure-random-key +POSTGRES_PASSWORD=choose-a-strong-password +``` + +### 2. Start services (development mode) + +The `docker-compose.override.yml` automatically activates when you run `docker compose up`, mounting the source code and using the development settings. + +```bash +docker compose up --build +``` + +The app is available at **http://localhost:8000** + +### 3. Create a superuser + +```bash +docker compose exec web python manage.py createsuperuser +``` + +### 4. Seed initial content + +Populate the database with content scraped and adapted from visera.ca: + +```bash +docker compose exec web python manage.py seed_content +``` + +To flush and re-seed from scratch: + +```bash +docker compose exec web python manage.py seed_content --flush +``` + +--- + +## Local Development (without Docker) + +### Prerequisites + +- Python 3.12+ +- PostgreSQL 14+ + +### 1. Set up virtual environment + +```bash +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements-dev.txt +``` + +### 2. Configure environment + +```bash +cp .env.example .env +``` + +Edit `.env`: + +``` +DJANGO_SECRET_KEY=your-key +POSTGRES_DB=tecvico +POSTGRES_USER=your_pg_user +POSTGRES_PASSWORD=your_pg_password +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +``` + +### 3. Create the database + +```bash +createdb tecvico +``` + +### 4. Run migrations & seed + +```bash +python manage.py migrate +python manage.py seed_content +python manage.py createsuperuser +``` + +### 5. Start development server + +```bash +python manage.py runserver +``` + +--- + +## Running Tests + +### With Django test runner + +```bash +python manage.py test apps +``` + +### With pytest (requires `requirements-dev.txt`) + +```bash +pytest +``` + +### With coverage report + +```bash +coverage run -m pytest +coverage report -m +coverage html # Generates htmlcov/index.html +``` + +--- + +## Production Deployment + +### 1. Build and start production containers + +Remove `docker-compose.override.yml` (or don't override it) and pass production environment variables: + +```bash +DJANGO_SETTINGS_MODULE=config.settings.production \ +docker compose -f docker-compose.yml up --build -d +``` + +### 2. Production `.env` checklist + +| Variable | Notes | +|---|---| +| `DJANGO_SECRET_KEY` | Use `python -c "import secrets; print(secrets.token_urlsafe(50))"` | +| `DEBUG` | Must be `False` | +| `ALLOWED_HOSTS` | Comma-separated: `yourdomain.com,www.yourdomain.com` | +| `CSRF_TRUSTED_ORIGINS` | `https://yourdomain.com` | +| `POSTGRES_PASSWORD` | Strong random password | +| `SECURE_SSL_REDIRECT` | `True` when behind TLS termination | + +### 3. Reverse proxy (recommended) + +Place an Nginx or Caddy reverse proxy in front of Gunicorn for TLS termination and serving static files (or let WhiteNoise handle statics directly). + +--- + +## Admin Panel + +Access Django Admin at `/admin/` with superuser credentials. + +### What you can manage + +| Model | Description | +|---|---| +| **Main Products** | Top-level products with nested sub-products inline | +| **Sub Products** | Modules within a main product; articles editable inline | +| **Articles** | Article entries with section key/values inline | +| **Article Sections** | Individual key-value metadata rows | +| **FAQ Entries** | Accordion FAQ items (order, active toggle) | +| **Download Items** | Platform download links (Windows/macOS/Linux) | + +--- + +## Seed Content + +The `seed_content` command populates: + +- **Tecvico** (MainProduct) with 5 sub-products: + - Image Processing + - Radiomics Features + - Medical Image Visualization + - Format Conversion + - Workflow Management +- Articles and sections for each sub-product +- 6 FAQ entries +- 3 download items (Windows active, macOS/Linux coming soon) + +Content is adapted from the original [visera.ca](https://visera.ca) website. + +--- + +## Design System + +The website uses a custom dark-glass design combining: + +- **visera.ca** aesthetic — dark background, organic blob animations, blue/teal accents +- **Apple visionOS Ice** aesthetic — frosted glass panels (`backdrop-filter: blur`), translucent cards, soft gradients + +Key CSS custom properties are in `static/css/main.css` under `:root`. + +--- + +## License + +Content is adapted from visera.ca under CC BY-NC-SA. Software code is proprietary to Tecvico. diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/__init__.py b/apps/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/admin.py b/apps/core/admin.py new file mode 100644 index 0000000..8c25e6d --- /dev/null +++ b/apps/core/admin.py @@ -0,0 +1,92 @@ +from django.contrib import admin +from django.http import HttpResponseRedirect +from django.urls import reverse + +from .models import SiteBranding, SiteContact + + +@admin.register(SiteBranding) +class SiteBrandingAdmin(admin.ModelAdmin): + fieldsets = ( + ("Icon", {"fields": ("icon", "icon_alt")}), + ( + "Sizes", + { + "fields": ("navbar_icon_size", "footer_icon_size"), + "description": "Square dimensions in pixels for each placement.", + }, + ), + ( + "Appearance", + { + "fields": ( + "object_fit", + "show_border", + "border_width", + "border_color", + ), + }, + ), + ) + + def has_add_permission(self, request): + return not SiteBranding.objects.exists() + + def has_delete_permission(self, request, obj=None): + return False + + def changelist_view(self, request, extra_context=None): + branding = SiteBranding.objects.first() + if branding: + return HttpResponseRedirect( + reverse("admin:core_sitebranding_change", args=[branding.pk]) + ) + return super().changelist_view(request, extra_context) + + +@admin.register(SiteContact) +class SiteContactAdmin(admin.ModelAdmin): + fieldsets = ( + ( + "Email", + { + "fields": ( + "support_email", + "email_card_title", + "email_card_description", + ), + }, + ), + ( + "Discord", + { + "fields": ( + "discord_url", + "discord_label", + "discord_card_title", + "discord_card_description", + ), + }, + ), + ( + "Office address", + { + "fields": ("office_address", "office_card_title"), + "description": "Enter one address line per row.", + }, + ), + ) + + def has_add_permission(self, request): + return not SiteContact.objects.exists() + + def has_delete_permission(self, request, obj=None): + return False + + def changelist_view(self, request, extra_context=None): + contact = SiteContact.objects.first() + if contact: + return HttpResponseRedirect( + reverse("admin:core_sitecontact_change", args=[contact.pk]) + ) + return super().changelist_view(request, extra_context) diff --git a/apps/core/admin_video.py b/apps/core/admin_video.py new file mode 100644 index 0000000..2eed616 --- /dev/null +++ b/apps/core/admin_video.py @@ -0,0 +1,30 @@ +from django.utils.html import format_html + +from apps.core.video import VIDEO_SOURCE_UPLOAD, VIDEO_SOURCE_YOUTUBE + + +def video_admin_preview(obj): + if not obj.has_video: + return "No video configured yet." + if obj.video_source == VIDEO_SOURCE_UPLOAD and obj.video_file: + if obj.video_poster: + return format_html( + '', + obj.video_poster.url, + obj.video_file.url, + ) + return format_html( + '', + obj.video_file.url, + ) + if obj.video_source == VIDEO_SOURCE_YOUTUBE and obj.youtube_embed_url: + return format_html( + '', + obj.youtube_embed_url, + ) + return "No video configured yet." + +video_admin_preview.short_description = "Preview" diff --git a/apps/core/apps.py b/apps/core/apps.py new file mode 100644 index 0000000..9c3e6ff --- /dev/null +++ b/apps/core/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.core" + verbose_name = "Core" diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py new file mode 100644 index 0000000..7420418 --- /dev/null +++ b/apps/core/context_processors.py @@ -0,0 +1,41 @@ +from django.db.models import Prefetch + +from apps.core.models import SiteBranding, SiteContact +from apps.pages.models import CustomPage +from apps.products.models import MainProduct, SubProduct + + +def site_branding(request): + return {"site_branding": SiteBranding.load()} + + +def site_contact(request): + return {"site_contact": SiteContact.load()} + + +def navigation(request): + main_products = ( + MainProduct.objects.filter(is_active=True) + .prefetch_related( + Prefetch( + "sub_products", + queryset=SubProduct.objects.filter(is_active=True).order_by( + "order", "name" + ), + ) + ) + .order_by("order", "name") + ) + all_footer_products = list(main_products[:5]) + footer_main_products = all_footer_products[:4] + footer_main_products_has_more = len(all_footer_products) == 5 + nav_custom_pages = CustomPage.objects.filter( + is_published=True, + show_in_nav=True, + ).order_by("menu_order", "title") + return { + "nav_main_products": main_products, + "nav_custom_pages": nav_custom_pages, + "footer_main_products": footer_main_products, + "footer_main_products_has_more": footer_main_products_has_more, + } diff --git a/apps/core/management/__init__.py b/apps/core/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/management/commands/__init__.py b/apps/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/management/commands/ensure_superuser.py b/apps/core/management/commands/ensure_superuser.py new file mode 100644 index 0000000..e0ac745 --- /dev/null +++ b/apps/core/management/commands/ensure_superuser.py @@ -0,0 +1,32 @@ +import os + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = "Create a superuser from environment variables if one does not already exist." + + def handle(self, *args, **options): + User = get_user_model() + + username = os.environ.get("DJANGO_SUPERUSER_USERNAME", "admin") + email = os.environ.get("DJANGO_SUPERUSER_EMAIL", "admin@tecvico.com") + password = os.environ.get("DJANGO_SUPERUSER_PASSWORD") + + if not password: + self.stdout.write( + self.style.WARNING( + "DJANGO_SUPERUSER_PASSWORD is not set — skipping superuser creation." + ) + ) + return + + if User.objects.filter(username=username).exists(): + self.stdout.write( + self.style.SUCCESS(f"Superuser '{username}' already exists — skipping.") + ) + return + + User.objects.create_superuser(username=username, email=email, password=password) + self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' created successfully.")) diff --git a/apps/core/management/commands/seed_content.py b/apps/core/management/commands/seed_content.py new file mode 100644 index 0000000..e9e2b53 --- /dev/null +++ b/apps/core/management/commands/seed_content.py @@ -0,0 +1,732 @@ +from pathlib import Path + +from django.conf import settings +from django.core.files import File +from django.core.management.base import BaseCommand +from django.db import transaction + +from apps.core.models import SiteBranding, SiteContact +from apps.pages.models import DownloadItem, FAQEntry, HeroSection, HomepageSection, HomepageSectionItem +from apps.products.models import Article, ArticleSection, MainProduct, SubProduct + +MAIN_PRODUCTS = [ + { + "name": "Tecvico", + "slug": "tecvico", + "short_description": "Visualized & Standardized Environment for Radiomics Analysis", + "description": ( + "Tecvico is a free, open-source software specialized for visualization, " + "processing, segmentation, registration, fusion and analysis of medical and " + "biomedical images, including radiomics and machine learning analysis. " + "Tecvico is a major, entirely-revamped upgrade to the original SERA " + "(Matlab-based), now built on Python for broader accessibility and community " + "contribution. It enables standardized and reproducible radiomic feature " + "extraction in compliance with the Image Biomarker Standardization Initiative " + "(IBSI 1.0), and implements image filters standardized against IBSI 2.0." + ), + "order": 1, + "show_on_homepage": True, + "homepage_order": 1, + "sub_products": [ + { + "name": "Image Processing", + "slug": "image-processing", + "short_description": "Standardized filtering, registration, and fusion techniques", + "description": ( + "Advanced image processing capabilities including standardized filtering " + "techniques compliant with IBSI 2.0, image registration, fusion, and " + "Standardized Uptake Value (SUV) conversion. Tecvico employs popular " + "image processing algorithms to create end-to-end standardized workflows " + "for consistent, reproducible research outcomes." + ), + "order": 1, + "articles": [ + { + "title": "Image Filtering Techniques", + "description": ( + "Tecvico implements a comprehensive set of image filtering techniques " + "fully standardized against the Image Biomarker Standardization " + "Initiative (IBSI) phase 2. These filters enable reproducible " + "preprocessing across institutions and studies." + ), + "order": 1, + "sections": [ + {"title": "Standardization", "value": "IBSI 2.0 compliant", "order": 1}, + { + "title": "Available Filters", + "value": "Mean, Gaussian, Laplacian of Gaussian (LoG), Laws kernels, Gabor, Wavelets (PyWavelets), Log-Sigma", + "order": 2, + }, + {"title": "Author", "value": "Tecvico R&D Team", "order": 3}, + ], + }, + { + "title": "Image Registration & Fusion", + "description": ( + "Tecvico provides robust image registration and fusion methods, " + "enabling multi-modal image alignment for PET/CT, PET/MRI, and " + "other combined modality studies. Standardized Uptake Value (SUV) " + "conversion is also supported." + ), + "order": 2, + "sections": [ + {"title": "Registration Methods", "value": "Rigid, Affine, Deformable (B-spline)", "order": 1}, + {"title": "Fusion Techniques", "value": "Overlay, weighted average, multi-modal blending", "order": 2}, + {"title": "Special Feature", "value": "Standardized Uptake Value (SUV) conversion", "order": 3}, + ], + }, + ], + }, + { + "name": "Radiomics Features", + "slug": "radiomics-features", + "short_description": "IBSI 1.0 compliant handcrafted radiomic feature extraction", + "description": ( + "Tecvico provides comprehensive handcrafted radiomic feature extraction " + "fully standardized by the Image Biomarker Standardization Initiative " + "(IBSI 1.0). Features are computed from segmented regions of interest " + "across multiple image modalities, enabling reproducible quantitative " + "imaging biomarker research." + ), + "order": 2, + "articles": [ + { + "title": "IBSI Compliant Feature Extraction", + "description": ( + "Tecvico computes a comprehensive set of radiomic features " + "covering all IBSI 1.0 feature classes. Features are extracted " + "from segmented Regions of Interest (ROIs) and are fully " + "reproducible across different platforms and institutions." + ), + "order": 1, + "sections": [ + {"title": "Standardization", "value": "IBSI 1.0 compliant", "order": 1}, + { + "title": "Feature Classes", + "value": "Shape (3D & 2D), First-order Statistics, GLCM, GLRLM, GLSZM, GLDM, NGTDM", + "order": 2, + }, + {"title": "Output Formats", "value": "CSV, JSON, Excel", "order": 3}, + {"title": "Reference", "value": "Zwanenburg et al. (2020), Radiology", "order": 4}, + ], + }, + ], + }, + { + "name": "Medical Image Visualization", + "slug": "medical-image-visualization", + "short_description": "Professional multi-modality medical image viewer", + "description": ( + "Tecvico includes a professional medical image viewer that supports " + "multiple imaging modalities and file formats. The viewer provides " + "comfortable, intuitive controls for slice navigation, windowing, " + "zoom, and annotation, suitable for radiation oncologists, radiologists, " + "physicists, and data scientists." + ), + "order": 3, + "articles": [ + { + "title": "Multi-Modal Image Viewer", + "description": ( + "The integrated viewer supports simultaneous display of multiple " + "image modalities with linked cursors, adjustable window/level, " + "and overlay capabilities. RT struct contours are rendered " + "directly over the underlying images." + ), + "order": 1, + "sections": [ + {"title": "Supported Modalities", "value": "CT, MRI, PET, SPECT, CBCT", "order": 1}, + {"title": "File Formats", "value": "DICOM, NIFTI (.nii, .nii.gz), NRRD, MHA, NII", "order": 2}, + {"title": "Special Support", "value": "RT Struct, RT Dose, RT Plan visualization", "order": 3}, + ], + }, + ], + }, + { + "name": "Format Conversion", + "slug": "format-conversion", + "short_description": "Professional converter for medical imaging file formats", + "description": ( + "Tecvico provides a professional image format converter supporting all " + "major medical imaging standards. Seamlessly convert between DICOM, " + "NIFTI, NRRD, MHA, and other formats without loss of spatial metadata " + "or patient information integrity." + ), + "order": 4, + "articles": [ + { + "title": "Medical Image Format Converter", + "description": ( + "The built-in converter handles complex DICOM series reconstruction, " + "preserving spatial orientation, voxel spacing, and relevant metadata " + "throughout conversion. Batch conversion is supported for large " + "research datasets." + ), + "order": 1, + "sections": [ + {"title": "Input Formats", "value": "DICOM (all SOP classes), NIFTI, NRRD, NII, MHA, MetaImage", "order": 1}, + {"title": "Output Formats", "value": "NIFTI (.nii.gz), NRRD, MHA, NII", "order": 2}, + {"title": "Batch Processing", "value": "Supported — process entire datasets automatically", "order": 3}, + ], + }, + ], + }, + { + "name": "Workflow Management", + "slug": "workflow-management", + "short_description": "Reproducible research workflow creation and sharing", + "description": ( + "Tecvico's workflow management system allows researchers to design, save, " + "share, and reuse analysis pipelines. Workflows connect individual " + "processing steps — from image loading and preprocessing to feature " + "extraction and machine learning — into reproducible, shareable sequences " + "that ensure consistency across studies and institutions." + ), + "order": 5, + "articles": [ + { + "title": "Reproducible Research Workflows", + "description": ( + "Create end-to-end analysis pipelines by visually connecting " + "processing nodes. Each workflow can be exported, shared with " + "collaborators, and re-executed to reproduce results on new datasets." + ), + "order": 1, + "sections": [ + {"title": "Key Benefit", "value": "Usability, Reusability and Reproducibility (URR)", "order": 1}, + {"title": "Collaboration", "value": "Share workflows, datasets, and results with research teams", "order": 2}, + {"title": "Compatibility", "value": "Works with all supported image modalities and feature extractors", "order": 3}, + ], + }, + ], + }, + ], + }, +] + +FAQ_ENTRIES = [ + { + "question": "What is the Tecvico license?", + "answer": ( + "Tecvico is free and open-source for research purposes.\n\n" + "License: CC BY-NC-SA (Creative Commons Attribution-NonCommercial-ShareAlike). " + "This means you may use, share, and adapt the software for non-commercial " + "research purposes, provided you give appropriate credit and distribute " + "derivatives under the same license." + ), + "order": 1, + }, + { + "question": "How do I cite Tecvico in my research?", + "answer": ( + "Please cite the following reference if you publish results obtained with " + "the help of Tecvico:\n\n" + "M. R. Salmanpour, I. Shiri, M. Hosseinzadeh, H. Zaidi, S. Ashrafinia, " + "M. Oveisi, A. Rahmim. Tecvico: Visualized & Standardized Environment for " + "Radiomics Analysis — A Shareable, Executable, and Reproducible Workflow " + "Generator. Proc. IEEE Medical Imaging Conference, 2023." + ), + "order": 2, + }, + { + "question": "Which operating systems does Tecvico support?", + "answer": ( + "Tecvico currently fully supports Windows 10 and above (64-bit). " + "New versions to support macOS and Linux systems are under active development " + "and coming soon. Follow our Discord or check each product module page for updates." + ), + "order": 3, + }, + { + "question": "Can I install a new version over an existing installation?", + "answer": ( + "Yes, you can install the new version without removing the previous one. " + "However, if you encounter any problems after upgrading, we recommend " + "uninstalling the old version first, then performing a clean installation " + "of the new release." + ), + "order": 4, + }, + { + "question": "Is Tecvico suitable for clinical use?", + "answer": ( + "Tecvico is designed and intended exclusively for research purposes. " + "It is not certified for clinical diagnostic use. Always consult with " + "qualified medical professionals for clinical decisions." + ), + "order": 5, + }, + { + "question": "Where can I get support or report issues?", + "answer": ( + "Support is available via email and through our community Discord server " + "(see the Contact page for current details). For bug reports and feature " + "requests, please use the Discord forum or contact us directly by email." + ), + "order": 6, + }, +] + +HOMEPAGE_SECTIONS = [ + { + "section_type": HomepageSection.TYPE_FEATURES, + "badge": "Capabilities", + "title": "Important Features", + "description": "Comprehensive tools for medical imaging research, standardized and reproducible.", + "order": 1, + "items": [ + {"icon": "⚗️", "title": "Image Filtering", "content": "Standardized image filtering techniques compliant with IBSI 2.0 guidelines.", "order": 1}, + {"icon": "🖥️", "title": "Professional Viewer", "content": "Comfortable, professional medical image viewer with multi-modality support.", "order": 2}, + {"icon": "📊", "title": "Radiomics Features", "content": "Handcrafted radiomics feature generation standardized by IBSI 1.0.", "order": 3}, + {"icon": "🔄", "title": "Format Support", "content": "NIFTI, DICOM, NRRD, and more — comprehensive multi-format support.", "order": 4}, + {"icon": "🗂️", "title": "Image Registration", "content": "Advanced image registration, fusion, and standardized SUV conversion.", "order": 5}, + {"icon": "🔬", "title": "RT Struct Support", "content": "Full RT struct support for radiation oncology workflows and research.", "order": 6}, + ], + }, + { + "section_type": HomepageSection.TYPE_SCREENSHOTS, + "badge": "Gallery", + "title": "See Tecvico in Action", + "description": "Explore Tecvico's powerful interface, workflow builder, and multi-modal image viewer.", + "order": 2, + "items": [], + }, + { + "section_type": HomepageSection.TYPE_PRODUCTS, + "badge": "Our Software", + "title": "Products", + "description": "Explore our suite of medical imaging and radiomics tools.", + "order": 3, + "items": [], + }, + { + "section_type": HomepageSection.TYPE_PROBLEMS, + "badge": "Value Proposition", + "title": "What Problems Does Tecvico Solve?", + "description": "", + "order": 4, + "items": [ + {"icon": "01", "title": "Accessibility", "content": "Tecvico provides a user-friendly interface and a wide range of tools, allowing researchers to perform complex data analysis without extensive technical knowledge or programming expertise.", "order": 1}, + {"icon": "02", "title": "Integrated Tools", "content": "Tecvico integrates a vast collection of tools and resources from various domains of healthcare and medical imaging research in a common, unified environment.", "order": 2}, + {"icon": "03", "title": "Flexibility", "content": "Tecvico offers flexibility in terms of tool optimization and workflow customization to match your specific research requirements.", "order": 3}, + {"icon": "04", "title": "Reproducibility", "content": "Improve usability, reusability, and reproducibility (URR) through a workflow management system that allows researchers to easily create, share, and reuse analysis pipelines.", "order": 4}, + ], + }, + { + "section_type": HomepageSection.TYPE_PROJECTS, + "title": "Our Journey in the Realm of", + "title_highlight": "Outstanding Projects", + "description": ( + "Explore our standout projects here and immerse yourself in our journey " + "through the world of innovation and development." + ), + "order": 5, + "items": [ + { + "title": "Revolutionizing Radiomics Analysis and Medical Image Visualization", + "content": ( + "Visera is a free, open-source software specialized for visualization, processing, " + "segmentation, registration, fusion and analysis of medical / biomedical images, " + "including radiomics and machine learning analysis." + ), + "tags": "Web Development, Publication", + "project_status": HomepageSectionItem.STATUS_NEW, + "url": "https://visera.ca/", + "image_path": "projects/research2.png", + "image_alt": "Visera medical imaging software", + "order": 1, + }, + { + "title": "Automatic Segmentation of Head and Neck Cancer using Fusion ...", + "content": ( + "People with the below expertise are able to apply for this project: " + "1-The individual with enough experience ..." + ), + "tags": "Web Development, Publication", + "project_status": HomepageSectionItem.STATUS_ONGOING, + "image_path": "projects/project-frame-2.png", + "order": 2, + }, + { + "title": "Predicting TNM Stage in Head and Neck Cancer using Multi-Modality Fusion ...", + "content": "People with the below expertise are able to apply for this project.", + "tags": "Web Development, Publication", + "project_status": HomepageSectionItem.STATUS_DONE, + "image_path": "projects/project-frame-3.png", + "order": 3, + }, + { + "title": "Application of Deep Learning Techniques Coupled with fusion Models for ...", + "content": ( + "People with the below expertise are able to apply for this project: " + "1-The individual with enough experience ..." + ), + "tags": "Web Development, Publication", + "project_status": HomepageSectionItem.STATUS_NEW, + "image_path": "projects/project-frame-4.png", + "order": 4, + }, + { + "title": "Identifying Reliable and Robust Tensor Radiomics Features in Lung Cancer", + "content": ( + "Radiomics is a major frontier in medical image analysis, enabling the mining " + "of high-dimensional data from ..." + ), + "tags": "Web Development, Publication", + "project_status": HomepageSectionItem.STATUS_ONGOING, + "image_path": "projects/project-frame-5.png", + "order": 5, + }, + ], + }, + { + "section_type": HomepageSection.TYPE_EXPERIENCE, + "title": "Experience Leading", + "title_highlight": "the Way in Development", + "description": ( + "Embark on a journey of accelerated product development, prioritizing stability, " + "security, and flexible technology choices.\n\n" + "Our commitment to crafting a distinctive user experience ensures your product " + "stands out with innovative design and seamless functionality." + ), + "order": 6, + "items": [ + { + "title": "Financial Benefits", + "content": ( + "Maximize your financial gains with our solutions, offering cost-effective " + "strategies and optimized financial performance." + ), + "image_path": "experience/financial-benefits.svg", + "order": 1, + }, + { + "title": "24/7 Support", + "content": ( + "Enjoy peace of mind with our round-the-clock support, ensuring assistance " + "and guidance whenever you need it, day or night." + ), + "image_path": "experience/support.svg", + "order": 2, + }, + { + "title": "Quality Assurance", + "content": ( + "Rely on our commitment to quality assurance, where meticulous processes " + "guarantee the delivery of high-quality, error-free outcomes." + ), + "image_path": "experience/quality-assurance.svg", + "order": 3, + }, + { + "title": "International Workshop", + "content": ( + "Engage in our international workshops, fostering collaboration and knowledge " + "exchange on a global scale for enhanced innovation and skill development." + ), + "image_path": "experience/international-workshop.svg", + "order": 4, + }, + ], + }, + { + "section_type": HomepageSection.TYPE_ABOUT_STRIP, + "badge": "Our Story", + "title": "More to Know", + "description": ( + "Tecvico has been developing since 2021 by the Quantitative Radiomolecular Imaging " + "and Therapy (Qurit) lab & program at the University of British Columbia & " + "BC Cancer Research Institute, Vancouver, BC, Canada." + ), + "link_text": "Learn More", + "link_url": "/about/", + "order": 7, + "items": [], + }, + { + "section_type": HomepageSection.TYPE_SUPPORTERS, + "badge": "Acknowledgements", + "title": "Our Supporters", + "description": "Tecvico is made possible by the support of leading research institutions and organizations.", + "order": 8, + "items": [ + { + "title": "University of British Columbia", + "content": "Faculty of Medicine and the Department of Integrative Oncology.", + "order": 1, + }, + { + "title": "BC Cancer Research Institute", + "content": "Supporting cutting-edge radiomics and medical imaging research in Vancouver, BC.", + "order": 2, + }, + ], + }, +] + +DOWNLOAD_ITEMS = [ + { + "name": "Tecvico Desktop", + "platform": "windows", + "version": "1.0.0", + "download_url": "https://github.com/tecvico/tecvico/releases/latest/download/Tecvico-Setup.exe", + "description": "Windows 10 and above (64-bit). Installer package.", + "is_active": True, + "order": 1, + }, + { + "name": "Tecvico Desktop", + "platform": "macos", + "version": "Coming Soon", + "download_url": "#", + "description": "macOS version is under development.", + "is_active": False, + "order": 2, + }, + { + "name": "Tecvico Desktop", + "platform": "linux", + "version": "Coming Soon", + "download_url": "#", + "description": "Linux version is under development.", + "is_active": False, + "order": 3, + }, +] + + +class Command(BaseCommand): + help = "Seed the database with initial Tecvico website content" + + def add_arguments(self, parser): + parser.add_argument( + "--flush", + action="store_true", + help="Delete all existing seed data before re-seeding", + ) + + @transaction.atomic + def handle(self, *args, **options): + if options["flush"]: + self.stdout.write("Flushing existing seed data...") + ArticleSection.objects.all().delete() + Article.objects.all().delete() + SubProduct.objects.all().delete() + MainProduct.objects.all().delete() + FAQEntry.objects.all().delete() + DownloadItem.objects.all().delete() + HomepageSectionItem.objects.all().delete() + HomepageSection.objects.all().delete() + HeroSection.objects.all().delete() + + self._seed_products() + self._seed_faq() + self._seed_downloads() + self._seed_homepage_sections() + self._seed_hero() + self._seed_site_contact() + self._seed_site_branding() + + self.stdout.write(self.style.SUCCESS("Content seeded successfully.")) + + def _seed_products(self): + for product_data in MAIN_PRODUCTS: + sub_products_data = product_data.pop("sub_products") + main_product, created = MainProduct.objects.get_or_create( + slug=product_data["slug"], + defaults=product_data, + ) + if not created: + for field, value in product_data.items(): + setattr(main_product, field, value) + main_product.save() + + action = "Created" if created else "Updated" + self.stdout.write(f" {action} main product: {main_product.name}") + + for sub_data in sub_products_data: + articles_data = sub_data.pop("articles") + sub_product, sub_created = SubProduct.objects.get_or_create( + main_product=main_product, + slug=sub_data["slug"], + defaults=sub_data, + ) + if not sub_created: + for field, value in sub_data.items(): + setattr(sub_product, field, value) + sub_product.save() + + sub_action = "Created" if sub_created else "Updated" + self.stdout.write(f" {sub_action} sub-product: {sub_product.name}") + + for article_data in articles_data: + sections_data = article_data.pop("sections") + article, art_created = Article.objects.get_or_create( + sub_product=sub_product, + title=article_data["title"], + defaults=article_data, + ) + if not art_created: + for field, value in article_data.items(): + setattr(article, field, value) + article.save() + + art_action = "Created" if art_created else "Updated" + self.stdout.write(f" {art_action} article: {article.title}") + + for section_data in sections_data: + section, _ = ArticleSection.objects.get_or_create( + article=article, + title=section_data["title"], + defaults=section_data, + ) + + def _seed_faq(self): + for entry_data in FAQ_ENTRIES: + faq, created = FAQEntry.objects.get_or_create( + question=entry_data["question"], + defaults=entry_data, + ) + if not created: + for field, value in entry_data.items(): + setattr(faq, field, value) + faq.save() + + action = "Created" if created else "Updated" + self.stdout.write(f" {action} FAQ: {faq.question[:60]}...") + + def _seed_homepage_sections(self): + for section_data in HOMEPAGE_SECTIONS: + items_data = section_data.pop("items") + section, created = HomepageSection.objects.get_or_create( + order=section_data["order"], + defaults=section_data, + ) + if not created: + for field, value in section_data.items(): + setattr(section, field, value) + section.is_active = True + section.save() + + action = "Created" if created else "Updated" + self.stdout.write(f" {action} homepage section: {section}") + + seed_titles = [item_data["title"] for item_data in items_data if item_data.get("title")] + if seed_titles: + section.items.exclude(title__in=seed_titles).delete() + else: + section.items.all().delete() + + for item_data in items_data: + image_path = item_data.pop("image_path", None) + item, item_created = HomepageSectionItem.objects.get_or_create( + section=section, + title=item_data["title"], + defaults=item_data, + ) + if not item_created: + for field, value in item_data.items(): + setattr(item, field, value) + item.save() + if image_path: + self._attach_item_image(item, image_path) + + def _attach_item_image(self, item, relative_path): + path = Path(settings.BASE_DIR) / "static" / "images" / "tecvico" / relative_path + if not path.exists(): + self.stdout.write(self.style.WARNING(f" Missing image: {path}")) + return + if item.image and item.image.name.endswith(path.name): + return + with path.open("rb") as handle: + item.image.save(path.name, File(handle), save=True) + + def _seed_downloads(self): + for item_data in DOWNLOAD_ITEMS: + item, created = DownloadItem.objects.get_or_create( + name=item_data["name"], + platform=item_data["platform"], + defaults=item_data, + ) + if not created: + for field, value in item_data.items(): + setattr(item, field, value) + item.save() + + action = "Created" if created else "Updated" + self.stdout.write(f" {action} download: {item}") + + def _seed_hero(self): + data = { + "badge": "Innovation, Advancement, Competition", + "title": "Advanced Solutions", + "title_highlight": "for Your Business Development", + "subtitle": "Welcome to a new era of commerce with us", + "description": ( + "At Tecvico, we're a dynamic team challenging business norms. Our creativity and " + "expertise converge to provide innovative solutions, transforming enterprises." + ), + "primary_cta_text": "Get started for free", + "primary_cta_url": "/products/", + "secondary_cta_text": "About Tecvico", + "secondary_cta_url": "/about/", + "image_alt": "Tecvico showcase", + } + hero = HeroSection.objects.first() + if hero is None: + HeroSection.objects.create(**data) + self.stdout.write(" Created hero section") + else: + for field, value in data.items(): + if field == "image": + continue + setattr(hero, field, value) + hero.save() + self.stdout.write(" Updated hero section") + + def _seed_site_contact(self): + contact, created = SiteContact.objects.get_or_create( + pk=1, + defaults={ + "support_email": "pr@tecvico.com", + "discord_url": "https://discord.gg/9XxA6pV9hb", + "email_card_description": "For direct software support:", + "discord_card_description": "Join for community support and announcements.", + "office_address": ( + "BC Cancer Research Center\n" + "675 West 10th Ave, Office 6-112\n" + "Vancouver, BC, V5Z 1L3\n" + "Canada" + ), + }, + ) + if not created: + updates = { + "support_email": "pr@tecvico.com", + "discord_url": "https://discord.gg/9XxA6pV9hb", + "email_card_description": "For direct software support:", + "discord_card_description": "Join for community support and announcements.", + "office_address": ( + "BC Cancer Research Center\n" + "675 West 10th Ave, Office 6-112\n" + "Vancouver, BC, V5Z 1L3\n" + "Canada" + ), + } + for field, value in updates.items(): + setattr(contact, field, value) + contact.save() + action = "Created" if created else "Updated" + self.stdout.write(f" {action} site contact") + + def _seed_site_branding(self): + branding = SiteBranding.load() + if branding.icon: + branding.icon.delete(save=False) + branding.icon = None + branding.icon_alt = "Tecvico" + branding.navbar_icon_size = 40 + branding.footer_icon_size = 48 + branding.show_border = False + branding.save() + self.stdout.write(" Updated site branding") diff --git a/apps/core/migrations/0001_site_branding.py b/apps/core/migrations/0001_site_branding.py new file mode 100644 index 0000000..04b97d1 --- /dev/null +++ b/apps/core/migrations/0001_site_branding.py @@ -0,0 +1,32 @@ +# Generated by Django 5.0.2 on 2026-05-25 09:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='SiteBranding', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('icon', models.ImageField(blank=True, help_text='Logo shown in the site header and footer. Leave empty to use the default static icon.', null=True, upload_to='branding/')), + ('icon_alt', models.CharField(blank=True, help_text='Alt text for the brand icon (decorative icons can stay empty).', max_length=200)), + ('navbar_icon_size', models.PositiveSmallIntegerField(default=26, help_text='Width and height in pixels for the header icon.')), + ('footer_icon_size', models.PositiveSmallIntegerField(default=34, help_text='Width and height in pixels for the footer icon.')), + ('show_border', models.BooleanField(default=False, help_text='Draw a border around the brand icon.')), + ('border_color', models.CharField(default='#c4b5fd', help_text='Border color as hex (e.g. #c4b5fd).', max_length=7)), + ('border_width', models.PositiveSmallIntegerField(default=1, help_text='Border width in pixels.')), + ('object_fit', models.CharField(choices=[('cover', 'Cover (fill square, may crop)'), ('contain', 'Contain (fit inside square)')], default='cover', max_length=10)), + ], + options={ + 'verbose_name': 'Site Branding', + 'verbose_name_plural': 'Site Branding', + }, + ), + ] diff --git a/apps/core/migrations/0002_site_contact.py b/apps/core/migrations/0002_site_contact.py new file mode 100644 index 0000000..2ce9405 --- /dev/null +++ b/apps/core/migrations/0002_site_contact.py @@ -0,0 +1,52 @@ +# Generated by Django 5.0.2 on 2026-05-25 11:11 + +from django.db import migrations, models + + +def seed_site_contact(apps, schema_editor): + SiteContact = apps.get_model("core", "SiteContact") + SiteContact.objects.get_or_create( + pk=1, + defaults={ + "support_email": "support@radiuma.com", + "discord_url": "https://discord.gg/9XxA6pV9hb", + "office_address": ( + "BC Cancer Research Center\n" + "675 West 10th Ave, Office 6-112\n" + "Vancouver, BC, V5Z 1L3\n" + "Canada" + ), + "email_card_description": "For direct software support:", + "discord_card_description": "Join for community support and announcements.", + }, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_site_branding'), + ] + + operations = [ + migrations.CreateModel( + name='SiteContact', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('support_email', models.EmailField(blank=True, help_text='Shown in the footer and on the contact page. Hidden when empty.', max_length=254)), + ('discord_url', models.URLField(blank=True, help_text='Discord invite link. Hidden when empty.')), + ('discord_label', models.CharField(blank=True, help_text='Button label (e.g. Join Discord). Defaults to “Join Discord” when empty.', max_length=100)), + ('office_address', models.TextField(blank=True, help_text='Physical address; one line per row. Hidden when empty.')), + ('email_card_title', models.CharField(blank=True, help_text='Contact page email card heading. Defaults to “Email Support”.', max_length=200)), + ('email_card_description', models.CharField(blank=True, help_text='Short text under the email card heading on the contact page.', max_length=500)), + ('discord_card_title', models.CharField(blank=True, help_text='Contact page Discord card heading. Defaults to “Discord Community”.', max_length=200)), + ('discord_card_description', models.CharField(blank=True, help_text='Short text under the Discord card heading on the contact page.', max_length=500)), + ('office_card_title', models.CharField(blank=True, help_text='Contact page office card heading. Defaults to “Office”.', max_length=200)), + ], + options={ + 'verbose_name': 'Site Contact', + 'verbose_name_plural': 'Site Contact', + }, + ), + migrations.RunPython(seed_site_contact, migrations.RunPython.noop), + ] diff --git a/apps/core/migrations/__init__.py b/apps/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/models.py b/apps/core/models.py new file mode 100644 index 0000000..f278b86 --- /dev/null +++ b/apps/core/models.py @@ -0,0 +1,177 @@ +from django.db import models + + +class SiteBranding(models.Model): + OBJECT_FIT_COVER = "cover" + OBJECT_FIT_CONTAIN = "contain" + OBJECT_FIT_CHOICES = [ + (OBJECT_FIT_COVER, "Cover (fill square, may crop)"), + (OBJECT_FIT_CONTAIN, "Contain (fit inside square)"), + ] + + icon = models.ImageField( + upload_to="branding/", + blank=True, + null=True, + help_text="Logo shown in the site header and footer. Leave empty to use the default static icon.", + ) + icon_alt = models.CharField( + max_length=200, + blank=True, + help_text="Alt text for the brand icon (decorative icons can stay empty).", + ) + navbar_icon_size = models.PositiveSmallIntegerField( + default=26, + help_text="Width and height in pixels for the header icon.", + ) + footer_icon_size = models.PositiveSmallIntegerField( + default=34, + help_text="Width and height in pixels for the footer icon.", + ) + show_border = models.BooleanField( + default=False, + help_text="Draw a border around the brand icon.", + ) + border_color = models.CharField( + max_length=7, + default="#3051ff", + help_text="Border color as hex (e.g. #c4b5fd).", + ) + border_width = models.PositiveSmallIntegerField( + default=1, + help_text="Border width in pixels.", + ) + object_fit = models.CharField( + max_length=10, + choices=OBJECT_FIT_CHOICES, + default=OBJECT_FIT_COVER, + ) + + class Meta: + verbose_name = "Site Branding" + verbose_name_plural = "Site Branding" + + def __str__(self): + return "Site Branding" + + @classmethod + def load(cls): + obj, _ = cls.objects.get_or_create(pk=1) + return obj + + def icon_style(self, size_px): + parts = [ + f"width:{size_px}px", + f"height:{size_px}px", + f"object-fit:{self.object_fit}", + ] + if self.show_border: + parts.append(f"border:{self.border_width}px solid {self.border_color}") + return ";".join(parts) + + @property + def navbar_icon_style(self): + return self.icon_style(self.navbar_icon_size) + + @property + def footer_icon_style(self): + return self.icon_style(self.footer_icon_size) + + +class SiteContact(models.Model): + support_email = models.EmailField( + blank=True, + help_text="Shown in the footer and on the contact page. Hidden when empty.", + ) + discord_url = models.URLField( + blank=True, + help_text="Discord invite link. Hidden when empty.", + ) + discord_label = models.CharField( + max_length=100, + blank=True, + help_text="Button label (e.g. Join Discord). Defaults to “Join Discord” when empty.", + ) + office_address = models.TextField( + blank=True, + help_text="Physical address; one line per row. Hidden when empty.", + ) + email_card_title = models.CharField( + max_length=200, + blank=True, + help_text="Contact page email card heading. Defaults to “Email Support”.", + ) + email_card_description = models.CharField( + max_length=500, + blank=True, + help_text="Short text under the email card heading on the contact page.", + ) + discord_card_title = models.CharField( + max_length=200, + blank=True, + help_text="Contact page Discord card heading. Defaults to “Discord Community”.", + ) + discord_card_description = models.CharField( + max_length=500, + blank=True, + help_text="Short text under the Discord card heading on the contact page.", + ) + office_card_title = models.CharField( + max_length=200, + blank=True, + help_text="Contact page office card heading. Defaults to “Office”.", + ) + + class Meta: + verbose_name = "Site Contact" + verbose_name_plural = "Site Contact" + + def __str__(self): + return "Site Contact" + + @classmethod + def load(cls): + obj, _ = cls.objects.get_or_create(pk=1) + return obj + + @property + def discord_label_display(self): + return self.discord_label.strip() or "Join Discord" + + @property + def office_address_lines(self): + if not self.office_address.strip(): + return [] + return [line.strip() for line in self.office_address.splitlines() if line.strip()] + + @property + def has_support_email(self): + return bool(self.support_email) + + @property + def has_discord(self): + return bool(self.discord_url) + + @property + def has_office_address(self): + return bool(self.office_address_lines) + + @property + def has_footer_contact(self): + return self.has_support_email or self.has_office_address + + @property + def has_contact_sidebar(self): + return self.has_support_email or self.has_discord or self.has_office_address + + @property + def email_card_title_display(self): + return self.email_card_title.strip() or "Email Support" + + @property + def discord_card_title_display(self): + return self.discord_card_title.strip() or "Discord Community" + + @property + def office_card_title_display(self): + return self.office_card_title.strip() or "Office" diff --git a/apps/core/tests/__init__.py b/apps/core/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/core/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/core/tests/test_site_branding.py b/apps/core/tests/test_site_branding.py new file mode 100644 index 0000000..b2eed72 --- /dev/null +++ b/apps/core/tests/test_site_branding.py @@ -0,0 +1,35 @@ +from django.test import RequestFactory, TestCase + +from apps.core.context_processors import site_branding +from apps.core.models import SiteBranding + + +class SiteBrandingModelTests(TestCase): + def test_load_creates_singleton(self): + branding = SiteBranding.load() + self.assertEqual(branding.pk, 1) + self.assertEqual(SiteBranding.objects.count(), 1) + + def test_icon_style_includes_border_when_enabled(self): + branding = SiteBranding.load() + branding.show_border = True + branding.border_color = "#ffffff" + branding.border_width = 2 + branding.navbar_icon_size = 30 + style = branding.navbar_icon_style + self.assertIn("width:30px", style) + self.assertIn("border:2px solid #ffffff", style) + + def test_icon_style_omits_border_when_disabled(self): + branding = SiteBranding.load() + branding.show_border = False + self.assertNotIn("border:", branding.navbar_icon_style) + + +class SiteBrandingContextProcessorTests(TestCase): + def test_site_branding_in_context(self): + SiteBranding.load() + request = RequestFactory().get("/") + ctx = site_branding(request) + self.assertIn("site_branding", ctx) + self.assertIsInstance(ctx["site_branding"], SiteBranding) diff --git a/apps/core/tests/test_site_contact.py b/apps/core/tests/test_site_contact.py new file mode 100644 index 0000000..80e0323 --- /dev/null +++ b/apps/core/tests/test_site_contact.py @@ -0,0 +1,55 @@ +from django.test import Client, RequestFactory, TestCase + +from apps.core.context_processors import site_contact +from apps.core.models import SiteContact + + +class SiteContactModelTests(TestCase): + def test_office_address_lines_skips_blank_lines(self): + contact = SiteContact.load() + contact.office_address = "Line one\n\nLine two" + self.assertEqual(contact.office_address_lines, ["Line one", "Line two"]) + + def test_has_footer_contact_requires_email_or_office(self): + contact = SiteContact.load() + contact.support_email = "" + contact.office_address = "" + contact.discord_url = "https://discord.gg/example" + self.assertFalse(contact.has_footer_contact) + self.assertTrue(contact.has_discord) + + def test_discord_label_default(self): + contact = SiteContact.load() + contact.discord_label = "" + self.assertEqual(contact.discord_label_display, "Join Discord") + + +class SiteContactContextProcessorTests(TestCase): + def test_site_contact_in_context(self): + SiteContact.load() + request = RequestFactory().get("/") + ctx = site_contact(request) + self.assertIn("site_contact", ctx) + self.assertIsInstance(ctx["site_contact"], SiteContact) + + +class SiteContactTemplateTests(TestCase): + def test_footer_hides_email_when_empty(self): + contact = SiteContact.load() + contact.support_email = "" + contact.discord_url = "" + contact.office_address = "" + contact.save() + response = Client().get("/") + self.assertNotContains(response, "mailto:") + self.assertNotContains(response, "discord.gg") + + def test_contact_page_hides_sidebar_when_empty(self): + contact = SiteContact.load() + contact.support_email = "" + contact.discord_url = "" + contact.office_address = "" + contact.save() + response = Client().get("/contact/") + self.assertNotContains(response, "contact-sidebar") + self.assertContains(response, "contact-layout--full") diff --git a/apps/core/utils.py b/apps/core/utils.py new file mode 100644 index 0000000..e237da2 --- /dev/null +++ b/apps/core/utils.py @@ -0,0 +1,34 @@ +import markdown as _md +from django.utils.html import escape, mark_safe + +FORMAT_PLAIN = "plain" +FORMAT_MARKDOWN = "markdown" +FORMAT_HTML = "html" + +CONTENT_FORMAT_CHOICES = [ + (FORMAT_PLAIN, "Plain Text"), + (FORMAT_MARKDOWN, "Markdown"), + (FORMAT_HTML, "HTML"), +] + +_MD_EXTENSIONS = ["extra", "nl2br", "sane_lists"] + + +def render_content(text: str, fmt: str) -> str: + if not text: + return mark_safe("") + + if fmt == FORMAT_HTML: + return mark_safe(text) + + if fmt == FORMAT_MARKDOWN: + return mark_safe(_md.markdown(text, extensions=_MD_EXTENSIONS)) + + paragraphs = text.split("\n\n") + parts = [] + for para in paragraphs: + para = para.strip() + if para: + lines = escape(para).split("\n") + parts.append("

" + "
".join(lines) + "

") + return mark_safe("".join(parts) if parts else f"

{escape(text)}

") diff --git a/apps/core/video.py b/apps/core/video.py new file mode 100644 index 0000000..ea0305d --- /dev/null +++ b/apps/core/video.py @@ -0,0 +1,172 @@ +import re + +from django.core.exceptions import ValidationError +from django.db import models + +from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content + +VIDEO_SOURCE_YOUTUBE = "youtube" +VIDEO_SOURCE_UPLOAD = "upload" + +VIDEO_SOURCE_CHOICES = [ + (VIDEO_SOURCE_YOUTUBE, "YouTube / external link"), + (VIDEO_SOURCE_UPLOAD, "Uploaded file"), +] + +VIDEO_SIZE_SMALL = "sm" +VIDEO_SIZE_MEDIUM = "md" +VIDEO_SIZE_LARGE = "lg" +VIDEO_SIZE_FULL = "full" + +VIDEO_SIZE_CHOICES = [ + (VIDEO_SIZE_SMALL, "Small (480px)"), + (VIDEO_SIZE_MEDIUM, "Medium (720px)"), + (VIDEO_SIZE_LARGE, "Large (960px)"), + (VIDEO_SIZE_FULL, "Full width"), +] + +VIDEO_ASPECT_16_9 = "16/9" +VIDEO_ASPECT_4_3 = "4/3" +VIDEO_ASPECT_1_1 = "1/1" + +VIDEO_ASPECT_CHOICES = [ + (VIDEO_ASPECT_16_9, "16:9 (widescreen)"), + (VIDEO_ASPECT_4_3, "4:3 (standard)"), + (VIDEO_ASPECT_1_1, "1:1 (square)"), +] + +VIDEO_UPLOAD_EXTENSIONS = frozenset({".mp4", ".webm", ".ogg", ".mov"}) + +VIDEO_ADMIN_FIELDS = ( + "video_source", + "video_url", + "video_file", + "video_poster", + "video_size", + "video_aspect_ratio", + "video_styled_background", +) + +VIDEO_ADMIN_FIELDSET = ( + "Video", + { + "fields": VIDEO_ADMIN_FIELDS + ("video_preview",), + "description": ( + "Choose YouTube link or uploaded file. Set display size and aspect ratio " + "to control how the preview appears on the site." + ), + }, +) + +YOUTUBE_ID_PATTERNS = ( + re.compile(r"(?:youtube\.com/watch\?(?:[^&]+&)*v=|youtube\.com/embed/|youtube\.com/shorts/|youtu\.be/)([\w-]{11})"), + re.compile(r"^([\w-]{11})$"), +) + + +def parse_youtube_video_id(url): + if not url: + return "" + value = url.strip() + for pattern in YOUTUBE_ID_PATTERNS: + match = pattern.search(value) + if match: + return match.group(1) + return "" + + +def youtube_embed_url(url): + video_id = parse_youtube_video_id(url) + if not video_id: + return "" + return f"https://www.youtube-nocookie.com/embed/{video_id}" + + +class VideoBlockMixin(models.Model): + video_source = models.CharField( + max_length=20, + choices=VIDEO_SOURCE_CHOICES, + default=VIDEO_SOURCE_YOUTUBE, + blank=True, + ) + video_url = models.CharField( + max_length=500, + blank=True, + help_text="YouTube watch, embed, or youtu.be link.", + ) + video_file = models.FileField( + upload_to="videos/", + blank=True, + help_text="MP4, WebM, OGG, or MOV file.", + ) + video_poster = models.ImageField( + upload_to="videos/posters/", + blank=True, + null=True, + help_text="Optional thumbnail shown before an uploaded video plays.", + ) + video_size = models.CharField( + max_length=10, + choices=VIDEO_SIZE_CHOICES, + default=VIDEO_SIZE_MEDIUM, + ) + video_aspect_ratio = models.CharField( + max_length=10, + choices=VIDEO_ASPECT_CHOICES, + default=VIDEO_ASPECT_16_9, + ) + video_styled_background = models.BooleanField( + default=False, + help_text="Glass panel with ambient glow (similar to the Downloads section).", + ) + description_format = models.CharField( + max_length=20, + choices=CONTENT_FORMAT_CHOICES, + default=FORMAT_PLAIN, + ) + + class Meta: + abstract = True + + @property + def youtube_embed_url(self): + return youtube_embed_url(self.video_url) + + @property + def has_video(self): + if self.video_source == VIDEO_SOURCE_UPLOAD: + return bool(self.video_file) + return bool(self.youtube_embed_url) + + @property + def video_size_class(self): + return f"video-block--{self.video_size or VIDEO_SIZE_MEDIUM}" + + @property + def video_aspect_class(self): + ratio = (self.video_aspect_ratio or VIDEO_ASPECT_16_9).replace("/", "-") + return f"video-block--ratio-{ratio}" + + @property + def rendered_description(self): + description = getattr(self, "description", "") or "" + return render_content(description, self.description_format) + + def clean_video_fields(self, require=False): + if not require and not self.video_url and not self.video_file: + return + if self.video_source == VIDEO_SOURCE_YOUTUBE: + if not self.video_url.strip(): + raise ValidationError({"video_url": "Enter a YouTube link."}) + if not self.youtube_embed_url: + raise ValidationError({"video_url": "Enter a valid YouTube link."}) + elif self.video_source == VIDEO_SOURCE_UPLOAD: + if not self.video_file: + raise ValidationError({"video_file": "Upload a video file."}) + extension = self.video_file.name.rsplit(".", 1)[-1].lower() if self.video_file.name else "" + if f".{extension}" not in VIDEO_UPLOAD_EXTENSIONS: + raise ValidationError( + { + "video_file": "Unsupported format. Use MP4, WebM, OGG, or MOV.", + } + ) diff --git a/apps/pages/__init__.py b/apps/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/pages/admin.py b/apps/pages/admin.py new file mode 100644 index 0000000..47a821c --- /dev/null +++ b/apps/pages/admin.py @@ -0,0 +1,330 @@ +from django.contrib import admin +from django.http import FileResponse, Http404, HttpResponseRedirect +from django.urls import path, reverse +from django.utils.html import format_html + +from apps.core.admin_video import video_admin_preview +from apps.core.video import VIDEO_ADMIN_FIELDSET + +from .models import ( + AboutSection, + AboutSectionItem, + ContactSubmission, + ContactSubmissionAttachment, + CustomPage, + CustomPageSection, + CustomPageSectionItem, + DownloadItem, + FAQEntry, + HeroSection, + HomepageSection, + HomepageSectionItem, + PageVideo, +) + + +@admin.register(HeroSection) +class HeroSectionAdmin(admin.ModelAdmin): + fieldsets = ( + ("Badge", {"fields": ("badge",)}), + ("Title", {"fields": ("title", "title_highlight")}), + ("Text", {"fields": ("subtitle", "description")}), + ("Primary Button", {"fields": ("primary_cta_text", "primary_cta_url")}), + ("Secondary Button", {"fields": ("secondary_cta_text", "secondary_cta_url")}), + ("Image", {"fields": ("image", "image_alt")}), + ) + + def has_add_permission(self, request): + return not HeroSection.objects.exists() + + def has_delete_permission(self, request, obj=None): + return False + + def changelist_view(self, request, extra_context=None): + hero = HeroSection.objects.first() + if hero: + return HttpResponseRedirect( + reverse("admin:pages_herosection_change", args=[hero.pk]) + ) + return super().changelist_view(request, extra_context) + + +class HomepageSectionItemInline(admin.TabularInline): + model = HomepageSectionItem + extra = 1 + fields = ( + "icon", + "title", + "content", + "url", + "image", + "image_alt", + "tags", + "project_status", + "order", + ) + ordering = ("order",) + + +@admin.register(HomepageSection) +class HomepageSectionAdmin(admin.ModelAdmin): + list_display = ("section_type", "title", "badge", "order", "is_active") + list_filter = ("section_type", "is_active") + list_editable = ("order", "is_active") + inlines = [HomepageSectionItemInline] + readonly_fields = ("video_preview",) + fieldsets = ( + (None, {"fields": ("section_type", "badge", "title", "title_highlight", "description_format", "description")}), + ("CTA Link (About Strip)", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}), + VIDEO_ADMIN_FIELDSET, + ("Settings", {"fields": ("order", "is_active")}), + ) + + @admin.display(description="Preview") + def video_preview(self, obj): + return video_admin_preview(obj) + + +class AboutSectionItemInline(admin.TabularInline): + model = AboutSectionItem + extra = 1 + fields = ("icon", "title", "content", "url", "image", "image_alt", "badge", "is_featured", "order") + ordering = ("order",) + + +@admin.register(AboutSection) +class AboutSectionAdmin(admin.ModelAdmin): + list_display = ("section_type", "badge", "title", "order", "is_active") + list_filter = ("section_type", "is_active") + list_editable = ("order", "is_active") + inlines = [AboutSectionItemInline] + readonly_fields = ("video_preview",) + fieldsets = ( + (None, {"fields": ("section_type", "badge", "title", "subtitle")}), + ("Content", {"fields": ("content_format", "content")}), + VIDEO_ADMIN_FIELDSET, + ("Settings", {"fields": ("order", "is_active")}), + ) + + @admin.display(description="Preview") + def video_preview(self, obj): + return video_admin_preview(obj) + + +class ContactSubmissionAttachmentInline(admin.TabularInline): + model = ContactSubmissionAttachment + extra = 0 + can_delete = False + fields = ("original_filename", "attachment_link", "uploaded_at") + readonly_fields = ("original_filename", "attachment_link", "uploaded_at") + + @admin.display(description="File") + def attachment_link(self, obj): + if not obj.file: + return "—" + url = reverse("admin:pages_contactattachment_download", args=[obj.pk]) + return format_html('Download', url) + + +@admin.register(ContactSubmission) +class ContactSubmissionAdmin(admin.ModelAdmin): + list_display = ("name", "title", "email", "submitted_at", "is_read") + list_filter = ("is_read", "submitted_at") + search_fields = ("name", "title", "description", "email") + list_editable = ("is_read",) + readonly_fields = ("name", "title", "description", "email", "submitted_at") + inlines = [ContactSubmissionAttachmentInline] + fieldsets = ( + (None, {"fields": ("name", "title", "email", "description")}), + ("Meta", {"fields": ("submitted_at", "is_read")}), + ) + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + "attachment//download/", + self.admin_site.admin_view(self.download_attachment), + name="pages_contactattachment_download", + ), + ] + return custom_urls + urls + + def download_attachment(self, request, attachment_id): + try: + attachment = ContactSubmissionAttachment.objects.get(pk=attachment_id) + except ContactSubmissionAttachment.DoesNotExist as exc: + raise Http404 from exc + if not attachment.file: + raise Http404 + return FileResponse( + attachment.file.open("rb"), + as_attachment=True, + filename=attachment.original_filename, + ) + + +@admin.register(FAQEntry) +class FAQEntryAdmin(admin.ModelAdmin): + list_display = ("question", "order", "is_active", "created_at") + list_filter = ("is_active",) + search_fields = ("question", "answer") + list_editable = ("order", "is_active") + fieldsets = ( + (None, {"fields": ("question",)}), + ("Answer", {"fields": ("answer_format", "answer")}), + ("Settings", {"fields": ("order", "is_active")}), + ) + + +class CustomPageSectionItemInline(admin.TabularInline): + model = CustomPageSectionItem + extra = 1 + fields = ( + "icon", + "badge", + "title", + "content_format", + "content", + "url", + "image", + "image_alt", + "is_featured", + "order", + ) + ordering = ("order",) + + +class CustomPageSectionInline(admin.StackedInline): + model = CustomPageSection + extra = 1 + fields = ( + "section_type", + "badge", + "title", + "subtitle", + "description_format", + "description", + "content_format", + "content", + "link_text", + "link_url", + "order", + "is_active", + ) + ordering = ("order",) + show_change_link = True + + +class CustomPageSectionItemOnPageInline(admin.TabularInline): + model = CustomPageSectionItem + fk_name = "page" + extra = 1 + verbose_name = "Section item" + verbose_name_plural = "Section items (subsections)" + fields = ( + "section", + "icon", + "badge", + "title", + "content_format", + "content", + "url", + "image", + "order", + ) + ordering = ("section", "order") + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "section": + page_id = request.resolver_match.kwargs.get("object_id") if request.resolver_match else None + if page_id: + kwargs["queryset"] = CustomPageSection.objects.filter(page_id=page_id).order_by("order") + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + +class CustomPageSectionAdmin(admin.ModelAdmin): + list_display = ("page", "section_type", "title", "order", "is_active") + list_filter = ("section_type", "is_active", "page") + list_editable = ("order", "is_active") + inlines = [CustomPageSectionItemInline] + readonly_fields = ("video_preview",) + fieldsets = ( + (None, {"fields": ("page", "section_type", "badge", "title", "subtitle")}), + ("Text", {"fields": ("description_format", "description", "content_format", "content")}), + ("CTA Link", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}), + VIDEO_ADMIN_FIELDSET, + ("Settings", {"fields": ("order", "is_active")}), + ) + + @admin.display(description="Preview") + def video_preview(self, obj): + return video_admin_preview(obj) + + def save_formset(self, request, form, formset, change): + instances = formset.save(commit=False) + for instance in instances: + if isinstance(instance, CustomPageSectionItem): + instance.page = form.instance.page + instance.save() + for obj in formset.deleted_objects: + obj.delete() + formset.save_m2m() + + +@admin.register(CustomPage) +class CustomPageAdmin(admin.ModelAdmin): + list_display = ("title", "slug", "show_in_nav", "menu_order", "is_published", "updated_at") + list_filter = ("show_in_nav", "is_published") + search_fields = ("title", "slug", "menu_label") + list_editable = ("show_in_nav", "menu_order", "is_published") + prepopulated_fields = {"slug": ("title",)} + inlines = [CustomPageSectionInline, CustomPageSectionItemOnPageInline] + fieldsets = ( + (None, {"fields": ("title", "slug", "menu_label", "meta_description")}), + ("Navigation", {"fields": ("show_in_nav", "menu_order")}), + ("Publishing", {"fields": ("is_published",)}), + ) + + def save_formset(self, request, form, formset, change): + instances = formset.save(commit=False) + for instance in instances: + if isinstance(instance, CustomPageSectionItem): + instance.page = form.instance + instance.save() + for obj in formset.deleted_objects: + obj.delete() + formset.save_m2m() + + +admin.site.register(CustomPageSection, CustomPageSectionAdmin) + + +@admin.register(PageVideo) +class PageVideoAdmin(admin.ModelAdmin): + list_display = ("page", "title", "video_source", "video_size", "order", "is_active") + list_filter = ("page", "video_source", "is_active") + list_editable = ("order", "is_active") + search_fields = ("title", "description", "video_url") + readonly_fields = ("video_preview",) + fieldsets = ( + (None, {"fields": ("page", "badge", "title", "description_format", "description")}), + VIDEO_ADMIN_FIELDSET, + ("Settings", {"fields": ("order", "is_active")}), + ) + + @admin.display(description="Preview") + def video_preview(self, obj): + return video_admin_preview(obj) + + +@admin.register(DownloadItem) +class DownloadItemAdmin(admin.ModelAdmin): + list_display = ("name", "platform", "version", "is_active", "order") + list_filter = ("platform", "is_active") + search_fields = ("name", "description") + list_editable = ("order", "is_active") + fieldsets = ( + (None, {"fields": ("name", "platform", "version", "download_url", "description")}), + ("Settings", {"fields": ("order", "is_active")}), + ) diff --git a/apps/pages/apps.py b/apps/pages/apps.py new file mode 100644 index 0000000..7440388 --- /dev/null +++ b/apps/pages/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PagesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.pages" + verbose_name = "Pages" diff --git a/apps/pages/contact_uploads.py b/apps/pages/contact_uploads.py new file mode 100644 index 0000000..70118b4 --- /dev/null +++ b/apps/pages/contact_uploads.py @@ -0,0 +1,188 @@ +import os +import uuid +import zipfile +from pathlib import PurePosixPath + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.files.storage import FileSystemStorage +from django.core.files.uploadedfile import UploadedFile +from django.utils.text import get_valid_filename +from PIL import Image + +ALLOWED_EXTENSIONS = frozenset( + { + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".bmp", + ".zip", + ".log", + ".txt", + } +) + +IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}) +TEXT_EXTENSIONS = frozenset({".log", ".txt"}) +ARCHIVE_EXTENSIONS = frozenset({".zip"}) + +MAX_FILE_SIZE = getattr(settings, "CONTACT_ATTACHMENT_MAX_SIZE", 10 * 1024 * 1024) +MAX_ATTACHMENTS = getattr(settings, "CONTACT_ATTACHMENT_MAX_COUNT", 3) +MAX_ZIP_ENTRIES = 100 +MAX_ZIP_UNCOMPRESSED_SIZE = 50 * 1024 * 1024 + + +class ContactAttachmentStorage(FileSystemStorage): + def __init__(self): + super().__init__(location=settings.CONTACT_UPLOAD_ROOT) + + +contact_attachment_storage = ContactAttachmentStorage() + + +def contact_attachment_upload_to(instance, _filename): + ext = os.path.splitext(instance.original_filename)[1].lower() + if ext not in ALLOWED_EXTENSIONS: + ext = ".bin" + subdir = str(instance.submission_id) if instance.submission_id else "pending" + return f"{subdir}/{uuid.uuid4().hex}{ext}" + + +def _extension(filename): + return os.path.splitext(filename)[1].lower() + + +def _read_header(uploaded_file, size=32): + uploaded_file.seek(0) + header = uploaded_file.read(size) + uploaded_file.seek(0) + return header + + +def _validate_magic(header, ext): + if ext == ".png" and not header.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValidationError("File content does not match its type.") + if ext in (".jpg", ".jpeg") and not header.startswith(b"\xff\xd8\xff"): + raise ValidationError("File content does not match its type.") + if ext == ".gif" and not ( + header.startswith(b"GIF87a") or header.startswith(b"GIF89a") + ): + raise ValidationError("File content does not match its type.") + if ext == ".webp" and not ( + len(header) >= 12 and header[0:4] == b"RIFF" and header[8:12] == b"WEBP" + ): + raise ValidationError("File content does not match its type.") + if ext == ".bmp" and not header.startswith(b"BM"): + raise ValidationError("File content does not match its type.") + if ext == ".zip" and not ( + header.startswith(b"PK\x03\x04") + or header.startswith(b"PK\x05\x06") + or header.startswith(b"PK\x07\x08") + ): + raise ValidationError("File content does not match its type.") + + +def _validate_image(uploaded_file): + try: + uploaded_file.seek(0) + with Image.open(uploaded_file) as img: + img.verify() + uploaded_file.seek(0) + with Image.open(uploaded_file) as img: + img.load() + except Exception as exc: + raise ValidationError("Invalid or corrupted image file.") from exc + finally: + uploaded_file.seek(0) + + +def _validate_text(uploaded_file): + uploaded_file.seek(0) + data = uploaded_file.read() + uploaded_file.seek(0) + if b"\x00" in data: + raise ValidationError("Text files must not contain binary data.") + try: + data.decode("utf-8") + except UnicodeDecodeError: + try: + data.decode("latin-1") + except UnicodeDecodeError as exc: + raise ValidationError("Text file is not valid UTF-8 or Latin-1.") from exc + + +def _validate_zip(uploaded_file): + uploaded_file.seek(0) + if not zipfile.is_zipfile(uploaded_file): + raise ValidationError("Invalid ZIP archive.") + uploaded_file.seek(0) + + total_uncompressed = 0 + entry_count = 0 + + with zipfile.ZipFile(uploaded_file, "r") as archive: + for info in archive.infolist(): + entry_count += 1 + if entry_count > MAX_ZIP_ENTRIES: + raise ValidationError("ZIP archive contains too many files.") + + name = info.filename + if name.startswith("/") or ".." in PurePosixPath(name).parts: + raise ValidationError("ZIP archive contains unsafe paths.") + + if info.flag_bits & 0x1: + raise ValidationError("Encrypted ZIP archives are not allowed.") + + total_uncompressed += info.file_size + if total_uncompressed > MAX_ZIP_UNCOMPRESSED_SIZE: + raise ValidationError("ZIP archive uncompressed size is too large.") + + uploaded_file.seek(0) + + +def validate_contact_attachment(uploaded_file): + if uploaded_file.size > MAX_FILE_SIZE: + max_mb = MAX_FILE_SIZE // (1024 * 1024) + raise ValidationError(f"File exceeds the maximum size of {max_mb} MB.") + + name = get_valid_filename(os.path.basename(uploaded_file.name)) + if not name: + raise ValidationError("Invalid file name.") + + ext = _extension(name) + if ext not in ALLOWED_EXTENSIONS: + raise ValidationError( + "File type not allowed. Permitted: images (PNG, JPG, GIF, WebP, BMP), " + "ZIP, LOG, or TXT." + ) + + header = _read_header(uploaded_file) + if ext in IMAGE_EXTENSIONS or ext in ARCHIVE_EXTENSIONS: + _validate_magic(header, ext) + + if ext in IMAGE_EXTENSIONS: + _validate_image(uploaded_file) + elif ext in TEXT_EXTENSIONS: + _validate_text(uploaded_file) + elif ext in ARCHIVE_EXTENSIONS: + _validate_zip(uploaded_file) + + return name + + +def validate_contact_attachments(files): + if not files: + return [] + + if len(files) > MAX_ATTACHMENTS: + raise ValidationError(f"You can attach at most {MAX_ATTACHMENTS} files.") + + validated = [] + for uploaded_file in files: + if not isinstance(uploaded_file, UploadedFile): + raise ValidationError("Invalid upload.") + original_name = validate_contact_attachment(uploaded_file) + validated.append((uploaded_file, original_name)) + return validated diff --git a/apps/pages/forms.py b/apps/pages/forms.py new file mode 100644 index 0000000..03bb005 --- /dev/null +++ b/apps/pages/forms.py @@ -0,0 +1,41 @@ +from django import forms +from django.core.exceptions import ValidationError + +from .contact_uploads import validate_contact_attachments +from .models import ContactSubmission + + +class ContactForm(forms.ModelForm): + attachments = forms.Field(required=False) + + class Meta: + model = ContactSubmission + fields = ["name", "title", "description", "email"] + widgets = { + "name": forms.TextInput( + attrs={"placeholder": "Your full name", "autocomplete": "name"} + ), + "title": forms.TextInput(attrs={"placeholder": "Subject / topic"}), + "description": forms.Textarea( + attrs={"placeholder": "Write your message here…", "rows": 5} + ), + "email": forms.EmailInput( + attrs={ + "placeholder": "your@email.com (optional)", + "autocomplete": "email", + } + ), + } + + def __init__(self, *args, file_list=None, **kwargs): + self.file_list = file_list + super().__init__(*args, **kwargs) + + def clean(self): + cleaned_data = super().clean() + try: + cleaned_data["attachments"] = validate_contact_attachments(self.file_list) + except ValidationError as exc: + self.add_error("attachments", exc) + cleaned_data["attachments"] = [] + return cleaned_data diff --git a/apps/pages/migrations/0001_initial.py b/apps/pages/migrations/0001_initial.py new file mode 100644 index 0000000..940b57b --- /dev/null +++ b/apps/pages/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# Generated by Django 5.2.13 on 2026-04-27 09:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='DownloadItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('platform', models.CharField(choices=[('windows', 'Windows'), ('macos', 'macOS'), ('linux', 'Linux')], max_length=20)), + ('version', models.CharField(max_length=50)), + ('download_url', models.URLField()), + ('description', models.TextField(blank=True)), + ('is_active', models.BooleanField(default=True)), + ('order', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'Download Item', + 'verbose_name_plural': 'Download Items', + 'ordering': ['order', 'platform'], + }, + ), + migrations.CreateModel( + name='FAQEntry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('question', models.CharField(max_length=500)), + ('answer', models.TextField()), + ('order', models.PositiveIntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'FAQ Entry', + 'verbose_name_plural': 'FAQ Entries', + 'ordering': ['order'], + }, + ), + ] diff --git a/apps/pages/migrations/0002_contactsubmission.py b/apps/pages/migrations/0002_contactsubmission.py new file mode 100644 index 0000000..45f939b --- /dev/null +++ b/apps/pages/migrations/0002_contactsubmission.py @@ -0,0 +1,30 @@ +# Generated by Django 5.0.2 on 2026-05-04 09:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ContactSubmission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('title', models.CharField(max_length=300)), + ('description', models.TextField()), + ('email', models.EmailField(blank=True, max_length=254)), + ('submitted_at', models.DateTimeField(auto_now_add=True)), + ('is_read', models.BooleanField(default=False)), + ], + options={ + 'verbose_name': 'Contact Submission', + 'verbose_name_plural': 'Contact Submissions', + 'ordering': ['-submitted_at'], + }, + ), + ] diff --git a/apps/pages/migrations/0003_faqentry_answer_format.py b/apps/pages/migrations/0003_faqentry_answer_format.py new file mode 100644 index 0000000..02d1c40 --- /dev/null +++ b/apps/pages/migrations/0003_faqentry_answer_format.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2026-05-14 05:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0002_contactsubmission'), + ] + + operations = [ + migrations.AddField( + model_name='faqentry', + name='answer_format', + field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20), + ), + ] diff --git a/apps/pages/migrations/0004_aboutsection_aboutsectionitem.py b/apps/pages/migrations/0004_aboutsection_aboutsectionitem.py new file mode 100644 index 0000000..591b009 --- /dev/null +++ b/apps/pages/migrations/0004_aboutsection_aboutsectionitem.py @@ -0,0 +1,103 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pages", "0003_faqentry_answer_format"), + ] + + operations = [ + migrations.CreateModel( + name="AboutSection", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "section_type", + models.CharField( + choices=[ + ("hero", "Hero"), + ("intro", "Intro Card"), + ("grid", "Grid Cards"), + ("history", "History Block"), + ("custom", "Custom Content"), + ], + default="custom", + max_length=30, + ), + ), + ("badge", models.CharField(blank=True, max_length=100)), + ("title", models.CharField(blank=True, max_length=300)), + ( + "subtitle", + models.CharField( + blank=True, + help_text="Used as subtitle in Hero and year in History.", + max_length=500, + ), + ), + ("content", models.TextField(blank=True)), + ( + "content_format", + models.CharField( + choices=[ + ("plain", "Plain Text"), + ("markdown", "Markdown"), + ("html", "HTML"), + ], + default="markdown", + max_length=20, + ), + ), + ("order", models.PositiveIntegerField(default=0)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "About Section", + "verbose_name_plural": "About Sections", + "ordering": ["order"], + }, + ), + migrations.CreateModel( + name="AboutSectionItem", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "section", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="items", + to="pages.aboutsection", + ), + ), + ("badge", models.CharField(blank=True, max_length=100)), + ("title", models.CharField(blank=True, max_length=300)), + ( + "content", + models.TextField( + blank=True, + help_text="Description text or link label for History links.", + ), + ), + ("url", models.URLField(blank=True, help_text="Used for History block links.")), + ("image", models.ImageField(blank=True, null=True, upload_to="about/")), + ("image_alt", models.CharField(blank=True, max_length=200)), + ( + "is_featured", + models.BooleanField( + default=False, + help_text="Mark as featured item (e.g. large screenshot).", + ), + ), + ("order", models.PositiveIntegerField(default=0)), + ], + options={ + "verbose_name": "About Section Item", + "verbose_name_plural": "About Section Items", + "ordering": ["order"], + }, + ), + ] diff --git a/apps/pages/migrations/0005_homepage_sections.py b/apps/pages/migrations/0005_homepage_sections.py new file mode 100644 index 0000000..e1c7e8f --- /dev/null +++ b/apps/pages/migrations/0005_homepage_sections.py @@ -0,0 +1,49 @@ +# Generated by Django 5.0.2 on 2026-05-17 14:39 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0004_aboutsection_aboutsectionitem'), + ] + + operations = [ + migrations.CreateModel( + name='HomepageSection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('section_type', models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip')], max_length=30, unique=True)), + ('badge', models.CharField(blank=True, max_length=100)), + ('title', models.CharField(blank=True, max_length=300)), + ('description', models.TextField(blank=True)), + ('link_text', models.CharField(blank=True, help_text='CTA button label (About Strip).', max_length=100)), + ('link_url', models.CharField(blank=True, help_text='CTA button URL (About Strip).', max_length=300)), + ('order', models.PositiveIntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'verbose_name': 'Homepage Section', + 'verbose_name_plural': 'Homepage Sections', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='HomepageSectionItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('icon', models.CharField(blank=True, help_text='Emoji or short symbol (e.g. ⚗️).', max_length=20)), + ('title', models.CharField(blank=True, max_length=300)), + ('content', models.TextField(blank=True)), + ('order', models.PositiveIntegerField(default=0)), + ('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='pages.homepagesection')), + ], + options={ + 'verbose_name': 'Homepage Section Item', + 'verbose_name_plural': 'Homepage Section Items', + 'ordering': ['order'], + }, + ), + ] diff --git a/apps/pages/migrations/0006_homepagesectionitem_image_supporters.py b/apps/pages/migrations/0006_homepagesectionitem_image_supporters.py new file mode 100644 index 0000000..0b27892 --- /dev/null +++ b/apps/pages/migrations/0006_homepagesectionitem_image_supporters.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.2 on 2026-05-17 14:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0005_homepage_sections'), + ] + + operations = [ + migrations.AddField( + model_name='homepagesectionitem', + name='image', + field=models.ImageField(blank=True, help_text='Logo or image (used for Supporters cards).', null=True, upload_to='homepage/items/'), + ), + migrations.AlterField( + model_name='homepagesection', + name='section_type', + field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters')], max_length=30, unique=True), + ), + ] diff --git a/apps/pages/migrations/0007_hero_section.py b/apps/pages/migrations/0007_hero_section.py new file mode 100644 index 0000000..9b7e321 --- /dev/null +++ b/apps/pages/migrations/0007_hero_section.py @@ -0,0 +1,34 @@ +# Generated by Django 5.0.2 on 2026-05-20 09:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0006_homepagesectionitem_image_supporters'), + ] + + operations = [ + migrations.CreateModel( + name='HeroSection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('badge', models.CharField(blank=True, help_text="Small label above the title (e.g. 'Developing since 2021').", max_length=200)), + ('title', models.CharField(blank=True, help_text="Main title line (e.g. 'Radiuma,').", max_length=300)), + ('title_highlight', models.CharField(blank=True, help_text="Second title line shown in gradient colour (e.g. 'A Powerful Workflow Generator').", max_length=300)), + ('subtitle', models.CharField(blank=True, help_text="Short line below the title (e.g. 'for Standardized Radiomics Analysis…').", max_length=500)), + ('description', models.TextField(blank=True, help_text='Longer paragraph below the subtitle.')), + ('primary_cta_text', models.CharField(blank=True, help_text='Primary button label.', max_length=100)), + ('primary_cta_url', models.CharField(blank=True, help_text='Primary button URL (relative or absolute).', max_length=300)), + ('secondary_cta_text', models.CharField(blank=True, help_text='Secondary (ghost) button label.', max_length=100)), + ('secondary_cta_url', models.CharField(blank=True, help_text='Secondary (ghost) button URL.', max_length=300)), + ('image', models.ImageField(blank=True, help_text='App preview screenshot shown on the right.', null=True, upload_to='hero/')), + ('image_alt', models.CharField(blank=True, help_text='Alt text for the preview image.', max_length=300)), + ], + options={ + 'verbose_name': 'Hero Section', + 'verbose_name_plural': 'Hero Section', + }, + ), + ] diff --git a/apps/pages/migrations/0008_custom_pages.py b/apps/pages/migrations/0008_custom_pages.py new file mode 100644 index 0000000..0e7cf2b --- /dev/null +++ b/apps/pages/migrations/0008_custom_pages.py @@ -0,0 +1,79 @@ +# Generated by Django 5.0.2 on 2026-05-25 12:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0007_hero_section'), + ] + + operations = [ + migrations.CreateModel( + name='CustomPage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('slug', models.SlugField(max_length=200, unique=True)), + ('menu_label', models.CharField(blank=True, help_text='Nav label when shown in menu. Defaults to title.', max_length=100)), + ('meta_description', models.CharField(blank=True, max_length=300)), + ('show_in_nav', models.BooleanField(default=True)), + ('menu_order', models.PositiveIntegerField(default=0)), + ('is_published', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Custom Page', + 'verbose_name_plural': 'Custom Pages', + 'ordering': ['menu_order', 'title'], + }, + ), + migrations.CreateModel( + name='CustomPageSection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('section_type', models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('features', 'Features Grid'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products Grid'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('faq', 'FAQ Accordion')], default='custom', max_length=30)), + ('badge', models.CharField(blank=True, max_length=100)), + ('title', models.CharField(blank=True, max_length=300)), + ('subtitle', models.CharField(blank=True, max_length=500)), + ('description', models.TextField(blank=True, help_text='Short intro text (homepage-style sections).')), + ('content', models.TextField(blank=True)), + ('content_format', models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='markdown', max_length=20)), + ('link_text', models.CharField(blank=True, max_length=100)), + ('link_url', models.CharField(blank=True, max_length=300)), + ('order', models.PositiveIntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='pages.custompage')), + ], + options={ + 'verbose_name': 'Custom Page Section', + 'verbose_name_plural': 'Custom Page Sections', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='CustomPageSectionItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('icon', models.CharField(blank=True, max_length=20)), + ('badge', models.CharField(blank=True, max_length=100)), + ('title', models.CharField(blank=True, max_length=300)), + ('content', models.TextField(blank=True)), + ('content_format', models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20)), + ('url', models.URLField(blank=True)), + ('image', models.ImageField(blank=True, null=True, upload_to='pages/custom/')), + ('image_alt', models.CharField(blank=True, max_length=200)), + ('is_featured', models.BooleanField(default=False)), + ('order', models.PositiveIntegerField(default=0)), + ('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='pages.custompagesection')), + ], + options={ + 'verbose_name': 'Custom Page Section Item', + 'verbose_name_plural': 'Custom Page Section Items', + 'ordering': ['order'], + }, + ), + ] diff --git a/apps/pages/migrations/0009_alter_homepagesection_section_type.py b/apps/pages/migrations/0009_alter_homepagesection_section_type.py new file mode 100644 index 0000000..3eb8b52 --- /dev/null +++ b/apps/pages/migrations/0009_alter_homepagesection_section_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2026-05-26 12:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0008_custom_pages'), + ] + + operations = [ + migrations.AlterField( + model_name='homepagesection', + name='section_type', + field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters')], max_length=30), + ), + ] diff --git a/apps/pages/migrations/0010_homepagesectionitem_url_and_more.py b/apps/pages/migrations/0010_homepagesectionitem_url_and_more.py new file mode 100644 index 0000000..6e08b29 --- /dev/null +++ b/apps/pages/migrations/0010_homepagesectionitem_url_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.0.2 on 2026-05-26 12:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0009_alter_homepagesection_section_type'), + ] + + operations = [ + migrations.AddField( + model_name='homepagesectionitem', + name='url', + field=models.CharField(blank=True, help_text='Optional link; makes this item clickable.', max_length=300), + ), + migrations.AlterField( + model_name='custompagesection', + name='section_type', + field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('features', 'Features Grid'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products Grid'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('faq', 'FAQ Accordion')], default='custom', max_length=30), + ), + migrations.AlterField( + model_name='custompagesectionitem', + name='url', + field=models.CharField(blank=True, help_text='Optional link; makes this item clickable.', max_length=300), + ), + migrations.AlterField( + model_name='homepagesection', + name='section_type', + field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters')], max_length=30), + ), + ] diff --git a/apps/pages/migrations/0011_custompagesectionitem_page_aboutsectionitem_url.py b/apps/pages/migrations/0011_custompagesectionitem_page_aboutsectionitem_url.py new file mode 100644 index 0000000..62dad68 --- /dev/null +++ b/apps/pages/migrations/0011_custompagesectionitem_page_aboutsectionitem_url.py @@ -0,0 +1,48 @@ +from django.db import migrations, models +import django.db.models.deletion + + +def set_custom_page_section_item_page(apps, schema_editor): + CustomPageSectionItem = apps.get_model("pages", "CustomPageSectionItem") + for item in CustomPageSectionItem.objects.select_related("section").iterator(): + item.page_id = item.section.page_id + item.save(update_fields=["page_id"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("pages", "0010_homepagesectionitem_url_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="aboutsectionitem", + name="url", + field=models.CharField( + blank=True, + help_text="Optional link; makes this item clickable.", + max_length=300, + ), + ), + migrations.AddField( + model_name="custompagesectionitem", + name="page", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="section_items", + to="pages.custompage", + ), + ), + migrations.RunPython(set_custom_page_section_item_page, migrations.RunPython.noop), + migrations.AlterField( + model_name="custompagesectionitem", + name="page", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="section_items", + to="pages.custompage", + ), + ), + ] diff --git a/apps/pages/migrations/0012_aboutsectionitem_icon.py b/apps/pages/migrations/0012_aboutsectionitem_icon.py new file mode 100644 index 0000000..a80749f --- /dev/null +++ b/apps/pages/migrations/0012_aboutsectionitem_icon.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pages", "0011_custompagesectionitem_page_aboutsectionitem_url"), + ] + + operations = [ + migrations.AddField( + model_name="aboutsectionitem", + name="icon", + field=models.CharField( + blank=True, + help_text="Emoji or short symbol (e.g. ⚗️).", + max_length=20, + ), + ), + ] diff --git a/apps/pages/migrations/0013_contact_submission_attachments.py b/apps/pages/migrations/0013_contact_submission_attachments.py new file mode 100644 index 0000000..c63c8de --- /dev/null +++ b/apps/pages/migrations/0013_contact_submission_attachments.py @@ -0,0 +1,30 @@ +# Generated by Django 5.0.2 on 2026-06-07 13:23 + +import apps.pages.contact_uploads +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0012_aboutsectionitem_icon'), + ] + + operations = [ + migrations.CreateModel( + name='ContactSubmissionAttachment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(storage=apps.pages.contact_uploads.ContactAttachmentStorage(), upload_to=apps.pages.contact_uploads.contact_attachment_upload_to)), + ('original_filename', models.CharField(max_length=255)), + ('uploaded_at', models.DateTimeField(auto_now_add=True)), + ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='pages.contactsubmission')), + ], + options={ + 'verbose_name': 'Contact Attachment', + 'verbose_name_plural': 'Contact Attachments', + 'ordering': ['uploaded_at'], + }, + ), + ] diff --git a/apps/pages/migrations/0014_alter_aboutsection_section_type_and_more.py b/apps/pages/migrations/0014_alter_aboutsection_section_type_and_more.py new file mode 100644 index 0000000..c8a4a65 --- /dev/null +++ b/apps/pages/migrations/0014_alter_aboutsection_section_type_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.13 on 2026-06-07 13:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0013_contact_submission_attachments'), + ] + + operations = [ + migrations.AlterField( + model_name='aboutsection', + name='section_type', + field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('supporters', 'Supporters')], default='custom', max_length=30), + ), + migrations.AlterField( + model_name='aboutsectionitem', + name='image', + field=models.ImageField(blank=True, help_text='Logo or image (used for Supporters cards).', null=True, upload_to='about/items/'), + ), + ] diff --git a/apps/pages/migrations/0015_video_support.py b/apps/pages/migrations/0015_video_support.py new file mode 100644 index 0000000..bfe7f8c --- /dev/null +++ b/apps/pages/migrations/0015_video_support.py @@ -0,0 +1,141 @@ +# Generated by Django 5.2.13 on 2026-06-08 12:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0014_alter_aboutsection_section_type_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='PageVideo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('video_source', models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20)), + ('video_url', models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500)), + ('video_file', models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/')), + ('video_poster', models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/')), + ('video_size', models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10)), + ('video_aspect_ratio', models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10)), + ('page', models.CharField(choices=[('contact', 'Contact'), ('faq', 'FAQ')], max_length=20)), + ('badge', models.CharField(blank=True, max_length=100)), + ('title', models.CharField(blank=True, max_length=300)), + ('description', models.TextField(blank=True)), + ('order', models.PositiveIntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'verbose_name': 'Page Video', + 'verbose_name_plural': 'Page Videos', + 'ordering': ['page', 'order'], + }, + ), + migrations.AddField( + model_name='aboutsection', + name='video_aspect_ratio', + field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10), + ), + migrations.AddField( + model_name='aboutsection', + name='video_file', + field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'), + ), + migrations.AddField( + model_name='aboutsection', + name='video_poster', + field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'), + ), + migrations.AddField( + model_name='aboutsection', + name='video_size', + field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10), + ), + migrations.AddField( + model_name='aboutsection', + name='video_source', + field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20), + ), + migrations.AddField( + model_name='aboutsection', + name='video_url', + field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500), + ), + migrations.AddField( + model_name='custompagesection', + name='video_aspect_ratio', + field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10), + ), + migrations.AddField( + model_name='custompagesection', + name='video_file', + field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'), + ), + migrations.AddField( + model_name='custompagesection', + name='video_poster', + field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'), + ), + migrations.AddField( + model_name='custompagesection', + name='video_size', + field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10), + ), + migrations.AddField( + model_name='custompagesection', + name='video_source', + field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20), + ), + migrations.AddField( + model_name='custompagesection', + name='video_url', + field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500), + ), + migrations.AddField( + model_name='homepagesection', + name='video_aspect_ratio', + field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10), + ), + migrations.AddField( + model_name='homepagesection', + name='video_file', + field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'), + ), + migrations.AddField( + model_name='homepagesection', + name='video_poster', + field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'), + ), + migrations.AddField( + model_name='homepagesection', + name='video_size', + field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10), + ), + migrations.AddField( + model_name='homepagesection', + name='video_source', + field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20), + ), + migrations.AddField( + model_name='homepagesection', + name='video_url', + field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500), + ), + migrations.AlterField( + model_name='aboutsection', + name='section_type', + field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('supporters', 'Supporters'), ('video', 'Video')], default='custom', max_length=30), + ), + migrations.AlterField( + model_name='custompagesection', + name='section_type', + field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('features', 'Features Grid'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products Grid'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('faq', 'FAQ Accordion'), ('video', 'Video')], default='custom', max_length=30), + ), + migrations.AlterField( + model_name='homepagesection', + name='section_type', + field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters'), ('video', 'Video')], max_length=30), + ), + ] diff --git a/apps/pages/migrations/0016_video_styled_background.py b/apps/pages/migrations/0016_video_styled_background.py new file mode 100644 index 0000000..08267d4 --- /dev/null +++ b/apps/pages/migrations/0016_video_styled_background.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.13 on 2026-06-08 13:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0015_video_support'), + ] + + operations = [ + migrations.AddField( + model_name='aboutsection', + name='video_styled_background', + field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'), + ), + migrations.AddField( + model_name='custompagesection', + name='video_styled_background', + field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'), + ), + migrations.AddField( + model_name='homepagesection', + name='video_styled_background', + field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'), + ), + migrations.AddField( + model_name='pagevideo', + name='video_styled_background', + field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'), + ), + ] diff --git a/apps/pages/migrations/0017_video_description_format.py b/apps/pages/migrations/0017_video_description_format.py new file mode 100644 index 0000000..aa07668 --- /dev/null +++ b/apps/pages/migrations/0017_video_description_format.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.13 on 2026-06-08 13:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0016_video_styled_background'), + ] + + operations = [ + migrations.AddField( + model_name='aboutsection', + name='description_format', + field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20), + ), + migrations.AddField( + model_name='custompagesection', + name='description_format', + field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20), + ), + migrations.AddField( + model_name='homepagesection', + name='description_format', + field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20), + ), + migrations.AddField( + model_name='pagevideo', + name='description_format', + field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20), + ), + ] diff --git a/apps/pages/migrations/0018_homepage_projects_experience.py b/apps/pages/migrations/0018_homepage_projects_experience.py new file mode 100644 index 0000000..0f5af06 --- /dev/null +++ b/apps/pages/migrations/0018_homepage_projects_experience.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.15 on 2026-06-20 23:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0017_video_description_format'), + ] + + operations = [ + migrations.AddField( + model_name='homepagesection', + name='title_highlight', + field=models.CharField(blank=True, help_text="Second title line shown in accent colour (e.g. 'Outstanding Projects').", max_length=300), + ), + migrations.AddField( + model_name='homepagesectionitem', + name='image_alt', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='homepagesectionitem', + name='project_status', + field=models.CharField(blank=True, choices=[('', '—'), ('new', 'New'), ('ongoing', 'Ongoing'), ('done', 'Done')], help_text='Project filter category (Projects Showcase section).', max_length=10), + ), + migrations.AddField( + model_name='homepagesectionitem', + name='tags', + field=models.CharField(blank=True, help_text='Comma-separated tags shown on project cards (e.g. Web Development, Publication).', max_length=300), + ), + migrations.AlterField( + model_name='herosection', + name='title', + field=models.CharField(blank=True, help_text="Main title line (e.g. 'Advanced Solutions').", max_length=300), + ), + migrations.AlterField( + model_name='homepagesection', + name='section_type', + field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('projects', 'Projects Showcase'), ('experience', 'Experience / Benefits'), ('about_strip', 'About Strip'), ('supporters', 'Supporters'), ('video', 'Video')], max_length=30), + ), + migrations.AlterField( + model_name='homepagesectionitem', + name='image', + field=models.ImageField(blank=True, help_text='Card image or icon.', null=True, upload_to='homepage/items/'), + ), + ] diff --git a/apps/pages/migrations/__init__.py b/apps/pages/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/pages/models.py b/apps/pages/models.py new file mode 100644 index 0000000..5dccf1a --- /dev/null +++ b/apps/pages/models.py @@ -0,0 +1,479 @@ +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.text import slugify + +from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_MARKDOWN, FORMAT_PLAIN, render_content +from apps.core.video import VideoBlockMixin +from apps.pages.contact_uploads import ( + contact_attachment_storage, + contact_attachment_upload_to, +) + +RESERVED_PAGE_SLUGS = frozenset({ + "admin", + "about", + "contact", + "faq", + "home", + "products", + "static", + "media", +}) + + +class HeroSection(models.Model): + badge = models.CharField(max_length=200, blank=True, help_text="Small label above the title (e.g. 'Developing since 2021').") + title = models.CharField(max_length=300, blank=True, help_text="Main title line (e.g. 'Advanced Solutions').") + title_highlight = models.CharField(max_length=300, blank=True, help_text="Second title line shown in gradient colour (e.g. 'A Powerful Workflow Generator').") + subtitle = models.CharField(max_length=500, blank=True, help_text="Short line below the title (e.g. 'for Standardized Radiomics Analysis…').") + description = models.TextField(blank=True, help_text="Longer paragraph below the subtitle.") + primary_cta_text = models.CharField(max_length=100, blank=True, help_text="Primary button label.") + primary_cta_url = models.CharField(max_length=300, blank=True, help_text="Primary button URL (relative or absolute).") + secondary_cta_text = models.CharField(max_length=100, blank=True, help_text="Secondary (ghost) button label.") + secondary_cta_url = models.CharField(max_length=300, blank=True, help_text="Secondary (ghost) button URL.") + image = models.ImageField(upload_to="hero/", blank=True, null=True, help_text="App preview screenshot shown on the right.") + image_alt = models.CharField(max_length=300, blank=True, help_text="Alt text for the preview image.") + + class Meta: + verbose_name = "Hero Section" + verbose_name_plural = "Hero Section" + + def __str__(self): + return "Hero Section" + + +class HomepageSection(VideoBlockMixin, models.Model): + TYPE_FEATURES = "features" + TYPE_SCREENSHOTS = "screenshots" + TYPE_PRODUCTS = "products" + TYPE_PRODUCTS_CATALOG = "products_catalog" + TYPE_PROBLEMS = "problems" + TYPE_PROJECTS = "projects" + TYPE_EXPERIENCE = "experience" + TYPE_ABOUT_STRIP = "about_strip" + TYPE_SUPPORTERS = "supporters" + TYPE_VIDEO = "video" + + TYPE_CHOICES = [ + (TYPE_FEATURES, "Features"), + (TYPE_SCREENSHOTS, "Screenshots Gallery"), + (TYPE_PRODUCTS, "Products"), + (TYPE_PRODUCTS_CATALOG, "Products with Sub-products"), + (TYPE_PROBLEMS, "Problems / Value Proposition"), + (TYPE_PROJECTS, "Projects Showcase"), + (TYPE_EXPERIENCE, "Experience / Benefits"), + (TYPE_ABOUT_STRIP, "About Strip"), + (TYPE_SUPPORTERS, "Supporters"), + (TYPE_VIDEO, "Video"), + ] + + section_type = models.CharField(max_length=30, choices=TYPE_CHOICES) + badge = models.CharField(max_length=100, blank=True) + title = models.CharField(max_length=300, blank=True) + title_highlight = models.CharField( + max_length=300, + blank=True, + help_text="Second title line shown in accent colour (e.g. 'Outstanding Projects').", + ) + description = models.TextField(blank=True) + link_text = models.CharField(max_length=100, blank=True, help_text="CTA button label (About Strip).") + link_url = models.CharField(max_length=300, blank=True, help_text="CTA button URL (About Strip).") + order = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["order"] + verbose_name = "Homepage Section" + verbose_name_plural = "Homepage Sections" + + def clean(self): + super().clean() + if self.section_type == self.TYPE_VIDEO: + self.clean_video_fields(require=True) + + def __str__(self): + return f"[{self.get_section_type_display()}] {self.title or self.badge}" + + +class HomepageSectionItem(models.Model): + STATUS_NEW = "new" + STATUS_ONGOING = "ongoing" + STATUS_DONE = "done" + + PROJECT_STATUS_CHOICES = [ + ("", "—"), + (STATUS_NEW, "New"), + (STATUS_ONGOING, "Ongoing"), + (STATUS_DONE, "Done"), + ] + + section = models.ForeignKey(HomepageSection, on_delete=models.CASCADE, related_name="items") + icon = models.CharField(max_length=20, blank=True, help_text="Emoji or short symbol (e.g. ⚗️).") + title = models.CharField(max_length=300, blank=True) + content = models.TextField(blank=True) + url = models.CharField(max_length=300, blank=True, help_text="Optional link; makes this item clickable.") + image = models.ImageField(upload_to="homepage/items/", blank=True, null=True, help_text="Card image or icon.") + image_alt = models.CharField(max_length=200, blank=True) + tags = models.CharField( + max_length=300, + blank=True, + help_text="Comma-separated tags shown on project cards (e.g. Web Development, Publication).", + ) + project_status = models.CharField( + max_length=10, + choices=PROJECT_STATUS_CHOICES, + blank=True, + help_text="Project filter category (Projects Showcase section).", + ) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["order"] + verbose_name = "Homepage Section Item" + verbose_name_plural = "Homepage Section Items" + + def __str__(self): + return f"{self.section} › {self.title or self.icon or '(item)'}" + + @property + def tag_list(self): + if not self.tags.strip(): + return [] + return [tag.strip() for tag in self.tags.split(",") if tag.strip()] + + +class AboutSection(VideoBlockMixin, models.Model): + TYPE_HERO = "hero" + TYPE_INTRO = "intro" + TYPE_GRID = "grid" + TYPE_HISTORY = "history" + TYPE_CUSTOM = "custom" + TYPE_SUPPORTERS = "supporters" + TYPE_VIDEO = "video" + + TYPE_CHOICES = [ + (TYPE_HERO, "Hero"), + (TYPE_INTRO, "Intro Card"), + (TYPE_GRID, "Grid Cards"), + (TYPE_HISTORY, "History Block"), + (TYPE_CUSTOM, "Custom Content"), + (TYPE_SUPPORTERS, "Supporters"), + (TYPE_VIDEO, "Video"), + ] + + section_type = models.CharField(max_length=30, choices=TYPE_CHOICES, default=TYPE_CUSTOM) + badge = models.CharField(max_length=100, blank=True) + title = models.CharField(max_length=300, blank=True) + subtitle = models.CharField(max_length=500, blank=True, help_text="Used as subtitle in Hero and year in History.") + content = models.TextField(blank=True) + content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_MARKDOWN) + order = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["order"] + verbose_name = "About Section" + verbose_name_plural = "About Sections" + + @property + def rendered_content(self): + return render_content(self.content, self.content_format) + + def clean(self): + super().clean() + if self.section_type == self.TYPE_VIDEO: + self.clean_video_fields(require=True) + + def __str__(self): + label = self.title or self.badge or self.get_section_type_display() + return f"[{self.get_section_type_display()}] {label}" + + +class AboutSectionItem(models.Model): + section = models.ForeignKey(AboutSection, on_delete=models.CASCADE, related_name="items") + icon = models.CharField(max_length=20, blank=True, help_text="Emoji or short symbol (e.g. ⚗️).") + badge = models.CharField(max_length=100, blank=True) + title = models.CharField(max_length=300, blank=True) + content = models.TextField(blank=True, help_text="Description text or link label for History links.") + url = models.CharField(max_length=300, blank=True, help_text="Optional link; makes this item clickable.") + image = models.ImageField( + upload_to="about/items/", + blank=True, + null=True, + help_text="Logo or image (used for Supporters cards).", + ) + image_alt = models.CharField(max_length=200, blank=True) + is_featured = models.BooleanField(default=False, help_text="Mark as featured item (e.g. large screenshot).") + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["order"] + verbose_name = "About Section Item" + verbose_name_plural = "About Section Items" + + def __str__(self): + return f"{self.section} › {self.title or self.icon or self.badge or '(item)'}" + + +class ContactSubmission(models.Model): + name = models.CharField(max_length=200) + title = models.CharField(max_length=300) + description = models.TextField() + email = models.EmailField(blank=True) + submitted_at = models.DateTimeField(auto_now_add=True) + is_read = models.BooleanField(default=False) + + class Meta: + ordering = ["-submitted_at"] + verbose_name = "Contact Submission" + verbose_name_plural = "Contact Submissions" + + def __str__(self): + return f"{self.name} — {self.title}" + + +class ContactSubmissionAttachment(models.Model): + submission = models.ForeignKey( + ContactSubmission, + on_delete=models.CASCADE, + related_name="attachments", + ) + file = models.FileField( + upload_to=contact_attachment_upload_to, + storage=contact_attachment_storage, + ) + original_filename = models.CharField(max_length=255) + uploaded_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["uploaded_at"] + verbose_name = "Contact Attachment" + verbose_name_plural = "Contact Attachments" + + def __str__(self): + return self.original_filename + + +class FAQEntry(models.Model): + question = models.CharField(max_length=500) + answer = models.TextField() + answer_format = models.CharField( + max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN + ) + order = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["order"] + verbose_name = "FAQ Entry" + verbose_name_plural = "FAQ Entries" + + def __str__(self): + return self.question + + @property + def rendered_answer(self): + return render_content(self.answer, self.answer_format) + + +class DownloadItem(models.Model): + PLATFORM_WINDOWS = "windows" + PLATFORM_MACOS = "macos" + PLATFORM_LINUX = "linux" + + PLATFORM_CHOICES = [ + (PLATFORM_WINDOWS, "Windows"), + (PLATFORM_MACOS, "macOS"), + (PLATFORM_LINUX, "Linux"), + ] + + name = models.CharField(max_length=200) + platform = models.CharField(max_length=20, choices=PLATFORM_CHOICES) + version = models.CharField(max_length=50) + download_url = models.URLField() + description = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + order = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["order", "platform"] + verbose_name = "Download Item" + verbose_name_plural = "Download Items" + + def __str__(self): + return f"{self.name} ({self.get_platform_display()})" + + +class CustomPage(models.Model): + title = models.CharField(max_length=200) + slug = models.SlugField(max_length=200, unique=True) + menu_label = models.CharField( + max_length=100, + blank=True, + help_text="Nav label when shown in menu. Defaults to title.", + ) + meta_description = models.CharField(max_length=300, blank=True) + show_in_nav = models.BooleanField(default=True) + menu_order = models.PositiveIntegerField(default=0) + is_published = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["menu_order", "title"] + verbose_name = "Custom Page" + verbose_name_plural = "Custom Pages" + + def __str__(self): + return self.title + + @property + def nav_label(self): + return self.menu_label or self.title + + def clean(self): + super().clean() + if self.slug in RESERVED_PAGE_SLUGS: + raise ValidationError({"slug": f'"{self.slug}" is reserved and cannot be used.'}) + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.title) + self.full_clean() + super().save(*args, **kwargs) + + +class CustomPageSection(VideoBlockMixin, models.Model): + TYPE_HERO = "hero" + TYPE_INTRO = "intro" + TYPE_GRID = "grid" + TYPE_HISTORY = "history" + TYPE_CUSTOM = "custom" + TYPE_FEATURES = "features" + TYPE_SCREENSHOTS = "screenshots" + TYPE_PRODUCTS = "products" + TYPE_PRODUCTS_CATALOG = "products_catalog" + TYPE_PROBLEMS = "problems" + TYPE_SUPPORTERS = "supporters" + TYPE_ABOUT_STRIP = "about_strip" + TYPE_FAQ = "faq" + TYPE_VIDEO = "video" + + TYPE_CHOICES = [ + (TYPE_HERO, "Hero"), + (TYPE_INTRO, "Intro Card"), + (TYPE_GRID, "Grid Cards"), + (TYPE_HISTORY, "History Block"), + (TYPE_CUSTOM, "Custom Content"), + (TYPE_FEATURES, "Features Grid"), + (TYPE_SCREENSHOTS, "Screenshots Gallery"), + (TYPE_PRODUCTS, "Products Grid"), + (TYPE_PRODUCTS_CATALOG, "Products with Sub-products"), + (TYPE_PROBLEMS, "Problems / Value Proposition"), + (TYPE_SUPPORTERS, "Supporters"), + (TYPE_ABOUT_STRIP, "About Strip"), + (TYPE_FAQ, "FAQ Accordion"), + (TYPE_VIDEO, "Video"), + ] + + page = models.ForeignKey(CustomPage, on_delete=models.CASCADE, related_name="sections") + section_type = models.CharField(max_length=30, choices=TYPE_CHOICES, default=TYPE_CUSTOM) + badge = models.CharField(max_length=100, blank=True) + title = models.CharField(max_length=300, blank=True) + subtitle = models.CharField(max_length=500, blank=True) + description = models.TextField(blank=True, help_text="Short intro text (homepage-style sections).") + content = models.TextField(blank=True) + content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_MARKDOWN) + link_text = models.CharField(max_length=100, blank=True) + link_url = models.CharField(max_length=300, blank=True) + order = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["order"] + verbose_name = "Custom Page Section" + verbose_name_plural = "Custom Page Sections" + + @property + def rendered_content(self): + return render_content(self.content, self.content_format) + + def clean(self): + super().clean() + if self.section_type == self.TYPE_VIDEO: + self.clean_video_fields(require=True) + + def __str__(self): + label = self.title or self.badge or self.get_section_type_display() + return f"{self.page} › [{self.get_section_type_display()}] {label}" + + +class PageVideo(VideoBlockMixin, models.Model): + PAGE_CONTACT = "contact" + PAGE_FAQ = "faq" + + PAGE_CHOICES = [ + (PAGE_CONTACT, "Contact"), + (PAGE_FAQ, "FAQ"), + ] + + page = models.CharField(max_length=20, choices=PAGE_CHOICES) + badge = models.CharField(max_length=100, blank=True) + title = models.CharField(max_length=300, blank=True) + description = models.TextField(blank=True) + order = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["page", "order"] + verbose_name = "Page Video" + verbose_name_plural = "Page Videos" + + def clean(self): + super().clean() + self.clean_video_fields(require=True) + + def __str__(self): + label = self.title or self.badge or self.get_page_display() + return f"{self.get_page_display()} › {label}" + + +class CustomPageSectionItem(models.Model): + page = models.ForeignKey( + CustomPage, + on_delete=models.CASCADE, + related_name="section_items", + ) + section = models.ForeignKey(CustomPageSection, on_delete=models.CASCADE, related_name="items") + icon = models.CharField(max_length=20, blank=True) + badge = models.CharField(max_length=100, blank=True) + title = models.CharField(max_length=300, blank=True) + content = models.TextField(blank=True) + content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN) + url = models.CharField(max_length=300, blank=True, help_text="Optional link; makes this item clickable.") + image = models.ImageField(upload_to="pages/custom/", blank=True, null=True) + image_alt = models.CharField(max_length=200, blank=True) + is_featured = models.BooleanField(default=False) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["order"] + verbose_name = "Custom Page Section Item" + verbose_name_plural = "Custom Page Section Items" + + @property + def rendered_content(self): + return render_content(self.content, self.content_format) + + def save(self, *args, **kwargs): + if self.section_id: + self.page_id = self.section.page_id + super().save(*args, **kwargs) + + def __str__(self): + return f"{self.section} › {self.title or self.icon or '(item)'}" diff --git a/apps/pages/tests/__init__.py b/apps/pages/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/pages/tests/test_contact_uploads.py b/apps/pages/tests/test_contact_uploads.py new file mode 100644 index 0000000..81062f6 --- /dev/null +++ b/apps/pages/tests/test_contact_uploads.py @@ -0,0 +1,158 @@ +import io +import zipfile + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import Client, TestCase, override_settings +from django.urls import reverse +from PIL import Image + +from apps.pages.contact_uploads import ( + MAX_ATTACHMENTS, + MAX_FILE_SIZE, + validate_contact_attachments, +) +from apps.pages.models import ContactSubmission, ContactSubmissionAttachment + + +def _png_file(name="screenshot.png"): + buffer = io.BytesIO() + Image.new("RGB", (8, 8), color="red").save(buffer, format="PNG") + buffer.seek(0) + return SimpleUploadedFile(name, buffer.read(), content_type="image/png") + + +def _zip_file(name="logs.zip", entries=None): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for entry_name, content in (entries or {"app.log": "line one\n"}).items(): + archive.writestr(entry_name, content) + buffer.seek(0) + return SimpleUploadedFile(name, buffer.read(), content_type="application/zip") + + +class ContactUploadValidationTest(TestCase): + def test_accepts_valid_png(self): + validated = validate_contact_attachments([_png_file()]) + self.assertEqual(len(validated), 1) + self.assertEqual(validated[0][1], "screenshot.png") + + def test_accepts_valid_zip(self): + validated = validate_contact_attachments([_zip_file()]) + self.assertEqual(len(validated), 1) + + def test_accepts_valid_text_log(self): + uploaded = SimpleUploadedFile( + "error.log", + b"2026-06-07 ERROR something failed\n", + content_type="text/plain", + ) + validated = validate_contact_attachments([uploaded]) + self.assertEqual(validated[0][1], "error.log") + + def test_rejects_executable_extension(self): + uploaded = SimpleUploadedFile( + "malware.exe", + b"MZfake", + content_type="application/octet-stream", + ) + with self.assertRaises(Exception): + validate_contact_attachments([uploaded]) + + def test_rejects_php_disguised_as_png(self): + uploaded = SimpleUploadedFile( + "image.png", + b"", + content_type="image/png", + ) + with self.assertRaises(Exception): + validate_contact_attachments([uploaded]) + + def test_rejects_oversized_file(self): + uploaded = SimpleUploadedFile( + "big.log", + b"x" * (MAX_FILE_SIZE + 1), + content_type="text/plain", + ) + with self.assertRaises(Exception): + validate_contact_attachments([uploaded]) + + def test_rejects_too_many_files(self): + files = [_png_file(f"shot-{index}.png") for index in range(MAX_ATTACHMENTS + 1)] + with self.assertRaises(Exception): + validate_contact_attachments(files) + + def test_rejects_zip_with_path_traversal(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("../escape.txt", "bad") + buffer.seek(0) + uploaded = SimpleUploadedFile( + "bad.zip", + buffer.read(), + content_type="application/zip", + ) + with self.assertRaises(Exception): + validate_contact_attachments([uploaded]) + + +@override_settings( + CONTACT_UPLOAD_ROOT=__import__("pathlib").Path(__file__).resolve().parents[3] + / "test_private_uploads" +) +class ContactViewUploadTest(TestCase): + def setUp(self): + self.client = Client(enforce_csrf_checks=True) + self.url = reverse("pages:contact") + + def _start_session(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.csrf_token = response.cookies["csrftoken"].value + captcha_question = response.context["captcha_question"] + left, right = captcha_question.split(" + ") + return int(left) + int(right) + + def _post_contact(self, captcha_answer, attachments=None, extra=None): + payload = { + "name": "Test User", + "title": "Upload test", + "description": "Testing attachments", + "email": "test@example.com", + "captcha_answer": captcha_answer, + } + if attachments is not None: + payload["attachments"] = attachments + if extra: + payload.update(extra) + return self.client.post( + self.url, + data=payload, + HTTP_X_CSRFTOKEN=self.csrf_token, + ) + + def test_contact_submission_with_png_attachment(self): + captcha_answer = self._start_session() + response = self._post_contact(captcha_answer, attachments=_png_file()) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["success"]) + submission = ContactSubmission.objects.get(title="Upload test") + self.assertEqual(submission.attachments.count(), 1) + attachment = submission.attachments.first() + self.assertEqual(attachment.original_filename, "screenshot.png") + self.assertTrue(attachment.file.storage.exists(attachment.file.name)) + + def test_contact_submission_rejects_invalid_attachment(self): + captcha_answer = self._start_session() + response = self._post_contact( + captcha_answer, + attachments=SimpleUploadedFile( + "bad.exe", + b"MZ", + content_type="application/octet-stream", + ), + ) + self.assertEqual(response.status_code, 400) + self.assertFalse(response.json()["success"]) + self.assertIn("attachments", response.json()["errors"]) + self.assertEqual(ContactSubmission.objects.count(), 0) + self.assertEqual(ContactSubmissionAttachment.objects.count(), 0) diff --git a/apps/pages/tests/test_custom_pages.py b/apps/pages/tests/test_custom_pages.py new file mode 100644 index 0000000..b7f6792 --- /dev/null +++ b/apps/pages/tests/test_custom_pages.py @@ -0,0 +1,94 @@ +from django.test import TestCase +from django.urls import reverse + +from apps.pages.models import CustomPage, CustomPageSection, CustomPageSectionItem + + +class CustomPageViewTest(TestCase): + def setUp(self): + self.page = CustomPage.objects.create( + title="Resources", + slug="resources", + show_in_nav=True, + menu_order=5, + is_published=True, + ) + CustomPageSection.objects.create( + page=self.page, + section_type=CustomPageSection.TYPE_HERO, + title="Resources", + badge="Docs", + is_active=True, + ) + CustomPage.objects.create( + title="Draft Page", + slug="draft", + is_published=False, + ) + + def test_published_page_returns_200(self): + response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "resources"})) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "pages/custom_page.html") + self.assertEqual(response.context["custom_page"], self.page) + + def test_unpublished_page_returns_404(self): + response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "draft"})) + self.assertEqual(response.status_code, 404) + + def test_unknown_slug_returns_404(self): + response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "missing"})) + self.assertEqual(response.status_code, 404) + + def test_section_item_with_url_renders_as_link(self): + section = CustomPageSection.objects.create( + page=self.page, + section_type=CustomPageSection.TYPE_FEATURES, + title="Highlights", + is_active=True, + ) + CustomPageSectionItem.objects.create( + page=self.page, + section=section, + title="Documentation", + content="Read the docs.", + url="/about/", + order=1, + ) + response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "resources"})) + self.assertContains(response, 'href="/about/"') + self.assertContains(response, "section-item-link") + self.assertContains(response, "Documentation") + + +class CustomPageNavTest(TestCase): + def test_nav_custom_pages_in_context(self): + CustomPage.objects.create( + title="Visible", + slug="visible", + show_in_nav=True, + menu_order=1, + is_published=True, + ) + CustomPage.objects.create( + title="Hidden Nav", + slug="hidden-nav", + show_in_nav=False, + is_published=True, + ) + response = self.client.get(reverse("pages:home")) + pages = list(response.context["nav_custom_pages"]) + self.assertEqual(len(pages), 1) + self.assertEqual(pages[0].slug, "visible") + + def test_nav_link_rendered(self): + CustomPage.objects.create( + title="Team", + slug="team", + menu_label="Our Team", + show_in_nav=True, + is_published=True, + ) + response = self.client.get(reverse("pages:home")) + self.assertContains(response, "Our Team") + self.assertContains(response, reverse("pages:custom_page", kwargs={"slug": "team"})) diff --git a/apps/pages/tests/test_video.py b/apps/pages/tests/test_video.py new file mode 100644 index 0000000..856947f --- /dev/null +++ b/apps/pages/tests/test_video.py @@ -0,0 +1,187 @@ +from django.test import TestCase +from django.urls import reverse + +from apps.core.video import parse_youtube_video_id, youtube_embed_url +from apps.pages.models import ( + AboutSection, + CustomPage, + CustomPageSection, + HomepageSection, + PageVideo, +) +from apps.products.models import MainProduct, ProductVideo + + +class YouTubeParsingTest(TestCase): + def test_watch_url(self): + self.assertEqual( + parse_youtube_video_id("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), + "dQw4w9WgXcQ", + ) + + def test_short_url(self): + self.assertEqual( + parse_youtube_video_id("https://youtu.be/dQw4w9WgXcQ"), + "dQw4w9WgXcQ", + ) + + def test_embed_url(self): + url = youtube_embed_url("https://youtu.be/dQw4w9WgXcQ") + self.assertEqual(url, "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ") + + +class HomepageVideoSectionTest(TestCase): + def test_video_section_renders_youtube_embed(self): + HomepageSection.objects.create( + section_type=HomepageSection.TYPE_VIDEO, + title="Demo", + video_source="youtube", + video_url="https://www.youtube.com/watch?v=dQw4w9WgXcQ", + is_active=True, + ) + response = self.client.get(reverse("pages:home")) + self.assertContains(response, "youtube-nocookie.com/embed/dQw4w9WgXcQ") + self.assertContains(response, "video-block--md") + + +class AboutVideoSectionTest(TestCase): + def test_video_section_renders_on_about_page(self): + AboutSection.objects.create( + section_type=AboutSection.TYPE_VIDEO, + title="Overview", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + video_size="lg", + is_active=True, + ) + response = self.client.get(reverse("pages:about")) + self.assertContains(response, "video-block--lg") + self.assertContains(response, "Overview") + + +class CustomPageVideoSectionTest(TestCase): + def test_video_section_renders_on_custom_page(self): + page = CustomPage.objects.create( + title="Media", + slug="media-page", + is_published=True, + ) + CustomPageSection.objects.create( + page=page, + section_type=CustomPageSection.TYPE_VIDEO, + title="Walkthrough", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + is_active=True, + ) + response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "media-page"})) + self.assertContains(response, "Walkthrough") + self.assertContains(response, "iframe") + + +class PageVideoTest(TestCase): + def test_contact_page_video(self): + PageVideo.objects.create( + page=PageVideo.PAGE_CONTACT, + title="Intro", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + is_active=True, + ) + response = self.client.get(reverse("pages:contact")) + self.assertContains(response, "Intro") + self.assertContains(response, "youtube-nocookie.com/embed/dQw4w9WgXcQ") + + def test_faq_page_video(self): + PageVideo.objects.create( + page=PageVideo.PAGE_FAQ, + title="Tutorial", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + is_active=True, + ) + response = self.client.get(reverse("pages:faq")) + self.assertContains(response, "Tutorial") + + +class ProductVideoTest(TestCase): + def setUp(self): + self.main_product = MainProduct.objects.create( + name="Tecvico", + slug="tecvico", + short_description="Short", + description="Long", + is_active=True, + ) + + def test_product_video_renders_on_detail_page(self): + ProductVideo.objects.create( + main_product=self.main_product, + title="Product Demo", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + video_size="full", + is_active=True, + ) + response = self.client.get( + reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"}) + ) + self.assertContains(response, "Product Demo") + self.assertContains(response, "video-block--full") + + +class VideoStyledBackgroundTest(TestCase): + def test_styled_background_renders_panel(self): + HomepageSection.objects.create( + section_type=HomepageSection.TYPE_VIDEO, + title="Styled Demo", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + video_styled_background=True, + is_active=True, + ) + response = self.client.get(reverse("pages:home")) + self.assertContains(response, "video-panel--styled") + self.assertContains(response, "video-panel-blob") + + def test_plain_background_without_panel(self): + HomepageSection.objects.create( + section_type=HomepageSection.TYPE_VIDEO, + title="Plain Demo", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + video_styled_background=False, + is_active=True, + ) + response = self.client.get(reverse("pages:home")) + self.assertNotContains(response, "video-panel--styled") + self.assertContains(response, "Plain Demo") + + +class VideoDescriptionFormatTest(TestCase): + def test_plain_description_preserves_line_breaks(self): + HomepageSection.objects.create( + section_type=HomepageSection.TYPE_VIDEO, + title="Demo", + description="First line\nSecond line", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + is_active=True, + ) + response = self.client.get(reverse("pages:home")) + self.assertContains(response, "First line") + self.assertContains(response, "Second line") + self.assertContains(response, "
") + + def test_markdown_description_renders(self): + PageVideo.objects.create( + page=PageVideo.PAGE_FAQ, + title="Guide", + description="**Bold** intro", + description_format="markdown", + video_source="youtube", + video_url="https://youtu.be/dQw4w9WgXcQ", + is_active=True, + ) + response = self.client.get(reverse("pages:faq")) + self.assertContains(response, "Bold") diff --git a/apps/pages/tests/test_views.py b/apps/pages/tests/test_views.py new file mode 100644 index 0000000..e3b28ad --- /dev/null +++ b/apps/pages/tests/test_views.py @@ -0,0 +1,125 @@ +from django.test import TestCase +from django.urls import reverse + +from apps.pages.models import FAQEntry + + +class HomeViewTest(TestCase): + def test_home_returns_200(self): + response = self.client.get(reverse("pages:home")) + self.assertEqual(response.status_code, 200) + + def test_home_uses_correct_template(self): + response = self.client.get(reverse("pages:home")) + self.assertTemplateUsed(response, "pages/home.html") + + def test_home_renders_dynamic_showcase_sections(self): + from apps.pages.models import HomepageSection + + projects = HomepageSection.objects.create( + section_type=HomepageSection.TYPE_PROJECTS, + title="Our Journey in the Realm of", + title_highlight="Outstanding Projects", + description="Explore our standout projects.", + order=50, + is_active=True, + ) + HomepageSection.objects.create( + section_type=HomepageSection.TYPE_EXPERIENCE, + title="Experience Leading", + title_highlight="the Way in Development", + description="Embark on a journey of accelerated product development.", + order=51, + is_active=True, + ) + + response = self.client.get(reverse("pages:home")) + content = response.content.decode() + self.assertIn("Our Journey in the Realm of", content) + self.assertIn("Outstanding Projects", content) + self.assertIn("Experience Leading", content) + self.assertIn("the Way in Development", content) + self.assertIn('data-project-filter="all"', content) + + +class AboutViewTest(TestCase): + def test_about_returns_200(self): + response = self.client.get(reverse("pages:about")) + self.assertEqual(response.status_code, 200) + + def test_about_uses_correct_template(self): + response = self.client.get(reverse("pages:about")) + self.assertTemplateUsed(response, "pages/about.html") + + +class FAQViewTest(TestCase): + def setUp(self): + FAQEntry.objects.create( + question="What is the license?", + answer="It is CC BY-NC-SA.", + order=1, + is_active=True, + ) + FAQEntry.objects.create( + question="Hidden question", + answer="Hidden answer", + order=2, + is_active=False, + ) + + def test_faq_returns_200(self): + response = self.client.get(reverse("pages:faq")) + self.assertEqual(response.status_code, 200) + + def test_faq_uses_correct_template(self): + response = self.client.get(reverse("pages:faq")) + self.assertTemplateUsed(response, "pages/faq.html") + + def test_faq_only_shows_active_entries(self): + response = self.client.get(reverse("pages:faq")) + entries = response.context["faq_entries"] + self.assertEqual(entries.count(), 1) + self.assertEqual(entries.first().question, "What is the license?") + + +class ContactViewTest(TestCase): + def test_contact_returns_200(self): + response = self.client.get(reverse("pages:contact")) + self.assertEqual(response.status_code, 200) + + def test_contact_uses_correct_template(self): + response = self.client.get(reverse("pages:contact")) + self.assertTemplateUsed(response, "pages/contact.html") + + +class NavigationContextTest(TestCase): + def test_nav_main_products_in_context_on_all_pages(self): + urls = [ + reverse("pages:home"), + reverse("pages:about"), + reverse("pages:faq"), + reverse("pages:contact"), + reverse("products:overview"), + ] + for url in urls: + response = self.client.get(url) + self.assertIn( + "nav_main_products", + response.context, + f"Missing nav_main_products at {url}", + ) + + def test_nav_custom_pages_in_context_on_all_pages(self): + urls = [ + reverse("pages:home"), + reverse("pages:about"), + reverse("pages:faq"), + reverse("pages:contact"), + ] + for url in urls: + response = self.client.get(url) + self.assertIn( + "nav_custom_pages", + response.context, + f"Missing nav_custom_pages at {url}", + ) diff --git a/apps/pages/urls.py b/apps/pages/urls.py new file mode 100644 index 0000000..cf13072 --- /dev/null +++ b/apps/pages/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from . import views + +app_name = "pages" + +urlpatterns = [ + path("", views.HomeView.as_view(), name="home"), + path("about/", views.AboutView.as_view(), name="about"), + path("faq/", views.FAQView.as_view(), name="faq"), + path("contact/", views.ContactView.as_view(), name="contact"), + path("/", views.CustomPageView.as_view(), name="custom_page"), +] diff --git a/apps/pages/views.py b/apps/pages/views.py new file mode 100644 index 0000000..ef5aab8 --- /dev/null +++ b/apps/pages/views.py @@ -0,0 +1,165 @@ +import random + +from django.db.models import Prefetch +from django.http import JsonResponse +from django.shortcuts import render +from django.views import View +from django.views.generic import DetailView, ListView, TemplateView + +from apps.products.models import MainProduct, SubProduct + +from .forms import ContactForm +from .models import ( + AboutSection, + ContactSubmission, + ContactSubmissionAttachment, + CustomPage, + FAQEntry, + HeroSection, + HomepageSection, + PageVideo, +) + + +def _homepage_products_catalog_queryset(): + return ( + MainProduct.objects.filter(is_active=True) + .prefetch_related( + Prefetch( + "sub_products", + queryset=SubProduct.objects.filter(is_active=True).order_by("order", "name"), + ) + ) + .order_by("order", "name") + ) + + +class HomeView(TemplateView): + template_name = "pages/home.html" + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["hero"] = HeroSection.objects.first() + ctx["homepage_sections"] = ( + HomepageSection.objects.filter(is_active=True) + .prefetch_related("items") + .order_by("order") + ) + ctx["homepage_products"] = ( + MainProduct.objects.filter(is_active=True, show_on_homepage=True) + .order_by("homepage_order", "order") + ) + ctx["homepage_products_catalog"] = _homepage_products_catalog_queryset() + return ctx + + +class AboutView(TemplateView): + template_name = "pages/about.html" + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["about_sections"] = ( + AboutSection.objects.filter(is_active=True) + .prefetch_related("items") + .order_by("order") + ) + return ctx + + +class FAQView(ListView): + model = FAQEntry + template_name = "pages/faq.html" + context_object_name = "faq_entries" + queryset = FAQEntry.objects.filter(is_active=True) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["faq_videos"] = PageVideo.objects.filter( + page=PageVideo.PAGE_FAQ, + is_active=True, + ).order_by("order") + return ctx + + +class ContactView(View): + template_name = "pages/contact.html" + + def _new_captcha(self, request): + a, b = random.randint(1, 9), random.randint(1, 9) + request.session["captcha_answer"] = a + b + return f"{a} + {b}" + + def get(self, request, *args, **kwargs): + return render( + request, + self.template_name, + { + "form": ContactForm(), + "captcha_question": self._new_captcha(request), + "contact_videos": PageVideo.objects.filter( + page=PageVideo.PAGE_CONTACT, + is_active=True, + ).order_by("order"), + }, + ) + + def post(self, request, *args, **kwargs): + form = ContactForm( + request.POST, + file_list=request.FILES.getlist("attachments"), + ) + expected = request.session.get("captcha_answer") + captcha_question = self._new_captcha(request) + + captcha_ok = False + try: + captcha_ok = int(request.POST.get("captcha_answer", "")) == expected + except (ValueError, TypeError): + pass + + if form.is_valid() and captcha_ok: + submission = form.save() + for uploaded_file, original_name in form.cleaned_data.get( + "attachments", [] + ): + attachment = ContactSubmissionAttachment( + submission=submission, + original_filename=original_name, + ) + attachment.file.save(original_name, uploaded_file, save=True) + return JsonResponse({"success": True}) + + errors: dict = {} + if not captcha_ok: + errors["captcha"] = ["Incorrect answer — please try again."] + errors.update( + {field: [str(e) for e in errs] for field, errs in form.errors.items()} + ) + return JsonResponse( + {"success": False, "errors": errors, "captcha_question": captcha_question}, + status=400, + ) + + +class CustomPageView(DetailView): + model = CustomPage + template_name = "pages/custom_page.html" + context_object_name = "custom_page" + slug_url_kwarg = "slug" + + def get_queryset(self): + return CustomPage.objects.filter(is_published=True) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["page_sections"] = ( + self.object.sections.filter(is_active=True) + .prefetch_related("items") + .order_by("order") + ) + ctx["homepage_products"] = ( + MainProduct.objects.filter(is_active=True, show_on_homepage=True) + .order_by("homepage_order", "order") + ) + ctx["homepage_products_catalog"] = _homepage_products_catalog_queryset() + return ctx diff --git a/apps/products/__init__.py b/apps/products/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/products/admin.py b/apps/products/admin.py new file mode 100644 index 0000000..ee95834 --- /dev/null +++ b/apps/products/admin.py @@ -0,0 +1,334 @@ +from django.contrib import admin + +from apps.core.admin_video import video_admin_preview +from apps.core.video import VIDEO_ADMIN_FIELDSET + +from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct, SubProductVersion + + +class ArticleCitationInline(admin.TabularInline): + model = ArticleCitation + extra = 1 + fields = ("text", "url", "order") + ordering = ("order",) + + +class ArticleSectionInline(admin.TabularInline): + model = ArticleSection + extra = 1 + fields = ("title", "value_format", "value", "order") + ordering = ("order",) + + +class SubProductArticleInline(admin.StackedInline): + model = Article + fk_name = "sub_product" + extra = 0 + fields = ("badge", "title", "description", "order") + ordering = ("order",) + show_change_link = True + + +class MainProductArticleInline(admin.StackedInline): + model = Article + fk_name = "main_product" + extra = 0 + fields = ("badge", "title", "description", "order") + ordering = ("order",) + show_change_link = True + + +RELEASE_VERSION_INLINE_FIELDS = ( + "version", + "release_channel", + "channel_label", + "is_featured", + "show_on_product_page", + "windows_download_url", + "macos_download_url", + "linux_download_url", + "source_code_url", + "package_resource_url", + "release_notes", + "is_active", + "order", +) + + +class SubProductVersionInline(admin.TabularInline): + model = SubProductVersion + fk_name = "sub_product" + extra = 0 + ordering = ("order", "version") + fields = RELEASE_VERSION_INLINE_FIELDS + show_change_link = True + + +class MainProductVersionInline(admin.TabularInline): + model = SubProductVersion + fk_name = "main_product" + extra = 0 + ordering = ("order", "version") + fields = RELEASE_VERSION_INLINE_FIELDS + show_change_link = True + + +class ProductVideoInline(admin.StackedInline): + model = ProductVideo + fk_name = "main_product" + extra = 0 + fields = ( + "badge", + "title", + "description_format", + "description", + "video_source", + "video_url", + "video_file", + "video_poster", + "video_size", + "video_aspect_ratio", + "video_styled_background", + "order", + "is_active", + ) + ordering = ("order",) + + +class SubProductInline(admin.StackedInline): + model = SubProduct + extra = 0 + fields = ("name", "slug", "distribution", "short_description", "image", "logo", "show_on_homepage", "homepage_order", "order", "is_active") + ordering = ("order",) + show_change_link = True + prepopulated_fields = {"slug": ("name",)} + + +@admin.register(MainProduct) +class MainProductAdmin(admin.ModelAdmin): + list_display = ("name", "show_on_homepage", "homepage_order", "order", "is_active", "created_at") + list_filter = ("is_active", "show_on_homepage") + search_fields = ("name", "description") + prepopulated_fields = {"slug": ("name",)} + list_editable = ("order", "is_active", "show_on_homepage", "homepage_order") + inlines = [MainProductArticleInline, ProductVideoInline, MainProductVersionInline, SubProductInline] + fieldsets = ( + ( + None, + { + "fields": ( + "name", + "slug", + "short_description", + "distribution", + "package_resource_button_text", + "package_source_button_text", + "downloads_section_link_text", + "downloads_section_link_url", + ) + }, + ), + ("Description", {"fields": ("description_format", "description")}), + ("Media", {"fields": ("image",)}), + ("Homepage", {"fields": ("show_on_homepage", "homepage_order")}), + ("Settings", {"fields": ("order", "is_active")}), + ) + + +class SubProductVideoInline(admin.StackedInline): + model = ProductVideo + fk_name = "sub_product" + extra = 0 + fields = ( + "badge", + "title", + "description_format", + "description", + "video_source", + "video_url", + "video_file", + "video_poster", + "video_size", + "video_aspect_ratio", + "video_styled_background", + "order", + "is_active", + ) + ordering = ("order",) + + +@admin.register(SubProduct) +class SubProductAdmin(admin.ModelAdmin): + list_display = ( + "name", + "main_product", + "distribution", + "show_on_homepage", + "homepage_order", + "order", + "is_active", + "created_at", + ) + list_filter = ("is_active", "distribution", "main_product", "show_on_homepage") + search_fields = ("name", "description", "main_product__name") + prepopulated_fields = {"slug": ("name",)} + list_editable = ("order", "is_active", "show_on_homepage", "homepage_order") + raw_id_fields = ("main_product",) + inlines = [SubProductArticleInline, SubProductVideoInline, SubProductVersionInline] + fieldsets = ( + ( + None, + { + "fields": ( + "main_product", + "name", + "slug", + "distribution", + "short_description", + "package_resource_button_text", + "package_source_button_text", + "downloads_section_link_text", + "downloads_section_link_url", + ) + }, + ), + ("Description", {"fields": ("description_format", "description")}), + ("Media", {"fields": ("image", "logo")}), + ("Homepage", {"fields": ("show_on_homepage", "homepage_order")}), + ("Settings", {"fields": ("order", "is_active")}), + ) + + +@admin.register(Article) +class ArticleAdmin(admin.ModelAdmin): + list_display = ("title", "parent", "order", "created_at") + list_filter = ("main_product", "sub_product__main_product") + search_fields = ("title", "description", "main_product__name", "sub_product__name") + list_editable = ("order",) + raw_id_fields = ("main_product", "sub_product") + inlines = [ArticleSectionInline, ArticleCitationInline] + fieldsets = ( + (None, {"fields": ("main_product", "sub_product", "badge", "title")}), + ("Description", {"fields": ("description_format", "description")}), + ("Citation Badge", {"fields": ("citation_count_display", "citation_count_label"), "description": "Number and optional label for the dashed citation circle badge. Both are optional — a label without a number shows an empty circle with the label; neither hides the badge entirely."}), + ("Settings", {"fields": ("order",)}), + ) + + @admin.display(description="Parent") + def parent(self, obj): + if obj.main_product_id: + return obj.main_product + if obj.sub_product_id: + return obj.sub_product + return "—" + + +@admin.register(ProductVideo) +class ProductVideoAdmin(admin.ModelAdmin): + list_display = ("title", "parent", "video_source", "video_size", "order", "is_active") + list_filter = ("video_source", "is_active", "main_product", "sub_product__main_product") + list_editable = ("order", "is_active") + search_fields = ("title", "description", "video_url", "main_product__name", "sub_product__name") + raw_id_fields = ("main_product", "sub_product") + readonly_fields = ("video_preview",) + fieldsets = ( + (None, {"fields": ("main_product", "sub_product", "badge", "title", "description_format", "description")}), + VIDEO_ADMIN_FIELDSET, + ("Settings", {"fields": ("order", "is_active")}), + ) + + @admin.display(description="Parent") + def parent(self, obj): + if obj.main_product_id: + return obj.main_product + if obj.sub_product_id: + return obj.sub_product + return "—" + + @admin.display(description="Preview") + def video_preview(self, obj): + return video_admin_preview(obj) + + +@admin.register(ArticleSection) +class ArticleSectionAdmin(admin.ModelAdmin): + list_display = ("title", "article", "value_format", "order") + search_fields = ("title", "value", "article__title") + list_editable = ("order",) + raw_id_fields = ("article",) + fieldsets = ( + (None, {"fields": ("article", "title")}), + ("Content", {"fields": ("value_format", "value")}), + ("Settings", {"fields": ("order",)}), + ) + + +@admin.register(SubProductVersion) +class SubProductVersionAdmin(admin.ModelAdmin): + list_display = ( + "parent", + "version", + "release_channel", + "channel_label", + "is_featured", + "show_on_product_page", + "is_active", + "order", + ) + list_filter = ("is_active", "release_channel", "is_featured", "show_on_product_page", "main_product", "sub_product__main_product") + search_fields = ("main_product__name", "sub_product__name", "version") + list_editable = ("is_active", "order") + raw_id_fields = ("main_product", "sub_product") + fieldsets = ( + ( + None, + { + "description": ( + "Set a preset channel badge (Stable, Beta, Previous, etc.) or write a custom " + "label. Mark one release as Primary (featured) for the main download block. " + "Enable “Show on product page” for additional inline channels such as beta or " + "previous versions." + ), + "fields": ( + "main_product", + "sub_product", + "version", + "release_channel", + "channel_label", + "is_featured", + "show_on_product_page", + ) + }, + ), + ( + "Installable downloads", + { + "description": ( + "For each platform, set an external URL, upload a file, both, or neither. " + "When a URL is set it is used on the site; otherwise an uploaded file is served. " + "To delete an uploaded file from the server, open this release, check Clear next to the file, and Save." + ), + "fields": ( + "windows_download_url", + "windows_download_file", + "macos_download_url", + "macos_download_file", + "linux_download_url", + "linux_download_file", + "source_code_url", + "source_code_file", + ), + }, + ), + ("Package link", {"fields": ("package_resource_url",)}), + ("Details", {"fields": ("release_notes",)}), + ("Settings", {"fields": ("is_active", "order")}), + ) + + @admin.display(description="Parent") + def parent(self, obj): + if obj.main_product_id: + return obj.main_product + if obj.sub_product_id: + return obj.sub_product + return "—" diff --git a/apps/products/apps.py b/apps/products/apps.py new file mode 100644 index 0000000..417a2ba --- /dev/null +++ b/apps/products/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class ProductsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.products" + verbose_name = "Products" + + def ready(self): + from . import signals # noqa: F401 diff --git a/apps/products/migrations/0001_initial.py b/apps/products/migrations/0001_initial.py new file mode 100644 index 0000000..34560ec --- /dev/null +++ b/apps/products/migrations/0001_initial.py @@ -0,0 +1,93 @@ +# Generated by Django 5.2.13 on 2026-04-27 09:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Article', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=300)), + ('description', models.TextField()), + ('order', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Article', + 'verbose_name_plural': 'Articles', + 'ordering': ['order', 'title'], + }, + ), + migrations.CreateModel( + name='MainProduct', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('slug', models.SlugField(blank=True, unique=True)), + ('short_description', models.CharField(max_length=300)), + ('description', models.TextField()), + ('image', models.ImageField(blank=True, null=True, upload_to='products/main/')), + ('order', models.PositiveIntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Main Product', + 'verbose_name_plural': 'Main Products', + 'ordering': ['order', 'name'], + }, + ), + migrations.CreateModel( + name='ArticleSection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('value', models.TextField()), + ('order', models.PositiveIntegerField(default=0)), + ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='products.article')), + ], + options={ + 'verbose_name': 'Article Section', + 'verbose_name_plural': 'Article Sections', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='SubProduct', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('slug', models.SlugField(blank=True)), + ('short_description', models.CharField(max_length=300)), + ('description', models.TextField()), + ('image', models.ImageField(blank=True, null=True, upload_to='products/sub/')), + ('order', models.PositiveIntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('main_product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sub_products', to='products.mainproduct')), + ], + options={ + 'verbose_name': 'Sub Product', + 'verbose_name_plural': 'Sub Products', + 'ordering': ['order', 'name'], + 'unique_together': {('main_product', 'slug')}, + }, + ), + migrations.AddField( + model_name='article', + name='sub_product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='articles', to='products.subproduct'), + ), + ] diff --git a/apps/products/migrations/0002_subproductrelease.py b/apps/products/migrations/0002_subproductrelease.py new file mode 100644 index 0000000..341ecc3 --- /dev/null +++ b/apps/products/migrations/0002_subproductrelease.py @@ -0,0 +1,34 @@ +# Generated by Django 5.0.2 on 2026-05-14 04:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='SubProductRelease', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('platform', models.CharField(choices=[('windows', 'Windows'), ('macos', 'macOS'), ('linux', 'Linux')], max_length=20)), + ('release_type', models.CharField(choices=[('stable', 'Stable'), ('previous', 'Previous')], default='stable', max_length=20)), + ('version', models.CharField(help_text='e.g. 2.1.0', max_length=50)), + ('download_url', models.URLField()), + ('release_notes', models.TextField(blank=True)), + ('is_active', models.BooleanField(default=True)), + ('order', models.PositiveIntegerField(default=0)), + ('sub_product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='releases', to='products.subproduct')), + ], + options={ + 'verbose_name': 'Release', + 'verbose_name_plural': 'Releases', + 'ordering': ['release_type', 'platform', 'order'], + 'unique_together': {('sub_product', 'platform', 'release_type')}, + }, + ), + ] diff --git a/apps/products/migrations/0003_article_description_format_and_more.py b/apps/products/migrations/0003_article_description_format_and_more.py new file mode 100644 index 0000000..72319b4 --- /dev/null +++ b/apps/products/migrations/0003_article_description_format_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.0.2 on 2026-05-14 05:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0002_subproductrelease'), + ] + + operations = [ + migrations.AddField( + model_name='article', + name='description_format', + field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20), + ), + migrations.AddField( + model_name='articlesection', + name='value_format', + field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20), + ), + migrations.AddField( + model_name='mainproduct', + name='description_format', + field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20), + ), + migrations.AddField( + model_name='subproduct', + name='description_format', + field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20), + ), + ] diff --git a/apps/products/migrations/0004_distribution_and_versions.py b/apps/products/migrations/0004_distribution_and_versions.py new file mode 100644 index 0000000..81cb321 --- /dev/null +++ b/apps/products/migrations/0004_distribution_and_versions.py @@ -0,0 +1,145 @@ +from django.db import migrations, models +import django.db.models.deletion + + +def migrate_legacy_releases_to_versions(apps, schema_editor): + OldRelease = apps.get_model("products", "SubProductRelease") + Version = apps.get_model("products", "SubProductVersion") + + PLATFORM_MAP = { + "windows": "windows_download_url", + "macos": "macos_download_url", + "linux": "linux_download_url", + } + + sub_ids = ( + OldRelease.objects.values_list("sub_product_id", flat=True).distinct().order_by() + ) + + for sub_id in sub_ids: + used_labels = set() + for release_type in ("stable", "previous"): + slab = OldRelease.objects.filter( + sub_product_id=sub_id, release_type=release_type + ).order_by("order", "pk") + if not slab.exists(): + continue + urls = {} + labels = [] + orders = [] + notes = [] + any_active = False + for r in slab: + orders.append(r.order) + if r.is_active: + any_active = True + f = PLATFORM_MAP.get(r.platform) + if f and r.download_url: + urls[f] = r.download_url + labels.append(r.version or "") + if (r.release_notes or "").strip(): + notes.append((r.release_notes or "").strip()) + vn = next((x for x in labels if x), None) or "1.0" + if vn in used_labels: + suffix = "older" if release_type == "previous" else "alternate" + candidate = f"{vn} ({suffix})" + n = 2 + while candidate in used_labels: + candidate = f"{vn} ({suffix} {n})" + n += 1 + vn = candidate + used_labels.add(vn) + Version.objects.create( + sub_product_id=sub_id, + version=vn, + is_featured_stable=(release_type == "stable"), + windows_download_url=urls.get("windows_download_url", ""), + macos_download_url=urls.get("macos_download_url", ""), + linux_download_url=urls.get("linux_download_url", ""), + source_code_url="", + package_resource_url="", + release_notes="\n\n".join(dict.fromkeys(notes)), + is_active=any_active, + order=min(orders) if orders else 0, + ) + + +def noop_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0003_article_description_format_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="subproduct", + name="distribution", + field=models.CharField( + choices=[ + ("installable", "Installable application"), + ("package", "Package (external / non-installable)"), + ], + default="installable", + help_text="Only affects how releases appear on the public site.", + max_length=20, + ), + ), + migrations.CreateModel( + name="SubProductVersion", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("version", models.CharField(max_length=80)), + ( + "is_featured_stable", + models.BooleanField( + default=False, + help_text="Highlighted as the main release on the product page.", + ), + ), + ("windows_download_url", models.URLField(blank=True)), + ("macos_download_url", models.URLField(blank=True)), + ("linux_download_url", models.URLField(blank=True)), + ("source_code_url", models.URLField(blank=True)), + ( + "package_resource_url", + models.URLField( + blank=True, + help_text="For package-type modules: external link for this release.", + ), + ), + ("release_notes", models.TextField(blank=True)), + ("is_active", models.BooleanField(default=True)), + ("order", models.PositiveIntegerField(default=0)), + ( + "sub_product", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="versions", + to="products.subproduct", + ), + ), + ], + options={ + "verbose_name": "Release version", + "verbose_name_plural": "Release versions", + "ordering": ["order", "version", "pk"], + "unique_together": {("sub_product", "version")}, + }, + ), + migrations.RunPython(migrate_legacy_releases_to_versions, noop_reverse), + migrations.DeleteModel( + name="SubProductRelease", + ), + ] diff --git a/apps/products/migrations/0005_article_badge.py b/apps/products/migrations/0005_article_badge.py new file mode 100644 index 0000000..8c6691b --- /dev/null +++ b/apps/products/migrations/0005_article_badge.py @@ -0,0 +1,22 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0004_distribution_and_versions"), + ] + + operations = [ + migrations.AddField( + model_name="article", + name="badge", + field=models.CharField( + blank=True, + default="", + help_text="Optional label shown above the article title (e.g. 'Overview', 'Note').", + max_length=100, + ), + preserve_default=False, + ), + ] diff --git a/apps/products/migrations/0006_subproduct_logo_homepage_citations.py b/apps/products/migrations/0006_subproduct_logo_homepage_citations.py new file mode 100644 index 0000000..a7b9f20 --- /dev/null +++ b/apps/products/migrations/0006_subproduct_logo_homepage_citations.py @@ -0,0 +1,49 @@ +# Generated by Django 5.0.2 on 2026-05-17 14:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0005_article_badge'), + ] + + operations = [ + migrations.AddField( + model_name='subproduct', + name='homepage_order', + field=models.PositiveIntegerField(default=0, help_text='Order within the homepage Products section.'), + ), + migrations.AddField( + model_name='subproduct', + name='logo', + field=models.ImageField(blank=True, help_text='Small logo shown as a corner badge on homepage product cards.', null=True, upload_to='products/sub_logos/'), + ), + migrations.AddField( + model_name='subproduct', + name='show_on_homepage', + field=models.BooleanField(default=False, help_text='Display this sub-product in the homepage Products section.'), + ), + migrations.AlterField( + model_name='subproductversion', + name='package_resource_url', + field=models.URLField(blank=True, help_text='Shown on the site when set. For Resources it is the main Open link; for Installable it appears under the platform grid.'), + ), + migrations.CreateModel( + name='ArticleCitation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField(help_text='Full citation text.')), + ('url', models.URLField(blank=True, help_text='Optional link to the cited source.')), + ('order', models.PositiveIntegerField(default=0)), + ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='citations', to='products.article')), + ], + options={ + 'verbose_name': 'Article Citation', + 'verbose_name_plural': 'Article Citations', + 'ordering': ['order', 'pk'], + }, + ), + ] diff --git a/apps/products/migrations/0007_article_citation_count_display.py b/apps/products/migrations/0007_article_citation_count_display.py new file mode 100644 index 0000000..d39c310 --- /dev/null +++ b/apps/products/migrations/0007_article_citation_count_display.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2026-05-17 15:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0006_subproduct_logo_homepage_citations'), + ] + + operations = [ + migrations.AddField( + model_name='article', + name='citation_count_display', + field=models.PositiveSmallIntegerField(blank=True, help_text='Optional number shown in the dashed citation circle badge at the bottom of the article card. Leave blank to hide the badge.', null=True), + ), + ] diff --git a/apps/products/migrations/0008_article_citation_count_label.py b/apps/products/migrations/0008_article_citation_count_label.py new file mode 100644 index 0000000..cbbf40f --- /dev/null +++ b/apps/products/migrations/0008_article_citation_count_label.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.2 on 2026-05-20 12:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0007_article_citation_count_display'), + ] + + operations = [ + migrations.AddField( + model_name='article', + name='citation_count_label', + field=models.CharField(blank=True, default='', help_text="Optional label shown below the number in the citation circle badge (e.g. 'cited'). Leave blank to show no label.", max_length=50), + ), + migrations.AlterField( + model_name='article', + name='citation_count_display', + field=models.PositiveSmallIntegerField(blank=True, help_text='Optional number shown in the dashed citation circle badge. Leave blank to show an empty circle (if a label is set) or hide the badge entirely.', null=True), + ), + ] diff --git a/apps/products/migrations/0009_article_main_product.py b/apps/products/migrations/0009_article_main_product.py new file mode 100644 index 0000000..a265535 --- /dev/null +++ b/apps/products/migrations/0009_article_main_product.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.2 on 2026-05-23 10:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0008_article_citation_count_label'), + ] + + operations = [ + migrations.AddField( + model_name='article', + name='main_product', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='articles', to='products.mainproduct'), + ), + migrations.AlterField( + model_name='article', + name='sub_product', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='articles', to='products.subproduct'), + ), + migrations.AddConstraint( + model_name='article', + constraint=models.CheckConstraint(check=models.Q(models.Q(('main_product__isnull', True), ('sub_product__isnull', False)), models.Q(('main_product__isnull', False), ('sub_product__isnull', True)), _connector='OR'), name='article_exactly_one_parent'), + ), + ] diff --git a/apps/products/migrations/0010_release_version_main_product.py b/apps/products/migrations/0010_release_version_main_product.py new file mode 100644 index 0000000..45d37b4 --- /dev/null +++ b/apps/products/migrations/0010_release_version_main_product.py @@ -0,0 +1,45 @@ +# Generated by Django 5.0.2 on 2026-05-23 10:31 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0009_article_main_product'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='subproductversion', + unique_together=set(), + ), + migrations.AddField( + model_name='mainproduct', + name='distribution', + field=models.CharField(choices=[('installable', 'Installable application'), ('package', 'Package (external / non-installable)')], default='installable', help_text='Only affects how releases appear on the public site.', max_length=20), + ), + migrations.AddField( + model_name='subproductversion', + name='main_product', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='products.mainproduct'), + ), + migrations.AlterField( + model_name='subproductversion', + name='sub_product', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='products.subproduct'), + ), + migrations.AddConstraint( + model_name='subproductversion', + constraint=models.CheckConstraint(check=models.Q(models.Q(('main_product__isnull', True), ('sub_product__isnull', False)), models.Q(('main_product__isnull', False), ('sub_product__isnull', True)), _connector='OR'), name='release_version_exactly_one_parent'), + ), + migrations.AddConstraint( + model_name='subproductversion', + constraint=models.UniqueConstraint(condition=models.Q(('sub_product__isnull', False)), fields=('sub_product', 'version'), name='release_version_unique_sub_product_version'), + ), + migrations.AddConstraint( + model_name='subproductversion', + constraint=models.UniqueConstraint(condition=models.Q(('main_product__isnull', False)), fields=('main_product', 'version'), name='release_version_unique_main_product_version'), + ), + ] diff --git a/apps/products/migrations/0011_mainproduct_homepage_order_and_more.py b/apps/products/migrations/0011_mainproduct_homepage_order_and_more.py new file mode 100644 index 0000000..74ae5cb --- /dev/null +++ b/apps/products/migrations/0011_mainproduct_homepage_order_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.2 on 2026-05-26 12:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0010_release_version_main_product'), + ] + + operations = [ + migrations.AddField( + model_name='mainproduct', + name='homepage_order', + field=models.PositiveIntegerField(default=0, help_text='Order within the homepage Products section.'), + ), + migrations.AddField( + model_name='mainproduct', + name='show_on_homepage', + field=models.BooleanField(default=False, help_text='Display this product in the homepage Products section.'), + ), + ] diff --git a/apps/products/migrations/0012_migrate_homepage_visibility_to_mainproduct.py b/apps/products/migrations/0012_migrate_homepage_visibility_to_mainproduct.py new file mode 100644 index 0000000..ef974c3 --- /dev/null +++ b/apps/products/migrations/0012_migrate_homepage_visibility_to_mainproduct.py @@ -0,0 +1,26 @@ +from django.db import migrations + + +def copy_subproduct_homepage_flags(apps, schema_editor): + SubProduct = apps.get_model("products", "SubProduct") + for sub in ( + SubProduct.objects.filter(show_on_homepage=True) + .select_related("main_product") + .order_by("homepage_order", "order") + ): + main = sub.main_product + if not main.show_on_homepage: + main.show_on_homepage = True + main.homepage_order = sub.homepage_order + main.save(update_fields=["show_on_homepage", "homepage_order"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0011_mainproduct_homepage_order_and_more"), + ] + + operations = [ + migrations.RunPython(copy_subproduct_homepage_flags, migrations.RunPython.noop), + ] diff --git a/apps/products/migrations/0013_package_release_button_labels.py b/apps/products/migrations/0013_package_release_button_labels.py new file mode 100644 index 0000000..b242665 --- /dev/null +++ b/apps/products/migrations/0013_package_release_button_labels.py @@ -0,0 +1,33 @@ +# Generated by Django 5.0.2 on 2026-05-31 09:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0012_migrate_homepage_visibility_to_mainproduct'), + ] + + operations = [ + migrations.AddField( + model_name='mainproduct', + name='package_resource_button_text', + field=models.CharField(default='PyPI', help_text='Label for the package resource button on package releases.', max_length=100), + ), + migrations.AddField( + model_name='mainproduct', + name='package_source_button_text', + field=models.CharField(default='Source', help_text='Label for the source code button on package releases.', max_length=100), + ), + migrations.AddField( + model_name='subproduct', + name='package_resource_button_text', + field=models.CharField(default='PyPI', help_text='Label for the package resource button on package releases.', max_length=100), + ), + migrations.AddField( + model_name='subproduct', + name='package_source_button_text', + field=models.CharField(default='Source', help_text='Label for the source code button on package releases.', max_length=100), + ), + ] diff --git a/apps/products/migrations/0014_release_download_files.py b/apps/products/migrations/0014_release_download_files.py new file mode 100644 index 0000000..e447e16 --- /dev/null +++ b/apps/products/migrations/0014_release_download_files.py @@ -0,0 +1,34 @@ +# Generated by Django 5.0.2 on 2026-05-31 09:42 + +import apps.products.release_assets +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0013_package_release_button_labels'), + ] + + operations = [ + migrations.AddField( + model_name='subproductversion', + name='linux_download_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', upload_to=apps.products.release_assets.release_file_upload_to), + ), + migrations.AddField( + model_name='subproductversion', + name='macos_download_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', upload_to=apps.products.release_assets.release_file_upload_to), + ), + migrations.AddField( + model_name='subproductversion', + name='source_code_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', upload_to=apps.products.release_assets.release_file_upload_to), + ), + migrations.AddField( + model_name='subproductversion', + name='windows_download_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', upload_to=apps.products.release_assets.release_file_upload_to), + ), + ] diff --git a/apps/products/migrations/0015_release_original_filenames.py b/apps/products/migrations/0015_release_original_filenames.py new file mode 100644 index 0000000..952edd3 --- /dev/null +++ b/apps/products/migrations/0015_release_original_filenames.py @@ -0,0 +1,54 @@ +# Generated by Django 5.0.2 on 2026-05-31 09:52 + +import apps.products.release_assets +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0014_release_download_files'), + ] + + operations = [ + migrations.AddField( + model_name='subproductversion', + name='linux_download_filename', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='subproductversion', + name='macos_download_filename', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='subproductversion', + name='source_code_filename', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='subproductversion', + name='windows_download_filename', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AlterField( + model_name='subproductversion', + name='linux_download_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_linux_file_upload_to), + ), + migrations.AlterField( + model_name='subproductversion', + name='macos_download_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_macos_file_upload_to), + ), + migrations.AlterField( + model_name='subproductversion', + name='source_code_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_source_file_upload_to), + ), + migrations.AlterField( + model_name='subproductversion', + name='windows_download_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_windows_file_upload_to), + ), + ] diff --git a/apps/products/migrations/0016_downloads_section_link.py b/apps/products/migrations/0016_downloads_section_link.py new file mode 100644 index 0000000..c98a704 --- /dev/null +++ b/apps/products/migrations/0016_downloads_section_link.py @@ -0,0 +1,45 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0015_release_original_filenames"), + ] + + operations = [ + migrations.AddField( + model_name="mainproduct", + name="downloads_section_link_text", + field=models.CharField( + blank=True, + help_text="Label for the optional downloads section link.", + max_length=100, + ), + ), + migrations.AddField( + model_name="mainproduct", + name="downloads_section_link_url", + field=models.URLField( + blank=True, + help_text="Optional link shown in the top-right corner of the downloads section.", + ), + ), + migrations.AddField( + model_name="subproduct", + name="downloads_section_link_text", + field=models.CharField( + blank=True, + help_text="Label for the optional downloads section link.", + max_length=100, + ), + ), + migrations.AddField( + model_name="subproduct", + name="downloads_section_link_url", + field=models.URLField( + blank=True, + help_text="Optional link shown in the top-right corner of the downloads section.", + ), + ), + ] diff --git a/apps/products/migrations/0017_alter_subproductversion_linux_download_file_and_more.py b/apps/products/migrations/0017_alter_subproductversion_linux_download_file_and_more.py new file mode 100644 index 0000000..8fa7f7d --- /dev/null +++ b/apps/products/migrations/0017_alter_subproductversion_linux_download_file_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.13 on 2026-06-07 13:49 + +import apps.products.release_assets +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0016_downloads_section_link'), + ] + + operations = [ + migrations.AlterField( + model_name='subproductversion', + name='linux_download_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. To remove an uploaded file, check Clear, then Save — the file is deleted from the server. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_linux_file_upload_to), + ), + migrations.AlterField( + model_name='subproductversion', + name='macos_download_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. To remove an uploaded file, check Clear, then Save — the file is deleted from the server. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_macos_file_upload_to), + ), + migrations.AlterField( + model_name='subproductversion', + name='source_code_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. To remove an uploaded file, check Clear, then Save — the file is deleted from the server. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_source_file_upload_to), + ), + migrations.AlterField( + model_name='subproductversion', + name='windows_download_file', + field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. To remove an uploaded file, check Clear, then Save — the file is deleted from the server. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_windows_file_upload_to), + ), + ] diff --git a/apps/products/migrations/0018_video_support.py b/apps/products/migrations/0018_video_support.py new file mode 100644 index 0000000..de6afc5 --- /dev/null +++ b/apps/products/migrations/0018_video_support.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.13 on 2026-06-08 12:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0017_alter_subproductversion_linux_download_file_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ProductVideo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('video_source', models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20)), + ('video_url', models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500)), + ('video_file', models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/')), + ('video_poster', models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/')), + ('video_size', models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10)), + ('video_aspect_ratio', models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10)), + ('badge', models.CharField(blank=True, max_length=100)), + ('title', models.CharField(blank=True, max_length=300)), + ('description', models.TextField(blank=True)), + ('order', models.PositiveIntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ('main_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='videos', to='products.mainproduct')), + ('sub_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='videos', to='products.subproduct')), + ], + options={ + 'verbose_name': 'Product Video', + 'verbose_name_plural': 'Product Videos', + 'ordering': ['order', 'pk'], + 'constraints': [models.CheckConstraint(condition=models.Q(models.Q(('main_product__isnull', True), ('sub_product__isnull', False)), models.Q(('main_product__isnull', False), ('sub_product__isnull', True)), _connector='OR'), name='product_video_exactly_one_parent')], + }, + ), + ] diff --git a/apps/products/migrations/0019_video_styled_background.py b/apps/products/migrations/0019_video_styled_background.py new file mode 100644 index 0000000..8a4bdb9 --- /dev/null +++ b/apps/products/migrations/0019_video_styled_background.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.13 on 2026-06-08 13:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0018_video_support'), + ] + + operations = [ + migrations.AddField( + model_name='productvideo', + name='video_styled_background', + field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'), + ), + ] diff --git a/apps/products/migrations/0020_video_description_format.py b/apps/products/migrations/0020_video_description_format.py new file mode 100644 index 0000000..14a3bb5 --- /dev/null +++ b/apps/products/migrations/0020_video_description_format.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.13 on 2026-06-08 13:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0019_video_styled_background'), + ] + + operations = [ + migrations.AddField( + model_name='productvideo', + name='description_format', + field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20), + ), + ] diff --git a/apps/products/migrations/0021_release_channels.py b/apps/products/migrations/0021_release_channels.py new file mode 100644 index 0000000..1e41dff --- /dev/null +++ b/apps/products/migrations/0021_release_channels.py @@ -0,0 +1,70 @@ +from django.db import migrations, models + + +def migrate_featured_stable_to_channels(apps, schema_editor): + Version = apps.get_model("products", "SubProductVersion") + for version in Version.objects.filter(is_featured_stable=True): + version.is_featured = True + if not version.release_channel: + version.release_channel = "stable" + version.save(update_fields=["is_featured", "release_channel"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0020_video_description_format"), + ] + + operations = [ + migrations.AddField( + model_name="subproductversion", + name="channel_label", + field=models.CharField( + blank=True, + help_text="Optional custom badge text. Overrides the preset channel label when set.", + max_length=50, + ), + ), + migrations.AddField( + model_name="subproductversion", + name="is_featured", + field=models.BooleanField( + default=False, + help_text="Primary release shown at the top of the downloads section.", + ), + ), + migrations.AddField( + model_name="subproductversion", + name="release_channel", + field=models.CharField( + blank=True, + choices=[ + ("", "None"), + ("stable", "Stable"), + ("beta", "Beta"), + ("rc", "Release candidate"), + ("preview", "Preview"), + ("nightly", "Nightly"), + ("current", "Current"), + ("previous", "Previous"), + ], + default="", + help_text="Preset badge for this release (Stable, Beta, Previous, etc.).", + max_length=20, + ), + ), + migrations.AddField( + model_name="subproductversion", + name="show_on_product_page", + field=models.BooleanField( + default=False, + help_text="Also show this release on the product page (e.g. beta or previous version).", + ), + ), + migrations.RunPython(migrate_featured_stable_to_channels, migrations.RunPython.noop), + migrations.RemoveField( + model_name="subproductversion", + name="is_featured_stable", + ), + ] diff --git a/apps/products/migrations/__init__.py b/apps/products/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/products/models.py b/apps/products/models.py new file mode 100644 index 0000000..5cb3f40 --- /dev/null +++ b/apps/products/models.py @@ -0,0 +1,600 @@ +import os + +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse +from django.utils.text import slugify + +from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content +from apps.core.video import VideoBlockMixin + +from .release_assets import ( + FILE_FIELD_BY_ASSET_KEY, + RELEASE_ASSET_KEY_BY_URL_FIELD, + RELEASE_FILE_BY_URL_FIELD, + RELEASE_FILE_HELP_TEXT, + RELEASE_ORIGINAL_FILENAME_BY_ASSET, + RELEASE_ORIGINAL_FILENAME_FIELDS, + release_file_storage, + release_linux_file_upload_to, + release_macos_file_upload_to, + release_source_file_upload_to, + release_windows_file_upload_to, +) + + +INSTALLABLE_PLATFORM_SPECS = ( + ("windows_download_url", "Windows", "images/icon-windows.svg"), + ("macos_download_url", "macOS", "images/icon-macos.svg"), + ("linux_download_url", "Linux", "images/icon-linux.svg"), + ("source_code_url", "Source code", None), +) + +DISTRIBUTION_INSTALLABLE = "installable" +DISTRIBUTION_PACKAGE = "package" +DISTRIBUTION_CHOICES = [ + (DISTRIBUTION_INSTALLABLE, "Installable application"), + (DISTRIBUTION_PACKAGE, "Package (external / non-installable)"), +] + +RELEASE_CHANNEL_STABLE = "stable" +RELEASE_CHANNEL_BETA = "beta" +RELEASE_CHANNEL_RC = "rc" +RELEASE_CHANNEL_PREVIEW = "preview" +RELEASE_CHANNEL_NIGHTLY = "nightly" +RELEASE_CHANNEL_CURRENT = "current" +RELEASE_CHANNEL_PREVIOUS = "previous" + +RELEASE_CHANNEL_CHOICES = [ + ("", "None"), + (RELEASE_CHANNEL_STABLE, "Stable"), + (RELEASE_CHANNEL_BETA, "Beta"), + (RELEASE_CHANNEL_RC, "Release candidate"), + (RELEASE_CHANNEL_PREVIEW, "Preview"), + (RELEASE_CHANNEL_NIGHTLY, "Nightly"), + (RELEASE_CHANNEL_CURRENT, "Current"), + (RELEASE_CHANNEL_PREVIOUS, "Previous"), +] + +RELEASE_CHANNEL_LABELS = { + RELEASE_CHANNEL_STABLE: "Stable", + RELEASE_CHANNEL_BETA: "Beta", + RELEASE_CHANNEL_RC: "Release candidate", + RELEASE_CHANNEL_PREVIEW: "Preview", + RELEASE_CHANNEL_NIGHTLY: "Nightly", + RELEASE_CHANNEL_CURRENT: "Current", + RELEASE_CHANNEL_PREVIOUS: "Previous", +} + + +class MainProduct(models.Model): + name = models.CharField(max_length=200) + slug = models.SlugField(unique=True, blank=True) + short_description = models.CharField(max_length=300) + description = models.TextField() + description_format = models.CharField( + max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN + ) + image = models.ImageField(upload_to="products/main/", blank=True, null=True) + distribution = models.CharField( + max_length=20, + choices=DISTRIBUTION_CHOICES, + default=DISTRIBUTION_INSTALLABLE, + help_text="Only affects how releases appear on the public site.", + ) + package_resource_button_text = models.CharField( + max_length=100, + default="PyPI", + help_text="Label for the package resource button on package releases.", + ) + package_source_button_text = models.CharField( + max_length=100, + default="Source", + help_text="Label for the source code button on package releases.", + ) + downloads_section_link_url = models.URLField( + blank=True, + help_text="Optional link shown in the top-right corner of the downloads section.", + ) + downloads_section_link_text = models.CharField( + max_length=100, + blank=True, + help_text="Label for the optional downloads section link.", + ) + order = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + show_on_homepage = models.BooleanField(default=False, help_text="Display this product in the homepage Products section.") + homepage_order = models.PositiveIntegerField(default=0, help_text="Order within the homepage Products section.") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["order", "name"] + verbose_name = "Main Product" + verbose_name_plural = "Main Products" + + @property + def rendered_description(self): + return render_content(self.description, self.description_format) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name) + super().save(*args, **kwargs) + + def get_absolute_url(self): + return reverse("products:main_product_detail", kwargs={"main_slug": self.slug}) + + def get_versions_archive_url(self): + return reverse( + "products:main_product_versions", + kwargs={"main_slug": self.slug}, + ) + + +class SubProduct(models.Model): + DISTRIBUTION_INSTALLABLE = DISTRIBUTION_INSTALLABLE + DISTRIBUTION_PACKAGE = DISTRIBUTION_PACKAGE + DISTRIBUTION_CHOICES = DISTRIBUTION_CHOICES + + main_product = models.ForeignKey( + MainProduct, + on_delete=models.CASCADE, + related_name="sub_products", + ) + name = models.CharField(max_length=200) + slug = models.SlugField(blank=True) + short_description = models.CharField(max_length=300) + description = models.TextField() + description_format = models.CharField( + max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN + ) + image = models.ImageField(upload_to="products/sub/", blank=True, null=True) + logo = models.ImageField(upload_to="products/sub_logos/", blank=True, null=True, help_text="Small logo shown as a corner badge on homepage product cards.") + order = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + show_on_homepage = models.BooleanField(default=False, help_text="Display this sub-product in the homepage Products section.") + homepage_order = models.PositiveIntegerField(default=0, help_text="Order within the homepage Products section.") + distribution = models.CharField( + max_length=20, + choices=DISTRIBUTION_CHOICES, + default=DISTRIBUTION_INSTALLABLE, + help_text="Only affects how releases appear on the public site.", + ) + package_resource_button_text = models.CharField( + max_length=100, + default="PyPI", + help_text="Label for the package resource button on package releases.", + ) + package_source_button_text = models.CharField( + max_length=100, + default="Source", + help_text="Label for the source code button on package releases.", + ) + downloads_section_link_url = models.URLField( + blank=True, + help_text="Optional link shown in the top-right corner of the downloads section.", + ) + downloads_section_link_text = models.CharField( + max_length=100, + blank=True, + help_text="Label for the optional downloads section link.", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["order", "name"] + unique_together = [["main_product", "slug"]] + verbose_name = "Sub Product" + verbose_name_plural = "Sub Products" + + @property + def rendered_description(self): + return render_content(self.description, self.description_format) + + def __str__(self): + return f"{self.main_product.name} › {self.name}" + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name) + super().save(*args, **kwargs) + + def get_absolute_url(self): + return reverse( + "products:sub_product_detail", + kwargs={ + "main_slug": self.main_product.slug, + "sub_slug": self.slug, + }, + ) + + def get_versions_archive_url(self): + return reverse( + "products:sub_product_versions", + kwargs={ + "main_slug": self.main_product.slug, + "sub_slug": self.slug, + }, + ) + + +class Article(models.Model): + main_product = models.ForeignKey( + MainProduct, + on_delete=models.CASCADE, + related_name="articles", + null=True, + blank=True, + ) + sub_product = models.ForeignKey( + SubProduct, + on_delete=models.CASCADE, + related_name="articles", + null=True, + blank=True, + ) + badge = models.CharField( + max_length=100, + blank=True, + help_text="Optional label shown above the article title (e.g. 'Overview', 'Note').", + ) + title = models.CharField(max_length=300) + description = models.TextField() + description_format = models.CharField( + max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN + ) + citation_count_display = models.PositiveSmallIntegerField( + null=True, + blank=True, + help_text="Optional number shown in the dashed citation circle badge. Leave blank to show an empty circle (if a label is set) or hide the badge entirely.", + ) + citation_count_label = models.CharField( + max_length=50, + blank=True, + default="", + help_text="Optional label shown below the number in the citation circle badge (e.g. 'cited'). Leave blank to show no label.", + ) + order = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["order", "title"] + verbose_name = "Article" + verbose_name_plural = "Articles" + constraints = [ + models.CheckConstraint( + check=( + models.Q(sub_product__isnull=False, main_product__isnull=True) + | models.Q(sub_product__isnull=True, main_product__isnull=False) + ), + name="article_exactly_one_parent", + ), + ] + + @property + def rendered_description(self): + return render_content(self.description, self.description_format) + + def clean(self): + super().clean() + has_main = self.main_product_id is not None + has_sub = self.sub_product_id is not None + if has_main == has_sub: + raise ValidationError( + "An article must belong to exactly one main product or sub-product." + ) + + def __str__(self): + return self.title + + +class SubProductVersion(models.Model): + main_product = models.ForeignKey( + MainProduct, + on_delete=models.CASCADE, + related_name="versions", + null=True, + blank=True, + ) + sub_product = models.ForeignKey( + SubProduct, + on_delete=models.CASCADE, + related_name="versions", + null=True, + blank=True, + ) + version = models.CharField(max_length=80) + release_channel = models.CharField( + max_length=20, + choices=RELEASE_CHANNEL_CHOICES, + blank=True, + default="", + help_text="Preset badge for this release (Stable, Beta, Previous, etc.).", + ) + channel_label = models.CharField( + max_length=50, + blank=True, + help_text="Optional custom badge text. Overrides the preset channel label when set.", + ) + is_featured = models.BooleanField( + default=False, + help_text="Primary release shown at the top of the downloads section.", + ) + show_on_product_page = models.BooleanField( + default=False, + help_text="Also show this release on the product page (e.g. beta or previous version).", + ) + windows_download_url = models.URLField(blank=True) + macos_download_url = models.URLField(blank=True) + linux_download_url = models.URLField(blank=True) + source_code_url = models.URLField(blank=True) + windows_download_file = models.FileField( + upload_to=release_windows_file_upload_to, + storage=release_file_storage, + blank=True, + help_text=RELEASE_FILE_HELP_TEXT, + ) + macos_download_file = models.FileField( + upload_to=release_macos_file_upload_to, + storage=release_file_storage, + blank=True, + help_text=RELEASE_FILE_HELP_TEXT, + ) + linux_download_file = models.FileField( + upload_to=release_linux_file_upload_to, + storage=release_file_storage, + blank=True, + help_text=RELEASE_FILE_HELP_TEXT, + ) + source_code_file = models.FileField( + upload_to=release_source_file_upload_to, + storage=release_file_storage, + blank=True, + help_text=RELEASE_FILE_HELP_TEXT, + ) + windows_download_filename = models.CharField(max_length=255, blank=True) + macos_download_filename = models.CharField(max_length=255, blank=True) + linux_download_filename = models.CharField(max_length=255, blank=True) + source_code_filename = models.CharField(max_length=255, blank=True) + package_resource_url = models.URLField( + blank=True, + help_text="Shown on the site when set. For Resources it is the main Open link; for Installable it appears under the platform grid.", + ) + release_notes = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["order", "version", "pk"] + verbose_name = "Release version" + verbose_name_plural = "Release versions" + constraints = [ + models.CheckConstraint( + check=( + models.Q(sub_product__isnull=False, main_product__isnull=True) + | models.Q(sub_product__isnull=True, main_product__isnull=False) + ), + name="release_version_exactly_one_parent", + ), + models.UniqueConstraint( + fields=["sub_product", "version"], + condition=models.Q(sub_product__isnull=False), + name="release_version_unique_sub_product_version", + ), + models.UniqueConstraint( + fields=["main_product", "version"], + condition=models.Q(main_product__isnull=False), + name="release_version_unique_main_product_version", + ), + ] + + def clean(self): + super().clean() + has_main = self.main_product_id is not None + has_sub = self.sub_product_id is not None + if has_main == has_sub: + raise ValidationError( + "A release version must belong to exactly one main product or sub-product." + ) + + def __str__(self): + parent = self.sub_product or self.main_product + return f"{parent.name} v{self.version}" + + @property + def display_channel_label(self): + custom = (self.channel_label or "").strip() + if custom: + return custom + if self.release_channel: + return RELEASE_CHANNEL_LABELS.get( + self.release_channel, + self.release_channel.replace("_", " ").title(), + ) + return "" + + @property + def display_channel_css_modifier(self): + if (self.channel_label or "").strip(): + return "custom" + return self.release_channel or "none" + + @property + def is_previous_channel(self): + if self.release_channel == RELEASE_CHANNEL_PREVIOUS: + return True + label = (self.channel_label or "").strip().lower() + return label == "previous" + + def save(self, *args, **kwargs): + for file_field, name_field in RELEASE_ORIGINAL_FILENAME_FIELDS.items(): + field_file = getattr(self, file_field) + if field_file and field_file._file is not None: + setattr(self, name_field, os.path.basename(field_file.name)) + elif not field_file: + setattr(self, name_field, "") + super().save(*args, **kwargs) + + def release_download_filename(self, asset): + name_field = RELEASE_ORIGINAL_FILENAME_BY_ASSET.get(asset) + if name_field: + stored = (getattr(self, name_field, "") or "").strip() + if stored: + return stored + file_field = FILE_FIELD_BY_ASSET_KEY.get(asset) + if file_field: + release_file = getattr(self, file_field) + if release_file: + return os.path.basename(release_file.name) + return "download" + + def has_platform_asset(self, url_field_name): + if (getattr(self, url_field_name) or "").strip(): + return True + file_field = RELEASE_FILE_BY_URL_FIELD.get(url_field_name) + if not file_field: + return False + return bool(getattr(self, file_field)) + + def resolve_asset_url(self, url_field_name): + url = (getattr(self, url_field_name) or "").strip() + if url: + return url + file_field = RELEASE_FILE_BY_URL_FIELD.get(url_field_name) + if not file_field or not getattr(self, file_field): + return "" + if not self.pk: + return "" + asset = RELEASE_ASSET_KEY_BY_URL_FIELD[url_field_name] + return reverse( + "products:release_asset_download", + kwargs={"version_id": self.pk, "asset": asset}, + ) + + def install_urls_for_specs(self, specs): + out = [] + for field_name, label, icon in specs: + url = self.resolve_asset_url(field_name) + if url: + out.append( + { + "field": field_name, + "label": label, + "icon": icon, + "url": url, + "external": bool((getattr(self, field_name) or "").strip()), + } + ) + return out + + def has_any_install_asset(self): + return any(self.has_platform_asset(f[0]) for f in INSTALLABLE_PLATFORM_SPECS) + + @property + def resolved_source_code_url(self): + return self.resolve_asset_url("source_code_url") + + def has_package_link(self): + return bool((self.package_resource_url or "").strip()) + + +class ArticleSection(models.Model): + article = models.ForeignKey( + Article, + on_delete=models.CASCADE, + related_name="sections", + ) + title = models.CharField(max_length=200) + value = models.TextField() + value_format = models.CharField( + max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN + ) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["order"] + verbose_name = "Article Section" + verbose_name_plural = "Article Sections" + + @property + def rendered_value(self): + return render_content(self.value, self.value_format) + + def __str__(self): + return f"{self.article.title} › {self.title}" + + +class ArticleCitation(models.Model): + article = models.ForeignKey( + Article, + on_delete=models.CASCADE, + related_name="citations", + ) + text = models.TextField(help_text="Full citation text.") + url = models.URLField(blank=True, help_text="Optional link to the cited source.") + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["order", "pk"] + verbose_name = "Article Citation" + verbose_name_plural = "Article Citations" + + def __str__(self): + return f"{self.article.title} — citation {self.order or self.pk}" + + +class ProductVideo(VideoBlockMixin, models.Model): + main_product = models.ForeignKey( + MainProduct, + on_delete=models.CASCADE, + related_name="videos", + null=True, + blank=True, + ) + sub_product = models.ForeignKey( + SubProduct, + on_delete=models.CASCADE, + related_name="videos", + null=True, + blank=True, + ) + badge = models.CharField(max_length=100, blank=True) + title = models.CharField(max_length=300, blank=True) + description = models.TextField(blank=True) + order = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["order", "pk"] + verbose_name = "Product Video" + verbose_name_plural = "Product Videos" + constraints = [ + models.CheckConstraint( + check=( + models.Q(sub_product__isnull=False, main_product__isnull=True) + | models.Q(sub_product__isnull=True, main_product__isnull=False) + ), + name="product_video_exactly_one_parent", + ), + ] + + def clean(self): + super().clean() + has_main = self.main_product_id is not None + has_sub = self.sub_product_id is not None + if has_main == has_sub: + raise ValidationError( + "A product video must belong to exactly one main product or sub-product." + ) + self.clean_video_fields(require=True) + + def __str__(self): + parent = self.sub_product or self.main_product + label = self.title or self.badge or "Video" + return f"{parent} › {label}" diff --git a/apps/products/release_assets.py b/apps/products/release_assets.py new file mode 100644 index 0000000..145438d --- /dev/null +++ b/apps/products/release_assets.py @@ -0,0 +1,84 @@ +import os + +from django.core.files.storage import FileSystemStorage +from django.utils.text import get_valid_filename + + +class ReleaseFileStorage(FileSystemStorage): + def get_available_name(self, name, max_length=None): + return name + + +release_file_storage = ReleaseFileStorage() + +RELEASE_FILE_BY_URL_FIELD = { + "windows_download_url": "windows_download_file", + "macos_download_url": "macos_download_file", + "linux_download_url": "linux_download_file", + "source_code_url": "source_code_file", +} + +RELEASE_ASSET_KEY_BY_URL_FIELD = { + "windows_download_url": "windows", + "macos_download_url": "macos", + "linux_download_url": "linux", + "source_code_url": "source", +} + +URL_FIELD_BY_ASSET_KEY = { + v: k for k, v in RELEASE_ASSET_KEY_BY_URL_FIELD.items() +} + +FILE_FIELD_BY_ASSET_KEY = { + asset: RELEASE_FILE_BY_URL_FIELD[url_field] + for url_field, asset in RELEASE_ASSET_KEY_BY_URL_FIELD.items() +} + +RELEASE_ASSET_KEYS = frozenset(FILE_FIELD_BY_ASSET_KEY.keys()) + +RELEASE_FILE_HELP_TEXT = ( + "Optional. Served from this site when the matching URL above is empty. " + "To remove an uploaded file, check Clear, then Save — the file is deleted from the server. " + "Any file type and size are allowed; large uploads may require web server limits." +) + +RELEASE_ORIGINAL_FILENAME_FIELDS = { + "windows_download_file": "windows_download_filename", + "macos_download_file": "macos_download_filename", + "linux_download_file": "linux_download_filename", + "source_code_file": "source_code_filename", +} + +RELEASE_ORIGINAL_FILENAME_BY_ASSET = { + RELEASE_ASSET_KEY_BY_URL_FIELD[url_field]: RELEASE_ORIGINAL_FILENAME_FIELDS[file_field] + for url_field, file_field in RELEASE_FILE_BY_URL_FIELD.items() +} + + +def _release_file_path(instance, filename, platform): + if instance.sub_product_id: + base = f"{instance.sub_product.main_product.slug}/{instance.sub_product.slug}" + else: + base = instance.main_product.slug + version_part = (instance.version or "release").replace("/", "-") + safe_name = get_valid_filename(os.path.basename(filename)) + return f"releases/{base}/{version_part}/{platform}/{safe_name}" + + +def release_windows_file_upload_to(instance, filename): + return _release_file_path(instance, filename, "windows") + + +def release_macos_file_upload_to(instance, filename): + return _release_file_path(instance, filename, "macos") + + +def release_linux_file_upload_to(instance, filename): + return _release_file_path(instance, filename, "linux") + + +def release_source_file_upload_to(instance, filename): + return _release_file_path(instance, filename, "source") + + +release_file_upload_to = release_windows_file_upload_to diff --git a/apps/products/release_context.py b/apps/products/release_context.py new file mode 100644 index 0000000..6b0f7b6 --- /dev/null +++ b/apps/products/release_context.py @@ -0,0 +1,161 @@ +from .models import DISTRIBUTION_INSTALLABLE, DISTRIBUTION_PACKAGE, INSTALLABLE_PLATFORM_SPECS + +DEFAULT_PACKAGE_RESOURCE_BUTTON_TEXT = "PyPI" +DEFAULT_PACKAGE_SOURCE_BUTTON_TEXT = "Source" + + +def package_button_labels(product): + resource = (getattr(product, "package_resource_button_text", "") or "").strip() + source = (getattr(product, "package_source_button_text", "") or "").strip() + return { + "package_resource_button_text": resource or DEFAULT_PACKAGE_RESOURCE_BUTTON_TEXT, + "package_source_button_text": source or DEFAULT_PACKAGE_SOURCE_BUTTON_TEXT, + } + + +def downloads_section_link(product): + url = (getattr(product, "downloads_section_link_url", "") or "").strip() + text = (getattr(product, "downloads_section_link_text", "") or "").strip() + if url and text: + return {"url": url, "text": text} + return None + + +def featured_version(qs): + ordered = qs.order_by("order", "pk") + cand = ordered.filter(is_featured=True).first() + return cand if cand else ordered.first() + + +def version_has_public_assets(version): + return version.has_any_install_asset() or bool((version.package_resource_url or "").strip()) + + +def install_specs_from_versions(version_list): + present = set() + for ver in version_list: + for field_name, *_ in INSTALLABLE_PLATFORM_SPECS: + if ver.has_platform_asset(field_name): + present.add(field_name) + return tuple(s for s in INSTALLABLE_PLATFORM_SPECS if s[0] in present) + + +def build_installable_channel_block(version): + if not version_has_public_assets(version): + return None + block = { + "version": version, + "install_cells": [], + "is_previous": version.is_previous_channel, + } + if version.has_any_install_asset(): + specs = install_specs_from_versions([version]) + block["install_cells"] = version.install_urls_for_specs(specs) + return block + + +def build_release_context(distribution, active_versions, product): + context = { + "distribution_installable": distribution == DISTRIBUTION_INSTALLABLE, + "distribution_package": distribution == DISTRIBUTION_PACKAGE, + "show_releases_section": False, + "featured_version": None, + "featured_channel": None, + "featured_install_cells": [], + "inline_download_channels": [], + "show_older_versions_link": False, + "package_versions": [], + **package_button_labels(product), + "downloads_section_link": downloads_section_link(product), + } + + if distribution == DISTRIBUTION_INSTALLABLE: + featured = featured_version(active_versions) + if featured and not version_has_public_assets(featured): + for cand in active_versions.exclude(pk=featured.pk).order_by("order", "pk"): + if version_has_public_assets(cand): + featured = cand + break + context["featured_version"] = featured + if featured and version_has_public_assets(featured): + featured_block = build_installable_channel_block(featured) + if featured_block: + context["featured_channel"] = featured_block + context["featured_install_cells"] = featured_block["install_cells"] + context["show_releases_section"] = True + + inline_blocks = [] + for ver in active_versions.order_by("order", "pk"): + if featured and ver.pk == featured.pk: + continue + if not ver.show_on_product_page: + continue + block = build_installable_channel_block(ver) + if block: + inline_blocks.append(block) + context["inline_download_channels"] = inline_blocks + + shown_pks = {featured.pk} if featured else set() + shown_pks.update(block["version"].pk for block in inline_blocks) + older_qs = active_versions.order_by("order", "pk") + if shown_pks: + older_qs = older_qs.exclude(pk__in=shown_pks) + context["show_older_versions_link"] = older_qs.exists() + + elif distribution == DISTRIBUTION_PACKAGE: + pkg_versions = list(active_versions.order_by("order", "pk")) + context["package_versions"] = pkg_versions + context["show_releases_section"] = len(pkg_versions) > 0 + + return context + + +def build_archive_context(distribution, active_versions, product): + featured = featured_version(active_versions) + inline_pks = set( + active_versions.filter(show_on_product_page=True).values_list("pk", flat=True) + ) + exclude_pks = set() + if featured: + exclude_pks.add(featured.pk) + exclude_pks.update(inline_pks) + archive = list( + active_versions.exclude(pk__in=exclude_pks).order_by("order", "pk") + if exclude_pks + else active_versions.order_by("order", "pk") + ) + context = { + "featured_version": featured, + "archive_versions": archive, + **package_button_labels(product), + } + + if distribution == DISTRIBUTION_INSTALLABLE: + specs = install_specs_from_versions(archive) + context["archive_specs"] = specs + archive_rows_installable = [] + for ver in archive: + cells = [] + for field_name, label, icon in specs: + cells.append( + { + "field": field_name, + "label": label, + "icon": icon, + "url": ver.resolve_asset_url(field_name), + "external": bool((getattr(ver, field_name) or "").strip()), + } + ) + archive_rows_installable.append({"version_obj": ver, "cells": cells}) + context["archive_rows_installable"] = archive_rows_installable + context["archive_rows_package"] = False + context["distribution_installable"] = True + context["distribution_package"] = False + else: + context["archive_specs"] = () + context["archive_rows_installable"] = [] + context["archive_rows_package"] = True + context["distribution_installable"] = False + context["distribution_package"] = True + + return context diff --git a/apps/products/signals.py b/apps/products/signals.py new file mode 100644 index 0000000..0ac231d --- /dev/null +++ b/apps/products/signals.py @@ -0,0 +1,37 @@ +from django.db.models.signals import post_delete, pre_save +from django.dispatch import receiver + +from .models import SubProductVersion +from .release_assets import RELEASE_FILE_BY_URL_FIELD + + +def _stored_release_file_name(version, file_field): + release_file = getattr(version, file_field) + return release_file.name if release_file else "" + + +def _delete_release_file_from_disk(version, file_field): + release_file = getattr(version, file_field) + if release_file: + release_file.delete(save=False) + + +@receiver(pre_save, sender=SubProductVersion) +def delete_replaced_or_cleared_release_files(sender, instance, **kwargs): + if not instance.pk: + return + try: + previous = SubProductVersion.objects.get(pk=instance.pk) + except SubProductVersion.DoesNotExist: + return + for file_field in RELEASE_FILE_BY_URL_FIELD.values(): + old_name = _stored_release_file_name(previous, file_field) + new_name = _stored_release_file_name(instance, file_field) + if old_name and old_name != new_name: + _delete_release_file_from_disk(previous, file_field) + + +@receiver(post_delete, sender=SubProductVersion) +def delete_release_files_on_version_delete(sender, instance, **kwargs): + for file_field in RELEASE_FILE_BY_URL_FIELD.values(): + _delete_release_file_from_disk(instance, file_field) diff --git a/apps/products/tests/__init__.py b/apps/products/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/products/tests/test_admin.py b/apps/products/tests/test_admin.py new file mode 100644 index 0000000..f827b46 --- /dev/null +++ b/apps/products/tests/test_admin.py @@ -0,0 +1,77 @@ +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse + +from apps.products.models import Article, ArticleSection, MainProduct, SubProduct + + +class AdminAccessTest(TestCase): + def setUp(self): + self.superuser = User.objects.create_superuser( + username="admin", + email="admin@example.com", + password="securepassword123", + ) + self.client.login(username="admin", password="securepassword123") + self.main_product = MainProduct.objects.create( + name="Test Product", + short_description="Short", + description="Description", + ) + self.sub_product = SubProduct.objects.create( + main_product=self.main_product, + name="Sub Product", + short_description="Short sub", + description="Sub description", + ) + self.article = Article.objects.create( + sub_product=self.sub_product, + title="Test Article", + description="Article body", + ) + self.section = ArticleSection.objects.create( + article=self.article, + title="Link", + value="https://example.com", + ) + + def test_main_product_changelist_accessible(self): + url = reverse("admin:products_mainproduct_changelist") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_sub_product_changelist_accessible(self): + url = reverse("admin:products_subproduct_changelist") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_article_changelist_accessible(self): + url = reverse("admin:products_article_changelist") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_article_section_changelist_accessible(self): + url = reverse("admin:products_articlesection_changelist") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_main_product_change_accessible(self): + url = reverse("admin:products_mainproduct_change", args=[self.main_product.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_sub_product_change_accessible(self): + url = reverse("admin:products_subproduct_change", args=[self.sub_product.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_article_change_accessible(self): + url = reverse("admin:products_article_change", args=[self.article.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_admin_requires_authentication(self): + self.client.logout() + url = reverse("admin:products_mainproduct_changelist") + response = self.client.get(url) + self.assertNotEqual(response.status_code, 200) diff --git a/apps/products/tests/test_models.py b/apps/products/tests/test_models.py new file mode 100644 index 0000000..825bc0a --- /dev/null +++ b/apps/products/tests/test_models.py @@ -0,0 +1,258 @@ +from django.test import TestCase +from django.urls import reverse + +from django.core.exceptions import ValidationError + +from apps.products.models import ( + Article, + ArticleSection, + MainProduct, + SubProduct, + SubProductVersion, +) + + +class MainProductModelTest(TestCase): + def setUp(self): + self.product = MainProduct.objects.create( + name="Test Product", + short_description="Short description", + description="Full description of the test product.", + ) + + def test_str_representation(self): + self.assertEqual(str(self.product), "Test Product") + + def test_slug_auto_generated_on_create(self): + self.assertEqual(self.product.slug, "test-product") + + def test_slug_not_overwritten_on_update(self): + self.product.name = "Changed Name" + self.product.save() + self.assertEqual(self.product.slug, "test-product") + + def test_default_order_is_zero(self): + self.assertEqual(self.product.order, 0) + + def test_default_is_active_is_true(self): + self.assertTrue(self.product.is_active) + + def test_get_absolute_url(self): + url = self.product.get_absolute_url() + self.assertEqual(url, reverse("products:main_product_detail", kwargs={"main_slug": "test-product"})) + + def test_slug_uniqueness(self): + from django.db import IntegrityError + with self.assertRaises(IntegrityError): + MainProduct.objects.create( + name="Test Product", + slug="test-product", + short_description="Another", + description="Another", + ) + + def test_timestamps_set_on_create(self): + self.assertIsNotNone(self.product.created_at) + self.assertIsNotNone(self.product.updated_at) + + +class SubProductModelTest(TestCase): + def setUp(self): + self.main_product = MainProduct.objects.create( + name="Main Product", + short_description="Short", + description="Description", + ) + self.sub_product = SubProduct.objects.create( + main_product=self.main_product, + name="Sub Product", + short_description="Sub short", + description="Sub description.", + ) + + def test_str_representation(self): + self.assertIn("Main Product", str(self.sub_product)) + self.assertIn("Sub Product", str(self.sub_product)) + + def test_slug_auto_generated(self): + self.assertEqual(self.sub_product.slug, "sub-product") + + def test_get_absolute_url(self): + url = self.sub_product.get_absolute_url() + expected = reverse( + "products:sub_product_detail", + kwargs={"main_slug": "main-product", "sub_slug": "sub-product"}, + ) + self.assertEqual(url, expected) + + def test_cascade_delete_with_main_product(self): + main_pk = self.main_product.pk + sub_pk = self.sub_product.pk + self.main_product.delete() + self.assertFalse(SubProduct.objects.filter(pk=sub_pk).exists()) + self.assertFalse(MainProduct.objects.filter(pk=main_pk).exists()) + + +class ArticleModelTest(TestCase): + def setUp(self): + self.main_product = MainProduct.objects.create( + name="Main", + short_description="Short", + description="Desc", + ) + self.sub_product = SubProduct.objects.create( + main_product=self.main_product, + name="Sub", + short_description="Short", + description="Desc", + ) + self.article = Article.objects.create( + sub_product=self.sub_product, + title="Test Article", + description="Article body.", + ) + + def test_str_representation(self): + self.assertEqual(str(self.article), "Test Article") + + def test_cascade_delete_with_sub_product(self): + article_pk = self.article.pk + self.sub_product.delete() + self.assertFalse(Article.objects.filter(pk=article_pk).exists()) + + def test_main_product_article(self): + article = Article.objects.create( + main_product=self.main_product, + title="Product Article", + description="Body.", + ) + self.assertEqual(article.main_product, self.main_product) + self.assertIsNone(article.sub_product_id) + + def test_cascade_delete_with_main_product(self): + article = Article.objects.create( + main_product=self.main_product, + title="Product Article", + description="Body.", + ) + article_pk = article.pk + self.main_product.delete() + self.assertFalse(Article.objects.filter(pk=article_pk).exists()) + + def test_requires_exactly_one_parent(self): + article = Article( + main_product=self.main_product, + sub_product=self.sub_product, + title="Invalid", + description="Body.", + ) + with self.assertRaises(ValidationError): + article.full_clean() + + def test_requires_a_parent(self): + article = Article(title="Orphan", description="Body.") + with self.assertRaises(ValidationError): + article.full_clean() + + +class ReleaseFileStorageTest(TestCase): + def test_save_overwrites_existing_release_file(self): + from django.core.files.uploadedfile import SimpleUploadedFile + + from apps.products.models import MainProduct, SubProductVersion + + main = MainProduct.objects.create( + name="Storage Test", + short_description="s", + description="d", + ) + version = SubProductVersion.objects.create( + main_product=main, + version="1.0", + windows_download_file=SimpleUploadedFile("setup.exe", b"v1"), + is_active=True, + ) + version.windows_download_file.save( + "setup.exe", + SimpleUploadedFile("setup.exe", b"v2"), + save=True, + ) + version.refresh_from_db() + with version.windows_download_file.open("rb") as handle: + self.assertEqual(handle.read(), b"v2") + + +class SubProductVersionModelTest(TestCase): + def setUp(self): + self.main_product = MainProduct.objects.create( + name="Main", + short_description="Short", + description="Desc", + ) + self.sub_product = SubProduct.objects.create( + main_product=self.main_product, + name="Sub", + short_description="Short", + description="Desc", + ) + + def test_main_product_version(self): + version = SubProductVersion.objects.create( + main_product=self.main_product, + version="1.0.0", + ) + self.assertEqual(version.main_product, self.main_product) + self.assertIsNone(version.sub_product_id) + + def test_requires_exactly_one_parent(self): + version = SubProductVersion( + main_product=self.main_product, + sub_product=self.sub_product, + version="1.0.0", + ) + with self.assertRaises(ValidationError): + version.full_clean() + + def test_display_channel_label_uses_custom_text(self): + from apps.products.models import RELEASE_CHANNEL_BETA + + version = SubProductVersion.objects.create( + main_product=self.main_product, + version="2.0", + release_channel=RELEASE_CHANNEL_BETA, + channel_label="Preview build", + ) + self.assertEqual(version.display_channel_label, "Preview build") + self.assertEqual(version.display_channel_css_modifier, "custom") + + def test_display_channel_label_uses_preset(self): + from apps.products.models import RELEASE_CHANNEL_PREVIOUS + + version = SubProductVersion.objects.create( + sub_product=self.sub_product, + version="1.0", + release_channel=RELEASE_CHANNEL_PREVIOUS, + ) + self.assertEqual(version.display_channel_label, "Previous") + self.assertTrue(version.is_previous_channel) + + +class ArticleSectionModelTest(TestCase): + def setUp(self): + main = MainProduct.objects.create(name="M", short_description="s", description="d") + sub = SubProduct.objects.create(main_product=main, name="S", short_description="s", description="d") + self.article = Article.objects.create(sub_product=sub, title="Article", description="Desc") + self.section = ArticleSection.objects.create( + article=self.article, + title="Link", + value="https://example.com", + ) + + def test_str_representation(self): + self.assertIn("Article", str(self.section)) + self.assertIn("Link", str(self.section)) + + def test_cascade_delete_with_article(self): + section_pk = self.section.pk + self.article.delete() + self.assertFalse(ArticleSection.objects.filter(pk=section_pk).exists()) diff --git a/apps/products/tests/test_views.py b/apps/products/tests/test_views.py new file mode 100644 index 0000000..817fdfb --- /dev/null +++ b/apps/products/tests/test_views.py @@ -0,0 +1,350 @@ +import os + +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings +from django.urls import reverse + +from apps.products.models import Article, ArticleSection, MainProduct, SubProduct, SubProductVersion + + +class ProductViewsSetup(TestCase): + def setUp(self): + self.main_product = MainProduct.objects.create( + name="Tecvico", + slug="tecvico", + short_description="Radiomics software", + description="Full description.", + ) + self.sub_product = SubProduct.objects.create( + main_product=self.main_product, + name="Image Processing", + slug="image-processing", + short_description="Filtering and registration", + description="Sub description.", + ) + self.article = Article.objects.create( + sub_product=self.sub_product, + title="Filtering", + description="Article body.", + ) + ArticleSection.objects.create( + article=self.article, + title="Standard", + value="IBSI 2.0", + ) + + +class ProductOverviewViewTest(ProductViewsSetup): + def test_overview_returns_200(self): + response = self.client.get(reverse("products:overview")) + self.assertEqual(response.status_code, 200) + + def test_overview_uses_correct_template(self): + response = self.client.get(reverse("products:overview")) + self.assertTemplateUsed(response, "products/overview.html") + + def test_overview_contains_main_product(self): + response = self.client.get(reverse("products:overview")) + self.assertIn(self.main_product, response.context["main_products"]) + + def test_inactive_product_excluded(self): + inactive = MainProduct.objects.create( + name="Inactive", + short_description="s", + description="d", + is_active=False, + ) + response = self.client.get(reverse("products:overview")) + self.assertNotIn(inactive, response.context["main_products"]) + + +class MainProductDetailViewTest(ProductViewsSetup): + def test_detail_returns_200(self): + url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_detail_uses_correct_template(self): + url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"}) + response = self.client.get(url) + self.assertTemplateUsed(response, "products/main_detail.html") + + def test_detail_contains_product_in_context(self): + url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"}) + response = self.client.get(url) + self.assertEqual(response.context["main_product"], self.main_product) + + def test_nonexistent_slug_returns_404(self): + url = reverse("products:main_product_detail", kwargs={"main_slug": "nonexistent"}) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_inactive_product_returns_404(self): + self.main_product.is_active = False + self.main_product.save() + url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"}) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_detail_articles_in_context(self): + main_article = Article.objects.create( + main_product=self.main_product, + title="Overview", + description="Main product article.", + ) + url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"}) + response = self.client.get(url) + self.assertIn("articles", response.context) + self.assertIn(main_article, list(response.context["articles"])) + self.assertNotIn(self.article, list(response.context["articles"])) + + +class SubProductDetailViewTest(ProductViewsSetup): + def test_sub_detail_returns_200(self): + url = reverse( + "products:sub_product_detail", + kwargs={"main_slug": "tecvico", "sub_slug": "image-processing"}, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_sub_detail_uses_correct_template(self): + url = reverse( + "products:sub_product_detail", + kwargs={"main_slug": "tecvico", "sub_slug": "image-processing"}, + ) + response = self.client.get(url) + self.assertTemplateUsed(response, "products/sub_detail.html") + + def test_sub_detail_context_keys(self): + url = reverse( + "products:sub_product_detail", + kwargs={"main_slug": "tecvico", "sub_slug": "image-processing"}, + ) + response = self.client.get(url) + self.assertIn("main_product", response.context) + self.assertIn("sub_product", response.context) + self.assertIn("articles", response.context) + self.assertIn("siblings", response.context) + + def test_sub_detail_articles_in_context(self): + url = reverse( + "products:sub_product_detail", + kwargs={"main_slug": "tecvico", "sub_slug": "image-processing"}, + ) + response = self.client.get(url) + self.assertIn(self.article, list(response.context["articles"])) + + def test_nonexistent_sub_slug_returns_404(self): + url = reverse( + "products:sub_product_detail", + kwargs={"main_slug": "tecvico", "sub_slug": "does-not-exist"}, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_mismatched_main_slug_returns_404(self): + url = reverse( + "products:sub_product_detail", + kwargs={"main_slug": "wrong-product", "sub_slug": "image-processing"}, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + +class SubProductOlderVersionsViewTest(ProductViewsSetup): + def test_versions_returns_200(self): + from apps.products.models import SubProductVersion + + SubProductVersion.objects.create( + sub_product=self.sub_product, + version="9.9", + is_featured=True, + release_channel="stable", + windows_download_url="https://example.com/w", + is_active=True, + ) + SubProductVersion.objects.create( + sub_product=self.sub_product, + version="9.8", + is_featured=False, + windows_download_url="https://example.com/w2", + is_active=True, + ) + url = reverse( + "products:sub_product_versions", + kwargs={"main_slug": "tecvico", "sub_slug": "image-processing"}, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "products/versions_archive.html") + + def test_main_versions_returns_200(self): + from apps.products.models import SubProductVersion + + SubProductVersion.objects.create( + main_product=self.main_product, + version="9.9", + is_featured=True, + release_channel="stable", + windows_download_url="https://example.com/win.exe", + is_active=True, + ) + SubProductVersion.objects.create( + main_product=self.main_product, + version="9.8", + is_active=True, + ) + url = reverse( + "products:main_product_versions", + kwargs={"main_slug": "tecvico"}, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "products/versions_archive.html") + + def test_main_detail_releases_in_context(self): + from apps.products.models import SubProductVersion + + SubProductVersion.objects.create( + main_product=self.main_product, + version="1.0", + windows_download_url="https://example.com/win.exe", + is_active=True, + ) + url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"}) + response = self.client.get(url) + self.assertTrue(response.context["show_releases_section"]) + + def test_installable_shows_release_channel_badges(self): + from apps.products.models import RELEASE_CHANNEL_BETA, RELEASE_CHANNEL_PREVIOUS, SubProductVersion + + SubProductVersion.objects.create( + main_product=self.main_product, + version="2.0", + release_channel=RELEASE_CHANNEL_BETA, + is_featured=True, + windows_download_url="https://example.com/beta.exe", + is_active=True, + ) + SubProductVersion.objects.create( + main_product=self.main_product, + version="1.9", + release_channel=RELEASE_CHANNEL_PREVIOUS, + show_on_product_page=True, + windows_download_url="https://example.com/prev.exe", + is_active=True, + ) + url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"}) + response = self.client.get(url) + self.assertContains(response, "Beta") + self.assertContains(response, "Previous") + self.assertEqual(len(response.context["inline_download_channels"]), 1) + + def test_custom_channel_label_overrides_preset(self): + from apps.products.models import RELEASE_CHANNEL_STABLE, SubProductVersion + + SubProductVersion.objects.create( + main_product=self.main_product, + version="3.0", + release_channel=RELEASE_CHANNEL_STABLE, + channel_label="Early Access", + is_featured=True, + windows_download_url="https://example.com/ea.exe", + is_active=True, + ) + url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"}) + response = self.client.get(url) + self.assertContains(response, "Early Access") + self.assertNotContains(response, ">Stable<") + + +@override_settings(MEDIA_ROOT=settings.BASE_DIR / "test_media_releases") +class ReleaseAssetDownloadViewTest(ProductViewsSetup): + def test_uploaded_file_download(self): + release_file = SimpleUploadedFile("setup.exe", b"binary-payload") + version = SubProductVersion.objects.create( + main_product=self.main_product, + version="2.0", + windows_download_file=release_file, + is_active=True, + ) + url = reverse( + "products:release_asset_download", + kwargs={"version_id": version.pk, "asset": "windows"}, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(b"".join(response.streaming_content), b"binary-payload") + self.assertIn("attachment", response["Content-Disposition"]) + self.assertIn("setup.exe", response["Content-Disposition"]) + + def test_external_url_blocks_file_download(self): + version = SubProductVersion.objects.create( + main_product=self.main_product, + version="2.1", + windows_download_url="https://example.com/win.exe", + windows_download_file=SimpleUploadedFile("local.exe", b"x"), + is_active=True, + ) + url = reverse( + "products:release_asset_download", + kwargs={"version_id": version.pk, "asset": "windows"}, + ) + self.assertEqual(self.client.get(url).status_code, 404) + + def test_product_page_uses_file_when_no_url(self): + version = SubProductVersion.objects.create( + main_product=self.main_product, + version="3.0", + windows_download_file=SimpleUploadedFile("win.tar.gz", b"gz"), + is_active=True, + ) + detail_url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"}) + response = self.client.get(detail_url) + download_url = reverse( + "products:release_asset_download", + kwargs={"version_id": version.pk, "asset": "windows"}, + ) + self.assertContains(response, download_url) + + def test_inactive_version_returns_404(self): + version = SubProductVersion.objects.create( + main_product=self.main_product, + version="0.1", + windows_download_file=SimpleUploadedFile("a.exe", b"a"), + is_active=False, + ) + url = reverse( + "products:release_asset_download", + kwargs={"version_id": version.pk, "asset": "windows"}, + ) + self.assertEqual(self.client.get(url).status_code, 404) + + def test_clearing_file_removes_from_disk(self): + version = SubProductVersion.objects.create( + main_product=self.main_product, + version="4.0", + windows_download_file=SimpleUploadedFile("remove-me.exe", b"data"), + is_active=True, + ) + stored_path = version.windows_download_file.path + self.assertTrue(stored_path) + version.windows_download_file = "" + version.save() + version.refresh_from_db() + self.assertFalse(version.windows_download_file) + self.assertEqual(version.windows_download_filename, "") + self.assertFalse(os.path.exists(stored_path)) + + def test_deleting_version_removes_files_from_disk(self): + version = SubProductVersion.objects.create( + main_product=self.main_product, + version="5.0", + windows_download_file=SimpleUploadedFile("gone.exe", b"data"), + is_active=True, + ) + stored_path = version.windows_download_file.path + version.delete() + self.assertFalse(os.path.exists(stored_path)) diff --git a/apps/products/urls.py b/apps/products/urls.py new file mode 100644 index 0000000..e94b4c2 --- /dev/null +++ b/apps/products/urls.py @@ -0,0 +1,34 @@ +from django.urls import path + +from . import views + +app_name = "products" + +urlpatterns = [ + path("", views.ProductOverviewView.as_view(), name="overview"), + path( + "releases///download/", + views.ReleaseAssetDownloadView.as_view(), + name="release_asset_download", + ), + path( + "/", + views.MainProductDetailView.as_view(), + name="main_product_detail", + ), + path( + "/versions/", + views.MainProductOlderVersionsView.as_view(), + name="main_product_versions", + ), + path( + "//versions/", + views.SubProductOlderVersionsView.as_view(), + name="sub_product_versions", + ), + path( + "//", + views.SubProductDetailView.as_view(), + name="sub_product_detail", + ), +] diff --git a/apps/products/views.py b/apps/products/views.py new file mode 100644 index 0000000..ec7e52f --- /dev/null +++ b/apps/products/views.py @@ -0,0 +1,155 @@ +import mimetypes + +from django.http import FileResponse, Http404 +from django.shortcuts import get_object_or_404 +from django.views import View +from django.views.generic import DetailView, ListView, TemplateView + +from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct, SubProductVersion +from .release_assets import FILE_FIELD_BY_ASSET_KEY, RELEASE_ASSET_KEYS, URL_FIELD_BY_ASSET_KEY +from .release_context import build_archive_context, build_release_context + + +class ReleaseAssetDownloadView(View): + def get(self, request, version_id, asset): + if asset not in RELEASE_ASSET_KEYS: + raise Http404 + version = get_object_or_404(SubProductVersion, pk=version_id, is_active=True) + parent = version.sub_product or version.main_product + if parent is None or not parent.is_active: + raise Http404 + url_field = URL_FIELD_BY_ASSET_KEY[asset] + if (getattr(version, url_field) or "").strip(): + raise Http404 + file_field = FILE_FIELD_BY_ASSET_KEY[asset] + release_file = getattr(version, file_field) + if not release_file: + raise Http404 + filename = version.release_download_filename(asset) + content_type, _ = mimetypes.guess_type(filename) + return FileResponse( + release_file.open("rb"), + as_attachment=True, + filename=filename, + content_type=content_type or "application/octet-stream", + ) + + +class ProductOverviewView(ListView): + model = MainProduct + template_name = "products/overview.html" + context_object_name = "main_products" + queryset = MainProduct.objects.filter(is_active=True).prefetch_related( + "sub_products" + ) + + +class MainProductDetailView(DetailView): + model = MainProduct + template_name = "products/main_detail.html" + context_object_name = "main_product" + slug_url_kwarg = "main_slug" + queryset = MainProduct.objects.filter(is_active=True).prefetch_related( + "sub_products" + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["articles"] = self.object.articles.prefetch_related( + "sections", "citations" + ).all() + context["product_videos"] = self.object.videos.filter(is_active=True).order_by("order") + release_context = build_release_context( + self.object.distribution, + self.object.versions.filter(is_active=True), + self.object, + ) + release_context["versions_archive_url"] = self.object.get_versions_archive_url() + context.update(release_context) + return context + + +class MainProductOlderVersionsView(TemplateView): + template_name = "products/versions_archive.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + main_product = get_object_or_404( + MainProduct, + slug=self.kwargs["main_slug"], + is_active=True, + ) + active_versions = main_product.versions.filter(is_active=True) + context["main_product"] = main_product + context["sub_product"] = None + context["product_detail_url"] = main_product.get_absolute_url() + context.update( + build_archive_context( + main_product.distribution, active_versions, main_product + ) + ) + return context + + +class SubProductDetailView(TemplateView): + template_name = "products/sub_detail.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + main_product = get_object_or_404( + MainProduct, + slug=self.kwargs["main_slug"], + is_active=True, + ) + sub_product = get_object_or_404( + SubProduct, + slug=self.kwargs["sub_slug"], + main_product=main_product, + is_active=True, + ) + context["main_product"] = main_product + context["sub_product"] = sub_product + context["articles"] = sub_product.articles.prefetch_related( + "sections", "citations" + ).all() + context["product_videos"] = sub_product.videos.filter(is_active=True).order_by("order") + context["siblings"] = ( + SubProduct.objects.filter(main_product=main_product, is_active=True) + .exclude(pk=sub_product.pk) + .order_by("order", "name") + ) + active_versions = sub_product.versions.filter(is_active=True) + release_context = build_release_context( + sub_product.distribution, active_versions, sub_product + ) + release_context["versions_archive_url"] = sub_product.get_versions_archive_url() + context.update(release_context) + return context + + +class SubProductOlderVersionsView(TemplateView): + template_name = "products/versions_archive.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + main_product = get_object_or_404( + MainProduct, + slug=self.kwargs["main_slug"], + is_active=True, + ) + sub_product = get_object_or_404( + SubProduct, + slug=self.kwargs["sub_slug"], + main_product=main_product, + is_active=True, + ) + active_versions = sub_product.versions.filter(is_active=True) + context["main_product"] = main_product + context["sub_product"] = sub_product + context["product_detail_url"] = sub_product.get_absolute_url() + context.update( + build_archive_context( + sub_product.distribution, active_versions, sub_product + ) + ) + return context diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..fc9906d --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") + +application = get_asgi_application() diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings/base.py b/config/settings/base.py new file mode 100644 index 0000000..b6d59bb --- /dev/null +++ b/config/settings/base.py @@ -0,0 +1,134 @@ +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +SECRET_KEY = os.environ.get( + "DJANGO_SECRET_KEY", + "django-insecure-base-key-override-in-production", +) + +DEBUG = False + +ALLOWED_HOSTS = [] + +DJANGO_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "whitenoise.runserver_nostatic", + "django.contrib.staticfiles", +] + +LOCAL_APPS = [ + "apps.core", + "apps.products", + "apps.pages", +] + +INSTALLED_APPS = DJANGO_APPS + LOCAL_APPS + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "apps.core.context_processors.site_branding", + "apps.core.context_processors.site_contact", + "apps.core.context_processors.navigation", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("POSTGRES_DB", "tecvico"), + "USER": os.environ.get("POSTGRES_USER", "tecvico_user"), + "PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""), + "HOST": os.environ.get("POSTGRES_HOST", "localhost"), + "PORT": os.environ.get("POSTGRES_PORT", "5432"), + "CONN_MAX_AGE": 60, + "OPTIONS": { + "connect_timeout": 10, + }, + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STATICFILES_DIRS = [BASE_DIR / "static"] +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" + +CONTACT_UPLOAD_ROOT = BASE_DIR / "private_uploads" / "contact" +CONTACT_ATTACHMENT_MAX_SIZE = 10 * 1024 * 1024 +CONTACT_ATTACHMENT_MAX_COUNT = 3 + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "root": { + "handlers": ["console"], + "level": "WARNING", + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": os.environ.get("DJANGO_LOG_LEVEL", "WARNING"), + "propagate": False, + }, + }, +} diff --git a/config/settings/development.py b/config/settings/development.py new file mode 100644 index 0000000..d02c049 --- /dev/null +++ b/config/settings/development.py @@ -0,0 +1,37 @@ +import os + +from dotenv import load_dotenv + +load_dotenv() + +from .base import * # noqa: F401, F403, E402 + +DEBUG = True + +SECRET_KEY = os.environ.get( + "DJANGO_SECRET_KEY", + "django-insecure-development-key-not-for-production-use", +) + +ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0").split(",") + +STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage" + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": {"class": "logging.StreamHandler"}, + }, + "root": { + "handlers": ["console"], + "level": "DEBUG", + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + }, +} diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 0000000..2aedb58 --- /dev/null +++ b/config/settings/production.py @@ -0,0 +1,25 @@ +import os + +from .base import * # noqa: F401, F403 + +SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] + +DEBUG = False + +ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",") + +SECURE_HSTS_SECONDS = 31536000 +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True +SECURE_SSL_REDIRECT = os.environ.get("SECURE_SSL_REDIRECT", "True") == "True" +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SECURE_BROWSER_XSS_FILTER = True +SECURE_CONTENT_TYPE_NOSNIFF = True +X_FRAME_OPTIONS = "DENY" + +CSRF_TRUSTED_ORIGINS = [ + origin.strip() + for origin in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") + if origin.strip() +] diff --git a/config/settings/test.py b/config/settings/test.py new file mode 100644 index 0000000..9cd8e83 --- /dev/null +++ b/config/settings/test.py @@ -0,0 +1,3 @@ +from .development import * # noqa: F401, F403 + +DATABASES["default"]["CONN_MAX_AGE"] = 0 # noqa: F405 diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..d669ebb --- /dev/null +++ b/config/urls.py @@ -0,0 +1,19 @@ +import os + +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path + +admin.site.site_header = "Tecvico Administration" +admin.site.site_title = "Tecvico Admin" +admin.site.index_title = "Welcome to Tecvico Administration" + +urlpatterns = [ + path("admin/", admin.site.urls), + path("products/", include("apps.products.urls", namespace="products")), + path("", include("apps.pages.urls", namespace="pages")), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..ee192cf --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") + +application = get_wsgi_application() diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..6efdf33 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,9 @@ +services: + web: + build: . + environment: + DJANGO_SETTINGS_MODULE: config.settings.development + volumes: + - .:/app + - media_data:/app/media + command: python manage.py runserver 0.0.0.0:8000 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fa43ee6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + volumes: + - postgres_data:/var/lib/postgresql/data + env_file: + - .env + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + + web: + build: . + restart: unless-stopped + ports: + - "8000:8000" + env_file: + - .env + environment: + DJANGO_SETTINGS_MODULE: config.settings.production + POSTGRES_HOST: db + volumes: + - ./media:/app/media + - ./staticfiles:/app/staticfiles + depends_on: + db: + condition: service_healthy + +volumes: + postgres_data: + media_data: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..54c1211 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,35 @@ +#!/bin/sh +set -e + +echo "Waiting for PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}..." +until python -c " +import os, psycopg2, sys +try: + psycopg2.connect( + dbname=os.environ.get('POSTGRES_DB', 'tecvico'), + user=os.environ.get('POSTGRES_USER', 'tecvico_user'), + password=os.environ.get('POSTGRES_PASSWORD', ''), + host=os.environ.get('POSTGRES_HOST', 'db'), + port=os.environ.get('POSTGRES_PORT', '5432'), + ) + sys.exit(0) +except Exception: + sys.exit(1) +"; do + echo "Database not ready. Retrying in 2 seconds..." + sleep 2 +done + +echo "PostgreSQL is ready." + +echo "Collecting static files..." +python manage.py collectstatic --noinput + +echo "Running database migrations..." +python manage.py migrate --noinput + +echo "Creating superuser if not exists..." +python manage.py ensure_superuser + +echo "Starting application..." +exec "$@" diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..3b6f940 --- /dev/null +++ b/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +import os +import sys + + +def main(): + if len(sys.argv) > 1 and sys.argv[1] == "test": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.test") + else: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..9b9cd60 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +DJANGO_SETTINGS_MODULE = config.settings.development +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --tb=short diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..43ac282 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest>=8.3.0 +pytest-django>=4.9.0 +coverage>=7.6.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ffab9d4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Django>=5.1,<6.0 +psycopg2-binary>=2.9.9 +gunicorn>=22.0.0 +whitenoise[brotli]>=6.7.0 +Pillow>=10.4.0 +python-dotenv>=1.0.1 +markdown>=3.6 diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..2f29340 --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,3840 @@ +/* ============================================================ + CSS Custom Properties — Tecvico Theme + ============================================================ */ +:root { + --bg-primary: #ffffff; + --bg-secondary: #f3f4f6; + --bg-surface: #ffffff; + + --glass-bg: #ffffff; + --glass-bg-hover: #f9fafb; + --glass-bg-strong: #eaedff; + --glass-border: #f3f4f6; + --glass-border-hover: #3051ff; + --glass-blur: none; + --glass-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); + --glass-shadow-hover: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); + + --accent-blue: #3051ff; + --accent-blue-light: #3051ff; + --accent-blue-dark: #0933df; + --accent-cyan: #3051ff; + --accent-purple: #cc9428; + --accent-gold: #cc9428; + --accent-glow-blue: rgba(48, 81, 255, 0.12); + --accent-glow-purple: rgba(204, 148, 40, 0.12); + + --gradient-brand: #3051ff; + --gradient-hero-bg: transparent; + --gradient-text: #3051ff; + + --text-primary: #1d2433; + --text-secondary: rgba(29, 36, 51, 0.72); + --text-muted: #9ca3af; + --text-link: #3051ff; + + --footer-bg: #1f2937; + --footer-bottom-bg: #000000; + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-pill: 999px; + + --spacing-xs: 0.5rem; + --spacing-sm: 1rem; + --spacing-md: 1.5rem; + --spacing-lg: 2.5rem; + --spacing-xl: 4rem; + --spacing-2xl: 7rem; + + --navbar-height: 72px; + --navbar-expand-duration: 0.85s; + --navbar-link-duration: 0.28s; + --navbar-expand-ease: cubic-bezier(0.4, 0, 0.1, .9); + --container-max: 1200px; + --container-padding: 1.5rem; + + --transition-fast: all 0.18s cubic-bezier(0.4, 0, 0.2, 1); + --transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + --transition-bounce: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); + + --font-sans: 'Ubuntu', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-heading: 'Open Sans', 'Ubuntu', sans-serif; +} + +/* ============================================================ + Reset & Base + ============================================================ */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; + font-size: 16px; +} + +body { + font-family: var(--font-sans); + background-color: var(--bg-primary); + color: var(--text-primary); + line-height: 1.65; + min-height: 100vh; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; +} + +body::before { + content: none; +} + +#main-content { + position: relative; + z-index: 1; +} + +img { max-width: 100%; height: auto; display: block; } +a { color: var(--text-link); text-decoration: none; transition: var(--transition-fast); } +a:hover { color: var(--accent-blue-light); } +ul, ol { list-style: none; } +address { font-style: normal; } + +.sr-only { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; +} + +/* ============================================================ + Typography + ============================================================ */ +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-heading); + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.02em; + color: var(--text-primary); +} + +.gradient-text { + color: var(--accent-blue); +} + +/* ============================================================ + Layout + ============================================================ */ +.container { + max-width: var(--container-max); + margin: 0 auto; + padding: 0 var(--container-padding); +} + +.section { + padding: var(--spacing-2xl) 0; + position: relative; +} + +.section-header { + text-align: center; + max-width: 680px; + margin: 0 auto var(--spacing-xl); +} + +.section-title { + font-size: clamp(1.75rem, 4vw, 2.5rem); + margin-bottom: var(--spacing-sm); +} + +.section-desc { + color: var(--text-secondary); + font-size: 1.05rem; + line-height: 1.7; +} + +/* ============================================================ + Badges + ============================================================ */ +.section-badge { + display: inline-block; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--accent-gold); + background: rgba(204, 148, 40, 0.1); + border: 1px solid rgba(204, 148, 40, 0.22); + padding: 0.3rem 0.85rem; + border-radius: var(--radius-pill); + margin-bottom: var(--spacing-sm); +} + +/* ============================================================ + Glass Card Component + ============================================================ */ +.glass-card { + background: #ffffff; + border: none; + border-radius: var(--radius-md); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); + transition: var(--transition-smooth); + overflow: hidden; +} + +.glass-card:hover { + background: #ffffff; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); + transform: scale(1.02); +} + +/* ============================================================ + Buttons + ============================================================ */ +.btn-primary, +.btn-ghost { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.7rem 1.6rem; + border-radius: var(--radius-pill); + font-size: 0.9rem; + font-weight: 600; + font-family: var(--font-sans); + cursor: pointer; + border: none; + transition: var(--transition-smooth); + white-space: nowrap; + text-decoration: none; +} + +.btn-primary { + background: var(--accent-blue); + color: #fff; + border: 2px solid transparent; + border-radius: var(--radius-sm); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); +} + +.btn-primary:hover { + color: var(--accent-blue); + background: transparent; + border-color: var(--accent-blue); + box-shadow: none; + transform: none; +} + +.btn-ghost { + background: #fff; + color: var(--accent-blue-dark); + border: 2px solid transparent; + border-radius: var(--radius-sm); +} + +.btn-ghost:hover { + background: var(--accent-blue); + border-color: var(--accent-blue); + color: #fff; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); + transform: none; +} + +.btn-sm { + padding: 0.5rem 1.1rem; + font-size: 0.82rem; +} + +/* ============================================================ + Animated Blob Shapes + ============================================================ */ +.blob { + display: none; +} + +.blob--1 { + width: 520px; height: 520px; + background: radial-gradient(circle, rgba(48, 81, 255, 0.18) 0%, rgba(48, 81, 255, 0.05) 70%); + top: -120px; left: -120px; + animation-delay: 0s, 0s; +} + +.blob--2 { + width: 440px; height: 440px; + background: radial-gradient(circle, rgba(167, 139, 250, 0.15) 0%, rgba(167, 139, 250, 0.04) 70%); + top: 80px; right: -100px; + animation-delay: 0.3s, 3s; + animation-duration: 1.2s, 18s; +} + +.blob--3 { + width: 340px; height: 340px; + background: radial-gradient(circle, rgba(34, 211, 238, 0.12) 0%, rgba(34, 211, 238, 0.03) 70%); + bottom: 0; left: 35%; + animation-delay: 0.6s, 6s; + animation-duration: 1.2s, 20s; +} + +@keyframes blob-fade-in { + from { opacity: 0; transform: scale(0.8); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes blob-morph { + 0%, 100% { border-radius: 60% 40% 30% 70% / 60% 30% 70% 40%; } + 20% { border-radius: 30% 60% 70% 40% / 50% 60% 30% 60%; } + 40% { border-radius: 50% 60% 30% 70% / 40% 70% 60% 50%; } + 60% { border-radius: 40% 50% 60% 40% / 70% 30% 50% 60%; } + 80% { border-radius: 60% 40% 50% 60% / 30% 60% 40% 70%; } +} + +/* ============================================================ + Navbar + ============================================================ */ +.navbar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + height: var(--navbar-height); + display: flex; + align-items: center; + background: #ffffff; + border-bottom: 2px solid var(--glass-border); + transition: var(--transition-smooth); +} + +.navbar.scrolled { + background: #ffffff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.navbar-container { + max-width: var(--container-max); + width: 100%; + margin: 0 auto; + padding: 0 var(--container-padding); + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.navbar-brand { + display: flex; + align-items: center; + gap: 0.6rem; + text-decoration: none; + flex-shrink: 0; +} + +.brand-icon { + width: auto; + height: 40px; + max-width: 184px; + border-radius: 0; + object-fit: contain; + display: block; + flex-shrink: 0; +} + +.footer-logo .brand-icon { + height: 48px; + max-width: 184px; +} + +.brand-name { + display: none; +} + +.navbar-menu { + display: flex; + align-items: center; +} + +.nav-list { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.nav-link { + display: flex; + align-items: center; + gap: 0.3rem; + padding: 0.45rem 0.85rem; + border-radius: var(--radius-sm); + font-size: 1.05rem; + font-weight: 500; + color: var(--text-primary); + text-decoration: none; + transition: color var(--navbar-link-duration) var(--navbar-expand-ease), + background var(--navbar-link-duration) var(--navbar-expand-ease); + white-space: nowrap; +} + +.nav-link:hover, +.nav-link.active { + color: var(--accent-blue); + background: transparent; +} + +.nav-link--cta { + color: #fff !important; + background: var(--accent-blue); + border: 2px solid var(--accent-blue); + border-radius: var(--radius-sm); + padding: 0.5rem 1.25rem; +} + +.nav-arrow { + transition: transform var(--navbar-expand-duration) var(--navbar-expand-ease); +} + +.nav-item.has-megamenu:hover .nav-arrow, +.nav-item.has-megamenu.open .nav-arrow { + transform: rotate(180deg); +} +.nav-item.has-megamenu { + position: static; +} + +.megamenu { + position: fixed; + left: 0; + right: 0; + top: var(--navbar-height); + background: #ffffff; + border-bottom: 1px solid var(--glass-border); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); + opacity: 0; + visibility: hidden; + transform: translateY(-10px); + transition: opacity var(--navbar-expand-duration) var(--navbar-expand-ease), + visibility var(--navbar-expand-duration) var(--navbar-expand-ease), + transform var(--navbar-expand-duration) var(--navbar-expand-ease); + pointer-events: none; + z-index: 999; +} + +.nav-item.has-megamenu:hover .megamenu, +.nav-item.has-megamenu.open .megamenu { + opacity: 1; + visibility: visible; + transform: translateY(0); + pointer-events: auto; +} + +.megamenu.is-closing { + pointer-events: auto; +} + +.megamenu-inner { + max-width: var(--container-max); + margin: 0 auto; + padding: 1.5rem var(--container-padding); +} + +.megamenu-products-scroll { + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + scrollbar-color: rgba(48, 81, 255, 0.45) transparent; + padding-bottom: 0.35rem; +} + +.megamenu-products-scroll::-webkit-scrollbar { + height: 6px; +} + +.megamenu-products-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.megamenu-products-scroll::-webkit-scrollbar-thumb { + background: rgba(48, 81, 255, 0.35); + border-radius: 999px; +} + +.megamenu-products-scroll::-webkit-scrollbar-thumb:hover { + background: rgba(48, 81, 255, 0.55); +} + +.megamenu-products { + display: flex; + align-items: flex-start; + gap: 1rem; + list-style: none; + margin: 0; + padding: 0; + min-width: min-content; +} + +.megamenu-product-item { + flex: 0 0 220px; + max-width: 220px; + border-radius: var(--radius-md); + border: 1px solid var(--glass-border); + background: var(--glass-bg); + transition: var(--transition-fast); +} + +.megamenu-product-item:hover, +.megamenu-product-item:focus-within { + border-color: rgba(48, 81, 255, 0.35); + background: rgba(48, 81, 255, 0.06); +} + +.megamenu-product-link { + display: block; + padding: 1rem; + text-decoration: none; +} + +.megamenu-product-name { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.95rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 0.35rem; +} + +.megamenu-product-link:hover .megamenu-product-name, +.megamenu-product-item:focus-within > .megamenu-product-link .megamenu-product-name { + color: var(--accent-blue-light); +} + +.megamenu-sub-chevron { + flex-shrink: 0; + color: var(--text-muted); + transition: transform var(--navbar-expand-duration) var(--navbar-expand-ease), + color var(--navbar-expand-duration) var(--navbar-expand-ease); +} + +.megamenu-product-item.has-subproducts:hover .megamenu-sub-chevron, +.megamenu-product-item.has-subproducts:focus-within .megamenu-sub-chevron { + transform: rotate(180deg); + color: var(--accent-blue-light); +} + +.megamenu-product-desc { + font-size: 0.78rem; + color: var(--text-muted); + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.megamenu-sublist { + list-style: none; + margin: 0; + padding: 0; + border-top: 1px solid transparent; + max-height: 0; + overflow: hidden; + opacity: 0; + transition: max-height var(--navbar-expand-duration) var(--navbar-expand-ease), + opacity var(--navbar-expand-duration) var(--navbar-expand-ease), + border-color var(--navbar-expand-duration) var(--navbar-expand-ease); +} + +.megamenu-product-item.has-subproducts:hover .megamenu-sublist, +.megamenu-product-item.has-subproducts:focus-within .megamenu-sublist { + max-height: 320px; + opacity: 1; + border-top-color: var(--glass-border); +} + +.megamenu-sublink { + display: block; + padding: 0.45rem 1rem; + font-size: 0.82rem; + color: var(--text-secondary); + text-decoration: none; + transition: var(--transition-fast); +} + +.megamenu-sublink:hover, +.megamenu-sublink:focus-visible { + color: var(--accent-blue-light); + background: rgba(48, 81, 255, 0.08); +} + +.megamenu-sublink:last-child { + border-radius: 0 0 var(--radius-md) var(--radius-md); +} + +/* ============================================================ + Navbar Toggle (mobile) + ============================================================ */ +.navbar-toggle { + display: none; + flex-direction: column; + gap: 5px; + background: none; + border: none; + cursor: pointer; + padding: 6px; + border-radius: var(--radius-sm); + transition: var(--transition-fast); +} + +.navbar-toggle:hover { + background: var(--glass-bg); +} + +.toggle-bar { + display: block; + width: 22px; height: 2px; + background: var(--text-primary); + border-radius: 2px; + transition: var(--transition-smooth); +} + +.navbar-toggle.open .toggle-bar:nth-child(1) { + transform: translateY(7px) rotate(45deg); +} +.navbar-toggle.open .toggle-bar:nth-child(2) { + opacity: 0; transform: scaleX(0); +} +.navbar-toggle.open .toggle-bar:nth-child(3) { + transform: translateY(-7px) rotate(-45deg); +} + +/* ============================================================ + SVG Blob Image Decorations + ============================================================ */ +.blob-img { + position: absolute; + pointer-events: none; + z-index: 0; +} + +.blob-img--hero-left { + width: 420px; + top: auto; + bottom: 0; + left: -80px; + opacity: 0.4; + filter: none; + animation: none; +} + +.blob-img--hero-right { + width: 380px; + top: 15%; + right: -60px; + opacity: 0.01; + filter: blur(1px); + animation: blob-morph 20s ease-in-out infinite reverse; +} + +.blob-img--page-hero { + width: 500px; + top: -50px; + right: -100px; + opacity: 0.15; + filter: blur(3px); + animation: blob-morph 18s ease-in-out infinite; +} + +.blob-img--page-corner { + width: 280px; + bottom: -30px; + left: 5%; + opacity: 0.1; + filter: blur(2px); + animation: blob-morph 22s ease-in-out infinite reverse; +} + +.blob-img--downloads { + width: 600px; + top: -80px; + right: -120px; + opacity: 0.12; + filter: blur(4px); +} + +/* ============================================================ + Hero + ============================================================ */ +.hero { + position: relative; + min-height: auto; + display: flex; + align-items: center; + padding-top: calc(var(--navbar-height) + 2.5rem); + overflow: hidden; + padding-bottom: var(--spacing-xl); + background: #ffffff; +} + +.hero-blobs { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; +} + +.hero-content { + position: relative; + z-index: 2; + width: 100%; + max-width: var(--container-max); + padding-top: var(--spacing-lg); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2.5rem; + align-items: center; +} + +.hero-text { + max-width: 620px; +} + +.hero-badge { + display: inline-block; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--accent-gold); + background: rgba(204, 148, 40, 0.1); + border: 1px solid rgba(204, 148, 40, 0.22); + padding: 0.3rem 0.85rem; + border-radius: var(--radius-pill); + margin-bottom: 1.5rem; + animation: fade-slide-up 0.8s ease-out 0.1s both; +} + +.hero-title { + font-family: var(--font-heading); + font-size: clamp(2rem, 4.5vw, 3rem); + font-weight: 800; + line-height: 1.15; + letter-spacing: -0.03em; + margin-bottom: 1.25rem; + color: var(--text-primary); + animation: fade-slide-up 0.8s ease-out 0.2s both; +} + +.hero-subtitle { + font-size: clamp(1rem, 2.5vw, 1.15rem); + color: var(--accent-blue); + font-weight: 500; + margin-bottom: 1.25rem; + animation: fade-slide-up 0.8s ease-out 0.3s both; +} + +.hero-description { + font-size: 1.05rem; + color: var(--text-secondary); + max-width: 600px; + line-height: 1.75; + margin-bottom: 2.5rem; + animation: fade-slide-up 0.8s ease-out 0.4s both; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 1rem; + animation: fade-slide-up 0.8s ease-out 0.5s both; +} + +/* ============================================================ + Page Hero (inner pages) + ============================================================ */ +.page-hero { + position: relative; + padding: calc(var(--navbar-height) + var(--spacing-2xl)) 0 var(--spacing-xl); + overflow: hidden; + background: #ffffff; +} + +.page-hero-blobs { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; +} + +.page-hero-content { + position: relative; + z-index: 2; + max-width: 700px; +} + +.page-hero-title { + font-size: clamp(2rem, 5vw, 3.5rem); + font-weight: 800; + letter-spacing: -0.04em; + margin-bottom: 1rem; + animation: fade-slide-up 0.7s ease-out 0.1s both; +} + +.page-hero-subtitle { + font-size: 1.1rem; + color: var(--text-secondary); + line-height: 1.65; + max-width: 560px; + animation: fade-slide-up 0.7s ease-out 0.2s both; +} + +/* ============================================================ + Animations + ============================================================ */ +@keyframes fade-slide-up { + from { opacity: 0; transform: translateY(22px); } + to { opacity: 1; transform: translateY(0); } +} + +.fade-in { + opacity: 0; + transform: translateY(20px); + transition: opacity 0.6s ease, transform 0.6s ease; +} + +.fade-in.visible { + opacity: 1; + transform: translateY(0); +} + +.section-header--center { + text-align: center; + margin-left: auto; + margin-right: auto; +} + +.section-header--center .section-desc { + margin-left: auto; + margin-right: auto; +} + +/* ============================================================ + Projects Showcase + ============================================================ */ +.projects-showcase-section { + background: var(--bg-secondary); +} + +.projects-filter { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; + margin-bottom: 2rem; +} + +.projects-filter-btn { + border: 1px solid var(--glass-border); + background: #ffffff; + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 0.95rem; + font-weight: 500; + padding: 0.55rem 1.25rem; + border-radius: var(--radius-pill); + cursor: pointer; + transition: var(--transition-fast); +} + +.projects-filter-btn:hover, +.projects-filter-btn.is-active { + background: var(--accent-blue); + border-color: var(--accent-blue); + color: #ffffff; +} + +.projects-showcase-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.projects-showcase-grid .project-card { + display: flex; + flex-direction: column; + text-decoration: none; + color: inherit; + overflow: hidden; +} + +.projects-showcase-grid .project-card.is-hidden { + display: none; +} + +.projects-showcase-grid .project-card-image { + aspect-ratio: 16/10; + overflow: hidden; + padding: 0.5rem 0.5rem 0; +} + +.projects-showcase-grid .project-card-image img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: var(--radius-sm); + transition: transform 0.5s ease; +} + +.projects-showcase-grid .project-card:hover .project-card-image img { + transform: scale(1.03); +} + +.projects-showcase-grid .project-card-body { + padding: 1.5rem; + display: flex; + flex-direction: column; + flex: 1; + gap: 0.75rem; +} + +.projects-showcase-grid .project-card-title { + font-size: 1.05rem; + font-weight: 700; + line-height: 1.4; + color: var(--text-primary); +} + +.projects-showcase-grid .project-card-desc { + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.65; + flex: 1; +} + +.project-card-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + list-style: none; + margin: 0; + padding: 0; +} + +.project-card-tags li { + font-size: 0.75rem; + font-weight: 600; + color: var(--accent-blue); + background: rgba(48, 81, 255, 0.08); + border: 1px solid rgba(48, 81, 255, 0.15); + padding: 0.25rem 0.65rem; + border-radius: var(--radius-pill); +} + +/* ============================================================ + Experience / Benefits + ============================================================ */ +.experience-section { + background: #ffffff; +} + +.experience-intro { + max-width: 720px; + margin: 0 auto 2.5rem; + text-align: center; + color: var(--text-secondary); + font-size: 1rem; + line-height: 1.75; +} + +.experience-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 1.25rem; +} + +.experience-card { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 2rem 1.5rem; +} + +.experience-card-icon { + width: 72px; + height: 72px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1rem; +} + +.experience-card-icon img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.experience-card-icon--text { + font-size: 2rem; + font-weight: 700; + color: var(--accent-gold); +} + +.experience-card-title { + font-size: 1.15rem; + font-weight: 700; + margin-bottom: 0.75rem; + color: var(--text-primary); +} + +.experience-card-desc { + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.65; + max-width: 320px; +} + +/* ============================================================ + Features Grid + ============================================================ */ +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.25rem; +} + +.feature-card { + padding: 1.75rem; +} + +.feature-icon { + font-size: 2rem; + margin-bottom: 1rem; + display: block; + line-height: 1; +} + +.feature-icon--inline { + font-size: 1.25rem; + margin-bottom: 0.35rem; +} + +.feature-title { + font-size: 1rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.feature-desc { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.65; +} + +a.section-item-link { + text-decoration: none; + color: inherit; + transition: var(--transition-fast); +} + +a.section-item-link:hover { + border-color: rgba(48, 81, 255, 0.35); +} + +a.supporter-card.section-item-link { + display: flex; +} + +a.screenshot-item.section-item-link { + display: block; +} + +a.faq-item--link.section-item-link { + display: block; + padding: 1.25rem 1.5rem; +} + +a.faq-item--link .faq-question { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 1rem; + font-weight: 600; + margin: 0; +} + +a.faq-item--link .faq-link-preview { + margin-top: 0.75rem; + font-size: 0.88rem; + color: var(--text-secondary); +} + +/* ============================================================ + Products Grid (Home) + ============================================================ */ +.products-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.product-card { + display: flex; + flex-direction: column; + text-decoration: none; + overflow: hidden; + color: inherit; +} + +.product-card-image { + padding: 0.5rem 0.5rem 0; + aspect-ratio: 16/9; + overflow: hidden; + border-radius: var(--radius-md) var(--radius-md) 0 0; +} + +.product-card-image img { + width: 100%; height: 100%; + object-fit: cover; + transition: transform 0.5s ease; +} + +.product-card:hover .product-card-image img { + transform: scale(1.05); +} + +.product-card-body { + padding: 1.5rem; + flex: 1; + display: flex; + flex-direction: column; + position: relative; +} + +.product-card-logo { + position: absolute; + top: 1rem; + right: 1rem; + width: 32px; + height: 32px; + object-fit: contain; + border-radius: 0; + opacity: 0.85; +} + +.product-card-title { + font-size: 1.1rem; + font-weight: 700; + margin-bottom: 0.5rem; + color: var(--text-primary); +} + +.product-card-desc { + font-size: 0.88rem; + color: var(--text-secondary); + flex: 1; + margin-bottom: 1rem; + line-height: 1.6; +} + +.product-card-link { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; + font-weight: 600; + color: var(--accent-blue-light); +} + +/* ============================================================ + Problems Grid + ============================================================ */ +.problems-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1.25rem; +} + +.problem-item { + padding: 2rem 1.75rem; + position: relative; +} + +.problem-number { + font-size: 2.5rem; + font-weight: 800; + color: rgba(48, 81, 255, 0.15); + letter-spacing: -0.05em; + margin-bottom: 0.75rem; + line-height: 1; +} + +.problem-title { + font-size: 1rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.problem-desc { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.65; +} + +/* ============================================================ + Supporters Grid + ============================================================ */ +.supporters-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 1.25rem; +} + +.supporter-card { + padding: 1.75rem; + display: flex; + align-items: flex-start; + gap: 1rem; +} + +.supporter-logo { + flex-shrink: 0; + width: 52px; + height: 52px; + border-radius: 0; + overflow: hidden; + background: rgba(255, 255, 255, 0.05); + display: flex; + align-items: center; + justify-content: center; +} + +.supporter-logo img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.supporter-body { + min-width: 0; +} + +.supporter-name { + font-size: 0.95rem; + font-weight: 700; + margin-bottom: 0.35rem; + color: var(--text-primary); +} + +.supporter-desc { + font-size: 0.83rem; + color: var(--text-secondary); + line-height: 1.55; +} + +/* ============================================================ + About Strip + ============================================================ */ +.about-strip-inner { + display: grid; + grid-template-columns: 1fr auto; + gap: 2rem; + padding: 3rem; + overflow: hidden; + position: relative; +} + +.about-strip-content { + position: relative; + z-index: 2; + max-width: 560px; +} + +.about-strip-text { + font-size: 0.95rem; + color: var(--text-secondary); + line-height: 1.75; + margin-bottom: 1.5rem; +} + +.about-strip-blobs { + position: absolute; + right: 0; top: 0; bottom: 0; + width: 40%; + overflow: hidden; + pointer-events: none; +} + +.strip-blob { + position: absolute; + border-radius: 50%; + filter: blur(50px); + animation: blob-morph 12s ease-in-out infinite; +} + +.strip-blob--1 { + width: 280px; height: 280px; + background: radial-gradient(circle, rgba(48, 81, 255, 0.3) 0%, transparent 70%); + top: -50px; right: -50px; +} + +.strip-blob--2 { + width: 200px; height: 200px; + background: radial-gradient(circle, rgba(167, 139, 250, 0.25) 0%, transparent 70%); + bottom: -30px; right: 60px; + animation-delay: 4s; +} + +/* ============================================================ + Breadcrumb + ============================================================ */ +.breadcrumb { + margin-bottom: 1.5rem; +} + +.breadcrumb-list { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + font-size: 0.82rem; + color: var(--text-muted); +} + +.breadcrumb-list a { + color: var(--text-muted); + text-decoration: none; +} + +.breadcrumb-list a:hover { + color: var(--text-secondary); +} + +.breadcrumb-list [aria-current="page"] { + color: var(--text-primary); +} + +.breadcrumb-sep { + color: var(--text-muted); + opacity: 0.5; +} + +/* ============================================================ + Product Detail + ============================================================ */ +.product-detail-intro { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 2rem; + padding: 2.5rem; + align-items: start; +} + +.product-detail-image img { + border-radius: var(--radius-md); + width: 100%; +} + +.product-detail-desc { + font-size: 1rem; + color: var(--text-secondary); + line-height: 1.8; +} + +.main-product-layout { + padding-top: var(--spacing-xl); +} + +.main-product-main .product-detail-text h2 { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.main-product-main .sub-downloads { + margin-top: 0; +} + +/* ============================================================ + Sub-products Grid + ============================================================ */ +.subproducts-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1.25rem; +} + +.subproduct-card { + display: flex; + flex-direction: column; + text-decoration: none; + overflow: hidden; +} + +.subproduct-card-image { + aspect-ratio: 16/10; + overflow: hidden; + border-radius: var(--radius-md) var(--radius-md) 0 0; +} + +.subproduct-card-image img { + width: 100%; height: 100%; + object-fit: cover; + transition: transform 0.5s ease; +} + +.subproduct-card:hover .subproduct-card-image img { + transform: scale(1.05); +} + +.subproduct-card-body { + padding: 1.5rem; + flex: 1; + display: flex; + flex-direction: column; +} + +.subproduct-card-title { + font-size: 1rem; + font-weight: 700; + margin-bottom: 0.4rem; + color: var(--text-primary); +} + +.subproduct-card-desc { + font-size: 0.86rem; + color: var(--text-secondary); + flex: 1; + margin-bottom: 1rem; + line-height: 1.6; +} + +.subproduct-card-link { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.82rem; + font-weight: 600; + color: var(--accent-blue-light); + transition: var(--transition-fast); +} + +.subproduct-card:hover .subproduct-card-link { + gap: 0.5rem; +} + +/* ============================================================ + Sub-product Detail Layout + ============================================================ */ +.sub-product-layout { + padding-top: var(--spacing-xl); +} + +.sub-product-grid { + display: grid; + grid-template-columns: 1fr 280px; + gap: 2rem; + align-items: start; +} + +.sub-product-main, +.main-product-main { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.sub-product-main .sub-downloads { + margin-top: 0; +} + +.sub-product-image { + overflow: hidden; + padding: 0; +} + +.sub-product-image img { + width: 100%; + border-radius: var(--radius-lg); +} + +.sub-product-desc { + padding: 2rem; +} + +.sub-product-desc h2 { + font-size: 1.3rem; + margin-bottom: 1rem; +} + +.sub-product-desc p { + color: var(--text-secondary); + line-height: 1.8; +} + +/* ============================================================ + Articles + ============================================================ */ +.articles-section-title { + font-size: 1.5rem; + margin-bottom: 1.25rem; + padding-top: 0.5rem; +} + +.articles-list { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.article-card { + padding: 2rem 2rem 5.5rem 2rem; + position: relative; +} + +.article-card-header { + margin-bottom: 0.5rem; +} + +.citation-count-badge { + position: absolute; + bottom: 1.25rem; + right: 1.5rem; + width: 64px; + height: 64px; + border-radius: 50%; + border: 2px dashed rgba(48, 81, 255, 0.4); + background: rgba(48, 81, 255, 0.06); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1px; + opacity: 0.7; + text-decoration: none; + color: var(--accent-blue-light); + transition: opacity 0.2s, border-color 0.2s, background 0.2s; + cursor: default; +} + +a.citation-count-badge { + cursor: pointer; +} + +a.citation-count-badge:hover { + opacity: 1; + border-color: rgba(48, 81, 255, 0.65); + background: rgba(48, 81, 255, 0.12); +} + +.citation-count-number { + font-size: 1.3rem; + font-weight: 800; + line-height: 1; + letter-spacing: -0.02em; +} + +.citation-count-label { + font-size: 0.6rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + opacity: 0.8; +} + +.article-citations { + margin-top: 1.25rem; + padding-top: 1.25rem; + border-top: 1px solid var(--glass-border); +} + +.article-citations-title { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--accent-blue-light); + margin-bottom: 0.75rem; +} + +.article-citations-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.6rem; + counter-reset: citations; +} + +.article-citation-item { + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.8rem; + color: var(--text-secondary); + line-height: 1.55; + counter-increment: citations; +} + +.article-citation-item::before { + content: counter(citations) "."; + font-size: 0.72rem; + font-weight: 700; + color: var(--accent-blue-light); + flex-shrink: 0; + min-width: 1.2em; +} + +.citation-text { + flex: 1; +} + +.citation-actions { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.65rem; +} + +.citation-link, +.citation-copy { + color: var(--accent-blue-light); + opacity: 0.75; + transition: opacity 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.citation-copy { + position: relative; + padding: 0; + border: none; + background: none; + cursor: pointer; + font: inherit; +} + +.citation-link:hover, +.citation-copy:hover { + opacity: 1; +} + +.citation-copy-feedback { + position: absolute; + left: 50%; + top: calc(100% + 0.2rem); + transform: translateX(-50%); + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--accent-blue-light); + white-space: nowrap; + pointer-events: none; +} + +.citation-copy-icon-done { + display: none; +} + +.citation-copy.is-copied .citation-copy-icon { + display: none; +} + +.citation-copy.is-copied .citation-copy-icon-done { + display: flex; +} + +.citation-copy.is-copied { + opacity: 1; +} + +.article-title { + font-size: 1.1rem; + font-weight: 700; + margin-bottom: 0.65rem; +} + +.article-description { + font-size: 0.92rem; + color: var(--text-secondary); + line-height: 1.75; + margin-bottom: 1.25rem; +} + +.article-sections { + border-top: 1px solid var(--glass-border); + padding-top: 1.25rem; +} + +.article-sections-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 0.85rem; +} + +.article-section-item { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + padding: 0.75rem 1rem; +} + +.section-key { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--accent-blue-light); + margin-bottom: 0.25rem; +} + +.section-value { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.5; +} + +/* ============================================================ + Sidebar + ============================================================ */ +.sub-product-sidebar { + min-width: 0; +} + +.sidebar-sticky { + position: sticky; + top: calc(var(--navbar-height) + 1.5rem); + display: flex; + flex-direction: column; + gap: 1rem; +} + +.sidebar-section { + padding: 1.5rem; +} + +.sidebar-title { + font-size: 0.9rem; + font-weight: 700; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--glass-border); +} + +.sidebar-title a { + color: var(--text-primary); + text-decoration: none; +} + +.sidebar-title a:hover { + color: var(--accent-blue-light); +} + +.sidebar-list { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.sidebar-link { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: var(--text-secondary); + padding: 0.4rem 0.5rem; + border-radius: var(--radius-sm); + transition: var(--transition-fast); +} + +.sidebar-link:hover { + color: var(--accent-blue-light); + background: rgba(48, 81, 255, 0.07); +} + +.sidebar-dot { + width: 5px; height: 5px; + border-radius: 50%; + background: var(--accent-blue); + flex-shrink: 0; + opacity: 0.5; +} + +.sidebar-back-btn { + justify-content: center; +} + +.sidebar-empty { + font-size: 0.82rem; + color: var(--text-muted); +} + +/* ============================================================ + Products Overview + ============================================================ */ +.products-overview-grid { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.product-overview-card { + display: grid; + grid-template-columns: 1fr auto; + gap: 2rem; + padding: 2.5rem; +} + +.product-overview-body { + grid-column: 1; +} + +.product-overview-image { + grid-column: 2; + width: 280px; + border-radius: var(--radius-md); + overflow: hidden; + align-self: start; +} + +.product-overview-title { + font-size: 1.6rem; + font-weight: 800; + margin-bottom: 0.5rem; +} + +.product-overview-short { + font-size: 1rem; + color: var(--accent-blue-light); + font-weight: 500; + margin-bottom: 1rem; +} + +.product-overview-desc { + font-size: 0.92rem; + color: var(--text-secondary); + line-height: 1.8; + margin-bottom: 1.5rem; +} + +.product-overview-subs { + grid-column: 1 / -1; + border-top: 1px solid var(--glass-border); + padding-top: 1.5rem; +} + +.product-overview-subs-title { + font-size: 0.82rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-muted); + margin-bottom: 0.85rem; +} + +.product-overview-subs-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.sub-link-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.82rem; + font-weight: 500; + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--glass-border); + padding: 0.35rem 0.8rem; + border-radius: var(--radius-pill); + text-decoration: none; + transition: var(--transition-fast); +} + +.sub-link-chip:hover { + color: var(--accent-blue-light); + background: rgba(48, 81, 255, 0.1); + border-color: rgba(48, 81, 255, 0.3); +} + +/* ============================================================ + About Page + ============================================================ */ +.about-intro { + padding: 2.5rem; +} + +.about-custom-card { + padding: 2.5rem; +} + +.about-intro h2 { + font-size: 1.4rem; + margin-bottom: 1.25rem; +} + +.about-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.about-list li { + display: flex; + gap: 0.75rem; + font-size: 0.95rem; + color: var(--text-secondary); + line-height: 1.65; + padding: 0.75rem 1rem; + background: rgba(255, 255, 255, 0.03); + border-radius: var(--radius-sm); + border-left: 3px solid var(--accent-blue); +} + +.standards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1.25rem; +} + +.standard-card { + padding: 1.75rem; +} + +.history-link-icon { + margin-right: 0.35rem; +} + +.history-visual-image { + position: relative; + z-index: 2; + max-width: 100%; + max-height: 160px; + object-fit: contain; + border-radius: var(--radius-sm); +} + +.about-hero-gallery { + margin-top: 2.5rem; +} + +.about-section-screenshots { + margin-top: 1.5rem; +} + +.about-hero-gallery:not(:has(.screenshot-item)) { + display: none; +} + +.about-section-screenshots:not(:has(.screenshot-item)) { + display: none; +} + +.standards-grid:not(:has(.standard-card)) { + display: none; +} + +.history-visual:has(.history-visual-image) .history-year { + display: none; +} + +.standard-badge { + display: inline-block; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--accent-gold); + background: rgba(204, 148, 40, 0.1); + border: 1px solid rgba(204, 148, 40, 0.22); + padding: 0.25rem 0.7rem; + border-radius: var(--radius-pill); + margin-bottom: 0.85rem; +} + +.standard-title { + font-size: 1rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.standard-desc { + font-size: 0.87rem; + color: var(--text-secondary); + line-height: 1.65; +} + +.history-block { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 2rem; + padding: 3rem; + overflow: hidden; +} + +.history-content h2 { + font-size: 1.5rem; + margin-bottom: 1rem; +} + +.history-content p { + font-size: 0.95rem; + color: var(--text-secondary); + line-height: 1.8; + margin-bottom: 1rem; +} + +.history-links { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.history-visual { + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-height: 180px; +} + +.history-blob { + position: absolute; + border-radius: 50%; + filter: blur(40px); + animation: blob-morph 10s ease-in-out infinite; +} + +.history-blob--1 { + width: 200px; height: 200px; + background: radial-gradient(circle, rgba(48, 81, 255, 0.3) 0%, transparent 70%); +} + +.history-blob--2 { + width: 150px; height: 150px; + background: radial-gradient(circle, rgba(167, 139, 250, 0.25) 0%, transparent 70%); + animation-delay: 4s; +} + +.history-year { + position: relative; + z-index: 2; + font-size: 4rem; + font-weight: 800; + letter-spacing: -0.05em; + background: var(--gradient-text); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ============================================================ + Downloads Page + ============================================================ */ +.downloads-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; +} + +.download-card { + padding: 2.5rem 2rem; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; +} + +.download-card--unavailable { + opacity: 0.65; +} + +.download-platform-icon { + color: var(--accent-blue); + margin-bottom: 0.5rem; +} + +.download-platform-name { + font-size: 1.2rem; + font-weight: 700; +} + +.download-platform-desc { + font-size: 0.84rem; + color: var(--text-muted); +} + +.download-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.65rem; + width: 100%; + margin-top: 0.5rem; +} + +.download-version { + font-size: 0.78rem; + font-weight: 600; + color: var(--accent-blue); + background: rgba(48, 81, 255, 0.08); + border: 1px solid rgba(48, 81, 255, 0.2); + padding: 0.25rem 0.7rem; + border-radius: var(--radius-pill); +} + +.download-item-desc { + font-size: 0.82rem; + color: var(--text-muted); +} + +.download-btn { + width: 100%; + justify-content: center; +} + +.coming-soon-badge { + font-size: 0.78rem; + font-weight: 600; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--glass-border); + padding: 0.35rem 1rem; + border-radius: var(--radius-pill); + margin-top: 0.5rem; +} + +.release-block { + margin-bottom: 3.5rem; +} + +.release-block-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1.25rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--glass-border); +} + +.release-product-parent { + display: block; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent-blue); + margin-bottom: 0.2rem; +} + +.release-product-name { + font-size: 1.35rem; + font-weight: 700; +} + +.release-channel { + margin-bottom: 1.25rem; +} + +.release-channel--previous { + opacity: 0.82; +} + +.release-channel-label { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 0.28rem 0.85rem; + border-radius: var(--radius-pill); + margin-bottom: 1rem; +} + +.release-channel-label--stable { + color: rgb(134, 239, 172); + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.2); +} + +.release-channel-label--beta { + color: rgb(253, 186, 116); + background: rgba(249, 115, 22, 0.12); + border: 1px solid rgba(249, 115, 22, 0.22); +} + +.release-channel-label--rc { + color: rgb(196, 181, 253); + background: rgba(139, 92, 246, 0.12); + border: 1px solid rgba(139, 92, 246, 0.22); +} + +.release-channel-label--preview { + color: rgb(147, 197, 253); + background: rgba(59, 130, 246, 0.12); + border: 1px solid rgba(59, 130, 246, 0.22); +} + +.release-channel-label--nightly { + color: rgb(252, 165, 165); + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.release-channel-label--current { + color: var(--accent-blue); + background: rgba(48, 81, 255, 0.08); + border: 1px solid rgba(48, 81, 255, 0.2); +} + +.release-channel-label--previous { + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--glass-border); +} + +.release-channel-label--custom { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--glass-border); +} + +.download-card--previous { + opacity: 0.78; +} + +.download-btn--prev { + opacity: 0.9; +} + +.empty-state { + padding: 3rem; + text-align: center; + color: var(--text-secondary); +} + +/* ============================================================ + Rich Content (rendered Markdown / HTML / plain descriptions) + ============================================================ */ +.rich-content p { + margin-bottom: 0.85em; + line-height: 1.75; +} + +.rich-content p:last-child { margin-bottom: 0; } + +.rich-content h1, .rich-content h2, .rich-content h3, +.rich-content h4, .rich-content h5 { + font-weight: 700; + margin: 1.25em 0 0.5em; + line-height: 1.25; +} + +.rich-content h1 { font-size: 1.5rem; } +.rich-content h2 { font-size: 1.25rem; } +.rich-content h3 { font-size: 1.05rem; } + +.rich-content ul { + list-style-type: disc; + padding-left: 1.5em; + margin-bottom: 0.85em; +} + +.rich-content ol { + list-style-type: decimal; + padding-left: 1.5em; + margin-bottom: 0.85em; +} + +.rich-content ul ul { list-style-type: circle; } +.rich-content ul ul ul { list-style-type: square; } + +.rich-content li { margin-bottom: 0.3em; line-height: 1.65; } + +.rich-content a { + color: var(--accent-blue-light); + text-decoration: underline; + text-underline-offset: 3px; +} + +.rich-content a:hover { color: var(--accent-cyan); } + +.rich-content code { + font-family: "SF Mono", "Fira Code", monospace; + font-size: 0.88em; + background: rgba(48, 81, 255, 0.1); + border: 1px solid rgba(48, 81, 255, 0.15); + padding: 0.15em 0.45em; + border-radius: 4px; +} + +.rich-content pre { + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + padding: 1rem 1.25rem; + overflow-x: auto; + margin-bottom: 1em; +} + +.rich-content pre code { + background: none; + border: none; + padding: 0; + font-size: 0.88rem; +} + +.rich-content blockquote { + border-left: 3px solid rgba(48, 81, 255, 0.45); + padding-left: 1rem; + color: var(--text-secondary); + margin: 1em 0; + font-style: italic; +} + +.rich-content table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1em; + font-size: 0.9rem; +} + +.rich-content th, .rich-content td { + padding: 0.6rem 0.9rem; + border: 1px solid var(--glass-border); + text-align: left; +} + +.rich-content th { + background: rgba(48, 81, 255, 0.08); + font-weight: 600; +} + +/* ============================================================ + Sub-Product Inline Downloads + ============================================================ */ +.sub-downloads { + margin-top: 2rem; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + padding: 1.5rem; + backdrop-filter: blur(12px); +} + +.sub-downloads-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1.25rem; +} + +.sub-downloads-title { + font-size: 1rem; + font-weight: 700; + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0; + color: var(--text-primary); +} + +.sub-downloads-corner-link { + flex-shrink: 0; + font-size: 0.88rem; + white-space: nowrap; +} + +.sub-downloads-channel { + margin-bottom: 1.1rem; +} + +.sub-downloads-channel--prev { + opacity: 0.78; +} + +.sub-downloads-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.85rem; + margin-top: 0.6rem; + align-items: stretch; +} + +.sub-dl-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.45rem; + padding: 0.9rem 0.5rem; + border-radius: var(--radius-sm); + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.03); + text-align: center; +} + +.sub-dl-item--na { + opacity: 0.5; +} + +.sub-dl-icon-wrap { + width: 100%; + min-height: 40px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.sub-dl-icon-wrap .sub-dl-icon, +.sub-dl-icon-wrap .sub-dl-source-svg { + display: block; +} + +.sub-dl-item .btn-primary.btn--sm { + margin-top: auto; +} + +.sub-dl-source-mark { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted, #8892a6); +} + +.sub-dl-icon { + width: 32px; + height: 32px; +} + +.sub-dl-source-svg { + color: var(--text-secondary, #aab4c5); +} + +.sub-dl-platform { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-secondary); +} + +.pkg-version-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +.pkg-version-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem 1rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--glass-border); +} + +.pkg-version-row:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.pkg-version-row:first-child { + padding-top: 0.5rem; +} + +.pkg-version-meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.pkg-version-meta .release-channel-label { + font-size: 0.7rem; + padding: 0.2rem 0.55rem; + margin-bottom: 0; +} + +.versions-table-version { + display: inline-block; + margin-right: 0.45rem; +} + +.versions-table th[scope="row"] .release-channel-label { + font-size: 0.68rem; + padding: 0.18rem 0.5rem; + margin-bottom: 0; + vertical-align: middle; +} + +.pkg-version-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-left: auto; +} + +.pkg-version-notes { + width: 100%; + margin: 0.35rem 0 0; + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.6; +} + +.sub-downloads--package .sub-package-stable { + margin-top: 0.6rem; +} + +.sub-package-row { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + margin-top: 0.75rem; +} + +.sub-release-notes { + margin-top: 1rem; + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.65; +} + +.sub-install-extra-resource { + margin-top: 0.75rem; + margin-bottom: 0; + font-size: 0.92rem; +} + +.sub-install-extra-resource a { + font-weight: 600; +} + +.sub-older-versions-inner { + margin-top: 1rem; + margin-bottom: 0; + padding-top: 1rem; + border-top: 1px solid var(--glass-border); +} + +.link-arrow { + font-weight: 600; + color: var(--accent-strong, var(--accent, #6b9dff)); +} + +.versions-archive-section { + padding-top: 0; +} + +.versions-empty { + padding: 2rem 1.5rem; +} + +.versions-table-wrap { + padding: 0; + overflow-x: auto; +} + +.versions-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.versions-table th, +.versions-table td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid var(--glass-border); +} + +.versions-table thead th { + background: rgba(48, 81, 255, 0.06); + font-weight: 600; +} + +.versions-table-link { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.35rem; + border-radius: var(--radius-sm); + transition: var(--transition-smooth); +} + +.versions-table-link:hover { + background: rgba(48, 81, 255, 0.1); +} + +.versions-table-icon { + display: block; + width: 22px; + height: 22px; + object-fit: contain; + opacity: 0.82; + transition: var(--transition-smooth); +} + +.versions-table-link:hover .versions-table-icon { + opacity: 1; +} + +.versions-table-text-link { + font-weight: 600; +} + +.versions-notes-row td { + background: rgba(0, 0, 0, 0.06); +} + +.versions-notes-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted, #8892a6); + display: block; + margin-bottom: 0.35rem; +} + +.versions-notes-body { + margin: 0; + font-size: 0.87rem; + color: var(--text-secondary); + line-height: 1.65; +} + +.versions-package-list { + display: flex; + flex-direction: column; + gap: 1rem; + list-style: none; + padding: 0; + margin: 0; +} + +.versions-package-card { + padding: 1.15rem 1.35rem; +} + +.versions-package-head { + display: flex; + align-items: center; + gap: 1rem; +} + +.versions-na { + opacity: 0.45; +} + +.info-block { + padding: 2.5rem; +} + +.info-block h2 { + font-size: 1.3rem; + margin-bottom: 0.75rem; +} + +.info-block p { + font-size: 0.92rem; + color: var(--text-secondary); + line-height: 1.75; + margin-bottom: 1.5rem; +} + +/* ============================================================ + FAQ Page + ============================================================ */ +.faq-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 800px; + margin: 0 auto var(--spacing-xl); +} + +.faq-item { + overflow: hidden; +} + +.faq-question { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1.4rem 1.75rem; + background: none; + border: none; + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 0.97rem; + font-weight: 600; + text-align: left; + cursor: pointer; + transition: var(--transition-fast); +} + +.faq-question:hover { + color: var(--accent-blue-light); +} + +.faq-icon { + flex-shrink: 0; + color: var(--text-muted); + transition: transform 0.25s ease; +} + +.faq-item.open .faq-icon { + transform: rotate(180deg); +} + +.faq-answer { + padding: 0 1.75rem 1.4rem; +} + +.faq-answer[hidden] { + display: none; +} + +.faq-answer p { + font-size: 0.92rem; + color: var(--text-secondary); + line-height: 1.75; +} + +.faq-contact { + max-width: 800px; + margin: 0 auto; + padding: 2.5rem; + text-align: center; +} + +.faq-contact h2 { + font-size: 1.3rem; + margin-bottom: 0.75rem; +} + +.faq-contact p { + color: var(--text-secondary); + margin-bottom: 1.5rem; + font-size: 0.92rem; +} + +/* ============================================================ + Contact Page + ============================================================ */ +.contact-layout { + display: grid; + grid-template-columns: 1fr 360px; + gap: 2.5rem; + align-items: start; +} + +.contact-layout--full { + grid-template-columns: 1fr; +} + +.contact-form-wrap { + padding: 2.5rem; +} + +.contact-form-title { + font-size: 1.35rem; + font-weight: 700; + margin-bottom: 1.75rem; +} + +.form-group { + margin-bottom: 1.2rem; +} + +.form-group label { + display: block; + font-size: 0.86rem; + font-weight: 600; + margin-bottom: 0.4rem; + color: var(--text-primary); +} + +.required-star { + color: var(--accent-blue-light); +} + +.optional-label { + font-size: 0.78rem; + font-weight: 400; + color: var(--text-muted, var(--text-secondary)); + margin-left: 0.25rem; +} + +.form-hint { + font-size: 0.78rem; + color: var(--text-muted, var(--text-secondary)); + margin-top: 0.35rem; + line-height: 1.4; +} + +.form-file-list { + font-size: 0.82rem; + color: var(--text-secondary); + margin-top: 0.35rem; + min-height: 1.2em; +} + +.form-group input[type="file"] { + padding: 0.55rem 0.75rem; + cursor: pointer; +} + +.form-group input[type="file"]::file-selector-button { + margin-right: 0.75rem; + padding: 0.4rem 0.85rem; + border-radius: var(--radius-sm); + border: 1px solid var(--glass-border); + background: rgba(48, 81, 255, 0.12); + color: var(--text-primary); + font-family: inherit; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 0.72rem 1rem; + border-radius: var(--radius-sm); + border: 1px solid #d1d5db; + background: #ffffff; + color: var(--text-primary); + font-family: inherit; + font-size: 0.95rem; + line-height: 1.5; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; + outline: none; + box-sizing: border-box; +} + +.form-group input:focus, +.form-group textarea:focus { + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(48, 81, 255, 0.12); + background: #ffffff; +} + +.form-group textarea { + resize: vertical; + min-height: 120px; +} + +.form-group.has-error input, +.form-group.has-error textarea, +.form-group.has-error input[type="file"] { + border-color: rgba(239, 68, 68, 0.5); + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); +} + +.form-field-error { + display: block; + font-size: 0.78rem; + color: rgb(252, 165, 165); + margin-top: 0.3rem; + min-height: 1em; +} + +.form-group--captcha { + background: rgba(48, 81, 255, 0.04); + border: 1px solid rgba(48, 81, 255, 0.15); + padding: 1rem; + border-radius: var(--radius-sm); +} + +.captcha-input { + max-width: 130px !important; +} + +.btn-submit { + width: 100%; + justify-content: center; + margin-top: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.btn-spinner svg { + animation: spin 0.9s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.btn--sm { + padding: 0.45rem 1.1rem !important; + font-size: 0.85rem !important; +} + +.contact-sidebar { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.contact-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; +} + +.contact-card { + padding: 2rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.contact-icon { + color: var(--accent-blue); + margin-bottom: 0.25rem; +} + +.contact-card-title { + font-size: 1.05rem; + font-weight: 700; +} + +.contact-card-desc { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.65; +} + +.contact-email-link { + font-size: 0.95rem; + font-weight: 600; + color: var(--accent-blue-light); + word-break: break-all; +} + +.contact-address { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.8; +} + +/* ============================================================ + Modal + ============================================================ */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + z-index: 9000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.modal-overlay[hidden] { + display: none; +} + +.modal-box { + max-width: 460px; + width: 100%; + padding: 2.5rem; + text-align: center; + animation: modal-pop 0.22s ease-out both; +} + +@keyframes modal-pop { + from { opacity: 0; transform: scale(0.92) translateY(10px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.modal-icon-wrap { + width: 64px; + height: 64px; + border-radius: 50%; + background: rgba(48, 81, 255, 0.12); + border: 1px solid rgba(48, 81, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 1.5rem; + color: var(--accent-blue-light); +} + +.modal-icon-wrap--success { + background: rgba(34, 197, 94, 0.12); + border-color: rgba(34, 197, 94, 0.2); + color: rgb(134, 239, 172); +} + +.modal-icon-wrap--error { + background: rgba(239, 68, 68, 0.12); + border-color: rgba(239, 68, 68, 0.2); + color: rgb(252, 165, 165); +} + +.modal-title { + font-size: 1.3rem; + font-weight: 700; + margin-bottom: 0.75rem; +} + +.modal-body { + font-size: 0.93rem; + color: var(--text-secondary); + line-height: 1.65; + margin-bottom: 1.75rem; +} + +.modal-actions { + display: flex; + gap: 0.75rem; + justify-content: center; + flex-wrap: wrap; +} + +/* ============================================================ + Footer + ============================================================ */ +.footer { + position: relative; + margin-top: var(--spacing-2xl); + color: #ffffff; +} + +.footer-main { + background: var(--footer-bg); + background-image: url('../images/tecvico/footer-bg.svg'); + background-repeat: no-repeat; + background-size: cover; + background-position: center; +} + +.footer-main-inner { + display: flex; + flex-direction: column; + gap: 2.5rem; + padding: 3.5rem var(--container-padding); +} + +.footer-brand { + max-width: 360px; +} + +.footer-logo { + display: inline-flex; + margin-bottom: 0.75rem; + text-decoration: none; +} + +.footer-tagline { + font-family: var(--font-sans); + font-size: 1rem; + color: rgba(255, 255, 255, 0.92); + line-height: 1.6; + margin: 0; +} + +.footer-social { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.footer-social-title { + font-family: var(--font-heading); + font-size: 1.5rem; + font-weight: 700; + margin: 0; + color: #ffffff; +} + +.footer-social-list { + display: flex; + align-items: center; + gap: 1.5rem; + list-style: none; + margin: 0; + padding: 0; +} + +.footer-social-list a { + display: block; + line-height: 0; + opacity: 0.92; + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.footer-social-list a:hover { + opacity: 1; + transform: translateY(-2px); +} + +.footer-social-list img { + width: 24px; + height: 24px; + display: block; +} + +.footer-bottom { + background: var(--footer-bottom-bg); + padding: 1.75rem var(--container-padding); + text-align: center; +} + +.footer-copy { + margin: 0; + font-family: var(--font-sans); + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.85); + line-height: 1.6; +} + +@media (min-width: 768px) { + .footer-main-inner { + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + gap: 3rem; + } + + .footer-social { + align-items: flex-start; + padding-right: 4rem; + } +} + +/* ============================================================ + Empty States + ============================================================ */ +.empty-state { + text-align: center; + color: var(--text-muted); + font-size: 0.95rem; + padding: var(--spacing-xl) 0; +} + +.empty-state-block { + padding: 3rem; + text-align: center; +} + +.empty-state-block p { + color: var(--text-muted); +} + +/* ============================================================ + Responsive — Tablet + ============================================================ */ +@media (max-width: 1024px) { + .footer-grid { + grid-template-columns: 1fr 1fr; + gap: 2rem; + } + + .product-detail-intro { + grid-template-columns: 1fr; + } + + .about-strip-inner { + grid-template-columns: 1fr; + } + + .about-strip-blobs { + display: none; + } + + .history-block { + grid-template-columns: 1fr; + } + + .history-visual { + display: none; + } + + .product-overview-card { + grid-template-columns: 1fr; + } + + .product-overview-image { + width: 100%; + max-width: 400px; + grid-column: 1; + } +} + +/* ============================================================ + Responsive — Mobile + ============================================================ */ +@media (max-width: 768px) { + :root { + --spacing-2xl: 4.5rem; + } + + .navbar-toggle { + display: flex; + } + + .navbar-menu { + position: fixed; + top: var(--navbar-height); + left: 0; + right: 0; + background: #ffffff; + border-bottom: 1px solid var(--glass-border); + padding: 1rem var(--container-padding) 1.5rem; + max-height: 0; + overflow: hidden; + transition: max-height var(--navbar-expand-duration) var(--navbar-expand-ease), + padding var(--navbar-expand-duration) var(--navbar-expand-ease); + } + + .navbar-menu.open { + max-height: 80vh; + overflow-y: auto; + padding-top: 1rem; + padding-bottom: 1.5rem; + } + + .nav-list { + flex-direction: column; + align-items: stretch; + gap: 0.25rem; + } + + .nav-link { + padding: 0.75rem 1rem; + font-size: 0.95rem; + } + + .nav-item.has-megamenu:hover .nav-arrow { + transform: none; + } + + .nav-item.has-megamenu.open .nav-arrow { + transform: rotate(180deg); + } + + .megamenu { + position: static; + max-height: 0; + overflow: hidden; + margin: 0; + padding: 0; + border: none; + border-radius: var(--radius-md); + transform: none; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; + opacity: 0; + visibility: hidden; + pointer-events: none; + } + + .nav-item.has-megamenu:hover .megamenu { + opacity: 0; + visibility: hidden; + max-height: 0; + pointer-events: none; + transform: none; + } + + .nav-item.has-megamenu.open .megamenu { + max-height: 2000px; + overflow: visible; + margin-top: 0.25rem; + padding: 0; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--glass-border); + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: none; + } + + .megamenu-inner { + padding: 1rem; + } + + .megamenu-products-scroll { + overflow-x: visible; + padding-bottom: 0; + } + + .megamenu-products { + flex-direction: column; + gap: 0.75rem; + } + + .megamenu-product-item { + flex: 1 1 auto; + max-width: none; + } + + .megamenu-product-item.has-subproducts:hover .megamenu-sub-chevron, + .megamenu-product-item.has-subproducts:focus-within .megamenu-sub-chevron { + transform: none; + color: var(--text-muted); + } + + .megamenu-product-item.has-subproducts:hover .megamenu-sublist, + .megamenu-product-item.has-subproducts:focus-within .megamenu-sublist { + max-height: 0; + opacity: 0; + border-top-color: transparent; + } + + .megamenu-product-item.has-subproducts.open .megamenu-sub-chevron { + transform: rotate(180deg); + color: var(--accent-blue-light); + } + + .megamenu-product-item.has-subproducts.open .megamenu-sublist { + max-height: 320px; + opacity: 1; + border-top-color: var(--glass-border); + } + + .sub-product-grid { + grid-template-columns: 1fr; + } + + .sub-product-sidebar { + order: -1; + } + + .sidebar-sticky { + position: static; + } + + .footer-grid { + grid-template-columns: 1fr; + gap: 2rem; + } + + .footer-bottom { + flex-direction: column; + gap: 0.5rem; + } + + .hero-title { + font-size: clamp(1.7rem, 6vw, 2.4rem); + } + + .about-strip-inner { + padding: 2rem; + } + + .product-overview-card { + padding: 1.75rem; + } + + .features-grid { + grid-template-columns: 1fr; + } +} + +/* ============================================================ + Hero App Preview (Screenshot) + ============================================================ */ +.hero-visual { + display: flex; + justify-content: flex-end; + align-items: center; + animation: hero-preview-in 1.1s ease-out 0.6s both; +} + +.hero-visual img { + width: 100%; + max-width: 520px; + height: auto; + display: block; +} + +@keyframes hero-preview-in { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ============================================================ + Screenshots Gallery + ============================================================ */ +.screenshots-section { + position: relative; + overflow: hidden; +} + +.screenshots-section::before { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(ellipse 70% 50% at 50% 50%, rgba(48, 81, 255, 0.06) 0%, transparent 70%); + pointer-events: none; +} + +.screenshots-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1.25rem; +} + +.screenshot-item { + border-radius: var(--radius-md); + overflow: hidden; + border: 1px solid var(--glass-border); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45); + transition: var(--transition-smooth); + background: var(--bg-secondary); + cursor: zoom-in; +} + +.screenshot-item img { + width: 100%; + display: block; + opacity: 0.82; + transition: opacity 0.35s ease, transform 0.45s ease; + aspect-ratio: 16/10; + object-fit: cover; +} + +.screenshot-item:hover { + border-color: rgba(48, 81, 255, 0.3); + box-shadow: + 0 16px 60px rgba(0, 0, 0, 0.6), + 0 0 20px rgba(48, 81, 255, 0.12); + transform: translateY(-3px); +} + +.screenshot-item:hover img { + opacity: 1; + transform: scale(1.03); +} + +/* About page screenshots */ +.about-screenshots-grid { + display: grid; + grid-template-columns: 1.5fr 1fr 1fr; + gap: 1.25rem; + align-items: start; +} + +.about-screenshots-grid .screenshot-item img { + aspect-ratio: 16/10; + object-fit: cover; +} + +/* ============================================================ + Platform Icons (Downloads) + ============================================================ */ +.platform-icon { + display: block; + width: 56px; + height: 56px; + object-fit: contain; + opacity: 0.9; + transition: var(--transition-smooth); +} + +.download-card:hover .platform-icon { + opacity: 1; +} + +.download-card--unavailable .platform-icon { + opacity: 0.35; +} + +.btn-icon-img { + width: 18px; + height: 18px; + object-fit: contain; + flex-shrink: 0; +} + +/* ============================================================ + Product Logo Image (fallback on product detail) + ============================================================ */ +.product-logo-img { + width: 100%; + max-width: 280px; + height: auto; + object-fit: contain; + opacity: 0.9; + filter: drop-shadow(0 4px 20px rgba(48, 81, 255, 0.3)); + padding: 1.5rem; +} + +/* ============================================================ + Responsive — Images + ============================================================ */ +@media (max-width: 1024px) { + .hero-content { + grid-template-columns: 1fr; + } + + .hero-visual { + display: none; + } + + .contact-layout { + grid-template-columns: 1fr; + } + + .about-screenshots-grid { + grid-template-columns: 1fr 1fr; + } + + .about-screenshots-grid .screenshot-item--featured { + grid-column: 1 / -1; + } +} + +@media (max-width: 768px) { + .blob-img--hero-left, + .blob-img--hero-right, + .blob-img--page-hero, + .blob-img--page-corner, + .blob-img--downloads { + opacity: 0.08; + } + + .about-screenshots-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 480px) { + :root { + --container-padding: 1rem; + } + + .hero-actions { + flex-direction: column; + } + + .btn-primary, .btn-ghost { + width: 100%; + justify-content: center; + } + + .downloads-grid { + grid-template-columns: 1fr; + } + + .contact-grid { + grid-template-columns: 1fr; + } + + .screenshots-grid { + grid-template-columns: 1fr; + } + + .modal-box { + padding: 1.75rem 1.25rem; + } + + .modal-actions { + flex-direction: column; + } +} + +/* ============================================================ + Read More / Content Truncation + ============================================================ */ +.readmore-wrap { + position: relative; + overflow: hidden; + transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1); +} + +.readmore-wrap.is-clamped { + -webkit-mask-image: linear-gradient(to bottom, black 45%, transparent 92%); + mask-image: linear-gradient(to bottom, black 45%, transparent 92%); +} + +.readmore-wrap:not(.is-clamped) { + -webkit-mask-image: none; + mask-image: none; +} + +.readmore-btn { + display: inline-flex; + align-items: center; + gap: 0.3em; + font-size: 0.8rem; + font-weight: 600; + color: var(--accent-blue-light); + background: none; + border: none; + cursor: pointer; + padding: 0.3rem 0; + margin-top: 0.5rem; + line-height: 1; + transition: color 0.18s ease; +} + +.readmore-btn:hover { + color: var(--accent-blue); +} + +.readmore-btn svg { + flex-shrink: 0; + transition: transform 0.22s ease; +} + +.readmore-btn.is-open svg { + transform: rotate(180deg); +} + +/* ============================================================ + Video Blocks + ============================================================ */ +.video-block { + width: 100%; + max-width: var(--video-max-width, 100%); + margin-inline: auto; +} + +.video-block--sm { + --video-max-width: 480px; +} + +.video-block--md { + --video-max-width: 720px; +} + +.video-block--lg { + --video-max-width: 960px; +} + +.video-block--full { + --video-max-width: 100%; +} + +.video-block-inner { + position: relative; + width: 100%; + aspect-ratio: var(--video-aspect-ratio, 16 / 9); + overflow: hidden; + border-radius: var(--radius-md); + background: rgba(8, 12, 24, 0.55); +} + +.video-block--ratio-16-9 { + --video-aspect-ratio: 16 / 9; +} + +.video-block--ratio-4-3 { + --video-aspect-ratio: 4 / 3; +} + +.video-block--ratio-1-1 { + --video-aspect-ratio: 1 / 1; +} + +.video-block-inner iframe, +.video-block-inner video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; + object-fit: contain; + background: #000; +} + +.video-section .section-desc { + margin-bottom: 1.5rem; + text-align: center; + max-width: 680px; + margin-left: auto; + margin-right: auto; +} + +.product-videos-list { + display: flex; + flex-direction: column; + gap: 2.5rem; +} + +.product-video-header { + margin-bottom: 0.75rem; +} + +.video-panel--styled { + position: relative; + overflow: hidden; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + padding: 1.75rem 1.5rem 1.5rem; + backdrop-filter: blur(12px); +} + +.video-panel-ambient { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; +} + +.video-panel-blob { + position: absolute; + border-radius: 50%; + filter: blur(52px); +} + +.video-panel-blob--1 { + width: 240px; + height: 240px; + top: -90px; + right: -50px; + background: radial-gradient(circle, rgba(48, 81, 255, 0.42) 0%, transparent 72%); + opacity: 0.85; +} + +.video-panel-blob--2 { + width: 200px; + height: 200px; + bottom: -70px; + left: -40px; + background: radial-gradient(circle, rgba(56, 189, 248, 0.28) 0%, transparent 72%); + opacity: 0.9; +} + +.video-panel-blob--3 { + width: 140px; + height: 140px; + top: 45%; + left: 55%; + background: radial-gradient(circle, rgba(139, 92, 246, 0.18) 0%, transparent 70%); + opacity: 0.75; +} + +.video-panel-inner { + position: relative; + z-index: 1; +} + +.video-panel-header { + text-align: center; + max-width: 680px; + margin: 0 auto 0.35rem; +} + +.video-panel-heading { + display: flex; + flex-direction: column; + align-items: center; +} + +.video-panel-heading .section-badge { + margin-bottom: 0.65rem; +} + +.video-panel-title { + font-size: 1.05rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + gap: 0.55rem; + margin: 0; + color: var(--text-primary); +} + +.video-panel-title svg { + flex-shrink: 0; + color: var(--accent-blue-light); +} + +.video-panel-desc { + text-align: center; + max-width: 680px; + margin: 0 auto 1.25rem; +} + +.video-panel--styled .video-block-inner { + border: 1px solid rgba(48, 81, 255, 0.22); + box-shadow: + 0 16px 48px rgba(8, 12, 24, 0.38), + inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.product-videos-list .product-video-item .section-desc, +.product-videos-list .product-video-item .rich-content.section-desc { + text-align: center; + max-width: 680px; + margin-left: auto; + margin-right: auto; +} + +.product-videos-list .video-panel--styled + .video-panel--styled, +.product-videos-list .product-video-item + .product-video-item { + margin-top: 0; +} + +.product-videos-list .video-panel--styled, +.product-videos-list .product-video-item { + margin-top: 0; +} + +@media (max-width: 640px) { + .video-panel--styled { + padding: 1.25rem 1rem 1rem; + } + + .video-panel-blob--1 { + width: 180px; + height: 180px; + top: -70px; + right: -70px; + } + + .video-panel-blob--2 { + width: 150px; + height: 150px; + } +} diff --git a/static/images/abstract-organic-shape.svg b/static/images/abstract-organic-shape.svg new file mode 100644 index 0000000..e8aabe7 --- /dev/null +++ b/static/images/abstract-organic-shape.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/abstract-shapes-2.svg b/static/images/abstract-shapes-2.svg new file mode 100644 index 0000000..1ec32f5 --- /dev/null +++ b/static/images/abstract-shapes-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/abstract-shapes.svg b/static/images/abstract-shapes.svg new file mode 100644 index 0000000..066717d --- /dev/null +++ b/static/images/abstract-shapes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/blob-blue-1.svg b/static/images/blob-blue-1.svg new file mode 100644 index 0000000..6105b8e --- /dev/null +++ b/static/images/blob-blue-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/blob-blue-2.svg b/static/images/blob-blue-2.svg new file mode 100644 index 0000000..e88030f --- /dev/null +++ b/static/images/blob-blue-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/blob-blue-3.svg b/static/images/blob-blue-3.svg new file mode 100644 index 0000000..66d7be7 --- /dev/null +++ b/static/images/blob-blue-3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/favicon-16.png b/static/images/favicon-16.png new file mode 100644 index 0000000..1569638 Binary files /dev/null and b/static/images/favicon-16.png differ diff --git a/static/images/favicon-180.png b/static/images/favicon-180.png new file mode 100644 index 0000000..84a151e Binary files /dev/null and b/static/images/favicon-180.png differ diff --git a/static/images/favicon-32.png b/static/images/favicon-32.png new file mode 100644 index 0000000..6285814 Binary files /dev/null and b/static/images/favicon-32.png differ diff --git a/static/images/icon-download.svg b/static/images/icon-download.svg new file mode 100644 index 0000000..5aace1c --- /dev/null +++ b/static/images/icon-download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/icon-linux.svg b/static/images/icon-linux.svg new file mode 100644 index 0000000..c931de0 --- /dev/null +++ b/static/images/icon-linux.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/icon-loading.svg b/static/images/icon-loading.svg new file mode 100644 index 0000000..46726f5 --- /dev/null +++ b/static/images/icon-loading.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/icon-macos.svg b/static/images/icon-macos.svg new file mode 100644 index 0000000..35de836 --- /dev/null +++ b/static/images/icon-macos.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/icon-windows.svg b/static/images/icon-windows.svg new file mode 100644 index 0000000..581f148 --- /dev/null +++ b/static/images/icon-windows.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/static/images/logo.png b/static/images/logo.png new file mode 100644 index 0000000..9063b4a Binary files /dev/null and b/static/images/logo.png differ diff --git a/static/images/modern-shape.svg b/static/images/modern-shape.svg new file mode 100644 index 0000000..e9ea717 --- /dev/null +++ b/static/images/modern-shape.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/screenshot-1.jpg b/static/images/screenshot-1.jpg new file mode 100644 index 0000000..bee8edd Binary files /dev/null and b/static/images/screenshot-1.jpg differ diff --git a/static/images/screenshot-10.jpg b/static/images/screenshot-10.jpg new file mode 100644 index 0000000..9008ed4 Binary files /dev/null and b/static/images/screenshot-10.jpg differ diff --git a/static/images/screenshot-11.jpg b/static/images/screenshot-11.jpg new file mode 100644 index 0000000..161e718 Binary files /dev/null and b/static/images/screenshot-11.jpg differ diff --git a/static/images/screenshot-2.jpg b/static/images/screenshot-2.jpg new file mode 100644 index 0000000..e090d50 Binary files /dev/null and b/static/images/screenshot-2.jpg differ diff --git a/static/images/screenshot-3.png b/static/images/screenshot-3.png new file mode 100644 index 0000000..5e59f85 Binary files /dev/null and b/static/images/screenshot-3.png differ diff --git a/static/images/screenshot-4.png b/static/images/screenshot-4.png new file mode 100644 index 0000000..0379f19 Binary files /dev/null and b/static/images/screenshot-4.png differ diff --git a/static/images/screenshot-5.png b/static/images/screenshot-5.png new file mode 100644 index 0000000..eab7aa0 Binary files /dev/null and b/static/images/screenshot-5.png differ diff --git a/static/images/screenshot-6.png b/static/images/screenshot-6.png new file mode 100644 index 0000000..b48f818 Binary files /dev/null and b/static/images/screenshot-6.png differ diff --git a/static/images/screenshot-7.png b/static/images/screenshot-7.png new file mode 100644 index 0000000..f21fcb8 Binary files /dev/null and b/static/images/screenshot-7.png differ diff --git a/static/images/screenshot-8.jpg b/static/images/screenshot-8.jpg new file mode 100644 index 0000000..bf3f38b Binary files /dev/null and b/static/images/screenshot-8.jpg differ diff --git a/static/images/screenshot-9.jpg b/static/images/screenshot-9.jpg new file mode 100644 index 0000000..75c32f2 Binary files /dev/null and b/static/images/screenshot-9.jpg differ diff --git a/static/images/tecvico/brand-logo.svg b/static/images/tecvico/brand-logo.svg new file mode 100644 index 0000000..a3ad822 --- /dev/null +++ b/static/images/tecvico/brand-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/images/tecvico/experience/financial-benefits.svg b/static/images/tecvico/experience/financial-benefits.svg new file mode 100644 index 0000000..d03a899 --- /dev/null +++ b/static/images/tecvico/experience/financial-benefits.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/images/tecvico/experience/international-workshop.svg b/static/images/tecvico/experience/international-workshop.svg new file mode 100644 index 0000000..ec24565 --- /dev/null +++ b/static/images/tecvico/experience/international-workshop.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/images/tecvico/experience/quality-assurance.svg b/static/images/tecvico/experience/quality-assurance.svg new file mode 100644 index 0000000..67637ac --- /dev/null +++ b/static/images/tecvico/experience/quality-assurance.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/images/tecvico/experience/support.svg b/static/images/tecvico/experience/support.svg new file mode 100644 index 0000000..1b3dbc9 --- /dev/null +++ b/static/images/tecvico/experience/support.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/images/tecvico/footer-bg.svg b/static/images/tecvico/footer-bg.svg new file mode 100644 index 0000000..2a72f9e --- /dev/null +++ b/static/images/tecvico/footer-bg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/images/tecvico/hero-decoration.svg b/static/images/tecvico/hero-decoration.svg new file mode 100644 index 0000000..92134d9 --- /dev/null +++ b/static/images/tecvico/hero-decoration.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/static/images/tecvico/hero-image.svg b/static/images/tecvico/hero-image.svg new file mode 100644 index 0000000..b6bcc7e --- /dev/null +++ b/static/images/tecvico/hero-image.svg @@ -0,0 +1,405 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/tecvico/projects/project-frame-2.png b/static/images/tecvico/projects/project-frame-2.png new file mode 100644 index 0000000..adeff54 Binary files /dev/null and b/static/images/tecvico/projects/project-frame-2.png differ diff --git a/static/images/tecvico/projects/project-frame-3.png b/static/images/tecvico/projects/project-frame-3.png new file mode 100644 index 0000000..d104b59 Binary files /dev/null and b/static/images/tecvico/projects/project-frame-3.png differ diff --git a/static/images/tecvico/projects/project-frame-4.png b/static/images/tecvico/projects/project-frame-4.png new file mode 100644 index 0000000..6695911 Binary files /dev/null and b/static/images/tecvico/projects/project-frame-4.png differ diff --git a/static/images/tecvico/projects/project-frame-5.png b/static/images/tecvico/projects/project-frame-5.png new file mode 100644 index 0000000..ed3e0a9 Binary files /dev/null and b/static/images/tecvico/projects/project-frame-5.png differ diff --git a/static/images/tecvico/projects/research2.png b/static/images/tecvico/projects/research2.png new file mode 100644 index 0000000..9fa708c Binary files /dev/null and b/static/images/tecvico/projects/research2.png differ diff --git a/static/images/tecvico/research-bg.png b/static/images/tecvico/research-bg.png new file mode 100644 index 0000000..17fee1f Binary files /dev/null and b/static/images/tecvico/research-bg.png differ diff --git a/static/images/tecvico/social/facebook.png b/static/images/tecvico/social/facebook.png new file mode 100644 index 0000000..8802f9f Binary files /dev/null and b/static/images/tecvico/social/facebook.png differ diff --git a/static/images/tecvico/social/instagram(1).png b/static/images/tecvico/social/instagram(1).png new file mode 100644 index 0000000..7ce0464 Binary files /dev/null and b/static/images/tecvico/social/instagram(1).png differ diff --git a/static/images/tecvico/social/linkedin.png b/static/images/tecvico/social/linkedin.png new file mode 100644 index 0000000..821e53a Binary files /dev/null and b/static/images/tecvico/social/linkedin.png differ diff --git a/static/images/tecvico/social/telegram.png b/static/images/tecvico/social/telegram.png new file mode 100644 index 0000000..a7cdbb5 Binary files /dev/null and b/static/images/tecvico/social/telegram.png differ diff --git a/static/images/tecvico/social/twitter.png b/static/images/tecvico/social/twitter.png new file mode 100644 index 0000000..1a7d823 Binary files /dev/null and b/static/images/tecvico/social/twitter.png differ diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..b0c1db3 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,371 @@ +'use strict'; + +const SELECTORS = { + navbar: '#navbar', + navbarToggle: '#navbarToggle', + navbarMenu: '#navbarMenu', + productsNavItem: '#productsNavItem', + faqItems: '.faq-item', + faqQuestion: '.faq-question', + faqAnswer: '.faq-answer', + fadeInElements: '.fade-in', + citationCopy: '.citation-copy', +}; + +const MOBILE_NAV_MQ = window.matchMedia('(max-width: 768px)'); + +function isMobileNav() { + return MOBILE_NAV_MQ.matches; +} + +function initNavbarScroll() { + const navbar = document.querySelector(SELECTORS.navbar); + if (!navbar) return; + + const onScroll = () => { + if (window.scrollY > 20) { + navbar.classList.add('scrolled'); + } else { + navbar.classList.remove('scrolled'); + } + }; + + window.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); +} + +function closeMobileNavMenu() { + const toggle = document.querySelector(SELECTORS.navbarToggle); + const menu = document.querySelector(SELECTORS.navbarMenu); + const productsItem = document.querySelector(SELECTORS.productsNavItem); + if (!toggle || !menu) return; + + menu.classList.remove('open'); + toggle.classList.remove('open'); + toggle.setAttribute('aria-expanded', 'false'); + + if (productsItem) { + productsItem.classList.remove('open'); + const productsTrigger = productsItem.querySelector('.nav-link'); + if (productsTrigger) productsTrigger.setAttribute('aria-expanded', 'false'); + productsItem.querySelectorAll('.megamenu-product-item.has-subproducts.open').forEach((item) => { + item.classList.remove('open'); + }); + } +} + +function initMobileMenu() { + const toggle = document.querySelector(SELECTORS.navbarToggle); + const menu = document.querySelector(SELECTORS.navbarMenu); + if (!toggle || !menu) return; + + toggle.addEventListener('click', () => { + const isOpen = menu.classList.toggle('open'); + toggle.classList.toggle('open', isOpen); + toggle.setAttribute('aria-expanded', String(isOpen)); + if (!isOpen) { + const productsItem = document.querySelector(SELECTORS.productsNavItem); + if (productsItem) { + productsItem.classList.remove('open'); + const productsTrigger = productsItem.querySelector('.nav-link'); + if (productsTrigger) productsTrigger.setAttribute('aria-expanded', 'false'); + } + } + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && menu.classList.contains('open')) { + closeMobileNavMenu(); + toggle.focus(); + } + }); + + document.addEventListener('click', (e) => { + if (!menu.contains(e.target) && !toggle.contains(e.target)) { + closeMobileNavMenu(); + } + }); +} + +function initMegaMenu() { + const productsItem = document.querySelector(SELECTORS.productsNavItem); + if (!productsItem) return; + + const trigger = productsItem.querySelector('.nav-link'); + const megamenu = productsItem.querySelector('.megamenu'); + if (!trigger || !megamenu) return; + + const CLOSE_DELAY_MS = 150; + let hideTimer = null; + + function isPointerInMenu(target) { + if (!target || !(target instanceof Node)) return false; + return productsItem.contains(target) || megamenu.contains(target); + } + + function cancelClose() { + clearTimeout(hideTimer); + hideTimer = null; + megamenu.classList.remove('is-closing'); + } + + function openMenu() { + if (isMobileNav()) return; + cancelClose(); + productsItem.classList.add('open'); + trigger.setAttribute('aria-expanded', 'true'); + } + + function beginClose() { + if (isMobileNav()) return; + productsItem.classList.remove('open'); + trigger.setAttribute('aria-expanded', 'false'); + megamenu.classList.add('is-closing'); + } + + function scheduleClose(event) { + if (isMobileNav()) return; + if (event && isPointerInMenu(event.relatedTarget)) return; + + clearTimeout(hideTimer); + hideTimer = setTimeout(beginClose, CLOSE_DELAY_MS); + } + + megamenu.addEventListener('transitionend', (event) => { + if (event.target !== megamenu) return; + if (event.propertyName !== 'opacity' && event.propertyName !== 'visibility') return; + if (productsItem.classList.contains('open')) return; + megamenu.classList.remove('is-closing'); + }); + + productsItem.addEventListener('mouseenter', openMenu); + productsItem.addEventListener('mouseleave', scheduleClose); + + megamenu.addEventListener('mouseenter', openMenu); + megamenu.addEventListener('mouseleave', scheduleClose); + + trigger.addEventListener('click', (e) => { + if (!isMobileNav()) return; + e.preventDefault(); + const isOpen = productsItem.classList.toggle('open'); + trigger.setAttribute('aria-expanded', String(isOpen)); + }); + + trigger.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (isMobileNav()) { + const isOpen = productsItem.classList.toggle('open'); + trigger.setAttribute('aria-expanded', String(isOpen)); + return; + } + const isOpen = productsItem.classList.toggle('open'); + if (isOpen) { + openMenu(); + } else { + cancelClose(); + beginClose(); + } + trigger.setAttribute('aria-expanded', String(isOpen)); + } + if (e.key === 'Escape') { + if (isMobileNav()) { + productsItem.classList.remove('open'); + trigger.setAttribute('aria-expanded', 'false'); + return; + } + cancelClose(); + beginClose(); + } + }); + + productsItem.querySelectorAll('.megamenu-product-item.has-subproducts').forEach((item) => { + const link = item.querySelector('.megamenu-product-link'); + if (!link) return; + + link.addEventListener('click', (e) => { + if (!isMobileNav()) return; + if (item.classList.contains('open')) return; + e.preventDefault(); + item.classList.add('open'); + }); + }); +} + +function initFAQAccordion() { + const faqItems = document.querySelectorAll(SELECTORS.faqItems); + faqItems.forEach((item) => { + const question = item.querySelector(SELECTORS.faqQuestion); + const answer = item.querySelector(SELECTORS.faqAnswer); + if (!question || !answer) return; + + question.addEventListener('click', () => { + const isOpen = item.classList.contains('open'); + + faqItems.forEach((other) => { + if (other !== item) { + other.classList.remove('open'); + const otherQuestion = other.querySelector(SELECTORS.faqQuestion); + const otherAnswer = other.querySelector(SELECTORS.faqAnswer); + if (otherQuestion) otherQuestion.setAttribute('aria-expanded', 'false'); + if (otherAnswer) otherAnswer.hidden = true; + } + }); + + item.classList.toggle('open', !isOpen); + question.setAttribute('aria-expanded', String(!isOpen)); + answer.hidden = isOpen; + }); + }); +} + +function initIntersectionObserver() { + const elements = document.querySelectorAll(SELECTORS.fadeInElements); + if (!elements.length) return; + + if (!('IntersectionObserver' in window)) { + elements.forEach((el) => el.classList.add('visible')); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add('visible'); + observer.unobserve(entry.target); + } + }); + }, + { threshold: 0.12, rootMargin: '0px 0px -40px 0px' } + ); + + elements.forEach((el, index) => { + el.style.transitionDelay = `${index * 0.06}s`; + observer.observe(el); + }); +} + +function initReadMore() { + var chevronSVG = ''; + + document.querySelectorAll('[data-readmore]').forEach(function (el) { + var threshold = parseInt(el.getAttribute('data-readmore'), 10) || 120; + var fullHeight = el.scrollHeight; + + if (fullHeight <= threshold + 12) return; + + var storedHeight = fullHeight; + + el.classList.add('readmore-wrap', 'is-clamped'); + el.style.maxHeight = threshold + 'px'; + + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'readmore-btn'; + btn.setAttribute('aria-expanded', 'false'); + btn.innerHTML = 'Read more ' + chevronSVG; + el.insertAdjacentElement('afterend', btn); + + btn.addEventListener('click', function () { + var collapsed = el.classList.toggle('is-clamped'); + if (collapsed) { + el.style.maxHeight = threshold + 'px'; + btn.innerHTML = 'Read more ' + chevronSVG; + btn.setAttribute('aria-expanded', 'false'); + btn.classList.remove('is-open'); + } else { + el.style.maxHeight = storedHeight + 'px'; + btn.innerHTML = 'Read less ' + chevronSVG; + btn.setAttribute('aria-expanded', 'true'); + btn.classList.add('is-open'); + } + }); + }); +} + +function initCitationCopy() { + const copyButtons = document.querySelectorAll(SELECTORS.citationCopy); + if (!copyButtons.length) return; + + copyButtons.forEach((btn) => { + const url = btn.getAttribute('data-citation-url'); + const feedback = btn.querySelector('.citation-copy-feedback'); + if (!url || !feedback) return; + + let resetTimer = null; + + btn.addEventListener('click', async () => { + clearTimeout(resetTimer); + + try { + await navigator.clipboard.writeText(url); + } catch { + const textarea = document.createElement('textarea'); + textarea.value = url; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } + + feedback.hidden = false; + btn.classList.add('is-copied'); + btn.setAttribute('aria-label', 'Link copied'); + + resetTimer = setTimeout(() => { + feedback.hidden = true; + btn.classList.remove('is-copied'); + btn.setAttribute('aria-label', 'Copy citation link'); + }, 2000); + }); + }); +} + +function initProjectFilters() { + document.querySelectorAll('[data-projects-section]').forEach((section) => { + const buttons = section.querySelectorAll('[data-project-filter]'); + const cards = section.querySelectorAll('[data-project-status]'); + if (!buttons.length || !cards.length) return; + + const applyFilter = (filter) => { + buttons.forEach((btn) => { + const isActive = btn.getAttribute('data-project-filter') === filter; + btn.classList.toggle('is-active', isActive); + btn.setAttribute('aria-selected', String(isActive)); + }); + + cards.forEach((card) => { + const status = card.getAttribute('data-project-status') || ''; + const visible = filter === 'all' || status === filter; + card.classList.toggle('is-hidden', !visible); + }); + }; + + buttons.forEach((btn) => { + btn.addEventListener('click', () => { + applyFilter(btn.getAttribute('data-project-filter') || 'all'); + }); + }); + }); +} + +function init() { + initNavbarScroll(); + initMobileMenu(); + initMegaMenu(); + initFAQAccordion(); + initIntersectionObserver(); + initReadMore(); + initCitationCopy(); + initProjectFilters(); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..f05d9a7 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,29 @@ +{% load static %} + + + + + + + {% block title %}Tecvico{% endblock %} | Tecvico + + + + + + {% block extra_css %}{% endblock %} + + + + {% include "partials/_navbar.html" %} + +
+ {% block content %}{% endblock %} +
+ + {% include "partials/_footer.html" %} + + + {% block extra_js %}{% endblock %} + + diff --git a/templates/pages/about.html b/templates/pages/about.html new file mode 100644 index 0000000..1c09e60 --- /dev/null +++ b/templates/pages/about.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block title %}About{% endblock %} +{% block meta_description %}Learn about Tecvico — a dynamic team challenging business norms with innovative solutions.{% endblock %} + +{% block content %} +{% include "partials/_page_sections.html" with sections=about_sections %} +{% endblock %} diff --git a/templates/pages/contact.html b/templates/pages/contact.html new file mode 100644 index 0000000..b17ea17 --- /dev/null +++ b/templates/pages/contact.html @@ -0,0 +1,307 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Contact{% endblock %} +{% block meta_description %}Contact Tecvico — support and general inquiries.{% endblock %} + +{% block content %} + +
+ +
+
+
Get in Touch
+

Contact Us

+

Have a question or want to reach the Tecvico team? Fill out the form or contact us directly below.

+
+
+
+ +{% include "partials/_page_videos.html" with videos=contact_videos %} + +
+
+
+ +
+

Send a Message

+
+ {% csrf_token %} + +
+ + {{ form.name }} + +
+ +
+ + {{ form.title }} + +
+ +
+ + {{ form.description }} + +
+ +
+ + {{ form.email }} + +
+ +
+ + +

Images, ZIP, or log/text files — up to 3 files, 10 MB each.

+

+ +
+ +
+ + + +
+ + +
+
+ + {% if site_contact.has_contact_sidebar %} + + {% endif %} + +
+
+
+ + + + + + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/pages/custom_page.html b/templates/pages/custom_page.html new file mode 100644 index 0000000..5495f18 --- /dev/null +++ b/templates/pages/custom_page.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block title %}{{ custom_page.title }}{% endblock %} +{% block meta_description %}{{ custom_page.meta_description|default:custom_page.title }}{% endblock %} + +{% block content %} +{% include "partials/_page_sections.html" with sections=page_sections %} +{% endblock %} diff --git a/templates/pages/faq.html b/templates/pages/faq.html new file mode 100644 index 0000000..5574277 --- /dev/null +++ b/templates/pages/faq.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}FAQ{% endblock %} +{% block meta_description %}Frequently asked questions about Tecvico products and services.{% endblock %} + +{% block content %} + +
+ +
+
+
Help
+

FAQ

+

Frequently Asked Questions

+
+
+
+ +{% include "partials/_page_videos.html" with videos=faq_videos %} + +
+
+

FAQ List

+ {% if faq_entries %} +
+ {% for entry in faq_entries %} +
+ + +
+ {% endfor %} +
+ {% else %} +

No FAQ entries available yet.

+ {% endif %} + +
+

Still Have Questions?

+

Can't find the answer you're looking for? Get in touch with our support team.

+ Contact Support +
+
+
+ +{% endblock %} diff --git a/templates/pages/home.html b/templates/pages/home.html new file mode 100644 index 0000000..e5f5172 --- /dev/null +++ b/templates/pages/home.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Home{% endblock %} +{% block meta_description %}Tecvico — Innovation, Advancement, Competition. Advanced solutions for business development.{% endblock %} + +{% block content %} + +
+ +
+
+ {% if hero %} + {% if hero.badge %}
{{ hero.badge }}
{% endif %} + {% if hero.title or hero.title_highlight %} +

+ {% if hero.title %}{{ hero.title }}{% endif %} + {% if hero.title and hero.title_highlight %}
{% endif %} + {% if hero.title_highlight %}{{ hero.title_highlight }}{% endif %} +

+ {% endif %} + {% if hero.subtitle %}

{{ hero.subtitle }}

{% endif %} + {% if hero.description %}

{{ hero.description }}

{% endif %} + {% if hero.primary_cta_text or hero.secondary_cta_text %} +
+ {% if hero.primary_cta_text %}{{ hero.primary_cta_text }}{% endif %} + {% if hero.secondary_cta_text %}{{ hero.secondary_cta_text }}{% endif %} +
+ {% endif %} + {% endif %} +
+
+ Tecvico showcase +
+
+
+ +{% include "partials/_page_sections.html" with sections=homepage_sections %} + +{% endblock %} diff --git a/templates/partials/_brand_icon.html b/templates/partials/_brand_icon.html new file mode 100644 index 0000000..87c209b --- /dev/null +++ b/templates/partials/_brand_icon.html @@ -0,0 +1,6 @@ +{% load static %} +Tecvico diff --git a/templates/partials/_contact_discord.html b/templates/partials/_contact_discord.html new file mode 100644 index 0000000..5edce92 --- /dev/null +++ b/templates/partials/_contact_discord.html @@ -0,0 +1,8 @@ +{% if site_contact.has_discord %} + + + {{ site_contact.discord_label_display }} + +{% endif %} diff --git a/templates/partials/_contact_email.html b/templates/partials/_contact_email.html new file mode 100644 index 0000000..a1da6e4 --- /dev/null +++ b/templates/partials/_contact_email.html @@ -0,0 +1,3 @@ +{% if site_contact.has_support_email %} +{{ site_contact.support_email }} +{% endif %} diff --git a/templates/partials/_contact_office.html b/templates/partials/_contact_office.html new file mode 100644 index 0000000..3eec51c --- /dev/null +++ b/templates/partials/_contact_office.html @@ -0,0 +1,7 @@ +{% if site_contact.has_office_address %} +
+ {% for line in site_contact.office_address_lines %} +

{{ line }}

+ {% endfor %} +
+{% endif %} diff --git a/templates/partials/_footer.html b/templates/partials/_footer.html new file mode 100644 index 0000000..b4c4863 --- /dev/null +++ b/templates/partials/_footer.html @@ -0,0 +1,51 @@ +{% load static %} +
+ + +
diff --git a/templates/partials/_linked_card_close.html b/templates/partials/_linked_card_close.html new file mode 100644 index 0000000..add2fb6 --- /dev/null +++ b/templates/partials/_linked_card_close.html @@ -0,0 +1,5 @@ +{% if url %} + +{% else %} + +{% endif %} diff --git a/templates/partials/_linked_card_open.html b/templates/partials/_linked_card_open.html new file mode 100644 index 0000000..f660851 --- /dev/null +++ b/templates/partials/_linked_card_open.html @@ -0,0 +1,5 @@ +{% if url %} + +{% else %} +
+{% endif %} diff --git a/templates/partials/_navbar.html b/templates/partials/_navbar.html new file mode 100644 index 0000000..eb9c416 --- /dev/null +++ b/templates/partials/_navbar.html @@ -0,0 +1,97 @@ +{% load static %} + diff --git a/templates/partials/_page_sections.html b/templates/partials/_page_sections.html new file mode 100644 index 0000000..ac5299f --- /dev/null +++ b/templates/partials/_page_sections.html @@ -0,0 +1,498 @@ +{% load static %} + +{% for section in sections %} + +{% if section.section_type == "hero" %} +
+ +
+
+ {% if section.badge %}
{{ section.badge }}
{% endif %} + {% if section.title %}

{{ section.title }}

{% endif %} + {% if section.subtitle %}

{{ section.subtitle }}

{% endif %} + {% if section.content %}
{{ section.rendered_content }}
{% endif %} +
+ {% with items=section.items.all %} + {% if items %} + + {% endif %} + {% endwith %} +
+
+ +{% elif section.section_type == "intro" %} +
+
+
+
+ {% if section.title %}

{{ section.title }}

{% endif %} + {% if section.content %} +
{{ section.rendered_content }}
+ {% endif %} +
+
+ {% with items=section.items.all %} + {% if items %} +
+ {% for item in items %} + {% if item.is_featured and item.image %} + {% else %} + {% include "partials/_standard_item_card.html" with item=item %} + {% endif %} + {% endfor %} +
+ {% include "partials/_section_featured_screenshots.html" with items=items %} + {% endif %} + {% endwith %} +
+
+ +{% elif section.section_type == "grid" %} +
+
+ {% if section.badge or section.title %} +
+ {% if section.badge %}
{{ section.badge }}
{% endif %} + {% if section.title %}

{{ section.title }}

{% endif %} +
+ {% endif %} + {% if section.content %}
{{ section.rendered_content }}
{% endif %} + {% with items=section.items.all %} + {% if items %} +
+ {% for item in items %} + {% if item.is_featured and item.image %} + {% else %} + {% include "partials/_standard_item_card.html" with item=item %} + {% endif %} + {% endfor %} +
+ {% include "partials/_section_featured_screenshots.html" with items=items %} + {% endif %} + {% endwith %} +
+
+ +{% elif section.section_type == "history" %} +
+
+
+ {% with links=section.items.all %} +
+ {% if section.badge %}
{{ section.badge }}
{% endif %} + {% if section.title %}

{{ section.title }}

{% endif %} + {% if section.content %}
{{ section.rendered_content }}
{% endif %} + {% if links %} + + {% endif %} +
+ + {% endwith %} +
+
+
+ +{% elif section.section_type == "custom" %} +
+
+ {% if section.badge or section.title %} +
+ {% if section.badge %}
{{ section.badge }}
{% endif %} + {% if section.title %}

{{ section.title }}

{% endif %} +
+ {% endif %} + {% if section.content %} +
+
{{ section.rendered_content }}
+
+ {% endif %} + {% with items=section.items.all %} + {% if items %} +
+ {% for item in items %} + {% if item.is_featured and item.image %} + {% else %} + {% include "partials/_standard_item_card.html" with item=item %} + {% endif %} + {% endfor %} +
+ {% include "partials/_section_featured_screenshots.html" with items=items %} + {% endif %} + {% endwith %} +
+
+ +{% elif section.section_type == "features" %} +
+
+
+ {% if section.badge %}
{{ section.badge }}
{% endif %} + {% if section.title %}

{{ section.title }}

{% endif %} + {% if section.description %}

{{ section.description }}

{% elif section.content %}
{{ section.rendered_content }}
{% endif %} +
+
+ {% for item in section.items.all %} + {% include "partials/_linked_card_open.html" with url=item.url card_class="feature-card" %} + {% if item.icon %}{% endif %} +

{{ item.title }}

+

{{ item.content }}

+ {% include "partials/_linked_card_close.html" with url=item.url %} + {% endfor %} +
+
+
+ +{% elif section.section_type == "screenshots" %} +
+
+
+ {% if section.badge %}
{{ section.badge }}
{% endif %} + {% if section.title %}

{{ section.title }}

{% endif %} + {% if section.description %}

{{ section.description }}

{% elif section.content %}
{{ section.rendered_content }}
{% endif %} +
+
+ {% for item in section.items.all %} + {% if item.image %} + {% include "partials/_linked_card_open.html" with url=item.url card_class="screenshot-item" %} + {{ item.image_alt|default:item.title }} + {% include "partials/_linked_card_close.html" with url=item.url %} + {% endif %} + {% endfor %} +
+
+
+ +{% elif section.section_type == "projects" %} +
+
+
+ {% if section.badge %}
{{ section.badge }}
{% endif %} + {% if section.title or section.title_highlight %} +

+ {% if section.title %}{{ section.title }}{% endif %} + {% if section.title_highlight %}{{ section.title_highlight }}{% endif %} +

+ {% endif %} + {% if section.description %}

{{ section.description }}

{% endif %} +
+
+ + + + +
+ +
+
+ +{% elif section.section_type == "experience" %} +
+
+
+ {% if section.badge %}
{{ section.badge }}
{% endif %} + {% if section.title or section.title_highlight %} +

+ {% if section.title %}{{ section.title }}{% endif %} + {% if section.title_highlight %} {{ section.title_highlight }}{% endif %} +

+ {% endif %} + {% if section.description %} +
{{ section.description|linebreaksbr }}
+ {% endif %} +
+
+ {% for item in section.items.all %} +
+ {% if item.image %} +
+ +
+ {% elif item.icon %} + + {% endif %} + {% if item.title %}

{{ item.title }}

{% endif %} + {% if item.content %}

{{ item.content }}

{% endif %} +
+ {% endfor %} +
+
+
+ +{% elif section.section_type == "products" %} +{% if homepage_products %} +
+
+
+ {% if section.badge %}
{{ section.badge }}
{% endif %} + {% if section.title %}

{{ section.title }}

{% endif %} + {% if section.description %}

{{ section.description }}

{% elif section.content %}
{{ section.rendered_content }}
{% endif %} +
+ +
+
+{% endif %} + +{% elif section.section_type == "about_strip" %} +
+
+
+
+ {% if section.badge %}
{{ section.badge }}
{% endif %} + {% if section.title %}

{{ section.title }}

{% endif %} + {% if section.description %}

{{ section.description }}

{% elif section.content %}

{{ section.rendered_content }}

{% endif %} + {% if section.link_text and section.link_url %} + {{ section.link_text }} + {% endif %} +
+ +
+
+
+ +{% elif section.section_type == "products_catalog" %} +{% if homepage_products_catalog %} +
+
+
+ {% if section.badge %}
{{ section.badge }}
{% endif %} + {% if section.title %}

{{ section.title }}

{% endif %} + {% if section.description %}

{{ section.description }}

{% elif section.content %}
{{ section.rendered_content }}
{% endif %} +
+
+ {% for product in homepage_products_catalog %} +
+ {% if product.image %} +
+ {{ product.name }} +
+ {% endif %} +
+

{{ product.name }}

+

{{ product.short_description }}

+

{{ product.description }}

+ Explore {{ product.name }} +
+ {% if product.sub_products.all %} +
+

Modules

+ +
+ {% endif %} +
+ {% endfor %} +
+
+
+{% endif %} + +{% elif section.section_type == "problems" %} +
+
+
+ {% if section.badge %}
{{ section.badge }}
{% endif %} + {% if section.title %}

{{ section.title }}

{% endif %} + {% if section.description %}

{{ section.description }}

{% elif section.content %}
{{ section.rendered_content }}
{% endif %} +
+
+ {% for item in section.items.all %} + {% include "partials/_linked_card_open.html" with url=item.url card_class="problem-item" %} + {% if item.icon %}{% endif %} +

{{ item.title }}

+

{{ item.content }}

+ {% include "partials/_linked_card_close.html" with url=item.url %} + {% endfor %} +
+
+
+ +{% elif section.section_type == "supporters" %} +
+
+
+ {% if section.badge %}
{{ section.badge }}
{% endif %} + {% if section.title %}

{{ section.title }}

{% endif %} + {% if section.description %}

{{ section.description }}

{% elif section.content %}
{{ section.rendered_content }}
{% endif %} +
+
+ {% for item in section.items.all %} + {% include "partials/_linked_card_open.html" with url=item.url card_class="supporter-card" %} + {% if item.image %} + + {% endif %} +
+

{{ item.title }}

+ {% if item.content %}

{{ item.content }}

{% endif %} +
+ {% include "partials/_linked_card_close.html" with url=item.url %} + {% endfor %} +
+
+
+ +{% elif section.section_type == "faq" %} +
+
+ {% if section.badge or section.title %} +
+ {% if section.badge %}
{{ section.badge }}
{% endif %} + {% if section.title %}

{{ section.title }}

{% endif %} +
+ {% endif %} + {% with items=section.items.all %} + {% if items %} +
+ {% for item in items %} + {% if item.url %} + {% include "partials/_linked_card_open.html" with url=item.url card_class="faq-item faq-item--link" %} +
+ {{ item.title }} + +
+ {% if item.content %}{% endif %} + {% include "partials/_linked_card_close.html" with url=item.url %} + {% else %} +
+ + +
+ {% endif %} + {% endfor %} +
+ {% endif %} + {% endwith %} +
+
+ +{% elif section.section_type == "video" %} +{% include "partials/_video_section.html" with section=section %} + +{% endif %} +{% empty %} +
+
+

+ No content configured yet. Add sections in the admin panel. +

+
+
+{% endfor %} diff --git a/templates/partials/_page_videos.html b/templates/partials/_page_videos.html new file mode 100644 index 0000000..9a6be3e --- /dev/null +++ b/templates/partials/_page_videos.html @@ -0,0 +1,15 @@ +{% if videos %} +
+
+
+ {% for video in videos %} + {% if video.has_video %} +
+ {% include "partials/_video_block.html" with video=video compact_header=True %} +
+ {% endif %} + {% endfor %} +
+
+
+{% endif %} diff --git a/templates/partials/_section_featured_screenshots.html b/templates/partials/_section_featured_screenshots.html new file mode 100644 index 0000000..174bbe7 --- /dev/null +++ b/templates/partials/_section_featured_screenshots.html @@ -0,0 +1,9 @@ +
+ {% for item in items %} + {% if item.is_featured and item.image %} + {% include "partials/_linked_card_open.html" with url=item.url card_class="screenshot-item fade-in screenshot-item--featured" %} + {{ item.image_alt|default:item.title }} + {% include "partials/_linked_card_close.html" with url=item.url %} + {% endif %} + {% endfor %} +
diff --git a/templates/partials/_standard_item_card.html b/templates/partials/_standard_item_card.html new file mode 100644 index 0000000..34765c8 --- /dev/null +++ b/templates/partials/_standard_item_card.html @@ -0,0 +1,20 @@ +{% if item.image %} +{% include "partials/_linked_card_open.html" with url=item.url card_class="supporter-card" %} + +
+ {% if item.icon %}{% endif %} + {% if item.badge %}
{{ item.badge }}
{% endif %} + {% if item.title %}

{{ item.title }}

{% endif %} + {% if item.content %}

{{ item.content }}

{% endif %} +
+{% include "partials/_linked_card_close.html" with url=item.url %} +{% else %} +{% include "partials/_linked_card_open.html" with url=item.url card_class="feature-card" %} + {% if item.icon %}{% endif %} + {% if item.badge %}
{{ item.badge }}
{% endif %} + {% if item.title %}

{{ item.title }}

{% endif %} + {% if item.content %}

{{ item.content }}

{% endif %} +{% include "partials/_linked_card_close.html" with url=item.url %} +{% endif %} diff --git a/templates/partials/_video_block.html b/templates/partials/_video_block.html new file mode 100644 index 0000000..f62ef46 --- /dev/null +++ b/templates/partials/_video_block.html @@ -0,0 +1,55 @@ +{% if video.has_video %} +{% if video.video_styled_background %} +
+ +
+ {% if video.badge or video.title %} +
+
+ {% if video.badge %}
{{ video.badge }}
{% endif %} + {% if video.title %} +

+ + {{ video.title }} +

+ {% else %} +

Video

+ {% endif %} +
+
+ {% else %} +

Video

+ {% endif %} + {% if video.description %} +
{{ video.rendered_description }}
+ {% elif video.content %} +
{{ video.rendered_content }}
+ {% endif %} + {% include "partials/_video_player.html" with video=video %} +
+
+{% else %} +{% if video.badge or video.title %} +
+ {% if video.badge %}
{{ video.badge }}
{% endif %} + {% if video.title %} +

{{ video.title }}

+ {% else %} +

Video

+ {% endif %} +
+{% endif %} +{% if video.description %} +
{{ video.rendered_description }}
+{% elif video.content %} +
{{ video.rendered_content }}
+{% endif %} +{% include "partials/_video_player.html" with video=video %} +{% endif %} +{% endif %} diff --git a/templates/partials/_video_player.html b/templates/partials/_video_player.html new file mode 100644 index 0000000..4f5bf9b --- /dev/null +++ b/templates/partials/_video_player.html @@ -0,0 +1,26 @@ +{% if video.has_video %} +
+
+ {% if video.video_source == "upload" and video.video_file %} + + {% elif video.youtube_embed_url %} + + {% endif %} +
+
+{% endif %} diff --git a/templates/partials/_video_section.html b/templates/partials/_video_section.html new file mode 100644 index 0000000..4d11a68 --- /dev/null +++ b/templates/partials/_video_section.html @@ -0,0 +1,7 @@ +{% if section.has_video %} +
+
+ {% include "partials/_video_block.html" with video=section %} +
+
+{% endif %} diff --git a/templates/products/_article_list.html b/templates/products/_article_list.html new file mode 100644 index 0000000..28a885d --- /dev/null +++ b/templates/products/_article_list.html @@ -0,0 +1,69 @@ +{% if articles %} +
+
+ {% for article in articles %} +
+ {% if article.badge %}
{{ article.badge }}
{% endif %} +

{{ article.title }}

+
{{ article.rendered_description }}
+ {% if article.sections.all %} +
+
+ {% for section in article.sections.all %} +
+
{{ section.title }}
+
{{ section.rendered_value }}
+
+ {% endfor %} +
+
+ {% endif %} + {% if article.citations.all %} +
+

Citations

+
    + {% for citation in article.citations.all %} +
  1. + {{ citation.text }} + {% if citation.url %} + + + + + + + {% endif %} +
  2. + {% endfor %} +
+
+ {% endif %} + {% if article.citation_count_display is not None or article.citation_count_label %} + {% if article.citations.exists %} + + {% if article.citation_count_display is not None %}{{ article.citation_count_display }}{% endif %} + {% if article.citation_count_label %}{{ article.citation_count_label }}{% endif %} + + {% else %} +
+ {% if article.citation_count_display is not None %}{{ article.citation_count_display }}{% endif %} + {% if article.citation_count_label %}{{ article.citation_count_label }}{% endif %} +
+ {% endif %} + {% endif %} +
+ {% endfor %} +
+
+{% endif %} diff --git a/templates/products/_installable_download_channel.html b/templates/products/_installable_download_channel.html new file mode 100644 index 0000000..d8f7eaa --- /dev/null +++ b/templates/products/_installable_download_channel.html @@ -0,0 +1,34 @@ +{% load static %} +
+ {% include "products/_release_channel_badge.html" with version=channel.version %} + {% if channel.install_cells %} +
+ {% for cell in channel.install_cells %} +
+
+ {% if cell.icon %} + + {% else %} + + {% endif %} +
+ {{ cell.label }} + {{ channel.version.version }} + {% if cell.label == "Source code" %}Source{% else %}Download{% endif %} +
+ {% endfor %} +
+ {% endif %} + {% if channel.version.package_resource_url %} +

+ Package resource +

+ {% endif %} + {% if channel.version.release_notes %} +

{{ channel.version.release_notes }}

+ {% endif %} +
diff --git a/templates/products/_release_channel_badge.html b/templates/products/_release_channel_badge.html new file mode 100644 index 0000000..e0f8363 --- /dev/null +++ b/templates/products/_release_channel_badge.html @@ -0,0 +1,10 @@ +{% if version.display_channel_label %} + + {% if version.release_channel == "stable" %} + + {% endif %} + {{ version.display_channel_label }} + +{% endif %} diff --git a/templates/products/_releases_section.html b/templates/products/_releases_section.html new file mode 100644 index 0000000..48390e8 --- /dev/null +++ b/templates/products/_releases_section.html @@ -0,0 +1,69 @@ +{% load static %} +{% if show_releases_section %} +{% if distribution_installable %} +
+
+

+ + Downloads +

+ {% if downloads_section_link %} + {{ downloads_section_link.text }} + {% endif %} +
+ + {% if featured_version %} + {% include "products/_installable_download_channel.html" with channel=featured_channel %} + {% endif %} + + {% for channel in inline_download_channels %} + {% include "products/_installable_download_channel.html" %} + {% endfor %} + + {% if show_older_versions_link %} +

+ Older releases +

+ {% endif %} +
+ +{% elif distribution_package %} +
+
+

+ + Resources +

+ {% if downloads_section_link %} + {{ downloads_section_link.text }} + {% endif %} +
+
    + {% for ver in package_versions %} +
  • +
    + {{ ver.version }} + {% include "products/_release_channel_badge.html" with version=ver %} +
    +
    + {% if ver.package_resource_url %} + {{ package_resource_button_text }} + {% endif %} + {% if ver.resolved_source_code_url %} + {{ package_source_button_text }} + {% endif %} +
    + {% if ver.release_notes %} +

    {{ ver.release_notes }}

    + {% endif %} +
  • + {% endfor %} +
+
+{% endif %} +{% endif %} diff --git a/templates/products/_versions_archive_body.html b/templates/products/_versions_archive_body.html new file mode 100644 index 0000000..ab39e92 --- /dev/null +++ b/templates/products/_versions_archive_body.html @@ -0,0 +1,70 @@ +{% load static %} +{% if not archive_versions %} +

There are no archived releases{% if sub_product %} for this module{% else %} for this product{% endif %}.

+{% elif distribution_installable %} +
+ + + + + {% for fname, label, icon in archive_specs %} + + {% endfor %} + + + + {% for row in archive_rows_installable %} + + + {% for cell in row.cells %} + + {% endfor %} + + {% if row.version_obj.release_notes %} + + + + {% endif %} + {% endfor %} + +
Version{{ label }}
+ {{ row.version_obj.version }} + {% include "products/_release_channel_badge.html" with version=row.version_obj %} + + {% if cell.url %} + {% if cell.icon %} + + + {{ cell.label }} + + {% else %} + Source + {% endif %} + {% else %} + + {% endif %} +
+ Notes +

{{ row.version_obj.release_notes }}

+
+
+{% else %} +
    + {% for ver in archive_versions %} +
  • +
    +
    + {{ ver.version }} + {% include "products/_release_channel_badge.html" with version=ver %} +
    + {% if ver.package_resource_url %} + {{ package_resource_button_text }} + {% endif %} +
    + {% if ver.release_notes %} +

    {{ ver.release_notes }}

    + {% endif %} +
  • + {% endfor %} +
+{% endif %} diff --git a/templates/products/main_detail.html b/templates/products/main_detail.html new file mode 100644 index 0000000..4511b01 --- /dev/null +++ b/templates/products/main_detail.html @@ -0,0 +1,105 @@ +{% extends "base.html" %} +{% load static %} + + +{% block title %}{{ main_product.name }}{% endblock %} +{% block meta_description %}{{ main_product.short_description }}{% endblock %} + +{% block content %} + +
+ +
+ +
+
Product
+

{{ main_product.name }}

+

{{ main_product.short_description }}

+
+
+
+ +
+
+
+
+
+ {% if main_product.image %} + {{ main_product.name }} + {% else %} + {{ main_product.name }} logo + {% endif %} +
+
+

About {{ main_product.name }}

+
{{ main_product.rendered_description }}
+
+
+ + {% include "products/_releases_section.html" %} +
+
+
+ +{% include "partials/_page_videos.html" with videos=product_videos %} + +{% if articles %} +
+
+ {% include "products/_article_list.html" %} +
+
+{% endif %} + +{% with subs=main_product.sub_products.all %} +{% if subs %} +
+
+
+
Modules
+

{{ main_product.name }} Modules

+

+ Explore the different modules and capabilities within {{ main_product.name }}. +

+
+
+ {% for sub in subs %} + {% if sub.is_active %} + + {% if sub.image %} +
+ {{ sub.name }} +
+ {% endif %} +
+

{{ sub.name }}

+

{{ sub.short_description }}

+ + Learn More + + +
+
+ {% endif %} + {% endfor %} +
+
+
+{% endif %} +{% endwith %} + +{% endblock %} diff --git a/templates/products/overview.html b/templates/products/overview.html new file mode 100644 index 0000000..0a0b818 --- /dev/null +++ b/templates/products/overview.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Products{% endblock %} +{% block meta_description %}Explore Tecvico's suite of products and solutions.{% endblock %} + +{% block content %} + +
+ +
+
+
Software Suite
+

Our Products

+

Advanced tools for medical imaging research and radiomics analysis.

+
+
+
+ +
+
+

Product List

+ {% if main_products %} +
+ {% for product in main_products %} +
+ {% if product.image %} +
+ {{ product.name }} +
+ {% endif %} +
+

{{ product.name }}

+

{{ product.short_description }}

+

{{ product.description }}

+ Explore {{ product.name }} +
+ {% with subs=product.sub_products.all %} + {% if subs %} +
+

Modules

+ +
+ {% endif %} + {% endwith %} +
+ {% endfor %} +
+ {% else %} +
+

No products available at this time. Check back soon.

+
+ {% endif %} +
+
+ +{% endblock %} diff --git a/templates/products/sub_detail.html b/templates/products/sub_detail.html new file mode 100644 index 0000000..f45e57a --- /dev/null +++ b/templates/products/sub_detail.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}{{ sub_product.name }} — {{ main_product.name }}{% endblock %} +{% block meta_description %}{{ sub_product.short_description }}{% endblock %} + +{% block content %} + +
+ +
+ +
+
{{ main_product.name }}
+

{{ sub_product.name }}

+

{{ sub_product.short_description }}

+
+
+
+ +
+
+
+ +
+ {% if sub_product.image %} +
+ {{ sub_product.name }} +
+ {% endif %} + +
+

About {{ sub_product.name }}

+
{{ sub_product.rendered_description }}
+
+ + {% include "products/_releases_section.html" %} + + {% include "partials/_page_videos.html" with videos=product_videos %} + + {% include "products/_article_list.html" %} +
+ + + +
+
+
+ +{% endblock %} diff --git a/templates/products/versions_archive.html b/templates/products/versions_archive.html new file mode 100644 index 0000000..bca3b16 --- /dev/null +++ b/templates/products/versions_archive.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block title %}Older releases — {% if sub_product %}{{ sub_product.name }} — {% endif %}{{ main_product.name }}{% endblock %} +{% block meta_description %}Archived releases{% if sub_product %} for {{ sub_product.name }}{% else %} for {{ main_product.name }}{% endif %}.{% endblock %} + +{% block content %} + +
+ +
+ +
+
{% if sub_product %}{{ sub_product.name }}{% else %}{{ main_product.name }}{% endif %}
+

Older releases

+

Archived versions of this {% if sub_product %}module{% else %}product{% endif %}.

+
+
+
+ +
+
+ {% include "products/_versions_archive_body.html" %} +
+
+ +{% endblock %} diff --git a/test_media_releases/releases/radiuma/0.1/windows/a.exe b/test_media_releases/releases/radiuma/0.1/windows/a.exe new file mode 100644 index 0000000..2e65efe --- /dev/null +++ b/test_media_releases/releases/radiuma/0.1/windows/a.exe @@ -0,0 +1 @@ +a \ No newline at end of file diff --git a/test_media_releases/releases/radiuma/2.0/windows/setup.exe b/test_media_releases/releases/radiuma/2.0/windows/setup.exe new file mode 100644 index 0000000..f99364c --- /dev/null +++ b/test_media_releases/releases/radiuma/2.0/windows/setup.exe @@ -0,0 +1 @@ +binary-payload \ No newline at end of file diff --git a/test_media_releases/releases/radiuma/2.1/windows/local.exe b/test_media_releases/releases/radiuma/2.1/windows/local.exe new file mode 100644 index 0000000..c1b0730 --- /dev/null +++ b/test_media_releases/releases/radiuma/2.1/windows/local.exe @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/test_media_releases/releases/radiuma/3.0/windows/win.tar.gz b/test_media_releases/releases/radiuma/3.0/windows/win.tar.gz new file mode 100644 index 0000000..29f2ec2 --- /dev/null +++ b/test_media_releases/releases/radiuma/3.0/windows/win.tar.gz @@ -0,0 +1 @@ +gz \ No newline at end of file diff --git a/test_media_releases/releases/storage-test2/1.0/windows/setup.exe b/test_media_releases/releases/storage-test2/1.0/windows/setup.exe new file mode 100644 index 0000000..28c218c --- /dev/null +++ b/test_media_releases/releases/storage-test2/1.0/windows/setup.exe @@ -0,0 +1 @@ +v1 \ No newline at end of file diff --git a/test_media_releases/releases/storage-test3/1.0/windows/setup.exe b/test_media_releases/releases/storage-test3/1.0/windows/setup.exe new file mode 100644 index 0000000..28c218c --- /dev/null +++ b/test_media_releases/releases/storage-test3/1.0/windows/setup.exe @@ -0,0 +1 @@ +v1 \ No newline at end of file