Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions main/components/GamePanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<template>
<NuxtLink
:href="href"
:class="[
'group relative flex-1 min-w-42 max-w-48 h-64 rounded-lg overflow-hidden',
'transition-all duration-300 text-left hover:scale-[1.02] hover:shadow-lg hover:-translate-y-0.5',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900',
isSelected
? 'ring-2 ring-blue-500 shadow-lg shadow-blue-500/20'
: '',
]"
>
<!-- Background Image -->
<div
:class="{
'transition-all duration-300 group-hover:scale-110': true,
}"
class="absolute inset-0"
>
<!-- Cover Image (if available) -->
<img
v-if="coverSrc"
:src="coverSrc"
:alt="game.mName"
class="w-full h-full object-cover brightness-[90%] blur-[1px]"
/>
<!-- Fallback to Icon -->
<div
v-else-if="iconSrc"
class="w-full h-full flex items-center justify-center bg-gradient-to-br from-zinc-800 to-zinc-900"
>
<img
class="w-2/3 h-2/3 object-contain brightness-[90%] blur-[1px]"
:src="iconSrc"
:alt="game.mName"
/>
</div>
<!-- Placeholder -->
<div
v-else
class="w-full h-full bg-gradient-to-br from-zinc-800 to-zinc-900"
/>
<!-- Gradient Overlay -->
<div
class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/0 to-transparent"
/>
</div>

<!-- Content Overlay -->
<div class="absolute bottom-0 left-0 w-full p-3">
<h1
:class="{
'group-hover:text-white transition-colors': true,
}"
class="text-zinc-100 text-sm font-bold font-display mb-1"
>
{{ game.mName }}
</h1>
<p
v-if="game.mShortDescription"
:class="{
'group-hover:text-zinc-300 transition-colors': true,
}"
class="text-zinc-400 text-xs line-clamp-2 mb-1.5"
>
{{ game.mShortDescription }}
</p>
<!-- Status Badge -->
<div v-if="statusText" class="flex items-center mt-1">
<span
:class="[
'inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-bold uppercase font-display',
statusClass,
]"
>
{{ statusText }}
</span>
</div>
</div>

<!-- Selected Indicator -->
<div
v-if="isSelected"
class="absolute top-3 right-3 z-20 w-2.5 h-2.5 rounded-full bg-blue-500 shadow-lg shadow-blue-500/50 ring-2 ring-blue-500/30"
/>
</NuxtLink>
</template>

<script setup lang="ts">
import type { Game, GameStatus } from "~/types";

type GameWithStatus = {
game: Game;
status: GameStatus;
};

defineProps<{
game: GameWithStatus;
href: string;
coverSrc?: string;
iconSrc?: string;
statusText?: string;
statusClass?: string;
isSelected?: boolean;
}>();
</script>
88 changes: 25 additions & 63 deletions main/components/LibrarySearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
>
<ArrowPathIcon class="size-4" />
</button>
<button
@click="toggleView"
class="p-1.5 flex items-center justify-center transition-all duration-200 size-10 hover:scale-105 active:scale-95 rounded-lg bg-zinc-800/50 text-zinc-100 hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-950"
:title="libraryView === 'list' ? 'Switch to grid view' : 'Switch to list view'"
>
<Squares2X2Icon v-if="libraryView === 'list'" class="size-4" />
<Bars3Icon v-else class="size-4" />
</button>
</div>

<TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5">
Expand Down Expand Up @@ -52,7 +60,7 @@
<DisclosurePanel as="dd" class="mt-2 flex flex-col gap-y-1.5">
<NuxtLink
v-for="item in nav.items"
:key="nav.id"
:key="item.id"
:class="[
'transition-all duration-300 rounded-lg flex items-center px-1 py-1.5 hover:scale-105 active:scale-95 hover:shadow-lg hover:shadow-zinc-950/50',
currentNavigation == item.id
Expand Down Expand Up @@ -127,13 +135,11 @@ import {
MagnifyingGlassIcon,
MinusSmallIcon,
PlusSmallIcon,
Squares2X2Icon,
Bars3Icon,
} from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
import {
GameStatusEnum,
type Collection as Collection,
type Game,
type GameStatus,
} from "~/types";
import { TransitionGroup } from "vue";
import { listen } from "@tauri-apps/api/event";
Expand All @@ -143,7 +149,7 @@ const gameStatusTextStyle: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Installed]: "text-green-500",
[GameStatusEnum.Downloading]: "text-zinc-400",
[GameStatusEnum.Validating]: "text-blue-300",
[GameStatusEnum.Running]: "text-green-500",
[GameStatusEnum.Running]: "text-blue-500",
[GameStatusEnum.Remote]: "text-zinc-700",
[GameStatusEnum.Queued]: "text-zinc-400",
[GameStatusEnum.Updating]: "text-zinc-400",
Expand All @@ -168,54 +174,9 @@ const router = useRouter();

const searchQuery = ref("");

const loading = ref(false);
const games: {
[key: string]: { game: Game; status: Ref<GameStatus, GameStatus> };
} = {};
const icons: { [key: string]: string } = {};

const collections: Ref<Collection[]> = ref([]);

async function calculateGames(clearAll = false, forceRefresh = false) {
if (clearAll) {
collections.value = [];
loading.value = true;
}
// If we update immediately, the navigation gets re-rendered before we
// add all the necessary state, and it freaks tf out
const newGames = await invoke<Game[]>("fetch_library", {
hardRefresh: forceRefresh,
});
const otherCollections = await invoke<Collection[]>("fetch_collections", {
hardRefresh: forceRefresh,
});
const allGames = [
...newGames,
...otherCollections
.map((e) => e.entries)
.flat()
.map((e) => e.game),
].filter((v, i, a) => a.indexOf(v) === i);

for (const game of allGames) {
if (games[game.id]) continue;
games[game.id] = await useGame(game.id);
}
for (const game of allGames) {
if (icons[game.id]) continue;
icons[game.id] = await useObject(game.mIconObjectId);
}

const libraryCollection = {
id: "library",
name: "Library",
isDefault: true,
entries: newGames.map((e) => ({ gameId: e.id, game: e })),
} satisfies Collection;

loading.value = false;
collections.value = [libraryCollection, ...otherCollections];
}
// Use library view composable
const { libraryView, toggleView } = useLibraryView();
const { loading, games, icons, collections, calculateGames } = useLibraryGames();

// Wait up to 300 ms for the library to load, otherwise
// show the loading state while we while
Expand All @@ -229,6 +190,16 @@ await new Promise<void>((r) => {
setTimeout(resolveFunc, 300);
});

// Listen for library updates
listen("update_library", async (event) => {
console.log("Updating library");
let oldNavigation = currentNavigation.value;
await calculateGames();
if (oldNavigation !== currentNavigation.value) {
router.push("/library");
}
});

const navigation = computed(() =>
collections.value.map((collection) => {
const items = collection.entries.map(({ game }) => {
Expand Down Expand Up @@ -273,15 +244,6 @@ const filteredNavigation = computed(() => {
}))
.filter((e) => e.items.length > 0);
});

listen("update_library", async (event) => {
console.log("Updating library");
let oldNavigation = currentNavigation.value;
await calculateGames();
if (oldNavigation !== currentNavigation.value) {
router.push("/library");
}
});
</script>

<style scoped>
Expand Down
71 changes: 71 additions & 0 deletions main/composables/library-games.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { invoke } from "@tauri-apps/api/core";
import {
GameStatusEnum,
type Collection,
type Game,
type GameStatus,
} from "~/types";

export const useLibraryGames = () => {
const loading = ref(false);
const games: {
[key: string]: { game: Game; status: Ref<GameStatus, GameStatus> };
} = {};
const icons: { [key: string]: string } = {};
const covers: { [key: string]: string } = {};
const collections: Ref<Collection[]> = ref([]);

async function calculateGames(clearAll = false, forceRefresh = false) {
if (clearAll) {
collections.value = [];
loading.value = true;
}
// If we update immediately, the navigation gets re-rendered before we
// add all the necessary state, and it freaks tf out
const newGames = await invoke<Game[]>("fetch_library", {
hardRefresh: forceRefresh,
});
const otherCollections = await invoke<Collection[]>("fetch_collections", {
hardRefresh: forceRefresh,
});
const allGames = [
...newGames,
...otherCollections
.map((e) => e.entries)
.flat()
.map((e) => e.game),
].filter((v, i, a) => a.indexOf(v) === i);

for (const game of allGames) {
if (games[game.id]) continue;
games[game.id] = await useGame(game.id);
}
for (const game of allGames) {
if (icons[game.id]) continue;
icons[game.id] = await useObject(game.mIconObjectId);
}
for (const game of allGames) {
if (covers[game.id]) continue;
covers[game.id] = await useObject(game.mCoverObjectId);
}

const libraryCollection = {
id: "library",
name: "Library",
isDefault: true,
entries: newGames.map((e) => ({ gameId: e.id, game: e })),
} satisfies Collection;

loading.value = false;
collections.value = [libraryCollection, ...otherCollections];
}

return {
loading: readonly(loading),
games,
icons,
covers,
collections: readonly(collections),
calculateGames,
};
};
39 changes: 39 additions & 0 deletions main/composables/library-view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { invoke } from "@tauri-apps/api/core";
import type { Settings } from "~/types";

export const useLibraryView = () => {
const libraryView = useState<"list" | "grid">("library-view", () => "list");

// Load initial value from settings if not already loaded
const state = libraryView.value;
if (state === "list") {
invoke<Settings>("fetch_settings").then((settings) => {
if (libraryView.value === "list") {
libraryView.value = (settings?.libraryView as "list" | "grid") || "list";
}
});
}

const toggleView = async () => {
const newView = libraryView.value === "list" ? "grid" : "list";
libraryView.value = newView;
await invoke("update_settings", {
newSettings: { libraryView: newView },
});
};

const setView = async (view: "list" | "grid") => {
if (libraryView.value !== view) {
libraryView.value = view;
await invoke("update_settings", {
newSettings: { libraryView: view },
});
}
};

return {
libraryView,
toggleView,
setView,
};
};
Loading