| @@ -5,7 +5,6 @@ | |||||
| <link rel="icon" type="image/svg+xml" href="/vite.svg" /> | <link rel="icon" type="image/svg+xml" href="/vite.svg" /> | ||||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
| <title>Puff Pastry</title> | <title>Puff Pastry</title> | ||||
| <script src="https://telegram.org/js/telegram-web-app.js"></script> | |||||
| </head> | </head> | ||||
| <body> | <body> | ||||
| <div id="app"></div> | <div id="app"></div> | ||||
| @@ -6,29 +6,38 @@ | |||||
| "scripts": { | "scripts": { | ||||
| "dev": "vite", | "dev": "vite", | ||||
| "build": "vue-tsc -b && vite build", | "build": "vue-tsc -b && vite build", | ||||
| "preview": "vite preview" | |||||
| "preview": "vite preview", | |||||
| "genapi": "npx openapi-typescript-codegen --input http://127.0.0.1:7300/openapi.json --output ./src/generated/typescript" | |||||
| }, | }, | ||||
| "dependencies": { | "dependencies": { | ||||
| "@types/quill": "^2.0.14", | |||||
| "@vueuse/core": "^11.1.0", | |||||
| "@vueuse/integrations": "^11.1.0", | |||||
| "luxon": "^3.5.0", | |||||
| "pinia": "^2.2.4", | |||||
| "quill": "^2.0.2", | |||||
| "universal-cookie": "^7.2.0", | |||||
| "vue": "^3.4.37", | |||||
| "vue-router": "^4.4.3", | |||||
| "vue-tg": "^0.8.0" | |||||
| "@stellar/freighter-api": "^4.1.0", | |||||
| "@tailwindcss/postcss": "^4.1.11", | |||||
| "@tailwindcss/vite": "^4.1.11", | |||||
| "@tiptap/extension-underline": "^3.0.9", | |||||
| "@tiptap/starter-kit": "^3.0.9", | |||||
| "@tiptap/vue-3": "^3.0.9", | |||||
| "@types/turndown": "^5.0.5", | |||||
| "@vueuse/core": "^13.6.0", | |||||
| "@vueuse/integrations": "^13.6.0", | |||||
| "dompurify": "^3.2.6", | |||||
| "luxon": "^3.7.1", | |||||
| "marked": "^16.1.2", | |||||
| "pinia": "^3.0.3", | |||||
| "turndown": "^7.2.0", | |||||
| "universal-cookie": "^8.0.1", | |||||
| "vue": "^3.5.18", | |||||
| "vue-router": "^4.5.1" | |||||
| }, | }, | ||||
| "devDependencies": { | "devDependencies": { | ||||
| "@stellar/stellar-sdk": "^12.2.0", | |||||
| "@types/luxon": "^3.4.2", | |||||
| "@vitejs/plugin-vue": "^5.1.2", | |||||
| "autoprefixer": "^10.4.20", | |||||
| "@stellar/stellar-sdk": "^13.3.0", | |||||
| "@types/luxon": "^3.7.1", | |||||
| "@vitejs/plugin-vue": "^6.0.1", | |||||
| "autoprefixer": "^10.4.21", | |||||
| "postcss": "^8.4.45", | "postcss": "^8.4.45", | ||||
| "tailwindcss": "^3.4.10", | |||||
| "typescript": "^5.5.3", | |||||
| "vite": "^5.4.1", | |||||
| "vue-tsc": "^2.0.29" | |||||
| "tailwindcss": "^4.1.11", | |||||
| "typescript": "^5.9.2", | |||||
| "vite": "6.3.4", | |||||
| "vite-plugin-vue-devtools": "^8.0.0", | |||||
| "vue-tsc": "^3.0.5" | |||||
| } | } | ||||
| } | } | ||||
| @@ -1,6 +0,0 @@ | |||||
| export default { | |||||
| plugins: { | |||||
| tailwindcss: {}, | |||||
| autoprefixer: {}, | |||||
| }, | |||||
| } | |||||
| @@ -1,6 +1,6 @@ | |||||
| <template> | <template> | ||||
| <BrandBar /> | <BrandBar /> | ||||
| <main class="bg-gray-200 min-h-dvh"> | |||||
| <main class="bg-gray-200 h-dvh"> | |||||
| <RouterView /> | <RouterView /> | ||||
| </main> | </main> | ||||
| <component :is="getModalContent()" v-show="getVisibility()" /> | <component :is="getModalContent()" v-show="getVisibility()" /> | ||||
| @@ -8,16 +8,16 @@ | |||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| import { useModal } from "./composables/useModal.ts"; | import { useModal } from "./composables/useModal.ts"; | ||||
| import { useSession } from "./composables/useSession.ts"; | |||||
| import BrandBar from "./components/BrandBar.vue"; | import BrandBar from "./components/BrandBar.vue"; | ||||
| import { onBeforeMount } from "vue"; | |||||
| import {onBeforeMount, ref} from "vue"; | |||||
| import freighter from "@stellar/freighter-api"; | |||||
| const { getModalContent, getVisibility } = useModal(); | const { getModalContent, getVisibility } = useModal(); | ||||
| const { initializeSessionId, initializeUsername } = useSession(); | |||||
| initializeSessionId(); | |||||
| const address = ref(""); | |||||
| onBeforeMount(async () => { | onBeforeMount(async () => { | ||||
| await initializeUsername(); | |||||
| }); | |||||
| const addr = await freighter.getAddress(); | |||||
| address.value = addr.address; | |||||
| }) | |||||
| </script> | </script> | ||||
| @@ -0,0 +1,125 @@ | |||||
| <template> | |||||
| <div class="relative bg-white rounded-md px-1 py-2 overflow-hidden w-full whitespace-nowrap"> | |||||
| <button | |||||
| class="flex items-center justify-center absolute top-1/2 left-0 border-2 border-gray-700 -translate-y-1/2 translate-x-1/3 z-10 bg-white rounded-full shadow-md w-10 h-10 rotate-180" | |||||
| @click="scrollLeft" | |||||
| v-show="!scrolledNone()" | |||||
| > | |||||
| <Arrow/> | |||||
| </button> | |||||
| <div ref="tray" class="tray" :style="{transform: `translateX(-${offset}px)`}"> | |||||
| <article | |||||
| class="inline-block rounded-md shadow-lg mx-1.5 py-1 px-2 w-36 md:w-72 lg:w-80 cursor-pointer hover:bg-amber-50 transition-colors" | |||||
| v-for="amendment in amendments" | |||||
| @click="showAmendment(amendment.id, amendment.status)"> | |||||
| <div class="flex flex-row justify-between"> | |||||
| <div class="font-bold mb-0.5 leading-tighter">{{ amendment.name }}</div> | |||||
| <div class="flex flex-row items-center" v-if="amendment.status.toLowerCase() === 'proposed'"> | |||||
| <Confirm class="cursor-pointer fill-[#a1a1a1] hover:fill-[#00b894]"/> | |||||
| <Cancel class="cursor-pointer fill-[#a1a1a1] hover:fill-[#ff5f56]"/> | |||||
| </div> | |||||
| </div> | |||||
| <Chip | |||||
| :text="amendment.status" | |||||
| :class="getChipClass(amendment.status)"/> | |||||
| <div class="text-xs whitespace-normal text-gray-600 h-12 overflow-hidden relative"> | |||||
| {{amendment.summary}} | |||||
| <div class="absolute bottom-0 left-0 h-4 w-full from-white to-transparent bg-gradient-to-t"></div> | |||||
| </div> | |||||
| </article> | |||||
| </div> | |||||
| <button | |||||
| class="flex items-center justify-center absolute top-1/2 right-0 border-2 border-gray-700 -translate-y-1/2 -translate-x-1/3 z-10 bg-white rounded-full shadow-md w-10 h-10" | |||||
| @click="scrollRight" | |||||
| v-show="!scrolledMax()" | |||||
| > | |||||
| <Arrow/> | |||||
| </button> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import Arrow from "./icons/Arrow.vue"; | |||||
| import Confirm from "./icons/Confirm.vue"; | |||||
| import Cancel from "./icons/Cancel.vue"; | |||||
| import {computed, onMounted, ref, toRefs, watch} from "vue"; | |||||
| import {Amendment} from "../types/amendment.ts"; | |||||
| import Chip from "./Chip.vue"; | |||||
| import {ContentModal, useModal} from "../composables/useModal.ts"; | |||||
| 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 offset = ref(0); | |||||
| const amendments = ref<Amendment[]>(); | |||||
| const {show: showModal, setAmendmentContent} = useModal(); | |||||
| const showAmendment = async (amendmentId: number, status: string) => { | |||||
| const resp = await AmendmentService.getAmendment(amendmentId); | |||||
| setAmendmentContent(resp.amendment.name, status, resp.amendment.content); | |||||
| showModal(AmendmentModal as ContentModal); | |||||
| } | |||||
| const getAmendments = async () => { | |||||
| if (!props.issueId) return; | |||||
| const resp = await AmendmentService.listAmendments(props.issueId) | |||||
| amendments.value = resp.amendments; | |||||
| } | |||||
| const tray = ref<HTMLElement>(); | |||||
| const trayWidth = ref(0); | |||||
| onMounted(async () => { | |||||
| await getAmendments(); | |||||
| if (!tray.value) return; | |||||
| trayWidth.value = tray.value.clientWidth; | |||||
| }); | |||||
| const determineOffsetChange = () => { | |||||
| const width = window.innerWidth; | |||||
| if (width < 768) return 156; | |||||
| else if (width < 1024) return 300; | |||||
| else return 332; | |||||
| } | |||||
| const scrollLeft = () => { | |||||
| if (!tray.value) return; | |||||
| if (!trayWidth.value) trayWidth.value = tray.value.clientWidth; | |||||
| offset.value = Math.max(offset.value - trayWidth.value * 0.6, 0); | |||||
| } | |||||
| const scrollRight = () => { | |||||
| if (!tray.value) return; | |||||
| if (!trayWidth.value) trayWidth.value = tray.value.clientWidth; | |||||
| offset.value = Math.min(offset.value + trayWidth.value * 0.6, numberOfAmendments.value * determineOffsetChange() - trayWidth.value) | |||||
| } | |||||
| const scrolledNone = () => { | |||||
| if (!tray.value) return; | |||||
| return offset.value < 8; | |||||
| } | |||||
| const scrolledMax = () => { | |||||
| if (!tray.value) return; | |||||
| if (!trayWidth.value) trayWidth.value = tray.value.clientWidth; | |||||
| return offset.value >= numberOfAmendments.value * determineOffsetChange() - trayWidth.value - 8; | |||||
| } | |||||
| const numberOfAmendments = computed(() => amendments.value?.length || 0); | |||||
| watch(issueId, async (newVal) => { | |||||
| if (!newVal) return; | |||||
| await getAmendments(); | |||||
| offset.value = 0; | |||||
| }) | |||||
| </script> | |||||
| <style scoped> | |||||
| .tray { | |||||
| transition: transform 0.5s ease; | |||||
| } | |||||
| </style> | |||||
| @@ -1,53 +1,50 @@ | |||||
| <template> | <template> | ||||
| <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"> | ||||
| <ColoredHeader text="Puff Pastry" from="#F2F3E2" to="#B2E5F8"/> | |||||
| <ColoredHeader text="Ikibani" from="#F2F3E2" to="#B2E5F8" /> | |||||
| </span> | </span> | ||||
| <div> | |||||
| <LoginWidget | |||||
| v-show="!username" | |||||
| bot-username="puffpastry_mfa_bot" | |||||
| @auth="handleUserAuth" | |||||
| corner-radius="2" | |||||
| size="medium" | |||||
| class="my-auto" | |||||
| /> | |||||
| <div class="my-auto"> | |||||
| <button v-show="username" class="bg-[#54a9eb] text-white text-[13px] px-3.5 py-1 rounded border-[#fcfcfc] border-solid border"> | |||||
| @{{ username }} | |||||
| </button> | |||||
| </div> | |||||
| <div class="flex flex-col justify-center items-center"> | |||||
| <button | |||||
| class="text-xs ml-auto rounded-md bg-white text-gray-900 font-semibold px-5 py-2 shadow-sm hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-orange-300 transition-colors duration-200" | |||||
| type="button" | |||||
| @click="connectOrDisplayUser"> | |||||
| {{ userText }} | |||||
| </button> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </template> | </template> | ||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| import { LoginWidget } from 'vue-tg'; | |||||
| import type { LoginWidgetUser } from "vue-tg"; | |||||
| import ColoredHeader from "./ColoredHeader.vue"; | import ColoredHeader from "./ColoredHeader.vue"; | ||||
| import { useFetch } from "../composables/useFetch.ts"; | |||||
| import { useSession } from "../composables/useSession.ts"; | |||||
| import { AuthenticateRequest, AuthenticateResponse } from "../types/user.ts"; | |||||
| import { computed } from "vue"; | |||||
| const { post } = useFetch(); | |||||
| const { setSessionId, setUsername, getUsername } = useSession(); | |||||
| const handleUserAuth = async (user: LoginWidgetUser) => { | |||||
| const response = await post<AuthenticateRequest, AuthenticateResponse>("/sessions/authenticate", | |||||
| { | |||||
| user_id: user.id, | |||||
| auth_date: user.auth_date, | |||||
| username: user.username, | |||||
| first_name: user.first_name, | |||||
| last_name: user.last_name, | |||||
| photo_url: user.photo_url | |||||
| }); | |||||
| setSessionId(response.session_id); | |||||
| setUsername(user.username); | |||||
| } | |||||
| const username = computed(() => getUsername()); | |||||
| import { ref } from "vue"; | |||||
| import freighter, { setAllowed } from "@stellar/freighter-api"; | |||||
| import { truncateWallet } from "../utils/wallet.ts"; | |||||
| import {AuthService} from "../generated/typescript"; | |||||
| const userText = ref('Connect Wallet'); | |||||
| const connectOrDisplayUser = async () => { | |||||
| try { | |||||
| await setAllowed(); | |||||
| const addr = await freighter.getAddress(); | |||||
| if (!addr?.address) return; | |||||
| // Send the Stellar address to the backend to obtain a JWT string | |||||
| const jwt = await AuthService.loginFreighter({stellarAddress: addr.address}); | |||||
| // Optionally store JWT for future authenticated requests | |||||
| try { | |||||
| localStorage.setItem("authToken", jwt.token as string); | |||||
| } catch (_) { | |||||
| // storage may not be available; ignore | |||||
| } | |||||
| // Update the button text to show the connected wallet (truncated) | |||||
| userText.value = truncateWallet(addr.address); | |||||
| } catch (e) { | |||||
| console.error("Failed to connect or authenticate with Freighter:", e); | |||||
| } | |||||
| }; | |||||
| </script> | </script> | ||||
| <style scoped> | <style scoped> | ||||
| @@ -0,0 +1,14 @@ | |||||
| <template> | |||||
| <span | |||||
| class="inline-block uppercase text-xs px-1 py-0.5 leading-tighter rounded-md"> | |||||
| {{ text }} | |||||
| </span> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| const props = defineProps<{ text: string }>(); | |||||
| </script> | |||||
| <style scoped> | |||||
| </style> | |||||
| @@ -1,5 +1,5 @@ | |||||
| <template> | <template> | ||||
| <span v-for="character in characters" :key="calcKey(character)"> | |||||
| <span v-for="character in characters" :key="calcKey(character.letter, character.color)"> | |||||
| <span :style="calcStyle(character.color)">{{ character.letter }}</span> | <span :style="calcStyle(character.color)">{{ character.letter }}</span> | ||||
| </span> | </span> | ||||
| </template> | </template> | ||||
| @@ -58,21 +58,28 @@ const colorIncrement: Color = { | |||||
| let currentColor = hexToRgb(fromHex); | let currentColor = hexToRgb(fromHex); | ||||
| for (const character of props.text) { | for (const character of props.text) { | ||||
| if (character.trim() === '') { | |||||
| characters.value.push({ | |||||
| letter: character, | |||||
| color: undefined | |||||
| }); | |||||
| continue; | |||||
| } | |||||
| characters.value.push({ | characters.value.push({ | ||||
| letter: character, | letter: character, | ||||
| color: character.trim() !== '' ? currentColor : undefined | |||||
| color: currentColor | |||||
| }); | }); | ||||
| if (character.trim() !== '') { | |||||
| currentColor = { | |||||
| R: currentColor.R + colorIncrement.R, | |||||
| G: currentColor.G + colorIncrement.G, | |||||
| B: currentColor.B + colorIncrement.B, | |||||
| }; | |||||
| } | |||||
| currentColor = { | |||||
| R: currentColor.R + colorIncrement.R, | |||||
| G: currentColor.G + colorIncrement.G, | |||||
| B: currentColor.B + colorIncrement.B, | |||||
| }; | |||||
| } | } | ||||
| </script> | </script> | ||||
| <style scoped> | <style scoped> | ||||
| span { | |||||
| font-family: 'Gabarito', sans-serif; | |||||
| } | |||||
| </style> | </style> | ||||
| @@ -1,23 +1,44 @@ | |||||
| <template> | <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="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"> | <div class="mb-2"> | ||||
| <span class=" block font-bold text-xl font-header">{{ issue.title }}</span> | |||||
| <span class="text-blue-600 text-xs py-1 px-1.5 bg-amber-50 drop-shadow rounded-sm italic">@{{ issue.telegram_handle }}</span> | |||||
| <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> | ||||
| <div class="max-h-12 overflow-hidden overflow-ellipsis"> | <div class="max-h-12 overflow-hidden overflow-ellipsis"> | ||||
| <span class="text-sm text-gray-600">{{ issue.summary }}</span> | |||||
| <span | |||||
| class="text-sm text-gray-600 leading-tighter text-justify" | |||||
| v-html="renderMarkdown(issue.summary)" | |||||
| /> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </template> | </template> | ||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| import { Issue } from "../types/issue.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 props = defineProps<{ issue: Issue, selected: boolean }>(); | |||||
| const emits = defineEmits(['selected']); | |||||
| const {show: showLoading, hide: hideLoading} = useLoading(); | |||||
| const handleSelection = () => { | |||||
| emits("selected", props.issue.id, props.issue.title); | |||||
| 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> | </script> | ||||
| @@ -11,19 +11,19 @@ const props = defineProps<{ identifier: string }>(); | |||||
| </script> | </script> | ||||
| <style scoped> | <style scoped> | ||||
| .spinner::before { | |||||
| content: ''; | |||||
| display: block; | |||||
| .spinner { | |||||
| width: 56px; | |||||
| height: 56px; | height: 56px; | ||||
| width: 11.2px; | |||||
| animation: spinner-x0t3la 0.7s infinite; | |||||
| position: absolute; | |||||
| background: rgb(252, 211, 77); | |||||
| border-radius: 50%; | |||||
| background: radial-gradient(farthest-side, #e2c98b 94%,#0000) top/9px 9px no-repeat, | |||||
| conic-gradient(#0000 30%, #e2c98b); | |||||
| -webkit-mask: radial-gradient(farthest-side,#0000 calc(100% - 9px),#000 0); | |||||
| animation: spinner-c7wet2 1s infinite linear; | |||||
| } | } | ||||
| @keyframes spinner-x0t3la { | |||||
| to { | |||||
| transform: rotate(360deg); | |||||
| @keyframes spinner-c7wet2 { | |||||
| 100% { | |||||
| transform: rotate(1turn); | |||||
| } | } | ||||
| } | } | ||||
| </style> | </style> | ||||
| @@ -1,42 +1,123 @@ | |||||
| <template> | <template> | ||||
| <div ref="editor"></div> | |||||
| <div class="editor-wrapper"> | |||||
| <div class="formatting-buttons"> | |||||
| <button @click="editor.chain().focus().toggleBold().run()" :class="{ 'is-active': editor.isActive('bold') }"> | |||||
| <strong>B</strong> | |||||
| </button> | |||||
| <button @click="editor.chain().focus().toggleItalic().run()" :class="{ 'is-active': editor.isActive('italic') }"> | |||||
| <em>I</em> | |||||
| </button> | |||||
| <button @click="editor.chain().focus().toggleUnderline().run()" | |||||
| :class="{ 'is-active': editor.isActive('underline') }"> | |||||
| <u>U</u> | |||||
| </button> | |||||
| </div> | |||||
| <div class="editor-content-wrapper"> | |||||
| <EditorContent :editor="editor"/> | |||||
| </div> | |||||
| </div> | |||||
| </template> | </template> | ||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| import Quill from 'quill'; | |||||
| import 'quill/dist/quill.core.css'; | |||||
| import 'quill/dist/quill.bubble.css'; | |||||
| import 'quill/dist/quill.snow.css'; | |||||
| import { onMounted, ref } from "vue"; | |||||
| const emits = defineEmits(['textChange']); | |||||
| const editor = ref(null); | |||||
| onMounted(() => { | |||||
| const quill = new Quill(editor.value, { | |||||
| theme: 'snow', | |||||
| modules: { | |||||
| toolbar: [ | |||||
| ['bold', 'italic', 'underline'], | |||||
| [{ 'list': 'ordered'}, { 'list': 'bullet' }] | |||||
| ], | |||||
| editor | |||||
| }, | |||||
| }); | |||||
| 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'; | |||||
| const turndownService = new TurndownService(); | |||||
| const emit = defineEmits<{ | |||||
| 'update:content': [content: string] | |||||
| }>() | |||||
| quill.on('text-change', (delta, oldDelta, source) => { | |||||
| if (source == 'user') { | |||||
| emits('textChange', quill.getText().split('\n').filter(item => item.trim())); | |||||
| } | |||||
| }); | |||||
| const editor = new Editor({ | |||||
| extensions: [ | |||||
| StarterKit, | |||||
| Underline, | |||||
| ], | |||||
| content: '', | |||||
| editorProps: { | |||||
| attributes: { | |||||
| class: 'prose prose-sm focus:outline-none max-w-none', | |||||
| }, | |||||
| }, | |||||
| onUpdate: ({ editor }) => { | |||||
| const html = editor.getHTML(); | |||||
| const content = convertEditorContentToMarkdown(html); | |||||
| emit('update:content', content); | |||||
| } | |||||
| }) | }) | ||||
| const convertEditorContentToMarkdown = (htmlContent: string) => { | |||||
| return turndownService.turndown(htmlContent); | |||||
| }; | |||||
| onBeforeUnmount(() => { | |||||
| editor.destroy() | |||||
| }) | |||||
| </script> | </script> | ||||
| <style> | <style> | ||||
| .ql-editor { | |||||
| max-height: 259px; | |||||
| .editor-wrapper { | |||||
| background-color: white; | |||||
| border-radius: 0.5rem; | |||||
| border: 1px solid #e5e7eb; | |||||
| box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); | |||||
| } | |||||
| .formatting-buttons { | |||||
| padding: 0.75rem; | |||||
| border-bottom: 1px solid #e5e7eb; | |||||
| background-color: #f9fafb; | |||||
| border-top-left-radius: 0.5rem; | |||||
| border-top-right-radius: 0.5rem; | |||||
| } | |||||
| .formatting-buttons button { | |||||
| margin-right: 0.5rem; | |||||
| padding: 0.375rem 0.75rem; | |||||
| border: 1px solid #e5e7eb; | |||||
| border-radius: 0.375rem; | |||||
| background: white; | |||||
| transition: all 0.2s ease; | |||||
| } | |||||
| .formatting-buttons button:hover { | |||||
| background-color: #f3f4f6; | |||||
| border-color: #d1d5db; | |||||
| } | |||||
| .formatting-buttons button.is-active { | |||||
| background: #e5e7eb; | |||||
| border-color: #d1d5db; | |||||
| } | |||||
| .editor-content-wrapper { | |||||
| max-height: 237px; | |||||
| overflow-y: auto; | |||||
| } | |||||
| .ProseMirror { | |||||
| min-height: 120px; | |||||
| padding: 1rem 1.25rem; | |||||
| line-height: 1.5; | |||||
| } | |||||
| .ProseMirror:focus { | |||||
| outline: none; | |||||
| background-color: #fafafa; | |||||
| } | |||||
| @media (max-width: 640px) { | |||||
| .formatting-buttons { | |||||
| padding: 0.5rem; | |||||
| } | |||||
| .ProseMirror { | |||||
| padding: 0.75rem; | |||||
| } | |||||
| } | } | ||||
| </style> | </style> | ||||
| @@ -0,0 +1,73 @@ | |||||
| <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"> | |||||
| <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" | |||||
| > | |||||
| Add Issue | |||||
| </button> | |||||
| <button | |||||
| class="flex flex-row w-2/12 h-full bg-gray-800 justify-center items-center cursor-pointer" | |||||
| @click="showFilterModal" | |||||
| > | |||||
| <Filter/> | |||||
| </button> | |||||
| </nav> | |||||
| <div class="h-full overflow-y-scroll"> | |||||
| <IssueContainer | |||||
| v-for="issue in issues" | |||||
| :key="issue.id" | |||||
| :issue="issue" | |||||
| :selected="selectedId === issue.id" | |||||
| @selected="handleSelection" | |||||
| /> | |||||
| <LoadingSpinner | |||||
| v-if="isLoading('issues')" | |||||
| identifier="issues" | |||||
| /> | |||||
| </div> | |||||
| </aside> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import IssueContainer from "./IssueContainer.vue"; | |||||
| import LoadingSpinner from "./LoadingSpinner.vue"; | |||||
| import {IssueSelection} from "../types/issue.ts"; | |||||
| import {useLoading} from "../composables/useLoading.ts"; | |||||
| import IssueModal from "./modals/IssueModal.vue"; | |||||
| 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"; | |||||
| const { show: showModal } = useModal(); | |||||
| const props = defineProps<{ issues: ProposalList | undefined }>(); | |||||
| const emit = defineEmits(['update:selection']); | |||||
| const {isLoading} = useLoading(); | |||||
| const selectedId = ref<number>(); | |||||
| const handleSelection = (issue: IssueSelection) => { | |||||
| const id = issue.proposal.id; | |||||
| if (selectedId.value && selectedId.value === id) return; | |||||
| selectedId.value = id; | |||||
| emit('update:selection', {proposal: issue.proposal, cid: issue.cid}); | |||||
| } | |||||
| const showNewIssueModal = () => { | |||||
| showModal(IssueModal as ContentModal); | |||||
| } | |||||
| const showFilterModal = () => { | |||||
| showModal(FilterModal as ContentModal); | |||||
| } | |||||
| </script> | |||||
| <style lang="postcss"> | |||||
| </style> | |||||
| @@ -0,0 +1,65 @@ | |||||
| <template> | |||||
| <div class="flex flex-row justify-around"> | |||||
| <div> | |||||
| <Confirm | |||||
| :width="24" :height="24" :box="96" | |||||
| class="inline-block fill-gray-50 hover:fill-green-500 cursor-pointer mx-2" | |||||
| :class="{'fill-green-500': vote === 'Positive'}" | |||||
| @click="recordAccept" | |||||
| /> | |||||
| <span class="text-white align-bottom font-poppins hidden sm:inline-block">Approve</span> | |||||
| </div> | |||||
| <div> | |||||
| <Cancel | |||||
| :width="24" :height="24" :box="96" | |||||
| class="inline-block fill-gray-50 hover:fill-red-500 cursor-pointer mx-2" | |||||
| :class="{'fill-red-500': vote === 'Negative'}" | |||||
| @click="recordDecline" | |||||
| /> | |||||
| <span class="text-white align-bottom font-poppins hidden sm:inline-block">Reject</span> | |||||
| </div> | |||||
| <div> | |||||
| <Abstain | |||||
| :width="24" :height="24" :box="96" | |||||
| class="inline-block fill-gray-50 hover:fill-amber-500 cursor-pointer mx-2" | |||||
| @click="recordAbstain" | |||||
| /> | |||||
| <span class="text-white align-bottom font-poppins hidden sm:inline-block">Abstain</span> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import {VoteIssueRequest, VoteIssueResponse} from "../types/issue.ts"; | |||||
| import Cancel from "./icons/Cancel.vue"; | |||||
| import Confirm from "./icons/Confirm.vue"; | |||||
| import freighter from "@stellar/freighter-api"; | |||||
| import Abstain from "./icons/Abstain.vue"; | |||||
| const props = defineProps<{ selectedId: string, vote?: 'Positive' | 'Negative' }>(); | |||||
| 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" }) | |||||
| } | |||||
| 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" }) | |||||
| } | |||||
| const recordAbstain = async () => { | |||||
| if (!props.selectedId) return; | |||||
| const addr = await freighter.getAddress(); | |||||
| } | |||||
| </script> | |||||
| <style lang="postcss"> | |||||
| .font-poppins { | |||||
| font-family: 'Poppins', sans-serif; | |||||
| } | |||||
| </style> | |||||
| @@ -0,0 +1,28 @@ | |||||
| <template> | |||||
| <svg | |||||
| xmlns="http://www.w3.org/2000/svg" | |||||
| xmlns:xlink="http://www.w3.org/1999/xlink" | |||||
| :viewBox="`0 0 ${box} ${box}`" | |||||
| :width="width" | |||||
| :height="height" | |||||
| xml:space="preserve" | |||||
| > | |||||
| <g> | |||||
| <path d="M48,0A48,48,0,1,0,96,48,48.0512,48.0512,0,0,0,48,0Zm0,84A36,36,0,1,1,84,48,36.0393,36.0393,0,0,1,48,84Z"/> | |||||
| <path d="M60,42H36a6,6,0,0,0,0,12H60a6,6,0,0,0,0-12Z"/> | |||||
| </g> | |||||
| </svg> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| const props = withDefaults(defineProps<{ box?: number; width?: number | string; height?: number | string }>(), { | |||||
| box: 120, | |||||
| width: 20, | |||||
| height: 20, | |||||
| }); | |||||
| const { box, width, height } = props; | |||||
| </script> | |||||
| <style scoped> | |||||
| </style> | |||||
| @@ -0,0 +1,13 @@ | |||||
| <template> | |||||
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" | |||||
| viewBox="0 0 320 500" style="width: 10px; height: 20px;" xml:space="preserve"> | |||||
| <path d="M285.476 272.971L91.132 467.314c-9.373 9.373-24.569 9.373-33.941 0l-22.667-22.667c-9.357-9.357-9.375-24.522-.04-33.901L188.505 256 34.484 101.255c-9.335-9.379-9.317-24.544.04-33.901l22.667-22.667c9.373-9.373 24.569-9.373 33.941 0L285.475 239.03c9.373 9.372 9.373 24.568.001 33.941z"/> | |||||
| </svg> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| </script> | |||||
| <style scoped> | |||||
| </style> | |||||
| @@ -0,0 +1,28 @@ | |||||
| <template> | |||||
| <svg | |||||
| xmlns="http://www.w3.org/2000/svg" | |||||
| xmlns:xlink="http://www.w3.org/1999/xlink" | |||||
| :viewBox="`0 0 ${box} ${box}`" | |||||
| :width="width" | |||||
| :height="height" | |||||
| xml:space="preserve" | |||||
| > | |||||
| <g> | |||||
| <path d="M48,0A48,48,0,1,0,96,48,48.0512,48.0512,0,0,0,48,0Zm0,84A36,36,0,1,1,84,48,36.0393,36.0393,0,0,1,48,84Z"/> | |||||
| <path d="M64.2422,31.7578a5.9979,5.9979,0,0,0-8.4844,0L48,39.5156l-7.7578-7.7578a5.9994,5.9994,0,0,0-8.4844,8.4844L39.5156,48l-7.7578,7.7578a5.9994,5.9994,0,1,0,8.4844,8.4844L48,56.4844l7.7578,7.7578a5.9994,5.9994,0,0,0,8.4844-8.4844L56.4844,48l7.7578-7.7578A5.9979,5.9979,0,0,0,64.2422,31.7578Z"/> | |||||
| </g> | |||||
| </svg> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| const props = withDefaults(defineProps<{ box?: number; width?: number | string; height?: number | string }>(), { | |||||
| box: 120, | |||||
| width: 20, | |||||
| height: 20, | |||||
| }); | |||||
| const { box, width, height } = props; | |||||
| </script> | |||||
| <style scoped> | |||||
| </style> | |||||
| @@ -0,0 +1,28 @@ | |||||
| <template> | |||||
| <svg | |||||
| xmlns="http://www.w3.org/2000/svg" | |||||
| xmlns:xlink="http://www.w3.org/1999/xlink" | |||||
| :viewBox="`0 0 ${box} ${box}`" | |||||
| :width="width" | |||||
| :height="height" | |||||
| xml:space="preserve" | |||||
| > | |||||
| <g> | |||||
| <path d="M58.3945,32.1563,42.9961,50.625l-5.3906-6.4629a5.995,5.995,0,1,0-9.211,7.6758l9.9961,12a5.9914,5.9914,0,0,0,9.211.0059l20.0039-24a5.9988,5.9988,0,1,0-9.211-7.6875Z"/> | |||||
| <path d="M48,0A48,48,0,1,0,96,48,48.0512,48.0512,0,0,0,48,0Zm0,84A36,36,0,1,1,84,48,36.0393,36.0393,0,0,1,48,84Z"/> | |||||
| </g> | |||||
| </svg> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| const props = withDefaults(defineProps<{ box?: number; width?: number | string; height?: number | string }>(), { | |||||
| box: 120, | |||||
| width: 20, | |||||
| height: 20, | |||||
| }); | |||||
| const { box, width, height } = props; | |||||
| </script> | |||||
| <style scoped> | |||||
| </style> | |||||
| @@ -0,0 +1,19 @@ | |||||
| <template> | |||||
| <div class="h-4 w-4 cursor-pointer mx-2"> | |||||
| <svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" | |||||
| viewBox="0 0 30 30" style="fill:#ffffff;" xml:space="preserve"> | |||||
| <g> | |||||
| <path d="M0.5,7h29C29.776,7,30,6.776,30,6.5S29.776,6,29.5,6h-29C0.224,6,0,6.224,0,6.5S0.224,7,0.5,7z"/> | |||||
| <path d="M29.5,15h-29C0.224,15,0,15.224,0,15.5S0.224,16,0.5,16h29c0.276,0,0.5-0.224,0.5-0.5S29.776,15,29.5,15z"/> | |||||
| <path d="M29.5,23h-29C0.224,23,0,23.224,0,23.5S0.224,24,0.5,24h29c0.276,0,0.5-0.224,0.5-0.5S29.776,23,29.5,23z"/> | |||||
| </g> | |||||
| </svg> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| </script> | |||||
| <style scoped> | |||||
| </style> | |||||
| @@ -1,7 +1,6 @@ | |||||
| <template> | <template> | ||||
| <div class="h-4 w-4 cursor-pointer mx-2"> | |||||
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="dislike-button" x="0px" y="0px" | |||||
| viewBox="0 0 30 30" :style="`fill:${getFill()}`" xml:space="preserve"> | |||||
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" | |||||
| viewBox="0 0 30 30" xml:space="preserve"> | |||||
| <g id="Thumbs_Down"> | <g id="Thumbs_Down"> | ||||
| <path d="M30,12.524c0-1.094-0.653-2.049-1.61-2.472c0.227-0.404,0.35-0.864,0.35-1.335c0-1.108-0.656-2.073-1.615-2.498 | <path d="M30,12.524c0-1.094-0.653-2.049-1.61-2.472c0.227-0.404,0.35-0.864,0.35-1.335c0-1.108-0.656-2.073-1.615-2.498 | ||||
| c0.448-0.812,0.537-1.916-0.1-2.924C26.519,2.496,25.575,2,24.562,2H13.108c-0.949,0-2.44,0.42-3.451,1.023 | c0.448-0.812,0.537-1.916-0.1-2.924C26.519,2.496,25.575,2,24.562,2H13.108c-0.949,0-2.44,0.42-3.451,1.023 | ||||
| @@ -10,18 +9,13 @@ | |||||
| c0-1.777-0.663-3.922-1.042-5h7.957c1.566,0,3.014-1.334,3.159-2.913c0.086-0.938-0.258-1.906-0.871-2.563 | c0-1.777-0.663-3.922-1.042-5h7.957c1.566,0,3.014-1.334,3.159-2.913c0.086-0.938-0.258-1.906-0.871-2.563 | ||||
| C29.673,14.018,30,13.299,30,12.524z"/> | C29.673,14.018,30,13.299,30,12.524z"/> | ||||
| </g> | </g> | ||||
| </svg> | |||||
| </div> | |||||
| </svg> | |||||
| </template> | </template> | ||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| const props = defineProps<{ 'selected': boolean }>(); | |||||
| const getFill = () => props.selected ? '#e61717' : '#ffffff'; | |||||
| </script> | </script> | ||||
| <style scoped> | <style scoped> | ||||
| #dislike-button:hover { | |||||
| fill: #e61717 !important; | |||||
| } | |||||
| </style> | </style> | ||||
| @@ -1,7 +1,6 @@ | |||||
| <template> | <template> | ||||
| <div class="h-4 w-4 cursor-pointer mx-2"> | |||||
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="like-button" x="0px" y="0px" | |||||
| viewBox="0 0 30 30" :style="`fill:${getFill()}`" xml:space="preserve"> | |||||
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" | |||||
| viewBox="0 0 30 30" xml:space="preserve"> | |||||
| <g id="Thumbs_Up"> | <g id="Thumbs_Up"> | ||||
| <path d="M30,18.476c0-0.774-0.327-1.493-0.884-1.999c0.613-0.657,0.957-1.626,0.871-2.563C29.842,12.334,28.395,11,26.828,11 | <path d="M30,18.476c0-0.774-0.327-1.493-0.884-1.999c0.613-0.657,0.957-1.626,0.871-2.563C29.842,12.334,28.395,11,26.828,11 | ||||
| h-7.957c0.379-1.078,1.042-3.223,1.042-5c0-2.48-2.087-5-3.587-5c-1.295,0-2.143,0.828-2.179,0.863C14.053,1.957,14,2.085,14,2.218 | h-7.957c0.379-1.078,1.042-3.223,1.042-5c0-2.48-2.087-5-3.587-5c-1.295,0-2.143,0.828-2.179,0.863C14.053,1.957,14,2.085,14,2.218 | ||||
| @@ -10,18 +9,13 @@ | |||||
| c0.637-1.008,0.548-2.112,0.1-2.924c0.959-0.425,1.615-1.39,1.615-2.498c0-0.471-0.123-0.931-0.35-1.335 | c0.637-1.008,0.548-2.112,0.1-2.924c0.959-0.425,1.615-1.39,1.615-2.498c0-0.471-0.123-0.931-0.35-1.335 | ||||
| C29.347,20.524,30,19.569,30,18.476z"/> | C29.347,20.524,30,19.569,30,18.476z"/> | ||||
| </g> | </g> | ||||
| </svg> | |||||
| </div> | |||||
| </svg> | |||||
| </template> | </template> | ||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| const props = defineProps<{ 'selected': boolean }>(); | |||||
| const getFill = () => props.selected ? "#45bf20" : "#ffffff"; | |||||
| </script> | </script> | ||||
| <style scoped> | <style scoped> | ||||
| #like-button:hover { | |||||
| fill: #45bf20 !important; | |||||
| } | |||||
| </style> | </style> | ||||
| @@ -0,0 +1,54 @@ | |||||
| <template> | |||||
| <Modal header="Amendment" button-text="Close"> | |||||
| <div v-for="item in amendmentContent" :key="item.key"> | |||||
| <template v-if="item.format === 'heading'"> | |||||
| <h3 class="text-xl font-bold">{{ item.value }}</h3> | |||||
| </template> | |||||
| <template v-else-if="item.format === 'chip'"> | |||||
| <Chip | |||||
| :text="getStringValue(item.value)" | |||||
| :class="styleChip(item.value)" | |||||
| /> | |||||
| </template> | |||||
| <template v-else-if="item.format === 'subheading'"> | |||||
| <h4 class="text-lg font-semibold">{{ item.value }}</h4> | |||||
| </template> | |||||
| <template v-else-if="item.format === 'small'"> | |||||
| <small class="text-sm text-gray-600">{{ item.value }}</small> | |||||
| </template> | |||||
| <template v-else> | |||||
| <p class="text-base whitespace-pre-wrap pt-2">{{ item.value }}</p> | |||||
| </template> | |||||
| </div> | |||||
| </Modal> | |||||
| </template> | |||||
| <script setup lang="ts" generic="T extends ContentModal"> | |||||
| import { ContentModal, useModal, ModalContentItem } from "../../composables/useModal.ts"; | |||||
| import Modal from "./Modal.vue"; | |||||
| import { computed } from "vue"; | |||||
| import Chip from "../Chip.vue"; | |||||
| import {getChipClass} from "../../utils/amendment.ts"; | |||||
| const { getAllContent } = useModal(); | |||||
| const amendmentContent = computed<ModalContentItem[]>(() => { | |||||
| // Return a shallow copy to ensure reactivity is maintained and avoid in-place mutations affecting iteration | |||||
| return [...getAllContent()]; | |||||
| }); | |||||
| const getStringValue = (value: string | number) => { | |||||
| if (typeof value === 'number') { | |||||
| return value.toString(); | |||||
| } | |||||
| return value as string; | |||||
| } | |||||
| const styleChip = (value: string | number) => { | |||||
| return getChipClass(getStringValue(value)); | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| </style> | |||||
| @@ -1,17 +1,17 @@ | |||||
| <template> | <template> | ||||
| <Modal header="Filter" @submitted="submit"> | |||||
| <Modal header="Filter" @on-closed="submit"> | |||||
| <slot> | <slot> | ||||
| <div class="flex flex-row justify-between my-3"> | <div class="flex flex-row justify-between my-3"> | ||||
| <div class="flex flex-col justify-center"> | <div class="flex flex-col justify-center"> | ||||
| <label for="min-positive-votes" class="text-sm text-gray-700">Minimum Positive Votes</label> | <label for="min-positive-votes" class="text-sm text-gray-700">Minimum Positive Votes</label> | ||||
| </div> | </div> | ||||
| <input type="number" v-model="minPositiveVotes" id="min-positive-votes" step="1" class="w-3/12 border-gray-300 border border-solid py-1.5 px-2.5 text-sm" /> | |||||
| <input type="number" v-model="minPositiveVotes" id="min-positive-votes" step="1" class="w-3/12 border-gray-300 border border-solid py-1.5 px-2.5 text-sm rounded-md" /> | |||||
| </div> | </div> | ||||
| <div class="flex flex-row justify-between my-3"> | <div class="flex flex-row justify-between my-3"> | ||||
| <div class="flex flex-col justify-center"> | <div class="flex flex-col justify-center"> | ||||
| <label for="min-votes" class="text-sm text-gray-700">Minimum Votes</label> | <label for="min-votes" class="text-sm text-gray-700">Minimum Votes</label> | ||||
| </div> | </div> | ||||
| <input type="number" v-model="minVotes" id="min-votes" step="1" class="w-3/12 border-gray-300 border border-solid py-1.5 px-2.5 text-sm" /> | |||||
| <input type="number" v-model="minVotes" id="min-votes" step="1" class="w-3/12 border-gray-300 border border-solid py-1.5 px-2.5 text-sm rounded-md" /> | |||||
| </div> | </div> | ||||
| </slot> | </slot> | ||||
| </Modal> | </Modal> | ||||
| @@ -1,11 +1,11 @@ | |||||
| <template> | <template> | ||||
| <Modal header="New Issue" @submitted="submit"> | |||||
| <Modal header="New Issue" @on-closed="submit"> | |||||
| <slot> | <slot> | ||||
| <div class="my-3"> | <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" /> | <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> | ||||
| <div class="my-3"> | <div class="my-3"> | ||||
| <PostBuilder @text-change="assignDescription" /> | |||||
| <PostBuilder @update:content="assignDescription" /> | |||||
| </div> | </div> | ||||
| </slot> | </slot> | ||||
| </Modal> | </Modal> | ||||
| @@ -13,32 +13,24 @@ | |||||
| <script setup lang="ts" generic="T extends ContentModal"> | <script setup lang="ts" generic="T extends ContentModal"> | ||||
| import { useFetch } from "../../composables/useFetch.ts"; | |||||
| import { useSession } from "../../composables/useSession.ts"; | |||||
| import Modal from "./Modal.vue"; | import Modal from "./Modal.vue"; | ||||
| import { ref } from "vue"; | import { ref } from "vue"; | ||||
| import { AddIssueRequest, AddIssueResponse } from "../../types/issue.ts"; | |||||
| import PostBuilder from "../PostBuilder.vue"; | import PostBuilder from "../PostBuilder.vue"; | ||||
| import { ContentModal } from "../../composables/useModal.ts"; | import { ContentModal } from "../../composables/useModal.ts"; | ||||
| import freighter from "@stellar/freighter-api"; | |||||
| import {ProposalService} from "../../generated/typescript"; | |||||
| const title = ref(''); | const title = ref(''); | ||||
| const description = ref<string[]>(['']); | |||||
| const { post } = useFetch(); | |||||
| const description = ref<string>(''); | |||||
| const submit = async () => { | const submit = async () => { | ||||
| const { getSessionId } = useSession(); | |||||
| const sessionId = getSessionId(); | |||||
| if (sessionId) { | |||||
| await post<AddIssueRequest, AddIssueResponse>('/issues/create', { | |||||
| title: title.value, | |||||
| paragraphs: description.value | |||||
| }); | |||||
| 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[]) => { | |||||
| const assignDescription = (paragraphs: string) => { | |||||
| description.value = paragraphs; | description.value = paragraphs; | ||||
| } | } | ||||
| </script> | </script> | ||||
| @@ -1,22 +1,24 @@ | |||||
| <template> | <template> | ||||
| <div ref="modalRef" class="flex flex-row justify-center 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="bg-white max-h-[512px] w-96 rounded-xl p-6 m-auto"> | |||||
| <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"> | <div class="flex flex-row justify-end" @click="hide"> | ||||
| <span class="text-2xl text-gray-600 leading-3 cursor-pointer">×</span> | <span class="text-2xl text-gray-600 leading-3 cursor-pointer">×</span> | ||||
| </div> | </div> | ||||
| <div class="h-11/12 w-full"> | |||||
| <div class="h-full"> | |||||
| <div class="flex-1 w-full overflow-y-auto"> | |||||
| <div> | |||||
| <div> | <div> | ||||
| <span class="text-2xl mx-auto font-bold"> | |||||
| {{ header }} | |||||
| </span> | |||||
| <span class="text-2xl mx-auto font-bold"> | |||||
| {{ header }} | |||||
| </span> | |||||
| </div> | </div> | ||||
| <slot name="default" /> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <div class="h-1/12 flex flex-row justify-end"> | |||||
| <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"> | <button class="bg-amber-500 rounded py-2 px-3 text-white font-bold" @click="handleSubmission"> | ||||
| Submit | |||||
| {{ buttonText }} | |||||
| </button> | </button> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| @@ -25,13 +27,16 @@ | |||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| import { useModal } from "../../composables/useModal.ts"; | import { useModal } from "../../composables/useModal.ts"; | ||||
| import { withDefaults } from "vue"; | |||||
| const { hide, getVisibility } = useModal(); | const { hide, getVisibility } = useModal(); | ||||
| const props = defineProps<{header: string}>(); | |||||
| const emits = defineEmits(['submitted']); | |||||
| withDefaults(defineProps<{ header: string; buttonText?: string }>(), { | |||||
| buttonText: 'Submit' | |||||
| }); | |||||
| const emits = defineEmits(['onClosed']); | |||||
| const handleSubmission = () => { | const handleSubmission = () => { | ||||
| emits('submitted'); | |||||
| emits('onClosed'); | |||||
| hide(); | hide(); | ||||
| }; | }; | ||||
| </script> | </script> | ||||
| @@ -1,20 +1,70 @@ | |||||
| /** | |||||
| * @deprecated This method is deprecated. | |||||
| */ | |||||
| export const useFetch = () => { | export const useFetch = () => { | ||||
| const get = async <T>(endpoint: string) => { | |||||
| const data = await fetch(`/api${ endpoint }`, { | |||||
| method: 'GET', | |||||
| 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', | mode: 'cors', | ||||
| headers: new Headers({ 'Content-Type': 'application/json' }) | |||||
| headers: new Headers(DEFAULT_CONFIG.headers), | |||||
| ...options | |||||
| }); | }); | ||||
| return await data.json() as T; | |||||
| const data = await response.json(); | |||||
| return {data, status: response.status}; | |||||
| }; | }; | ||||
| const post = async <T, U>(endpoint: string, payload: T) => { | |||||
| const data = await fetch(`/api${ endpoint }`, { | |||||
| method: 'POST', | |||||
| mode: 'cors', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| /** | |||||
| * @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) | body: JSON.stringify(payload) | ||||
| }); | }); | ||||
| return await data.json() as U; | |||||
| return response.data; | |||||
| }; | }; | ||||
| return { get, post } | return { get, post } | ||||
| @@ -2,8 +2,16 @@ import { ref } from 'vue' | |||||
| export class ContentModal {} | export class ContentModal {} | ||||
| export type ModalContentFormat = 'heading' | 'paragraph' | 'subheading' | 'small' | 'chip'; | |||||
| export type ModalContentItem = { | |||||
| key: string; | |||||
| value: string | number; | |||||
| format?: ModalContentFormat; | |||||
| }; | |||||
| const visible = ref(false); | const visible = ref(false); | ||||
| const modalContent = ref<ContentModal | null>(null); | const modalContent = ref<ContentModal | null>(null); | ||||
| const contentItems = ref<ModalContentItem[]>([]); | |||||
| export const useModal = () => { | export const useModal = () => { | ||||
| const getVisibility = () => visible.value; | const getVisibility = () => visible.value; | ||||
| @@ -16,5 +24,48 @@ export const useModal = () => { | |||||
| modalContent.value = null; | modalContent.value = null; | ||||
| visible.value = false; | visible.value = false; | ||||
| } | } | ||||
| return { getVisibility, getModalContent, show, hide }; | |||||
| // Set or update a single content entry (by key) | |||||
| const setContent = (key: string, value: string | number, format?: ModalContentFormat) => { | |||||
| const idx = contentItems.value.findIndex(i => i.key === key); | |||||
| const item: ModalContentItem = { key, value, format }; | |||||
| if (idx >= 0) contentItems.value.splice(idx, 1, item); | |||||
| else contentItems.value.push(item); | |||||
| } | |||||
| // Add content entries | |||||
| function addContent(item: ModalContentItem): void; | |||||
| function addContent(items: ModalContentItem[]): void; | |||||
| function addContent(arg: ModalContentItem | ModalContentItem[]) { | |||||
| if (Array.isArray(arg)) { | |||||
| for (const it of arg) contentItems.value.push(it); | |||||
| } else if (arg && typeof arg === 'object') { | |||||
| contentItems.value.push(arg); | |||||
| } | |||||
| } | |||||
| // Remove a content entry by key | |||||
| const removeContent = (key: string) => { | |||||
| const idx = contentItems.value.findIndex(i => i.key === key); | |||||
| if (idx >= 0) contentItems.value.splice(idx, 1); | |||||
| } | |||||
| // Clear all dynamic content | |||||
| const clearContent = () => { | |||||
| contentItems.value = []; | |||||
| } | |||||
| const getContent = (key: string) => contentItems.value.find(i => i.key === key); | |||||
| const getAllContent = () => contentItems.value; | |||||
| // Convenience: set amendment content with formatting centralized here | |||||
| const setAmendmentContent = (name: string, status: string, body: string) => { | |||||
| contentItems.value = [ | |||||
| { key: 'name', value: name, format: 'heading' }, | |||||
| { key: 'status', value: status, format: 'chip' }, | |||||
| { key: 'body', value: body, format: 'paragraph' }, | |||||
| ]; | |||||
| } | |||||
| return { getVisibility, getModalContent, setContent, addContent, removeContent, clearContent, getContent, getAllContent, setAmendmentContent, show, hide }; | |||||
| } | } | ||||
| @@ -1,46 +1,9 @@ | |||||
| import { ref } from "vue"; | |||||
| import { useCookies } from '@vueuse/integrations/useCookies' | |||||
| import { useFetch } from "./useFetch.ts"; | |||||
| import { GetUsernameResponse } from "../types/user.ts"; | |||||
| const cookies = useCookies(['session']); | |||||
| const { get } = useFetch(); | |||||
| const sessionId = ref<string>(); | |||||
| const username = ref<string>(); | |||||
| import freighter from '@stellar/freighter-api'; | |||||
| export const useSession = () => { | export const useSession = () => { | ||||
| const setSessionId = (id: string) => { | |||||
| cookies.set('session', id); | |||||
| sessionId.value = id; | |||||
| } | |||||
| const getSessionId = (): string | undefined => sessionId.value; | |||||
| const initializeSessionId = (): void => { | |||||
| if (!sessionId.value) { | |||||
| const cookieSessionId = cookies.get('session'); | |||||
| if (cookieSessionId) { | |||||
| setSessionId(cookieSessionId); | |||||
| } | |||||
| } | |||||
| }; | |||||
| const setUsername = (name: string | undefined): void => { | |||||
| username.value = name; | |||||
| } | |||||
| const getUsername = (): string | undefined => username.value; | |||||
| const initializeUsername = async () => { | |||||
| if (!username.value) { | |||||
| const cookieSessionId = cookies.get('session'); | |||||
| if (cookieSessionId) { | |||||
| const username = await get<GetUsernameResponse>(`/sessions/get_username?session_id=${ cookieSessionId }`); | |||||
| setUsername(username.username); | |||||
| } | |||||
| } | |||||
| const hasFreighter = async () => { | |||||
| return freighter.isConnected(); | |||||
| } | } | ||||
| return { initializeSessionId, setSessionId, getSessionId, setUsername, getUsername, initializeUsername }; | |||||
| return { hasFreighter }; | |||||
| } | } | ||||
| @@ -0,0 +1,25 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| import type { ApiRequestOptions } from './ApiRequestOptions'; | |||||
| import type { ApiResult } from './ApiResult'; | |||||
| export class ApiError extends Error { | |||||
| public readonly url: string; | |||||
| public readonly status: number; | |||||
| public readonly statusText: string; | |||||
| public readonly body: any; | |||||
| public readonly request: ApiRequestOptions; | |||||
| constructor(request: ApiRequestOptions, response: ApiResult, message: string) { | |||||
| super(message); | |||||
| this.name = 'ApiError'; | |||||
| this.url = response.url; | |||||
| this.status = response.status; | |||||
| this.statusText = response.statusText; | |||||
| this.body = response.body; | |||||
| this.request = request; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,17 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export type ApiRequestOptions = { | |||||
| readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; | |||||
| readonly url: string; | |||||
| readonly path?: Record<string, any>; | |||||
| readonly cookies?: Record<string, any>; | |||||
| readonly headers?: Record<string, any>; | |||||
| readonly query?: Record<string, any>; | |||||
| readonly formData?: Record<string, any>; | |||||
| readonly body?: any; | |||||
| readonly mediaType?: string; | |||||
| readonly responseHeader?: string; | |||||
| readonly errors?: Record<number, string>; | |||||
| }; | |||||
| @@ -0,0 +1,11 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export type ApiResult = { | |||||
| readonly url: string; | |||||
| readonly ok: boolean; | |||||
| readonly status: number; | |||||
| readonly statusText: string; | |||||
| readonly body: any; | |||||
| }; | |||||
| @@ -0,0 +1,131 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export class CancelError extends Error { | |||||
| constructor(message: string) { | |||||
| super(message); | |||||
| this.name = 'CancelError'; | |||||
| } | |||||
| public get isCancelled(): boolean { | |||||
| return true; | |||||
| } | |||||
| } | |||||
| export interface OnCancel { | |||||
| readonly isResolved: boolean; | |||||
| readonly isRejected: boolean; | |||||
| readonly isCancelled: boolean; | |||||
| (cancelHandler: () => void): void; | |||||
| } | |||||
| export class CancelablePromise<T> implements Promise<T> { | |||||
| #isResolved: boolean; | |||||
| #isRejected: boolean; | |||||
| #isCancelled: boolean; | |||||
| readonly #cancelHandlers: (() => void)[]; | |||||
| readonly #promise: Promise<T>; | |||||
| #resolve?: (value: T | PromiseLike<T>) => void; | |||||
| #reject?: (reason?: any) => void; | |||||
| constructor( | |||||
| executor: ( | |||||
| resolve: (value: T | PromiseLike<T>) => void, | |||||
| reject: (reason?: any) => void, | |||||
| onCancel: OnCancel | |||||
| ) => void | |||||
| ) { | |||||
| this.#isResolved = false; | |||||
| this.#isRejected = false; | |||||
| this.#isCancelled = false; | |||||
| this.#cancelHandlers = []; | |||||
| this.#promise = new Promise<T>((resolve, reject) => { | |||||
| this.#resolve = resolve; | |||||
| this.#reject = reject; | |||||
| const onResolve = (value: T | PromiseLike<T>): void => { | |||||
| if (this.#isResolved || this.#isRejected || this.#isCancelled) { | |||||
| return; | |||||
| } | |||||
| this.#isResolved = true; | |||||
| if (this.#resolve) this.#resolve(value); | |||||
| }; | |||||
| const onReject = (reason?: any): void => { | |||||
| if (this.#isResolved || this.#isRejected || this.#isCancelled) { | |||||
| return; | |||||
| } | |||||
| this.#isRejected = true; | |||||
| if (this.#reject) this.#reject(reason); | |||||
| }; | |||||
| const onCancel = (cancelHandler: () => void): void => { | |||||
| if (this.#isResolved || this.#isRejected || this.#isCancelled) { | |||||
| return; | |||||
| } | |||||
| this.#cancelHandlers.push(cancelHandler); | |||||
| }; | |||||
| Object.defineProperty(onCancel, 'isResolved', { | |||||
| get: (): boolean => this.#isResolved, | |||||
| }); | |||||
| Object.defineProperty(onCancel, 'isRejected', { | |||||
| get: (): boolean => this.#isRejected, | |||||
| }); | |||||
| Object.defineProperty(onCancel, 'isCancelled', { | |||||
| get: (): boolean => this.#isCancelled, | |||||
| }); | |||||
| return executor(onResolve, onReject, onCancel as OnCancel); | |||||
| }); | |||||
| } | |||||
| get [Symbol.toStringTag]() { | |||||
| return "Cancellable Promise"; | |||||
| } | |||||
| public then<TResult1 = T, TResult2 = never>( | |||||
| onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null, | |||||
| onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null | |||||
| ): Promise<TResult1 | TResult2> { | |||||
| return this.#promise.then(onFulfilled, onRejected); | |||||
| } | |||||
| public catch<TResult = never>( | |||||
| onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null | |||||
| ): Promise<T | TResult> { | |||||
| return this.#promise.catch(onRejected); | |||||
| } | |||||
| public finally(onFinally?: (() => void) | null): Promise<T> { | |||||
| return this.#promise.finally(onFinally); | |||||
| } | |||||
| public cancel(): void { | |||||
| if (this.#isResolved || this.#isRejected || this.#isCancelled) { | |||||
| return; | |||||
| } | |||||
| this.#isCancelled = true; | |||||
| if (this.#cancelHandlers.length) { | |||||
| try { | |||||
| for (const cancelHandler of this.#cancelHandlers) { | |||||
| cancelHandler(); | |||||
| } | |||||
| } catch (error) { | |||||
| console.warn('Cancellation threw an error', error); | |||||
| return; | |||||
| } | |||||
| } | |||||
| this.#cancelHandlers.length = 0; | |||||
| if (this.#reject) this.#reject(new CancelError('Request aborted')); | |||||
| } | |||||
| public get isCancelled(): boolean { | |||||
| return this.#isCancelled; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,32 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| import type { ApiRequestOptions } from './ApiRequestOptions'; | |||||
| type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; | |||||
| type Headers = Record<string, string>; | |||||
| export type OpenAPIConfig = { | |||||
| BASE: string; | |||||
| VERSION: string; | |||||
| WITH_CREDENTIALS: boolean; | |||||
| CREDENTIALS: 'include' | 'omit' | 'same-origin'; | |||||
| TOKEN?: string | Resolver<string> | undefined; | |||||
| USERNAME?: string | Resolver<string> | undefined; | |||||
| PASSWORD?: string | Resolver<string> | undefined; | |||||
| HEADERS?: Headers | Resolver<Headers> | undefined; | |||||
| ENCODE_PATH?: ((path: string) => string) | undefined; | |||||
| }; | |||||
| export const OpenAPI: OpenAPIConfig = { | |||||
| BASE: 'http://localhost:7300', | |||||
| VERSION: '1.0.0', | |||||
| WITH_CREDENTIALS: false, | |||||
| CREDENTIALS: 'include', | |||||
| TOKEN: undefined, | |||||
| USERNAME: undefined, | |||||
| PASSWORD: undefined, | |||||
| HEADERS: undefined, | |||||
| ENCODE_PATH: undefined, | |||||
| }; | |||||
| @@ -0,0 +1,322 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| import { ApiError } from './ApiError'; | |||||
| import type { ApiRequestOptions } from './ApiRequestOptions'; | |||||
| import type { ApiResult } from './ApiResult'; | |||||
| import { CancelablePromise } from './CancelablePromise'; | |||||
| import type { OnCancel } from './CancelablePromise'; | |||||
| import type { OpenAPIConfig } from './OpenAPI'; | |||||
| export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => { | |||||
| return value !== undefined && value !== null; | |||||
| }; | |||||
| export const isString = (value: any): value is string => { | |||||
| return typeof value === 'string'; | |||||
| }; | |||||
| export const isStringWithValue = (value: any): value is string => { | |||||
| return isString(value) && value !== ''; | |||||
| }; | |||||
| export const isBlob = (value: any): value is Blob => { | |||||
| return ( | |||||
| typeof value === 'object' && | |||||
| typeof value.type === 'string' && | |||||
| typeof value.stream === 'function' && | |||||
| typeof value.arrayBuffer === 'function' && | |||||
| typeof value.constructor === 'function' && | |||||
| typeof value.constructor.name === 'string' && | |||||
| /^(Blob|File)$/.test(value.constructor.name) && | |||||
| /^(Blob|File)$/.test(value[Symbol.toStringTag]) | |||||
| ); | |||||
| }; | |||||
| export const isFormData = (value: any): value is FormData => { | |||||
| return value instanceof FormData; | |||||
| }; | |||||
| export const base64 = (str: string): string => { | |||||
| try { | |||||
| return btoa(str); | |||||
| } catch (err) { | |||||
| // @ts-ignore | |||||
| return Buffer.from(str).toString('base64'); | |||||
| } | |||||
| }; | |||||
| export const getQueryString = (params: Record<string, any>): string => { | |||||
| const qs: string[] = []; | |||||
| const append = (key: string, value: any) => { | |||||
| qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); | |||||
| }; | |||||
| const process = (key: string, value: any) => { | |||||
| if (isDefined(value)) { | |||||
| if (Array.isArray(value)) { | |||||
| value.forEach(v => { | |||||
| process(key, v); | |||||
| }); | |||||
| } else if (typeof value === 'object') { | |||||
| Object.entries(value).forEach(([k, v]) => { | |||||
| process(`${key}[${k}]`, v); | |||||
| }); | |||||
| } else { | |||||
| append(key, value); | |||||
| } | |||||
| } | |||||
| }; | |||||
| Object.entries(params).forEach(([key, value]) => { | |||||
| process(key, value); | |||||
| }); | |||||
| if (qs.length > 0) { | |||||
| return `?${qs.join('&')}`; | |||||
| } | |||||
| return ''; | |||||
| }; | |||||
| const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { | |||||
| const encoder = config.ENCODE_PATH || encodeURI; | |||||
| const path = options.url | |||||
| .replace('{api-version}', config.VERSION) | |||||
| .replace(/{(.*?)}/g, (substring: string, group: string) => { | |||||
| if (options.path?.hasOwnProperty(group)) { | |||||
| return encoder(String(options.path[group])); | |||||
| } | |||||
| return substring; | |||||
| }); | |||||
| const url = `${config.BASE}${path}`; | |||||
| if (options.query) { | |||||
| return `${url}${getQueryString(options.query)}`; | |||||
| } | |||||
| return url; | |||||
| }; | |||||
| export const getFormData = (options: ApiRequestOptions): FormData | undefined => { | |||||
| if (options.formData) { | |||||
| const formData = new FormData(); | |||||
| const process = (key: string, value: any) => { | |||||
| if (isString(value) || isBlob(value)) { | |||||
| formData.append(key, value); | |||||
| } else { | |||||
| formData.append(key, JSON.stringify(value)); | |||||
| } | |||||
| }; | |||||
| Object.entries(options.formData) | |||||
| .filter(([_, value]) => isDefined(value)) | |||||
| .forEach(([key, value]) => { | |||||
| if (Array.isArray(value)) { | |||||
| value.forEach(v => process(key, v)); | |||||
| } else { | |||||
| process(key, value); | |||||
| } | |||||
| }); | |||||
| return formData; | |||||
| } | |||||
| return undefined; | |||||
| }; | |||||
| type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; | |||||
| export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => { | |||||
| if (typeof resolver === 'function') { | |||||
| return (resolver as Resolver<T>)(options); | |||||
| } | |||||
| return resolver; | |||||
| }; | |||||
| export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions): Promise<Headers> => { | |||||
| const [token, username, password, additionalHeaders] = await Promise.all([ | |||||
| resolve(options, config.TOKEN), | |||||
| resolve(options, config.USERNAME), | |||||
| resolve(options, config.PASSWORD), | |||||
| resolve(options, config.HEADERS), | |||||
| ]); | |||||
| const headers = Object.entries({ | |||||
| Accept: 'application/json', | |||||
| ...additionalHeaders, | |||||
| ...options.headers, | |||||
| }) | |||||
| .filter(([_, value]) => isDefined(value)) | |||||
| .reduce((headers, [key, value]) => ({ | |||||
| ...headers, | |||||
| [key]: String(value), | |||||
| }), {} as Record<string, string>); | |||||
| if (isStringWithValue(token)) { | |||||
| headers['Authorization'] = `Bearer ${token}`; | |||||
| } | |||||
| if (isStringWithValue(username) && isStringWithValue(password)) { | |||||
| const credentials = base64(`${username}:${password}`); | |||||
| headers['Authorization'] = `Basic ${credentials}`; | |||||
| } | |||||
| if (options.body !== undefined) { | |||||
| if (options.mediaType) { | |||||
| headers['Content-Type'] = options.mediaType; | |||||
| } else if (isBlob(options.body)) { | |||||
| headers['Content-Type'] = options.body.type || 'application/octet-stream'; | |||||
| } else if (isString(options.body)) { | |||||
| headers['Content-Type'] = 'text/plain'; | |||||
| } else if (!isFormData(options.body)) { | |||||
| headers['Content-Type'] = 'application/json'; | |||||
| } | |||||
| } | |||||
| return new Headers(headers); | |||||
| }; | |||||
| export const getRequestBody = (options: ApiRequestOptions): any => { | |||||
| if (options.body !== undefined) { | |||||
| if (options.mediaType?.includes('/json')) { | |||||
| return JSON.stringify(options.body) | |||||
| } else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) { | |||||
| return options.body; | |||||
| } else { | |||||
| return JSON.stringify(options.body); | |||||
| } | |||||
| } | |||||
| return undefined; | |||||
| }; | |||||
| export const sendRequest = async ( | |||||
| config: OpenAPIConfig, | |||||
| options: ApiRequestOptions, | |||||
| url: string, | |||||
| body: any, | |||||
| formData: FormData | undefined, | |||||
| headers: Headers, | |||||
| onCancel: OnCancel | |||||
| ): Promise<Response> => { | |||||
| const controller = new AbortController(); | |||||
| const request: RequestInit = { | |||||
| headers, | |||||
| body: body ?? formData, | |||||
| method: options.method, | |||||
| signal: controller.signal, | |||||
| }; | |||||
| if (config.WITH_CREDENTIALS) { | |||||
| request.credentials = config.CREDENTIALS; | |||||
| } | |||||
| onCancel(() => controller.abort()); | |||||
| return await fetch(url, request); | |||||
| }; | |||||
| export const getResponseHeader = (response: Response, responseHeader?: string): string | undefined => { | |||||
| if (responseHeader) { | |||||
| const content = response.headers.get(responseHeader); | |||||
| if (isString(content)) { | |||||
| return content; | |||||
| } | |||||
| } | |||||
| return undefined; | |||||
| }; | |||||
| export const getResponseBody = async (response: Response): Promise<any> => { | |||||
| if (response.status !== 204) { | |||||
| try { | |||||
| const contentType = response.headers.get('Content-Type'); | |||||
| if (contentType) { | |||||
| const jsonTypes = ['application/json', 'application/problem+json'] | |||||
| const isJSON = jsonTypes.some(type => contentType.toLowerCase().startsWith(type)); | |||||
| if (isJSON) { | |||||
| return await response.json(); | |||||
| } else { | |||||
| return await response.text(); | |||||
| } | |||||
| } | |||||
| } catch (error) { | |||||
| console.error(error); | |||||
| } | |||||
| } | |||||
| return undefined; | |||||
| }; | |||||
| export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { | |||||
| const errors: Record<number, string> = { | |||||
| 400: 'Bad Request', | |||||
| 401: 'Unauthorized', | |||||
| 403: 'Forbidden', | |||||
| 404: 'Not Found', | |||||
| 500: 'Internal Server Error', | |||||
| 502: 'Bad Gateway', | |||||
| 503: 'Service Unavailable', | |||||
| ...options.errors, | |||||
| } | |||||
| const error = errors[result.status]; | |||||
| if (error) { | |||||
| throw new ApiError(options, result, error); | |||||
| } | |||||
| if (!result.ok) { | |||||
| const errorStatus = result.status ?? 'unknown'; | |||||
| const errorStatusText = result.statusText ?? 'unknown'; | |||||
| const errorBody = (() => { | |||||
| try { | |||||
| return JSON.stringify(result.body, null, 2); | |||||
| } catch (e) { | |||||
| return undefined; | |||||
| } | |||||
| })(); | |||||
| throw new ApiError(options, result, | |||||
| `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` | |||||
| ); | |||||
| } | |||||
| }; | |||||
| /** | |||||
| * Request method | |||||
| * @param config The OpenAPI configuration object | |||||
| * @param options The request options from the service | |||||
| * @returns CancelablePromise<T> | |||||
| * @throws ApiError | |||||
| */ | |||||
| export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions): CancelablePromise<T> => { | |||||
| return new CancelablePromise(async (resolve, reject, onCancel) => { | |||||
| try { | |||||
| const url = getUrl(config, options); | |||||
| const formData = getFormData(options); | |||||
| const body = getRequestBody(options); | |||||
| const headers = await getHeaders(config, options); | |||||
| if (!onCancel.isCancelled) { | |||||
| const response = await sendRequest(config, options, url, body, formData, headers, onCancel); | |||||
| const responseBody = await getResponseBody(response); | |||||
| const responseHeader = getResponseHeader(response, options.responseHeader); | |||||
| const result: ApiResult = { | |||||
| url, | |||||
| ok: response.ok, | |||||
| status: response.status, | |||||
| statusText: response.statusText, | |||||
| body: responseHeader ?? responseBody, | |||||
| }; | |||||
| catchErrorCodes(options, result); | |||||
| resolve(result.body); | |||||
| } | |||||
| } catch (error) { | |||||
| reject(error); | |||||
| } | |||||
| }); | |||||
| }; | |||||
| @@ -0,0 +1,30 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export { ApiError } from './core/ApiError'; | |||||
| export { CancelablePromise, CancelError } from './core/CancelablePromise'; | |||||
| export { OpenAPI } from './core/OpenAPI'; | |||||
| export type { OpenAPIConfig } from './core/OpenAPI'; | |||||
| 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 { 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 { AmendmentService } from './services/AmendmentService'; | |||||
| export { AuthService } from './services/AuthService'; | |||||
| export { ProposalService } from './services/ProposalService'; | |||||
| @@ -0,0 +1,10 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export type AddProposalRequest = { | |||||
| creator: string; | |||||
| description: string; | |||||
| name: string; | |||||
| }; | |||||
| @@ -0,0 +1,8 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export type FreighterLoginRequest = { | |||||
| stellarAddress: string; | |||||
| }; | |||||
| @@ -0,0 +1,9 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| import type { ProposalList } from './ProposalList'; | |||||
| export type ListProposalsResponse = { | |||||
| proposals?: ProposalList; | |||||
| }; | |||||
| @@ -0,0 +1,9 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export type LoginRequest = { | |||||
| password: string; | |||||
| username: string; | |||||
| }; | |||||
| @@ -0,0 +1,16 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export type ProposalItem = { | |||||
| cid: string; | |||||
| createdAt?: string | null; | |||||
| creator: string; | |||||
| id: number; | |||||
| isCurrent: boolean; | |||||
| name: string; | |||||
| previousCid?: string | null; | |||||
| summary?: string | null; | |||||
| updatedAt?: string | null; | |||||
| }; | |||||
| @@ -0,0 +1,6 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| import type { ProposalItem } from './ProposalItem'; | |||||
| export type ProposalList = Array<ProposalItem>; | |||||
| @@ -0,0 +1,11 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export type RegisterRequest = { | |||||
| email?: string | null; | |||||
| password: string; | |||||
| stellarAddress: string; | |||||
| username: string; | |||||
| }; | |||||
| @@ -0,0 +1,13 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export type VisitProposalRequest = { | |||||
| method?: string; | |||||
| path?: string; | |||||
| proposalId: number; | |||||
| queryString?: string; | |||||
| referrer?: string; | |||||
| statusCode?: number; | |||||
| }; | |||||
| @@ -0,0 +1,20 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export const $AddProposalRequest = { | |||||
| properties: { | |||||
| creator: { | |||||
| type: 'string', | |||||
| isRequired: true, | |||||
| }, | |||||
| description: { | |||||
| type: 'string', | |||||
| isRequired: true, | |||||
| }, | |||||
| name: { | |||||
| type: 'string', | |||||
| isRequired: true, | |||||
| }, | |||||
| }, | |||||
| } as const; | |||||
| @@ -0,0 +1,12 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export const $FreighterLoginRequest = { | |||||
| properties: { | |||||
| stellarAddress: { | |||||
| type: 'string', | |||||
| isRequired: true, | |||||
| }, | |||||
| }, | |||||
| } as const; | |||||
| @@ -0,0 +1,11 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export const $ListProposalsResponse = { | |||||
| properties: { | |||||
| proposals: { | |||||
| type: 'ProposalList', | |||||
| }, | |||||
| }, | |||||
| } as const; | |||||
| @@ -0,0 +1,16 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export const $LoginRequest = { | |||||
| properties: { | |||||
| password: { | |||||
| type: 'string', | |||||
| isRequired: true, | |||||
| }, | |||||
| username: { | |||||
| type: 'string', | |||||
| isRequired: true, | |||||
| }, | |||||
| }, | |||||
| } as const; | |||||
| @@ -0,0 +1,45 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export const $ProposalItem = { | |||||
| properties: { | |||||
| cid: { | |||||
| type: 'string', | |||||
| isRequired: true, | |||||
| }, | |||||
| createdAt: { | |||||
| type: 'string', | |||||
| isNullable: true, | |||||
| }, | |||||
| creator: { | |||||
| type: 'string', | |||||
| isRequired: true, | |||||
| }, | |||||
| id: { | |||||
| type: 'number', | |||||
| isRequired: true, | |||||
| format: 'int32', | |||||
| }, | |||||
| isCurrent: { | |||||
| type: 'boolean', | |||||
| isRequired: true, | |||||
| }, | |||||
| name: { | |||||
| type: 'string', | |||||
| isRequired: true, | |||||
| }, | |||||
| previousCid: { | |||||
| type: 'string', | |||||
| isNullable: true, | |||||
| }, | |||||
| summary: { | |||||
| type: 'string', | |||||
| isNullable: true, | |||||
| }, | |||||
| updatedAt: { | |||||
| type: 'string', | |||||
| isNullable: true, | |||||
| }, | |||||
| }, | |||||
| } as const; | |||||
| @@ -0,0 +1,10 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export const $ProposalList = { | |||||
| type: 'array', | |||||
| contains: { | |||||
| type: 'ProposalItem', | |||||
| }, | |||||
| } as const; | |||||
| @@ -0,0 +1,24 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export const $RegisterRequest = { | |||||
| properties: { | |||||
| email: { | |||||
| type: 'string', | |||||
| isNullable: true, | |||||
| }, | |||||
| password: { | |||||
| type: 'string', | |||||
| isRequired: true, | |||||
| }, | |||||
| stellarAddress: { | |||||
| type: 'string', | |||||
| isRequired: true, | |||||
| }, | |||||
| username: { | |||||
| type: 'string', | |||||
| isRequired: true, | |||||
| }, | |||||
| }, | |||||
| } as const; | |||||
| @@ -0,0 +1,29 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| export const $VisitProposalRequest = { | |||||
| properties: { | |||||
| method: { | |||||
| type: 'string', | |||||
| }, | |||||
| path: { | |||||
| type: 'string', | |||||
| }, | |||||
| proposalId: { | |||||
| type: 'number', | |||||
| isRequired: true, | |||||
| format: 'int32', | |||||
| }, | |||||
| queryString: { | |||||
| type: 'string', | |||||
| }, | |||||
| referrer: { | |||||
| type: 'string', | |||||
| }, | |||||
| statusCode: { | |||||
| type: 'number', | |||||
| format: 'int32', | |||||
| }, | |||||
| }, | |||||
| } as const; | |||||
| @@ -0,0 +1,59 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| 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 | |||||
| * @throws ApiError | |||||
| */ | |||||
| public static createAmendment( | |||||
| requestBody: Record<string, any>, | |||||
| ): CancelablePromise<any> { | |||||
| return __request(OpenAPI, { | |||||
| method: 'POST', | |||||
| url: '/api/v1/amendment/add', | |||||
| body: requestBody, | |||||
| mediaType: 'application/json', | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * Get an amendment | |||||
| * @param id | |||||
| * @returns any OK | |||||
| * @throws ApiError | |||||
| */ | |||||
| public static getAmendment( | |||||
| id: number, | |||||
| ): CancelablePromise<Record<string, any>> { | |||||
| return __request(OpenAPI, { | |||||
| method: 'GET', | |||||
| url: '/api/v1/amendment/get', | |||||
| query: { | |||||
| 'id': id, | |||||
| }, | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * List amendments | |||||
| * @param proposalId | |||||
| * @returns any OK | |||||
| * @throws ApiError | |||||
| */ | |||||
| public static listAmendments( | |||||
| proposalId: number, | |||||
| ): CancelablePromise<Record<string, any>> { | |||||
| return __request(OpenAPI, { | |||||
| method: 'GET', | |||||
| url: '/api/v1/amendments/list', | |||||
| query: { | |||||
| 'proposalId': proposalId, | |||||
| }, | |||||
| }); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,81 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* tslint:disable */ | |||||
| /* eslint-disable */ | |||||
| import type { FreighterLoginRequest } from '../models/FreighterLoginRequest'; | |||||
| import type { LoginRequest } from '../models/LoginRequest'; | |||||
| import type { RegisterRequest } from '../models/RegisterRequest'; | |||||
| import type { CancelablePromise } from '../core/CancelablePromise'; | |||||
| import { OpenAPI } from '../core/OpenAPI'; | |||||
| import { request as __request } from '../core/request'; | |||||
| export class AuthService { | |||||
| /** | |||||
| * Login with username/password | |||||
| * @param requestBody | |||||
| * @returns any OK | |||||
| * @throws ApiError | |||||
| */ | |||||
| public static loginUser( | |||||
| requestBody: LoginRequest, | |||||
| ): CancelablePromise<Record<string, any>> { | |||||
| return __request(OpenAPI, { | |||||
| method: 'POST', | |||||
| url: '/api/v1/auth/login', | |||||
| body: requestBody, | |||||
| mediaType: 'application/json', | |||||
| errors: { | |||||
| 401: `Unauthorized`, | |||||
| }, | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * Login with Stellar (Freighter) | |||||
| * @param requestBody | |||||
| * @returns any OK | |||||
| * @throws ApiError | |||||
| */ | |||||
| public static loginFreighter( | |||||
| requestBody: FreighterLoginRequest, | |||||
| ): CancelablePromise<Record<string, any>> { | |||||
| return __request(OpenAPI, { | |||||
| method: 'POST', | |||||
| url: '/api/v1/auth/login/freighter', | |||||
| body: requestBody, | |||||
| mediaType: 'application/json', | |||||
| errors: { | |||||
| 401: `Unauthorized`, | |||||
| }, | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * Get freighter login nonce | |||||
| * @returns any OK | |||||
| * @throws ApiError | |||||
| */ | |||||
| public static getFreighterNonce(): CancelablePromise<Record<string, any>> { | |||||
| return __request(OpenAPI, { | |||||
| method: 'GET', | |||||
| url: '/api/v1/auth/nonce', | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * Register a user | |||||
| * @param requestBody | |||||
| * @returns any Created | |||||
| * @throws ApiError | |||||
| */ | |||||
| public static registerUser( | |||||
| requestBody: RegisterRequest, | |||||
| ): CancelablePromise<Record<string, any>> { | |||||
| return __request(OpenAPI, { | |||||
| method: 'POST', | |||||
| url: '/api/v1/auth/register', | |||||
| body: requestBody, | |||||
| mediaType: 'application/json', | |||||
| errors: { | |||||
| 400: `Bad Request`, | |||||
| 409: `Conflict`, | |||||
| }, | |||||
| }); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,84 @@ | |||||
| /* generated using openapi-typescript-codegen -- do not edit */ | |||||
| /* istanbul ignore file */ | |||||
| /* 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 | |||||
| * @throws ApiError | |||||
| */ | |||||
| public static createProposal( | |||||
| requestBody: AddProposalRequest, | |||||
| ): CancelablePromise<Record<string, any>> { | |||||
| return __request(OpenAPI, { | |||||
| method: 'POST', | |||||
| url: '/api/v1/proposal/add', | |||||
| body: requestBody, | |||||
| mediaType: 'application/json', | |||||
| errors: { | |||||
| 400: `Bad Request`, | |||||
| }, | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * Get a proposal by id | |||||
| * @param id | |||||
| * @returns any OK | |||||
| * @throws ApiError | |||||
| */ | |||||
| public static getProposal( | |||||
| id: number, | |||||
| ): CancelablePromise<Record<string, any>> { | |||||
| return __request(OpenAPI, { | |||||
| method: 'GET', | |||||
| url: '/api/v1/proposal/get', | |||||
| query: { | |||||
| 'id': id, | |||||
| }, | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 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 | |||||
| * @throws ApiError | |||||
| */ | |||||
| public static listProposals( | |||||
| offset: number, | |||||
| limit: number, | |||||
| ): CancelablePromise<ListProposalsResponse> { | |||||
| return __request(OpenAPI, { | |||||
| method: 'GET', | |||||
| url: '/api/v1/proposals/list', | |||||
| query: { | |||||
| 'offset': offset, | |||||
| 'limit': limit, | |||||
| }, | |||||
| }); | |||||
| } | |||||
| } | |||||
| @@ -1,5 +1,13 @@ | |||||
| @import url('https://fonts.googleapis.com/css2?family=Aleo:ital,wght@0,100..900;1,100..900&display=swap'); | |||||
| @import url('https://fonts.googleapis.com/css2?family=Gabarito:wght@400..900&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap'); | |||||
| @tailwind base; | |||||
| @tailwind components; | |||||
| @tailwind utilities; | |||||
| @import "tailwindcss"; | |||||
| @layer base { | |||||
| input[type='number']::-webkit-outer-spin-button, | |||||
| input[type='number']::-webkit-inner-spin-button, | |||||
| input[type='number'] { | |||||
| -webkit-appearance: none; | |||||
| margin: 0; | |||||
| -moz-appearance: textfield !important; | |||||
| } | |||||
| } | |||||
| @@ -3,6 +3,7 @@ import './index.css' | |||||
| import App from './App.vue' | import App from './App.vue' | ||||
| import router from './router' | import router from './router' | ||||
| import { createPinia } from 'pinia' | import { createPinia } from 'pinia' | ||||
| import './index.css' | |||||
| const pinia = createPinia(); | const pinia = createPinia(); | ||||
| createApp(App).use(pinia).use(router).mount('#app') | createApp(App).use(pinia).use(router).mount('#app') | ||||
| @@ -0,0 +1,30 @@ | |||||
| export interface Amendment { | |||||
| id: number; | |||||
| name: string; | |||||
| cid: string; | |||||
| summary?: string; | |||||
| status: 'proposed' | 'approved' | 'withdrawn' | 'rejected'; | |||||
| creator: string; | |||||
| isCurrent: boolean; | |||||
| previousCid?: string; | |||||
| createdAt: string; | |||||
| updatedAt: string; | |||||
| proposalId: number; | |||||
| } | |||||
| export interface ListAmendmentsResponse { | |||||
| amendments: Amendment[]; | |||||
| } | |||||
| export interface AmendmentFile { | |||||
| name: string; | |||||
| content: string; | |||||
| creator: string; | |||||
| createdAt: Date; | |||||
| updatedAt: Date; | |||||
| proposalId: number; | |||||
| } | |||||
| export interface GetAmendmentResponse { | |||||
| amendment: AmendmentFile; | |||||
| } | |||||
| @@ -1,5 +1,5 @@ | |||||
| export interface Color { | export interface Color { | ||||
| R: string; | |||||
| G: string; | |||||
| B: string; | |||||
| R: number; | |||||
| G: number; | |||||
| B: number; | |||||
| } | } | ||||
| @@ -2,5 +2,5 @@ import { Color } from "./color.ts"; | |||||
| export interface HeaderLetter { | export interface HeaderLetter { | ||||
| letter: string; | letter: string; | ||||
| color: Color; | |||||
| color?: Color; | |||||
| } | } | ||||
| @@ -1,35 +1,49 @@ | |||||
| import { u32, u64 } from "@stellar/stellar-sdk/contract"; | |||||
| // Base interface for common issue properties | |||||
| export interface BaseIssue { | |||||
| id: number; | |||||
| name: string; | |||||
| cid: string; | |||||
| creator: string; | |||||
| } | |||||
| // Main proposal issue interface extending base | |||||
| export interface ProposalIssue extends BaseIssue { | |||||
| summary: string; | |||||
| isCurrent: boolean; | |||||
| previousCid?: string; | |||||
| createdAt: Date; | |||||
| updatedAt: Date; | |||||
| } | |||||
| export interface Issue { | |||||
| id: String; | |||||
| title: String, | |||||
| summary: String, | |||||
| paragraph_count: u32, | |||||
| positive_votes: u32, | |||||
| negative_votes: u32, | |||||
| telegram_handle: string, | |||||
| created_at: u64, | |||||
| // Interface for content holding | |||||
| interface ContentHolder { | |||||
| content: string; | |||||
| } | } | ||||
| // Type for issue content combining base fields and content | |||||
| export type ProposalContent = BaseIssue & ContentHolder; | |||||
| export interface ListIssuesResponse { | export interface ListIssuesResponse { | ||||
| issues: Issue[]; | |||||
| proposals: ProposalIssue[] | |||||
| } | } | ||||
| export interface GetParagraphsResponse { | |||||
| paragraphs: string[]; | |||||
| export interface GetIssueResponse { | |||||
| proposal: ProposalContent; | |||||
| } | } | ||||
| export interface AddIssueRequest { | export interface AddIssueRequest { | ||||
| title: string; | |||||
| paragraphs: string[]; | |||||
| name: string; | |||||
| description: string; | |||||
| creator: string; | |||||
| } | } | ||||
| export interface AddIssueResponse { | export interface AddIssueResponse { | ||||
| result: boolean; | |||||
| cid: string; | |||||
| } | } | ||||
| export interface VoteIssueRequest { | export interface VoteIssueRequest { | ||||
| issue_id: string; | issue_id: string; | ||||
| wallet: string; | |||||
| vote: 'Positive' | 'Negative'; | vote: 'Positive' | 'Negative'; | ||||
| } | } | ||||
| @@ -39,10 +53,7 @@ export interface VoteIssueResponse { | |||||
| positive: boolean; | positive: boolean; | ||||
| } | } | ||||
| export interface GetVoteRequest { | |||||
| issue_id: number; | |||||
| } | |||||
| export interface GetVoteResponse { | |||||
| vote?: 'Positive' | 'Negative'; | |||||
| } | |||||
| export interface IssueSelection { | |||||
| proposal: ProposalContent; | |||||
| cid: string; | |||||
| } | |||||
| @@ -0,0 +1,4 @@ | |||||
| export enum VoteType { | |||||
| Positive = 0, | |||||
| Negative = 1, | |||||
| } | |||||
| @@ -0,0 +1,13 @@ | |||||
| export const getChipClass = (status: string) => { | |||||
| const normalizedStatus = status.toLowerCase(); | |||||
| switch (normalizedStatus) { | |||||
| case 'approved': | |||||
| return 'bg-green-500 text-white'; | |||||
| case 'rejected': | |||||
| return 'bg-red-500 text-white'; | |||||
| case 'proposed': | |||||
| return 'bg-transparent border border-black text-black'; | |||||
| default: | |||||
| return ''; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,3 @@ | |||||
| export const truncateWallet = (wallet: string) => { | |||||
| return `${wallet.slice(0, 6)}...${wallet.slice(-4)}`; | |||||
| } | |||||
| @@ -1,130 +1,135 @@ | |||||
| <template> | <template> | ||||
| <div class="home"> | |||||
| <div class="flex flex-row"> | |||||
| <div class="sm:flex sm:flex-col sm:w-1/4 hidden bg-gray-600 h-screen"> | |||||
| <div class="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"> | |||||
| Add Issue | |||||
| </button> | |||||
| <button class="flex flex-row w-2/12 h-full bg-gray-800 justify-center items-center cursor-pointer" @click="showFilterModal"> | |||||
| <Filter /> | |||||
| </button> | |||||
| </div> | |||||
| <div class="h-full overflow-y-scroll"> | |||||
| <div v-for="issue in issues" :key="issue.id"> | |||||
| <IssueContainer | |||||
| :issue="issue" | |||||
| :selected="selectedId === issue.id" | |||||
| @selected="handleSelection" | |||||
| /> | |||||
| <div class="home h-screen"> | |||||
| <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'" | |||||
| /> | |||||
| <!-- Overlay for small screens when sidebar is open --> | |||||
| <div | |||||
| v-if="isSidebarOpen" | |||||
| class="md:hidden fixed inset-0 bg-black/40 z-20" | |||||
| @click="isSidebarOpen = false" | |||||
| ></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"/>--> | |||||
| </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> | |||||
| </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> | </div> | ||||
| <LoadingSpinner identifier="issues" v-if="isLoading('issues')" /> | |||||
| </div> | |||||
| </div> | |||||
| <div class="sm:w-3/4 w-full"> | |||||
| <div class="min-h-svh" v-if="selectedId"> | |||||
| <div v-if="!isLoading('content')"> | |||||
| <div class="px-5 pt-5 pb-12 h-full overflow-y-scroll"> | |||||
| <template v-if="selected?.content"> | |||||
| <section class="mb-6 min-h-screen"> | |||||
| <div class="text-5xl font-bold font-header">{{ selected?.title }}</div> | |||||
| <p class="my-3" v-for="paragraph in selected?.content"> | |||||
| {{ paragraph }} | |||||
| </p> | |||||
| </section> | |||||
| <section> | |||||
| <template v-for="comment in comments"> | |||||
| <div class="mb-3"> | |||||
| <Thread :starter-comment="comment" /> | |||||
| </div> | |||||
| </template> | |||||
| </section> | |||||
| </template> | |||||
| </div> | |||||
| </div> | |||||
| <LoadingSpinner identifier="content" v-else /> | |||||
| <div class="flex flex-row justify-between w-40 fixed bottom-0 right-0 bg-gray-800 px-5 py-3 rounded-tl-lg"> | |||||
| <div class="flex flex-row"> | |||||
| <ThumbsUp @click="recordThumbsUp" :selected="selected?.vote === 'Positive'" /> | |||||
| <ThumbsDown @click="recordThumbsDown" :selected="selected?.vote === 'Negative'" /> | |||||
| </div> | |||||
| <SpeechBubbles /> | |||||
| </div> | |||||
| </div> | |||||
| <div class="flex flex-row h-full justify-center items-center" v-else> | |||||
| <span class="text-gray-500">Nothing is selected</span> | |||||
| </div> | |||||
| <LoadingSpinner v-if="isLoading('content')" identifier="content"/> | |||||
| </main> | |||||
| </div> | </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" | |||||
| @click="isSidebarOpen = !isSidebarOpen" | |||||
| aria-label="Toggle sidebar" | |||||
| > | |||||
| <Menu /> | |||||
| </button> | |||||
| </div> | </div> | ||||
| </div> | |||||
| </template> | </template> | ||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| import IssueContainer from "../components/IssueContainer.vue"; | |||||
| import { onMounted, onUnmounted, ref } from "vue"; | |||||
| import ThumbsUp from "../components/icons/ThumbsUp.vue"; | |||||
| import ThumbsDown from "../components/icons/ThumbsDown.vue"; | |||||
| import SpeechBubbles from "../components/icons/SpeechBubbles.vue"; | |||||
| import { ContentModal, useModal } from "../composables/useModal.ts"; | |||||
| import { useFetch } from "../composables/useFetch.ts"; | |||||
| import { | |||||
| GetParagraphsResponse, GetVoteResponse, | |||||
| Issue, | |||||
| ListIssuesResponse, | |||||
| VoteIssueRequest, | |||||
| VoteIssueResponse | |||||
| } from "../types/issue.ts"; | |||||
| import { useLoading } from "../composables/useLoading.ts"; | |||||
| import DOMPurify from "dompurify"; | |||||
| import {parse} from "marked"; | |||||
| import {computed, onMounted, onUnmounted, ref} from "vue"; | |||||
| import {ProposalList, ProposalService} from "../generated/typescript"; | |||||
| import {IssueSelection} from "../types/issue.ts"; | |||||
| import {useLoading} from "../composables/useLoading.ts"; | |||||
| import LoadingSpinner from "../components/LoadingSpinner.vue"; | import LoadingSpinner from "../components/LoadingSpinner.vue"; | ||||
| import IssueModal from "../components/modals/IssueModal.vue"; | |||||
| import Filter from "../components/icons/Filter.vue"; | |||||
| import FilterModal from "../components/modals/FilterModal.vue"; | |||||
| import { useFilterStore } from "../stores/filterStore.ts"; | |||||
| import { storeToRefs } from "pinia"; | |||||
| import { eventBus } from "../utils/eventBus.ts"; | |||||
| import { CommentWithChildren, GetCommentsResponse } from "../types/comment.ts"; | |||||
| import {eventBus} from "../utils/eventBus.ts"; | |||||
| import {CommentWithChildren} from "../types/comment.ts"; | |||||
| import Thread from "../components/Thread.vue"; | 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"; | |||||
| const { show: showModal } = useModal(); | |||||
| const { get, post } = useFetch(); | |||||
| const { register, unregister, isLoading, show: showLoading, hide: hideLoading } = useLoading(); | const { register, unregister, isLoading, show: showLoading, hide: hideLoading } = useLoading(); | ||||
| register('issues', true); | register('issues', true); | ||||
| register('content'); | register('content'); | ||||
| const issues = ref<Issue[]>([]); | |||||
| const selectedId = ref(''); | |||||
| const selected = ref<{title: string, content: string[], vote?: 'Positive' | 'Negative'}>(); | |||||
| const issues = ref<ProposalList>(); | |||||
| const selected = ref<IssueSelection>(); | |||||
| const comments = ref<CommentWithChildren[]>(); | const comments = ref<CommentWithChildren[]>(); | ||||
| const { minPositiveVotes, minVotes } = storeToRefs(useFilterStore()); | |||||
| const isSidebarOpen = ref(false); | |||||
| const [offset, limit] = [ref(0), ref(10)]; | const [offset, limit] = [ref(0), ref(10)]; | ||||
| showLoading('issues'); | showLoading('issues'); | ||||
| onMounted(async () => { | onMounted(async () => { | ||||
| const resp = await get<ListIssuesResponse>(`/issues/list?offset=${ offset.value }&limit=${ limit.value }`); | |||||
| issues.value = resp.issues; | |||||
| const issuesResponse = await ProposalService.listProposals(offset.value, limit.value); | |||||
| issues.value = issuesResponse.proposals; | |||||
| hideLoading('issues'); | hideLoading('issues'); | ||||
| eventBus.on('filtersApplied', async (_) => { | |||||
| showLoading('issues'); | |||||
| const r = ref<ListIssuesResponse>(); | |||||
| if (minPositiveVotes.value !== undefined && minVotes.value !== undefined) { | |||||
| r.value = await get<ListIssuesResponse>(`/issues/list?min_positive_votes=${ minPositiveVotes.value }&min_votes=${ minVotes.value }&offset=${ offset.value }&limit=${ limit.value }`); | |||||
| } else if (minPositiveVotes.value !== undefined) { | |||||
| r.value = await get<ListIssuesResponse>(`/issues/list?min_positive_votes=${ minPositiveVotes.value }&offset=${ offset.value }&limit=${ limit.value }`); | |||||
| } else if (minVotes.value !== undefined) { | |||||
| r.value = await get<ListIssuesResponse>(`/issues/list?min_votes=${ minVotes.value }&offset=${ offset.value }&limit=${ limit.value }`); | |||||
| } | |||||
| hideLoading('issues'); | |||||
| if (r.value) { | |||||
| issues.value = r.value.issues; | |||||
| } | |||||
| }); | |||||
| }); | }); | ||||
| onUnmounted(() => { | onUnmounted(() => { | ||||
| @@ -134,35 +139,47 @@ onUnmounted(() => { | |||||
| eventBus.off('filtersApplied'); | eventBus.off('filtersApplied'); | ||||
| }) | }) | ||||
| const handleSelection = async (id: string, title: string) => { | |||||
| showLoading('content'); | |||||
| selectedId.value = id; | |||||
| const paragraphs = await get<GetParagraphsResponse>(`/issues/paragraphs?issue_id=${id}`); | |||||
| const vote = await get<GetVoteResponse>(`/issues/get_vote?issue_id=${id}`); | |||||
| selected.value = {title, content: paragraphs.paragraphs, vote: vote.vote}; | |||||
| const comms = await get<GetCommentsResponse>(`/issues/comments?issue_id=${id}`); | |||||
| comments.value = comms.comments; | |||||
| hideLoading('content'); | |||||
| const applyFormatting = (markdown: string) => { | |||||
| const parsed = parse(markdown) as string; | |||||
| return DOMPurify.sanitize(parsed); | |||||
| } | } | ||||
| const recordThumbsUp = async () => { | |||||
| await post<VoteIssueRequest, VoteIssueResponse>('/issues/vote', | |||||
| { issue_id: selectedId.value, vote: "Positive" }) | |||||
| } | |||||
| const recordThumbsDown = async () => { | |||||
| await post<VoteIssueRequest, VoteIssueResponse>('/issues/vote', | |||||
| { issue_id: selectedId.value, vote: "Negative" }) | |||||
| const handleSelection = async (issue: IssueSelection) => { | |||||
| selected.value = issue; | |||||
| // Close sidebar on small screens after selection | |||||
| isSidebarOpen.value = false; | |||||
| } | } | ||||
| const showNewIssueModal = () => { | |||||
| showModal(IssueModal as ContentModal); | |||||
| } | |||||
| const showFilterModal = () => { | |||||
| showModal(FilterModal as ContentModal); | |||||
| } | |||||
| const paragraphs = computed(() => selected.value?.proposal.content.split('\n').filter(p => p.trim().length > 0) ?? []); | |||||
| const currentId = computed(() => selected.value?.proposal.id); | |||||
| </script> | </script> | ||||
| <style scoped> | |||||
| <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> | </style> | ||||
| @@ -3,11 +3,18 @@ export default { | |||||
| content: ['./index.html', './src/**/*.{vue,js,ts}'], | content: ['./index.html', './src/**/*.{vue,js,ts}'], | ||||
| theme: { | theme: { | ||||
| extend: { | extend: { | ||||
| fontSize: { | |||||
| 'mxs': '0.92rem', | |||||
| }, | |||||
| height: { | height: { | ||||
| '15/16': '93.75%', | '15/16': '93.75%', | ||||
| '1/16': '6.25%', | '1/16': '6.25%', | ||||
| '11/12': '91.666666%', | '11/12': '91.666666%', | ||||
| '1/10': '8.333333%' | '1/10': '8.333333%' | ||||
| }, | |||||
| lineHeight: { | |||||
| 'short': '0.9', | |||||
| 'tighter': '1.15', | |||||
| } | } | ||||
| }, | }, | ||||
| fontFamily: { | fontFamily: { | ||||
| @@ -0,0 +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"} | |||||
| @@ -0,0 +1 @@ | |||||
| {"root":["./vite.config.ts"],"version":"5.9.2"} | |||||
| @@ -1,14 +1,19 @@ | |||||
| import { defineConfig } from 'vite' | import { defineConfig } from 'vite' | ||||
| import vue from '@vitejs/plugin-vue' | import vue from '@vitejs/plugin-vue' | ||||
| import tailwindcss from '@tailwindcss/vite' | |||||
| import vueDevTools from 'vite-plugin-vue-devtools' | |||||
| // https://vitejs.dev/config/ | // https://vitejs.dev/config/ | ||||
| export default defineConfig({ | export default defineConfig({ | ||||
| plugins: [vue()], | |||||
| plugins: [ | |||||
| vue(), | |||||
| vueDevTools(), | |||||
| tailwindcss() | |||||
| ], | |||||
| server: { | server: { | ||||
| proxy: { | proxy: { | ||||
| '/api': { | '/api': { | ||||
| target: 'http://127.0.0.1:8080', | |||||
| rewrite: (url: string) => url.replace(/\/api/, ''), | |||||
| target: 'http://127.0.0.1:7300', | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||