The frontend component of PuffPastry, meant to interface with the backend, which in turn interfaces with the smart contract.
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

ProposalRevisionDropdown.vue 13 KiB

​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  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>