Started new conjure os frontend
This commit is contained in:
parent
a2c63be9e7
commit
8fe320784c
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
13
.idea/conjure-os.iml
generated
Normal file
13
.idea/conjure-os.iml
generated
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="Go" enabled="true" />
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/conjure-os.iml" filepath="$PROJECT_DIR$/.idea/conjure-os.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
2414
frontend/package-lock.json
generated
2414
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -3,19 +3,28 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"npm": ">=9.0.0",
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.2.37"
|
"pinia": "^3.0.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"tailwindcss": "^4.1.11",
|
||||||
|
"vue": "^3.5.17"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^3.0.3",
|
"@babel/types": "^7.18.10",
|
||||||
"typescript": "^4.6.4",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"vite": "^3.0.7",
|
"@types/qrcode": "^1.5.5",
|
||||||
"vue-tsc": "^1.8.27",
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
"@babel/types": "^7.18.10"
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vue-tsc": "^2.0.29"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
bb7ffb87329c9ad4990374471d4ce9a4
|
1f77c5bc2ac4189b53ca32c331845b25
|
||||||
@ -1,110 +1,66 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { ref, onMounted, nextTick, computed } from 'vue';
|
|
||||||
import { LoadGames, SelectGame } from '../wailsjs/go/main/App.js';
|
|
||||||
import { provider } from "../wailsjs/go/models.js"
|
|
||||||
|
|
||||||
const games = ref<provider.Game[]>([]);
|
|
||||||
const activeIndex = ref(0);
|
|
||||||
const currentDeg = ref(0);
|
|
||||||
const selectedGame = ref<provider.Game>();
|
|
||||||
const slides = ref([]);
|
|
||||||
|
|
||||||
const carouselStyle = computed(() => `transform: rotateY(${currentDeg.value}deg);`);
|
|
||||||
|
|
||||||
const parse = () => {
|
|
||||||
games.value.forEach((game, i) => {
|
|
||||||
const element = document.getElementById(game.Title);
|
|
||||||
if (element) {
|
|
||||||
element.style.transform = `rotateY(${40 * i}deg) translateZ(412px)`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectGame = (index: string) => {
|
|
||||||
SelectGame(index).then(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const switchGame = (direction: number) => {
|
|
||||||
if (activeIndex.value - direction < 0 || activeIndex.value - direction >= games.value.length) return;
|
|
||||||
activeIndex.value -= direction;
|
|
||||||
if (activeIndex.value >= games.value.length) {
|
|
||||||
activeIndex.value += direction;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
currentDeg.value = 40 * activeIndex.value * -1;
|
|
||||||
selectedGame.value = games.value[activeIndex.value];
|
|
||||||
};
|
|
||||||
|
|
||||||
const prev = () => switchGame(1);
|
|
||||||
const next = () => switchGame(-1);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const result = await LoadGames();
|
|
||||||
games.value = result;
|
|
||||||
await nextTick();
|
|
||||||
parse();
|
|
||||||
selectedGame.value = games.value[activeIndex.value];
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<div class="flex h-screen w-screen bg-black text-white">
|
||||||
<div class="game-basic-info">
|
<!-- Sidebar -->
|
||||||
<h1>{{ selectedGame?.Title }}</h1>
|
<Sidebar
|
||||||
<h2>{{ selectedGame?.Developper }} - {{ selectedGame?.Year }}</h2>
|
:tags="tags"
|
||||||
</div>
|
:selectedTag="selectedTag"
|
||||||
<div class="input-box" id="input">
|
@selectTag="store.selectTag"
|
||||||
<button @click="prev">previous</button>
|
@openOptions="optionsOpen = true"
|
||||||
<button @click="next">next</button>
|
/>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="flex flex-col flex-1 overflow-hidden">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<GamePreview
|
||||||
|
:game="selectedGame"
|
||||||
|
@qr="store.showQr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<GameCarousel
|
||||||
|
:games="store.filteredGames"
|
||||||
|
:selectedGame="selectedGame"
|
||||||
|
:selectedTag="selectedTag"
|
||||||
|
:direction="transitionDirection"
|
||||||
|
@selectGame="store.selectGame"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<OptionsModal v-if="optionsOpen" @close="optionsOpen = false" />
|
||||||
<div id="game-detailed-info">
|
<QrModal v-if="qrLink" :link="qrLink" @close="qrLink = ''" />
|
||||||
<p>{{ selectedGame?.Description }}</p>
|
</div>
|
||||||
</div>
|
|
||||||
<div id="game-media"></div>
|
|
||||||
<div id="game-scoreboard"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="carousel_wrapper">
|
|
||||||
<div id="carousel" class="carousel" :style="carouselStyle">
|
|
||||||
<div v-for="(game, i) in games" :key="game.Title" :id="game.Title" class="slide" ref="slides">
|
|
||||||
<img :src="game.Cartridge" alt="cartridge"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import Sidebar from './components/Sidebar.vue';
|
||||||
|
import GamePreview from './components/GamePreview.vue';
|
||||||
|
import GameCarousel from './components/GameCarousel.vue';
|
||||||
|
import OptionsModal from './components/OptionsModal.vue';
|
||||||
|
import QrModal from './components/QrModal.vue';
|
||||||
|
import { useKeyboardNavigation } from './utils/use-keyboard-navigation';
|
||||||
|
import { fetchGames } from './services/game-service';
|
||||||
|
import { useAppStore } from "./stores/app-store";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { KeyboardManager } from "./utils/keyboard-manager";
|
||||||
|
|
||||||
|
const store = useAppStore();
|
||||||
|
const { selectedTag, selectedGame, tags, games, transitionDirection, qrLink } = storeToRefs(store);
|
||||||
|
|
||||||
|
const optionsOpen = ref(false);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
games.value = await fetchGames();
|
||||||
|
tags.value = [...new Set(games.value.flatMap(game => game.tags))];
|
||||||
|
selectedTag.value = tags.value[0];
|
||||||
|
selectedGame.value = store.filteredGames[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
KeyboardManager.switchContext("sidebar")
|
||||||
|
useKeyboardNavigation();
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.carousel_wrapper {
|
body {
|
||||||
position: relative;
|
overflow: hidden;
|
||||||
width: 320px;
|
|
||||||
margin: 100px auto 0 auto;
|
|
||||||
perspective: 1000px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
transform-style: preserve-3d;
|
|
||||||
transition: all 1s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
left: 10px;
|
|
||||||
width: 300px;
|
|
||||||
height: 187px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide img {
|
|
||||||
width: 280px;
|
|
||||||
height: 175px;
|
|
||||||
border: 3px inset rgba(29, 203, 84, 0.75);
|
|
||||||
box-shadow: 0 0 15px 3px rgba(110, 72, 221, 0.9);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
84
frontend/src/components/GameCarousel.vue
Normal file
84
frontend/src/components/GameCarousel.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative h-[170px]">
|
||||||
|
<Transition :name="`carousel-${direction}`" mode="out-in">
|
||||||
|
<div :key="selectedTag" class="w-full py-4 px-6 flex overflow-x-auto space-x-4 items-end transition-inner">
|
||||||
|
<div
|
||||||
|
v-for="game in games"
|
||||||
|
:key="game.id"
|
||||||
|
@click="$emit('selectGame', game)"
|
||||||
|
:class="[
|
||||||
|
'transition-transform transform cursor-pointer rounded-lg overflow-hidden border-4',
|
||||||
|
game.id === selectedGame.id ? 'scale-110 border-blue-500' : 'scale-100 border-transparent'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="game.thumbnail"
|
||||||
|
class="h-32 w-48 object-cover"
|
||||||
|
:alt="game.title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Give the whole carousel a key based on selectedTag to trigger transition -->
|
||||||
|
<!-- <div :key="selectedTag" class="flex gap-4 w-full transition-inner">-->
|
||||||
|
<!-- <GameCard-->
|
||||||
|
<!-- v-for="game in games"-->
|
||||||
|
<!-- :key="game.id"-->
|
||||||
|
<!-- :game="game"-->
|
||||||
|
<!-- :selected="game.id === selectedGame?.id"-->
|
||||||
|
<!-- @click="$emit('selectGame', game)"-->
|
||||||
|
<!-- />-->
|
||||||
|
<!-- </div>-->
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Game } from "../models/game";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
games: Game[],
|
||||||
|
selectedGame: Game
|
||||||
|
selectedTag: string;
|
||||||
|
direction: 'up' | 'down';
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits(['selectGame']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-up-enter-active,
|
||||||
|
.carousel-up-leave-active {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.carousel-up-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
.carousel-up-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide DOWN */
|
||||||
|
.carousel-down-enter-active,
|
||||||
|
.carousel-down-leave-active {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.carousel-down-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
.carousel-down-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
50
frontend/src/components/GamePreview.vue
Normal file
50
frontend/src/components/GamePreview.vue
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-6 h-full overflow-auto">
|
||||||
|
<div v-if="game" class="space-y-4">
|
||||||
|
<h1 class="text-4xl font-bold">{{ game.title }}</h1>
|
||||||
|
<div class="text-sm text-gray-400">{{ game.genre }} - {{ game.players }} players</div>
|
||||||
|
<p class="text-lg">{{ game.description }}</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<img
|
||||||
|
v-for="(img, index) in game.media"
|
||||||
|
:key="index"
|
||||||
|
:src="img"
|
||||||
|
class="rounded-lg border border-gray-600 max-h-48 object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div><strong>Languages:</strong> {{ game.languages.join(', ') }}</div>
|
||||||
|
<div class="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
v-for="(link, name) in game.links"
|
||||||
|
:key="name"
|
||||||
|
class="bg-blue-600 px-3 py-1 rounded hover:bg-blue-500"
|
||||||
|
@click="$emit('qr', link)"
|
||||||
|
>
|
||||||
|
{{ name }} 🔗
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center text-gray-500">No game selected</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps({
|
||||||
|
game: Object,
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['qr']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
img {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
img:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,71 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import {reactive} from 'vue'
|
|
||||||
import {Greet} from '../../wailsjs/go/main/App'
|
|
||||||
|
|
||||||
const data = reactive({
|
|
||||||
name: "",
|
|
||||||
resultText: "Please enter your name below 👇",
|
|
||||||
})
|
|
||||||
|
|
||||||
function greet() {
|
|
||||||
Greet(data.name).then(result => {
|
|
||||||
data.resultText = result
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<main>
|
|
||||||
<div id="result" class="result">{{ data.resultText }}</div>
|
|
||||||
<div id="input" class="input-box">
|
|
||||||
<input id="name" v-model="data.name" autocomplete="off" class="input" type="text"/>
|
|
||||||
<button class="btn" @click="greet">Greet</button>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.result {
|
|
||||||
height: 20px;
|
|
||||||
line-height: 20px;
|
|
||||||
margin: 1.5rem auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-box .btn {
|
|
||||||
width: 60px;
|
|
||||||
height: 30px;
|
|
||||||
line-height: 30px;
|
|
||||||
border-radius: 3px;
|
|
||||||
border: none;
|
|
||||||
margin: 0 0 0 20px;
|
|
||||||
padding: 0 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-box .btn:hover {
|
|
||||||
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
|
|
||||||
color: #333333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-box .input {
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
outline: none;
|
|
||||||
height: 30px;
|
|
||||||
line-height: 30px;
|
|
||||||
padding: 0 10px;
|
|
||||||
background-color: rgba(240, 240, 240, 1);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-box .input:hover {
|
|
||||||
border: none;
|
|
||||||
background-color: rgba(255, 255, 255, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-box .input:focus {
|
|
||||||
border: none;
|
|
||||||
background-color: rgba(255, 255, 255, 1);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
42
frontend/src/components/OptionsModal.vue
Normal file
42
frontend/src/components/OptionsModal.vue
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white text-black p-6 rounded-lg w-full max-w-md">
|
||||||
|
<h2 class="text-2xl font-bold mb-4">Options</h2>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-gray-700">Data Source:</span>
|
||||||
|
<select v-model="dataSource" class="mt-1 block w-full">
|
||||||
|
<option value="local">Local (Mock)</option>
|
||||||
|
<option value="remote">Remote (API)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-gray-700">Volume:</span>
|
||||||
|
<input type="range" min="0" max="100" v-model="volume" class="w-full" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-2">
|
||||||
|
<button @click="$emit('close')" class="px-4 py-2 bg-gray-600 text-white rounded">Close</button>
|
||||||
|
<button @click="save" class="px-4 py-2 bg-blue-600 text-white rounded">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
const dataSource = ref(localStorage.getItem('dataSource') || 'local');
|
||||||
|
const volume = ref(+localStorage.getItem('volume') || 50);
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
localStorage.setItem('dataSource', dataSource.value);
|
||||||
|
localStorage.setItem('volume', volume.value.toString());
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
36
frontend/src/components/QrModal.vue
Normal file
36
frontend/src/components/QrModal.vue
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// File: src/components/QrModal.vue
|
||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white text-black p-6 rounded-lg text-center max-w-sm w-full">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Scan with your phone</h2>
|
||||||
|
<img :src="qrCodeDataUrl" alt="QR Code" class="mx-auto mb-4" />
|
||||||
|
<p class="text-sm break-words">{{ link }}</p>
|
||||||
|
<button class="mt-4 bg-blue-600 text-white px-4 py-2 rounded" @click="$emit('close')">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
link: String
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
const qrCodeDataUrl = ref('');
|
||||||
|
|
||||||
|
watch(() => props.link, async (newLink) => {
|
||||||
|
if (newLink) {
|
||||||
|
qrCodeDataUrl.value = await QRCode.toDataURL(newLink);
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
img {
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
29
frontend/src/components/Sidebar.vue
Normal file
29
frontend/src/components/Sidebar.vue
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-20 flex flex-col items-center bg-gray-900 py-4">
|
||||||
|
<div class="flex-1 space-y-4">
|
||||||
|
<button
|
||||||
|
v-for="tag in tags"
|
||||||
|
:key="tag"
|
||||||
|
:class="[
|
||||||
|
'w-12 h-12 rounded-full flex items-center justify-center',
|
||||||
|
tag === selectedTag ? 'bg-blue-500' : 'bg-gray-700 hover:bg-gray-600'
|
||||||
|
]"
|
||||||
|
@click="$emit('selectTag', tag)"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="mt-auto w-12 h-12 bg-gray-600 hover:bg-gray-500 rounded-full flex items-center justify-center"
|
||||||
|
@click="$emit('openOptions')"
|
||||||
|
>
|
||||||
|
⚙️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps(['tags', 'selectedTag']);
|
||||||
|
defineEmits(['selectTag', 'openOptions']);
|
||||||
|
</script>
|
||||||
@ -1,5 +1,8 @@
|
|||||||
import {createApp} from 'vue'
|
import {createApp} from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
const app = createApp(App);
|
||||||
|
app.use(createPinia());
|
||||||
|
app.mount('#app');
|
||||||
|
|||||||
22
frontend/src/models/game.ts
Normal file
22
frontend/src/models/game.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
export interface Game {
|
||||||
|
id: number | string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
playerCount: "1" | "2" | "1-2";
|
||||||
|
teamMember?: TeamMember[];
|
||||||
|
languages?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
media?: string[];
|
||||||
|
thumbnail?: string;
|
||||||
|
links?: object;
|
||||||
|
metadata?: object;
|
||||||
|
executablePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamMember {
|
||||||
|
id: number | string;
|
||||||
|
name: string;
|
||||||
|
role?: string;
|
||||||
|
picture?: string;
|
||||||
|
socials?: object;
|
||||||
|
}
|
||||||
53
frontend/src/services/game-service.ts
Normal file
53
frontend/src/services/game-service.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { Game } from "../models/game";
|
||||||
|
|
||||||
|
const localGames: Game[] = [
|
||||||
|
{
|
||||||
|
id: 'g1',
|
||||||
|
title: 'Neon Rush',
|
||||||
|
description: 'A fast-paced 2D platformer through glowing neon levels.',
|
||||||
|
playerCount: '1',
|
||||||
|
languages: ['English', 'French'],
|
||||||
|
tags: ['platformer', 'neon', 'funny'],
|
||||||
|
media: [
|
||||||
|
'/assets/neon-rush-1.png',
|
||||||
|
'/assets/neon-rush-2.png'
|
||||||
|
],
|
||||||
|
thumbnail: '/assets/neon-rush-thumb.png',
|
||||||
|
links: {
|
||||||
|
Website: 'https://neonrush.dev',
|
||||||
|
Itch: 'https://hyperbyte.itch.io/neonrush'
|
||||||
|
},
|
||||||
|
executablePath: "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'g2',
|
||||||
|
title: 'Ghost Bakery',
|
||||||
|
description: 'Bake spooky pastries in a haunted kitchen.',
|
||||||
|
playerCount: '1-2',
|
||||||
|
languages: ['English'],
|
||||||
|
tags: ['cooking', 'funny', 'co-op'],
|
||||||
|
media: [
|
||||||
|
'/assets/ghost-bakery-1.jpg'
|
||||||
|
],
|
||||||
|
thumbnail: '/assets/ghost-bakery-thumb.jpg',
|
||||||
|
links: {
|
||||||
|
Itch: 'https://phantomforks.itch.io/ghostbakery'
|
||||||
|
},
|
||||||
|
executablePath: "."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function fetchGames(): Promise<Game[]> {
|
||||||
|
const source = localStorage.getItem('dataSource') || 'local';
|
||||||
|
if (source === 'local') return localGames;
|
||||||
|
|
||||||
|
// TODO games should be loaded from and started from the wails/go code
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://your-api-server.com/api/games');
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch games');
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('API fetch failed, falling back to local:', e);
|
||||||
|
return localGames;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
frontend/src/stores/app-store.ts
Normal file
52
frontend/src/stores/app-store.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { Game } from "../models/game";
|
||||||
|
|
||||||
|
export const useAppStore = defineStore('app', {
|
||||||
|
state: () => ({
|
||||||
|
tags: [] as string[],
|
||||||
|
games: [] as Game[],
|
||||||
|
selectedTag: null as string | null,
|
||||||
|
transitionDirection: 'down' as 'up' | 'down',
|
||||||
|
selectedGame: null as Game | null,
|
||||||
|
selectedGameIndex: 0,
|
||||||
|
qrLink: '' as string
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
filteredGames(state): Game[] {
|
||||||
|
return state.games.filter(game => game.tags.includes(state.selectedTag ?? ''));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
moveGameRight() {
|
||||||
|
this.selectGame(this.selectedGameIndex + 1);
|
||||||
|
},
|
||||||
|
moveGameLeft() {
|
||||||
|
this.selectGame(this.selectedGameIndex - 1);
|
||||||
|
},
|
||||||
|
moveTagUp() {
|
||||||
|
const index = this.tags.findIndex(t => t === this.selectedTag);
|
||||||
|
this.transitionDirection = 'down';
|
||||||
|
if (index > 0) this.selectTag(this.tags[index - 1]);
|
||||||
|
},
|
||||||
|
moveTagDown() {
|
||||||
|
const index = this.tags.findIndex(t => t === this.selectedTag);
|
||||||
|
this.transitionDirection = 'up';
|
||||||
|
if (index < this.tags.length - 1) this.selectTag(this.tags[index + 1]);
|
||||||
|
},
|
||||||
|
selectGame(index: number) {
|
||||||
|
const games = this.filteredGames;
|
||||||
|
if (index >= 0 && index < games.length) {
|
||||||
|
this.selectedGameIndex = index;
|
||||||
|
this.selectedGame = games[index];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectTag(tag: string) {
|
||||||
|
this.selectedTag = tag;
|
||||||
|
this.selectedGameIndex = 0;
|
||||||
|
this.selectedGame = this.filteredGames[0] ?? null;
|
||||||
|
},
|
||||||
|
showQr(link: string) {
|
||||||
|
this.qrLink = link;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
html {
|
html {
|
||||||
background-color: rgba(27, 38, 54, 1);
|
background-color: rgba(27, 38, 54, 1);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
39
frontend/src/utils/key-contexts/carousel-key-context.ts
Normal file
39
frontend/src/utils/key-contexts/carousel-key-context.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { KeyContext } from "./key-context";
|
||||||
|
import { KeyboardManager } from "../keyboard-manager";
|
||||||
|
|
||||||
|
export class CarouselKeyContext extends KeyContext {
|
||||||
|
readonly name: string = "CarouselContext";
|
||||||
|
|
||||||
|
protected onKeyUp() {
|
||||||
|
super.onKeyUp();
|
||||||
|
this.store.moveTagUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onKeyDown() {
|
||||||
|
super.onKeyDown();
|
||||||
|
this.store.moveTagDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onKeyRight() {
|
||||||
|
super.onKeyRight();
|
||||||
|
this.store.moveGameRight();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onKeyLeft() {
|
||||||
|
super.onKeyLeft();
|
||||||
|
if (this.store.selectedGameIndex === 0) {
|
||||||
|
KeyboardManager.switchContext("sidebar");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.store.moveGameLeft();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onEscape() {
|
||||||
|
super.onEscape();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onEnter() {
|
||||||
|
super.onEnter();
|
||||||
|
}
|
||||||
|
}
|
||||||
25
frontend/src/utils/key-contexts/game-preview-key-context.ts
Normal file
25
frontend/src/utils/key-contexts/game-preview-key-context.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { KeyContext } from "./key-context";
|
||||||
|
|
||||||
|
export class GamePreviewKeyContext extends KeyContext {
|
||||||
|
readonly name: string = "GamePreviewKeyContext";
|
||||||
|
|
||||||
|
protected onKeyUp() {
|
||||||
|
super.onKeyUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onKeyDown() {
|
||||||
|
super.onKeyDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onKeyRight() {
|
||||||
|
super.onKeyRight();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onEnter() {
|
||||||
|
super.onEnter();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onEscape() {
|
||||||
|
super.onEscape();
|
||||||
|
}
|
||||||
|
}
|
||||||
56
frontend/src/utils/key-contexts/key-context.ts
Normal file
56
frontend/src/utils/key-contexts/key-context.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { useAppStore } from "../../stores/app-store";
|
||||||
|
|
||||||
|
export abstract class KeyContext {
|
||||||
|
|
||||||
|
public abstract readonly name: string;
|
||||||
|
protected store = useAppStore();
|
||||||
|
|
||||||
|
public handleKey(event: KeyboardEvent): void {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
this.onKeyRight();
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
this.onKeyLeft();
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
this.onKeyUp();
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
this.onKeyDown();
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
this.onEscape();
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
this.onEnter();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onKeyRight(): void {
|
||||||
|
console.log('onKeyRight');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onKeyLeft(): void {
|
||||||
|
console.log('onKeyLeft');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onKeyUp(): void {
|
||||||
|
console.log('onKeyUp');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onKeyDown(): void {
|
||||||
|
console.log('onKeyDown');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onEscape(): void {
|
||||||
|
console.log('onEscape');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onEnter(): void {
|
||||||
|
console.log('onEnter');
|
||||||
|
}
|
||||||
|
}
|
||||||
8
frontend/src/utils/key-contexts/modal-key-context.ts
Normal file
8
frontend/src/utils/key-contexts/modal-key-context.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { KeyContext } from "./key-context";
|
||||||
|
|
||||||
|
export abstract class ModalKeyContext extends KeyContext {
|
||||||
|
|
||||||
|
protected onEscape() {
|
||||||
|
super.onEscape();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
frontend/src/utils/key-contexts/sidebar-key-context.ts
Normal file
33
frontend/src/utils/key-contexts/sidebar-key-context.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { KeyContext } from "./key-context";
|
||||||
|
import { KeyboardManager } from "../keyboard-manager";
|
||||||
|
|
||||||
|
export class SidebarKeyContext extends KeyContext {
|
||||||
|
readonly name: string = "SidebarContext";
|
||||||
|
|
||||||
|
protected onKeyUp() {
|
||||||
|
super.onKeyUp();
|
||||||
|
this.store.moveTagUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onKeyDown() {
|
||||||
|
super.onKeyDown();
|
||||||
|
this.store.moveTagDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onKeyRight() {
|
||||||
|
super.onKeyRight();
|
||||||
|
this.store.moveGameRight();
|
||||||
|
KeyboardManager.switchContext("carousel")
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onEnter() {
|
||||||
|
super.onEnter();
|
||||||
|
this.store.moveGameRight();
|
||||||
|
KeyboardManager.switchContext("carousel")
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onEscape() {
|
||||||
|
super.onEscape();
|
||||||
|
// TODO options menu
|
||||||
|
}
|
||||||
|
}
|
||||||
17
frontend/src/utils/keyboard-manager.ts
Normal file
17
frontend/src/utils/keyboard-manager.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { CarouselKeyContext } from "./key-contexts/carousel-key-context";
|
||||||
|
import { KeyContext } from "./key-contexts/key-context";
|
||||||
|
import { SidebarKeyContext } from "./key-contexts/sidebar-key-context";
|
||||||
|
|
||||||
|
export class KeyboardManager {
|
||||||
|
private static current?: KeyContext;
|
||||||
|
|
||||||
|
static switchContext(name: 'sidebar' | 'carousel') {
|
||||||
|
console.log("Switching context to " + name);
|
||||||
|
if (name === 'sidebar') this.current = new SidebarKeyContext();
|
||||||
|
else this.current = new CarouselKeyContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
static handle(event: KeyboardEvent) {
|
||||||
|
this.current?.handleKey(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/src/utils/use-keyboard-navigation.ts
Normal file
12
frontend/src/utils/use-keyboard-navigation.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
import { KeyboardManager } from "./keyboard-manager";
|
||||||
|
|
||||||
|
export function useKeyboardNavigation(): void {
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', KeyboardManager.handle.bind(KeyboardManager));
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', KeyboardManager.handle);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -3,8 +3,8 @@
|
|||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "bundler",
|
||||||
"strict": true,
|
"strict": false,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import {defineConfig} from 'vite'
|
import {defineConfig} from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()]
|
plugins: [vue(), tailwindcss()]
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user