Selaa lähdekoodia

Updated project to use ipfs

master
jared 1 viikko sitten
vanhempi
commit
bc8973a745
68 muutettua tiedostoa jossa 6448 lisäystä ja 2454 poistoa
  1. +0
    -1
      index.html
  2. +4305
    -2057
      package-lock.json
  3. +28
    -19
      package.json
  4. +0
    -6
      postcss.config.js
  5. +7
    -7
      src/App.vue
  6. +125
    -0
      src/components/AmendmentCarousel.vue
  7. +37
    -40
      src/components/BrandBar.vue
  8. +14
    -0
      src/components/Chip.vue
  9. +17
    -10
      src/components/ColoredHeader.vue
  10. +29
    -8
      src/components/IssueContainer.vue
  11. +10
    -10
      src/components/LoadingSpinner.vue
  12. +110
    -29
      src/components/PostBuilder.vue
  13. +73
    -0
      src/components/Sidebar.vue
  14. +65
    -0
      src/components/VotingControls.vue
  15. +28
    -0
      src/components/icons/Abstain.vue
  16. +13
    -0
      src/components/icons/Arrow.vue
  17. +28
    -0
      src/components/icons/Cancel.vue
  18. +28
    -0
      src/components/icons/Confirm.vue
  19. +19
    -0
      src/components/icons/Menu.vue
  20. +4
    -10
      src/components/icons/ThumbsDown.vue
  21. +4
    -10
      src/components/icons/ThumbsUp.vue
  22. +54
    -0
      src/components/modals/AmendmentModal.vue
  23. +3
    -3
      src/components/modals/FilterModal.vue
  24. +9
    -17
      src/components/modals/IssueModal.vue
  25. +18
    -13
      src/components/modals/Modal.vue
  26. +61
    -11
      src/composables/useFetch.ts
  27. +52
    -1
      src/composables/useModal.ts
  28. +4
    -41
      src/composables/useSession.ts
  29. +25
    -0
      src/generated/typescript/core/ApiError.ts
  30. +17
    -0
      src/generated/typescript/core/ApiRequestOptions.ts
  31. +11
    -0
      src/generated/typescript/core/ApiResult.ts
  32. +131
    -0
      src/generated/typescript/core/CancelablePromise.ts
  33. +32
    -0
      src/generated/typescript/core/OpenAPI.ts
  34. +322
    -0
      src/generated/typescript/core/request.ts
  35. +30
    -0
      src/generated/typescript/index.ts
  36. +10
    -0
      src/generated/typescript/models/AddProposalRequest.ts
  37. +8
    -0
      src/generated/typescript/models/FreighterLoginRequest.ts
  38. +9
    -0
      src/generated/typescript/models/ListProposalsResponse.ts
  39. +9
    -0
      src/generated/typescript/models/LoginRequest.ts
  40. +16
    -0
      src/generated/typescript/models/ProposalItem.ts
  41. +6
    -0
      src/generated/typescript/models/ProposalList.ts
  42. +11
    -0
      src/generated/typescript/models/RegisterRequest.ts
  43. +13
    -0
      src/generated/typescript/models/VisitProposalRequest.ts
  44. +20
    -0
      src/generated/typescript/schemas/$AddProposalRequest.ts
  45. +12
    -0
      src/generated/typescript/schemas/$FreighterLoginRequest.ts
  46. +11
    -0
      src/generated/typescript/schemas/$ListProposalsResponse.ts
  47. +16
    -0
      src/generated/typescript/schemas/$LoginRequest.ts
  48. +45
    -0
      src/generated/typescript/schemas/$ProposalItem.ts
  49. +10
    -0
      src/generated/typescript/schemas/$ProposalList.ts
  50. +24
    -0
      src/generated/typescript/schemas/$RegisterRequest.ts
  51. +29
    -0
      src/generated/typescript/schemas/$VisitProposalRequest.ts
  52. +59
    -0
      src/generated/typescript/services/AmendmentService.ts
  53. +81
    -0
      src/generated/typescript/services/AuthService.ts
  54. +84
    -0
      src/generated/typescript/services/ProposalService.ts
  55. +12
    -4
      src/index.css
  56. +1
    -0
      src/main.ts
  57. +30
    -0
      src/types/amendment.ts
  58. +3
    -3
      src/types/color.ts
  59. +1
    -1
      src/types/headerletter.ts
  60. +34
    -23
      src/types/issue.ts
  61. +4
    -0
      src/types/vote.ts
  62. +13
    -0
      src/utils/amendment.ts
  63. +3
    -0
      src/utils/wallet.ts
  64. +144
    -127
      src/views/HomeView.vue
  65. +7
    -0
      tailwind.config.js
  66. +1
    -0
      tsconfig.app.tsbuildinfo
  67. +1
    -0
      tsconfig.node.tsbuildinfo
  68. +8
    -3
      vite.config.ts

+ 0
- 1
index.html Näytä tiedosto

@@ -5,7 +5,6 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Puff Pastry</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body>
<div id="app"></div>


+ 4305
- 2057
package-lock.json
File diff suppressed because it is too large
Näytä tiedosto


+ 28
- 19
package.json Näytä tiedosto

@@ -6,29 +6,38 @@
"scripts": {
"dev": "vite",
"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": {
"@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": {
"@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",
"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"
}
}

+ 0
- 6
postcss.config.js Näytä tiedosto

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

+ 7
- 7
src/App.vue Näytä tiedosto

@@ -1,6 +1,6 @@
<template>
<BrandBar />
<main class="bg-gray-200 min-h-dvh">
<main class="bg-gray-200 h-dvh">
<RouterView />
</main>
<component :is="getModalContent()" v-show="getVisibility()" />
@@ -8,16 +8,16 @@

<script setup lang="ts">
import { useModal } from "./composables/useModal.ts";
import { useSession } from "./composables/useSession.ts";
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 { initializeSessionId, initializeUsername } = useSession();

initializeSessionId();
const address = ref("");

onBeforeMount(async () => {
await initializeUsername();
});
const addr = await freighter.getAddress();
address.value = addr.address;
})
</script>

+ 125
- 0
src/components/AmendmentCarousel.vue Näytä tiedosto

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

+ 37
- 40
src/components/BrandBar.vue Näytä tiedosto

@@ -1,53 +1,50 @@
<template>
<div class="flex flex-row justify-between bg-gray-500 drop-shadow-md px-3 py-2">
<span class="text-4xl font-header font-bold">
<ColoredHeader text="Puff Pastry" from="#F2F3E2" to="#B2E5F8"/>
<ColoredHeader text="Ikibani" from="#F2F3E2" to="#B2E5F8" />
</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>
</template>

<script setup lang="ts">
import { LoginWidget } from 'vue-tg';
import type { LoginWidgetUser } from "vue-tg";
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>

<style scoped>


+ 14
- 0
src/components/Chip.vue Näytä tiedosto

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

+ 17
- 10
src/components/ColoredHeader.vue Näytä tiedosto

@@ -1,5 +1,5 @@
<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>
</template>
@@ -58,21 +58,28 @@ const colorIncrement: Color = {
let currentColor = hexToRgb(fromHex);

for (const character of props.text) {
if (character.trim() === '') {
characters.value.push({
letter: character,
color: undefined
});
continue;
}
characters.value.push({
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>

<style scoped>

span {
font-family: 'Gabarito', sans-serif;
}
</style>

+ 29
- 8
src/components/IssueContainer.vue Näytä tiedosto

@@ -1,23 +1,44 @@
<template>
<div class="p-8 my-px hover:bg-amber-100 cursor-pointer" :class="{'bg-amber-300': selected, 'bg-amber-50': !selected}" @click="handleSelection">
<div class="mb-2">
<span class=" block font-bold text-xl font-header">{{ 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 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>
</template>

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



+ 10
- 10
src/components/LoadingSpinner.vue Näytä tiedosto

@@ -11,19 +11,19 @@ const props = defineProps<{ identifier: string }>();
</script>

<style scoped>
.spinner::before {
content: '';
display: block;
.spinner {
width: 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>

+ 110
- 29
src/components/PostBuilder.vue Näytä tiedosto

@@ -1,42 +1,123 @@
<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>

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

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

+ 73
- 0
src/components/Sidebar.vue Näytä tiedosto

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

+ 65
- 0
src/components/VotingControls.vue Näytä tiedosto

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

+ 28
- 0
src/components/icons/Abstain.vue Näytä tiedosto

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

+ 13
- 0
src/components/icons/Arrow.vue Näytä tiedosto

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

+ 28
- 0
src/components/icons/Cancel.vue Näytä tiedosto

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

+ 28
- 0
src/components/icons/Confirm.vue Näytä tiedosto

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

+ 19
- 0
src/components/icons/Menu.vue Näytä tiedosto

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

+ 4
- 10
src/components/icons/ThumbsDown.vue Näytä tiedosto

@@ -1,7 +1,6 @@
<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">
<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
@@ -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
C29.673,14.018,30,13.299,30,12.524z"/>
</g>
</svg>
</div>
</svg>
</template>

<script setup lang="ts">
const props = defineProps<{ 'selected': boolean }>();

const getFill = () => props.selected ? '#e61717' : '#ffffff';
</script>

<style scoped>
#dislike-button:hover {
fill: #e61717 !important;
}

</style>

+ 4
- 10
src/components/icons/ThumbsUp.vue Näytä tiedosto

@@ -1,7 +1,6 @@
<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">
<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
@@ -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
C29.347,20.524,30,19.569,30,18.476z"/>
</g>
</svg>
</div>
</svg>
</template>

<script setup lang="ts">
const props = defineProps<{ 'selected': boolean }>();

const getFill = () => props.selected ? "#45bf20" : "#ffffff";
</script>

<style scoped>
#like-button:hover {
fill: #45bf20 !important;
}

</style>

+ 54
- 0
src/components/modals/AmendmentModal.vue Näytä tiedosto

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

+ 3
- 3
src/components/modals/FilterModal.vue Näytä tiedosto

@@ -1,17 +1,17 @@
<template>
<Modal header="Filter" @submitted="submit">
<Modal header="Filter" @on-closed="submit">
<slot>
<div class="flex flex-row justify-between my-3">
<div class="flex flex-col justify-center">
<label for="min-positive-votes" class="text-sm text-gray-700">Minimum Positive Votes</label>
</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 class="flex flex-row justify-between my-3">
<div class="flex flex-col justify-center">
<label for="min-votes" class="text-sm text-gray-700">Minimum Votes</label>
</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>
</slot>
</Modal>


+ 9
- 17
src/components/modals/IssueModal.vue Näytä tiedosto

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

<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 { ref } from "vue";
import { AddIssueRequest, AddIssueResponse } from "../../types/issue.ts";
import PostBuilder from "../PostBuilder.vue";
import { ContentModal } from "../../composables/useModal.ts";
import freighter from "@stellar/freighter-api";
import {ProposalService} from "../../generated/typescript";

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

const { post } = useFetch();
const description = ref<string>('');

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;
}
</script>


+ 18
- 13
src/components/modals/Modal.vue Näytä tiedosto

@@ -1,22 +1,24 @@
<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">
<span class="text-2xl text-gray-600 leading-3 cursor-pointer">&times;</span>
</div>
<div class="h-11/12 w-full">
<div class="h-full">
<div class="flex-1 w-full overflow-y-auto">
<div>
<div>
<span class="text-2xl mx-auto font-bold">
{{ header }}
</span>
<span class="text-2xl mx-auto font-bold">
{{ header }}
</span>
</div>
<slot name="default" />
</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">
Submit
{{ buttonText }}
</button>
</div>
</div>
@@ -25,13 +27,16 @@

<script setup lang="ts">
import { useModal } from "../../composables/useModal.ts";
import { withDefaults } from "vue";

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 = () => {
emits('submitted');
emits('onClosed');
hide();
};
</script>


+ 61
- 11
src/composables/useFetch.ts Näytä tiedosto

@@ -1,20 +1,70 @@
/**
* @deprecated This method is deprecated.
*/
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',
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)
});
return await data.json() as U;
return response.data;
};

return { get, post }

+ 52
- 1
src/composables/useModal.ts Näytä tiedosto

@@ -2,8 +2,16 @@ import { ref } from 'vue'

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 modalContent = ref<ContentModal | null>(null);
const contentItems = ref<ModalContentItem[]>([]);

export const useModal = () => {
const getVisibility = () => visible.value;
@@ -16,5 +24,48 @@ export const useModal = () => {
modalContent.value = null;
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 };
}

+ 4
- 41
src/composables/useSession.ts Näytä tiedosto

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

+ 25
- 0
src/generated/typescript/core/ApiError.ts Näytä tiedosto

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

+ 17
- 0
src/generated/typescript/core/ApiRequestOptions.ts Näytä tiedosto

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

+ 11
- 0
src/generated/typescript/core/ApiResult.ts Näytä tiedosto

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

+ 131
- 0
src/generated/typescript/core/CancelablePromise.ts Näytä tiedosto

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

+ 32
- 0
src/generated/typescript/core/OpenAPI.ts Näytä tiedosto

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

+ 322
- 0
src/generated/typescript/core/request.ts Näytä tiedosto

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

+ 30
- 0
src/generated/typescript/index.ts Näytä tiedosto

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

+ 10
- 0
src/generated/typescript/models/AddProposalRequest.ts Näytä tiedosto

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


+ 8
- 0
src/generated/typescript/models/FreighterLoginRequest.ts Näytä tiedosto

@@ -0,0 +1,8 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type FreighterLoginRequest = {
stellarAddress: string;
};


+ 9
- 0
src/generated/typescript/models/ListProposalsResponse.ts Näytä tiedosto

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


+ 9
- 0
src/generated/typescript/models/LoginRequest.ts Näytä tiedosto

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


+ 16
- 0
src/generated/typescript/models/ProposalItem.ts Näytä tiedosto

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


+ 6
- 0
src/generated/typescript/models/ProposalList.ts Näytä tiedosto

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

+ 11
- 0
src/generated/typescript/models/RegisterRequest.ts Näytä tiedosto

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


+ 13
- 0
src/generated/typescript/models/VisitProposalRequest.ts Näytä tiedosto

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


+ 20
- 0
src/generated/typescript/schemas/$AddProposalRequest.ts Näytä tiedosto

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

+ 12
- 0
src/generated/typescript/schemas/$FreighterLoginRequest.ts Näytä tiedosto

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

+ 11
- 0
src/generated/typescript/schemas/$ListProposalsResponse.ts Näytä tiedosto

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

+ 16
- 0
src/generated/typescript/schemas/$LoginRequest.ts Näytä tiedosto

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

+ 45
- 0
src/generated/typescript/schemas/$ProposalItem.ts Näytä tiedosto

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

+ 10
- 0
src/generated/typescript/schemas/$ProposalList.ts Näytä tiedosto

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

+ 24
- 0
src/generated/typescript/schemas/$RegisterRequest.ts Näytä tiedosto

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

+ 29
- 0
src/generated/typescript/schemas/$VisitProposalRequest.ts Näytä tiedosto

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

+ 59
- 0
src/generated/typescript/services/AmendmentService.ts Näytä tiedosto

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

+ 81
- 0
src/generated/typescript/services/AuthService.ts Näytä tiedosto

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

+ 84
- 0
src/generated/typescript/services/ProposalService.ts Näytä tiedosto

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

+ 12
- 4
src/index.css Näytä tiedosto

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

+ 1
- 0
src/main.ts Näytä tiedosto

@@ -3,6 +3,7 @@ import './index.css'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import './index.css'

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

+ 30
- 0
src/types/amendment.ts Näytä tiedosto

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

+ 3
- 3
src/types/color.ts Näytä tiedosto

@@ -1,5 +1,5 @@
export interface Color {
R: string;
G: string;
B: string;
R: number;
G: number;
B: number;
}

+ 1
- 1
src/types/headerletter.ts Näytä tiedosto

@@ -2,5 +2,5 @@ import { Color } from "./color.ts";

export interface HeaderLetter {
letter: string;
color: Color;
color?: Color;
}

+ 34
- 23
src/types/issue.ts Näytä tiedosto

@@ -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 {
issues: Issue[];
proposals: ProposalIssue[]
}

export interface GetParagraphsResponse {
paragraphs: string[];
export interface GetIssueResponse {
proposal: ProposalContent;
}

export interface AddIssueRequest {
title: string;
paragraphs: string[];
name: string;
description: string;
creator: string;
}

export interface AddIssueResponse {
result: boolean;
cid: string;
}

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

@@ -39,10 +53,7 @@ export interface VoteIssueResponse {
positive: boolean;
}

export interface GetVoteRequest {
issue_id: number;
}

export interface GetVoteResponse {
vote?: 'Positive' | 'Negative';
}
export interface IssueSelection {
proposal: ProposalContent;
cid: string;
}

+ 4
- 0
src/types/vote.ts Näytä tiedosto

@@ -0,0 +1,4 @@
export enum VoteType {
Positive = 0,
Negative = 1,
}

+ 13
- 0
src/utils/amendment.ts Näytä tiedosto

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

+ 3
- 0
src/utils/wallet.ts Näytä tiedosto

@@ -0,0 +1,3 @@
export const truncateWallet = (wallet: string) => {
return `${wallet.slice(0, 6)}...${wallet.slice(-4)}`;
}

+ 144
- 127
src/views/HomeView.vue Näytä tiedosto

@@ -1,130 +1,135 @@
<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>
<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>

<!-- 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>
</template>

<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 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 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();

register('issues', true);
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 { minPositiveVotes, minVotes } = storeToRefs(useFilterStore());
const isSidebarOpen = ref(false);

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

showLoading('issues');

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');

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(() => {
@@ -134,35 +139,47 @@ onUnmounted(() => {
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>

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

+ 7
- 0
tailwind.config.js Näytä tiedosto

@@ -3,11 +3,18 @@ export default {
content: ['./index.html', './src/**/*.{vue,js,ts}'],
theme: {
extend: {
fontSize: {
'mxs': '0.92rem',
},
height: {
'15/16': '93.75%',
'1/16': '6.25%',
'11/12': '91.666666%',
'1/10': '8.333333%'
},
lineHeight: {
'short': '0.9',
'tighter': '1.15',
}
},
fontFamily: {


+ 1
- 0
tsconfig.app.tsbuildinfo Näytä tiedosto

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

+ 1
- 0
tsconfig.node.tsbuildinfo Näytä tiedosto

@@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.9.2"}

+ 8
- 3
vite.config.ts Näytä tiedosto

@@ -1,14 +1,19 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import vueDevTools from 'vite-plugin-vue-devtools'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
vueDevTools(),
tailwindcss()
],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
rewrite: (url: string) => url.replace(/\/api/, ''),
target: 'http://127.0.0.1:7300',
}
}
}


Ladataan…
Peruuta
Tallenna