Skip to content

Commit b6e124d

Browse files
feat: store Content and Description in browser localstorage
1 parent fb184c0 commit b6e124d

2 files changed

Lines changed: 133 additions & 20 deletions

File tree

static/css/v3/create-post-page.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@
100100
flex-wrap: wrap;
101101
}
102102

103+
/* Content has no Auto-Generate button, so its save indicator sits alone on the right. */
104+
.create-post-page__content-actions {
105+
display: flex;
106+
justify-content: flex-end;
107+
}
108+
103109
.create-post-page__generate-btn {
104110
flex: 0 0 auto;
105111
}

templates/news/v3/create.html

Lines changed: 127 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -83,15 +83,29 @@ <h1 class="create-post-page__title">Create Post</h1>
8383
maxlength="20000"
8484
placeholder="What would you like to write?"
8585
x-model="content"
86+
@input="onContentEdited($event)"
8687
:disabled="isLinkType"
8788
:aria-invalid="!!errors.content"
8889
:aria-describedby="errors.content ? 'field-content-error' : null"
8990
></textarea>
9091
</div>
9192
<p class="field__error" id="field-content-error" role="alert" x-show="errors.content" x-text="errors.content" x-cloak aria-live="polite"></p>
93+
{% comment %} Content auto-save indicator (no Auto-Generate button — that's Description-only). Hidden when nothing is written; driven by contentSaveStatus. {% endcomment %}
94+
<div class="create-post-page__content-actions" x-show="contentSaveStatus" x-cloak>
95+
<span class="create-post-page__save-indicator" role="status" aria-live="polite">
96+
<span class="create-post-page__save-state" x-show="contentSaveStatus === 'saving'" x-cloak>
97+
<span class="create-post-page__save-text">Saving</span>
98+
{% include "includes/icon.html" with icon_name="saving" icon_class="create-post-page__save-icon create-post-page__save-icon--spin" icon_size=16 icon_viewbox="0 0 13.3333 13.3333" %}
99+
</span>
100+
<span class="create-post-page__save-state" x-show="contentSaveStatus === 'saved'" x-cloak>
101+
<span class="create-post-page__save-text">Saved</span>
102+
{% include "includes/icon.html" with icon_name="saved" icon_class="create-post-page__save-icon" icon_size=16 icon_viewbox="0 0 13.3333 13.3333" %}
103+
</span>
104+
</span>
105+
</div>
92106
</div>
93107

94-
{% comment %} Description (Blog, News): plain-text field with char counter, Auto-Generate button, and auto-save indicator. Generation + save/saved toggle are wired in follow-up commits. {% endcomment %}
108+
{% comment %} Description (Blog, News): plain-text field with char counter, Auto-Generate button, and auto-save indicator. {% endcomment %}
95109
<div class="field field--textarea create-post-page__description-section" :style="isWriteUp ? 'display: flex' : 'display: none'">
96110
<div class="create-post-page__field-header">
97111
<label class="field__label" for="field-post_description">Description</label>
@@ -106,18 +120,19 @@ <h1 class="create-post-page__title">Create Post</h1>
106120
maxlength="1000"
107121
placeholder="What would you like to write?"
108122
x-model="description"
123+
@input="onDescriptionEdited($event)"
109124
></textarea>
110125
</div>
111126
<div class="create-post-page__description-actions">
112127
{% comment %} Calls the generate-description endpoint; disabled while in flight. Full idle/generating/generated states are a follow-up. {% endcomment %}
113128
<button type="button" class="btn btn-secondary create-post-page__generate-btn" @click="generateDescription()" :disabled="generating">Auto-Generate Description</button>
114-
{% comment %} Hidden when nothing is written; "Saving" while the Description changes, "Saved" once it settles (saveStatus is driven by the $watch in init()). {% endcomment %}
129+
{% comment %} Hidden when nothing is written; "Saving" while editing, "Saved" once the draft is written to localStorage (descriptionSaveStatus driven by onDescriptionEdited / restoreDescriptionDraft). {% endcomment %}
115130
<span class="create-post-page__save-indicator" role="status" aria-live="polite">
116-
<span class="create-post-page__save-state" x-show="saveStatus === 'saving'" x-cloak>
131+
<span class="create-post-page__save-state" x-show="descriptionSaveStatus === 'saving'" x-cloak>
117132
<span class="create-post-page__save-text">Saving</span>
118133
{% include "includes/icon.html" with icon_name="saving" icon_class="create-post-page__save-icon create-post-page__save-icon--spin" icon_size=16 icon_viewbox="0 0 13.3333 13.3333" %}
119134
</span>
120-
<span class="create-post-page__save-state" x-show="saveStatus === 'saved'" x-cloak>
135+
<span class="create-post-page__save-state" x-show="descriptionSaveStatus === 'saved'" x-cloak>
121136
<span class="create-post-page__save-text">Saved</span>
122137
{% include "includes/icon.html" with icon_name="saved" icon_class="create-post-page__save-icon" icon_size=16 icon_viewbox="0 0 13.3333 13.3333" %}
123138
</span>
@@ -152,8 +167,10 @@ <h1 class="create-post-page__title">Create Post</h1>
152167
content: '{{ form.content.value|default_if_none:""|escapejs }}',
153168
description: '{{ form.description.value|default_if_none:""|escapejs }}',
154169
generating: false,
155-
saveStatus: '', // '' = nothing written (hidden), 'saving', 'saved'
156-
_saveTimer: null,
170+
descriptionSaveStatus: '', // '' (hidden) / 'saving' / 'saved'
171+
contentSaveStatus: '', // same states, for the Content field
172+
_descriptionSaveTimer: null,
173+
_contentSaveTimer: null,
157174
errors: {
158175
post_type: '',
159176
title: '{{ form.title.errors.0|default:""|escapejs }}',
@@ -166,29 +183,118 @@ <h1 class="create-post-page__title">Create Post</h1>
166183
get isLinkType() { return this.postType === 'video' || this.postType === 'link'; },
167184

168185
init() {
169-
// Description indicator: show "Saving" while it changes (typing or
170-
// auto-generate), settle to "Saved" shortly after, hide when empty.
171-
// Display only for now — localStorage persistence comes next.
172-
this.$watch('description', (value) => {
173-
clearTimeout(this._saveTimer);
174-
if (!value) {
175-
this.saveStatus = '';
176-
return;
177-
}
178-
this.saveStatus = 'saving';
179-
this._saveTimer = setTimeout(() => { this.saveStatus = 'saved'; }, 800);
180-
});
186+
// Restore saved Content and Description drafts for the current post
187+
// type. On a fresh load with no draft this leaves the field untouched.
188+
this.restoreContentDraft();
189+
this.restoreDescriptionDraft();
181190
},
182191

183192
handleFieldChange(e) {
184193
const { name, value } = e.detail;
185194
if (name !== 'post_type') return;
195+
// Persist the current type's draft before switching, then load the
196+
// draft belonging to the newly selected type.
197+
clearTimeout(this._descriptionSaveTimer);
198+
clearTimeout(this._contentSaveTimer);
199+
this.saveContentDraft();
200+
this.saveDescriptionDraft();
186201
this.postType = value;
187202
this.errors.post_type = '';
203+
this.restoreContentDraft({ allowEmpty: true });
204+
this.restoreDescriptionDraft({ allowEmpty: true });
205+
},
206+
207+
// ── Description draft: localStorage, keyed per post type ─────────────
208+
descriptionDraftKey() {
209+
return 'boost:createPost:descriptionDraft:' + (this.postType || '');
210+
},
211+
212+
// Runs on each keystroke (and after auto-generate): show "Saving", then
213+
// write the draft and show "Saved" once edits settle. Empty clears it.
214+
onDescriptionEdited(e) {
215+
const value = e ? e.target.value : this.description;
216+
clearTimeout(this._descriptionSaveTimer);
217+
if (!value) {
218+
this.descriptionSaveStatus = '';
219+
this.clearDescriptionDraft();
220+
return;
221+
}
222+
this.descriptionSaveStatus = 'saving';
223+
this._descriptionSaveTimer = setTimeout(() => {
224+
this.saveDescriptionDraft();
225+
this.descriptionSaveStatus = 'saved';
226+
}, 800);
227+
},
228+
229+
saveDescriptionDraft() {
230+
if (!this.isWriteUp) return;
231+
try {
232+
if (this.description) localStorage.setItem(this.descriptionDraftKey(), this.description);
233+
else localStorage.removeItem(this.descriptionDraftKey());
234+
} catch (_) {}
235+
},
236+
237+
restoreDescriptionDraft({ allowEmpty = false } = {}) {
238+
if (!this.isWriteUp) {
239+
if (allowEmpty) this.descriptionSaveStatus = '';
240+
return;
241+
}
242+
let saved = null;
243+
try { saved = localStorage.getItem(this.descriptionDraftKey()); } catch (_) {}
244+
if (saved !== null) this.description = saved;
245+
else if (allowEmpty) this.description = '';
246+
this.descriptionSaveStatus = this.description ? 'saved' : '';
247+
},
248+
249+
clearDescriptionDraft() {
250+
try { localStorage.removeItem(this.descriptionDraftKey()); } catch (_) {}
251+
},
252+
253+
// ── Content draft: localStorage, keyed per post type (mirrors Description) ──
254+
contentDraftKey() {
255+
return 'boost:createPost:contentDraft:' + (this.postType || '');
256+
},
257+
258+
onContentEdited(e) {
259+
const value = e ? e.target.value : this.content;
260+
clearTimeout(this._contentSaveTimer);
261+
if (!value) {
262+
this.contentSaveStatus = '';
263+
this.clearContentDraft();
264+
return;
265+
}
266+
this.contentSaveStatus = 'saving';
267+
this._contentSaveTimer = setTimeout(() => {
268+
this.saveContentDraft();
269+
this.contentSaveStatus = 'saved';
270+
}, 800);
271+
},
272+
273+
saveContentDraft() {
274+
if (!this.isWriteUp) return;
275+
try {
276+
if (this.content) localStorage.setItem(this.contentDraftKey(), this.content);
277+
else localStorage.removeItem(this.contentDraftKey());
278+
} catch (_) {}
279+
},
280+
281+
restoreContentDraft({ allowEmpty = false } = {}) {
282+
if (!this.isWriteUp) {
283+
if (allowEmpty) this.contentSaveStatus = '';
284+
return;
285+
}
286+
let saved = null;
287+
try { saved = localStorage.getItem(this.contentDraftKey()); } catch (_) {}
288+
if (saved !== null) this.content = saved;
289+
else if (allowEmpty) this.content = '';
290+
this.contentSaveStatus = this.content ? 'saved' : '';
291+
},
292+
293+
clearContentDraft() {
294+
try { localStorage.removeItem(this.contentDraftKey()); } catch (_) {}
188295
},
189296

190-
// Send the Content to the backend, which runs the summarization model
191-
// and returns a description (capped server-side at ~970 chars).
297+
// Send the Content to the backend, which runs the summarization model.
192298
async generateDescription() {
193299
const content = (this.content || '').trim();
194300
if (!content || this.generating) return;
@@ -206,6 +312,7 @@ <h1 class="create-post-page__title">Create Post</h1>
206312
const data = await res.json();
207313
if (res.ok && data.description) {
208314
this.description = data.description;
315+
this.onDescriptionEdited(); // persist generated text + show Saving/Saved
209316
} else {
210317
console.error('Description generation failed:', data.error || res.status);
211318
}

0 commit comments

Comments
 (0)