Compare commits

...

17 commits

33 changed files with 1074 additions and 294 deletions

View file

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

View file

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

View file

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

Binary file not shown.

View file

@ -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
View 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"
}

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View 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
View 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
}
})

View file

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

@ -0,0 +1,6 @@
export type Config = {
apiBaseURL: string,
oidcAuthority: string,
oidcClientID: string,
oidcProjectID: string,
}

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

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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