소스 검색

Add proposal editing, revision history, and sponsorship support along with auto-generated TypeScript models

master
Jared Bell 2 시간 전
부모
커밋
2d00a75922
38개의 변경된 파일과 2257개의 추가작업 그리고 247개의 파일을 삭제
  1. +98
    -70
      src/components/AmendmentCarousel.vue
  2. +16
    -2
      src/components/BrandBar.vue
  3. +1
    -1
      src/components/Chip.vue
  4. +144
    -3
      src/components/CommentThread.vue
  5. +1
    -1
      src/components/Dropdown.vue
  6. +1
    -1
      src/components/LoadingSpinner.vue
  7. +33
    -6
      src/components/PostBuilder.vue
  8. +403
    -137
      src/components/ProposalDetail.vue
  9. +372
    -0
      src/components/ProposalRevisionDropdown.vue
  10. +219
    -0
      src/components/ProposalSponsorCard.vue
  11. +1
    -1
      src/components/Thread.vue
  12. +192
    -0
      src/components/modals/EditProposalModal.vue
  13. +17
    -1
      src/components/modals/NewAmendmentModal.vue
  14. +317
    -15
      src/components/modals/RegistrationModal.vue
  15. +13
    -0
      src/generated/typescript/index.ts
  16. +17
    -0
      src/generated/typescript/models/CommentReaction.ts
  17. +15
    -0
      src/generated/typescript/models/CommentReactionSummary.ts
  18. +22
    -0
      src/generated/typescript/models/EditProposalRequest.ts
  19. +10
    -0
      src/generated/typescript/models/ListCommentsResponse.ts
  20. +3
    -0
      src/generated/typescript/models/ProposalParagraph.ts
  21. +26
    -0
      src/generated/typescript/models/ProposalRevision.ts
  22. +9
    -0
      src/generated/typescript/models/ProposalRevisionBlock.ts
  23. +17
    -0
      src/generated/typescript/models/ProposalSponsor.ts
  24. +13
    -0
      src/generated/typescript/models/ProposalState.ts
  25. +7
    -0
      src/generated/typescript/models/ProposalWithParagraphs.ts
  26. +2
    -0
      src/generated/typescript/models/ProposalWithSummary.ts
  27. +10
    -0
      src/generated/typescript/models/ReactCommentRequest.ts
  28. +13
    -0
      src/generated/typescript/models/ReactCommentResponse.ts
  29. +8
    -0
      src/generated/typescript/models/ReactionType.ts
  30. +6
    -0
      src/generated/typescript/models/SelectableProposal.ts
  31. +16
    -0
      src/generated/typescript/models/SetProposalRevisionRequest.ts
  32. +8
    -0
      src/generated/typescript/models/SponsorProposalRequest.ts
  33. +3
    -0
      src/generated/typescript/services/AmendmentService.ts
  34. +55
    -2
      src/generated/typescript/services/CommentService.ts
  35. +140
    -1
      src/generated/typescript/services/ProposalService.ts
  36. +14
    -4
      src/views/HomeView.vue
  37. +14
    -1
      src/views/ProposalView.vue
  38. +1
    -1
      tsconfig.app.tsbuildinfo

+ 98
- 70
src/components/AmendmentCarousel.vue 파일 보기

@@ -15,75 +15,87 @@
class="flex gap-3 overflow-x-auto scroll-smooth px-1 py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
@scroll.passive="updateEdges"
>
<article
v-for="amendment in amendments"
:key="amendment.id"
class="group relative flex flex-col justify-between w-48 sm:w-72 lg:w-80 shrink-0 rounded-xl border border-slate-200/90 bg-white p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-amber-300 hover:bg-amber-50/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 cursor-pointer"
role="button"
tabindex="0"
@click="showAmendment(amendment.id)"
@keydown.enter.prevent="showAmendment(amendment.id)"
@keydown.space.prevent="showAmendment(amendment.id)"
>
<div class="flex items-start justify-between gap-2">
<h3 class="font-header font-bold text-slate-900 text-sm sm:text-base leading-snug line-clamp-2">{{ amendment.name }}</h3>
<div
v-if="amendment.status.toLowerCase() === 'proposed'"
class="flex items-center gap-1 shrink-0"
>
<button
type="button"
:aria-pressed="getAmendmentVote(amendment.id) === true"
aria-label="Confirm amendment"
title="Confirm amendment"
class="rounded-full p-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900"
:class="getAmendmentVote(amendment.id) === true
? 'text-emerald-600 bg-emerald-50 border border-emerald-200'
: 'text-slate-400 hover:bg-slate-100 hover:text-emerald-500'"
@click.stop="confirm(amendment.id)"
>
<Confirm class="size-4" />
</button>
<button
type="button"
:aria-pressed="getAmendmentVote(amendment.id) === false"
aria-label="Reject amendment"
title="Reject amendment"
class="rounded-full p-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900"
:class="getAmendmentVote(amendment.id) === false
? 'text-rose-600 bg-rose-50 border border-rose-200'
: 'text-slate-400 hover:bg-slate-100 hover:text-rose-500'"
@click.stop="reject(amendment.id)"
<template v-if="isApproved">
<article
v-for="amendment in amendments"
:key="amendment.id"
class="group relative flex flex-col justify-between w-48 sm:w-72 lg:w-80 shrink-0 rounded-xl border border-slate-200/90 bg-white p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-amber-300 hover:bg-amber-50/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 cursor-pointer"
role="button"
tabindex="0"
@click="showAmendment(amendment.id)"
@keydown.enter.prevent="showAmendment(amendment.id)"
@keydown.space.prevent="showAmendment(amendment.id)"
>
<div class="flex items-start justify-between gap-2">
<h3 class="font-header font-bold text-slate-900 text-sm sm:text-base leading-snug line-clamp-2">{{ amendment.name }}</h3>
<div
v-if="amendment.status.toLowerCase() === 'proposed'"
class="flex items-center gap-1 shrink-0"
>
<Cancel class="size-4" />
</button>
<button
type="button"
:aria-pressed="getAmendmentVote(amendment.id) === true"
aria-label="Confirm amendment"
title="Confirm amendment"
class="rounded-full p-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900"
:class="getAmendmentVote(amendment.id) === true
? 'text-emerald-600 bg-emerald-50 border border-emerald-200'
: 'text-slate-400 hover:bg-slate-100 hover:text-emerald-500'"
@click.stop="confirm(amendment.id)"
>
<Confirm class="size-4" />
</button>
<button
type="button"
:aria-pressed="getAmendmentVote(amendment.id) === false"
aria-label="Reject amendment"
title="Reject amendment"
class="rounded-full p-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900"
:class="getAmendmentVote(amendment.id) === false
? 'text-rose-600 bg-rose-50 border border-rose-200'
: 'text-slate-400 hover:bg-slate-100 hover:text-rose-500'"
@click.stop="reject(amendment.id)"
>
<Cancel class="size-4" />
</button>
</div>
</div>
</div>
<div class="mt-2.5 mb-1.5 flex items-center">
<Chip :text="amendment.status" :class="getChipClass(amendment.status)" />
</div>
<p class="relative mt-1 h-12 overflow-hidden text-xs leading-relaxed text-slate-600">
{{ amendment.summary }}
<span class="pointer-events-none absolute inset-x-0 bottom-0 h-4 bg-gradient-to-t from-white group-hover:from-amber-50/40 transition-colors duration-200" />
</p>
</article>
<article
class="group flex w-48 sm:w-72 lg:w-80 shrink-0 flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-300 bg-slate-50/70 p-4 text-center shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-amber-400 hover:bg-amber-50/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 cursor-pointer"
role="button"
tabindex="0"
aria-label="Add amendment"
@click="openNewAmendmentModal"
@keydown.enter.prevent="openNewAmendmentModal"
@keydown.space.prevent="openNewAmendmentModal"
<div class="mt-2.5 mb-1.5 flex items-center">
<Chip :text="amendment.status" :class="getChipClass(amendment.status)" />
</div>
<p class="relative mt-1 h-12 overflow-hidden text-xs leading-relaxed text-slate-600">
{{ amendment.summary }}
<span class="pointer-events-none absolute inset-x-0 bottom-0 h-4 bg-gradient-to-t from-white group-hover:from-amber-50/40 transition-colors duration-200" />
</p>
</article>

<article
class="group flex w-48 sm:w-72 lg:w-80 shrink-0 flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-300 bg-slate-50/70 p-4 text-center shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-amber-400 hover:bg-amber-50/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 cursor-pointer"
role="button"
tabindex="0"
aria-label="Add amendment"
@click="openNewAmendmentModal"
@keydown.enter.prevent="openNewAmendmentModal"
@keydown.space.prevent="openNewAmendmentModal"
>
<div class="mb-2 flex size-8 items-center justify-center rounded-full bg-slate-200 text-slate-700 transition-colors group-hover:bg-amber-400 group-hover:text-white">
<Plus class="size-4" />
</div>
<h3 class="font-header text-sm sm:text-base font-bold text-slate-800 transition-colors group-hover:text-amber-800">Add Amendment</h3>
<p class="mt-1 text-xs text-slate-500 line-clamp-2">
Propose a revision or improvement
</p>
</article>
</template>

<div
v-else
class="flex w-full min-h-[7rem] items-center justify-center rounded-xl border border-dashed border-slate-200 bg-slate-50/60 p-6 text-center text-sm text-slate-500"
>
<div class="mb-2 flex size-8 items-center justify-center rounded-full bg-slate-200 text-slate-700 transition-colors group-hover:bg-amber-400 group-hover:text-white">
<Plus class="size-4" />
</div>
<h3 class="font-header text-sm sm:text-base font-bold text-slate-800 transition-colors group-hover:text-amber-800">Add Amendment</h3>
<p class="mt-1 text-xs text-slate-500 line-clamp-2">
Propose a revision or improvement
<p class="max-w-xl">
Amendments become available after approval. Once this proposal is approved, you can suggest changes here.
</p>
</article>
</div>
</div>

<button
@@ -113,10 +125,16 @@ import { getUserIdFromJwt } from "../utils/auth.ts";
import { ContentModal, useModal } from "../composables/useModal.ts";
import { eventBus } from "../utils/eventBus.ts";

const props = defineProps<{
proposalId?: number;
amendmentVotes?: UserAmendmentVote[];
}>();
const props = withDefaults(
defineProps<{
proposalId?: number;
amendmentVotes?: UserAmendmentVote[];
isApproved?: boolean;
}>(),
{
isApproved: true,
}
);
const emit = defineEmits<{
(e: 'voted'): void;
}>();
@@ -139,7 +157,7 @@ const { show: showModal, setContent, addContent } = useModal();

const updateEdges = () => {
const el = scroller.value;
if (!el) {
if (!el || !props.isApproved) {
canScrollLeft.value = false;
canScrollRight.value = false;
return;
@@ -194,8 +212,10 @@ const showAmendment = async (id: number) => {
};

const openNewAmendmentModal = () => {
if (props.isApproved === false) return;
if (props.proposalId) {
setContent("proposalId", props.proposalId);
setContent("isApproved", props.isApproved ? "true" : "false");
}
showModal(NewAmendmentModal as unknown as ContentModal);
};
@@ -260,6 +280,14 @@ watch(proposalId, async (id) => {
if (scroller.value) scroller.value.scrollLeft = 0;
await getAmendments();
});

watch(
() => props.isApproved,
async () => {
await nextTick();
updateEdges();
}
);
</script>

<style lang="postcss" scoped>


+ 16
- 2
src/components/BrandBar.vue 파일 보기

@@ -6,9 +6,16 @@
<ColoredHeader text="Senara" from="#F2F3E2" to="#B2E5F8" />
</RouterLink>
</span>
<div class="flex flex-col justify-center items-center">
<div class="flex flex-row gap-2 justify-center items-center">
<button
class="text-xs ml-auto rounded-md bg-white text-gray-900 font-semibold px-5 py-2 shadow-sm hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-orange-300 transition-colors duration-200"
class="text-xs rounded-md bg-white text-gray-900 font-semibold px-5 py-2 shadow-sm hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-orange-300 transition-colors duration-200 cursor-pointer"
type="button"
@click="openRegistrationModal"
v-if="!isAuthenticated">
Register
</button>
<button
class="text-xs rounded-md bg-white text-gray-900 font-semibold px-5 py-2 shadow-sm hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-orange-300 transition-colors duration-200 cursor-pointer"
type="button"
:disabled="isConnecting"
@click="connectOrDisplayUser">
@@ -22,8 +29,11 @@
import ColoredHeader from "./ColoredHeader.vue";
import { computed } from "vue";
import { useSession } from "../composables/useSession";
import { ContentModal, useModal } from "../composables/useModal.ts";
import RegistrationModal from "./modals/RegistrationModal.vue";

const { isAuthenticated, truncatedAddress, isConnecting, connectWallet } = useSession();
const { show: showModal } = useModal();

const buttonText = computed(() => {
if (isConnecting.value) return "Connecting...";
@@ -36,6 +46,10 @@ const connectOrDisplayUser = async () => {
await connectWallet();
}
};

const openRegistrationModal = () => {
showModal(RegistrationModal as ContentModal);
};
</script>

<style scoped>


+ 1
- 1
src/components/Chip.vue 파일 보기

@@ -6,7 +6,7 @@
</template>

<script setup lang="ts">
const props = defineProps<{ text: string }>();
defineProps<{ text: string }>();
</script>

<style scoped>


+ 144
- 3
src/components/CommentThread.vue 파일 보기

@@ -25,7 +25,44 @@
{{ node.comment.content }}
</p>

<div class="mt-2">
<div class="mt-3 flex items-center gap-3">
<!-- Reactions -->
<div class="flex items-center gap-1.5" role="group" aria-label="Comment reactions">
<button
type="button"
:aria-pressed="reactionSummary?.userReaction === ReactionType.POSITIVE"
:aria-label="`Like comment (${reactionSummary?.positive ?? 0})`"
:disabled="isReacting"
@click="handleReaction(ReactionType.POSITIVE)"
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/40 disabled:opacity-50 disabled:cursor-not-allowed"
:class="reactionSummary?.userReaction === ReactionType.POSITIVE
? 'bg-emerald-50 text-emerald-700 border border-emerald-300 font-semibold'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 border border-transparent'"
>
<ThumbsUp size="xs" aria-hidden="true" />
<span v-if="(reactionSummary?.positive ?? 0) > 0" class="tabular-nums">
{{ reactionSummary?.positive }}
</span>
</button>

<button
type="button"
:aria-pressed="reactionSummary?.userReaction === ReactionType.NEGATIVE"
:aria-label="`Dislike comment (${reactionSummary?.negative ?? 0})`"
:disabled="isReacting"
@click="handleReaction(ReactionType.NEGATIVE)"
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-rose-500/40 disabled:opacity-50 disabled:cursor-not-allowed"
:class="reactionSummary?.userReaction === ReactionType.NEGATIVE
? 'bg-rose-50 text-rose-700 border border-rose-300 font-semibold'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 border border-transparent'"
>
<ThumbsDown size="xs" aria-hidden="true" />
<span v-if="(reactionSummary?.negative ?? 0) > 0" class="tabular-nums">
{{ reactionSummary?.negative }}
</span>
</button>
</div>

<button
v-if="!isReplying"
type="button"
@@ -36,6 +73,22 @@
</button>
</div>

<!-- Reaction error notification -->
<div
v-if="reactionError"
class="mt-2 flex items-center justify-between rounded-md border border-rose-200 bg-rose-50 px-2.5 py-1.5 text-xs text-rose-700"
>
<span>{{ reactionError }}</span>
<button
type="button"
@click="reactionError = null"
class="ml-2 font-bold text-rose-500 hover:text-rose-800"
aria-label="Dismiss reaction error"
>
&times;
</button>
</div>

<!-- Inline Reply Form -->
<form
v-if="isReplying"
@@ -97,9 +150,11 @@
</template>

<script setup lang="ts">
import {computed, nextTick, ref} from "vue";
import {CommentWithUser} from "../generated/typescript";
import {computed, nextTick, onMounted, ref, watch} from "vue";
import {CommentReactionSummary, CommentService, CommentWithUser, ReactionType} from "../generated/typescript";
import {CommentNode} from "../utils/comments.ts";
import ThumbsUp from "./icons/ThumbsUp.vue";
import ThumbsDown from "./icons/ThumbsDown.vue";

const props = defineProps<{
node: CommentNode;
@@ -113,6 +168,92 @@ const isSubmitting = ref(false);
const replyError = ref<string | null>(null);
const replyInput = ref<HTMLTextAreaElement | null>(null);

const reactionSummary = ref<CommentReactionSummary>({
commentId: props.node.comment.id,
positive: 0,
negative: 0,
userReaction: null,
});
const isReacting = ref(false);
const reactionError = ref<string | null>(null);

const fetchReactions = async () => {
if (!props.node.comment?.id) return;
try {
const summary = await CommentService.getCommentReactions(props.node.comment.id);
reactionSummary.value = summary;
} catch (err) {
console.warn("Failed to fetch comment reactions:", err);
}
};

const handleReaction = async (targetType: ReactionType) => {
if (isReacting.value) return;

const previousSummary = { ...reactionSummary.value };
const currentReaction = previousSummary.userReaction;
const newReaction = currentReaction === targetType ? null : targetType;

let newPositive = previousSummary.positive;
let newNegative = previousSummary.negative;

if (currentReaction === ReactionType.POSITIVE) {
newPositive = Math.max(0, newPositive - 1);
} else if (currentReaction === ReactionType.NEGATIVE) {
newNegative = Math.max(0, newNegative - 1);
}

if (newReaction === ReactionType.POSITIVE) {
newPositive += 1;
} else if (newReaction === ReactionType.NEGATIVE) {
newNegative += 1;
}

reactionSummary.value = {
...previousSummary,
positive: newPositive,
negative: newNegative,
userReaction: newReaction,
};

isReacting.value = true;
reactionError.value = null;

try {
await CommentService.reactComment({
commentId: props.node.comment.id,
reaction: newReaction,
});
await fetchReactions();
} catch (err) {
console.error("Failed to react to comment:", err);
reactionSummary.value = previousSummary;
reactionError.value =
err instanceof Error ? err.message : "Failed to update reaction. Please try again.";
} finally {
isReacting.value = false;
}
};

onMounted(() => {
fetchReactions();
});

watch(
() => props.node.comment.id,
(newId, oldId) => {
if (newId !== oldId) {
reactionSummary.value = {
commentId: newId,
positive: 0,
negative: 0,
userReaction: null,
};
fetchReactions();
}
}
);

// Visual nesting levels are 1-based; nestingLevel is 0 for top-level comments.
const MAX_NESTING_LEVEL = 3;



+ 1
- 1
src/components/Dropdown.vue 파일 보기

@@ -7,7 +7,7 @@
<script setup lang="ts">
import { DropdownOption } from "../types/dropdown.ts";

const props = defineProps<{ emptyValue: string, options: DropdownOption[], expanded?: boolean }>()
defineProps<{ emptyValue: string, options: DropdownOption[], expanded?: boolean }>()
</script>

<style scoped>


+ 1
- 1
src/components/LoadingSpinner.vue 파일 보기

@@ -5,7 +5,7 @@
</template>

<script setup lang="ts">
const props = defineProps<{ identifier: string }>();
defineProps<{ identifier: string }>();


</script>


+ 33
- 6
src/components/PostBuilder.vue 파일 보기

@@ -21,9 +21,14 @@
<script setup lang="ts">
import {Editor, EditorContent} from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import {onBeforeUnmount} from 'vue'
import {onBeforeUnmount, onMounted, watch} from 'vue'
import TurndownService from 'turndown';
import {ProposalParagraph} from "../types/proposal.ts";
import {renderMarkdown} from "../utils/markdown.ts";

const props = defineProps<{
initialContent?: string;
}>();

const turndownService = new TurndownService();

@@ -31,24 +36,46 @@ const emit = defineEmits<{
'update:content': [content: ProposalParagraph[]],
}>()

const emitParagraphs = (html: string) => {
const content = convertEditorContentToMarkdown(html);
const paragraphs = content.split('\n').map(p => p.trim()).filter(p => p.length > 0).map((p, i) => ({paragraphText: p, index: i}));
emit('update:content', paragraphs);
};

const editor = new Editor({
extensions: [
StarterKit,
],
content: '',
content: props.initialContent ? renderMarkdown(props.initialContent) : '',
editorProps: {
attributes: {
class: 'prose prose-sm focus:outline-none max-w-none',
},
},
onUpdate: ({ editor }) => {
const html = editor.getHTML();
const content = convertEditorContentToMarkdown(html);
const paragraphs = content.split('\n').map(p => p.trim()).filter(p => p.length > 0).map((p, i) => ({paragraphText: p, index: i}));
emit('update:content', paragraphs);
emitParagraphs(editor.getHTML());
}
})

onMounted(() => {
if (props.initialContent) {
emitParagraphs(editor.getHTML());
}
});

watch(
() => props.initialContent,
(newContent) => {
if (!editor) return;
const currentMarkdown = convertEditorContentToMarkdown(editor.getHTML());
if (currentMarkdown.trim() !== (newContent || '').trim()) {
const html = newContent ? renderMarkdown(newContent) : '';
editor.commands.setContent(html, { emitUpdate: false });
emitParagraphs(editor.getHTML());
}
}
);

const convertEditorContentToMarkdown = (htmlContent: string) => {
return turndownService.turndown(htmlContent);
};


+ 403
- 137
src/components/ProposalDetail.vue 파일 보기

@@ -6,24 +6,70 @@
<div class="grid gap-8 xl:grid-cols-[minmax(0,1fr)_15rem] xl:items-start">
<!-- Primary reading column -->
<div class="min-w-0">
<header class="border-b border-slate-300 pb-7">
<div class="mb-4 flex flex-wrap items-center gap-2">
<span
class="rounded-full border border-slate-300 bg-white px-2.5 py-1
text-[0.65rem] font-bold uppercase tracking-[0.12em] text-slate-600"
>
Proposal
</span>
<!-- Sticky title reminder; appears once the main heading scrolls out of view.
The negative margin keeps it from taking up space in the layout. -->
<div
class="sticky top-0 z-20 -mb-14 flex h-14 items-center border-b border-slate-300
bg-slate-100/90 backdrop-blur-sm transition-[opacity,transform] duration-200"
:class="isTitleVisible
? 'pointer-events-none -translate-y-1 opacity-0'
: 'translate-y-0 opacity-100'"
aria-hidden="true"
>
<p
class="truncate font-header text-lg font-bold tracking-tight text-slate-950"
:title="proposal.name"
>
{{ proposal.name }}
</p>
</div>

<header class="pb-7">
<div class="mb-4 flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<span
class="rounded-full border border-slate-300 bg-white px-2.5 py-1
text-[0.65rem] font-bold uppercase tracking-[0.12em] text-slate-600"
>
Proposal
</span>

<span
class="rounded-full border border-slate-300 bg-slate-200/70 px-2.5 py-1
text-[0.65rem] font-bold uppercase tracking-[0.12em] text-slate-600"
<span
class="rounded-full border border-slate-300 bg-slate-200/70 px-2.5 py-1
text-[0.65rem] font-bold uppercase tracking-[0.12em] text-slate-600"
>
{{ truncateWallet(proposal.creator) }}
</span>

<span
v-if="proposal.state"
class="rounded-full border border-amber-300 bg-amber-50 px-2.5 py-1
text-[0.65rem] font-bold uppercase tracking-[0.12em] text-amber-800"
>
{{ proposal.state }}
</span>

<ProposalRevisionDropdown
v-if="proposal.revision !== undefined"
:proposal="proposal"
/>
</div>

<button
v-if="isEditable"
type="button"
@click="isEditModalOpen = true"
class="inline-flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-800 shadow-sm transition hover:bg-slate-50 hover:border-slate-400 focus:outline-none focus:ring-2 focus:ring-amber-500/20"
>
{{ truncateWallet(proposal.creator) }}
</span>
<svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 text-slate-600" viewBox="0 0 20 20" fill="currentColor">
<path d="m13.586 3.586 2.828 2.828a2 2 0 0 1 0 2.828l-8.485 8.485a2 2 0 0 1-.879.497l-4.242 1.06a.75.75 0 0 1-.91-.91l1.06-4.242a2 2 0 0 1 .497-.879l8.485-8.485a2 2 0 0 1 2.828 0Z" />
</svg>
Edit Proposal
</button>
</div>

<h1
ref="titleEl"
:id="`proposal-${proposal.id}-title`"
class="max-w-4xl font-header text-4xl font-bold leading-[0.95]
tracking-tight text-slate-950 sm:text-5xl"
@@ -50,164 +96,286 @@
</div>
</header>

<section
class="max-w-3xl py-8"
aria-labelledby="proposal-content-heading"
<!-- Proposal / discussion tabs -->
<div
role="tablist"
aria-label="Proposal sections"
class="flex gap-6 border-b border-slate-300"
@keydown.left.prevent="focusAdjacentTab(-1)"
@keydown.right.prevent="focusAdjacentTab(1)"
>
<h2 id="proposal-content-heading" class="sr-only">
Proposal details
</h2>

<div class="content-body space-y-5 text-[0.98rem] leading-7 text-slate-800">
<p
v-for="paragraph in proposal.paragraphs"
:key="paragraph.id ?? paragraph.paragraphText"
v-html="renderMarkdown(paragraph.paragraphText)"
/>
</div>
</section>

<section
class="border-t border-slate-300 py-8"
aria-labelledby="amendments-heading"
>
<div class="mb-4 flex items-baseline justify-between gap-4">
<h2
id="amendments-heading"
class="font-header text-2xl font-bold tracking-tight text-slate-950"
>
Amendments
</h2>

<button
v-for="tab in tabs"
:key="tab.id"
:id="`proposal-tab-${tab.id}`"
:ref="(el) => setTabRef(tab.id, el)"
type="button"
role="tab"
:aria-selected="activeTab === tab.id"
:aria-controls="`proposal-panel-${tab.id}`"
:tabindex="activeTab === tab.id ? 0 : -1"
@click="activeTab = tab.id"
:class="[
'-mb-px inline-flex items-center gap-2 border-b-2 px-0.5 pb-3 pt-1 text-xs font-bold uppercase tracking-[0.12em] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/20',
activeTab === tab.id
? 'border-amber-500 text-slate-950'
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-800',
]"
>
{{ tab.label }}
<span
class="text-[0.65rem] font-bold uppercase tracking-[0.1em] text-slate-500"
v-if="tab.id === 'discussion' && numComments > 0"
:class="[
'rounded-full border px-1.5 py-0.5 text-[0.6rem] leading-none tracking-normal',
activeTab === tab.id
? 'border-amber-300 bg-amber-50 text-amber-800'
: 'border-slate-300 bg-white text-slate-600',
]"
>
Proposed changes
{{ numComments }}
</span>
</div>

<AmendmentCarousel
:proposal-id="proposal.id"
:amendment-votes="userVotes?.amendmentVotes"
@voted="fetchUserVotes(proposal.id)"
/>
</section>

<section
class="border-t border-slate-300 py-8"
aria-labelledby="comments-heading"
</button>
</div>

<div
v-show="activeTab === 'proposal'"
id="proposal-panel-proposal"
role="tabpanel"
aria-labelledby="proposal-tab-proposal"
>
<div class="mb-4 flex items-baseline justify-between gap-4">
<h2
id="comments-heading"
class="font-header text-2xl font-bold tracking-tight text-slate-950"
>
Comments
<section
class="max-w-3xl py-8"
aria-labelledby="proposal-content-heading"
>
<h2 id="proposal-content-heading" class="sr-only">
Proposal details
</h2>

<span
class="text-[0.65rem] font-bold uppercase tracking-[0.1em] text-slate-500"
>
Discussion
</span>
</div>

<!-- Inline Comment Form -->
<form @submit.prevent="submitComment" class="mb-8 space-y-3" aria-label="Add a comment">
<div v-if="commentError" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 border border-red-200">
{{ commentError }}
<div class="content-body space-y-5 text-[0.98rem] leading-7 text-slate-800">
<p
v-for="paragraph in proposal.paragraphs"
:key="paragraph.id ?? paragraph.paragraphText"
v-html="renderMarkdown(paragraph.paragraphText)"
/>
</div>
</section>

<div>
<label for="proposal-comment-input" class="sr-only">
Add to the discussion
</label>
<textarea
id="proposal-comment-input"
v-model="commentText"
rows="3"
:disabled="isSubmitting"
placeholder="Add to the discussion..."
class="w-full rounded-lg border border-slate-300 p-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/10 disabled:bg-slate-50 disabled:text-slate-400 resize-y"
></textarea>
<section
class="border-t border-slate-300 py-8"
aria-labelledby="amendments-heading"
>
<div class="mb-4 flex items-baseline justify-between gap-4">
<h2
id="amendments-heading"
class="font-header text-2xl font-bold tracking-tight text-slate-950"
>
Amendments
</h2>

<span
class="text-[0.65rem] font-bold uppercase tracking-[0.1em] text-slate-500"
>
Proposed changes
</span>
</div>

<div class="flex items-center justify-between gap-3">
<span class="text-xs text-slate-500">
Share your perspective on this proposal.
<AmendmentCarousel
:proposal-id="proposal.id"
:amendment-votes="userVotes?.amendmentVotes"
:is-approved="isApproved"
@voted="fetchUserVotes(proposal.id)"
/>
</section>
</div>

<div
v-show="activeTab === 'discussion'"
id="proposal-panel-discussion"
role="tabpanel"
aria-labelledby="proposal-tab-discussion"
>
<section
class="py-8"
aria-labelledby="comments-heading"
>
<div class="mb-4 flex items-baseline justify-between gap-4">
<h2
id="comments-heading"
class="font-header text-2xl font-bold tracking-tight text-slate-950"
>
Comments
</h2>

<span
class="text-[0.65rem] font-bold uppercase tracking-[0.1em] text-slate-500"
>
Discussion
</span>
<div class="flex items-center gap-2">
<button
v-if="commentText.trim().length > 0"
type="button"
</div>

<!-- Inline Comment Form -->
<form @submit.prevent="submitComment" class="mb-8 space-y-3" aria-label="Add a comment">
<div v-if="commentError" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 border border-red-200">
{{ commentError }}
</div>

<div>
<label for="proposal-comment-input" class="sr-only">
Add to the discussion
</label>
<textarea
id="proposal-comment-input"
v-model="commentText"
rows="3"
:disabled="isSubmitting"
@click="handleCancel"
class="rounded-lg border border-slate-300 px-3.5 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-300 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
:disabled="isSubmitDisabled"
class="rounded-lg bg-amber-500 px-4 py-2 text-xs font-semibold text-white shadow-sm hover:bg-amber-600 focus:outline-none focus:ring-4 focus:ring-amber-500/20 disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-400 transition-colors"
>
<span v-if="isSubmitting">Posting...</span>
<span v-else>Add Comment</span>
</button>
placeholder="Add to the discussion..."
class="w-full rounded-lg border border-slate-300 p-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/10 disabled:bg-slate-50 disabled:text-slate-400 resize-y"
></textarea>
</div>
</div>
</form>

<!-- Comments List -->
<div class="space-y-4">
<!-- Only show the loading state on an empty list, so refetching after a reply
doesn't unmount the threads (and their open reply forms) mid-submit. -->
<div v-if="isLoadingComments && comments.length === 0" class="py-6 text-center text-sm text-slate-500">
Loading comments...
</div>

<div
v-else-if="comments.length === 0"
class="rounded-lg border border-dashed border-slate-300 bg-white/50 px-5 py-8 text-center text-sm text-slate-500"
>
No comments yet. Be the first to share your feedback!
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-xs text-slate-500">
Share your perspective on this proposal.
</span>
<div class="flex items-center gap-2">
<button
v-if="commentText.trim().length > 0"
type="button"
:disabled="isSubmitting"
@click="handleCancel"
class="rounded-lg border border-slate-300 px-3.5 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-300 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
:disabled="isSubmitDisabled"
class="rounded-lg bg-amber-500 px-4 py-2 text-xs font-semibold text-white shadow-sm hover:bg-amber-600 focus:outline-none focus:ring-4 focus:ring-amber-500/20 disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-400 transition-colors"
>
<span v-if="isSubmitting">Posting...</span>
<span v-else>Add Comment</span>
</button>
</div>
</div>
</form>

<!-- Comments List -->
<div class="space-y-4">
<!-- Only show the loading state on an empty list, so refetching after a reply
doesn't unmount the threads (and their open reply forms) mid-submit. -->
<div v-if="isLoadingComments && numComments === 0" class="py-6 text-center text-sm text-slate-500">
Loading comments...
</div>

<div v-else class="space-y-3">
<CommentThread
v-for="node in commentTree"
:key="node.comment.id"
:node="node"
:post-reply="postComment"
:nesting-level="0"
/>
<div
v-else-if="numComments === 0"
class="rounded-lg border border-dashed border-slate-300 bg-white/50 px-5 py-8 text-center text-sm text-slate-500"
>
No comments yet. Be the first to share your feedback!
</div>

<div v-else class="space-y-3">
<CommentThread
v-for="node in commentTree"
:key="node.comment.id"
:node="node"
:post-reply="postComment"
:nesting-level="0"
/>

<div v-if="comments.length < numComments" class="pt-2 text-center">
<button
type="button"
:disabled="isLoadingComments"
@click="loadMoreComments"
class="rounded-lg border border-slate-300 px-4 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-300 disabled:opacity-50"
>
<span v-if="isLoadingComments">Loading...</span>
<span v-else>Load more comments ({{ numComments - comments.length }} remaining)</span>
</button>
</div>
</div>
</div>
</div>
</section>
</section>
</div>
</div>

<!-- Voting action rail -->
<aside
class="xl:sticky xl:top-6"
aria-labelledby="vote-heading"
aria-label="Proposal actions"
>
<ProposalVotingCard
v-if="isVoting"
:proposal-id="proposal.id"
:current-vote="userVotes?.proposalVote?.choice"
@vote="recordVote"
/>
<ProposalExportCard :proposal-id="proposal.id" :proposal-name="proposal.name" />

<section
v-else
class="relative rounded-xl border border-indigo-100 bg-indigo-50/60 p-4 pl-14 mb-2"
aria-labelledby="voting-status-heading"
>
<span
class="absolute left-4 top-4 flex size-7 items-center justify-center rounded-full bg-indigo-100/80 text-indigo-600"
aria-hidden="true"
>
<svg
class="size-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 1.75" />
</svg>
</span>

<h2
id="voting-status-heading"
class="font-header text-sm font-semibold leading-6 tracking-tight text-slate-900"
>
Voting hasn't started
</h2>

<p class="mt-1 text-xs leading-5 text-slate-600">
You can vote once this proposal enters the
<span class="font-medium text-indigo-700">voting phase</span>.
</p>
</section>

<ProposalSponsorCard
:proposal-id="proposal.id"
:state="proposal.state"
/>

<ProposalExportCard
:proposal-id="proposal.id"
:proposal-name="proposal.name"
/>
</aside>
</div>

<EditProposalModal
v-if="isEditModalOpen"
v-model="isEditModalOpen"
:proposal="proposal"
/>
</article>
</template>

<script setup lang="ts">
import {computed, onMounted, ref, watch} from "vue";
import {computed, onMounted, ref, useTemplateRef, watch} from "vue";
import {useIntersectionObserver} from "@vueuse/core";
import {
CommentService,
type CommentWithUser,
ProposalService,
ProposalState,
ProposalWithParagraphs,
UserProposalVotes,
VoteChoice,
@@ -217,8 +385,11 @@ import {renderMarkdown} from "../utils/markdown.ts";
import Identification from "./icons/Identification.vue";
import AmendmentCarousel from "./AmendmentCarousel.vue";
import ProposalVotingCard from "./ProposalVotingCard.vue";
import ProposalSponsorCard from "./ProposalSponsorCard.vue";
import ProposalExportCard from "./ProposalExportCard.vue";
import EditProposalModal from "./modals/EditProposalModal.vue";
import CommentThread from "./CommentThread.vue";
import ProposalRevisionDropdown from "./ProposalRevisionDropdown.vue";
import {getUserIdFromJwt} from "../utils/auth.ts";
import {buildCommentTree} from "../utils/comments.ts";

@@ -226,10 +397,60 @@ const props = defineProps<{
proposal: ProposalWithParagraphs;
}>();

const titleEl = useTemplateRef<HTMLElement>('titleEl');
const isTitleVisible = ref(true);

// The top margin matches the sticky bar's height (h-14), so the bar appears
// as soon as the heading slides underneath it.
useIntersectionObserver(
titleEl,
([entry]) => {
isTitleVisible.value = entry?.isIntersecting ?? true;
},
{ rootMargin: '-56px 0px 0px 0px' }
);

const isEditModalOpen = ref(false);
const isEditable = computed(() => {
return props.proposal?.state === ProposalState.DRAFT || (props.proposal?.state as any) === 'DRAFT';
});

const isVoting = computed(() => {
return props.proposal?.state === ProposalState.VOTING || (props.proposal?.state as any) === 'VOTING';
});

const isApproved = computed(() => {
return props.proposal?.state === ProposalState.APPROVED || (props.proposal?.state as any) === 'APPROVED';
});

type ProposalTab = 'proposal' | 'discussion';

const tabs: { id: ProposalTab; label: string }[] = [
{ id: 'proposal', label: 'Proposal' },
{ id: 'discussion', label: 'Discussion' },
];

const activeTab = ref<ProposalTab>('proposal');
const tabRefs: Partial<Record<ProposalTab, HTMLElement>> = {};

const setTabRef = (id: ProposalTab, el: unknown) => {
if (el instanceof HTMLElement) tabRefs[id] = el;
};

// Arrow keys move between tabs, per the WAI-ARIA tabs pattern.
const focusAdjacentTab = (direction: 1 | -1) => {
const index = tabs.findIndex((tab) => tab.id === activeTab.value);
const next = tabs[(index + direction + tabs.length) % tabs.length];
activeTab.value = next.id;
tabRefs[next.id]?.focus();
};

const commentText = ref("");
const isSubmitting = ref(false);
const commentError = ref<string | null>(null);
const COMMENTS_PAGE_SIZE = 40;
const comments = ref<CommentWithUser[]>([]);
const numComments = ref(0);
const isLoadingComments = ref(false);

const userVotes = ref<UserProposalVotes | null>(null);
@@ -277,18 +498,61 @@ const handleCancel = () => {
commentError.value = null;
};

const fetchComments = async (proposalId?: number) => {
// Fetches pages of COMMENTS_PAGE_SIZE starting at `offset` until at least `count` comments
// have been loaded (or the proposal runs out of comments).
const fetchCommentRange = async (proposalId: number, offset: number, count: number) => {
const fetched: CommentWithUser[] = [];
let total = numComments.value;
while (fetched.length < count) {
const res = await CommentService.listComments(
proposalId,
offset + fetched.length,
COMMENTS_PAGE_SIZE,
);
const page = res.comments ?? [];
total = res.numComments ?? 0;
fetched.push(...page);
if (page.length < COMMENTS_PAGE_SIZE || offset + fetched.length >= total) break;
}
return {fetched, total};
};

// Replaces the loaded comments with the first `count` comments (at least one page), so a
// refetch after posting keeps every page the user has already expanded.
const fetchComments = async (proposalId?: number, count = COMMENTS_PAGE_SIZE) => {
if (proposalId === undefined || proposalId === null || isNaN(proposalId)) {
comments.value = [];
numComments.value = 0;
return;
}
isLoadingComments.value = true;
try {
const res = await CommentService.listComments(proposalId);
comments.value = res ?? [];
const {fetched, total} = await fetchCommentRange(proposalId, 0, Math.max(count, COMMENTS_PAGE_SIZE));
if (props.proposal?.id !== proposalId) return;
comments.value = fetched;
numComments.value = total;
} catch (err) {
console.error("Failed to load comments:", err);
comments.value = [];
numComments.value = 0;
} finally {
isLoadingComments.value = false;
}
};

const loadMoreComments = async () => {
const proposalId = props.proposal?.id;
if (!proposalId || isLoadingComments.value) return;
isLoadingComments.value = true;
try {
const {fetched, total} = await fetchCommentRange(proposalId, comments.value.length, COMMENTS_PAGE_SIZE);
if (props.proposal?.id !== proposalId) return;
// Comments posted since the last fetch can shift offsets, so skip any already loaded.
const loadedIds = new Set(comments.value.map((comment) => comment.id));
comments.value = [...comments.value, ...fetched.filter((comment) => !loadedIds.has(comment.id))];
numComments.value = total;
} catch (err) {
console.error("Failed to load more comments:", err);
} finally {
isLoadingComments.value = false;
}
@@ -302,7 +566,7 @@ const postComment = async (parentId: number | null, content: string) => {
parentId,
userId: 1,
});
await fetchComments(props.proposal.id);
await fetchComments(props.proposal.id, comments.value.length + 1);
};

const submitComment = async () => {
@@ -336,11 +600,13 @@ onMounted(() => {
watch(
() => props.proposal?.id,
(newId) => {
activeTab.value = 'proposal';
if (newId) {
fetchComments(newId);
fetchUserVotes(newId);
} else {
comments.value = [];
numComments.value = 0;
userVotes.value = null;
}
}


+ 372
- 0
src/components/ProposalRevisionDropdown.vue 파일 보기

@@ -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 &rarr;</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>

+ 219
- 0
src/components/ProposalSponsorCard.vue 파일 보기

@@ -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>

+ 1
- 1
src/components/Thread.vue 파일 보기

@@ -13,7 +13,7 @@
<script setup lang="ts">
import { CommentWithChildren } from "../types/comment.ts";

const props = defineProps<{ starterComment: CommentWithChildren }>();
defineProps<{ starterComment: CommentWithChildren }>();
</script>

<style scoped>


+ 192
- 0
src/components/modals/EditProposalModal.vue 파일 보기

@@ -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>

+ 17
- 1
src/components/modals/NewAmendmentModal.vue 파일 보기

@@ -40,7 +40,7 @@ import { useRoute } from "vue-router";
import * as freighter from "@stellar/freighter-api";
import Modal from "./Modal.vue";
import PostBuilder from "../PostBuilder.vue";
import { AmendmentService } from "../../generated/typescript";
import { AmendmentService, ProposalService, ProposalState } from "../../generated/typescript";
import { useModal } from "../../composables/useModal.ts";
import { eventBus } from "../../utils/eventBus.ts";
import type { ProposalParagraph } from "../../types/proposal.ts";
@@ -86,6 +86,22 @@ const submit = async () => {
errorMessage.value = null;

try {
const isApprovedFromModal = modal.getContent("isApproved")?.value;
if (isApprovedFromModal === "false") {
errorMessage.value = "Amendments can only be added once the proposal has reached the Approved state.";
return;
}

try {
const proposal = await ProposalService.getProposal(proposalId);
if (proposal?.state && proposal.state !== ProposalState.APPROVED && (proposal.state as any) !== 'APPROVED') {
errorMessage.value = "Amendments can only be added once the proposal has reached the Approved state.";
return;
}
} catch (fetchErr) {
console.warn("Could not verify proposal state before submitting amendment:", fetchErr);
}

const isAllowed = await freighter.isAllowed();
if (!isAllowed) {
await freighter.setAllowed();


+ 317
- 15
src/components/modals/RegistrationModal.vue 파일 보기

@@ -1,24 +1,326 @@
<template>
<Modal header="Register" model-value>
<slot>
<div class="my-3">
<input type="text" class="w-full border-gray-300 border border-solid py-2 px-2.5" placeholder="Username" v-model="username" />
</div>
<div class="my-3">
<input type="text" class="w-full border-gray-300 border border-solid py-2 px-2.5" placeholder="Full name" v-model="fullName" />
</div>
<div class="my-3">
<input type="password" class="w-full border-gray-300 border border-solid py-2 px-2.5" placeholder="Password" v-model="password" />
<Modal
v-model="isOpen"
header="Create Account"
:button-text="isSubmitting ? 'Registering...' : 'Register'"
:submit-disabled="isSubmitDisabled"
@submit="formRef?.requestSubmit()"
>
<div class="space-y-5">
<!-- Intro Banner -->
<div class="flex items-start gap-3.5 rounded-xl border border-amber-200/70 bg-gradient-to-r from-amber-50 to-orange-50/60 p-4 text-slate-800 shadow-xs">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-amber-500/15 text-amber-700">
<Identification size="lg" :aria-hidden="true" />
</div>
<div>
<h3 class="text-sm font-semibold text-slate-900">
Join Senara Network
</h3>
<p class="mt-0.5 text-xs text-slate-600 leading-relaxed">
Create your account with your organization ID to participate in governance, voting, and discussions.
</p>
</div>
</div>

<!-- Error Alert -->
<div
ref="errorAlertRef"
:class="errorMessage ? 'flex items-start gap-3 rounded-xl border border-rose-200 bg-rose-50/90 p-3.5 text-sm text-rose-800 shadow-xs' : 'sr-only'"
role="alert"
aria-live="assertive"
aria-atomic="true"
>
<template v-if="errorMessage">
<Cancel size="sm" class="mt-0.5 shrink-0 text-rose-600" :aria-hidden="true" />
<div class="flex-1">
<p class="font-medium text-rose-900">Registration Failed</p>
<p class="mt-0.5 text-xs text-rose-700 leading-relaxed">{{ errorMessage }}</p>
</div>
</template>
</div>

<!-- Success Alert -->
<div
:class="successMessage ? 'flex items-start gap-3 rounded-xl border border-emerald-200 bg-emerald-50/90 p-3.5 text-sm text-emerald-800 shadow-xs' : 'sr-only'"
role="status"
aria-live="polite"
aria-atomic="true"
>
<template v-if="successMessage">
<Confirm size="sm" class="mt-0.5 shrink-0 text-emerald-600" :aria-hidden="true" />
<div class="flex-1">
<p class="font-medium text-emerald-900">Registration Successful</p>
<p class="mt-0.5 text-xs text-emerald-700 leading-relaxed">{{ successMessage }}</p>
</div>
</template>
</div>

<!-- Form Grid -->
<form ref="formRef" class="space-y-4" @submit.prevent="submit">
<button type="submit" class="sr-only" tabindex="-1" aria-hidden="true" :disabled="isSubmitDisabled">
Register
</button>
<!-- Row 1: Username & Organization ID -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<div class="mb-1.5 flex items-center justify-between">
<label for="reg-username" class="text-xs font-semibold uppercase tracking-wider text-slate-700">
Username <span class="text-rose-500">*</span>
</label>
</div>
<input
id="reg-username"
v-model="username"
type="text"
autocomplete="username"
required
aria-required="true"
aria-describedby="reg-username-help"
class="w-full rounded-lg border border-slate-300 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-xs placeholder:text-slate-400 transition focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/15 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
placeholder="Choose username"
:disabled="isSubmitting"
/>
<p id="reg-username-help" class="mt-1 text-xs text-slate-500">
Unique handle for your account
</p>
</div>

<div>
<div class="mb-1.5 flex items-center justify-between">
<label for="reg-org-id" class="text-xs font-semibold uppercase tracking-wider text-slate-700">
Organization ID <span class="text-rose-500">*</span>
</label>
</div>
<input
id="reg-org-id"
v-model="organizationId"
type="number"
min="1"
step="1"
required
aria-required="true"
aria-describedby="reg-org-help"
class="w-full rounded-lg border border-slate-300 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-xs placeholder:text-slate-400 transition focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/15 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
placeholder="e.g. 1"
:disabled="isSubmitting"
/>
<p id="reg-org-help" class="mt-1 text-xs text-slate-500">
Positive numeric organization ID
</p>
</div>
</div>

<!-- Row 2: Full Name & Email (Optional) -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<div class="mb-1.5 flex items-center justify-between">
<label for="reg-fullname" class="text-xs font-semibold uppercase tracking-wider text-slate-700">
Full Name
</label>
<span class="rounded bg-slate-100 px-1.5 py-0.5 text-xs font-medium text-slate-600">
Optional
</span>
</div>
<input
id="reg-fullname"
v-model="fullName"
type="text"
autocomplete="name"
aria-describedby="reg-fullname-help"
class="w-full rounded-lg border border-slate-300 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-xs placeholder:text-slate-400 transition focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/15 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
placeholder="Jane Doe"
:disabled="isSubmitting"
/>
<p id="reg-fullname-help" class="mt-1 text-xs text-slate-500">
Display name visible to members
</p>
</div>

<div>
<div class="mb-1.5 flex items-center justify-between">
<label for="reg-email" class="text-xs font-semibold uppercase tracking-wider text-slate-700">
Email
</label>
<span class="rounded bg-slate-100 px-1.5 py-0.5 text-xs font-medium text-slate-600">
Optional
</span>
</div>
<input
id="reg-email"
v-model="email"
type="email"
autocomplete="email"
aria-describedby="reg-email-help"
class="w-full rounded-lg border border-slate-300 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-xs placeholder:text-slate-400 transition focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/15 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
placeholder="jane@example.com"
:disabled="isSubmitting"
/>
<p id="reg-email-help" class="mt-1 text-xs text-slate-500">
For notifications and updates
</p>
</div>
</div>

<!-- Row 3: Password (Full width) -->
<div>
<div class="mb-1.5 flex items-center justify-between">
<label for="reg-password" class="text-xs font-semibold uppercase tracking-wider text-slate-700">
Password <span class="text-rose-500">*</span>
</label>
</div>
<div class="relative">
<input
id="reg-password"
v-model="password"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
required
aria-required="true"
aria-describedby="reg-password-help"
class="w-full rounded-lg border border-slate-300 bg-white pl-3.5 pr-10 py-2.5 text-sm text-slate-900 shadow-xs placeholder:text-slate-400 transition focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/15 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
placeholder="Enter a secure password"
:disabled="isSubmitting"
/>
<button
type="button"
class="absolute inset-y-0 right-0 flex items-center rounded-md pr-3 text-slate-500 transition hover:text-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/50 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:text-slate-400"
:aria-label="showPassword ? 'Hide password' : 'Show password'"
:aria-pressed="showPassword"
aria-controls="reg-password"
:disabled="isSubmitting"
@click="showPassword = !showPassword"
>
<svg
v-if="showPassword"
class="h-4 w-4"
aria-hidden="true"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l18 18" />
</svg>
<svg
v-else
class="h-4 w-4"
aria-hidden="true"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
</div>
<p id="reg-password-help" class="mt-1 text-xs text-slate-500">
Must not be empty
</p>
</div>
</form>
</div>
</slot>
</Modal>
</Modal>
</template>

<script setup lang="ts">
<script setup lang="ts" generic="T extends ContentModal">
import { computed, ref, onUnmounted, nextTick } from "vue";
import Modal from "./Modal.vue";
import { ref } from "vue";
import Identification from "../icons/Identification.vue";
import Confirm from "../icons/Confirm.vue";
import Cancel from "../icons/Cancel.vue";
import { AuthService } from "../../generated/typescript";
import { ContentModal, useModal } from "../../composables/useModal.ts";
import type { RegisterRequest } from "../../generated/typescript";

const modal = useModal();
const formRef = ref<HTMLFormElement | null>(null);
const isOpen = ref(modal.getVisibility());

const username = ref("");
const organizationId = ref<number | string>("");
const fullName = ref("");
const email = ref("");
const password = ref("");
const showPassword = ref(false);

let timeoutId: ReturnType<typeof setTimeout> | undefined;
let disposed = false;
const isSubmitting = ref(false);
const errorMessage = ref<string | null>(null);
const successMessage = ref<string | null>(null);
const errorAlertRef = ref<HTMLElement | null>(null);

onUnmounted(() => {
disposed = true;
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
});

const isSubmitDisabled = computed(() => {
return (
!username.value.trim() ||
!password.value ||
!organizationId.value ||
isNaN(Number(organizationId.value)) ||
Number(organizationId.value) <= 0 ||
isSubmitting.value
);
});

const submit = async () => {
if (isSubmitDisabled.value) return;

isSubmitting.value = true;
errorMessage.value = null;
successMessage.value = null;

const requestBody: RegisterRequest = {
username: username.value.trim(),
password: password.value,
organizationId: Number(organizationId.value),
fullName: fullName.value.trim() || null,
email: email.value.trim() || null,
};

const [username, fullName, password] = [ref(''), ref(''), ref('')];
try {
const user = await AuthService.register(requestBody);
if (disposed) return;
successMessage.value = `User ${user.username || requestBody.username} registered successfully!`;
username.value = "";
organizationId.value = "";
fullName.value = "";
email.value = "";
password.value = "";
timeoutId = setTimeout(() => {
timeoutId = undefined;
if (!disposed) modal.hide();
}, 1500);
} catch (err: any) {
if (disposed) return;
console.error("Registration failed:", err);
if (typeof err?.body === "string") {
errorMessage.value = err.body;
} else if (err?.body?.message) {
errorMessage.value = err.body.message;
} else if (err?.body?.error) {
errorMessage.value = err.body.error;
} else if (err?.body?.detail) {
errorMessage.value = err.body.detail;
} else if (err?.message) {
errorMessage.value = err.message;
} else {
errorMessage.value = "Failed to register. Please check your inputs and try again.";
}
await nextTick();
errorAlertRef.value?.scrollIntoView({ behavior: "smooth", block: "nearest" });
} finally {
if (!disposed) isSubmitting.value = false;
}
};
</script>

<style scoped>


+ 13
- 0
src/generated/typescript/index.ts 파일 보기

@@ -17,23 +17,36 @@ export type { AmendmentVote } from './models/AmendmentVote';
export type { AmendmentWithParagraphs } from './models/AmendmentWithParagraphs';
export type { AmendmentWithSummary } from './models/AmendmentWithSummary';
export type { Claims } from './models/Claims';
export type { CommentReaction } from './models/CommentReaction';
export type { CommentReactionSummary } from './models/CommentReactionSummary';
export type { CommentWithUser } from './models/CommentWithUser';
export type { EditProposalRequest } from './models/EditProposalRequest';
export type { FreighterLoginRequest } from './models/FreighterLoginRequest';
export type { FreighterLoginResponse } from './models/FreighterLoginResponse';
export type { ListCommentsResponse } from './models/ListCommentsResponse';
export type { LoginRequest } from './models/LoginRequest';
export type { Organization } from './models/Organization';
export type { Proposal } from './models/Proposal';
export type { ProposalParagraph } from './models/ProposalParagraph';
export type { ProposalParagraphRequest } from './models/ProposalParagraphRequest';
export type { ProposalRevision } from './models/ProposalRevision';
export type { ProposalRevisionBlock } from './models/ProposalRevisionBlock';
export type { ProposalSponsor } from './models/ProposalSponsor';
export { ProposalState } from './models/ProposalState';
export type { ProposalVote } from './models/ProposalVote';
export type { ProposalWithParagraphs } from './models/ProposalWithParagraphs';
export type { ProposalWithSummary } from './models/ProposalWithSummary';
export type { ReactCommentRequest } from './models/ReactCommentRequest';
export type { ReactCommentResponse } from './models/ReactCommentResponse';
export { ReactionType } from './models/ReactionType';
export type { RegisteredUser } from './models/RegisteredUser';
export type { RegisterOrganizationRequest } from './models/RegisterOrganizationRequest';
export type { RegisterRequest } from './models/RegisterRequest';
export type { SelectableAmendment } from './models/SelectableAmendment';
export type { SelectableComment } from './models/SelectableComment';
export type { SelectableProposal } from './models/SelectableProposal';
export type { SetProposalRevisionRequest } from './models/SetProposalRevisionRequest';
export type { SponsorProposalRequest } from './models/SponsorProposalRequest';
export type { UserAmendmentVote } from './models/UserAmendmentVote';
export type { UserProposalVotes } from './models/UserProposalVotes';
export type { VoteAmendmentRequest } from './models/VoteAmendmentRequest';


+ 17
- 0
src/generated/typescript/models/CommentReaction.ts 파일 보기

@@ -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;
};


+ 15
- 0
src/generated/typescript/models/CommentReactionSummary.ts 파일 보기

@@ -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);
};


+ 22
- 0
src/generated/typescript/models/EditProposalRequest.ts 파일 보기

@@ -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;
};


+ 10
- 0
src/generated/typescript/models/ListCommentsResponse.ts 파일 보기

@@ -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;
};


+ 3
- 0
src/generated/typescript/models/ProposalParagraph.ts 파일 보기

@@ -2,6 +2,9 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* A block of a proposal's live content, i.e. of its current revision
*/
export type ProposalParagraph = {
id: number;
index: number;


+ 26
- 0
src/generated/typescript/models/ProposalRevision.ts 파일 보기

@@ -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;
};


+ 9
- 0
src/generated/typescript/models/ProposalRevisionBlock.ts 파일 보기

@@ -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;
};


+ 17
- 0
src/generated/typescript/models/ProposalSponsor.ts 파일 보기

@@ -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;
};


+ 13
- 0
src/generated/typescript/models/ProposalState.ts 파일 보기

@@ -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',
}

+ 7
- 0
src/generated/typescript/models/ProposalWithParagraphs.ts 파일 보기

@@ -3,12 +3,19 @@
/* tslint:disable */
/* eslint-disable */
import type { ProposalParagraph } from './ProposalParagraph';
import type { ProposalState } from './ProposalState';
export type ProposalWithParagraphs = {
createdAt: string;
creator: string;
id: number;
name: string;
paragraphs: Array<ProposalParagraph>;
/**
* Current draft revision, which may be older than the latest after a rollback; send as
* `baseRevision` when editing. 0 if the proposal has no recorded revisions
*/
revision: number;
state: ProposalState;
updatedAt?: string | null;
};


+ 2
- 0
src/generated/typescript/models/ProposalWithSummary.ts 파일 보기

@@ -2,11 +2,13 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ProposalState } from './ProposalState';
export type ProposalWithSummary = {
createdAt: string;
creator: string;
id: number;
name: string;
state: ProposalState;
summary: string;
updatedAt?: string | null;
};


+ 10
- 0
src/generated/typescript/models/ReactCommentRequest.ts 파일 보기

@@ -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);
};


+ 13
- 0
src/generated/typescript/models/ReactCommentResponse.ts 파일 보기

@@ -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;
};


+ 8
- 0
src/generated/typescript/models/ReactionType.ts 파일 보기

@@ -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',
}

+ 6
- 0
src/generated/typescript/models/SelectableProposal.ts 파일 보기

@@ -2,12 +2,18 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ProposalState } from './ProposalState';
export type SelectableProposal = {
createdAt: string;
creator: string;
/**
* Revision whose snapshot is the live content; None if the proposal has no recorded revisions
*/
currentRevision?: number | null;
id: number;
name: string;
organizationId: number;
state: ProposalState;
updatedAt?: string | null;
};


+ 16
- 0
src/generated/typescript/models/SetProposalRevisionRequest.ts 파일 보기

@@ -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;
};


+ 8
- 0
src/generated/typescript/models/SponsorProposalRequest.ts 파일 보기

@@ -0,0 +1,8 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type SponsorProposalRequest = {
proposalId: number;
};


+ 3
- 0
src/generated/typescript/services/AmendmentService.ts 파일 보기

@@ -27,6 +27,9 @@ export class AmendmentService {
mediaType: 'application/json',
errors: {
400: `Bad request`,
401: `Missing, invalid, or expired token`,
404: `Proposal not found in the current user's organization`,
409: `Proposal is not in the \`APPROVED\` state`,
500: `Internal server error`,
},
});


+ 55
- 2
src/generated/typescript/services/CommentService.ts 파일 보기

@@ -3,7 +3,11 @@
/* tslint:disable */
/* eslint-disable */
import type { AddCommentRequest } from '../models/AddCommentRequest';
import type { CommentReactionSummary } from '../models/CommentReactionSummary';
import type { CommentWithUser } from '../models/CommentWithUser';
import type { ListCommentsResponse } from '../models/ListCommentsResponse';
import type { ReactCommentRequest } from '../models/ReactCommentRequest';
import type { ReactCommentResponse } from '../models/ReactCommentResponse';
import type { SelectableComment } from '../models/SelectableComment';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
@@ -50,19 +54,68 @@ export class CommentService {
},
});
}
/**
* @param requestBody
* @returns ReactCommentResponse Reaction result; `recorded` is false if the reaction was unchanged
* @throws ApiError
*/
public static reactComment(
requestBody: ReactCommentRequest,
): CancelablePromise<ReactCommentResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/comment/react',
body: requestBody,
mediaType: 'application/json',
errors: {
400: `Bad request`,
401: `Missing, invalid, or expired token`,
404: `Comment not found in the current user's organization`,
500: `Internal server error`,
},
});
}
/**
* @param commentId
* @returns CommentReactionSummary Current reaction totals and the current user's reaction
* @throws ApiError
*/
public static getCommentReactions(
commentId: number,
): CancelablePromise<CommentReactionSummary> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/comment/reactions',
query: {
'commentId': commentId,
},
errors: {
400: `Bad request`,
401: `Missing, invalid, or expired token`,
404: `Comment not found`,
500: `Internal server error`,
},
});
}
/**
* @param proposalId
* @returns CommentWithUser List of comments for a proposal
* @param offset Number of comments to skip. Defaults to 0.
* @param limit Maximum number of comments to return (1–40). Defaults to 40.
* @returns ListCommentsResponse A page of comments for a proposal; `numComments` is the total across all pages
* @throws ApiError
*/
public static listComments(
proposalId: number,
): CancelablePromise<Array<CommentWithUser>> {
offset?: number | null,
limit?: number | null,
): CancelablePromise<ListCommentsResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/comments/list',
query: {
'proposalId': proposalId,
'offset': offset,
'limit': limit,
},
errors: {
400: `Bad request`,


+ 140
- 1
src/generated/typescript/services/ProposalService.ts 파일 보기

@@ -3,8 +3,13 @@
/* tslint:disable */
/* eslint-disable */
import type { AddProposalRequest } from '../models/AddProposalRequest';
import type { EditProposalRequest } from '../models/EditProposalRequest';
import type { ProposalRevision } from '../models/ProposalRevision';
import type { ProposalSponsor } from '../models/ProposalSponsor';
import type { ProposalVote } from '../models/ProposalVote';
import type { ProposalWithParagraphs } from '../models/ProposalWithParagraphs';
import type { SetProposalRevisionRequest } from '../models/SetProposalRevisionRequest';
import type { SponsorProposalRequest } from '../models/SponsorProposalRequest';
import type { UserProposalVotes } from '../models/UserProposalVotes';
import type { VoteProposalRequest } from '../models/VoteProposalRequest';
import type { CancelablePromise } from '../core/CancelablePromise';
@@ -51,6 +56,50 @@ export class ProposalService {
},
});
}
/**
* @param requestBody
* @returns ProposalRevision The recorded revision
* @throws ApiError
*/
public static editProposal(
requestBody: EditProposalRequest,
): CancelablePromise<ProposalRevision> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/proposal/edit',
body: requestBody,
mediaType: 'application/json',
errors: {
400: `Bad request`,
401: `Missing, invalid, or expired token`,
404: `Proposal not found in the current user's organization`,
409: `Proposal is not a draft, or has been edited since \`baseRevision\``,
500: `Internal server error`,
},
});
}
/**
* @param proposalId
* @returns ProposalRevision The proposal's draft revision history, newest first
* @throws ApiError
*/
public static listProposalEdits(
proposalId: number,
): CancelablePromise<Array<ProposalRevision>> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/proposal/edits',
query: {
'proposalId': proposalId,
},
errors: {
400: `Bad request`,
401: `Missing, invalid, or expired token`,
404: `Proposal not found`,
500: `Internal server error`,
},
});
}
/**
* @param id
* @returns any Proposal retrieved successfully
@@ -73,6 +122,96 @@ export class ProposalService {
},
});
}
/**
* @param requestBody
* @returns ProposalWithParagraphs The proposal with the selected revision's content applied
* @throws ApiError
*/
public static setProposalRevision(
requestBody: SetProposalRevisionRequest,
): CancelablePromise<ProposalWithParagraphs> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/proposal/revision',
body: requestBody,
mediaType: 'application/json',
errors: {
400: `Bad request`,
401: `Missing, invalid, or expired token`,
403: `Current user is not the proposal's original author`,
404: `Proposal or revision not found`,
409: `Proposal is not a draft, or has been edited since \`baseRevision\``,
500: `Internal server error`,
},
});
}
/**
* @param requestBody
* @returns ProposalSponsor The recorded sponsorship
* @throws ApiError
*/
public static sponsorProposal(
requestBody: SponsorProposalRequest,
): CancelablePromise<ProposalSponsor> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/proposal/sponsor',
body: requestBody,
mediaType: 'application/json',
errors: {
400: `Bad request`,
401: `Missing, invalid, or expired token`,
403: `Current user is not an active member of the organization`,
404: `Proposal not found in the current user's organization`,
409: `User has already sponsored this proposal, or the proposal is not in the \`DRAFT\` state`,
500: `Internal server error`,
},
});
}
/**
* @param proposalId
* @returns ProposalSponsor The proposal's sponsors, oldest first
* @throws ApiError
*/
public static listProposalSponsors(
proposalId: number,
): CancelablePromise<Array<ProposalSponsor>> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/proposal/sponsors',
query: {
'proposalId': proposalId,
},
errors: {
400: `Bad request`,
401: `Missing, invalid, or expired token`,
404: `Proposal not found`,
500: `Internal server error`,
},
});
}
/**
* @param requestBody
* @returns any Sponsorship withdrawn
* @throws ApiError
*/
public static unsponsorProposal(
requestBody: SponsorProposalRequest,
): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/proposal/unsponsor',
body: requestBody,
mediaType: 'application/json',
errors: {
400: `Bad request`,
401: `Missing, invalid, or expired token`,
404: `Proposal not found in the current user's organization, or the user has not sponsored it`,
409: `Proposal is not in the \`DRAFT\` state`,
500: `Internal server error`,
},
});
}
/**
* @param requestBody
* @returns ProposalVote The recorded vote
@@ -90,7 +229,7 @@ export class ProposalService {
400: `Bad request`,
401: `Missing, invalid, or expired token`,
404: `Proposal not found in the current user's organization`,
409: `User has already voted on this proposal`,
409: `User has already voted on this proposal, or the proposal is not in the \`VOTING\` state`,
500: `Internal server error`,
},
});


+ 14
- 4
src/views/HomeView.vue 파일 보기

@@ -16,7 +16,7 @@
></div>

<!-- Main Content -->
<main class="h-full flex-1 overflow-y-auto bg-slate-100">
<main class="h-full min-w-0 flex-1 bg-slate-100">
<RouterView />
</main>
</div>
@@ -54,11 +54,20 @@ const isSidebarOpen = ref(false);

const [offset, limit] = [ref(0), ref(10)];

showLoading('proposals');
const fetchProposals = async () => {
showLoading('proposals');
try {
proposals.value = await ProposalService.listProposals(offset.value, limit.value);
} catch (err) {
console.error('Failed to list proposals', err);
} finally {
hideLoading('proposals');
}
};

onMounted(async () => {
proposals.value = await ProposalService.listProposals(offset.value, limit.value);
hideLoading('proposals');
await fetchProposals();
eventBus.on('proposalUpdated', fetchProposals);
});

watch(() => route.path, () => {
@@ -68,5 +77,6 @@ watch(() => route.path, () => {
onUnmounted(() => {
unregister('proposals');
eventBus.off('filtersApplied');
eventBus.off('proposalUpdated');
});
</script>

+ 14
- 1
src/views/ProposalView.vue 파일 보기

@@ -17,9 +17,10 @@
</template>

<script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue';
import { ref, watch, onMounted, onUnmounted } from 'vue';
import { ProposalService, ProposalWithParagraphs } from '../generated/typescript';
import { useLoading } from '../composables/useLoading';
import { eventBus } from '../utils/eventBus';
import ProposalDetail from '../components/ProposalDetail.vue';
import ProposalEmptyState from '../components/ProposalEmptyState.vue';
import LoadingSpinner from '../components/LoadingSpinner.vue';
@@ -52,6 +53,13 @@ const fetchProposal = async (id: number | string) => {
}
};

const handleProposalUpdated = (updatedId?: number) => {
const currentNumericId = Number(props.id);
if (!updatedId || updatedId === currentNumericId) {
fetchProposal(props.id);
}
};

watch(
() => props.id,
(newId) => {
@@ -62,7 +70,12 @@ watch(
{ immediate: true }
);

onMounted(() => {
eventBus.on('proposalUpdated', handleProposalUpdated);
});

onUnmounted(() => {
unregister('content');
eventBus.off('proposalUpdated');
});
</script>

+ 1
- 1
tsconfig.app.tsbuildinfo
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
파일 보기


불러오는 중...
취소
저장