Skip to content

Commit 2bbca7a

Browse files
committed
Add 'Re-create' discussion action
Implement re-create flow for discussions: archive/lock the original, rename its slug with a YYYY-MM-DD prefix (resolving conflicts), create a new discussion with the original content, and warm/invalidate the discussion cache. Use TinyBadge for RSVP tab counts. In the add-discussion modal respect user settings for archived topics, invalidate/warm cache on save, and defer navigation until modal teardown. Add a start-on-drafts prop and a Drafts quick-open button on the forum index. Add Appearance link to the user sheet and small profile-badge CSS tweaks. Bump forum topics cache key to v2 and add a page meta key for forum/[id].
1 parent c8db641 commit 2bbca7a

File tree

8 files changed

+306
-26
lines changed

8 files changed

+306
-26
lines changed

app/components/Discussions/Discussion.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ if (linkedCommentId) {
248248
showOfftopic.value = true
249249
hasManuallySwitched.value = true
250250
}
251-
}, { immediate: true })
251+
})
252252
}
253253
254254
async function handleGoToPinnedReply() {

app/components/Events/EventRSVPModal.vue

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Tables } from '@/types/database.overrides'
33
import { Alert, Badge, Button, Flex, Modal, Skeleton, Tab, Tabs } from '@dolanske/vui'
44
import { computed, ref, watch } from 'vue'
55
import BulkUserDisplay from '@/components/Shared/BulkUserDisplay.vue'
6+
import TinyBadge from '@/components/Shared/TinyBadge.vue'
67
import { useEventTiming } from '@/composables/useEventTiming'
78
import { useRsvpBus } from '@/composables/useRsvpBus'
89
import { useBreakpoint } from '@/lib/mediaQuery'
@@ -186,19 +187,28 @@ function handleClose() {
186187
<Tab value="yes">
187188
<Flex y-center gap="xs">
188189
<Icon name="ph:check-circle" class="rsvp-modal__icon" />
189-
<span>{{ yesTabLabel }} {{ yesCount > 0 ? `(${yesCount})` : '' }}</span>
190+
{{ yesTabLabel }}
191+
<TinyBadge v-if="yesCount > 0" variant="success">
192+
{{ yesCount }}
193+
</TinyBadge>
190194
</Flex>
191195
</Tab>
192196
<Tab value="tentative">
193197
<Flex y-center gap="xs">
194-
<Icon name="ph:question" sizeclass="rsvp-modal__icon" />
195-
<span>Maybe {{ tentativeCount > 0 ? `(${tentativeCount})` : '' }}</span>
198+
<Icon name="ph:question" class="rsvp-modal__icon" />
199+
Maybe
200+
<TinyBadge v-if="tentativeCount > 0" variant="warning">
201+
{{ tentativeCount }}
202+
</TinyBadge>
196203
</Flex>
197204
</Tab>
198205
<Tab value="no">
199206
<Flex y-center gap="xs">
200207
<Icon name="ph:x-circle" class="rsvp-modal__icon" />
201-
<span>Not Going {{ noCount > 0 ? `(${noCount})` : '' }}</span>
208+
Not Going
209+
<TinyBadge v-if="noCount > 0" variant="danger">
210+
{{ noCount }}
211+
</TinyBadge>
202212
</Flex>
203213
</Tab>
204214
</Tabs>

app/components/Forum/ForumItemActions.vue

Lines changed: 175 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,20 @@ import type { Tables } from '@/types/database.overrides'
33
import { Alert, Button, Divider, Dropdown, DropdownItem, pushToast, Tooltip } from '@dolanske/vui'
44
import { useDataUser } from '@/composables/useDataUser'
55
import { useDiscussionCache } from '@/composables/useDiscussionCache'
6+
import { slugify } from '@/lib/utils/formatting'
67
import ConfirmModal from '../Shared/ConfirmModal.vue'
78
import ForumModalAddDiscussion from './ForumModalAddDiscussion.vue'
89
import 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+
1020
interface 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-
}>()
2833
const dropdownRef = useTemplateRef('dropdownRef')
2934
const 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
184326
const deleteLoading = ref(false)
185327
const 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

Comments
 (0)