| @@ -1,3 +1,16 @@ | |||||
| # Environment | |||||
| Make sure you've set up the database before you do this. This project uses PostgreSQL with Diesel on the backend. | |||||
| I use [localtunnel](https://github.com/localtunnel/localtunnel) for my local development environment. You'll need to set up a Telegram bot using BotFather, and take the following steps: | |||||
| * Change the `bot-username` prop on the `LoginWidget` tag within `BrandBar.vue` to the username of the bot you set up with BotFather. | |||||
| * Send the command `/setdomain` to BotFather, and select the bot you set up previously. | |||||
| * Run localtunnel with `lt --port 5173`. | |||||
| * Send the domain provided by localtunnel to BotFather. | |||||
| After these steps have been completed, you will be able to authenticate with Telegram via stored sessions. | |||||
| # Vue 3 + TypeScript + Vite | # 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. | 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. | ||||
| @@ -12,6 +12,7 @@ | |||||
| "@vueuse/core": "^11.1.0", | "@vueuse/core": "^11.1.0", | ||||
| "@vueuse/integrations": "^11.1.0", | "@vueuse/integrations": "^11.1.0", | ||||
| "luxon": "^3.5.0", | "luxon": "^3.5.0", | ||||
| "pinia": "^2.2.4", | |||||
| "quill": "^2.0.2", | "quill": "^2.0.2", | ||||
| "universal-cookie": "^7.2.0", | "universal-cookie": "^7.2.0", | ||||
| "vue": "^3.4.37", | "vue": "^3.4.37", | ||||
| @@ -2302,6 +2303,58 @@ | |||||
| "node": ">=0.10.0" | "node": ">=0.10.0" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/pinia": { | |||||
| "version": "2.2.4", | |||||
| "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.2.4.tgz", | |||||
| "integrity": "sha512-K7ZhpMY9iJ9ShTC0cR2+PnxdQRuwVIsXDO/WIEV/RnMC/vmSoKDTKW/exNQYPI+4ij10UjXqdNiEHwn47McANQ==", | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "@vue/devtools-api": "^6.6.3", | |||||
| "vue-demi": "^0.14.10" | |||||
| }, | |||||
| "funding": { | |||||
| "url": "https://github.com/sponsors/posva" | |||||
| }, | |||||
| "peerDependencies": { | |||||
| "@vue/composition-api": "^1.4.0", | |||||
| "typescript": ">=4.4.4", | |||||
| "vue": "^2.6.14 || ^3.3.0" | |||||
| }, | |||||
| "peerDependenciesMeta": { | |||||
| "@vue/composition-api": { | |||||
| "optional": true | |||||
| }, | |||||
| "typescript": { | |||||
| "optional": true | |||||
| } | |||||
| } | |||||
| }, | |||||
| "node_modules/pinia/node_modules/vue-demi": { | |||||
| "version": "0.14.10", | |||||
| "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", | |||||
| "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", | |||||
| "hasInstallScript": true, | |||||
| "license": "MIT", | |||||
| "bin": { | |||||
| "vue-demi-fix": "bin/vue-demi-fix.js", | |||||
| "vue-demi-switch": "bin/vue-demi-switch.js" | |||||
| }, | |||||
| "engines": { | |||||
| "node": ">=12" | |||||
| }, | |||||
| "funding": { | |||||
| "url": "https://github.com/sponsors/antfu" | |||||
| }, | |||||
| "peerDependencies": { | |||||
| "@vue/composition-api": "^1.0.0-rc.1", | |||||
| "vue": "^3.0.0-0 || ^2.6.0" | |||||
| }, | |||||
| "peerDependenciesMeta": { | |||||
| "@vue/composition-api": { | |||||
| "optional": true | |||||
| } | |||||
| } | |||||
| }, | |||||
| "node_modules/pirates": { | "node_modules/pirates": { | ||||
| "version": "4.0.6", | "version": "4.0.6", | ||||
| "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", | "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", | ||||
| @@ -13,6 +13,7 @@ | |||||
| "@vueuse/core": "^11.1.0", | "@vueuse/core": "^11.1.0", | ||||
| "@vueuse/integrations": "^11.1.0", | "@vueuse/integrations": "^11.1.0", | ||||
| "luxon": "^3.5.0", | "luxon": "^3.5.0", | ||||
| "pinia": "^2.2.4", | |||||
| "quill": "^2.0.2", | "quill": "^2.0.2", | ||||
| "universal-cookie": "^7.2.0", | "universal-cookie": "^7.2.0", | ||||
| "vue": "^3.4.37", | "vue": "^3.4.37", | ||||
| @@ -10,9 +10,14 @@ | |||||
| import { useModal } from "./composables/useModal.ts"; | import { useModal } from "./composables/useModal.ts"; | ||||
| import { useSession } from "./composables/useSession.ts"; | import { useSession } from "./composables/useSession.ts"; | ||||
| import BrandBar from "./components/BrandBar.vue"; | import BrandBar from "./components/BrandBar.vue"; | ||||
| import { onBeforeMount } from "vue"; | |||||
| const { getModalContent, getVisibility } = useModal(); | const { getModalContent, getVisibility } = useModal(); | ||||
| const { initializeSessionId } = useSession(); | |||||
| const { initializeSessionId, initializeUsername } = useSession(); | |||||
| initializeSessionId(); | initializeSessionId(); | ||||
| onBeforeMount(async () => { | |||||
| await initializeUsername(); | |||||
| }); | |||||
| </script> | </script> | ||||
| @@ -3,7 +3,21 @@ | |||||
| <span class="text-4xl font-header font-bold"> | <span class="text-4xl font-header font-bold"> | ||||
| <ColoredHeader text="Puff Pastry" from="#F2F3E2" to="#B2E5F8"/> | <ColoredHeader text="Puff Pastry" from="#F2F3E2" to="#B2E5F8"/> | ||||
| </span> | </span> | ||||
| <LoginWidget bot-username="puffpastry_mfa_bot" @auth="handleUserAuth" corner-radius="2" size="medium" class="my-auto" /> | |||||
| <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> | |||||
| </div> | </div> | ||||
| </template> | </template> | ||||
| @@ -13,21 +27,27 @@ import type { LoginWidgetUser } from "vue-tg"; | |||||
| import ColoredHeader from "./ColoredHeader.vue"; | import ColoredHeader from "./ColoredHeader.vue"; | ||||
| import { useFetch } from "../composables/useFetch.ts"; | import { useFetch } from "../composables/useFetch.ts"; | ||||
| import { useSession } from "../composables/useSession.ts"; | import { useSession } from "../composables/useSession.ts"; | ||||
| import { AuthenticateRequest, AuthenticateResponse } from "../types/user.ts"; | |||||
| import { computed } from "vue"; | |||||
| const { post } = useFetch(); | const { post } = useFetch(); | ||||
| const { setSessionId } = useSession(); | |||||
| const { setSessionId, setUsername, getUsername } = useSession(); | |||||
| const handleUserAuth = async (user: LoginWidgetUser) => { | const handleUserAuth = async (user: LoginWidgetUser) => { | ||||
| const sessionId = await post("/user/authenticate", | |||||
| const response = await post<AuthenticateRequest, AuthenticateResponse>("/sessions/authenticate", | |||||
| { | { | ||||
| user_id: user.id, | |||||
| auth_date: user.auth_date, | auth_date: user.auth_date, | ||||
| username: user.username, | username: user.username, | ||||
| first_name: user.first_name, | first_name: user.first_name, | ||||
| last_name: user.last_name, | last_name: user.last_name, | ||||
| photo_url: user.photo_url | photo_url: user.photo_url | ||||
| }); | }); | ||||
| setSessionId(sessionId.session_id); | |||||
| setSessionId(response.session_id); | |||||
| setUsername(user.username); | |||||
| } | } | ||||
| const username = computed(() => getUsername()); | |||||
| </script> | </script> | ||||
| <style scoped> | <style scoped> | ||||
| @@ -0,0 +1,15 @@ | |||||
| <template> | |||||
| <div class="w-full bg-amber-100 rounded-lg"> | |||||
| <button>{{ emptyValue }}</button> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import { DropdownOption } from "../types/dropdown.ts"; | |||||
| const props = defineProps<{ emptyValue: string, options: DropdownOption[], expanded?: boolean }>() | |||||
| </script> | |||||
| <style scoped> | |||||
| </style> | |||||
| @@ -0,0 +1,21 @@ | |||||
| <template> | |||||
| <div :data-parent="starterComment.comment.parent"> | |||||
| <span class="block text-gray-500">@{{ starterComment.comment.telegram_handle }}</span> | |||||
| {{ starterComment.comment.content }} | |||||
| <div class="ml-3"> | |||||
| <template v-for="child in starterComment.children"> | |||||
| <Thread :starter-comment="child" /> | |||||
| </template> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import { CommentWithChildren } from "../types/comment.ts"; | |||||
| const props = defineProps<{ starterComment: CommentWithChildren }>(); | |||||
| </script> | |||||
| <style scoped> | |||||
| </style> | |||||
| @@ -0,0 +1,21 @@ | |||||
| <template> | |||||
| <div class="h-4 w-4 cursor-pointer mx-2"> | |||||
| <svg id="down-arrow" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" | |||||
| viewBox="0 0 30 30" style="fill:#ffffff" xml:space="preserve"> | |||||
| <g id="Down_Arrow"> | |||||
| <path d="M29.4,2.8c-0.1-0.2-0.3-0.3-0.4-0.3H1c-0.2,0-0.3,0.1-0.4,0.3s-0.1,0.3,0,0.5l14,24c0.1,0.2,0.3,0.2,0.4,0.2 | |||||
| s0.3-0.1,0.4-0.2l14-24C29.5,3.1,29.5,2.9,29.4,2.8z"/> | |||||
| </g> | |||||
| </svg> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| </script> | |||||
| <style scoped> | |||||
| #down-arrow:hover { | |||||
| fill: #dedede !important; | |||||
| } | |||||
| </style> | |||||
| @@ -0,0 +1,25 @@ | |||||
| <template> | |||||
| <div class="h-4 w-4 cursor-pointer mx-2"> | |||||
| <svg id="filter-button" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" | |||||
| viewBox="0 0 30 30" style="fill:#ffffff" xml:space="preserve"> | |||||
| <g> | |||||
| <path d="M28.8,0.9C28.5,0.4,27.9,0,27.3,0L2.7,0C2.1,0,1.5,0.4,1.2,1C0.9,1.6,0.9,2.2,1.3,2.8l9.6,14c0.1,0.1,0.1,0.3,0.1,0.4v11 | |||||
| c0,1,0.8,1.7,1.8,1.7c0.3,0,0.6-0.1,0.9-0.2l4.5-2.3c0.5-0.3,0.8-0.9,0.8-1.5V17c0-0.2,0-0.3,0.1-0.4l2.6-3.7c0,0,0,0,0.1,0.1 | |||||
| l1.5,0.8c0.3,0.2,0.6,0.3,0.9,0.3c0.5,0,1.1-0.2,1.4-0.7l2.1-2.7c0.3-0.4,0.4-0.9,0.3-1.4s-0.4-0.9-0.8-1.1l-1.4-1c0,0-0.1,0-0.1,0 | |||||
| l3-4.3C29.1,2.2,29.1,1.5,28.8,0.9z M12.8,13.9C12.7,14,12.6,14,12.5,14c-0.2,0-0.3-0.1-0.4-0.2l-7-10C4.9,3.6,5,3.2,5.2,3.1 | |||||
| C5.4,2.9,5.8,3,5.9,3.2l7,10C13.1,13.4,13,13.8,12.8,13.9z M26.6,8.9C26.8,9,26.9,9.2,27,9.4s0,0.4-0.1,0.6l-2.1,2.7 | |||||
| c-0.2,0.3-0.7,0.4-1,0.2l-1.5-0.8l2.9-4.2c0,0,0,0,0.1,0.1L26.6,8.9z"/> | |||||
| </g> | |||||
| </svg> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| </script> | |||||
| <style scoped> | |||||
| #filter-button:hover { | |||||
| fill: #cfcfcf !important; | |||||
| } | |||||
| </style> | |||||
| @@ -1,7 +1,7 @@ | |||||
| <template> | <template> | ||||
| <div class="h-4 w-4 cursor-pointer mx-2"> | <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" | <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"> | |||||
| viewBox="0 0 30 30" :style="`fill:${getFill()}`" xml:space="preserve"> | |||||
| <g id="Thumbs_Down"> | <g id="Thumbs_Down"> | ||||
| <path d="M30,12.524c0-1.094-0.653-2.049-1.61-2.472c0.227-0.404,0.35-0.864,0.35-1.335c0-1.108-0.656-2.073-1.615-2.498 | <path d="M30,12.524c0-1.094-0.653-2.049-1.61-2.472c0.227-0.404,0.35-0.864,0.35-1.335c0-1.108-0.656-2.073-1.615-2.498 | ||||
| c0.448-0.812,0.537-1.916-0.1-2.924C26.519,2.496,25.575,2,24.562,2H13.108c-0.949,0-2.44,0.42-3.451,1.023 | c0.448-0.812,0.537-1.916-0.1-2.924C26.519,2.496,25.575,2,24.562,2H13.108c-0.949,0-2.44,0.42-3.451,1.023 | ||||
| @@ -15,11 +15,13 @@ | |||||
| </template> | </template> | ||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| const props = defineProps<{ 'selected': boolean }>(); | |||||
| const getFill = () => props.selected ? '#e61717' : '#ffffff'; | |||||
| </script> | </script> | ||||
| <style scoped> | <style scoped> | ||||
| #dislike-button:hover { | #dislike-button:hover { | ||||
| fill: #e65e5e !important; | |||||
| fill: #e61717 !important; | |||||
| } | } | ||||
| </style> | </style> | ||||
| @@ -1,7 +1,7 @@ | |||||
| <template> | <template> | ||||
| <div class="h-4 w-4 cursor-pointer mx-2"> | <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" | <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"> | |||||
| viewBox="0 0 30 30" :style="`fill:${getFill()}`" xml:space="preserve"> | |||||
| <g id="Thumbs_Up"> | <g id="Thumbs_Up"> | ||||
| <path d="M30,18.476c0-0.774-0.327-1.493-0.884-1.999c0.613-0.657,0.957-1.626,0.871-2.563C29.842,12.334,28.395,11,26.828,11 | <path d="M30,18.476c0-0.774-0.327-1.493-0.884-1.999c0.613-0.657,0.957-1.626,0.871-2.563C29.842,12.334,28.395,11,26.828,11 | ||||
| h-7.957c0.379-1.078,1.042-3.223,1.042-5c0-2.48-2.087-5-3.587-5c-1.295,0-2.143,0.828-2.179,0.863C14.053,1.957,14,2.085,14,2.218 | h-7.957c0.379-1.078,1.042-3.223,1.042-5c0-2.48-2.087-5-3.587-5c-1.295,0-2.143,0.828-2.179,0.863C14.053,1.957,14,2.085,14,2.218 | ||||
| @@ -15,11 +15,13 @@ | |||||
| </template> | </template> | ||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||
| const props = defineProps<{ 'selected': boolean }>(); | |||||
| const getFill = () => props.selected ? "#45bf20" : "#ffffff"; | |||||
| </script> | </script> | ||||
| <style scoped> | <style scoped> | ||||
| #like-button:hover { | #like-button:hover { | ||||
| fill: #f6ed40 !important; | |||||
| fill: #45bf20 !important; | |||||
| } | } | ||||
| </style> | </style> | ||||
| @@ -0,0 +1,39 @@ | |||||
| <template> | |||||
| <Modal header="Filter" @submitted="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" /> | |||||
| </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" /> | |||||
| </div> | |||||
| </slot> | |||||
| </Modal> | |||||
| </template> | |||||
| <script setup lang="ts" generic="T extends ContentModal"> | |||||
| import Modal from "./Modal.vue"; | |||||
| import { ContentModal } from "../../composables/useModal.ts"; | |||||
| import { useFilterNotifier } from "../../composables/useFilterNotifier.ts"; | |||||
| import { eventBus } from "../../utils/eventBus.ts"; | |||||
| const { | |||||
| minPositiveVotes, | |||||
| minVotes | |||||
| } = useFilterNotifier(); | |||||
| const submit = () => { | |||||
| eventBus.emit("filtersApplied", {minPositiveVotes, minVotes}); | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| </style> | |||||
| @@ -11,7 +11,7 @@ | |||||
| </Modal> | </Modal> | ||||
| </template> | </template> | ||||
| <script setup lang="ts"> | |||||
| <script setup lang="ts" generic="T extends ContentModal"> | |||||
| import { useFetch } from "../../composables/useFetch.ts"; | import { useFetch } from "../../composables/useFetch.ts"; | ||||
| import { useSession } from "../../composables/useSession.ts"; | import { useSession } from "../../composables/useSession.ts"; | ||||
| @@ -19,6 +19,7 @@ import Modal from "./Modal.vue"; | |||||
| import { ref } from "vue"; | import { ref } from "vue"; | ||||
| import { AddIssueRequest, AddIssueResponse } from "../../types/issue.ts"; | import { AddIssueRequest, AddIssueResponse } from "../../types/issue.ts"; | ||||
| import PostBuilder from "../PostBuilder.vue"; | import PostBuilder from "../PostBuilder.vue"; | ||||
| import { ContentModal } from "../../composables/useModal.ts"; | |||||
| const title = ref(''); | const title = ref(''); | ||||
| const description = ref<string[]>(['']); | const description = ref<string[]>(['']); | ||||
| @@ -31,7 +32,6 @@ const submit = async () => { | |||||
| const sessionId = getSessionId(); | const sessionId = getSessionId(); | ||||
| if (sessionId) { | if (sessionId) { | ||||
| await post<AddIssueRequest, AddIssueResponse>('/issues/create', { | await post<AddIssueRequest, AddIssueResponse>('/issues/create', { | ||||
| session_id: sessionId, | |||||
| title: title.value, | title: title.value, | ||||
| paragraphs: description.value | paragraphs: description.value | ||||
| }); | }); | ||||
| @@ -1,8 +1,6 @@ | |||||
| export const useFetch = () => { | export const useFetch = () => { | ||||
| const ver = 'v1'; | |||||
| const get = async <T>(endpoint: string) => { | const get = async <T>(endpoint: string) => { | ||||
| const data = await fetch(`/api/${ ver }/${ endpoint }`, { | |||||
| const data = await fetch(`/api${ endpoint }`, { | |||||
| method: 'GET', | method: 'GET', | ||||
| mode: 'cors', | mode: 'cors', | ||||
| headers: new Headers({ 'Content-Type': 'application/json' }) | headers: new Headers({ 'Content-Type': 'application/json' }) | ||||
| @@ -10,7 +8,7 @@ export const useFetch = () => { | |||||
| return await data.json() as T; | return await data.json() as T; | ||||
| }; | }; | ||||
| const post = async <T, U>(endpoint: string, payload: T) => { | const post = async <T, U>(endpoint: string, payload: T) => { | ||||
| const data = await fetch(`/api/${ ver }/${ endpoint }`, { | |||||
| const data = await fetch(`/api${ endpoint }`, { | |||||
| method: 'POST', | method: 'POST', | ||||
| mode: 'cors', | mode: 'cors', | ||||
| headers: { 'Content-Type': 'application/json' }, | headers: { 'Content-Type': 'application/json' }, | ||||
| @@ -0,0 +1,16 @@ | |||||
| import { useFilterStore } from "../stores/filterStore.ts"; | |||||
| import { eventBus } from "../utils/eventBus.ts"; | |||||
| import { storeToRefs } from "pinia"; | |||||
| export const useFilterNotifier = () => { | |||||
| const filterStore = storeToRefs(useFilterStore()); | |||||
| const applyFilters = () => { | |||||
| eventBus.emit('filtersApplied', filterStore); | |||||
| } | |||||
| return { | |||||
| ...filterStore, | |||||
| applyFilters | |||||
| } | |||||
| }; | |||||
| @@ -1,8 +1,6 @@ | |||||
| import { ref } from 'vue' | 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>; | |||||
| export class ContentModal {} | |||||
| const visible = ref(false); | const visible = ref(false); | ||||
| const modalContent = ref<ContentModal | null>(null); | const modalContent = ref<ContentModal | null>(null); | ||||
| @@ -1,9 +1,13 @@ | |||||
| import { ref } from "vue"; | import { ref } from "vue"; | ||||
| import { useCookies } from '@vueuse/integrations/useCookies' | import { useCookies } from '@vueuse/integrations/useCookies' | ||||
| import { useFetch } from "./useFetch.ts"; | |||||
| import { GetUsernameResponse } from "../types/user.ts"; | |||||
| const cookies = useCookies(['session']); | const cookies = useCookies(['session']); | ||||
| const { get } = useFetch(); | |||||
| const sessionId = ref<string>(); | const sessionId = ref<string>(); | ||||
| const username = ref<string>(); | |||||
| export const useSession = () => { | export const useSession = () => { | ||||
| const setSessionId = (id: string) => { | const setSessionId = (id: string) => { | ||||
| @@ -22,5 +26,21 @@ export const useSession = () => { | |||||
| } | } | ||||
| }; | }; | ||||
| return { initializeSessionId, setSessionId, getSessionId }; | |||||
| 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); | |||||
| } | |||||
| } | |||||
| } | |||||
| return { initializeSessionId, setSessionId, getSessionId, setUsername, getUsername, initializeUsername }; | |||||
| } | } | ||||
| @@ -2,5 +2,7 @@ import { createApp } from 'vue' | |||||
| import './index.css' | import './index.css' | ||||
| import App from './App.vue' | import App from './App.vue' | ||||
| import router from './router' | import router from './router' | ||||
| import { createPinia } from 'pinia' | |||||
| createApp(App).use(router).mount('#app') | |||||
| const pinia = createPinia(); | |||||
| createApp(App).use(pinia).use(router).mount('#app') | |||||
| @@ -0,0 +1,16 @@ | |||||
| import { defineStore } from 'pinia'; | |||||
| export const useFilterStore = defineStore('filter', { | |||||
| state: () => ({ | |||||
| minPositiveVotes: undefined as number | undefined, | |||||
| minVotes: undefined as number | undefined | |||||
| }), | |||||
| actions: { | |||||
| filterByMinEquity(votes: number) { | |||||
| this.minPositiveVotes = votes; | |||||
| }, | |||||
| filterByTotalVotes(votes: number) { | |||||
| this.minVotes = votes; | |||||
| } | |||||
| } | |||||
| }) | |||||
| @@ -0,0 +1,17 @@ | |||||
| export interface Comment { | |||||
| id: number; | |||||
| content: string; | |||||
| parent: number; | |||||
| children?: Comment[]; | |||||
| telegram_handle: string; | |||||
| created_at: number; | |||||
| } | |||||
| export interface CommentWithChildren { | |||||
| comment: Comment; | |||||
| children: CommentWithChildren[]; | |||||
| } | |||||
| export interface GetCommentsResponse { | |||||
| comments: CommentWithChildren[]; | |||||
| } | |||||
| @@ -0,0 +1,5 @@ | |||||
| export interface DropdownOption { | |||||
| id: number; | |||||
| text: string; | |||||
| value: string; | |||||
| } | |||||
| @@ -11,8 +11,15 @@ export interface Issue { | |||||
| created_at: u64, | created_at: u64, | ||||
| } | } | ||||
| export interface ListIssuesResponse { | |||||
| issues: Issue[]; | |||||
| } | |||||
| export interface GetParagraphsResponse { | |||||
| paragraphs: string[]; | |||||
| } | |||||
| export interface AddIssueRequest { | export interface AddIssueRequest { | ||||
| session_id: string; | |||||
| title: string; | title: string; | ||||
| paragraphs: string[]; | paragraphs: string[]; | ||||
| } | } | ||||
| @@ -23,6 +30,19 @@ export interface AddIssueResponse { | |||||
| export interface VoteIssueRequest { | export interface VoteIssueRequest { | ||||
| issue_id: string; | issue_id: string; | ||||
| increase: boolean; | |||||
| decrease: boolean; | |||||
| vote: 'Positive' | 'Negative'; | |||||
| } | |||||
| export interface VoteIssueResponse { | |||||
| issue_id: string; | |||||
| equity: number; | |||||
| positive: boolean; | |||||
| } | |||||
| export interface GetVoteRequest { | |||||
| issue_id: number; | |||||
| } | |||||
| export interface GetVoteResponse { | |||||
| vote?: 'Positive' | 'Negative'; | |||||
| } | } | ||||
| @@ -5,4 +5,21 @@ export interface User { | |||||
| first_name: string | first_name: string | ||||
| last_name: string | last_name: string | ||||
| photo_url: string | photo_url: string | ||||
| } | |||||
| export interface AuthenticateRequest { | |||||
| user_id: number | |||||
| auth_date: number | |||||
| username?: string | |||||
| first_name: string | |||||
| last_name?: string | |||||
| photo_url?: string | |||||
| } | |||||
| export interface AuthenticateResponse { | |||||
| session_id: string | |||||
| } | |||||
| export interface GetUsernameResponse { | |||||
| username: string; | |||||
| } | } | ||||
| @@ -0,0 +1,31 @@ | |||||
| import { useFilterStore } from "../stores/filterStore.ts"; | |||||
| import { Ref } from "vue"; | |||||
| type RefState<T> = { | |||||
| [K in keyof T]: Ref<T[K]> | |||||
| } | |||||
| type FilterStoreState = ReturnType<typeof useFilterStore>['$state']; | |||||
| type Callback = (...args: RefState<FilterStoreState>[]) => void | |||||
| class EventBus { | |||||
| private events: { [key: string]: Callback[] } = {} | |||||
| on(event: string, callback: Callback) { | |||||
| if (!this.events[event]) { | |||||
| this.events[event] = [] | |||||
| } | |||||
| this.events[event].push(callback) | |||||
| } | |||||
| off(event: string) { | |||||
| delete this.events[event]; | |||||
| } | |||||
| emit(event: string, ...args: any[]) { | |||||
| if (this.events[event]) { | |||||
| this.events[event].forEach(callback => callback(...args)) | |||||
| } | |||||
| } | |||||
| } | |||||
| export const eventBus = new EventBus() | |||||
| @@ -2,9 +2,14 @@ | |||||
| <div class="home"> | <div class="home"> | ||||
| <div class="flex flex-row"> | <div class="flex flex-row"> | ||||
| <div class="sm:flex sm:flex-col sm:w-1/4 hidden bg-gray-600 h-screen"> | <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="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 class="h-full overflow-y-scroll"> | ||||
| <div v-for="issue in issues" :key="issue.id"> | <div v-for="issue in issues" :key="issue.id"> | ||||
| <IssueContainer | <IssueContainer | ||||
| @@ -17,22 +22,32 @@ | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <div class="sm:w-3/4 w-full"> | <div class="sm:w-3/4 w-full"> | ||||
| <div class="h-svh" v-if="selectedId"> | |||||
| <div class="min-h-svh" v-if="selectedId"> | |||||
| <div v-if="!isLoading('content')"> | <div v-if="!isLoading('content')"> | ||||
| <div class="px-5 pt-5 pb-12 h-full overflow-y-scroll"> | <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"> | <template v-if="selected?.content"> | ||||
| <p class="my-3" v-for="paragraph in selected?.content"> | |||||
| {{ paragraph }} | |||||
| </p> | |||||
| <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> | </template> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <LoadingSpinner identifier="content" v-else /> | <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 justify-between w-40 fixed bottom-0 right-0 bg-gray-800 px-5 py-3 rounded-tl-lg"> | ||||
| <div class="flex flex-row"> | <div class="flex flex-row"> | ||||
| <ThumbsUp @click="recordThumbsUp" /> | |||||
| <ThumbsDown @click="recordThumbsDown" /> | |||||
| <ThumbsUp @click="recordThumbsUp" :selected="selected?.vote === 'Positive'" /> | |||||
| <ThumbsDown @click="recordThumbsDown" :selected="selected?.vote === 'Negative'" /> | |||||
| </div> | </div> | ||||
| <SpeechBubbles /> | <SpeechBubbles /> | ||||
| </div> | </div> | ||||
| @@ -53,10 +68,23 @@ import ThumbsDown from "../components/icons/ThumbsDown.vue"; | |||||
| import SpeechBubbles from "../components/icons/SpeechBubbles.vue"; | import SpeechBubbles from "../components/icons/SpeechBubbles.vue"; | ||||
| import { ContentModal, useModal } from "../composables/useModal.ts"; | import { ContentModal, useModal } from "../composables/useModal.ts"; | ||||
| import { useFetch } from "../composables/useFetch.ts"; | import { useFetch } from "../composables/useFetch.ts"; | ||||
| import { Issue, VoteIssueRequest } from "../types/issue.ts"; | |||||
| import { | |||||
| GetParagraphsResponse, GetVoteResponse, | |||||
| Issue, | |||||
| ListIssuesResponse, | |||||
| VoteIssueRequest, | |||||
| VoteIssueResponse | |||||
| } from "../types/issue.ts"; | |||||
| import { useLoading } from "../composables/useLoading.ts"; | import { useLoading } from "../composables/useLoading.ts"; | ||||
| import LoadingSpinner from "../components/LoadingSpinner.vue"; | import LoadingSpinner from "../components/LoadingSpinner.vue"; | ||||
| import IssueModal from "../components/modals/IssueModal.vue"; | import 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 Thread from "../components/Thread.vue"; | |||||
| const { show: showModal } = useModal(); | const { show: showModal } = useModal(); | ||||
| const { get, post } = useFetch(); | const { get, post } = useFetch(); | ||||
| @@ -67,40 +95,72 @@ register('content'); | |||||
| const issues = ref<Issue[]>([]); | const issues = ref<Issue[]>([]); | ||||
| const selectedId = ref(''); | const selectedId = ref(''); | ||||
| const selected = ref<{title: string, content: string[]}>(); | |||||
| const selected = ref<{title: string, content: string[], vote?: 'Positive' | 'Negative'}>(); | |||||
| const comments = ref<CommentWithChildren[]>(); | |||||
| const { minPositiveVotes, minVotes } = storeToRefs(useFilterStore()); | |||||
| const [offset, limit] = [ref(0), ref(10)]; | |||||
| showLoading('issues'); | showLoading('issues'); | ||||
| onMounted(async () => { | onMounted(async () => { | ||||
| issues.value = await get<Issue[]>('/issues/list'); | |||||
| const resp = await get<ListIssuesResponse>(`/issues/list?offset=${ offset.value }&limit=${ limit.value }`); | |||||
| issues.value = resp.issues; | |||||
| hideLoading('issues'); | hideLoading('issues'); | ||||
| eventBus.on('filtersApplied', async (_) => { | |||||
| showLoading('issues'); | |||||
| const r = ref<ListIssuesResponse>(); | |||||
| if (minPositiveVotes.value !== undefined && minVotes.value !== undefined) { | |||||
| r.value = await get<ListIssuesResponse>(`/issues/list?min_positive_votes=${ minPositiveVotes.value }&min_votes=${ minVotes.value }&offset=${ offset.value }&limit=${ limit.value }`); | |||||
| } else if (minPositiveVotes.value !== undefined) { | |||||
| r.value = await get<ListIssuesResponse>(`/issues/list?min_positive_votes=${ minPositiveVotes.value }&offset=${ offset.value }&limit=${ limit.value }`); | |||||
| } else if (minVotes.value !== undefined) { | |||||
| r.value = await get<ListIssuesResponse>(`/issues/list?min_votes=${ minVotes.value }&offset=${ offset.value }&limit=${ limit.value }`); | |||||
| } | |||||
| hideLoading('issues'); | |||||
| if (r.value) { | |||||
| issues.value = r.value.issues; | |||||
| } | |||||
| }); | |||||
| }); | }); | ||||
| onUnmounted(() => { | onUnmounted(() => { | ||||
| unregister('issues'); | unregister('issues'); | ||||
| unregister('content'); | unregister('content'); | ||||
| eventBus.off('filtersApplied'); | |||||
| }) | }) | ||||
| const handleSelection = async (id: string, title: string) => { | const handleSelection = async (id: string, title: string) => { | ||||
| showLoading('content'); | showLoading('content'); | ||||
| selectedId.value = id; | selectedId.value = id; | ||||
| const paragraphs = await get<string[]>(`/issues/paragraphs?issue_id=${id}`); | |||||
| selected.value = {title, content: paragraphs}; | |||||
| 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'); | hideLoading('content'); | ||||
| } | } | ||||
| const recordThumbsUp = async () => { | const recordThumbsUp = async () => { | ||||
| await post<VoteIssueRequest>('/issues/vote', | |||||
| { issue_id: selectedId.value, increase: true, decrease: false }) | |||||
| await post<VoteIssueRequest, VoteIssueResponse>('/issues/vote', | |||||
| { issue_id: selectedId.value, vote: "Positive" }) | |||||
| } | } | ||||
| const recordThumbsDown = async () => { | const recordThumbsDown = async () => { | ||||
| await post<VoteIssueRequest>('/issues/vote', | |||||
| { issue_id: selectedId.value, increase: false, decrease: true }) | |||||
| await post<VoteIssueRequest, VoteIssueResponse>('/issues/vote', | |||||
| { issue_id: selectedId.value, vote: "Negative" }) | |||||
| } | } | ||||
| const showNewIssueModal = () => { | const showNewIssueModal = () => { | ||||
| showModal(IssueModal as ContentModal); | showModal(IssueModal as ContentModal); | ||||
| } | } | ||||
| const showFilterModal = () => { | |||||
| showModal(FilterModal as ContentModal); | |||||
| } | |||||
| </script> | </script> | ||||
| <style scoped> | <style scoped> | ||||
| @@ -6,7 +6,10 @@ export default defineConfig({ | |||||
| plugins: [vue()], | plugins: [vue()], | ||||
| server: { | server: { | ||||
| proxy: { | proxy: { | ||||
| '/api/v1': 'http://localhost:8000' | |||||
| '/api': { | |||||
| target: 'http://127.0.0.1:8080', | |||||
| rewrite: (url: string) => url.replace(/\/api/, ''), | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| }) | }) | ||||