Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/mock_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ class SharedResources:
"author": {
"name": "Prof. Sven G. Bilén, Ph.D.",
"profile_url": "#",
"avatar_url": large_static("img/v3/demo_page/avatar.png"),
"avatar_url": large_static("img/v3/demo-page/avatar.png"),
"role": "The Pennsylvania State University",
"badge": BadgeToken.TIER_3,
},
Expand Down
125 changes: 110 additions & 15 deletions frontend/wysiwyg-editor.js

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,16 @@
"@tiptap/extension-task-item": "^2.27.2",
"@tiptap/extension-task-list": "^2.27.2",
"@tiptap/extension-underline": "^2.10.0",
"@tiptap/pm": "^2.27.2",
"@tiptap/starter-kit": "^2.10.0",
"alpinejs": "^3.10.2",
"autoprefixer": "^10.4.12",
"cssnano": "^5.1.14",
"dompurify": "^3.2.2",
"fuse.js": "7.3.0",
"hast-util-to-html": "^9.0.5",
"htmx": "^0.0.2",
"lowlight": "^3.0.0",
"dompurify": "^3.2.2",
"fuse.js": "7.3.0",
"marked": "^17.0.0",
"tailwindcss": "3.2.1",
"turndown": "^7.2.0",
Expand Down
4 changes: 4 additions & 0 deletions static/css/v3/forms.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
gap: var(--space-default, 8px);
}

.field + .field__label-row {
margin-top: var(--space-default);
}

.field__label {
font-family: var(--font-sans);
font-size: var(--font-size-xs);
Expand Down
7 changes: 6 additions & 1 deletion static/css/v3/user-profile-page.css
Original file line number Diff line number Diff line change
Expand Up @@ -215,14 +215,19 @@
z-index: 10;
}

.user-profile__content-actions {
flex-shrink: 0;
}

.user-profile__save-indicator {
display: inline-flex;
display: flex;
align-items: center;
font-family: var(--font-sans);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
letter-spacing: var(--letter-spacing-tight);
color: var(--color-text-tertiary);
white-space: nowrap;
}

.user-profile__save-state {
Expand Down
15 changes: 15 additions & 0 deletions static/css/v3/wysiwyg-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,20 @@
height: 18px;
}

/* Disabled state (e.g. undo/redo with no available history). */
.wysiwyg-toolbar__btn--disabled,
.wysiwyg-toolbar__btn:disabled {
color: var(--color-text-tertiary);
opacity: 0.25;
cursor: default;
pointer-events: none;
}

.wysiwyg-toolbar__btn--disabled:hover,
.wysiwyg-toolbar__btn:disabled:hover {
background: transparent;
}

.wysiwyg-toolbar__btn--active {
background: var(--color-surface-brand-accent-default, var(--color-primary-orange-mustard));
color: var(--color-icon-on-accent, #050816);
Expand Down Expand Up @@ -308,6 +322,7 @@
color: var(--color-text-primary, #050816);
outline: none;
overflow-wrap: break-word;
word-break: break-word;
}

.wysiwyg-editor__prose p {
Expand Down
94 changes: 47 additions & 47 deletions static/js/v3/wysiwyg-editor.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion templates/v3/includes/_button.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
- icon-library (uses btn-icon-library styles; ignores btn + btn-* classes)
- disabled (optional): if truthy, adds disabled attribute to button
- alpine_disabled (optional): Alpine.js expression for dynamic disabled binding (e.g. "hasErrors")
- alpine_click (optional): Alpine.js expression run on click (e.g. "save()")
- alpine_click (optional): Alpine.js expression for a click handler (e.g. "save()"); button must sit inside an x-data scope
- extra_classes (optional) : Any extra classes that the button should have, as a string
- aria_label (optional): An optional label for assistive technology. Defaults to the button label
- js_disabled (optional, boolean): If this value is true, the button is hidden unless Javascript is disabled on the page
Expand Down
5 changes: 3 additions & 2 deletions templates/v3/includes/_field_text.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
{% elif alpine_error %}:class="{ 'field--error': {{ alpine_error }} }"
{% elif max_chars %}
x-data="{
value: '',
value: '{{ value|default:''|escapejs }}',
maxChars: {{max_chars}},
get charLeft() {
return Number(this.maxChars) - this.value?.length || 0;
Expand Down Expand Up @@ -103,6 +103,7 @@
{% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
{% if value %}value="{{ value }}"{% endif %}
{% if max_chars %}maxlength="{{ max_chars }}"{% endif %}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{% if alpine_error %}:aria-invalid="!!{{ alpine_error }}" :aria-describedby="{{ alpine_error }} ? 'field-{{ name }}-error' : {% if help_text %}'field-{{ name }}-help'{% else %}null{% endif %}
Expand Down Expand Up @@ -133,7 +134,7 @@
<p class="field__error" x-cloak x-show="isEmpty" role="alert">This field is required.</p>
{% endif %}
{% if max_chars and display_max_chars %}
<p class="field__error" x-cloak x-show="isTooLong" role="alert">This field is too long.</p>
<p class="field__error" x-cloak x-show="isTooLong" role="alert">Character limit reached.</p>
{% endif %}
{% if alpine_error %}
<p class="field__error"
Expand Down
8 changes: 5 additions & 3 deletions templates/v3/includes/_wysiwyg_editor.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
disabled_expr — Alpine expression to conditionally disable the textarea for form submission
help_text — Optional help text to explain context
preset — toolbar preset: "full" (default) or "minimal" (fewer tools)
maxlength — optional character limit; when set, input is blocked past it (TipTap CharacterCount)

Usage:
{% include "v3/includes/_wysiwyg_editor.html" with textarea_id="id_content" textarea_name="content" %}
Expand All @@ -18,10 +19,11 @@
{% load static %}

<div class="wysiwyg-v3-container">
<div class="wysiwyg-v3" data-wysiwyg="v3" data-wysiwyg-preset="{{ preset|default:'full' }}" x-data="{ jsReady: false }" x-init="
<div class="wysiwyg-v3" data-wysiwyg="v3" data-wysiwyg-preset="{{ preset|default:'full' }}" {% if maxlength %}data-wysiwyg-maxlength="{{ maxlength }}"{% endif %} x-data="{ jsReady: false }" x-init="
$nextTick(() => {
autoInit('{{textarea_id}}');
jsReady = true;
const mount = () => { window.autoInit('{{textarea_id}}'); jsReady = true; };
if (window.autoInit) mount();
else window.addEventListener('wysiwyg-editor-ready', mount, { once: true });
})"
>
<textarea
Expand Down
91 changes: 64 additions & 27 deletions templates/v3/user_profile_edit.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{% extends 'base.html' %}
{% load static %}
{% load humanize %}

{% block title %}
User Profile - Edit
Expand Down Expand Up @@ -29,11 +30,11 @@
<div class="user-profile__content-actions" x-show="taglineSaveStatus" x-cloak>
<span class="user-profile__save-indicator" role="status" aria-live="polite">
<span class="user-profile__save-state" x-show="taglineSaveStatus === 'saving'" x-cloak>
<span class="user-profile__save-text">Saving</span>
<span class="user-profile__save-text">Saving to draft</span>
{% include "includes/icon.html" with icon_name="saving" icon_class="create-post-page__save-icon create-post-page__save-icon--spin" icon_size=16 %}
</span>
<span class="user-profile__save-state" x-show="taglineSaveStatus === 'saved'" x-cloak>
<span class="user-profile__save-text">Saved</span>
<span class="user-profile__save-text">Saved to draft</span>
{% include "includes/icon.html" with icon_name="saved" icon_class="create-post-page__save-icon" icon_size=16 %}
</span>
</span>
Expand All @@ -42,30 +43,31 @@
</div>
</div>
<div class="user-profile__default-space-col">
{% comment %} Content (Blog, News): WYSIWYG editor. Char counter, Saving/Saved indicator, and draft persistence are driven by the wysiwyg-update event (see handleWysiwygUpdate). No placeholder by design. {% endcomment %}
<div x-data="createWysiwyg" @wysiwyg-update="handleWysiwygUpdate($event)" class="field create-post-page__content-field" :class="{ 'field--error': errors.content }">
{% comment %} Content (Blog, News): WYSIWYG editor. Char counter, Saving to draft/Saved to draft indicator, and draft persistence are driven by the wysiwyg-update event (see handleWysiwygUpdate). No placeholder by design. {% endcomment %}
<div x-data="createWysiwyg" @wysiwyg-update="handleWysiwygUpdate($event)" class="field create-post-page__content-field" :class="{ 'field--error': errors.content || contentLength >= {{ biography_max_length }} }">
<div class="field__label-row">
<label class="field__label" for="field-content">Biography</label>
<span
class="field__max-chars"
aria-hidden="true"
x-text="(20000 - contentLength).toLocaleString() + ' left'">
20,000 left
x-text="({{ biography_max_length }} - contentLength).toLocaleString() + ' left'">
{{ biography_max_length|intcomma }} left
</span>
</div>
{% include "v3/includes/_wysiwyg_editor.html" with textarea_id="field-content" textarea_name="content" label="Content" value=form.content.value preset="minimal" only %}
{% include "v3/includes/_wysiwyg_editor.html" with textarea_id="field-content" textarea_name="content" label="Content" value=user_profile_form.bio.value preset="bio" maxlength=biography_max_length only %}
<p class="field__error" id="field-content-error" role="alert" x-show="errors.content" x-text="errors.content" x-cloak aria-live="polite"></p>
{% comment %} Content auto-save indicator (no Auto-Generate button — that's Description-only). Hidden when nothing is written; driven by contentSaveStatus. {% endcomment %}
<p class="field__error" role="alert" x-show="contentLength >= {{ biography_max_length }}" x-cloak aria-live="polite">Character limit reached.</p>
{% comment %} Content auto-draft-save indicator (no Auto-Generate button — that's Description-only). Hidden when nothing is written; driven by contentSaveStatus. {% endcomment %}
<div class="field__label-row">
<div class="field__help">This text field supports Markdown and this content is what will appear on your public profile</div>
<div class="user-profile__content-actions" x-show="contentSaveStatus" x-cloak>
<span class="user-profile__save-indicator" role="status" aria-live="polite">
<span class="user-profile__save-state" x-show="contentSaveStatus === 'saving'" x-cloak>
<span class="user-profile__save-text">Saving</span>
<span class="user-profile__save-text">Saving to draft</span>
{% include "includes/icon.html" with icon_name="saving" icon_class="create-post-page__save-icon create-post-page__save-icon--spin" icon_size=16 %}
</span>
<span class="user-profile__save-state" x-show="contentSaveStatus === 'saved'" x-cloak>
<span class="user-profile__save-text">Saved</span>
<span class="user-profile__save-text">Saved to draft</span>
{% include "includes/icon.html" with icon_name="saved" icon_class="create-post-page__save-icon" icon_size=16 %}
</span>
</span>
Expand Down Expand Up @@ -140,8 +142,8 @@
</div>
</div>
<hr class="card__hr" aria-hidden="true" />
{% comment %} Persists the Profile links to the current user via PATCH /api/v1/users/me/. {% endcomment %}
<div class="card__cta_section">
{% comment %} Persists the Profile section to the current user via PATCH /api/v1/users/me/. {% endcomment %}
{% include 'v3/includes/_button.html' with style='primary' type='button' label='Save Changes' alpine_disabled="saving" alpine_click="save()" only %}
<span class="user-profile__save-indicator" role="status" aria-live="polite">
<span class="user-profile__save-state" x-show="saveStatus === 'saved'" x-cloak>
Expand Down Expand Up @@ -280,6 +282,11 @@


document.addEventListener('alpine:init', () => {
// Draft keys are scoped to the logged-in user so drafts never leak across
// accounts sharing the same browser.
const TAGLINE_DRAFT_KEY = 'boost:editUser:taglineDraft:{{ request.user.pk }}';
const BIO_DRAFT_KEY = 'boost:editUser:bioDraft:{{ request.user.pk }}';

window.createTagline = function () {
const taglineInput = document.querySelector("input[name='{{user_profile_form.tagline.name}}']")

Expand All @@ -295,11 +302,18 @@
},

taglineDraftKey() {
return 'boost:editUser:taglineDraft';
return TAGLINE_DRAFT_KEY;
},

onTaglineEdited() {
clearTimeout(this._taglineSaveTimer);
// Empty tagline: hide the indicator and drop the draft (mirrors the
// Biography behaviour in createWysiwyg.onContentEdited).
if (!taglineInput || !taglineInput.value) {
this.taglineSaveStatus = '';
this.clearTaglineDraft();
return;
}
this.taglineSaveStatus = 'saving';
this._taglineSaveTimer = setTimeout(() => this.saveTaglineDraft(), 800);
},
Expand Down Expand Up @@ -334,30 +348,29 @@

window.createWysiwyg = function () {
return {
content: '{{ user_profile_form.bio|default_if_none:""|escapejs }}',
content: '{{ user_profile_form.bio.value|default_if_none:""|escapejs }}',
contentLength: 0,
contentSaveStatus: '', // same states, for the Content field
_contentSaveTimer: null,
_draftRestored: false,
errors: {
content: '{{ user_profile_form.bio.errors.0|default:""|escapejs }}',
},


async init() {
await Alpine.nextTick()
this.restoreContentDraft();
},

// Bridge from the WYSIWYG editor: keep the counter, indicator, and draft
// in sync. Programmatic updates (initial load / restore) skip the
// "Saving" animation and persistence.
// "Saving to draft" animation and persistence.
handleWysiwygUpdate(e) {
if (e.detail.id !== 'field-content') return;
this.contentLength = e.detail.characters;
this.contentLength = e.detail.markdownCharacters;
this.content = e.detail.value;
if (e.detail.programmatic) {
this.contentSaveStatus = this.content ? 'saved' : '';
} else {
// The first (programmatic) update signals the editor is mounted —
// restoring any sooner would dispatch into a not-yet-created editor.
if (!this._draftRestored) {
this._draftRestored = true;
this.restoreContentDraft();
}
if (!e.detail.programmatic) {
this.onContentEdited();
}
},
Expand All @@ -369,9 +382,9 @@
}));
},

// ── Content draft: localStorage, keyed per post type (mirrors Description) ──
// ── Content draft: localStorage, keyed per user (mirrors Tagline) ──
contentDraftKey() {
return 'boost:userEdit:bioDraft:' + (this.postType || '');
return BIO_DRAFT_KEY;
},

onContentEdited(e) {
Expand Down Expand Up @@ -418,6 +431,7 @@
const linkInput = (type) => document.querySelector(`[data-link-type='${type}']`);
const SLACK_PROFILE_URL_PREFIX = '{{ SLACK_PROFILE_URL_PREFIX|escapejs }}';
const SLACK_MEMBER_ID_PATTERN = /^[A-Z0-9]{9,11}$/i;
const taglineInput = document.querySelector("input[name='{{user_profile_form.tagline.name}}']");

return {
saving: false,
Expand Down Expand Up @@ -497,6 +511,12 @@
return links;
},

// The <textarea> only syncs on form submit, which we don't use; read the live value.
currentBiography() {
const el = document.querySelector('[x-data="createWysiwyg"]');
return el && window.Alpine ? (Alpine.$data(el).content || '') : '';
},

async save() {
if (this.saving) return;

Expand All @@ -509,6 +529,13 @@
});
if (hasError) return;

const biography = this.currentBiography();
if (biography.length > {{ biography_max_length }}) {
this.saveStatus = 'error';
this.saveError = 'Biography is too long. Please shorten it and try again.';
return;
}

this.saving = true;
this.saveStatus = '';
this.saveError = '';
Expand All @@ -519,7 +546,11 @@
'X-CSRFToken': '{{ csrf_token }}',
'Content-Type': 'application/json',
},
body: JSON.stringify({ profile_links: this.collectLinks() }),
body: JSON.stringify({
profile_links: this.collectLinks(),
tagline: taglineInput ? taglineInput.value : '',
biography: biography,
}),
});
if (!res.ok) {
let data = {};
Expand All @@ -542,6 +573,12 @@
throw new Error(fieldErrors.length ? fieldErrors.join(' ') : `Save failed (${res.status})`);
}
this.saveStatus = 'saved';
// The server now holds these values; drop the drafts so they can't
// shadow the saved data on the next visit.
try {
localStorage.removeItem(TAGLINE_DRAFT_KEY);
localStorage.removeItem(BIO_DRAFT_KEY);
} catch (_) {}
} catch (err) {
console.error('Profile save failed:', err);
this.saveStatus = 'error';
Expand Down
Loading
Loading