|
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372 |
- <template>
- <div v-if="proposalId || proposal" ref="dropdownRef" class="relative inline-block text-left">
- <!-- Non-interactive static badge (for non-author or non-draft) -->
- <span
- v-if="!isInteractive"
- class="rounded-full border border-slate-200 bg-slate-100 px-2.5 py-1 text-[0.65rem] font-bold uppercase tracking-[0.12em] text-slate-600"
- >
- Rev {{ curRev }}
- </span>
-
- <!-- Interactive trigger button for draft author -->
- <button
- v-else
- type="button"
- class="inline-flex items-center gap-1.5 rounded-full border border-slate-300 bg-white px-2.5 py-1 text-[0.65rem] font-bold uppercase tracking-[0.12em] text-slate-700 shadow-sm transition hover:border-slate-400 hover:bg-slate-50 focus:outline-none focus:ring-4 focus:ring-amber-500/20 active:bg-slate-100"
- :aria-expanded="isOpen"
- aria-haspopup="true"
- aria-label="Revision history and rollback menu"
- @click="toggleDropdown"
- >
- <span>Rev {{ curRev }}</span>
- <IconDownArrow :size="8" class="text-slate-500 transition-transform duration-150" :class="{ 'rotate-180': isOpen }" />
- </button>
-
- <!-- Dropdown Menu -->
- <Transition
- enter-active-class="transition duration-150 ease-out"
- enter-from-class="transform scale-95 opacity-0"
- enter-to-class="transform scale-100 opacity-100"
- leave-active-class="transition duration-100 ease-in"
- leave-from-class="transform scale-100 opacity-100"
- leave-to-class="transform scale-95 opacity-0"
- >
- <div
- v-if="isOpen && isInteractive"
- class="absolute left-0 z-30 mt-2 w-80 sm:w-96 rounded-xl border border-slate-200 bg-white p-3 shadow-xl ring-1 ring-black/5"
- role="menu"
- aria-orientation="vertical"
- tabindex="-1"
- >
- <div class="mb-2 flex items-center justify-between border-b border-slate-100 pb-2 px-1">
- <span class="text-xs font-semibold uppercase tracking-wider text-slate-500">
- Revision History
- </span>
- <span class="text-[0.7rem] text-slate-400">
- Active: Rev {{ curRev }}
- </span>
- </div>
-
- <!-- Loading state -->
- <div v-if="isLoading" class="flex items-center justify-center py-6 text-xs text-slate-500">
- <svg class="mr-2 h-4 w-4 animate-spin text-amber-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
- </svg>
- <span>Loading revisions...</span>
- </div>
-
- <!-- Error state -->
- <div v-else-if="error" class="rounded-lg bg-red-50 p-3 text-center text-xs text-red-700">
- <p class="font-medium">{{ error }}</p>
- <button
- type="button"
- class="mt-2 font-semibold text-red-800 underline hover:no-underline"
- @click="fetchRevisions"
- >
- Try again
- </button>
- </div>
-
- <!-- Empty state -->
- <div v-else-if="revisions.length === 0" class="py-4 text-center text-xs text-slate-500">
- No revision history found.
- </div>
-
- <!-- Revision List -->
- <div v-else class="max-h-72 space-y-1.5 overflow-y-auto pr-1">
- <div
- v-for="edit in revisions"
- :key="edit.id"
- class="group relative flex flex-col rounded-lg border p-2.5 transition"
- :class="[
- edit.revision === curRev
- ? 'border-amber-300 bg-amber-50/60'
- : 'border-slate-200 bg-slate-50/50 hover:border-slate-300 hover:bg-slate-100/80 cursor-pointer'
- ]"
- role="menuitem"
- :tabindex="edit.revision === curRev ? -1 : 0"
- @click="handleSelectRevision(edit)"
- @keydown.enter.prevent="handleSelectRevision(edit)"
- @keydown.space.prevent="handleSelectRevision(edit)"
- >
- <div class="flex items-center justify-between gap-2">
- <div class="flex items-center gap-1.5">
- <span class="text-xs font-bold text-slate-800">
- Rev {{ edit.revision }}
- </span>
- <span
- v-if="edit.revision === curRev"
- class="rounded-full bg-amber-200/80 px-2 py-0.5 text-[0.65rem] font-semibold text-amber-900"
- >
- Current
- </span>
- </div>
- <time
- v-if="edit.createdAt"
- :datetime="edit.createdAt"
- class="text-[0.7rem] text-slate-500"
- >
- {{ formatDate(edit.createdAt) }}
- </time>
- </div>
-
- <p class="mt-1 line-clamp-2 text-xs text-slate-600">
- {{ edit.summary || (edit.revision === 1 ? 'Initial revision' : 'No summary provided') }}
- </p>
-
- <div
- v-if="edit.revision !== curRev"
- class="mt-1.5 flex items-center justify-end text-[0.7rem] font-semibold text-amber-600 group-hover:text-amber-700"
- >
- <span>Click to roll back →</span>
- </div>
- </div>
- </div>
- </div>
- </Transition>
-
- <!-- Rollback Confirmation Modal -->
- <Modal
- v-if="isConfirmModalOpen && selectedRevision"
- v-model="isConfirmModalOpen"
- header="Roll Back Proposal"
- :button-text="isRollingBack ? 'Rolling back...' : 'Confirm Rollback'"
- :submit-disabled="isRollingBack"
- @submit="confirmRollback"
- >
- <div class="space-y-4">
- <!-- Error Banner -->
- <div
- v-if="rollbackError"
- class="rounded-lg p-3.5 text-sm border"
- :class="isConflict
- ? 'bg-amber-50 text-amber-900 border-amber-200'
- : 'bg-red-50 text-red-700 border-red-200'"
- role="alert"
- >
- <div class="flex items-start gap-2">
- <span v-if="isConflict" class="font-semibold">Revision Conflict:</span>
- <span>{{ rollbackError }}</span>
- </div>
- </div>
-
- <p class="text-sm text-slate-700 leading-relaxed">
- Are you sure you want to roll back this proposal to
- <strong class="font-semibold text-slate-900">Revision {{ selectedRevision.revision }}</strong>?
- This will restore the proposal's title and paragraph content to the version recorded at that time.
- </p>
-
- <div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-xs space-y-2">
- <div class="flex items-center justify-between font-medium text-slate-700">
- <span>Target Revision:</span>
- <span class="font-bold text-slate-900">Rev {{ selectedRevision.revision }}</span>
- </div>
- <div class="flex items-center justify-between text-slate-600">
- <span>Created At:</span>
- <span>{{ formatDate(selectedRevision.createdAt) }}</span>
- </div>
- <div class="flex items-center justify-between text-slate-600">
- <span>Paragraphs:</span>
- <span>{{ selectedRevision.paragraphs?.length ?? 0 }} section(s)</span>
- </div>
- <div class="pt-2 border-t border-slate-200">
- <span class="font-medium text-slate-700">Revision Summary:</span>
- <p class="mt-1 text-slate-600 italic">
- {{ selectedRevision.summary || (selectedRevision.revision === 1 ? 'Initial revision' : 'No summary provided') }}
- </p>
- </div>
- </div>
-
- <p class="text-xs text-slate-500">
- Note: This action updates your working draft immediately. You can always view revision history to inspect or roll back to any other version.
- </p>
- </div>
- </Modal>
- </div>
- </template>
-
- <script setup lang="ts">
- import { computed, onMounted, ref, watch } from 'vue';
- import { onClickOutside, onKeyStroke } from '@vueuse/core';
- import {
- type ProposalRevision,
- ProposalService,
- ProposalState,
- type ProposalWithParagraphs,
- } from '../generated/typescript';
- import { useSession } from '../composables/useSession';
- import { eventBus } from '../utils/eventBus';
- import { IconDownArrow } from './icons';
- import Modal from './modals/Modal.vue';
-
- const props = withDefaults(
- defineProps<{
- proposal?: ProposalWithParagraphs;
- proposalId?: number;
- currentRevision?: number;
- creator?: string;
- state?: ProposalState | string;
- }>(),
- {
- currentRevision: 1,
- creator: '',
- state: '',
- }
- );
-
- const emit = defineEmits<{
- selectRevision: [revision: ProposalRevision];
- }>();
-
- const { walletAddress } = useSession();
-
- const propId = computed(() => props.proposal?.id ?? props.proposalId ?? 0);
- const curRev = computed(() => props.proposal?.revision ?? props.currentRevision ?? 1);
- const propCreator = computed(() => props.proposal?.creator ?? props.creator ?? '');
- const propState = computed(() => props.proposal?.state ?? props.state ?? '');
-
- const isAuthor = computed(() => {
- if (!walletAddress.value || !propCreator.value) return false;
- return walletAddress.value.trim().toLowerCase() === propCreator.value.trim().toLowerCase();
- });
-
- const isDraft = computed(() => {
- return propState.value === ProposalState.DRAFT || propState.value === 'DRAFT';
- });
-
- const hasFetched = ref(false);
-
- const hasMultipleRevisions = computed(() => {
- if (curRev.value > 1) return true;
- if (hasFetched.value) return revisions.value.length > 1;
- return false;
- });
-
- const isInteractive = computed(() => {
- return isAuthor.value && isDraft.value && hasMultipleRevisions.value;
- });
-
- const isOpen = ref(false);
- const isLoading = ref(false);
- const error = ref<string | null>(null);
- const revisions = ref<ProposalRevision[]>([]);
- const dropdownRef = ref<HTMLElement | null>(null);
-
- const selectedRevision = ref<ProposalRevision | null>(null);
- const isConfirmModalOpen = ref(false);
- const isRollingBack = ref(false);
- const rollbackError = ref<string | null>(null);
- const isConflict = ref(false);
-
- onClickOutside(dropdownRef, () => {
- isOpen.value = false;
- });
-
- onKeyStroke('Escape', () => {
- isOpen.value = false;
- });
-
- async function fetchRevisions() {
- if (!propId.value) return;
- isLoading.value = true;
- error.value = null;
- try {
- const edits = await ProposalService.listProposalEdits(propId.value);
- revisions.value = [...edits].sort((a, b) => b.revision - a.revision);
- hasFetched.value = true;
- } catch (err: any) {
- console.error('Failed to load proposal revisions:', err);
- error.value = err?.body?.message || err?.message || 'Failed to load revisions';
- } finally {
- isLoading.value = false;
- }
- }
-
- function toggleDropdown() {
- if (!isInteractive.value) return;
- isOpen.value = !isOpen.value;
- if (isOpen.value) {
- fetchRevisions();
- }
- }
-
- onMounted(() => {
- if (isAuthor.value && isDraft.value && propId.value) {
- fetchRevisions();
- }
- });
-
- watch(
- [propId, isAuthor, isDraft],
- ([newId, newIsAuthor, newIsDraft]) => {
- if (newId && newIsAuthor && newIsDraft) {
- fetchRevisions();
- } else {
- revisions.value = [];
- hasFetched.value = false;
- }
- }
- );
-
- function formatDate(dateString?: string): string {
- if (!dateString) return '';
- try {
- const d = new Date(dateString);
- if (isNaN(d.getTime())) return dateString;
- return d.toLocaleDateString(undefined, {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- });
- } catch {
- return dateString;
- }
- }
-
- function handleSelectRevision(edit: ProposalRevision) {
- if (edit.revision === curRev.value) return;
- emit('selectRevision', edit);
- selectedRevision.value = edit;
- rollbackError.value = null;
- isConflict.value = false;
- isOpen.value = false;
- isConfirmModalOpen.value = true;
- }
-
- async function confirmRollback() {
- if (!selectedRevision.value || !propId.value) return;
- isRollingBack.value = true;
- rollbackError.value = null;
- isConflict.value = false;
-
- try {
- await ProposalService.setProposalRevision({
- proposalId: propId.value,
- baseRevision: curRev.value,
- revision: selectedRevision.value.revision,
- });
-
- eventBus.emit('proposalUpdated', propId.value);
- isConfirmModalOpen.value = false;
- selectedRevision.value = null;
- } catch (err: any) {
- console.error('Failed to roll back proposal revision:', err);
- if (err?.status === 409 || err?.statusCode === 409) {
- isConflict.value = true;
- rollbackError.value =
- 'This proposal has been modified since you started editing, or is no longer a draft. Please refresh the page.';
- } else if (err?.status === 403 || err?.statusCode === 403) {
- rollbackError.value =
- 'Permission denied: Only the original proposal author can roll back revisions.';
- } else {
- rollbackError.value =
- err?.body?.message || err?.message || 'Failed to roll back to the selected revision. Please try again.';
- }
- } finally {
- isRollingBack.value = false;
- }
- }
- </script>
|