@@ -3,10 +3,20 @@ import type { Tables } from '@/types/database.overrides'
33import { Alert , Button , Divider , Dropdown , DropdownItem , pushToast , Tooltip } from ' @dolanske/vui'
44import { useDataUser } from ' @/composables/useDataUser'
55import { useDiscussionCache } from ' @/composables/useDiscussionCache'
6+ import { slugify } from ' @/lib/utils/formatting'
67import ConfirmModal from ' ../Shared/ConfirmModal.vue'
78import ForumModalAddDiscussion from ' ./ForumModalAddDiscussion.vue'
89import ForumModalAddTopic from ' ./ForumModalAddTopic.vue'
910
11+ const props = defineProps <Props >()
12+
13+ const emit = defineEmits <{
14+ update: [data : Props [' data' ]]
15+ remove: [id : string ]
16+ }>()
17+
18+ const SLUG_DATE_PREFIX_RE = / ^ \d {4} -\d {2} -\d {2} -/
19+
1020interface ModalControls {
1121 hideDiscussionTabs? : boolean
1222}
@@ -20,11 +30,6 @@ type Props
2030 data: Tables <' discussions' >
2131 }
2232
23- const props = defineProps <Props >()
24- const emit = defineEmits <{
25- update: [data : Props [' data' ]]
26- remove: [id : string ]
27- }>()
2833const dropdownRef = useTemplateRef (' dropdownRef' )
2934const supabase = useSupabaseClient ()
3035
@@ -177,9 +182,146 @@ const linkedDiscussionReason = computed(() => {
177182 || discussion .referendum_id ,
178183 )
179184
180- return isLinked ? ' This discussion is linked to an entity and cannot be deleted.' : null
185+ return isLinked ? ' This discussion is linked to an entity and cannot be re-created or deleted.' : null
181186})
182187
188+ // Re-create
189+ const recreateConfirm = ref (false )
190+ const recreateLoading = ref (false )
191+
192+ const recreateDescription = computed (() => {
193+ if (props .table !== ' discussions' )
194+ return ' '
195+ const discussion = props .data as Tables <' discussions' >
196+ const created = new Date (discussion .created_at )
197+ const suffix = ` ${created .getFullYear ()}-${String (created .getMonth () + 1 ).padStart (2 , ' 0' )}-${String (created .getDate ()).padStart (2 , ' 0' )} `
198+ return ` This will lock, unpin, and archive "${discussion .title ?? ' this discussion' }" - renaming its slug with a ${suffix }- prefix - then create a fresh discussion with the same title, description, and content. This cannot be undone. `
199+ })
200+
201+ async function handleRecreate() {
202+ if (props .table !== ' discussions' )
203+ return
204+
205+ recreateLoading .value = true
206+ dropdownRef .value ?.close ()
207+
208+ const discussion = props .data as Tables <' discussions' >
209+
210+ // Build the YYYY-MM-DD suffix from the discussion's creation date
211+ const created = new Date (discussion .created_at )
212+ const suffix = ` ${created .getFullYear ()}-${String (created .getMonth () + 1 ).padStart (2 , ' 0' )}-${String (created .getDate ()).padStart (2 , ' 0' )} `
213+
214+ const oldTitle = discussion .title ?? ' Untitled'
215+ const archivedTitle = ` ${oldTitle } (${suffix }) `
216+ const baseSlug = discussion .slug ?? slugify (oldTitle )
217+ // Strip any existing YYYY-MM-DD prefix from the slug before prepending
218+ const cleanSlug = baseSlug .replace (SLUG_DATE_PREFIX_RE , ' ' )
219+ const archivedSlugBase = ` ${suffix }-${cleanSlug } `
220+
221+ // Resolve a free archived slug - append -2, -3, etc. on conflict
222+ let archivedSlug = archivedSlugBase
223+ {
224+ let counter = 1
225+ while (true ) {
226+ const { data : existing } = await supabase
227+ .from (' discussions' )
228+ .select (' id' )
229+ .eq (' slug' , archivedSlug )
230+ .neq (' id' , discussion .id )
231+ .limit (1 )
232+ if (! existing || existing .length === 0 )
233+ break
234+ counter ++
235+ archivedSlug = ` ${archivedSlugBase }-${counter } `
236+ }
237+ }
238+
239+ // Resolve a free slug for the new discussion - the original slug may already
240+ // be taken if this discussion was previously re-created
241+ let newSlug = baseSlug
242+ {
243+ let counter = 1
244+ while (true ) {
245+ const { data : existing } = await supabase
246+ .from (' discussions' )
247+ .select (' id' )
248+ .eq (' slug' , newSlug )
249+ .limit (1 )
250+ if (! existing || existing .length === 0 )
251+ break
252+ counter ++
253+ newSlug = ` ${baseSlug }-${counter } `
254+ }
255+ }
256+
257+ try {
258+ // 1. Update the old discussion: lock, unsticky, archive, rename
259+ const { data : archivedData, error : archiveError } = await supabase
260+ .from (' discussions' )
261+ .update ({
262+ is_locked: true ,
263+ is_sticky: false ,
264+ is_archived: true ,
265+ title: archivedTitle ,
266+ slug: archivedSlug ,
267+ })
268+ .eq (' id' , discussion .id )
269+ .select ()
270+ .single ()
271+
272+ if (archiveError ) {
273+ pushToast (' Failed to archive the original discussion' , { description: archiveError .message })
274+ recreateLoading .value = false
275+ return
276+ }
277+
278+ // Invalidate the old slug key now that the slug has changed - the old URL
279+ // should no longer serve a cache hit for the pre-rename data.
280+ discussionCache .invalidate (discussion .id , discussion .slug )
281+ // Warm the cache with the renamed archived record so /forum/YYYY-MM-DD-slug
282+ // is a cache hit if someone navigates there.
283+ discussionCache .set (archivedData )
284+
285+ // 2. Create the new discussion with the original title, description, content and topic
286+ const { data : newData, error : createError } = await supabase
287+ .from (' discussions' )
288+ .insert ({
289+ title: oldTitle ,
290+ slug: newSlug ,
291+ description: discussion .description ,
292+ markdown: discussion .markdown ,
293+ discussion_topic_id: discussion .discussion_topic_id ,
294+ is_draft: false ,
295+ is_locked: false ,
296+ is_sticky: false ,
297+ is_archived: false ,
298+ is_nsfw: discussion .is_nsfw ?? false ,
299+ })
300+ .select ()
301+ .single ()
302+
303+ if (createError ) {
304+ pushToast (' Failed to create the new discussion' , { description: createError .message })
305+ recreateLoading .value = false
306+ return
307+ }
308+
309+ // Warm the cache with the new discussion so the navigation below is an
310+ // immediate cache hit rather than a fresh DB round-trip.
311+ discussionCache .set (newData )
312+
313+ pushToast (` Re-created discussion "${oldTitle }" ` )
314+ emit (' update' , props .data )
315+ recreateConfirm .value = false
316+ recreateLoading .value = false
317+
318+ await navigateTo (` /forum/${newData .slug ?? newData .id } ` )
319+ }
320+ catch {
321+ recreateLoading .value = false
322+ }
323+ }
324+
183325// Delete
184326const deleteLoading = ref (false )
185327const deleteConfirm = ref (false )
@@ -256,6 +398,22 @@ function handleDelete() {
256398 <DropdownItem @click =" showEditModal = true" >
257399 Edit
258400 </DropdownItem >
401+ <!-- Re-create - discussions only, admin/mod only, blocked for entity-linked discussions -->
402+ <template v-if =" props .table === ' discussions' && (user ?.role === ' admin' || user ?.role === ' moderator' )" >
403+ <template v-if =" linkedDiscussionReason " >
404+ <Tooltip placement =" left" >
405+ <template #tooltip >
406+ <p >{{ linkedDiscussionReason }}</p >
407+ </template >
408+ <DropdownItem class =" forum__item-actions-disabled" @click.stop.prevent >
409+ Re-create
410+ </DropdownItem >
411+ </Tooltip >
412+ </template >
413+ <DropdownItem v-else @click =" recreateConfirm = true; dropdownRef?.close()" >
414+ Re-create
415+ </DropdownItem >
416+ </template >
259417 <!-- Topic-only: create sub-topic and create discussion shortcuts -->
260418 <template v-if =" props .table === ' discussion_topics' " >
261419 <Divider :size =" 0" margin =" 8px 0" />
@@ -314,6 +472,17 @@ function handleDelete() {
314472 </Alert >
315473 </ConfirmModal >
316474
475+ <!-- Confirmation modal for re-create -->
476+ <ConfirmModal
477+ v-model:open =" recreateConfirm"
478+ :confirm-loading =" recreateLoading"
479+ destructive
480+ confirm-text =" Re-create"
481+ title =" Re-create discussion"
482+ :description =" recreateDescription"
483+ @confirm =" handleRecreate"
484+ />
485+
317486 <ConfirmModal
318487 v-model:open =" deleteConfirm"
319488 :confirm-loading =" deleteLoading"
0 commit comments