| @@ -15,75 +15,87 @@ | |||||
| class="flex gap-3 overflow-x-auto scroll-smooth px-1 py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" | class="flex gap-3 overflow-x-auto scroll-smooth px-1 py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" | ||||
| @scroll.passive="updateEdges" | @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> | |||||
| <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> | </p> | ||||
| </article> | |||||
| </div> | |||||
| </div> | </div> | ||||
| <button | <button | ||||
| @@ -113,10 +125,16 @@ import { getUserIdFromJwt } from "../utils/auth.ts"; | |||||
| import { ContentModal, useModal } from "../composables/useModal.ts"; | import { ContentModal, useModal } from "../composables/useModal.ts"; | ||||
| import { eventBus } from "../utils/eventBus.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<{ | const emit = defineEmits<{ | ||||
| (e: 'voted'): void; | (e: 'voted'): void; | ||||
| }>(); | }>(); | ||||
| @@ -139,7 +157,7 @@ const { show: showModal, setContent, addContent } = useModal(); | |||||
| const updateEdges = () => { | const updateEdges = () => { | ||||
| const el = scroller.value; | const el = scroller.value; | ||||
| if (!el) { | |||||
| if (!el || !props.isApproved) { | |||||
| canScrollLeft.value = false; | canScrollLeft.value = false; | ||||
| canScrollRight.value = false; | canScrollRight.value = false; | ||||
| return; | return; | ||||
| @@ -194,8 +212,10 @@ const showAmendment = async (id: number) => { | |||||
| }; | }; | ||||
| const openNewAmendmentModal = () => { | const openNewAmendmentModal = () => { | ||||
| if (props.isApproved === false) return; | |||||
| if (props.proposalId) { | if (props.proposalId) { | ||||
| setContent("proposalId", props.proposalId); | setContent("proposalId", props.proposalId); | ||||
| setContent("isApproved", props.isApproved ? "true" : "false"); | |||||
| } | } | ||||
| showModal(NewAmendmentModal as unknown as ContentModal); | showModal(NewAmendmentModal as unknown as ContentModal); | ||||
| }; | }; | ||||
| @@ -260,6 +280,14 @@ watch(proposalId, async (id) => { | |||||
| if (scroller.value) scroller.value.scrollLeft = 0; | if (scroller.value) scroller.value.scrollLeft = 0; | ||||
| await getAmendments(); | await getAmendments(); | ||||
| }); | }); | ||||
| watch( | |||||
| () => props.isApproved, | |||||
| async () => { | |||||
| await nextTick(); | |||||
| updateEdges(); | |||||
| } | |||||
| ); | |||||
| </script> | </script> | ||||
| <style lang="postcss" scoped> | <style lang="postcss" scoped> | ||||
| @@ -6,9 +6,16 @@ | |||||
| <ColoredHeader text="Senara" from="#F2F3E2" to="#B2E5F8" /> | <ColoredHeader text="Senara" from="#F2F3E2" to="#B2E5F8" /> | ||||
| </RouterLink> | </RouterLink> | ||||
| </span> | </span> | ||||
| <div class="flex flex-col justify-center items-center"> | |||||
| <div class="flex flex-row gap-2 justify-center items-center"> | |||||
| <button | <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" | type="button" | ||||
| :disabled="isConnecting" | :disabled="isConnecting" | ||||
| @click="connectOrDisplayUser"> | @click="connectOrDisplayUser"> | ||||
| @@ -22,8 +29,11 @@ | |||||
| import ColoredHeader from "./ColoredHeader.vue"; | import ColoredHeader from "./ColoredHeader.vue"; | ||||
| import { computed } from "vue"; | import { computed } from "vue"; | ||||
| import { useSession } from "../composables/useSession"; | 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 { isAuthenticated, truncatedAddress, isConnecting, connectWallet } = useSession(); | ||||
| const { show: showModal } = useModal(); | |||||
| const buttonText = computed(() => { | const buttonText = computed(() => { | ||||
| if (isConnecting.value) return "Connecting..."; | if (isConnecting.value) return "Connecting..."; | ||||
| @@ -36,6 +46,10 @@ const connectOrDisplayUser = async () => { | |||||
| await connectWallet(); | await connectWallet(); | ||||
| } | } | ||||
| }; | }; | ||||
| const openRegistrationModal = () => { | |||||
| showModal(RegistrationModal as ContentModal); | |||||
| }; | |||||
| </script> | </script> | ||||
| <style scoped> | <style scoped> | ||||
| @@ -6,7 +6,7 @@ | |||||
| </template> | </template> | ||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| const props = defineProps<{ text: string }>(); | |||||
| defineProps<{ text: string }>(); | |||||
| </script> | </script> | ||||
| <style scoped> | <style scoped> | ||||
| @@ -25,7 +25,44 @@ | |||||
| {{ node.comment.content }} | {{ node.comment.content }} | ||||
| </p> | </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 | <button | ||||
| v-if="!isReplying" | v-if="!isReplying" | ||||
| type="button" | type="button" | ||||
| @@ -36,6 +73,22 @@ | |||||
| </button> | </button> | ||||
| </div> | </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 --> | <!-- Inline Reply Form --> | ||||
| <form | <form | ||||
| v-if="isReplying" | v-if="isReplying" | ||||
| @@ -97,9 +150,11 @@ | |||||
| </template> | </template> | ||||
| <script setup lang="ts"> | <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 {CommentNode} from "../utils/comments.ts"; | ||||
| import ThumbsUp from "./icons/ThumbsUp.vue"; | |||||
| import ThumbsDown from "./icons/ThumbsDown.vue"; | |||||
| const props = defineProps<{ | const props = defineProps<{ | ||||
| node: CommentNode; | node: CommentNode; | ||||
| @@ -113,6 +168,92 @@ const isSubmitting = ref(false); | |||||
| const replyError = ref<string | null>(null); | const replyError = ref<string | null>(null); | ||||
| const replyInput = ref<HTMLTextAreaElement | 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. | // Visual nesting levels are 1-based; nestingLevel is 0 for top-level comments. | ||||
| const MAX_NESTING_LEVEL = 3; | const MAX_NESTING_LEVEL = 3; | ||||
| @@ -7,7 +7,7 @@ | |||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| import { DropdownOption } from "../types/dropdown.ts"; | import { DropdownOption } from "../types/dropdown.ts"; | ||||
| const props = defineProps<{ emptyValue: string, options: DropdownOption[], expanded?: boolean }>() | |||||
| defineProps<{ emptyValue: string, options: DropdownOption[], expanded?: boolean }>() | |||||
| </script> | </script> | ||||
| <style scoped> | <style scoped> | ||||
| @@ -5,7 +5,7 @@ | |||||
| </template> | </template> | ||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| const props = defineProps<{ identifier: string }>(); | |||||
| defineProps<{ identifier: string }>(); | |||||
| </script> | </script> | ||||
| @@ -21,9 +21,14 @@ | |||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| import {Editor, EditorContent} from '@tiptap/vue-3' | import {Editor, EditorContent} from '@tiptap/vue-3' | ||||
| import StarterKit from '@tiptap/starter-kit' | import StarterKit from '@tiptap/starter-kit' | ||||
| import {onBeforeUnmount} from 'vue' | |||||
| import {onBeforeUnmount, onMounted, watch} from 'vue' | |||||
| import TurndownService from 'turndown'; | import TurndownService from 'turndown'; | ||||
| import {ProposalParagraph} from "../types/proposal.ts"; | import {ProposalParagraph} from "../types/proposal.ts"; | ||||
| import {renderMarkdown} from "../utils/markdown.ts"; | |||||
| const props = defineProps<{ | |||||
| initialContent?: string; | |||||
| }>(); | |||||
| const turndownService = new TurndownService(); | const turndownService = new TurndownService(); | ||||
| @@ -31,24 +36,46 @@ const emit = defineEmits<{ | |||||
| 'update:content': [content: ProposalParagraph[]], | '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({ | const editor = new Editor({ | ||||
| extensions: [ | extensions: [ | ||||
| StarterKit, | StarterKit, | ||||
| ], | ], | ||||
| content: '', | |||||
| content: props.initialContent ? renderMarkdown(props.initialContent) : '', | |||||
| editorProps: { | editorProps: { | ||||
| attributes: { | attributes: { | ||||
| class: 'prose prose-sm focus:outline-none max-w-none', | class: 'prose prose-sm focus:outline-none max-w-none', | ||||
| }, | }, | ||||
| }, | }, | ||||
| onUpdate: ({ editor }) => { | 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) => { | const convertEditorContentToMarkdown = (htmlContent: string) => { | ||||
| return turndownService.turndown(htmlContent); | return turndownService.turndown(htmlContent); | ||||
| }; | }; | ||||
| @@ -6,24 +6,70 @@ | |||||
| <div class="grid gap-8 xl:grid-cols-[minmax(0,1fr)_15rem] xl:items-start"> | <div class="grid gap-8 xl:grid-cols-[minmax(0,1fr)_15rem] xl:items-start"> | ||||
| <!-- Primary reading column --> | <!-- Primary reading column --> | ||||
| <div class="min-w-0"> | <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> | </div> | ||||
| <h1 | <h1 | ||||
| ref="titleEl" | |||||
| :id="`proposal-${proposal.id}-title`" | :id="`proposal-${proposal.id}-title`" | ||||
| class="max-w-4xl font-header text-4xl font-bold leading-[0.95] | class="max-w-4xl font-header text-4xl font-bold leading-[0.95] | ||||
| tracking-tight text-slate-950 sm:text-5xl" | tracking-tight text-slate-950 sm:text-5xl" | ||||
| @@ -50,164 +96,286 @@ | |||||
| </div> | </div> | ||||
| </header> | </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 | <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> | </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> | </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> | </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> | ||||
| <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> | </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" | :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> | ||||
| </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> | ||||
| </div> | |||||
| </section> | |||||
| </section> | |||||
| </div> | |||||
| </div> | </div> | ||||
| <!-- Voting action rail --> | <!-- Voting action rail --> | ||||
| <aside | <aside | ||||
| class="xl:sticky xl:top-6" | class="xl:sticky xl:top-6" | ||||
| aria-labelledby="vote-heading" | |||||
| aria-label="Proposal actions" | |||||
| > | > | ||||
| <ProposalVotingCard | <ProposalVotingCard | ||||
| v-if="isVoting" | |||||
| :proposal-id="proposal.id" | :proposal-id="proposal.id" | ||||
| :current-vote="userVotes?.proposalVote?.choice" | :current-vote="userVotes?.proposalVote?.choice" | ||||
| @vote="recordVote" | @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> | </aside> | ||||
| </div> | </div> | ||||
| <EditProposalModal | |||||
| v-if="isEditModalOpen" | |||||
| v-model="isEditModalOpen" | |||||
| :proposal="proposal" | |||||
| /> | |||||
| </article> | </article> | ||||
| </template> | </template> | ||||
| <script setup lang="ts"> | <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 { | import { | ||||
| CommentService, | CommentService, | ||||
| type CommentWithUser, | type CommentWithUser, | ||||
| ProposalService, | ProposalService, | ||||
| ProposalState, | |||||
| ProposalWithParagraphs, | ProposalWithParagraphs, | ||||
| UserProposalVotes, | UserProposalVotes, | ||||
| VoteChoice, | VoteChoice, | ||||
| @@ -217,8 +385,11 @@ import {renderMarkdown} from "../utils/markdown.ts"; | |||||
| import Identification from "./icons/Identification.vue"; | import Identification from "./icons/Identification.vue"; | ||||
| import AmendmentCarousel from "./AmendmentCarousel.vue"; | import AmendmentCarousel from "./AmendmentCarousel.vue"; | ||||
| import ProposalVotingCard from "./ProposalVotingCard.vue"; | import ProposalVotingCard from "./ProposalVotingCard.vue"; | ||||
| import ProposalSponsorCard from "./ProposalSponsorCard.vue"; | |||||
| import ProposalExportCard from "./ProposalExportCard.vue"; | import ProposalExportCard from "./ProposalExportCard.vue"; | ||||
| import EditProposalModal from "./modals/EditProposalModal.vue"; | |||||
| import CommentThread from "./CommentThread.vue"; | import CommentThread from "./CommentThread.vue"; | ||||
| import ProposalRevisionDropdown from "./ProposalRevisionDropdown.vue"; | |||||
| import {getUserIdFromJwt} from "../utils/auth.ts"; | import {getUserIdFromJwt} from "../utils/auth.ts"; | ||||
| import {buildCommentTree} from "../utils/comments.ts"; | import {buildCommentTree} from "../utils/comments.ts"; | ||||
| @@ -226,10 +397,60 @@ const props = defineProps<{ | |||||
| proposal: ProposalWithParagraphs; | 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 commentText = ref(""); | ||||
| const isSubmitting = ref(false); | const isSubmitting = ref(false); | ||||
| const commentError = ref<string | null>(null); | const commentError = ref<string | null>(null); | ||||
| const COMMENTS_PAGE_SIZE = 40; | |||||
| const comments = ref<CommentWithUser[]>([]); | const comments = ref<CommentWithUser[]>([]); | ||||
| const numComments = ref(0); | |||||
| const isLoadingComments = ref(false); | const isLoadingComments = ref(false); | ||||
| const userVotes = ref<UserProposalVotes | null>(null); | const userVotes = ref<UserProposalVotes | null>(null); | ||||
| @@ -277,18 +498,61 @@ const handleCancel = () => { | |||||
| commentError.value = null; | 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)) { | if (proposalId === undefined || proposalId === null || isNaN(proposalId)) { | ||||
| comments.value = []; | comments.value = []; | ||||
| numComments.value = 0; | |||||
| return; | return; | ||||
| } | } | ||||
| isLoadingComments.value = true; | isLoadingComments.value = true; | ||||
| try { | 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) { | } catch (err) { | ||||
| console.error("Failed to load comments:", err); | console.error("Failed to load comments:", err); | ||||
| comments.value = []; | 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 { | } finally { | ||||
| isLoadingComments.value = false; | isLoadingComments.value = false; | ||||
| } | } | ||||
| @@ -302,7 +566,7 @@ const postComment = async (parentId: number | null, content: string) => { | |||||
| parentId, | parentId, | ||||
| userId: 1, | userId: 1, | ||||
| }); | }); | ||||
| await fetchComments(props.proposal.id); | |||||
| await fetchComments(props.proposal.id, comments.value.length + 1); | |||||
| }; | }; | ||||
| const submitComment = async () => { | const submitComment = async () => { | ||||
| @@ -336,11 +600,13 @@ onMounted(() => { | |||||
| watch( | watch( | ||||
| () => props.proposal?.id, | () => props.proposal?.id, | ||||
| (newId) => { | (newId) => { | ||||
| activeTab.value = 'proposal'; | |||||
| if (newId) { | if (newId) { | ||||
| fetchComments(newId); | fetchComments(newId); | ||||
| fetchUserVotes(newId); | fetchUserVotes(newId); | ||||
| } else { | } else { | ||||
| comments.value = []; | comments.value = []; | ||||
| numComments.value = 0; | |||||
| userVotes.value = null; | 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"> | <script setup lang="ts"> | ||||
| import { CommentWithChildren } from "../types/comment.ts"; | import { CommentWithChildren } from "../types/comment.ts"; | ||||
| const props = defineProps<{ starterComment: CommentWithChildren }>(); | |||||
| defineProps<{ starterComment: CommentWithChildren }>(); | |||||
| </script> | </script> | ||||
| <style scoped> | <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 * as freighter from "@stellar/freighter-api"; | ||||
| import Modal from "./Modal.vue"; | import Modal from "./Modal.vue"; | ||||
| import PostBuilder from "../PostBuilder.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 { useModal } from "../../composables/useModal.ts"; | ||||
| import { eventBus } from "../../utils/eventBus.ts"; | import { eventBus } from "../../utils/eventBus.ts"; | ||||
| import type { ProposalParagraph } from "../../types/proposal.ts"; | import type { ProposalParagraph } from "../../types/proposal.ts"; | ||||
| @@ -86,6 +86,22 @@ const submit = async () => { | |||||
| errorMessage.value = null; | errorMessage.value = null; | ||||
| try { | 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(); | const isAllowed = await freighter.isAllowed(); | ||||
| if (!isAllowed) { | if (!isAllowed) { | ||||
| await freighter.setAllowed(); | await freighter.setAllowed(); | ||||
| @@ -1,24 +1,326 @@ | |||||
| <template> | <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> | </div> | ||||
| </slot> | |||||
| </Modal> | |||||
| </Modal> | |||||
| </template> | </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 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> | </script> | ||||
| <style scoped> | <style scoped> | ||||
| @@ -17,23 +17,36 @@ export type { AmendmentVote } from './models/AmendmentVote'; | |||||
| export type { AmendmentWithParagraphs } from './models/AmendmentWithParagraphs'; | export type { AmendmentWithParagraphs } from './models/AmendmentWithParagraphs'; | ||||
| export type { AmendmentWithSummary } from './models/AmendmentWithSummary'; | export type { AmendmentWithSummary } from './models/AmendmentWithSummary'; | ||||
| export type { Claims } from './models/Claims'; | 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 { CommentWithUser } from './models/CommentWithUser'; | ||||
| export type { EditProposalRequest } from './models/EditProposalRequest'; | |||||
| export type { FreighterLoginRequest } from './models/FreighterLoginRequest'; | export type { FreighterLoginRequest } from './models/FreighterLoginRequest'; | ||||
| export type { FreighterLoginResponse } from './models/FreighterLoginResponse'; | export type { FreighterLoginResponse } from './models/FreighterLoginResponse'; | ||||
| export type { ListCommentsResponse } from './models/ListCommentsResponse'; | |||||
| export type { LoginRequest } from './models/LoginRequest'; | export type { LoginRequest } from './models/LoginRequest'; | ||||
| export type { Organization } from './models/Organization'; | export type { Organization } from './models/Organization'; | ||||
| export type { Proposal } from './models/Proposal'; | export type { Proposal } from './models/Proposal'; | ||||
| export type { ProposalParagraph } from './models/ProposalParagraph'; | export type { ProposalParagraph } from './models/ProposalParagraph'; | ||||
| export type { ProposalParagraphRequest } from './models/ProposalParagraphRequest'; | 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 { ProposalVote } from './models/ProposalVote'; | ||||
| export type { ProposalWithParagraphs } from './models/ProposalWithParagraphs'; | export type { ProposalWithParagraphs } from './models/ProposalWithParagraphs'; | ||||
| export type { ProposalWithSummary } from './models/ProposalWithSummary'; | 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 { RegisteredUser } from './models/RegisteredUser'; | ||||
| export type { RegisterOrganizationRequest } from './models/RegisterOrganizationRequest'; | export type { RegisterOrganizationRequest } from './models/RegisterOrganizationRequest'; | ||||
| export type { RegisterRequest } from './models/RegisterRequest'; | export type { RegisterRequest } from './models/RegisterRequest'; | ||||
| export type { SelectableAmendment } from './models/SelectableAmendment'; | export type { SelectableAmendment } from './models/SelectableAmendment'; | ||||
| export type { SelectableComment } from './models/SelectableComment'; | export type { SelectableComment } from './models/SelectableComment'; | ||||
| export type { SelectableProposal } from './models/SelectableProposal'; | 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 { UserAmendmentVote } from './models/UserAmendmentVote'; | ||||
| export type { UserProposalVotes } from './models/UserProposalVotes'; | export type { UserProposalVotes } from './models/UserProposalVotes'; | ||||
| export type { VoteAmendmentRequest } from './models/VoteAmendmentRequest'; | 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 */ | /* istanbul ignore file */ | ||||
| /* tslint:disable */ | /* tslint:disable */ | ||||
| /* eslint-disable */ | /* eslint-disable */ | ||||
| /** | |||||
| * A block of a proposal's live content, i.e. of its current revision | |||||
| */ | |||||
| export type ProposalParagraph = { | export type ProposalParagraph = { | ||||
| id: number; | id: number; | ||||
| index: 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 */ | /* tslint:disable */ | ||||
| /* eslint-disable */ | /* eslint-disable */ | ||||
| import type { ProposalParagraph } from './ProposalParagraph'; | import type { ProposalParagraph } from './ProposalParagraph'; | ||||
| import type { ProposalState } from './ProposalState'; | |||||
| export type ProposalWithParagraphs = { | export type ProposalWithParagraphs = { | ||||
| createdAt: string; | createdAt: string; | ||||
| creator: string; | creator: string; | ||||
| id: number; | id: number; | ||||
| name: string; | name: string; | ||||
| paragraphs: Array<ProposalParagraph>; | 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; | updatedAt?: string | null; | ||||
| }; | }; | ||||
| @@ -2,11 +2,13 @@ | |||||
| /* istanbul ignore file */ | /* istanbul ignore file */ | ||||
| /* tslint:disable */ | /* tslint:disable */ | ||||
| /* eslint-disable */ | /* eslint-disable */ | ||||
| import type { ProposalState } from './ProposalState'; | |||||
| export type ProposalWithSummary = { | export type ProposalWithSummary = { | ||||
| createdAt: string; | createdAt: string; | ||||
| creator: string; | creator: string; | ||||
| id: number; | id: number; | ||||
| name: string; | name: string; | ||||
| state: ProposalState; | |||||
| summary: string; | summary: string; | ||||
| updatedAt?: string | null; | 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 */ | /* istanbul ignore file */ | ||||
| /* tslint:disable */ | /* tslint:disable */ | ||||
| /* eslint-disable */ | /* eslint-disable */ | ||||
| import type { ProposalState } from './ProposalState'; | |||||
| export type SelectableProposal = { | export type SelectableProposal = { | ||||
| createdAt: string; | createdAt: string; | ||||
| creator: string; | creator: string; | ||||
| /** | |||||
| * Revision whose snapshot is the live content; None if the proposal has no recorded revisions | |||||
| */ | |||||
| currentRevision?: number | null; | |||||
| id: number; | id: number; | ||||
| name: string; | name: string; | ||||
| organizationId: number; | organizationId: number; | ||||
| state: ProposalState; | |||||
| updatedAt?: string | null; | 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', | mediaType: 'application/json', | ||||
| errors: { | errors: { | ||||
| 400: `Bad request`, | 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`, | 500: `Internal server error`, | ||||
| }, | }, | ||||
| }); | }); | ||||
| @@ -3,7 +3,11 @@ | |||||
| /* tslint:disable */ | /* tslint:disable */ | ||||
| /* eslint-disable */ | /* eslint-disable */ | ||||
| import type { AddCommentRequest } from '../models/AddCommentRequest'; | import type { AddCommentRequest } from '../models/AddCommentRequest'; | ||||
| import type { CommentReactionSummary } from '../models/CommentReactionSummary'; | |||||
| import type { CommentWithUser } from '../models/CommentWithUser'; | 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 { SelectableComment } from '../models/SelectableComment'; | ||||
| import type { CancelablePromise } from '../core/CancelablePromise'; | import type { CancelablePromise } from '../core/CancelablePromise'; | ||||
| import { OpenAPI } from '../core/OpenAPI'; | 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 | * @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 | * @throws ApiError | ||||
| */ | */ | ||||
| public static listComments( | public static listComments( | ||||
| proposalId: number, | proposalId: number, | ||||
| ): CancelablePromise<Array<CommentWithUser>> { | |||||
| offset?: number | null, | |||||
| limit?: number | null, | |||||
| ): CancelablePromise<ListCommentsResponse> { | |||||
| return __request(OpenAPI, { | return __request(OpenAPI, { | ||||
| method: 'GET', | method: 'GET', | ||||
| url: '/api/v1/comments/list', | url: '/api/v1/comments/list', | ||||
| query: { | query: { | ||||
| 'proposalId': proposalId, | 'proposalId': proposalId, | ||||
| 'offset': offset, | |||||
| 'limit': limit, | |||||
| }, | }, | ||||
| errors: { | errors: { | ||||
| 400: `Bad request`, | 400: `Bad request`, | ||||
| @@ -3,8 +3,13 @@ | |||||
| /* tslint:disable */ | /* tslint:disable */ | ||||
| /* eslint-disable */ | /* eslint-disable */ | ||||
| import type { AddProposalRequest } from '../models/AddProposalRequest'; | 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 { ProposalVote } from '../models/ProposalVote'; | ||||
| import type { ProposalWithParagraphs } from '../models/ProposalWithParagraphs'; | 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 { UserProposalVotes } from '../models/UserProposalVotes'; | ||||
| import type { VoteProposalRequest } from '../models/VoteProposalRequest'; | import type { VoteProposalRequest } from '../models/VoteProposalRequest'; | ||||
| import type { CancelablePromise } from '../core/CancelablePromise'; | 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 | * @param id | ||||
| * @returns any Proposal retrieved successfully | * @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 | * @param requestBody | ||||
| * @returns ProposalVote The recorded vote | * @returns ProposalVote The recorded vote | ||||
| @@ -90,7 +229,7 @@ export class ProposalService { | |||||
| 400: `Bad request`, | 400: `Bad request`, | ||||
| 401: `Missing, invalid, or expired token`, | 401: `Missing, invalid, or expired token`, | ||||
| 404: `Proposal not found in the current user's organization`, | 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`, | 500: `Internal server error`, | ||||
| }, | }, | ||||
| }); | }); | ||||
| @@ -16,7 +16,7 @@ | |||||
| ></div> | ></div> | ||||
| <!-- Main Content --> | <!-- 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 /> | <RouterView /> | ||||
| </main> | </main> | ||||
| </div> | </div> | ||||
| @@ -54,11 +54,20 @@ const isSidebarOpen = ref(false); | |||||
| const [offset, limit] = [ref(0), ref(10)]; | 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 () => { | onMounted(async () => { | ||||
| proposals.value = await ProposalService.listProposals(offset.value, limit.value); | |||||
| hideLoading('proposals'); | |||||
| await fetchProposals(); | |||||
| eventBus.on('proposalUpdated', fetchProposals); | |||||
| }); | }); | ||||
| watch(() => route.path, () => { | watch(() => route.path, () => { | ||||
| @@ -68,5 +77,6 @@ watch(() => route.path, () => { | |||||
| onUnmounted(() => { | onUnmounted(() => { | ||||
| unregister('proposals'); | unregister('proposals'); | ||||
| eventBus.off('filtersApplied'); | eventBus.off('filtersApplied'); | ||||
| eventBus.off('proposalUpdated'); | |||||
| }); | }); | ||||
| </script> | </script> | ||||
| @@ -17,9 +17,10 @@ | |||||
| </template> | </template> | ||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| import { ref, watch, onUnmounted } from 'vue'; | |||||
| import { ref, watch, onMounted, onUnmounted } from 'vue'; | |||||
| import { ProposalService, ProposalWithParagraphs } from '../generated/typescript'; | import { ProposalService, ProposalWithParagraphs } from '../generated/typescript'; | ||||
| import { useLoading } from '../composables/useLoading'; | import { useLoading } from '../composables/useLoading'; | ||||
| import { eventBus } from '../utils/eventBus'; | |||||
| import ProposalDetail from '../components/ProposalDetail.vue'; | import ProposalDetail from '../components/ProposalDetail.vue'; | ||||
| import ProposalEmptyState from '../components/ProposalEmptyState.vue'; | import ProposalEmptyState from '../components/ProposalEmptyState.vue'; | ||||
| import LoadingSpinner from '../components/LoadingSpinner.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( | watch( | ||||
| () => props.id, | () => props.id, | ||||
| (newId) => { | (newId) => { | ||||
| @@ -62,7 +70,12 @@ watch( | |||||
| { immediate: true } | { immediate: true } | ||||
| ); | ); | ||||
| onMounted(() => { | |||||
| eventBus.on('proposalUpdated', handleProposalUpdated); | |||||
| }); | |||||
| onUnmounted(() => { | onUnmounted(() => { | ||||
| unregister('content'); | unregister('content'); | ||||
| eventBus.off('proposalUpdated'); | |||||
| }); | }); | ||||
| </script> | </script> | ||||