Просмотр исходного кода

Update UI, add filtering, comment threads, fill for thumbs up/down buttons if user has already voted on an issue.

master
jbell 2 месяцев назад
Родитель
Сommit
741e9cc4c1
26 измененных файлов: 463 добавлений и 43 удалений
  1. +13
    -0
      README.md
  2. +53
    -0
      package-lock.json
  3. +1
    -0
      package.json
  4. +6
    -1
      src/App.vue
  5. +24
    -4
      src/components/BrandBar.vue
  6. +15
    -0
      src/components/Dropdown.vue
  7. +21
    -0
      src/components/Thread.vue
  8. +21
    -0
      src/components/icons/DownArrow.vue
  9. +25
    -0
      src/components/icons/Filter.vue
  10. +4
    -2
      src/components/icons/ThumbsDown.vue
  11. +4
    -2
      src/components/icons/ThumbsUp.vue
  12. +39
    -0
      src/components/modals/FilterModal.vue
  13. +2
    -2
      src/components/modals/IssueModal.vue
  14. +2
    -4
      src/composables/useFetch.ts
  15. +16
    -0
      src/composables/useFilterNotifier.ts
  16. +1
    -3
      src/composables/useModal.ts
  17. +21
    -1
      src/composables/useSession.ts
  18. +3
    -1
      src/main.ts
  19. +16
    -0
      src/stores/filterStore.ts
  20. +17
    -0
      src/types/comment.ts
  21. +5
    -0
      src/types/dropdown.ts
  22. +23
    -3
      src/types/issue.ts
  23. +17
    -0
      src/types/user.ts
  24. +31
    -0
      src/utils/eventBus.ts
  25. +79
    -19
      src/views/HomeView.vue
  26. +4
    -1
      vite.config.ts

+ 13
- 0
README.md Просмотреть файл

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

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.


+ 53
- 0
package-lock.json Просмотреть файл

@@ -12,6 +12,7 @@
"@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",
@@ -2302,6 +2303,58 @@
"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": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",


+ 1
- 0
package.json Просмотреть файл

@@ -13,6 +13,7 @@
"@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",


+ 6
- 1
src/App.vue Просмотреть файл

@@ -10,9 +10,14 @@
import { useModal } from "./composables/useModal.ts";
import { useSession } from "./composables/useSession.ts";
import BrandBar from "./components/BrandBar.vue";
import { onBeforeMount } from "vue";

const { getModalContent, getVisibility } = useModal();
const { initializeSessionId } = useSession();
const { initializeSessionId, initializeUsername } = useSession();

initializeSessionId();

onBeforeMount(async () => {
await initializeUsername();
});
</script>

+ 24
- 4
src/components/BrandBar.vue Просмотреть файл

@@ -3,7 +3,21 @@
<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>
<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>
</template>

@@ -13,21 +27,27 @@ 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 } = useSession();
const { setSessionId, setUsername, getUsername } = useSession();

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,
username: user.username,
first_name: user.first_name,
last_name: user.last_name,
photo_url: user.photo_url
});
setSessionId(sessionId.session_id);
setSessionId(response.session_id);
setUsername(user.username);
}

const username = computed(() => getUsername());
</script>

<style scoped>


+ 15
- 0
src/components/Dropdown.vue Просмотреть файл

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

+ 21
- 0
src/components/Thread.vue Просмотреть файл

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

+ 21
- 0
src/components/icons/DownArrow.vue Просмотреть файл

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

+ 25
- 0
src/components/icons/Filter.vue Просмотреть файл

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

+ 4
- 2
src/components/icons/ThumbsDown.vue Просмотреть файл

@@ -1,7 +1,7 @@
<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">
viewBox="0 0 30 30" :style="`fill:${getFill()}`" 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
@@ -15,11 +15,13 @@
</template>

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

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

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

+ 4
- 2
src/components/icons/ThumbsUp.vue Просмотреть файл

@@ -1,7 +1,7 @@
<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">
viewBox="0 0 30 30" :style="`fill:${getFill()}`" 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
@@ -15,11 +15,13 @@
</template>

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

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

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

+ 39
- 0
src/components/modals/FilterModal.vue Просмотреть файл

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

+ 2
- 2
src/components/modals/IssueModal.vue Просмотреть файл

@@ -11,7 +11,7 @@
</Modal>
</template>

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

import { useFetch } from "../../composables/useFetch.ts";
import { useSession } from "../../composables/useSession.ts";
@@ -19,6 +19,7 @@ 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";

const title = ref('');
const description = ref<string[]>(['']);
@@ -31,7 +32,6 @@ const submit = async () => {
const sessionId = getSessionId();
if (sessionId) {
await post<AddIssueRequest, AddIssueResponse>('/issues/create', {
session_id: sessionId,
title: title.value,
paragraphs: description.value
});


+ 2
- 4
src/composables/useFetch.ts Просмотреть файл

@@ -1,8 +1,6 @@
export const useFetch = () => {
const ver = 'v1';

const get = async <T>(endpoint: string) => {
const data = await fetch(`/api/${ ver }/${ endpoint }`, {
const data = await fetch(`/api${ endpoint }`, {
method: 'GET',
mode: 'cors',
headers: new Headers({ 'Content-Type': 'application/json' })
@@ -10,7 +8,7 @@ export const useFetch = () => {
return await data.json() as 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',
mode: 'cors',
headers: { 'Content-Type': 'application/json' },


+ 16
- 0
src/composables/useFilterNotifier.ts Просмотреть файл

@@ -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
- 3
src/composables/useModal.ts Просмотреть файл

@@ -1,8 +1,6 @@
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 modalContent = ref<ContentModal | null>(null);


+ 21
- 1
src/composables/useSession.ts Просмотреть файл

@@ -1,9 +1,13 @@
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>();

export const useSession = () => {
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 };
}

+ 3
- 1
src/main.ts Просмотреть файл

@@ -2,5 +2,7 @@ import { createApp } from 'vue'
import './index.css'
import App from './App.vue'
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')

+ 16
- 0
src/stores/filterStore.ts Просмотреть файл

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

+ 17
- 0
src/types/comment.ts Просмотреть файл

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

+ 5
- 0
src/types/dropdown.ts Просмотреть файл

@@ -0,0 +1,5 @@
export interface DropdownOption {
id: number;
text: string;
value: string;
}

+ 23
- 3
src/types/issue.ts Просмотреть файл

@@ -11,8 +11,15 @@ export interface Issue {
created_at: u64,
}

export interface ListIssuesResponse {
issues: Issue[];
}

export interface GetParagraphsResponse {
paragraphs: string[];
}

export interface AddIssueRequest {
session_id: string;
title: string;
paragraphs: string[];
}
@@ -23,6 +30,19 @@ export interface AddIssueResponse {

export interface VoteIssueRequest {
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';
}

+ 17
- 0
src/types/user.ts Просмотреть файл

@@ -5,4 +5,21 @@ export interface User {
first_name: string
last_name: 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;
}

+ 31
- 0
src/utils/eventBus.ts Просмотреть файл

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

+ 79
- 19
src/views/HomeView.vue Просмотреть файл

@@ -2,9 +2,14 @@
<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="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
@@ -17,22 +22,32 @@
</div>
</div>
<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 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>
<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" />
<ThumbsDown @click="recordThumbsDown" />
<ThumbsUp @click="recordThumbsUp" :selected="selected?.vote === 'Positive'" />
<ThumbsDown @click="recordThumbsDown" :selected="selected?.vote === 'Negative'" />
</div>
<SpeechBubbles />
</div>
@@ -53,10 +68,23 @@ 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 {
GetParagraphsResponse, GetVoteResponse,
Issue,
ListIssuesResponse,
VoteIssueRequest,
VoteIssueResponse
} 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 Thread from "../components/Thread.vue";

const { show: showModal } = useModal();
const { get, post } = useFetch();
@@ -67,40 +95,72 @@ register('content');

const issues = ref<Issue[]>([]);
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');

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

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(() => {
unregister('issues');
unregister('content');

eventBus.off('filtersApplied');
})

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};
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 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 () => {
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 = () => {
showModal(IssueModal as ContentModal);
}

const showFilterModal = () => {
showModal(FilterModal as ContentModal);
}
</script>

<style scoped>


+ 4
- 1
vite.config.ts Просмотреть файл

@@ -6,7 +6,10 @@ export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api/v1': 'http://localhost:8000'
'/api': {
target: 'http://127.0.0.1:8080',
rewrite: (url: string) => url.replace(/\/api/, ''),
}
}
}
})

Загрузка…
Отмена
Сохранить