浏览代码

Initial commit

wip/alt-interface
Jared 2 年前
父节点
当前提交
7b155a75cd
共有 32 个文件被更改,包括 27376 次插入1071 次删除
  1. +3
    -0
      .gitignore
  2. +1
    -1
      README.md
  3. +24992
    -0
      package-lock.json
  4. +8
    -1
      package.json
  5. +78
    -7
      src/App.vue
  6. +51
    -0
      src/api/requests.ts
  7. +127
    -0
      src/api/types.ts
  8. 二进制
      src/assets/checkmark.png
  9. 二进制
      src/assets/icons8-close-48.png
  10. 二进制
      src/assets/icons8-coin-wallet-30.png
  11. 二进制
      src/assets/icons8-expensive-24.png
  12. 二进制
      src/assets/icons8-goal-48.png
  13. 二进制
      src/assets/icons8-home-48.png
  14. 二进制
      src/assets/icons8-menu-96.png
  15. 二进制
      src/assets/icons8-note-24.png
  16. 二进制
      src/assets/icons8-plus-math-48.png
  17. 二进制
      src/assets/icons8-stellar-cryptocurrency-64.png
  18. 二进制
      src/assets/icons8-wallet-24.png
  19. +72
    -0
      src/components/FundLink.vue
  20. +72
    -0
      src/components/FundTierInput.vue
  21. +33
    -0
      src/lib/auth.ts
  22. +6
    -0
      src/lib/helpers.ts
  23. +5
    -1
      src/main.ts
  24. +51
    -8
      src/router/index.ts
  25. +14
    -0
      src/store/index.ts
  26. +125
    -0
      src/views/AddFundView.vue
  27. +36
    -0
      src/views/AdminView.vue
  28. +459
    -0
      src/views/FundView.vue
  29. +40
    -12
      src/views/HomeView.vue
  30. +50
    -0
      src/views/LoginView.vue
  31. +50
    -0
      src/views/RegisterView.vue
  32. +1103
    -1041
      yarn.lock

+ 3
- 0
.gitignore 查看文件

@@ -21,3 +21,6 @@ pnpm-debug.log*
*.njsproj
*.sln
*.sw?


vue.config.js

+ 1
- 1
README.md 查看文件

@@ -1,4 +1,4 @@
# signet
# beignet

## Project setup
```


+ 24992
- 0
package-lock.json
文件差异内容过多而无法显示
查看文件


+ 8
- 1
package.json 查看文件

@@ -1,5 +1,5 @@
{
"name": "signet",
"name": "beignet",
"version": "0.1.0",
"private": true,
"scripts": {
@@ -9,13 +9,20 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"@mdi/js": "^7.0.96",
"@vueuse/core": "^9.5.0",
"bulma": "^0.9.4",
"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-router": "^4.0.3",
"vuex": "^4.0.0"
},
"devDependencies": {
"@types/chai": "^4.2.15",
"@types/luxon": "^3.1.0",
"@types/mocha": "^8.2.1",
"@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0",


+ 78
- 7
src/App.vue 查看文件

@@ -1,17 +1,88 @@
<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>
<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>

<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">
@import "../node_modules/bulma/css/bulma.min.css"

#content
min-height 80vh

#app
font-family Avenir, Helvetica, Arial, sans-serif
-webkit-font-smoothing antialiased
-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>

+ 51
- 0
src/api/requests.ts 查看文件

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

+ 127
- 0
src/api/types.ts 查看文件

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

二进制
src/assets/checkmark.png 查看文件

之前 之后
宽度: 128  |  高度: 128  |  大小: 2.5 KiB

二进制
src/assets/icons8-close-48.png 查看文件

之前 之后
宽度: 48  |  高度: 48  |  大小: 641 B

二进制
src/assets/icons8-coin-wallet-30.png 查看文件

之前 之后
宽度: 24  |  高度: 24  |  大小: 283 B

二进制
src/assets/icons8-expensive-24.png 查看文件

之前 之后
宽度: 24  |  高度: 24  |  大小: 863 B

二进制
src/assets/icons8-goal-48.png 查看文件

之前 之后
宽度: 48  |  高度: 48  |  大小: 1.2 KiB

二进制
src/assets/icons8-home-48.png 查看文件

之前 之后
宽度: 48  |  高度: 48  |  大小: 380 B

二进制
src/assets/icons8-menu-96.png 查看文件

之前 之后
宽度: 96  |  高度: 96  |  大小: 203 B

二进制
src/assets/icons8-note-24.png 查看文件

之前 之后
宽度: 24  |  高度: 24  |  大小: 246 B

二进制
src/assets/icons8-plus-math-48.png 查看文件

之前 之后
宽度: 48  |  高度: 48  |  大小: 166 B

二进制
src/assets/icons8-stellar-cryptocurrency-64.png 查看文件

之前 之后
宽度: 64  |  高度: 64  |  大小: 2.0 KiB

二进制
src/assets/icons8-wallet-24.png 查看文件

之前 之后
宽度: 24  |  高度: 24  |  大小: 275 B

+ 72
- 0
src/components/FundLink.vue 查看文件

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

+ 72
- 0
src/components/FundTierInput.vue 查看文件

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

+ 33
- 0
src/lib/auth.ts 查看文件

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

+ 6
- 0
src/lib/helpers.ts 查看文件

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

+ 5
- 1
src/main.ts 查看文件

@@ -1,6 +1,10 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';

import store from './store';

createApp(App).use(store).use(router).mount('#app');
createApp(App)
.use(store)
.use(router)
.mount('#app');

+ 51
- 8
src/router/index.ts 查看文件

@@ -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> = [
{
path: '/',
name: 'home',
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,
});

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;

+ 14
- 0
src/store/index.ts 查看文件

@@ -2,12 +2,26 @@ import { createStore } from 'vuex';

export default createStore({
state: {
token: undefined as string | undefined,
},
getters: {
getToken: (state) => state.token,
},
mutations: {
setToken: (state, token) => {
state.token = token;
},
clearToken: (state) => {
state.token = undefined;
},
},
actions: {
setToken: (context) => {
context.commit('setToken');
},
clearToken: (context) => {
context.commit('clearToken');
},
},
modules: {
},


+ 125
- 0
src/views/AddFundView.vue 查看文件

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

+ 36
- 0
src/views/AdminView.vue 查看文件

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

+ 459
- 0
src/views/FundView.vue 查看文件

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

+ 40
- 12
src/views/HomeView.vue 查看文件

@@ -1,18 +1,46 @@
<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>
</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>

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

+ 50
- 0
src/views/LoginView.vue 查看文件

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

+ 50
- 0
src/views/RegisterView.vue 查看文件

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

+ 1103
- 1041
yarn.lock
文件差异内容过多而无法显示
查看文件


正在加载...
取消
保存