@@ -0,0 +1,24 @@ | |||
# Logs | |||
logs | |||
*.log | |||
npm-debug.log* | |||
yarn-debug.log* | |||
yarn-error.log* | |||
pnpm-debug.log* | |||
lerna-debug.log* | |||
node_modules | |||
dist | |||
dist-ssr | |||
*.local | |||
# Editor directories and files | |||
.vscode/* | |||
!.vscode/extensions.json | |||
.idea | |||
.DS_Store | |||
*.suo | |||
*.ntvs* | |||
*.njsproj | |||
*.sln | |||
*.sw? |
@@ -0,0 +1,3 @@ | |||
{ | |||
"recommendations": ["Vue.volar"] | |||
} |
@@ -0,0 +1,5 @@ | |||
# Vue 3 + TypeScript + Vite | |||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. | |||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup). |
@@ -0,0 +1,14 @@ | |||
<!doctype html> | |||
<html lang="en"> | |||
<head> | |||
<meta charset="UTF-8" /> | |||
<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> | |||
<script type="module" src="/src/main.ts"></script> | |||
</body> | |||
</html> |
@@ -0,0 +1,33 @@ | |||
{ | |||
"name": "puffpastry", | |||
"private": true, | |||
"version": "0.0.0", | |||
"type": "module", | |||
"scripts": { | |||
"dev": "vite", | |||
"build": "vue-tsc -b && vite build", | |||
"preview": "vite preview" | |||
}, | |||
"dependencies": { | |||
"@types/quill": "^2.0.14", | |||
"@vueuse/core": "^11.1.0", | |||
"@vueuse/integrations": "^11.1.0", | |||
"luxon": "^3.5.0", | |||
"quill": "^2.0.2", | |||
"universal-cookie": "^7.2.0", | |||
"vue": "^3.4.37", | |||
"vue-router": "^4.4.3", | |||
"vue-tg": "^0.8.0" | |||
}, | |||
"devDependencies": { | |||
"@stellar/stellar-sdk": "^12.2.0", | |||
"@types/luxon": "^3.4.2", | |||
"@vitejs/plugin-vue": "^5.1.2", | |||
"autoprefixer": "^10.4.20", | |||
"postcss": "^8.4.45", | |||
"tailwindcss": "^3.4.10", | |||
"typescript": "^5.5.3", | |||
"vite": "^5.4.1", | |||
"vue-tsc": "^2.0.29" | |||
} | |||
} |
@@ -0,0 +1,6 @@ | |||
export default { | |||
plugins: { | |||
tailwindcss: {}, | |||
autoprefixer: {}, | |||
}, | |||
} |
@@ -0,0 +1 @@ | |||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg> |
@@ -0,0 +1,18 @@ | |||
<template> | |||
<BrandBar /> | |||
<main class="bg-gray-200 min-h-dvh"> | |||
<RouterView /> | |||
</main> | |||
<component :is="getModalContent()" v-show="getVisibility()" /> | |||
</template> | |||
<script setup lang="ts"> | |||
import { useModal } from "./composables/useModal.ts"; | |||
import { useSession } from "./composables/useSession.ts"; | |||
import BrandBar from "./components/BrandBar.vue"; | |||
const { getModalContent, getVisibility } = useModal(); | |||
const { initializeSessionId } = useSession(); | |||
initializeSessionId(); | |||
</script> |
@@ -0,0 +1 @@ | |||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg> |
@@ -0,0 +1,35 @@ | |||
<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"/> | |||
</span> | |||
<LoginWidget bot-username="puffpastry_mfa_bot" @auth="handleUserAuth" corner-radius="2" size="medium" class="my-auto" /> | |||
</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"; | |||
const { post } = useFetch(); | |||
const { setSessionId } = useSession(); | |||
const handleUserAuth = async (user: LoginWidgetUser) => { | |||
const sessionId = await post("/user/authenticate", | |||
{ | |||
auth_date: user.auth_date, | |||
username: user.username, | |||
first_name: user.first_name, | |||
last_name: user.last_name, | |||
photo_url: user.photo_url | |||
}); | |||
setSessionId(sessionId.session_id); | |||
} | |||
</script> | |||
<style scoped> | |||
</style> |
@@ -0,0 +1,78 @@ | |||
<template> | |||
<span v-for="character in characters" :key="calcKey(character)"> | |||
<span :style="calcStyle(character.color)">{{ character.letter }}</span> | |||
</span> | |||
</template> | |||
<script setup lang="ts"> | |||
import { ref } from "vue"; | |||
import { HeaderLetter } from "../types/headerletter.ts"; | |||
import { Color } from "../types/color.ts"; | |||
const hexToRgb = (hex: string): Color => { | |||
return { | |||
R: parseInt(hex.slice(0, 2), 16), | |||
G: parseInt(hex.slice(2, 4), 16), | |||
B: parseInt(hex.slice(4, 6), 16) | |||
}; | |||
}; | |||
const calcIncrement = (startHex: string, endHex: string, itemsCount: number): number => { | |||
const startInt = parseInt(startHex, 16); | |||
const endInt = parseInt(endHex, 16); | |||
return Math.round((endInt - startInt) / itemsCount); | |||
}; | |||
const calcStyle = (color?: Color) => { | |||
if (color !== undefined) { | |||
return `color: rgb(${color.R}, ${color.G}, ${color.B})`; | |||
} else { | |||
return ''; | |||
} | |||
}; | |||
const calcKey = (letter: string, color?: Color) => { | |||
if (color !== undefined) { | |||
return `${ letter }${ color.R + color.G + color.B }`; | |||
} else { | |||
return letter; | |||
} | |||
}; | |||
const props = defineProps<{ text: string; from: string; to: string }>(); | |||
const characters = ref<HeaderLetter[]>([]); | |||
const fromHex = props.from.startsWith('#') ? props.from.substring(1) : props.from; | |||
const toHex = props.to.startsWith('#') ? props.to.substring(1) : props.to; | |||
const textLength = props.text.length; | |||
const colorIncrement: Color = { | |||
R: calcIncrement(fromHex.slice(0, 2), toHex.slice(0, 2), textLength), | |||
G: calcIncrement(fromHex.slice(2, 4), toHex.slice(2, 4), textLength), | |||
B: calcIncrement(fromHex.slice(4, 6), toHex.slice(4, 6), textLength), | |||
}; | |||
let currentColor = hexToRgb(fromHex); | |||
for (const character of props.text) { | |||
characters.value.push({ | |||
letter: character, | |||
color: character.trim() !== '' ? currentColor : undefined | |||
}); | |||
if (character.trim() !== '') { | |||
currentColor = { | |||
R: currentColor.R + colorIncrement.R, | |||
G: currentColor.G + colorIncrement.G, | |||
B: currentColor.B + colorIncrement.B, | |||
}; | |||
} | |||
} | |||
</script> | |||
<style scoped> | |||
</style> |
@@ -0,0 +1,26 @@ | |||
<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> | |||
</div> | |||
<div class="max-h-12 overflow-hidden overflow-ellipsis"> | |||
<span class="text-sm text-gray-600">{{ issue.summary }}</span> | |||
</div> | |||
</div> | |||
</template> | |||
<script setup lang="ts"> | |||
import { Issue } from "../types/issue.ts"; | |||
const props = defineProps<{ issue: Issue, selected: boolean }>(); | |||
const emits = defineEmits(['selected']); | |||
const handleSelection = () => { | |||
emits("selected", props.issue.id, props.issue.title); | |||
} | |||
</script> | |||
<style scoped> | |||
</style> |
@@ -0,0 +1,29 @@ | |||
<template> | |||
<div class="flex justify-center items-center w-full h-full"> | |||
<div class="spinner relative width-[56px] height-[56px]"></div> | |||
</div> | |||
</template> | |||
<script setup lang="ts"> | |||
const props = defineProps<{ identifier: string }>(); | |||
</script> | |||
<style scoped> | |||
.spinner::before { | |||
content: ''; | |||
display: block; | |||
height: 56px; | |||
width: 11.2px; | |||
animation: spinner-x0t3la 0.7s infinite; | |||
position: absolute; | |||
background: rgb(252, 211, 77); | |||
} | |||
@keyframes spinner-x0t3la { | |||
to { | |||
transform: rotate(360deg); | |||
} | |||
} | |||
</style> |
@@ -0,0 +1,42 @@ | |||
<template> | |||
<div ref="editor"></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 | |||
}, | |||
}); | |||
quill.on('text-change', (delta, oldDelta, source) => { | |||
if (source == 'user') { | |||
emits('textChange', quill.getText().split('\n').filter(item => item.trim())); | |||
} | |||
}); | |||
}) | |||
</script> | |||
<style> | |||
.ql-editor { | |||
max-height: 259px; | |||
} | |||
</style> |
@@ -0,0 +1,26 @@ | |||
<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="speech-bubbles" x="0px" y="0px" | |||
viewBox="0 0 30 30" style="fill:#ffffff" xml:space="preserve"> | |||
<g id="Speech_Bubbles"> | |||
<g> | |||
<path d="M27.5,0h-21C5.122,0,4,1.122,4,2.5v15C4,18.879,5.122,20,6.5,20h12.708l5.941,5.856C25.244,25.95,25.371,26,25.5,26 | |||
c0.065,0,0.131-0.013,0.193-0.039C25.879,25.884,26,25.701,26,25.5V20h1.5c1.379,0,2.5-1.121,2.5-2.5v-15 | |||
C30,1.122,28.879,0,27.5,0z"/> | |||
<path d="M6.5,21C4.57,21,3,19.43,3,17.5V4H2.5C1.121,4,0,5.122,0,6.5v15C0,22.879,1.121,24,2.5,24H4v5.5 | |||
c0,0.201,0.121,0.384,0.307,0.461C4.369,29.987,4.435,30,4.5,30c0.129,0,0.256-0.05,0.351-0.144L10.792,24h11.05l-3.044-3H6.5z"/> | |||
</g> | |||
</g> | |||
</svg> | |||
</div> | |||
</template> | |||
<script setup lang="ts"> | |||
</script> | |||
<style scoped> | |||
#speech-bubbles:hover { | |||
fill: #2f92de !important; | |||
} | |||
</style> |
@@ -0,0 +1,25 @@ | |||
<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:#ffffff" 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 | |||
C9.243,1.847,8.121,1,6.805,1H2.5C1.121,1,0,2.121,0,3.5v13C0,17.879,1.121,19,2.5,19h5c1.379,0,2.5-1.121,2.5-2.5v-0.315 | |||
l0.357,0.182L14,24.305v4.478c0,0.133,0.053,0.261,0.147,0.354C14.184,29.172,15.031,30,16.326,30c1.5,0,3.587-2.52,3.587-5 | |||
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> | |||
</template> | |||
<script setup lang="ts"> | |||
</script> | |||
<style scoped> | |||
#dislike-button:hover { | |||
fill: #e65e5e !important; | |||
} | |||
</style> |
@@ -0,0 +1,25 @@ | |||
<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:#ffffff" 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 | |||
v4.478l-3.643,7.938L10,14.815V14.5c0-1.379-1.121-2.5-2.5-2.5h-5C1.121,12,0,13.121,0,14.5v13C0,28.879,1.121,30,2.5,30h4.305 | |||
c1.316,0,2.438-0.847,2.853-2.023C10.668,28.58,12.159,29,13.108,29h11.453c1.014,0,1.957-0.496,2.463-1.296 | |||
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> | |||
</template> | |||
<script setup lang="ts"> | |||
</script> | |||
<style scoped> | |||
#like-button:hover { | |||
fill: #f6ed40 !important; | |||
} | |||
</style> |
@@ -0,0 +1,48 @@ | |||
<template> | |||
<Modal header="New Issue" @submitted="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" /> | |||
</div> | |||
</slot> | |||
</Modal> | |||
</template> | |||
<script setup lang="ts"> | |||
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"; | |||
const title = ref(''); | |||
const description = ref<string[]>(['']); | |||
const { post } = useFetch(); | |||
const submit = async () => { | |||
const { getSessionId } = useSession(); | |||
const sessionId = getSessionId(); | |||
if (sessionId) { | |||
await post<AddIssueRequest, AddIssueResponse>('/issues/create', { | |||
session_id: sessionId, | |||
title: title.value, | |||
paragraphs: description.value | |||
}); | |||
} | |||
} | |||
const assignDescription = (paragraphs: string[]) => { | |||
description.value = paragraphs; | |||
} | |||
</script> | |||
<style scoped> | |||
</style> |
@@ -0,0 +1,41 @@ | |||
<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 class="flex flex-row justify-end" @click="hide"> | |||
<span class="text-2xl text-gray-600 leading-3 cursor-pointer">×</span> | |||
</div> | |||
<div class="h-11/12 w-full"> | |||
<div class="h-full"> | |||
<div> | |||
<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"> | |||
<button class="bg-amber-500 rounded py-2 px-3 text-white font-bold" @click="handleSubmission"> | |||
Submit | |||
</button> | |||
</div> | |||
</div> | |||
</div> | |||
</template> | |||
<script setup lang="ts"> | |||
import { useModal } from "../../composables/useModal.ts"; | |||
const { hide, getVisibility } = useModal(); | |||
const props = defineProps<{header: string}>(); | |||
const emits = defineEmits(['submitted']); | |||
const handleSubmission = () => { | |||
emits('submitted'); | |||
hide(); | |||
}; | |||
</script> | |||
<style scoped> | |||
</style> |
@@ -0,0 +1,47 @@ | |||
<template> | |||
<Modal header="Register"> | |||
<slot> | |||
<div class="my-3"> | |||
<input type="text" class="w-full border-gray-300 border border-solid py-2 px-2.5" placeholder="Username" v-model="username" /> | |||
</div> | |||
<div class="my-3"> | |||
<input type="text" class="w-full border-gray-300 border border-solid py-2 px-2.5" placeholder="Telegram Username" v-model="telegramHandle" /> | |||
</div> | |||
<div class="my-3"> | |||
<input type="password" class="w-full border-gray-300 border border-solid py-2 px-2.5" placeholder="Password" v-model="password" /> | |||
</div> | |||
</slot> | |||
</Modal> | |||
</template> | |||
<script setup lang="ts"> | |||
import Modal from "./Modal.vue"; | |||
import { computed, ref } from "vue"; | |||
const trimAtSymbol = (str: string, symbol: string) => { | |||
const trimmed = ref(str); | |||
while (trimmed.value.startsWith(symbol)) { | |||
trimmed.value = trimmed.value.substring(1); | |||
} | |||
return trimmed.value; | |||
} | |||
const [username, tgUsername, password] = [ref(''), ref(''), ref('')]; | |||
const telegramHandle = computed({ | |||
get: () => tgUsername.value, | |||
set: (value: string) => { | |||
const handle = trimAtSymbol(value.trim(), '@'); | |||
if (handle.length > 0) { | |||
tgUsername.value = `@${ handle }` | |||
} else { | |||
tgUsername.value = ''; | |||
} | |||
} | |||
}); | |||
</script> | |||
<style scoped> | |||
</style> |
@@ -0,0 +1,23 @@ | |||
export const useFetch = () => { | |||
const ver = 'v1'; | |||
const get = async <T>(endpoint: string) => { | |||
const data = await fetch(`/api/${ ver }/${ endpoint }`, { | |||
method: 'GET', | |||
mode: 'cors', | |||
headers: new Headers({ 'Content-Type': 'application/json' }) | |||
}); | |||
return await data.json() as T; | |||
}; | |||
const post = async <T, U>(endpoint: string, payload: T) => { | |||
const data = await fetch(`/api/${ ver }/${ endpoint }`, { | |||
method: 'POST', | |||
mode: 'cors', | |||
headers: { 'Content-Type': 'application/json' }, | |||
body: JSON.stringify(payload) | |||
}); | |||
return await data.json() as U; | |||
}; | |||
return { get, post } | |||
} |
@@ -0,0 +1,55 @@ | |||
import { ref, reactive } from "vue"; | |||
interface LoadingState { | |||
[key: string]: boolean; | |||
} | |||
const loading = ref<LoadingState>({}); | |||
export const useLoading = () => { | |||
const register = (identifier: string, state = false) => { | |||
loading.value[identifier] = state; | |||
}; | |||
const unregister = (identifier: string) => { | |||
delete loading.value[identifier]; | |||
}; | |||
const isLoading = (identifier: string): boolean => { | |||
if (!(identifier in loading.value)) { | |||
console.warn(`Loading state for "${identifier}" is not registered.`); | |||
return false; | |||
} | |||
return loading.value[identifier]; | |||
}; | |||
const show = (identifier: string) => { | |||
if (identifier in loading.value) { | |||
loading.value[identifier] = true; | |||
} else { | |||
console.warn(`Cannot show loading for unregistered identifier: "${identifier}"`); | |||
} | |||
}; | |||
const hide = (identifier: string) => { | |||
if (identifier in loading.value) { | |||
loading.value[identifier] = false; | |||
} else { | |||
console.warn(`Cannot hide loading for unregistered identifier: "${identifier}"`); | |||
} | |||
}; | |||
const isAnyLoading = (): boolean => { | |||
return Object.values(loading.value).some(state => state); | |||
}; | |||
return reactive({ | |||
register, | |||
unregister, | |||
isLoading, | |||
show, | |||
hide, | |||
isAnyLoading, | |||
loadingState: loading | |||
}); | |||
}; |
@@ -0,0 +1,22 @@ | |||
import { ref } from 'vue' | |||
import IssueModal from "../components/modals/IssueModal.vue"; | |||
import RegistrationModal from "../components/modals/RegistrationModal.vue"; | |||
export type ContentModal = InstanceType<typeof IssueModal> | InstanceType<typeof RegistrationModal>; | |||
const visible = ref(false); | |||
const modalContent = ref<ContentModal | null>(null); | |||
export const useModal = () => { | |||
const getVisibility = () => visible.value; | |||
const getModalContent = () => modalContent.value; | |||
const show = (modal: ContentModal) => { | |||
modalContent.value = modal; | |||
visible.value = true; | |||
} | |||
const hide = () => { | |||
modalContent.value = null; | |||
visible.value = false; | |||
} | |||
return { getVisibility, getModalContent, show, hide }; | |||
} |
@@ -0,0 +1,26 @@ | |||
import { ref } from "vue"; | |||
import { useCookies } from '@vueuse/integrations/useCookies' | |||
const cookies = useCookies(['session']); | |||
const sessionId = ref<string>(); | |||
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); | |||
} | |||
} | |||
}; | |||
return { initializeSessionId, setSessionId, getSessionId }; | |||
} |
@@ -0,0 +1,5 @@ | |||
@import url('https://fonts.googleapis.com/css2?family=Aleo:ital,wght@0,100..900;1,100..900&display=swap'); | |||
@tailwind base; | |||
@tailwind components; | |||
@tailwind utilities; |
@@ -0,0 +1,6 @@ | |||
import { createApp } from 'vue' | |||
import './index.css' | |||
import App from './App.vue' | |||
import router from './router' | |||
createApp(App).use(router).mount('#app') |
@@ -0,0 +1,14 @@ | |||
import { createMemoryHistory, createRouter } from 'vue-router' | |||
import HomeView from '../views/HomeView.vue' | |||
const routes = [ | |||
{ path: '/', component: HomeView }, | |||
] | |||
const router = createRouter({ | |||
history: createMemoryHistory(), | |||
routes, | |||
}) | |||
export default router; |
@@ -0,0 +1,5 @@ | |||
export interface Color { | |||
R: string; | |||
G: string; | |||
B: string; | |||
} |
@@ -0,0 +1,6 @@ | |||
import { Color } from "./color.ts"; | |||
export interface HeaderLetter { | |||
letter: string; | |||
color: Color; | |||
} |
@@ -0,0 +1,28 @@ | |||
import { u32, u64 } from "@stellar/stellar-sdk/contract"; | |||
export interface Issue { | |||
id: String; | |||
title: String, | |||
summary: String, | |||
paragraph_count: u32, | |||
positive_votes: u32, | |||
negative_votes: u32, | |||
telegram_handle: string, | |||
created_at: u64, | |||
} | |||
export interface AddIssueRequest { | |||
session_id: string; | |||
title: string; | |||
paragraphs: string[]; | |||
} | |||
export interface AddIssueResponse { | |||
result: boolean; | |||
} | |||
export interface VoteIssueRequest { | |||
issue_id: string; | |||
increase: boolean; | |||
decrease: boolean; | |||
} |
@@ -0,0 +1,8 @@ | |||
export interface User { | |||
session_id: string | |||
auth_date: number | |||
username: string | |||
first_name: string | |||
last_name: string | |||
photo_url: string | |||
} |
@@ -0,0 +1,108 @@ | |||
<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"> | |||
<button class="w-full bg-gray-800 px-5 py-3 h-1/16 text-sm font-bold text-white uppercase text-center cursor-pointer" @click="showNewIssueModal"> | |||
Add Issue | |||
</button> | |||
<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> | |||
<LoadingSpinner identifier="issues" v-if="isLoading('issues')" /> | |||
</div> | |||
</div> | |||
<div class="sm:w-3/4 w-full"> | |||
<div class="h-svh" v-if="selectedId"> | |||
<div v-if="!isLoading('content')"> | |||
<div class="px-5 pt-5 pb-12 h-full overflow-y-scroll"> | |||
<div class="text-5xl font-bold font-header">{{ selected?.title }}</div> | |||
<template v-if="selected?.content"> | |||
<p class="my-3" v-for="paragraph in selected?.content"> | |||
{{ paragraph }} | |||
</p> | |||
</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" /> | |||
<ThumbsDown @click="recordThumbsDown" /> | |||
</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> | |||
</div> | |||
</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 { Issue, VoteIssueRequest } from "../types/issue.ts"; | |||
import { useLoading } from "../composables/useLoading.ts"; | |||
import LoadingSpinner from "../components/LoadingSpinner.vue"; | |||
import IssueModal from "../components/modals/IssueModal.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[]}>(); | |||
showLoading('issues'); | |||
onMounted(async () => { | |||
issues.value = await get<Issue[]>('/issues/list'); | |||
hideLoading('issues'); | |||
}); | |||
onUnmounted(() => { | |||
unregister('issues'); | |||
unregister('content'); | |||
}) | |||
const handleSelection = async (id: string, title: string) => { | |||
showLoading('content'); | |||
selectedId.value = id; | |||
const paragraphs = await get<string[]>(`/issues/paragraphs?issue_id=${id}`); | |||
selected.value = {title, content: paragraphs}; | |||
hideLoading('content'); | |||
} | |||
const recordThumbsUp = async () => { | |||
await post<VoteIssueRequest>('/issues/vote', | |||
{ issue_id: selectedId.value, increase: true, decrease: false }) | |||
} | |||
const recordThumbsDown = async () => { | |||
await post<VoteIssueRequest>('/issues/vote', | |||
{ issue_id: selectedId.value, increase: false, decrease: true }) | |||
} | |||
const showNewIssueModal = () => { | |||
showModal(IssueModal as ContentModal); | |||
} | |||
</script> | |||
<style scoped> | |||
</style> |
@@ -0,0 +1 @@ | |||
/// <reference types="vite/client" /> |
@@ -0,0 +1,19 @@ | |||
/** @type {import('tailwindcss').Config} */ | |||
export default { | |||
content: ['./index.html', './src/**/*.{vue,js,ts}'], | |||
theme: { | |||
extend: { | |||
height: { | |||
'15/16': '93.75%', | |||
'1/16': '6.25%', | |||
'11/12': '91.666666%', | |||
'1/10': '8.333333%' | |||
} | |||
}, | |||
fontFamily: { | |||
'header': ['Aleo'], | |||
} | |||
}, | |||
plugins: [], | |||
} | |||
@@ -0,0 +1,24 @@ | |||
{ | |||
"compilerOptions": { | |||
"target": "ES2020", | |||
"useDefineForClassFields": true, | |||
"module": "ESNext", | |||
"lib": ["ES2020", "DOM", "DOM.Iterable"], | |||
"skipLibCheck": true, | |||
/* Bundler mode */ | |||
"moduleResolution": "bundler", | |||
"allowImportingTsExtensions": true, | |||
"isolatedModules": true, | |||
"moduleDetection": "force", | |||
"noEmit": true, | |||
"jsx": "preserve", | |||
/* Linting */ | |||
"strict": true, | |||
"noUnusedLocals": true, | |||
"noUnusedParameters": true, | |||
"noFallthroughCasesInSwitch": true | |||
}, | |||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] | |||
} |
@@ -0,0 +1,7 @@ | |||
{ | |||
"files": [], | |||
"references": [ | |||
{ "path": "./tsconfig.app.json" }, | |||
{ "path": "./tsconfig.node.json" } | |||
] | |||
} |
@@ -0,0 +1,22 @@ | |||
{ | |||
"compilerOptions": { | |||
"target": "ES2022", | |||
"lib": ["ES2023"], | |||
"module": "ESNext", | |||
"skipLibCheck": true, | |||
/* Bundler mode */ | |||
"moduleResolution": "bundler", | |||
"allowImportingTsExtensions": true, | |||
"isolatedModules": true, | |||
"moduleDetection": "force", | |||
"noEmit": true, | |||
/* Linting */ | |||
"strict": true, | |||
"noUnusedLocals": true, | |||
"noUnusedParameters": true, | |||
"noFallthroughCasesInSwitch": true | |||
}, | |||
"include": ["vite.config.ts"] | |||
} |
@@ -0,0 +1,12 @@ | |||
import { defineConfig } from 'vite' | |||
import vue from '@vitejs/plugin-vue' | |||
// https://vitejs.dev/config/ | |||
export default defineConfig({ | |||
plugins: [vue()], | |||
server: { | |||
proxy: { | |||
'/api/v1': 'http://localhost:8000' | |||
} | |||
} | |||
}) |