feat : working UI for words

This commit is contained in:
2024-12-29 19:34:26 +01:00
parent d49813e776
commit 3830ea84c8
23 changed files with 441 additions and 129 deletions
+12 -3
View File
@@ -1,11 +1,20 @@
<script lang="ts">
import 'reflect-metadata';
<script setup lang="ts">
import { useUserStore } from '~/store/user.store';
const userStore = useUserStore();
const req = await userStore.whoami()
</script>
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtWelcome />
<NuxtLayout>
<NuxtLoadingIndicator> Loading... </NuxtLoadingIndicator>
<Header />
<NuxtPage />
</NuxtLayout>
</div>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { useUserStore } from '~/store/user.store';
const userStore = useUserStore();
</script>
<template>
<header>
<div id="self">
<template v-if="userStore.has_contacted">
<ClientOnly>
<template v-if="userStore.self && userStore.self.id">
<User :id="userStore.self.id"></User>
<button>Logout</button>
</template>
<button v-else>Login</button>
</ClientOnly>
</template>
<template v-else>
Contacting server...
</template>
</div>
<div id="links">
<NuxtLink to="/">Home</NuxtLink>
<NuxtLink to="/words">Words</NuxtLink>
</div>
</header>
</template>
<style lang="css" scoped>
header,
header * {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
}
div#self {
height: 32px
}
div#links {
width: 100%;
gap: 1%;
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { fetchUser } from '~/service/user.service';
import { useUserStore } from '~/store/user.store';
export interface Props {
id: string;
}
const userStore = useUserStore();
const props = defineProps<Props>()
const user = await userStore.fetchUser(props.id)
</script>
<template>
<div class="user">
<img class="user-avatar" :src="`https://cdn.discordapp.com/avatars/${user?.id}/${user?.avatar}.webp?size=32`">
{{ user?.username }}
</div>
</template>
<style lang="css">
div.user {
display: flex;
align-items: center;
}
img.user-avatar {
height: 32px;
border-radius: 50%;
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { WordResponseDTO } from '~/netcode/events/dto/wordresponse.dto';
import { useWordStore } from '~/store/word.store';
export interface Props {
word: WordResponseDTO;
}
const props = defineProps<Props>()
const loading = ref(false)
const wordStore = useWordStore();
async function changeWordState() {
if (loading.value) {
return
}
loading.value = true
let data
if (props.word.enabled) {
data = await wordStore.disableWord(props.word.id)
} else {
data = await wordStore.enableWord(props.word.id)
}
loading.value = false
props.word.enabled = data.enabled
}
</script>
<template>
<div class="word">
<span>{{ word.value }}</span>
<input type="checkbox" :class="{ loading }" :checked="word.enabled" :disabled="loading"
@click.prevent="changeWordState" />
<User :id="word.ownerId"></User>
</div>
</template>
<style lang="css" scoped>
div.word {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: center;
}
input {
min-height: 28px;
}
input.loading {
cursor: wait
}
</style>
-20
View File
@@ -1,20 +0,0 @@
import { Expose, Type } from 'class-transformer';
import { ConceptResponseDTO } from './conceptresponse.dto';
export class ConceptInTurnResponseDTO {
constructor(partial: Partial<ConceptInTurnResponseDTO>) {
Object.assign(this, partial);
}
@Type(() => ConceptResponseDTO)
@Expose()
concept: ConceptResponseDTO;
@Expose()
order: number;
@Expose()
subconcept: number;
@Expose()
markers: number;
}
-13
View File
@@ -1,13 +0,0 @@
import { Expose } from 'class-transformer';
export class ConceptResponseDTO {
constructor(partial: Partial<ConceptResponseDTO>) {
Object.assign(this, partial);
}
@Expose()
id: number;
@Expose()
value: string;
}
-36
View File
@@ -1,36 +0,0 @@
import { Expose, Type } from 'class-transformer';
import { UserResponseDTO } from './userresponse.dto';
import { PlayerResponseDTO } from './playerresponse.dto';
import { ConceptInTurnResponseDTO } from './conceptinturnresponse.dto';
export class GameResponseDTO {
constructor(partial: Partial<GameResponseDTO>) {
Object.assign(this, partial);
this.owner = partial.owner;
this.players = partial.players;
this.currentPlayer = partial.currentPlayer;
this.currentConcepts = partial.currentConcepts;
}
@Expose()
id: number;
@Type(() => UserResponseDTO)
@Expose()
owner: UserResponseDTO;
@Type(() => PlayerResponseDTO)
@Expose()
players: PlayerResponseDTO[];
@Type(() => PlayerResponseDTO)
@Expose()
currentPlayer: PlayerResponseDTO;
@Expose()
started: boolean;
@Type(() => ConceptInTurnResponseDTO)
@Expose()
currentConcepts: ConceptInTurnResponseDTO[];
}
-16
View File
@@ -1,16 +0,0 @@
import { Expose, Type } from 'class-transformer';
import { UserResponseDTO } from './userresponse.dto';
export class PlayerResponseDTO {
constructor(partial: Partial<PlayerResponseDTO>) {
Object.assign(this, partial);
this.user = partial.user;
}
@Type(() => UserResponseDTO)
@Expose()
user: UserResponseDTO;
@Expose()
score: number;
}
-16
View File
@@ -1,16 +0,0 @@
import { Expose } from 'class-transformer';
export class UserResponseDTO {
constructor(partial: Partial<UserResponseDTO>) {
Object.assign(this, partial);
this.id = partial.id;
}
@Expose()
id: string;
@Expose()
username: string;
@Expose()
avatar: string;
}
-18
View File
@@ -1,18 +0,0 @@
import { Expose } from 'class-transformer';
export class WordResponseDTO {
constructor(partial: Partial<WordResponseDTO>) {
Object.assign(this, partial);
}
@Expose()
id: number;
@Expose()
value: string;
@Expose()
ownerId: string;
@Expose()
enabled: boolean;
}
+63
View File
@@ -0,0 +1,63 @@
import typescriptEslintEslintPlugin from '@typescript-eslint/eslint-plugin';
import globals from 'globals';
import tsParser from '@typescript-eslint/parser';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import js from '@eslint/js';
import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default [
{
ignores: ['**/.eslintrc.js'],
},
...compat.extends(
'plugin:@typescript-eslint/recommended-type-checked',
'plugin:prettier/recommended',
),
{
plugins: {
'@typescript-eslint': typescriptEslintEslintPlugin,
},
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parser: tsParser,
ecmaVersion: 5,
sourceType: 'module',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: '/home/legonzaur/Documents/code/concept/back',
},
},
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'require-await': 'off',
'@typescript-eslint/require-await': 'error',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
];
+1 -1
View File
@@ -1,4 +1,4 @@
const validPaths = ["/", "/rules", "/test"];
const validPaths = ["/", "/rules", "/test", "/words"];
export default defineNuxtRouteMiddleware(async (to, from) => {
const config = useRuntimeConfig();
+8
View File
@@ -0,0 +1,8 @@
export function subscribe(endpoint: string) {
const config = useRuntimeConfig();
const evtSource = new EventSource(config.public.endpoint + endpoint, {
withCredentials: true,
});
return evtSource;
}
+6 -1
View File
@@ -1,8 +1,10 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
import 'reflect-metadata';
export default defineNuxtConfig({
compatibilityDate: '2024-11-01',
ssr: true,
devtools: { enabled: true },
runtimeConfig: {
public: {
endpoint: "https://playtest.legonzaur.fr/api", // can be overridden by NUXT_PUBLIC_ENDPOINT environment variable
@@ -10,10 +12,13 @@ export default defineNuxtConfig({
devMode: false,
},
},
nitro: {
prerender: {
routes: ["/login", "/"],
crawlLinks: true,
},
},
modules: ["@pinia/nuxt"],
})
+65
View File
@@ -7,8 +7,10 @@
"name": "nuxt-app",
"hasInstallScript": true,
"dependencies": {
"@pinia/nuxt": "^0.9.0",
"class-transformer": "^0.5.1",
"nuxt": "^3.15.0",
"pinia": "^2.3.0",
"reflect-metadata": "^0.2.2",
"vue": "latest",
"vue-router": "latest"
@@ -1827,6 +1829,21 @@
"node": ">=0.10"
}
},
"node_modules/@pinia/nuxt": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.9.0.tgz",
"integrity": "sha512-2yeRo7LeyCF68AbNeL3xu2h6uw0617RkcsYxmA8DJM0R0PMdz5wQHnc44KeENQxR/Mrq8T910XVT6buosqsjBQ==",
"license": "MIT",
"dependencies": {
"@nuxt/kit": "^3.9.0"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"pinia": "^2.3.0"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -6474,6 +6491,28 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pinia": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.0.tgz",
"integrity": "sha512-ohZj3jla0LL0OH5PlLTDMzqKiVw2XARmC1XYLdLWIPBMdhDW/123ZWr4zVAhtJm+aoSkFa13pYXskAvAscIkhQ==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.3",
"vue-demi": "^0.14.10"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.4.4",
"vue": "^2.7.0 || ^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pkg-types": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.0.tgz",
@@ -9431,6 +9470,32 @@
"ufo": "^1.5.4"
}
},
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vue-devtools-stub": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/vue-devtools-stub/-/vue-devtools-stub-0.1.0.tgz",
+2
View File
@@ -10,8 +10,10 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"@pinia/nuxt": "^0.9.0",
"class-transformer": "^0.5.1",
"nuxt": "^3.15.0",
"pinia": "^2.3.0",
"reflect-metadata": "^0.2.2",
"vue": "latest",
"vue-router": "latest"
+4
View File
@@ -1,3 +1,7 @@
<script setup lang="ts">
</script>
<template>
Hello
</template>
+1 -1
View File
@@ -1,3 +1,3 @@
<template>
Hello
Login
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { useWordStore } from '~/store/word.store';
const wordStore = useWordStore();
await wordStore.fetchWordList()
</script>
<template>
<div id="words">
<div class="header">
<span>Word</span>
<span>Status</span>
<span>Submitter</span>
</div>
<Word v-for="word in wordStore.words" :word="word" />
</div>
</template>
<style lang="css" scoped>
div#words {
display: grid;
grid-template-columns: 1fr;
}
div.header {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: center;
}
</style>
+15
View File
@@ -0,0 +1,15 @@
import { UserResponseDTO } from "~/netcode/events/dto/userresponse.dto";
export async function fetchUser(id: string) {
const config = useRuntimeConfig();
const data = await useFetch<UserResponseDTO>(config.public.endpoint + `/users/` + id, {
credentials: "include",
}).then(_d => {
if (_d.data.value) {
_d.data.value = new UserResponseDTO(_d.data.value)
}
return _d
})
return data
}
+40
View File
@@ -0,0 +1,40 @@
import { defineStore } from "pinia";
import { UserResponseDTO } from "~/netcode/events/dto/userresponse.dto";
export const useUserStore = defineStore("user", {
state: () => ({
users: {} as Record<string, UserResponseDTO>,
self: null as UserResponseDTO | null,
has_contacted: false
}),
actions: {
async fetchUser(id: string) {
if (id in this.users) {
return this.users[id]
}
const config = useRuntimeConfig();
const data = await useFetch<UserResponseDTO>(config.public.endpoint + `/users/` + id, {
credentials: "include",
immediate: true
})
if (data.data.value) {
this.users[data.data.value.id] = data.data.value
}
return data.data.value
},
async whoami() {
const config = useRuntimeConfig();
const req = await useFetch<UserResponseDTO>(config.public.endpoint + "/whoami", {
credentials: "include",
server: false,
immediate: true,
onResponse: (data) => {
this.self = data.response._data
this.has_contacted = true
},
lazy: true
})
return req;
}
},
});
+39
View File
@@ -0,0 +1,39 @@
import { defineStore } from "pinia";
import { WordResponseDTO } from "~/netcode/events/dto/wordresponse.dto";
export const useWordStore = defineStore("word", {
state: () => ({
words: {} as Record<string, WordResponseDTO>,
}),
actions: {
async fetchWordList() {
const config = useRuntimeConfig();
const data = await useFetch<WordResponseDTO[]>(config.public.endpoint + `/words/all`, {
credentials: "include",
})
if (data.data.value && !data.error.value) {
for (const word of data.data.value) {
this.words[word.id] = word
}
}
return data
},
async enableWord(id: number) {
const config = useRuntimeConfig();
const data = await $fetch<WordResponseDTO>(config.public.endpoint + `/words/` + id + "/enable", {
credentials: "include",
method: "PUT"
})
return data
},
async disableWord(id: number) {
const config = useRuntimeConfig();
const data = await $fetch<WordResponseDTO>(config.public.endpoint + `/words/` + id + "/disable", {
credentials: "include",
method: "PUT"
})
return data
}
},
});
+6 -1
View File
@@ -1,4 +1,9 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictPropertyInitialization": false,
}
}