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..0ba8c4b --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +DJANGO_SETTINGS_MODULE=config.settings.development +DJANGO_SECRET_KEY=django-insecure-replace-this-with-a-real-secret-key-in-production +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 + +POSTGRES_DB=tecvico +POSTGRES_USER=tecvico_user +POSTGRES_PASSWORD=tecvico_password +POSTGRES_HOST=db +POSTGRES_PORT=5432 + +SECURE_SSL_REDIRECT=False diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96dbc5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +__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/ +staticfiles/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..64a47de --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +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 . . + +RUN python manage.py collectstatic --noinput + +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 index 6224e25..cb140ce 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,278 @@ -# Radiuma_Website +# Tecvico Website +Django MVT informational website for **Tecvico Corp** (formerly Visera), showcasing the **ViSERA** medical imaging and radiomics software suite. + +## 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 (dark glass/ice 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` | What is 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: + +- **ViSERA** (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 Corp. 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/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..2b45915 --- /dev/null +++ b/apps/core/context_processors.py @@ -0,0 +1,10 @@ +from apps.products.models import MainProduct + + +def navigation(request): + main_products = ( + MainProduct.objects.filter(is_active=True) + .prefetch_related("sub_products") + .order_by("order", "name") + ) + return {"nav_main_products": main_products} 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/seed_content.py b/apps/core/management/commands/seed_content.py new file mode 100644 index 0000000..606c319 --- /dev/null +++ b/apps/core/management/commands/seed_content.py @@ -0,0 +1,400 @@ +from django.core.management.base import BaseCommand +from django.db import transaction + +from apps.pages.models import DownloadItem, FAQEntry +from apps.products.models import Article, ArticleSection, MainProduct, SubProduct + +MAIN_PRODUCTS = [ + { + "name": "ViSERA", + "slug": "visera", + "short_description": "Visualized & Standardized Environment for Radiomics Analysis", + "description": ( + "ViSERA 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. " + "ViSERA 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, + "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. ViSERA 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": ( + "ViSERA 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 Corp R&D Team", "order": 3}, + ], + }, + { + "title": "Image Registration & Fusion", + "description": ( + "ViSERA 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": ( + "ViSERA 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": ( + "ViSERA 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": ( + "ViSERA 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": ( + "ViSERA 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": ( + "ViSERA'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 ViSERA license?", + "answer": ( + "ViSERA 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 ViSERA in my research?", + "answer": ( + "Please cite the following reference if you publish results obtained with " + "the help of ViSERA:\n\n" + "M. R. Salmanpour, I. Shiri, M. Hosseinzadeh, H. Zaidi, S. Ashrafinia, " + "M. Oveisi, A. Rahmim. ViSERA: 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 ViSERA support?", + "answer": ( + "ViSERA 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 the Downloads 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 ViSERA suitable for clinical use?", + "answer": ( + "ViSERA 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 at support@tecvico.com and through our " + "community Discord server. For bug reports and feature requests, please " + "use the Discord forum or contact us directly by email." + ), + "order": 6, + }, +] + +DOWNLOAD_ITEMS = [ + { + "name": "ViSERA Desktop", + "platform": "windows", + "version": "1.0.0", + "download_url": "https://github.com/tecvico/visera/releases/latest/download/ViSERA-Setup.exe", + "description": "Windows 10 and above (64-bit). Installer package.", + "is_active": True, + "order": 1, + }, + { + "name": "ViSERA Desktop", + "platform": "macos", + "version": "Coming Soon", + "download_url": "#", + "description": "macOS version is under development.", + "is_active": False, + "order": 2, + }, + { + "name": "ViSERA 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 / ViSERA content from visera.ca" + + 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() + + self._seed_products() + self._seed_faq() + self._seed_downloads() + + 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_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}") 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..9d7eeb2 --- /dev/null +++ b/apps/pages/admin.py @@ -0,0 +1,27 @@ +from django.contrib import admin + +from .models import DownloadItem, FAQEntry + + +@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")}), + ("Settings", {"fields": ("order", "is_active")}), + ) + + +@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/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/__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..e27f2c8 --- /dev/null +++ b/apps/pages/models.py @@ -0,0 +1,47 @@ +from django.db import models + + +class FAQEntry(models.Model): + 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) + + class Meta: + ordering = ["order"] + verbose_name = "FAQ Entry" + verbose_name_plural = "FAQ Entries" + + def __str__(self): + return self.question + + +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()})" 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_views.py b/apps/pages/tests/test_views.py new file mode 100644 index 0000000..02d0427 --- /dev/null +++ b/apps/pages/tests/test_views.py @@ -0,0 +1,114 @@ +from django.test import TestCase +from django.urls import reverse + +from apps.pages.models import DownloadItem, 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") + + +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 DownloadsViewTest(TestCase): + def setUp(self): + DownloadItem.objects.create( + name="ViSERA Desktop", + platform="windows", + version="1.0", + download_url="https://example.com/windows", + is_active=True, + ) + DownloadItem.objects.create( + name="ViSERA Desktop", + platform="macos", + version="Coming Soon", + download_url="#", + is_active=False, + ) + + def test_downloads_returns_200(self): + response = self.client.get(reverse("pages:downloads")) + self.assertEqual(response.status_code, 200) + + def test_downloads_uses_correct_template(self): + response = self.client.get(reverse("pages:downloads")) + self.assertTemplateUsed(response, "pages/downloads.html") + + def test_downloads_context_has_platform_keys(self): + response = self.client.get(reverse("pages:downloads")) + self.assertIn("windows_items", response.context) + self.assertIn("macos_items", response.context) + self.assertIn("linux_items", response.context) + + def test_windows_item_in_context(self): + response = self.client.get(reverse("pages:downloads")) + self.assertEqual(response.context["windows_items"].count(), 1) + + +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("pages:downloads"), + ] + for url in urls: + response = self.client.get(url) + self.assertIn("nav_main_products", response.context, f"Missing nav_main_products at {url}") diff --git a/apps/pages/urls.py b/apps/pages/urls.py new file mode 100644 index 0000000..9673e0c --- /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("downloads/", views.DownloadsView.as_view(), name="downloads"), + path("faq/", views.FAQView.as_view(), name="faq"), + path("contact/", views.ContactView.as_view(), name="contact"), +] diff --git a/apps/pages/views.py b/apps/pages/views.py new file mode 100644 index 0000000..fef3471 --- /dev/null +++ b/apps/pages/views.py @@ -0,0 +1,40 @@ +from django.views.generic import ListView, TemplateView + +from .models import DownloadItem, FAQEntry + + +class HomeView(TemplateView): + template_name = "pages/home.html" + + +class AboutView(TemplateView): + template_name = "pages/about.html" + + +class DownloadsView(ListView): + template_name = "pages/downloads.html" + context_object_name = "download_items" + + def get_queryset(self): + return DownloadItem.objects.filter(is_active=True).order_by("order", "platform") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + all_items = DownloadItem.objects.order_by("order", "platform") + context["windows_items"] = all_items.filter( + platform=DownloadItem.PLATFORM_WINDOWS + ) + context["macos_items"] = all_items.filter(platform=DownloadItem.PLATFORM_MACOS) + context["linux_items"] = all_items.filter(platform=DownloadItem.PLATFORM_LINUX) + return context + + +class FAQView(ListView): + model = FAQEntry + template_name = "pages/faq.html" + context_object_name = "faq_entries" + queryset = FAQEntry.objects.filter(is_active=True) + + +class ContactView(TemplateView): + template_name = "pages/contact.html" 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..9ba6a6d --- /dev/null +++ b/apps/products/admin.py @@ -0,0 +1,83 @@ +from django.contrib import admin + +from .models import Article, ArticleSection, MainProduct, SubProduct + + +class ArticleSectionInline(admin.TabularInline): + model = ArticleSection + extra = 1 + fields = ("title", "value", "order") + ordering = ("order",) + + +class ArticleInline(admin.StackedInline): + model = Article + extra = 0 + fields = ("title", "description", "order") + ordering = ("order",) + show_change_link = True + + +class SubProductInline(admin.StackedInline): + model = SubProduct + extra = 0 + fields = ("name", "slug", "short_description", "image", "order", "is_active") + ordering = ("order",) + show_change_link = True + prepopulated_fields = {"slug": ("name",)} + + +@admin.register(MainProduct) +class MainProductAdmin(admin.ModelAdmin): + list_display = ("name", "order", "is_active", "created_at") + list_filter = ("is_active",) + search_fields = ("name", "description") + prepopulated_fields = {"slug": ("name",)} + list_editable = ("order", "is_active") + inlines = [SubProductInline] + fieldsets = ( + (None, {"fields": ("name", "slug", "short_description", "description")}), + ("Media", {"fields": ("image",)}), + ("Settings", {"fields": ("order", "is_active")}), + ) + + +@admin.register(SubProduct) +class SubProductAdmin(admin.ModelAdmin): + list_display = ("name", "main_product", "order", "is_active", "created_at") + list_filter = ("is_active", "main_product") + search_fields = ("name", "description", "main_product__name") + prepopulated_fields = {"slug": ("name",)} + list_editable = ("order", "is_active") + raw_id_fields = ("main_product",) + inlines = [ArticleInline] + fieldsets = ( + ( + None, + {"fields": ("main_product", "name", "slug", "short_description", "description")}, + ), + ("Media", {"fields": ("image",)}), + ("Settings", {"fields": ("order", "is_active")}), + ) + + +@admin.register(Article) +class ArticleAdmin(admin.ModelAdmin): + list_display = ("title", "sub_product", "order", "created_at") + list_filter = ("sub_product__main_product",) + search_fields = ("title", "description") + list_editable = ("order",) + raw_id_fields = ("sub_product",) + inlines = [ArticleSectionInline] + fieldsets = ( + (None, {"fields": ("sub_product", "title", "description")}), + ("Settings", {"fields": ("order",)}), + ) + + +@admin.register(ArticleSection) +class ArticleSectionAdmin(admin.ModelAdmin): + list_display = ("title", "article", "order") + search_fields = ("title", "value", "article__title") + list_editable = ("order",) + raw_id_fields = ("article",) diff --git a/apps/products/apps.py b/apps/products/apps.py new file mode 100644 index 0000000..6185e2f --- /dev/null +++ b/apps/products/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ProductsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.products" + verbose_name = "Products" 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/__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..cd87cad --- /dev/null +++ b/apps/products/models.py @@ -0,0 +1,111 @@ +from django.db import models +from django.urls import reverse +from django.utils.text import slugify + + +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() + image = models.ImageField(upload_to="products/main/", blank=True, null=True) + 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", "name"] + verbose_name = "Main Product" + verbose_name_plural = "Main Products" + + 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}) + + +class SubProduct(models.Model): + 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() + image = models.ImageField(upload_to="products/sub/", blank=True, null=True) + 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", "name"] + unique_together = [["main_product", "slug"]] + verbose_name = "Sub Product" + verbose_name_plural = "Sub Products" + + 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, + }, + ) + + +class Article(models.Model): + sub_product = models.ForeignKey( + SubProduct, + on_delete=models.CASCADE, + related_name="articles", + ) + 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) + + class Meta: + ordering = ["order", "title"] + verbose_name = "Article" + verbose_name_plural = "Articles" + + def __str__(self): + return self.title + + +class ArticleSection(models.Model): + article = models.ForeignKey( + Article, + on_delete=models.CASCADE, + related_name="sections", + ) + title = models.CharField(max_length=200) + value = models.TextField() + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["order"] + verbose_name = "Article Section" + verbose_name_plural = "Article Sections" + + def __str__(self): + return f"{self.article.title} › {self.title}" 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..7899489 --- /dev/null +++ b/apps/products/tests/test_models.py @@ -0,0 +1,134 @@ +from django.test import TestCase +from django.urls import reverse + +from apps.products.models import Article, ArticleSection, MainProduct, SubProduct + + +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()) + + +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..2affea3 --- /dev/null +++ b/apps/products/tests/test_views.py @@ -0,0 +1,137 @@ +from django.test import TestCase +from django.urls import reverse + +from apps.products.models import Article, ArticleSection, MainProduct, SubProduct + + +class ProductViewsSetup(TestCase): + def setUp(self): + self.main_product = MainProduct.objects.create( + name="ViSERA", + slug="visera", + 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": "visera"}) + 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": "visera"}) + 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": "visera"}) + 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": "visera"}) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + +class SubProductDetailViewTest(ProductViewsSetup): + def test_sub_detail_returns_200(self): + url = reverse( + "products:sub_product_detail", + kwargs={"main_slug": "visera", "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": "visera", "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": "visera", "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": "visera", "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": "visera", "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) diff --git a/apps/products/urls.py b/apps/products/urls.py new file mode 100644 index 0000000..cb8a32f --- /dev/null +++ b/apps/products/urls.py @@ -0,0 +1,19 @@ +from django.urls import path + +from . import views + +app_name = "products" + +urlpatterns = [ + path("", views.ProductOverviewView.as_view(), name="overview"), + path( + "/", + views.MainProductDetailView.as_view(), + name="main_product_detail", + ), + 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..7b887b3 --- /dev/null +++ b/apps/products/views.py @@ -0,0 +1,53 @@ +from django.shortcuts import get_object_or_404 +from django.views.generic import DetailView, ListView, TemplateView + +from .models import MainProduct, SubProduct + + +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" + ) + + +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").all() + context["siblings"] = ( + SubProduct.objects.filter( + main_product=main_product, + is_active=True, + ) + .exclude(pk=sub_product.pk) + .order_by("order", "name") + ) + 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..8e69e76 --- /dev/null +++ b/config/settings/base.py @@ -0,0 +1,128 @@ +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.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" + +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/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..be5658c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +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_data:/app/media + 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..0e41866 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,29 @@ +#!/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 "Running database migrations..." +python manage.py migrate --noinput + +echo "Starting application..." +exec "$@" diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..1fb25a0 --- /dev/null +++ b/manage.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +import os +import sys + + +def main(): + 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..783fabd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +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 diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..71f3b81 --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,1942 @@ +/* ============================================================ + CSS Custom Properties — Dark Ice Theme + ============================================================ */ +:root { + --bg-primary: #070713; + --bg-secondary: #0c0c1e; + --bg-surface: #10102a; + + --glass-bg: rgba(255, 255, 255, 0.05); + --glass-bg-hover: rgba(255, 255, 255, 0.09); + --glass-bg-strong: rgba(255, 255, 255, 0.08); + --glass-border: rgba(255, 255, 255, 0.09); + --glass-border-hover: rgba(255, 255, 255, 0.18); + --glass-blur: blur(20px); + --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.08); + --glass-shadow-hover: 0 20px 60px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.14); + + --accent-blue: #4f8ef7; + --accent-blue-light: #7bb3ff; + --accent-cyan: #22d3ee; + --accent-purple: #a78bfa; + --accent-glow-blue: rgba(79, 142, 247, 0.35); + --accent-glow-purple: rgba(167, 139, 250, 0.25); + + --gradient-brand: linear-gradient(135deg, #4f8ef7 0%, #a78bfa 100%); + --gradient-hero-bg: radial-gradient(ellipse 80% 60% at 50% -10%, rgba(79, 142, 247, 0.18) 0%, transparent 70%); + --gradient-text: linear-gradient(135deg, #7bb3ff 0%, #c4b5fd 50%, #22d3ee 100%); + + --text-primary: #eef2ff; + --text-secondary: rgba(238, 242, 255, 0.68); + --text-muted: rgba(238, 242, 255, 0.42); + --text-link: #7bb3ff; + + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 20px; + --radius-xl: 28px; + --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: 68px; + --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: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 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: ''; + position: fixed; + inset: 0; + background: + radial-gradient(ellipse 80% 50% at 50% -5%, rgba(79, 142, 247, 0.12) 0%, transparent 65%), + radial-gradient(ellipse 60% 40% at 100% 80%, rgba(167, 139, 250, 0.08) 0%, transparent 60%), + radial-gradient(ellipse 50% 40% at 0% 50%, rgba(34, 211, 238, 0.05) 0%, transparent 60%); + pointer-events: none; + z-index: 0; +} + +#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-weight: 700; + line-height: 1.2; + letter-spacing: -0.02em; + color: var(--text-primary); +} + +.gradient-text { + background: var(--gradient-text); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ============================================================ + 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-blue-light); + background: rgba(79, 142, 247, 0.12); + border: 1px solid rgba(79, 142, 247, 0.25); + padding: 0.3rem 0.85rem; + border-radius: var(--radius-pill); + margin-bottom: var(--spacing-sm); +} + +/* ============================================================ + Glass Card Component + ============================================================ */ +.glass-card { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + box-shadow: var(--glass-shadow); + transition: var(--transition-smooth); +} + +.glass-card:hover { + background: var(--glass-bg-hover); + border-color: var(--glass-border-hover); + box-shadow: var(--glass-shadow-hover); + transform: translateY(-3px); +} + +/* ============================================================ + 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(--gradient-brand); + color: #fff; + box-shadow: 0 4px 20px rgba(79, 142, 247, 0.35); +} + +.btn-primary:hover { + color: #fff; + transform: translateY(-2px); + box-shadow: 0 8px 30px rgba(79, 142, 247, 0.55); +} + +.btn-ghost { + background: var(--glass-bg); + color: var(--text-primary); + border: 1px solid var(--glass-border); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.btn-ghost:hover { + background: var(--glass-bg-hover); + border-color: var(--glass-border-hover); + color: var(--text-primary); + transform: translateY(-2px); +} + +.btn-sm { + padding: 0.5rem 1.1rem; + font-size: 0.82rem; +} + +/* ============================================================ + Animated Blob Shapes + ============================================================ */ +.blob { + position: absolute; + border-radius: 60% 40% 30% 70% / 60% 30% 70% 40%; + filter: blur(70px); + opacity: 0; + animation: blob-fade-in 1.2s ease-out forwards, blob-morph 14s ease-in-out infinite; + pointer-events: none; +} + +.blob--1 { + width: 520px; height: 520px; + background: radial-gradient(circle, rgba(79, 142, 247, 0.4) 0%, rgba(79, 142, 247, 0.1) 70%); + top: -120px; left: -120px; + animation-delay: 0s, 0s; +} + +.blob--2 { + width: 440px; height: 440px; + background: radial-gradient(circle, rgba(167, 139, 250, 0.35) 0%, rgba(167, 139, 250, 0.08) 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.25) 0%, rgba(34, 211, 238, 0.06) 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: rgba(7, 7, 19, 0.72); + border-bottom: 1px solid var(--glass-border); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + transition: var(--transition-smooth); +} + +.navbar.scrolled { + background: rgba(7, 7, 19, 0.88); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); +} + +.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: 34px; height: 34px; + display: flex; align-items: center; justify-content: center; + background: var(--gradient-brand); + border-radius: 9px; + font-size: 1rem; + font-weight: 800; + color: #fff; + letter-spacing: -0.03em; +} + +.brand-name { + font-size: 1.15rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.03em; +} + +.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: 0.88rem; + font-weight: 500; + color: var(--text-secondary); + text-decoration: none; + transition: var(--transition-fast); + white-space: nowrap; +} + +.nav-link:hover, +.nav-link.active { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.07); +} + +.nav-arrow { + transition: transform 0.22s ease; +} + +.nav-item.has-megamenu:hover .nav-arrow, +.nav-item.has-megamenu.open .nav-arrow { + transform: rotate(180deg); +} + +.nav-link--cta { + color: var(--accent-blue-light) !important; + border: 1px solid rgba(79, 142, 247, 0.3); +} + +.nav-link--cta:hover { + background: rgba(79, 142, 247, 0.12) !important; + border-color: rgba(79, 142, 247, 0.55); +} + +/* ============================================================ + Mega-menu + ============================================================ */ +.nav-item.has-megamenu { + position: static; +} + +.megamenu { + position: fixed; + left: 0; + right: 0; + top: var(--navbar-height); + background: rgba(10, 10, 26, 0.96); + border-bottom: 1px solid var(--glass-border); + backdrop-filter: blur(28px); + -webkit-backdrop-filter: blur(28px); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + opacity: 0; + visibility: hidden; + transform: translateY(-10px); + transition: opacity 0.22s ease, visibility 0.22s ease, transform 0.22s 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-inner { + max-width: var(--container-max); + margin: 0 auto; + padding: 2rem var(--container-padding); +} + +.megamenu-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 1.5rem; +} + +.megamenu-column { + padding: 1rem; + border-radius: var(--radius-md); + transition: var(--transition-fast); +} + +.megamenu-column:hover { + background: var(--glass-bg); +} + +.megamenu-product-title { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.95rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 0.4rem; + text-decoration: none; +} + +.megamenu-product-title:hover { + color: var(--accent-blue-light); +} + +.megamenu-product-desc { + font-size: 0.78rem; + color: var(--text-muted); + line-height: 1.5; + margin-bottom: 0.85rem; +} + +.megamenu-sublist { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.megamenu-sublink { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.82rem; + color: var(--text-secondary); + padding: 0.3rem 0.4rem; + border-radius: var(--radius-sm); + text-decoration: none; + transition: var(--transition-fast); +} + +.megamenu-sublink:hover { + color: var(--accent-blue-light); + background: rgba(79, 142, 247, 0.08); +} + +.sublink-dot { + width: 5px; height: 5px; + border-radius: 50%; + background: var(--accent-blue); + flex-shrink: 0; + opacity: 0.6; + transition: var(--transition-fast); +} + +.megamenu-sublink:hover .sublink-dot { + opacity: 1; + box-shadow: 0 0 6px var(--accent-blue); +} + +/* ============================================================ + 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); +} + +/* ============================================================ + Hero + ============================================================ */ +.hero { + position: relative; + min-height: 100svh; + display: flex; + align-items: center; + padding-top: var(--navbar-height); + overflow: hidden; + padding-bottom: var(--spacing-2xl); +} + +.hero-blobs { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; +} + +.hero-content { + position: relative; + z-index: 2; + max-width: 780px; + padding-top: var(--spacing-2xl); +} + +.hero-badge { + display: inline-block; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--accent-cyan); + background: rgba(34, 211, 238, 0.1); + border: 1px solid rgba(34, 211, 238, 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-size: clamp(2.8rem, 7vw, 5.5rem); + font-weight: 800; + line-height: 1.05; + letter-spacing: -0.04em; + margin-bottom: 1.25rem; + animation: fade-slide-up 0.8s ease-out 0.2s both; +} + +.hero-subtitle { + font-size: clamp(1rem, 2.5vw, 1.3rem); + color: var(--accent-blue-light); + 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; +} + +.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); +} + +/* ============================================================ + 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; +} + +.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; +} + +/* ============================================================ + 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; +} + +.product-card-image { + 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; +} + +.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(79, 142, 247, 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; +} + +/* ============================================================ + 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(79, 142, 247, 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; +} + +/* ============================================================ + 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 { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.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; +} + +.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(79, 142, 247, 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(79, 142, 247, 0.1); + border-color: rgba(79, 142, 247, 0.3); +} + +/* ============================================================ + About Page + ============================================================ */ +.about-intro { + 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; +} + +.standard-badge { + display: inline-block; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--accent-purple); + background: rgba(167, 139, 250, 0.1); + border: 1px solid rgba(167, 139, 250, 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(79, 142, 247, 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-cyan); + background: rgba(34, 211, 238, 0.08); + border: 1px solid rgba(34, 211, 238, 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; +} + +.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-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; +} + +.contact-card { + padding: 2.25rem; + 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; +} + +/* ============================================================ + Footer + ============================================================ */ +.footer { + position: relative; + background: var(--bg-secondary); + border-top: 1px solid var(--glass-border); + padding: var(--spacing-2xl) 0 var(--spacing-lg); + overflow: hidden; + margin-top: var(--spacing-2xl); +} + +.footer-blobs { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; +} + +.footer-blob { + position: absolute; + border-radius: 50%; + filter: blur(80px); + animation: blob-morph 16s ease-in-out infinite; +} + +.footer-blob--1 { + width: 400px; height: 400px; + background: radial-gradient(circle, rgba(79, 142, 247, 0.08) 0%, transparent 70%); + top: -100px; left: -100px; +} + +.footer-blob--2 { + width: 320px; height: 320px; + background: radial-gradient(circle, rgba(167, 139, 250, 0.07) 0%, transparent 70%); + bottom: -80px; right: -80px; + animation-delay: 6s; +} + +.footer-grid { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: 2fr 1fr 1fr 1.5fr; + gap: 3rem; + margin-bottom: var(--spacing-xl); +} + +.footer-logo { + display: flex; + align-items: center; + gap: 0.6rem; + text-decoration: none; + margin-bottom: 1rem; +} + +.footer-tagline { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.7; + margin-bottom: 1.25rem; + max-width: 280px; +} + +.footer-heading { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-muted); + margin-bottom: 1rem; +} + +.footer-links { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.footer-links a { + font-size: 0.87rem; + color: var(--text-secondary); + text-decoration: none; + transition: var(--transition-fast); +} + +.footer-links a:hover { + color: var(--text-primary); +} + +.footer-address { + font-size: 0.87rem; + color: var(--text-secondary); + line-height: 1.8; + margin-bottom: 0.75rem; +} + +.footer-email { + font-size: 0.87rem; + color: var(--accent-blue-light); + font-weight: 500; + word-break: break-all; +} + +.footer-bottom { + position: relative; + z-index: 1; + padding-top: var(--spacing-md); + border-top: 1px solid var(--glass-border); + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.5rem; +} + +.footer-copy, .footer-credit { + font-size: 0.78rem; + color: var(--text-muted); + line-height: 1.6; +} + +.footer-credit a { + color: var(--text-muted); + text-decoration: underline; + text-underline-offset: 2px; +} + +.footer-credit a:hover { + color: var(--text-secondary); +} + +/* ============================================================ + 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: rgba(7, 7, 19, 0.97); + border-bottom: 1px solid var(--glass-border); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + padding: 1rem var(--container-padding) 1.5rem; + max-height: 0; + overflow: hidden; + transition: max-height 0.35s ease, padding 0.35s 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; + } + + .megamenu { + position: static; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + transform: none; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + .megamenu-inner { + padding: 1rem; + } + + .megamenu-grid { + grid-template-columns: 1fr; + gap: 0.75rem; + } + + .nav-item.has-megamenu:hover .megamenu { + transform: none; + } + + .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(2.2rem, 8vw, 3rem); + } + + .about-strip-inner { + padding: 2rem; + } + + .product-overview-card { + padding: 1.75rem; + } + + .features-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; + } +} diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..fa0024d --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,156 @@ +'use strict'; + +const SELECTORS = { + navbar: '#navbar', + navbarToggle: '#navbarToggle', + navbarMenu: '#navbarMenu', + productsNavItem: '#productsNavItem', + faqItems: '.faq-item', + faqQuestion: '.faq-question', + faqAnswer: '.faq-answer', + fadeInElements: '.fade-in', +}; + +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 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)); + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && menu.classList.contains('open')) { + menu.classList.remove('open'); + toggle.classList.remove('open'); + toggle.setAttribute('aria-expanded', 'false'); + toggle.focus(); + } + }); + + document.addEventListener('click', (e) => { + if (!menu.contains(e.target) && !toggle.contains(e.target)) { + menu.classList.remove('open'); + toggle.classList.remove('open'); + toggle.setAttribute('aria-expanded', 'false'); + } + }); +} + +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; + + productsItem.addEventListener('mouseenter', () => { + productsItem.classList.add('open'); + trigger.setAttribute('aria-expanded', 'true'); + }); + + productsItem.addEventListener('mouseleave', () => { + productsItem.classList.remove('open'); + trigger.setAttribute('aria-expanded', 'false'); + }); + + trigger.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + const isOpen = productsItem.classList.toggle('open'); + trigger.setAttribute('aria-expanded', String(isOpen)); + } + if (e.key === 'Escape') { + productsItem.classList.remove('open'); + trigger.setAttribute('aria-expanded', 'false'); + } + }); +} + +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 init() { + initNavbarScroll(); + initMobileMenu(); + initMegaMenu(); + initFAQAccordion(); + initIntersectionObserver(); +} + +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..eae023d --- /dev/null +++ b/templates/base.html @@ -0,0 +1,28 @@ +{% load static %} + + + + + + + {% block title %}Tecvico Corp{% 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..2d8ff3b --- /dev/null +++ b/templates/pages/about.html @@ -0,0 +1,117 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}What is Tecvico{% endblock %} +{% block meta_description %}Learn about Tecvico Corp and ViSERA — Visualized & Standardized Environment for Radiomics Analysis, developed at UBC and BC Cancer Research Institute.{% endblock %} + +{% block content %} + +
+ +
+
+
About
+

What is Tecvico?

+

+ Visualized & Standardized Environment for Radiomics Analysis +

+
+
+
+ +
+
+
+
+

Desktop Software for Medical Imaging Research

+
    +
  • Desktop software to improve usability, reusability, and reproducibility in medical imaging & healthcare research.
  • +
  • Development platform to create reproducible research workflows by connecting different tools.
  • +
  • Useful for collaborative research projects and for ensuring consistency across different studies.
  • +
  • User-friendly for different expertise levels, including radiation oncologists, radiologists, physicists & data scientists.
  • +
+
+
+
+
+ +
+
+
+
Standards
+

Standardization

+
+
+
+
IBSI 1.0
+

Radiomic Feature Extraction

+

+ ViSERA is a python-based open-source package that enables standardized and + reproducible radiomic feature extraction in compliance with the Image Biomarker + Standardization Initiative (IBSI 1.0). +

+
+
+
IBSI 2.0
+

Image Filtering

+

+ Image filters have been standardized against IBSI 2.0 by implementing and + validating several filter options, ensuring reproducibility across research + institutions worldwide. +

+
+
+
Python
+

Open Source

+

+ ViSERA is a major, entirely-revamped upgrade to the original SERA (Matlab-based), + now built on Python for broader accessibility and community contribution. +

+
+
+
End-to-End
+

Standardized Workflows

+

+ ViSERA employs a number of popular image processing algorithms to create + end-to-end standardized workflows for consistent, reproducible research results. +

+
+
+
+
+ +
+
+
+
+
History
+

Our Origins

+

+ Tecvico (formerly known as Visera) 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. +

+

+ Our mission is to bridge the gap between cutting-edge radiomics research and + practical clinical application by providing standardized, reproducible, and + user-friendly software tools. +

+ +
+ +
+
+
+ +{% endblock %} diff --git a/templates/pages/contact.html b/templates/pages/contact.html new file mode 100644 index 0000000..ce16fef --- /dev/null +++ b/templates/pages/contact.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Contact{% endblock %} +{% block meta_description %}Contact Tecvico Corp — support for ViSERA software and general inquiries.{% endblock %} + +{% block content %} + +
+ +
+
+
Get in Touch
+

Contact Us

+

Support for ViSERA software is available via email and Discord.

+
+
+
+ +
+
+

Contact Details

+
+ +
+ +

Email Support

+

+ For software support and general inquiries, reach us at: +

+ + support@tecvico.com + +
+ +
+ +

Discord Community

+

+ Join our Discord server for community support, feature discussions, and announcements. +

+ + Join Discord Forum + +
+ +
+ +

Office Address

+
+

BC Cancer Research Center

+

675 West 10th Ave

+

Office 6-112

+

Vancouver, BC, V5Z 1L3

+

Canada

+
+
+ +
+
+
+ +{% endblock %} diff --git a/templates/pages/downloads.html b/templates/pages/downloads.html new file mode 100644 index 0000000..94e7384 --- /dev/null +++ b/templates/pages/downloads.html @@ -0,0 +1,130 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Downloads{% endblock %} +{% block meta_description %}Download ViSERA by Tecvico — available for Windows, macOS, and Linux.{% endblock %} + +{% block content %} + +
+ +
+
+
Software
+

Downloads

+

Download the latest version of ViSERA for your platform.

+
+
+
+ +
+
+

Available Platforms

+
+ +
+ +

Windows

+

Windows 10 and up (64-bit)

+ {% if windows_items %} + {% for item in windows_items %} +
+ v{{ item.version }} + {% if item.description %}

{{ item.description }}

{% endif %} + {% if item.download_url and item.download_url != '#' %} + + + Download + + {% endif %} +
+ {% endfor %} + {% else %} + Coming Soon + {% endif %} +
+ +
+ +

macOS

+

macOS 11 Big Sur and up

+ {% if macos_items %} + {% for item in macos_items %} +
+ v{{ item.version }} + {% if item.description %}

{{ item.description }}

{% endif %} + {% if item.download_url and item.download_url != '#' %} + + + Download + + {% endif %} +
+ {% endfor %} + {% else %} + Coming Soon + {% endif %} +
+ +
+ +

Linux

+

Ubuntu 20.04+ and compatible distributions

+ {% if linux_items %} + {% for item in linux_items %} +
+ v{{ item.version }} + {% if item.description %}

{{ item.description }}

{% endif %} + {% if item.download_url and item.download_url != '#' %} + + + Download + + {% endif %} +
+ {% endfor %} + {% else %} + Coming Soon + {% endif %} +
+ +
+
+
+ +
+
+
+

Installation Notes

+

+ If you have installed an older version of ViSERA, you can install the new version + over it without removing the previous installation. However, if you encounter any + problems, please remove the old version first before reinstalling. +

+ View Full FAQ +
+
+
+ +{% endblock %} diff --git a/templates/pages/faq.html b/templates/pages/faq.html new file mode 100644 index 0000000..b39867f --- /dev/null +++ b/templates/pages/faq.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}FAQ{% endblock %} +{% block meta_description %}Frequently asked questions about ViSERA by Tecvico — licensing, citation, system requirements, and more.{% endblock %} + +{% block content %} + +
+ +
+
+
Help
+

FAQ

+

Frequently Asked Questions

+
+
+
+ +
+
+

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..93beb59 --- /dev/null +++ b/templates/pages/home.html @@ -0,0 +1,188 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Home{% endblock %} +{% block meta_description %}Tecvico Corp — A Powerful Workflow Generator for Standardized Radiomics Analysis and Medical Image Visualization.{% endblock %} + +{% block content %} + +
+ +
+
Developing since 2021
+

+ Tecvico, +
+ A Powerful Workflow Generator +

+

+ for Standardized Radiomics Analysis and Medical Image Visualization +

+

+ ViSERA 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. +

+ +
+
+ +
+
+
+
Capabilities
+

Important Features

+

+ Comprehensive tools for medical imaging research, standardized and reproducible. +

+
+
+ {% for feature in features %} +
+ +

{{ feature.title }}

+

{{ feature.desc }}

+
+ {% empty %} +
+ +

Image Filtering

+

Standardized image filtering techniques compliant with IBSI 2.0 guidelines.

+
+
+ +

Professional Viewer

+

Comfortable, professional medical image viewer with multi-modality support.

+
+
+ +

Radiomics Features

+

Handcrafted radiomics feature generation standardized by IBSI 1.0.

+
+
+ +

Format Support

+

NIFTI, DICOM, NRRD, and more — comprehensive multi-format support.

+
+
+ +

Image Registration

+

Advanced image registration, fusion, and standardized SUV conversion.

+
+
+ +

RT Struct Support

+

Full RT struct support for radiation oncology workflows and research.

+
+ {% endfor %} +
+
+
+ +{% if nav_main_products %} +
+
+
+
Our Software
+

Products

+

Explore our suite of medical imaging and radiomics tools.

+
+ +
+
+{% endif %} + +
+
+
+
Value Proposition
+

What Problems Does Tecvico Solve?

+
+
+
+ +

Accessibility

+

+ 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. +

+
+
+ +

Integrated Tools

+

+ Tecvico integrates a vast collection of tools and resources from various domains + of healthcare and medical imaging research in a common, unified environment. +

+
+
+ +

Flexibility

+

+ Tecvico offers flexibility in terms of tool optimization and workflow customization + to match your specific research requirements. +

+
+
+ +

Reproducibility

+

+ Improve usability, reusability, and reproducibility (URR) through a workflow + management system that allows researchers to easily create, share, and reuse + analysis pipelines. +

+
+
+
+
+ +
+
+
+
+
Our Story
+

More to Know

+

+ 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. +

+ Learn More +
+ +
+
+
+ +{% endblock %} diff --git a/templates/partials/_footer.html b/templates/partials/_footer.html new file mode 100644 index 0000000..efd81ff --- /dev/null +++ b/templates/partials/_footer.html @@ -0,0 +1,73 @@ +{% load static %} + diff --git a/templates/partials/_navbar.html b/templates/partials/_navbar.html new file mode 100644 index 0000000..5eccf0d --- /dev/null +++ b/templates/partials/_navbar.html @@ -0,0 +1,97 @@ +{% load static %} + diff --git a/templates/products/main_detail.html b/templates/products/main_detail.html new file mode 100644 index 0000000..34503a1 --- /dev/null +++ b/templates/products/main_detail.html @@ -0,0 +1,88 @@ +{% 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 }}

+
+
+
+ +
+
+
+

About {{ main_product.name }}

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

{{ main_product.description }}

+
+
+
+
+ +{% 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..18b02cc --- /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 medical imaging and radiomics software products.{% 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..62f1eef --- /dev/null +++ b/templates/products/sub_detail.html @@ -0,0 +1,111 @@ +{% 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.description }}

+
+ + {% if articles %} +
+

Articles

+
+ {% for article in articles %} +
+

{{ article.title }}

+

{{ article.description }}

+ {% if article.sections.all %} +
+
+ {% for section in article.sections.all %} +
+
{{ section.title }}
+
{{ section.value }}
+
+ {% endfor %} +
+
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} +
+ + + +
+
+
+ +{% endblock %}