@@ -24,6 +24,7 @@ import {
2424 Box ,
2525 Button ,
2626 Flex ,
27+ Hint ,
2728 IconClose ,
2829 IconCloudUpload ,
2930 IconKebabHorizontal ,
@@ -58,6 +59,7 @@ import { AttachVideoModal } from '../fan-club-detail-page/components/AttachVideo
5859
5960import { AddSourceTrackModal } from './AddSourceTrackModal'
6061import { ManageStemsModal } from './ManageStemsModal'
62+ import { useContestDraft } from './useContestDraft'
6163import { useUploadContestCover } from './useUploadContestCover'
6264
6365const { CONTESTS_PAGE } = route
@@ -95,6 +97,8 @@ const messages = {
9597 sourceTracksSectionLabel : 'SOURCE TRACK' ,
9698 addTrack : '+ Add Track' ,
9799 cancel : 'Cancel' ,
100+ saveDraft : 'Save Draft' ,
101+ draftSaved : 'Draft saved' ,
98102 launch : 'Launch' ,
99103 save : 'Save' ,
100104 turnOff : 'Delete Contest' ,
@@ -108,7 +112,11 @@ const messages = {
108112 manageStems : 'Manage Stems' ,
109113 remove : 'Remove' ,
110114 noStems : 'No Stems' ,
111- stemsCount : ( n : number ) => `${ n } Stem${ n === 1 ? '' : 's' } `
115+ stemsCount : ( n : number ) => `${ n } Stem${ n === 1 ? '' : 's' } ` ,
116+ draftRestoreTitle : ( savedAt : string ) =>
117+ `You have an unsaved draft from ${ savedAt } .` ,
118+ restoreDraft : 'Restore Draft' ,
119+ discardDraft : 'Discard'
112120}
113121
114122const SOURCE_TRACK_ROW_HEIGHT = 56
@@ -177,74 +185,50 @@ export const HostRemixContestPage = () => {
177185 const primaryPermalink = primaryTrack ?. permalink ?? ''
178186
179187 // ---------------------------------------------------------------------------
180- // Draft persistence — keyed by handle/slug (or 'new' for the trackless
181- // route) so a user can navigate to edit a source track and return without
182- // losing in-flight form state. Cleared on launch / cancel / delete.
188+ // Draft persistence
183189 // ---------------------------------------------------------------------------
184- const draftStorageKey = useMemo (
185- ( ) =>
186- handle && slug
187- ? `host-remix-contest-draft:${ handle } /${ slug } `
188- : 'host-remix-contest-draft:new' ,
189- [ handle , slug ]
190- )
191- const draft = useMemo ( ( ) => {
192- if ( typeof window === 'undefined' ) return null
193- try {
194- const raw = window . sessionStorage . getItem ( draftStorageKey )
195- return raw ? JSON . parse ( raw ) : null
196- } catch {
197- return null
198- }
199- // Read once on mount; ignore deps churn.
200- // eslint-disable-next-line react-hooks/exhaustive-deps
201- } , [ draftStorageKey ] )
190+ // Drafts only apply to the create flow — edit mode is driven by the
191+ // live event. Scope per user and per primary track (or "trackless" for
192+ // the /host-contest entry).
193+ const { existingDraft, saveDraft, clearDraft } = useContestDraft ( {
194+ userId : currentUserId ,
195+ primaryTrackId,
196+ enabled : ! isEdit
197+ } )
198+ const [ showDraftBanner , setShowDraftBanner ] = useState ( ! ! existingDraft )
199+ const [ draftSavedAt , setDraftSavedAt ] = useState < string | null > ( null )
202200
203201 // ---------------------------------------------------------------------------
204202 // Form state
205203 // ---------------------------------------------------------------------------
206- const [ title , setTitle ] = useState (
207- draft ?. title ?? existingEventData . title ?? ''
208- )
204+ const [ title , setTitle ] = useState ( existingEventData . title ?? '' )
209205 const [ description , setDescription ] = useState (
210- draft ?. description ?? existingEventData . description ?? ''
206+ existingEventData . description ?? ''
211207 )
212208 const [ descriptionError , setDescriptionError ] = useState ( false )
213- const [ videoUrl , setVideoUrl ] = useState (
214- draft ?. videoUrl ?? existingEventData . videoUrl ?? ''
215- )
209+ const [ videoUrl , setVideoUrl ] = useState ( existingEventData . videoUrl ?? '' )
216210 const [ showAttachVideoModal , setShowAttachVideoModal ] = useState ( false )
217211 const [ coverPhotoUrl , setCoverPhotoUrl ] = useState (
218- draft ?. coverPhotoUrl ?? existingEventData . coverPhotoUrl ?? ''
219- )
220- const [ prizeInfo , setPrizeInfo ] = useState (
221- draft ?. prizeInfo ?? existingEventData . prizeInfo ?? ''
212+ existingEventData . coverPhotoUrl ?? ''
222213 )
214+ const [ prizeInfo , setPrizeInfo ] = useState ( existingEventData . prizeInfo ?? '' )
223215
224- const initialEndDate = draft ?. contestEndDate
225- ? dayjs ( draft . contestEndDate )
226- : remixContest
227- ? dayjs ( remixContest . endDate )
228- : null
216+ const initialEndDate = remixContest ? dayjs ( remixContest . endDate ) : null
229217 const [ contestEndDate , setContestEndDate ] = useState < dayjs . Dayjs | null > (
230218 initialEndDate
231219 )
232220 const [ endDateTouched , setEndDateTouched ] = useState ( false )
233221 const [ endDateError , setEndDateError ] = useState ( false )
234222 const [ timeValue , setTimeValue ] = useState (
235- draft ?. timeValue ??
236- ( initialEndDate ? dayjs ( initialEndDate ) . format ( 'hh:mm' ) : '' )
223+ initialEndDate ? dayjs ( initialEndDate ) . format ( 'hh:mm' ) : ''
237224 )
238225 const [ timeError , setTimeError ] = useState ( false )
239226 const [ meridianValue , setMeridianValue ] = useState (
240- draft ?. meridianValue ??
241- ( initialEndDate ? dayjs ( initialEndDate ) . format ( 'A' ) : '' )
227+ initialEndDate ? dayjs ( initialEndDate ) . format ( 'A' ) : ''
242228 )
243229
244230 const [ sourceTrackIds , setSourceTrackIds ] = useState < number [ ] > (
245- draft ?. sourceTrackIds ??
246- existingEventData . sourceTrackIds ??
247- ( primaryTrackId ? [ primaryTrackId ] : [ ] )
231+ existingEventData . sourceTrackIds ?? ( primaryTrackId ? [ primaryTrackId ] : [ ] )
248232 )
249233
250234 // Hydrate form state once `remixContest` resolves.
@@ -253,15 +237,12 @@ export const HostRemixContestPage = () => {
253237 // the form mounts before the React Query fetch finishes, so the initial
254238 // state captures empty values and never picks up the resolved contest
255239 // data — Title, Description, Prizes, Video Link, etc. all rendered blank
256- // even though the backend had them. Skip if a draft exists (the user
257- // already started typing) so we don't overwrite in-flight edits.
240+ // even though the backend had them. Drafts are disabled in edit mode
241+ // (the useContestDraft hook is gated on `!isEdit`), so there's no
242+ // in-flight draft to worry about clobbering here.
258243 const hasHydratedRef = useRef ( false )
259244 useEffect ( ( ) => {
260245 if ( hasHydratedRef . current ) return
261- if ( draft ) {
262- hasHydratedRef . current = true
263- return
264- }
265246 if ( ! remixContest ) return
266247 const data = remixContest . eventData as ContestEventData
267248 if ( data . title ) setTitle ( data . title )
@@ -279,7 +260,7 @@ export const HostRemixContestPage = () => {
279260 setSourceTrackIds ( data . sourceTrackIds )
280261 }
281262 hasHydratedRef . current = true
282- } , [ remixContest , draft ] )
263+ } , [ remixContest ] )
283264
284265 // On the track-scoped route (/:handle/:slug/host-contest) the URL
285266 // identifies the source track, but `useTrackByPermalink` is async — by
@@ -319,30 +300,42 @@ export const HostRemixContestPage = () => {
319300 )
320301 const [ isDeleteConfirmOpen , setIsDeleteConfirmOpen ] = useState ( false )
321302
322- // Persist form state on every change so the user can leave (e.g. to
323- // edit a source track) and return without losing what they typed.
303+ // Debounced auto-save: while the user is editing in create mode, persist
304+ // the draft a couple of seconds after they stop typing. Skipped while the
305+ // restore banner is up (the user hasn't decided what to do with their
306+ // existing draft yet) and when the form is entirely empty (no point
307+ // overwriting an existing localStorage entry — discarding it should
308+ // mean discarded).
309+ const hasFormContent =
310+ ! ! title ||
311+ ! ! description ||
312+ ! ! videoUrl ||
313+ ! ! coverPhotoUrl ||
314+ ! ! prizeInfo ||
315+ ! ! contestEndDate ||
316+ sourceTrackIds . length > 0
317+
324318 useEffect ( ( ) => {
325- if ( typeof window === 'undefined' ) return
326- try {
327- window . sessionStorage . setItem (
328- draftStorageKey ,
329- JSON . stringify ( {
330- title,
331- description,
332- videoUrl,
333- coverPhotoUrl,
334- prizeInfo,
335- contestEndDate : contestEndDate ?. toISOString ( ) ?? null ,
336- timeValue,
337- meridianValue,
338- sourceTrackIds
339- } )
340- )
341- } catch {
342- /* ignore quota errors */
343- }
319+ if ( isEdit || showDraftBanner || ! hasFormContent ) return
320+ const timer = window . setTimeout ( ( ) => {
321+ saveDraft ( {
322+ title,
323+ description,
324+ videoUrl,
325+ coverPhotoUrl,
326+ prizeInfo,
327+ contestEndDate : contestEndDate ?. toISOString ( ) ,
328+ timeValue,
329+ meridianValue,
330+ sourceTrackIds
331+ } )
332+ } , 1500 )
333+ return ( ) => window . clearTimeout ( timer )
344334 } , [
345- draftStorageKey ,
335+ isEdit ,
336+ showDraftBanner ,
337+ hasFormContent ,
338+ saveDraft ,
346339 title ,
347340 description ,
348341 videoUrl ,
@@ -354,15 +347,6 @@ export const HostRemixContestPage = () => {
354347 sourceTrackIds
355348 ] )
356349
357- const clearDraft = useCallback ( ( ) => {
358- if ( typeof window === 'undefined' ) return
359- try {
360- window . sessionStorage . removeItem ( draftStorageKey )
361- } catch {
362- /* ignore */
363- }
364- } , [ draftStorageKey ] )
365-
366350 // ---------------------------------------------------------------------------
367351 // Cover-photo upload + fallback to track artwork
368352 // ---------------------------------------------------------------------------
@@ -435,6 +419,70 @@ export const HostRemixContestPage = () => {
435419 } )
436420 } , [ ] )
437421
422+ const handleRestoreDraft = useCallback ( ( ) => {
423+ if ( ! existingDraft ) return
424+ if ( existingDraft . title !== undefined ) setTitle ( existingDraft . title )
425+ if ( existingDraft . description !== undefined ) {
426+ setDescription ( existingDraft . description )
427+ }
428+ if ( existingDraft . videoUrl !== undefined ) {
429+ setVideoUrl ( existingDraft . videoUrl )
430+ }
431+ if ( existingDraft . coverPhotoUrl !== undefined ) {
432+ setCoverPhotoUrl ( existingDraft . coverPhotoUrl )
433+ }
434+ if ( existingDraft . prizeInfo !== undefined ) {
435+ setPrizeInfo ( existingDraft . prizeInfo )
436+ }
437+ if ( existingDraft . contestEndDate ) {
438+ setContestEndDate ( dayjs ( existingDraft . contestEndDate ) )
439+ }
440+ if ( existingDraft . timeValue !== undefined ) {
441+ setTimeValue ( existingDraft . timeValue )
442+ }
443+ if ( existingDraft . meridianValue !== undefined ) {
444+ setMeridianValue ( existingDraft . meridianValue )
445+ }
446+ if ( existingDraft . sourceTrackIds !== undefined ) {
447+ setSourceTrackIds ( existingDraft . sourceTrackIds )
448+ }
449+ setShowDraftBanner ( false )
450+ } , [ existingDraft ] )
451+
452+ const handleDiscardDraft = useCallback ( ( ) => {
453+ clearDraft ( )
454+ setShowDraftBanner ( false )
455+ } , [ clearDraft ] )
456+
457+ const handleSaveDraft = useCallback ( ( ) => {
458+ saveDraft ( {
459+ title,
460+ description,
461+ videoUrl,
462+ coverPhotoUrl,
463+ prizeInfo,
464+ contestEndDate : contestEndDate ?. toISOString ( ) ,
465+ timeValue,
466+ meridianValue,
467+ sourceTrackIds
468+ } )
469+ setDraftSavedAt ( new Date ( ) . toISOString ( ) )
470+ // After saving, the banner is no longer offering to restore — the
471+ // user is already working with that state.
472+ setShowDraftBanner ( false )
473+ } , [
474+ saveDraft ,
475+ title ,
476+ description ,
477+ videoUrl ,
478+ coverPhotoUrl ,
479+ prizeInfo ,
480+ contestEndDate ,
481+ timeValue ,
482+ meridianValue ,
483+ sourceTrackIds
484+ ] )
485+
438486 const handleCancel = useCallback ( ( ) => {
439487 clearDraft ( )
440488 // Track-less flow: fall back to the contests discovery page.
@@ -594,6 +642,33 @@ export const HostRemixContestPage = () => {
594642 { isEdit ? messages . editPageTitle : messages . pageTitle }
595643 </ Text >
596644
645+ { showDraftBanner && existingDraft ? (
646+ < Hint
647+ actions = {
648+ < >
649+ < Button
650+ variant = 'primary'
651+ size = 'small'
652+ onClick = { handleRestoreDraft }
653+ >
654+ { messages . restoreDraft }
655+ </ Button >
656+ < Button
657+ variant = 'secondary'
658+ size = 'small'
659+ onClick = { handleDiscardDraft }
660+ >
661+ { messages . discardDraft }
662+ </ Button >
663+ </ >
664+ }
665+ >
666+ { messages . draftRestoreTitle (
667+ dayjs ( existingDraft . savedAt ) . format ( 'MMM D, h:mm A' )
668+ ) }
669+ </ Hint >
670+ ) : null }
671+
597672 { /* Section: Contest Title */ }
598673 < Paper
599674 direction = 'column'
@@ -976,6 +1051,15 @@ export const HostRemixContestPage = () => {
9761051 { messages . turnOff }
9771052 </ Button >
9781053 ) : null }
1054+ { /* Manual draft save: persists the full form to localStorage
1055+ for the current user + scope until the user discards it
1056+ or launches the contest. Hidden in edit mode since the
1057+ live event is the source of truth. */ }
1058+ { ! isEdit ? (
1059+ < Button variant = 'secondary' onClick = { handleSaveDraft } >
1060+ { draftSavedAt ? messages . draftSaved : messages . saveDraft }
1061+ </ Button >
1062+ ) : null }
9791063 < Button
9801064 variant = 'primary'
9811065 onClick = { handleSubmit }
@@ -1034,7 +1118,7 @@ type SourceTrackRowProps = {
10341118 /**
10351119 * Path the track-edit page should return to when the user clicks
10361120 * Save / Back. Encoded into a `returnTo` query param so the in-flight
1037- * contest-creation form is restored from sessionStorage on landing.
1121+ * contest-creation form can be picked back up from its draft on landing.
10381122 */
10391123 editReturnTo : string
10401124}
0 commit comments