Преглед на файлове

Make automatic API generation more robust

master
Jared Bell преди 1 ден
родител
ревизия
01bdeebdfb
променени са 34 файла, в които са добавени 788 реда и са изтрити 418 реда
  1. +21
    -0
      gen-api.sh
  2. +1
    -2
      package.json
  3. +5
    -5
      src/components/AmendmentCarousel.vue
  4. +2
    -1
      src/components/BrandBar.vue
  5. +0
    -47
      src/components/IssueContainer.vue
  6. +4
    -4
      src/components/PostBuilder.vue
  7. +74
    -0
      src/components/ProposalContainer.vue
  8. +20
    -20
      src/components/Sidebar.vue
  9. +5
    -5
      src/components/VotingControls.vue
  10. +31
    -0
      src/components/icons/Identification.vue
  11. +24
    -0
      src/components/icons/Stack.vue
  12. +1
    -1
      src/components/modals/FilterModal.vue
  13. +0
    -40
      src/components/modals/IssueModal.vue
  14. +131
    -31
      src/components/modals/Modal.vue
  15. +74
    -0
      src/components/modals/ProposalModal.vue
  16. +0
    -71
      src/composables/useFetch.ts
  17. +2
    -2
      src/generated/typescript/core/OpenAPI.ts
  18. +7
    -13
      src/generated/typescript/index.ts
  19. +5
    -3
      src/generated/typescript/models/AddAmendmentRequest.ts
  20. +2
    -1
      src/generated/typescript/models/AddProposalRequest.ts
  21. +11
    -0
      src/generated/typescript/models/Proposal.ts
  22. +3
    -6
      src/generated/typescript/models/ProposalParagraph.ts
  23. +5
    -2
      src/generated/typescript/models/ProposalParagraphRequest.ts
  24. +14
    -0
      src/generated/typescript/models/ProposalWithParagraphs.ts
  25. +3
    -6
      src/generated/typescript/models/ProposalWithSummary.ts
  26. +12
    -0
      src/generated/typescript/models/SelectableProposal.ts
  27. +17
    -20
      src/generated/typescript/services/AmendmentService.ts
  28. +22
    -17
      src/generated/typescript/services/AuthService.ts
  29. +16
    -29
      src/generated/typescript/services/ProposalService.ts
  30. +4
    -0
      src/main.ts
  31. +18
    -14
      src/types/proposal.ts
  32. +249
    -75
      src/views/HomeView.vue
  33. +1
    -1
      tsconfig.app.tsbuildinfo
  34. +4
    -2
      vite.config.ts

+ 21
- 0
gen-api.sh Целия файл

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail

OPENAPI_URL="${OPENAPI_URL:-http://127.0.0.1:7300/openapi.json}"
SPEC_FILE="openapi.fetched.json"
OUTPUT_DIR="src/generated/typescript"

cleanup() {
rm -f "$SPEC_FILE"
}

trap cleanup EXIT

curl --fail --silent --show-error \
--header "Accept: application/json" \
"$OPENAPI_URL" \
--output "$SPEC_FILE"

npx openapi-typescript-codegen \
--input "$SPEC_FILE" \
--output "$OUTPUT_DIR"

+ 1
- 2
package.json Целия файл

@@ -6,8 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"genapi": "npx openapi-typescript-codegen --input http://127.0.0.1:7300/openapi.json --output ./src/generated/typescript"
"preview": "vite preview"
},
"dependencies": {
"@stellar/freighter-api": "^4.1.0",


+ 5
- 5
src/components/AmendmentCarousel.vue Целия файл

@@ -51,8 +51,8 @@ import AmendmentModal from "./modals/AmendmentModal.vue";
import {getChipClass} from "../utils/amendment.ts";
import {AmendmentService} from "../generated/typescript";

const props = defineProps<{issueId?: number}>();
const {issueId} = toRefs(props);
const props = defineProps<{proposalId?: number}>();
const {proposalId} = toRefs(props);

const offset = ref(0);
const amendments = ref<Amendment[]>();
@@ -65,8 +65,8 @@ const showAmendment = async (amendmentId: number, status: string) => {
}

const getAmendments = async () => {
if (!props.issueId) return;
const resp = await AmendmentService.listAmendments(props.issueId)
if (!props.proposalId) return;
const resp = await AmendmentService.listAmendments(props.proposalId)
amendments.value = resp.amendments;
}

@@ -111,7 +111,7 @@ const scrolledMax = () => {

const numberOfAmendments = computed(() => amendments.value?.length || 0);

watch(issueId, async (newVal) => {
watch(proposalId, async (newVal) => {
if (!newVal) return;
await getAmendments();
offset.value = 0;


+ 2
- 1
src/components/BrandBar.vue Целия файл

@@ -1,7 +1,8 @@
<template>
<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">
<ColoredHeader text="Ikibani" from="#F2F3E2" to="#B2E5F8" />
<!-- https://senara.network -->
<ColoredHeader text="Senara" from="#F2F3E2" to="#B2E5F8" />
</span>
<div class="flex flex-col justify-center items-center">
<button


+ 0
- 47
src/components/IssueContainer.vue Целия файл

@@ -1,47 +0,0 @@
<template>
<div class="p-8 my-px hover:bg-amber-100 cursor-pointer" :class="{'bg-amber-300': selected, 'bg-amber-50': !selected}" @click="handleSelection">
<div class="mb-2">
<span class=" block font-bold text-xl font-header whitespace-nowrap overflow-hidden overflow-ellipsis">{{ issue.name }}</span>
<span class="text-blue-600 text-xs py-1 px-1.5 bg-amber-50 drop-shadow rounded-sm italic">{{ truncateWallet(issue.creator) }}</span>
</div>
<div class="max-h-12 overflow-hidden overflow-ellipsis">
<span
class="text-sm text-gray-600 leading-tighter text-justify"
v-html="renderMarkdown(issue.summary)"
/>
</div>
</div>
</template>

<script setup lang="ts">
import {IssueSelection} from "../types/issue.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";

const {show: showLoading, hide: hideLoading} = useLoading();

const props = defineProps<{ issue: ProposalItem, selected: boolean }>();
const emits = defineEmits<{
(e: 'selected', selection: IssueSelection): void
}>();
const handleSelection = async () => {
showLoading('content');
const proposalContent = await ProposalService.getProposal(props.issue.id);
proposalContent.proposal.id = props.issue.id;
emits('selected', {proposal: proposalContent.proposal, cid: props.issue.cid});
hideLoading('content');
}

const renderMarkdown = (markdown?: string | null) => {
if (!markdown) return;
const parsed = parse(markdown) as string;
return DOMPurify.sanitize(parsed);
}
</script>

<style scoped>

</style>

+ 4
- 4
src/components/PostBuilder.vue Целия файл

@@ -22,19 +22,18 @@
import {Editor, EditorContent} from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import {onBeforeUnmount} from 'vue'
import Underline from '@tiptap/extension-underline'
import TurndownService from 'turndown';
import {ProposalParagraph} from "../types/proposal.ts";

const turndownService = new TurndownService();

const emit = defineEmits<{
'update:content': [content: string]
'update:content': [content: ProposalParagraph[]],
}>()

const editor = new Editor({
extensions: [
StarterKit,
Underline,
],
content: '',
editorProps: {
@@ -45,7 +44,8 @@ const editor = new Editor({
onUpdate: ({ editor }) => {
const html = editor.getHTML();
const content = convertEditorContentToMarkdown(html);
emit('update:content', content);
const paragraphs = content.split('\n').map(p => p.trim()).filter(p => p.length > 0).map((p, i) => ({paragraphText: p, index: i}));
emit('update:content', paragraphs);
}
})



+ 74
- 0
src/components/ProposalContainer.vue Целия файл

@@ -0,0 +1,74 @@
<template>
<article
class="p-8 my-px hover:bg-amber-100 cursor-pointer"
:class="{'bg-amber-300': selected, 'bg-amber-50': !selected}"
@click="handleSelection"
>
<header class="min-w-0">
<h3
class="truncate font-header text-xl font-bold text-slate-950"
:title="proposal.name"
>
{{ proposal.name }}
</h3>
</header>

<div class="mt-2 text-sm leading-5 text-slate-800/80 max-h-16 overflow-hidden" v-html="renderMarkdown(proposal.summary)" />

<footer
class="mt-auto flex min-w-0 items-center gap-1.5 pt-4 text-slate-900/70"
:title="proposal.creator"
>
<Identification
class="size-3.5 shrink-0"
aria-hidden="true"
/>

<span class="pb-0.5">
<!-- Attribution -->
<span class="text-[0.6rem] font-bold uppercase tracking-[0.12em]">
Created by
</span>

<span class="text-[0.6rem] truncate text-xs font-semibold tracking-wide">
{{ truncateWallet(proposal.creator) }}
</span>
</span>

</footer>
</article>
</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 Identification from "./icons/Identification.vue";

const {show: showLoading, hide: hideLoading} = useLoading();

const props = defineProps<{ proposal: ProposalItem, selected: boolean }>();
const emits = defineEmits<{
(e: 'selected', selection: ProposalSelection): 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);
}
</script>

<style scoped>

</style>

+ 20
- 20
src/components/Sidebar.vue Целия файл

@@ -1,11 +1,11 @@
<template>
<aside class="sm:flex sm:flex-col sm:w-1/4 bg-gray-600 h-screen">
<nav class="flex flex-row w-full h-1/16 justify-between">
<nav class="sticky top-0 left-0 flex flex-row w-full h-1/16 justify-between">
<button
class="w-10/12 h-full bg-gray-800 px-5 py-3 text-sm font-bold text-white border-r border-white border-solid uppercase text-center cursor-pointer"
@click="showNewIssueModal"
@click="showNewProposalModal"
>
Add Issue
Add Proposal
</button>
<button
class="flex flex-row w-2/12 h-full bg-gray-800 justify-center items-center cursor-pointer"
@@ -15,28 +15,28 @@
</button>
</nav>

<div class="h-full overflow-y-scroll">
<IssueContainer
v-for="issue in issues"
:key="issue.id"
:issue="issue"
:selected="selectedId === issue.id"
<div class="h-11/12">
<ProposalContainer
v-for="proposal in proposals"
:key="proposal.id"
:proposal="proposal"
:selected="selectedId === proposal.id"
@selected="handleSelection"
/>
<LoadingSpinner
v-if="isLoading('issues')"
identifier="issues"
v-if="isLoading('proposals')"
identifier="proposals"
/>
</div>
</aside>
</template>

<script setup lang="ts">
import IssueContainer from "./IssueContainer.vue";
import ProposalContainer from "./ProposalContainer.vue";
import LoadingSpinner from "./LoadingSpinner.vue";
import {IssueSelection} from "../types/issue.ts";
import {ProposalSelection} from "../types/proposal.ts";
import {useLoading} from "../composables/useLoading.ts";
import IssueModal from "./modals/IssueModal.vue";
import ProposalModal from "./modals/ProposalModal.vue";
import {ContentModal, useModal} from "../composables/useModal.ts";
import FilterModal from "./modals/FilterModal.vue";
import Filter from "./icons/Filter.vue";
@@ -44,22 +44,22 @@ import {ref} from "vue";
import {ProposalList} from "../generated/typescript";
const { show: showModal } = useModal();

const props = defineProps<{ issues: ProposalList | undefined }>();
const props = defineProps<{ proposals: ProposalList | undefined }>();
const emit = defineEmits(['update:selection']);

const {isLoading} = useLoading();

const selectedId = ref<number>();

const handleSelection = (issue: IssueSelection) => {
const id = issue.proposal.id;
const handleSelection = (proposal: ProposalSelection) => {
const id = proposal.proposal.id;
if (selectedId.value && selectedId.value === id) return;
selectedId.value = id;
emit('update:selection', {proposal: issue.proposal, cid: issue.cid});
emit('update:selection', {proposal: proposal.proposal});
}

const showNewIssueModal = () => {
showModal(IssueModal as ContentModal);
const showNewProposalModal = () => {
showModal(ProposalModal as ContentModal);
}

const showFilterModal = () => {


+ 5
- 5
src/components/VotingControls.vue Целия файл

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

<script setup lang="ts">
import {VoteIssueRequest, VoteIssueResponse} from "../types/issue.ts";
import {VoteProposalRequest, VoteProposalResponse} from "../types/proposal.ts";
import Cancel from "./icons/Cancel.vue";
import Confirm from "./icons/Confirm.vue";
import freighter from "@stellar/freighter-api";
@@ -42,15 +42,15 @@ const recordAccept = async () => {
if (!props.selectedId) return;
const addr = await freighter.getAddress();
// TODO: replace this call
await post<VoteIssueRequest, VoteIssueResponse>('/issues/vote',
{ issue_id: props.selectedId.toString(), wallet: addr.address, vote: "Positive" })
await post<VoteProposalRequest, VoteProposalResponse>('/issues/vote',
{ proposal_id: props.selectedId.toString(), wallet: addr.address, vote: "Positive" })
}
const recordDecline = async () => {
if (!props.selectedId) return;
const addr = await freighter.getAddress();
// TODO: replace this call
await post<VoteIssueRequest, VoteIssueResponse>('/issues/vote',
{ issue_id: props.selectedId, wallet: addr.address, vote: "Negative" })
await post<VoteProposalRequest, VoteProposalResponse>('/issues/vote',
{ proposal_id: props.selectedId, wallet: addr.address, vote: "Negative" })
}
const recordAbstain = async () => {
if (!props.selectedId) return;


+ 31
- 0
src/components/icons/Identification.vue Целия файл

@@ -0,0 +1,31 @@
<script setup lang="ts">

</script>

<template>
<div class="h-4 w-4 cursor-pointer mx-2">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<g>
<g>
<path d="M28.8,7.1c-0.2-0.1-0.4-0.1-0.5,0C28.1,7.1,28,7.3,28,7.5V20c0,1.9-1.6,3.5-3.5,3.5H3c-0.2,0-0.4,0.1-0.4,0.3
c-0.1,0.2-0.1,0.4,0,0.5c0.6,0.8,1.4,1.2,2.4,1.2h22c1.7,0,3-1.3,3-3v-13C30,8.6,29.6,7.7,28.8,7.1z"/>
</g>
<g>
<path d="M27,20V7c0-1.4-1.1-2.5-2.5-2.5h-22C1.1,4.5,0,5.6,0,7v13c0,1.4,1.1,2.5,2.5,2.5h22C25.9,22.5,27,21.4,27,20z M18.5,9.5h6
c0.3,0,0.5,0.2,0.5,0.5s-0.2,0.5-0.5,0.5h-6c-0.3,0-0.5-0.2-0.5-0.5S18.2,9.5,18.5,9.5z M18.5,13.5h6c0.3,0,0.5,0.2,0.5,0.5
s-0.2,0.5-0.5,0.5h-6c-0.3,0-0.5-0.2-0.5-0.5S18.2,13.5,18.5,13.5z M9,20c-3.6,0-6.5-2.9-6.5-6.5S5.4,7,9,7s6.5,2.9,6.5,6.5
S12.6,20,9,20z M18,18c0-0.3,0.2-0.5,0.5-0.5h6c0.3,0,0.5,0.2,0.5,0.5s-0.2,0.5-0.5,0.5h-6C18.2,18.5,18,18.3,18,18z"/>
</g>
<g>
<path d="M10.9,14.8c0.7-0.6,1.1-1.4,1.1-2.3c0-1.7-1.3-3-3-3s-3,1.3-3,3c0,0.9,0.4,1.8,1.1,2.3c-1,0.3-1.9,1-2.7,1.8
C5.5,18,7.1,19,9,19s3.5-1,4.5-2.4C12.8,15.8,11.9,15.2,10.9,14.8z"/>
</g>
</g>
</svg>
</div>
</template>

<style scoped>

</style>

+ 24
- 0
src/components/icons/Stack.vue Целия файл

@@ -0,0 +1,24 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
:viewBox="`0 0 ${viewBox} ${viewBox}`" style="width: 20px; height: 20px;" xml:space="preserve" fill="white">
<path d="M0.271,10.444l14.5,7.5C14.842,17.981,14.921,18,15,18s0.158-0.019,0.229-0.056l14.5-7.5C29.896,10.358,30,10.187,30,10
s-0.104-0.358-0.271-0.444l-14.5-7.5c-0.144-0.074-0.315-0.074-0.459,0l-14.5,7.5C0.104,9.642,0,9.813,0,10
S0.104,10.358,0.271,10.444z"/>
<path d="M29.729,19.556l-2.886-1.493l-11.155,5.77C15.477,23.942,15.239,24,15,24s-0.477-0.058-0.689-0.167l-11.155-5.77
l-2.886,1.493C0.104,19.642,0,19.813,0,20s0.104,0.358,0.271,0.444l14.5,7.5C14.842,27.981,14.921,28,15,28
s0.158-0.019,0.229-0.056l14.5-7.5C29.896,20.358,30,20.187,30,20S29.896,19.642,29.729,19.556z"/>
<path d="M29.729,14.556l-2.886-1.493l-11.155,5.77C15.477,18.942,15.239,19,15,19s-0.477-0.058-0.689-0.167l-11.155-5.77
l-2.886,1.493C0.104,14.642,0,14.813,0,15s0.104,0.358,0.271,0.444l14.5,7.5C14.842,22.981,14.921,23,15,23
s0.158-0.019,0.229-0.056l14.5-7.5C29.896,15.358,30,15.187,30,15S29.896,14.642,29.729,14.556z"/>
</svg>
</template>

<script setup lang="ts">
const props = defineProps({
viewBox: {type: Number, default: 90},
})
</script>

<style scoped>

</style>

+ 1
- 1
src/components/modals/FilterModal.vue Целия файл

@@ -20,7 +20,7 @@
<script setup lang="ts" generic="T extends ContentModal">

import Modal from "./Modal.vue";
import { ContentModal } from "../../composables/useModal.ts";
import { ContentModal } from "../../stores/modal.ts";
import { useFilterNotifier } from "../../composables/useFilterNotifier.ts";
import { eventBus } from "../../utils/eventBus.ts";



+ 0
- 40
src/components/modals/IssueModal.vue Целия файл

@@ -1,40 +0,0 @@
<template>
<Modal header="New Issue" @on-closed="submit">
<slot>
<div class="my-3">
<input type="text" class="w-full border-gray-300 border border-solid py-2 px-2.5" placeholder="Title" aria-label="Title" v-model="title" />
</div>
<div class="my-3">
<PostBuilder @update:content="assignDescription" />
</div>
</slot>
</Modal>
</template>

<script setup lang="ts" generic="T extends ContentModal">

import Modal from "./Modal.vue";
import { ref } from "vue";
import PostBuilder from "../PostBuilder.vue";
import { ContentModal } from "../../composables/useModal.ts";
import freighter from "@stellar/freighter-api";
import {ProposalService} from "../../generated/typescript";

const title = ref('');
const description = ref<string>('');

const submit = async () => {
if (await freighter.isAllowed()) {
const addr = await freighter.getAddress();
await ProposalService.createProposal({creator: addr.address, name: title.value, description: description.value});
}
}

const assignDescription = (paragraphs: string) => {
description.value = paragraphs;
}
</script>

<style scoped>

</style>

+ 131
- 31
src/components/modals/Modal.vue Целия файл

@@ -1,44 +1,144 @@
<template>
<div ref="modalRef" class="fixed top-0 left-0 w-screen h-screen p-4 sm:p-40" style="background-color:rgba(0, 0, 0, 0.33)" @click.self="hide" v-if="getVisibility()">
<div class="h-full max-h-[500px] bg-white rounded-xl p-6 mx-auto max-w-[540px]">
<div class="flex flex-row justify-end" @click="hide">
<span class="text-2xl text-gray-600 leading-3 cursor-pointer">&times;</span>
</div>
<div class="flex-1 w-full overflow-y-auto">
<div>
<div>
<span class="text-2xl mx-auto font-bold">
<div>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isOpen"
ref="modalRef"
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/45 p-4 sm:p-6"
role="presentation"
@click.self="handleBackdropClick"
@keydown="handleKeydown"
>
<section
class="flex max-h-[min(720px,calc(100vh-2rem))] w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
role="dialog"
aria-modal="true"
:aria-label="header"
>
<header class="flex items-center justify-between border-b border-slate-200 px-5 py-4 sm:px-6">
<h2 class="text-xl font-semibold tracking-tight text-slate-900">
{{ header }}
</span>
</div>
</h2>

<button
ref="closeButtonRef"
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-lg text-slate-500 transition hover:bg-slate-100 hover:text-slate-900 focus:outline-none focus:ring-4 focus:ring-amber-500/20"
aria-label="Close modal"
@click="close"
>
<span aria-hidden="true" class="text-2xl leading-none">&times;</span>
</button>
</header>

<main class="min-h-0 flex-1 overflow-y-auto px-5 py-5 sm:px-6">
<slot />
</main>

<footer class="flex flex-col-reverse gap-3 border-t border-slate-200 bg-slate-50 px-5 py-4 sm:flex-row sm:justify-end sm:px-6">
<button
type="button"
class="rounded-lg px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-200 focus:outline-none focus:ring-4 focus:ring-slate-300"
@click="close"
>
Cancel
</button>

<button
type="button"
class="rounded-lg bg-amber-500 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-amber-600 focus:outline-none focus:ring-4 focus:ring-amber-500/30 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="submitDisabled"
@click="handleSubmission"
>
{{ buttonText }}
</button>
</footer>
</section>
</div>
</div>
<div class="h-2/3 overflow-y-auto border border-gray-200 rounded-md px-2">
<slot name="default" />
</div>
<div class="flex flex-row justify-end pt-2">
<button class="bg-amber-500 rounded py-2 px-3 text-white font-bold" @click="handleSubmission">
{{ buttonText }}
</button>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>

<script setup lang="ts">
import { useModal } from "../../composables/useModal.ts";
import { withDefaults } from "vue";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import {useModal} from "../../composables/useModal.ts";

const { hide, getVisibility } = useModal();
const props = withDefaults(
defineProps<{
modelValue: boolean;
header: string;
buttonText?: string;
submitDisabled?: boolean;
closeOnBackdrop?: boolean;
}>(),
{
buttonText: "Submit",
submitDisabled: false,
closeOnBackdrop: true,
},
);

withDefaults(defineProps<{ header: string; buttonText?: string }>(), {
buttonText: 'Submit'
});
const emits = defineEmits(['onClosed']);
const handleSubmission = () => {
emits('onClosed');
const emit = defineEmits<{
"update:modelValue": [value: boolean];
submit: [];
}>();

const {hide} = useModal();

const modalRef = ref<HTMLElement | null>(null);
const closeButtonRef = ref<HTMLButtonElement | null>(null);

const isOpen = computed(() => props.modelValue);

function close() {
emit("update:modelValue", false);
hide();
};
}

function handleBackdropClick() {
if (props.closeOnBackdrop) {
close();
}
}

function handleSubmission() {
if (!props.submitDisabled) {
emit("submit");
}
}

function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
close();
}
}

watch(
isOpen,
async (open) => {
if (open) {
document.body.style.overflow = "hidden";
await nextTick();
closeButtonRef.value?.focus();
} else {
document.body.style.overflow = "";
}
},
{ immediate: true },
);

onBeforeUnmount(() => {
document.body.style.overflow = "";
});
</script>

<style scoped>


+ 74
- 0
src/components/modals/ProposalModal.vue Целия файл

@@ -0,0 +1,74 @@
<template>
<Modal
v-model="isNewProposalOpen"
header="New Proposal"
button-text="Create proposal"
:submit-disabled="!title.trim()"
@submit="submit"
>
<div class="space-y-5">
<div>
<label for="proposal-title" class="mb-1.5 block text-sm font-medium text-slate-700">
Title
</label>

<input
id="proposal-title"
v-model="title"
type="text"
class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-amber-500 focus:ring-4 focus:ring-amber-500/15"
placeholder="Briefly describe the proposal"
aria-describedby="proposal-title-help"
@keydown.meta.enter="submit"
@keydown.ctrl.enter="submit"
/>

<p id="proposal-title-help" class="mt-1.5 text-xs text-slate-500">
Keep it concise; add supporting detail below.
</p>
</div>

<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">
Description
</label>

<PostBuilder @update:content="assignDescription" />
</div>
</div>
</Modal>
</template>

<script setup lang="ts" generic="T extends ContentModal">

import Modal from "./Modal.vue";
import { ref } from "vue";
import PostBuilder from "../PostBuilder.vue";
import { ContentModal } from "../../composables/useModal.ts";
import freighter from "@stellar/freighter-api";
import {ProposalService} from "../../generated/typescript";
import {useModal} from "../../composables/useModal.ts";
import {ProposalParagraph} from "../../types/proposal.ts";

const {getVisibility} = useModal();

const title = ref('');
const description = ref<ProposalParagraph[]>([]);

const submit = async () => {
if (await freighter.isAllowed()) {
const addr = await freighter.getAddress();
await ProposalService.createProposal({creator: addr.address, name: title.value, paragraphs: description.value});
}
}

const assignDescription = (paragraphs: ProposalParagraph[]) => {
description.value = paragraphs;
}

const isNewProposalOpen = ref(getVisibility());
</script>

<style scoped>

</style>

+ 0
- 71
src/composables/useFetch.ts Целия файл

@@ -1,71 +0,0 @@
/**
* @deprecated This method is deprecated.
*/
export const useFetch = () => {
interface FetchConfig {
baseUrl: string;
headers: HeadersInit;
}

interface ApiResponse<T> {
data: T;
status: number;
}

const DEFAULT_CONFIG: FetchConfig = {
baseUrl: '/api/v1',
headers: {
'Content-Type': 'application/json'
}
};

const buildQueryString = (params?: Record<string, string | number>): string => {
if (!params) return '';
return '?' + Object.entries(params)
.map(([key, value]) => `${key}=${value}`)
.join('&');
};

/**
* @deprecated This function is deprecated.
*/
const createRequest = async <T>(
endpoint: string,
method: 'GET' | 'POST',
options: RequestInit = {}
): Promise<ApiResponse<T>> => {
const response = await fetch(`${DEFAULT_CONFIG.baseUrl}${endpoint}`, {
method,
mode: 'cors',
headers: new Headers(DEFAULT_CONFIG.headers),
...options
});

const data = await response.json();
return {data, status: response.status};
};

/**
* @deprecated This function is deprecated.
*/
const get = async <T>(
endpoint: string,
queryParams?: Record<string, string | number>
): Promise<T> => {
const query = buildQueryString(queryParams);
const response = await createRequest<T>(`${endpoint}${query}`, 'GET');
return response.data;
};

/**
* @deprecated This function is deprecated.
*/
const post = async <T, U>(endpoint: string, payload: T): Promise<U> => {
const response = await createRequest<U>(endpoint, 'POST', {
body: JSON.stringify(payload)
});
return response.data;
};

return { get, post }
}

+ 2
- 2
src/generated/typescript/core/OpenAPI.ts Целия файл

@@ -20,8 +20,8 @@ export type OpenAPIConfig = {
};

export const OpenAPI: OpenAPIConfig = {
BASE: 'http://localhost:7300',
VERSION: '1.0.0',
BASE: '',
VERSION: '0.1.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'include',
TOKEN: undefined,


+ 7
- 13
src/generated/typescript/index.ts Целия файл

@@ -7,23 +7,17 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise';
export { OpenAPI } from './core/OpenAPI';
export type { OpenAPIConfig } from './core/OpenAPI';

export type { AddAmendmentRequest } from './models/AddAmendmentRequest';
export type { AddProposalRequest } from './models/AddProposalRequest';
export type { FreighterLoginRequest } from './models/FreighterLoginRequest';
export type { ListProposalsResponse } from './models/ListProposalsResponse';
export type { LoginRequest } from './models/LoginRequest';
export type { ProposalItem } from './models/ProposalItem';
export type { ProposalList } from './models/ProposalList';
export type { Proposal } from './models/Proposal';
export type { ProposalParagraph } from './models/ProposalParagraph';
export type { ProposalParagraphRequest } from './models/ProposalParagraphRequest';
export type { ProposalWithParagraphs } from './models/ProposalWithParagraphs';
export type { ProposalWithSummary } from './models/ProposalWithSummary';
export type { RegisterRequest } from './models/RegisterRequest';
export type { VisitProposalRequest } from './models/VisitProposalRequest';

export { $AddProposalRequest } from './schemas/$AddProposalRequest';
export { $FreighterLoginRequest } from './schemas/$FreighterLoginRequest';
export { $ListProposalsResponse } from './schemas/$ListProposalsResponse';
export { $LoginRequest } from './schemas/$LoginRequest';
export { $ProposalItem } from './schemas/$ProposalItem';
export { $ProposalList } from './schemas/$ProposalList';
export { $RegisterRequest } from './schemas/$RegisterRequest';
export { $VisitProposalRequest } from './schemas/$VisitProposalRequest';
export type { SelectableProposal } from './models/SelectableProposal';

export { AmendmentService } from './services/AmendmentService';
export { AuthService } from './services/AuthService';


src/generated/typescript/models/ListProposalsResponse.ts → src/generated/typescript/models/AddAmendmentRequest.ts Целия файл

@@ -2,8 +2,10 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ProposalList } from './ProposalList';
export type ListProposalsResponse = {
proposals?: ProposalList;
export type AddAmendmentRequest = {
content: string;
creator: string;
name: string;
proposalId: number;
};


+ 2
- 1
src/generated/typescript/models/AddProposalRequest.ts Целия файл

@@ -2,9 +2,10 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ProposalParagraphRequest } from './ProposalParagraphRequest';
export type AddProposalRequest = {
creator: string;
description: string;
name: string;
paragraphs: Array<ProposalParagraphRequest>;
};


+ 11
- 0
src/generated/typescript/models/Proposal.ts Целия файл

@@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type Proposal = {
createdAt: string;
creator: string;
name: string;
updatedAt: string;
};


src/generated/typescript/models/VisitProposalRequest.ts → src/generated/typescript/models/ProposalParagraph.ts Целия файл

@@ -2,12 +2,9 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type VisitProposalRequest = {
method?: string;
path?: string;
export type ProposalParagraph = {
index: number;
paragraphText: string;
proposalId: number;
queryString?: string;
referrer?: string;
statusCode?: number;
};


src/generated/typescript/models/ProposalList.ts → src/generated/typescript/models/ProposalParagraphRequest.ts Целия файл

@@ -2,5 +2,8 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ProposalItem } from './ProposalItem';
export type ProposalList = Array<ProposalItem>;
export type ProposalParagraphRequest = {
index: number;
paragraphText: string;
};


+ 14
- 0
src/generated/typescript/models/ProposalWithParagraphs.ts Целия файл

@@ -0,0 +1,14 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ProposalParagraph } from './ProposalParagraph';
export type ProposalWithParagraphs = {
createdAt: string;
creator: string;
id: number;
name: string;
paragraphs: Array<ProposalParagraph>;
updatedAt?: string | null;
};


src/generated/typescript/models/ProposalItem.ts → src/generated/typescript/models/ProposalWithSummary.ts Целия файл

@@ -2,15 +2,12 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ProposalItem = {
cid: string;
createdAt?: string | null;
export type ProposalWithSummary = {
createdAt: string;
creator: string;
id: number;
isCurrent: boolean;
name: string;
previousCid?: string | null;
summary?: string | null;
summary: string;
updatedAt?: string | null;
};


+ 12
- 0
src/generated/typescript/models/SelectableProposal.ts Целия файл

@@ -0,0 +1,12 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type SelectableProposal = {
createdAt: string;
creator: string;
id: number;
name: string;
updatedAt?: string | null;
};


+ 17
- 20
src/generated/typescript/services/AmendmentService.ts Целия файл

@@ -2,57 +2,54 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { AddAmendmentRequest } from '../models/AddAmendmentRequest';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class AmendmentService {
/**
* Create an amendment
* @param requestBody
* @returns any OK
* @returns any CID of the added amendment
* @throws ApiError
*/
public static createAmendment(
requestBody: Record<string, any>,
public static addAmendment(
requestBody: AddAmendmentRequest,
): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/amendment/add',
body: requestBody,
mediaType: 'application/json',
errors: {
400: `Bad request`,
500: `Internal server error`,
},
});
}
/**
* Get an amendment
* @param id
* @returns any OK
* @returns any Amendment details
* @throws ApiError
*/
public static getAmendment(
id: number,
): CancelablePromise<Record<string, any>> {
public static getAmendment(): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/amendment/get',
query: {
'id': id,
errors: {
400: `Bad request`,
500: `Internal server error`,
},
});
}
/**
* List amendments
* @param proposalId
* @returns any OK
* @returns any List of amendments for a proposal
* @throws ApiError
*/
public static listAmendments(
proposalId: number,
): CancelablePromise<Record<string, any>> {
public static listAmendments(): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/amendments/list',
query: {
'proposalId': proposalId,
errors: {
500: `Internal server error`,
},
});
}


+ 22
- 17
src/generated/typescript/services/AuthService.ts Целия файл

@@ -10,71 +10,76 @@ import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class AuthService {
/**
* Login with username/password
* @param requestBody
* @returns any OK
* @returns any User logged in successfully
* @throws ApiError
*/
public static loginUser(
public static login(
requestBody: LoginRequest,
): CancelablePromise<Record<string, any>> {
): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/auth/login',
body: requestBody,
mediaType: 'application/json',
errors: {
400: `Bad request`,
401: `Unauthorized`,
500: `Internal server error`,
},
});
}
/**
* Login with Stellar (Freighter)
* @param requestBody
* @returns any OK
* @returns any User logged in successfully
* @throws ApiError
*/
public static loginFreighter(
requestBody: FreighterLoginRequest,
): CancelablePromise<Record<string, any>> {
): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/auth/login/freighter',
body: requestBody,
mediaType: 'application/json',
errors: {
400: `Bad request`,
401: `Unauthorized`,
500: `Internal server error`,
},
});
}
/**
* Get freighter login nonce
* @returns any OK
* @returns any User logged in successfully
* @throws ApiError
*/
public static getFreighterNonce(): CancelablePromise<Record<string, any>> {
public static getNonce(): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
method: 'POST',
url: '/api/v1/auth/nonce',
errors: {
400: `Bad request`,
401: `Unauthorized`,
500: `Internal server error`,
},
});
}
/**
* Register a user
* @param requestBody
* @returns any Created
* @returns any User registered successfully
* @throws ApiError
*/
public static registerUser(
public static register(
requestBody: RegisterRequest,
): CancelablePromise<Record<string, any>> {
): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/auth/register',
body: requestBody,
mediaType: 'application/json',
errors: {
400: `Bad Request`,
409: `Conflict`,
400: `Bad request`,
500: `Internal server error`,
},
});
}


+ 16
- 29
src/generated/typescript/services/ProposalService.ts Целия файл

@@ -3,75 +3,59 @@
/* tslint:disable */
/* eslint-disable */
import type { AddProposalRequest } from '../models/AddProposalRequest';
import type { ListProposalsResponse } from '../models/ListProposalsResponse';
import type { VisitProposalRequest } from '../models/VisitProposalRequest';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class ProposalService {
/**
* Create a proposal
* @param requestBody
* @returns any OK
* @returns any Proposal added successfully
* @throws ApiError
*/
public static createProposal(
public static addProposal(
requestBody: AddProposalRequest,
): CancelablePromise<Record<string, any>> {
): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/proposal/add',
body: requestBody,
mediaType: 'application/json',
errors: {
400: `Bad Request`,
400: `Bad request`,
500: `Failed to add proposal`,
},
});
}
/**
* Get a proposal by id
* @param id
* @returns any OK
* @returns any Proposal retrieved successfully
* @throws ApiError
*/
public static getProposal(
id: number,
): CancelablePromise<Record<string, any>> {
): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/proposal/get',
query: {
'id': id,
},
errors: {
400: `Invalid proposalId`,
500: `Failed to retrieve proposal`,
},
});
}
/**
* Record a proposal visit
* @param requestBody
* @returns any Created
* @throws ApiError
*/
public static recordProposalVisit(
requestBody: VisitProposalRequest,
): CancelablePromise<Record<string, any>> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/proposal/visit',
body: requestBody,
mediaType: 'application/json',
});
}
/**
* List proposals
* @param offset
* @param limit
* @returns ListProposalsResponse OK
* @returns any Returns a list of proposals
* @throws ApiError
*/
public static listProposals(
offset: number,
limit: number,
): CancelablePromise<ListProposalsResponse> {
): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/proposals/list',
@@ -79,6 +63,9 @@ export class ProposalService {
'offset': offset,
'limit': limit,
},
errors: {
500: `Internal server error`,
},
});
}
}

+ 4
- 0
src/main.ts Целия файл

@@ -4,6 +4,10 @@ import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import './index.css'
import { OpenAPI } from './generated/typescript'

OpenAPI.BASE = ''
OpenAPI.HEADERS = localStorage.getItem("token") ? {"Authorization": "Bearer " + localStorage.getItem("token")} : {};

const pinia = createPinia();
createApp(App).use(pinia).use(router).mount('#app')

src/types/issue.ts → src/types/proposal.ts Целия файл

@@ -1,5 +1,5 @@
// Base interface for common issue properties
export interface BaseIssue {
export interface BaseProposal {
id: number;
name: string;
cid: string;
@@ -7,7 +7,7 @@ export interface BaseIssue {
}

// Main proposal issue interface extending base
export interface ProposalIssue extends BaseIssue {
export interface Proposal extends BaseProposal {
summary: string;
isCurrent: boolean;
previousCid?: string;
@@ -21,39 +21,43 @@ interface ContentHolder {
}

// Type for issue content combining base fields and content
export type ProposalContent = BaseIssue & ContentHolder;
export type ProposalContent = BaseProposal & ContentHolder;

export interface ListIssuesResponse {
proposals: ProposalIssue[]
export interface ListProposalsResponse {
proposals: Proposal[]
}

export interface GetIssueResponse {
export interface GetProposalResponse {
proposal: ProposalContent;
}

export interface AddIssueRequest {
export interface AddProposalRequest {
name: string;
description: string;
creator: string;
}

export interface AddIssueResponse {
export interface AddProposalResponse {
cid: string;
}

export interface VoteIssueRequest {
issue_id: string;
export interface VoteProposalRequest {
proposal_id: string;
wallet: string;
vote: 'Positive' | 'Negative';
}

export interface VoteIssueResponse {
issue_id: string;
export interface VoteProposalResponse {
proposal_id: string;
equity: number;
positive: boolean;
}

export interface IssueSelection {
export interface ProposalSelection {
proposal: ProposalContent;
cid: string;
}

export interface ProposalParagraph {
paragraphText: string;
index: number;
}

+ 249
- 75
src/views/HomeView.vue Целия файл

@@ -1,19 +1,12 @@
<template>
<div class="home h-screen">
<div class="home h-screen pb-12 md:pb-0">
<div class="flex h-full">
<!-- Sidebar for md+ screens (always visible) -->
<Sidebar
:issues="issues"
@update:selection="handleSelection"
class="hidden md:block w-64 h-full overflow-y-auto flex-shrink-0 border-r"
/>

<!-- Slide-in Sidebar for small screens -->
<Sidebar
:issues="issues"
@update:selection="handleSelection"
class="md:hidden fixed inset-y-0 left-0 z-30 w-64 h-full overflow-y-auto border-r transform transition-transform duration-300 ease-in-out"
:class="isSidebarOpen ? 'translate-x-0' : '-translate-x-full'"
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"
/>

<!-- Overlay for small screens when sidebar is open -->
@@ -24,71 +17,248 @@
></div>

<!-- Main Content -->
<main class="h-full flex-1 overflow-y-auto">
<template v-if="selected?.cid && !isLoading('content')">
<article class="px-5 pt-5 pb-12 min-h-0 h-full">
<header v-if="selected?.proposal.content" class="mb-6">
<h1 class="decoration-[#f7d012] text-5xl font-bold font-header underline underline-offset-4 mb-4">
{{ selected?.proposal.name }}
</h1>
<h2 class="font-header font-bold ml-2 text-2xl tracking-tight mb-3">Proposal</h2>
<div class="content-body">
<p
v-for="paragraph in paragraphs"
class="my-3 text-mxs"
v-html="applyFormatting(paragraph)"
/>
</div>
<div class="inset-divider" aria-hidden="true"></div>
<div class="flex flex-row mt-8">
<button class="flex flex-row items-center font-poppins rounded-full bg-gray-400 hover:bg-green-500 text-white px-1.5 py-0.5 mr-2 cursor-pointer">
<Confirm class="inline-block fill-white" :width="14" :height="14" :box="96" />
<span class="ml-1 font-poppins text-xs">Approve</span>
</button>
<button class="flex flex-row items-center font-poppins rounded-full bg-gray-400 hover:bg-red-500 text-white px-1.5 py-0.5 mr-2 cursor-pointer">
<Cancel class="inline-block fill-white" :width="14" :height="14" :box="96" />
<span class="ml-1 font-poppins text-xs">Reject</span>
</button>
<button class="flex flex-row items-center font-poppins rounded-full bg-gray-400 hover:bg-amber-500 text-white px-1.5 py-0.5 cursor-pointer">
<Abstain class="inline-block fill-white" :width="14" :height="14" :box="96" />
<span class="ml-1 font-poppins text-xs">Abstain</span>
</button>
<!-- <VotingControls class="w-full max-w-[480px] py-2 px-5 h-11 rounded-md bg-gray-600 border border-black" :selected-id="selected?.cid" :vote="selected.vote"/>-->
<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>
</header>

<section class="mb-6">
<h2 class="font-header font-bold ml-2 text-2xl tracking-tight mb-3">Amendments</h2>
<AmendmentCarousel :issue-id="currentId"/>
</section>

<section class="mb-6">
<h2 class="font-header font-bold ml-2 text-2xl tracking-tight mb-3">Comments</h2>
<Thread
v-for="comment in comments"
:key="comment.id"
:starter-comment="comment"
class="comment-thread"
/>
</section>

<!-- 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-if="!selected?.cid && !isLoading('content')" class="flex flex-row h-full justify-center items-center text-gray-500">
<span>Nothing is selected</span>
<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"/>
<LoadingSpinner
v-if="isLoading('content')"
identifier="content"
/>
</main>
</div>

<!-- Toggle button for small screens -->
<button
class="md:hidden fixed z-40 bottom-3 right-4 bg-orange-600 px-3 py-3 rounded-full shadow-lg"
class="md:hidden fixed w-full z-40 bottom-0 left-0 bg-gray-500 px-3 py-3 text-white font-semibold"
@click="isSidebarOpen = !isSidebarOpen"
aria-label="Toggle sidebar"
>
<Menu />
<Stack :viewBox="36" style="display: inline-block" />
View Proposals
</button>
</div>
</template>
@@ -97,8 +267,9 @@
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 {IssueSelection} from "../types/issue.ts";
import {ProposalSelection} from "../types/proposal.ts";
import {useLoading} from "../composables/useLoading.ts";
import LoadingSpinner from "../components/LoadingSpinner.vue";
import {eventBus} from "../utils/eventBus.ts";
@@ -106,34 +277,37 @@ import {CommentWithChildren} from "../types/comment.ts";
import Thread from "../components/Thread.vue";
import Sidebar from "../components/Sidebar.vue";
import AmendmentCarousel from "../components/AmendmentCarousel.vue";
import Menu from "../components/icons/Menu.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 { width } = useWindowSize();

register('issues', true);
register('proposals', true);
register('content');

const issues = ref<ProposalList>();
const selected = ref<IssueSelection>();
const proposals = ref<ProposalList>();
const selected = ref<ProposalSelection>();
const comments = ref<CommentWithChildren[]>();

const isSidebarOpen = ref(false);

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

showLoading('issues');
showLoading('proposals');

onMounted(async () => {
const issuesResponse = await ProposalService.listProposals(offset.value, limit.value);
issues.value = issuesResponse.proposals;
hideLoading('issues');
const proposalsResponse = await ProposalService.listProposals(offset.value, limit.value);
proposals.value = proposalsResponse.proposals;
hideLoading('proposals');
});

onUnmounted(() => {
unregister('issues');
unregister('proposals');
unregister('content');

eventBus.off('filtersApplied');
@@ -144,8 +318,8 @@ const applyFormatting = (markdown: string) => {
return DOMPurify.sanitize(parsed);
}

const handleSelection = async (issue: IssueSelection) => {
selected.value = issue;
const handleSelection = async (proposal: ProposalSelection) => {
selected.value = proposal;
// Close sidebar on small screens after selection
isSidebarOpen.value = false;
}


+ 1
- 1
tsconfig.app.tsbuildinfo Целия файл

@@ -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/issue.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/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"}

+ 4
- 2
vite.config.ts Целия файл

@@ -11,9 +11,11 @@ export default defineConfig({
tailwindcss()
],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:7300',
'/api/': {
target: 'http://localhost:7300',
changeOrigin: true,
}
}
}


Зареждане…
Отказ
Запис