| @@ -15,75 +15,87 @@ | |||
| class="flex gap-3 overflow-x-auto scroll-smooth px-1 py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" | |||
| @scroll.passive="updateEdges" | |||
| > | |||
| <article | |||
| v-for="amendment in amendments" | |||
| :key="amendment.id" | |||
| class="group relative flex flex-col justify-between w-48 sm:w-72 lg:w-80 shrink-0 rounded-xl border border-slate-200/90 bg-white p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-amber-300 hover:bg-amber-50/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 cursor-pointer" | |||
| role="button" | |||
| tabindex="0" | |||
| @click="showAmendment(amendment.id)" | |||
| @keydown.enter.prevent="showAmendment(amendment.id)" | |||
| @keydown.space.prevent="showAmendment(amendment.id)" | |||
| > | |||
| <div class="flex items-start justify-between gap-2"> | |||
| <h3 class="font-header font-bold text-slate-900 text-sm sm:text-base leading-snug line-clamp-2">{{ amendment.name }}</h3> | |||
| <div | |||
| v-if="amendment.status.toLowerCase() === 'proposed'" | |||
| class="flex items-center gap-1 shrink-0" | |||
| > | |||
| <button | |||
| type="button" | |||
| :aria-pressed="getAmendmentVote(amendment.id) === true" | |||
| aria-label="Confirm amendment" | |||
| title="Confirm amendment" | |||
| class="rounded-full p-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900" | |||
| :class="getAmendmentVote(amendment.id) === true | |||
| ? 'text-emerald-600 bg-emerald-50 border border-emerald-200' | |||
| : 'text-slate-400 hover:bg-slate-100 hover:text-emerald-500'" | |||
| @click.stop="confirm(amendment.id)" | |||
| > | |||
| <Confirm class="size-4" /> | |||
| </button> | |||
| <button | |||
| type="button" | |||
| :aria-pressed="getAmendmentVote(amendment.id) === false" | |||
| aria-label="Reject amendment" | |||
| title="Reject amendment" | |||
| class="rounded-full p-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900" | |||
| :class="getAmendmentVote(amendment.id) === false | |||
| ? 'text-rose-600 bg-rose-50 border border-rose-200' | |||
| : 'text-slate-400 hover:bg-slate-100 hover:text-rose-500'" | |||
| @click.stop="reject(amendment.id)" | |||
| <template v-if="isApproved"> | |||
| <article | |||
| v-for="amendment in amendments" | |||
| :key="amendment.id" | |||
| class="group relative flex flex-col justify-between w-48 sm:w-72 lg:w-80 shrink-0 rounded-xl border border-slate-200/90 bg-white p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-amber-300 hover:bg-amber-50/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 cursor-pointer" | |||
| role="button" | |||
| tabindex="0" | |||
| @click="showAmendment(amendment.id)" | |||
| @keydown.enter.prevent="showAmendment(amendment.id)" | |||
| @keydown.space.prevent="showAmendment(amendment.id)" | |||
| > | |||
| <div class="flex items-start justify-between gap-2"> | |||
| <h3 class="font-header font-bold text-slate-900 text-sm sm:text-base leading-snug line-clamp-2">{{ amendment.name }}</h3> | |||
| <div | |||
| v-if="amendment.status.toLowerCase() === 'proposed'" | |||
| class="flex items-center gap-1 shrink-0" | |||
| > | |||
| <Cancel class="size-4" /> | |||
| </button> | |||
| <button | |||
| type="button" | |||
| :aria-pressed="getAmendmentVote(amendment.id) === true" | |||
| aria-label="Confirm amendment" | |||
| title="Confirm amendment" | |||
| class="rounded-full p-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900" | |||
| :class="getAmendmentVote(amendment.id) === true | |||
| ? 'text-emerald-600 bg-emerald-50 border border-emerald-200' | |||
| : 'text-slate-400 hover:bg-slate-100 hover:text-emerald-500'" | |||
| @click.stop="confirm(amendment.id)" | |||
| > | |||
| <Confirm class="size-4" /> | |||
| </button> | |||
| <button | |||
| type="button" | |||
| :aria-pressed="getAmendmentVote(amendment.id) === false" | |||
| aria-label="Reject amendment" | |||
| title="Reject amendment" | |||
| class="rounded-full p-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900" | |||
| :class="getAmendmentVote(amendment.id) === false | |||
| ? 'text-rose-600 bg-rose-50 border border-rose-200' | |||
| : 'text-slate-400 hover:bg-slate-100 hover:text-rose-500'" | |||
| @click.stop="reject(amendment.id)" | |||
| > | |||
| <Cancel class="size-4" /> | |||
| </button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="mt-2.5 mb-1.5 flex items-center"> | |||
| <Chip :text="amendment.status" :class="getChipClass(amendment.status)" /> | |||
| </div> | |||
| <p class="relative mt-1 h-12 overflow-hidden text-xs leading-relaxed text-slate-600"> | |||
| {{ amendment.summary }} | |||
| <span class="pointer-events-none absolute inset-x-0 bottom-0 h-4 bg-gradient-to-t from-white group-hover:from-amber-50/40 transition-colors duration-200" /> | |||
| </p> | |||
| </article> | |||
| <article | |||
| class="group flex w-48 sm:w-72 lg:w-80 shrink-0 flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-300 bg-slate-50/70 p-4 text-center shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-amber-400 hover:bg-amber-50/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 cursor-pointer" | |||
| role="button" | |||
| tabindex="0" | |||
| aria-label="Add amendment" | |||
| @click="openNewAmendmentModal" | |||
| @keydown.enter.prevent="openNewAmendmentModal" | |||
| @keydown.space.prevent="openNewAmendmentModal" | |||
| <div class="mt-2.5 mb-1.5 flex items-center"> | |||
| <Chip :text="amendment.status" :class="getChipClass(amendment.status)" /> | |||
| </div> | |||
| <p class="relative mt-1 h-12 overflow-hidden text-xs leading-relaxed text-slate-600"> | |||
| {{ amendment.summary }} | |||
| <span class="pointer-events-none absolute inset-x-0 bottom-0 h-4 bg-gradient-to-t from-white group-hover:from-amber-50/40 transition-colors duration-200" /> | |||
| </p> | |||
| </article> | |||
| <article | |||
| class="group flex w-48 sm:w-72 lg:w-80 shrink-0 flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-300 bg-slate-50/70 p-4 text-center shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-amber-400 hover:bg-amber-50/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 cursor-pointer" | |||
| role="button" | |||
| tabindex="0" | |||
| aria-label="Add amendment" | |||
| @click="openNewAmendmentModal" | |||
| @keydown.enter.prevent="openNewAmendmentModal" | |||
| @keydown.space.prevent="openNewAmendmentModal" | |||
| > | |||
| <div class="mb-2 flex size-8 items-center justify-center rounded-full bg-slate-200 text-slate-700 transition-colors group-hover:bg-amber-400 group-hover:text-white"> | |||
| <Plus class="size-4" /> | |||
| </div> | |||
| <h3 class="font-header text-sm sm:text-base font-bold text-slate-800 transition-colors group-hover:text-amber-800">Add Amendment</h3> | |||
| <p class="mt-1 text-xs text-slate-500 line-clamp-2"> | |||
| Propose a revision or improvement | |||
| </p> | |||
| </article> | |||
| </template> | |||
| <div | |||
| v-else | |||
| class="flex w-full min-h-[7rem] items-center justify-center rounded-xl border border-dashed border-slate-200 bg-slate-50/60 p-6 text-center text-sm text-slate-500" | |||
| > | |||
| <div class="mb-2 flex size-8 items-center justify-center rounded-full bg-slate-200 text-slate-700 transition-colors group-hover:bg-amber-400 group-hover:text-white"> | |||
| <Plus class="size-4" /> | |||
| </div> | |||
| <h3 class="font-header text-sm sm:text-base font-bold text-slate-800 transition-colors group-hover:text-amber-800">Add Amendment</h3> | |||
| <p class="mt-1 text-xs text-slate-500 line-clamp-2"> | |||
| Propose a revision or improvement | |||
| <p class="max-w-xl"> | |||
| Amendments become available after approval. Once this proposal is approved, you can suggest changes here. | |||
| </p> | |||
| </article> | |||
| </div> | |||
| </div> | |||
| <button | |||
| @@ -113,10 +125,16 @@ import { getUserIdFromJwt } from "../utils/auth.ts"; | |||
| import { ContentModal, useModal } from "../composables/useModal.ts"; | |||
| import { eventBus } from "../utils/eventBus.ts"; | |||
| const props = defineProps<{ | |||
| proposalId?: number; | |||
| amendmentVotes?: UserAmendmentVote[]; | |||
| }>(); | |||
| const props = withDefaults( | |||
| defineProps<{ | |||
| proposalId?: number; | |||
| amendmentVotes?: UserAmendmentVote[]; | |||
| isApproved?: boolean; | |||
| }>(), | |||
| { | |||
| isApproved: true, | |||
| } | |||
| ); | |||
| const emit = defineEmits<{ | |||
| (e: 'voted'): void; | |||
| }>(); | |||
| @@ -139,7 +157,7 @@ const { show: showModal, setContent, addContent } = useModal(); | |||
| const updateEdges = () => { | |||
| const el = scroller.value; | |||
| if (!el) { | |||
| if (!el || !props.isApproved) { | |||
| canScrollLeft.value = false; | |||
| canScrollRight.value = false; | |||
| return; | |||
| @@ -194,8 +212,10 @@ const showAmendment = async (id: number) => { | |||
| }; | |||
| const openNewAmendmentModal = () => { | |||
| if (props.isApproved === false) return; | |||
| if (props.proposalId) { | |||
| setContent("proposalId", props.proposalId); | |||
| setContent("isApproved", props.isApproved ? "true" : "false"); | |||
| } | |||
| showModal(NewAmendmentModal as unknown as ContentModal); | |||
| }; | |||
| @@ -260,6 +280,14 @@ watch(proposalId, async (id) => { | |||
| if (scroller.value) scroller.value.scrollLeft = 0; | |||
| await getAmendments(); | |||
| }); | |||
| watch( | |||
| () => props.isApproved, | |||
| async () => { | |||
| await nextTick(); | |||
| updateEdges(); | |||
| } | |||
| ); | |||
| </script> | |||
| <style lang="postcss" scoped> | |||
| @@ -6,9 +6,16 @@ | |||
| <ColoredHeader text="Senara" from="#F2F3E2" to="#B2E5F8" /> | |||
| </RouterLink> | |||
| </span> | |||
| <div class="flex flex-col justify-center items-center"> | |||
| <div class="flex flex-row gap-2 justify-center items-center"> | |||
| <button | |||
| class="text-xs ml-auto rounded-md bg-white text-gray-900 font-semibold px-5 py-2 shadow-sm hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-orange-300 transition-colors duration-200" | |||
| class="text-xs rounded-md bg-white text-gray-900 font-semibold px-5 py-2 shadow-sm hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-orange-300 transition-colors duration-200 cursor-pointer" | |||
| type="button" | |||
| @click="openRegistrationModal" | |||
| v-if="!isAuthenticated"> | |||
| Register | |||
| </button> | |||
| <button | |||
| class="text-xs rounded-md bg-white text-gray-900 font-semibold px-5 py-2 shadow-sm hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-orange-300 transition-colors duration-200 cursor-pointer" | |||
| type="button" | |||
| :disabled="isConnecting" | |||
| @click="connectOrDisplayUser"> | |||
| @@ -22,8 +29,11 @@ | |||
| import ColoredHeader from "./ColoredHeader.vue"; | |||
| import { computed } from "vue"; | |||
| import { useSession } from "../composables/useSession"; | |||
| import { ContentModal, useModal } from "../composables/useModal.ts"; | |||
| import RegistrationModal from "./modals/RegistrationModal.vue"; | |||
| const { isAuthenticated, truncatedAddress, isConnecting, connectWallet } = useSession(); | |||
| const { show: showModal } = useModal(); | |||
| const buttonText = computed(() => { | |||
| if (isConnecting.value) return "Connecting..."; | |||
| @@ -36,6 +46,10 @@ const connectOrDisplayUser = async () => { | |||
| await connectWallet(); | |||
| } | |||
| }; | |||
| const openRegistrationModal = () => { | |||
| showModal(RegistrationModal as ContentModal); | |||
| }; | |||
| </script> | |||
| <style scoped> | |||
| @@ -6,7 +6,7 @@ | |||
| </template> | |||
| <script setup lang="ts"> | |||
| const props = defineProps<{ text: string }>(); | |||
| defineProps<{ text: string }>(); | |||
| </script> | |||
| <style scoped> | |||
| @@ -25,7 +25,44 @@ | |||
| {{ node.comment.content }} | |||
| </p> | |||
| <div class="mt-2"> | |||
| <div class="mt-3 flex items-center gap-3"> | |||
| <!-- Reactions --> | |||
| <div class="flex items-center gap-1.5" role="group" aria-label="Comment reactions"> | |||
| <button | |||
| type="button" | |||
| :aria-pressed="reactionSummary?.userReaction === ReactionType.POSITIVE" | |||
| :aria-label="`Like comment (${reactionSummary?.positive ?? 0})`" | |||
| :disabled="isReacting" | |||
| @click="handleReaction(ReactionType.POSITIVE)" | |||
| class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/40 disabled:opacity-50 disabled:cursor-not-allowed" | |||
| :class="reactionSummary?.userReaction === ReactionType.POSITIVE | |||
| ? 'bg-emerald-50 text-emerald-700 border border-emerald-300 font-semibold' | |||
| : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 border border-transparent'" | |||
| > | |||
| <ThumbsUp size="xs" aria-hidden="true" /> | |||
| <span v-if="(reactionSummary?.positive ?? 0) > 0" class="tabular-nums"> | |||
| {{ reactionSummary?.positive }} | |||
| </span> | |||
| </button> | |||
| <button | |||
| type="button" | |||
| :aria-pressed="reactionSummary?.userReaction === ReactionType.NEGATIVE" | |||
| :aria-label="`Dislike comment (${reactionSummary?.negative ?? 0})`" | |||
| :disabled="isReacting" | |||
| @click="handleReaction(ReactionType.NEGATIVE)" | |||
| class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-rose-500/40 disabled:opacity-50 disabled:cursor-not-allowed" | |||
| :class="reactionSummary?.userReaction === ReactionType.NEGATIVE | |||
| ? 'bg-rose-50 text-rose-700 border border-rose-300 font-semibold' | |||
| : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 border border-transparent'" | |||
| > | |||
| <ThumbsDown size="xs" aria-hidden="true" /> | |||
| <span v-if="(reactionSummary?.negative ?? 0) > 0" class="tabular-nums"> | |||
| {{ reactionSummary?.negative }} | |||
| </span> | |||
| </button> | |||
| </div> | |||
| <button | |||
| v-if="!isReplying" | |||
| type="button" | |||
| @@ -36,6 +73,22 @@ | |||
| </button> | |||
| </div> | |||
| <!-- Reaction error notification --> | |||
| <div | |||
| v-if="reactionError" | |||
| class="mt-2 flex items-center justify-between rounded-md border border-rose-200 bg-rose-50 px-2.5 py-1.5 text-xs text-rose-700" | |||
| > | |||
| <span>{{ reactionError }}</span> | |||
| <button | |||
| type="button" | |||
| @click="reactionError = null" | |||
| class="ml-2 font-bold text-rose-500 hover:text-rose-800" | |||
| aria-label="Dismiss reaction error" | |||
| > | |||
| × | |||
| </button> | |||
| </div> | |||
| <!-- Inline Reply Form --> | |||
| <form | |||
| v-if="isReplying" | |||
| @@ -97,9 +150,11 @@ | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import {computed, nextTick, ref} from "vue"; | |||
| import {CommentWithUser} from "../generated/typescript"; | |||
| import {computed, nextTick, onMounted, ref, watch} from "vue"; | |||
| import {CommentReactionSummary, CommentService, CommentWithUser, ReactionType} from "../generated/typescript"; | |||
| import {CommentNode} from "../utils/comments.ts"; | |||
| import ThumbsUp from "./icons/ThumbsUp.vue"; | |||
| import ThumbsDown from "./icons/ThumbsDown.vue"; | |||
| const props = defineProps<{ | |||
| node: CommentNode; | |||
| @@ -113,6 +168,92 @@ const isSubmitting = ref(false); | |||
| const replyError = ref<string | null>(null); | |||
| const replyInput = ref<HTMLTextAreaElement | null>(null); | |||
| const reactionSummary = ref<CommentReactionSummary>({ | |||
| commentId: props.node.comment.id, | |||
| positive: 0, | |||
| negative: 0, | |||
| userReaction: null, | |||
| }); | |||
| const isReacting = ref(false); | |||
| const reactionError = ref<string | null>(null); | |||
| const fetchReactions = async () => { | |||
| if (!props.node.comment?.id) return; | |||
| try { | |||
| const summary = await CommentService.getCommentReactions(props.node.comment.id); | |||
| reactionSummary.value = summary; | |||
| } catch (err) { | |||
| console.warn("Failed to fetch comment reactions:", err); | |||
| } | |||
| }; | |||
| const handleReaction = async (targetType: ReactionType) => { | |||
| if (isReacting.value) return; | |||
| const previousSummary = { ...reactionSummary.value }; | |||
| const currentReaction = previousSummary.userReaction; | |||
| const newReaction = currentReaction === targetType ? null : targetType; | |||
| let newPositive = previousSummary.positive; | |||
| let newNegative = previousSummary.negative; | |||
| if (currentReaction === ReactionType.POSITIVE) { | |||
| newPositive = Math.max(0, newPositive - 1); | |||
| } else if (currentReaction === ReactionType.NEGATIVE) { | |||
| newNegative = Math.max(0, newNegative - 1); | |||
| } | |||
| if (newReaction === ReactionType.POSITIVE) { | |||
| newPositive += 1; | |||
| } else if (newReaction === ReactionType.NEGATIVE) { | |||
| newNegative += 1; | |||
| } | |||
| reactionSummary.value = { | |||
| ...previousSummary, | |||
| positive: newPositive, | |||
| negative: newNegative, | |||
| userReaction: newReaction, | |||
| }; | |||
| isReacting.value = true; | |||
| reactionError.value = null; | |||
| try { | |||
| await CommentService.reactComment({ | |||
| commentId: props.node.comment.id, | |||
| reaction: newReaction, | |||
| }); | |||
| await fetchReactions(); | |||
| } catch (err) { | |||
| console.error("Failed to react to comment:", err); | |||
| reactionSummary.value = previousSummary; | |||
| reactionError.value = | |||
| err instanceof Error ? err.message : "Failed to update reaction. Please try again."; | |||
| } finally { | |||
| isReacting.value = false; | |||
| } | |||
| }; | |||
| onMounted(() => { | |||
| fetchReactions(); | |||
| }); | |||
| watch( | |||
| () => props.node.comment.id, | |||
| (newId, oldId) => { | |||
| if (newId !== oldId) { | |||
| reactionSummary.value = { | |||
| commentId: newId, | |||
| positive: 0, | |||
| negative: 0, | |||
| userReaction: null, | |||
| }; | |||
| fetchReactions(); | |||
| } | |||
| } | |||
| ); | |||
| // Visual nesting levels are 1-based; nestingLevel is 0 for top-level comments. | |||
| const MAX_NESTING_LEVEL = 3; | |||
| @@ -7,7 +7,7 @@ | |||
| <script setup lang="ts"> | |||
| import { DropdownOption } from "../types/dropdown.ts"; | |||
| const props = defineProps<{ emptyValue: string, options: DropdownOption[], expanded?: boolean }>() | |||
| defineProps<{ emptyValue: string, options: DropdownOption[], expanded?: boolean }>() | |||
| </script> | |||
| <style scoped> | |||
| @@ -5,7 +5,7 @@ | |||
| </template> | |||
| <script setup lang="ts"> | |||
| const props = defineProps<{ identifier: string }>(); | |||
| defineProps<{ identifier: string }>(); | |||
| </script> | |||
| @@ -21,9 +21,14 @@ | |||
| <script setup lang="ts"> | |||
| import {Editor, EditorContent} from '@tiptap/vue-3' | |||
| import StarterKit from '@tiptap/starter-kit' | |||
| import {onBeforeUnmount} from 'vue' | |||
| import {onBeforeUnmount, onMounted, watch} from 'vue' | |||
| import TurndownService from 'turndown'; | |||
| import {ProposalParagraph} from "../types/proposal.ts"; | |||
| import {renderMarkdown} from "../utils/markdown.ts"; | |||
| const props = defineProps<{ | |||
| initialContent?: string; | |||
| }>(); | |||
| const turndownService = new TurndownService(); | |||
| @@ -31,24 +36,46 @@ const emit = defineEmits<{ | |||
| 'update:content': [content: ProposalParagraph[]], | |||
| }>() | |||
| const emitParagraphs = (html: string) => { | |||
| const content = convertEditorContentToMarkdown(html); | |||
| const paragraphs = content.split('\n').map(p => p.trim()).filter(p => p.length > 0).map((p, i) => ({paragraphText: p, index: i})); | |||
| emit('update:content', paragraphs); | |||
| }; | |||
| const editor = new Editor({ | |||
| extensions: [ | |||
| StarterKit, | |||
| ], | |||
| content: '', | |||
| content: props.initialContent ? renderMarkdown(props.initialContent) : '', | |||
| editorProps: { | |||
| attributes: { | |||
| class: 'prose prose-sm focus:outline-none max-w-none', | |||
| }, | |||
| }, | |||
| onUpdate: ({ editor }) => { | |||
| const html = editor.getHTML(); | |||
| const content = convertEditorContentToMarkdown(html); | |||
| const paragraphs = content.split('\n').map(p => p.trim()).filter(p => p.length > 0).map((p, i) => ({paragraphText: p, index: i})); | |||
| emit('update:content', paragraphs); | |||
| emitParagraphs(editor.getHTML()); | |||
| } | |||
| }) | |||
| onMounted(() => { | |||
| if (props.initialContent) { | |||
| emitParagraphs(editor.getHTML()); | |||
| } | |||
| }); | |||
| watch( | |||
| () => props.initialContent, | |||
| (newContent) => { | |||
| if (!editor) return; | |||
| const currentMarkdown = convertEditorContentToMarkdown(editor.getHTML()); | |||
| if (currentMarkdown.trim() !== (newContent || '').trim()) { | |||
| const html = newContent ? renderMarkdown(newContent) : ''; | |||
| editor.commands.setContent(html, { emitUpdate: false }); | |||
| emitParagraphs(editor.getHTML()); | |||
| } | |||
| } | |||
| ); | |||
| const convertEditorContentToMarkdown = (htmlContent: string) => { | |||
| return turndownService.turndown(htmlContent); | |||
| }; | |||
| @@ -6,24 +6,70 @@ | |||
| <div class="grid gap-8 xl:grid-cols-[minmax(0,1fr)_15rem] xl:items-start"> | |||
| <!-- Primary reading column --> | |||
| <div class="min-w-0"> | |||
| <header class="border-b border-slate-300 pb-7"> | |||
| <div class="mb-4 flex flex-wrap items-center gap-2"> | |||
| <span | |||
| class="rounded-full border border-slate-300 bg-white px-2.5 py-1 | |||
| text-[0.65rem] font-bold uppercase tracking-[0.12em] text-slate-600" | |||
| > | |||
| Proposal | |||
| </span> | |||
| <!-- Sticky title reminder; appears once the main heading scrolls out of view. | |||
| The negative margin keeps it from taking up space in the layout. --> | |||
| <div | |||
| class="sticky top-0 z-20 -mb-14 flex h-14 items-center border-b border-slate-300 | |||
| bg-slate-100/90 backdrop-blur-sm transition-[opacity,transform] duration-200" | |||
| :class="isTitleVisible | |||
| ? 'pointer-events-none -translate-y-1 opacity-0' | |||
| : 'translate-y-0 opacity-100'" | |||
| aria-hidden="true" | |||
| > | |||
| <p | |||
| class="truncate font-header text-lg font-bold tracking-tight text-slate-950" | |||
| :title="proposal.name" | |||
| > | |||
| {{ proposal.name }} | |||
| </p> | |||
| </div> | |||
| <header class="pb-7"> | |||
| <div class="mb-4 flex flex-wrap items-center justify-between gap-2"> | |||
| <div class="flex flex-wrap items-center gap-2"> | |||
| <span | |||
| class="rounded-full border border-slate-300 bg-white px-2.5 py-1 | |||
| text-[0.65rem] font-bold uppercase tracking-[0.12em] text-slate-600" | |||
| > | |||
| Proposal | |||
| </span> | |||
| <span | |||
| class="rounded-full border border-slate-300 bg-slate-200/70 px-2.5 py-1 | |||
| text-[0.65rem] font-bold uppercase tracking-[0.12em] text-slate-600" | |||
| <span | |||
| class="rounded-full border border-slate-300 bg-slate-200/70 px-2.5 py-1 | |||
| text-[0.65rem] font-bold uppercase tracking-[0.12em] text-slate-600" | |||
| > | |||
| {{ truncateWallet(proposal.creator) }} | |||
| </span> | |||
| <span | |||
| v-if="proposal.state" | |||
| class="rounded-full border border-amber-300 bg-amber-50 px-2.5 py-1 | |||
| text-[0.65rem] font-bold uppercase tracking-[0.12em] text-amber-800" | |||
| > | |||
| {{ proposal.state }} | |||
| </span> | |||
| <ProposalRevisionDropdown | |||
| v-if="proposal.revision !== undefined" | |||
| :proposal="proposal" | |||
| /> | |||
| </div> | |||
| <button | |||
| v-if="isEditable" | |||
| type="button" | |||
| @click="isEditModalOpen = true" | |||
| class="inline-flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-800 shadow-sm transition hover:bg-slate-50 hover:border-slate-400 focus:outline-none focus:ring-2 focus:ring-amber-500/20" | |||
| > | |||
| {{ truncateWallet(proposal.creator) }} | |||
| </span> | |||
| <svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 text-slate-600" viewBox="0 0 20 20" fill="currentColor"> | |||
| <path d="m13.586 3.586 2.828 2.828a2 2 0 0 1 0 2.828l-8.485 8.485a2 2 0 0 1-.879.497l-4.242 1.06a.75.75 0 0 1-.91-.91l1.06-4.242a2 2 0 0 1 .497-.879l8.485-8.485a2 2 0 0 1 2.828 0Z" /> | |||
| </svg> | |||
| Edit Proposal | |||
| </button> | |||
| </div> | |||
| <h1 | |||
| ref="titleEl" | |||
| :id="`proposal-${proposal.id}-title`" | |||
| class="max-w-4xl font-header text-4xl font-bold leading-[0.95] | |||
| tracking-tight text-slate-950 sm:text-5xl" | |||
| @@ -50,164 +96,286 @@ | |||
| </div> | |||
| </header> | |||
| <section | |||
| class="max-w-3xl py-8" | |||
| aria-labelledby="proposal-content-heading" | |||
| <!-- Proposal / discussion tabs --> | |||
| <div | |||
| role="tablist" | |||
| aria-label="Proposal sections" | |||
| class="flex gap-6 border-b border-slate-300" | |||
| @keydown.left.prevent="focusAdjacentTab(-1)" | |||
| @keydown.right.prevent="focusAdjacentTab(1)" | |||
| > | |||
| <h2 id="proposal-content-heading" class="sr-only"> | |||
| Proposal details | |||
| </h2> | |||
| <div class="content-body space-y-5 text-[0.98rem] leading-7 text-slate-800"> | |||
| <p | |||
| v-for="paragraph in proposal.paragraphs" | |||
| :key="paragraph.id ?? paragraph.paragraphText" | |||
| v-html="renderMarkdown(paragraph.paragraphText)" | |||
| /> | |||
| </div> | |||
| </section> | |||
| <section | |||
| class="border-t border-slate-300 py-8" | |||
| aria-labelledby="amendments-heading" | |||
| > | |||
| <div class="mb-4 flex items-baseline justify-between gap-4"> | |||
| <h2 | |||
| id="amendments-heading" | |||
| class="font-header text-2xl font-bold tracking-tight text-slate-950" | |||
| > | |||
| Amendments | |||
| </h2> | |||
| <button | |||
| v-for="tab in tabs" | |||
| :key="tab.id" | |||
| :id="`proposal-tab-${tab.id}`" | |||
| :ref="(el) => setTabRef(tab.id, el)" | |||
| type="button" | |||
| role="tab" | |||
| :aria-selected="activeTab === tab.id" | |||
| :aria-controls="`proposal-panel-${tab.id}`" | |||
| :tabindex="activeTab === tab.id ? 0 : -1" | |||
| @click="activeTab = tab.id" | |||
| :class="[ | |||
| '-mb-px inline-flex items-center gap-2 border-b-2 px-0.5 pb-3 pt-1 text-xs font-bold uppercase tracking-[0.12em] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/20', | |||
| activeTab === tab.id | |||
| ? 'border-amber-500 text-slate-950' | |||
| : 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-800', | |||
| ]" | |||
| > | |||
| {{ tab.label }} | |||
| <span | |||
| class="text-[0.65rem] font-bold uppercase tracking-[0.1em] text-slate-500" | |||
| v-if="tab.id === 'discussion' && numComments > 0" | |||
| :class="[ | |||
| 'rounded-full border px-1.5 py-0.5 text-[0.6rem] leading-none tracking-normal', | |||
| activeTab === tab.id | |||
| ? 'border-amber-300 bg-amber-50 text-amber-800' | |||
| : 'border-slate-300 bg-white text-slate-600', | |||
| ]" | |||
| > | |||
| Proposed changes | |||
| {{ numComments }} | |||
| </span> | |||
| </div> | |||
| <AmendmentCarousel | |||
| :proposal-id="proposal.id" | |||
| :amendment-votes="userVotes?.amendmentVotes" | |||
| @voted="fetchUserVotes(proposal.id)" | |||
| /> | |||
| </section> | |||
| <section | |||
| class="border-t border-slate-300 py-8" | |||
| aria-labelledby="comments-heading" | |||
| </button> | |||
| </div> | |||
| <div | |||
| v-show="activeTab === 'proposal'" | |||
| id="proposal-panel-proposal" | |||
| role="tabpanel" | |||
| aria-labelledby="proposal-tab-proposal" | |||
| > | |||
| <div class="mb-4 flex items-baseline justify-between gap-4"> | |||
| <h2 | |||
| id="comments-heading" | |||
| class="font-header text-2xl font-bold tracking-tight text-slate-950" | |||
| > | |||
| Comments | |||
| <section | |||
| class="max-w-3xl py-8" | |||
| aria-labelledby="proposal-content-heading" | |||
| > | |||
| <h2 id="proposal-content-heading" class="sr-only"> | |||
| Proposal details | |||
| </h2> | |||
| <span | |||
| class="text-[0.65rem] font-bold uppercase tracking-[0.1em] text-slate-500" | |||
| > | |||
| Discussion | |||
| </span> | |||
| </div> | |||
| <!-- Inline Comment Form --> | |||
| <form @submit.prevent="submitComment" class="mb-8 space-y-3" aria-label="Add a comment"> | |||
| <div v-if="commentError" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 border border-red-200"> | |||
| {{ commentError }} | |||
| <div class="content-body space-y-5 text-[0.98rem] leading-7 text-slate-800"> | |||
| <p | |||
| v-for="paragraph in proposal.paragraphs" | |||
| :key="paragraph.id ?? paragraph.paragraphText" | |||
| v-html="renderMarkdown(paragraph.paragraphText)" | |||
| /> | |||
| </div> | |||
| </section> | |||
| <div> | |||
| <label for="proposal-comment-input" class="sr-only"> | |||
| Add to the discussion | |||
| </label> | |||
| <textarea | |||
| id="proposal-comment-input" | |||
| v-model="commentText" | |||
| rows="3" | |||
| :disabled="isSubmitting" | |||
| placeholder="Add to the discussion..." | |||
| class="w-full rounded-lg border border-slate-300 p-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/10 disabled:bg-slate-50 disabled:text-slate-400 resize-y" | |||
| ></textarea> | |||
| <section | |||
| class="border-t border-slate-300 py-8" | |||
| aria-labelledby="amendments-heading" | |||
| > | |||
| <div class="mb-4 flex items-baseline justify-between gap-4"> | |||
| <h2 | |||
| id="amendments-heading" | |||
| class="font-header text-2xl font-bold tracking-tight text-slate-950" | |||
| > | |||
| Amendments | |||
| </h2> | |||
| <span | |||
| class="text-[0.65rem] font-bold uppercase tracking-[0.1em] text-slate-500" | |||
| > | |||
| Proposed changes | |||
| </span> | |||
| </div> | |||
| <div class="flex items-center justify-between gap-3"> | |||
| <span class="text-xs text-slate-500"> | |||
| Share your perspective on this proposal. | |||
| <AmendmentCarousel | |||
| :proposal-id="proposal.id" | |||
| :amendment-votes="userVotes?.amendmentVotes" | |||
| :is-approved="isApproved" | |||
| @voted="fetchUserVotes(proposal.id)" | |||
| /> | |||
| </section> | |||
| </div> | |||
| <div | |||
| v-show="activeTab === 'discussion'" | |||
| id="proposal-panel-discussion" | |||
| role="tabpanel" | |||
| aria-labelledby="proposal-tab-discussion" | |||
| > | |||
| <section | |||
| class="py-8" | |||
| aria-labelledby="comments-heading" | |||
| > | |||
| <div class="mb-4 flex items-baseline justify-between gap-4"> | |||
| <h2 | |||
| id="comments-heading" | |||
| class="font-header text-2xl font-bold tracking-tight text-slate-950" | |||
| > | |||
| Comments | |||
| </h2> | |||
| <span | |||
| class="text-[0.65rem] font-bold uppercase tracking-[0.1em] text-slate-500" | |||
| > | |||
| Discussion | |||
| </span> | |||
| <div class="flex items-center gap-2"> | |||
| <button | |||
| v-if="commentText.trim().length > 0" | |||
| type="button" | |||
| </div> | |||
| <!-- Inline Comment Form --> | |||
| <form @submit.prevent="submitComment" class="mb-8 space-y-3" aria-label="Add a comment"> | |||
| <div v-if="commentError" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 border border-red-200"> | |||
| {{ commentError }} | |||
| </div> | |||
| <div> | |||
| <label for="proposal-comment-input" class="sr-only"> | |||
| Add to the discussion | |||
| </label> | |||
| <textarea | |||
| id="proposal-comment-input" | |||
| v-model="commentText" | |||
| rows="3" | |||
| :disabled="isSubmitting" | |||
| @click="handleCancel" | |||
| class="rounded-lg border border-slate-300 px-3.5 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-300 disabled:opacity-50" | |||
| > | |||
| Cancel | |||
| </button> | |||
| <button | |||
| type="submit" | |||
| :disabled="isSubmitDisabled" | |||
| class="rounded-lg bg-amber-500 px-4 py-2 text-xs font-semibold text-white shadow-sm hover:bg-amber-600 focus:outline-none focus:ring-4 focus:ring-amber-500/20 disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-400 transition-colors" | |||
| > | |||
| <span v-if="isSubmitting">Posting...</span> | |||
| <span v-else>Add Comment</span> | |||
| </button> | |||
| placeholder="Add to the discussion..." | |||
| class="w-full rounded-lg border border-slate-300 p-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/10 disabled:bg-slate-50 disabled:text-slate-400 resize-y" | |||
| ></textarea> | |||
| </div> | |||
| </div> | |||
| </form> | |||
| <!-- Comments List --> | |||
| <div class="space-y-4"> | |||
| <!-- Only show the loading state on an empty list, so refetching after a reply | |||
| doesn't unmount the threads (and their open reply forms) mid-submit. --> | |||
| <div v-if="isLoadingComments && comments.length === 0" class="py-6 text-center text-sm text-slate-500"> | |||
| Loading comments... | |||
| </div> | |||
| <div | |||
| v-else-if="comments.length === 0" | |||
| class="rounded-lg border border-dashed border-slate-300 bg-white/50 px-5 py-8 text-center text-sm text-slate-500" | |||
| > | |||
| No comments yet. Be the first to share your feedback! | |||
| </div> | |||
| <div class="flex items-center justify-between gap-3"> | |||
| <span class="text-xs text-slate-500"> | |||
| Share your perspective on this proposal. | |||
| </span> | |||
| <div class="flex items-center gap-2"> | |||
| <button | |||
| v-if="commentText.trim().length > 0" | |||
| type="button" | |||
| :disabled="isSubmitting" | |||
| @click="handleCancel" | |||
| class="rounded-lg border border-slate-300 px-3.5 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-300 disabled:opacity-50" | |||
| > | |||
| Cancel | |||
| </button> | |||
| <button | |||
| type="submit" | |||
| :disabled="isSubmitDisabled" | |||
| class="rounded-lg bg-amber-500 px-4 py-2 text-xs font-semibold text-white shadow-sm hover:bg-amber-600 focus:outline-none focus:ring-4 focus:ring-amber-500/20 disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-400 transition-colors" | |||
| > | |||
| <span v-if="isSubmitting">Posting...</span> | |||
| <span v-else>Add Comment</span> | |||
| </button> | |||
| </div> | |||
| </div> | |||
| </form> | |||
| <!-- Comments List --> | |||
| <div class="space-y-4"> | |||
| <!-- Only show the loading state on an empty list, so refetching after a reply | |||
| doesn't unmount the threads (and their open reply forms) mid-submit. --> | |||
| <div v-if="isLoadingComments && numComments === 0" class="py-6 text-center text-sm text-slate-500"> | |||
| Loading comments... | |||
| </div> | |||
| <div v-else class="space-y-3"> | |||
| <CommentThread | |||
| v-for="node in commentTree" | |||
| :key="node.comment.id" | |||
| :node="node" | |||
| :post-reply="postComment" | |||
| :nesting-level="0" | |||
| /> | |||
| <div | |||
| v-else-if="numComments === 0" | |||
| class="rounded-lg border border-dashed border-slate-300 bg-white/50 px-5 py-8 text-center text-sm text-slate-500" | |||
| > | |||
| No comments yet. Be the first to share your feedback! | |||
| </div> | |||
| <div v-else class="space-y-3"> | |||
| <CommentThread | |||
| v-for="node in commentTree" | |||
| :key="node.comment.id" | |||
| :node="node" | |||
| :post-reply="postComment" | |||
| :nesting-level="0" | |||
| /> | |||
| <div v-if="comments.length < numComments" class="pt-2 text-center"> | |||
| <button | |||
| type="button" | |||
| :disabled="isLoadingComments" | |||
| @click="loadMoreComments" | |||
| class="rounded-lg border border-slate-300 px-4 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-300 disabled:opacity-50" | |||
| > | |||
| <span v-if="isLoadingComments">Loading...</span> | |||
| <span v-else>Load more comments ({{ numComments - comments.length }} remaining)</span> | |||
| </button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </section> | |||
| </section> | |||
| </div> | |||
| </div> | |||
| <!-- Voting action rail --> | |||
| <aside | |||
| class="xl:sticky xl:top-6" | |||
| aria-labelledby="vote-heading" | |||
| aria-label="Proposal actions" | |||
| > | |||
| <ProposalVotingCard | |||
| v-if="isVoting" | |||
| :proposal-id="proposal.id" | |||
| :current-vote="userVotes?.proposalVote?.choice" | |||
| @vote="recordVote" | |||
| /> | |||
| <ProposalExportCard :proposal-id="proposal.id" :proposal-name="proposal.name" /> | |||
| <section | |||
| v-else | |||
| class="relative rounded-xl border border-indigo-100 bg-indigo-50/60 p-4 pl-14 mb-2" | |||
| aria-labelledby="voting-status-heading" | |||
| > | |||
| <span | |||
| class="absolute left-4 top-4 flex size-7 items-center justify-center rounded-full bg-indigo-100/80 text-indigo-600" | |||
| aria-hidden="true" | |||
| > | |||
| <svg | |||
| class="size-4" | |||
| viewBox="0 0 24 24" | |||
| fill="none" | |||
| stroke="currentColor" | |||
| stroke-width="1.75" | |||
| stroke-linecap="round" | |||
| stroke-linejoin="round" | |||
| > | |||
| <circle cx="12" cy="12" r="8.5" /> | |||
| <path d="M12 7.5V12l3 1.75" /> | |||
| </svg> | |||
| </span> | |||
| <h2 | |||
| id="voting-status-heading" | |||
| class="font-header text-sm font-semibold leading-6 tracking-tight text-slate-900" | |||
| > | |||
| Voting hasn't started | |||
| </h2> | |||
| <p class="mt-1 text-xs leading-5 text-slate-600"> | |||
| You can vote once this proposal enters the | |||
| <span class="font-medium text-indigo-700">voting phase</span>. | |||
| </p> | |||
| </section> | |||
| <ProposalSponsorCard | |||
| :proposal-id="proposal.id" | |||
| :state="proposal.state" | |||
| /> | |||
| <ProposalExportCard | |||
| :proposal-id="proposal.id" | |||
| :proposal-name="proposal.name" | |||
| /> | |||
| </aside> | |||
| </div> | |||
| <EditProposalModal | |||
| v-if="isEditModalOpen" | |||
| v-model="isEditModalOpen" | |||
| :proposal="proposal" | |||
| /> | |||
| </article> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import {computed, onMounted, ref, watch} from "vue"; | |||
| import {computed, onMounted, ref, useTemplateRef, watch} from "vue"; | |||
| import {useIntersectionObserver} from "@vueuse/core"; | |||
| import { | |||
| CommentService, | |||
| type CommentWithUser, | |||
| ProposalService, | |||
| ProposalState, | |||
| ProposalWithParagraphs, | |||
| UserProposalVotes, | |||
| VoteChoice, | |||
| @@ -217,8 +385,11 @@ import {renderMarkdown} from "../utils/markdown.ts"; | |||
| import Identification from "./icons/Identification.vue"; | |||
| import AmendmentCarousel from "./AmendmentCarousel.vue"; | |||
| import ProposalVotingCard from "./ProposalVotingCard.vue"; | |||
| import ProposalSponsorCard from "./ProposalSponsorCard.vue"; | |||
| import ProposalExportCard from "./ProposalExportCard.vue"; | |||
| import EditProposalModal from "./modals/EditProposalModal.vue"; | |||
| import CommentThread from "./CommentThread.vue"; | |||
| import ProposalRevisionDropdown from "./ProposalRevisionDropdown.vue"; | |||
| import {getUserIdFromJwt} from "../utils/auth.ts"; | |||
| import {buildCommentTree} from "../utils/comments.ts"; | |||
| @@ -226,10 +397,60 @@ const props = defineProps<{ | |||
| proposal: ProposalWithParagraphs; | |||
| }>(); | |||
| const titleEl = useTemplateRef<HTMLElement>('titleEl'); | |||
| const isTitleVisible = ref(true); | |||
| // The top margin matches the sticky bar's height (h-14), so the bar appears | |||
| // as soon as the heading slides underneath it. | |||
| useIntersectionObserver( | |||
| titleEl, | |||
| ([entry]) => { | |||
| isTitleVisible.value = entry?.isIntersecting ?? true; | |||
| }, | |||
| { rootMargin: '-56px 0px 0px 0px' } | |||
| ); | |||
| const isEditModalOpen = ref(false); | |||
| const isEditable = computed(() => { | |||
| return props.proposal?.state === ProposalState.DRAFT || (props.proposal?.state as any) === 'DRAFT'; | |||
| }); | |||
| const isVoting = computed(() => { | |||
| return props.proposal?.state === ProposalState.VOTING || (props.proposal?.state as any) === 'VOTING'; | |||
| }); | |||
| const isApproved = computed(() => { | |||
| return props.proposal?.state === ProposalState.APPROVED || (props.proposal?.state as any) === 'APPROVED'; | |||
| }); | |||
| type ProposalTab = 'proposal' | 'discussion'; | |||
| const tabs: { id: ProposalTab; label: string }[] = [ | |||
| { id: 'proposal', label: 'Proposal' }, | |||
| { id: 'discussion', label: 'Discussion' }, | |||
| ]; | |||
| const activeTab = ref<ProposalTab>('proposal'); | |||
| const tabRefs: Partial<Record<ProposalTab, HTMLElement>> = {}; | |||
| const setTabRef = (id: ProposalTab, el: unknown) => { | |||
| if (el instanceof HTMLElement) tabRefs[id] = el; | |||
| }; | |||
| // Arrow keys move between tabs, per the WAI-ARIA tabs pattern. | |||
| const focusAdjacentTab = (direction: 1 | -1) => { | |||
| const index = tabs.findIndex((tab) => tab.id === activeTab.value); | |||
| const next = tabs[(index + direction + tabs.length) % tabs.length]; | |||
| activeTab.value = next.id; | |||
| tabRefs[next.id]?.focus(); | |||
| }; | |||
| const commentText = ref(""); | |||
| const isSubmitting = ref(false); | |||
| const commentError = ref<string | null>(null); | |||
| const COMMENTS_PAGE_SIZE = 40; | |||
| const comments = ref<CommentWithUser[]>([]); | |||
| const numComments = ref(0); | |||
| const isLoadingComments = ref(false); | |||
| const userVotes = ref<UserProposalVotes | null>(null); | |||
| @@ -277,18 +498,61 @@ const handleCancel = () => { | |||
| commentError.value = null; | |||
| }; | |||
| const fetchComments = async (proposalId?: number) => { | |||
| // Fetches pages of COMMENTS_PAGE_SIZE starting at `offset` until at least `count` comments | |||
| // have been loaded (or the proposal runs out of comments). | |||
| const fetchCommentRange = async (proposalId: number, offset: number, count: number) => { | |||
| const fetched: CommentWithUser[] = []; | |||
| let total = numComments.value; | |||
| while (fetched.length < count) { | |||
| const res = await CommentService.listComments( | |||
| proposalId, | |||
| offset + fetched.length, | |||
| COMMENTS_PAGE_SIZE, | |||
| ); | |||
| const page = res.comments ?? []; | |||
| total = res.numComments ?? 0; | |||
| fetched.push(...page); | |||
| if (page.length < COMMENTS_PAGE_SIZE || offset + fetched.length >= total) break; | |||
| } | |||
| return {fetched, total}; | |||
| }; | |||
| // Replaces the loaded comments with the first `count` comments (at least one page), so a | |||
| // refetch after posting keeps every page the user has already expanded. | |||
| const fetchComments = async (proposalId?: number, count = COMMENTS_PAGE_SIZE) => { | |||
| if (proposalId === undefined || proposalId === null || isNaN(proposalId)) { | |||
| comments.value = []; | |||
| numComments.value = 0; | |||
| return; | |||
| } | |||
| isLoadingComments.value = true; | |||
| try { | |||
| const res = await CommentService.listComments(proposalId); | |||
| comments.value = res ?? []; | |||
| const {fetched, total} = await fetchCommentRange(proposalId, 0, Math.max(count, COMMENTS_PAGE_SIZE)); | |||
| if (props.proposal?.id !== proposalId) return; | |||
| comments.value = fetched; | |||
| numComments.value = total; | |||
| } catch (err) { | |||
| console.error("Failed to load comments:", err); | |||
| comments.value = []; | |||
| numComments.value = 0; | |||
| } finally { | |||
| isLoadingComments.value = false; | |||
| } | |||
| }; | |||
| const loadMoreComments = async () => { | |||
| const proposalId = props.proposal?.id; | |||
| if (!proposalId || isLoadingComments.value) return; | |||
| isLoadingComments.value = true; | |||
| try { | |||
| const {fetched, total} = await fetchCommentRange(proposalId, comments.value.length, COMMENTS_PAGE_SIZE); | |||
| if (props.proposal?.id !== proposalId) return; | |||
| // Comments posted since the last fetch can shift offsets, so skip any already loaded. | |||
| const loadedIds = new Set(comments.value.map((comment) => comment.id)); | |||
| comments.value = [...comments.value, ...fetched.filter((comment) => !loadedIds.has(comment.id))]; | |||
| numComments.value = total; | |||
| } catch (err) { | |||
| console.error("Failed to load more comments:", err); | |||
| } finally { | |||
| isLoadingComments.value = false; | |||
| } | |||
| @@ -302,7 +566,7 @@ const postComment = async (parentId: number | null, content: string) => { | |||
| parentId, | |||
| userId: 1, | |||
| }); | |||
| await fetchComments(props.proposal.id); | |||
| await fetchComments(props.proposal.id, comments.value.length + 1); | |||
| }; | |||
| const submitComment = async () => { | |||
| @@ -336,11 +600,13 @@ onMounted(() => { | |||
| watch( | |||
| () => props.proposal?.id, | |||
| (newId) => { | |||
| activeTab.value = 'proposal'; | |||
| if (newId) { | |||
| fetchComments(newId); | |||
| fetchUserVotes(newId); | |||
| } else { | |||
| comments.value = []; | |||
| numComments.value = 0; | |||
| userVotes.value = null; | |||
| } | |||
| } | |||
| @@ -0,0 +1,372 @@ | |||
| <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> | |||
| @@ -0,0 +1,219 @@ | |||
| <template> | |||
| <section | |||
| class="rounded-xl border border-slate-300 bg-white p-4 shadow-sm mb-2" | |||
| aria-labelledby="proposal-sponsor-heading" | |||
| > | |||
| <div class="mb-3 flex items-start justify-between gap-3"> | |||
| <div> | |||
| <h2 | |||
| id="proposal-sponsor-heading" | |||
| class="font-header text-base font-bold tracking-tight text-slate-950" | |||
| > | |||
| Proposal Sponsorship | |||
| </h2> | |||
| <p class="mt-1 text-xs leading-5 text-slate-500"> | |||
| {{ | |||
| isDraft | |||
| ? 'Back this proposal to endorse it moving to the formal voting stage.' | |||
| : 'Community members who sponsored this proposal during drafting.' | |||
| }} | |||
| </p> | |||
| </div> | |||
| <span | |||
| class="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-bold text-amber-800" | |||
| :title="`${sponsorCount} ${sponsorCount === 1 ? 'sponsor' : 'sponsors'}`" | |||
| > | |||
| <ThumbsUp class="size-3.5 text-amber-700 shrink-0" :aria-hidden="true" /> | |||
| <span>{{ sponsorCount }}</span> | |||
| </span> | |||
| </div> | |||
| <!-- Draft state sponsorship controls --> | |||
| <div v-if="isDraft" class="space-y-2.5"> | |||
| <div | |||
| v-if="isSponsoredByMe" | |||
| class="flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-xs font-semibold text-emerald-800 shadow-xs" | |||
| > | |||
| <svg | |||
| class="size-4 shrink-0 text-emerald-600" | |||
| viewBox="0 0 20 20" | |||
| fill="currentColor" | |||
| :aria-hidden="true" | |||
| > | |||
| <path | |||
| fill-rule="evenodd" | |||
| d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" | |||
| clip-rule="evenodd" | |||
| /> | |||
| </svg> | |||
| <span>You have sponsored this proposal</span> | |||
| </div> | |||
| <button | |||
| v-else | |||
| type="button" | |||
| @click="handleSponsor" | |||
| :disabled="isSponsoring || isLoadingSponsors" | |||
| class="group flex h-10 w-full items-center justify-center gap-2 rounded-md | |||
| bg-amber-600 px-3 text-sm font-semibold text-white shadow-sm | |||
| transition duration-150 | |||
| hover:-translate-y-px hover:bg-amber-500 hover:shadow-md | |||
| active:translate-y-0 active:bg-amber-600 active:shadow-none | |||
| focus-visible:outline focus-visible:outline-2 | |||
| focus-visible:outline-offset-2 focus-visible:outline-amber-600 | |||
| disabled:opacity-60 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none" | |||
| > | |||
| <span | |||
| v-if="isSponsoring" | |||
| class="size-4 border-2 border-white/30 border-t-white rounded-full animate-spin shrink-0" | |||
| :aria-hidden="true" | |||
| /> | |||
| <ThumbsUp | |||
| v-else | |||
| class="size-4 transition-transform duration-150 group-hover:scale-110 shrink-0" | |||
| :aria-hidden="true" | |||
| /> | |||
| <span>{{ isSponsoring ? 'Sponsoring...' : 'Sponsor Proposal' }}</span> | |||
| </button> | |||
| <div | |||
| v-if="errorMessage" | |||
| class="rounded-lg border border-rose-200 bg-rose-50 p-2.5 text-xs text-rose-700" | |||
| role="alert" | |||
| > | |||
| {{ errorMessage }} | |||
| </div> | |||
| </div> | |||
| <!-- Non-draft informational display --> | |||
| <div v-else class="space-y-2"> | |||
| <div class="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs leading-5 text-slate-600"> | |||
| <span class="font-semibold text-slate-900">{{ sponsorCount }}</span> | |||
| {{ sponsorCount === 1 ? ' member' : ' members' }} backed this proposal during the draft phase. | |||
| </div> | |||
| </div> | |||
| </section> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import { computed, onMounted, ref, watch } from "vue"; | |||
| import ThumbsUp from "./icons/ThumbsUp.vue"; | |||
| import { | |||
| type ProposalSponsor, | |||
| ProposalState, | |||
| ProposalService, | |||
| ApiError, | |||
| } from "../generated/typescript"; | |||
| import { getUserIdFromJwt } from "../utils/auth.ts"; | |||
| import { eventBus } from "../utils/eventBus.ts"; | |||
| const props = defineProps<{ | |||
| proposalId: number; | |||
| state?: ProposalState | string; | |||
| }>(); | |||
| const emit = defineEmits<{ | |||
| (e: 'sponsored', sponsor: ProposalSponsor): void; | |||
| (e: 'update'): void; | |||
| }>(); | |||
| const sponsors = ref<ProposalSponsor[]>([]); | |||
| const isLoadingSponsors = ref(false); | |||
| const isSponsoring = ref(false); | |||
| const errorMessage = ref<string | null>(null); | |||
| const sponsorCount = computed(() => sponsors.value.length); | |||
| const isDraft = computed(() => { | |||
| return props.state === ProposalState.DRAFT || (props.state as any) === 'DRAFT'; | |||
| }); | |||
| const currentUserId = computed(() => getUserIdFromJwt()); | |||
| const isSponsoredByMe = computed(() => { | |||
| const uid = currentUserId.value; | |||
| if (!uid) return false; | |||
| return sponsors.value.some((sponsor) => sponsor.userId === uid); | |||
| }); | |||
| const fetchSponsors = async (propId?: number) => { | |||
| const id = propId ?? props.proposalId; | |||
| if (id === undefined || id === null || isNaN(id)) { | |||
| sponsors.value = []; | |||
| return; | |||
| } | |||
| isLoadingSponsors.value = true; | |||
| try { | |||
| const list = await ProposalService.listProposalSponsors(id); | |||
| sponsors.value = Array.isArray(list) ? list : []; | |||
| } catch (err) { | |||
| console.warn("Failed to fetch proposal sponsors:", err); | |||
| sponsors.value = []; | |||
| } finally { | |||
| isLoadingSponsors.value = false; | |||
| } | |||
| }; | |||
| const handleSponsor = async () => { | |||
| if (!props.proposalId || isSponsoring.value) return; | |||
| isSponsoring.value = true; | |||
| errorMessage.value = null; | |||
| try { | |||
| const recordedSponsor = await ProposalService.sponsorProposal({ | |||
| proposalId: props.proposalId, | |||
| }); | |||
| await fetchSponsors(props.proposalId); | |||
| eventBus.emit('proposalUpdated', props.proposalId); | |||
| emit('sponsored', recordedSponsor); | |||
| emit('update'); | |||
| } catch (err: any) { | |||
| console.error("Failed to sponsor proposal:", err); | |||
| if (err instanceof ApiError) { | |||
| if (err.status === 401) { | |||
| errorMessage.value = "Please connect your wallet or sign in to sponsor this proposal."; | |||
| } else if (err.status === 403) { | |||
| errorMessage.value = "You must be an active organization member to sponsor proposals."; | |||
| } else if (err.status === 409) { | |||
| errorMessage.value = "You have already sponsored this proposal or it is no longer in draft state."; | |||
| // Refresh sponsors to synchronize state | |||
| await fetchSponsors(props.proposalId); | |||
| } else if (err.body && typeof err.body === 'object' && err.body.message) { | |||
| errorMessage.value = err.body.message; | |||
| } else if (err.message) { | |||
| errorMessage.value = err.message; | |||
| } else { | |||
| errorMessage.value = "An error occurred while sponsoring the proposal."; | |||
| } | |||
| } else if (err?.message) { | |||
| errorMessage.value = err.message; | |||
| } else { | |||
| errorMessage.value = "Failed to sponsor proposal. Please try again."; | |||
| } | |||
| } finally { | |||
| isSponsoring.value = false; | |||
| } | |||
| }; | |||
| onMounted(() => { | |||
| if (props.proposalId) { | |||
| fetchSponsors(props.proposalId); | |||
| } | |||
| }); | |||
| watch( | |||
| () => props.proposalId, | |||
| (newId) => { | |||
| errorMessage.value = null; | |||
| if (newId) { | |||
| fetchSponsors(newId); | |||
| } else { | |||
| sponsors.value = []; | |||
| } | |||
| } | |||
| ); | |||
| </script> | |||
| @@ -13,7 +13,7 @@ | |||
| <script setup lang="ts"> | |||
| import { CommentWithChildren } from "../types/comment.ts"; | |||
| const props = defineProps<{ starterComment: CommentWithChildren }>(); | |||
| defineProps<{ starterComment: CommentWithChildren }>(); | |||
| </script> | |||
| <style scoped> | |||
| @@ -0,0 +1,192 @@ | |||
| <template> | |||
| <Modal | |||
| v-model="isOpen" | |||
| header="Edit Proposal" | |||
| :button-text="isSubmitting ? 'Saving...' : 'Save Changes'" | |||
| :submit-disabled="isSubmitDisabled" | |||
| @submit="submit" | |||
| > | |||
| <div class="space-y-5"> | |||
| <div | |||
| v-if="errorMessage" | |||
| 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 class="font-semibold" v-if="isConflict">Revision Conflict:</span> | |||
| <span>{{ errorMessage }}</span> | |||
| </div> | |||
| </div> | |||
| <div> | |||
| <label for="edit-proposal-title" class="mb-1.5 block text-sm font-medium text-slate-700"> | |||
| Proposal Title | |||
| </label> | |||
| <input | |||
| id="edit-proposal-title" | |||
| v-model="title" | |||
| type="text" | |||
| class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15" | |||
| placeholder="Proposal title" | |||
| :disabled="isSubmitting" | |||
| @keydown.meta.enter="submit" | |||
| @keydown.ctrl.enter="submit" | |||
| /> | |||
| </div> | |||
| <div> | |||
| <label class="mb-1.5 block text-sm font-medium text-slate-700"> | |||
| Proposal Content | |||
| </label> | |||
| <PostBuilder | |||
| :initial-content="initialMarkdown" | |||
| @update:content="assignContent" | |||
| /> | |||
| </div> | |||
| <div> | |||
| <label for="edit-proposal-summary" class="mb-1.5 block text-sm font-medium text-slate-700"> | |||
| Edit Summary <span class="font-normal text-slate-500">(optional)</span> | |||
| </label> | |||
| <input | |||
| id="edit-proposal-summary" | |||
| v-model="summary" | |||
| type="text" | |||
| class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15" | |||
| placeholder="Briefly describe what you changed (e.g., fixed typo in section 2)" | |||
| :disabled="isSubmitting" | |||
| @keydown.meta.enter="submit" | |||
| @keydown.ctrl.enter="submit" | |||
| /> | |||
| <p class="mt-1.5 text-xs text-slate-500"> | |||
| Summarizing your edits helps others track the revision history. | |||
| </p> | |||
| </div> | |||
| </div> | |||
| </Modal> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import { computed, ref, watch } from "vue"; | |||
| import Modal from "./Modal.vue"; | |||
| import PostBuilder from "../PostBuilder.vue"; | |||
| import { ProposalService, type ProposalWithParagraphs } from "../../generated/typescript"; | |||
| import { eventBus } from "../../utils/eventBus.ts"; | |||
| import type { ProposalParagraph } from "../../types/proposal.ts"; | |||
| const props = withDefaults( | |||
| defineProps<{ | |||
| modelValue?: boolean; | |||
| proposal: ProposalWithParagraphs; | |||
| }>(), | |||
| { | |||
| modelValue: true, | |||
| } | |||
| ); | |||
| const emit = defineEmits<{ | |||
| "update:modelValue": [value: boolean]; | |||
| }>(); | |||
| const isOpen = computed({ | |||
| get: () => props.modelValue, | |||
| set: (val: boolean) => emit("update:modelValue", val), | |||
| }); | |||
| const title = ref(props.proposal.name); | |||
| const summary = ref(""); | |||
| const paragraphs = ref<ProposalParagraph[]>(props.proposal.paragraphs || []); | |||
| const isSubmitting = ref(false); | |||
| const errorMessage = ref<string | null>(null); | |||
| const isConflict = ref(false); | |||
| const initialMarkdown = computed(() => { | |||
| return (props.proposal.paragraphs || []) | |||
| .map((p) => p.paragraphText) | |||
| .join("\n\n"); | |||
| }); | |||
| const isSubmitDisabled = computed(() => { | |||
| return ( | |||
| !title.value.trim() || | |||
| !paragraphs.value.some((p) => p.paragraphText.trim().length > 0) || | |||
| isSubmitting.value | |||
| ); | |||
| }); | |||
| const assignContent = (newParagraphs: ProposalParagraph[]) => { | |||
| paragraphs.value = newParagraphs; | |||
| }; | |||
| const resetForm = () => { | |||
| title.value = props.proposal.name; | |||
| summary.value = ""; | |||
| paragraphs.value = props.proposal.paragraphs || []; | |||
| errorMessage.value = null; | |||
| isConflict.value = false; | |||
| }; | |||
| watch( | |||
| () => props.proposal, | |||
| () => { | |||
| resetForm(); | |||
| }, | |||
| { deep: true } | |||
| ); | |||
| watch( | |||
| () => props.modelValue, | |||
| (newVal) => { | |||
| if (newVal) { | |||
| resetForm(); | |||
| } | |||
| } | |||
| ); | |||
| const submit = async () => { | |||
| if (isSubmitDisabled.value) return; | |||
| isSubmitting.value = true; | |||
| errorMessage.value = null; | |||
| isConflict.value = false; | |||
| try { | |||
| const paragraphRequests = paragraphs.value | |||
| .map((p) => p.paragraphText.trim()) | |||
| .filter((p) => p.length > 0) | |||
| .map((p, idx) => ({ | |||
| index: idx, | |||
| paragraphText: p, | |||
| })); | |||
| await ProposalService.editProposal({ | |||
| proposalId: props.proposal.id, | |||
| baseRevision: props.proposal.revision, | |||
| name: title.value.trim(), | |||
| paragraphs: paragraphRequests, | |||
| summary: summary.value.trim() || undefined, | |||
| }); | |||
| eventBus.emit("proposalUpdated", props.proposal.id); | |||
| isOpen.value = false; | |||
| } catch (err: any) { | |||
| console.error("Failed to edit proposal:", err); | |||
| if (err?.status === 409 || err?.statusCode === 409) { | |||
| isConflict.value = true; | |||
| errorMessage.value = | |||
| "This proposal has been modified since you started editing, or is no longer a draft. Your changes have been preserved above so you can copy them."; | |||
| } else { | |||
| errorMessage.value = | |||
| err?.body?.message || err?.message || "Failed to update proposal. Please try again."; | |||
| } | |||
| } finally { | |||
| isSubmitting.value = false; | |||
| } | |||
| }; | |||
| </script> | |||
| <style scoped> | |||
| </style> | |||
| @@ -40,7 +40,7 @@ import { useRoute } from "vue-router"; | |||
| import * as freighter from "@stellar/freighter-api"; | |||
| import Modal from "./Modal.vue"; | |||
| import PostBuilder from "../PostBuilder.vue"; | |||
| import { AmendmentService } from "../../generated/typescript"; | |||
| import { AmendmentService, ProposalService, ProposalState } from "../../generated/typescript"; | |||
| import { useModal } from "../../composables/useModal.ts"; | |||
| import { eventBus } from "../../utils/eventBus.ts"; | |||
| import type { ProposalParagraph } from "../../types/proposal.ts"; | |||
| @@ -86,6 +86,22 @@ const submit = async () => { | |||
| errorMessage.value = null; | |||
| try { | |||
| const isApprovedFromModal = modal.getContent("isApproved")?.value; | |||
| if (isApprovedFromModal === "false") { | |||
| errorMessage.value = "Amendments can only be added once the proposal has reached the Approved state."; | |||
| return; | |||
| } | |||
| try { | |||
| const proposal = await ProposalService.getProposal(proposalId); | |||
| if (proposal?.state && proposal.state !== ProposalState.APPROVED && (proposal.state as any) !== 'APPROVED') { | |||
| errorMessage.value = "Amendments can only be added once the proposal has reached the Approved state."; | |||
| return; | |||
| } | |||
| } catch (fetchErr) { | |||
| console.warn("Could not verify proposal state before submitting amendment:", fetchErr); | |||
| } | |||
| const isAllowed = await freighter.isAllowed(); | |||
| if (!isAllowed) { | |||
| await freighter.setAllowed(); | |||
| @@ -1,24 +1,326 @@ | |||
| <template> | |||
| <Modal header="Register" model-value> | |||
| <slot> | |||
| <div class="my-3"> | |||
| <input type="text" class="w-full border-gray-300 border border-solid py-2 px-2.5" placeholder="Username" v-model="username" /> | |||
| </div> | |||
| <div class="my-3"> | |||
| <input type="text" class="w-full border-gray-300 border border-solid py-2 px-2.5" placeholder="Full name" v-model="fullName" /> | |||
| </div> | |||
| <div class="my-3"> | |||
| <input type="password" class="w-full border-gray-300 border border-solid py-2 px-2.5" placeholder="Password" v-model="password" /> | |||
| <Modal | |||
| v-model="isOpen" | |||
| header="Create Account" | |||
| :button-text="isSubmitting ? 'Registering...' : 'Register'" | |||
| :submit-disabled="isSubmitDisabled" | |||
| @submit="formRef?.requestSubmit()" | |||
| > | |||
| <div class="space-y-5"> | |||
| <!-- Intro Banner --> | |||
| <div class="flex items-start gap-3.5 rounded-xl border border-amber-200/70 bg-gradient-to-r from-amber-50 to-orange-50/60 p-4 text-slate-800 shadow-xs"> | |||
| <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-amber-500/15 text-amber-700"> | |||
| <Identification size="lg" :aria-hidden="true" /> | |||
| </div> | |||
| <div> | |||
| <h3 class="text-sm font-semibold text-slate-900"> | |||
| Join Senara Network | |||
| </h3> | |||
| <p class="mt-0.5 text-xs text-slate-600 leading-relaxed"> | |||
| Create your account with your organization ID to participate in governance, voting, and discussions. | |||
| </p> | |||
| </div> | |||
| </div> | |||
| <!-- Error Alert --> | |||
| <div | |||
| ref="errorAlertRef" | |||
| :class="errorMessage ? 'flex items-start gap-3 rounded-xl border border-rose-200 bg-rose-50/90 p-3.5 text-sm text-rose-800 shadow-xs' : 'sr-only'" | |||
| role="alert" | |||
| aria-live="assertive" | |||
| aria-atomic="true" | |||
| > | |||
| <template v-if="errorMessage"> | |||
| <Cancel size="sm" class="mt-0.5 shrink-0 text-rose-600" :aria-hidden="true" /> | |||
| <div class="flex-1"> | |||
| <p class="font-medium text-rose-900">Registration Failed</p> | |||
| <p class="mt-0.5 text-xs text-rose-700 leading-relaxed">{{ errorMessage }}</p> | |||
| </div> | |||
| </template> | |||
| </div> | |||
| <!-- Success Alert --> | |||
| <div | |||
| :class="successMessage ? 'flex items-start gap-3 rounded-xl border border-emerald-200 bg-emerald-50/90 p-3.5 text-sm text-emerald-800 shadow-xs' : 'sr-only'" | |||
| role="status" | |||
| aria-live="polite" | |||
| aria-atomic="true" | |||
| > | |||
| <template v-if="successMessage"> | |||
| <Confirm size="sm" class="mt-0.5 shrink-0 text-emerald-600" :aria-hidden="true" /> | |||
| <div class="flex-1"> | |||
| <p class="font-medium text-emerald-900">Registration Successful</p> | |||
| <p class="mt-0.5 text-xs text-emerald-700 leading-relaxed">{{ successMessage }}</p> | |||
| </div> | |||
| </template> | |||
| </div> | |||
| <!-- Form Grid --> | |||
| <form ref="formRef" class="space-y-4" @submit.prevent="submit"> | |||
| <button type="submit" class="sr-only" tabindex="-1" aria-hidden="true" :disabled="isSubmitDisabled"> | |||
| Register | |||
| </button> | |||
| <!-- Row 1: Username & Organization ID --> | |||
| <div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> | |||
| <div> | |||
| <div class="mb-1.5 flex items-center justify-between"> | |||
| <label for="reg-username" class="text-xs font-semibold uppercase tracking-wider text-slate-700"> | |||
| Username <span class="text-rose-500">*</span> | |||
| </label> | |||
| </div> | |||
| <input | |||
| id="reg-username" | |||
| v-model="username" | |||
| type="text" | |||
| autocomplete="username" | |||
| required | |||
| aria-required="true" | |||
| aria-describedby="reg-username-help" | |||
| class="w-full rounded-lg border border-slate-300 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-xs placeholder:text-slate-400 transition focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/15 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" | |||
| placeholder="Choose username" | |||
| :disabled="isSubmitting" | |||
| /> | |||
| <p id="reg-username-help" class="mt-1 text-xs text-slate-500"> | |||
| Unique handle for your account | |||
| </p> | |||
| </div> | |||
| <div> | |||
| <div class="mb-1.5 flex items-center justify-between"> | |||
| <label for="reg-org-id" class="text-xs font-semibold uppercase tracking-wider text-slate-700"> | |||
| Organization ID <span class="text-rose-500">*</span> | |||
| </label> | |||
| </div> | |||
| <input | |||
| id="reg-org-id" | |||
| v-model="organizationId" | |||
| type="number" | |||
| min="1" | |||
| step="1" | |||
| required | |||
| aria-required="true" | |||
| aria-describedby="reg-org-help" | |||
| class="w-full rounded-lg border border-slate-300 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-xs placeholder:text-slate-400 transition focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/15 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" | |||
| placeholder="e.g. 1" | |||
| :disabled="isSubmitting" | |||
| /> | |||
| <p id="reg-org-help" class="mt-1 text-xs text-slate-500"> | |||
| Positive numeric organization ID | |||
| </p> | |||
| </div> | |||
| </div> | |||
| <!-- Row 2: Full Name & Email (Optional) --> | |||
| <div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> | |||
| <div> | |||
| <div class="mb-1.5 flex items-center justify-between"> | |||
| <label for="reg-fullname" class="text-xs font-semibold uppercase tracking-wider text-slate-700"> | |||
| Full Name | |||
| </label> | |||
| <span class="rounded bg-slate-100 px-1.5 py-0.5 text-xs font-medium text-slate-600"> | |||
| Optional | |||
| </span> | |||
| </div> | |||
| <input | |||
| id="reg-fullname" | |||
| v-model="fullName" | |||
| type="text" | |||
| autocomplete="name" | |||
| aria-describedby="reg-fullname-help" | |||
| class="w-full rounded-lg border border-slate-300 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-xs placeholder:text-slate-400 transition focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/15 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" | |||
| placeholder="Jane Doe" | |||
| :disabled="isSubmitting" | |||
| /> | |||
| <p id="reg-fullname-help" class="mt-1 text-xs text-slate-500"> | |||
| Display name visible to members | |||
| </p> | |||
| </div> | |||
| <div> | |||
| <div class="mb-1.5 flex items-center justify-between"> | |||
| <label for="reg-email" class="text-xs font-semibold uppercase tracking-wider text-slate-700"> | |||
| </label> | |||
| <span class="rounded bg-slate-100 px-1.5 py-0.5 text-xs font-medium text-slate-600"> | |||
| Optional | |||
| </span> | |||
| </div> | |||
| <input | |||
| id="reg-email" | |||
| v-model="email" | |||
| type="email" | |||
| autocomplete="email" | |||
| aria-describedby="reg-email-help" | |||
| class="w-full rounded-lg border border-slate-300 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-xs placeholder:text-slate-400 transition focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/15 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" | |||
| placeholder="jane@example.com" | |||
| :disabled="isSubmitting" | |||
| /> | |||
| <p id="reg-email-help" class="mt-1 text-xs text-slate-500"> | |||
| For notifications and updates | |||
| </p> | |||
| </div> | |||
| </div> | |||
| <!-- Row 3: Password (Full width) --> | |||
| <div> | |||
| <div class="mb-1.5 flex items-center justify-between"> | |||
| <label for="reg-password" class="text-xs font-semibold uppercase tracking-wider text-slate-700"> | |||
| Password <span class="text-rose-500">*</span> | |||
| </label> | |||
| </div> | |||
| <div class="relative"> | |||
| <input | |||
| id="reg-password" | |||
| v-model="password" | |||
| :type="showPassword ? 'text' : 'password'" | |||
| autocomplete="new-password" | |||
| required | |||
| aria-required="true" | |||
| aria-describedby="reg-password-help" | |||
| class="w-full rounded-lg border border-slate-300 bg-white pl-3.5 pr-10 py-2.5 text-sm text-slate-900 shadow-xs placeholder:text-slate-400 transition focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/15 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" | |||
| placeholder="Enter a secure password" | |||
| :disabled="isSubmitting" | |||
| /> | |||
| <button | |||
| type="button" | |||
| class="absolute inset-y-0 right-0 flex items-center rounded-md pr-3 text-slate-500 transition hover:text-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/50 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:text-slate-400" | |||
| :aria-label="showPassword ? 'Hide password' : 'Show password'" | |||
| :aria-pressed="showPassword" | |||
| aria-controls="reg-password" | |||
| :disabled="isSubmitting" | |||
| @click="showPassword = !showPassword" | |||
| > | |||
| <svg | |||
| v-if="showPassword" | |||
| class="h-4 w-4" | |||
| aria-hidden="true" | |||
| fill="none" | |||
| stroke="currentColor" | |||
| viewBox="0 0 24 24" | |||
| xmlns="http://www.w3.org/2000/svg" | |||
| > | |||
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l18 18" /> | |||
| </svg> | |||
| <svg | |||
| v-else | |||
| class="h-4 w-4" | |||
| aria-hidden="true" | |||
| fill="none" | |||
| stroke="currentColor" | |||
| viewBox="0 0 24 24" | |||
| xmlns="http://www.w3.org/2000/svg" | |||
| > | |||
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> | |||
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /> | |||
| </svg> | |||
| </button> | |||
| </div> | |||
| <p id="reg-password-help" class="mt-1 text-xs text-slate-500"> | |||
| Must not be empty | |||
| </p> | |||
| </div> | |||
| </form> | |||
| </div> | |||
| </slot> | |||
| </Modal> | |||
| </Modal> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| <script setup lang="ts" generic="T extends ContentModal"> | |||
| import { computed, ref, onUnmounted, nextTick } from "vue"; | |||
| import Modal from "./Modal.vue"; | |||
| import { ref } from "vue"; | |||
| import Identification from "../icons/Identification.vue"; | |||
| import Confirm from "../icons/Confirm.vue"; | |||
| import Cancel from "../icons/Cancel.vue"; | |||
| import { AuthService } from "../../generated/typescript"; | |||
| import { ContentModal, useModal } from "../../composables/useModal.ts"; | |||
| import type { RegisterRequest } from "../../generated/typescript"; | |||
| const modal = useModal(); | |||
| const formRef = ref<HTMLFormElement | null>(null); | |||
| const isOpen = ref(modal.getVisibility()); | |||
| const username = ref(""); | |||
| const organizationId = ref<number | string>(""); | |||
| const fullName = ref(""); | |||
| const email = ref(""); | |||
| const password = ref(""); | |||
| const showPassword = ref(false); | |||
| let timeoutId: ReturnType<typeof setTimeout> | undefined; | |||
| let disposed = false; | |||
| const isSubmitting = ref(false); | |||
| const errorMessage = ref<string | null>(null); | |||
| const successMessage = ref<string | null>(null); | |||
| const errorAlertRef = ref<HTMLElement | null>(null); | |||
| onUnmounted(() => { | |||
| disposed = true; | |||
| if (timeoutId !== undefined) { | |||
| clearTimeout(timeoutId); | |||
| timeoutId = undefined; | |||
| } | |||
| }); | |||
| const isSubmitDisabled = computed(() => { | |||
| return ( | |||
| !username.value.trim() || | |||
| !password.value || | |||
| !organizationId.value || | |||
| isNaN(Number(organizationId.value)) || | |||
| Number(organizationId.value) <= 0 || | |||
| isSubmitting.value | |||
| ); | |||
| }); | |||
| const submit = async () => { | |||
| if (isSubmitDisabled.value) return; | |||
| isSubmitting.value = true; | |||
| errorMessage.value = null; | |||
| successMessage.value = null; | |||
| const requestBody: RegisterRequest = { | |||
| username: username.value.trim(), | |||
| password: password.value, | |||
| organizationId: Number(organizationId.value), | |||
| fullName: fullName.value.trim() || null, | |||
| email: email.value.trim() || null, | |||
| }; | |||
| const [username, fullName, password] = [ref(''), ref(''), ref('')]; | |||
| try { | |||
| const user = await AuthService.register(requestBody); | |||
| if (disposed) return; | |||
| successMessage.value = `User ${user.username || requestBody.username} registered successfully!`; | |||
| username.value = ""; | |||
| organizationId.value = ""; | |||
| fullName.value = ""; | |||
| email.value = ""; | |||
| password.value = ""; | |||
| timeoutId = setTimeout(() => { | |||
| timeoutId = undefined; | |||
| if (!disposed) modal.hide(); | |||
| }, 1500); | |||
| } catch (err: any) { | |||
| if (disposed) return; | |||
| console.error("Registration failed:", err); | |||
| if (typeof err?.body === "string") { | |||
| errorMessage.value = err.body; | |||
| } else if (err?.body?.message) { | |||
| errorMessage.value = err.body.message; | |||
| } else if (err?.body?.error) { | |||
| errorMessage.value = err.body.error; | |||
| } else if (err?.body?.detail) { | |||
| errorMessage.value = err.body.detail; | |||
| } else if (err?.message) { | |||
| errorMessage.value = err.message; | |||
| } else { | |||
| errorMessage.value = "Failed to register. Please check your inputs and try again."; | |||
| } | |||
| await nextTick(); | |||
| errorAlertRef.value?.scrollIntoView({ behavior: "smooth", block: "nearest" }); | |||
| } finally { | |||
| if (!disposed) isSubmitting.value = false; | |||
| } | |||
| }; | |||
| </script> | |||
| <style scoped> | |||
| @@ -17,23 +17,36 @@ export type { AmendmentVote } from './models/AmendmentVote'; | |||
| export type { AmendmentWithParagraphs } from './models/AmendmentWithParagraphs'; | |||
| export type { AmendmentWithSummary } from './models/AmendmentWithSummary'; | |||
| export type { Claims } from './models/Claims'; | |||
| export type { CommentReaction } from './models/CommentReaction'; | |||
| export type { CommentReactionSummary } from './models/CommentReactionSummary'; | |||
| export type { CommentWithUser } from './models/CommentWithUser'; | |||
| export type { EditProposalRequest } from './models/EditProposalRequest'; | |||
| export type { FreighterLoginRequest } from './models/FreighterLoginRequest'; | |||
| export type { FreighterLoginResponse } from './models/FreighterLoginResponse'; | |||
| export type { ListCommentsResponse } from './models/ListCommentsResponse'; | |||
| export type { LoginRequest } from './models/LoginRequest'; | |||
| export type { Organization } from './models/Organization'; | |||
| export type { Proposal } from './models/Proposal'; | |||
| export type { ProposalParagraph } from './models/ProposalParagraph'; | |||
| export type { ProposalParagraphRequest } from './models/ProposalParagraphRequest'; | |||
| export type { ProposalRevision } from './models/ProposalRevision'; | |||
| export type { ProposalRevisionBlock } from './models/ProposalRevisionBlock'; | |||
| export type { ProposalSponsor } from './models/ProposalSponsor'; | |||
| export { ProposalState } from './models/ProposalState'; | |||
| export type { ProposalVote } from './models/ProposalVote'; | |||
| export type { ProposalWithParagraphs } from './models/ProposalWithParagraphs'; | |||
| export type { ProposalWithSummary } from './models/ProposalWithSummary'; | |||
| export type { ReactCommentRequest } from './models/ReactCommentRequest'; | |||
| export type { ReactCommentResponse } from './models/ReactCommentResponse'; | |||
| export { ReactionType } from './models/ReactionType'; | |||
| export type { RegisteredUser } from './models/RegisteredUser'; | |||
| export type { RegisterOrganizationRequest } from './models/RegisterOrganizationRequest'; | |||
| export type { RegisterRequest } from './models/RegisterRequest'; | |||
| export type { SelectableAmendment } from './models/SelectableAmendment'; | |||
| export type { SelectableComment } from './models/SelectableComment'; | |||
| export type { SelectableProposal } from './models/SelectableProposal'; | |||
| export type { SetProposalRevisionRequest } from './models/SetProposalRevisionRequest'; | |||
| export type { SponsorProposalRequest } from './models/SponsorProposalRequest'; | |||
| export type { UserAmendmentVote } from './models/UserAmendmentVote'; | |||
| export type { UserProposalVotes } from './models/UserProposalVotes'; | |||
| export type { VoteAmendmentRequest } from './models/VoteAmendmentRequest'; | |||
| @@ -0,0 +1,17 @@ | |||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||
| /* istanbul ignore file */ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| import type { ReactionType } from './ReactionType'; | |||
| /** | |||
| * One entry in a user's reaction history for a comment. Rows are never modified; | |||
| * the latest row is the user's current reaction. | |||
| */ | |||
| export type CommentReaction = { | |||
| commentId: number; | |||
| createdAt: string; | |||
| id: number; | |||
| reaction?: (ReactionType | null); | |||
| userId: number; | |||
| }; | |||
| @@ -0,0 +1,15 @@ | |||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||
| /* istanbul ignore file */ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| import type { ReactionType } from './ReactionType'; | |||
| /** | |||
| * Current reaction totals for a comment, counting only each user's latest reaction | |||
| */ | |||
| export type CommentReactionSummary = { | |||
| commentId: number; | |||
| negative: number; | |||
| positive: number; | |||
| userReaction?: (ReactionType | null); | |||
| }; | |||
| @@ -0,0 +1,22 @@ | |||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||
| /* istanbul ignore file */ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| import type { ProposalParagraphRequest } from './ProposalParagraphRequest'; | |||
| export type EditProposalRequest = { | |||
| /** | |||
| * The revision the edit was made against; must be the proposal's current revision | |||
| */ | |||
| baseRevision: number; | |||
| name: string; | |||
| /** | |||
| * The complete new set of paragraphs, replacing the current ones | |||
| */ | |||
| paragraphs: Array<ProposalParagraphRequest>; | |||
| proposalId: number; | |||
| /** | |||
| * Optional description of the change | |||
| */ | |||
| summary?: string | null; | |||
| }; | |||
| @@ -0,0 +1,10 @@ | |||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||
| /* istanbul ignore file */ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| import type { CommentWithUser } from './CommentWithUser'; | |||
| export type ListCommentsResponse = { | |||
| comments: Array<CommentWithUser>; | |||
| numComments: number; | |||
| }; | |||
| @@ -2,6 +2,9 @@ | |||
| /* istanbul ignore file */ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| /** | |||
| * A block of a proposal's live content, i.e. of its current revision | |||
| */ | |||
| export type ProposalParagraph = { | |||
| id: number; | |||
| index: number; | |||
| @@ -0,0 +1,26 @@ | |||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||
| /* istanbul ignore file */ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| import type { ProposalRevisionBlock } from './ProposalRevisionBlock'; | |||
| /** | |||
| * One revision in a proposal's draft history: a full snapshot of its title and paragraphs. | |||
| * Revisions are never modified. | |||
| */ | |||
| export type ProposalRevision = { | |||
| createdAt: string; | |||
| id: number; | |||
| name: string; | |||
| /** | |||
| * Ordered by index | |||
| */ | |||
| paragraphs: Array<ProposalRevisionBlock>; | |||
| proposalId: number; | |||
| revision: number; | |||
| summary?: string | null; | |||
| /** | |||
| * The user who made the edit | |||
| */ | |||
| userId: number; | |||
| }; | |||
| @@ -0,0 +1,9 @@ | |||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||
| /* istanbul ignore file */ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| export type ProposalRevisionBlock = { | |||
| index: number; | |||
| paragraphText: string; | |||
| }; | |||
| @@ -0,0 +1,17 @@ | |||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||
| /* istanbul ignore file */ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| /** | |||
| * A user's sponsorship of a draft proposal | |||
| */ | |||
| export type ProposalSponsor = { | |||
| createdAt: string; | |||
| id: number; | |||
| proposalId: number; | |||
| /** | |||
| * The sponsoring user | |||
| */ | |||
| userId: number; | |||
| }; | |||
| @@ -0,0 +1,13 @@ | |||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||
| /* istanbul ignore file */ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| export enum ProposalState { | |||
| DRAFT = 'DRAFT', | |||
| PENDING = 'PENDING', | |||
| VOTING = 'VOTING', | |||
| APPROVED = 'APPROVED', | |||
| REJECTED = 'REJECTED', | |||
| NO_QUORUM = 'NO_QUORUM', | |||
| WITHDRAWN = 'WITHDRAWN', | |||
| } | |||
| @@ -3,12 +3,19 @@ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| import type { ProposalParagraph } from './ProposalParagraph'; | |||
| import type { ProposalState } from './ProposalState'; | |||
| export type ProposalWithParagraphs = { | |||
| createdAt: string; | |||
| creator: string; | |||
| id: number; | |||
| name: string; | |||
| paragraphs: Array<ProposalParagraph>; | |||
| /** | |||
| * Current draft revision, which may be older than the latest after a rollback; send as | |||
| * `baseRevision` when editing. 0 if the proposal has no recorded revisions | |||
| */ | |||
| revision: number; | |||
| state: ProposalState; | |||
| updatedAt?: string | null; | |||
| }; | |||
| @@ -2,11 +2,13 @@ | |||
| /* istanbul ignore file */ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| import type { ProposalState } from './ProposalState'; | |||
| export type ProposalWithSummary = { | |||
| createdAt: string; | |||
| creator: string; | |||
| id: number; | |||
| name: string; | |||
| state: ProposalState; | |||
| summary: string; | |||
| updatedAt?: string | null; | |||
| }; | |||
| @@ -0,0 +1,10 @@ | |||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||
| /* istanbul ignore file */ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| import type { ReactionType } from './ReactionType'; | |||
| export type ReactCommentRequest = { | |||
| commentId: number; | |||
| reaction?: (ReactionType | null); | |||
| }; | |||
| @@ -0,0 +1,13 @@ | |||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||
| /* istanbul ignore file */ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| import type { CommentReaction } from './CommentReaction'; | |||
| export type ReactCommentResponse = { | |||
| reaction?: (CommentReaction | null); | |||
| /** | |||
| * False if the reaction was unchanged from the user's current one, in which case nothing was recorded | |||
| */ | |||
| recorded: boolean; | |||
| }; | |||
| @@ -0,0 +1,8 @@ | |||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||
| /* istanbul ignore file */ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| export enum ReactionType { | |||
| POSITIVE = 'positive', | |||
| NEGATIVE = 'negative', | |||
| } | |||
| @@ -2,12 +2,18 @@ | |||
| /* istanbul ignore file */ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| import type { ProposalState } from './ProposalState'; | |||
| export type SelectableProposal = { | |||
| createdAt: string; | |||
| creator: string; | |||
| /** | |||
| * Revision whose snapshot is the live content; None if the proposal has no recorded revisions | |||
| */ | |||
| currentRevision?: number | null; | |||
| id: number; | |||
| name: string; | |||
| organizationId: number; | |||
| state: ProposalState; | |||
| updatedAt?: string | null; | |||
| }; | |||
| @@ -0,0 +1,16 @@ | |||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||
| /* istanbul ignore file */ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| export type SetProposalRevisionRequest = { | |||
| /** | |||
| * The revision the change was made against; must be the proposal's current revision | |||
| */ | |||
| baseRevision: number; | |||
| proposalId: number; | |||
| /** | |||
| * The existing revision to make current | |||
| */ | |||
| revision: number; | |||
| }; | |||
| @@ -0,0 +1,8 @@ | |||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||
| /* istanbul ignore file */ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| export type SponsorProposalRequest = { | |||
| proposalId: number; | |||
| }; | |||
| @@ -27,6 +27,9 @@ export class AmendmentService { | |||
| mediaType: 'application/json', | |||
| errors: { | |||
| 400: `Bad request`, | |||
| 401: `Missing, invalid, or expired token`, | |||
| 404: `Proposal not found in the current user's organization`, | |||
| 409: `Proposal is not in the \`APPROVED\` state`, | |||
| 500: `Internal server error`, | |||
| }, | |||
| }); | |||
| @@ -3,7 +3,11 @@ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| import type { AddCommentRequest } from '../models/AddCommentRequest'; | |||
| import type { CommentReactionSummary } from '../models/CommentReactionSummary'; | |||
| import type { CommentWithUser } from '../models/CommentWithUser'; | |||
| import type { ListCommentsResponse } from '../models/ListCommentsResponse'; | |||
| import type { ReactCommentRequest } from '../models/ReactCommentRequest'; | |||
| import type { ReactCommentResponse } from '../models/ReactCommentResponse'; | |||
| import type { SelectableComment } from '../models/SelectableComment'; | |||
| import type { CancelablePromise } from '../core/CancelablePromise'; | |||
| import { OpenAPI } from '../core/OpenAPI'; | |||
| @@ -50,19 +54,68 @@ export class CommentService { | |||
| }, | |||
| }); | |||
| } | |||
| /** | |||
| * @param requestBody | |||
| * @returns ReactCommentResponse Reaction result; `recorded` is false if the reaction was unchanged | |||
| * @throws ApiError | |||
| */ | |||
| public static reactComment( | |||
| requestBody: ReactCommentRequest, | |||
| ): CancelablePromise<ReactCommentResponse> { | |||
| return __request(OpenAPI, { | |||
| method: 'POST', | |||
| url: '/api/v1/comment/react', | |||
| body: requestBody, | |||
| mediaType: 'application/json', | |||
| errors: { | |||
| 400: `Bad request`, | |||
| 401: `Missing, invalid, or expired token`, | |||
| 404: `Comment not found in the current user's organization`, | |||
| 500: `Internal server error`, | |||
| }, | |||
| }); | |||
| } | |||
| /** | |||
| * @param commentId | |||
| * @returns CommentReactionSummary Current reaction totals and the current user's reaction | |||
| * @throws ApiError | |||
| */ | |||
| public static getCommentReactions( | |||
| commentId: number, | |||
| ): CancelablePromise<CommentReactionSummary> { | |||
| return __request(OpenAPI, { | |||
| method: 'GET', | |||
| url: '/api/v1/comment/reactions', | |||
| query: { | |||
| 'commentId': commentId, | |||
| }, | |||
| errors: { | |||
| 400: `Bad request`, | |||
| 401: `Missing, invalid, or expired token`, | |||
| 404: `Comment not found`, | |||
| 500: `Internal server error`, | |||
| }, | |||
| }); | |||
| } | |||
| /** | |||
| * @param proposalId | |||
| * @returns CommentWithUser List of comments for a proposal | |||
| * @param offset Number of comments to skip. Defaults to 0. | |||
| * @param limit Maximum number of comments to return (1–40). Defaults to 40. | |||
| * @returns ListCommentsResponse A page of comments for a proposal; `numComments` is the total across all pages | |||
| * @throws ApiError | |||
| */ | |||
| public static listComments( | |||
| proposalId: number, | |||
| ): CancelablePromise<Array<CommentWithUser>> { | |||
| offset?: number | null, | |||
| limit?: number | null, | |||
| ): CancelablePromise<ListCommentsResponse> { | |||
| return __request(OpenAPI, { | |||
| method: 'GET', | |||
| url: '/api/v1/comments/list', | |||
| query: { | |||
| 'proposalId': proposalId, | |||
| 'offset': offset, | |||
| 'limit': limit, | |||
| }, | |||
| errors: { | |||
| 400: `Bad request`, | |||
| @@ -3,8 +3,13 @@ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| import type { AddProposalRequest } from '../models/AddProposalRequest'; | |||
| import type { EditProposalRequest } from '../models/EditProposalRequest'; | |||
| import type { ProposalRevision } from '../models/ProposalRevision'; | |||
| import type { ProposalSponsor } from '../models/ProposalSponsor'; | |||
| import type { ProposalVote } from '../models/ProposalVote'; | |||
| import type { ProposalWithParagraphs } from '../models/ProposalWithParagraphs'; | |||
| import type { SetProposalRevisionRequest } from '../models/SetProposalRevisionRequest'; | |||
| import type { SponsorProposalRequest } from '../models/SponsorProposalRequest'; | |||
| import type { UserProposalVotes } from '../models/UserProposalVotes'; | |||
| import type { VoteProposalRequest } from '../models/VoteProposalRequest'; | |||
| import type { CancelablePromise } from '../core/CancelablePromise'; | |||
| @@ -51,6 +56,50 @@ export class ProposalService { | |||
| }, | |||
| }); | |||
| } | |||
| /** | |||
| * @param requestBody | |||
| * @returns ProposalRevision The recorded revision | |||
| * @throws ApiError | |||
| */ | |||
| public static editProposal( | |||
| requestBody: EditProposalRequest, | |||
| ): CancelablePromise<ProposalRevision> { | |||
| return __request(OpenAPI, { | |||
| method: 'POST', | |||
| url: '/api/v1/proposal/edit', | |||
| body: requestBody, | |||
| mediaType: 'application/json', | |||
| errors: { | |||
| 400: `Bad request`, | |||
| 401: `Missing, invalid, or expired token`, | |||
| 404: `Proposal not found in the current user's organization`, | |||
| 409: `Proposal is not a draft, or has been edited since \`baseRevision\``, | |||
| 500: `Internal server error`, | |||
| }, | |||
| }); | |||
| } | |||
| /** | |||
| * @param proposalId | |||
| * @returns ProposalRevision The proposal's draft revision history, newest first | |||
| * @throws ApiError | |||
| */ | |||
| public static listProposalEdits( | |||
| proposalId: number, | |||
| ): CancelablePromise<Array<ProposalRevision>> { | |||
| return __request(OpenAPI, { | |||
| method: 'GET', | |||
| url: '/api/v1/proposal/edits', | |||
| query: { | |||
| 'proposalId': proposalId, | |||
| }, | |||
| errors: { | |||
| 400: `Bad request`, | |||
| 401: `Missing, invalid, or expired token`, | |||
| 404: `Proposal not found`, | |||
| 500: `Internal server error`, | |||
| }, | |||
| }); | |||
| } | |||
| /** | |||
| * @param id | |||
| * @returns any Proposal retrieved successfully | |||
| @@ -73,6 +122,96 @@ export class ProposalService { | |||
| }, | |||
| }); | |||
| } | |||
| /** | |||
| * @param requestBody | |||
| * @returns ProposalWithParagraphs The proposal with the selected revision's content applied | |||
| * @throws ApiError | |||
| */ | |||
| public static setProposalRevision( | |||
| requestBody: SetProposalRevisionRequest, | |||
| ): CancelablePromise<ProposalWithParagraphs> { | |||
| return __request(OpenAPI, { | |||
| method: 'POST', | |||
| url: '/api/v1/proposal/revision', | |||
| body: requestBody, | |||
| mediaType: 'application/json', | |||
| errors: { | |||
| 400: `Bad request`, | |||
| 401: `Missing, invalid, or expired token`, | |||
| 403: `Current user is not the proposal's original author`, | |||
| 404: `Proposal or revision not found`, | |||
| 409: `Proposal is not a draft, or has been edited since \`baseRevision\``, | |||
| 500: `Internal server error`, | |||
| }, | |||
| }); | |||
| } | |||
| /** | |||
| * @param requestBody | |||
| * @returns ProposalSponsor The recorded sponsorship | |||
| * @throws ApiError | |||
| */ | |||
| public static sponsorProposal( | |||
| requestBody: SponsorProposalRequest, | |||
| ): CancelablePromise<ProposalSponsor> { | |||
| return __request(OpenAPI, { | |||
| method: 'POST', | |||
| url: '/api/v1/proposal/sponsor', | |||
| body: requestBody, | |||
| mediaType: 'application/json', | |||
| errors: { | |||
| 400: `Bad request`, | |||
| 401: `Missing, invalid, or expired token`, | |||
| 403: `Current user is not an active member of the organization`, | |||
| 404: `Proposal not found in the current user's organization`, | |||
| 409: `User has already sponsored this proposal, or the proposal is not in the \`DRAFT\` state`, | |||
| 500: `Internal server error`, | |||
| }, | |||
| }); | |||
| } | |||
| /** | |||
| * @param proposalId | |||
| * @returns ProposalSponsor The proposal's sponsors, oldest first | |||
| * @throws ApiError | |||
| */ | |||
| public static listProposalSponsors( | |||
| proposalId: number, | |||
| ): CancelablePromise<Array<ProposalSponsor>> { | |||
| return __request(OpenAPI, { | |||
| method: 'GET', | |||
| url: '/api/v1/proposal/sponsors', | |||
| query: { | |||
| 'proposalId': proposalId, | |||
| }, | |||
| errors: { | |||
| 400: `Bad request`, | |||
| 401: `Missing, invalid, or expired token`, | |||
| 404: `Proposal not found`, | |||
| 500: `Internal server error`, | |||
| }, | |||
| }); | |||
| } | |||
| /** | |||
| * @param requestBody | |||
| * @returns any Sponsorship withdrawn | |||
| * @throws ApiError | |||
| */ | |||
| public static unsponsorProposal( | |||
| requestBody: SponsorProposalRequest, | |||
| ): CancelablePromise<any> { | |||
| return __request(OpenAPI, { | |||
| method: 'POST', | |||
| url: '/api/v1/proposal/unsponsor', | |||
| body: requestBody, | |||
| mediaType: 'application/json', | |||
| errors: { | |||
| 400: `Bad request`, | |||
| 401: `Missing, invalid, or expired token`, | |||
| 404: `Proposal not found in the current user's organization, or the user has not sponsored it`, | |||
| 409: `Proposal is not in the \`DRAFT\` state`, | |||
| 500: `Internal server error`, | |||
| }, | |||
| }); | |||
| } | |||
| /** | |||
| * @param requestBody | |||
| * @returns ProposalVote The recorded vote | |||
| @@ -90,7 +229,7 @@ export class ProposalService { | |||
| 400: `Bad request`, | |||
| 401: `Missing, invalid, or expired token`, | |||
| 404: `Proposal not found in the current user's organization`, | |||
| 409: `User has already voted on this proposal`, | |||
| 409: `User has already voted on this proposal, or the proposal is not in the \`VOTING\` state`, | |||
| 500: `Internal server error`, | |||
| }, | |||
| }); | |||
| @@ -16,7 +16,7 @@ | |||
| ></div> | |||
| <!-- Main Content --> | |||
| <main class="h-full flex-1 overflow-y-auto bg-slate-100"> | |||
| <main class="h-full min-w-0 flex-1 bg-slate-100"> | |||
| <RouterView /> | |||
| </main> | |||
| </div> | |||
| @@ -54,11 +54,20 @@ const isSidebarOpen = ref(false); | |||
| const [offset, limit] = [ref(0), ref(10)]; | |||
| showLoading('proposals'); | |||
| const fetchProposals = async () => { | |||
| showLoading('proposals'); | |||
| try { | |||
| proposals.value = await ProposalService.listProposals(offset.value, limit.value); | |||
| } catch (err) { | |||
| console.error('Failed to list proposals', err); | |||
| } finally { | |||
| hideLoading('proposals'); | |||
| } | |||
| }; | |||
| onMounted(async () => { | |||
| proposals.value = await ProposalService.listProposals(offset.value, limit.value); | |||
| hideLoading('proposals'); | |||
| await fetchProposals(); | |||
| eventBus.on('proposalUpdated', fetchProposals); | |||
| }); | |||
| watch(() => route.path, () => { | |||
| @@ -68,5 +77,6 @@ watch(() => route.path, () => { | |||
| onUnmounted(() => { | |||
| unregister('proposals'); | |||
| eventBus.off('filtersApplied'); | |||
| eventBus.off('proposalUpdated'); | |||
| }); | |||
| </script> | |||
| @@ -17,9 +17,10 @@ | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import { ref, watch, onUnmounted } from 'vue'; | |||
| import { ref, watch, onMounted, onUnmounted } from 'vue'; | |||
| import { ProposalService, ProposalWithParagraphs } from '../generated/typescript'; | |||
| import { useLoading } from '../composables/useLoading'; | |||
| import { eventBus } from '../utils/eventBus'; | |||
| import ProposalDetail from '../components/ProposalDetail.vue'; | |||
| import ProposalEmptyState from '../components/ProposalEmptyState.vue'; | |||
| import LoadingSpinner from '../components/LoadingSpinner.vue'; | |||
| @@ -52,6 +53,13 @@ const fetchProposal = async (id: number | string) => { | |||
| } | |||
| }; | |||
| const handleProposalUpdated = (updatedId?: number) => { | |||
| const currentNumericId = Number(props.id); | |||
| if (!updatedId || updatedId === currentNumericId) { | |||
| fetchProposal(props.id); | |||
| } | |||
| }; | |||
| watch( | |||
| () => props.id, | |||
| (newId) => { | |||
| @@ -62,7 +70,12 @@ watch( | |||
| { immediate: true } | |||
| ); | |||
| onMounted(() => { | |||
| eventBus.on('proposalUpdated', handleProposalUpdated); | |||
| }); | |||
| onUnmounted(() => { | |||
| unregister('content'); | |||
| eventBus.off('proposalUpdated'); | |||
| }); | |||
| </script> | |||