| @@ -2,7 +2,9 @@ | |||
| <div class="flex flex-row justify-between bg-gray-500 drop-shadow-md px-3 py-2"> | |||
| <span class="text-4xl font-header font-bold"> | |||
| <!-- https://senara.network --> | |||
| <ColoredHeader text="Senara" from="#F2F3E2" to="#B2E5F8" /> | |||
| <RouterLink to="/"> | |||
| <ColoredHeader text="Senara" from="#F2F3E2" to="#B2E5F8" /> | |||
| </RouterLink> | |||
| </span> | |||
| <div class="flex flex-col justify-center items-center"> | |||
| <button | |||
| @@ -40,32 +40,17 @@ | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import {ProposalSelection} from "../types/proposal.ts"; | |||
| import {truncateWallet} from "../utils/wallet.ts"; | |||
| import DOMPurify from "dompurify"; | |||
| import {parse} from "marked"; | |||
| import {useLoading} from "../composables/useLoading.ts"; | |||
| import {ProposalService, ProposalItem} from "../generated/typescript"; | |||
| import {renderMarkdown} from "../utils/markdown.ts"; | |||
| import {ProposalWithSummary} from "../generated/typescript"; | |||
| import Identification from "./icons/Identification.vue"; | |||
| const {show: showLoading, hide: hideLoading} = useLoading(); | |||
| const props = defineProps<{ proposal: ProposalItem, selected: boolean }>(); | |||
| const props = defineProps<{ proposal: ProposalWithSummary, selected: boolean }>(); | |||
| const emits = defineEmits<{ | |||
| (e: 'selected', selection: ProposalSelection): void | |||
| (e: 'selected', selectionId: number): void | |||
| }>(); | |||
| const handleSelection = async () => { | |||
| showLoading('content'); | |||
| const proposalContent = await ProposalService.getProposal(props.proposal.id); | |||
| proposalContent.proposal.id = props.proposal.id; | |||
| emits('selected', {proposal: proposalContent.proposal}); | |||
| hideLoading('content'); | |||
| } | |||
| const renderMarkdown = (markdown?: string | null) => { | |||
| if (!markdown) return; | |||
| const parsed = parse(markdown) as string; | |||
| return DOMPurify.sanitize(parsed); | |||
| const handleSelection = () => { | |||
| emits('selected', props.proposal.id); | |||
| } | |||
| </script> | |||
| @@ -0,0 +1,145 @@ | |||
| <template> | |||
| <article | |||
| class="mx-auto w-full max-w-[96rem] px-5 py-6 sm:px-8 lg:px-10 lg:py-10" | |||
| :aria-labelledby="`proposal-${proposal.id}-title`" | |||
| > | |||
| <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> | |||
| <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> | |||
| </div> | |||
| <h1 | |||
| :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" | |||
| > | |||
| {{ proposal.name }} | |||
| </h1> | |||
| <div class="mt-5 flex min-w-0 items-center gap-2 text-xs text-slate-600"> | |||
| <Identification | |||
| class="size-4 shrink-0 text-slate-700" | |||
| aria-hidden="true" | |||
| /> | |||
| <span class="font-semibold uppercase tracking-[0.1em]"> | |||
| Created by | |||
| </span> | |||
| <span | |||
| class="truncate font-medium text-slate-800" | |||
| :title="proposal.creator" | |||
| > | |||
| {{ truncateWallet(proposal.creator) }} | |||
| </span> | |||
| </div> | |||
| </header> | |||
| <section | |||
| class="max-w-3xl py-8" | |||
| aria-labelledby="proposal-content-heading" | |||
| > | |||
| <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> | |||
| <span | |||
| class="text-[0.65rem] font-bold uppercase tracking-[0.1em] text-slate-500" | |||
| > | |||
| Proposed changes | |||
| </span> | |||
| </div> | |||
| <AmendmentCarousel :proposal-id="proposal.id" /> | |||
| </section> | |||
| <section | |||
| class="border-t border-slate-300 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> | |||
| <div | |||
| v-if="false" | |||
| class="rounded-lg border border-dashed border-slate-300 bg-white/50 px-5 py-8 | |||
| text-sm text-slate-500" | |||
| > | |||
| Discussion will appear here. | |||
| </div> | |||
| </section> | |||
| </div> | |||
| <!-- Voting action rail --> | |||
| <aside | |||
| class="xl:sticky xl:top-6" | |||
| aria-labelledby="vote-heading" | |||
| > | |||
| <ProposalVotingCard :proposal-id="proposal.id" /> | |||
| <ProposalExportCard :proposal-id="proposal.id" :proposal-name="proposal.name" /> | |||
| </aside> | |||
| </div> | |||
| </article> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import { ProposalWithParagraphs } from "../generated/typescript"; | |||
| import { truncateWallet } from "../utils/wallet.ts"; | |||
| import { renderMarkdown } from "../utils/markdown.ts"; | |||
| import Identification from "./icons/Identification.vue"; | |||
| import AmendmentCarousel from "./AmendmentCarousel.vue"; | |||
| import ProposalVotingCard from "./ProposalVotingCard.vue"; | |||
| import ProposalExportCard from "./ProposalExportCard.vue"; | |||
| defineProps<{ | |||
| proposal: ProposalWithParagraphs; | |||
| }>(); | |||
| </script> | |||
| @@ -0,0 +1,16 @@ | |||
| <template> | |||
| <div class="flex h-full items-center justify-center p-8"> | |||
| <div class="max-w-sm text-center"> | |||
| <p class="font-header text-2xl font-bold text-slate-700"> | |||
| Select a proposal | |||
| </p> | |||
| <p class="mt-2 text-sm leading-6 text-slate-500"> | |||
| Choose a proposal from the list to read its details, review amendments, | |||
| and cast a vote. | |||
| </p> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| </script> | |||
| @@ -0,0 +1,63 @@ | |||
| <template> | |||
| <section | |||
| class="rounded-xl border border-slate-300 bg-white p-4 shadow-sm" | |||
| aria-labelledby="proposal-download-heading" | |||
| > | |||
| <div class="mb-3 flex items-start justify-between gap-3"> | |||
| <div> | |||
| <h2 | |||
| id="proposal-download-heading" | |||
| class="font-header text-base font-bold tracking-tight text-slate-950" | |||
| > | |||
| Proposal record | |||
| </h2> | |||
| <p class="mt-1 text-xs leading-5 text-slate-500"> | |||
| Download a portable Markdown copy. | |||
| </p> | |||
| </div> | |||
| <span | |||
| class="rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 | |||
| text-[0.6rem] font-bold uppercase tracking-[0.1em] text-slate-500" | |||
| > | |||
| .md | |||
| </span> | |||
| </div> | |||
| <button | |||
| type="button" | |||
| @click="handleDownload" | |||
| class="group flex h-10 w-full items-center justify-center gap-2 rounded-md | |||
| bg-blue-950 px-3 text-sm font-semibold text-white shadow-sm | |||
| transition duration-150 | |||
| hover:-translate-y-px hover:bg-blue-900 hover:shadow-md | |||
| active:translate-y-0 active:bg-blue-950 active:shadow-none | |||
| focus-visible:outline focus-visible:outline-2 | |||
| focus-visible:outline-offset-2 focus-visible:outline-blue-950" | |||
| > | |||
| <Download | |||
| class="size-4 transition-transform duration-150 group-hover:translate-y-0.5" | |||
| aria-hidden="true" | |||
| /> | |||
| <span>Download proposal</span> | |||
| </button> | |||
| </section> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import Download from "./icons/Download.vue"; | |||
| import { downloadProposalMarkdown } from "../utils/proposalExport.ts"; | |||
| const props = defineProps<{ | |||
| proposalId: number; | |||
| proposalName?: string; | |||
| }>(); | |||
| const handleDownload = async () => { | |||
| if (props.proposalId != null) { | |||
| await downloadProposalMarkdown(props.proposalId); | |||
| } | |||
| }; | |||
| </script> | |||
| @@ -0,0 +1,93 @@ | |||
| <template> | |||
| <section | |||
| class="overflow-hidden rounded-xl border border-slate-300 bg-white shadow-sm mb-2" | |||
| aria-labelledby="vote-heading" | |||
| > | |||
| <div class="border-b border-slate-200 px-5 py-4"> | |||
| <h2 | |||
| id="vote-heading" | |||
| class="font-header text-lg font-bold tracking-tight text-slate-950" | |||
| > | |||
| Cast your vote | |||
| </h2> | |||
| <p class="mt-1 text-xs leading-5 text-slate-500"> | |||
| Choose one position for this proposal. | |||
| </p> | |||
| </div> | |||
| <div class="space-y-2 p-3"> | |||
| <button | |||
| type="button" | |||
| @click="emit('vote', 'Positive')" | |||
| class="group flex w-full items-center gap-3 rounded-lg border border-slate-300 | |||
| bg-white px-3 py-2.5 text-left text-slate-700 transition | |||
| hover:border-green-600 hover:bg-green-600 hover:text-white | |||
| focus-visible:outline focus-visible:outline-2 | |||
| focus-visible:outline-offset-2 focus-visible:outline-slate-950" | |||
| > | |||
| <Confirm | |||
| class="size-5 shrink-0 fill-current" | |||
| :box="96" | |||
| aria-hidden="true" | |||
| /> | |||
| <span class="font-semibold">Approve</span> | |||
| </button> | |||
| <button | |||
| type="button" | |||
| @click="emit('vote', 'Negative')" | |||
| class="group flex w-full items-center gap-3 rounded-lg border border-slate-300 | |||
| bg-white px-3 py-2.5 text-left text-slate-700 transition | |||
| hover:border-red-600 hover:bg-red-600 hover:text-white | |||
| focus-visible:outline focus-visible:outline-2 | |||
| focus-visible:outline-offset-2 focus-visible:outline-slate-950" | |||
| > | |||
| <Cancel | |||
| class="size-5 shrink-0 fill-current" | |||
| :box="96" | |||
| aria-hidden="true" | |||
| /> | |||
| <span class="font-semibold">Reject</span> | |||
| </button> | |||
| <button | |||
| type="button" | |||
| @click="emit('vote', 'Abstain')" | |||
| class="group flex w-full items-center gap-3 rounded-lg border border-slate-300 | |||
| bg-white px-3 py-2.5 text-left text-slate-700 transition | |||
| hover:border-amber-500 hover:bg-amber-500 hover:text-slate-950 | |||
| focus-visible:outline focus-visible:outline-2 | |||
| focus-visible:outline-offset-2 focus-visible:outline-slate-950" | |||
| > | |||
| <Abstain | |||
| class="size-5 shrink-0 fill-current" | |||
| :box="96" | |||
| aria-hidden="true" | |||
| /> | |||
| <span class="font-semibold">Abstain</span> | |||
| </button> | |||
| </div> | |||
| <div class="border-t border-slate-200 bg-slate-50 px-5 py-4"> | |||
| <p class="text-xs leading-5 text-slate-500"> | |||
| Your selection can be shown here later, alongside quorum, | |||
| voting power, and the deadline. | |||
| </p> | |||
| </div> | |||
| </section> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import Confirm from "./icons/Confirm.vue"; | |||
| import Cancel from "./icons/Cancel.vue"; | |||
| import Abstain from "./icons/Abstain.vue"; | |||
| defineProps<{ | |||
| proposalId?: number; | |||
| }>(); | |||
| const emit = defineEmits<{ | |||
| (e: 'vote', type: 'Positive' | 'Negative' | 'Abstain'): void; | |||
| }>(); | |||
| </script> | |||
| @@ -21,7 +21,7 @@ | |||
| :key="proposal.id" | |||
| :proposal="proposal" | |||
| :selected="selectedId === proposal.id" | |||
| @selected="handleSelection" | |||
| @selected="handleSelection(proposal.id)" | |||
| /> | |||
| <LoadingSpinner | |||
| v-if="isLoading('proposals')" | |||
| @@ -32,40 +32,45 @@ | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import { computed } from "vue"; | |||
| import { useRoute, useRouter } from "vue-router"; | |||
| import ProposalContainer from "./ProposalContainer.vue"; | |||
| import LoadingSpinner from "./LoadingSpinner.vue"; | |||
| import {ProposalSelection} from "../types/proposal.ts"; | |||
| import {useLoading} from "../composables/useLoading.ts"; | |||
| import { useLoading } from "../composables/useLoading.ts"; | |||
| import ProposalModal from "./modals/ProposalModal.vue"; | |||
| import {ContentModal, useModal} from "../composables/useModal.ts"; | |||
| import { ContentModal, useModal } from "../composables/useModal.ts"; | |||
| import FilterModal from "./modals/FilterModal.vue"; | |||
| import Filter from "./icons/Filter.vue"; | |||
| import {ref} from "vue"; | |||
| import {ProposalList} from "../generated/typescript"; | |||
| import { ProposalWithSummary } from "../generated/typescript"; | |||
| const { show: showModal } = useModal(); | |||
| const route = useRoute(); | |||
| const router = useRouter(); | |||
| const props = defineProps<{ proposals: ProposalList | undefined }>(); | |||
| const emit = defineEmits(['update:selection']); | |||
| defineProps<{ proposals: ProposalWithSummary[] | undefined }>(); | |||
| const emit = defineEmits<{ | |||
| "update:selection": [proposalId: number] | |||
| }>(); | |||
| const {isLoading} = useLoading(); | |||
| const { isLoading } = useLoading(); | |||
| const selectedId = ref<number>(); | |||
| const selectedId = computed(() => { | |||
| return route.params.id ? Number(route.params.id) : undefined; | |||
| }); | |||
| const handleSelection = (proposal: ProposalSelection) => { | |||
| const id = proposal.proposal.id; | |||
| if (selectedId.value && selectedId.value === id) return; | |||
| selectedId.value = id; | |||
| emit('update:selection', {proposal: proposal.proposal}); | |||
| } | |||
| const handleSelection = (proposalId: number) => { | |||
| if (selectedId.value === proposalId) return; | |||
| router.push({ name: 'proposal', params: { id: proposalId } }); | |||
| emit('update:selection', proposalId); | |||
| }; | |||
| const showNewProposalModal = () => { | |||
| showModal(ProposalModal as ContentModal); | |||
| } | |||
| }; | |||
| const showFilterModal = () => { | |||
| showModal(FilterModal as ContentModal); | |||
| } | |||
| }; | |||
| </script> | |||
| <style lang="postcss"> | |||
| @@ -0,0 +1,18 @@ | |||
| <template> | |||
| <svg | |||
| xmlns="http://www.w3.org/2000/svg" | |||
| fill="none" | |||
| viewBox="0 0 24 24" | |||
| stroke-width="2" | |||
| stroke="currentColor" | |||
| > | |||
| <path | |||
| stroke-linecap="round" | |||
| stroke-linejoin="round" | |||
| d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" | |||
| /> | |||
| </svg> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| </script> | |||
| @@ -3,6 +3,7 @@ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| export type ProposalParagraph = { | |||
| id: number; | |||
| index: number; | |||
| paragraphText: string; | |||
| proposalId: number; | |||
| @@ -3,6 +3,7 @@ | |||
| /* tslint:disable */ | |||
| /* eslint-disable */ | |||
| import type { AddProposalRequest } from '../models/AddProposalRequest'; | |||
| import type { ProposalWithParagraphs } from '../models/ProposalWithParagraphs'; | |||
| import type { CancelablePromise } from '../core/CancelablePromise'; | |||
| import { OpenAPI } from '../core/OpenAPI'; | |||
| import { request as __request } from '../core/request'; | |||
| @@ -26,6 +27,25 @@ export class ProposalService { | |||
| }, | |||
| }); | |||
| } | |||
| /** | |||
| * @param propId | |||
| * @returns ProposalWithParagraphs Proposal with paragraphs | |||
| * @throws ApiError | |||
| */ | |||
| public static getProposalContent( | |||
| propId: number, | |||
| ): CancelablePromise<ProposalWithParagraphs> { | |||
| return __request(OpenAPI, { | |||
| method: 'GET', | |||
| url: '/api/v1/proposal/content', | |||
| query: { | |||
| 'propId': propId, | |||
| }, | |||
| errors: { | |||
| 404: `Proposal not found`, | |||
| }, | |||
| }); | |||
| } | |||
| /** | |||
| * @param id | |||
| * @returns any Proposal retrieved successfully | |||
| @@ -1,13 +1,30 @@ | |||
| import { createMemoryHistory, createRouter } from 'vue-router' | |||
| import { createRouter, createWebHistory } from 'vue-router' | |||
| import HomeView from '../views/HomeView.vue' | |||
| import ProposalEmptyState from '../components/ProposalEmptyState.vue' | |||
| const routes = [ | |||
| { path: '/', component: HomeView }, | |||
| { | |||
| path: '/', | |||
| component: HomeView, | |||
| children: [ | |||
| { | |||
| path: '', | |||
| name: 'home', | |||
| component: ProposalEmptyState | |||
| }, | |||
| { | |||
| path: 'proposals/:id', | |||
| name: 'proposal', | |||
| component: () => import('../views/ProposalView.vue'), | |||
| props: (route: any) => ({ id: Number(route.params.id) }) | |||
| } | |||
| ] | |||
| }, | |||
| ] | |||
| const router = createRouter({ | |||
| history: createMemoryHistory(), | |||
| history: createWebHistory(), | |||
| routes, | |||
| }) | |||
| @@ -0,0 +1,10 @@ | |||
| import DOMPurify from "dompurify"; | |||
| import { parse } from "marked"; | |||
| export function renderMarkdown(markdown?: string | null): string { | |||
| if (!markdown) return ""; | |||
| const parsed = parse(markdown) as string; | |||
| return DOMPurify.sanitize(parsed); | |||
| } | |||
| export const applyFormatting = renderMarkdown; | |||
| @@ -0,0 +1,40 @@ | |||
| import { ProposalService } from "../generated/typescript"; | |||
| export function sanitizeFilename(name: string): string { | |||
| return ( | |||
| name | |||
| .trim() | |||
| .replace(/[<>:"/\\|?*\u0000-\u001F]/g, "-") | |||
| .replace(/\s+/g, "-") | |||
| .slice(0, 150) || "proposal" | |||
| ); | |||
| } | |||
| export async function downloadProposalMarkdown(proposalId: number): Promise<void> { | |||
| const proposalContent = await ProposalService.getProposalContent(proposalId); | |||
| const paragraphs = proposalContent.paragraphs | |||
| .map(({ paragraphText }) => paragraphText.trim()) | |||
| .filter(Boolean); | |||
| const content = [ | |||
| `# ${proposalContent.name}`, | |||
| `*Created by ${proposalContent.creator}*`, | |||
| "---", | |||
| "", | |||
| ...paragraphs, | |||
| ].join("\n\n"); | |||
| const blob = new Blob([content], { type: "text/markdown;charset=utf-8" }); | |||
| const url = URL.createObjectURL(blob); | |||
| const link = document.createElement("a"); | |||
| link.href = url; | |||
| link.download = `${sanitizeFilename(proposalContent.name)}.md`; | |||
| document.body.appendChild(link); | |||
| link.click(); | |||
| link.remove(); | |||
| URL.revokeObjectURL(url); | |||
| } | |||
| @@ -5,7 +5,6 @@ | |||
| <Sidebar | |||
| v-show="width > 768 || isSidebarOpen" | |||
| :proposals="proposals" | |||
| @update:selection="handleSelection" | |||
| class="fixed md:static w-64 h-full overflow-y-auto border-r md:flex-shrink-0 inset-y-0 left-0 z-30 transform transition-transform duration-300 ease-in-out" | |||
| /> | |||
| @@ -18,236 +17,7 @@ | |||
| <!-- Main Content --> | |||
| <main class="h-full flex-1 overflow-y-auto bg-slate-100"> | |||
| <template v-if="selected && !isLoading('content')"> | |||
| <article | |||
| v-if="selected.proposal" | |||
| class="mx-auto w-full max-w-[96rem] px-5 py-6 sm:px-8 lg:px-10 lg:py-10" | |||
| :aria-labelledby="`proposal-${selected.proposal.id}-title`" | |||
| > | |||
| <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> | |||
| <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(selected.proposal.creator) }} | |||
| </span> | |||
| </div> | |||
| <h1 | |||
| :id="`proposal-${selected.proposal.id}-title`" | |||
| class="max-w-4xl font-header text-4xl font-bold leading-[0.95] | |||
| tracking-tight text-slate-950 sm:text-5xl" | |||
| > | |||
| {{ selected.proposal.name }} | |||
| </h1> | |||
| <div class="mt-5 flex min-w-0 items-center gap-2 text-xs text-slate-600"> | |||
| <Identification | |||
| class="size-4 shrink-0 text-slate-700" | |||
| aria-hidden="true" | |||
| /> | |||
| <span class="font-semibold uppercase tracking-[0.1em]"> | |||
| Created by | |||
| </span> | |||
| <span | |||
| class="truncate font-medium text-slate-800" | |||
| :title="selected.proposal.creator" | |||
| > | |||
| {{ truncateWallet(selected.proposal.creator) }} | |||
| </span> | |||
| </div> | |||
| </header> | |||
| <section | |||
| class="max-w-3xl py-8" | |||
| aria-labelledby="proposal-content-heading" | |||
| > | |||
| <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 selected.proposal.paragraphs" | |||
| :key="paragraph.id ?? paragraph.paragraphText" | |||
| v-html="applyFormatting(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> | |||
| <span | |||
| class="text-[0.65rem] font-bold uppercase tracking-[0.1em] text-slate-500" | |||
| > | |||
| Proposed changes | |||
| </span> | |||
| </div> | |||
| <AmendmentCarousel :proposal-id="currentId" /> | |||
| </section> | |||
| <section | |||
| class="border-t border-slate-300 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> | |||
| <div | |||
| v-if="false" | |||
| class="rounded-lg border border-dashed border-slate-300 bg-white/50 px-5 py-8 | |||
| text-sm text-slate-500" | |||
| > | |||
| Discussion will appear here. | |||
| </div> | |||
| <Thread | |||
| v-for="comment in comments" | |||
| :key="comment.id" | |||
| :starter-comment="comment" | |||
| class="comment-thread" | |||
| /> | |||
| </section> | |||
| </div> | |||
| <!-- Voting action rail --> | |||
| <aside | |||
| class="xl:sticky xl:top-6" | |||
| aria-labelledby="vote-heading" | |||
| > | |||
| <section | |||
| class="overflow-hidden rounded-xl border border-slate-300 bg-white shadow-sm" | |||
| > | |||
| <div class="border-b border-slate-200 px-5 py-4"> | |||
| <h2 | |||
| id="vote-heading" | |||
| class="font-header text-lg font-bold tracking-tight text-slate-950" | |||
| > | |||
| Cast your vote | |||
| </h2> | |||
| <p class="mt-1 text-xs leading-5 text-slate-500"> | |||
| Choose one position for this proposal. | |||
| </p> | |||
| </div> | |||
| <div class="space-y-2 p-3"> | |||
| <button | |||
| type="button" | |||
| class="group flex w-full items-center gap-3 rounded-lg border border-slate-300 | |||
| bg-white px-3 py-2.5 text-left text-slate-700 transition | |||
| hover:border-green-600 hover:bg-green-600 hover:text-white | |||
| focus-visible:outline focus-visible:outline-2 | |||
| focus-visible:outline-offset-2 focus-visible:outline-slate-950" | |||
| > | |||
| <Confirm | |||
| class="size-5 shrink-0 fill-current" | |||
| :box="96" | |||
| aria-hidden="true" | |||
| /> | |||
| <span class="font-semibold">Approve</span> | |||
| </button> | |||
| <button | |||
| type="button" | |||
| class="group flex w-full items-center gap-3 rounded-lg border border-slate-300 | |||
| bg-white px-3 py-2.5 text-left text-slate-700 transition | |||
| hover:border-red-600 hover:bg-red-600 hover:text-white | |||
| focus-visible:outline focus-visible:outline-2 | |||
| focus-visible:outline-offset-2 focus-visible:outline-slate-950" | |||
| > | |||
| <Cancel | |||
| class="size-5 shrink-0 fill-current" | |||
| :box="96" | |||
| aria-hidden="true" | |||
| /> | |||
| <span class="font-semibold">Reject</span> | |||
| </button> | |||
| <button | |||
| type="button" | |||
| class="group flex w-full items-center gap-3 rounded-lg border border-slate-300 | |||
| bg-white px-3 py-2.5 text-left text-slate-700 transition | |||
| hover:border-amber-500 hover:bg-amber-500 hover:text-slate-950 | |||
| focus-visible:outline focus-visible:outline-2 | |||
| focus-visible:outline-offset-2 focus-visible:outline-slate-950" | |||
| > | |||
| <Abstain | |||
| class="size-5 shrink-0 fill-current" | |||
| :box="96" | |||
| aria-hidden="true" | |||
| /> | |||
| <span class="font-semibold">Abstain</span> | |||
| </button> | |||
| </div> | |||
| <div class="border-t border-slate-200 bg-slate-50 px-5 py-4"> | |||
| <p class="text-xs leading-5 text-slate-500"> | |||
| Your selection can be shown here later, alongside quorum, | |||
| voting power, and the deadline. | |||
| </p> | |||
| </div> | |||
| </section> | |||
| </aside> | |||
| </div> | |||
| </article> | |||
| </template> | |||
| <div | |||
| v-else-if="!isLoading('content')" | |||
| class="flex h-full items-center justify-center p-8" | |||
| > | |||
| <div class="max-w-sm text-center"> | |||
| <p class="font-header text-2xl font-bold text-slate-700"> | |||
| Select a proposal | |||
| </p> | |||
| <p class="mt-2 text-sm leading-6 text-slate-500"> | |||
| Choose a proposal from the list to read its details, review amendments, | |||
| and cast a vote. | |||
| </p> | |||
| </div> | |||
| </div> | |||
| <LoadingSpinner | |||
| v-if="isLoading('content')" | |||
| identifier="content" | |||
| /> | |||
| <RouterView /> | |||
| </main> | |||
| </div> | |||
| @@ -264,36 +34,22 @@ | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import DOMPurify from "dompurify"; | |||
| import {parse} from "marked"; | |||
| import {computed, onMounted, onUnmounted, ref} from "vue"; | |||
| import { useWindowSize } from '@vueuse/core' | |||
| import {ProposalList, ProposalService} from "../generated/typescript"; | |||
| import {ProposalSelection} from "../types/proposal.ts"; | |||
| import {useLoading} from "../composables/useLoading.ts"; | |||
| import LoadingSpinner from "../components/LoadingSpinner.vue"; | |||
| import {eventBus} from "../utils/eventBus.ts"; | |||
| import {CommentWithChildren} from "../types/comment.ts"; | |||
| import Thread from "../components/Thread.vue"; | |||
| import { onMounted, onUnmounted, ref, watch } from "vue"; | |||
| import { useRoute } from "vue-router"; | |||
| import { useWindowSize } from "@vueuse/core"; | |||
| import { ProposalService, ProposalWithSummary } from "../generated/typescript"; | |||
| import { useLoading } from "../composables/useLoading.ts"; | |||
| import { eventBus } from "../utils/eventBus.ts"; | |||
| import Sidebar from "../components/Sidebar.vue"; | |||
| import AmendmentCarousel from "../components/AmendmentCarousel.vue"; | |||
| import Confirm from "../components/icons/Confirm.vue"; | |||
| import Cancel from "../components/icons/Cancel.vue"; | |||
| import Abstain from "../components/icons/Abstain.vue"; | |||
| import {truncateWallet} from "../utils/wallet.ts"; | |||
| import Stack from "../components/icons/Stack.vue"; | |||
| import Identification from "../components/icons/Identification.vue"; | |||
| const { register, unregister, isLoading, show: showLoading, hide: hideLoading } = useLoading(); | |||
| const { register, unregister, show: showLoading, hide: hideLoading } = useLoading(); | |||
| const { width } = useWindowSize(); | |||
| const route = useRoute(); | |||
| register('proposals', true); | |||
| register('content'); | |||
| const proposals = ref<ProposalList>(); | |||
| const selected = ref<ProposalSelection>(); | |||
| const comments = ref<CommentWithChildren[]>(); | |||
| const proposals = ref<ProposalWithSummary[]>(); | |||
| const isSidebarOpen = ref(false); | |||
| const [offset, limit] = [ref(0), ref(10)]; | |||
| @@ -301,59 +57,16 @@ const [offset, limit] = [ref(0), ref(10)]; | |||
| showLoading('proposals'); | |||
| onMounted(async () => { | |||
| const proposalsResponse = await ProposalService.listProposals(offset.value, limit.value); | |||
| proposals.value = proposalsResponse.proposals; | |||
| proposals.value = await ProposalService.listProposals(offset.value, limit.value); | |||
| hideLoading('proposals'); | |||
| }); | |||
| watch(() => route.path, () => { | |||
| isSidebarOpen.value = false; | |||
| }); | |||
| onUnmounted(() => { | |||
| unregister('proposals'); | |||
| unregister('content'); | |||
| eventBus.off('filtersApplied'); | |||
| }) | |||
| const applyFormatting = (markdown: string) => { | |||
| const parsed = parse(markdown) as string; | |||
| return DOMPurify.sanitize(parsed); | |||
| } | |||
| const handleSelection = async (proposal: ProposalSelection) => { | |||
| selected.value = proposal; | |||
| // Close sidebar on small screens after selection | |||
| isSidebarOpen.value = false; | |||
| } | |||
| const paragraphs = computed(() => selected.value?.proposal.content.split('\n').filter(p => p.trim().length > 0) ?? []); | |||
| const currentId = computed(() => selected.value?.proposal.id); | |||
| </script> | |||
| <style lang="postcss"> | |||
| .font-poppins { | |||
| font-family: 'Poppins', sans-serif; | |||
| } | |||
| /* Light inset divider above voting area */ | |||
| .inset-divider { | |||
| position: relative; | |||
| height: 0; | |||
| margin-top: 1rem; /* similar to mt-4 */ | |||
| margin-bottom: 1rem; /* similar to mb-4 */ | |||
| } | |||
| .inset-divider::before, | |||
| .inset-divider::after { | |||
| content: ""; | |||
| position: absolute; | |||
| left: 0; | |||
| right: 0; | |||
| height: 1px; | |||
| } | |||
| .inset-divider::before { | |||
| background: rgba(255, 255, 255, 0.8); | |||
| top: 0; | |||
| } | |||
| .inset-divider::after { | |||
| background: rgba(0, 0, 0, 0.12); | |||
| top: 1px; | |||
| } | |||
| </style> | |||
| }); | |||
| </script> | |||
| @@ -0,0 +1,68 @@ | |||
| <template> | |||
| <div class="h-full"> | |||
| <LoadingSpinner | |||
| v-if="isLoading('content')" | |||
| identifier="content" | |||
| /> | |||
| <ProposalDetail | |||
| v-else-if="proposal" | |||
| :proposal="proposal" | |||
| /> | |||
| <ProposalEmptyState | |||
| v-else | |||
| /> | |||
| </div> | |||
| </template> | |||
| <script setup lang="ts"> | |||
| import { ref, watch, onUnmounted } from 'vue'; | |||
| import { ProposalService, ProposalWithParagraphs } from '../generated/typescript'; | |||
| import { useLoading } from '../composables/useLoading'; | |||
| import ProposalDetail from '../components/ProposalDetail.vue'; | |||
| import ProposalEmptyState from '../components/ProposalEmptyState.vue'; | |||
| import LoadingSpinner from '../components/LoadingSpinner.vue'; | |||
| const props = defineProps<{ | |||
| id: number | string; | |||
| }>(); | |||
| const { register, unregister, isLoading, show: showLoading, hide: hideLoading } = useLoading(); | |||
| register('content'); | |||
| const proposal = ref<ProposalWithParagraphs | null>(null); | |||
| const fetchProposal = async (id: number | string) => { | |||
| const numericId = Number(id); | |||
| if (isNaN(numericId) || numericId <= 0) { | |||
| proposal.value = null; | |||
| return; | |||
| } | |||
| showLoading('content'); | |||
| try { | |||
| proposal.value = await ProposalService.getProposal(numericId); | |||
| } catch (err) { | |||
| console.error('Failed to fetch proposal', err); | |||
| proposal.value = null; | |||
| } finally { | |||
| hideLoading('content'); | |||
| } | |||
| }; | |||
| watch( | |||
| () => props.id, | |||
| (newId) => { | |||
| if (newId !== undefined && newId !== null) { | |||
| fetchProposal(newId); | |||
| } | |||
| }, | |||
| { immediate: true } | |||
| ); | |||
| onUnmounted(() => { | |||
| unregister('content'); | |||
| }); | |||
| </script> | |||
| @@ -1 +1 @@ | |||
| {"root":["./src/main.ts","./src/vite-env.d.ts","./src/composables/usefetch.ts","./src/composables/usefilternotifier.ts","./src/composables/useloading.ts","./src/composables/usemodal.ts","./src/composables/usesession.ts","./src/router/index.ts","./src/stores/filterstore.ts","./src/types/amendment.ts","./src/types/color.ts","./src/types/comment.ts","./src/types/dropdown.ts","./src/types/headerletter.ts","./src/types/proposal.ts","./src/types/user.ts","./src/types/vote.ts","./src/utils/amendment.ts","./src/utils/eventbus.ts","./src/utils/wallet.ts","./src/app.vue","./src/components/amendmentcarousel.vue","./src/components/brandbar.vue","./src/components/chip.vue","./src/components/coloredheader.vue","./src/components/dropdown.vue","./src/components/issuecontainer.vue","./src/components/loadingspinner.vue","./src/components/postbuilder.vue","./src/components/sidebar.vue","./src/components/thread.vue","./src/components/votingcontrols.vue","./src/components/icons/abstain.vue","./src/components/icons/arrow.vue","./src/components/icons/cancel.vue","./src/components/icons/confirm.vue","./src/components/icons/downarrow.vue","./src/components/icons/filter.vue","./src/components/icons/menu.vue","./src/components/icons/speechbubbles.vue","./src/components/icons/thumbsdown.vue","./src/components/icons/thumbsup.vue","./src/components/modals/amendmentmodal.vue","./src/components/modals/filtermodal.vue","./src/components/modals/issuemodal.vue","./src/components/modals/modal.vue","./src/components/modals/registrationmodal.vue","./src/views/homeview.vue"],"errors":true,"version":"5.9.2"} | |||
| {"root":["./src/main.ts","./src/vite-env.d.ts","./src/composables/useFilterNotifier.ts","./src/composables/useLoading.ts","./src/composables/useModal.ts","./src/composables/useSession.ts","./src/generated/typescript/index.ts","./src/generated/typescript/core/ApiError.ts","./src/generated/typescript/core/ApiRequestOptions.ts","./src/generated/typescript/core/ApiResult.ts","./src/generated/typescript/core/CancelablePromise.ts","./src/generated/typescript/core/OpenAPI.ts","./src/generated/typescript/core/request.ts","./src/generated/typescript/models/AddAmendmentRequest.ts","./src/generated/typescript/models/AddProposalRequest.ts","./src/generated/typescript/models/FreighterLoginRequest.ts","./src/generated/typescript/models/LoginRequest.ts","./src/generated/typescript/models/Proposal.ts","./src/generated/typescript/models/ProposalParagraph.ts","./src/generated/typescript/models/ProposalParagraphRequest.ts","./src/generated/typescript/models/ProposalWithParagraphs.ts","./src/generated/typescript/models/ProposalWithSummary.ts","./src/generated/typescript/models/RegisterRequest.ts","./src/generated/typescript/models/SelectableProposal.ts","./src/generated/typescript/schemas/$AddProposalRequest.ts","./src/generated/typescript/schemas/$FreighterLoginRequest.ts","./src/generated/typescript/schemas/$ListProposalsResponse.ts","./src/generated/typescript/schemas/$LoginRequest.ts","./src/generated/typescript/schemas/$ProposalItem.ts","./src/generated/typescript/schemas/$ProposalList.ts","./src/generated/typescript/schemas/$RegisterRequest.ts","./src/generated/typescript/schemas/$VisitProposalRequest.ts","./src/generated/typescript/services/AmendmentService.ts","./src/generated/typescript/services/AuthService.ts","./src/generated/typescript/services/ProposalService.ts","./src/router/index.ts","./src/stores/filterStore.ts","./src/types/amendment.ts","./src/types/color.ts","./src/types/comment.ts","./src/types/dropdown.ts","./src/types/headerletter.ts","./src/types/proposal.ts","./src/types/user.ts","./src/types/vote.ts","./src/utils/amendment.ts","./src/utils/eventBus.ts","./src/utils/markdown.ts","./src/utils/proposalExport.ts","./src/utils/wallet.ts","./src/App.vue","./src/components/AmendmentCarousel.vue","./src/components/BrandBar.vue","./src/components/Chip.vue","./src/components/ColoredHeader.vue","./src/components/Dropdown.vue","./src/components/LoadingSpinner.vue","./src/components/PostBuilder.vue","./src/components/ProposalContainer.vue","./src/components/ProposalDetail.vue","./src/components/ProposalEmptyState.vue","./src/components/ProposalExportCard.vue","./src/components/ProposalVotingCard.vue","./src/components/Sidebar.vue","./src/components/Thread.vue","./src/components/VotingControls.vue","./src/components/icons/Abstain.vue","./src/components/icons/Arrow.vue","./src/components/icons/Cancel.vue","./src/components/icons/Confirm.vue","./src/components/icons/DownArrow.vue","./src/components/icons/Download.vue","./src/components/icons/Filter.vue","./src/components/icons/Identification.vue","./src/components/icons/Menu.vue","./src/components/icons/SpeechBubbles.vue","./src/components/icons/Stack.vue","./src/components/icons/ThumbsDown.vue","./src/components/icons/ThumbsUp.vue","./src/components/modals/AmendmentModal.vue","./src/components/modals/FilterModal.vue","./src/components/modals/Modal.vue","./src/components/modals/ProposalModal.vue","./src/components/modals/RegistrationModal.vue","./src/views/HomeView.vue","./src/views/ProposalView.vue"],"errors":true,"version":"5.9.2"} | |||