10 Commits

Author SHA1 Message Date
e817e5778b chore(deps): bump @nuxt/devtools from 2.6.2 to 2.7.0
Bumps [@nuxt/devtools](https://github.com/nuxt/devtools/tree/HEAD/packages/devtools) from 2.6.2 to 2.7.0.
- [Release notes](https://github.com/nuxt/devtools/releases)
- [Changelog](https://github.com/nuxt/devtools/blob/main/CHANGELOG.md)
- [Commits](https://github.com/nuxt/devtools/commits/v2.7.0/packages/devtools)

---
updated-dependencies:
- dependency-name: "@nuxt/devtools"
  dependency-version: 2.7.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-07 18:11:35 +00:00
289034d0c8 Add manual release date editor (#262)
* add manual release date editor

* watch() releaseDate instead of relying on coreMetadata updates

* make linter happy

---------

Co-authored-by: udifogiel <udifogiel@proton.me>
2025-11-07 09:27:37 +11:00
2a23f4d14c Fix lints 2025-10-24 09:33:39 +11:00
b20d355527 Improve igdb metadata fetching (#257)
* improve igdb metadata fetching

    * Make sure to get images with reasonable resolution.
      By default the url igdb returns is in "t_thumb" size,
      an image of size 90x90, which is good only for the icon,
      but bad for pretty much else. This commit will make sure
      covers will be of size "t_cover_big", artworks of 1080p
      height (i.e. "t_1080p") and logos will have their original
      size ("t_original"). Maybe "t_logo_med" is more appropriate?

    * Fetch screenshots as well.

    * Use a separate image for icon and for cover.
      icon needs to be a square, and can be of low
      resolution, so the "t_thmb" size is more appropriate
      for him.

    * If there is a storyline for a game use it as a short
      description.

* IDGB -> IGDB

* use the longer text between storyline and description for description

---------

Co-authored-by: udifogiel <udifogiel@proton.me>
2025-10-24 09:25:54 +11:00
fa9620eac1 Use 7zip for archive backend (#264)
* feat: use 7zip for archive backend

* fix: lint
2025-10-13 13:02:27 +11:00
a201b62c04 chore(deps): bump axios from 1.11.0 to 1.12.0 (#246)
Bumps [axios](https://github.com/axios/axios) from 1.11.0 to 1.12.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.11.0...v1.12.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-13 11:36:59 +11:00
9bf164ab77 chore(deps): bump tar-fs from 2.1.3 to 2.1.4 (#256)
Bumps [tar-fs](https://github.com/mafintosh/tar-fs) from 2.1.3 to 2.1.4.
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.3...v2.1.4)

---
updated-dependencies:
- dependency-name: tar-fs
  dependency-version: 2.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-13 11:36:31 +11:00
97c6f3490c Add store sort options (#238) (#261)
This commit adds the option
to sort store items by name,
and to choose the sort order.

Co-authored-by: udifogiel <udifogiel@proton.me>
2025-10-13 11:20:48 +11:00
f5cb856d3d Carousel UI improvements (#258)
* make carousel pagination clickable

* make carousel in game pages wrap around

* make items in store fit the row when the filter menu is visible

---------

Co-authored-by: udifogiel <udifogiel@proton.me>
2025-10-13 11:18:52 +11:00
67de1f6c02 Add Steam metadata provider (#232) (#250)
* feat(metadata): add Steam metadata provider (#232)

* style(steam): remove emojis from log messages
2025-09-21 10:43:35 +10:00
17 changed files with 1423 additions and 269 deletions

View File

@ -4,9 +4,10 @@
v-for="(_, i) in amount"
:key="i"
:class="[
carousel.currentSlide == i ? 'bg-blue-600 w-6' : 'bg-zinc-700 w-3',
carousel.currentSlide === i ? 'bg-blue-600 w-6' : 'bg-zinc-700 w-3',
'transition-all cursor-pointer h-2 rounded-full',
]"
@click="slideTo(i)"
/>
</div>
</template>
@ -18,8 +19,8 @@ const carousel = inject(injectCarousel)!;
const amount = carousel.maxSlide - carousel.minSlide + 1;
// function slideTo(index: number) {
// const offsetIndex = index + carousel.minSlide;
// carousel.nav.slideTo(offsetIndex);
// }
function slideTo(index: number) {
const offsetIndex = index + carousel.minSlide;
carousel.nav.slideTo(offsetIndex);
}
</script>

View File

@ -29,6 +29,23 @@
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 pt-8">
<MultiItemSelector v-model="currentTags" :items="tags" />
<div class="flex flex-col">
<label
for="releaseDate"
class="text-sm/6 font-medium text-zinc-100"
>
{{ $t("library.admin.game.editReleaseDate") }}
</label>
<div class="mt-2">
<input
id="releaseDate"
v-model="releaseDate"
type="date"
name="releaseDate"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
/>
</div>
</div>
</div>
<!-- image carousel pick -->
@ -491,11 +508,38 @@ watch(
{ deep: true },
);
const releaseDate = ref(
game.value.mReleased
? new Date(game.value.mReleased).toISOString().substring(0, 10)
: "",
);
watch(releaseDate, async (newDate) => {
const body: PatchGameBody = {};
if (newDate) {
const parsed = new Date(newDate);
if (!isNaN(parsed.getTime())) {
body.mReleased = parsed;
}
}
await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH",
params: {
id: game.value.id,
},
body,
failTitle: "Failed to update release date",
});
});
const { t } = useI18n();
// I don't know why I split these fields off.
const coreMetadataName = ref(game.value.mName);
const coreMetadataDescription = ref(game.value.mShortDescription);
const coreMetadataIconUrl = ref(useObject(game.value.mIconObjectId));
const coreMetadataIconFileUpload = ref<FileList | undefined>();
const coreMetadataLoading = ref(false);

View File

@ -1,3 +1,12 @@
<i18n>
{
"en": {
"↓": "↓",
"↑": "↑"
}
}
</i18n>
<template>
<div>
<div>
@ -176,9 +185,12 @@
active ? 'bg-zinc-900 outline-hidden' : '',
'w-full text-left block px-4 py-2 text-sm',
]"
@click="() => (currentSort = option.param)"
@click.prevent="handleSortClick(option, $event)"
>
{{ option.name }}
<span v-if="currentSort === option.param">
{{ sortOrder === "asc" ? $t("↑") : $t("↓") }}
</span>
</button>
</MenuItem>
</div>
@ -292,7 +304,7 @@
<div
v-if="games?.length ?? 0 > 0"
ref="product-grid"
class="relative lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-7 gap-4"
class="relative lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4"
>
<!-- Your content -->
<GamePanel
@ -389,8 +401,13 @@ const sorts: Array<StoreSortOption> = [
name: "Recently Added",
param: "recent",
},
{
name: "Name",
param: "name",
},
];
const currentSort = ref(sorts[0].param);
const sortOrder = ref<"asc" | "desc">("desc");
const options: Array<StoreFilterOption> = [
...(tags.length > 0
@ -466,7 +483,7 @@ async function updateGames(query: string, resetGames: boolean) {
results: Array<SerializeObject<GameModel>>;
count: number;
}>(
`/api/v1/store?take=50&skip=${resetGames ? 0 : games.value?.length || 0}&sort=${currentSort.value}${query ? "&" + query : ""}`,
`/api/v1/store?take=50&skip=${resetGames ? 0 : games.value?.length || 0}&sort=${currentSort.value}&order=${sortOrder.value}${query ? "&" + query : ""}`,
);
if (resetGames) {
games.value = newValues.results;
@ -483,6 +500,19 @@ watch(filterQuery, (newUrl) => {
watch(currentSort, (_) => {
updateGames(filterQuery.value, true);
});
watch(sortOrder, (_) => {
updateGames(filterQuery.value, true);
});
await updateGames(filterQuery.value, true);
function handleSortClick(option: StoreSortOption, event: MouseEvent) {
event.stopPropagation();
if (currentSort.value === option.param) {
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
} else {
currentSort.value = option.param;
sortOrder.value = option.param === "name" ? "asc" : "desc";
}
}
</script>

View File

@ -292,6 +292,7 @@
"deleteImage": "Delete image",
"editGameDescription": "Game Description",
"editGameName": "Game Name",
"editReleaseDate": "Release Date",
"imageCarousel": "Image Carousel",
"imageCarouselDescription": "Customise what images and what order are shown on the store page.",
"imageCarouselEmpty": "No images added to the carousel yet.",

View File

@ -256,6 +256,7 @@ export default defineNuxtConfig({
"https://www.giantbomb.com",
"https://images.pcgamingwiki.com",
"https://images.igdb.com",
"https://*.steamstatic.com",
],
},
strictTransportSecurity: false,

View File

@ -21,7 +21,7 @@
},
"dependencies": {
"@discordapp/twemoji": "^16.0.1",
"@drop-oss/droplet": "3.0.1",
"@drop-oss/droplet": "3.2.0",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@lobomfz/prismark": "0.0.3",
@ -33,7 +33,7 @@
"@vueuse/nuxt": "13.6.0",
"argon2": "^0.43.0",
"arktype": "^2.1.10",
"axios": "^1.7.7",
"axios": "^1.12.0",
"bcryptjs": "^3.0.2",
"cheerio": "^1.0.0",
"cookie-es": "^2.0.0",

View File

@ -72,7 +72,7 @@
{{ $t("store.images") }}
</h2>
<div class="relative">
<VueCarousel :items-to-show="1">
<VueCarousel :items-to-show="1" :wrap-around="true">
<VueSlide
v-for="image in game.mImageCarouselObjectIds"
:key="image"

View File

@ -183,7 +183,7 @@
{{ game.mShortDescription }}
</p>
<div class="mt-6 py-4 rounded">
<VueCarousel :items-to-show="1">
<VueCarousel :items-to-show="1" :wrap-around="true">
<VueSlide
v-for="image in game.mImageCarouselObjectIds"
:key="image"

459
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1 +1,4 @@
overrides:
droplet: link:../../.local/share/pnpm/global/5/node_modules/@drop-oss/droplet
shamefullyHoist: true

View File

@ -0,0 +1,8 @@
-- AlterEnum
ALTER TYPE "MetadataSource" ADD VALUE 'Steam';
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@ -1,6 +1,7 @@
enum MetadataSource {
Manual
GiantBomb
Steam
PCGamingWiki
IGDB
Metacritic

View File

@ -18,7 +18,8 @@ const StoreRead = type({
company: "string?",
companyActions: "string = 'published,developed'",
sort: "'default' | 'newest' | 'recent' = 'default'",
sort: "'default' | 'newest' | 'recent' | 'name' = 'default'",
order: "'asc' | 'desc' = 'desc'",
});
export default defineEventHandler(async (h3) => {
@ -101,10 +102,13 @@ export default defineEventHandler(async (h3) => {
switch (options.sort) {
case "default":
case "newest":
sort.mReleased = "desc";
sort.mReleased = options.order;
break;
case "recent":
sort.created = "desc";
sort.created = options.order;
break;
case "name":
sort.mName = options.order;
break;
}

View File

@ -191,7 +191,7 @@ export class GiantBombProvider implements MetadataProvider {
const res = await publisher(pub.name);
if (res === undefined) {
context?.logger.warn(`Failed to import publisher "${pub}"`);
context?.logger.warn(`Failed to import publisher "${pub.name}"`);
continue;
}
context?.logger.info(`Imported publisher "${pub.name}"`);
@ -208,10 +208,10 @@ export class GiantBombProvider implements MetadataProvider {
const res = await developer(dev.name);
if (res === undefined) {
context?.logger.warn(`Failed to import developer "${dev}"`);
context?.logger.warn(`Failed to import developer "${dev.name}"`);
continue;
}
context?.logger.info(`Imported developer "${dev}"`);
context?.logger.info(`Imported developer "${dev.name}"`);
developers.push(res);
}
}

View File

@ -72,7 +72,7 @@ interface IGDBCompanyWebsite extends IGDBItem {
}
interface IGDBCover extends IGDBItem {
url: string;
image_id: string;
}
interface IGDBSearchStub extends IGDBItem {
@ -179,7 +179,7 @@ export class IGDBProvider implements MetadataProvider {
if (response.status !== 200)
throw new Error(
`Error in IDGB \nStatus Code: ${response.status}\n${response.data}`,
`Error in IGDB \nStatus Code: ${response.status}\n${response.data}`,
);
this.accessToken = response.data.access_token;
@ -187,7 +187,7 @@ export class IGDBProvider implements MetadataProvider {
seconds: response.data.expires_in,
});
logger.info("IDGB done authorizing with twitch");
logger.info("IGDB done authorizing with twitch");
}
private async refreshCredentials() {
@ -246,39 +246,47 @@ export class IGDBProvider implements MetadataProvider {
return <T[]>response.data;
}
private async _getMediaInternal(mediaID: IGDBID, type: string) {
private async _getMediaInternal(
mediaID: IGDBID,
type: string,
size: string = "t_thumb",
) {
if (mediaID === undefined)
throw new Error(
`IGDB mediaID when getting item of type ${type} was undefined`,
);
const body = `where id = ${mediaID}; fields url;`;
const body = `where id = ${mediaID}; fields image_id;`;
const response = await this.request<IGDBCover>(type, body);
let result = "";
if (!response.length || !response[0].image_id) {
throw new Error(`No image_id found for ${type} with id ${mediaID}`);
}
response.forEach((cover) => {
if (cover.url.startsWith("https:")) {
result = cover.url;
} else {
// twitch *sometimes* provides it in the format "//images.igdb.com"
result = `https:${cover.url}`;
}
});
const imageId = response[0].image_id;
const result = `https://images.igdb.com/igdb/image/upload/${size}/${imageId}.jpg`;
return result;
}
private async getCoverURL(id: IGDBID) {
return await this._getMediaInternal(id, "covers");
return await this._getMediaInternal(id, "covers", "t_cover_big");
}
private async getArtworkURL(id: IGDBID) {
return await this._getMediaInternal(id, "artworks");
return await this._getMediaInternal(id, "artworks", "t_1080p");
}
private async getScreenshotURL(id: IGDBID) {
return await this._getMediaInternal(id, "screenshots", "t_1080p");
}
private async getIconURL(id: IGDBID) {
return await this._getMediaInternal(id, "covers", "t_thumb");
}
private async getCompanyLogoURl(id: IGDBID) {
return await this._getMediaInternal(id, "company_logos");
return await this._getMediaInternal(id, "company_logos", "t_original");
}
private trimMessage(msg: string, len: number) {
@ -327,7 +335,7 @@ export class IGDBProvider implements MetadataProvider {
let icon = "";
const cover = response[i].cover;
if (cover !== undefined) {
icon = await this.getCoverURL(cover);
icon = await this.getIconURL(cover);
} else {
icon = "";
}
@ -355,23 +363,26 @@ export class IGDBProvider implements MetadataProvider {
const currentGame = (await this.request<IGDBGameFull>("games", body)).at(0);
if (!currentGame) throw new Error("No game found on IGDB with that id");
context?.logger.info("Using IDGB provider.");
context?.logger.info("Using IGDB provider.");
let iconRaw;
let iconRaw, coverRaw;
const cover = currentGame.cover;
if (cover !== undefined) {
context?.logger.info("Found cover URL, using...");
iconRaw = await this.getCoverURL(cover);
iconRaw = await this.getIconURL(cover);
coverRaw = await this.getCoverURL(cover);
} else {
context?.logger.info("Missing cover URL, using fallback...");
iconRaw = jdenticon.toPng(id, 512);
coverRaw = iconRaw;
}
const icon = createObject(iconRaw);
const coverID = createObject(coverRaw);
let banner;
const images = [icon];
const images = [coverID];
for (const art of currentGame.artworks ?? []) {
const objectId = createObject(await this.getArtworkURL(art));
if (!banner) {
@ -384,6 +395,11 @@ export class IGDBProvider implements MetadataProvider {
banner = createObject(jdenticon.toPng(id, 512));
}
for (const screenshot of currentGame.screenshots ?? []) {
const objectId = createObject(await this.getScreenshotURL(screenshot));
images.push(objectId);
}
context?.progress(20);
const publishers: CompanyModel[] = [];
@ -452,13 +468,25 @@ export class IGDBProvider implements MetadataProvider {
const genres = await this.getGenres(currentGame.genres);
const deck = this.trimMessage(currentGame.summary, 280);
let description = "";
let shortDescription = "";
if (currentGame.summary.length > (currentGame.storyline?.length ?? 0)) {
description = currentGame.summary;
shortDescription = this.trimMessage(
currentGame.storyline ?? currentGame.summary,
280,
);
} else {
description = currentGame.storyline ?? currentGame.summary;
shortDescription = this.trimMessage(currentGame.summary, 280);
}
const metadata = {
id: currentGame.id.toString(),
name: currentGame.name,
shortDescription: deck,
description: currentGame.summary,
shortDescription,
description,
released,
genres,
@ -471,7 +499,7 @@ export class IGDBProvider implements MetadataProvider {
icon,
bannerId: banner,
coverId: icon,
coverId: coverID,
images,
};

File diff suppressed because it is too large Load Diff

View File

@ -5,11 +5,13 @@ import { GiantBombProvider } from "../internal/metadata/giantbomb";
import { IGDBProvider } from "../internal/metadata/igdb";
import { ManualMetadataProvider } from "../internal/metadata/manual";
import { PCGamingWikiProvider } from "../internal/metadata/pcgamingwiki";
import { SteamProvider } from "../internal/metadata/steam";
import { logger } from "~/server/internal/logging";
export default defineNitroPlugin(async (_nitro) => {
const metadataProviders = [
GiantBombProvider,
SteamProvider,
PCGamingWikiProvider,
IGDBProvider,
];