diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 5e8987a..1741cb2 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000..eca0993 Binary files /dev/null and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index a251b5f..4e53e6a 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index f705988..87c7640 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 2f0989c..1999a5f 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index 8c38f3f..ba6d035 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,18 @@ from django.contrib import admin -# Register your models here. +from .models import JobPosting, JobSource + + +@admin.register(JobSource) +class JobSourceAdmin(admin.ModelAdmin): + list_display = ("name", "family", "status", "url", "last_checked_at", "created_at") + list_filter = ("family", "status") + search_fields = ("name", "url", "owner") + + +@admin.register(JobPosting) +class JobPostingAdmin(admin.ModelAdmin): + list_display = ("title", "company", "location", "contract_type", "source", "published_at", "is_active") + list_filter = ("contract_type", "is_active", "source__family", "published_at") + search_fields = ("title", "company", "location", "description") + autocomplete_fields = ("source",) diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..b4267ed --- /dev/null +++ b/core/forms.py @@ -0,0 +1,69 @@ +from django import forms + +from .models import JobPosting, JobSource + + +class StyledModelForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + widget = field.widget + if isinstance(widget, forms.CheckboxInput): + widget.attrs.setdefault("class", "form-check-input") + elif isinstance(widget, forms.Select): + widget.attrs.setdefault("class", "form-select") + else: + widget.attrs.setdefault("class", "form-control") + + +class JobSourceForm(StyledModelForm): + class Meta: + model = JobSource + fields = ["name", "family", "url", "status", "owner", "notes"] + widgets = { + "notes": forms.Textarea(attrs={"rows": 4}), + } + help_texts = { + "url": "Paste the public job board, agency, or careers page URL.", + "owner": "Optional teammate responsible for this connector.", + } + + def clean_name(self): + return self.cleaned_data["name"].strip() + + +class JobPostingForm(StyledModelForm): + class Meta: + model = JobPosting + fields = [ + "source", + "title", + "company", + "location", + "contract_type", + "remote", + "salary", + "apply_url", + "published_at", + "description", + "is_active", + ] + widgets = { + "published_at": forms.DateInput(attrs={"type": "date"}), + "description": forms.Textarea(attrs={"rows": 6}), + } + help_texts = { + "source": "Choose the connector/source that found this offer.", + "description": "Paste a concise cleaned description; the pipeline view will evolve from here.", + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["source"].queryset = JobSource.objects.order_by("family", "name") + self.fields["source"].empty_label = "Select a source" + + def clean_title(self): + return self.cleaned_data["title"].strip() + + def clean_company(self): + return self.cleaned_data["company"].strip() diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..d155908 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,59 @@ +# Generated by Django 5.2.7 on 2026-06-09 22:57 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='JobSource', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=160)), + ('family', models.CharField(choices=[('portal', 'National portal'), ('agency', 'Interim agency'), ('company', 'Company careers')], max_length=24)), + ('url', models.URLField(unique=True)), + ('status', models.CharField(choices=[('planned', 'Planned'), ('active', 'Active'), ('paused', 'Paused'), ('error', 'Needs attention')], default='planned', max_length=24)), + ('owner', models.CharField(blank=True, max_length=120)), + ('notes', models.TextField(blank=True)), + ('last_checked_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['family', 'name'], + 'indexes': [models.Index(fields=['family', 'status'], name='core_jobsou_family_d61233_idx'), models.Index(fields=['name'], name='core_jobsou_name_a1bfb5_idx')], + }, + ), + migrations.CreateModel( + name='JobPosting', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=220)), + ('company', models.CharField(max_length=180)), + ('location', models.CharField(default='Dijon, Bourgogne-Franche-Comté', max_length=160)), + ('contract_type', models.CharField(choices=[('cdi', 'CDI'), ('cdd', 'CDD'), ('interim', 'Interim'), ('apprenticeship', 'Apprenticeship'), ('freelance', 'Freelance'), ('other', 'Other')], default='cdi', max_length=24)), + ('remote', models.BooleanField(default=False)), + ('salary', models.CharField(blank=True, max_length=120)), + ('apply_url', models.URLField(blank=True)), + ('published_at', models.DateField(default=django.utils.timezone.localdate)), + ('description', models.TextField()), + ('is_active', models.BooleanField(default=True)), + ('duplicate_score', models.DecimalField(decimal_places=2, default=0, max_digits=5)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('source', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='jobs', to='core.jobsource')), + ], + options={ + 'ordering': ['-published_at', '-created_at'], + 'indexes': [models.Index(fields=['is_active', 'published_at'], name='core_jobpos_is_acti_ee6ffc_idx'), models.Index(fields=['company', 'title'], name='core_jobpos_company_49594a_idx'), models.Index(fields=['contract_type'], name='core_jobpos_contrac_b2fa07_idx')], + }, + ), + ] diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000..e13f837 Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 71a8362..a13a58b 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,78 @@ from django.db import models +from django.urls import reverse +from django.utils import timezone -# Create your models here. + +class JobSource(models.Model): + class Family(models.TextChoices): + PORTAL = "portal", "National portal" + AGENCY = "agency", "Interim agency" + COMPANY = "company", "Company careers" + + class Status(models.TextChoices): + PLANNED = "planned", "Planned" + ACTIVE = "active", "Active" + PAUSED = "paused", "Paused" + ERROR = "error", "Needs attention" + + name = models.CharField(max_length=160) + family = models.CharField(max_length=24, choices=Family.choices) + url = models.URLField(unique=True) + status = models.CharField(max_length=24, choices=Status.choices, default=Status.PLANNED) + owner = models.CharField(max_length=120, blank=True) + notes = models.TextField(blank=True) + last_checked_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["family", "name"] + indexes = [ + models.Index(fields=["family", "status"]), + models.Index(fields=["name"]), + ] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse("source_success", args=[self.pk]) + + +class JobPosting(models.Model): + class ContractType(models.TextChoices): + CDI = "cdi", "CDI" + CDD = "cdd", "CDD" + INTERIM = "interim", "Interim" + APPRENTICESHIP = "apprenticeship", "Apprenticeship" + FREELANCE = "freelance", "Freelance" + OTHER = "other", "Other" + + source = models.ForeignKey(JobSource, on_delete=models.PROTECT, related_name="jobs") + title = models.CharField(max_length=220) + company = models.CharField(max_length=180) + location = models.CharField(max_length=160, default="Dijon, Bourgogne-Franche-Comté") + contract_type = models.CharField(max_length=24, choices=ContractType.choices, default=ContractType.CDI) + remote = models.BooleanField(default=False) + salary = models.CharField(max_length=120, blank=True) + apply_url = models.URLField(blank=True) + published_at = models.DateField(default=timezone.localdate) + description = models.TextField() + is_active = models.BooleanField(default=True) + duplicate_score = models.DecimalField(max_digits=5, decimal_places=2, default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-published_at", "-created_at"] + indexes = [ + models.Index(fields=["is_active", "published_at"]), + models.Index(fields=["company", "title"]), + models.Index(fields=["contract_type"]), + ] + + def __str__(self): + return f"{self.title} — {self.company}" + + def get_absolute_url(self): + return reverse("job_detail", args=[self.pk]) diff --git a/core/templates/base.html b/core/templates/base.html index 1e7e5fb..98dbb73 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -3,23 +3,68 @@ - {% block title %}Knowledge Base{% endblock %} - {% if project_description %} - - - - {% endif %} + + {% block title %}{{ page_title|default:"Dijon Job Aggregator" }}{% endblock %} + {% if project_image_url %} {% endif %} {% load static %} + + + + {% block head %}{% endblock %} - {% block content %}{% endblock %} + + + + {% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+ {% endif %} + +
+ {% block content %}{% endblock %} +
+ + + + diff --git a/core/templates/core/index.html b/core/templates/core/index.html index faec813..f544fd8 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -1,145 +1,113 @@ {% extends "base.html" %} -{% block title %}{{ project_name }}{% endblock %} - -{% block head %} - - - - -{% endblock %} +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} {% block content %} -
-
-

Analyzing your requirements and generating your app…

-
- Loading… +
+
+
+
+
+
+ Pilot MVP · Dijon employment intelligence +

Aggregate, normalize, and review Dijon job offers from day one.

+

A polished first slice for the future distributed scraping platform: register sources, capture offers, search the active queue, and inspect normalized job details.

+ + +
+
+
+
+
+

System snapshot

+

Connector runway

+
+ Live MVP +
+
+
{{ source_count }}Sources
+
{{ active_jobs }}Active offers
+
{{ total_jobs }}Total captured
+
+
+
Source registry
+
Manual intake queue
+
Search + detail review
+
+
+
-

AppWizzy AI is collecting your requirements and applying the first changes.

-

This page will refresh automatically as the plan is implemented.

-

- Runtime: Django {{ django_version }} · Python {{ python_version }} - — UTC {{ current_time|date:"Y-m-d H:i:s" }} -

-
- -{% endblock %} \ No newline at end of file + + +
+
+
+
+
+ 01 +

Register sources

+

Add national portals, interim agencies, and company careers pages with family/status metadata.

+ Create a source +
+
+
+
+ 02 +

Capture offers

+

Store a cleaned offer linked to its source, including contract, salary, remote flag, and apply URL.

+ Add an offer +
+
+
+
+ 03 +

Review the queue

+

Search active offers and open a detailed review page designed for future dedupe and metrics.

+ Browse jobs +
+
+
+
+
+ +
+
+
+
+ Latest normalized offers +

Review queue

+
+ View all offers +
+ + {% if latest_jobs %} +
+ {% for job in latest_jobs %} +
+ {% include "core/partials/job_card.html" with job=job %} +
+ {% endfor %} +
+ {% else %} +
+
+

No offers captured yet

+

Start by registering a source, then add the first normalized offer to make the queue useful.

+ +
+ {% endif %} +
+
+{% endblock %} diff --git a/core/templates/core/job_detail.html b/core/templates/core/job_detail.html new file mode 100644 index 0000000..62f36f2 --- /dev/null +++ b/core/templates/core/job_detail.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+
+ ← Back to jobs +
+
+ {{ job.get_contract_type_display }} +

{{ job.title }}

+

{{ job.company }} · {{ job.location }}

+
+
+
+

Source

+

{{ job.source.name }}

+

{{ job.source.get_family_display }} · {{ job.source.get_status_display }}

+ {% if job.apply_url %}Open apply URL{% endif %} +
+
+
+
+
+
+
+
+
+
+

Description

+

{{ job.description|linebreaksbr }}

+
+
+
+ +
+
+ {% if related_jobs %} +

More from {{ job.company }}

+
+ {% for related in related_jobs %} +
{% include "core/partials/job_card.html" with job=related %}
+ {% endfor %} +
+ {% endif %} +
+
+{% endblock %} diff --git a/core/templates/core/job_form.html b/core/templates/core/job_form.html new file mode 100644 index 0000000..dfdace7 --- /dev/null +++ b/core/templates/core/job_form.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+
+
+
+ Offer intake +

Add a normalized job offer

+

This manual intake mirrors the future scraper output: one source, one cleaned offer, ready for search and review.

+
+
+
+
+
+
+
+
+
+ {% csrf_token %} + {% include "core/partials/form_errors.html" with form=form %} +
+ {% for field in form %} +
+ {% if field.field.widget.input_type == 'checkbox' %} +
+ {{ field }} + +
+ {% else %} + + {{ field }} + {% endif %} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} +
+
+ + Cancel +
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/job_list.html b/core/templates/core/job_list.html new file mode 100644 index 0000000..7e04596 --- /dev/null +++ b/core/templates/core/job_list.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+
+
+
+ Searchable review queue +

Active job offers

+

Filter normalized offers by keyword, source family, or contract type.

+
+ Add offer +
+ +
+
+
+
+

{{ result_count }} result{{ result_count|pluralize }} found

+ {% if jobs %} +
+ {% for job in jobs %} +
+ {% include "core/partials/job_card.html" with job=job %} +
+ {% endfor %} +
+ {% else %} +
+
+

No matching offers

+

Adjust filters or add the first offer from a registered source.

+ Add offer +
+ {% endif %} +
+
+{% endblock %} diff --git a/core/templates/core/job_success.html b/core/templates/core/job_success.html new file mode 100644 index 0000000..ae8a0a7 --- /dev/null +++ b/core/templates/core/job_success.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+
+
+ +

{{ job.title }} added

+

The offer from {{ job.company }} is now searchable and linked to {{ job.source.name }}.

+ +
+
+
+{% endblock %} diff --git a/core/templates/core/ops_dashboard.html b/core/templates/core/ops_dashboard.html new file mode 100644 index 0000000..45bf119 --- /dev/null +++ b/core/templates/core/ops_dashboard.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+
+
+
+ Operator cockpit +

Connector readiness and intake queue

+

Track source status, latest offers, and coverage by connector family before automated scraping is introduced.

+
+ +
+
+
+ +
+
+
+
{{ total_sources }}Total sources
+
{{ active_jobs }}Active offers
+
{{ needs_attention }}Need attention
+
{{ stale_sources }}Never checked
+
+ +
+
+
+
+
+

Source registry

+

Connector queue

+
+ {{ sources|length }} tracked +
+ {% if sources %} +
+ + + + {% for source in sources %} + + + + + + + + {% endfor %} + +
SourceFamilyStatusOffersLast checked
{{ source.name }}
Open source
{{ source.get_family_display }}{{ source.get_status_display }}{{ source.job_total }}{% if source.last_checked_at %}{{ source.last_checked_at|date:"M d, H:i" }}{% else %}Not checked{% endif %}
+
+ {% else %} +
No sources yet. Register the first portal, agency, or company careers page.
+ {% endif %} +
+
+ +
+
+

Coverage

+

By family

+ {% for row in family_breakdown %} +
{{ row.family|title }}{{ row.total }} sources · {{ row.jobs }} offers
+ {% empty %}

No family data yet.

{% endfor %} +
+
+

Health

+

By status

+ {% for row in status_breakdown %} +
{{ row.status|title }}{{ row.total }}
+ {% empty %}

No status data yet.

{% endfor %} +
+
+
+ +
+
+

Review queue

Latest normalized offers

+ View all +
+
+ {% for job in recent_jobs %} +
+
+ {{ job.source.get_family_display }} +

{{ job.title }}

+

{{ job.company }}

+ {{ job.location }} · {{ job.created_at|date:"M d" }} +
+
+ {% empty %} +
No offers in the queue yet.
+ {% endfor %} +
+
+
+
+{% endblock %} diff --git a/core/templates/core/partials/form_errors.html b/core/templates/core/partials/form_errors.html new file mode 100644 index 0000000..a599161 --- /dev/null +++ b/core/templates/core/partials/form_errors.html @@ -0,0 +1,5 @@ +{% if form.non_field_errors %} + +{% endif %} diff --git a/core/templates/core/partials/job_card.html b/core/templates/core/partials/job_card.html new file mode 100644 index 0000000..ae72494 --- /dev/null +++ b/core/templates/core/partials/job_card.html @@ -0,0 +1,11 @@ +
+
+ {{ job.get_contract_type_display }} + {% if job.remote %}Remote{% endif %} +
+

{{ job.title }}

+

{{ job.company }}

+

{{ job.location }} · {{ job.published_at|date:"M d, Y" }}

+

{{ job.description|truncatechars:120 }}

+
{{ job.source.get_family_display }} · {{ job.source.name }}
+
diff --git a/core/templates/core/source_form.html b/core/templates/core/source_form.html new file mode 100644 index 0000000..fd925f0 --- /dev/null +++ b/core/templates/core/source_form.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+
+
+
+ Connector registry +

Register a new job source

+

Capture the minimum metadata needed to make a future connector discoverable, monitorable, and attachable to offers.

+
+
+
+
+
+
+
+
+
+ {% csrf_token %} + {% include "core/partials/form_errors.html" with form=form %} +
+ {% for field in form %} +
+ + {{ field }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} +
+
+ + Cancel +
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/source_success.html b/core/templates/core/source_success.html new file mode 100644 index 0000000..7763338 --- /dev/null +++ b/core/templates/core/source_success.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} +{% block meta_description %}{{ meta_description }}{% endblock %} + +{% block content %} +
+
+
+ +

{{ source.name }} is ready for intake

+

The source is registered as {{ source.get_family_display }} with status {{ source.get_status_display }}.

+
+
URL
{{ source.url }}
+
Offers attached
{{ source.job_count }}
+
+ +
+
+
+{% endblock %} diff --git a/core/urls.py b/core/urls.py index 6299e3d..da3374f 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,7 +1,14 @@ from django.urls import path -from .views import home +from .views import home, job_create, job_detail, job_list, job_success, ops_dashboard, source_create, source_success urlpatterns = [ path("", home, name="home"), + path("sources/new/", source_create, name="source_create"), + path("sources//ready/", source_success, name="source_success"), + path("ops/", ops_dashboard, name="ops_dashboard"), + path("jobs/", job_list, name="job_list"), + path("jobs/new/", job_create, name="job_create"), + path("jobs//", job_detail, name="job_detail"), + path("jobs//added/", job_success, name="job_success"), ] diff --git a/core/views.py b/core/views.py index c9aed12..10e55f9 100644 --- a/core/views.py +++ b/core/views.py @@ -1,25 +1,173 @@ -import os -import platform - -from django import get_version as django_version -from django.shortcuts import render +from django.contrib import messages +from django.db.models import Count, Q +from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone +from .forms import JobPostingForm, JobSourceForm +from .models import JobPosting, JobSource + + +PAGE_META = { + "project_name": "Dijon Job Aggregator", + "project_description": "Pilot dashboard for collecting, reviewing, and searching job offers around Dijon.", +} + + +def _meta(title, description=None): + return { + **PAGE_META, + "page_title": title, + "meta_description": description or PAGE_META["project_description"], + } + def home(request): - """Render the landing screen with loader and environment details.""" - host_name = request.get_host().lower() - agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" - now = timezone.now() - + """Landing dashboard with search, source stats, and latest offers.""" + latest_jobs = JobPosting.objects.select_related("source").filter(is_active=True)[:6] + sources_by_family = JobSource.objects.values("family").annotate(total=Count("id")).order_by("family") context = { - "project_name": "New Style", - "agent_brand": agent_brand, - "django_version": django_version(), - "python_version": platform.python_version(), - "current_time": now, - "host_name": host_name, - "project_description": os.getenv("PROJECT_DESCRIPTION", ""), - "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), + **_meta("Dijon Job Aggregator — Pilot Dashboard"), + "total_jobs": JobPosting.objects.count(), + "active_jobs": JobPosting.objects.filter(is_active=True).count(), + "source_count": JobSource.objects.count(), + "sources_by_family": sources_by_family, + "latest_jobs": latest_jobs, + "today": timezone.localdate(), } return render(request, "core/index.html", context) + + +def source_create(request): + if request.method == "POST": + form = JobSourceForm(request.POST) + if form.is_valid(): + source = form.save() + messages.success(request, "Source registered. You can now attach job offers to it.") + return redirect("source_success", pk=source.pk) + else: + form = JobSourceForm(initial={"status": JobSource.Status.PLANNED}) + return render( + request, + "core/source_form.html", + {**_meta("Register a Source", "Add a job portal, agency, or careers page to the Dijon aggregation pipeline."), "form": form}, + ) + + +def source_success(request, pk): + source = get_object_or_404(JobSource.objects.annotate(job_count=Count("jobs")), pk=pk) + return render( + request, + "core/source_success.html", + {**_meta("Source Registered", "Connector source confirmation for the Dijon Job Aggregator."), "source": source}, + ) + + +def job_create(request): + if not JobSource.objects.exists(): + messages.info(request, "Create your first source before adding a job offer.") + return redirect("source_create") + if request.method == "POST": + form = JobPostingForm(request.POST) + if form.is_valid(): + job = form.save() + messages.success(request, "Job offer added to the review queue.") + return redirect("job_success", pk=job.pk) + else: + form = JobPostingForm() + return render( + request, + "core/job_form.html", + {**_meta("Add a Job Offer", "Create a normalized job offer linked to a registered source."), "form": form}, + ) + + +def job_success(request, pk): + job = get_object_or_404(JobPosting.objects.select_related("source"), pk=pk) + return render( + request, + "core/job_success.html", + {**_meta("Offer Added", "Confirmation page for a newly captured Dijon job offer."), "job": job}, + ) + + +def job_list(request): + query = request.GET.get("q", "").strip() + contract = request.GET.get("contract", "").strip() + family = request.GET.get("family", "").strip() + + jobs = JobPosting.objects.select_related("source").filter(is_active=True) + if query: + jobs = jobs.filter( + Q(title__icontains=query) + | Q(company__icontains=query) + | Q(location__icontains=query) + | Q(description__icontains=query) + ) + if contract: + jobs = jobs.filter(contract_type=contract) + if family: + jobs = jobs.filter(source__family=family) + + context = { + **_meta("Search Job Offers", "Search normalized job offers collected for Dijon and nearby opportunities."), + "jobs": jobs, + "query": query, + "contract": contract, + "family": family, + "contract_choices": JobPosting.ContractType.choices, + "family_choices": JobSource.Family.choices, + "result_count": jobs.count(), + } + return render(request, "core/job_list.html", context) + + +def job_detail(request, pk): + job = get_object_or_404(JobPosting.objects.select_related("source"), pk=pk) + related_jobs = ( + JobPosting.objects.select_related("source") + .filter(is_active=True, company__iexact=job.company) + .exclude(pk=job.pk)[:3] + ) + return render( + request, + "core/job_detail.html", + {**_meta(job.title, f"{job.title} at {job.company} — normalized offer from {job.source.name}."), "job": job, "related_jobs": related_jobs}, + ) + +def ops_dashboard(request): + sources = JobSource.objects.annotate(job_total=Count("jobs")).order_by("family", "name") + recent_jobs = ( + JobPosting.objects.select_related("source") + .order_by("-created_at")[:8] + ) + family_breakdown = ( + JobSource.objects.values("family") + .annotate(total=Count("id"), jobs=Count("jobs")) + .order_by("family") + ) + status_breakdown = ( + JobSource.objects.values("status") + .annotate(total=Count("id")) + .order_by("status") + ) + needs_attention = sources.filter(status=JobSource.Status.ERROR).count() + stale_sources = sources.filter(last_checked_at__isnull=True).count() + return render( + request, + "core/ops_dashboard.html", + { + **_meta( + "Ops dashboard · Dijon Job Aggregator", + "Monitor source readiness, intake recency, and connector-family coverage for the Dijon job aggregator pilot.", + ), + "sources": sources, + "recent_jobs": recent_jobs, + "family_breakdown": family_breakdown, + "status_breakdown": status_breakdown, + "needs_attention": needs_attention, + "stale_sources": stale_sources, + "active_jobs": JobPosting.objects.filter(is_active=True).count(), + "total_sources": sources.count(), + }, + ) + diff --git a/static/css/custom.css b/static/css/custom.css index 925f6ed..ebbb007 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -1,4 +1,226 @@ -/* Custom styles for the application */ -body { - font-family: system-ui, -apple-system, sans-serif; +/* Dijon Job Aggregator custom design system */ +:root { + --dja-ink: #17211d; + --dja-muted: #65736d; + --dja-paper: #fbf7ef; + --dja-surface: #fffdf8; + --dja-line: rgba(23, 33, 29, 0.12); + --dja-primary: #0f6b55; + --dja-primary-dark: #084737; + --dja-secondary: #f2b84b; + --dja-accent: #ff6b35; + --dja-sky: #67c9d0; + --dja-shadow: 0 24px 70px rgba(23, 33, 29, 0.14); + --dja-radius: 28px; } + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--dja-ink); + background: + radial-gradient(circle at 12% 8%, rgba(242, 184, 75, 0.22), transparent 26rem), + radial-gradient(circle at 88% 18%, rgba(103, 201, 208, 0.22), transparent 28rem), + linear-gradient(180deg, #fffaf1 0%, #f7efe2 52%, #f8f4ec 100%); + min-height: 100vh; +} + +h1, h2, h3, .navbar-brand { + font-family: "Space Grotesk", "Inter", sans-serif; + letter-spacing: -0.035em; +} + +a { color: var(--dja-primary-dark); } +a:hover { color: var(--dja-accent); } + +.skip-link { + position: absolute; + left: 1rem; + top: -4rem; + z-index: 2000; + background: var(--dja-ink); + color: #fff; + padding: .75rem 1rem; + border-radius: 999px; + transition: top .2s ease; +} +.skip-link:focus { top: 1rem; } + +.app-nav { + background: rgba(255, 250, 241, 0.76); + border-bottom: 1px solid rgba(23, 33, 29, 0.08); + backdrop-filter: blur(18px); +} +.brand-lockup { display: inline-flex; align-items: center; gap: .7rem; font-weight: 700; color: var(--dja-ink); } +.brand-mark { + display: inline-grid; + place-items: center; + width: 42px; + height: 42px; + border-radius: 14px; + background: linear-gradient(135deg, var(--dja-primary), var(--dja-sky)); + color: #fff; + font-size: .86rem; + box-shadow: 0 14px 30px rgba(15, 107, 85, .25); +} +.nav-link { color: rgba(23, 33, 29, .72); font-weight: 650; } +.nav-link:hover { color: var(--dja-primary-dark); } +.message-stack { position: fixed; top: 86px; left: 0; right: 0; z-index: 1020; pointer-events: none; } +.message-stack .alert { pointer-events: auto; max-width: 780px; margin-left: auto; } + +.hero-section { padding: 4.5rem 0 3rem; } +.form-hero, .list-hero, .detail-hero { padding: 4.5rem 0 2.5rem; } +.success-wrap { padding: 5rem 0; min-height: 66vh; display: grid; align-items: center; } +.section-pad { padding: 4.5rem 0; } + +.eyebrow { + display: inline-flex; + align-items: center; + gap: .45rem; + color: var(--dja-primary-dark); + font-weight: 800; + text-transform: uppercase; + letter-spacing: .11em; + font-size: .76rem; +} +.eyebrow::before { + content: ""; + width: .65rem; + height: .65rem; + border-radius: 999px; + background: var(--dja-accent); + box-shadow: 0 0 0 7px rgba(255, 107, 53, .13); +} +.display-title, .form-hero h1, .list-hero h1, .detail-hero h1 { + font-size: clamp(2.6rem, 7vw, 5.9rem); + line-height: .92; + font-weight: 700; + margin: 0; +} +.form-hero h1, .list-hero h1, .detail-hero h1 { max-width: 980px; } +.hero-copy { max-width: 720px; color: var(--dja-muted); font-size: 1.18rem; } +.lead { color: var(--dja-muted); } + +.shape { position: absolute; border-radius: 36px; opacity: .86; filter: blur(.1px); } +.shape-one { width: 180px; height: 180px; right: 11%; top: 6rem; background: linear-gradient(135deg, var(--dja-secondary), var(--dja-accent)); transform: rotate(18deg); } +.shape-two { width: 120px; height: 120px; right: 3%; bottom: 1rem; background: linear-gradient(135deg, var(--dja-sky), var(--dja-primary)); border-radius: 999px; } + +.glass-card, .feature-card, .job-card, .content-panel { + background: rgba(255, 253, 248, 0.82); + border: 1px solid rgba(255, 255, 255, 0.74); + box-shadow: var(--dja-shadow); + backdrop-filter: blur(18px); +} +.command-card { border-radius: 34px; padding: 1.5rem; position: relative; overflow: hidden; } +.command-card::after { + content: ""; + position: absolute; + width: 180px; + height: 180px; + right: -75px; + bottom: -80px; + background: rgba(255, 107, 53, .16); + border-radius: 999px; +} +.card-kicker { text-transform: uppercase; letter-spacing: .12em; color: var(--dja-muted); font-size: .74rem; font-weight: 800; } +.status-pill, .remote-pill, .badge-soft, .source-chip { + display: inline-flex; + align-items: center; + width: fit-content; + border-radius: 999px; + font-weight: 800; + font-size: .76rem; +} +.status-pill { background: rgba(15, 107, 85, .1); color: var(--dja-primary-dark); padding: .45rem .75rem; } +.badge-soft { background: rgba(242, 184, 75, .26); color: #6b4507; padding: .38rem .7rem; } +.remote-pill { background: rgba(103, 201, 208, .2); color: #17656b; padding: .38rem .7rem; } +.source-chip { background: rgba(23, 33, 29, .06); color: var(--dja-muted); padding: .45rem .7rem; } + +.metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .75rem; } +.metric-tile { background: rgba(255,255,255,.7); border: 1px solid var(--dja-line); border-radius: 22px; padding: 1rem; } +.metric-tile span { display: block; font-family: "Space Grotesk"; font-size: 2.1rem; line-height: 1; color: var(--dja-primary-dark); } +.metric-tile small { color: var(--dja-muted); font-weight: 700; } +.pipeline-list { display: grid; gap: .75rem; color: var(--dja-muted); font-weight: 700; } +.dot { display: inline-block; width: .65rem; height: .65rem; border-radius: 999px; margin-right: .55rem; } +.dot-green { background: var(--dja-primary); } +.dot-orange { background: var(--dja-accent); } +.dot-blue { background: var(--dja-sky); } + +.hero-search { + display: flex; + gap: .75rem; + padding: .5rem; + border-radius: 999px; + background: rgba(255,255,255,.8); + border: 1px solid rgba(255,255,255,.9); + box-shadow: 0 18px 45px rgba(23,33,29,.11); + max-width: 760px; +} +.hero-search .form-control { border: 0; border-radius: 999px; background: transparent; padding-left: 1.2rem; } +.form-control:focus, .form-select:focus, .form-check-input:focus { border-color: var(--dja-primary); box-shadow: 0 0 0 .2rem rgba(15, 107, 85, .18); } +.btn-accent { background: var(--dja-accent); border-color: var(--dja-accent); color: #fff; font-weight: 800; } +.btn-accent:hover { background: #e65722; border-color: #e65722; color: #fff; transform: translateY(-1px); } +.btn-dark { background: var(--dja-ink); border-color: var(--dja-ink); } +.btn-glass { background: rgba(255,255,255,.58); border: 1px solid rgba(23,33,29,.15); color: var(--dja-ink); font-weight: 800; } +.btn { transition: transform .18s ease, box-shadow .18s ease; } +.btn:hover { box-shadow: 0 14px 25px rgba(23,33,29,.12); } + +.feature-card, .job-card, .content-panel { border-radius: var(--dja-radius); padding: 1.35rem; position: relative; } +.feature-card { min-height: 250px; } +.feature-card p, .job-excerpt { color: var(--dja-muted); } +.feature-icon { display: inline-grid; place-items: center; width: 52px; height: 52px; border-radius: 18px; background: var(--dja-ink); color: #fff; font-family: "Space Grotesk"; margin-bottom: 1.5rem; } +.section-heading h2 { font-size: clamp(2rem, 4vw, 3.3rem); margin: .2rem 0 0; } +.job-card { display: flex; flex-direction: column; min-height: 310px; } +.job-card h3 a { text-decoration: none; color: var(--dja-ink); } +.job-card h3 a:hover { color: var(--dja-primary-dark); } +.company { font-weight: 800; } + +.empty-state { text-align: center; border: 1px dashed rgba(23,33,29,.2); border-radius: 34px; padding: 3rem 1.5rem; background: rgba(255,255,255,.45); } +.empty-orb { width: 86px; height: 86px; border-radius: 30px; background: linear-gradient(135deg, var(--dja-secondary), var(--dja-sky)); margin: 0 auto 1rem; transform: rotate(-10deg); } + +.app-form, .filter-bar, .success-card { border-radius: 34px; padding: clamp(1.25rem, 4vw, 2.2rem); } +.app-form .form-control, .app-form .form-select, .filter-bar .form-control, .filter-bar .form-select { border-radius: 16px; min-height: 50px; border-color: var(--dja-line); } +.app-form textarea.form-control { min-height: 150px; } +.form-label { font-weight: 800; color: var(--dja-ink); } +.form-text { color: var(--dja-muted); } +.form-switch .form-check-input { width: 3.2rem; height: 1.65rem; } +.form-check-input:checked { background-color: var(--dja-primary); border-color: var(--dja-primary); } +.success-card { max-width: 820px; text-align: center; } +.success-mark { display: inline-grid; place-items: center; width: 78px; height: 78px; border-radius: 26px; background: var(--dja-primary); color: #fff; font-size: 2rem; font-weight: 900; margin-bottom: 1.2rem; } +.detail-list { display: grid; gap: .75rem; text-align: left; margin: 1.5rem 0 0; } +.detail-list div { padding: .8rem 0; border-top: 1px solid var(--dja-line); } +.detail-list dt { color: var(--dja-muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; } +.detail-list dd { margin: 0; font-weight: 800; overflow-wrap: anywhere; } +.detail-list.compact div:first-child { border-top: 0; } +.results-label { color: var(--dja-muted); font-weight: 800; } +.back-link { text-decoration: none; font-weight: 800; color: var(--dja-primary-dark); } +.detail-side { border-radius: 28px; padding: 1.35rem; } +.content-panel h2 { margin-bottom: 1rem; } +.content-panel p { color: var(--dja-muted); line-height: 1.75; } + +.app-footer { padding: 2rem 0; border-top: 1px solid var(--dja-line); color: var(--dja-muted); } + +@media (max-width: 768px) { + .hero-search { border-radius: 28px; flex-direction: column; } + .hero-search .btn, .hero-search .form-control { width: 100%; } + .metric-grid { grid-template-columns: 1fr; } + .shape { opacity: .35; } +} +/* Ops dashboard */ +.ops-hero { padding: 4.5rem 0 2.25rem; } +.ops-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1rem; } +.ops-tile { min-height: 132px; background: rgba(255,255,255,.66); border: 1px solid var(--dja-line); } +.ops-panel { padding: 1.35rem; } +.ops-table { --bs-table-bg: transparent; margin-bottom: 0; } +.ops-table th { color: var(--dja-muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; } +.status-chip { display: inline-flex; border-radius: 999px; padding: .35rem .65rem; font-weight: 800; font-size: .78rem; background: rgba(103,201,208,.18); color: var(--dja-primary-dark); } +.status-error { background: rgba(255,107,53,.16); color: #9b3417; } +.status-paused, .status-planned { background: rgba(242,184,75,.22); color: #7a5310; } +.status-active { background: rgba(15,107,85,.14); color: var(--dja-primary-dark); } +.breakdown-row { display: flex; justify-content: space-between; gap: 1rem; padding: .85rem 0; border-bottom: 1px solid var(--dja-line); } +.breakdown-row:last-child { border-bottom: 0; } +.queue-card { height: 100%; padding: 1rem; border: 1px solid var(--dja-line); border-radius: 20px; background: rgba(255,253,248,.72); } +@media (max-width: 991px) { .ops-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 575px) { .ops-grid { grid-template-columns: 1fr; } } diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css index 108056f..ebbb007 100644 --- a/staticfiles/css/custom.css +++ b/staticfiles/css/custom.css @@ -1,21 +1,226 @@ - +/* Dijon Job Aggregator custom design system */ :root { - --bg-color-start: #6a11cb; - --bg-color-end: #2575fc; - --text-color: #ffffff; - --card-bg-color: rgba(255, 255, 255, 0.01); - --card-border-color: rgba(255, 255, 255, 0.1); + --dja-ink: #17211d; + --dja-muted: #65736d; + --dja-paper: #fbf7ef; + --dja-surface: #fffdf8; + --dja-line: rgba(23, 33, 29, 0.12); + --dja-primary: #0f6b55; + --dja-primary-dark: #084737; + --dja-secondary: #f2b84b; + --dja-accent: #ff6b35; + --dja-sky: #67c9d0; + --dja-shadow: 0 24px 70px rgba(23, 33, 29, 0.14); + --dja-radius: 28px; } + +* { box-sizing: border-box; } + body { margin: 0; - font-family: 'Inter', sans-serif; - background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); - color: var(--text-color); - display: flex; - justify-content: center; - align-items: center; + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--dja-ink); + background: + radial-gradient(circle at 12% 8%, rgba(242, 184, 75, 0.22), transparent 26rem), + radial-gradient(circle at 88% 18%, rgba(103, 201, 208, 0.22), transparent 28rem), + linear-gradient(180deg, #fffaf1 0%, #f7efe2 52%, #f8f4ec 100%); min-height: 100vh; - text-align: center; - overflow: hidden; - position: relative; } + +h1, h2, h3, .navbar-brand { + font-family: "Space Grotesk", "Inter", sans-serif; + letter-spacing: -0.035em; +} + +a { color: var(--dja-primary-dark); } +a:hover { color: var(--dja-accent); } + +.skip-link { + position: absolute; + left: 1rem; + top: -4rem; + z-index: 2000; + background: var(--dja-ink); + color: #fff; + padding: .75rem 1rem; + border-radius: 999px; + transition: top .2s ease; +} +.skip-link:focus { top: 1rem; } + +.app-nav { + background: rgba(255, 250, 241, 0.76); + border-bottom: 1px solid rgba(23, 33, 29, 0.08); + backdrop-filter: blur(18px); +} +.brand-lockup { display: inline-flex; align-items: center; gap: .7rem; font-weight: 700; color: var(--dja-ink); } +.brand-mark { + display: inline-grid; + place-items: center; + width: 42px; + height: 42px; + border-radius: 14px; + background: linear-gradient(135deg, var(--dja-primary), var(--dja-sky)); + color: #fff; + font-size: .86rem; + box-shadow: 0 14px 30px rgba(15, 107, 85, .25); +} +.nav-link { color: rgba(23, 33, 29, .72); font-weight: 650; } +.nav-link:hover { color: var(--dja-primary-dark); } +.message-stack { position: fixed; top: 86px; left: 0; right: 0; z-index: 1020; pointer-events: none; } +.message-stack .alert { pointer-events: auto; max-width: 780px; margin-left: auto; } + +.hero-section { padding: 4.5rem 0 3rem; } +.form-hero, .list-hero, .detail-hero { padding: 4.5rem 0 2.5rem; } +.success-wrap { padding: 5rem 0; min-height: 66vh; display: grid; align-items: center; } +.section-pad { padding: 4.5rem 0; } + +.eyebrow { + display: inline-flex; + align-items: center; + gap: .45rem; + color: var(--dja-primary-dark); + font-weight: 800; + text-transform: uppercase; + letter-spacing: .11em; + font-size: .76rem; +} +.eyebrow::before { + content: ""; + width: .65rem; + height: .65rem; + border-radius: 999px; + background: var(--dja-accent); + box-shadow: 0 0 0 7px rgba(255, 107, 53, .13); +} +.display-title, .form-hero h1, .list-hero h1, .detail-hero h1 { + font-size: clamp(2.6rem, 7vw, 5.9rem); + line-height: .92; + font-weight: 700; + margin: 0; +} +.form-hero h1, .list-hero h1, .detail-hero h1 { max-width: 980px; } +.hero-copy { max-width: 720px; color: var(--dja-muted); font-size: 1.18rem; } +.lead { color: var(--dja-muted); } + +.shape { position: absolute; border-radius: 36px; opacity: .86; filter: blur(.1px); } +.shape-one { width: 180px; height: 180px; right: 11%; top: 6rem; background: linear-gradient(135deg, var(--dja-secondary), var(--dja-accent)); transform: rotate(18deg); } +.shape-two { width: 120px; height: 120px; right: 3%; bottom: 1rem; background: linear-gradient(135deg, var(--dja-sky), var(--dja-primary)); border-radius: 999px; } + +.glass-card, .feature-card, .job-card, .content-panel { + background: rgba(255, 253, 248, 0.82); + border: 1px solid rgba(255, 255, 255, 0.74); + box-shadow: var(--dja-shadow); + backdrop-filter: blur(18px); +} +.command-card { border-radius: 34px; padding: 1.5rem; position: relative; overflow: hidden; } +.command-card::after { + content: ""; + position: absolute; + width: 180px; + height: 180px; + right: -75px; + bottom: -80px; + background: rgba(255, 107, 53, .16); + border-radius: 999px; +} +.card-kicker { text-transform: uppercase; letter-spacing: .12em; color: var(--dja-muted); font-size: .74rem; font-weight: 800; } +.status-pill, .remote-pill, .badge-soft, .source-chip { + display: inline-flex; + align-items: center; + width: fit-content; + border-radius: 999px; + font-weight: 800; + font-size: .76rem; +} +.status-pill { background: rgba(15, 107, 85, .1); color: var(--dja-primary-dark); padding: .45rem .75rem; } +.badge-soft { background: rgba(242, 184, 75, .26); color: #6b4507; padding: .38rem .7rem; } +.remote-pill { background: rgba(103, 201, 208, .2); color: #17656b; padding: .38rem .7rem; } +.source-chip { background: rgba(23, 33, 29, .06); color: var(--dja-muted); padding: .45rem .7rem; } + +.metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .75rem; } +.metric-tile { background: rgba(255,255,255,.7); border: 1px solid var(--dja-line); border-radius: 22px; padding: 1rem; } +.metric-tile span { display: block; font-family: "Space Grotesk"; font-size: 2.1rem; line-height: 1; color: var(--dja-primary-dark); } +.metric-tile small { color: var(--dja-muted); font-weight: 700; } +.pipeline-list { display: grid; gap: .75rem; color: var(--dja-muted); font-weight: 700; } +.dot { display: inline-block; width: .65rem; height: .65rem; border-radius: 999px; margin-right: .55rem; } +.dot-green { background: var(--dja-primary); } +.dot-orange { background: var(--dja-accent); } +.dot-blue { background: var(--dja-sky); } + +.hero-search { + display: flex; + gap: .75rem; + padding: .5rem; + border-radius: 999px; + background: rgba(255,255,255,.8); + border: 1px solid rgba(255,255,255,.9); + box-shadow: 0 18px 45px rgba(23,33,29,.11); + max-width: 760px; +} +.hero-search .form-control { border: 0; border-radius: 999px; background: transparent; padding-left: 1.2rem; } +.form-control:focus, .form-select:focus, .form-check-input:focus { border-color: var(--dja-primary); box-shadow: 0 0 0 .2rem rgba(15, 107, 85, .18); } +.btn-accent { background: var(--dja-accent); border-color: var(--dja-accent); color: #fff; font-weight: 800; } +.btn-accent:hover { background: #e65722; border-color: #e65722; color: #fff; transform: translateY(-1px); } +.btn-dark { background: var(--dja-ink); border-color: var(--dja-ink); } +.btn-glass { background: rgba(255,255,255,.58); border: 1px solid rgba(23,33,29,.15); color: var(--dja-ink); font-weight: 800; } +.btn { transition: transform .18s ease, box-shadow .18s ease; } +.btn:hover { box-shadow: 0 14px 25px rgba(23,33,29,.12); } + +.feature-card, .job-card, .content-panel { border-radius: var(--dja-radius); padding: 1.35rem; position: relative; } +.feature-card { min-height: 250px; } +.feature-card p, .job-excerpt { color: var(--dja-muted); } +.feature-icon { display: inline-grid; place-items: center; width: 52px; height: 52px; border-radius: 18px; background: var(--dja-ink); color: #fff; font-family: "Space Grotesk"; margin-bottom: 1.5rem; } +.section-heading h2 { font-size: clamp(2rem, 4vw, 3.3rem); margin: .2rem 0 0; } +.job-card { display: flex; flex-direction: column; min-height: 310px; } +.job-card h3 a { text-decoration: none; color: var(--dja-ink); } +.job-card h3 a:hover { color: var(--dja-primary-dark); } +.company { font-weight: 800; } + +.empty-state { text-align: center; border: 1px dashed rgba(23,33,29,.2); border-radius: 34px; padding: 3rem 1.5rem; background: rgba(255,255,255,.45); } +.empty-orb { width: 86px; height: 86px; border-radius: 30px; background: linear-gradient(135deg, var(--dja-secondary), var(--dja-sky)); margin: 0 auto 1rem; transform: rotate(-10deg); } + +.app-form, .filter-bar, .success-card { border-radius: 34px; padding: clamp(1.25rem, 4vw, 2.2rem); } +.app-form .form-control, .app-form .form-select, .filter-bar .form-control, .filter-bar .form-select { border-radius: 16px; min-height: 50px; border-color: var(--dja-line); } +.app-form textarea.form-control { min-height: 150px; } +.form-label { font-weight: 800; color: var(--dja-ink); } +.form-text { color: var(--dja-muted); } +.form-switch .form-check-input { width: 3.2rem; height: 1.65rem; } +.form-check-input:checked { background-color: var(--dja-primary); border-color: var(--dja-primary); } +.success-card { max-width: 820px; text-align: center; } +.success-mark { display: inline-grid; place-items: center; width: 78px; height: 78px; border-radius: 26px; background: var(--dja-primary); color: #fff; font-size: 2rem; font-weight: 900; margin-bottom: 1.2rem; } +.detail-list { display: grid; gap: .75rem; text-align: left; margin: 1.5rem 0 0; } +.detail-list div { padding: .8rem 0; border-top: 1px solid var(--dja-line); } +.detail-list dt { color: var(--dja-muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; } +.detail-list dd { margin: 0; font-weight: 800; overflow-wrap: anywhere; } +.detail-list.compact div:first-child { border-top: 0; } +.results-label { color: var(--dja-muted); font-weight: 800; } +.back-link { text-decoration: none; font-weight: 800; color: var(--dja-primary-dark); } +.detail-side { border-radius: 28px; padding: 1.35rem; } +.content-panel h2 { margin-bottom: 1rem; } +.content-panel p { color: var(--dja-muted); line-height: 1.75; } + +.app-footer { padding: 2rem 0; border-top: 1px solid var(--dja-line); color: var(--dja-muted); } + +@media (max-width: 768px) { + .hero-search { border-radius: 28px; flex-direction: column; } + .hero-search .btn, .hero-search .form-control { width: 100%; } + .metric-grid { grid-template-columns: 1fr; } + .shape { opacity: .35; } +} +/* Ops dashboard */ +.ops-hero { padding: 4.5rem 0 2.25rem; } +.ops-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1rem; } +.ops-tile { min-height: 132px; background: rgba(255,255,255,.66); border: 1px solid var(--dja-line); } +.ops-panel { padding: 1.35rem; } +.ops-table { --bs-table-bg: transparent; margin-bottom: 0; } +.ops-table th { color: var(--dja-muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; } +.status-chip { display: inline-flex; border-radius: 999px; padding: .35rem .65rem; font-weight: 800; font-size: .78rem; background: rgba(103,201,208,.18); color: var(--dja-primary-dark); } +.status-error { background: rgba(255,107,53,.16); color: #9b3417; } +.status-paused, .status-planned { background: rgba(242,184,75,.22); color: #7a5310; } +.status-active { background: rgba(15,107,85,.14); color: var(--dja-primary-dark); } +.breakdown-row { display: flex; justify-content: space-between; gap: 1rem; padding: .85rem 0; border-bottom: 1px solid var(--dja-line); } +.breakdown-row:last-child { border-bottom: 0; } +.queue-card { height: 100%; padding: 1rem; border: 1px solid var(--dja-line); border-radius: 20px; background: rgba(255,253,248,.72); } +@media (max-width: 991px) { .ops-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 575px) { .ops-grid { grid-template-columns: 1fr; } }