Compare commits

...

16 Commits

Author SHA1 Message Date
Flatlogic Bot
d88fd824f6 added recurring events 2026-04-25 19:45:55 +00:00
Flatlogic Bot
8a0f5ac78a Autosave: 20260425-194338 2026-04-25 19:43:38 +00:00
Flatlogic Bot
fb55c53d9a bigger logo 2026-04-05 22:06:14 +00:00
Flatlogic Bot
c03a0d6158 larger logo remove text 2026-04-05 22:04:36 +00:00
Flatlogic Bot
c251c57396 logo 25% larger 2026-04-05 21:39:30 +00:00
Flatlogic Bot
d7cbee6e18 logo smaller 2026-04-05 21:38:02 +00:00
Flatlogic Bot
803f8c3d0a logo v3 2026-04-05 21:36:11 +00:00
Flatlogic Bot
be54b5eed6 logo v2 2026-04-05 21:33:25 +00:00
Flatlogic Bot
fae06d0190 logo v1 2026-04-05 21:31:09 +00:00
Flatlogic Bot
1a4a7390f2 fix adding events 2026-04-02 21:22:55 +00:00
Flatlogic Bot
242b727260 v5 2026-04-02 20:58:36 +00:00
Flatlogic Bot
1ea2df1292 v4 2026-04-02 17:55:43 +00:00
Flatlogic Bot
8443b777b1 v3 with import/export 2026-04-02 16:25:16 +00:00
Flatlogic Bot
376d533341 Autosave: 20260402-161641 2026-04-02 16:16:41 +00:00
Flatlogic Bot
25c22e7107 v2 with admin changes 2026-04-02 16:08:07 +00:00
Flatlogic Bot
693107e079 v1 2026-04-02 16:02:38 +00:00
38 changed files with 4161 additions and 245 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

@ -15,38 +15,32 @@ import os
from dotenv import load_dotenv from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent 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") SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'change-me')
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true" DEBUG = os.getenv('DJANGO_DEBUG', 'true').lower() == 'true'
ALLOWED_HOSTS = [ ALLOWED_HOSTS = [
"127.0.0.1", '127.0.0.1',
"localhost", 'localhost',
os.getenv("HOST_FQDN", ""), os.getenv('HOST_FQDN', ''),
] ]
CSRF_TRUSTED_ORIGINS = [ CSRF_TRUSTED_ORIGINS = [
origin for origin in [ origin for origin in [
os.getenv("HOST_FQDN", ""), os.getenv('HOST_FQDN', ''),
os.getenv("CSRF_TRUSTED_ORIGIN", "") os.getenv('CSRF_TRUSTED_ORIGIN', '')
] if origin ] if origin
] ]
CSRF_TRUSTED_ORIGINS = [ 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 for host in CSRF_TRUSTED_ORIGINS
] ]
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "None" SESSION_COOKIE_SAMESITE = 'None'
CSRF_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
INSTALLED_APPS = [ INSTALLED_APPS = [
'django.contrib.admin', 'django.contrib.admin',
@ -65,8 +59,6 @@ MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
] ]
X_FRAME_OPTIONS = 'ALLOWALL' X_FRAME_OPTIONS = 'ALLOWALL'
@ -83,7 +75,6 @@ TEMPLATES = [
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', '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', 'core.context_processors.project_context',
], ],
}, },
@ -92,10 +83,6 @@ TEMPLATES = [
WSGI_APPLICATION = 'config.wsgi.application' WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.mysql', '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 = [ AUTH_PASSWORD_VALIDATORS = [
{ {
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', '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' LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC' TIME_ZONE = 'UTC'
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/' STATIC_URL = 'static/'
# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS.
STATIC_ROOT = BASE_DIR / 'staticfiles' STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [ STATICFILES_DIRS = [
BASE_DIR / 'static', BASE_DIR / 'static',
BASE_DIR / 'assets', BASE_DIR / 'assets',
BASE_DIR / 'node_modules', BASE_DIR / 'node_modules',
] ]
# Email
EMAIL_BACKEND = os.getenv( EMAIL_BACKEND = os.getenv(
"EMAIL_BACKEND", 'EMAIL_BACKEND',
"django.core.mail.backends.smtp.EmailBackend" 'django.core.mail.backends.smtp.EmailBackend'
) )
EMAIL_HOST = os.getenv("EMAIL_HOST", "127.0.0.1") EMAIL_HOST = os.getenv('EMAIL_HOST', '127.0.0.1')
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587")) EMAIL_PORT = int(os.getenv('EMAIL_PORT', '587'))
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '')
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '')
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true" EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'true').lower() == 'true'
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").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") DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'no-reply@example.com')
CONTACT_EMAIL_TO = [ CONTACT_EMAIL_TO = [
item.strip() 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() if item.strip()
] ]
# When both TLS and SSL flags are enabled, prefer SSL explicitly
if EMAIL_USE_SSL: if EMAIL_USE_SSL:
EMAIL_USE_TLS = False 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' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
LOGIN_REDIRECT_URL = '/dashboard/events/'
LOGOUT_REDIRECT_URL = '/'

Binary file not shown.

Binary file not shown.

View File

@ -1,3 +1,16 @@
from django.contrib import admin 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',)

105
core/forms.py Normal file
View File

@ -0,0 +1,105 @@
from datetime import timedelta
from django import forms
from .models import Event
WEEKDAY_CHOICES = [
('0', 'Monday'),
('1', 'Tuesday'),
('2', 'Wednesday'),
('3', 'Thursday'),
('4', 'Friday'),
('5', 'Saturday'),
('6', 'Sunday'),
]
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',
),
)
recurrence_start_date = forms.DateField(
required=False,
label='Repeat from',
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
help_text='Optional: first date in the weekly recurrence window.',
)
recurrence_end_date = forms.DateField(
required=False,
label='Repeat until',
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
help_text='Optional: last date in the weekly recurrence window.',
)
recurrence_weekday = forms.ChoiceField(
required=False,
label='Weekday',
choices=[('', 'Select a weekday')] + WEEKDAY_CHOICES,
widget=forms.Select(attrs={'class': 'form-select'}),
help_text='Optional: create one event each week on this weekday.',
)
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.')
recurrence_start_date = cleaned_data.get('recurrence_start_date')
recurrence_end_date = cleaned_data.get('recurrence_end_date')
recurrence_weekday = cleaned_data.get('recurrence_weekday')
recurrence_requested = any([recurrence_start_date, recurrence_end_date, recurrence_weekday])
if recurrence_requested:
if not recurrence_start_date:
self.add_error('recurrence_start_date', 'Enter the first date for the recurring series.')
if not recurrence_end_date:
self.add_error('recurrence_end_date', 'Enter the last date for the recurring series.')
if not recurrence_weekday:
self.add_error('recurrence_weekday', 'Choose which weekday should repeat each week.')
if recurrence_start_date and recurrence_end_date and recurrence_end_date < recurrence_start_date:
self.add_error('recurrence_end_date', 'The recurring series must end on or after the start date.')
if recurrence_start_date and recurrence_end_date and recurrence_weekday:
weekday_index = int(recurrence_weekday)
days_until_first = (weekday_index - recurrence_start_date.weekday()) % 7
first_occurrence = recurrence_start_date + timedelta(days=days_until_first)
if first_occurrence > recurrence_end_date:
self.add_error('recurrence_weekday', 'No selected weekday falls inside that recurrence date range.')
return cleaned_data

View File

@ -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'],
},
),
]

View File

@ -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),
]

View File

@ -1,3 +1,40 @@
from django.core.exceptions import ValidationError
from django.db import models 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.start is not None and self.end is not None and 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)

View File

@ -1,25 +1,88 @@
{% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{% block title %}Knowledge Base{% endblock %}</title> <meta name="viewport" content="width=device-width, initial-scale=1">
{% if project_description %} <title>{% block title %}{{ page_title|default:project_name }}{% endblock %}</title>
<meta name="description" content="{{ project_description }}"> <meta name="description" content="{% block meta_description %}{{ meta_description|default:project_description|default:'Secure business event calendar with a polished public schedule and private admin tools.' }}{% endblock %}">
<meta property="og:description" content="{{ project_description }}"> {% if noindex %}
<meta property="twitter:description" content="{{ project_description }}"> <meta name="robots" content="noindex, nofollow">
{% endif %} {% endif %}
{% if project_image_url %} {% if project_image_url %}
<meta property="og:image" content="{{ project_image_url }}"> <meta property="og:image" content="{{ project_image_url }}">
<meta property="twitter:image" content="{{ project_image_url }}"> <meta property="twitter:image" content="{{ project_image_url }}">
{% endif %} {% endif %}
{% load static %} <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@500;700;800&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}"> <link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body class="{% if embedded %}embedded-shell{% else %}app-shell{% endif %}">
{% if not embedded %}
<div class="page-glow page-glow-one"></div>
<div class="page-glow page-glow-two"></div>
<header class="site-header">
<div class="container">
<nav class="navbar navbar-expand-lg py-3">
<a class="navbar-brand brand-mark d-flex align-items-center gap-3" href="{% url 'home' %}">
<img src="{% static 'images/just-roasted-coffee-logo.png' %}" alt="Just Roasted Coffee logo" class="brand-logo" width="166" height="52">
<span class="brand-label">Calendar</span>
</a>
<button class="navbar-toggler calendar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#siteNav" aria-controls="siteNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="siteNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
<li class="nav-item"><a class="nav-link" href="{% url 'calendar_page' %}">Calendar</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'event_list' %}">Events</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'calendar_embed' %}">Embed Widget</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/">Admin</a></li>
{% if request.user.is_authenticated %}
<li class="nav-item"><a class="btn btn-ghost ms-lg-2" href="{% url 'event_dashboard' %}">Dashboard</a></li>
<li class="nav-item"><a class="btn btn-primary-brand ms-lg-2" href="{% url 'logout' %}">Log out</a></li>
{% else %}
<li class="nav-item"><a class="btn btn-primary-brand ms-lg-2" href="{% url 'login' %}">Staff login</a></li>
{% endif %}
</ul>
</div>
</nav>
</div>
</header>
{% endif %}
{% if messages and not embedded %}
<div class="container">
<div class="message-stack">
{% for message in messages %}
<div class="alert alert-success shadow-sm border-0" role="alert">{{ message }}</div>
{% endfor %}
</div>
</div>
{% endif %}
<body>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</body>
{% if not embedded %}
<footer class="site-footer">
<div class="container d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center">
<div>
<p class="footer-title mb-1">Secure schedule publishing for your website.</p>
<p class="footer-copy mb-0">Publish dates publicly, manage them privately, and keep your audience informed.</p>
</div>
<div class="footer-links d-flex gap-3 flex-wrap">
<a href="{% url 'calendar_page' %}">View calendar</a>
<a href="{% url 'calendar_embed' %}">Embed widget</a>
<a href="/admin/">Admin</a>
</div>
</div>
</footer>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="{% static 'js/calendar.js' %}?v={{ deployment_timestamp }}" defer></script>
{% block scripts %}{% endblock %}
</body>
</html> </html>

View File

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Embedded Calendar{% endblock %}
{% block content %}
<section class="embed-section embed-section-wide">
<div class="embed-container">
<div class="embed-shell-card {% if not show_embed_header %}embed-shell-card-compact{% endif %}">
{% if show_embed_header %}
<div class="embed-header d-flex flex-column flex-md-row justify-content-between gap-3 align-items-md-center">
<div>
<img src="{% static 'images/just-roasted-coffee-logo.png' %}" alt="Just Roasted Coffee logo" class="embed-logo mb-3 d-block" width="160" height="48">
<h1 class="section-title mb-2">Where to find us</h1>
<p class="section-copy mb-0">Tap a date to see the event name, time window, and event link.</p>
</div>
<a class="btn btn-ghost" href="{% url 'calendar_page' %}" target="_blank" rel="noopener">Open full page</a>
</div>
{% endif %}
{% include "core/includes/calendar_widget.html" with calendar_variant="embed" %}
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Public Calendar | Roadshow Calendar{% endblock %}
{% block content %}
<section class="section-shell page-top-space">
<div class="container">
<div class="section-heading d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-end">
<div>
<span class="eyebrow-pill">Live schedule</span>
<h1 class="section-title">See where the business will be on any day</h1>
<p class="section-copy">This full-page calendar mirrors the embeddable widget and stays read-only for visitors.</p>
</div>
<div class="compact-actions d-flex gap-2">
<a class="btn btn-ghost" href="{% url 'calendar_embed' %}">Open embeddable view</a>
<a class="btn btn-primary-brand" href="{% url 'event_list' %}">Event directory</a>
</div>
</div>
{% include "core/includes/calendar_widget.html" with calendar_variant="full" %}
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Delete {{ event.name }} | Roadshow Calendar{% endblock %}
{% block content %}
<section class="section-shell page-top-space">
<div class="container">
<div class="row justify-content-center">
<div class="col-xl-7">
<div class="card-surface detail-card">
<span class="eyebrow-pill">Delete event</span>
<h1 class="section-title mt-3">Remove {{ event.name }}?</h1>
<p class="section-copy">This removes the event from the custom dashboard, the public calendar, and the embed widget. This action cannot be undone.</p>
<div class="detail-meta-grid">
<div>
<span>Starts</span>
<strong>{{ event.start|date:"M j, Y g:i A" }}</strong>
</div>
<div>
<span>Ends</span>
<strong>{{ event.end|date:"M j, Y g:i A" }}</strong>
</div>
<div>
<span>Location</span>
<strong>{{ event.location|default:"Location announced soon" }}</strong>
</div>
</div>
<form method="post" class="mt-4">
{% csrf_token %}
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-primary-brand" type="submit">Yes, delete event</button>
<a class="btn btn-ghost" href="{% url 'event_dashboard_detail' event.slug %}">Keep event</a>
</div>
</form>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,268 @@
{% extends "base.html" %}
{% block title %}Manage Events | Roadshow Calendar{% endblock %}
{% block content %}
<section class="section-shell page-top-space">
<div class="container">
<div class="dashboard-banner card-surface mb-4">
<div>
<span class="eyebrow-pill">Staff workspace</span>
<h1 class="section-title mt-3 mb-2">Manage your public event calendar securely</h1>
<p class="section-copy mb-0">Only staff members can reach this area. Create events here or jump into Django Admin for bulk edits.</p>
</div>
<div class="dashboard-banner-actions d-flex gap-2 flex-wrap">
<a class="btn btn-primary-brand" href="{% url 'event_create' %}">Add event</a>
<a class="btn btn-ghost" href="/admin/">Open admin</a>
</div>
</div>
<div class="row g-4 mb-4">
<div class="col-xl-6">
<div class="card-surface h-100">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center">
<div>
<span class="eyebrow-pill">Backup</span>
<h2 class="h4 mt-3 mb-2">Download a full event backup</h2>
<p class="section-copy mb-0">Export every event into one JSON file so you can keep an offline backup before larger edits or restore the calendar later if needed.</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-primary-brand" href="{% url 'event_export' %}">Download backup JSON</a>
</div>
</div>
</div>
</div>
<div class="col-xl-6">
<div class="card-surface h-100">
<span class="eyebrow-pill">Restore</span>
<h2 class="h4 mt-3 mb-2">Import a previous backup</h2>
<p class="section-copy">Upload an exported JSON file, review the preview, then confirm before anything in the live calendar is replaced.</p>
<form method="post" action="{% url 'event_import_preview' %}" enctype="multipart/form-data" class="row g-3">
{% csrf_token %}
<div class="col-12">
<label class="form-label" for="backupFile">Backup JSON file</label>
<input class="form-control" id="backupFile" type="file" name="backup_file" accept="application/json,.json" required>
<div class="form-text">Uploading only prepares a preview. The actual restore is a separate confirmation step.</div>
</div>
<div class="col-12 d-flex gap-2 flex-wrap align-items-center">
<button class="btn btn-primary-brand" type="submit">Preview restore</button>
<span class="small text-muted">Recommended flow: export a fresh backup first, then test your restore file.</span>
</div>
</form>
</div>
</div>
</div>
{% if pending_import %}
<div class="card-surface mb-4">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-start mb-4">
<div>
<span class="eyebrow-pill">Restore preview</span>
<h2 class="h4 mt-3 mb-2">Ready to replace the current calendar</h2>
<p class="section-copy mb-0">This backup contains {{ pending_import.event_count }} event{{ pending_import.event_count|pluralize }}. If you confirm, Django will replace the current {{ dashboard_count }} live item{{ dashboard_count|pluralize }} in one transaction.</p>
</div>
<form method="post" action="{% url 'event_import_restore' %}" class="d-grid">
{% csrf_token %}
<button class="btn btn-danger" type="submit">Confirm restore and replace events</button>
</form>
</div>
<div class="row g-3 mb-4">
<div class="col-sm-6 col-xl-3">
<div class="border rounded-4 p-3 h-100 bg-white bg-opacity-50">
<small class="text-uppercase text-muted d-block mb-2">Backup events</small>
<strong class="d-block fs-4">{{ pending_import.event_count }}</strong>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="border rounded-4 p-3 h-100 bg-white bg-opacity-50">
<small class="text-uppercase text-muted d-block mb-2">Published</small>
<strong class="d-block fs-4">{{ pending_import.published_count }}</strong>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="border rounded-4 p-3 h-100 bg-white bg-opacity-50">
<small class="text-uppercase text-muted d-block mb-2">Drafts</small>
<strong class="d-block fs-4">{{ pending_import.draft_count }}</strong>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="border rounded-4 p-3 h-100 bg-white bg-opacity-50">
<small class="text-uppercase text-muted d-block mb-2">Exported</small>
<strong class="d-block">{{ pending_import.exported_at_display }}</strong>
</div>
</div>
</div>
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<h3 class="h5 mb-3">First events in this backup</h3>
<div class="list-group">
{% for event in pending_import.preview_events %}
<div class="list-group-item border rounded-4 mb-2">
<div class="d-flex flex-column flex-md-row justify-content-between gap-2">
<div>
<strong>{{ event.name }}</strong>
<div class="small text-muted">{{ event.start|date:"M j, Y g:i A" }} {{ event.end|date:"M j, Y g:i A" }}</div>
</div>
<div class="text-md-end small">
<div>{{ event.location|default:"—" }}</div>
<div>{% if event.is_published %}Published{% else %}Draft{% endif %}</div>
</div>
</div>
</div>
{% empty %}
<p class="section-copy mb-0">This backup has no events. Confirming the restore would clear the current calendar.</p>
{% endfor %}
</div>
{% if pending_import.remaining_count %}
<p class="small text-muted mt-2 mb-0">Plus {{ pending_import.remaining_count }} more event{{ pending_import.remaining_count|pluralize }} in the same backup.</p>
{% endif %}
</div>
<div class="col-lg-5">
<h3 class="h5 mb-3">Restore source</h3>
<dl class="row mb-3">
<dt class="col-sm-4">Project</dt>
<dd class="col-sm-8">{{ pending_import.project }}</dd>
<dt class="col-sm-4">Version</dt>
<dd class="col-sm-8">{{ pending_import.version }}</dd>
<dt class="col-sm-4">Action</dt>
<dd class="col-sm-8">Replace all current events</dd>
</dl>
<p class="small text-muted mb-0">Safety note: the restore deletes the current event table contents and recreates them from this uploaded backup inside one database transaction.</p>
</div>
</div>
</div>
{% endif %}
<div class="row g-4 mb-4">
<div class="col-xl-5">
<div class="card-surface embed-settings-card h-100">
<span class="eyebrow-pill">Embed settings</span>
<h2 class="h4 mt-3 mb-2">Generate a copy-ready widget snippet</h2>
<p class="section-copy">Tune the iframe once, copy the finished code, and paste it into your existing HTML site.</p>
<form class="embed-settings-form" data-embed-settings data-embed-base-url="{{ embed_base_url }}">
<div class="row g-3">
<div class="col-sm-6">
<label class="form-label" for="embedTitle">Iframe title</label>
<input class="form-control" id="embedTitle" name="title" type="text" value="Where to find us calendar">
</div>
<div class="col-sm-6">
<label class="form-label" for="embedMonth">Starting month</label>
<input class="form-control" id="embedMonth" name="month" type="month" value="{{ default_embed_month }}">
</div>
<div class="col-sm-6">
<label class="form-label" for="embedWidth">Width</label>
<input class="form-control" id="embedWidth" name="width" type="text" value="100%">
</div>
<div class="col-sm-6">
<label class="form-label" for="embedHeight">Height (px)</label>
<input class="form-control" id="embedHeight" name="height" type="number" min="360" step="10" value="760">
</div>
<div class="col-sm-6">
<label class="form-label" for="embedRadius">Corner radius (px)</label>
<input class="form-control" id="embedRadius" name="radius" type="number" min="0" step="2" value="24">
</div>
<div class="col-sm-6">
<label class="form-label" for="embedHeader">Widget header</label>
<select class="form-select" id="embedHeader" name="header">
<option value="1" selected>Show header and action</option>
<option value="0">Hide header for a tighter embed</option>
</select>
</div>
</div>
</form>
</div>
</div>
<div class="col-xl-7">
<div class="card-surface code-card h-100">
<div class="code-card-header d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center">
<div>
<strong>Live embed output</strong>
<p class="mb-0 text-white-50 small">Copy either the direct widget URL or the full iframe snippet.</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-sm btn-primary-brand" href="{{ embed_base_url }}" target="_blank" rel="noopener" data-embed-preview>Preview widget</a>
<button class="btn btn-sm btn-ghost" type="button" data-copy-snippet data-copy-target="#dashboardEmbedUrl">Copy URL</button>
<button class="btn btn-sm btn-ghost" type="button" data-copy-snippet data-copy-target="#dashboardIframeSnippet">Copy iframe</button>
</div>
</div>
<div class="embed-output-stack mt-4">
<div>
<small class="text-uppercase text-white-50 d-block mb-2">Widget URL</small>
<pre id="dashboardEmbedUrl" class="code-snippet mb-0">{{ embed_base_url }}</pre>
</div>
<div>
<small class="text-uppercase text-white-50 d-block mb-2">Iframe snippet</small>
<pre id="dashboardIframeSnippet" class="code-snippet mb-0">&lt;iframe src="{{ embed_base_url }}" title="Where to find us calendar" width="100%" height="760" style="border:0;border-radius:24px;overflow:hidden;" loading="lazy"&gt;&lt;/iframe&gt;</pre>
</div>
</div>
</div>
</div>
</div>
<div class="table-card card-surface">
<div class="d-flex flex-column flex-md-row justify-content-between gap-3 align-items-md-center mb-3">
<div>
<h2 class="h4 mb-1">All events</h2>
<p class="section-copy mb-0">{{ dashboard_count }} scheduled item{{ dashboard_count|pluralize }} currently in the system.</p>
</div>
</div>
<div class="table-responsive">
<table class="table align-middle mb-0 dashboard-table">
<thead>
<tr>
<th>Event</th>
<th>Date</th>
<th>Location</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for event in events %}
<tr>
<td>
<strong>{{ event.name }}</strong><br>
<small>{{ event.start|date:"g:i A" }} {{ event.end|date:"g:i A" }}</small>
</td>
<td>{{ event.start|date:"M j, Y" }}</td>
<td>{{ event.location|default:"—" }}</td>
<td>
{% if event.is_published %}
<span class="status-pill status-live">Published</span>
{% else %}
<span class="status-pill status-draft">Draft</span>
{% endif %}
</td>
<td>
<div class="d-flex gap-2 flex-wrap justify-content-end">
<a class="btn btn-sm btn-ghost" href="{% url 'event_dashboard_detail' event.slug %}">Review</a>
<a class="btn btn-sm btn-ghost" href="{% url 'event_edit' event.slug %}">Edit</a>
<a class="btn btn-sm btn-ghost" href="{% url 'event_delete' event.slug %}">Delete</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5">
<div class="empty-state text-center py-5">
<h3 class="h5">No events yet</h3>
<p class="mb-3">Create your first event to populate the public calendar and widget.</p>
<a class="btn btn-primary-brand" href="{% url 'event_create' %}">Create event</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}{{ event.name }} | Staff Review{% endblock %}
{% block content %}
<section class="section-shell page-top-space">
<div class="container">
<div class="row justify-content-center">
<div class="col-xl-8">
<article class="detail-card card-surface">
<div class="d-flex flex-column flex-md-row justify-content-between gap-3 align-items-md-start">
<div>
<span class="eyebrow-pill">Saved successfully</span>
<h1 class="section-title mt-3">{{ event.name }}</h1>
</div>
{% if event.is_published %}
<span class="status-pill status-live">Live on the public calendar</span>
{% else %}
<span class="status-pill status-draft">Saved as draft</span>
{% endif %}
</div>
<div class="detail-meta-grid">
<div>
<span>Starts</span>
<strong>{{ event.start|date:"M j, Y g:i A" }}</strong>
</div>
<div>
<span>Ends</span>
<strong>{{ event.end|date:"M j, Y g:i A" }}</strong>
</div>
<div>
<span>Location</span>
<strong>{{ event.location|default:"Location announced soon" }}</strong>
</div>
</div>
<div class="detail-body">
<p>{{ event.summary|default:"No additional staff notes yet." }}</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-primary-brand" href="{% url 'event_dashboard' %}">Back to dashboard</a>
<a class="btn btn-ghost" href="{% url 'event_edit' event.slug %}">Edit in dashboard</a>
<a class="btn btn-ghost" href="{% url 'event_delete' event.slug %}">Delete event</a>
<a class="btn btn-ghost" href="/admin/core/event/{{ event.id }}/change/">Open in admin</a>
<a class="btn btn-ghost" href="{% url 'event_detail' event.slug %}">View public page</a>
</div>
</article>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}{{ event.name }} | Roadshow Calendar{% endblock %}
{% block content %}
<section class="section-shell page-top-space">
<div class="container">
<div class="row justify-content-center">
<div class="col-xl-8">
<article class="detail-card card-surface">
<span class="eyebrow-pill">Event detail</span>
<h1 class="section-title mt-3">{{ event.name }}</h1>
<div class="detail-meta-grid">
<div>
<span>Date</span>
<strong>{{ event.start|date:"l, F j, Y" }}</strong>
</div>
<div>
<span>Time</span>
<strong>{{ event.start|date:"g:i A" }} {{ event.end|date:"g:i A" }}</strong>
</div>
<div>
<span>Location</span>
<strong>{{ event.location|default:"Location announced soon" }}</strong>
</div>
</div>
<div class="detail-body">
<p>{{ event.summary|default:"More information about this event will be shared soon." }}</p>
</div>
<div class="d-flex flex-wrap gap-2">
{% if event.event_url %}
<a class="btn btn-primary-brand" href="{{ event.event_url }}" target="_blank" rel="noopener">Visit event page</a>
{% endif %}
<a class="btn btn-ghost" href="{% url 'calendar_page' %}?month={{ event.start|date:'Y-m' }}">Back to calendar month</a>
<a class="btn btn-ghost" href="{% url 'event_list' %}">Back to event list</a>
</div>
</article>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,107 @@
{% extends "base.html" %}
{% block title %}{{ page_title }} | Roadshow Calendar{% endblock %}
{% block content %}
<section class="section-shell page-top-space">
<div class="container">
<div class="row justify-content-center">
<div class="col-xl-8">
<div class="card-surface form-card">
<div class="section-heading mb-4">
<span class="eyebrow-pill">{% if form_mode == 'edit' %}Edit event{% else %}Create event{% endif %}</span>
<h1 class="section-title">{{ form_title }}</h1>
<p class="section-copy">{{ form_intro }}</p>
</div>
<form method="post" novalidate>
{% csrf_token %}
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="{{ form.name.id_for_label }}">{{ form.name.label }}</label>
{{ form.name }}
{% if form.name.help_text %}<div class="form-help">{{ form.name.help_text }}</div>{% endif %}
{% for error in form.name.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-md-6">
<label class="form-label" for="{{ form.location.id_for_label }}">{{ form.location.label }}</label>
{{ form.location }}
{% if form.location.help_text %}<div class="form-help">{{ form.location.help_text }}</div>{% endif %}
{% for error in form.location.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-md-6">
<label class="form-label" for="{{ form.start.id_for_label }}">{{ form.start.label }}</label>
{{ form.start }}
{% if form.start.help_text %}<div class="form-help">{{ form.start.help_text }}</div>{% endif %}
{% for error in form.start.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-md-6">
<label class="form-label" for="{{ form.end.id_for_label }}">{{ form.end.label }}</label>
{{ form.end }}
{% if form.end.help_text %}<div class="form-help">{{ form.end.help_text }}</div>{% endif %}
{% for error in form.end.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-md-6">
<label class="form-label" for="{{ form.event_url.id_for_label }}">{{ form.event_url.label }}</label>
{{ form.event_url }}
{% if form.event_url.help_text %}<div class="form-help">{{ form.event_url.help_text }}</div>{% endif %}
{% for error in form.event_url.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-12">
<label class="form-label" for="{{ form.summary.id_for_label }}">{{ form.summary.label }}</label>
{{ form.summary }}
{% if form.summary.help_text %}<div class="form-help">{{ form.summary.help_text }}</div>{% endif %}
{% for error in form.summary.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-12">
<div class="form-check card-check">
{{ form.is_published }}
<label class="form-check-label" for="{{ form.is_published.id_for_label }}">Publish this event publicly</label>
{% if form.is_published.help_text %}<div class="form-help">{{ form.is_published.help_text }}</div>{% endif %}
</div>
{% for error in form.is_published.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
</div>
{% if form_mode == 'create' %}
<div class="mt-4 pt-4 border-top">
<div class="section-heading mb-3">
<h2 class="h4 mb-2">Repeat weekly (optional)</h2>
<p class="section-copy mb-0">Use the same event details and time range to create one event per week within a date window.</p>
</div>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="{{ form.recurrence_start_date.id_for_label }}">{{ form.recurrence_start_date.label }}</label>
{{ form.recurrence_start_date }}
{% if form.recurrence_start_date.help_text %}<div class="form-help">{{ form.recurrence_start_date.help_text }}</div>{% endif %}
{% for error in form.recurrence_start_date.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-md-4">
<label class="form-label" for="{{ form.recurrence_end_date.id_for_label }}">{{ form.recurrence_end_date.label }}</label>
{{ form.recurrence_end_date }}
{% if form.recurrence_end_date.help_text %}<div class="form-help">{{ form.recurrence_end_date.help_text }}</div>{% endif %}
{% for error in form.recurrence_end_date.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
<div class="col-md-4">
<label class="form-label" for="{{ form.recurrence_weekday.id_for_label }}">{{ form.recurrence_weekday.label }}</label>
{{ form.recurrence_weekday }}
{% if form.recurrence_weekday.help_text %}<div class="form-help">{{ form.recurrence_weekday.help_text }}</div>{% endif %}
{% for error in form.recurrence_weekday.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if form.non_field_errors %}
<div class="field-error mt-3">{{ form.non_field_errors }}</div>
{% endif %}
<div class="d-flex gap-2 flex-wrap mt-4">
<button class="btn btn-primary-brand" type="submit">{{ submit_label }}</button>
<a class="btn btn-ghost" href="{% if event %}{% url 'event_dashboard_detail' event.slug %}{% else %}{% url 'event_dashboard' %}{% endif %}">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Upcoming Events | Roadshow Calendar{% endblock %}
{% block content %}
<section class="section-shell page-top-space">
<div class="container">
<div class="section-heading">
<span class="eyebrow-pill">Public event list</span>
<h1 class="section-title">All published stops in one clean list</h1>
<p class="section-copy">Visitors can browse every published event even if they prefer a list over the calendar grid.</p>
</div>
<div class="row g-4">
{% for event in events %}
<div class="col-lg-4 col-md-6">
<article class="event-card card-surface h-100">
<div class="event-date-chip">{{ event.start|date:"M j, Y" }}</div>
<h2>{{ event.name }}</h2>
<p class="event-meta">{{ event.start|date:"g:i A" }} {{ event.end|date:"g:i A" }}</p>
<p class="event-meta">{{ event.location|default:"Location announced soon" }}</p>
<p class="event-summary">{{ event.summary|default:"Extra details will be shared here when available."|truncatechars:150 }}</p>
<div class="mt-auto d-flex gap-2 flex-wrap">
<a class="btn btn-sm btn-primary-brand" href="{% url 'event_detail' event.slug %}">View details</a>
{% if event.event_url %}
<a class="btn btn-sm btn-ghost" href="{{ event.event_url }}" target="_blank" rel="noopener">External event page</a>
{% endif %}
</div>
</article>
</div>
{% empty %}
<div class="col-12">
<div class="empty-state card-surface text-center">
<h2>No published events yet</h2>
<p>Once your team adds and publishes events, visitors will see them here and in the embeddable calendar.</p>
</div>
</div>
{% endfor %}
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,53 @@
<div class="calendar-card card-surface {% if calendar_variant %}calendar-{{ calendar_variant }}{% endif %}">
<div class="calendar-toolbar d-flex flex-column flex-md-row justify-content-between gap-3 align-items-md-center">
<div>
<p class="toolbar-label mb-1">Click any date to view event details</p>
<h3 class="calendar-title mb-0">{{ month_label }}</h3>
</div>
<div class="calendar-nav d-flex gap-2">
<a class="btn btn-ghost btn-sm" href="?month={{ prev_month }}{% if calendar_variant == 'embed' %}&header={{ embed_header_value|default:'0' }}{% endif %}">Previous</a>
<a class="btn btn-ghost btn-sm" href="?month={{ today_value }}{% if calendar_variant == 'embed' %}&header={{ embed_header_value|default:'0' }}{% endif %}">Today</a>
<a class="btn btn-ghost btn-sm" href="?month={{ next_month }}{% if calendar_variant == 'embed' %}&header={{ embed_header_value|default:'0' }}{% endif %}">Next</a>
</div>
</div>
<div class="calendar-grid-wrap">
<div class="calendar-weekdays">
<span>Sun</span>
<span>Mon</span>
<span>Tue</span>
<span>Wed</span>
<span>Thu</span>
<span>Fri</span>
<span>Sat</span>
</div>
<div class="calendar-grid">
{% for week in calendar_weeks %}
{% for day in week %}
<button type="button" class="calendar-day {% if not day.in_month %}is-muted{% endif %} {% if day.is_today %}is-today{% endif %} {% if day.has_events %}has-events{% endif %}" data-calendar-date="{{ day.iso }}" aria-label="View schedule for {{ day.date|date:'F j, Y' }}">
<span class="calendar-day-number">{{ day.day }}</span>
{% if day.has_events %}
<span class="calendar-badge">{{ day.event_count }} event{{ day.event_count|pluralize }}</span>
{% else %}
<span class="calendar-badge calendar-badge-empty">Open day</span>
{% endif %}
</button>
{% endfor %}
{% endfor %}
</div>
</div>
</div>
{{ calendar_events|json_script:"calendar-events-data" }}
<div class="modal fade" id="dayEventsModal" tabindex="-1" aria-labelledby="dayEventsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content event-modal">
<div class="modal-header border-0 pb-0">
<div>
<p class="modal-label mb-1">Daily event details</p>
<h3 class="modal-title" id="dayEventsModalLabel" data-calendar-title>Selected day</h3>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pt-3" data-calendar-body></div>
</div>
</div>
</div>

View File

@ -1,145 +1,143 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ project_name }}{% endblock %} {% block title %}Roadshow Calendar | Secure Event Calendar{% endblock %}
{% block head %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
: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);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% {
background-position: 0% 0%;
}
100% {
background-position: 100% 100%;
}
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2.5rem 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
h1 {
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
font-weight: 700;
margin: 0 0 1.2rem;
letter-spacing: -0.02em;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
opacity: 0.92;
}
.loader {
margin: 1.5rem auto;
width: 56px;
height: 56px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.runtime code {
background: rgba(0, 0, 0, 0.25);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
footer {
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
font-size: 0.85rem;
opacity: 0.75;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<main> <section class="hero-section">
<div class="card"> <div class="container">
<h1>Analyzing your requirements and generating your app…</h1> <div class="hero-card">
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <div class="hero-copy">
<span class="sr-only">Loading…</span> <span class="eyebrow-pill">Embeddable calendar widget</span>
<h1>Show visitors exactly where your business will be on any day.</h1>
<p class="hero-lead">A polished public calendar for your website, plus secure staff-only tools for publishing event dates, times, and direct event links.</p>
<div class="hero-actions">
<a class="btn btn-primary-brand btn-lg" href="#calendar-preview">Explore the calendar</a>
<a class="btn btn-ghost btn-lg" href="#embed-snippet">Copy the embed snippet</a>
</div>
<div class="hero-stats row g-3">
<div class="col-md-4">
<div class="stat-card">
<strong>Secure updates</strong>
<span>Only logged-in staff can add or change events.</span>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<strong>Click any day</strong>
<span>Visitors get event names, times, and helpful links in a modal.</span>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<strong>Embed ready</strong>
<span>Drop the iframe into your existing HTML website in minutes.</span>
</div>
</div>
</div>
</div>
<div class="hero-panel">
<div class="floating-orb orb-one"></div>
<div class="floating-orb orb-two"></div>
<div class="mini-widget card-surface">
<p class="mini-label">Next live dates</p>
{% if hero_events %}
<ul class="list-unstyled mini-event-list mb-0">
{% for event in hero_events %}
<li>
<span>{{ event.start|date:"M d" }}</span>
<div>
<strong>{{ event.name }}</strong>
<small>{{ event.location|default:"Location announced soon" }}</small>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="empty-note">Add your first event from the staff dashboard to see it appear here.</div>
{% endif %}
</div>
</div>
</div> </div>
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
<p class="runtime">
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
</p>
</div> </div>
</main> </section>
<footer>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC) <section class="section-shell" id="calendar-preview">
</footer> <div class="container">
{% endblock %} <div class="section-heading d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-end">
<div>
<span class="eyebrow-pill">Public calendar</span>
<h2 class="section-title">A visitor-friendly monthly schedule</h2>
<p class="section-copy">Each day opens a clean detail modal so guests can confirm where you will be and jump straight to the event page.</p>
</div>
<div class="compact-actions d-flex gap-2">
<a class="btn btn-ghost" href="{% url 'calendar_page' %}">Open full calendar</a>
<a class="btn btn-primary-brand" href="{% url 'event_list' %}">Browse event list</a>
</div>
</div>
{% include "core/includes/calendar_widget.html" with calendar_variant="landing" %}
</div>
</section>
<section class="section-shell section-soft">
<div class="container">
<div class="section-heading">
<span class="eyebrow-pill">Upcoming stops</span>
<h2 class="section-title">Events your audience can scan in seconds</h2>
</div>
<div class="row g-4">
{% for event in upcoming_events %}
<div class="col-lg-4 col-md-6">
<article class="event-card card-surface h-100">
<div class="event-date-chip">{{ event.start|date:"D, M j" }}</div>
<h3>{{ event.name }}</h3>
<p class="event-meta">{{ event.start|date:"g:i A" }} {{ event.end|date:"g:i A" }}</p>
<p class="event-meta">{{ event.location|default:"Location announced soon" }}</p>
<p class="event-summary">{{ event.summary|default:"Event details will appear here as soon as your team adds them."|truncatechars:120 }}</p>
<div class="mt-auto d-flex gap-2 flex-wrap">
<a class="btn btn-sm btn-primary-brand" href="{% url 'event_detail' event.slug %}">Details</a>
{% if event.event_url %}
<a class="btn btn-sm btn-ghost" href="{{ event.event_url }}" target="_blank" rel="noopener">Event page</a>
{% endif %}
</div>
</article>
</div>
{% empty %}
<div class="col-12">
<div class="empty-state card-surface text-center">
<h3>No public events yet</h3>
<p>Create events from the secure dashboard or Django admin, then they will automatically appear on the landing page and embed widget.</p>
<a class="btn btn-primary-brand" href="{% url 'login' %}">Staff login</a>
</div>
</div>
{% endfor %}
</div>
</div>
</section>
<section class="section-shell" id="embed-snippet">
<div class="container">
<div class="row g-4 align-items-stretch">
<div class="col-lg-5">
<div class="card-surface h-100 embed-copy-card">
<span class="eyebrow-pill">Embed on your HTML site</span>
<h2 class="section-title">Paste one iframe and you are live</h2>
<p class="section-copy">Use the ready-made widget page below inside your existing HTML website. It stays read-only for visitors, while your team updates dates privately.</p>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-primary-brand" href="{{ embed_url }}" target="_blank" rel="noopener">Preview widget</a>
<a class="btn btn-ghost" href="/admin/">Open admin</a>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card-surface code-card h-100">
<div class="code-card-header d-flex justify-content-between align-items-center gap-3">
<strong>Embed snippet</strong>
<button class="btn btn-sm btn-ghost" type="button" data-copy-snippet data-copy-target="#iframeSnippet">Copy snippet</button>
</div>
<pre id="iframeSnippet" class="code-snippet mb-0">{{ iframe_snippet }}</pre>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Staff Login | Roadshow Calendar{% endblock %}
{% block content %}
<section class="section-shell page-top-space">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-5 col-xl-4">
<div class="card-surface form-card">
<div class="section-heading mb-4 text-center">
<span class="eyebrow-pill">Staff only</span>
<h1 class="section-title">Secure sign in</h1>
<p class="section-copy">Use your staff credentials to add or manage events. Visitors cannot modify the calendar.</p>
</div>
<form method="post" novalidate>
{% csrf_token %}
{% for field in form %}
<div class="mb-3">
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% for error in field.errors %}<div class="field-error">{{ error }}</div>{% endfor %}
</div>
{% endfor %}
{% if form.non_field_errors %}
<div class="field-error mb-3">{{ form.non_field_errors }}</div>
{% endif %}
<button class="btn btn-primary-brand w-100" type="submit">Log in</button>
<div class="text-center mt-3">
<a class="text-link" href="/admin/">Prefer Django Admin?</a>
</div>
</form>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -1,3 +1,234 @@
from django.test import TestCase from datetime import datetime
import json
# Create your tests here. from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile
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')
self.assertContains(response, 'Generate a copy-ready widget snippet')
self.assertContains(response, 'Import a previous backup')
def test_embed_page_can_hide_header(self):
response = self.client.get(reverse('calendar_embed') + '?header=0')
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'Open full page')
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 _backup_file(self, payload):
return SimpleUploadedFile(
'events-backup.json',
json.dumps(payload).encode('utf-8'),
content_type='application/json',
)
def test_staff_can_export_event_backup(self):
self.client.login(username='staffer', password='pass12345')
response = self.client.get(reverse('event_export'))
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/json')
self.assertIn('attachment; filename="roadshow-calendar-events-', response['Content-Disposition'])
payload = json.loads(response.content)
self.assertEqual(payload['version'], 1)
self.assertEqual(payload['event_count'], len(payload['events']))
exported_event = next(item for item in payload['events'] if item['slug'] == self.event.slug)
self.assertEqual(exported_event['name'], self.event.name)
def test_staff_can_preview_event_backup_restore(self):
self.client.login(username='staffer', password='pass12345')
payload = {
'version': 1,
'project': 'Roadshow Calendar',
'exported_at': '2026-04-01T10:30:00+00:00',
'event_count': 1,
'events': [
{
'name': 'Recovered Market',
'slug': 'recovered-market-2026-04-20',
'location': 'Harbor Plaza',
'start': '2026-04-20T10:00:00+00:00',
'end': '2026-04-20T14:00:00+00:00',
'event_url': 'https://example.com/recovered',
'summary': 'Recovered from backup',
'is_published': False,
}
],
}
response = self.client.post(reverse('event_import_preview'), {'backup_file': self._backup_file(payload)})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Ready to replace the current calendar')
self.assertContains(response, 'Recovered Market')
self.assertContains(response, 'Confirm restore and replace events')
self.assertIn('event_backup_import_payload', self.client.session)
def test_staff_can_restore_event_backup(self):
self.client.login(username='staffer', password='pass12345')
payload = {
'version': 1,
'project': 'Roadshow Calendar',
'exported_at': '2026-04-01T10:30:00+00:00',
'event_count': 1,
'events': [
{
'name': 'Recovered Market',
'slug': 'recovered-market-2026-04-20',
'location': 'Harbor Plaza',
'start': '2026-04-20T10:00:00+00:00',
'end': '2026-04-20T14:00:00+00:00',
'event_url': 'https://example.com/recovered',
'summary': 'Recovered from backup',
'is_published': False,
}
],
}
preview_response = self.client.post(reverse('event_import_preview'), {'backup_file': self._backup_file(payload)})
self.assertEqual(preview_response.status_code, 200)
response = self.client.post(reverse('event_import_restore'))
self.assertRedirects(response, reverse('event_dashboard'))
self.assertEqual(Event.objects.count(), 1)
restored_event = Event.objects.get()
self.assertEqual(restored_event.name, 'Recovered Market')
self.assertEqual(restored_event.slug, 'recovered-market-2026-04-20')
self.assertEqual(restored_event.location, 'Harbor Plaza')
self.assertFalse(restored_event.is_published)
self.assertFalse(Event.objects.filter(pk=self.event.pk).exists())
self.assertNotIn('event_backup_import_payload', self.client.session)
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())
def test_staff_can_create_recurring_events(self):
self.client.login(username='staffer', password='pass12345')
response = self.client.post(
reverse('event_create'),
{
'name': 'Weekly Demo',
'location': 'HQ Lab',
'start': '2026-05-05T09:30',
'end': '2026-05-05T11:00',
'event_url': 'https://example.com/demo',
'summary': 'Recurring demo day',
'is_published': 'on',
'recurrence_start_date': '2026-05-01',
'recurrence_end_date': '2026-05-31',
'recurrence_weekday': '2',
},
)
self.assertRedirects(response, reverse('event_dashboard'))
recurring_events = Event.objects.filter(name='Weekly Demo').order_by('start')
self.assertEqual(recurring_events.count(), 4)
self.assertEqual(
[event.start.date().isoformat() for event in recurring_events],
['2026-05-06', '2026-05-13', '2026-05-20', '2026-05-27'],
)
self.assertEqual([event.start.hour for event in recurring_events], [9, 9, 9, 9])
self.assertEqual([event.start.minute for event in recurring_events], [30, 30, 30, 30])
self.assertTrue(all(event.is_published for event in recurring_events))
self.assertTrue(all(event.end - event.start == recurring_events[0].end - recurring_events[0].start for event in recurring_events))
def test_staff_sees_validation_error_for_invalid_recurrence_range(self):
self.client.login(username='staffer', password='pass12345')
response = self.client.post(
reverse('event_create'),
{
'name': 'Broken Weekly Demo',
'location': 'HQ Lab',
'start': '2026-05-05T09:30',
'end': '2026-05-05T11:00',
'event_url': 'https://example.com/demo',
'summary': 'Should not save',
'is_published': 'on',
'recurrence_start_date': '2026-05-20',
'recurrence_end_date': '2026-05-10',
'recurrence_weekday': '2',
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'The recurring series must end on or after the start date.')
self.assertFalse(Event.objects.filter(name='Broken Weekly Demo').exists())

View File

@ -1,7 +1,40 @@
from django.contrib.auth import views as auth_views
from django.urls import path 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_export,
event_import_preview,
event_import_restore,
event_detail,
event_edit,
event_list,
home,
)
urlpatterns = [ 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/<slug:slug>/', event_detail, name='event_detail'),
path('dashboard/events/', event_dashboard, name='event_dashboard'),
path('dashboard/events/export/', event_export, name='event_export'),
path('dashboard/events/import/preview/', event_import_preview, name='event_import_preview'),
path('dashboard/events/import/restore/', event_import_restore, name='event_import_restore'),
path('dashboard/events/new/', event_create, name='event_create'),
path('dashboard/events/<slug:slug>/edit/', event_edit, name='event_edit'),
path('dashboard/events/<slug:slug>/delete/', event_delete, name='event_delete'),
path('dashboard/events/<slug:slug>/', 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'),
] ]

View File

@ -1,25 +1,588 @@
import os import calendar
import platform from collections import defaultdict
from datetime import datetime, time, timedelta
import json
from django import get_version as django_version from django.contrib import messages
from django.shortcuts import render from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone 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.'
BACKUP_IMPORT_SESSION_KEY = 'event_backup_import_payload'
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
def _embed_base_url(request):
return request.build_absolute_uri(reverse('calendar_embed'))
def _show_embed_header(request):
return request.GET.get('header', '1') != '0'
def _embed_header_value(request):
return '1' if _show_embed_header(request) else '0'
def _serialize_event(event):
return {
'name': event.name,
'slug': event.slug,
'location': event.location,
'start': event.start.isoformat(),
'end': event.end.isoformat(),
'event_url': event.event_url,
'summary': event.summary,
'is_published': event.is_published,
}
def _build_event_backup_payload():
events = list(Event.objects.all().order_by('start', 'name'))
return {
'version': 1,
'project': PROJECT_NAME,
'exported_at': timezone.now().isoformat(),
'event_count': len(events),
'events': [_serialize_event(event) for event in events],
}
def _parse_backup_datetime(value, label):
if not isinstance(value, str) or not value.strip():
raise ValueError(f'{label} is missing.')
try:
parsed = datetime.fromisoformat(value)
except ValueError as exc:
raise ValueError(f'{label} is invalid.') from exc
if timezone.is_naive(parsed):
parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
return parsed
def _parse_backup_bool(value, label):
if isinstance(value, bool):
return value
if isinstance(value, int) and value in (0, 1):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {'1', 'true', 'yes', 'on'}:
return True
if normalized in {'0', 'false', 'no', 'off'}:
return False
raise ValueError(f'{label} must be true or false.')
def _normalize_event_backup_payload(payload):
if not isinstance(payload, dict):
raise ValueError('Backup file must contain one JSON object.')
if payload.get('version') != 1:
raise ValueError('Only backup version 1 is supported right now.')
raw_events = payload.get('events')
if not isinstance(raw_events, list):
raise ValueError('Backup file is missing a valid events list.')
normalized_events = []
seen_slugs = set()
for index, raw_event in enumerate(raw_events, start=1):
if not isinstance(raw_event, dict):
raise ValueError(f'Event #{index} is invalid.')
name = str(raw_event.get('name') or '').strip()
if not name:
raise ValueError(f'Event #{index} is missing a name.')
slug = str(raw_event.get('slug') or '').strip()
if slug:
if slug in seen_slugs:
raise ValueError(f'Backup contains a duplicate slug: {slug}.')
seen_slugs.add(slug)
start = _parse_backup_datetime(raw_event.get('start'), f'Event #{index} start')
end = _parse_backup_datetime(raw_event.get('end'), f'Event #{index} end')
if end <= start:
raise ValueError(f'Event #{index} ends before it starts.')
normalized_events.append(
{
'name': name,
'slug': slug,
'location': str(raw_event.get('location') or '').strip(),
'start': start,
'end': end,
'event_url': str(raw_event.get('event_url') or '').strip(),
'summary': str(raw_event.get('summary') or '').strip(),
'is_published': _parse_backup_bool(raw_event.get('is_published', True), f'Event #{index} publication flag'),
}
)
declared_count = payload.get('event_count')
if declared_count is not None and declared_count != len(normalized_events):
raise ValueError('Backup event count does not match the number of event records.')
return {
'version': 1,
'project': str(payload.get('project') or PROJECT_NAME),
'exported_at': payload.get('exported_at'),
'event_count': len(normalized_events),
'events': normalized_events,
}
def _format_backup_timestamp(value):
if not value:
return 'Unknown'
try:
parsed = datetime.fromisoformat(value)
except (TypeError, ValueError):
return str(value)
if timezone.is_naive(parsed):
parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
return timezone.localtime(parsed).strftime('%b %d, %Y, %I:%M %p %Z')
def _build_event_import_preview(payload):
normalized = _normalize_event_backup_payload(payload)
published_count = sum(1 for event in normalized['events'] if event['is_published'])
preview_events = normalized['events'][:5]
return {
'version': normalized['version'],
'project': normalized['project'],
'event_count': normalized['event_count'],
'published_count': published_count,
'draft_count': normalized['event_count'] - published_count,
'exported_at': normalized['exported_at'],
'exported_at_display': _format_backup_timestamp(normalized['exported_at']),
'preview_events': preview_events,
'remaining_count': max(normalized['event_count'] - len(preview_events), 0),
}
def _get_pending_event_import_preview(request):
payload = request.session.get(BACKUP_IMPORT_SESSION_KEY)
if not payload:
return None
try:
return _build_event_import_preview(payload)
except ValueError:
request.session.pop(BACKUP_IMPORT_SESSION_KEY, None)
request.session.modified = True
return None
def _build_dashboard_context(request, import_preview=None):
events = Event.objects.all().order_by('start', 'name')
return _base_context(
page_title='Manage events',
events=events,
dashboard_count=events.count(),
embed_base_url=_embed_base_url(request),
default_embed_month=timezone.localdate().replace(day=1).strftime('%Y-%m'),
pending_import=import_preview if import_preview is not None else _get_pending_event_import_preview(request),
)
def _restore_events_from_backup(payload):
normalized = _normalize_event_backup_payload(payload)
with transaction.atomic():
Event.objects.all().delete()
for event_data in normalized['events']:
event = Event(**event_data)
event.full_clean()
event.save()
return normalized['event_count']
@login_required(login_url='login')
def event_dashboard(request):
if not request.user.is_staff:
raise PermissionDenied
return render(request, 'core/event_dashboard.html', _build_dashboard_context(request))
@login_required(login_url='login')
def event_export(request):
if not request.user.is_staff:
raise PermissionDenied
payload = _build_event_backup_payload()
timestamp = timezone.localtime().strftime('%Y%m%d-%H%M%S')
response = HttpResponse(
json.dumps(payload, indent=2),
content_type='application/json',
)
response['Content-Disposition'] = f'attachment; filename="roadshow-calendar-events-{timestamp}.json"'
return response
@login_required(login_url='login')
def event_import_preview(request):
if not request.user.is_staff:
raise PermissionDenied
if request.method != 'POST':
return redirect('event_dashboard')
uploaded_file = request.FILES.get('backup_file')
if not uploaded_file:
messages.error(request, 'Choose a backup JSON file before previewing the restore.')
return redirect('event_dashboard')
try:
payload = json.loads(uploaded_file.read().decode('utf-8'))
preview = _build_event_import_preview(payload)
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
request.session.pop(BACKUP_IMPORT_SESSION_KEY, None)
request.session.modified = True
messages.error(request, f'Backup import failed: {exc}')
return redirect('event_dashboard')
request.session[BACKUP_IMPORT_SESSION_KEY] = payload
request.session.modified = True
messages.success(request, 'Backup file loaded. Review the restore preview below before replacing live events.')
return render(request, 'core/event_dashboard.html', _build_dashboard_context(request, import_preview=preview))
@login_required(login_url='login')
def event_import_restore(request):
if not request.user.is_staff:
raise PermissionDenied
if request.method != 'POST':
return redirect('event_dashboard')
payload = request.session.get(BACKUP_IMPORT_SESSION_KEY)
if not payload:
messages.error(request, 'Upload a backup JSON and preview it before running a restore.')
return redirect('event_dashboard')
try:
restored_count = _restore_events_from_backup(payload)
except ValueError as exc:
request.session.pop(BACKUP_IMPORT_SESSION_KEY, None)
request.session.modified = True
messages.error(request, f'Restore failed: {exc}')
return redirect('event_dashboard')
request.session.pop(BACKUP_IMPORT_SESSION_KEY, None)
request.session.modified = True
event_label = 'event' if restored_count == 1 else 'events'
messages.success(request, f'Restore complete. Replaced the live calendar with {restored_count} {event_label} from the backup.')
return redirect('event_dashboard')
def _build_weekly_recurrence_datetimes(start, end, recurrence_start_date, recurrence_end_date, recurrence_weekday):
tz = timezone.get_current_timezone()
local_start = timezone.localtime(start, tz) if timezone.is_aware(start) else start
start_time = local_start.time().replace(tzinfo=None)
duration = end - start
weekday_index = int(recurrence_weekday)
days_until_first = (weekday_index - recurrence_start_date.weekday()) % 7
current_date = recurrence_start_date + timedelta(days=days_until_first)
occurrence_datetimes = []
while current_date <= recurrence_end_date:
occurrence_start = timezone.make_aware(datetime.combine(current_date, start_time), tz)
occurrence_end = occurrence_start + duration
occurrence_datetimes.append((occurrence_start, occurrence_end))
current_date += timedelta(days=7)
return occurrence_datetimes
@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():
recurrence_start_date = form.cleaned_data.get('recurrence_start_date')
recurrence_end_date = form.cleaned_data.get('recurrence_end_date')
recurrence_weekday = form.cleaned_data.get('recurrence_weekday')
if recurrence_start_date and recurrence_end_date and recurrence_weekday:
base_event = form.save(commit=False)
occurrence_datetimes = _build_weekly_recurrence_datetimes(
start=base_event.start,
end=base_event.end,
recurrence_start_date=recurrence_start_date,
recurrence_end_date=recurrence_end_date,
recurrence_weekday=recurrence_weekday,
)
created_events = []
with transaction.atomic():
for occurrence_start, occurrence_end in occurrence_datetimes:
event = Event(
name=base_event.name,
location=base_event.location,
start=occurrence_start,
end=occurrence_end,
event_url=base_event.event_url,
summary=base_event.summary,
is_published=base_event.is_published,
)
event.full_clean()
event.save()
created_events.append(event)
event_label = 'event' if len(created_events) == 1 else 'events'
messages.success(request, f'Created {len(created_events)} recurring {event_label} and added them to the calendar.')
return redirect('event_dashboard')
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): def home(request):
"""Render the landing screen with loader and environment details.""" month_context = _build_month_context(request.GET.get('month'))
host_name = request.get_host().lower() upcoming_events = list(Event.objects.filter(is_published=True, end__gte=timezone.now()).order_by('start', 'name')[:6])
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" embed_url = _embed_base_url(request)
now = timezone.now() iframe_snippet = f'<iframe src="{embed_url}" title="Where to find us calendar" width="100%" height="760" style="border:0;border-radius:24px;overflow:hidden;"></iframe>'
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", def calendar_page(request):
"agent_brand": agent_brand, month_context = _build_month_context(request.GET.get('month'))
"django_version": django_version(), return render(
"python_version": platform.python_version(), request,
"current_time": now, 'core/calendar_page.html',
"host_name": host_name, _base_context(
"project_description": os.getenv("PROJECT_DESCRIPTION", ""), page_title='Public calendar',
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), **month_context,
} ),
return render(request, "core/index.html", 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,
show_embed_header=_show_embed_header(request),
embed_header_value=_embed_header_value(request),
**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,
),
)

View File

@ -1,4 +1,913 @@
/* Custom styles for the application */ /* Brand system */
body { :root {
font-family: system-ui, -apple-system, sans-serif; --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);
}
.brand-logo {
display: block;
width: auto;
height: 44px;
}
.brand-label {
color: var(--brand-ink);
font-size: 1rem;
font-weight: 800;
letter-spacing: -0.02em;
white-space: nowrap;
}
@media (max-width: 575.98px) {
.brand-logo {
height: 34px;
}
.brand-label {
font-size: 0.94rem;
}
}
.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-section-wide {
padding: 0.3rem 0 0.85rem;
}
.embed-container {
width: 100%;
max-width: none;
margin: 0 auto;
padding-inline: clamp(0.35rem, 1vw, 0.85rem);
}
.embed-shell-card {
padding: clamp(0.6rem, 1vw, 0.95rem);
margin: 0;
max-width: none;
}
.embed-logo {
display: block;
width: auto;
height: 48px;
max-width: none;
}
.embed-section .calendar-card {
background: transparent;
border: 0;
box-shadow: none;
padding: 0;
}
.calendar-embed .calendar-toolbar {
margin-bottom: 0.7rem;
}
.calendar-embed .calendar-weekdays,
.calendar-embed .calendar-grid {
gap: 0.5rem;
}
.calendar-embed .calendar-day {
min-height: 96px;
padding: 0.7rem;
gap: 0.45rem;
}
.calendar-embed .calendar-day-number {
font-size: 1.22rem;
}
.calendar-embed .calendar-badge {
padding: 0.34rem 0.58rem;
font-size: 0.78rem;
}
.calendar-embed .calendar-title {
font-size: clamp(1.35rem, 2vw, 1.7rem);
}
.calendar-embed .toolbar-label {
font-size: 0.82rem;
margin-bottom: 0.2rem !important;
}
.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;
}
.embed-settings-card {
padding: 1.5rem;
}
.embed-settings-form .form-label {
font-weight: 700;
color: var(--brand-ink);
}
.embed-output-stack {
display: grid;
gap: 1rem;
}
.embed-shell-card-compact {
padding-top: 0.8rem;
}
.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="month"],
input[type="number"],
input[type="datetime-local"],
textarea,
.form-control,
.form-select,
.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;
}
.embed-container {
padding-inline: 0.4rem;
}
.embed-shell-card {
margin-top: 0;
padding: 0.55rem;
}
.calendar-embed .calendar-day {
min-height: 92px;
padding: 0.62rem;
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

157
static/js/calendar.js Normal file
View File

@ -0,0 +1,157 @@
document.addEventListener('DOMContentLoaded', () => {
const dataElement = document.getElementById('calendar-events-data');
const modalElement = document.getElementById('dayEventsModal');
let calendarEvents = {};
const escapeHtml = (value) => String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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) {
if (modalElement.parentElement !== document.body) {
document.body.appendChild(modalElement);
}
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 = `
<div class="modal-event-card">
<h4>No event scheduled</h4>
<p class="mb-0 text-secondary">This date is currently open. Check another day or revisit after the next update.</p>
</div>
`;
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 ? `<p>${escapeHtml(event.summary)}</p>` : '';
const detailUrl = escapeHtml(event.detail_url || '#');
const eventUrl = safeExternalUrl(event.event_url);
return `
<article class="modal-event-card">
<h4>${name}</h4>
<div class="modal-event-meta">
<span>${time}</span>
<span>${location}</span>
</div>
${summary}
<div class="d-flex gap-2 flex-wrap mt-3">
<a class="btn btn-sm btn-primary-brand" href="${detailUrl}">View details</a>
${eventUrl ? `<a class="btn btn-sm btn-ghost" href="${escapeHtml(eventUrl)}" target="_blank" rel="noopener">Event page</a>` : ''}
</div>
</article>
`;
})
.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';
}
});
});
const escapeAttribute = (value) => String(value || '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
document.querySelectorAll('[data-embed-settings]').forEach((form) => {
const baseUrl = form.dataset.embedBaseUrl || '';
const urlOutput = document.getElementById('dashboardEmbedUrl');
const iframeOutput = document.getElementById('dashboardIframeSnippet');
const previewLink = document.querySelector('[data-embed-preview]');
if (!baseUrl || !urlOutput || !iframeOutput) return;
const updateEmbedOutput = () => {
const formData = new FormData(form);
const params = new URLSearchParams();
const month = String(formData.get('month') || '').trim();
const header = String(formData.get('header') || '1');
const title = String(formData.get('title') || 'Where to find us calendar').trim() || 'Where to find us calendar';
const width = String(formData.get('width') || '100%').trim() || '100%';
const height = Math.max(parseInt(String(formData.get('height') || '760'), 10) || 760, 360);
const radius = Math.max(parseInt(String(formData.get('radius') || '24'), 10) || 24, 0);
if (/^\d{4}-\d{2}$/.test(month)) {
params.set('month', month);
}
if (header === '0') {
params.set('header', '0');
}
const widgetUrl = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
const iframeSnippet = `<iframe src="${escapeAttribute(widgetUrl)}" title="${escapeAttribute(title)}" width="${escapeAttribute(width)}" height="${height}" style="border:0;border-radius:${radius}px;overflow:hidden;" loading="lazy"></iframe>`;
urlOutput.textContent = widgetUrl;
iframeOutput.textContent = iframeSnippet;
if (previewLink) {
previewLink.href = widgetUrl;
}
};
['input', 'change'].forEach((eventName) => {
form.addEventListener(eventName, updateEmbedOutput);
});
updateEmbedOutput();
});
});

View File

@ -1,21 +1,913 @@
/* Brand system */
:root { :root {
--bg-color-start: #6a11cb; --brand-ink: #122023;
--bg-color-end: #2575fc; --brand-primary: #e76f51;
--text-color: #ffffff; --brand-primary-dark: #cb5a3e;
--card-bg-color: rgba(255, 255, 255, 0.01); --brand-secondary: #0f766e;
--card-border-color: rgba(255, 255, 255, 0.1); --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 { body {
margin: 0; margin: 0;
font-family: 'Inter', sans-serif; font-family: 'Inter', system-ui, sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); color: var(--brand-ink);
color: var(--text-color); background:
display: flex; radial-gradient(circle at top left, rgba(243, 201, 107, 0.33), transparent 28%),
justify-content: center; radial-gradient(circle at top right, rgba(15, 118, 110, 0.18), transparent 24%),
align-items: center; linear-gradient(180deg, #fffaf4 0%, var(--brand-bg) 52%, #fffaf4 100%);
min-height: 100vh; min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative; 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);
}
.brand-logo {
display: block;
width: auto;
height: 44px;
}
.brand-label {
color: var(--brand-ink);
font-size: 1rem;
font-weight: 800;
letter-spacing: -0.02em;
white-space: nowrap;
}
@media (max-width: 575.98px) {
.brand-logo {
height: 34px;
}
.brand-label {
font-size: 0.94rem;
}
}
.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-section-wide {
padding: 0.3rem 0 0.85rem;
}
.embed-container {
width: 100%;
max-width: none;
margin: 0 auto;
padding-inline: clamp(0.35rem, 1vw, 0.85rem);
}
.embed-shell-card {
padding: clamp(0.6rem, 1vw, 0.95rem);
margin: 0;
max-width: none;
}
.embed-logo {
display: block;
width: auto;
height: 48px;
max-width: none;
}
.embed-section .calendar-card {
background: transparent;
border: 0;
box-shadow: none;
padding: 0;
}
.calendar-embed .calendar-toolbar {
margin-bottom: 0.7rem;
}
.calendar-embed .calendar-weekdays,
.calendar-embed .calendar-grid {
gap: 0.5rem;
}
.calendar-embed .calendar-day {
min-height: 96px;
padding: 0.7rem;
gap: 0.45rem;
}
.calendar-embed .calendar-day-number {
font-size: 1.22rem;
}
.calendar-embed .calendar-badge {
padding: 0.34rem 0.58rem;
font-size: 0.78rem;
}
.calendar-embed .calendar-title {
font-size: clamp(1.35rem, 2vw, 1.7rem);
}
.calendar-embed .toolbar-label {
font-size: 0.82rem;
margin-bottom: 0.2rem !important;
}
.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;
}
.embed-settings-card {
padding: 1.5rem;
}
.embed-settings-form .form-label {
font-weight: 700;
color: var(--brand-ink);
}
.embed-output-stack {
display: grid;
gap: 1rem;
}
.embed-shell-card-compact {
padding-top: 0.8rem;
}
.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="month"],
input[type="number"],
input[type="datetime-local"],
textarea,
.form-control,
.form-select,
.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;
}
.embed-container {
padding-inline: 0.4rem;
}
.embed-shell-card {
margin-top: 0;
padding: 0.55rem;
}
.calendar-embed .calendar-day {
min-height: 92px;
padding: 0.62rem;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

157
staticfiles/js/calendar.js Normal file
View File

@ -0,0 +1,157 @@
document.addEventListener('DOMContentLoaded', () => {
const dataElement = document.getElementById('calendar-events-data');
const modalElement = document.getElementById('dayEventsModal');
let calendarEvents = {};
const escapeHtml = (value) => String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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) {
if (modalElement.parentElement !== document.body) {
document.body.appendChild(modalElement);
}
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 = `
<div class="modal-event-card">
<h4>No event scheduled</h4>
<p class="mb-0 text-secondary">This date is currently open. Check another day or revisit after the next update.</p>
</div>
`;
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 ? `<p>${escapeHtml(event.summary)}</p>` : '';
const detailUrl = escapeHtml(event.detail_url || '#');
const eventUrl = safeExternalUrl(event.event_url);
return `
<article class="modal-event-card">
<h4>${name}</h4>
<div class="modal-event-meta">
<span>${time}</span>
<span>${location}</span>
</div>
${summary}
<div class="d-flex gap-2 flex-wrap mt-3">
<a class="btn btn-sm btn-primary-brand" href="${detailUrl}">View details</a>
${eventUrl ? `<a class="btn btn-sm btn-ghost" href="${escapeHtml(eventUrl)}" target="_blank" rel="noopener">Event page</a>` : ''}
</div>
</article>
`;
})
.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';
}
});
});
const escapeAttribute = (value) => String(value || '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
document.querySelectorAll('[data-embed-settings]').forEach((form) => {
const baseUrl = form.dataset.embedBaseUrl || '';
const urlOutput = document.getElementById('dashboardEmbedUrl');
const iframeOutput = document.getElementById('dashboardIframeSnippet');
const previewLink = document.querySelector('[data-embed-preview]');
if (!baseUrl || !urlOutput || !iframeOutput) return;
const updateEmbedOutput = () => {
const formData = new FormData(form);
const params = new URLSearchParams();
const month = String(formData.get('month') || '').trim();
const header = String(formData.get('header') || '1');
const title = String(formData.get('title') || 'Where to find us calendar').trim() || 'Where to find us calendar';
const width = String(formData.get('width') || '100%').trim() || '100%';
const height = Math.max(parseInt(String(formData.get('height') || '760'), 10) || 760, 360);
const radius = Math.max(parseInt(String(formData.get('radius') || '24'), 10) || 24, 0);
if (/^\d{4}-\d{2}$/.test(month)) {
params.set('month', month);
}
if (header === '0') {
params.set('header', '0');
}
const widgetUrl = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
const iframeSnippet = `<iframe src="${escapeAttribute(widgetUrl)}" title="${escapeAttribute(title)}" width="${escapeAttribute(width)}" height="${height}" style="border:0;border-radius:${radius}px;overflow:hidden;" loading="lazy"></iframe>`;
urlOutput.textContent = widgetUrl;
iframeOutput.textContent = iframeSnippet;
if (previewLink) {
previewLink.href = widgetUrl;
}
};
['input', 'change'].forEach((eventName) => {
form.addEventListener(eventName, updateEmbedOutput);
});
updateEmbedOutput();
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB