From 693107e07949b5e329f77b650ed25d5ab3d0edc0 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 2 Apr 2026 16:02:38 +0000 Subject: [PATCH] v1 --- config/__pycache__/settings.cpython-311.pyc | Bin 5552 -> 5630 bytes config/settings.py | 78 +- core/__pycache__/admin.cpython-311.pyc | Bin 212 -> 1045 bytes core/__pycache__/forms.cpython-311.pyc | Bin 0 -> 3261 bytes core/__pycache__/models.cpython-311.pyc | Bin 209 -> 3723 bytes core/__pycache__/tests.cpython-311.pyc | Bin 0 -> 7933 bytes core/__pycache__/urls.cpython-311.pyc | Bin 347 -> 1789 bytes core/__pycache__/views.cpython-311.pyc | Bin 1364 -> 13512 bytes core/admin.py | 15 +- core/forms.py | 49 ++ core/migrations/0001_initial.py | 33 + core/migrations/0002_seed_demo_events.py | 69 ++ .../__pycache__/0001_initial.cpython-311.pyc | Bin 0 -> 1744 bytes .../0002_seed_demo_events.cpython-311.pyc | Bin 0 -> 3563 bytes core/models.py | 39 +- core/templates/base.html | 78 +- core/templates/core/calendar_embed.html | 21 + core/templates/core/calendar_page.html | 22 + core/templates/core/event_confirm_delete.html | 42 + core/templates/core/event_dashboard.html | 79 ++ .../core/event_dashboard_detail.html | 51 ++ core/templates/core/event_detail.html | 42 + core/templates/core/event_form.html | 49 ++ core/templates/core/event_list.html | 41 + .../core/includes/calendar_widget.html | 53 ++ core/templates/core/index.html | 276 +++--- core/templates/registration/login.html | 38 + core/tests.py | 91 +- core/urls.py | 31 +- core/views.py | 299 ++++++- static/css/custom.css | 805 ++++++++++++++++- static/js/calendar.js | 106 +++ staticfiles/css/custom.css | 810 +++++++++++++++++- staticfiles/js/calendar.js | 106 +++ 34 files changed, 3078 insertions(+), 245 deletions(-) create mode 100644 core/__pycache__/forms.cpython-311.pyc create mode 100644 core/__pycache__/tests.cpython-311.pyc create mode 100644 core/forms.py create mode 100644 core/migrations/0001_initial.py create mode 100644 core/migrations/0002_seed_demo_events.py create mode 100644 core/migrations/__pycache__/0001_initial.cpython-311.pyc create mode 100644 core/migrations/__pycache__/0002_seed_demo_events.cpython-311.pyc create mode 100644 core/templates/core/calendar_embed.html create mode 100644 core/templates/core/calendar_page.html create mode 100644 core/templates/core/event_confirm_delete.html create mode 100644 core/templates/core/event_dashboard.html create mode 100644 core/templates/core/event_dashboard_detail.html create mode 100644 core/templates/core/event_detail.html create mode 100644 core/templates/core/event_form.html create mode 100644 core/templates/core/event_list.html create mode 100644 core/templates/core/includes/calendar_widget.html create mode 100644 core/templates/registration/login.html create mode 100644 static/js/calendar.js create mode 100644 staticfiles/js/calendar.js diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index d79d6a79890a58443ee5f65d8d558823a19e9aed..73d635070ea8c283b9ad25f69772426153ec8f85 100644 GIT binary patch delta 363 zcmdm>{ZE^3IWI340}woHK9?yjFp*D!@y&in^;A-7`rEn z@;69U3F)UK7H1^oCl;mXrVW60QXb6g65f9MPB0&(r4l2|{SN+cf9~#c{4^Fb&I2$pJ$^W!s5_Cf#q;LEc3P(}npB>n z0`^!I60Olr^&+KJe#HIY%nUG7m+w`Q?6R8>dPNOg-R;$o=>DLFOlL+>B)r5TSONtp zUl?Ht3@C+8fwGaS7?KC-km)GVDsRF8fx998q2;N7eD1ge-)Kp0p-=0e-0d1766IUx zifxq#YX4{magMwRWAvN0M6dBVx)g%QFla=2U(Cf2+vI}7Z?ETertR>rPv>?)E4H$J eOsiKFu{8m9Ou$%Qs~(=1J<~mN>2F@d<`ZAnaaQX9 diff --git a/config/settings.py b/config/settings.py index 291d043..20888c9 100644 --- a/config/settings.py +++ b/config/settings.py @@ -15,38 +15,32 @@ import os from dotenv import load_dotenv BASE_DIR = Path(__file__).resolve().parent.parent -load_dotenv(BASE_DIR.parent / ".env") +load_dotenv(BASE_DIR.parent / '.env') -SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me") -DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true" +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'change-me') +DEBUG = os.getenv('DJANGO_DEBUG', 'true').lower() == 'true' ALLOWED_HOSTS = [ - "127.0.0.1", - "localhost", - os.getenv("HOST_FQDN", ""), + '127.0.0.1', + 'localhost', + os.getenv('HOST_FQDN', ''), ] CSRF_TRUSTED_ORIGINS = [ origin for origin in [ - os.getenv("HOST_FQDN", ""), - os.getenv("CSRF_TRUSTED_ORIGIN", "") + os.getenv('HOST_FQDN', ''), + os.getenv('CSRF_TRUSTED_ORIGIN', '') ] if origin ] CSRF_TRUSTED_ORIGINS = [ - f"https://{host}" if not host.startswith(("http://", "https://")) else host + f"https://{host}" if not host.startswith(('http://', 'https://')) else host for host in CSRF_TRUSTED_ORIGINS ] -# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy. SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True -SESSION_COOKIE_SAMESITE = "None" -CSRF_COOKIE_SAMESITE = "None" - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ - -# Application definition +SESSION_COOKIE_SAMESITE = 'None' +CSRF_COOKIE_SAMESITE = 'None' INSTALLED_APPS = [ 'django.contrib.admin', @@ -65,8 +59,6 @@ MIDDLEWARE = [ 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', - # Disable X-Frame-Options middleware to allow Flatlogic preview iframes. - # 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] X_FRAME_OPTIONS = 'ALLOWALL' @@ -83,7 +75,6 @@ TEMPLATES = [ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', - # IMPORTANT: do not remove – injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp 'core.context_processors.project_context', ], }, @@ -92,10 +83,6 @@ TEMPLATES = [ WSGI_APPLICATION = 'config.wsgi.application' - -# Database -# https://docs.djangoproject.com/en/5.2/ref/settings/#databases - DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', @@ -110,10 +97,6 @@ DATABASES = { }, } - -# Password validation -# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators - AUTH_PASSWORD_VALIDATORS = [ { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', @@ -129,54 +112,39 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] - -# Internationalization -# https://docs.djangoproject.com/en/5.2/topics/i18n/ - LANGUAGE_CODE = 'en-us' - TIME_ZONE = 'UTC' - USE_I18N = True - USE_TZ = True - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/5.2/howto/static-files/ - STATIC_URL = 'static/' -# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS. STATIC_ROOT = BASE_DIR / 'staticfiles' - STATICFILES_DIRS = [ BASE_DIR / 'static', BASE_DIR / 'assets', BASE_DIR / 'node_modules', ] -# Email EMAIL_BACKEND = os.getenv( - "EMAIL_BACKEND", - "django.core.mail.backends.smtp.EmailBackend" + 'EMAIL_BACKEND', + 'django.core.mail.backends.smtp.EmailBackend' ) -EMAIL_HOST = os.getenv("EMAIL_HOST", "127.0.0.1") -EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587")) -EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") -EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") -EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true" -EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true" -DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "no-reply@example.com") +EMAIL_HOST = os.getenv('EMAIL_HOST', '127.0.0.1') +EMAIL_PORT = int(os.getenv('EMAIL_PORT', '587')) +EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '') +EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '') +EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'true').lower() == 'true' +EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', 'false').lower() == 'true' +DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'no-reply@example.com') CONTACT_EMAIL_TO = [ item.strip() - for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",") + for item in os.getenv('CONTACT_EMAIL_TO', DEFAULT_FROM_EMAIL).split(',') if item.strip() ] -# When both TLS and SSL flags are enabled, prefer SSL explicitly if EMAIL_USE_SSL: EMAIL_USE_TLS = False -# Default primary key field type -# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +LOGIN_REDIRECT_URL = '/dashboard/events/' +LOGOUT_REDIRECT_URL = '/' diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 5e8987a0cd478c226e501b5189550e758662c560..b6d480fc6266c27a9e3c1f7bc0e2e09d91966d27 100644 GIT binary patch literal 1045 zcmZWnO-~dt7;fidcNi8Cbr+U{S@mEhL^i>AGbU;bCJG+B&7~>RvRj;wwVhFx(74{MDpt zwF$WVslXv1fWQz$(1#EZgP5TanZBv8Md6NBEkbsMh&|=6b_1j~<*!%q22g&dV$?i> z0353^ZU$Hfq`nV`qoQUi?~$ou)EKuT^*gQz@Le+V5qR#o_IOa*m_#f-*E?$V<_nF} zy~S6VU@;Y<^BRXVCYX0#>ae|q8xwoW8#u;2+8HJp?@+zGBZ599SxAFxRRyY?FCQv$ z2q^F&0lq!hYKtV_2{rWE#UX~j6A(g~9Qr!0x3EmF)=X{z*O zg9$Xqwn8S9uxIdwlHmFZJ(KIRHtm?FZ&AjCsCTe58yrj*M$K`{^dXq(ZH2sy9hND_y` z%HbrOP|o7s4{)h+16NPF{UoB@Y%7b?tV?%kkfjOleogo%F~9-s1_`I#^0HP3L$2RP zX>^t1u`>B}KfAl0zc22r1R$J6pMX1``lB6sR)dY(P-9=;KaN1KRqC+av7r-85vTTf*CZK zUxE~9GTvfMOv%m6^V4Ly#g~$mn3tZfmzKBJiZhlH>PO4oI c2T+U=h>K-`#0O?ZM#dWq3Ky`UA~v8306TId_y7O^ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f805a3e52973ac562c5e541f8ec2827b55da3c17 GIT binary patch literal 3261 zcmb_e-)kGm9iP=|^;@!%D7Ji2d`m9AL~(5edM(8q;YzGr8^^xZb~#SA51AJZ4R23!adv{a8Ji76=YeiaBZQGHyQika9{fU?%Hy6 ziJ^s#WqWIq@ia@y~|H;P>ctRe+r zdLi(C94qKXAqY_jePxzVgUoYE`2A2J?3N->iZsY7SBOGHqE6Ud{2hqLQDX3&;la}I zpfWS~ozcOG^@Jju)Kp7!SSuH0?I?y(8!nc0la;ls&+(gU73v5@O*Rau3bJW3!wFz7 z*@T9;s#mI}*Sv#FMKM=(s8nGR2N#R*fSr&MK{6Ev!&lFZhe zm}*Fs>YSz;i&SCg>w524xu%=&h|?9O>I>q8%$6za1B-&$iB>eZNEgeRLRpq$G5qAh zl)6IMJcAt-zbCJ#8ce-DuxOeUV>Fj@ryZ`y3v`68%B6}%M~darQ#@kpxo_)h<|59r zq8h4MW`<}imaCc~F6-r*I7eaAsR)FC1wgNnEbn>YXXTo~`e6vSzMk|bwMdK0!!BF0 z0y~e^3kbakeF*6jSkJ$uYinX>?V{_YAxxKAb5s#M z4vtJ_<4#DDFzS-zL?o$HR;n77W0Lfvs;qeyhUeN5z(_S9wn{a{V5llic!sW;-$i~q zp&v}$J1$|P%&6?dZ{w2Y%B!9s=uK-lkwvOiBon$Be9LC8QsjNAEWDGk8Ut)BOzXEJ_es=O=L$J@iYq_}*_#*Yn zR(|`fN0Zj{vUNkloGLX_rB+zkJO}>( z_zia-#NT|z{s3AZ!sDf-j-}42r0)AY_e;DwxH0nX_c!=DS?WmhUgx4X`4-=GzWXRw zE}9)t04rJQh<=?{NcV-a;jjDI<+^_lJ$^1JzV4Bq^`*|g4*frvQV2)|KMBV-1$A~2 z0hJ5HLNKZo%8T5JHyQw6ZW23` z{)OSil-(@M?$ z<;{=BKAHG*)O!1e&DUmIugx~{c2BBJxcEKJ9=iGGTYtJ`joxhzjkkuzxBT0&okBBt z{mXXnbYz!BT?9h$!p%PT8~_P)HhuOw^ntFO)J?GsuFmasXw>Nh1%>P0;tk)KJ-E1N1aU|(~-=tyGcV?}2piDbaX%fxjyeGF{OeH!B%3h!Q0U!5CJH(HSN83ruXu{o!yzWW657l{(N%h8`hP(&DeM=Hg3hn z?Vd|k=#tBZY>Z`KW_B520Kt{>_!#`vnSlV^L5+7hRad^BStBZv%YTL|bK=K`q)0VQ zlHgi(V}M)shIdr(e+xsvGh~Fo0&70`*|C=!`Bv;@yYHISd)@B2(x|q2uGp7`9_iNk zJY@UAD{X;KM)nE7AwpXS!~^>T;1FRm-%gO|Kx42Kx%|J462(!XI7&3IdApsA382Q$ z!#=hho*FWOfXDU>Oaz1sX!?Hzp2A~jCP9<}%95-vl)cRexc9B$UB2N7ynOG#_29@LbJ21q7&(Fh_u-&td?T0Tg Hx(@sYPCp{4 literal 0 HcmV?d00001 diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index a251b5fb7c2b603c587f8162d643eb10596d2858..73573bf954322639ea89faf12bb7d7bdcc2ab94f 100644 GIT binary patch literal 3723 zcmd59OKcm*b(XtJE`Or9lx10VB+4=6(6S`7W)jtQ+M=P9+L47KRuY(Ku~>0d66GbS z%r0$Nl>wr0Q6^AY6>w{#t^poWSFL@}A%`A)%%Lcj#sVf5(4pu_HyKKS!l%BOB`H#N zg0`m)XWz`cc{B6oeTRPxhXV-8-#`9aj`t$;4?1WTvC-N4F?8-Aj4-F8JeT1(+V|+5 zJfGoNpVx)Fm=RfD(7kzI#>XKKdIw?g7Q$YQUy_=eKHw302%lRre(bx9lF|+xCOIea zfvV@QYUYZCNkWPQP~LnIYq|kFpP`o)a`QK!E19{xwpuJ`*~Uh!X)*je4xwKF%NojX z7-c*j1S|+mQp`Q_JOGM^@VO-;G71oYTQH9WEIbkqFzY+84^TbwKJdU!@VO-;F&ck! z`~W%+n*)>yeB*48&4!xfu)FU7pJ7JP^$ipeM$z3yAsyHw+Q|c);y*wkzd#W1DkL^bS|h3YX~vb$2`BWZRk zg#~kQ=LuHJ(oEG64O1niBNWuUHj@+_Nl|E5 zQJjFHfO(d5+7BtpMqDn$^a$MWT(n#y+-u!x8AwM24j?_>4XP<<_s>bM)>9R_sJYqCe6PljInHY|Er! zK;40Q+TJvPJ7^6pLF<-#;LB#q3lUms3WZX&@SRG>o_hl3?+a8sGpnBXYpedmTs(nK z#}ikcbIGJYdSG)xoyhU)#jL7RCnc17(1l=V`n*Bei<6_2#HT2RNfc!@>`J;Zq)IVH z0F+T(?!BF~A{U|FU$FS>yqtJ1fdr{t0=@Iz$3cvuBce}A|aEAQf8XGy%v4ZN8 zm9IC?g13F{CT|9tYY^pza>xob{WYE%^L@TkN08L`pL?i~+_KDJ;ij-A+!V2R(`&Y( zF1Sl8omJcP-dxAdV`-IQAVYwS?B|+l8((cJ#DlTE+R6j_R0wt2B~jJ{;96?un%&QP z58dZ)ifdl%Uxje_|H=K9vL>z}rFA_IGHGOJl+K$jwWPns<r(Cf75n@Zt3P-DHM{@x=Ioif|8(T9}__1LjWy54iB zGPx}uzCHR>KKVpGxtV(OrX`=O$!Bc&%v1U76Z!08!IIC`fML#Mt>dH5dSHnSl|D+|E?Dud8LwGzfhNY zJ`-t>TMunuOG@0G2hd(w97`S{hd_>mCLVH_0vc=_|J#cynaXK8W+veHFN2cYF|`^; zqH3h?R41HPO>GupxjReq9gEYB^!|I(4CnZnH7X^dJN{X1#dNVgqm<99Z~uL8M07E-SrM}Af6XNWomu;8O49~l1awozX9%q BNWlOA delta 164 zcmeB{y~voboR^o20SM}RR%BWN>Bk@r3@||%pM`*o=?p0hDU3M`xr|Yaj0`DE!3>(r zFF^`48E>)W=BK3Q6#Hp1-QrBiO3X{o*Gow%Vg^c1PG&Z@`o&=bR8X3eYFESo6k`P9 XVp$;Zftit!@dks;1#GB@4X6SDb15Sm diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c713401b4b56800b2f35bf2c7a6ffbac56c27c5 GIT binary patch literal 7933 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 HXIuGS2j!c7 literal 0 HcmV?d00001 diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index f705988a223abc7b5af333902925bc196186bbf5..1c56cb85ee9fbba1ed308a585f1a3441e3e1758d 100644 GIT binary patch literal 1789 zcmcgszi-<{6h6vN5=n`Ye<(>4E4CAOh=SCF?M_Buw6^O4MeM~v2coD<>kg6}{t%92 z+-l00p+nH69zD4!lEGug`~}4^AP|8aI|b;VBAqhjj-re>Au{$z9^ZZ6dmrAtcjRxH zrXoIm{^idu1BCt-i}9FBjW_>D2>psM!jgq-$;e5<&RG*S7{Kib43=c)jl5kj3U<*b z+Oi?rilNx5q1q**WS5Pytr?nKF)9+uAp$s0D!A~xcsha5GyaT5!pNk6l}tt@d0fh5 z%0$CjCQ~6LT+L)AiHxT*nQ2nQGnq_{l<}oZ<}y+7l}zR{qTs8U%r%lbtd947GC8*1 zf8$wPe}4Tm$E(Dj(ZF+=YV%xe+%c4`cFF~wjb#vC_)w=^Hm327J=D1QT6i_2IRCIq zjy!Ha&-6R5`8hxhHLkQwi#XV%O=2Gq9Bb$6p4ldGnLH$p-)vE0`XsK7R@n492d+tR zeE9>QiHUD^t#KG87U5y3NXn_B7=pWgoZ42`^JD0^Hi;1xWfWODrcL_FI31l9Io_|H zuak6jS`cV4k@R$05v!SWb=R|k_L`X1by^n8)OqImI6@KlrH^oj9O+aR+|7@;_jScC zbgBu*#ZNkpN} zJJS@!if1WLhmw}xGy}iG`_^r74mSgj&^Vuj_KW=G2EHB(eQxYFX5txqXgY0ospUF8 z?H(+NXez$sSQTb7IXH1C^$MuvQT~5HN$r!iB$c9~lu7Q%CXsgCl> zqpio&kMAEB7|e$-AHnH#cqGezG*Vp4`Dxo{RAG!lLL7=lIySJ#h3CTTfN5) z9VRb^vKz_nK&uUlkT3j;`1GHchU%yid@oV0@v64`vbG%7R-@YLAGa86hOimI=J3W; NHTS3_E;`4X{0{6<=7azM delta 266 zcmey%dz-0#IWI340}#~ttjNp)(vLwL7+{4mKHC5p(-~42QW$d>av7r-85vTTQkZj? za+#x;85x)uQW;ZNQkhd&*RU;PW?)zi#1N3q7{!vp9?YQ0@e(AU$#{#UAh9IlB_ouR zVVs|vdW)e5WD!U*FEKaOPm}c)cS=@bUV6S>X;Dsb5y-S#tYw+0<;7rylMl1_TJQk{ vL8cUY0f`UHjEsyo7-TM>q6ZA(7f{g$w#f%rMFc)DGx0MuaD!kG4^Te<01rC# diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 2f0989c7137f7930a63ea76fcd63516c4974e834..148300c0f53524e7606e0d0d573b660c62874813 100644 GIT binary patch literal 13512 zcmcgSTWlNGl{0)(96t0SB}derR z7%J7a=~n4_+teGZoyOj>h3l1Tch}u7qu7rYXn_>y7Bj>EA_fp(>>?il_CwydKsMNq zJ?9QN}D z0XDRlkTi$Dr#N1KrcQys#tR~kW8i?O}nZcC=+4}6rD}0l-=IA zYO%Q+S6Vl!t$q179=)Wr?pIs)FSi~pv>sMkkE*Rl7mpR2+ixDcaWEfy=b+NuuQvC~ zO#de<{`OUhb~jc)kqImguUet(lTSW*;t72Jz;_Sa`pV(~#nY{Nx@B9p*a-8ux&Lqr z_3M^~!#&ntvoyf9DQp%|fj;u^Tc0@kp~Za8l0j1x2DjmfZD8b_p z_X1d;icDyk=`Jwc`6DvZtuR|uX3Nhbj~nH!`;@KwRDjIBWzTTIGyFlL>={-(Csof$ z*>>_{XicrXVP~NRH>{c|>$fQjIxugrD)v@RfLq5pK<;G4!Y05Xy8&y*W$PQz3%TkQ z`=iq!@Z#x=SX%W~K)|MA1OQp}oybz7ykS7uFrWft29`a$3Z7lCaL+EqvrqNxlWqHi ze!w;@(hCK6z8@E0aI@UWQRb7Lr>iL;N7vL^)|Nvx^2KVxLINO3@|1HFHHF(iQjV68 z`%7ucRnD2^TFz>IpSOLXF)V-57LO$UoQIpKs<@iS8BLIX7p2-^Mv|4fuP?Wa>OkpCQVrMpu2A#-oHvhhx-RADDYje)`JQs#ytZNL^?#TT zKtGMS0OYgtO}VD$p@aG6oHHLT_gVd$YraC6$Cz))HLgP~O}XY=(-cZvk|1x)vwF_@ ziRYSfK0PNLFSnqmT#I?{q?7B{ae>=TGd4|Ke2y{9HNAmlhI+Fu zaWy@yi`QfxQTj@yt6I&l&PX*qfZe(FjB4?G&xgk~-*$q%(K31goC+o`Mv2Le#=GVw z21PWQkTe�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 Yd^JE@1HKwygHb_H+RU#;GjiGcf5**&(EtDd literal 1364 zcmZ`(&1)M+6ra_{dS%J>I!@|TuH%hUh$T{~CE!96oTjQ1+$K)2Q%ZHHP<`ZWm8k%0`!Liw{@%1fY!tdJefM7AQl zBhn19Sa6Ea_&F0f52+#t(zs7E+?3c%aE4Gt31lN`$cK>CaWDY%0l$fN{S4(m*^98b zAQFOZ8L?*M5b8J{)dItqY^-hhUDsF{YgW-QOsAxII>p%0Y9=dd7Ae9|G&s?)vtv@? z*w|s3fmzYCyi6leblsZSXw)-0vAJ^Fb$=8ZkKM6~%pxUIZ^SlnP1|O53-h^$TMxcl z&pj%9yS|;<+IaYARqQc33p$+=>?7eip%aJUUFM01nxMCzWy{3I+3Mq}!>U=li}flaG+QI|spl4T zob~hDF>%eyxOE;8-gFVJ3n+Sj0BfPw>ijEJJ5jZcy4+QlU#Tl6>PkmVchz)jqo<{g zem>D|w6z=i>HYM}h1U1|$#c)H{hoQ2Y2^ko`uNk{{Bl3J(n+Sf$#g%t+)aMbNnYMq_I%*JNq0Rq;#`{q1K zoZG+JUbuEt>cm&O@zr)@^%Rl>0Md)F(RpB8KAS2P06puedS=OZWkMOW!1G(l6GL;A z0R;Uy__hCq=p}LW6JT$Zn>I137G9%QKox%h?+LI$Sdye3inaW&hh|#-_ZofP9$!6l Xr9HlS=yH2}4P;gNa)91vejfh;`i@)& diff --git a/core/admin.py b/core/admin.py index 8c38f3f..29d545a 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,16 @@ from django.contrib import admin -# Register your models here. +from .models import Event + +admin.site.site_header = 'Business Calendar Admin' +admin.site.site_title = 'Business Calendar' +admin.site.index_title = 'Manage your event schedule' + + +@admin.register(Event) +class EventAdmin(admin.ModelAdmin): + list_display = ('name', 'location', 'start', 'end', 'is_published') + list_filter = ('is_published', 'start') + search_fields = ('name', 'location', 'summary') + readonly_fields = ('created_at', 'updated_at') + ordering = ('start',) diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..97aca69 --- /dev/null +++ b/core/forms.py @@ -0,0 +1,49 @@ +from django import forms + +from .models import Event + + +class EventForm(forms.ModelForm): + start = forms.DateTimeField( + input_formats=['%Y-%m-%dT%H:%M'], + widget=forms.DateTimeInput( + attrs={'type': 'datetime-local', 'class': 'form-control'}, + format='%Y-%m-%dT%H:%M', + ), + ) + end = forms.DateTimeField( + input_formats=['%Y-%m-%dT%H:%M'], + widget=forms.DateTimeInput( + attrs={'type': 'datetime-local', 'class': 'form-control'}, + format='%Y-%m-%dT%H:%M', + ), + ) + + class Meta: + model = Event + fields = ['name', 'location', 'start', 'end', 'event_url', 'summary', 'is_published'] + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Downtown Spring Market'}), + 'location': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Riverfront Pavilion'}), + 'event_url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': 'https://event-page.example.com'}), + 'summary': forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': 'Anything visitors should know before attending.'}), + 'is_published': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + } + help_texts = { + 'is_published': 'Only published events appear in the public calendar and embed widget.', + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field_name in ['start', 'end']: + value = self.initial.get(field_name) or getattr(self.instance, field_name, None) + if value: + self.initial[field_name] = value.strftime('%Y-%m-%dT%H:%M') + + def clean(self): + cleaned_data = super().clean() + start = cleaned_data.get('start') + end = cleaned_data.get('end') + if start and end and end <= start: + self.add_error('end', 'End time must be after the start time.') + return cleaned_data diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..c7f419a --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.7 on 2026-04-02 14:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Event', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=150)), + ('slug', models.SlugField(blank=True, max_length=180, unique=True)), + ('location', models.CharField(blank=True, max_length=180)), + ('start', models.DateTimeField()), + ('end', models.DateTimeField()), + ('event_url', models.URLField(blank=True)), + ('summary', models.TextField(blank=True)), + ('is_published', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['start', 'name'], + }, + ), + ] diff --git a/core/migrations/0002_seed_demo_events.py b/core/migrations/0002_seed_demo_events.py new file mode 100644 index 0000000..3bccdd4 --- /dev/null +++ b/core/migrations/0002_seed_demo_events.py @@ -0,0 +1,69 @@ +# Generated by Django 5.2.7 on 2026-04-02 14:57 + +import datetime + +from django.db import migrations +from django.utils import timezone + + +def seed_events(apps, schema_editor): + Event = apps.get_model('core', 'Event') + if Event.objects.exists(): + return + + tz = timezone.get_current_timezone() + demo_events = [ + { + 'name': 'Spring Market Pop-Up', + 'slug': 'spring-market-pop-up-2026-04-10', + 'location': 'Riverfront Pavilion', + 'start': timezone.make_aware(datetime.datetime(2026, 4, 10, 10, 0), tz), + 'end': timezone.make_aware(datetime.datetime(2026, 4, 10, 15, 0), tz), + 'event_url': 'https://example.com/events/spring-market-pop-up', + 'summary': 'Browse seasonal specials, meet the team, and pick up limited weekend offers.', + 'is_published': True, + }, + { + 'name': 'Neighborhood Food Fair', + 'slug': 'neighborhood-food-fair-2026-04-16', + 'location': 'Main Street Square', + 'start': timezone.make_aware(datetime.datetime(2026, 4, 16, 11, 30), tz), + 'end': timezone.make_aware(datetime.datetime(2026, 4, 16, 18, 0), tz), + 'event_url': 'https://example.com/events/neighborhood-food-fair', + 'summary': 'A full afternoon of tastings, live music, and a simple way for visitors to find your booth.', + 'is_published': True, + }, + { + 'name': 'Weekend Makers Showcase', + 'slug': 'weekend-makers-showcase-2026-04-25', + 'location': 'Cedar Hall', + 'start': timezone.make_aware(datetime.datetime(2026, 4, 25, 9, 0), tz), + 'end': timezone.make_aware(datetime.datetime(2026, 4, 25, 14, 30), tz), + 'event_url': 'https://example.com/events/weekend-makers-showcase', + 'summary': 'An example public event showing how names, times, and external links appear in the widget.', + 'is_published': True, + }, + ] + Event.objects.bulk_create([Event(**item) for item in demo_events]) + + +def remove_seeded_events(apps, schema_editor): + Event = apps.get_model('core', 'Event') + Event.objects.filter( + slug__in=[ + 'spring-market-pop-up-2026-04-10', + 'neighborhood-food-fair-2026-04-16', + 'weekend-makers-showcase-2026-04-25', + ] + ).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.RunPython(seed_events, remove_seeded_events), + ] diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c28f760829992dae111b969c13121764d6cf9c53 GIT binary patch literal 1744 zcmah}J4_=-7@l3fje%WWeqduEouc>zHUuK!=#(gi7;(D00?0*jvsyfZSy=D-_Q63a zq@;+H=BmU+imQksC@4}~NqJQ?E3G1Hs*_UD`NUOMWoFkVaE_8$&-nl6`)2;f{{Q|u zJRApfTz&V4@?#KyznIb=p`LL4fC@hY2q33|CRgK{e2wP-57q(XzXvdYxcxx?&?%M9 zZnRne2Dd;Vbj9ETPvT9bVaS%EYbFUdb%<26-apCy#h!BgC&x@8P~#w|@sO(p8lV>B z0SfJh`v)Ko19T^{AHCV(AP0jmbRIsX!JN_Qk6j){JZ7xF1Ec4$Q)=Uk&KpkL<9G>S zd-Sd$$ekzdhD7fg{@^akJ0Cga>1}lSgR(|F)=+=vlnQ5k1yEf0A6)eCME?l&WNg6W zagQ_npPUJgGjfYFIdIR^U00~ zuX_bq;Eiz#_-M$qWWyo>q(Krx%tOgG)B@*GNW`?8%qNmiOsQq>sEWCVAhjHUXjisX zi^O~GOR`0`{fIRExgW7c{G z>kpEsD2*>^#;eR2iLLI*Mo-+Lsq@9*HR|xT(nP)*{d)6JkF$-AEMH7ibX`TV=4)}i zg?f$t8@Q%+!22&sdwLU<>>XRP>=HUcb!ycpJ=cu`vnAJ2sjeHS^v?&Pw6L(SDD`3~ zwvG+f_};<|_G~J|$1*;<_ak|i+a?^#2Eu1bp>9U(HW8t&gK=5+>^)^m#I_XU}y@`db zjyR2T^X;c@ZvJBJatr4+oZN<++rYv@Y7}RR?T_3{(a9{hnFTB?cEl;1nrnyM)SQzl zx~U=-N*yuV*4w+k=of2vrtHj=-5K9(250BmRX00#v5d1TPIkr3u3(|u5%YL<`Q?;5 zyZmY!&sLn-iaT4u!fHp%;rvqj(9JJhPT+ji$yeQc6$|UP0D2-{aq<;6&rWociHVVJ z7KqdBZ(i==?0h%CPmXl+W3l*KK$mxH=>{go!Y2>eV|>z2{S(@tF}N>l4ZR3=yqfaL z$bUJDjOKBdsPsPiz^~BHjZdk{zO?33x^x2^$92PiD`4>P;IA8==E7YtxV(tF!o9Z) Kc=s=;XZLRqn$yhy literal 0 HcmV?d00001 diff --git a/core/migrations/__pycache__/0002_seed_demo_events.cpython-311.pyc b/core/migrations/__pycache__/0002_seed_demo_events.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2a10b75417b52efd90c04af2445da170f36f3463 GIT binary patch literal 3563 zcmb_eT}&I<6}~eb+hbz_!FGTWl1rL~xWt&CY&Ke@*(7bUt1XZOQng}LS!3U8dtf}{ zy)y&B0?DnAnqBp0`%onheL$-%ZAB~fF^_rVslw4nYmKy0rK%6~$tzZB#Z%86|9}&^ zG^^g3IdlKd-*@i0zwho2BPfyezp4XJ`#W#6ioaRe`w>*0AcBaXp?UrnHK8cZivq9v zG+$Agm!K}Kqj^8^0}Y7i9Yg}_hyzDAi35@8M{T5*8?BlVNVA7GwBIhMH|q3wGe@bo3L`Lz4{~RLuo;1&D?1B zw|e-06TYo>2NMaJLsnaUgchXMVCT#=k@$#2{2g;!_Cr>8YZdGeXw^x81c?k!XcJ~` zwP&xsB=^_YGkn~hq>DsIH#zZJne>oea`Lr#^pPlu{h#ITBk^P9K1KSQau1L}@{QM) zdx)Gqj>p&7bND!0kTc|LL+-q9&DVj$L+END1EI%KP9S()UiE$NB}{Gtg16cz<`D=n zG&18#S%Xp6e`ks6Ryj6TVyd3U(+XRp7QSzkl0PWzpEbSSWYOzQmY~-zB_}5)uO=t1 zBri{tqqFJ~WjSW(utr%@H3&uZpCI5ahy5-;DHso%T^@zyRKc=J=3A*0eWVmi8XeCX z#T4cI&D2qx%2T(Qv20RoQpGfMMZ;!^W>rNqFX18uF|7i{7FCLuu%Z)OQnQQLE@4eA zsum@9nbJiH{YEZFnK}M2A-j^U6saqjnw@uL&B%IZhwC>jg;}l-Cc7a|p0v$0H)z^L z(0s*>sAjrkXEfC;P*OfML)CmCW3Yl@5Pa7oipus!^h5p09P}r1(0^dAtL46FMb+_~ z#klZuAJ__`yS>~zU7VcZq>mfw@>dw@XfEaVZ{eJ+X;{fwl<9_{gWA|qObfgU=F-4V zxM-VdwqYv;n<{q!URG9c&R}>+HC4-CCbkTmQ^9I0hRtxsFs#COx&MbvQ%)<3U`jk! zFqX55N%zk+Z43zE14*+v&{lf#jdJ+Alqd|pt7zKpAXhQO$qBCK{5Ux1BW@Rfkf!R3CRR!%3POQ5IEI#0lBd>qwjCuP z9FMXne0vzkZ-gf(51He~;Wa@-AHpfGqE+Ej;g`a%#h-UUJPCh9jQcO)bH(uqR{{VQ z8Q;R>xd-FQ{=2DyQKTt5W9ya;5kRvx*gLgsutl?^WNC_rW9lHGr6wjOCetRRBu!}1 zNH^kSytJ|(z79yVAa+VO%4fX^&9Uno(VBT<{9rG-fUC)PV7oCsA!{=Rv72tCT}a1(g0!*> zuy$plSd@>rT^U~FM>nW;II=Mt|P(U zQ*apo?Ur<^7-v;ar;&^ zG2LMPS^U)P99@tqk+oz>o*1D z53f&uCJn!khT9N=%CjLL)iZY-pweAOy1UbQ**C#s9k(-%ChvCkF;rgXZnh?_ipSl} z@^IJuMEH}`nE^LAzgJ4pq?kJlq>{z^JVgJ*y%n0 N1t0kGh*3}6{{TgRdZ_>a literal 0 HcmV?d00001 diff --git a/core/models.py b/core/models.py index 71a8362..d2892a0 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,40 @@ +from django.core.exceptions import ValidationError from django.db import models +from django.template.defaultfilters import slugify +from django.utils import timezone -# Create your models here. + +class Event(models.Model): + name = models.CharField(max_length=150) + slug = models.SlugField(max_length=180, unique=True, blank=True) + location = models.CharField(max_length=180, blank=True) + start = models.DateTimeField() + end = models.DateTimeField() + event_url = models.URLField(blank=True) + summary = models.TextField(blank=True) + is_published = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['start', 'name'] + + def __str__(self): + return f"{self.name} ({timezone.localtime(self.start):%b %d, %Y})" + + def clean(self): + super().clean() + if self.end <= self.start: + raise ValidationError({'end': 'End time must be after the start time.'}) + + def save(self, *args, **kwargs): + if not self.slug: + local_start = timezone.localtime(self.start) if timezone.is_aware(self.start) else self.start + base_slug = slugify(f"{self.name}-{local_start:%Y-%m-%d}")[:170] or 'event' + slug = base_slug + index = 2 + while Event.objects.exclude(pk=self.pk).filter(slug=slug).exists(): + slug = f"{base_slug[:165]}-{index}" + index += 1 + self.slug = slug + super().save(*args, **kwargs) diff --git a/core/templates/base.html b/core/templates/base.html index 1e7e5fb..d15912d 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -1,25 +1,85 @@ +{% load static %} - - {% block title %}Knowledge Base{% endblock %} - {% if project_description %} - - - + + {% block title %}{{ page_title|default:project_name }}{% endblock %} + + {% if noindex %} + {% endif %} {% if project_image_url %} {% endif %} - {% load static %} + + + + {% block head %}{% endblock %} + + {% if not embedded %} +
+
+ + {% endif %} + + {% if messages and not embedded %} +
+
+ {% for message in messages %} + + {% endfor %} +
+
+ {% endif %} - {% block content %}{% endblock %} - + {% if not embedded %} +
+
+
+ + +
+ +
+
+ {% endif %} + + + + {% block scripts %}{% endblock %} + diff --git a/core/templates/core/calendar_embed.html b/core/templates/core/calendar_embed.html new file mode 100644 index 0000000..c177e1b --- /dev/null +++ b/core/templates/core/calendar_embed.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block title %}Embeddable Calendar Widget{% endblock %} + +{% block content %} +
+
+
+
+
+ Embeddable widget +

Where to find us

+

Tap a date to see the event name, time window, and event link.

+
+ Open full page +
+ {% include "core/includes/calendar_widget.html" with calendar_variant="embed" %} +
+
+
+{% endblock %} diff --git a/core/templates/core/calendar_page.html b/core/templates/core/calendar_page.html new file mode 100644 index 0000000..01b936d --- /dev/null +++ b/core/templates/core/calendar_page.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block title %}Public Calendar | Roadshow Calendar{% endblock %} + +{% block content %} +
+
+
+
+ Live schedule +

See where the business will be on any day

+

This full-page calendar mirrors the embeddable widget and stays read-only for visitors.

+
+ +
+ {% include "core/includes/calendar_widget.html" with calendar_variant="full" %} +
+
+{% endblock %} diff --git a/core/templates/core/event_confirm_delete.html b/core/templates/core/event_confirm_delete.html new file mode 100644 index 0000000..0d87d55 --- /dev/null +++ b/core/templates/core/event_confirm_delete.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% block title %}Delete {{ event.name }} | Roadshow Calendar{% endblock %} + +{% block content %} +
+
+
+
+
+ Delete event +

Remove {{ event.name }}?

+

This removes the event from the custom dashboard, the public calendar, and the embed widget. This action cannot be undone.

+ +
+
+ Starts + {{ event.start|date:"M j, Y g:i A" }} +
+
+ Ends + {{ event.end|date:"M j, Y g:i A" }} +
+
+ Location + {{ event.location|default:"Location announced soon" }} +
+
+ +
+ {% csrf_token %} +
+ + Keep event +
+
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/event_dashboard.html b/core/templates/core/event_dashboard.html new file mode 100644 index 0000000..7eadcb2 --- /dev/null +++ b/core/templates/core/event_dashboard.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} + +{% block title %}Manage Events | Roadshow Calendar{% endblock %} + +{% block content %} +
+
+
+
+ Staff workspace +

Manage your public event calendar securely

+

Only staff members can reach this area. Create events here or jump into Django Admin for bulk edits.

+
+ +
+ +
+
+
+

All events

+

{{ dashboard_count }} scheduled item{{ dashboard_count|pluralize }} currently in the system.

+
+
+
+ + + + + + + + + + + + {% for event in events %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
EventDateLocationStatusActions
+ {{ event.name }}
+ {{ event.start|date:"g:i A" }} – {{ event.end|date:"g:i A" }} +
{{ event.start|date:"M j, Y" }}{{ event.location|default:"—" }} + {% if event.is_published %} + Published + {% else %} + Draft + {% endif %} + +
+ Review + Edit + Delete +
+
+
+

No events yet

+

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

+ Create event +
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/event_dashboard_detail.html b/core/templates/core/event_dashboard_detail.html new file mode 100644 index 0000000..8a8f515 --- /dev/null +++ b/core/templates/core/event_dashboard_detail.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block title %}{{ event.name }} | Staff Review{% endblock %} + +{% block content %} +
+
+
+
+
+
+
+ Saved successfully +

{{ event.name }}

+
+ {% if event.is_published %} + Live on the public calendar + {% else %} + Saved as draft + {% endif %} +
+
+
+ Starts + {{ event.start|date:"M j, Y g:i A" }} +
+
+ Ends + {{ event.end|date:"M j, Y g:i A" }} +
+
+ Location + {{ event.location|default:"Location announced soon" }} +
+
+
+

{{ event.summary|default:"No additional staff notes yet." }}

+
+ +
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/event_detail.html b/core/templates/core/event_detail.html new file mode 100644 index 0000000..6fa986f --- /dev/null +++ b/core/templates/core/event_detail.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% block title %}{{ event.name }} | Roadshow Calendar{% endblock %} + +{% block content %} +
+
+
+
+
+ Event detail +

{{ event.name }}

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

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

+
+
+ {% if event.event_url %} + Visit event page + {% endif %} + Back to calendar month + Back to event list +
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/event_form.html b/core/templates/core/event_form.html new file mode 100644 index 0000000..f90b3fe --- /dev/null +++ b/core/templates/core/event_form.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }} | Roadshow Calendar{% endblock %} + +{% block content %} +
+
+
+
+
+
+ {% if form_mode == 'edit' %}Edit event{% else %}Create event{% endif %} +

{{ form_title }}

+

{{ form_intro }}

+
+
+ {% csrf_token %} +
+ {% for field in form %} +
+ {% if field.name == 'is_published' %} +
+ {{ field }} + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} +
+ {% else %} + + {{ field }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {% endif %} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} +
+ {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} +
+ + Cancel +
+
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/event_list.html b/core/templates/core/event_list.html new file mode 100644 index 0000000..55b2840 --- /dev/null +++ b/core/templates/core/event_list.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} + +{% block title %}Upcoming Events | Roadshow Calendar{% endblock %} + +{% block content %} +
+
+
+ Public event list +

All published stops in one clean list

+

Visitors can browse every published event even if they prefer a list over the calendar grid.

+
+
+ {% for event in events %} +
+
+
{{ event.start|date:"M j, Y" }}
+

{{ event.name }}

+

{{ event.start|date:"g:i A" }} – {{ event.end|date:"g:i A" }}

+

{{ event.location|default:"Location announced soon" }}

+

{{ event.summary|default:"Extra details will be shared here when available."|truncatechars:150 }}

+
+ View details + {% if event.event_url %} + External event page + {% endif %} +
+
+
+ {% empty %} +
+
+

No published events yet

+

Once your team adds and publishes events, visitors will see them here and in the embeddable calendar.

+
+
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/core/templates/core/includes/calendar_widget.html b/core/templates/core/includes/calendar_widget.html new file mode 100644 index 0000000..7555b3f --- /dev/null +++ b/core/templates/core/includes/calendar_widget.html @@ -0,0 +1,53 @@ +
+
+
+

Click any date to view event details

+

{{ month_label }}

+
+
+ Previous + Today + Next +
+
+
+
+ Sun + Mon + Tue + Wed + Thu + Fri + Sat +
+
+ {% for week in calendar_weeks %} + {% for day in week %} + + {% endfor %} + {% endfor %} +
+
+
+{{ calendar_events|json_script:"calendar-events-data" }} + diff --git a/core/templates/core/index.html b/core/templates/core/index.html index faec813..daa4bd5 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -1,145 +1,143 @@ {% extends "base.html" %} -{% block title %}{{ project_name }}{% endblock %} - -{% block head %} - - - - -{% endblock %} +{% block title %}Roadshow Calendar | Secure Event Calendar{% endblock %} {% block content %} -
-
-

Analyzing your requirements and generating your app…

-
- Loading… +
+
+
+
+ Embeddable calendar widget +

Show visitors exactly where your business will be on any day.

+

A polished public calendar for your website, plus secure staff-only tools for publishing event dates, times, and direct event links.

+ +
+
+
+ Secure updates + Only logged-in staff can add or change events. +
+
+
+
+ Click any day + Visitors get event names, times, and helpful links in a modal. +
+
+
+
+ Embed ready + Drop the iframe into your existing HTML website in minutes. +
+
+
+
+
+
+
+
+

Next live dates

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

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

-

This page will refresh automatically as the plan is implemented.

-

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

-
-
- Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC) -
-{% endblock %} \ No newline at end of file + + +
+
+
+
+ Public calendar +

A visitor-friendly monthly schedule

+

Each day opens a clean detail modal so guests can confirm where you will be and jump straight to the event page.

+
+ +
+ {% include "core/includes/calendar_widget.html" with calendar_variant="landing" %} +
+
+ +
+
+
+ Upcoming stops +

Events your audience can scan in seconds

+
+
+ {% for event in upcoming_events %} +
+
+
{{ event.start|date:"D, M j" }}
+

{{ event.name }}

+

{{ event.start|date:"g:i A" }} – {{ event.end|date:"g:i A" }}

+

{{ event.location|default:"Location announced soon" }}

+

{{ event.summary|default:"Event details will appear here as soon as your team adds them."|truncatechars:120 }}

+
+ Details + {% if event.event_url %} + Event page + {% endif %} +
+
+
+ {% empty %} +
+
+

No public events yet

+

Create events from the secure dashboard or Django admin, then they will automatically appear on the landing page and embed widget.

+ Staff login +
+
+ {% endfor %} +
+
+
+ +
+
+
+
+
+ Embed on your HTML site +

Paste one iframe and you are live

+

Use the ready-made widget page below inside your existing HTML website. It stays read-only for visitors, while your team updates dates privately.

+ +
+
+
+
+
+ Embed snippet + +
+
{{ iframe_snippet }}
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/registration/login.html b/core/templates/registration/login.html new file mode 100644 index 0000000..933ec3c --- /dev/null +++ b/core/templates/registration/login.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} + +{% block title %}Staff Login | Roadshow Calendar{% endblock %} + +{% block content %} +
+
+
+
+
+
+ Staff only +

Secure sign in

+

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

+
+
+ {% csrf_token %} + {% for field in form %} +
+ + {{ field }} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} + + +
+
+
+
+
+
+{% endblock %} diff --git a/core/tests.py b/core/tests.py index 7ce503c..20438a9 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1,3 +1,90 @@ -from django.test import TestCase +from datetime import datetime -# Create your tests here. +from django.contrib.auth.models import User +from django.test import Client, TestCase +from django.urls import reverse +from django.utils import timezone + +from .models import Event + + +class EventModelTests(TestCase): + def test_slug_is_created_on_save(self): + event = Event.objects.create( + name='City Market', + start=timezone.make_aware(datetime(2026, 4, 10, 10, 0)), + end=timezone.make_aware(datetime(2026, 4, 10, 14, 0)), + ) + self.assertTrue(event.slug.startswith('city-market-2026-04-10')) + + +class CalendarViewTests(TestCase): + def setUp(self): + Event.objects.create( + name='Riverfront Market', + location='Riverfront Pavilion', + start=timezone.make_aware(datetime(2026, 4, 10, 10, 0)), + end=timezone.make_aware(datetime(2026, 4, 10, 16, 0)), + is_published=True, + ) + + def test_home_page_renders(self): + response = self.client.get(reverse('home')) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Show visitors exactly where your business will be on any day.') + + def test_staff_dashboard_requires_login(self): + response = self.client.get(reverse('event_dashboard')) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse('login'), response.url) + + def test_staff_dashboard_for_staff_user(self): + staff = User.objects.create_user(username='staffer', password='pass12345', is_staff=True) + client = Client() + client.login(username='staffer', password='pass12345') + response = client.get(reverse('event_dashboard')) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Manage your public event calendar securely') + + +class EventDashboardMutationTests(TestCase): + def setUp(self): + self.staff = User.objects.create_user(username='staffer', password='pass12345', is_staff=True) + self.event = Event.objects.create( + name='Editable Market', + location='Town Square', + start=timezone.make_aware(datetime(2026, 4, 12, 9, 0)), + end=timezone.make_aware(datetime(2026, 4, 12, 12, 0)), + is_published=True, + ) + + def test_staff_can_open_edit_page(self): + self.client.login(username='staffer', password='pass12345') + response = self.client.get(reverse('event_edit', args=[self.event.slug])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Save changes') + + def test_staff_can_update_event(self): + self.client.login(username='staffer', password='pass12345') + response = self.client.post( + reverse('event_edit', args=[self.event.slug]), + { + 'name': 'Updated Market', + 'location': 'Town Square', + 'start': '2026-04-12T09:00', + 'end': '2026-04-12T13:00', + 'event_url': 'https://example.com/event', + 'summary': 'Updated details', + 'is_published': 'on', + }, + ) + self.assertRedirects(response, reverse('event_dashboard_detail', args=[self.event.slug])) + self.event.refresh_from_db() + self.assertEqual(self.event.name, 'Updated Market') + self.assertEqual(self.event.summary, 'Updated details') + + def test_staff_can_delete_event(self): + self.client.login(username='staffer', password='pass12345') + response = self.client.post(reverse('event_delete', args=[self.event.slug])) + self.assertRedirects(response, reverse('event_dashboard')) + self.assertFalse(Event.objects.filter(pk=self.event.pk).exists()) diff --git a/core/urls.py b/core/urls.py index 6299e3d..4f0ba4c 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,7 +1,34 @@ +from django.contrib.auth import views as auth_views from django.urls import path -from .views import home +from .views import ( + calendar_embed, + calendar_page, + event_create, + event_dashboard, + event_dashboard_detail, + event_delete, + event_detail, + event_edit, + event_list, + home, +) urlpatterns = [ - path("", home, name="home"), + path('', home, name='home'), + path('calendar/', calendar_page, name='calendar_page'), + path('calendar/embed/', calendar_embed, name='calendar_embed'), + path('events/', event_list, name='event_list'), + path('events//', event_detail, name='event_detail'), + path('dashboard/events/', event_dashboard, name='event_dashboard'), + path('dashboard/events/new/', event_create, name='event_create'), + path('dashboard/events//edit/', event_edit, name='event_edit'), + path('dashboard/events//delete/', event_delete, name='event_delete'), + path('dashboard/events//', event_dashboard_detail, name='event_dashboard_detail'), + path( + 'login/', + auth_views.LoginView.as_view(template_name='registration/login.html', redirect_authenticated_user=True), + name='login', + ), + path('logout/', auth_views.LogoutView.as_view(), name='logout'), ] diff --git a/core/views.py b/core/views.py index c9aed12..a5bd905 100644 --- a/core/views.py +++ b/core/views.py @@ -1,25 +1,286 @@ -import os -import platform +import calendar +from collections import defaultdict +from datetime import datetime, time, timedelta -from django import get_version as django_version -from django.shortcuts import render +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from django.utils import timezone +from .forms import EventForm +from .models import Event + + +PROJECT_NAME = 'Roadshow Calendar' +DEFAULT_META_DESCRIPTION = 'Track where your business will be each day with a polished public calendar and secure staff-only event management.' + + +def _parse_month(month_value: str | None): + today = timezone.localdate() + if month_value: + try: + parsed = datetime.strptime(month_value, '%Y-%m').date() + return parsed.replace(day=1) + except ValueError: + pass + return today.replace(day=1) + + +def _next_month(month_start): + return (month_start.replace(day=28) + timedelta(days=4)).replace(day=1) + + +def _previous_month(month_start): + return (month_start - timedelta(days=1)).replace(day=1) + + +def _build_month_context(month_value=None): + month_start = _parse_month(month_value) + next_month = _next_month(month_start) + tz = timezone.get_current_timezone() + range_start = timezone.make_aware(datetime.combine(month_start, time.min), tz) + range_end = timezone.make_aware(datetime.combine(next_month, time.min), tz) + + events = list( + Event.objects.filter(is_published=True, start__lt=range_end, end__gte=range_start).order_by('start', 'name') + ) + + event_map = defaultdict(list) + for event in events: + start_local = timezone.localtime(event.start) + end_local = timezone.localtime(event.end) + current_day = start_local.date() + final_day = end_local.date() + while current_day <= final_day: + event_map[current_day.isoformat()].append( + { + 'name': event.name, + 'location': event.location, + 'time': f"{start_local:%I:%M %p} – {end_local:%I:%M %p}" if start_local.date() == end_local.date() else f"{start_local:%b %d, %I:%M %p} – {end_local:%b %d, %I:%M %p}", + 'summary': event.summary, + 'event_url': event.event_url, + 'detail_url': reverse('event_detail', kwargs={'slug': event.slug}), + } + ) + current_day += timedelta(days=1) + + month_calendar = calendar.Calendar(firstweekday=6) + today = timezone.localdate() + calendar_weeks = [] + for week in month_calendar.monthdatescalendar(month_start.year, month_start.month): + week_days = [] + for day in week: + iso = day.isoformat() + day_events = event_map.get(iso, []) + week_days.append( + { + 'date': day, + 'iso': iso, + 'day': day.day, + 'in_month': day.month == month_start.month, + 'is_today': day == today, + 'event_count': len(day_events), + 'has_events': bool(day_events), + } + ) + calendar_weeks.append(week_days) + + return { + 'calendar_weeks': calendar_weeks, + 'calendar_events': dict(event_map), + 'month_label': month_start.strftime('%B %Y'), + 'month_value': month_start.strftime('%Y-%m'), + 'prev_month': _previous_month(month_start).strftime('%Y-%m'), + 'next_month': next_month.strftime('%Y-%m'), + 'today_value': today.strftime('%Y-%m'), + } + + +def _base_context(**extra): + context = { + 'project_name': PROJECT_NAME, + 'meta_description': DEFAULT_META_DESCRIPTION, + } + context.update(extra) + return context + + +@login_required(login_url='login') +def event_dashboard(request): + if not request.user.is_staff: + raise PermissionDenied + events = Event.objects.all().order_by('start', 'name') + return render( + request, + 'core/event_dashboard.html', + _base_context( + page_title='Manage events', + events=events, + dashboard_count=events.count(), + ), + ) + + +@login_required(login_url='login') +def event_create(request): + if not request.user.is_staff: + raise PermissionDenied + + if request.method == 'POST': + form = EventForm(request.POST) + if form.is_valid(): + event = form.save() + messages.success(request, 'Event saved and ready for the public calendar.') + return redirect('event_dashboard_detail', slug=event.slug) + else: + form = EventForm() + + return render( + request, + 'core/event_form.html', + _base_context( + page_title='Add event', + form=form, + form_mode='create', + form_title='Add a new stop to the calendar', + form_intro='Once saved, published events immediately power the landing page, public calendar, and embeddable widget.', + submit_label='Save event', + ), + ) + + +@login_required(login_url='login') +def event_edit(request, slug): + if not request.user.is_staff: + raise PermissionDenied + + event = get_object_or_404(Event, slug=slug) + if request.method == 'POST': + form = EventForm(request.POST, instance=event) + if form.is_valid(): + event = form.save() + messages.success(request, 'Event updated successfully.') + return redirect('event_dashboard_detail', slug=event.slug) + else: + form = EventForm(instance=event) + + return render( + request, + 'core/event_form.html', + _base_context( + page_title=f'Edit {event.name}', + form=form, + event=event, + form_mode='edit', + form_title='Update this calendar stop', + form_intro='Change dates, copy, publication status, or the event link without leaving the custom dashboard.', + submit_label='Save changes', + ), + ) + + +@login_required(login_url='login') +def event_delete(request, slug): + if not request.user.is_staff: + raise PermissionDenied + + event = get_object_or_404(Event, slug=slug) + if request.method == 'POST': + event_name = event.name + event.delete() + messages.success(request, f'{event_name} was deleted.') + return redirect('event_dashboard') + + return render( + request, + 'core/event_confirm_delete.html', + _base_context( + page_title=f'Delete {event.name}', + event=event, + ), + ) + + +@login_required(login_url='login') +def event_dashboard_detail(request, slug): + if not request.user.is_staff: + raise PermissionDenied + event = get_object_or_404(Event, slug=slug) + return render( + request, + 'core/event_dashboard_detail.html', + _base_context( + page_title=event.name, + event=event, + ), + ) + def home(request): - """Render the landing screen with loader and environment details.""" - host_name = request.get_host().lower() - agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" - now = timezone.now() + month_context = _build_month_context(request.GET.get('month')) + upcoming_events = list(Event.objects.filter(is_published=True, end__gte=timezone.now()).order_by('start', 'name')[:6]) + embed_url = request.build_absolute_uri(reverse('calendar_embed')) + iframe_snippet = f'' + return render( + request, + 'core/index.html', + _base_context( + page_title=PROJECT_NAME, + hero_events=upcoming_events[:3], + upcoming_events=upcoming_events, + embed_url=embed_url, + iframe_snippet=iframe_snippet, + **month_context, + ), + ) - context = { - "project_name": "New Style", - "agent_brand": agent_brand, - "django_version": django_version(), - "python_version": platform.python_version(), - "current_time": now, - "host_name": host_name, - "project_description": os.getenv("PROJECT_DESCRIPTION", ""), - "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), - } - return render(request, "core/index.html", context) + +def calendar_page(request): + month_context = _build_month_context(request.GET.get('month')) + return render( + request, + 'core/calendar_page.html', + _base_context( + page_title='Public calendar', + **month_context, + ), + ) + + +def calendar_embed(request): + month_context = _build_month_context(request.GET.get('month')) + return render( + request, + 'core/calendar_embed.html', + _base_context( + page_title='Embeddable calendar', + embedded=True, + **month_context, + ), + ) + + +def event_list(request): + events = Event.objects.filter(is_published=True).order_by('start', 'name') + return render( + request, + 'core/event_list.html', + _base_context( + page_title='Upcoming events', + events=events, + ), + ) + + +def event_detail(request, slug): + event = get_object_or_404(Event, slug=slug, is_published=True) + return render( + request, + 'core/event_detail.html', + _base_context( + page_title=event.name, + event=event, + ), + ) diff --git a/static/css/custom.css b/static/css/custom.css index 925f6ed..75d37a1 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -1,4 +1,803 @@ -/* Custom styles for the application */ -body { - font-family: system-ui, -apple-system, sans-serif; +/* Brand system */ +:root { + --brand-ink: #122023; + --brand-primary: #e76f51; + --brand-primary-dark: #cb5a3e; + --brand-secondary: #0f766e; + --brand-accent: #f3c96b; + --brand-surface: rgba(255, 249, 243, 0.8); + --brand-surface-strong: #fffdf9; + --brand-border: rgba(18, 32, 35, 0.08); + --brand-muted: #5d6c70; + --brand-bg: #f8efe6; + --brand-bg-deep: #f1e6dc; + --brand-shadow: 0 24px 60px rgba(18, 32, 35, 0.12); + --radius-lg: 28px; + --radius-md: 20px; + --radius-sm: 14px; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + font-family: 'Inter', system-ui, sans-serif; + color: var(--brand-ink); + background: + radial-gradient(circle at top left, rgba(243, 201, 107, 0.33), transparent 28%), + radial-gradient(circle at top right, rgba(15, 118, 110, 0.18), transparent 24%), + linear-gradient(180deg, #fffaf4 0%, var(--brand-bg) 52%, #fffaf4 100%); + min-height: 100vh; + position: relative; +} + +h1, +h2, +h3, +h4, +h5, +h6, +.navbar-brand, +.section-title { + font-family: 'Manrope', 'Inter', sans-serif; + letter-spacing: -0.03em; +} + +img { + max-width: 100%; + height: auto; +} + +p, +a, +button, +input, +textarea, +label, +small, +span { + font-family: 'Inter', system-ui, sans-serif; +} + +.page-glow { + position: fixed; + border-radius: 999px; + filter: blur(90px); + opacity: 0.6; + pointer-events: none; + z-index: 0; +} + +.page-glow-one { + width: 280px; + height: 280px; + background: rgba(231, 111, 81, 0.22); + top: 4rem; + left: -4rem; +} + +.page-glow-two { + width: 360px; + height: 360px; + background: rgba(15, 118, 110, 0.15); + right: -5rem; + top: 18rem; +} + +.site-header, +.site-footer, +.hero-section, +.section-shell { + position: relative; + z-index: 1; +} + +.site-header { + padding-top: 1.25rem; +} + +.navbar { + background: rgba(255, 253, 249, 0.78); + border: 1px solid rgba(255, 255, 255, 0.7); + border-radius: 999px; + padding-left: 1.1rem; + padding-right: 1.1rem; + box-shadow: 0 16px 40px rgba(18, 32, 35, 0.08); + backdrop-filter: blur(18px); +} + +.brand-mark { + color: var(--brand-ink); + font-weight: 800; + font-size: 1.2rem; + text-decoration: none; +} + +.brand-mark span { + color: var(--brand-primary); +} + +.nav-link { + color: var(--brand-ink); + font-weight: 600; + opacity: 0.88; +} + +.nav-link:hover, +.nav-link:focus, +.footer-links a:hover, +.footer-links a:focus, +.text-link:hover, +.text-link:focus { + color: var(--brand-secondary); +} + +.calendar-toggler { + border: 0; + box-shadow: none !important; +} + +.message-stack { + margin-top: 1rem; +} + +.hero-section { + padding: 2rem 0 1rem; +} + +.hero-card { + position: relative; + overflow: hidden; + background: linear-gradient(135deg, rgba(18, 32, 35, 0.95), rgba(18, 32, 35, 0.82)); + color: #fff8f1; + border-radius: calc(var(--radius-lg) + 6px); + padding: clamp(2rem, 4vw, 4rem); + min-height: 620px; + box-shadow: var(--brand-shadow); + display: grid; + gap: 2rem; + align-items: center; + grid-template-columns: minmax(0, 1.1fr) minmax(280px, 420px); +} + +.hero-card::before { + content: ''; + position: absolute; + inset: auto auto -80px -60px; + width: 220px; + height: 220px; + border-radius: 42px; + background: linear-gradient(145deg, rgba(231, 111, 81, 0.35), rgba(243, 201, 107, 0.12)); + transform: rotate(18deg); +} + +.hero-card::after { + content: ''; + position: absolute; + top: 40px; + right: 100px; + width: 170px; + height: 170px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); +} + +.hero-copy, +.hero-panel { + position: relative; + z-index: 1; +} + +.eyebrow-pill { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.9rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); + color: inherit; + font-size: 0.86rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.hero-copy h1 { + font-size: clamp(2.7rem, 5vw, 4.8rem); + line-height: 0.97; + margin: 1.2rem 0 1.2rem; + max-width: 10ch; +} + +.hero-lead, +.section-copy, +.footer-copy, +.event-summary, +.detail-body p, +.form-help { + color: rgba(18, 32, 35, 0.74); + line-height: 1.7; +} + +.hero-lead { + color: rgba(255, 248, 241, 0.82); + font-size: 1.08rem; + max-width: 50ch; +} + +.hero-actions, +.compact-actions, +.dashboard-banner-actions { + display: flex; + flex-wrap: wrap; + gap: 0.85rem; + margin-top: 1.7rem; +} + +.btn { + border-radius: 999px; + font-weight: 700; + padding: 0.8rem 1.25rem; + border-width: 1px; + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, color 0.2s ease; +} + +.btn:hover, +.btn:focus { + transform: translateY(-1px); + box-shadow: 0 12px 25px rgba(18, 32, 35, 0.12); +} + +.btn-primary-brand { + background: linear-gradient(135deg, var(--brand-primary), #f08f5b); + color: #fff; + border-color: transparent; +} + +.btn-primary-brand:hover, +.btn-primary-brand:focus { + background: linear-gradient(135deg, var(--brand-primary-dark), var(--brand-primary)); + color: #fff; +} + +.btn-ghost { + background: rgba(255, 255, 255, 0.68); + color: var(--brand-ink); + border-color: rgba(18, 32, 35, 0.08); + backdrop-filter: blur(14px); +} + +.hero-card .btn-ghost { + background: rgba(255, 255, 255, 0.1); + color: #fff8f1; + border-color: rgba(255, 255, 255, 0.18); +} + +.card-surface, +.calendar-card, +.embed-shell-card, +.detail-card, +.dashboard-banner, +.table-card, +.form-card, +.empty-state, +.event-card { + background: rgba(255, 253, 249, 0.82); + border: 1px solid rgba(255, 255, 255, 0.9); + border-radius: var(--radius-lg); + box-shadow: var(--brand-shadow); + backdrop-filter: blur(18px); +} + +.hero-stats { + margin-top: 2rem; +} + +.stat-card { + height: 100%; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: var(--radius-md); + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.stat-card strong, +.mini-event-list strong, +.event-card h2, +.event-card h3, +.detail-card strong { + font-weight: 800; +} + +.stat-card span, +.mini-event-list small, +.event-meta, +.toolbar-label, +.modal-label, +.footer-title, +.status-pill, +.form-label { + color: rgba(255, 248, 241, 0.72); +} + +.hero-panel { + min-height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.mini-widget { + position: relative; + width: 100%; + max-width: 360px; + padding: 1.6rem; + background: rgba(255, 253, 249, 0.1); + border: 1px solid rgba(255, 255, 255, 0.14); +} + +.mini-label { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255, 248, 241, 0.65); + margin-bottom: 1rem; +} + +.mini-event-list { + display: grid; + gap: 0.9rem; +} + +.mini-event-list li { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.9rem; + align-items: start; +} + +.mini-event-list span { + min-width: 52px; + padding: 0.45rem 0.65rem; + border-radius: 12px; + background: rgba(255, 255, 255, 0.12); + color: #fff8f1; + font-weight: 700; +} + +.empty-note { + color: rgba(255, 248, 241, 0.76); + line-height: 1.6; +} + +.floating-orb { + position: absolute; + border-radius: 999px; + filter: blur(6px); + opacity: 0.85; +} + +.orb-one { + width: 118px; + height: 118px; + background: linear-gradient(145deg, rgba(243, 201, 107, 0.95), rgba(231, 111, 81, 0.6)); + right: 12px; + top: 12px; +} + +.orb-two { + width: 78px; + height: 78px; + background: linear-gradient(145deg, rgba(15, 118, 110, 0.9), rgba(255, 255, 255, 0.2)); + left: -18px; + bottom: 44px; +} + +.section-shell, +.embed-section { + padding: 1.5rem 0 3.2rem; +} + +.page-top-space { + padding-top: 2rem; +} + +.section-soft { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.45), rgba(243, 201, 107, 0.06)); +} + +.section-heading { + margin-bottom: 1.5rem; +} + +.section-title { + font-size: clamp(2rem, 3vw, 3.1rem); + color: var(--brand-ink); + margin-top: 0.9rem; + margin-bottom: 0.7rem; +} + +.section-copy, +.footer-copy, +.event-summary, +.form-help, +.field-error, +.text-link { + color: var(--brand-muted); +} + +.calendar-card { + padding: 1.35rem; +} + +.calendar-toolbar { + margin-bottom: 1.2rem; +} + +.calendar-title { + font-size: 1.9rem; + color: var(--brand-ink); +} + +.toolbar-label, +.modal-label, +.event-meta, +.footer-title, +.form-label, +.detail-meta-grid span { + color: var(--brand-muted); + font-weight: 600; + letter-spacing: 0.01em; +} + +.calendar-weekdays, +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 0.85rem; +} + +.calendar-weekdays { + margin-bottom: 0.85rem; +} + +.calendar-weekdays span { + text-align: center; + padding: 0.35rem 0; + font-size: 0.88rem; + font-weight: 700; + color: var(--brand-muted); +} + +.calendar-day { + min-height: 124px; + border-radius: 22px; + border: 1px solid rgba(18, 32, 35, 0.08); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(249, 241, 233, 0.9)); + padding: 0.95rem; + display: flex; + flex-direction: column; + justify-content: space-between; + text-align: left; + gap: 0.75rem; + color: var(--brand-ink); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); +} + +.calendar-day:hover, +.calendar-day:focus { + border-color: rgba(231, 111, 81, 0.3); + background: linear-gradient(180deg, rgba(255, 255, 255, 1), rgba(255, 244, 235, 1)); + outline: none; +} + +.calendar-day-number { + font-family: 'Manrope', sans-serif; + font-size: 1.45rem; + font-weight: 800; +} + +.calendar-day.is-muted { + opacity: 0.5; +} + +.calendar-day.is-today { + border-color: rgba(15, 118, 110, 0.3); + box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.12); +} + +.calendar-day.has-events { + background: linear-gradient(180deg, rgba(255, 245, 238, 1), rgba(255, 251, 244, 1)); +} + +.calendar-badge { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: 0.35rem; + padding: 0.45rem 0.7rem; + border-radius: 999px; + background: rgba(231, 111, 81, 0.12); + color: var(--brand-primary-dark); + font-size: 0.82rem; + font-weight: 700; +} + +.calendar-badge-empty { + background: rgba(15, 118, 110, 0.08); + color: var(--brand-secondary); +} + +.event-modal { + border: 0; + border-radius: 28px; + background: linear-gradient(180deg, rgba(255, 253, 249, 0.98), rgba(248, 239, 230, 0.96)); + box-shadow: 0 40px 90px rgba(18, 32, 35, 0.2); +} + +.event-modal .modal-body { + padding-bottom: 1.6rem; +} + +.modal-event-card { + background: rgba(255, 255, 255, 0.76); + border: 1px solid rgba(18, 32, 35, 0.06); + border-radius: 22px; + padding: 1.15rem; + margin-bottom: 0.9rem; +} + +.modal-event-card:last-child { + margin-bottom: 0; +} + +.modal-event-card h4 { + margin-bottom: 0.45rem; + font-size: 1.2rem; +} + +.modal-event-meta { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; + color: var(--brand-muted); + font-size: 0.95rem; + margin-bottom: 0.7rem; +} + +.embed-shell-card { + padding: 1.2rem; + margin: 1.2rem auto; + max-width: 1200px; +} + +.embed-section .calendar-card { + background: transparent; + border: 0; + box-shadow: none; + padding: 0; +} + +.event-card, +.detail-card, +.form-card, +.dashboard-banner, +.table-card, +.embed-copy-card, +.code-card, +.empty-state { + padding: 1.5rem; +} + +.event-card { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.event-date-chip, +.status-pill { + display: inline-flex; + align-self: flex-start; + align-items: center; + justify-content: center; + padding: 0.45rem 0.8rem; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 800; +} + +.event-date-chip { + background: rgba(243, 201, 107, 0.24); + color: #8c6423; +} + +.status-live { + background: rgba(15, 118, 110, 0.12); + color: var(--brand-secondary); +} + +.status-draft { + background: rgba(93, 108, 112, 0.14); + color: var(--brand-muted); +} + +.detail-meta-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + margin: 1.5rem 0; +} + +.detail-meta-grid strong { + display: block; + color: var(--brand-ink); + margin-top: 0.35rem; +} + +.code-card { + background: linear-gradient(180deg, rgba(18, 32, 35, 0.96), rgba(18, 32, 35, 0.88)); + color: #fff8f1; +} + +.code-card-header strong { + color: #fff8f1; +} + +.code-card .btn-ghost { + background: rgba(255, 255, 255, 0.08); + color: #fff8f1; + border-color: rgba(255, 255, 255, 0.12); +} + +.code-snippet { + white-space: pre-wrap; + word-break: break-word; + margin-top: 1rem; + padding: 1.1rem; + border-radius: 20px; + background: rgba(255, 255, 255, 0.06); + color: #fff2df; +} + +.dashboard-banner { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1.25rem; +} + +.dashboard-table th { + color: var(--brand-muted); + font-weight: 700; +} + +.dashboard-table td, +.dashboard-table th { + padding-top: 1rem; + padding-bottom: 1rem; + border-color: rgba(18, 32, 35, 0.06); + background: transparent; +} + +input[type="text"], +input[type="password"], +input[type="url"], +input[type="datetime-local"], +textarea, +.form-control, +.form-check-input { + border-radius: 16px; + border-color: rgba(18, 32, 35, 0.12); + padding: 0.85rem 1rem; + background: rgba(255, 255, 255, 0.94); +} + +.form-control:focus, +.form-check-input:focus, +.btn:focus, +.nav-link:focus, +.text-link:focus { + border-color: rgba(15, 118, 110, 0.4); + box-shadow: 0 0 0 0.25rem rgba(15, 118, 110, 0.14); +} + +.form-check-input { + width: 1.2rem; + height: 1.2rem; +} + +.card-check { + background: rgba(243, 201, 107, 0.08); + border: 1px solid rgba(243, 201, 107, 0.32); + border-radius: 18px; + padding: 1rem; +} + +.form-check-label { + margin-left: 0.45rem; + font-weight: 700; +} + +.field-error { + margin-top: 0.4rem; + color: #b6412b; + font-size: 0.92rem; + font-weight: 600; +} + +.text-link { + text-decoration: none; + font-weight: 700; +} + +.site-footer { + padding: 0.4rem 0 2rem; +} + +.footer-title { + color: var(--brand-ink); + font-weight: 800; +} + +.footer-links a { + color: var(--brand-muted); + text-decoration: none; + font-weight: 600; +} + +@media (max-width: 991.98px) { + .hero-card { + grid-template-columns: 1fr; + min-height: auto; + } + + .dashboard-banner { + flex-direction: column; + align-items: flex-start; + } + + .calendar-weekdays, + .calendar-grid { + gap: 0.55rem; + } +} + +@media (max-width: 767.98px) { + .navbar { + border-radius: 28px; + } + + .hero-copy h1, + .section-title { + max-width: none; + } + + .calendar-weekdays { + display: none; + } + + .calendar-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .calendar-day { + min-height: 112px; + } + + .hero-section, + .section-shell, + .embed-section { + padding-bottom: 2.3rem; + } } diff --git a/static/js/calendar.js b/static/js/calendar.js new file mode 100644 index 0000000..0d7c6c2 --- /dev/null +++ b/static/js/calendar.js @@ -0,0 +1,106 @@ +document.addEventListener('DOMContentLoaded', () => { + const dataElement = document.getElementById('calendar-events-data'); + const modalElement = document.getElementById('dayEventsModal'); + let calendarEvents = {}; + + const escapeHtml = (value) => String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + const safeExternalUrl = (value) => { + if (!value) return ''; + try { + const url = new URL(value, window.location.origin); + return ['http:', 'https:'].includes(url.protocol) ? url.href : ''; + } catch (error) { + return ''; + } + }; + + if (dataElement) { + try { + calendarEvents = JSON.parse(dataElement.textContent); + } catch (error) { + calendarEvents = {}; + } + } + + if (modalElement && window.bootstrap) { + const modal = new window.bootstrap.Modal(modalElement); + const titleNode = modalElement.querySelector('[data-calendar-title]'); + const bodyNode = modalElement.querySelector('[data-calendar-body]'); + + const renderEvents = (isoDate) => { + const selectedEvents = calendarEvents[isoDate] || []; + const titleDate = new Date(`${isoDate}T12:00:00`); + titleNode.textContent = titleDate.toLocaleDateString(undefined, { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + }); + + if (!selectedEvents.length) { + bodyNode.innerHTML = ` + + `; + return; + } + + bodyNode.innerHTML = selectedEvents + .map((event) => { + const name = escapeHtml(event.name); + const time = escapeHtml(event.time); + const location = escapeHtml(event.location || 'Location announced soon'); + const summary = event.summary ? `

${escapeHtml(event.summary)}

` : ''; + const detailUrl = escapeHtml(event.detail_url || '#'); + const eventUrl = safeExternalUrl(event.event_url); + return ` + + `; + }) + .join(''); + }; + + document.querySelectorAll('[data-calendar-date]').forEach((button) => { + button.addEventListener('click', () => { + renderEvents(button.dataset.calendarDate); + modal.show(); + }); + }); + } + + document.querySelectorAll('[data-copy-snippet]').forEach((button) => { + button.addEventListener('click', async () => { + const target = document.querySelector(button.dataset.copyTarget); + if (!target) return; + try { + await navigator.clipboard.writeText(target.textContent.trim()); + const original = button.textContent; + button.textContent = 'Copied'; + window.setTimeout(() => { + button.textContent = original; + }, 1600); + } catch (error) { + button.textContent = 'Copy manually'; + } + }); + }); +}); diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css index 108056f..75d37a1 100644 --- a/staticfiles/css/custom.css +++ b/staticfiles/css/custom.css @@ -1,21 +1,803 @@ - +/* Brand system */ :root { - --bg-color-start: #6a11cb; - --bg-color-end: #2575fc; - --text-color: #ffffff; - --card-bg-color: rgba(255, 255, 255, 0.01); - --card-border-color: rgba(255, 255, 255, 0.1); + --brand-ink: #122023; + --brand-primary: #e76f51; + --brand-primary-dark: #cb5a3e; + --brand-secondary: #0f766e; + --brand-accent: #f3c96b; + --brand-surface: rgba(255, 249, 243, 0.8); + --brand-surface-strong: #fffdf9; + --brand-border: rgba(18, 32, 35, 0.08); + --brand-muted: #5d6c70; + --brand-bg: #f8efe6; + --brand-bg-deep: #f1e6dc; + --brand-shadow: 0 24px 60px rgba(18, 32, 35, 0.12); + --radius-lg: 28px; + --radius-md: 20px; + --radius-sm: 14px; } + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + body { margin: 0; - font-family: 'Inter', sans-serif; - background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); - color: var(--text-color); - display: flex; - justify-content: center; - align-items: center; + font-family: 'Inter', system-ui, sans-serif; + color: var(--brand-ink); + background: + radial-gradient(circle at top left, rgba(243, 201, 107, 0.33), transparent 28%), + radial-gradient(circle at top right, rgba(15, 118, 110, 0.18), transparent 24%), + linear-gradient(180deg, #fffaf4 0%, var(--brand-bg) 52%, #fffaf4 100%); min-height: 100vh; - text-align: center; - overflow: hidden; position: relative; } + +h1, +h2, +h3, +h4, +h5, +h6, +.navbar-brand, +.section-title { + font-family: 'Manrope', 'Inter', sans-serif; + letter-spacing: -0.03em; +} + +img { + max-width: 100%; + height: auto; +} + +p, +a, +button, +input, +textarea, +label, +small, +span { + font-family: 'Inter', system-ui, sans-serif; +} + +.page-glow { + position: fixed; + border-radius: 999px; + filter: blur(90px); + opacity: 0.6; + pointer-events: none; + z-index: 0; +} + +.page-glow-one { + width: 280px; + height: 280px; + background: rgba(231, 111, 81, 0.22); + top: 4rem; + left: -4rem; +} + +.page-glow-two { + width: 360px; + height: 360px; + background: rgba(15, 118, 110, 0.15); + right: -5rem; + top: 18rem; +} + +.site-header, +.site-footer, +.hero-section, +.section-shell { + position: relative; + z-index: 1; +} + +.site-header { + padding-top: 1.25rem; +} + +.navbar { + background: rgba(255, 253, 249, 0.78); + border: 1px solid rgba(255, 255, 255, 0.7); + border-radius: 999px; + padding-left: 1.1rem; + padding-right: 1.1rem; + box-shadow: 0 16px 40px rgba(18, 32, 35, 0.08); + backdrop-filter: blur(18px); +} + +.brand-mark { + color: var(--brand-ink); + font-weight: 800; + font-size: 1.2rem; + text-decoration: none; +} + +.brand-mark span { + color: var(--brand-primary); +} + +.nav-link { + color: var(--brand-ink); + font-weight: 600; + opacity: 0.88; +} + +.nav-link:hover, +.nav-link:focus, +.footer-links a:hover, +.footer-links a:focus, +.text-link:hover, +.text-link:focus { + color: var(--brand-secondary); +} + +.calendar-toggler { + border: 0; + box-shadow: none !important; +} + +.message-stack { + margin-top: 1rem; +} + +.hero-section { + padding: 2rem 0 1rem; +} + +.hero-card { + position: relative; + overflow: hidden; + background: linear-gradient(135deg, rgba(18, 32, 35, 0.95), rgba(18, 32, 35, 0.82)); + color: #fff8f1; + border-radius: calc(var(--radius-lg) + 6px); + padding: clamp(2rem, 4vw, 4rem); + min-height: 620px; + box-shadow: var(--brand-shadow); + display: grid; + gap: 2rem; + align-items: center; + grid-template-columns: minmax(0, 1.1fr) minmax(280px, 420px); +} + +.hero-card::before { + content: ''; + position: absolute; + inset: auto auto -80px -60px; + width: 220px; + height: 220px; + border-radius: 42px; + background: linear-gradient(145deg, rgba(231, 111, 81, 0.35), rgba(243, 201, 107, 0.12)); + transform: rotate(18deg); +} + +.hero-card::after { + content: ''; + position: absolute; + top: 40px; + right: 100px; + width: 170px; + height: 170px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); +} + +.hero-copy, +.hero-panel { + position: relative; + z-index: 1; +} + +.eyebrow-pill { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.9rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); + color: inherit; + font-size: 0.86rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.hero-copy h1 { + font-size: clamp(2.7rem, 5vw, 4.8rem); + line-height: 0.97; + margin: 1.2rem 0 1.2rem; + max-width: 10ch; +} + +.hero-lead, +.section-copy, +.footer-copy, +.event-summary, +.detail-body p, +.form-help { + color: rgba(18, 32, 35, 0.74); + line-height: 1.7; +} + +.hero-lead { + color: rgba(255, 248, 241, 0.82); + font-size: 1.08rem; + max-width: 50ch; +} + +.hero-actions, +.compact-actions, +.dashboard-banner-actions { + display: flex; + flex-wrap: wrap; + gap: 0.85rem; + margin-top: 1.7rem; +} + +.btn { + border-radius: 999px; + font-weight: 700; + padding: 0.8rem 1.25rem; + border-width: 1px; + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, color 0.2s ease; +} + +.btn:hover, +.btn:focus { + transform: translateY(-1px); + box-shadow: 0 12px 25px rgba(18, 32, 35, 0.12); +} + +.btn-primary-brand { + background: linear-gradient(135deg, var(--brand-primary), #f08f5b); + color: #fff; + border-color: transparent; +} + +.btn-primary-brand:hover, +.btn-primary-brand:focus { + background: linear-gradient(135deg, var(--brand-primary-dark), var(--brand-primary)); + color: #fff; +} + +.btn-ghost { + background: rgba(255, 255, 255, 0.68); + color: var(--brand-ink); + border-color: rgba(18, 32, 35, 0.08); + backdrop-filter: blur(14px); +} + +.hero-card .btn-ghost { + background: rgba(255, 255, 255, 0.1); + color: #fff8f1; + border-color: rgba(255, 255, 255, 0.18); +} + +.card-surface, +.calendar-card, +.embed-shell-card, +.detail-card, +.dashboard-banner, +.table-card, +.form-card, +.empty-state, +.event-card { + background: rgba(255, 253, 249, 0.82); + border: 1px solid rgba(255, 255, 255, 0.9); + border-radius: var(--radius-lg); + box-shadow: var(--brand-shadow); + backdrop-filter: blur(18px); +} + +.hero-stats { + margin-top: 2rem; +} + +.stat-card { + height: 100%; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: var(--radius-md); + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.stat-card strong, +.mini-event-list strong, +.event-card h2, +.event-card h3, +.detail-card strong { + font-weight: 800; +} + +.stat-card span, +.mini-event-list small, +.event-meta, +.toolbar-label, +.modal-label, +.footer-title, +.status-pill, +.form-label { + color: rgba(255, 248, 241, 0.72); +} + +.hero-panel { + min-height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.mini-widget { + position: relative; + width: 100%; + max-width: 360px; + padding: 1.6rem; + background: rgba(255, 253, 249, 0.1); + border: 1px solid rgba(255, 255, 255, 0.14); +} + +.mini-label { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255, 248, 241, 0.65); + margin-bottom: 1rem; +} + +.mini-event-list { + display: grid; + gap: 0.9rem; +} + +.mini-event-list li { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.9rem; + align-items: start; +} + +.mini-event-list span { + min-width: 52px; + padding: 0.45rem 0.65rem; + border-radius: 12px; + background: rgba(255, 255, 255, 0.12); + color: #fff8f1; + font-weight: 700; +} + +.empty-note { + color: rgba(255, 248, 241, 0.76); + line-height: 1.6; +} + +.floating-orb { + position: absolute; + border-radius: 999px; + filter: blur(6px); + opacity: 0.85; +} + +.orb-one { + width: 118px; + height: 118px; + background: linear-gradient(145deg, rgba(243, 201, 107, 0.95), rgba(231, 111, 81, 0.6)); + right: 12px; + top: 12px; +} + +.orb-two { + width: 78px; + height: 78px; + background: linear-gradient(145deg, rgba(15, 118, 110, 0.9), rgba(255, 255, 255, 0.2)); + left: -18px; + bottom: 44px; +} + +.section-shell, +.embed-section { + padding: 1.5rem 0 3.2rem; +} + +.page-top-space { + padding-top: 2rem; +} + +.section-soft { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.45), rgba(243, 201, 107, 0.06)); +} + +.section-heading { + margin-bottom: 1.5rem; +} + +.section-title { + font-size: clamp(2rem, 3vw, 3.1rem); + color: var(--brand-ink); + margin-top: 0.9rem; + margin-bottom: 0.7rem; +} + +.section-copy, +.footer-copy, +.event-summary, +.form-help, +.field-error, +.text-link { + color: var(--brand-muted); +} + +.calendar-card { + padding: 1.35rem; +} + +.calendar-toolbar { + margin-bottom: 1.2rem; +} + +.calendar-title { + font-size: 1.9rem; + color: var(--brand-ink); +} + +.toolbar-label, +.modal-label, +.event-meta, +.footer-title, +.form-label, +.detail-meta-grid span { + color: var(--brand-muted); + font-weight: 600; + letter-spacing: 0.01em; +} + +.calendar-weekdays, +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 0.85rem; +} + +.calendar-weekdays { + margin-bottom: 0.85rem; +} + +.calendar-weekdays span { + text-align: center; + padding: 0.35rem 0; + font-size: 0.88rem; + font-weight: 700; + color: var(--brand-muted); +} + +.calendar-day { + min-height: 124px; + border-radius: 22px; + border: 1px solid rgba(18, 32, 35, 0.08); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(249, 241, 233, 0.9)); + padding: 0.95rem; + display: flex; + flex-direction: column; + justify-content: space-between; + text-align: left; + gap: 0.75rem; + color: var(--brand-ink); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); +} + +.calendar-day:hover, +.calendar-day:focus { + border-color: rgba(231, 111, 81, 0.3); + background: linear-gradient(180deg, rgba(255, 255, 255, 1), rgba(255, 244, 235, 1)); + outline: none; +} + +.calendar-day-number { + font-family: 'Manrope', sans-serif; + font-size: 1.45rem; + font-weight: 800; +} + +.calendar-day.is-muted { + opacity: 0.5; +} + +.calendar-day.is-today { + border-color: rgba(15, 118, 110, 0.3); + box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.12); +} + +.calendar-day.has-events { + background: linear-gradient(180deg, rgba(255, 245, 238, 1), rgba(255, 251, 244, 1)); +} + +.calendar-badge { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: 0.35rem; + padding: 0.45rem 0.7rem; + border-radius: 999px; + background: rgba(231, 111, 81, 0.12); + color: var(--brand-primary-dark); + font-size: 0.82rem; + font-weight: 700; +} + +.calendar-badge-empty { + background: rgba(15, 118, 110, 0.08); + color: var(--brand-secondary); +} + +.event-modal { + border: 0; + border-radius: 28px; + background: linear-gradient(180deg, rgba(255, 253, 249, 0.98), rgba(248, 239, 230, 0.96)); + box-shadow: 0 40px 90px rgba(18, 32, 35, 0.2); +} + +.event-modal .modal-body { + padding-bottom: 1.6rem; +} + +.modal-event-card { + background: rgba(255, 255, 255, 0.76); + border: 1px solid rgba(18, 32, 35, 0.06); + border-radius: 22px; + padding: 1.15rem; + margin-bottom: 0.9rem; +} + +.modal-event-card:last-child { + margin-bottom: 0; +} + +.modal-event-card h4 { + margin-bottom: 0.45rem; + font-size: 1.2rem; +} + +.modal-event-meta { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; + color: var(--brand-muted); + font-size: 0.95rem; + margin-bottom: 0.7rem; +} + +.embed-shell-card { + padding: 1.2rem; + margin: 1.2rem auto; + max-width: 1200px; +} + +.embed-section .calendar-card { + background: transparent; + border: 0; + box-shadow: none; + padding: 0; +} + +.event-card, +.detail-card, +.form-card, +.dashboard-banner, +.table-card, +.embed-copy-card, +.code-card, +.empty-state { + padding: 1.5rem; +} + +.event-card { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.event-date-chip, +.status-pill { + display: inline-flex; + align-self: flex-start; + align-items: center; + justify-content: center; + padding: 0.45rem 0.8rem; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 800; +} + +.event-date-chip { + background: rgba(243, 201, 107, 0.24); + color: #8c6423; +} + +.status-live { + background: rgba(15, 118, 110, 0.12); + color: var(--brand-secondary); +} + +.status-draft { + background: rgba(93, 108, 112, 0.14); + color: var(--brand-muted); +} + +.detail-meta-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + margin: 1.5rem 0; +} + +.detail-meta-grid strong { + display: block; + color: var(--brand-ink); + margin-top: 0.35rem; +} + +.code-card { + background: linear-gradient(180deg, rgba(18, 32, 35, 0.96), rgba(18, 32, 35, 0.88)); + color: #fff8f1; +} + +.code-card-header strong { + color: #fff8f1; +} + +.code-card .btn-ghost { + background: rgba(255, 255, 255, 0.08); + color: #fff8f1; + border-color: rgba(255, 255, 255, 0.12); +} + +.code-snippet { + white-space: pre-wrap; + word-break: break-word; + margin-top: 1rem; + padding: 1.1rem; + border-radius: 20px; + background: rgba(255, 255, 255, 0.06); + color: #fff2df; +} + +.dashboard-banner { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1.25rem; +} + +.dashboard-table th { + color: var(--brand-muted); + font-weight: 700; +} + +.dashboard-table td, +.dashboard-table th { + padding-top: 1rem; + padding-bottom: 1rem; + border-color: rgba(18, 32, 35, 0.06); + background: transparent; +} + +input[type="text"], +input[type="password"], +input[type="url"], +input[type="datetime-local"], +textarea, +.form-control, +.form-check-input { + border-radius: 16px; + border-color: rgba(18, 32, 35, 0.12); + padding: 0.85rem 1rem; + background: rgba(255, 255, 255, 0.94); +} + +.form-control:focus, +.form-check-input:focus, +.btn:focus, +.nav-link:focus, +.text-link:focus { + border-color: rgba(15, 118, 110, 0.4); + box-shadow: 0 0 0 0.25rem rgba(15, 118, 110, 0.14); +} + +.form-check-input { + width: 1.2rem; + height: 1.2rem; +} + +.card-check { + background: rgba(243, 201, 107, 0.08); + border: 1px solid rgba(243, 201, 107, 0.32); + border-radius: 18px; + padding: 1rem; +} + +.form-check-label { + margin-left: 0.45rem; + font-weight: 700; +} + +.field-error { + margin-top: 0.4rem; + color: #b6412b; + font-size: 0.92rem; + font-weight: 600; +} + +.text-link { + text-decoration: none; + font-weight: 700; +} + +.site-footer { + padding: 0.4rem 0 2rem; +} + +.footer-title { + color: var(--brand-ink); + font-weight: 800; +} + +.footer-links a { + color: var(--brand-muted); + text-decoration: none; + font-weight: 600; +} + +@media (max-width: 991.98px) { + .hero-card { + grid-template-columns: 1fr; + min-height: auto; + } + + .dashboard-banner { + flex-direction: column; + align-items: flex-start; + } + + .calendar-weekdays, + .calendar-grid { + gap: 0.55rem; + } +} + +@media (max-width: 767.98px) { + .navbar { + border-radius: 28px; + } + + .hero-copy h1, + .section-title { + max-width: none; + } + + .calendar-weekdays { + display: none; + } + + .calendar-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .calendar-day { + min-height: 112px; + } + + .hero-section, + .section-shell, + .embed-section { + padding-bottom: 2.3rem; + } +} diff --git a/staticfiles/js/calendar.js b/staticfiles/js/calendar.js new file mode 100644 index 0000000..0d7c6c2 --- /dev/null +++ b/staticfiles/js/calendar.js @@ -0,0 +1,106 @@ +document.addEventListener('DOMContentLoaded', () => { + const dataElement = document.getElementById('calendar-events-data'); + const modalElement = document.getElementById('dayEventsModal'); + let calendarEvents = {}; + + const escapeHtml = (value) => String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + const safeExternalUrl = (value) => { + if (!value) return ''; + try { + const url = new URL(value, window.location.origin); + return ['http:', 'https:'].includes(url.protocol) ? url.href : ''; + } catch (error) { + return ''; + } + }; + + if (dataElement) { + try { + calendarEvents = JSON.parse(dataElement.textContent); + } catch (error) { + calendarEvents = {}; + } + } + + if (modalElement && window.bootstrap) { + const modal = new window.bootstrap.Modal(modalElement); + const titleNode = modalElement.querySelector('[data-calendar-title]'); + const bodyNode = modalElement.querySelector('[data-calendar-body]'); + + const renderEvents = (isoDate) => { + const selectedEvents = calendarEvents[isoDate] || []; + const titleDate = new Date(`${isoDate}T12:00:00`); + titleNode.textContent = titleDate.toLocaleDateString(undefined, { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + }); + + if (!selectedEvents.length) { + bodyNode.innerHTML = ` + + `; + return; + } + + bodyNode.innerHTML = selectedEvents + .map((event) => { + const name = escapeHtml(event.name); + const time = escapeHtml(event.time); + const location = escapeHtml(event.location || 'Location announced soon'); + const summary = event.summary ? `

${escapeHtml(event.summary)}

` : ''; + const detailUrl = escapeHtml(event.detail_url || '#'); + const eventUrl = safeExternalUrl(event.event_url); + return ` + + `; + }) + .join(''); + }; + + document.querySelectorAll('[data-calendar-date]').forEach((button) => { + button.addEventListener('click', () => { + renderEvents(button.dataset.calendarDate); + modal.show(); + }); + }); + } + + document.querySelectorAll('[data-copy-snippet]').forEach((button) => { + button.addEventListener('click', async () => { + const target = document.querySelector(button.dataset.copyTarget); + if (!target) return; + try { + await navigator.clipboard.writeText(target.textContent.trim()); + const original = button.textContent; + button.textContent = 'Copied'; + window.setTimeout(() => { + button.textContent = original; + }, 1600); + } catch (error) { + button.textContent = 'Copy manually'; + } + }); + }); +});