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 %} + + {% 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.

+ +
+
+ Starts + {{ event.start|date:"M j, Y g:i A" }} +
+
+ Ends + {{ event.end|date:"M j, Y g:i A" }} +
+
+ Location + {{ event.location|default:"Location announced soon" }} +
+
+ +
+ {% csrf_token %} +
+ + Keep event +
+
+
+
+
+
+
+{% 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.

+
+
+
+ + + + + + + + + + + + {% for event in events %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
EventDateLocationStatusActions
+ {{ 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 %} + +
+ Review + Edit + Delete +
+
+
+

No events yet

+

Create your first event to populate the public calendar and widget.

+ Create event +
+
+
+
+
+
+{% 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 %} +
+
+
+ Starts + {{ event.start|date:"M j, Y g:i A" }} +
+
+ Ends + {{ event.end|date:"M j, Y g:i A" }} +
+
+ Location + {{ event.location|default:"Location announced soon" }} +
+
+
+

{{ 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 }}

+
+
+ Date + {{ event.start|date:"l, F j, Y" }} +
+
+ Time + {{ event.start|date:"g:i A" }} – {{ event.end|date:"g:i A" }} +
+
+ Location + {{ event.location|default:"Location announced soon" }} +
+
+
+

{{ event.summary|default:"More information about this event will be shared soon." }}

+
+
+ {% if event.event_url %} + Visit event page + {% endif %} + Back to calendar month + Back to event list +
+
+
+
+
+
+{% 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 %} +
+
+
+
+
+
+ {% if form_mode == 'edit' %}Edit event{% else %}Create event{% endif %} +

{{ form_title }}

+

{{ form_intro }}

+
+
+ {% csrf_token %} +
+ {% for field in form %} +
+ {% if field.name == 'is_published' %} +
+ {{ field }} + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} +
+ {% else %} + + {{ field }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {% endif %} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} +
+ {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} +
+ + Cancel +
+
+
+
+
+
+
+{% 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 }}

+
+ View details + {% if event.event_url %} + External event page + {% endif %} +
+
+
+ {% 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 }}

+
+
+ Previous + Today + Next +
+
+
+
+ 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. +
+
+
+
+
+
+
+
+

Next live dates

+ {% if hero_events %} +
    + {% for event in hero_events %} +
  • + {{ event.start|date:"M d" }} +
    + {{ event.name }} + {{ event.location|default:"Location announced soon" }} +
    +
  • + {% endfor %} +
+ {% else %} +
Add your first event from the staff dashboard to see it appear here.
+ {% endif %} +
+
-

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

+
+ Details + {% if event.event_url %} + Event page + {% endif %} +
+
+
+ {% 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.

+ +
+
+
+
+
+ Embed snippet + +
+
{{ 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 %} +
+
+
+
+
+
+ Staff only +

Secure sign in

+

Use your staff credentials to add or manage events. Visitors cannot modify the calendar.

+
+
+ {% csrf_token %} + {% for field in form %} +
+ + {{ field }} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} + + +
+
+
+
+
+
+{% 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 = ` + + `; + 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 ` + + `; + }) + .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 = ` + + `; + 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 ` + + `; + }) + .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'; + } + }); + }); +});