| @@ -21,3 +21,6 @@ pnpm-debug.log* | |||||
| *.njsproj | *.njsproj | ||||
| *.sln | *.sln | ||||
| *.sw? | *.sw? | ||||
| vue.config.js | |||||
| @@ -1,4 +1,4 @@ | |||||
| # signet | |||||
| # beignet | |||||
| ## Project setup | ## Project setup | ||||
| ``` | ``` | ||||
| @@ -1,5 +1,5 @@ | |||||
| { | { | ||||
| "name": "signet", | |||||
| "name": "beignet", | |||||
| "version": "0.1.0", | "version": "0.1.0", | ||||
| "private": true, | "private": true, | ||||
| "scripts": { | "scripts": { | ||||
| @@ -9,13 +9,20 @@ | |||||
| "lint": "vue-cli-service lint" | "lint": "vue-cli-service lint" | ||||
| }, | }, | ||||
| "dependencies": { | "dependencies": { | ||||
| "@mdi/js": "^7.0.96", | |||||
| "@vueuse/core": "^9.5.0", | |||||
| "bulma": "^0.9.4", | |||||
| "core-js": "^3.8.3", | "core-js": "^3.8.3", | ||||
| "jenesius-vue-modal": "^1.8.2", | |||||
| "jwt-decode": "^3.1.2", | |||||
| "luxon": "^3.1.0", | |||||
| "vue": "^3.2.13", | "vue": "^3.2.13", | ||||
| "vue-router": "^4.0.3", | "vue-router": "^4.0.3", | ||||
| "vuex": "^4.0.0" | "vuex": "^4.0.0" | ||||
| }, | }, | ||||
| "devDependencies": { | "devDependencies": { | ||||
| "@types/chai": "^4.2.15", | "@types/chai": "^4.2.15", | ||||
| "@types/luxon": "^3.1.0", | |||||
| "@types/mocha": "^8.2.1", | "@types/mocha": "^8.2.1", | ||||
| "@typescript-eslint/eslint-plugin": "^5.4.0", | "@typescript-eslint/eslint-plugin": "^5.4.0", | ||||
| "@typescript-eslint/parser": "^5.4.0", | "@typescript-eslint/parser": "^5.4.0", | ||||
| @@ -1,17 +1,88 @@ | |||||
| <template> | <template> | ||||
| <nav> | |||||
| <router-link to="/">Home</router-link> | | |||||
| <router-link to="/about">About</router-link> | |||||
| <nav class="navbar has-background-grey-dark" role="navigation" aria-label="main navigation"> | |||||
| <div class="navbar-brand"> | |||||
| <RouterLink to="/" class="navbar-item"> | |||||
| <span class="title is-3-desktop is-4-mobile has-text-white-ter">Beignet</span> | |||||
| </RouterLink> | |||||
| <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false"> | |||||
| <span aria-hidden="true"></span> | |||||
| <span aria-hidden="true"></span> | |||||
| <span aria-hidden="true"></span> | |||||
| </a> | |||||
| </div> | |||||
| <div class="navbar-end"> | |||||
| <div class="navbar-item"> | |||||
| <div class="buttons is-hidden-mobile is-hidden-tablet-only"> | |||||
| <div class="authentication" v-if="!hasToken"> | |||||
| <RouterLink to="/login" class="button is-primary"> | |||||
| Log in | |||||
| </RouterLink> | |||||
| </div> | |||||
| <div v-else> | |||||
| <RouterLink to="/addfund" class="button is-primary"> | |||||
| Add Fund | |||||
| </RouterLink> | |||||
| <RouterLink to="/register" class="button is-white"> | |||||
| Register | |||||
| </RouterLink> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </nav> | </nav> | ||||
| <router-view/> | |||||
| <div id="content"> | |||||
| <RouterView v-slot="{ Component }"> | |||||
| <Suspense> | |||||
| <Component :is="Component" /> | |||||
| <template #fallback> | |||||
| <span style="font-size: 4em; color: saddlebrown">Loading</span> | |||||
| </template> | |||||
| </Suspense> | |||||
| </RouterView> | |||||
| </div> | |||||
| </template> | </template> | ||||
| <script setup lang="ts"> | |||||
| import { useSessionStorage } from '@vueuse/core'; | |||||
| import store from '@/store'; | |||||
| import { | |||||
| computed, | |||||
| ref, | |||||
| } from 'vue'; | |||||
| import jwtDecode from 'jwt-decode'; | |||||
| import { Claims } from '@/api/types'; | |||||
| const userData = ref({ username: '', privileges: -1, exp: -1 } as Claims); | |||||
| const state = useSessionStorage('jwt', { token: '' }); | |||||
| if (state.value.token) { | |||||
| store.commit('setToken', state.value.token); | |||||
| userData.value = jwtDecode<Claims>(state.value.token); | |||||
| } | |||||
| const hasToken = computed(() => !!store.getters.getToken); | |||||
| </script> | |||||
| <style lang="stylus"> | <style lang="stylus"> | ||||
| @import "../node_modules/bulma/css/bulma.min.css" | |||||
| #content | |||||
| min-height 80vh | |||||
| #app | #app | ||||
| font-family Avenir, Helvetica, Arial, sans-serif | font-family Avenir, Helvetica, Arial, sans-serif | ||||
| -webkit-font-smoothing antialiased | -webkit-font-smoothing antialiased | ||||
| -moz-osx-font-smoothing grayscale | -moz-osx-font-smoothing grayscale | ||||
| text-align center | |||||
| color #2c3e50 | |||||
| margin-top 60px | |||||
| html, body | |||||
| padding 0 | |||||
| margin 0 | |||||
| body | |||||
| min-height 100vh | |||||
| color #e8dbca | |||||
| background-color #313538 | |||||
| </style> | </style> | ||||
| @@ -0,0 +1,51 @@ | |||||
| const setHeaders = (headers: HeadersInit | undefined, token: string | undefined) => { | |||||
| if (!headers && !!token) { | |||||
| return { Authorization: `Bearer ${token}` }; | |||||
| } | |||||
| if (!!headers && !token) { | |||||
| return headers; | |||||
| } | |||||
| if (!!headers && !!token) { | |||||
| return { ...headers, Authorization: `Bearer ${token}` }; | |||||
| } | |||||
| return {}; | |||||
| }; | |||||
| class SignetRequestController { | |||||
| token?: string = undefined; | |||||
| constructor(token?: string) { | |||||
| this.token = token; | |||||
| } | |||||
| get = async <T>(endpoint: string): Promise<T | null> => { | |||||
| const resp = await fetch( | |||||
| `/api/${endpoint}`, | |||||
| { | |||||
| method: 'GET', | |||||
| headers: setHeaders(undefined, this.token), | |||||
| }, | |||||
| ); | |||||
| if (resp.status === 404) { | |||||
| return null; | |||||
| } | |||||
| return await resp.json() as Promise<T>; | |||||
| }; | |||||
| post = async <T1, T2>(endpoint: string, payload: T2): Promise<T1 | null> => { | |||||
| const resp = await fetch( | |||||
| `/api/${endpoint}`, | |||||
| { | |||||
| method: 'POST', | |||||
| body: JSON.stringify(payload), | |||||
| headers: setHeaders({ 'Content-Type': 'application/json' }, this.token), | |||||
| }, | |||||
| ); | |||||
| if (resp.status === 404) { | |||||
| return null; | |||||
| } | |||||
| return await resp.json() as Promise<T1>; | |||||
| }; | |||||
| } | |||||
| export default SignetRequestController; | |||||
| @@ -0,0 +1,127 @@ | |||||
| // eslint-disable-next-line no-shadow | |||||
| export enum Privileges { | |||||
| None = -1, | |||||
| SuperUser, | |||||
| AdminPlus, | |||||
| Admin | |||||
| } | |||||
| export interface Tag { | |||||
| CreatedAt: string; | |||||
| DeletedAt: string; | |||||
| ID: number; | |||||
| UpdatedAt: string; | |||||
| description: string; | |||||
| active: boolean; | |||||
| contribution: number; | |||||
| } | |||||
| export interface Contribution { | |||||
| CreatedAt: string; | |||||
| amount: number; | |||||
| rewardFundID: number; | |||||
| tags: Tag[]; | |||||
| transactionID: string; | |||||
| wallet: string; | |||||
| } | |||||
| interface Contributions { | |||||
| list: Contribution[] | |||||
| dates: string[] | |||||
| total: number | |||||
| } | |||||
| export interface RewardFund { | |||||
| id: number | |||||
| asset: string | |||||
| wallet: string | |||||
| memo: string | |||||
| amountGoal: number | |||||
| minContribution: number | |||||
| contributions: Contribution[] | null | |||||
| title: string | |||||
| description: string | |||||
| } | |||||
| export interface SuccessResponse { | |||||
| success: boolean | |||||
| } | |||||
| export interface GetRewardFundRequest { | |||||
| id: number | |||||
| consolidateContributions: boolean | |||||
| } | |||||
| export interface Bonus { | |||||
| goal?: number; | |||||
| percent?: number; | |||||
| } | |||||
| export interface FundInfo { | |||||
| id: number; | |||||
| asset: string; | |||||
| fundWallet: string; | |||||
| fundSecret: string; | |||||
| issuerWallet: string; | |||||
| memo: string; | |||||
| price: number; | |||||
| amountGoal: number; | |||||
| minContribution: number; | |||||
| title: string; | |||||
| description: string; | |||||
| bonuses: Bonus[]; | |||||
| } | |||||
| interface Total { | |||||
| amountHeld: number; | |||||
| } | |||||
| export interface GetRewardFundResponse { | |||||
| fundInfo: FundInfo | |||||
| contributions: Contributions | |||||
| total: Total | |||||
| } | |||||
| export interface ContributeRequest { | |||||
| privateKey: string | |||||
| amount: number | |||||
| rewardFund: number | |||||
| } | |||||
| export interface AuthenticationRequest { | |||||
| username: string; | |||||
| password: string; | |||||
| } | |||||
| export interface LoginResponse { | |||||
| token: string | null; | |||||
| } | |||||
| export interface GetRewardFundsRequest { | |||||
| offset: number; | |||||
| } | |||||
| export interface GetRewardFundsResponse { | |||||
| rewardFunds: FundInfo[] | |||||
| total: number | |||||
| } | |||||
| export interface Claims { | |||||
| username: string | |||||
| privileges: Privileges; | |||||
| exp: number; | |||||
| } | |||||
| export interface GetContributionsRequest { | |||||
| id: number | |||||
| offset: number | |||||
| forDate: string | undefined | |||||
| consolidateContributions: boolean | |||||
| } | |||||
| export type GetContributionsResponse = Contributions; | |||||
| export interface CloseRewardFundRequest { | |||||
| id: number; | |||||
| close: boolean; | |||||
| } | |||||
| @@ -0,0 +1,72 @@ | |||||
| <template> | |||||
| <div class="card py-2 px-4 has-text-dark" | |||||
| :style="generateBackgroundStyle(`${fund.asset} ${fund.title}`)"> | |||||
| <div class="is-size-2-desktop is-size-2-tablet is-size-3-mobile"> | |||||
| {{ fund.asset }} | |||||
| </div> | |||||
| <div> | |||||
| <ul class="is-flex is-flex-direction-row is-justify-content-space-between"> | |||||
| <li class="is-size-7 is-inline-block px-2"> | |||||
| <span class="stellar-icon-base coin"></span> | |||||
| <span class="fund-label">{{ props.fund.minContribution.toLocaleString() }}</span> | |||||
| </li> | |||||
| <li class="is-size-7 is-inline-block px-2"> | |||||
| <span class="stellar-icon-base goal"></span> | |||||
| <span class="fund-label">{{ props.fund.amountGoal.toLocaleString() }}</span> | |||||
| </li> | |||||
| <li class="is-size-7 is-inline-block px-2"> | |||||
| <span class="stellar-icon-base memo"></span> | |||||
| <span class="fund-label">{{ props.fund.memo }}</span> | |||||
| </li> | |||||
| <li class="is-size-7 is-inline-block px-2"> | |||||
| <span class="stellar-icon-base wallet"></span> | |||||
| <span class="fund-label"> | |||||
| {{ truncateWallet(props.fund.fundWallet, 5, undefined) }} | |||||
| </span> | |||||
| </li> | |||||
| </ul> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import { truncateWallet } from '@/lib/helpers'; | |||||
| import { | |||||
| FundInfo, | |||||
| } from '@/api/types'; | |||||
| import { PropType } from 'vue'; | |||||
| // eslint-disable-next-line no-undef | |||||
| const props = defineProps({ fund: Object as PropType<FundInfo> }); | |||||
| const generateHue = (seed: string) => seed.split('').map((c) => c.charCodeAt(0)).reduce((v1, v2) => v1 + v2) % 256; | |||||
| const generateBackgroundStyle = (hueSeed: string) => { | |||||
| const hue = generateHue(hueSeed); | |||||
| return `background-color: hsl(${hue}deg, 54%, 76%)`; | |||||
| }; | |||||
| </script> | |||||
| <style scoped lang="stylus"> | |||||
| .fund-label | |||||
| vertical-align top | |||||
| .stellar-icon-base | |||||
| display inline-block | |||||
| height 20px | |||||
| width 20px | |||||
| margin-right 4px | |||||
| background-position center | |||||
| background-size 16px | |||||
| background-repeat no-repeat | |||||
| &.coin | |||||
| background-image url("../assets/icons8-expensive-24.png") | |||||
| &.goal | |||||
| background-image url("../assets/icons8-goal-48.png") | |||||
| &.memo | |||||
| background-image url("../assets/icons8-note-24.png") | |||||
| &.wallet | |||||
| background-image url("../assets/icons8-wallet-24.png") | |||||
| </style> | |||||
| @@ -0,0 +1,72 @@ | |||||
| <template> | |||||
| <div> | |||||
| <table class="table is-fullwidth"> | |||||
| <thead> | |||||
| <tr> | |||||
| <th>Goal Amount</th> | |||||
| <th>Percent Bonus</th> | |||||
| </tr> | |||||
| </thead> | |||||
| <tbody> | |||||
| <tr v-for="kv in bonuses" v-bind:key="kv.id"> | |||||
| <td class="p-0"> | |||||
| <input | |||||
| type="text" | |||||
| class="input is-small" | |||||
| v-model="kv.goal" | |||||
| :aria-label="`Goal #${bonuses.length}`" | |||||
| @input="checkInputs" | |||||
| @blur="saveValues" | |||||
| > | |||||
| </td> | |||||
| <td class="p-0"> | |||||
| <input | |||||
| type="text" | |||||
| class="input is-small" | |||||
| :class="kv.percent < 1 ? 'is-danger' : ''" | |||||
| v-model="kv.percent" | |||||
| :aria-label="`Cashback percent #${bonuses.length}`" | |||||
| @input="checkInputs" | |||||
| @blur="saveValues" | |||||
| > | |||||
| </td> | |||||
| </tr> | |||||
| </tbody> | |||||
| </table> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import { ref } from 'vue'; | |||||
| interface Bonus { | |||||
| id: number; | |||||
| goal: string | undefined; | |||||
| percent: string | undefined; | |||||
| } | |||||
| // eslint-disable-next-line no-undef | |||||
| const emits = defineEmits(['save']); | |||||
| const bonuses = ref([{ id: 1, goal: undefined, percent: undefined }] as Bonus[]); | |||||
| const getNextId = () => bonuses.value.length + 1; | |||||
| const checkInputs = () => { | |||||
| if (bonuses.value.every((b) => Object.values(b).every((v) => !!v))) { | |||||
| bonuses.value.push({ | |||||
| id: getNextId(), goal: undefined, percent: undefined, | |||||
| }); | |||||
| } | |||||
| }; | |||||
| const saveValues = () => { | |||||
| emits('save', bonuses.value.filter((b) => !!b.goal && !!b.percent).map((b) => ( | |||||
| { goal: parseFloat(b.goal as string), percent: parseFloat(b.percent as string) } | |||||
| ))); | |||||
| }; | |||||
| </script> | |||||
| <style scoped lang="stylus"> | |||||
| </style> | |||||
| @@ -0,0 +1,33 @@ | |||||
| import store from '@/store'; | |||||
| import jwtDecode from 'jwt-decode'; | |||||
| import { Claims } from '@/api/types'; | |||||
| import * as luxon from 'luxon'; | |||||
| const removeToken = () => { | |||||
| sessionStorage.removeItem('jwt'); | |||||
| store.commit('clearToken'); | |||||
| }; | |||||
| const hasPermission = (requiredRights: number) => { | |||||
| const jwt = store.getters.getToken; | |||||
| if (jwt !== undefined && requiredRights !== undefined) { | |||||
| try { | |||||
| const decoded = jwtDecode<Claims>(store.getters.getToken); | |||||
| const expired = luxon.DateTime.now() | |||||
| .toUnixInteger() > decoded.exp; | |||||
| jwtDecode(jwt, { header: true }); | |||||
| if (!expired && decoded.privileges <= requiredRights) { | |||||
| return true; | |||||
| } | |||||
| if (expired) { | |||||
| removeToken(); | |||||
| } | |||||
| return false; | |||||
| } catch { | |||||
| removeToken(); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| return !(jwt === undefined && requiredRights !== undefined); | |||||
| }; | |||||
| export default hasPermission; | |||||
| @@ -0,0 +1,6 @@ | |||||
| import { useRouter } from 'vue-router'; | |||||
| const router = useRouter(); | |||||
| export const truncateWallet: (wallet: string, preDigits: number, postDigits: number | undefined) => string = (wallet: string, preDigits: number, postDigits = preDigits) => `${wallet.slice(0, preDigits)}...${wallet.slice(-(postDigits + 1), -1)}`; | |||||
| export const isNumber = (s: string) => /^[0-9]+$/.test(s); | |||||
| @@ -1,6 +1,10 @@ | |||||
| import { createApp } from 'vue'; | import { createApp } from 'vue'; | ||||
| import App from './App.vue'; | import App from './App.vue'; | ||||
| import router from './router'; | import router from './router'; | ||||
| import store from './store'; | import store from './store'; | ||||
| createApp(App).use(store).use(router).mount('#app'); | |||||
| createApp(App) | |||||
| .use(store) | |||||
| .use(router) | |||||
| .mount('#app'); | |||||
| @@ -1,19 +1,52 @@ | |||||
| import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'; | |||||
| import HomeView from '../views/HomeView.vue'; | |||||
| import { | |||||
| createRouter, | |||||
| createWebHistory, | |||||
| RouteRecordRaw, | |||||
| } from 'vue-router'; | |||||
| import RegisterView from '@/views/RegisterView.vue'; | |||||
| import LoginView from '@/views/LoginView.vue'; | |||||
| import { Privileges } from '@/api/types'; | |||||
| import HomeView from '@/views/HomeView.vue'; | |||||
| import FundView from '@/views/FundView.vue'; | |||||
| import AddFundView from '@/views/AddFundView.vue'; | |||||
| import hasPermission from '@/lib/auth'; | |||||
| const routes: Array<RouteRecordRaw> = [ | const routes: Array<RouteRecordRaw> = [ | ||||
| { | { | ||||
| path: '/', | path: '/', | ||||
| name: 'home', | name: 'home', | ||||
| component: HomeView, | component: HomeView, | ||||
| meta: { title: 'Home' }, | |||||
| }, | }, | ||||
| { | { | ||||
| path: '/about', | |||||
| name: 'about', | |||||
| // route level code-splitting | |||||
| // this generates a separate chunk (about.[hash].js) for this route | |||||
| // which is lazy-loaded when the route is visited. | |||||
| component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue'), | |||||
| path: '/fund/:id', | |||||
| name: 'fund', | |||||
| component: FundView, | |||||
| meta: { title: undefined }, | |||||
| }, | |||||
| { | |||||
| path: '/login', | |||||
| name: 'login', | |||||
| component: LoginView, | |||||
| meta: { title: 'Login' }, | |||||
| }, | |||||
| { | |||||
| path: '/register', | |||||
| name: 'register', | |||||
| component: RegisterView, | |||||
| meta: { | |||||
| requiredRights: Privileges.AdminPlus, | |||||
| title: 'Register', | |||||
| }, | |||||
| }, | |||||
| { | |||||
| path: '/addfund', | |||||
| name: 'addfund', | |||||
| component: AddFundView, | |||||
| meta: { | |||||
| requiredRights: Privileges.Admin, | |||||
| title: 'Add Group Fund', | |||||
| }, | |||||
| }, | }, | ||||
| ]; | ]; | ||||
| @@ -22,4 +55,14 @@ const router = createRouter({ | |||||
| routes, | routes, | ||||
| }); | }); | ||||
| router.beforeEach(async (to, from, next) => { | |||||
| document.title = `Beignet - ${to.meta.title}`; | |||||
| if (hasPermission(to.meta.requiredRights as number)) { | |||||
| next(); | |||||
| } else { | |||||
| next('/'); | |||||
| } | |||||
| }); | |||||
| export default router; | export default router; | ||||
| @@ -2,12 +2,26 @@ import { createStore } from 'vuex'; | |||||
| export default createStore({ | export default createStore({ | ||||
| state: { | state: { | ||||
| token: undefined as string | undefined, | |||||
| }, | }, | ||||
| getters: { | getters: { | ||||
| getToken: (state) => state.token, | |||||
| }, | }, | ||||
| mutations: { | mutations: { | ||||
| setToken: (state, token) => { | |||||
| state.token = token; | |||||
| }, | |||||
| clearToken: (state) => { | |||||
| state.token = undefined; | |||||
| }, | |||||
| }, | }, | ||||
| actions: { | actions: { | ||||
| setToken: (context) => { | |||||
| context.commit('setToken'); | |||||
| }, | |||||
| clearToken: (context) => { | |||||
| context.commit('clearToken'); | |||||
| }, | |||||
| }, | }, | ||||
| modules: { | modules: { | ||||
| }, | }, | ||||
| @@ -0,0 +1,125 @@ | |||||
| <template> | |||||
| <div class="container is-max-desktop"> | |||||
| <section class="section is-small"> | |||||
| <div class="title is-4 has-text-white-ter has-text-centered">Add Fund</div> | |||||
| <section class="section px-0 py-4"> | |||||
| <div class="title is-5 has-text-white-ter">Post</div> | |||||
| <div class="control my-2"> | |||||
| <input class="input is-normal has-background-white has-text-black" type="text" | |||||
| placeholder="Title" aria-label="Title" v-model="title"> | |||||
| </div> | |||||
| <div class="control my-2"> | |||||
| <textarea class="textarea is-normal has-background-white has-text-black" | |||||
| placeholder="Description" aria-label="Description" v-model="description"> | |||||
| </textarea> | |||||
| </div> | |||||
| </section> | |||||
| <section class="section px-0 py-4"> | |||||
| <div class="title is-5 has-text-white-ter">Wallet</div> | |||||
| <div class="control my-2"> | |||||
| <input class="input is-normal has-background-white has-text-black" type="text" | |||||
| placeholder="Fund Wallet" aria-label="Fund Wallet" v-model="fundWallet"> | |||||
| </div> | |||||
| <div class="control my-2"> | |||||
| <input class="input is-normal has-background-white has-text-black" type="text" | |||||
| placeholder="Fund Secret" aria-label="Fund Wallet" v-model="fundSecret"> | |||||
| </div> | |||||
| <div class="control my-2"> | |||||
| <input class="input is-normal has-background-white has-text-black" type="text" | |||||
| placeholder="Issuer Wallet" aria-label="Issuer Wallet" v-model="issuerWallet"> | |||||
| </div> | |||||
| </section> | |||||
| <section class="section px-0 py-4"> | |||||
| <div class="title is-5 has-text-white-ter">Fund</div> | |||||
| <div class="control my-2 is-flex is-justify-content-space-between"> | |||||
| <input class="input is-normal mr-1 has-background-white has-text-black" type="text" | |||||
| placeholder="Asset" aria-label="Asset" v-model="asset"> | |||||
| <input class="input is-normal ml-1 has-background-white has-text-black" type="text" | |||||
| placeholder="Memo" aria-label="Memo" v-model="memo"> | |||||
| </div> | |||||
| <div class="control my-2 is-flex is-justify-content-space-between"> | |||||
| <input class="input is-normal mr-1 has-background-white has-text-black" type="number" | |||||
| placeholder="Min Contribution" aria-label="Asset" v-model="minContribution"> | |||||
| <input class="input is-normal ml-1 has-background-white has-text-black" type="number" | |||||
| placeholder="Amount Goal" aria-label="Memo" v-model="amtGoal"> | |||||
| </div> | |||||
| </section> | |||||
| <section class="section px-0 py-4"> | |||||
| <div class="title is-5 has-text-white-ter">Bonus Structure</div> | |||||
| <FundTierInput @save="saveBonuses" /> | |||||
| </section> | |||||
| </section> | |||||
| <div class="buttons is-flex is-justify-content-end mt-5"> | |||||
| <button | |||||
| class="button is-success" | |||||
| :class="requesting ? 'is-loading' : ''" | |||||
| @click="submit" | |||||
| >Submit</button> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import SignetRequestController from '@/api/requests'; | |||||
| import { | |||||
| Bonus, | |||||
| FundInfo, | |||||
| SuccessResponse, | |||||
| } from '@/api/types'; | |||||
| import { ref } from 'vue'; | |||||
| import store from '@/store'; | |||||
| import { useRouter } from 'vue-router'; | |||||
| import FundTierInput from '@/components/FundTierInput.vue'; | |||||
| const router = useRouter(); | |||||
| document.title = 'Beignet - Add Fund'; | |||||
| const controller = new SignetRequestController(store.getters.getToken); | |||||
| const title = ref(''); | |||||
| const description = ref(''); | |||||
| const fundWallet = ref(''); | |||||
| const fundSecret = ref(''); | |||||
| const issuerWallet = ref(''); | |||||
| const asset = ref(''); | |||||
| const memo = ref(''); | |||||
| const minContribution = ref(undefined as number | undefined); | |||||
| const amtGoal = ref(undefined as number | undefined); | |||||
| const bonuses = ref([] as Bonus[]); | |||||
| const saveBonuses = (evt: Bonus[]) => { | |||||
| bonuses.value = evt; | |||||
| }; | |||||
| const requesting = ref(false); | |||||
| const submit = async () => { | |||||
| if (!requesting.value) { | |||||
| requesting.value = true; | |||||
| const resp = await controller.post<SuccessResponse, Partial<FundInfo>>('CreateRewardFund', { | |||||
| asset: asset.value, | |||||
| fundWallet: fundWallet.value, | |||||
| fundSecret: fundSecret.value, | |||||
| issuerWallet: issuerWallet.value, | |||||
| memo: memo.value, | |||||
| amountGoal: amtGoal.value as number, | |||||
| minContribution: minContribution.value as number, | |||||
| title: title.value, | |||||
| description: description.value, | |||||
| bonuses: bonuses.value, | |||||
| }); | |||||
| requesting.value = false; | |||||
| if (!resp) throw new Error('Could not get response for fund creation'); | |||||
| if (resp.success) { | |||||
| await router.push('/'); | |||||
| } | |||||
| } | |||||
| }; | |||||
| </script> | |||||
| <style scoped lang="stylus"> | |||||
| input::placeholder, textarea::placeholder | |||||
| color #7d7d7d | |||||
| </style> | |||||
| @@ -0,0 +1,36 @@ | |||||
| <template> | |||||
| <!-- Unused --> | |||||
| <div class="is-flex is-flex-direction-row"> | |||||
| <nav class="is-hidden-tablet-only is-hidden-mobile has-background-white-ter"> | |||||
| <div class="has-background-primary"> | |||||
| <span class="has-text-white has-text-weight-bold my-4 mx-2">Administration</span> | |||||
| </div> | |||||
| <ul class="p-2"> | |||||
| <li v-for="(link, i) in links" v-bind:key="i"> | |||||
| <RouterLink :to="link.to">{{ link.text }}</RouterLink> | |||||
| </li> | |||||
| </ul> | |||||
| </nav> | |||||
| <div class="container is-max-desktop"> | |||||
| <RouterView></RouterView> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| const links = [ | |||||
| { text: 'Add Fund', to: '/admin/addfund' }, | |||||
| { text: 'Modify Fund', to: '' }, | |||||
| { text: 'Create Tag', to: '' }, | |||||
| ]; | |||||
| </script> | |||||
| <style scoped lang="stylus"> | |||||
| nav | |||||
| min-width 134px | |||||
| min-height 100vh | |||||
| li | |||||
| font-variant all-petite-caps | |||||
| font-size 18px | |||||
| </style> | |||||
| @@ -0,0 +1,459 @@ | |||||
| <template> | |||||
| <div class="container is-max-desktop pb-4"> | |||||
| <section class="section is-small"> | |||||
| <div class="title is-size-4 has-text-white-ter has-text-centered"> | |||||
| {{ fund.fundInfo.title }} | |||||
| </div> | |||||
| <div | |||||
| class="is-block-mobile | |||||
| is-flex-tablet-only | |||||
| is-flex-desktop | |||||
| is-flex-direction-row | |||||
| is-justify-content-space-between"> | |||||
| <div class="fund-description pr-5"> | |||||
| {{ fund.fundInfo.description }} | |||||
| </div> | |||||
| <div | |||||
| class="fund-details is-flex is-flex-direction-row is-justify-content-end my-auto py-6"> | |||||
| <ul> | |||||
| <li v-for="(detail, i) in fundDetails" v-bind:key="i"> | |||||
| <span class="has-text-weight-bold is-size-6 mr-2">{{ detail.title }}</span> | |||||
| <span class="is-size-6">{{ detail.val }}</span> | |||||
| </li> | |||||
| </ul> | |||||
| </div> | |||||
| </div> | |||||
| </section> | |||||
| <section> | |||||
| <div class="box"> | |||||
| <div class="title is-size-4 has-text-grey-dark has-text-centered"> | |||||
| Tracker | |||||
| </div> | |||||
| <div class="has-text-centered is-size-4 has-text-grey-dark mb-3"> | |||||
| <span class="total-label is-size-3 pr-2 has-text-weight-light"> | |||||
| Total | |||||
| </span> | |||||
| <span class="pl-3 has-text-weight-bold"> | |||||
| {{ fund.total.amountHeld.toLocaleString() }} XLM | |||||
| </span> | |||||
| </div> | |||||
| <div class="level" v-if="fund.fundInfo.bonuses.length > 0"> | |||||
| <div class="level-item has-text-centered" | |||||
| v-for="bonus in fund.fundInfo.bonuses.sort((v1, v2) => v1.goal - v2.goal)" | |||||
| v-bind:key="bonus.goal" | |||||
| > | |||||
| <div> | |||||
| <p | |||||
| class="heading" | |||||
| :class="fund.total.amountHeld >= bonus.goal | |||||
| ? 'has-text-success' : 'has-text-grey-dark'" | |||||
| > | |||||
| {{ bonus.goal.toLocaleString() }} XLM | |||||
| </p> | |||||
| <p | |||||
| class="title" | |||||
| :class="fund.total.amountHeld >= bonus.goal | |||||
| ? 'has-text-success' : 'has-text-grey-dark'" | |||||
| > | |||||
| {{ bonus.percent }}% | |||||
| </p> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </section> | |||||
| <section class="section is-small"> | |||||
| <div class="title is-size-4 has-text-white-ter has-text-centered"> | |||||
| Contribute | |||||
| </div> | |||||
| <div class="control my-2"> | |||||
| <input class="input is-normal has-background-white has-text-black" type="text" | |||||
| placeholder="Private Key" aria-label="Wallet" v-model="pk"> | |||||
| </div> | |||||
| <div class="control my-2"> | |||||
| <input class="input is-normal has-background-white has-text-black" type="number" | |||||
| placeholder="Amount" aria-label="Amount" v-model="amt"> | |||||
| </div> | |||||
| <div class="is-flex is-justify-content-end"> | |||||
| <button | |||||
| class="button is-primary" | |||||
| :class="requesting ? 'is-loading' : ''" | |||||
| @click="makeContribution" | |||||
| >Submit</button> | |||||
| </div> | |||||
| </section> | |||||
| <section class="section is-small" v-if="contributions.length > 0"> | |||||
| <div class="title is-size-4 has-text-white-ter has-text-centered"> | |||||
| Contributions | |||||
| </div> | |||||
| <div class="is-flex is-justify-content-space-between is-rounded my-2"> | |||||
| <div class="select"> | |||||
| <select v-model="selectedDate" aria-label="Filter by date"> | |||||
| <option v-for="date in selectableDates" v-bind:key="date" :value="date"> | |||||
| {{ date ?? 'Cutoff Date' }} | |||||
| </option> | |||||
| </select> | |||||
| </div> | |||||
| <div class="consolidate-option has-background-white px-4 py-1 my-auto"> | |||||
| <label for="consolidate" class="checkbox has-text-dark is-size-6"> | |||||
| <span class="consolidate-label is-inline-block"> | |||||
| Consolidate wallets | |||||
| </span> | |||||
| <input | |||||
| type="checkbox" | |||||
| class="ml-2" | |||||
| id="consolidate" | |||||
| aria-label="Consolidate wallets" | |||||
| v-model="enableConsolidation" | |||||
| > | |||||
| </label> | |||||
| </div> | |||||
| </div> | |||||
| <div id="contribution-container" @scroll="loadMoreIfNeeded"> | |||||
| <table class="contribution-table table is-fullwidth"> | |||||
| <thead> | |||||
| <tr> | |||||
| <th>Wallet</th> | |||||
| <th>Amount</th> | |||||
| <th v-if="!enableConsolidation">Time</th> | |||||
| <th v-else>Tokens</th> | |||||
| </tr> | |||||
| </thead> | |||||
| <tbody> | |||||
| <tr v-for="(contribution, i) in contributions" v-bind:key="i"> | |||||
| <td>{{ truncateWallet(contribution.wallet, 6, undefined) }}</td> | |||||
| <td>{{ contribution.amount }}</td> | |||||
| <td v-if="!enableConsolidation"> | |||||
| <span class="transaction-date" :title="formatDate(contribution.CreatedAt, true)"> | |||||
| {{ formatDate(contribution.CreatedAt, true) }} | |||||
| </span> | |||||
| </td> | |||||
| <td v-else> | |||||
| <span>{{ calculateReward(contribution.amount / fund.fundInfo.price) }}</span> | |||||
| </td> | |||||
| </tr> | |||||
| </tbody> | |||||
| </table> | |||||
| </div> | |||||
| </section> | |||||
| <section v-if="store.getters.getToken && hasPermission(Privileges.AdminPlus)"> | |||||
| <div class="title is-size-4 has-text-white-ter has-text-centered"> | |||||
| Close Group Fund | |||||
| </div> | |||||
| <div class="box is-flex is-flex-direction-row is-justify-content-space-between"> | |||||
| <div class="my-auto"> | |||||
| <label class="checkbox" for="delete-confirm"> | |||||
| <input type="checkbox" id="delete-confirm" v-model="allowDelete"> Delete | |||||
| </label> | |||||
| </div> | |||||
| <div> | |||||
| <button | |||||
| class="button is-danger" | |||||
| :disabled="!allowDelete" | |||||
| @click="deleteFund" | |||||
| > | |||||
| Delete | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| </section> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import { | |||||
| useRoute, | |||||
| useRouter, | |||||
| } from 'vue-router'; | |||||
| import { | |||||
| Bonus, | |||||
| CloseRewardFundRequest, | |||||
| ContributeRequest, | |||||
| Contribution, | |||||
| FundInfo, | |||||
| GetContributionsRequest, | |||||
| GetContributionsResponse, | |||||
| GetRewardFundRequest, | |||||
| GetRewardFundResponse, | |||||
| Privileges, | |||||
| SuccessResponse, | |||||
| } from '@/api/types'; | |||||
| import { | |||||
| Ref, | |||||
| ref, | |||||
| watch, | |||||
| } from 'vue'; | |||||
| import { useWebSocket } from '@vueuse/core'; | |||||
| import SignetRequestController from '@/api/requests'; | |||||
| import store from '@/store'; | |||||
| import { truncateWallet } from '@/lib/helpers'; | |||||
| import * as luxon from 'luxon'; | |||||
| import hasPermission from '@/lib/auth'; | |||||
| const controller = new SignetRequestController(store.getters.getToken); | |||||
| const route = useRoute(); | |||||
| const router = useRouter(); | |||||
| const { id } = route.params; | |||||
| const identifier = parseInt(id as string, 10); | |||||
| const formatDate = (time: string, includeTime = false) => { | |||||
| const s = luxon.DateTime.fromISO(time).toUTC(); | |||||
| const date = s.toFormat('yyyy-LLL-dd'); | |||||
| return includeTime ? `${date} ${s.toFormat('HH:mm')} (UTC)` : date; | |||||
| }; | |||||
| const pk = ref(''); | |||||
| const amt = ref(undefined as number | undefined); | |||||
| const selectableDates = ref([undefined] as (string | undefined)[]); | |||||
| const selectedDate = ref(undefined as string | undefined); | |||||
| const allowDelete = ref(false); | |||||
| const deleteFund = async () => { | |||||
| if (allowDelete.value) { | |||||
| const deleted = await controller.post<SuccessResponse, CloseRewardFundRequest>( | |||||
| 'CloseRewardFund', | |||||
| { | |||||
| id: identifier, | |||||
| close: true, | |||||
| }, | |||||
| ); | |||||
| if (deleted && deleted.success) { | |||||
| await router.push('/'); | |||||
| } | |||||
| } | |||||
| }; | |||||
| const enableConsolidation = ref(false); | |||||
| const fund = ref( | |||||
| { | |||||
| fundInfo: {} as FundInfo, | |||||
| contributions: { list: [], dates: [] as string[], total: 0 }, | |||||
| total: { amountHeld: 0 }, | |||||
| } as GetRewardFundResponse | null, | |||||
| ); | |||||
| const fundDetails = ref([{ title: '', val: '' }]); | |||||
| fund.value = await controller.post<GetRewardFundResponse, GetRewardFundRequest>('GetRewardFund', { | |||||
| id: identifier, | |||||
| consolidateContributions: enableConsolidation.value, | |||||
| }); | |||||
| if (!fund.value) { | |||||
| router.push('/'); | |||||
| throw new Error('Fund not found'); | |||||
| } | |||||
| fundDetails.value = [ | |||||
| { | |||||
| title: 'Asset', | |||||
| val: fund.value.fundInfo.asset, | |||||
| }, | |||||
| { | |||||
| title: 'Min', | |||||
| val: `${fund.value.fundInfo.minContribution.toLocaleString()}`, | |||||
| }, | |||||
| { | |||||
| title: 'Goal', | |||||
| val: `${fund.value.fundInfo.amountGoal.toLocaleString()}`, | |||||
| }, | |||||
| { | |||||
| title: 'Memo', | |||||
| val: `"${fund.value.fundInfo.memo}"`, | |||||
| }, | |||||
| ]; | |||||
| if (fund.value.contributions.dates) { | |||||
| selectableDates.value = selectableDates.value.concat( | |||||
| fund.value.contributions.dates.map((d) => formatDate(d)), | |||||
| ); | |||||
| } | |||||
| const reward = ref(0); | |||||
| const maxBonus = ref(0); | |||||
| const bonus = ref(undefined as Bonus | undefined); | |||||
| const achievedBonuses = fund.value.fundInfo.bonuses.filter( | |||||
| (b) => { | |||||
| if (!fund.value) throw new Error('Fund not found'); | |||||
| return b.goal && fund.value.total.amountHeld >= b.goal; | |||||
| }, | |||||
| ); | |||||
| if (achievedBonuses.length > 0) { | |||||
| maxBonus.value = Math.max(...achievedBonuses.map((b) => b.goal ?? -1)); | |||||
| bonus.value = achievedBonuses.find((b) => b.goal === maxBonus.value); | |||||
| if (!Object.entries(bonus).length) throw new Error('Something went wrong'); | |||||
| } | |||||
| const calculateReward = (bought: number) => { | |||||
| if (achievedBonuses.length > 0) { | |||||
| if (!bonus.value || !bonus.value.percent) throw new Error('Something went wrong'); | |||||
| reward.value = bought + bought * (bonus.value.percent / 100); | |||||
| } else { | |||||
| reward.value = bought; | |||||
| } | |||||
| return reward.value.toLocaleString(); | |||||
| }; | |||||
| document.title = `Beignet - ${fund.value.fundInfo.title}`; | |||||
| const contributions: Ref<Contribution[]> = ref(fund.value.contributions.list ?? []); | |||||
| const offset = ref(contributions.value.length); | |||||
| const total = ref(fund.value.contributions.total); | |||||
| watch(selectedDate, async (newVal) => { | |||||
| offset.value = 0; | |||||
| const conts = await controller.post< | |||||
| GetContributionsResponse, GetContributionsRequest | |||||
| >( | |||||
| 'GetContributions', | |||||
| { | |||||
| id: identifier, | |||||
| offset: offset.value, | |||||
| forDate: newVal, | |||||
| consolidateContributions: enableConsolidation.value, | |||||
| }, | |||||
| ); | |||||
| if (!fund.value) throw new Error('Fund not found'); | |||||
| if (!conts) throw new Error('Contributions not found'); | |||||
| contributions.value = conts.list; | |||||
| offset.value = contributions.value.length; | |||||
| total.value = fund.value.contributions.total; | |||||
| }); | |||||
| watch(enableConsolidation, async () => { | |||||
| offset.value = 0; | |||||
| const conts = await controller.post< | |||||
| GetContributionsResponse, GetContributionsRequest | |||||
| >( | |||||
| 'GetContributions', | |||||
| { | |||||
| id: identifier, | |||||
| offset: offset.value, | |||||
| forDate: selectedDate.value, | |||||
| consolidateContributions: enableConsolidation.value, | |||||
| }, | |||||
| ); | |||||
| if (!fund.value) throw new Error('Fund not found'); | |||||
| if (!conts) throw new Error('Contributions not found'); | |||||
| contributions.value = conts.list; | |||||
| offset.value = contributions.value.length; | |||||
| total.value = fund.value.contributions.total; | |||||
| }); | |||||
| const contributionsLoading = ref(false); | |||||
| const loadMoreIfNeeded = async (e: Event) => { | |||||
| const target = e.target as Element | null; | |||||
| const canLoadMore = () => target | |||||
| && (target.scrollTop + target.clientHeight) / target.scrollHeight > 0.8 | |||||
| && offset.value < total.value && !contributionsLoading.value; | |||||
| if (canLoadMore()) { | |||||
| contributionsLoading.value = true; | |||||
| const moreContribs = await controller.post<GetContributionsResponse, GetContributionsRequest>( | |||||
| 'GetContributions', | |||||
| { | |||||
| id: identifier, | |||||
| offset: offset.value, | |||||
| forDate: selectedDate.value, | |||||
| consolidateContributions: enableConsolidation.value, | |||||
| }, | |||||
| ); | |||||
| if (!moreContribs) throw new Error('Contributions not found'); | |||||
| offset.value += moreContribs.list.length; | |||||
| total.value = moreContribs.total; | |||||
| contributions.value = contributions.value.concat(moreContribs.list); | |||||
| contributionsLoading.value = false; | |||||
| } | |||||
| }; | |||||
| const { | |||||
| status, | |||||
| data, | |||||
| } = useWebSocket( | |||||
| 'ws://127.0.0.1:7300/ContributorStream', // TODO: change url | |||||
| { | |||||
| immediate: true, | |||||
| autoReconnect: true, | |||||
| }, | |||||
| ); | |||||
| watch(data, (newVal) => { | |||||
| if (!fund.value) throw new Error('Fund not found'); | |||||
| if (status.value === 'OPEN') { | |||||
| const v = JSON.parse(newVal.trim()) as Contribution; | |||||
| v.CreatedAt = luxon.DateTime.now().toISO(); | |||||
| if (enableConsolidation.value && contributions.value | |||||
| && contributions.value.map((c: Contribution) => c.wallet).includes(v.wallet)) { | |||||
| const hasContribution = contributions.value.find((c: Contribution) => c.wallet === v.wallet); | |||||
| if (!hasContribution) throw new Error('Something went wrong'); | |||||
| hasContribution.amount += v.amount; | |||||
| } else { | |||||
| contributions.value.splice(0, 0, v); | |||||
| offset.value += 1; | |||||
| const formattedDate = formatDate(v.CreatedAt); | |||||
| if (!selectableDates.value.includes(formattedDate)) { | |||||
| selectableDates.value.push(formattedDate); | |||||
| } | |||||
| } | |||||
| fund.value.total.amountHeld += v.amount; | |||||
| } | |||||
| }); | |||||
| const requesting = ref(false); | |||||
| const makeContribution = async () => { | |||||
| if (!fund.value) throw new Error('Fund not found'); | |||||
| if (!requesting.value && pk.value && amt.value) { | |||||
| requesting.value = true; | |||||
| await controller.post<SuccessResponse, ContributeRequest>('Contribute', { | |||||
| privateKey: pk.value, | |||||
| amount: amt.value, | |||||
| rewardFund: fund.value.fundInfo.id, | |||||
| }); | |||||
| requesting.value = false; | |||||
| pk.value = ''; | |||||
| amt.value = undefined; | |||||
| } | |||||
| }; | |||||
| </script> | |||||
| <style scoped lang="stylus"> | |||||
| .transaction-date | |||||
| white-space nowrap | |||||
| overflow hidden | |||||
| max-width 20vw | |||||
| display block | |||||
| .consolidate-label | |||||
| max-width: 24vw; | |||||
| white-space: nowrap; | |||||
| overflow: hidden; | |||||
| vertical-align: bottom; | |||||
| text-overflow: ellipsis; | |||||
| .total-label | |||||
| border-right 1px solid #8f8f8f | |||||
| font-variant all-petite-caps | |||||
| .signet-asset-name | |||||
| cursor pointer | |||||
| #contribution-container | |||||
| max-height 360px | |||||
| overflow-y auto | |||||
| .consolidate-option | |||||
| border-radius 16px | |||||
| .fund-description | |||||
| min-height 280px | |||||
| .fund-details | |||||
| width 182px | |||||
| #consolidate | |||||
| vertical-align middle | |||||
| @media screen and (min-width: 1024px) | |||||
| .fund-details | |||||
| max-width 14vw | |||||
| border-left 1px #777 solid | |||||
| padding-left 10px | |||||
| </style> | |||||
| @@ -1,18 +1,46 @@ | |||||
| <template> | <template> | ||||
| <div class="home"> | |||||
| <img alt="Vue logo" src="../assets/logo.png"> | |||||
| <HelloWorld msg="Welcome to Your Vue.js + TypeScript App"/> | |||||
| <div class="container is-max-desktop"> | |||||
| <section class="section is-small px-0"> | |||||
| <div class="container-grid"> | |||||
| <template v-for="fund in rewardFunds" v-bind:key="fund.id"> | |||||
| <RouterLink :to="`/fund/${fund.id}`"> | |||||
| <FundLink :fund="fund"/> | |||||
| </RouterLink> | |||||
| </template> | |||||
| </div> | |||||
| </section> | |||||
| </div> | </div> | ||||
| </template> | </template> | ||||
| <script lang="ts"> | |||||
| import { defineComponent } from 'vue'; | |||||
| import HelloWorld from '@/components/HelloWorld.vue'; // @ is an alias to /src | |||||
| <script setup lang="ts"> | |||||
| import { | |||||
| GetRewardFundsRequest, | |||||
| GetRewardFundsResponse, | |||||
| } from '@/api/types'; | |||||
| import { ref } from 'vue'; | |||||
| import SignetRequestController from '@/api/requests'; | |||||
| import store from '@/store'; | |||||
| import FundLink from '@/components/FundLink.vue'; | |||||
| export default defineComponent({ | |||||
| name: 'HomeView', | |||||
| components: { | |||||
| HelloWorld, | |||||
| }, | |||||
| }); | |||||
| const controller = new SignetRequestController(store.getters.getToken); | |||||
| const offset = ref(0); | |||||
| const response = await controller.post<GetRewardFundsResponse, GetRewardFundsRequest>('GetRewardFunds', { offset: offset.value }); | |||||
| if (!response) throw new Error('Could not get reward funds'); | |||||
| const { total, rewardFunds } = response; | |||||
| offset.value = total; | |||||
| </script> | </script> | ||||
| <style scoped lang="stylus"> | |||||
| .container-grid | |||||
| display grid | |||||
| grid-template-columns: repeat(2, 1fr); | |||||
| gap: 10px; | |||||
| grid-auto-rows: 120px; | |||||
| @media screen and (max-width: 768px) | |||||
| .container-grid | |||||
| display flex | |||||
| flex-direction column | |||||
| </style> | |||||
| @@ -0,0 +1,50 @@ | |||||
| <template> | |||||
| <div class="container is-max-desktop"> | |||||
| <section class="section is-large"> | |||||
| <div class="title is-4 has-text-white-ter has-text-centered">Login</div> | |||||
| <div class="control my-2"> | |||||
| <input class="input is-medium" type="text" placeholder="Username" | |||||
| aria-label="Username" v-model="username" @keyup.enter="submit"> | |||||
| </div> | |||||
| <div class="control my-2"> | |||||
| <input class="input is-medium" type="password" placeholder="Password" | |||||
| aria-label="Password" v-model="password" @keyup.enter="submit"> | |||||
| </div> | |||||
| <div class="buttons is-flex is-justify-content-end mt-5"> | |||||
| <button class="button is-success" @click="submit">Submit</button> | |||||
| </div> | |||||
| </section> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import { ref } from 'vue'; | |||||
| import { useRouter } from 'vue-router'; | |||||
| import store from '@/store'; | |||||
| import { | |||||
| AuthenticationRequest, | |||||
| LoginResponse, | |||||
| } from '@/api/types'; | |||||
| import SignetRequestController from '@/api/requests'; | |||||
| const controller = new SignetRequestController(store.getters.getToken); | |||||
| const router = useRouter(); | |||||
| const username = ref(''); | |||||
| const password = ref(''); | |||||
| const submit = async () => { | |||||
| const resp = await controller.post<LoginResponse, AuthenticationRequest>('Login', { username: username.value, password: password.value }); | |||||
| if (!resp) throw new Error('Could not get response from login'); | |||||
| if (resp.token !== null) { | |||||
| sessionStorage.setItem('jwt', JSON.stringify({ token: resp.token })); | |||||
| store.commit('setToken', resp.token); | |||||
| await router.push('/'); | |||||
| } | |||||
| }; | |||||
| </script> | |||||
| <style scoped lang="stylus"> | |||||
| </style> | |||||
| @@ -0,0 +1,50 @@ | |||||
| <template> | |||||
| <div class="container is-max-desktop"> | |||||
| <section class="section is-large"> | |||||
| <div class="title is-4 has-text-white-ter has-text-centered">Register</div> | |||||
| <div class="control my-2"> | |||||
| <input class="input is-medium" type="text" placeholder="Username" | |||||
| aria-label="Username" v-model="username"> | |||||
| </div> | |||||
| <div class="control my-2"> | |||||
| <input class="input is-medium" type="password" placeholder="Password" | |||||
| aria-label="Password" v-model="password"> | |||||
| </div> | |||||
| <div class="buttons is-flex is-justify-content-end mt-5"> | |||||
| <button class="button is-success" @click="submit">Submit</button> | |||||
| </div> | |||||
| </section> | |||||
| </div> | |||||
| </template> | |||||
| <script setup lang="ts"> | |||||
| import { ref } from 'vue'; | |||||
| import { | |||||
| AuthenticationRequest, | |||||
| SuccessResponse, | |||||
| } from '@/api/types'; | |||||
| import SignetRequestController from '@/api/requests'; | |||||
| import store from '@/store'; | |||||
| import { useRouter } from 'vue-router'; | |||||
| const controller = new SignetRequestController(store.getters.getToken); | |||||
| const router = useRouter(); | |||||
| const username = ref(''); | |||||
| const password = ref(''); | |||||
| const success = ref(false); | |||||
| const submit = async () => { | |||||
| const resp = await controller.post<SuccessResponse, AuthenticationRequest>('Register', { username: username.value, password: password.value }); | |||||
| if (!resp) throw new Error('Could not get response from registration'); | |||||
| success.value = resp.success; | |||||
| if (success.value) { | |||||
| await router.push('/login'); | |||||
| } | |||||
| }; | |||||
| </script> | |||||
| <style scoped lang="stylus"> | |||||
| </style> | |||||