The frontend component of PuffPastry, meant to interface with the backend, which in turn interfaces with the smart contract.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

373 lines
13 KiB

  1. <template>
  2. <div v-if="proposalId || proposal" ref="dropdownRef" class="relative inline-block text-left">
  3. <!-- Non-interactive static badge (for non-author or non-draft) -->
  4. <span
  5. v-if="!isInteractive"
  6. 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"
  7. >
  8. Rev {{ curRev }}
  9. </span>
  10. <!-- Interactive trigger button for draft author -->
  11. <button
  12. v-else
  13. type="button"
  14. 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"
  15. :aria-expanded="isOpen"
  16. aria-haspopup="true"
  17. aria-label="Revision history and rollback menu"
  18. @click="toggleDropdown"
  19. >
  20. <span>Rev {{ curRev }}</span>
  21. <IconDownArrow :size="8" class="text-slate-500 transition-transform duration-150" :class="{ 'rotate-180': isOpen }" />
  22. </button>
  23. <!-- Dropdown Menu -->
  24. <Transition
  25. enter-active-class="transition duration-150 ease-out"
  26. enter-from-class="transform scale-95 opacity-0"
  27. enter-to-class="transform scale-100 opacity-100"
  28. leave-active-class="transition duration-100 ease-in"
  29. leave-from-class="transform scale-100 opacity-100"
  30. leave-to-class="transform scale-95 opacity-0"
  31. >
  32. <div
  33. v-if="isOpen && isInteractive"
  34. 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"
  35. role="menu"
  36. aria-orientation="vertical"
  37. tabindex="-1"
  38. >
  39. <div class="mb-2 flex items-center justify-between border-b border-slate-100 pb-2 px-1">
  40. <span class="text-xs font-semibold uppercase tracking-wider text-slate-500">
  41. Revision History
  42. </span>
  43. <span class="text-[0.7rem] text-slate-400">
  44. Active: Rev {{ curRev }}
  45. </span>
  46. </div>
  47. <!-- Loading state -->
  48. <div v-if="isLoading" class="flex items-center justify-center py-6 text-xs text-slate-500">
  49. <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">
  50. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
  51. <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
  52. </svg>
  53. <span>Loading revisions...</span>
  54. </div>
  55. <!-- Error state -->
  56. <div v-else-if="error" class="rounded-lg bg-red-50 p-3 text-center text-xs text-red-700">
  57. <p class="font-medium">{{ error }}</p>
  58. <button
  59. type="button"
  60. class="mt-2 font-semibold text-red-800 underline hover:no-underline"
  61. @click="fetchRevisions"
  62. >
  63. Try again
  64. </button>
  65. </div>
  66. <!-- Empty state -->
  67. <div v-else-if="revisions.length === 0" class="py-4 text-center text-xs text-slate-500">
  68. No revision history found.
  69. </div>
  70. <!-- Revision List -->
  71. <div v-else class="max-h-72 space-y-1.5 overflow-y-auto pr-1">
  72. <div
  73. v-for="edit in revisions"
  74. :key="edit.id"
  75. class="group relative flex flex-col rounded-lg border p-2.5 transition"
  76. :class="[
  77. edit.revision === curRev
  78. ? 'border-amber-300 bg-amber-50/60'
  79. : 'border-slate-200 bg-slate-50/50 hover:border-slate-300 hover:bg-slate-100/80 cursor-pointer'
  80. ]"
  81. role="menuitem"
  82. :tabindex="edit.revision === curRev ? -1 : 0"
  83. @click="handleSelectRevision(edit)"
  84. @keydown.enter.prevent="handleSelectRevision(edit)"
  85. @keydown.space.prevent="handleSelectRevision(edit)"
  86. >
  87. <div class="flex items-center justify-between gap-2">
  88. <div class="flex items-center gap-1.5">
  89. <span class="text-xs font-bold text-slate-800">
  90. Rev {{ edit.revision }}
  91. </span>
  92. <span
  93. v-if="edit.revision === curRev"
  94. class="rounded-full bg-amber-200/80 px-2 py-0.5 text-[0.65rem] font-semibold text-amber-900"
  95. >
  96. Current
  97. </span>
  98. </div>
  99. <time
  100. v-if="edit.createdAt"
  101. :datetime="edit.createdAt"
  102. class="text-[0.7rem] text-slate-500"
  103. >
  104. {{ formatDate(edit.createdAt) }}
  105. </time>
  106. </div>
  107. <p class="mt-1 line-clamp-2 text-xs text-slate-600">
  108. {{ edit.summary || (edit.revision === 1 ? 'Initial revision' : 'No summary provided') }}
  109. </p>
  110. <div
  111. v-if="edit.revision !== curRev"
  112. class="mt-1.5 flex items-center justify-end text-[0.7rem] font-semibold text-amber-600 group-hover:text-amber-700"
  113. >
  114. <span>Click to roll back &rarr;</span>
  115. </div>
  116. </div>
  117. </div>
  118. </div>
  119. </Transition>
  120. <!-- Rollback Confirmation Modal -->
  121. <Modal
  122. v-if="isConfirmModalOpen && selectedRevision"
  123. v-model="isConfirmModalOpen"
  124. header="Roll Back Proposal"
  125. :button-text="isRollingBack ? 'Rolling back...' : 'Confirm Rollback'"
  126. :submit-disabled="isRollingBack"
  127. @submit="confirmRollback"
  128. >
  129. <div class="space-y-4">
  130. <!-- Error Banner -->
  131. <div
  132. v-if="rollbackError"
  133. class="rounded-lg p-3.5 text-sm border"
  134. :class="isConflict
  135. ? 'bg-amber-50 text-amber-900 border-amber-200'
  136. : 'bg-red-50 text-red-700 border-red-200'"
  137. role="alert"
  138. >
  139. <div class="flex items-start gap-2">
  140. <span v-if="isConflict" class="font-semibold">Revision Conflict:</span>
  141. <span>{{ rollbackError }}</span>
  142. </div>
  143. </div>
  144. <p class="text-sm text-slate-700 leading-relaxed">
  145. Are you sure you want to roll back this proposal to
  146. <strong class="font-semibold text-slate-900">Revision {{ selectedRevision.revision }}</strong>?
  147. This will restore the proposal's title and paragraph content to the version recorded at that time.
  148. </p>
  149. <div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-xs space-y-2">
  150. <div class="flex items-center justify-between font-medium text-slate-700">
  151. <span>Target Revision:</span>
  152. <span class="font-bold text-slate-900">Rev {{ selectedRevision.revision }}</span>
  153. </div>
  154. <div class="flex items-center justify-between text-slate-600">
  155. <span>Created At:</span>
  156. <span>{{ formatDate(selectedRevision.createdAt) }}</span>
  157. </div>
  158. <div class="flex items-center justify-between text-slate-600">
  159. <span>Paragraphs:</span>
  160. <span>{{ selectedRevision.paragraphs?.length ?? 0 }} section(s)</span>
  161. </div>
  162. <div class="pt-2 border-t border-slate-200">
  163. <span class="font-medium text-slate-700">Revision Summary:</span>
  164. <p class="mt-1 text-slate-600 italic">
  165. {{ selectedRevision.summary || (selectedRevision.revision === 1 ? 'Initial revision' : 'No summary provided') }}
  166. </p>
  167. </div>
  168. </div>
  169. <p class="text-xs text-slate-500">
  170. Note: This action updates your working draft immediately. You can always view revision history to inspect or roll back to any other version.
  171. </p>
  172. </div>
  173. </Modal>
  174. </div>
  175. </template>
  176. <script setup lang="ts">
  177. import { computed, onMounted, ref, watch } from 'vue';
  178. import { onClickOutside, onKeyStroke } from '@vueuse/core';
  179. import {
  180. type ProposalRevision,
  181. ProposalService,
  182. ProposalState,
  183. type ProposalWithParagraphs,
  184. } from '../generated/typescript';
  185. import { useSession } from '../composables/useSession';
  186. import { eventBus } from '../utils/eventBus';
  187. import { IconDownArrow } from './icons';
  188. import Modal from './modals/Modal.vue';
  189. const props = withDefaults(
  190. defineProps<{
  191. proposal?: ProposalWithParagraphs;
  192. proposalId?: number;
  193. currentRevision?: number;
  194. creator?: string;
  195. state?: ProposalState | string;
  196. }>(),
  197. {
  198. currentRevision: 1,
  199. creator: '',
  200. state: '',
  201. }
  202. );
  203. const emit = defineEmits<{
  204. selectRevision: [revision: ProposalRevision];
  205. }>();
  206. const { walletAddress } = useSession();
  207. const propId = computed(() => props.proposal?.id ?? props.proposalId ?? 0);
  208. const curRev = computed(() => props.proposal?.revision ?? props.currentRevision ?? 1);
  209. const propCreator = computed(() => props.proposal?.creator ?? props.creator ?? '');
  210. const propState = computed(() => props.proposal?.state ?? props.state ?? '');
  211. const isAuthor = computed(() => {
  212. if (!walletAddress.value || !propCreator.value) return false;
  213. return walletAddress.value.trim().toLowerCase() === propCreator.value.trim().toLowerCase();
  214. });
  215. const isDraft = computed(() => {
  216. return propState.value === ProposalState.DRAFT || propState.value === 'DRAFT';
  217. });
  218. const hasFetched = ref(false);
  219. const hasMultipleRevisions = computed(() => {
  220. if (curRev.value > 1) return true;
  221. if (hasFetched.value) return revisions.value.length > 1;
  222. return false;
  223. });
  224. const isInteractive = computed(() => {
  225. return isAuthor.value && isDraft.value && hasMultipleRevisions.value;
  226. });
  227. const isOpen = ref(false);
  228. const isLoading = ref(false);
  229. const error = ref<string | null>(null);
  230. const revisions = ref<ProposalRevision[]>([]);
  231. const dropdownRef = ref<HTMLElement | null>(null);
  232. const selectedRevision = ref<ProposalRevision | null>(null);
  233. const isConfirmModalOpen = ref(false);
  234. const isRollingBack = ref(false);
  235. const rollbackError = ref<string | null>(null);
  236. const isConflict = ref(false);
  237. onClickOutside(dropdownRef, () => {
  238. isOpen.value = false;
  239. });
  240. onKeyStroke('Escape', () => {
  241. isOpen.value = false;
  242. });
  243. async function fetchRevisions() {
  244. if (!propId.value) return;
  245. isLoading.value = true;
  246. error.value = null;
  247. try {
  248. const edits = await ProposalService.listProposalEdits(propId.value);
  249. revisions.value = [...edits].sort((a, b) => b.revision - a.revision);
  250. hasFetched.value = true;
  251. } catch (err: any) {
  252. console.error('Failed to load proposal revisions:', err);
  253. error.value = err?.body?.message || err?.message || 'Failed to load revisions';
  254. } finally {
  255. isLoading.value = false;
  256. }
  257. }
  258. function toggleDropdown() {
  259. if (!isInteractive.value) return;
  260. isOpen.value = !isOpen.value;
  261. if (isOpen.value) {
  262. fetchRevisions();
  263. }
  264. }
  265. onMounted(() => {
  266. if (isAuthor.value && isDraft.value && propId.value) {
  267. fetchRevisions();
  268. }
  269. });
  270. watch(
  271. [propId, isAuthor, isDraft],
  272. ([newId, newIsAuthor, newIsDraft]) => {
  273. if (newId && newIsAuthor && newIsDraft) {
  274. fetchRevisions();
  275. } else {
  276. revisions.value = [];
  277. hasFetched.value = false;
  278. }
  279. }
  280. );
  281. function formatDate(dateString?: string): string {
  282. if (!dateString) return '';
  283. try {
  284. const d = new Date(dateString);
  285. if (isNaN(d.getTime())) return dateString;
  286. return d.toLocaleDateString(undefined, {
  287. year: 'numeric',
  288. month: 'short',
  289. day: 'numeric',
  290. hour: '2-digit',
  291. minute: '2-digit',
  292. });
  293. } catch {
  294. return dateString;
  295. }
  296. }
  297. function handleSelectRevision(edit: ProposalRevision) {
  298. if (edit.revision === curRev.value) return;
  299. emit('selectRevision', edit);
  300. selectedRevision.value = edit;
  301. rollbackError.value = null;
  302. isConflict.value = false;
  303. isOpen.value = false;
  304. isConfirmModalOpen.value = true;
  305. }
  306. async function confirmRollback() {
  307. if (!selectedRevision.value || !propId.value) return;
  308. isRollingBack.value = true;
  309. rollbackError.value = null;
  310. isConflict.value = false;
  311. try {
  312. await ProposalService.setProposalRevision({
  313. proposalId: propId.value,
  314. baseRevision: curRev.value,
  315. revision: selectedRevision.value.revision,
  316. });
  317. eventBus.emit('proposalUpdated', propId.value);
  318. isConfirmModalOpen.value = false;
  319. selectedRevision.value = null;
  320. } catch (err: any) {
  321. console.error('Failed to roll back proposal revision:', err);
  322. if (err?.status === 409 || err?.statusCode === 409) {
  323. isConflict.value = true;
  324. rollbackError.value =
  325. 'This proposal has been modified since you started editing, or is no longer a draft. Please refresh the page.';
  326. } else if (err?.status === 403 || err?.statusCode === 403) {
  327. rollbackError.value =
  328. 'Permission denied: Only the original proposal author can roll back revisions.';
  329. } else {
  330. rollbackError.value =
  331. err?.body?.message || err?.message || 'Failed to roll back to the selected revision. Please try again.';
  332. }
  333. } finally {
  334. isRollingBack.value = false;
  335. }
  336. }
  337. </script>