diff --git a/.gitignore b/.gitignore index 646d9ac71..365daa391 100644 --- a/.gitignore +++ b/.gitignore @@ -101,6 +101,8 @@ __pypackages__/ # Celery stuff celerybeat-schedule +celerybeat-schedule-shm +celerybeat-schedule-wal celerybeat.pid celerybeat-schedule* diff --git a/core/views.py b/core/views.py index d74b9766c..9417142e9 100644 --- a/core/views.py +++ b/core/views.py @@ -1168,6 +1168,12 @@ def get_context_data(self, **kwargs): ("math", "Math & Numerics"), ("networking", "Networking"), ] + context["demo_dropdown_options"] = [ + ("blog", "Blog"), + ("link", "Link"), + ("news", "News"), + ("video", "Video"), + ] badge_img = f"{settings.STATIC_URL}img/v3/badges" context["badge_icon_srcs"] = [ f"{badge_img}/badge-first-place.png", diff --git a/frontend/wysiwyg-editor.js b/frontend/wysiwyg-editor.js index 5c5a0eb0f..403e2aa38 100644 --- a/frontend/wysiwyg-editor.js +++ b/frontend/wysiwyg-editor.js @@ -880,20 +880,22 @@ export const initWysiwyg = (textareaId) => { textarea.tabIndex = -1; const form = wrapper.closest("form"); + const syncTextarea = () => { + if (state.mode === "markdown") { + textarea.value = state.markdownText; + } else { + textarea.value = turndown.turndown(editor.getHTML()); + } + }; if (form) { - form.addEventListener("submit", () => { - if (state.mode === "markdown") { - textarea.value = state.markdownText; - } else { - textarea.value = turndown.turndown(editor.getHTML()); - } - }); + form.addEventListener("submit", syncTextarea, true); } editorInstances.set(textareaId, { editor, cleanup: () => { document.removeEventListener("click", handleDocClick); + if (form) form.removeEventListener("submit", syncTextarea, true); }, }); return editor; diff --git a/news/views.py b/news/views.py index 9be6d8af9..b52187df8 100644 --- a/news/views.py +++ b/news/views.py @@ -2,6 +2,7 @@ from functools import partial from django.conf import settings +from django.db import IntegrityError from django.contrib import messages from django.contrib.auth import get_user_model from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin @@ -12,7 +13,7 @@ from django.template.defaultfilters import date as datefilter from django.urls import reverse_lazy from django.utils.http import url_has_allowed_host_and_scheme -from django.utils.timezone import now +from django.utils.timezone import localtime, now from django.utils.translation import gettext as _ from django.views.generic import ( CreateView, @@ -25,6 +26,7 @@ ) from django.views.generic.detail import SingleObjectMixin from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadData +from waffle import flag_is_active from .acl import can_approve from .constants import NEWS_APPROVAL_SALT, MAGIC_LINK_EXPIRATION @@ -211,17 +213,58 @@ def get(self, request, token, *args, **kwargs): return redirect(entry) +def _v3_create_context(): + """Shared context variables needed by the v3 create-post template.""" + return { + "post_type_options": [ + ("blog", "Blog"), + ("news", "News"), + ("video", "Video"), + ("link", "Link"), + ], + "related_libraries_options": [ + ("", "Select"), + ("asio", "Asio"), + ("beast", "Beast"), + ("filesystem", "Filesystem"), + ("json", "JSON"), + ("spirit", "Spirit"), + ], + "publish_at_initial": localtime(now()).strftime("%Y-%m-%dT%H:%M"), + "blogpost_create_url": reverse_lazy("news-blogpost-create"), + "news_create_url": reverse_lazy("news-news-create"), + "link_create_url": reverse_lazy("news-link-create"), + "video_create_url": reverse_lazy("news-video-create"), + } + + class EntryCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): model = None form_class = None template_name = "news/form.html" add_label = None add_url_name = None + post_type_selected = "" success_message = _("The news entry was successfully created.") + def get_template_names(self): + if flag_is_active(self.request, "v3"): + return ["news/create_v3.html"] + return [self.template_name] + def form_valid(self, form): form.instance.author = self.request.user - result = super().form_valid(form) + try: + result = super().form_valid(form) + except IntegrityError as e: + if "slug" in str(e): + form.add_error( + "title", + "A post with this title already exists. Please choose a different title.", + ) + else: + form.add_error(None, "An unexpected error occurred. Please try again.") + return self.form_invalid(form) if not form.instance.is_approved: send_email_news_needs_moderation(request=self.request, entry=form.instance) else: @@ -233,6 +276,9 @@ def get_context_data(self, **kwargs): context["add_label"] = self.add_label context["add_url_name"] = self.add_url_name context["cancel_url"] = reverse_lazy("news") + if flag_is_active(self.request, "v3"): + context.update(_v3_create_context()) + context["post_type_selected"] = self.post_type_selected return context @@ -241,6 +287,7 @@ class BlogPostCreateView(EntryCreateView): form_class = BlogPostForm add_label = _("Create Blog Post") add_url_name = "news-blogpost-create" + post_type_selected = "blog" class LinkCreateView(EntryCreateView): @@ -248,6 +295,7 @@ class LinkCreateView(EntryCreateView): form_class = LinkForm add_label = _("Create Link") add_url_name = "news-link-create" + post_type_selected = "link" class NewsCreateView(EntryCreateView): @@ -255,6 +303,7 @@ class NewsCreateView(EntryCreateView): form_class = NewsForm add_label = _("Create News") add_url_name = "news-news-create" + post_type_selected = "news" class PollCreateView(EntryCreateView): @@ -269,6 +318,7 @@ class VideoCreateView(EntryCreateView): form_class = VideoForm add_label = _("Upload a Video") add_url_name = "news-video-create" + post_type_selected = "video" class AllTypesCreateView(LoginRequiredMixin, TemplateView): @@ -303,6 +353,11 @@ def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) + def get_template_names(self): + if flag_is_active(self.request, "v3"): + return ["news/create_v3.html"] + return ["news/create.html"] + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) items = [ @@ -315,6 +370,8 @@ def get_context_data(self, **kwargs): if can_approve(self.request.user): items.append(self.item_params(PollCreateView)) context["items"] = items + if flag_is_active(self.request, "v3"): + context.update(_v3_create_context()) return context diff --git a/static/css/v3/create-post-page.css b/static/css/v3/create-post-page.css new file mode 100644 index 000000000..df3b83202 --- /dev/null +++ b/static/css/v3/create-post-page.css @@ -0,0 +1,94 @@ +/** + * Create post page layout (Django). + */ + +.create-post-section { + width: 100%; + display: flex; + justify-content: center; + margin-top: 40px; + margin-bottom: 80px; +} + +.create-post-wrapper { + width: 100%; + max-width: 694px; +} + +.create-post-page { + display: flex; + flex-direction: column; + gap: var(--space-xl); + align-items: stretch; + width: 100%; + max-width: 100%; +} + +.create-post-page__header { + display: flex; + flex-direction: column; + gap: var(--space-large); +} + +.create-post-page__title { + font-family: var(--font-sans); + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-medium); + line-height: 1; + letter-spacing: -1px; + color: var(--color-text-primary); + margin: 0; +} + +.create-post-page__subtitle { + font-family: var(--font-sans); + font-size: var(--font-size-large); + font-weight: var(--font-weight-regular); + line-height: 1.33; + letter-spacing: -1px; + color: var(--color-text-secondary); + padding: 0; +} + +.create-post-page__card { + background: var(--color-surface-weak); + border: 1px solid var(--color-stroke-weak); + border-radius: 16px; + padding: var(--space-large); + display: flex; + flex-direction: column; + gap: var(--space-xlarge); +} + +.create-post-page__link-fields { + display: flex; + flex-direction: column; + gap: var(--space-xlarge); +} + +.create-post-page__actions { + display: flex; + flex-direction: row; + gap: var(--space-default); + flex-wrap: wrap; +} + +.create-post-page__actions .btn { + flex: 1 1 0; + min-width: 128px; + justify-content: center; + text-decoration: none; +} + +@media (max-width: 767px) { + .create-post-section { + margin-top: 32px; + margin-bottom: 64px; + } + .create-post-page__actions { + flex-direction: column; + } + .create-post-page__actions .btn { + min-width: 100%; + } +} diff --git a/static/css/v3/forms.css b/static/css/v3/forms.css index e09891319..0b41a2c93 100644 --- a/static/css/v3/forms.css +++ b/static/css/v3/forms.css @@ -1,3 +1,10 @@ +/** + * Foundations – Inputs + * Matches Django v3 form structure: field, dropdown, combo, multiselect, checkbox, textarea, file. + * BEM: .field, .field__*, .dropdown, .dropdown__*, .combo__*, .checkbox, .checkbox__*, .multiselect__* + * Uses design tokens with fallbacks for compatibility. + */ + .field { display: flex; flex-direction: column; @@ -82,6 +89,15 @@ color: var(--color-icon-secondary, #6b6d78); } +.field__control:hover .field__icon, +.field__control--hover .field__icon { + color: var(--color-text-link-accent, #00778B); +} + +.field__control:focus-within .field__icon { + color: var(--color-text-link-accent, #00778B); +} + .field__submit { display: flex; align-items: center; @@ -159,6 +175,20 @@ color: var(--color-text-error, #d32f2f); } +.field--error .field__control .field__icon { + color: var(--color-text-error, #d32f2f); +} + +/* Wrapper error state (e.g. for conditional fields with client-side validation) */ +.field-wrapper--error .field .field__label { + color: var(--color-text-error, #d32f2f); +} + +.field-wrapper--error .field .field__control { + background-color: var(--color-surface-error-weak, #fdf2f2); + border-color: var(--color-stroke-error, #D53F3F33); +} + .field__control--disabled, .field__control--disabled:hover, .field__control--disabled:focus-within { @@ -170,6 +200,28 @@ cursor: not-allowed; } +/* ========== Datetime-local: hide native icon, full-area click ========== */ +.field__control--datetime { + position: relative; + cursor: pointer; +} + +.field__control--datetime .field__input { + cursor: pointer; +} + +.field__input[type="datetime-local"]::-webkit-calendar-picker-indicator { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + margin: 0; + padding: 0; +} + +/* ========== Dropdown ========== */ .dropdown { position: relative; } @@ -228,6 +280,12 @@ transition: transform 0.15s ease; } +.dropdown__chevron-icon { + width: 100%; + height: 100%; + flex-shrink: 0; +} + .dropdown--open .dropdown__chevron { transform: rotate(180deg); } @@ -239,6 +297,16 @@ border-bottom-right-radius: 0; } +/* Combo: trigger when open shows search row; same active look */ +.dropdown--combo.dropdown--open .dropdown__trigger, +.dropdown__trigger--active { + background-color: var(--color-surface-mid, #f7f7f8); + border-color: var(--color-stroke-strong, #05081640); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom-color: var(--color-stroke-weak, #0508161A); +} + .dropdown__panel { position: absolute; top: 100%; @@ -256,6 +324,12 @@ overflow: hidden; } +/* Ensure open panels overlay (never push layout) */ +.dropdown .dropdown__panel { + position: absolute; + z-index: 1000; +} + .dropdown__list { max-height: 320px; overflow-y: auto; @@ -285,6 +359,11 @@ color: var(--color-text-secondary); cursor: pointer; transition: background-color 0.1s ease; + border-bottom: 1px solid var(--color-stroke-subtle, #05081614); +} + +.dropdown__item:last-child { + border-bottom: none; } .dropdown__item:not(:first-of-type) { @@ -315,10 +394,19 @@ border-color: var(--color-stroke-error, #d53f3f33); } +.field--error .dropdown__trigger .dropdown__chevron-icon { + color: var(--color-text-error, #d32f2f); +} + .field--error .dropdown__trigger:hover { border-color: var(--color-stroke-error, #d53f3f33); } +.field--error .combo__search-icon { + color: var(--color-text-error, #d32f2f); +} + +/* ========== Combo (search + dropdown) ========== */ .combo__search-row { display: flex; align-items: center; @@ -346,6 +434,10 @@ color: var(--color-icon-secondary, #6b6d78); } +.dropdown__trigger:focus-within .combo__search-icon { + color: var(--color-text-link-accent); +} + .combo__search-input { flex: 1; min-width: 0; @@ -381,6 +473,7 @@ color: var(--color-text-tertiary, #6b6d78); } +/* ========== Multiselect ========== */ .multiselect__item { display: flex; align-items: center; @@ -440,6 +533,7 @@ white-space: nowrap; } +/* ========== Checkbox ========== */ .checkbox { display: inline-flex; align-items: center; @@ -508,3 +602,122 @@ opacity: 0.5; cursor: not-allowed; } + +/* ========== Textarea variant ========== */ +.field--textarea .field__control--textarea { + min-height: 120px; + height: auto; + align-items: flex-start; + padding: var(--space-medium, 12px) var(--space-large, 16px); +} + +.field__input--textarea { + resize: vertical; + min-height: 96px; + padding: 0; + line-height: 1.4; +} + +/* ========== File input variant ========== */ +.field__control--file { + cursor: pointer; + position: relative; +} + +.field__file-display { + text-align: center; + position: relative; + z-index: 0; + pointer-events: none; + flex: 1; + min-width: 0; + font-family: var(--font-sans); + font-size: var(--font-size-small, 14px); + font-weight: var(--font-weight-regular, 400); + line-height: var(--line-height-relaxed, 1.24); +} + +.field__file-placeholder { + color: var(--color-text-secondary, #6b6d78); +} + +.field__file-name { + color: var(--color-text-primary, #050816); +} + +.field__image-preview-wrapper { + margin-top: var(--space-medium, 12px); + cursor: pointer; +} + +.field__image-preview { + display: block; + width: 100%; + max-width: 100%; + border-radius: 2px; + object-fit: cover; +} + +.field__input--file { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + margin: 0; + padding: 0; + border: none; + background: transparent; + outline: none; + font-size: 0; + cursor: pointer; + z-index: 1; +} + +.field__input--file::-webkit-file-upload-button, +.field__input--file::file-selector-button { + display: none; +} + +.field--file-narrow .field__control--file { + align-self: flex-start; + width: auto; + min-width: 180px; + max-width: 280px; +} +.field--file-narrow .field__input--file { + flex: 0 1 auto; +} + +/* ========== No-JS fallback: native + + + + + + {% comment %} Link (Video, Link): Link (required) and Description (optional). {% endcomment %} +
+
+ +
+ +
+ +
+
+ + {% comment %} Write Up (Blog, News): Content required. WYSIWYG editor (Markdown). {% endcomment %} +
+ + {% include "v3/includes/_wysiwyg_editor.html" with textarea_id="field-content" textarea_name="content" label="Content" value=form.content.value %} + +

This editor supports Markdown: headings, lists, code blocks, links, and more.

+
+ + {% include "v3/includes/_field_file.html" with name="image" label="Image" accept="image/png,image/jpeg" preview=True alpine_error="errors.image" error=form.errors.image.0 help_text="This should be a PNG or JPEG format and no longer than 1MB" extra_class="field--file-narrow create-post-page__field-image" %} + + {% include "v3/includes/_field_dropdown.html" with name="related_libraries" label="Related Libraries" options=related_libraries_options placeholder="Select" %} + + {% include "v3/includes/_field_datetime.html" with name="publish_at" label="Publish Date" value=form.publish_at.value|default:publish_at_initial %} + +
+ Cancel + +
+ + + + + + +{% endblock %} diff --git a/templates/v3/examples/_v3_example_section.html b/templates/v3/examples/_v3_example_section.html index 5cc489366..ea9ab3bb5 100644 --- a/templates/v3/examples/_v3_example_section.html +++ b/templates/v3/examples/_v3_example_section.html @@ -381,10 +381,13 @@

{{ section_title }}

{{ section_title }}

+ {% include "v3/includes/_field_textarea.html" with name="ex_content" label="Textarea" placeholder="What would you like to write?" help_text="This text field supports Markdown." rows=4 %} + {% include "v3/includes/_field_file.html" with name="ex_image" label="File upload" accept="image/png,image/jpeg" help_text="PNG or JPEG, max 1MB" %} {% include "v3/includes/_field_text.html" with name="ex_basic" label="Text field" placeholder="Enter text..." %} {% include "v3/includes/_field_text.html" with name="ex_search" label="With icon" placeholder="Search..." icon_left="search" %} {% include "v3/includes/_field_text.html" with name="ex_error" label="Error state" placeholder="Enter value" error="This field is required." %} {% include "v3/includes/_field_checkbox.html" with name="ex_agree" label="I agree to the terms and conditions" %} + {% include "v3/includes/_field_dropdown.html" with name="ex_type" label="Dropdown" options=demo_dropdown_options placeholder="Select type..." %} {% include "v3/includes/_field_combo.html" with name="ex_library" label="Combo (searchable)" placeholder="Search libraries..." options=demo_libs %} {% include "v3/includes/_field_multiselect.html" with name="ex_categories" label="Multi-select" placeholder="Select categories..." options=demo_cats %}
diff --git a/templates/v3/includes/_field_datetime.html b/templates/v3/includes/_field_datetime.html new file mode 100644 index 000000000..216fcd804 --- /dev/null +++ b/templates/v3/includes/_field_datetime.html @@ -0,0 +1,63 @@ +{% comment %} + V3 datetime-local input field. + Variables: + name (required) — input name attribute + label (optional) — label text + value (optional) — pre-filled value + help_text (optional) — help text below the field + error (optional) — error message (activates error state) + required (optional) — if truthy, adds required attribute + disabled (optional) — if truthy, adds disabled attribute + extra_class (optional) — additional classes on the wrapper + Usage: + {% include "v3/includes/_field_datetime.html" with name="publish_at" label="Publish Date" value=publish_at_initial %} +{% endcomment %} +
+ {% if label %} + + {% endif %} +
+ +
+ {% if error %} + + {% elif help_text %} +

{{ help_text }}

+ {% endif %} + +
diff --git a/templates/v3/includes/_field_dropdown.html b/templates/v3/includes/_field_dropdown.html index 8e2ca38cc..6d7d483ce 100644 --- a/templates/v3/includes/_field_dropdown.html +++ b/templates/v3/includes/_field_dropdown.html @@ -34,6 +34,7 @@ this.selectedLabel = label; this.open = false; this.$refs.trigger.focus(); + this.$dispatch('field-change', { name: '{{ name }}', value, label }); } }"> {# djlint:on #} @@ -44,7 +45,8 @@ {% if label %}aria-labelledby="field-{{ name }}-label"{% endif %} {% if error %}aria-invalid="true" aria-describedby="field-{{ name }}-error"{% elif help_text %}aria-describedby="field-{{ name }}-help"{% endif %} x-show="!jsReady" - :disabled="jsReady"> + :disabled="jsReady" + @change="$dispatch('field-change', { name: '{{ name }}', value: $event.target.value, label: $event.target.options[$event.target.selectedIndex].text })"> {% if placeholder %}{% endif %} {% for value, label in options %} diff --git a/templates/v3/includes/_field_file.html b/templates/v3/includes/_field_file.html new file mode 100644 index 000000000..cd1ac1ca5 --- /dev/null +++ b/templates/v3/includes/_field_file.html @@ -0,0 +1,102 @@ +{% comment %} + V3 file input field. + Variables: + name (required) — input name attribute + label (optional) — label text + accept (optional) — accept attribute (e.g. "image/png,image/jpeg") + help_text (optional) — help text below the field + error (optional) — error message (activates error state) + required (optional) — if truthy, adds required attribute + disabled (optional) — if truthy, adds disabled attribute + extra_class (optional) — additional classes on the wrapper + preview (optional) — if truthy, enables clickable image preview after file selection + alpine_error (optional) — Alpine expression for dynamic error binding (e.g. "errors.image") + Usage: + {% include "v3/includes/_field_file.html" with name="image" label="Image" accept="image/png,image/jpeg" help_text="PNG or JPEG, max 1MB" %} + {% include "v3/includes/_field_file.html" with name="image" label="Image" accept="image/png,image/jpeg" preview=True alpine_error="errors.image" help_text="PNG or JPEG, max 1MB" %} + Custom display: no native "No file chosen"; shows "Choose File" until a file is selected, then the file name. +{% endcomment %} + +
+ {% if label %} + + {% endif %} +
+ + +
+ {% if preview %} +
+ +
+ {% endif %} + {% if alpine_error %} + + {% if error %} + + {% endif %} + {% if help_text %} + + {% endif %} + {% else %} + {% if error %} + + {% elif help_text %} +

{{ help_text }}

+ {% endif %} + {% endif %} +
diff --git a/templates/v3/includes/_field_text.html b/templates/v3/includes/_field_text.html index 5d68b9249..e52d5ab47 100644 --- a/templates/v3/includes/_field_text.html +++ b/templates/v3/includes/_field_text.html @@ -8,7 +8,7 @@ type (optional) — input type, default "text" help_text (optional) — help text below the field error (optional) — error message (activates error state) - icon_left (optional) — icon name for left slot (e.g. "search") + icon_left (optional) — if truthy, shows a search icon on the left submit_icon (optional) — icon name for a submit button in the right slot (e.g. "arrow-right") submit_label (optional) — aria-label for the submit button, default "Submit" required (optional) — if truthy, adds required attribute @@ -16,7 +16,6 @@ extra_class (optional) — additional classes on the wrapper Usage: {% include "v3/includes/_field_text.html" with name="email" label="Email" placeholder="Enter email" %} - {% include "v3/includes/_field_text.html" with name="q" placeholder="Search..." submit_icon="arrow-right" submit_label="Search" %} {% endcomment %}
{% if label %} @@ -24,7 +23,8 @@ {% endif %}
{% if icon_left %} - + {% endif %} + {% if label %} + + {% endif %} +
+ +
+ {% if error %} + + {% elif help_text %} +

{{ help_text }}

+ {% endif %} +
diff --git a/templates/v3/includes/_wysiwyg_editor.html b/templates/v3/includes/_wysiwyg_editor.html index 5f6771657..96ac0e9a6 100644 --- a/templates/v3/includes/_wysiwyg_editor.html +++ b/templates/v3/includes/_wysiwyg_editor.html @@ -12,14 +12,15 @@ {% include "v3/includes/_wysiwyg_editor.html" with textarea_id="id_content" textarea_name="content" %} {% include "v3/includes/_wysiwyg_editor.html" with textarea_id="id_body" textarea_name="body" label="Body" %} {% endcomment %} -
+
-
-
+ x-show="!jsReady" + :aria-hidden="true" + >{{ value|default_if_none:"" }} +
+