| @@ -2,7 +2,9 @@ | |||||
| <div class="flex flex-row justify-between bg-gray-500 drop-shadow-md px-3 py-2"> | <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"> | <span class="text-4xl font-header font-bold"> | ||||
| <!-- https://senara.network --> | <!-- https://senara.network --> | ||||
| <ColoredHeader text="Senara" from="#F2F3E2" to="#B2E5F8" /> | |||||
| <RouterLink to="/"> | |||||
| <ColoredHeader text="Senara" from="#F2F3E2" to="#B2E5F8" /> | |||||
| </RouterLink> | |||||
| </span> | </span> | ||||
| <div class="flex flex-col justify-center items-center"> | <div class="flex flex-col justify-center items-center"> | ||||
| <button | <button | ||||
| @@ -40,32 +40,17 @@ | |||||
| </template> | </template> | ||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| import {ProposalSelection} from "../types/proposal.ts"; | |||||
| import {truncateWallet} from "../utils/wallet.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"; | 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<{ | 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> | </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" | :key="proposal.id" | ||||
| :proposal="proposal" | :proposal="proposal" | ||||
| :selected="selectedId === proposal.id" | :selected="selectedId === proposal.id" | ||||
| @selected="handleSelection" | |||||
| @selected="handleSelection(proposal.id)" | |||||
| /> | /> | ||||
| <LoadingSpinner | <LoadingSpinner | ||||
| v-if="isLoading('proposals')" | v-if="isLoading('proposals')" | ||||
| @@ -32,40 +32,45 @@ | |||||
| </template> | </template> | ||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| import { computed } from "vue"; | |||||
| import { useRoute, useRouter } from "vue-router"; | |||||
| import ProposalContainer from "./ProposalContainer.vue"; | import ProposalContainer from "./ProposalContainer.vue"; | ||||
| import LoadingSpinner from "./LoadingSpinner.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 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 FilterModal from "./modals/FilterModal.vue"; | ||||
| import Filter from "./icons/Filter.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 { 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 = () => { | const showNewProposalModal = () => { | ||||
| showModal(ProposalModal as ContentModal); | showModal(ProposalModal as ContentModal); | ||||
| } | |||||
| }; | |||||
| const showFilterModal = () => { | const showFilterModal = () => { | ||||
| showModal(FilterModal as ContentModal); | showModal(FilterModal as ContentModal); | ||||
| } | |||||
| }; | |||||
| </script> | </script> | ||||
| <style lang="postcss"> | <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 */ | /* tslint:disable */ | ||||
| /* eslint-disable */ | /* eslint-disable */ | ||||
| export type ProposalParagraph = { | export type ProposalParagraph = { | ||||
| id: number; | |||||
| index: number; | index: number; | ||||
| paragraphText: string; | paragraphText: string; | ||||
| proposalId: number; | proposalId: number; | ||||
| @@ -3,6 +3,7 @@ | |||||
| /* tslint:disable */ | /* tslint:disable */ | ||||
| /* eslint-disable */ | /* eslint-disable */ | ||||
| import type { AddProposalRequest } from '../models/AddProposalRequest'; | import type { AddProposalRequest } from '../models/AddProposalRequest'; | ||||
| import type { ProposalWithParagraphs } from '../models/ProposalWithParagraphs'; | |||||
| import type { CancelablePromise } from '../core/CancelablePromise'; | import type { CancelablePromise } from '../core/CancelablePromise'; | ||||
| import { OpenAPI } from '../core/OpenAPI'; | import { OpenAPI } from '../core/OpenAPI'; | ||||
| import { request as __request } from '../core/request'; | 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 | * @param id | ||||
| * @returns any Proposal retrieved successfully | * @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 HomeView from '../views/HomeView.vue' | ||||
| import ProposalEmptyState from '../components/ProposalEmptyState.vue' | |||||
| const routes = [ | 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({ | const router = createRouter({ | ||||
| history: createMemoryHistory(), | |||||
| history: createWebHistory(), | |||||
| routes, | 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 | <Sidebar | ||||
| v-show="width > 768 || isSidebarOpen" | v-show="width > 768 || isSidebarOpen" | ||||
| :proposals="proposals" | :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" | 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 Content --> | ||||
| <main class="h-full flex-1 overflow-y-auto bg-slate-100"> | <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> | </main> | ||||
| </div> | </div> | ||||
| @@ -264,36 +34,22 @@ | |||||
| </template> | </template> | ||||
| <script setup lang="ts"> | <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 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 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 { width } = useWindowSize(); | ||||
| const route = useRoute(); | |||||
| register('proposals', true); | 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 isSidebarOpen = ref(false); | ||||
| const [offset, limit] = [ref(0), ref(10)]; | const [offset, limit] = [ref(0), ref(10)]; | ||||
| @@ -301,59 +57,16 @@ const [offset, limit] = [ref(0), ref(10)]; | |||||
| showLoading('proposals'); | showLoading('proposals'); | ||||
| onMounted(async () => { | 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'); | hideLoading('proposals'); | ||||
| }); | }); | ||||
| watch(() => route.path, () => { | |||||
| isSidebarOpen.value = false; | |||||
| }); | |||||
| onUnmounted(() => { | onUnmounted(() => { | ||||
| unregister('proposals'); | unregister('proposals'); | ||||
| unregister('content'); | |||||
| eventBus.off('filtersApplied'); | 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"} | |||||