mirror of
https://forgejo.merr.is/annika/isl-vue3.git
synced 2025-12-13 05:42:15 -05:00
Compare commits
17 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b822e4d321 | |||
| a8e7e18bf9 | |||
| e6b3f4142f | |||
| 6eb298c0b9 | |||
| 42e9b2f628 | |||
| a4c98b1818 | |||
| 7171757234 | |||
| 9a064353ee | |||
| 1ac38f584e | |||
| 6123437a07 | |||
| d914b7cbd6 | |||
| 5c0d3c2fd7 | |||
| aafd3c19de | |||
| a1aa171bb1 | |||
| baf8e0f954 | |||
| 823455cf78 | |||
| 50d56297a7 |
33 changed files with 1074 additions and 294 deletions
|
|
@ -58,20 +58,20 @@ jobs:
|
|||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
- name: Extract Binary For Release
|
||||
uses: https://github.com/moosetheory/actions-docker-extract@v3.1
|
||||
id: extract
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
shell_command: /bin/ash -c
|
||||
image: ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
|
||||
path: /dist
|
||||
destination: dist
|
||||
- name: Create a Release
|
||||
uses: https://forgejo.merr.is/actions/forgejo-release@alpinev1
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
direction: upload
|
||||
release-dir: dist
|
||||
token: ${{ secrets.ADMIN_TOKEN }}
|
||||
# - name: Extract Binary For Release
|
||||
# uses: https://github.com/moosetheory/actions-docker-extract@v3.1
|
||||
# id: extract
|
||||
# if: startsWith(github.ref, 'refs/tags/')
|
||||
# with:
|
||||
# shell_command: /bin/ash -c
|
||||
# image: ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
|
||||
# path: /usr/share/nginx/html
|
||||
# destination: dist
|
||||
# - name: Create a Release
|
||||
# uses: https://forgejo.merr.is/actions/forgejo-release@alpinev1
|
||||
# if: startsWith(github.ref, 'refs/tags/')
|
||||
# with:
|
||||
# direction: upload
|
||||
# release-dir: dist
|
||||
# token: ${{ secrets.ADMIN_TOKEN }}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ COPY . .
|
|||
|
||||
# [optional] tests & build
|
||||
ENV NODE_ENV=production
|
||||
# I can't run the type checking because of some sort of issue with
|
||||
# bun and vue-tsc.
|
||||
# see https://github.com/oven-sh/bun/issues/4754
|
||||
# RUN bunx --bun vue-tsc --build --force
|
||||
RUN bunx --bun vite build
|
||||
|
||||
# Copy the distribution folder into the final image.
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
# This is making minimal changes from the guide at
|
||||
# https://bun.sh/guides/ecosystem/docker (as of 2024-02-04)
|
||||
|
||||
# use the official Bun image
|
||||
# see all versions at https://hub.docker.com/r/oven/bun/tags
|
||||
FROM oven/bun:1-alpine as base
|
||||
WORKDIR /app
|
||||
|
||||
# install dependencies into temp directory
|
||||
# this will cache them and speed up future builds
|
||||
FROM base as install
|
||||
RUN mkdir -p /tmp/dev
|
||||
COPY package.json bun.lockb /temp/dev/
|
||||
RUN cd /temp/dev && bun install --frozen-lockfile
|
||||
|
||||
# install with --production (exclude devDependencies)
|
||||
RUN mkdir -p /temp/prod
|
||||
COPY package.json bun.lockb /temp/prod/
|
||||
RUN cd /temp/prod && bun install --frozen-lockfile --production
|
||||
# ^ Is this necessary? Don't we either need prod or not prod?
|
||||
|
||||
# copy node_modules from temp directory
|
||||
# then copy all (non-ignored) project files into the image
|
||||
FROM base AS prerelease
|
||||
COPY --from=install /temp/dev/node_modules node_modules
|
||||
COPY . .
|
||||
|
||||
FROM nginx:stable-alpine as release
|
||||
|
||||
COPY --from=build-stage /app/docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
|
@ -15,9 +15,11 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.5",
|
||||
"axios-retry": "^4.0.0",
|
||||
"oidc-client-ts": "^3.0.1",
|
||||
"pinia": "^2.1.7",
|
||||
"sass": "^1.69.7",
|
||||
"vue": "^3.3.11",
|
||||
"vue-oidc-client": "^1.0.0-alpha.5",
|
||||
"vue-router": "^4.2.5",
|
||||
"vuetify": "^3.4.10"
|
||||
},
|
||||
|
|
@ -41,6 +43,8 @@
|
|||
"typescript": "~5.3.0",
|
||||
"vite": "^5.0.10",
|
||||
"vitest": "^1.0.4",
|
||||
"vue-tsc": "^1.8.25"
|
||||
"vue-tsc": "^1.8.25",
|
||||
"sass": "^1.69.7",
|
||||
"vite-plugin-vuetify": "^2.0.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
public/config.json
Normal file
6
public/config.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"apiBaseURL": "http://coder.local.merr.is:3001",
|
||||
"oidcAuthority": "https://auth.joes.moosenet.work",
|
||||
"oidcClientID": "255988227184328707@isekai:_slow_life_calculator",
|
||||
"oidcProjectID": "255987963094106115"
|
||||
}
|
||||
14
src/App.vue
14
src/App.vue
|
|
@ -1,25 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import GlobalHeader from '@/components/GlobalHeader.vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { usePowerItems } from './stores/powerItems'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { blessingPowerItems, fellowPowerItems, intimacyPowerItems } = storeToRefs(usePowerItems())
|
||||
|
||||
const isLoaded = computed(
|
||||
() =>
|
||||
!blessingPowerItems.value.keys().next().done &&
|
||||
!fellowPowerItems.value.keys().next().done &&
|
||||
!intimacyPowerItems.value.keys().next().done
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app>
|
||||
<GlobalHeader />
|
||||
<v-main class="d-flex">
|
||||
<RouterView v-if="isLoaded" />
|
||||
<RouterView />
|
||||
</v-main>
|
||||
<v-footer app>
|
||||
<div>Copyright Annika Merris 2024</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { oidc } from '@/types/ConfigSymbols';
|
||||
import { inject } from 'vue';
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const zitadel = ref(inject(oidc))
|
||||
const route = ref(useRoute())
|
||||
zitadel.value?.isAuthenticated
|
||||
const drawer = ref(false)
|
||||
|
||||
const login = () => {
|
||||
console.log(route.value.path)
|
||||
zitadel.value?.signIn({redirect_uri: "http://coder.local.merr.is:5173/login"})
|
||||
}
|
||||
const logout = () => {
|
||||
zitadel.value?.signOut()
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -25,5 +40,11 @@ const drawer = ref(false)
|
|||
<v-list-item-title>Intimacy Power</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<template v-slot:append>
|
||||
<div class="pa-2">
|
||||
<v-btn block @click="logout" v-if="zitadel?.isAuthenticated">Logout</v-btn>
|
||||
<v-btn block @click="login" v-else>Login</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
|
|
|||
213
src/components/NewItemForm.vue
Normal file
213
src/components/NewItemForm.vue
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { PowerItem } from '@/types/PowerItem'
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import {User} from "oidc-client";
|
||||
import axios from 'axios';
|
||||
|
||||
type EventItemListing = {
|
||||
name: string
|
||||
id: number
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const itemType = ref(0)
|
||||
const itemName = ref('')
|
||||
const iconURL = ref('')
|
||||
const minPower = ref(0)
|
||||
const maxPower = ref(0)
|
||||
const rarity = ref(0)
|
||||
const origin = ref('')
|
||||
const tooltip = ref('')
|
||||
const isEventItem = ref(false)
|
||||
|
||||
const eventItems = [
|
||||
{
|
||||
name: 'Blessing Power',
|
||||
id: 1
|
||||
},
|
||||
{
|
||||
name: 'Intimacy Power',
|
||||
id: 2
|
||||
},
|
||||
{
|
||||
name: 'Fellow Power',
|
||||
id: 3
|
||||
}
|
||||
]
|
||||
const rarities = [
|
||||
{
|
||||
name: 'Green',
|
||||
id: 1
|
||||
},
|
||||
{
|
||||
name: 'Blue',
|
||||
id: 2
|
||||
},
|
||||
{
|
||||
name: 'Purple',
|
||||
id: 3
|
||||
},
|
||||
{
|
||||
name: 'Yellow',
|
||||
id: 4
|
||||
},
|
||||
{
|
||||
name: 'Red',
|
||||
id: 5
|
||||
}
|
||||
]
|
||||
const yesNo = [
|
||||
{
|
||||
name: 'Yes',
|
||||
id: 1
|
||||
},
|
||||
{
|
||||
name: 'No',
|
||||
id: 0
|
||||
}
|
||||
]
|
||||
|
||||
// Yes, I do want any, I don't know WHAT they have typed, but this rule is only checking that they typed SOMETHING.
|
||||
const requiredField = (v: any) => (v ? true : 'This field is required.')
|
||||
const textRules = [requiredField]
|
||||
const minPowerRules = [
|
||||
requiredField,
|
||||
(v: number) =>
|
||||
v <= maxPower.value ? true : 'Minimum power must be less than or equal to maximum power.'
|
||||
]
|
||||
const maxPowerRules = [
|
||||
requiredField,
|
||||
(v: number) =>
|
||||
v >= minPower.value ? true : 'Maximum power must be greater than or equal to minimum power.'
|
||||
]
|
||||
const listToProps = (item: EventItemListing) => ({ title: item.name, value: item.id })
|
||||
const yesNoProps = (item: EventItemListing) => ({ title: item.name, value: item.id === 1 })
|
||||
const submit = () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// Get the user's JWT token
|
||||
getCurrentInstance()!
|
||||
.appContext.config.globalProperties.$zitadel!.oidcAuth.mgr.getUser()
|
||||
.then((oidcUser: User | null) => {
|
||||
if (oidcUser === null) {
|
||||
// TODO: Add some error handling to the page
|
||||
console.log("OIDC user was null")
|
||||
return
|
||||
}
|
||||
const newItem: PowerItem = {
|
||||
itemType: itemType.value,
|
||||
iconURL: iconURL.value,
|
||||
itemName: itemName.value,
|
||||
minItemPower: minPower.value,
|
||||
maxItemPower: maxPower.value,
|
||||
owned: 0,
|
||||
rarity: rarity.value,
|
||||
origin: origin.value,
|
||||
tooltip: tooltip.value,
|
||||
isEventItem: isEventItem.value
|
||||
}
|
||||
const userJWT = oidcUser.access_token
|
||||
axios.post()
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const clearForm = () => {
|
||||
itemType.value = 0
|
||||
itemName.value = ''
|
||||
iconURL.value = ''
|
||||
minPower.value = 0
|
||||
maxPower.value = 0
|
||||
rarity.value = 0
|
||||
origin.value = ''
|
||||
tooltip.value = ''
|
||||
isEventItem.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container>
|
||||
<v-row justify="center">
|
||||
<v-col lg="8" sm="12">
|
||||
<v-card :loading="loading" color="deep-purple-darken-4">
|
||||
<v-card-title>Add New Item</v-card-title>
|
||||
<v-card-item>
|
||||
<v-form @submit.prevent>
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-select
|
||||
:items="eventItems"
|
||||
:item-props="listToProps"
|
||||
v-model="itemType"
|
||||
label="Item Type"
|
||||
></v-select>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-text-field
|
||||
v-model="itemName"
|
||||
:rules="textRules"
|
||||
label="Item Name"
|
||||
></v-text-field>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-text-field
|
||||
v-model="iconURL"
|
||||
:rules="textRules"
|
||||
label="Image URL"
|
||||
disabled
|
||||
></v-text-field>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col class="pl-0">
|
||||
<v-text-field
|
||||
v-model="minPower"
|
||||
:rules="minPowerRules"
|
||||
label="Minimum Power"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col class="pr-0">
|
||||
<v-text-field
|
||||
v-model="maxPower"
|
||||
:rules="maxPowerRules"
|
||||
label="Maximum Power"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-select
|
||||
v-model="rarity"
|
||||
:items="rarities"
|
||||
:item-props="listToProps"
|
||||
label="Rarity"
|
||||
></v-select>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-text-field v-model="origin" :rules="textRules" label="Origin"></v-text-field>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-text-field v-model="tooltip" :rules="textRules" label="Tooltip"></v-text-field>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-select
|
||||
v-model="isEventItem"
|
||||
:items="yesNo"
|
||||
:item-props="yesNoProps"
|
||||
label="Is From An Event?"
|
||||
></v-select>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-form>
|
||||
</v-card-item>
|
||||
<v-card-actions>
|
||||
<v-btn @click="submit">Submit</v-btn>
|
||||
<v-btn @click="clearForm">Clear</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
|
@ -1,72 +1,82 @@
|
|||
<script setup lang="ts">
|
||||
import { usePowerItems } from '@/stores/powerItems'
|
||||
import type { PowerItem } from '@/types/PowerItem';
|
||||
import type { DataTableHeader } from '@/types/DataTableHeader'
|
||||
import type { PowerItem } from '@/types/PowerItem'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { VDataTable } from 'vuetify/components'
|
||||
|
||||
type SortItem = { key: string, order?: boolean | 'asc' | 'desc' }
|
||||
type ReadonlyHeaders = VDataTable['headers']
|
||||
type SortItem = { key: string; order?: boolean | 'asc' | 'desc' }
|
||||
const { lgAndUp } = useDisplay()
|
||||
|
||||
export interface Props {
|
||||
items: Map<string, PowerItem>
|
||||
minimumTotal: number
|
||||
maximumTotal: number
|
||||
averageTotal: number
|
||||
items: Map<string, PowerItem>
|
||||
minimumTotal: number
|
||||
maximumTotal: number
|
||||
averageTotal: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
items: () => new Map<string, PowerItem>
|
||||
items: () => new Map<string, PowerItem>()
|
||||
})
|
||||
|
||||
const headers = ref([
|
||||
{
|
||||
title: 'Event',
|
||||
align: ' d-none d-lg-table-cell start',
|
||||
sortable: true,
|
||||
value: '1.origin'
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
align: 'start',
|
||||
sortable: true,
|
||||
value: '1.itemName'
|
||||
},
|
||||
{
|
||||
title: 'Min.',
|
||||
align: 'end d-none d-lg-table-cell',
|
||||
sortable: true,
|
||||
key: '1.minItemPower'
|
||||
},
|
||||
{
|
||||
title: 'Max.',
|
||||
align: 'end d-none d-lg-table-cell',
|
||||
sortable: true,
|
||||
key: '1.maxItemPower'
|
||||
},
|
||||
{
|
||||
title: 'Owned',
|
||||
align: 'end',
|
||||
sortable: true,
|
||||
key: `1.owned`
|
||||
},
|
||||
{
|
||||
title: 'Min. Total',
|
||||
align: 'end d-none d-lg-table-cell',
|
||||
sortable: false,
|
||||
key: `1.minTotalPower`
|
||||
},
|
||||
{
|
||||
title: 'Max. Total',
|
||||
align: 'end d-none d-lg-table-cell',
|
||||
sortable: false,
|
||||
key: `1.maxTotalPower`
|
||||
},
|
||||
{
|
||||
title: 'Mean Total',
|
||||
align: 'end',
|
||||
sortable: false,
|
||||
key: `1.aveTotalPower`
|
||||
const computedHeaders = computed((): ReadonlyHeaders => {
|
||||
let initialHeaders: DataTableHeader[] = [
|
||||
{
|
||||
title: 'Name',
|
||||
align: 'start',
|
||||
sortable: true,
|
||||
value: '1.itemName'
|
||||
},
|
||||
{
|
||||
title: 'Owned',
|
||||
align: 'end',
|
||||
sortable: true,
|
||||
key: `1.owned`
|
||||
},
|
||||
{
|
||||
title: 'Mean Total',
|
||||
align: 'end',
|
||||
sortable: false,
|
||||
key: `1.aveTotalPower`
|
||||
}
|
||||
]
|
||||
if (lgAndUp.value) {
|
||||
initialHeaders!.unshift({
|
||||
title: 'Event',
|
||||
align: 'start',
|
||||
sortable: true,
|
||||
value: '1.origin'
|
||||
})
|
||||
initialHeaders.splice(2, 0, {
|
||||
title: 'Min.',
|
||||
align: 'end',
|
||||
sortable: true,
|
||||
key: '1.minItemPower'
|
||||
})
|
||||
initialHeaders.splice(3, 0, {
|
||||
title: 'Max.',
|
||||
align: 'end',
|
||||
sortable: true,
|
||||
key: '1.maxItemPower'
|
||||
})
|
||||
initialHeaders.splice(5, 0, {
|
||||
title: 'Min. Total',
|
||||
align: 'end',
|
||||
sortable: false,
|
||||
key: `1.minTotalPower`
|
||||
})
|
||||
initialHeaders.splice(6, 0, {
|
||||
title: 'Max. Total',
|
||||
align: 'end',
|
||||
sortable: false,
|
||||
key: `1.maxTotalPower`
|
||||
})
|
||||
}
|
||||
])
|
||||
return initialHeaders as ReadonlyHeaders
|
||||
})
|
||||
const sortBy: Ref<SortItem[]> = ref([
|
||||
{
|
||||
key: '1.minItemPower',
|
||||
|
|
@ -101,7 +111,7 @@ const filteredItems = computed(() =>
|
|||
? value
|
||||
: undefined
|
||||
)
|
||||
.filter((value) => value !== undefined)
|
||||
.filter((value): value is [string, PowerItem] => !!value)
|
||||
)
|
||||
const getColor = computed(() => (rarity: number): string => {
|
||||
if (rarity === 5) {
|
||||
|
|
@ -118,11 +128,19 @@ const getColor = computed(() => (rarity: number): string => {
|
|||
})
|
||||
const allSelected = computed(() => events.value.length === selectedEvents.value.length)
|
||||
const partialSelected = computed(() => selectedEvents.value.length > 0)
|
||||
toggle()
|
||||
const isLoading = computed((): boolean => props.items.keys()?.next().done ?? false)
|
||||
watch(isLoading, (newLoading) => {
|
||||
if (!newLoading) {
|
||||
toggle()
|
||||
}
|
||||
})
|
||||
if (!isLoading.value) {
|
||||
toggle()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card fluid>
|
||||
<v-card fluid :loading="isLoading">
|
||||
<v-card-title>Special Items</v-card-title>
|
||||
<v-card-subtitle>Items from events</v-card-subtitle>
|
||||
<v-card-item>
|
||||
|
|
@ -151,9 +169,9 @@ toggle()
|
|||
density="compact"
|
||||
v-model:sort-by="sortBy"
|
||||
:items="filteredItems"
|
||||
:headers="headers"
|
||||
:headers="computedHeaders"
|
||||
>
|
||||
<template v-slot:item.1.origin="{ item }">
|
||||
<template v-slot:[`item.1.origin`]="{ item }">
|
||||
<v-card
|
||||
class="my-2"
|
||||
elevation="2"
|
||||
|
|
@ -163,10 +181,10 @@ toggle()
|
|||
color="transparent"
|
||||
>
|
||||
<v-img :src="getEventImageUrl(item[1].origin)"></v-img>
|
||||
<v-tooltip activator="parent">{{ item?.[1].origin }}</v-tooltip>
|
||||
</v-card>
|
||||
<v-tooltip activator="parent">{{ item[1].origin }}</v-tooltip> </v-card
|
||||
>
|
||||
</template>
|
||||
<template v-slot:item.1.itemName="{ item }">
|
||||
<template v-slot:[`item.1.itemName`]="{ item }">
|
||||
<v-tooltip
|
||||
activator="parent"
|
||||
v-if="item[1].tooltip != undefined"
|
||||
|
|
@ -183,7 +201,7 @@ toggle()
|
|||
{{ item[1].itemName }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template v-slot:item.1.owned="{ item }">
|
||||
<template v-slot:[`item.1.owned`]="{ item }">
|
||||
<v-text-field
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
|
|
@ -191,13 +209,13 @@ toggle()
|
|||
@update:model-value="usePowerItems().updateOwned(item[0], item[1].owned)"
|
||||
></v-text-field>
|
||||
</template>
|
||||
<template v-slot:item.1.minTotalPower="{ item }">
|
||||
<template v-slot:[`item.1.minTotalPower`]="{ item }">
|
||||
{{ item[1].minItemPower * item[1].owned }}
|
||||
</template>
|
||||
<template v-slot:item.1.maxTotalPower="{ item }">
|
||||
<template v-slot:[`item.1.maxTotalPower`]="{ item }">
|
||||
{{ item[1].maxItemPower * item[1].owned }}
|
||||
</template>
|
||||
<template v-slot:item.1.aveTotalPower="{ item }">
|
||||
<template v-slot:[`item.1.aveTotalPower`]="{ item }">
|
||||
{{ ((item[1].minItemPower + item[1].maxItemPower) / 2) * item[1].owned }}
|
||||
</template>
|
||||
<template v-slot:tfoot>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { usePowerItems } from '@/stores/powerItems'
|
||||
import type { PowerItem } from '@/types/PowerItem'
|
||||
import type { Ref } from 'vue';
|
||||
import { computed, ref } from 'vue'
|
||||
import { useDisplay } from 'vuetify';
|
||||
import type { VDataTable } from 'vuetify/components';
|
||||
|
||||
type ReadonlyHeaders = VDataTable['headers']
|
||||
type SortItem = { key: string; order?: boolean | 'asc' | 'desc' }
|
||||
const { lgAndUp } = useDisplay()
|
||||
|
||||
export interface Props {
|
||||
items: Map<string, PowerItem>
|
||||
|
|
@ -12,39 +19,46 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
items: () => new Map<string, PowerItem>()
|
||||
})
|
||||
|
||||
const headers = ref([
|
||||
{
|
||||
title: '',
|
||||
align: 'start',
|
||||
sortable: false,
|
||||
value: '1.iconURL'
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
align: ' d-none d-lg-table-cell start',
|
||||
sortable: true,
|
||||
value: '1.itemName'
|
||||
},
|
||||
{
|
||||
title: 'Power',
|
||||
align: ' d-none d-lg-table-cell',
|
||||
sortable: true,
|
||||
key: '1.minItemPower'
|
||||
},
|
||||
{
|
||||
title: 'Owned',
|
||||
align: 'end',
|
||||
sortable: true,
|
||||
key: `1.owned`
|
||||
},
|
||||
{
|
||||
title: 'Total',
|
||||
align: 'end',
|
||||
sortable: false,
|
||||
key: `1.totalPower`
|
||||
const headers = computed((): ReadonlyHeaders => {
|
||||
const initialHeaders = [
|
||||
{
|
||||
title: '',
|
||||
align: 'start',
|
||||
sortable: false,
|
||||
value: '1.iconURL'
|
||||
},
|
||||
{
|
||||
title: 'Owned',
|
||||
align: 'end',
|
||||
sortable: true,
|
||||
key: `1.owned`
|
||||
},
|
||||
{
|
||||
title: 'Total',
|
||||
align: 'end',
|
||||
sortable: false,
|
||||
key: `1.totalPower`
|
||||
}
|
||||
]
|
||||
if (lgAndUp.value) {
|
||||
initialHeaders!.splice(1, 0,
|
||||
{
|
||||
title: 'Name',
|
||||
align: 'start',
|
||||
sortable: true,
|
||||
value: '1.itemName'
|
||||
})
|
||||
initialHeaders!.splice(2, 0,
|
||||
{
|
||||
title: 'Power',
|
||||
align: 'end',
|
||||
sortable: true,
|
||||
key: '1.minItemPower'
|
||||
})
|
||||
}
|
||||
])
|
||||
const sortBy = ref([
|
||||
return initialHeaders as ReadonlyHeaders
|
||||
})
|
||||
const sortBy: Ref<SortItem[]> = ref([
|
||||
{
|
||||
key: '1.minItemPower',
|
||||
order: 'asc'
|
||||
|
|
@ -64,10 +78,11 @@ const getColor = computed(() => (rarity: number): string => {
|
|||
return 'green'
|
||||
}
|
||||
})
|
||||
const isLoading = computed((): boolean => props.items.keys()?.next().done ?? false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card :loading="isLoading">
|
||||
<v-card-title>Standard Items</v-card-title>
|
||||
<v-card-subtitle>Items that exist all the time</v-card-subtitle>
|
||||
<v-card-item>
|
||||
|
|
@ -77,7 +92,7 @@ const getColor = computed(() => (rarity: number): string => {
|
|||
:items="[...items.entries()]"
|
||||
:headers="headers"
|
||||
>
|
||||
<template v-slot:item.1.iconURL="{ item }">
|
||||
<template v-slot:[`item.1.iconURL`]="{ item }">
|
||||
<v-card class="my-2" elevation="2" rounded width="41">
|
||||
<v-tooltip
|
||||
activator="parent"
|
||||
|
|
@ -94,7 +109,7 @@ const getColor = computed(() => (rarity: number): string => {
|
|||
<v-img :src="`/images/${item[1].iconURL}`" height="41" width="41" cover></v-img>
|
||||
</v-card>
|
||||
</template>
|
||||
<template v-slot:item.1.itemName="{ item }">
|
||||
<template v-slot:[`item.1.itemName`]="{ item }">
|
||||
<v-tooltip
|
||||
activator="parent"
|
||||
v-if="item[1].tooltip != undefined"
|
||||
|
|
@ -111,7 +126,7 @@ const getColor = computed(() => (rarity: number): string => {
|
|||
{{ item[1].itemName }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template v-slot:item.1.owned="{ item }">
|
||||
<template v-slot:[`item.1.owned`]="{ item }">
|
||||
<v-text-field
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
|
|
@ -119,7 +134,7 @@ const getColor = computed(() => (rarity: number): string => {
|
|||
@update:model-value="usePowerItems().updateOwned(item[0], item[1].owned)"
|
||||
></v-text-field>
|
||||
</template>
|
||||
<template v-slot:item.1.totalPower="{ item }">
|
||||
<template v-slot:[`item.1.totalPower`]="{ item }">
|
||||
{{ item[1].minItemPower * item[1].owned }}
|
||||
</template>
|
||||
<template v-slot:tfoot>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface Props {
|
||||
standardTotal: number
|
||||
minimumTotal: number
|
||||
maximumTotal: number
|
||||
averageTotal: number
|
||||
itemType: string
|
||||
standardTotal: number
|
||||
minimumTotal: number
|
||||
maximumTotal: number
|
||||
averageTotal: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
|
@ -17,17 +18,17 @@ const aveIncrease = computed(() => props.standardTotal + props.averageTotal)
|
|||
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>Overall Blessing Power Increase</v-card-title>
|
||||
<v-card-title>Overall {{ itemType }} Power Increase</v-card-title>
|
||||
<v-card-item>
|
||||
<v-list density="compact">
|
||||
<v-list-item>
|
||||
<v-list-item-title>Minimum: {{ minIncrease }}</v-list-item-title>
|
||||
<v-list-item-title>Minimum: {{ minIncrease }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-list-item-title>Maximum: {{ maxIncrease }}</v-list-item-title>
|
||||
<v-list-item-title>Maximum: {{ maxIncrease }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-list-item-title>Mean: {{ aveIncrease }}</v-list-item-title>
|
||||
<v-list-item-title>Mean: {{ aveIncrease }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-item>
|
||||
|
|
|
|||
48
src/main.ts
48
src/main.ts
|
|
@ -2,32 +2,52 @@ import './assets/main.css'
|
|||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
// Vuetify
|
||||
import 'vuetify/styles'
|
||||
import '@mdi/font/css/materialdesignicons.css'
|
||||
import { createVuetify } from 'vuetify'
|
||||
import * as components from 'vuetify/components'
|
||||
import * as directives from 'vuetify/directives'
|
||||
|
||||
import App from './App.vue'
|
||||
import { apiBaseURL, oidc } from './types/ConfigSymbols'
|
||||
import { configureOidc } from './services/authentikAuth'
|
||||
import router from './router'
|
||||
import { getConfig } from './services/siteConfig'
|
||||
import type { Config } from './types/Config'
|
||||
|
||||
const vuetify = createVuetify({
|
||||
components,
|
||||
directives,
|
||||
icons: {
|
||||
defaultSet: 'mdi',
|
||||
defaultSet: 'mdi'
|
||||
},
|
||||
theme: {
|
||||
defaultTheme: 'dark'
|
||||
}
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
getConfig().then((conf: Config | null) => {
|
||||
configureOidc().then((oidcAuth) => {
|
||||
if (conf === null) {
|
||||
throw new Error('config was null')
|
||||
}
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(vuetify)
|
||||
app.provide(apiBaseURL, conf.apiBaseURL)
|
||||
|
||||
app.mount('#app')
|
||||
oidcAuth.startup().then((ok: boolean) => {
|
||||
if (ok) {
|
||||
app.provide(oidc, oidcAuth)
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(router)
|
||||
app.use(vuetify)
|
||||
app.use(pinia)
|
||||
|
||||
app.mount('#app')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch((reason) => {
|
||||
console.log(reason)
|
||||
throw new Error(reason)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
import { configureOidc } from '@/services/authentikAuth'
|
||||
import { getConfig } from '@/services/siteConfig'
|
||||
import type { Config } from '@/types/Config'
|
||||
import { LOGIN_RETURN_URL_STORAGE } from '@/types/ConfigSymbols'
|
||||
import type { OidcAuth } from 'vue-oidc-client/vue3'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
let config: Config = {} as Config
|
||||
getConfig().then((conf: Config | null): Config => (config = conf !== null ? conf : ({} as Config)))
|
||||
let oidcAuth: OidcAuth = {} as OidcAuth
|
||||
configureOidc().then((auth: OidcAuth) => {
|
||||
oidcAuth = auth
|
||||
oidcAuth!.useRouter(router)
|
||||
})
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
|
|
@ -9,26 +22,70 @@ const router = createRouter({
|
|||
component: () => import('@/views/HomeView.vue')
|
||||
},
|
||||
{
|
||||
path: "/blessing-power",
|
||||
name: "blessing-power",
|
||||
path: '/blessing-power',
|
||||
name: 'blessing-power',
|
||||
component: () => import('@/views/BlessingPowerView.vue')
|
||||
},
|
||||
{
|
||||
path: "/fellow-power",
|
||||
name: "fellow-power",
|
||||
path: '/fellow-power',
|
||||
name: 'fellow-power',
|
||||
component: () => import('@/views/FellowPowerView.vue')
|
||||
},
|
||||
{
|
||||
path: "/intimacy-power",
|
||||
name: "intimacy-power",
|
||||
path: '/intimacy-power',
|
||||
name: 'intimacy-power',
|
||||
component: () => import('@/views/IntimacyPowerView.vue')
|
||||
},
|
||||
{
|
||||
path: "/test",
|
||||
name: "test",
|
||||
component: () => import('@/views/Test.vue')
|
||||
path: '/test',
|
||||
name: 'test',
|
||||
component: () =>
|
||||
hasRole('admin') ? import('@/views/TestView.vue') : import('@/views/NoAccess.vue')
|
||||
},
|
||||
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
meta: {
|
||||
authName: oidcAuth.authName
|
||||
},
|
||||
component: () => import('@/views/LoginView.vue')
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
meta: {
|
||||
authName: oidcAuth.authName
|
||||
},
|
||||
component: () =>
|
||||
hasRole('admin') ? import('@/views/Admin.vue') : import('@/views/NoAccess.vue')
|
||||
},
|
||||
{
|
||||
path: '/postLogin',
|
||||
name: 'postLogin',
|
||||
redirect: () => {
|
||||
let redirectUrl = sessionStorage.getItem(LOGIN_RETURN_URL_STORAGE)
|
||||
if (redirectUrl === "" || redirectUrl === null) {
|
||||
redirectUrl = "/"
|
||||
}
|
||||
sessionStorage.removeItem(LOGIN_RETURN_URL_STORAGE)
|
||||
return { path: redirectUrl }
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const hasRole = (role: string) => {
|
||||
if (config.oidcProjectID === undefined) {
|
||||
throw new Error('Config was not loaded')
|
||||
}
|
||||
const roles = oidcAuth.userProfile[
|
||||
`urn:zitadel:iam:org:project:${config.oidcProjectID}:roles`
|
||||
] as Array<any>
|
||||
if (!roles) {
|
||||
return false
|
||||
}
|
||||
return roles.find((r) => r[role])
|
||||
}
|
||||
|
||||
export default router
|
||||
|
|
|
|||
80
src/services/authentikAuth.ts
Normal file
80
src/services/authentikAuth.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import type { User } from 'oidc-client'
|
||||
import { createOidcAuth, SignInType, type OidcAuth, LogLevel } from 'vue-oidc-client/vue3'
|
||||
import { getConfig } from './siteConfig'
|
||||
import type { Config } from '@/types/Config'
|
||||
|
||||
const loco = window.location
|
||||
const appRootUrl = `${loco.protocol}//${loco.host}${import.meta.env.BASE_URL}`
|
||||
|
||||
let authObj = null as OidcAuth | null
|
||||
|
||||
export async function configureOidc() {
|
||||
if (authObj) return Promise.resolve(authObj)
|
||||
const appConfig: Config = await getConfig().then((c) => {
|
||||
if (c === null) {
|
||||
throw new Error("config was null")
|
||||
}
|
||||
return c
|
||||
}).catch((reason) => {
|
||||
console.log(reason)
|
||||
throw new Error(reason);
|
||||
})
|
||||
|
||||
const config = {
|
||||
authority: appConfig.oidcAuthority,
|
||||
client_id: appConfig.oidcClientID,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email api offline_access'
|
||||
}
|
||||
|
||||
authObj = createOidcAuth(
|
||||
'main',
|
||||
SignInType.Window,
|
||||
appRootUrl,
|
||||
{
|
||||
...config,
|
||||
// test use
|
||||
prompt: 'login'
|
||||
},
|
||||
console,
|
||||
LogLevel.Debug
|
||||
)
|
||||
|
||||
// handle events
|
||||
authObj.events.addAccessTokenExpiring(function () {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('access token expiring')
|
||||
})
|
||||
|
||||
authObj.events.addAccessTokenExpired(function () {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('access token expired')
|
||||
})
|
||||
|
||||
authObj.events.addSilentRenewError(function (err: Error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('silent renew error', err)
|
||||
})
|
||||
|
||||
authObj.events.addUserLoaded(function (user: User) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('user loaded', user)
|
||||
})
|
||||
|
||||
authObj.events.addUserUnloaded(function () {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('user unloaded')
|
||||
})
|
||||
|
||||
authObj.events.addUserSignedOut(function () {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('user signed out')
|
||||
})
|
||||
|
||||
authObj.events.addUserSessionChanged(function () {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('user session changed')
|
||||
})
|
||||
|
||||
return authObj
|
||||
}
|
||||
18
src/services/siteConfig.ts
Normal file
18
src/services/siteConfig.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { Config } from '@/types/Config'
|
||||
import axios from 'axios'
|
||||
|
||||
let configObject = null as Config | null
|
||||
|
||||
export async function getConfig() {
|
||||
if (configObject) return Promise.resolve(configObject)
|
||||
|
||||
configObject = await axios.get('/config.json?noCache=' + Date.now()).then((resp) => {
|
||||
return {
|
||||
apiBaseURL: resp.data.apiBaseURL,
|
||||
oidcAuthority: resp.data.oidcAuthority,
|
||||
oidcClientID: resp.data.oidcClientID,
|
||||
oidcProjectID: resp.data.oidcProjectID
|
||||
} as Config
|
||||
})
|
||||
return configObject
|
||||
}
|
||||
46
src/services/zitadelAuth.ts
Normal file
46
src/services/zitadelAuth.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import {createZITADELAuth} from "@zitadel/vue";
|
||||
import {User} from "oidc-client";
|
||||
|
||||
const zitadelAuth = createZITADELAuth({
|
||||
project_resource_id: '252968011466539011',
|
||||
client_id: "252968365600079875@isl",
|
||||
issuer: "http://localhost:8080/",
|
||||
})
|
||||
|
||||
// handle events
|
||||
zitadelAuth.oidcAuth.events.addAccessTokenExpiring(function() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('access token expiring')
|
||||
})
|
||||
|
||||
zitadelAuth.oidcAuth.events.addAccessTokenExpired(function() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('access token expired')
|
||||
})
|
||||
|
||||
zitadelAuth.oidcAuth.events.addSilentRenewError(function(err: Error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('silent renew error', err)
|
||||
})
|
||||
|
||||
zitadelAuth.oidcAuth.events.addUserLoaded(function(user: User) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('user loaded', user)
|
||||
})
|
||||
|
||||
zitadelAuth.oidcAuth.events.addUserUnloaded(function() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('user unloaded')
|
||||
})
|
||||
|
||||
zitadelAuth.oidcAuth.events.addUserSignedOut(function() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('user signed out')
|
||||
})
|
||||
|
||||
zitadelAuth.oidcAuth.events.addUserSessionChanged(function() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('user session changed')
|
||||
})
|
||||
|
||||
export default zitadelAuth
|
||||
34
src/stores/general.ts
Normal file
34
src/stores/general.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useGeneralState = defineStore('general', () => {
|
||||
const LOGIN_RETURN_URL_STORAGE = 'LOGIN_RETURN_URL_STORAGE'
|
||||
|
||||
const loginReturnUrl = ref("")
|
||||
|
||||
function setLoginReturnUrl(url: string) {
|
||||
loginReturnUrl.value = url
|
||||
localStorage.setItem(
|
||||
LOGIN_RETURN_URL_STORAGE,
|
||||
loginReturnUrl.value,
|
||||
)
|
||||
}
|
||||
|
||||
function getLoginReturnUrl(): string {
|
||||
const storedUrl = localStorage.getItem(LOGIN_RETURN_URL_STORAGE)
|
||||
if (storedUrl === null || storedUrl === undefined || storedUrl === "") {
|
||||
localStorage.removeItem(LOGIN_RETURN_URL_STORAGE)
|
||||
const res = loginReturnUrl.value
|
||||
loginReturnUrl.value == null
|
||||
return res
|
||||
}
|
||||
localStorage.removeItem(LOGIN_RETURN_URL_STORAGE)
|
||||
loginReturnUrl.value == null
|
||||
return storedUrl
|
||||
}
|
||||
|
||||
return {
|
||||
setLoginReturnUrl,
|
||||
getLoginReturnUrl
|
||||
}
|
||||
})
|
||||
|
|
@ -1,26 +1,56 @@
|
|||
import type { PowerItem } from '@/types/PowerItem'
|
||||
import axios from 'axios'
|
||||
import axios, { type AxiosRequestConfig } from 'axios'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, toRaw } from 'vue'
|
||||
import axiosRetry from 'axios-retry'
|
||||
import { apiBaseURL as apiBaseURLKey } from '@/types/ConfigSymbols'
|
||||
import { inject } from 'vue'
|
||||
import { getConfig } from '@/services/siteConfig'
|
||||
import type { Config } from '@/types/Config'
|
||||
|
||||
const BLESSING = 1
|
||||
const INTIMACY = 2
|
||||
const FELLOW = 3
|
||||
|
||||
const noCacheConfig: AxiosRequestConfig = {
|
||||
responseType: 'json',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
Pragma: 'no-cache',
|
||||
Expires: '0'
|
||||
}
|
||||
}
|
||||
|
||||
export const usePowerItems = defineStore('powerItems', () => {
|
||||
//#region privateVariables
|
||||
const BLESSING_POWER_ITEM_STORAGE = 'BLESSING_POWER_ITEM_STORAGE'
|
||||
const FELLOW_POWER_ITEM_STORAGE = 'FELLOW_POWER_ITEM_STORAGE'
|
||||
const INTIMACY_POWER_ITEM_STORAGE = 'INTIMACY_POWER_ITEM_STORAGE'
|
||||
let apiBaseURL = inject(apiBaseURLKey)!
|
||||
if (apiBaseURL === undefined) {
|
||||
getConfig().then((conf: Config | null) => {
|
||||
if (conf !== null) {
|
||||
apiBaseURL = conf.apiBaseURL
|
||||
}
|
||||
})
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region state
|
||||
const blessingPowerItems = ref(new Map<string, PowerItem>())
|
||||
const fellowPowerItems = ref(new Map<string, PowerItem>())
|
||||
const intimacyPowerItems = ref(new Map<string, PowerItem>())
|
||||
const isLoadComplete = ref(false)
|
||||
//#endregion
|
||||
|
||||
async function fetchPowerItems() {
|
||||
axiosRetry(axios, {
|
||||
retries: 3,
|
||||
retryDelay: axiosRetry.exponentialDelay
|
||||
})
|
||||
|
||||
//#region loaders
|
||||
async function fetchBlessingItems() {
|
||||
axios
|
||||
.get('/items/blessingPowerItems.json', {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
Pragma: 'no-cache',
|
||||
Expires: '0'
|
||||
}
|
||||
})
|
||||
.get(apiBaseURL + '/powerItems/byType/' + BLESSING + '/asMap', noCacheConfig)
|
||||
.then((resp) => {
|
||||
const plainMap = new Map<string, PowerItem>(
|
||||
Object.entries(JSON.parse(localStorage.getItem(BLESSING_POWER_ITEM_STORAGE) || '{}'))
|
||||
|
|
@ -35,14 +65,10 @@ export const usePowerItems = defineStore('powerItems', () => {
|
|||
.catch((err) => {
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
async function fetchFellowItems() {
|
||||
axios
|
||||
.get('/items/fellowPowerItems.json', {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
Pragma: 'no-cache',
|
||||
Expires: '0'
|
||||
}
|
||||
})
|
||||
.get(apiBaseURL + '/powerItems/byType/' + FELLOW + '/asMap', noCacheConfig)
|
||||
.then((resp) => {
|
||||
const plainMap = new Map<string, PowerItem>(
|
||||
Object.entries(JSON.parse(localStorage.getItem(FELLOW_POWER_ITEM_STORAGE) || '{}'))
|
||||
|
|
@ -57,14 +83,10 @@ export const usePowerItems = defineStore('powerItems', () => {
|
|||
.catch((err) => {
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
async function fetchIntimacyItems() {
|
||||
axios
|
||||
.get('/items/intimacyPowerItems.json', {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
Pragma: 'no-cache',
|
||||
Expires: '0'
|
||||
}
|
||||
})
|
||||
.get(apiBaseURL + '/powerItems/byType/' + INTIMACY + '/asMap', noCacheConfig)
|
||||
.then((resp) => {
|
||||
const plainMap = new Map<string, PowerItem>(
|
||||
Object.entries(JSON.parse(localStorage.getItem(INTIMACY_POWER_ITEM_STORAGE) || '{}'))
|
||||
|
|
@ -79,9 +101,10 @@ export const usePowerItems = defineStore('powerItems', () => {
|
|||
.catch((err) => {
|
||||
console.log(err)
|
||||
})
|
||||
isLoadComplete.value = true
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region setters
|
||||
function updateOwned(key: string, newOwned: number) {
|
||||
let cur = blessingPowerItems.value.get(key)
|
||||
if (cur !== undefined) {
|
||||
|
|
@ -113,16 +136,21 @@ export const usePowerItems = defineStore('powerItems', () => {
|
|||
}
|
||||
}
|
||||
|
||||
function mapToObj(map: Map<string, PowerItem>) {
|
||||
return Array.from(map).reduce((obj, [key, value]) => {
|
||||
// Doing weird magic to work with maps is infuriating, and I haven't found a better solution for this.
|
||||
// So ignore that error TypeScript, I (don't) know what I'm doing!
|
||||
// @ts-ignore: noImplicitAny
|
||||
obj[key] = value
|
||||
return obj
|
||||
}, {})
|
||||
}
|
||||
function addPowerItem(newItem: PowerItem): Promise<PowerItem | null> {
|
||||
const resultPromise: Promise<PowerItem | null> = new Promise((resolve, reject) => {
|
||||
axios
|
||||
.post(apiBaseURL + '/powerItems/', newItem, noCacheConfig)
|
||||
.then((resp) => {
|
||||
resolve(resp.data)
|
||||
})
|
||||
.catch((rejected) => reject(rejected))
|
||||
})
|
||||
|
||||
return resultPromise
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region computed
|
||||
const totalBlessingPower = computed(() =>
|
||||
[...blessingPowerItems.value.values()].reduce(
|
||||
(accumulator: number, currentValue: PowerItem) => {
|
||||
|
|
@ -305,15 +333,29 @@ export const usePowerItems = defineStore('powerItems', () => {
|
|||
0
|
||||
)
|
||||
)
|
||||
//#endregion
|
||||
|
||||
fetchPowerItems()
|
||||
//#region helpers
|
||||
function mapToObj(map: Map<string, PowerItem>) {
|
||||
return Array.from(map).reduce((obj, [key, value]) => {
|
||||
// Doing weird magic to work with maps is infuriating, and I haven't found a better solution for this.
|
||||
// So ignore that error TypeScript, I (don't) know what I'm doing!
|
||||
// @ts-ignore: noImplicitAny
|
||||
obj[key] = value
|
||||
return obj
|
||||
}, {})
|
||||
}
|
||||
//#endregion
|
||||
|
||||
return {
|
||||
blessingPowerItems,
|
||||
fellowPowerItems,
|
||||
intimacyPowerItems,
|
||||
isLoadComplete,
|
||||
fetchPowerItems,
|
||||
|
||||
fetchBlessingItems,
|
||||
fetchFellowItems,
|
||||
fetchIntimacyItems,
|
||||
|
||||
updateOwned,
|
||||
totalBlessingPower,
|
||||
standardBlessingItems,
|
||||
|
|
|
|||
6
src/types/Config.ts
Normal file
6
src/types/Config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export type Config = {
|
||||
apiBaseURL: string,
|
||||
oidcAuthority: string,
|
||||
oidcClientID: string,
|
||||
oidcProjectID: string,
|
||||
}
|
||||
15
src/types/ConfigSymbols.ts
Normal file
15
src/types/ConfigSymbols.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { InjectionKey } from "vue";
|
||||
import type { OidcAuth } from "vue-oidc-client/vue3";
|
||||
|
||||
const apiBaseURL = Symbol() as InjectionKey<string>
|
||||
const oidcProjectID = Symbol() as InjectionKey<string>
|
||||
const oidc = Symbol() as InjectionKey<OidcAuth>
|
||||
|
||||
const LOGIN_RETURN_URL_STORAGE = "LOGIN_RETURN_URL_STORAGE"
|
||||
|
||||
export {
|
||||
apiBaseURL,
|
||||
oidcProjectID,
|
||||
oidc,
|
||||
LOGIN_RETURN_URL_STORAGE
|
||||
}
|
||||
110
src/types/DataTableHeader.ts
Normal file
110
src/types/DataTableHeader.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import type { ComputedRef } from "vue"
|
||||
import type { Ref } from "vue"
|
||||
|
||||
export type DataTableHeader = {
|
||||
key?: 'data-table-group' | 'data-table-select' | 'data-table-expand' | (string & {})
|
||||
value?: SelectItemKey
|
||||
title?: string
|
||||
fixed?: boolean
|
||||
align?: 'start' | 'end' | 'center'
|
||||
width?: number | string
|
||||
minWidth?: string
|
||||
maxWidth?: string
|
||||
headerProps?: Record<string, any>
|
||||
cellProps?: HeaderCellProps
|
||||
sortable?: boolean
|
||||
sort?: DataTableCompareFunction
|
||||
filter?: FilterFunction
|
||||
children?: DataTableHeader[]
|
||||
}
|
||||
|
||||
type SelectItemKey<T = Record<string, any>> = boolean | null | undefined | string | readonly (string | number)[] | ((item: T, fallback?: any) => any);
|
||||
type HeaderCellProps = Record<string, any> | ((data: Pick<ItemKeySlot<any>, 'index' | 'item' | 'internalItem' | 'value'>) => Record<string, any>);
|
||||
type DataTableCompareFunction<T = any> = (a: T, b: T) => number;
|
||||
type FilterFunction = (value: string, query: string, item?: InternalItem) => FilterMatch;
|
||||
type ItemKeySlot<T> = ItemSlotBase<T> & {
|
||||
value: any;
|
||||
column: InternalDataTableHeader;
|
||||
};
|
||||
type FilterMatch = boolean | number | [number, number] | [number, number][];
|
||||
type ItemSlotBase<T> = {
|
||||
index: number;
|
||||
item: T;
|
||||
internalItem: DataTableItem<T>;
|
||||
isExpanded: ReturnType<typeof provideExpanded>['isExpanded'];
|
||||
toggleExpand: ReturnType<typeof provideExpanded>['toggleExpand'];
|
||||
isSelected: ReturnType<typeof provideSelection>['isSelected'];
|
||||
toggleSelect: ReturnType<typeof provideSelection>['toggleSelect'];
|
||||
};
|
||||
type InternalDataTableHeader = Omit<DataTableHeader, 'key' | 'value' | 'children'> & {
|
||||
key: string | null;
|
||||
value: SelectItemKey | null;
|
||||
sortable: boolean;
|
||||
fixedOffset?: number;
|
||||
lastFixed?: boolean;
|
||||
colspan?: number;
|
||||
rowspan?: number;
|
||||
children?: InternalDataTableHeader[];
|
||||
};
|
||||
type ExpandProps = {
|
||||
expandOnClick: boolean;
|
||||
expanded: readonly string[];
|
||||
'onUpdate:expanded': ((value: any[]) => void) | undefined;
|
||||
};
|
||||
type SelectionProps = Pick<DataTableItemProps, 'itemValue'> & {
|
||||
modelValue: readonly any[];
|
||||
selectStrategy: 'single' | 'page' | 'all';
|
||||
valueComparator: typeof deepEqual;
|
||||
'onUpdate:modelValue': EventProp<[any[]]> | undefined;
|
||||
};
|
||||
type EventProp<T extends any[] = any[], F = (...args: T) => void> = F;
|
||||
|
||||
declare function provideExpanded(props: ExpandProps): {
|
||||
expand: (item: DataTableItem, value: boolean) => void;
|
||||
expanded: Ref<Set<string>> & {
|
||||
readonly externalValue: readonly string[];
|
||||
};
|
||||
expandOnClick: Ref<boolean>;
|
||||
isExpanded: (item: DataTableItem) => boolean;
|
||||
toggleExpand: (item: DataTableItem) => void;
|
||||
};
|
||||
declare function provideSelection(props: SelectionProps, { allItems, currentPage }: {
|
||||
allItems: Ref<SelectableItem[]>;
|
||||
currentPage: Ref<SelectableItem[]>;
|
||||
}): {
|
||||
toggleSelect: (item: SelectableItem) => void;
|
||||
select: (items: SelectableItem[], value: boolean) => void;
|
||||
selectAll: (value: boolean) => void;
|
||||
isSelected: (items: SelectableItem | SelectableItem[]) => boolean;
|
||||
isSomeSelected: (items: SelectableItem | SelectableItem[]) => boolean;
|
||||
someSelected: ComputedRef<boolean>;
|
||||
allSelected: ComputedRef<boolean>;
|
||||
showSelectAll: boolean;
|
||||
};
|
||||
declare function deepEqual(a: any, b: any): boolean;
|
||||
|
||||
interface InternalItem<T = any> {
|
||||
value: any;
|
||||
raw: T;
|
||||
}
|
||||
interface DataTableItemProps {
|
||||
items: any[];
|
||||
itemValue: SelectItemKey;
|
||||
itemSelectable: SelectItemKey;
|
||||
returnObject: boolean;
|
||||
}
|
||||
interface DataTableItem<T = any> extends InternalItem<T>, GroupableItem<T>, SelectableItem {
|
||||
key: any;
|
||||
index: number;
|
||||
columns: {
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
interface SelectableItem {
|
||||
value: any;
|
||||
selectable: boolean;
|
||||
}
|
||||
interface GroupableItem<T = any> {
|
||||
type: 'item';
|
||||
raw: T;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export type PowerItem = {
|
||||
itemType: number
|
||||
iconURL: string
|
||||
itemName: string
|
||||
minItemPower: number
|
||||
|
|
@ -7,5 +8,5 @@ export type PowerItem = {
|
|||
rarity: number
|
||||
origin: string
|
||||
tooltip: string
|
||||
isEventItem: string
|
||||
isEventItem: boolean
|
||||
}
|
||||
|
|
|
|||
22
src/views/Admin.vue
Normal file
22
src/views/Admin.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<div class="admin">
|
||||
<div>
|
||||
<h1>
|
||||
This is an administrator page.
|
||||
</h1>
|
||||
<h2>
|
||||
You can see it because you have the role "admin" in your ZITADEL project.
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1024px) {
|
||||
.admin {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,6 +4,7 @@ import StandardItemsCard from '@/components/StandardItemsCard.vue'
|
|||
import SummaryCard from '@/components/SummaryCard.vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { usePowerItems } from '@/stores/powerItems'
|
||||
import { onBeforeMount } from 'vue'
|
||||
|
||||
const {
|
||||
standardBlessingItems,
|
||||
|
|
@ -13,10 +14,19 @@ const {
|
|||
specialBlessingItemsMaxTotal,
|
||||
specialBlessingItemsAveTotal
|
||||
} = storeToRefs(usePowerItems())
|
||||
|
||||
onBeforeMount(() => {
|
||||
if (standardBlessingItems.value.keys().next().done) {
|
||||
usePowerItems().fetchBlessingItems()
|
||||
}
|
||||
if (specialBlessingItems.value.keys().next().done) {
|
||||
usePowerItems().fetchBlessingItems()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card class="flex-fill">
|
||||
<v-card-title>Blessing Power</v-card-title>
|
||||
<v-card-text>
|
||||
<v-sheet class="d-flex flex-wrap flex-fill">
|
||||
|
|
@ -34,6 +44,7 @@ const {
|
|||
/>
|
||||
<SummaryCard
|
||||
class="ma-2 align-self-start"
|
||||
item-type="Blessing"
|
||||
:standard-total="standardBlessingItemTotal"
|
||||
:minimum-total="specialBlessingItemsMinTotal"
|
||||
:maximum-total="specialBlessingItemsMaxTotal"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import StandardItemsCard from '@/components/StandardItemsCard.vue'
|
|||
import SummaryCard from '@/components/SummaryCard.vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { usePowerItems } from '@/stores/powerItems'
|
||||
import { onBeforeMount } from 'vue'
|
||||
|
||||
const {
|
||||
standardFellowItems,
|
||||
|
|
@ -13,10 +14,19 @@ const {
|
|||
specialFellowItemsMaxTotal,
|
||||
specialFellowItemsAveTotal
|
||||
} = storeToRefs(usePowerItems())
|
||||
|
||||
onBeforeMount(() => {
|
||||
if (standardFellowItems.value.keys().next().done) {
|
||||
usePowerItems().fetchFellowItems()
|
||||
}
|
||||
if (specialFellowItems.value.keys().next().done) {
|
||||
usePowerItems().fetchFellowItems()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card class="flex-fill">
|
||||
<v-card-title>Fellow Power</v-card-title>
|
||||
<v-card-text>
|
||||
<v-sheet class="d-flex flex-wrap flex-fill">
|
||||
|
|
@ -33,6 +43,7 @@ const {
|
|||
:average-total="specialFellowItemsAveTotal"
|
||||
/>
|
||||
<SummaryCard
|
||||
item-type="Fellow"
|
||||
class="ma-2 align-self-start"
|
||||
:standard-total="standardFellowItemTotal"
|
||||
:minimum-total="specialFellowItemsMinTotal"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import StandardItemsCard from '@/components/StandardItemsCard.vue'
|
|||
import SummaryCard from '@/components/SummaryCard.vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { usePowerItems } from '@/stores/powerItems'
|
||||
import { onBeforeMount } from 'vue'
|
||||
|
||||
const {
|
||||
standardIntimacyItems,
|
||||
|
|
@ -13,10 +14,19 @@ const {
|
|||
specialIntimacyItemsMaxTotal,
|
||||
specialIntimacyItemsAveTotal
|
||||
} = storeToRefs(usePowerItems())
|
||||
|
||||
onBeforeMount(() => {
|
||||
if (standardIntimacyItems.value.keys().next().done) {
|
||||
usePowerItems().fetchIntimacyItems()
|
||||
}
|
||||
if (specialIntimacyItems.value.keys().next().done) {
|
||||
usePowerItems().fetchIntimacyItems()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card class="flex-fill">
|
||||
<v-card-title>Intimacy Power</v-card-title>
|
||||
<v-card-text>
|
||||
<v-sheet class="d-flex flex-wrap flex-fill">
|
||||
|
|
@ -33,6 +43,7 @@ const {
|
|||
:average-total="specialIntimacyItemsAveTotal"
|
||||
/>
|
||||
<SummaryCard
|
||||
item-type="Intimacy"
|
||||
class="ma-2 align-self-start"
|
||||
:standard-total="standardIntimacyItemTotal"
|
||||
:minimum-total="specialIntimacyItemsMinTotal"
|
||||
|
|
|
|||
63
src/views/LoginView.vue
Normal file
63
src/views/LoginView.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<template>
|
||||
<div class="userinfo">
|
||||
<div>
|
||||
<h1>This is a login-protected page</h1>
|
||||
<h2>
|
||||
The following profile data is extended by information from ZITADELs userinfo endpoint.
|
||||
</h2>
|
||||
<p>
|
||||
<ul class="claims">
|
||||
<li v-for="c in claims" :key="c.key">
|
||||
<strong>{{ c.key }}</strong
|
||||
>: {{ c.value }}
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
<button @click="signout">Sign Out</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1024px) {
|
||||
.userinfo {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { oidc, LOGIN_RETURN_URL_STORAGE } from '@/types/ConfigSymbols';
|
||||
import { inject } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const zitadel = ref(inject(oidc))
|
||||
const user = ref(zitadel.value?.userProfile)
|
||||
const claims = computed(() => {
|
||||
if (user.value !== undefined) {
|
||||
return Object.keys(user.value).map((key) => ({
|
||||
key,
|
||||
value: (user.value as Oidc.Profile)[key]
|
||||
}))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const signout = function() {
|
||||
if (user.value) {
|
||||
zitadel.value?.signOut()
|
||||
}
|
||||
}
|
||||
|
||||
if (!(zitadel.value?.isAuthenticated)) {
|
||||
let routePath = useRoute().path
|
||||
if (routePath !== undefined && routePath !== null) {
|
||||
sessionStorage.setItem(LOGIN_RETURN_URL_STORAGE, routePath.toString())
|
||||
zitadel.value?.signIn()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
22
src/views/NoAccess.vue
Normal file
22
src/views/NoAccess.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<div class="admin">
|
||||
<div>
|
||||
<h1>
|
||||
Access denied
|
||||
</h1>
|
||||
<h2>
|
||||
You don't have the role "admin" in your ZITADEL project.
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1024px) {
|
||||
.admin {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
10
src/views/PostLoginView.vue
Normal file
10
src/views/PostLoginView.vue
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { useGeneralState } from '@/stores/general'
|
||||
|
||||
console.log(useGeneralState().getLoginReturnUrl())
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>Hi mom</div>
|
||||
</template>
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import SpecialItemsCard from '@/components/SpecialItemsCard.vue'
|
||||
import StandardItemsCard from '@/components/StandardItemsCard.vue'
|
||||
import SummaryCard from '@/components/SummaryCard.vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { usePowerItems } from '@/stores/powerItems'
|
||||
|
||||
const {
|
||||
standardBlessingItems,
|
||||
standardBlessingItemTotal,
|
||||
specialBlessingItems,
|
||||
specialBlessingItemsMinTotal,
|
||||
specialBlessingItemsMaxTotal,
|
||||
specialBlessingItemsAveTotal
|
||||
} = storeToRefs(usePowerItems())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>Intimacy Power</v-card-title>
|
||||
<v-card-text>
|
||||
<v-sheet class="d-flex flex-wrap flex-fill">
|
||||
<StandardItemsCard
|
||||
class="ma-2 align-self-start"
|
||||
:items="standardBlessingItems"
|
||||
:total="standardBlessingItemTotal"
|
||||
/>
|
||||
<SpecialItemsCard
|
||||
class="ma-2 align-self-start"
|
||||
:items="specialBlessingItems"
|
||||
:minimum-total="specialBlessingItemsMinTotal"
|
||||
:maximum-total="specialBlessingItemsMaxTotal"
|
||||
:average-total="specialBlessingItemsAveTotal"
|
||||
/>
|
||||
<SummaryCard
|
||||
class="ma-2 align-self-start"
|
||||
:standard-total="standardBlessingItemTotal"
|
||||
:minimum-total="specialBlessingItemsMinTotal"
|
||||
:maximum-total="specialBlessingItemsMaxTotal"
|
||||
:average-total="specialBlessingItemsAveTotal"
|
||||
/>
|
||||
</v-sheet>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/styles/settings.scss';
|
||||
:deep(tbody) tr:nth-of-type(even) {
|
||||
background-color: rgba(var(--v-theme-primary-darken-1), 0.25);
|
||||
}
|
||||
</style>
|
||||
12
src/views/TestView.vue
Normal file
12
src/views/TestView.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import NewItemForm from '@/components/NewItemForm.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-sheet class="flex-fill">
|
||||
<NewItemForm />
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
||||
|
|
@ -2,11 +2,13 @@ import { fileURLToPath, URL } from 'node:url'
|
|||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vuetify from 'vite-plugin-vuetify'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vuetify(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue