feat(ui): more ui improvements

This commit is contained in:
DecDuck
2024-11-24 16:12:19 +11:00
parent 305de9f45a
commit e408ac5df8
12 changed files with 201 additions and 85 deletions

View File

@ -2,7 +2,7 @@
<NuxtLink <NuxtLink
v-if="game" v-if="game"
:href="`/store/${game.id}`" :href="`/store/${game.id}`"
class="rounded overflow-hidden w-48 h-64 group relative transition-all duration-300 text-left transform-3d hover:rotate-y-180" class="rounded overflow-hidden w-48 h-64 group relative transition-all duration-300 text-left"
> >
<img :src="useObject(game.mCoverId)" class="w-full h-full object-cover" /> <img :src="useObject(game.mCoverId)" class="w-full h-full object-cover" />
<div <div

View File

@ -38,17 +38,17 @@
<div class="mt-2"> <div class="mt-2">
<label <label
for="file-upload" for="file-upload"
class="cursor-pointer transition relative block w-full rounded-lg border-2 border-dashed border-zinc-800 p-12 text-center hover:border-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2" class="group cursor-pointer transition relative block w-full rounded-lg border-2 border-dashed border-zinc-600 p-12 text-center hover:border-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2"
> >
<ArrowUpTrayIcon <ArrowUpTrayIcon
class="mx-auto h-6 w-6 text-zinc-700" class="transition mx-auto h-6 w-6 text-zinc-600 group-hover:text-zinc-700"
stroke="currentColor" stroke="currentColor"
fill="none" fill="none"
viewBox="0 0 48 48" viewBox="0 0 48 48"
aria-hidden="true" aria-hidden="true"
/> />
<span <span
class="mt-2 block text-sm font-semibold text-zinc-100" class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
>Upload file</span >Upload file</span
> >
<p class="mt-1 text-xs text-zinc-400" v-if="currentFile"> <p class="mt-1 text-xs text-zinc-400" v-if="currentFile">

View File

@ -49,8 +49,27 @@
aria-hidden="true" aria-hidden="true"
/> />
</NuxtLink> </NuxtLink>
<div class="inline-flex items-center gap-x-3"> <table class="min-w-full">
<span class="text-zinc-100 font-semibold">Available on:</span> <tbody>
<tr>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
>
Released
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ moment(game.mReleased).format("Do MMMM, YYYY") }}
</td>
</tr>
<tr>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
>
Platform(s)
</td>
<td
class="whitespace-nowrap inline-flex gap-x-4 px-3 py-4 text-sm text-zinc-400"
>
<component <component
v-for="platform in platforms" v-for="platform in platforms"
:is="icons[platform]" :is="icons[platform]"
@ -61,7 +80,29 @@
class="font-semibold text-blue-600" class="font-semibold text-blue-600"
>coming soon</span >coming soon</span
> >
</div> </td>
</tr>
<tr>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
>
Rating
</td>
<td
class="whitespace-nowrap flex flex-row items-center gap-x-1 px-3 py-4 text-sm text-zinc-400"
>
<StarIcon
v-for="value in ratingArray"
:class="[
value ? 'text-yellow-600' : 'text-zinc-600',
'w-4 h-4',
]"
/>
<span class="text-zinc-600">({{ game.mReviewCount }} reviews)</span>
</td>
</tr>
</tbody>
</table>
</div> </div>
<div class="row-start-2 lg:row-start-1 lg:col-span-3"> <div class="row-start-2 lg:row-start-1 lg:col-span-3">
@ -118,8 +159,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { PlusIcon } from "@heroicons/vue/20/solid"; import { PlusIcon } from "@heroicons/vue/20/solid";
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline"; import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
import { StarIcon } from "@heroicons/vue/24/solid";
import { Platform, type Game, type GameVersion } from "@prisma/client"; import { Platform, type Game, type GameVersion } from "@prisma/client";
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import moment from "moment";
import LinuxLogo from "~/components/icons/LinuxLogo.vue"; import LinuxLogo from "~/components/icons/LinuxLogo.vue";
import WindowsLogo from "~/components/WindowsLogo.vue"; import WindowsLogo from "~/components/WindowsLogo.vue";
@ -168,6 +211,11 @@ const icons = {
[Platform.Windows]: WindowsLogo, [Platform.Windows]: WindowsLogo,
}; };
const rating = Math.round(game.mReviewRating * 5);
const ratingArray = Array(5)
.fill(null)
.map((_, i) => i + 1 <= rating);
useHead({ useHead({
title: game.mName, title: game.mName,
}); });

View File

@ -13,9 +13,10 @@
``` ```
--> -->
<template> <template>
<div class="w-full"> <div class="w-full flex flex-col">
<!-- Hero section --> <!-- Hero section -->
<VueCarousel <VueCarousel
v-if="recent.length > 0"
:wrapAround="true" :wrapAround="true"
:items-to-show="1" :items-to-show="1"
:autoplay="15 * 1000" :autoplay="15 * 1000"
@ -32,7 +33,7 @@
/> />
</div> </div>
<div <div
class="relative w-full h-full bg-gray-900/75 px-6 py-32 sm:px-12 sm:py-40 lg:px-16" class="relative w-full h-full bg-zinc-900/75 px-6 py-32 sm:px-12 sm:py-40 lg:px-16"
> >
<div <div
class="relative mx-auto flex max-w-xl flex-col items-center text-center" class="relative mx-auto flex max-w-xl flex-col items-center text-center"
@ -62,19 +63,40 @@
<CarouselPagination class="py-2" :items="recent" /> <CarouselPagination class="py-2" :items="recent" />
</template> </template>
</VueCarousel> </VueCarousel>
<div
v-else
class="w-full h-full flex items-center justify-center bg-zinc-950/50 px-6 py-32 sm:px-12 sm:py-40 lg:px-16"
>
<h2
class="uppercase text-xl font-bold tracking-tight text-zinc-700 sm:text-3xl"
>
no game
</h2>
</div>
<!-- recently updated --> <!-- new releases -->
<div class="px-4 sm:px-12 py-4"> <div class="px-4 sm:px-12 py-4">
<h1 class="text-zinc-100 text-2xl font-bold font-display">Recently updated</h1> <h1 class="text-zinc-100 text-2xl font-bold font-display">
Recently released
</h1>
<NuxtLink class="text-blue-600 font-semibold" <NuxtLink class="text-blue-600 font-semibold"
>Explore more &rarr;</NuxtLink >Explore more &rarr;</NuxtLink
> >
<div class="mt-4"> <div class="mt-4">
<GameCarousel <GameCarousel :items="released" :min="12" />
v-if="updated" </div>
:items="updated.map((e) => e.game)" </div>
:min="24"
/> <!-- recently updated -->
<div class="px-4 sm:px-12 py-4">
<h1 class="text-zinc-100 text-2xl font-bold font-display">
Recently updated
</h1>
<NuxtLink class="text-blue-600 font-semibold"
>Explore more &rarr;</NuxtLink
>
<div class="mt-4">
<GameCarousel :items="updated" :min="12" />
</div> </div>
</div> </div>
</div> </div>
@ -82,8 +104,11 @@
<script setup lang="ts"> <script setup lang="ts">
const headers = useRequestHeaders(["cookie"]); const headers = useRequestHeaders(["cookie"]);
const { data: recent } = await useFetch("/api/v1/store/recent", { headers }); const recent = await $fetch("/api/v1/store/recent", { headers });
const { data: updated } = await useFetch("/api/v1/store/updated", { headers }); const updated = await $fetch("/api/v1/store/updated", { headers });
const released = await $fetch("/api/v1/store/released", {
headers,
});
useHead({ useHead({
title: "Store", title: "Store",

View File

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `mReleased` to the `Game` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Game" ADD COLUMN "mReleased" TIMESTAMP(3) NOT NULL;

View File

@ -17,9 +17,10 @@ model Game {
mDescription String // Supports markdown mDescription String // Supports markdown
mDevelopers Developer[] mDevelopers Developer[]
mPublishers Publisher[] mPublishers Publisher[]
mReleased DateTime // When the game was released
mReviewCount Int mReviewCount Int
mReviewRating Float mReviewRating Float // 0 to 1
mIconId String // linked to objects in s3 mIconId String // linked to objects in s3
mBannerId String // linked to objects in s3 mBannerId String // linked to objects in s3

View File

@ -0,0 +1,15 @@
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const userId = await h3.context.session.getUserId(h3);
if (!userId) throw createError({ statusCode: 403 });
const games = await prisma.game.findMany({
orderBy: {
mReleased: "desc",
},
take: 12,
});
return games;
});

View File

@ -12,14 +12,16 @@ export default defineEventHandler(async (h3) => {
}, },
select: { select: {
game: true, game: true,
created: true,
platform: true,
}, },
orderBy: { orderBy: {
created: "desc", created: "desc",
}, },
take: 8, take: 12,
}); });
return versions; const games = versions
.map((e) => e.game)
.filter((v, i, a) => a.findIndex((e) => e.id === v.id) === i);
return games;
}); });

View File

@ -1,6 +1,6 @@
import { EnumDictionary } from "../utils/types"; import { EnumDictionary } from "../utils/types";
import https from "https"; import https from "https";
import { useGlobalCertificateAuthority } from "~/server/plugins/ca"; import { useCertificateAuthority } from "~/server/plugins/ca";
import prisma from "../db/database"; import prisma from "../db/database";
import { ClientCapabilities } from "@prisma/client"; import { ClientCapabilities } from "@prisma/client";
@ -48,7 +48,7 @@ class CapabilityManager {
) )
return false; return false;
const ca = useGlobalCertificateAuthority(); const ca = useCertificateAuthority();
const serverCertificate = await ca.fetchClientCertificate("server"); const serverCertificate = await ca.fetchClientCertificate("server");
if (!serverCertificate) if (!serverCertificate)
throw new Error( throw new Error(

View File

@ -45,6 +45,11 @@ interface GameResult {
publishers: Array<{ id: number; name: string }>; publishers: Array<{ id: number; name: string }>;
number_of_user_reviews: number; // Doesn't provide an actual rating, so kinda useless number_of_user_reviews: number; // Doesn't provide an actual rating, so kinda useless
original_release_date?: string;
expected_release_day?: number;
expected_release_month?: number;
expected_release_year?: number;
image: { image: {
icon_url: string; icon_url: string;
@ -180,11 +185,20 @@ export class GiantBombProvider implements MetadataProvider {
const images = [banner, ...imageURLs.map(createObject)]; const images = [banner, ...imageURLs.map(createObject)];
const releaseDate = gameData.original_release_date
? moment(gameData.original_release_date).toDate()
: moment(
`${gameData.expected_release_day ?? 1}/${
gameData.expected_release_month ?? 1
}/${gameData.expected_release_year ?? new Date().getFullYear()}`
).toDate();
const metadata: GameMetadata = { const metadata: GameMetadata = {
id: gameData.guid, id: gameData.guid,
name: gameData.name, name: gameData.name,
shortDescription: gameData.deck, shortDescription: gameData.deck,
description: longDescription, description: longDescription,
released: releaseDate,
reviewCount: 0, reviewCount: 0,
reviewRating: 0, reviewRating: 0,

View File

@ -26,10 +26,10 @@ export abstract class MetadataProvider {
abstract search(query: string): Promise<GameMetadataSearchResult[]>; abstract search(query: string): Promise<GameMetadataSearchResult[]>;
abstract fetchGame(params: _FetchGameMetadataParams): Promise<GameMetadata>; abstract fetchGame(params: _FetchGameMetadataParams): Promise<GameMetadata>;
abstract fetchPublisher( abstract fetchPublisher(
params: _FetchPublisherMetadataParams, params: _FetchPublisherMetadataParams
): Promise<PublisherMetadata>; ): Promise<PublisherMetadata>;
abstract fetchDeveloper( abstract fetchDeveloper(
params: _FetchDeveloperMetadataParams, params: _FetchDeveloperMetadataParams
): Promise<DeveloperMetadata>; ): Promise<DeveloperMetadata>;
} }
@ -56,7 +56,7 @@ export class MetadataHandler {
Object.assign({}, result, { Object.assign({}, result, {
sourceId: provider.id(), sourceId: provider.id(),
sourceName: provider.name(), sourceName: provider.name(),
}), })
); );
resolve(mappedResults); resolve(mappedResults);
}); });
@ -74,7 +74,7 @@ export class MetadataHandler {
async createGame( async createGame(
result: InternalGameMetadataResult, result: InternalGameMetadataResult,
libraryBasePath: string, libraryBasePath: string
) { ) {
const provider = this.providers.get(result.sourceId); const provider = this.providers.get(result.sourceId);
if (!provider) if (!provider)
@ -92,7 +92,7 @@ export class MetadataHandler {
const [createObject, pullObjects, dumpObjects] = this.objectHandler.new( const [createObject, pullObjects, dumpObjects] = this.objectHandler.new(
{}, {},
["internal:read"], ["internal:read"]
); );
let metadata; let metadata;
@ -127,6 +127,7 @@ export class MetadataHandler {
mReviewCount: metadata.reviewCount, mReviewCount: metadata.reviewCount,
mReviewRating: metadata.reviewRating, mReviewRating: metadata.reviewRating,
mReleased: metadata.released,
mIconId: metadata.icon, mIconId: metadata.icon,
mBannerId: metadata.bannerId, mBannerId: metadata.bannerId,
@ -144,7 +145,7 @@ export class MetadataHandler {
return (await this.fetchDeveloperPublisher( return (await this.fetchDeveloperPublisher(
query, query,
"fetchDeveloper", "fetchDeveloper",
"developer", "developer"
)) as Developer; )) as Developer;
} }
@ -152,7 +153,7 @@ export class MetadataHandler {
return (await this.fetchDeveloperPublisher( return (await this.fetchDeveloperPublisher(
query, query,
"fetchPublisher", "fetchPublisher",
"publisher", "publisher"
)) as Publisher; )) as Publisher;
} }
@ -161,7 +162,7 @@ export class MetadataHandler {
private async fetchDeveloperPublisher( private async fetchDeveloperPublisher(
query: string, query: string,
functionName: any, functionName: any,
databaseName: any, databaseName: any
) { ) {
const existing = await (prisma as any)[databaseName].findFirst({ const existing = await (prisma as any)[databaseName].findFirst({
where: { where: {
@ -173,7 +174,7 @@ export class MetadataHandler {
for (const provider of this.providers.values() as any) { for (const provider of this.providers.values() as any) {
const [createObject, pullObjects, dumpObjects] = this.objectHandler.new( const [createObject, pullObjects, dumpObjects] = this.objectHandler.new(
{}, {},
["internal:read"], ["internal:read"]
); );
let result; let result;
try { try {
@ -206,7 +207,7 @@ export class MetadataHandler {
} }
throw new Error( throw new Error(
`No metadata provider found a ${databaseName} for "${query}"`, `No metadata provider found a ${databaseName} for "${query}"`
); );
} }
} }

View File

@ -14,27 +14,29 @@ export interface GameMetadataSource {
sourceName: string; sourceName: string;
} }
export type InternalGameMetadataResult = GameMetadataSearchResult & GameMetadataSource; export type InternalGameMetadataResult = GameMetadataSearchResult &
GameMetadataSource;
export interface GameMetadata { export interface GameMetadata {
id: string; id: string;
name: string; name: string;
shortDescription: string; shortDescription: string;
description: string; description: string;
released: Date;
// These are created using utility functions passed to the metadata loader // These are created using utility functions passed to the metadata loader
// (that then call back into the metadata provider chain) // (that then call back into the metadata provider chain)
publishers: Publisher[] publishers: Publisher[];
developers: Developer[] developers: Developer[];
reviewCount: number; reviewCount: number;
reviewRating: number; reviewRating: number;
// Created with another utility function // Created with another utility function
icon: ObjectReference, icon: ObjectReference;
bannerId: ObjectReference, bannerId: ObjectReference;
coverId: ObjectReference; coverId: ObjectReference;
images: ObjectReference[], images: ObjectReference[];
} }
export interface PublisherMetadata { export interface PublisherMetadata {
@ -51,12 +53,12 @@ export interface PublisherMetadata {
export type DeveloperMetadata = PublisherMetadata; export type DeveloperMetadata = PublisherMetadata;
export interface _FetchGameMetadataParams { export interface _FetchGameMetadataParams {
id: string, id: string;
publisher: (query: string) => Promise<Publisher> publisher: (query: string) => Promise<Publisher>;
developer: (query: string) => Promise<Developer> developer: (query: string) => Promise<Developer>;
createObject: (url: string) => ObjectReference createObject: (url: string) => ObjectReference;
} }
export interface _FetchPublisherMetadataParams { export interface _FetchPublisherMetadataParams {