From 41cce0039055d822dc3dc5cf09966f49d4156a5f Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Tue, 9 Jun 2026 23:09:44 +0000 Subject: [PATCH] A --- core/__pycache__/admin.cpython-311.pyc | Bin 212 -> 1309 bytes core/__pycache__/forms.cpython-311.pyc | Bin 0 -> 4555 bytes core/__pycache__/models.cpython-311.pyc | Bin 209 -> 5814 bytes core/__pycache__/urls.cpython-311.pyc | Bin 347 -> 1007 bytes core/__pycache__/views.cpython-311.pyc | Bin 1364 -> 10555 bytes core/admin.py | 17 +- core/forms.py | 69 +++++ core/migrations/0001_initial.py | 59 +++++ .../__pycache__/0001_initial.cpython-311.pyc | Bin 0 -> 4451 bytes core/models.py | 77 +++++- core/templates/base.html | 59 ++++- core/templates/core/index.html | 246 ++++++++---------- core/templates/core/job_detail.html | 58 +++++ core/templates/core/job_form.html | 51 ++++ core/templates/core/job_list.html | 65 +++++ core/templates/core/job_success.html | 21 ++ core/templates/core/ops_dashboard.html | 104 ++++++++ core/templates/core/partials/form_errors.html | 5 + core/templates/core/partials/job_card.html | 11 + core/templates/core/source_form.html | 44 ++++ core/templates/core/source_success.html | 24 ++ core/urls.py | 9 +- core/views.py | 184 +++++++++++-- static/css/custom.css | 228 +++++++++++++++- staticfiles/css/custom.css | 235 +++++++++++++++-- 25 files changed, 1381 insertions(+), 185 deletions(-) create mode 100644 core/__pycache__/forms.cpython-311.pyc create mode 100644 core/forms.py create mode 100644 core/migrations/0001_initial.py create mode 100644 core/migrations/__pycache__/0001_initial.cpython-311.pyc create mode 100644 core/templates/core/job_detail.html create mode 100644 core/templates/core/job_form.html create mode 100644 core/templates/core/job_list.html create mode 100644 core/templates/core/job_success.html create mode 100644 core/templates/core/ops_dashboard.html create mode 100644 core/templates/core/partials/form_errors.html create mode 100644 core/templates/core/partials/job_card.html create mode 100644 core/templates/core/source_form.html create mode 100644 core/templates/core/source_success.html diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 5e8987a0cd478c226e501b5189550e758662c560..1741cb2997b929f5b9e3cb7877f5f192ae86c664 100644 GIT binary patch literal 1309 zcma)5%T5$Q6s_vlJeUFGX$C}GKGX6JOhZ4$>TYp%v)}{R zk@x`^L;MU~L&9QGD-u`ihQNxI_jWU*2qsonpT1T1)~Va)R(+{f%LLl{&kr}hDTMsQ zLBAPS#`zC0_6Q@4`XrzhrGzO=^_4)i)IhT|N)*EBrrux3wS7v+0laxr$aBK99l~^o z>a&u46=LAc+dg~_`U7GW{$f;bVKjiT@ZXF@R$L=y=@j=csni`7c;PALXezS&B5J&j z;=~I#WC`rGC>1Vu2l;@}|C{p~4E6{o7G=az7`0TUSQ=9;ooTRxE{6vMPjL^^kXq;j zTpH_6;Q4K7#EFxnu~bvx%Mss+6WeWacZ)OINo3g-+(|OqR0LK>3;~#`D4|^ekb1No zab94kDKNXjg3eL+bcfo{I0m7R-xVzSEXN*=XJK6E0*m=8kZi_PWP? z7Jnt@z)mig>dh$N^|XA zcuvOObzXJH7CSGy6OTG8_=xF#{O$VAf%{+jS_acWV?&PoE?6#dLF$Q@_*@p;C}=rh zTNeGubrLTMWz~(sL^y6@C+!y0ARv zrIQAn7VgIaZ>_+}$)eyJa8lq{lvmJ~37c0pOXGTKm&M2Vd3z#ESdG^s{tnPnDWzR9 ze|Wv05cBZ1-ZO|Y+L_#$+btc@+A*#56qPRZ$W7RNdpx;thK>t_GnFVagZN6HrF=wT dcBZFjbg@Tn!Y)6aT0BF?1p>0n-i*)m`vsKmL-hav delta 147 zcmbQsb%ilyIWI340}#~ttjM$n(vLwL7+``jJ_`XE(-~42QW$d>av7r-85vTTf*CZK zUxE~9GTvfMOv%m6^V4Ly#g~$mn3tZfmzPO4oI Z2T+U=h>K+>2e3$SUtmxGq9Qh+2mn_>9_;`C diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eca099378cd41e327debafad16cf6f0ed96417db GIT binary patch literal 4555 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 Ui)AMZh)Ho@V2}Z#A~v810Or>n$N&HU diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index f705988a223abc7b5af333902925bc196186bbf5..87c76403c827f515180a56585358041409285329 100644 GIT binary patch literal 1007 zcmbVJziZn-6h7HcvSis#^P4tJ4Vhvv(P9q{alp`}retX+Ek@BDiGRp4cT(M$4jD6g ztA`F=+NQKa{}c%{1Wkob*#g-z^-i`aDV};diTA$mz3)A}_cfo-B7Q!9+V6Bz2>lk$ z;^gMWeL+I#Bf*EHBq5x_>0Zi7n`sA3aAZ?<6jO0BX2wxX)zM7N$(mUSr4W%Y zkTjNmC|}YDedq7&Bus{ASY66!B#X048J(y&x0J~f1s9ewD+KV9rOYak@!C>mon-LF zzs#9x>Ui@FDsA10-zZ5j3@q>bmJbM()ELfPhs4>Ad)l%ovAnsBiD$KYu`2Lhn|ZM= zEauxbVJyzO1J=S8J3n_kKnej)MyDr5w>@ffr)CfJ}Fr%p<_ zSjQc!f94+ew8yB}b1c7WlOanQrb3kpm84#hlys5woU|6}Jd;n;Bec)Hp?e;S9?FIo zso|gcea|<@dt&>ZOO2sRyKG?D#IRk;X(s0Te?hMo_!~m18d^yVtD%>LJu4s3%yU?M+XD+N%JX zAv7as&LX$QyHg%n4WSxAb%t$^_ovSSD2GsvpghBN#+~Vh04gC=BB;z2pH2?1Uk6YN gp%y`H4i=|7KkETBLTE(LxLeg$QkS}T=sKUqACm(S&Hw-a delta 230 zcmaFQew)c@IWI340}#~ttjNp)(vLwL7+{4mKHC5p(-~42QW$d>av7r-85vTTQkZj? za+#x;C&tN`r87pcq_77wXmY#+Y0+f7#Zr)1lJSxe%E`#jO})iX1TqgKmzS8E>Zi#% z*_%mjau1Vl3?EP&q_o%zNPJ*sWMsU-Aael~Jzx;OfQmk_F|cwrxO510gv?;Oz#@N< WMg9tl{0C+xex?R)5G>*W8V3N6Lo@RL diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 2f0989c7137f7930a63ea76fcd63516c4974e834..1999a5fb0e526eb11c2d0023425c533b53dd7398 100644 GIT binary patch literal 10555 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 Xr9HlS=yH2}4P;gNa)91vejfh;`i@)& 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 0000000000000000000000000000000000000000..e13f837b1e7869e0fca0f07a01ab77953904b608 GIT binary patch literal 4451 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 literal 0 HcmV?d00001 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 %} +
+ +
+
+ Built for a progressive Dijon scraping platform pilot. + Next: connectors, dedupe, metrics, and scheduler. +
+
+ + 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" }} -

-
-
- Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC) -
-{% 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 }}.

+
+ +
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; } }