Parcourir la source

Add proposal detail and export functionality, including markdown rendering and voting integration

master
Jared Bell il y a 1 jour
Parent
révision
592d891227
16 fichiers modifiés avec 545 ajouts et 349 suppressions
  1. +3
    -1
      src/components/BrandBar.vue
  2. +6
    -21
      src/components/ProposalContainer.vue
  3. +145
    -0
      src/components/ProposalDetail.vue
  4. +16
    -0
      src/components/ProposalEmptyState.vue
  5. +63
    -0
      src/components/ProposalExportCard.vue
  6. +93
    -0
      src/components/ProposalVotingCard.vue
  7. +24
    -19
      src/components/Sidebar.vue
  8. +18
    -0
      src/components/icons/Download.vue
  9. +1
    -0
      src/generated/typescript/models/ProposalParagraph.ts
  10. +20
    -0
      src/generated/typescript/services/ProposalService.ts
  11. +20
    -3
      src/router/index.ts
  12. +10
    -0
      src/utils/markdown.ts
  13. +40
    -0
      src/utils/proposalExport.ts
  14. +17
    -304
      src/views/HomeView.vue
  15. +68
    -0
      src/views/ProposalView.vue
  16. +1
    -1
      tsconfig.app.tsbuildinfo

+ 3
- 1
src/components/BrandBar.vue Voir le fichier

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


+ 6
- 21
src/components/ProposalContainer.vue Voir le fichier

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



+ 145
- 0
src/components/ProposalDetail.vue Voir le fichier

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

+ 16
- 0
src/components/ProposalEmptyState.vue Voir le fichier

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

+ 63
- 0
src/components/ProposalExportCard.vue Voir le fichier

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

+ 93
- 0
src/components/ProposalVotingCard.vue Voir le fichier

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

+ 24
- 19
src/components/Sidebar.vue Voir le fichier

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


+ 18
- 0
src/components/icons/Download.vue Voir le fichier

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

+ 1
- 0
src/generated/typescript/models/ProposalParagraph.ts Voir le fichier

@@ -3,6 +3,7 @@
/* tslint:disable */
/* eslint-disable */
export type ProposalParagraph = {
id: number;
index: number;
paragraphText: string;
proposalId: number;


+ 20
- 0
src/generated/typescript/services/ProposalService.ts Voir le fichier

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


+ 20
- 3
src/router/index.ts Voir le fichier

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


+ 10
- 0
src/utils/markdown.ts Voir le fichier

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

+ 40
- 0
src/utils/proposalExport.ts Voir le fichier

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

+ 17
- 304
src/views/HomeView.vue Voir le fichier

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

+ 68
- 0
src/views/ProposalView.vue Voir le fichier

@@ -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
tsconfig.app.tsbuildinfo Voir le fichier

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

Chargement…
Annuler
Enregistrer