Started new conjure os frontend

This commit is contained in:
Trit0 2025-06-28 21:05:42 -04:00
parent a2c63be9e7
commit 8fe320784c
29 changed files with 2883 additions and 392 deletions

8
.idea/.gitignore generated vendored Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

File diff suppressed because it is too large Load Diff

View File

@ -3,19 +3,28 @@
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"npm": ">=9.0.0",
"node": ">=20.0.0"
},
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.2.37"
"pinia": "^3.0.3",
"qrcode": "^1.5.4",
"tailwindcss": "^4.1.11",
"vue": "^3.5.17"
},
"devDependencies": {
"@vitejs/plugin-vue": "^3.0.3",
"typescript": "^4.6.4",
"vite": "^3.0.7",
"vue-tsc": "^1.8.27",
"@babel/types": "^7.18.10"
"@babel/types": "^7.18.10",
"@tailwindcss/vite": "^4.1.11",
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^6.0.0",
"typescript": "^5.6.2",
"vite": "^6.3.5",
"vue-tsc": "^2.0.29"
}
}

View File

@ -1 +1 @@
bb7ffb87329c9ad4990374471d4ce9a4
1f77c5bc2ac4189b53ca32c331845b25

View File

@ -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>
<main>
<div class="game-basic-info">
<h1>{{ selectedGame?.Title }}</h1>
<h2>{{ selectedGame?.Developper }} - {{ selectedGame?.Year }}</h2>
</div>
<div class="input-box" id="input">
<button @click="prev">previous</button>
<button @click="next">next</button>
<div class="flex h-screen w-screen bg-black text-white">
<!-- Sidebar -->
<Sidebar
:tags="tags"
:selectedTag="selectedTag"
@selectTag="store.selectTag"
@openOptions="optionsOpen = true"
/>
<!-- 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 id="game-detailed-info">
<p>{{ selectedGame?.Description }}</p>
</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>
<OptionsModal v-if="optionsOpen" @close="optionsOpen = false" />
<QrModal v-if="qrLink" :link="qrLink" @close="qrLink = ''" />
</div>
</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>
.carousel_wrapper {
position: relative;
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);
body {
overflow: hidden;
}
</style>

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

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

View File

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

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

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

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

View File

@ -1,5 +1,8 @@
import {createApp} from 'vue'
import App from './App.vue'
import './style.css';
import { createPinia } from "pinia";
createApp(App).mount('#app')
const app = createApp(App);
app.use(createPinia());
app.mount('#app');

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

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

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

View File

@ -1,3 +1,5 @@
@import "tailwindcss";
html {
background-color: rgba(27, 38, 54, 1);
text-align: center;

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

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

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

View File

@ -0,0 +1,8 @@
import { KeyContext } from "./key-context";
export abstract class ModalKeyContext extends KeyContext {
protected onEscape() {
super.onEscape();
}
}

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

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

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

View File

@ -3,8 +3,8 @@
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"moduleResolution": "bundler",
"strict": false,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,

View File

@ -2,7 +2,7 @@
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": [

View File

@ -1,7 +1,8 @@
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()]
plugins: [vue(), tailwindcss()]
})