diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc
index d79d6a7..73d6350 100644
Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ
diff --git a/config/settings.py b/config/settings.py
index 291d043..20888c9 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -15,38 +15,32 @@ import os
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
-load_dotenv(BASE_DIR.parent / ".env")
+load_dotenv(BASE_DIR.parent / '.env')
-SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me")
-DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
+SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'change-me')
+DEBUG = os.getenv('DJANGO_DEBUG', 'true').lower() == 'true'
ALLOWED_HOSTS = [
- "127.0.0.1",
- "localhost",
- os.getenv("HOST_FQDN", ""),
+ '127.0.0.1',
+ 'localhost',
+ os.getenv('HOST_FQDN', ''),
]
CSRF_TRUSTED_ORIGINS = [
origin for origin in [
- os.getenv("HOST_FQDN", ""),
- os.getenv("CSRF_TRUSTED_ORIGIN", "")
+ os.getenv('HOST_FQDN', ''),
+ os.getenv('CSRF_TRUSTED_ORIGIN', '')
] if origin
]
CSRF_TRUSTED_ORIGINS = [
- f"https://{host}" if not host.startswith(("http://", "https://")) else host
+ f"https://{host}" if not host.startswith(('http://', 'https://')) else host
for host in CSRF_TRUSTED_ORIGINS
]
-# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
-SESSION_COOKIE_SAMESITE = "None"
-CSRF_COOKIE_SAMESITE = "None"
-
-# Quick-start development settings - unsuitable for production
-# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
-
-# Application definition
+SESSION_COOKIE_SAMESITE = 'None'
+CSRF_COOKIE_SAMESITE = 'None'
INSTALLED_APPS = [
'django.contrib.admin',
@@ -65,8 +59,6 @@ MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
- # Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
- # 'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
X_FRAME_OPTIONS = 'ALLOWALL'
@@ -83,7 +75,6 @@ TEMPLATES = [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
- # IMPORTANT: do not remove – injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp
'core.context_processors.project_context',
],
},
@@ -92,10 +83,6 @@ TEMPLATES = [
WSGI_APPLICATION = 'config.wsgi.application'
-
-# Database
-# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
-
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
@@ -110,10 +97,6 @@ DATABASES = {
},
}
-
-# Password validation
-# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
-
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
@@ -129,54 +112,39 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
-
-# Internationalization
-# https://docs.djangoproject.com/en/5.2/topics/i18n/
-
LANGUAGE_CODE = 'en-us'
-
TIME_ZONE = 'UTC'
-
USE_I18N = True
-
USE_TZ = True
-
-# Static files (CSS, JavaScript, Images)
-# https://docs.djangoproject.com/en/5.2/howto/static-files/
-
STATIC_URL = 'static/'
-# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS.
STATIC_ROOT = BASE_DIR / 'staticfiles'
-
STATICFILES_DIRS = [
BASE_DIR / 'static',
BASE_DIR / 'assets',
BASE_DIR / 'node_modules',
]
-# Email
EMAIL_BACKEND = os.getenv(
- "EMAIL_BACKEND",
- "django.core.mail.backends.smtp.EmailBackend"
+ 'EMAIL_BACKEND',
+ 'django.core.mail.backends.smtp.EmailBackend'
)
-EMAIL_HOST = os.getenv("EMAIL_HOST", "127.0.0.1")
-EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
-EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "")
-EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "")
-EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true"
-EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true"
-DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "no-reply@example.com")
+EMAIL_HOST = os.getenv('EMAIL_HOST', '127.0.0.1')
+EMAIL_PORT = int(os.getenv('EMAIL_PORT', '587'))
+EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '')
+EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '')
+EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'true').lower() == 'true'
+EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', 'false').lower() == 'true'
+DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'no-reply@example.com')
CONTACT_EMAIL_TO = [
item.strip()
- for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",")
+ for item in os.getenv('CONTACT_EMAIL_TO', DEFAULT_FROM_EMAIL).split(',')
if item.strip()
]
-# When both TLS and SSL flags are enabled, prefer SSL explicitly
if EMAIL_USE_SSL:
EMAIL_USE_TLS = False
-# Default primary key field type
-# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+LOGIN_REDIRECT_URL = '/dashboard/events/'
+LOGOUT_REDIRECT_URL = '/'
diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc
index 5e8987a..b6d480f 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..f805a3e
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..73573bf 100644
Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ
diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc
new file mode 100644
index 0000000..1c71340
Binary files /dev/null and b/core/__pycache__/tests.cpython-311.pyc differ
diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc
index f705988..1c56cb8 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..148300c 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..29d545a 100644
--- a/core/admin.py
+++ b/core/admin.py
@@ -1,3 +1,16 @@
from django.contrib import admin
-# Register your models here.
+from .models import Event
+
+admin.site.site_header = 'Business Calendar Admin'
+admin.site.site_title = 'Business Calendar'
+admin.site.index_title = 'Manage your event schedule'
+
+
+@admin.register(Event)
+class EventAdmin(admin.ModelAdmin):
+ list_display = ('name', 'location', 'start', 'end', 'is_published')
+ list_filter = ('is_published', 'start')
+ search_fields = ('name', 'location', 'summary')
+ readonly_fields = ('created_at', 'updated_at')
+ ordering = ('start',)
diff --git a/core/forms.py b/core/forms.py
new file mode 100644
index 0000000..97aca69
--- /dev/null
+++ b/core/forms.py
@@ -0,0 +1,49 @@
+from django import forms
+
+from .models import Event
+
+
+class EventForm(forms.ModelForm):
+ start = forms.DateTimeField(
+ input_formats=['%Y-%m-%dT%H:%M'],
+ widget=forms.DateTimeInput(
+ attrs={'type': 'datetime-local', 'class': 'form-control'},
+ format='%Y-%m-%dT%H:%M',
+ ),
+ )
+ end = forms.DateTimeField(
+ input_formats=['%Y-%m-%dT%H:%M'],
+ widget=forms.DateTimeInput(
+ attrs={'type': 'datetime-local', 'class': 'form-control'},
+ format='%Y-%m-%dT%H:%M',
+ ),
+ )
+
+ class Meta:
+ model = Event
+ fields = ['name', 'location', 'start', 'end', 'event_url', 'summary', 'is_published']
+ widgets = {
+ 'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Downtown Spring Market'}),
+ 'location': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Riverfront Pavilion'}),
+ 'event_url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': 'https://event-page.example.com'}),
+ 'summary': forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': 'Anything visitors should know before attending.'}),
+ 'is_published': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
+ }
+ help_texts = {
+ 'is_published': 'Only published events appear in the public calendar and embed widget.',
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ for field_name in ['start', 'end']:
+ value = self.initial.get(field_name) or getattr(self.instance, field_name, None)
+ if value:
+ self.initial[field_name] = value.strftime('%Y-%m-%dT%H:%M')
+
+ def clean(self):
+ cleaned_data = super().clean()
+ start = cleaned_data.get('start')
+ end = cleaned_data.get('end')
+ if start and end and end <= start:
+ self.add_error('end', 'End time must be after the start time.')
+ return cleaned_data
diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py
new file mode 100644
index 0000000..c7f419a
--- /dev/null
+++ b/core/migrations/0001_initial.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.2.7 on 2026-04-02 14:57
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Event',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=150)),
+ ('slug', models.SlugField(blank=True, max_length=180, unique=True)),
+ ('location', models.CharField(blank=True, max_length=180)),
+ ('start', models.DateTimeField()),
+ ('end', models.DateTimeField()),
+ ('event_url', models.URLField(blank=True)),
+ ('summary', models.TextField(blank=True)),
+ ('is_published', models.BooleanField(default=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ 'ordering': ['start', 'name'],
+ },
+ ),
+ ]
diff --git a/core/migrations/0002_seed_demo_events.py b/core/migrations/0002_seed_demo_events.py
new file mode 100644
index 0000000..3bccdd4
--- /dev/null
+++ b/core/migrations/0002_seed_demo_events.py
@@ -0,0 +1,69 @@
+# Generated by Django 5.2.7 on 2026-04-02 14:57
+
+import datetime
+
+from django.db import migrations
+from django.utils import timezone
+
+
+def seed_events(apps, schema_editor):
+ Event = apps.get_model('core', 'Event')
+ if Event.objects.exists():
+ return
+
+ tz = timezone.get_current_timezone()
+ demo_events = [
+ {
+ 'name': 'Spring Market Pop-Up',
+ 'slug': 'spring-market-pop-up-2026-04-10',
+ 'location': 'Riverfront Pavilion',
+ 'start': timezone.make_aware(datetime.datetime(2026, 4, 10, 10, 0), tz),
+ 'end': timezone.make_aware(datetime.datetime(2026, 4, 10, 15, 0), tz),
+ 'event_url': 'https://example.com/events/spring-market-pop-up',
+ 'summary': 'Browse seasonal specials, meet the team, and pick up limited weekend offers.',
+ 'is_published': True,
+ },
+ {
+ 'name': 'Neighborhood Food Fair',
+ 'slug': 'neighborhood-food-fair-2026-04-16',
+ 'location': 'Main Street Square',
+ 'start': timezone.make_aware(datetime.datetime(2026, 4, 16, 11, 30), tz),
+ 'end': timezone.make_aware(datetime.datetime(2026, 4, 16, 18, 0), tz),
+ 'event_url': 'https://example.com/events/neighborhood-food-fair',
+ 'summary': 'A full afternoon of tastings, live music, and a simple way for visitors to find your booth.',
+ 'is_published': True,
+ },
+ {
+ 'name': 'Weekend Makers Showcase',
+ 'slug': 'weekend-makers-showcase-2026-04-25',
+ 'location': 'Cedar Hall',
+ 'start': timezone.make_aware(datetime.datetime(2026, 4, 25, 9, 0), tz),
+ 'end': timezone.make_aware(datetime.datetime(2026, 4, 25, 14, 30), tz),
+ 'event_url': 'https://example.com/events/weekend-makers-showcase',
+ 'summary': 'An example public event showing how names, times, and external links appear in the widget.',
+ 'is_published': True,
+ },
+ ]
+ Event.objects.bulk_create([Event(**item) for item in demo_events])
+
+
+def remove_seeded_events(apps, schema_editor):
+ Event = apps.get_model('core', 'Event')
+ Event.objects.filter(
+ slug__in=[
+ 'spring-market-pop-up-2026-04-10',
+ 'neighborhood-food-fair-2026-04-16',
+ 'weekend-makers-showcase-2026-04-25',
+ ]
+ ).delete()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.RunPython(seed_events, remove_seeded_events),
+ ]
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..c28f760
Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ
diff --git a/core/migrations/__pycache__/0002_seed_demo_events.cpython-311.pyc b/core/migrations/__pycache__/0002_seed_demo_events.cpython-311.pyc
new file mode 100644
index 0000000..2a10b75
Binary files /dev/null and b/core/migrations/__pycache__/0002_seed_demo_events.cpython-311.pyc differ
diff --git a/core/models.py b/core/models.py
index 71a8362..d2892a0 100644
--- a/core/models.py
+++ b/core/models.py
@@ -1,3 +1,40 @@
+from django.core.exceptions import ValidationError
from django.db import models
+from django.template.defaultfilters import slugify
+from django.utils import timezone
-# Create your models here.
+
+class Event(models.Model):
+ name = models.CharField(max_length=150)
+ slug = models.SlugField(max_length=180, unique=True, blank=True)
+ location = models.CharField(max_length=180, blank=True)
+ start = models.DateTimeField()
+ end = models.DateTimeField()
+ event_url = models.URLField(blank=True)
+ summary = models.TextField(blank=True)
+ is_published = models.BooleanField(default=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ['start', 'name']
+
+ def __str__(self):
+ return f"{self.name} ({timezone.localtime(self.start):%b %d, %Y})"
+
+ def clean(self):
+ super().clean()
+ if self.end <= self.start:
+ raise ValidationError({'end': 'End time must be after the start time.'})
+
+ def save(self, *args, **kwargs):
+ if not self.slug:
+ local_start = timezone.localtime(self.start) if timezone.is_aware(self.start) else self.start
+ base_slug = slugify(f"{self.name}-{local_start:%Y-%m-%d}")[:170] or 'event'
+ slug = base_slug
+ index = 2
+ while Event.objects.exclude(pk=self.pk).filter(slug=slug).exists():
+ slug = f"{base_slug[:165]}-{index}"
+ index += 1
+ self.slug = slug
+ super().save(*args, **kwargs)
diff --git a/core/templates/base.html b/core/templates/base.html
index 1e7e5fb..d15912d 100644
--- a/core/templates/base.html
+++ b/core/templates/base.html
@@ -1,25 +1,85 @@
+{% load static %}
-
- {% block title %}Knowledge Base{% endblock %}
- {% if project_description %}
-
-
-
+
+ {% block title %}{{ page_title|default:project_name }}{% endblock %}
+
+ {% if noindex %}
+
{% endif %}
{% if project_image_url %}
{% endif %}
- {% load static %}
+
+
+
+
{% block head %}{% endblock %}
+
+ {% if not embedded %}
+
+
+
+ {% endif %}
+
+ {% if messages and not embedded %}
+
+
+ {% for message in messages %}
+
{{ message }}
+ {% endfor %}
+
+
+ {% endif %}
-
{% block content %}{% endblock %}
-
+ {% if not embedded %}
+
+ {% endif %}
+
+
+
+ {% block scripts %}{% endblock %}
+
diff --git a/core/templates/core/calendar_embed.html b/core/templates/core/calendar_embed.html
new file mode 100644
index 0000000..c177e1b
--- /dev/null
+++ b/core/templates/core/calendar_embed.html
@@ -0,0 +1,21 @@
+{% extends "base.html" %}
+
+{% block title %}Embeddable Calendar Widget{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
Embeddable widget
+
Where to find us
+
Tap a date to see the event name, time window, and event link.
+
+
Open full page
+
+ {% include "core/includes/calendar_widget.html" with calendar_variant="embed" %}
+
+
+
+{% endblock %}
diff --git a/core/templates/core/calendar_page.html b/core/templates/core/calendar_page.html
new file mode 100644
index 0000000..01b936d
--- /dev/null
+++ b/core/templates/core/calendar_page.html
@@ -0,0 +1,22 @@
+{% extends "base.html" %}
+
+{% block title %}Public Calendar | Roadshow Calendar{% endblock %}
+
+{% block content %}
+
+
+
+
+
Live schedule
+
See where the business will be on any day
+
This full-page calendar mirrors the embeddable widget and stays read-only for visitors.
+
+
+
+ {% include "core/includes/calendar_widget.html" with calendar_variant="full" %}
+
+
+{% endblock %}
diff --git a/core/templates/core/event_confirm_delete.html b/core/templates/core/event_confirm_delete.html
new file mode 100644
index 0000000..0d87d55
--- /dev/null
+++ b/core/templates/core/event_confirm_delete.html
@@ -0,0 +1,42 @@
+{% extends "base.html" %}
+
+{% block title %}Delete {{ event.name }} | Roadshow Calendar{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
Delete event
+
Remove {{ event.name }}?
+
This removes the event from the custom dashboard, the public calendar, and the embed widget. This action cannot be undone.
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/event_dashboard.html b/core/templates/core/event_dashboard.html
new file mode 100644
index 0000000..7eadcb2
--- /dev/null
+++ b/core/templates/core/event_dashboard.html
@@ -0,0 +1,79 @@
+{% extends "base.html" %}
+
+{% block title %}Manage Events | Roadshow Calendar{% endblock %}
+
+{% block content %}
+
+
+
+
+
Staff workspace
+
Manage your public event calendar securely
+
Only staff members can reach this area. Create events here or jump into Django Admin for bulk edits.
+
+
+
+
+
+
+
+
All events
+
{{ dashboard_count }} scheduled item{{ dashboard_count|pluralize }} currently in the system.
+
+
+
+
+
+
+ | Event |
+ Date |
+ Location |
+ Status |
+ Actions |
+
+
+
+ {% for event in events %}
+
+
+ {{ event.name }}
+ {{ event.start|date:"g:i A" }} – {{ event.end|date:"g:i A" }}
+ |
+ {{ event.start|date:"M j, Y" }} |
+ {{ event.location|default:"—" }} |
+
+ {% if event.is_published %}
+ Published
+ {% else %}
+ Draft
+ {% endif %}
+ |
+
+
+ |
+
+ {% empty %}
+
+
+
+ No events yet
+ Create your first event to populate the public calendar and widget.
+ Create event
+
+ |
+
+ {% endfor %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/event_dashboard_detail.html b/core/templates/core/event_dashboard_detail.html
new file mode 100644
index 0000000..8a8f515
--- /dev/null
+++ b/core/templates/core/event_dashboard_detail.html
@@ -0,0 +1,51 @@
+{% extends "base.html" %}
+
+{% block title %}{{ event.name }} | Staff Review{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+ Saved successfully
+
{{ event.name }}
+
+ {% if event.is_published %}
+
Live on the public calendar
+ {% else %}
+
Saved as draft
+ {% endif %}
+
+
+
+
{{ event.summary|default:"No additional staff notes yet." }}
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/event_detail.html b/core/templates/core/event_detail.html
new file mode 100644
index 0000000..6fa986f
--- /dev/null
+++ b/core/templates/core/event_detail.html
@@ -0,0 +1,42 @@
+{% extends "base.html" %}
+
+{% block title %}{{ event.name }} | Roadshow Calendar{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ Event detail
+ {{ event.name }}
+
+
+
{{ event.summary|default:"More information about this event will be shared soon." }}
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/core/event_form.html b/core/templates/core/event_form.html
new file mode 100644
index 0000000..f90b3fe
--- /dev/null
+++ b/core/templates/core/event_form.html
@@ -0,0 +1,49 @@
+{% extends "base.html" %}
+
+{% block title %}{{ page_title }} | Roadshow Calendar{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/core/templates/core/event_list.html b/core/templates/core/event_list.html
new file mode 100644
index 0000000..55b2840
--- /dev/null
+++ b/core/templates/core/event_list.html
@@ -0,0 +1,41 @@
+{% extends "base.html" %}
+
+{% block title %}Upcoming Events | Roadshow Calendar{% endblock %}
+
+{% block content %}
+
+
+
+
Public event list
+
All published stops in one clean list
+
Visitors can browse every published event even if they prefer a list over the calendar grid.
+
+
+ {% for event in events %}
+
+
+ {{ event.start|date:"M j, Y" }}
+ {{ event.name }}
+ {{ event.start|date:"g:i A" }} – {{ event.end|date:"g:i A" }}
+ {{ event.location|default:"Location announced soon" }}
+ {{ event.summary|default:"Extra details will be shared here when available."|truncatechars:150 }}
+
+
+
+ {% empty %}
+
+
+
No published events yet
+
Once your team adds and publishes events, visitors will see them here and in the embeddable calendar.
+
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/core/templates/core/includes/calendar_widget.html b/core/templates/core/includes/calendar_widget.html
new file mode 100644
index 0000000..7555b3f
--- /dev/null
+++ b/core/templates/core/includes/calendar_widget.html
@@ -0,0 +1,53 @@
+
+
+
+
Click any date to view event details
+
{{ month_label }}
+
+
+
+
+
+ Sun
+ Mon
+ Tue
+ Wed
+ Thu
+ Fri
+ Sat
+
+
+ {% for week in calendar_weeks %}
+ {% for day in week %}
+
+ {% endfor %}
+ {% endfor %}
+
+
+
+{{ calendar_events|json_script:"calendar-events-data" }}
+
diff --git a/core/templates/core/index.html b/core/templates/core/index.html
index faec813..daa4bd5 100644
--- a/core/templates/core/index.html
+++ b/core/templates/core/index.html
@@ -1,145 +1,143 @@
{% extends "base.html" %}
-{% block title %}{{ project_name }}{% endblock %}
-
-{% block head %}
-
-
-
-
-{% endblock %}
+{% block title %}Roadshow Calendar | Secure Event Calendar{% endblock %}
{% block content %}
-
-
-
Analyzing your requirements and generating your app…
-
-
Loading…
+
+
+
+
+
Embeddable calendar widget
+
Show visitors exactly where your business will be on any day.
+
A polished public calendar for your website, plus secure staff-only tools for publishing event dates, times, and direct event links.
+
+
+
+
+ Secure updates
+ Only logged-in staff can add or change events.
+
+
+
+
+ Click any day
+ Visitors get event names, times, and helpful links in a modal.
+
+
+
+
+ Embed ready
+ Drop the iframe into your existing HTML website in minutes.
+
+
+
+
+
-
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
+
+
+
+
+
+
+
Public calendar
+
A visitor-friendly monthly schedule
+
Each day opens a clean detail modal so guests can confirm where you will be and jump straight to the event page.
+
+
+
+ {% include "core/includes/calendar_widget.html" with calendar_variant="landing" %}
+
+
+
+
+
+
+ Upcoming stops
+
Events your audience can scan in seconds
+
+
+ {% for event in upcoming_events %}
+
+
+ {{ event.start|date:"D, M j" }}
+ {{ event.name }}
+ {{ event.start|date:"g:i A" }} – {{ event.end|date:"g:i A" }}
+ {{ event.location|default:"Location announced soon" }}
+ {{ event.summary|default:"Event details will appear here as soon as your team adds them."|truncatechars:120 }}
+
+
+
+ {% empty %}
+
+
+
No public events yet
+
Create events from the secure dashboard or Django admin, then they will automatically appear on the landing page and embed widget.
+
Staff login
+
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
Embed on your HTML site
+
Paste one iframe and you are live
+
Use the ready-made widget page below inside your existing HTML website. It stays read-only for visitors, while your team updates dates privately.
+
+
+
+
+
+
+
{{ iframe_snippet }}
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/registration/login.html b/core/templates/registration/login.html
new file mode 100644
index 0000000..933ec3c
--- /dev/null
+++ b/core/templates/registration/login.html
@@ -0,0 +1,38 @@
+{% extends "base.html" %}
+
+{% block title %}Staff Login | Roadshow Calendar{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/core/tests.py b/core/tests.py
index 7ce503c..20438a9 100644
--- a/core/tests.py
+++ b/core/tests.py
@@ -1,3 +1,90 @@
-from django.test import TestCase
+from datetime import datetime
-# Create your tests here.
+from django.contrib.auth.models import User
+from django.test import Client, TestCase
+from django.urls import reverse
+from django.utils import timezone
+
+from .models import Event
+
+
+class EventModelTests(TestCase):
+ def test_slug_is_created_on_save(self):
+ event = Event.objects.create(
+ name='City Market',
+ start=timezone.make_aware(datetime(2026, 4, 10, 10, 0)),
+ end=timezone.make_aware(datetime(2026, 4, 10, 14, 0)),
+ )
+ self.assertTrue(event.slug.startswith('city-market-2026-04-10'))
+
+
+class CalendarViewTests(TestCase):
+ def setUp(self):
+ Event.objects.create(
+ name='Riverfront Market',
+ location='Riverfront Pavilion',
+ start=timezone.make_aware(datetime(2026, 4, 10, 10, 0)),
+ end=timezone.make_aware(datetime(2026, 4, 10, 16, 0)),
+ is_published=True,
+ )
+
+ def test_home_page_renders(self):
+ response = self.client.get(reverse('home'))
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'Show visitors exactly where your business will be on any day.')
+
+ def test_staff_dashboard_requires_login(self):
+ response = self.client.get(reverse('event_dashboard'))
+ self.assertEqual(response.status_code, 302)
+ self.assertIn(reverse('login'), response.url)
+
+ def test_staff_dashboard_for_staff_user(self):
+ staff = User.objects.create_user(username='staffer', password='pass12345', is_staff=True)
+ client = Client()
+ client.login(username='staffer', password='pass12345')
+ response = client.get(reverse('event_dashboard'))
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'Manage your public event calendar securely')
+
+
+class EventDashboardMutationTests(TestCase):
+ def setUp(self):
+ self.staff = User.objects.create_user(username='staffer', password='pass12345', is_staff=True)
+ self.event = Event.objects.create(
+ name='Editable Market',
+ location='Town Square',
+ start=timezone.make_aware(datetime(2026, 4, 12, 9, 0)),
+ end=timezone.make_aware(datetime(2026, 4, 12, 12, 0)),
+ is_published=True,
+ )
+
+ def test_staff_can_open_edit_page(self):
+ self.client.login(username='staffer', password='pass12345')
+ response = self.client.get(reverse('event_edit', args=[self.event.slug]))
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'Save changes')
+
+ def test_staff_can_update_event(self):
+ self.client.login(username='staffer', password='pass12345')
+ response = self.client.post(
+ reverse('event_edit', args=[self.event.slug]),
+ {
+ 'name': 'Updated Market',
+ 'location': 'Town Square',
+ 'start': '2026-04-12T09:00',
+ 'end': '2026-04-12T13:00',
+ 'event_url': 'https://example.com/event',
+ 'summary': 'Updated details',
+ 'is_published': 'on',
+ },
+ )
+ self.assertRedirects(response, reverse('event_dashboard_detail', args=[self.event.slug]))
+ self.event.refresh_from_db()
+ self.assertEqual(self.event.name, 'Updated Market')
+ self.assertEqual(self.event.summary, 'Updated details')
+
+ def test_staff_can_delete_event(self):
+ self.client.login(username='staffer', password='pass12345')
+ response = self.client.post(reverse('event_delete', args=[self.event.slug]))
+ self.assertRedirects(response, reverse('event_dashboard'))
+ self.assertFalse(Event.objects.filter(pk=self.event.pk).exists())
diff --git a/core/urls.py b/core/urls.py
index 6299e3d..4f0ba4c 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -1,7 +1,34 @@
+from django.contrib.auth import views as auth_views
from django.urls import path
-from .views import home
+from .views import (
+ calendar_embed,
+ calendar_page,
+ event_create,
+ event_dashboard,
+ event_dashboard_detail,
+ event_delete,
+ event_detail,
+ event_edit,
+ event_list,
+ home,
+)
urlpatterns = [
- path("", home, name="home"),
+ path('', home, name='home'),
+ path('calendar/', calendar_page, name='calendar_page'),
+ path('calendar/embed/', calendar_embed, name='calendar_embed'),
+ path('events/', event_list, name='event_list'),
+ path('events/
/', event_detail, name='event_detail'),
+ path('dashboard/events/', event_dashboard, name='event_dashboard'),
+ path('dashboard/events/new/', event_create, name='event_create'),
+ path('dashboard/events//edit/', event_edit, name='event_edit'),
+ path('dashboard/events//delete/', event_delete, name='event_delete'),
+ path('dashboard/events//', event_dashboard_detail, name='event_dashboard_detail'),
+ path(
+ 'login/',
+ auth_views.LoginView.as_view(template_name='registration/login.html', redirect_authenticated_user=True),
+ name='login',
+ ),
+ path('logout/', auth_views.LogoutView.as_view(), name='logout'),
]
diff --git a/core/views.py b/core/views.py
index c9aed12..a5bd905 100644
--- a/core/views.py
+++ b/core/views.py
@@ -1,25 +1,286 @@
-import os
-import platform
+import calendar
+from collections import defaultdict
+from datetime import datetime, time, timedelta
-from django import get_version as django_version
-from django.shortcuts import render
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import PermissionDenied
+from django.shortcuts import get_object_or_404, redirect, render
+from django.urls import reverse
from django.utils import timezone
+from .forms import EventForm
+from .models import Event
+
+
+PROJECT_NAME = 'Roadshow Calendar'
+DEFAULT_META_DESCRIPTION = 'Track where your business will be each day with a polished public calendar and secure staff-only event management.'
+
+
+def _parse_month(month_value: str | None):
+ today = timezone.localdate()
+ if month_value:
+ try:
+ parsed = datetime.strptime(month_value, '%Y-%m').date()
+ return parsed.replace(day=1)
+ except ValueError:
+ pass
+ return today.replace(day=1)
+
+
+def _next_month(month_start):
+ return (month_start.replace(day=28) + timedelta(days=4)).replace(day=1)
+
+
+def _previous_month(month_start):
+ return (month_start - timedelta(days=1)).replace(day=1)
+
+
+def _build_month_context(month_value=None):
+ month_start = _parse_month(month_value)
+ next_month = _next_month(month_start)
+ tz = timezone.get_current_timezone()
+ range_start = timezone.make_aware(datetime.combine(month_start, time.min), tz)
+ range_end = timezone.make_aware(datetime.combine(next_month, time.min), tz)
+
+ events = list(
+ Event.objects.filter(is_published=True, start__lt=range_end, end__gte=range_start).order_by('start', 'name')
+ )
+
+ event_map = defaultdict(list)
+ for event in events:
+ start_local = timezone.localtime(event.start)
+ end_local = timezone.localtime(event.end)
+ current_day = start_local.date()
+ final_day = end_local.date()
+ while current_day <= final_day:
+ event_map[current_day.isoformat()].append(
+ {
+ 'name': event.name,
+ 'location': event.location,
+ 'time': f"{start_local:%I:%M %p} – {end_local:%I:%M %p}" if start_local.date() == end_local.date() else f"{start_local:%b %d, %I:%M %p} – {end_local:%b %d, %I:%M %p}",
+ 'summary': event.summary,
+ 'event_url': event.event_url,
+ 'detail_url': reverse('event_detail', kwargs={'slug': event.slug}),
+ }
+ )
+ current_day += timedelta(days=1)
+
+ month_calendar = calendar.Calendar(firstweekday=6)
+ today = timezone.localdate()
+ calendar_weeks = []
+ for week in month_calendar.monthdatescalendar(month_start.year, month_start.month):
+ week_days = []
+ for day in week:
+ iso = day.isoformat()
+ day_events = event_map.get(iso, [])
+ week_days.append(
+ {
+ 'date': day,
+ 'iso': iso,
+ 'day': day.day,
+ 'in_month': day.month == month_start.month,
+ 'is_today': day == today,
+ 'event_count': len(day_events),
+ 'has_events': bool(day_events),
+ }
+ )
+ calendar_weeks.append(week_days)
+
+ return {
+ 'calendar_weeks': calendar_weeks,
+ 'calendar_events': dict(event_map),
+ 'month_label': month_start.strftime('%B %Y'),
+ 'month_value': month_start.strftime('%Y-%m'),
+ 'prev_month': _previous_month(month_start).strftime('%Y-%m'),
+ 'next_month': next_month.strftime('%Y-%m'),
+ 'today_value': today.strftime('%Y-%m'),
+ }
+
+
+def _base_context(**extra):
+ context = {
+ 'project_name': PROJECT_NAME,
+ 'meta_description': DEFAULT_META_DESCRIPTION,
+ }
+ context.update(extra)
+ return context
+
+
+@login_required(login_url='login')
+def event_dashboard(request):
+ if not request.user.is_staff:
+ raise PermissionDenied
+ events = Event.objects.all().order_by('start', 'name')
+ return render(
+ request,
+ 'core/event_dashboard.html',
+ _base_context(
+ page_title='Manage events',
+ events=events,
+ dashboard_count=events.count(),
+ ),
+ )
+
+
+@login_required(login_url='login')
+def event_create(request):
+ if not request.user.is_staff:
+ raise PermissionDenied
+
+ if request.method == 'POST':
+ form = EventForm(request.POST)
+ if form.is_valid():
+ event = form.save()
+ messages.success(request, 'Event saved and ready for the public calendar.')
+ return redirect('event_dashboard_detail', slug=event.slug)
+ else:
+ form = EventForm()
+
+ return render(
+ request,
+ 'core/event_form.html',
+ _base_context(
+ page_title='Add event',
+ form=form,
+ form_mode='create',
+ form_title='Add a new stop to the calendar',
+ form_intro='Once saved, published events immediately power the landing page, public calendar, and embeddable widget.',
+ submit_label='Save event',
+ ),
+ )
+
+
+@login_required(login_url='login')
+def event_edit(request, slug):
+ if not request.user.is_staff:
+ raise PermissionDenied
+
+ event = get_object_or_404(Event, slug=slug)
+ if request.method == 'POST':
+ form = EventForm(request.POST, instance=event)
+ if form.is_valid():
+ event = form.save()
+ messages.success(request, 'Event updated successfully.')
+ return redirect('event_dashboard_detail', slug=event.slug)
+ else:
+ form = EventForm(instance=event)
+
+ return render(
+ request,
+ 'core/event_form.html',
+ _base_context(
+ page_title=f'Edit {event.name}',
+ form=form,
+ event=event,
+ form_mode='edit',
+ form_title='Update this calendar stop',
+ form_intro='Change dates, copy, publication status, or the event link without leaving the custom dashboard.',
+ submit_label='Save changes',
+ ),
+ )
+
+
+@login_required(login_url='login')
+def event_delete(request, slug):
+ if not request.user.is_staff:
+ raise PermissionDenied
+
+ event = get_object_or_404(Event, slug=slug)
+ if request.method == 'POST':
+ event_name = event.name
+ event.delete()
+ messages.success(request, f'{event_name} was deleted.')
+ return redirect('event_dashboard')
+
+ return render(
+ request,
+ 'core/event_confirm_delete.html',
+ _base_context(
+ page_title=f'Delete {event.name}',
+ event=event,
+ ),
+ )
+
+
+@login_required(login_url='login')
+def event_dashboard_detail(request, slug):
+ if not request.user.is_staff:
+ raise PermissionDenied
+ event = get_object_or_404(Event, slug=slug)
+ return render(
+ request,
+ 'core/event_dashboard_detail.html',
+ _base_context(
+ page_title=event.name,
+ event=event,
+ ),
+ )
+
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()
+ month_context = _build_month_context(request.GET.get('month'))
+ upcoming_events = list(Event.objects.filter(is_published=True, end__gte=timezone.now()).order_by('start', 'name')[:6])
+ embed_url = request.build_absolute_uri(reverse('calendar_embed'))
+ iframe_snippet = f''
+ return render(
+ request,
+ 'core/index.html',
+ _base_context(
+ page_title=PROJECT_NAME,
+ hero_events=upcoming_events[:3],
+ upcoming_events=upcoming_events,
+ embed_url=embed_url,
+ iframe_snippet=iframe_snippet,
+ **month_context,
+ ),
+ )
- 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", ""),
- }
- return render(request, "core/index.html", context)
+
+def calendar_page(request):
+ month_context = _build_month_context(request.GET.get('month'))
+ return render(
+ request,
+ 'core/calendar_page.html',
+ _base_context(
+ page_title='Public calendar',
+ **month_context,
+ ),
+ )
+
+
+def calendar_embed(request):
+ month_context = _build_month_context(request.GET.get('month'))
+ return render(
+ request,
+ 'core/calendar_embed.html',
+ _base_context(
+ page_title='Embeddable calendar',
+ embedded=True,
+ **month_context,
+ ),
+ )
+
+
+def event_list(request):
+ events = Event.objects.filter(is_published=True).order_by('start', 'name')
+ return render(
+ request,
+ 'core/event_list.html',
+ _base_context(
+ page_title='Upcoming events',
+ events=events,
+ ),
+ )
+
+
+def event_detail(request, slug):
+ event = get_object_or_404(Event, slug=slug, is_published=True)
+ return render(
+ request,
+ 'core/event_detail.html',
+ _base_context(
+ page_title=event.name,
+ event=event,
+ ),
+ )
diff --git a/static/css/custom.css b/static/css/custom.css
index 925f6ed..75d37a1 100644
--- a/static/css/custom.css
+++ b/static/css/custom.css
@@ -1,4 +1,803 @@
-/* Custom styles for the application */
-body {
- font-family: system-ui, -apple-system, sans-serif;
+/* Brand system */
+:root {
+ --brand-ink: #122023;
+ --brand-primary: #e76f51;
+ --brand-primary-dark: #cb5a3e;
+ --brand-secondary: #0f766e;
+ --brand-accent: #f3c96b;
+ --brand-surface: rgba(255, 249, 243, 0.8);
+ --brand-surface-strong: #fffdf9;
+ --brand-border: rgba(18, 32, 35, 0.08);
+ --brand-muted: #5d6c70;
+ --brand-bg: #f8efe6;
+ --brand-bg-deep: #f1e6dc;
+ --brand-shadow: 0 24px 60px rgba(18, 32, 35, 0.12);
+ --radius-lg: 28px;
+ --radius-md: 20px;
+ --radius-sm: 14px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ margin: 0;
+ font-family: 'Inter', system-ui, sans-serif;
+ color: var(--brand-ink);
+ background:
+ radial-gradient(circle at top left, rgba(243, 201, 107, 0.33), transparent 28%),
+ radial-gradient(circle at top right, rgba(15, 118, 110, 0.18), transparent 24%),
+ linear-gradient(180deg, #fffaf4 0%, var(--brand-bg) 52%, #fffaf4 100%);
+ min-height: 100vh;
+ position: relative;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+.navbar-brand,
+.section-title {
+ font-family: 'Manrope', 'Inter', sans-serif;
+ letter-spacing: -0.03em;
+}
+
+img {
+ max-width: 100%;
+ height: auto;
+}
+
+p,
+a,
+button,
+input,
+textarea,
+label,
+small,
+span {
+ font-family: 'Inter', system-ui, sans-serif;
+}
+
+.page-glow {
+ position: fixed;
+ border-radius: 999px;
+ filter: blur(90px);
+ opacity: 0.6;
+ pointer-events: none;
+ z-index: 0;
+}
+
+.page-glow-one {
+ width: 280px;
+ height: 280px;
+ background: rgba(231, 111, 81, 0.22);
+ top: 4rem;
+ left: -4rem;
+}
+
+.page-glow-two {
+ width: 360px;
+ height: 360px;
+ background: rgba(15, 118, 110, 0.15);
+ right: -5rem;
+ top: 18rem;
+}
+
+.site-header,
+.site-footer,
+.hero-section,
+.section-shell {
+ position: relative;
+ z-index: 1;
+}
+
+.site-header {
+ padding-top: 1.25rem;
+}
+
+.navbar {
+ background: rgba(255, 253, 249, 0.78);
+ border: 1px solid rgba(255, 255, 255, 0.7);
+ border-radius: 999px;
+ padding-left: 1.1rem;
+ padding-right: 1.1rem;
+ box-shadow: 0 16px 40px rgba(18, 32, 35, 0.08);
+ backdrop-filter: blur(18px);
+}
+
+.brand-mark {
+ color: var(--brand-ink);
+ font-weight: 800;
+ font-size: 1.2rem;
+ text-decoration: none;
+}
+
+.brand-mark span {
+ color: var(--brand-primary);
+}
+
+.nav-link {
+ color: var(--brand-ink);
+ font-weight: 600;
+ opacity: 0.88;
+}
+
+.nav-link:hover,
+.nav-link:focus,
+.footer-links a:hover,
+.footer-links a:focus,
+.text-link:hover,
+.text-link:focus {
+ color: var(--brand-secondary);
+}
+
+.calendar-toggler {
+ border: 0;
+ box-shadow: none !important;
+}
+
+.message-stack {
+ margin-top: 1rem;
+}
+
+.hero-section {
+ padding: 2rem 0 1rem;
+}
+
+.hero-card {
+ position: relative;
+ overflow: hidden;
+ background: linear-gradient(135deg, rgba(18, 32, 35, 0.95), rgba(18, 32, 35, 0.82));
+ color: #fff8f1;
+ border-radius: calc(var(--radius-lg) + 6px);
+ padding: clamp(2rem, 4vw, 4rem);
+ min-height: 620px;
+ box-shadow: var(--brand-shadow);
+ display: grid;
+ gap: 2rem;
+ align-items: center;
+ grid-template-columns: minmax(0, 1.1fr) minmax(280px, 420px);
+}
+
+.hero-card::before {
+ content: '';
+ position: absolute;
+ inset: auto auto -80px -60px;
+ width: 220px;
+ height: 220px;
+ border-radius: 42px;
+ background: linear-gradient(145deg, rgba(231, 111, 81, 0.35), rgba(243, 201, 107, 0.12));
+ transform: rotate(18deg);
+}
+
+.hero-card::after {
+ content: '';
+ position: absolute;
+ top: 40px;
+ right: 100px;
+ width: 170px;
+ height: 170px;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.hero-copy,
+.hero-panel {
+ position: relative;
+ z-index: 1;
+}
+
+.eyebrow-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ padding: 0.45rem 0.9rem;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.12);
+ color: inherit;
+ font-size: 0.86rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.hero-copy h1 {
+ font-size: clamp(2.7rem, 5vw, 4.8rem);
+ line-height: 0.97;
+ margin: 1.2rem 0 1.2rem;
+ max-width: 10ch;
+}
+
+.hero-lead,
+.section-copy,
+.footer-copy,
+.event-summary,
+.detail-body p,
+.form-help {
+ color: rgba(18, 32, 35, 0.74);
+ line-height: 1.7;
+}
+
+.hero-lead {
+ color: rgba(255, 248, 241, 0.82);
+ font-size: 1.08rem;
+ max-width: 50ch;
+}
+
+.hero-actions,
+.compact-actions,
+.dashboard-banner-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.85rem;
+ margin-top: 1.7rem;
+}
+
+.btn {
+ border-radius: 999px;
+ font-weight: 700;
+ padding: 0.8rem 1.25rem;
+ border-width: 1px;
+ transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, color 0.2s ease;
+}
+
+.btn:hover,
+.btn:focus {
+ transform: translateY(-1px);
+ box-shadow: 0 12px 25px rgba(18, 32, 35, 0.12);
+}
+
+.btn-primary-brand {
+ background: linear-gradient(135deg, var(--brand-primary), #f08f5b);
+ color: #fff;
+ border-color: transparent;
+}
+
+.btn-primary-brand:hover,
+.btn-primary-brand:focus {
+ background: linear-gradient(135deg, var(--brand-primary-dark), var(--brand-primary));
+ color: #fff;
+}
+
+.btn-ghost {
+ background: rgba(255, 255, 255, 0.68);
+ color: var(--brand-ink);
+ border-color: rgba(18, 32, 35, 0.08);
+ backdrop-filter: blur(14px);
+}
+
+.hero-card .btn-ghost {
+ background: rgba(255, 255, 255, 0.1);
+ color: #fff8f1;
+ border-color: rgba(255, 255, 255, 0.18);
+}
+
+.card-surface,
+.calendar-card,
+.embed-shell-card,
+.detail-card,
+.dashboard-banner,
+.table-card,
+.form-card,
+.empty-state,
+.event-card {
+ background: rgba(255, 253, 249, 0.82);
+ border: 1px solid rgba(255, 255, 255, 0.9);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--brand-shadow);
+ backdrop-filter: blur(18px);
+}
+
+.hero-stats {
+ margin-top: 2rem;
+}
+
+.stat-card {
+ height: 100%;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: var(--radius-md);
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.stat-card strong,
+.mini-event-list strong,
+.event-card h2,
+.event-card h3,
+.detail-card strong {
+ font-weight: 800;
+}
+
+.stat-card span,
+.mini-event-list small,
+.event-meta,
+.toolbar-label,
+.modal-label,
+.footer-title,
+.status-pill,
+.form-label {
+ color: rgba(255, 248, 241, 0.72);
+}
+
+.hero-panel {
+ min-height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.mini-widget {
+ position: relative;
+ width: 100%;
+ max-width: 360px;
+ padding: 1.6rem;
+ background: rgba(255, 253, 249, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.14);
+}
+
+.mini-label {
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: rgba(255, 248, 241, 0.65);
+ margin-bottom: 1rem;
+}
+
+.mini-event-list {
+ display: grid;
+ gap: 0.9rem;
+}
+
+.mini-event-list li {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 0.9rem;
+ align-items: start;
+}
+
+.mini-event-list span {
+ min-width: 52px;
+ padding: 0.45rem 0.65rem;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.12);
+ color: #fff8f1;
+ font-weight: 700;
+}
+
+.empty-note {
+ color: rgba(255, 248, 241, 0.76);
+ line-height: 1.6;
+}
+
+.floating-orb {
+ position: absolute;
+ border-radius: 999px;
+ filter: blur(6px);
+ opacity: 0.85;
+}
+
+.orb-one {
+ width: 118px;
+ height: 118px;
+ background: linear-gradient(145deg, rgba(243, 201, 107, 0.95), rgba(231, 111, 81, 0.6));
+ right: 12px;
+ top: 12px;
+}
+
+.orb-two {
+ width: 78px;
+ height: 78px;
+ background: linear-gradient(145deg, rgba(15, 118, 110, 0.9), rgba(255, 255, 255, 0.2));
+ left: -18px;
+ bottom: 44px;
+}
+
+.section-shell,
+.embed-section {
+ padding: 1.5rem 0 3.2rem;
+}
+
+.page-top-space {
+ padding-top: 2rem;
+}
+
+.section-soft {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.45), rgba(243, 201, 107, 0.06));
+}
+
+.section-heading {
+ margin-bottom: 1.5rem;
+}
+
+.section-title {
+ font-size: clamp(2rem, 3vw, 3.1rem);
+ color: var(--brand-ink);
+ margin-top: 0.9rem;
+ margin-bottom: 0.7rem;
+}
+
+.section-copy,
+.footer-copy,
+.event-summary,
+.form-help,
+.field-error,
+.text-link {
+ color: var(--brand-muted);
+}
+
+.calendar-card {
+ padding: 1.35rem;
+}
+
+.calendar-toolbar {
+ margin-bottom: 1.2rem;
+}
+
+.calendar-title {
+ font-size: 1.9rem;
+ color: var(--brand-ink);
+}
+
+.toolbar-label,
+.modal-label,
+.event-meta,
+.footer-title,
+.form-label,
+.detail-meta-grid span {
+ color: var(--brand-muted);
+ font-weight: 600;
+ letter-spacing: 0.01em;
+}
+
+.calendar-weekdays,
+.calendar-grid {
+ display: grid;
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+ gap: 0.85rem;
+}
+
+.calendar-weekdays {
+ margin-bottom: 0.85rem;
+}
+
+.calendar-weekdays span {
+ text-align: center;
+ padding: 0.35rem 0;
+ font-size: 0.88rem;
+ font-weight: 700;
+ color: var(--brand-muted);
+}
+
+.calendar-day {
+ min-height: 124px;
+ border-radius: 22px;
+ border: 1px solid rgba(18, 32, 35, 0.08);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(249, 241, 233, 0.9));
+ padding: 0.95rem;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ text-align: left;
+ gap: 0.75rem;
+ color: var(--brand-ink);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
+}
+
+.calendar-day:hover,
+.calendar-day:focus {
+ border-color: rgba(231, 111, 81, 0.3);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 1), rgba(255, 244, 235, 1));
+ outline: none;
+}
+
+.calendar-day-number {
+ font-family: 'Manrope', sans-serif;
+ font-size: 1.45rem;
+ font-weight: 800;
+}
+
+.calendar-day.is-muted {
+ opacity: 0.5;
+}
+
+.calendar-day.is-today {
+ border-color: rgba(15, 118, 110, 0.3);
+ box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.12);
+}
+
+.calendar-day.has-events {
+ background: linear-gradient(180deg, rgba(255, 245, 238, 1), rgba(255, 251, 244, 1));
+}
+
+.calendar-badge {
+ display: inline-flex;
+ align-items: center;
+ align-self: flex-start;
+ gap: 0.35rem;
+ padding: 0.45rem 0.7rem;
+ border-radius: 999px;
+ background: rgba(231, 111, 81, 0.12);
+ color: var(--brand-primary-dark);
+ font-size: 0.82rem;
+ font-weight: 700;
+}
+
+.calendar-badge-empty {
+ background: rgba(15, 118, 110, 0.08);
+ color: var(--brand-secondary);
+}
+
+.event-modal {
+ border: 0;
+ border-radius: 28px;
+ background: linear-gradient(180deg, rgba(255, 253, 249, 0.98), rgba(248, 239, 230, 0.96));
+ box-shadow: 0 40px 90px rgba(18, 32, 35, 0.2);
+}
+
+.event-modal .modal-body {
+ padding-bottom: 1.6rem;
+}
+
+.modal-event-card {
+ background: rgba(255, 255, 255, 0.76);
+ border: 1px solid rgba(18, 32, 35, 0.06);
+ border-radius: 22px;
+ padding: 1.15rem;
+ margin-bottom: 0.9rem;
+}
+
+.modal-event-card:last-child {
+ margin-bottom: 0;
+}
+
+.modal-event-card h4 {
+ margin-bottom: 0.45rem;
+ font-size: 1.2rem;
+}
+
+.modal-event-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.8rem;
+ color: var(--brand-muted);
+ font-size: 0.95rem;
+ margin-bottom: 0.7rem;
+}
+
+.embed-shell-card {
+ padding: 1.2rem;
+ margin: 1.2rem auto;
+ max-width: 1200px;
+}
+
+.embed-section .calendar-card {
+ background: transparent;
+ border: 0;
+ box-shadow: none;
+ padding: 0;
+}
+
+.event-card,
+.detail-card,
+.form-card,
+.dashboard-banner,
+.table-card,
+.embed-copy-card,
+.code-card,
+.empty-state {
+ padding: 1.5rem;
+}
+
+.event-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.8rem;
+}
+
+.event-date-chip,
+.status-pill {
+ display: inline-flex;
+ align-self: flex-start;
+ align-items: center;
+ justify-content: center;
+ padding: 0.45rem 0.8rem;
+ border-radius: 999px;
+ font-size: 0.82rem;
+ font-weight: 800;
+}
+
+.event-date-chip {
+ background: rgba(243, 201, 107, 0.24);
+ color: #8c6423;
+}
+
+.status-live {
+ background: rgba(15, 118, 110, 0.12);
+ color: var(--brand-secondary);
+}
+
+.status-draft {
+ background: rgba(93, 108, 112, 0.14);
+ color: var(--brand-muted);
+}
+
+.detail-meta-grid {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ margin: 1.5rem 0;
+}
+
+.detail-meta-grid strong {
+ display: block;
+ color: var(--brand-ink);
+ margin-top: 0.35rem;
+}
+
+.code-card {
+ background: linear-gradient(180deg, rgba(18, 32, 35, 0.96), rgba(18, 32, 35, 0.88));
+ color: #fff8f1;
+}
+
+.code-card-header strong {
+ color: #fff8f1;
+}
+
+.code-card .btn-ghost {
+ background: rgba(255, 255, 255, 0.08);
+ color: #fff8f1;
+ border-color: rgba(255, 255, 255, 0.12);
+}
+
+.code-snippet {
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin-top: 1rem;
+ padding: 1.1rem;
+ border-radius: 20px;
+ background: rgba(255, 255, 255, 0.06);
+ color: #fff2df;
+}
+
+.dashboard-banner {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1.25rem;
+}
+
+.dashboard-table th {
+ color: var(--brand-muted);
+ font-weight: 700;
+}
+
+.dashboard-table td,
+.dashboard-table th {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+ border-color: rgba(18, 32, 35, 0.06);
+ background: transparent;
+}
+
+input[type="text"],
+input[type="password"],
+input[type="url"],
+input[type="datetime-local"],
+textarea,
+.form-control,
+.form-check-input {
+ border-radius: 16px;
+ border-color: rgba(18, 32, 35, 0.12);
+ padding: 0.85rem 1rem;
+ background: rgba(255, 255, 255, 0.94);
+}
+
+.form-control:focus,
+.form-check-input:focus,
+.btn:focus,
+.nav-link:focus,
+.text-link:focus {
+ border-color: rgba(15, 118, 110, 0.4);
+ box-shadow: 0 0 0 0.25rem rgba(15, 118, 110, 0.14);
+}
+
+.form-check-input {
+ width: 1.2rem;
+ height: 1.2rem;
+}
+
+.card-check {
+ background: rgba(243, 201, 107, 0.08);
+ border: 1px solid rgba(243, 201, 107, 0.32);
+ border-radius: 18px;
+ padding: 1rem;
+}
+
+.form-check-label {
+ margin-left: 0.45rem;
+ font-weight: 700;
+}
+
+.field-error {
+ margin-top: 0.4rem;
+ color: #b6412b;
+ font-size: 0.92rem;
+ font-weight: 600;
+}
+
+.text-link {
+ text-decoration: none;
+ font-weight: 700;
+}
+
+.site-footer {
+ padding: 0.4rem 0 2rem;
+}
+
+.footer-title {
+ color: var(--brand-ink);
+ font-weight: 800;
+}
+
+.footer-links a {
+ color: var(--brand-muted);
+ text-decoration: none;
+ font-weight: 600;
+}
+
+@media (max-width: 991.98px) {
+ .hero-card {
+ grid-template-columns: 1fr;
+ min-height: auto;
+ }
+
+ .dashboard-banner {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .calendar-weekdays,
+ .calendar-grid {
+ gap: 0.55rem;
+ }
+}
+
+@media (max-width: 767.98px) {
+ .navbar {
+ border-radius: 28px;
+ }
+
+ .hero-copy h1,
+ .section-title {
+ max-width: none;
+ }
+
+ .calendar-weekdays {
+ display: none;
+ }
+
+ .calendar-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .calendar-day {
+ min-height: 112px;
+ }
+
+ .hero-section,
+ .section-shell,
+ .embed-section {
+ padding-bottom: 2.3rem;
+ }
}
diff --git a/static/js/calendar.js b/static/js/calendar.js
new file mode 100644
index 0000000..0d7c6c2
--- /dev/null
+++ b/static/js/calendar.js
@@ -0,0 +1,106 @@
+document.addEventListener('DOMContentLoaded', () => {
+ const dataElement = document.getElementById('calendar-events-data');
+ const modalElement = document.getElementById('dayEventsModal');
+ let calendarEvents = {};
+
+ const escapeHtml = (value) => String(value || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+ const safeExternalUrl = (value) => {
+ if (!value) return '';
+ try {
+ const url = new URL(value, window.location.origin);
+ return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
+ } catch (error) {
+ return '';
+ }
+ };
+
+ if (dataElement) {
+ try {
+ calendarEvents = JSON.parse(dataElement.textContent);
+ } catch (error) {
+ calendarEvents = {};
+ }
+ }
+
+ if (modalElement && window.bootstrap) {
+ const modal = new window.bootstrap.Modal(modalElement);
+ const titleNode = modalElement.querySelector('[data-calendar-title]');
+ const bodyNode = modalElement.querySelector('[data-calendar-body]');
+
+ const renderEvents = (isoDate) => {
+ const selectedEvents = calendarEvents[isoDate] || [];
+ const titleDate = new Date(`${isoDate}T12:00:00`);
+ titleNode.textContent = titleDate.toLocaleDateString(undefined, {
+ weekday: 'long',
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+ });
+
+ if (!selectedEvents.length) {
+ bodyNode.innerHTML = `
+
+
No event scheduled
+
This date is currently open. Check another day or revisit after the next update.
+
+ `;
+ return;
+ }
+
+ bodyNode.innerHTML = selectedEvents
+ .map((event) => {
+ const name = escapeHtml(event.name);
+ const time = escapeHtml(event.time);
+ const location = escapeHtml(event.location || 'Location announced soon');
+ const summary = event.summary ? `${escapeHtml(event.summary)}
` : '';
+ const detailUrl = escapeHtml(event.detail_url || '#');
+ const eventUrl = safeExternalUrl(event.event_url);
+ return `
+
+ ${name}
+
+ ${time}
+ ${location}
+
+ ${summary}
+
+
+ `;
+ })
+ .join('');
+ };
+
+ document.querySelectorAll('[data-calendar-date]').forEach((button) => {
+ button.addEventListener('click', () => {
+ renderEvents(button.dataset.calendarDate);
+ modal.show();
+ });
+ });
+ }
+
+ document.querySelectorAll('[data-copy-snippet]').forEach((button) => {
+ button.addEventListener('click', async () => {
+ const target = document.querySelector(button.dataset.copyTarget);
+ if (!target) return;
+ try {
+ await navigator.clipboard.writeText(target.textContent.trim());
+ const original = button.textContent;
+ button.textContent = 'Copied';
+ window.setTimeout(() => {
+ button.textContent = original;
+ }, 1600);
+ } catch (error) {
+ button.textContent = 'Copy manually';
+ }
+ });
+ });
+});
diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css
index 108056f..75d37a1 100644
--- a/staticfiles/css/custom.css
+++ b/staticfiles/css/custom.css
@@ -1,21 +1,803 @@
-
+/* Brand 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);
+ --brand-ink: #122023;
+ --brand-primary: #e76f51;
+ --brand-primary-dark: #cb5a3e;
+ --brand-secondary: #0f766e;
+ --brand-accent: #f3c96b;
+ --brand-surface: rgba(255, 249, 243, 0.8);
+ --brand-surface-strong: #fffdf9;
+ --brand-border: rgba(18, 32, 35, 0.08);
+ --brand-muted: #5d6c70;
+ --brand-bg: #f8efe6;
+ --brand-bg-deep: #f1e6dc;
+ --brand-shadow: 0 24px 60px rgba(18, 32, 35, 0.12);
+ --radius-lg: 28px;
+ --radius-md: 20px;
+ --radius-sm: 14px;
}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
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, sans-serif;
+ color: var(--brand-ink);
+ background:
+ radial-gradient(circle at top left, rgba(243, 201, 107, 0.33), transparent 28%),
+ radial-gradient(circle at top right, rgba(15, 118, 110, 0.18), transparent 24%),
+ linear-gradient(180deg, #fffaf4 0%, var(--brand-bg) 52%, #fffaf4 100%);
min-height: 100vh;
- text-align: center;
- overflow: hidden;
position: relative;
}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+.navbar-brand,
+.section-title {
+ font-family: 'Manrope', 'Inter', sans-serif;
+ letter-spacing: -0.03em;
+}
+
+img {
+ max-width: 100%;
+ height: auto;
+}
+
+p,
+a,
+button,
+input,
+textarea,
+label,
+small,
+span {
+ font-family: 'Inter', system-ui, sans-serif;
+}
+
+.page-glow {
+ position: fixed;
+ border-radius: 999px;
+ filter: blur(90px);
+ opacity: 0.6;
+ pointer-events: none;
+ z-index: 0;
+}
+
+.page-glow-one {
+ width: 280px;
+ height: 280px;
+ background: rgba(231, 111, 81, 0.22);
+ top: 4rem;
+ left: -4rem;
+}
+
+.page-glow-two {
+ width: 360px;
+ height: 360px;
+ background: rgba(15, 118, 110, 0.15);
+ right: -5rem;
+ top: 18rem;
+}
+
+.site-header,
+.site-footer,
+.hero-section,
+.section-shell {
+ position: relative;
+ z-index: 1;
+}
+
+.site-header {
+ padding-top: 1.25rem;
+}
+
+.navbar {
+ background: rgba(255, 253, 249, 0.78);
+ border: 1px solid rgba(255, 255, 255, 0.7);
+ border-radius: 999px;
+ padding-left: 1.1rem;
+ padding-right: 1.1rem;
+ box-shadow: 0 16px 40px rgba(18, 32, 35, 0.08);
+ backdrop-filter: blur(18px);
+}
+
+.brand-mark {
+ color: var(--brand-ink);
+ font-weight: 800;
+ font-size: 1.2rem;
+ text-decoration: none;
+}
+
+.brand-mark span {
+ color: var(--brand-primary);
+}
+
+.nav-link {
+ color: var(--brand-ink);
+ font-weight: 600;
+ opacity: 0.88;
+}
+
+.nav-link:hover,
+.nav-link:focus,
+.footer-links a:hover,
+.footer-links a:focus,
+.text-link:hover,
+.text-link:focus {
+ color: var(--brand-secondary);
+}
+
+.calendar-toggler {
+ border: 0;
+ box-shadow: none !important;
+}
+
+.message-stack {
+ margin-top: 1rem;
+}
+
+.hero-section {
+ padding: 2rem 0 1rem;
+}
+
+.hero-card {
+ position: relative;
+ overflow: hidden;
+ background: linear-gradient(135deg, rgba(18, 32, 35, 0.95), rgba(18, 32, 35, 0.82));
+ color: #fff8f1;
+ border-radius: calc(var(--radius-lg) + 6px);
+ padding: clamp(2rem, 4vw, 4rem);
+ min-height: 620px;
+ box-shadow: var(--brand-shadow);
+ display: grid;
+ gap: 2rem;
+ align-items: center;
+ grid-template-columns: minmax(0, 1.1fr) minmax(280px, 420px);
+}
+
+.hero-card::before {
+ content: '';
+ position: absolute;
+ inset: auto auto -80px -60px;
+ width: 220px;
+ height: 220px;
+ border-radius: 42px;
+ background: linear-gradient(145deg, rgba(231, 111, 81, 0.35), rgba(243, 201, 107, 0.12));
+ transform: rotate(18deg);
+}
+
+.hero-card::after {
+ content: '';
+ position: absolute;
+ top: 40px;
+ right: 100px;
+ width: 170px;
+ height: 170px;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.hero-copy,
+.hero-panel {
+ position: relative;
+ z-index: 1;
+}
+
+.eyebrow-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ padding: 0.45rem 0.9rem;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.12);
+ color: inherit;
+ font-size: 0.86rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.hero-copy h1 {
+ font-size: clamp(2.7rem, 5vw, 4.8rem);
+ line-height: 0.97;
+ margin: 1.2rem 0 1.2rem;
+ max-width: 10ch;
+}
+
+.hero-lead,
+.section-copy,
+.footer-copy,
+.event-summary,
+.detail-body p,
+.form-help {
+ color: rgba(18, 32, 35, 0.74);
+ line-height: 1.7;
+}
+
+.hero-lead {
+ color: rgba(255, 248, 241, 0.82);
+ font-size: 1.08rem;
+ max-width: 50ch;
+}
+
+.hero-actions,
+.compact-actions,
+.dashboard-banner-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.85rem;
+ margin-top: 1.7rem;
+}
+
+.btn {
+ border-radius: 999px;
+ font-weight: 700;
+ padding: 0.8rem 1.25rem;
+ border-width: 1px;
+ transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, color 0.2s ease;
+}
+
+.btn:hover,
+.btn:focus {
+ transform: translateY(-1px);
+ box-shadow: 0 12px 25px rgba(18, 32, 35, 0.12);
+}
+
+.btn-primary-brand {
+ background: linear-gradient(135deg, var(--brand-primary), #f08f5b);
+ color: #fff;
+ border-color: transparent;
+}
+
+.btn-primary-brand:hover,
+.btn-primary-brand:focus {
+ background: linear-gradient(135deg, var(--brand-primary-dark), var(--brand-primary));
+ color: #fff;
+}
+
+.btn-ghost {
+ background: rgba(255, 255, 255, 0.68);
+ color: var(--brand-ink);
+ border-color: rgba(18, 32, 35, 0.08);
+ backdrop-filter: blur(14px);
+}
+
+.hero-card .btn-ghost {
+ background: rgba(255, 255, 255, 0.1);
+ color: #fff8f1;
+ border-color: rgba(255, 255, 255, 0.18);
+}
+
+.card-surface,
+.calendar-card,
+.embed-shell-card,
+.detail-card,
+.dashboard-banner,
+.table-card,
+.form-card,
+.empty-state,
+.event-card {
+ background: rgba(255, 253, 249, 0.82);
+ border: 1px solid rgba(255, 255, 255, 0.9);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--brand-shadow);
+ backdrop-filter: blur(18px);
+}
+
+.hero-stats {
+ margin-top: 2rem;
+}
+
+.stat-card {
+ height: 100%;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: var(--radius-md);
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.stat-card strong,
+.mini-event-list strong,
+.event-card h2,
+.event-card h3,
+.detail-card strong {
+ font-weight: 800;
+}
+
+.stat-card span,
+.mini-event-list small,
+.event-meta,
+.toolbar-label,
+.modal-label,
+.footer-title,
+.status-pill,
+.form-label {
+ color: rgba(255, 248, 241, 0.72);
+}
+
+.hero-panel {
+ min-height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.mini-widget {
+ position: relative;
+ width: 100%;
+ max-width: 360px;
+ padding: 1.6rem;
+ background: rgba(255, 253, 249, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.14);
+}
+
+.mini-label {
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: rgba(255, 248, 241, 0.65);
+ margin-bottom: 1rem;
+}
+
+.mini-event-list {
+ display: grid;
+ gap: 0.9rem;
+}
+
+.mini-event-list li {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 0.9rem;
+ align-items: start;
+}
+
+.mini-event-list span {
+ min-width: 52px;
+ padding: 0.45rem 0.65rem;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.12);
+ color: #fff8f1;
+ font-weight: 700;
+}
+
+.empty-note {
+ color: rgba(255, 248, 241, 0.76);
+ line-height: 1.6;
+}
+
+.floating-orb {
+ position: absolute;
+ border-radius: 999px;
+ filter: blur(6px);
+ opacity: 0.85;
+}
+
+.orb-one {
+ width: 118px;
+ height: 118px;
+ background: linear-gradient(145deg, rgba(243, 201, 107, 0.95), rgba(231, 111, 81, 0.6));
+ right: 12px;
+ top: 12px;
+}
+
+.orb-two {
+ width: 78px;
+ height: 78px;
+ background: linear-gradient(145deg, rgba(15, 118, 110, 0.9), rgba(255, 255, 255, 0.2));
+ left: -18px;
+ bottom: 44px;
+}
+
+.section-shell,
+.embed-section {
+ padding: 1.5rem 0 3.2rem;
+}
+
+.page-top-space {
+ padding-top: 2rem;
+}
+
+.section-soft {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.45), rgba(243, 201, 107, 0.06));
+}
+
+.section-heading {
+ margin-bottom: 1.5rem;
+}
+
+.section-title {
+ font-size: clamp(2rem, 3vw, 3.1rem);
+ color: var(--brand-ink);
+ margin-top: 0.9rem;
+ margin-bottom: 0.7rem;
+}
+
+.section-copy,
+.footer-copy,
+.event-summary,
+.form-help,
+.field-error,
+.text-link {
+ color: var(--brand-muted);
+}
+
+.calendar-card {
+ padding: 1.35rem;
+}
+
+.calendar-toolbar {
+ margin-bottom: 1.2rem;
+}
+
+.calendar-title {
+ font-size: 1.9rem;
+ color: var(--brand-ink);
+}
+
+.toolbar-label,
+.modal-label,
+.event-meta,
+.footer-title,
+.form-label,
+.detail-meta-grid span {
+ color: var(--brand-muted);
+ font-weight: 600;
+ letter-spacing: 0.01em;
+}
+
+.calendar-weekdays,
+.calendar-grid {
+ display: grid;
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+ gap: 0.85rem;
+}
+
+.calendar-weekdays {
+ margin-bottom: 0.85rem;
+}
+
+.calendar-weekdays span {
+ text-align: center;
+ padding: 0.35rem 0;
+ font-size: 0.88rem;
+ font-weight: 700;
+ color: var(--brand-muted);
+}
+
+.calendar-day {
+ min-height: 124px;
+ border-radius: 22px;
+ border: 1px solid rgba(18, 32, 35, 0.08);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(249, 241, 233, 0.9));
+ padding: 0.95rem;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ text-align: left;
+ gap: 0.75rem;
+ color: var(--brand-ink);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
+}
+
+.calendar-day:hover,
+.calendar-day:focus {
+ border-color: rgba(231, 111, 81, 0.3);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 1), rgba(255, 244, 235, 1));
+ outline: none;
+}
+
+.calendar-day-number {
+ font-family: 'Manrope', sans-serif;
+ font-size: 1.45rem;
+ font-weight: 800;
+}
+
+.calendar-day.is-muted {
+ opacity: 0.5;
+}
+
+.calendar-day.is-today {
+ border-color: rgba(15, 118, 110, 0.3);
+ box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.12);
+}
+
+.calendar-day.has-events {
+ background: linear-gradient(180deg, rgba(255, 245, 238, 1), rgba(255, 251, 244, 1));
+}
+
+.calendar-badge {
+ display: inline-flex;
+ align-items: center;
+ align-self: flex-start;
+ gap: 0.35rem;
+ padding: 0.45rem 0.7rem;
+ border-radius: 999px;
+ background: rgba(231, 111, 81, 0.12);
+ color: var(--brand-primary-dark);
+ font-size: 0.82rem;
+ font-weight: 700;
+}
+
+.calendar-badge-empty {
+ background: rgba(15, 118, 110, 0.08);
+ color: var(--brand-secondary);
+}
+
+.event-modal {
+ border: 0;
+ border-radius: 28px;
+ background: linear-gradient(180deg, rgba(255, 253, 249, 0.98), rgba(248, 239, 230, 0.96));
+ box-shadow: 0 40px 90px rgba(18, 32, 35, 0.2);
+}
+
+.event-modal .modal-body {
+ padding-bottom: 1.6rem;
+}
+
+.modal-event-card {
+ background: rgba(255, 255, 255, 0.76);
+ border: 1px solid rgba(18, 32, 35, 0.06);
+ border-radius: 22px;
+ padding: 1.15rem;
+ margin-bottom: 0.9rem;
+}
+
+.modal-event-card:last-child {
+ margin-bottom: 0;
+}
+
+.modal-event-card h4 {
+ margin-bottom: 0.45rem;
+ font-size: 1.2rem;
+}
+
+.modal-event-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.8rem;
+ color: var(--brand-muted);
+ font-size: 0.95rem;
+ margin-bottom: 0.7rem;
+}
+
+.embed-shell-card {
+ padding: 1.2rem;
+ margin: 1.2rem auto;
+ max-width: 1200px;
+}
+
+.embed-section .calendar-card {
+ background: transparent;
+ border: 0;
+ box-shadow: none;
+ padding: 0;
+}
+
+.event-card,
+.detail-card,
+.form-card,
+.dashboard-banner,
+.table-card,
+.embed-copy-card,
+.code-card,
+.empty-state {
+ padding: 1.5rem;
+}
+
+.event-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.8rem;
+}
+
+.event-date-chip,
+.status-pill {
+ display: inline-flex;
+ align-self: flex-start;
+ align-items: center;
+ justify-content: center;
+ padding: 0.45rem 0.8rem;
+ border-radius: 999px;
+ font-size: 0.82rem;
+ font-weight: 800;
+}
+
+.event-date-chip {
+ background: rgba(243, 201, 107, 0.24);
+ color: #8c6423;
+}
+
+.status-live {
+ background: rgba(15, 118, 110, 0.12);
+ color: var(--brand-secondary);
+}
+
+.status-draft {
+ background: rgba(93, 108, 112, 0.14);
+ color: var(--brand-muted);
+}
+
+.detail-meta-grid {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ margin: 1.5rem 0;
+}
+
+.detail-meta-grid strong {
+ display: block;
+ color: var(--brand-ink);
+ margin-top: 0.35rem;
+}
+
+.code-card {
+ background: linear-gradient(180deg, rgba(18, 32, 35, 0.96), rgba(18, 32, 35, 0.88));
+ color: #fff8f1;
+}
+
+.code-card-header strong {
+ color: #fff8f1;
+}
+
+.code-card .btn-ghost {
+ background: rgba(255, 255, 255, 0.08);
+ color: #fff8f1;
+ border-color: rgba(255, 255, 255, 0.12);
+}
+
+.code-snippet {
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin-top: 1rem;
+ padding: 1.1rem;
+ border-radius: 20px;
+ background: rgba(255, 255, 255, 0.06);
+ color: #fff2df;
+}
+
+.dashboard-banner {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1.25rem;
+}
+
+.dashboard-table th {
+ color: var(--brand-muted);
+ font-weight: 700;
+}
+
+.dashboard-table td,
+.dashboard-table th {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+ border-color: rgba(18, 32, 35, 0.06);
+ background: transparent;
+}
+
+input[type="text"],
+input[type="password"],
+input[type="url"],
+input[type="datetime-local"],
+textarea,
+.form-control,
+.form-check-input {
+ border-radius: 16px;
+ border-color: rgba(18, 32, 35, 0.12);
+ padding: 0.85rem 1rem;
+ background: rgba(255, 255, 255, 0.94);
+}
+
+.form-control:focus,
+.form-check-input:focus,
+.btn:focus,
+.nav-link:focus,
+.text-link:focus {
+ border-color: rgba(15, 118, 110, 0.4);
+ box-shadow: 0 0 0 0.25rem rgba(15, 118, 110, 0.14);
+}
+
+.form-check-input {
+ width: 1.2rem;
+ height: 1.2rem;
+}
+
+.card-check {
+ background: rgba(243, 201, 107, 0.08);
+ border: 1px solid rgba(243, 201, 107, 0.32);
+ border-radius: 18px;
+ padding: 1rem;
+}
+
+.form-check-label {
+ margin-left: 0.45rem;
+ font-weight: 700;
+}
+
+.field-error {
+ margin-top: 0.4rem;
+ color: #b6412b;
+ font-size: 0.92rem;
+ font-weight: 600;
+}
+
+.text-link {
+ text-decoration: none;
+ font-weight: 700;
+}
+
+.site-footer {
+ padding: 0.4rem 0 2rem;
+}
+
+.footer-title {
+ color: var(--brand-ink);
+ font-weight: 800;
+}
+
+.footer-links a {
+ color: var(--brand-muted);
+ text-decoration: none;
+ font-weight: 600;
+}
+
+@media (max-width: 991.98px) {
+ .hero-card {
+ grid-template-columns: 1fr;
+ min-height: auto;
+ }
+
+ .dashboard-banner {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .calendar-weekdays,
+ .calendar-grid {
+ gap: 0.55rem;
+ }
+}
+
+@media (max-width: 767.98px) {
+ .navbar {
+ border-radius: 28px;
+ }
+
+ .hero-copy h1,
+ .section-title {
+ max-width: none;
+ }
+
+ .calendar-weekdays {
+ display: none;
+ }
+
+ .calendar-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .calendar-day {
+ min-height: 112px;
+ }
+
+ .hero-section,
+ .section-shell,
+ .embed-section {
+ padding-bottom: 2.3rem;
+ }
+}
diff --git a/staticfiles/js/calendar.js b/staticfiles/js/calendar.js
new file mode 100644
index 0000000..0d7c6c2
--- /dev/null
+++ b/staticfiles/js/calendar.js
@@ -0,0 +1,106 @@
+document.addEventListener('DOMContentLoaded', () => {
+ const dataElement = document.getElementById('calendar-events-data');
+ const modalElement = document.getElementById('dayEventsModal');
+ let calendarEvents = {};
+
+ const escapeHtml = (value) => String(value || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+ const safeExternalUrl = (value) => {
+ if (!value) return '';
+ try {
+ const url = new URL(value, window.location.origin);
+ return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
+ } catch (error) {
+ return '';
+ }
+ };
+
+ if (dataElement) {
+ try {
+ calendarEvents = JSON.parse(dataElement.textContent);
+ } catch (error) {
+ calendarEvents = {};
+ }
+ }
+
+ if (modalElement && window.bootstrap) {
+ const modal = new window.bootstrap.Modal(modalElement);
+ const titleNode = modalElement.querySelector('[data-calendar-title]');
+ const bodyNode = modalElement.querySelector('[data-calendar-body]');
+
+ const renderEvents = (isoDate) => {
+ const selectedEvents = calendarEvents[isoDate] || [];
+ const titleDate = new Date(`${isoDate}T12:00:00`);
+ titleNode.textContent = titleDate.toLocaleDateString(undefined, {
+ weekday: 'long',
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+ });
+
+ if (!selectedEvents.length) {
+ bodyNode.innerHTML = `
+
+
No event scheduled
+
This date is currently open. Check another day or revisit after the next update.
+
+ `;
+ return;
+ }
+
+ bodyNode.innerHTML = selectedEvents
+ .map((event) => {
+ const name = escapeHtml(event.name);
+ const time = escapeHtml(event.time);
+ const location = escapeHtml(event.location || 'Location announced soon');
+ const summary = event.summary ? `${escapeHtml(event.summary)}
` : '';
+ const detailUrl = escapeHtml(event.detail_url || '#');
+ const eventUrl = safeExternalUrl(event.event_url);
+ return `
+
+ ${name}
+
+ ${time}
+ ${location}
+
+ ${summary}
+
+
+ `;
+ })
+ .join('');
+ };
+
+ document.querySelectorAll('[data-calendar-date]').forEach((button) => {
+ button.addEventListener('click', () => {
+ renderEvents(button.dataset.calendarDate);
+ modal.show();
+ });
+ });
+ }
+
+ document.querySelectorAll('[data-copy-snippet]').forEach((button) => {
+ button.addEventListener('click', async () => {
+ const target = document.querySelector(button.dataset.copyTarget);
+ if (!target) return;
+ try {
+ await navigator.clipboard.writeText(target.textContent.trim());
+ const original = button.textContent;
+ button.textContent = 'Copied';
+ window.setTimeout(() => {
+ button.textContent = original;
+ }, 1600);
+ } catch (error) {
+ button.textContent = 'Copy manually';
+ }
+ });
+ });
+});