20 Commits

Author SHA1 Message Date
c0b69048cf fix: light mode style fixes 2025-11-22 11:44:32 +11:00
1e7ed34a60 fix: lint 2025-11-22 10:46:03 +11:00
e230f79b54 feat: delete all notifications 2025-11-22 10:28:21 +11:00
973c3efa18 Merge branch 'develop' into small-fixes 2025-11-21 23:21:11 +11:00
bcb88f8069 fix: type errors 2025-11-21 23:20:53 +11:00
b842d78b94 fix: oidc scopes override 2025-11-21 23:18:24 +11:00
b0bf1a2795 fix: bump droplet 2025-11-21 23:08:49 +11:00
2d165bf997 fix: typescript for lint 2025-11-21 23:07:50 +11:00
650a3ca98d fix: add no-prisma-delete lint 2025-11-21 23:04:00 +11:00
246c97ccc9 Add additional content screenshots for Steam provider (#284) 2025-11-21 22:27:36 +11:00
f1fccd9bff Remove .gitlab-ci.yml 2025-11-20 16:09:16 +11:00
2ae7f41be0 Fix 7z archives with spaces (#288)
* fix: ignore imported versions

* fix: bump droplet for 7z fixes
2025-11-20 14:02:56 +11:00
beb824c8d9 Add metadata timeout (#287)
* Add metadata timeout

* Fix lint
2025-11-20 11:17:58 +11:00
8f41024be2 Fix Prisma build 2025-11-15 10:59:17 +11:00
2420814862 Add 7zip to container 2025-11-15 10:01:58 +11:00
41855bccd2 Bump version 2025-11-15 09:05:14 +11:00
dfa30c8a65 Admin home page #128 (#259)
* First iteration on the new PieChart component

* #128 Adds new admin home page

* Fixes code after merging conflicts

* Removes empty file

* Uses real data for admin home page, and improves style

* Reverts debugging code

* Defines missing variable

* Caches user stats data for admin home page

* Typo

* Styles improvements

* Invalidates cache on signup/signin

* Implements top 5 biggest games

* Improves styling

* Improves style

* Using generateManifest to get the proper size

* Reading data from cache

* Removes unnecessary import

* Improves caching mechanism for game sizes

* Removes lint errors

* Replaces piechart tooltip with colors in legend

* Fixes caching

* Fixes caching and slight improvement on pie chart colours

* Fixes a few bugs related to caching

* Fixes bug where app signin didn't refresh cache

* feat: style improvements

* fix: lint

---------

Co-authored-by: DecDuck <declanahofmeyr@gmail.com>
2025-11-08 09:14:45 +11: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
78 changed files with 4573 additions and 1213 deletions

View File

@ -1,54 +0,0 @@
variables:
GIT_SUBMODULE_STRATEGY: recursive
stages:
- build
services:
- docker:24.0.5-dind
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
build:
stage: build
image: docker:latest
variables:
IMAGE_NAME: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_NAME:$CI_COMMIT_SHORT_SHA
LATEST_IMAGE_NAME: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_NAME:latest
PUBLISH_IMAGE_NAME: $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
PUBLISH_LATEST_IMAGE_NAME: $CI_REGISTRY_IMAGE:latest
script:
- docker build -t $IMAGE_NAME .
- docker image tag $IMAGE_NAME $LATEST_IMAGE_NAME
- docker push $IMAGE_NAME
- docker push $LATEST_IMAGE_NAME
- |
if [ $CI_COMMIT_TAG ]; then
docker image tag $IMAGE_NAME $PUBLISH_IMAGE_NAME
docker image tag $IMAGE_NAME $PUBLISH_LATEST_IMAGE_NAME
docker push $PUBLISH_IMAGE_NAME $PUBLISH_LATEST_IMAGE_NAME
fi
build-arm64:
stage: build
image: arm64v8/docker:latest
tags:
- aarch64
variables:
IMAGE_NAME: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_NAME:$CI_COMMIT_SHORT_SHA-arm64
LATEST_IMAGE_NAME: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_NAME:latest-arm64
PUBLISH_IMAGE_NAME: $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG-arm64
PUBLISH_LATEST_IMAGE_NAME: $CI_REGISTRY_IMAGE:latest-arm64
script:
- docker build -t $IMAGE_NAME . --platform=linux/arm64
- docker image tag $IMAGE_NAME $LATEST_IMAGE_NAME
- docker push $IMAGE_NAME
- docker push $LATEST_IMAGE_NAME
- |
if [ $CI_COMMIT_TAG ]; then
docker image tag $IMAGE_NAME $PUBLISH_IMAGE_NAME
docker image tag $IMAGE_NAME $PUBLISH_LATEST_IMAGE_NAME
docker push $PUBLISH_IMAGE_NAME
docker push $PUBLISH_LATEST_IMAGE_NAME
fi

View File

@ -45,12 +45,12 @@ ENV NODE_ENV=production
ENV NUXT_TELEMETRY_DISABLED=1 ENV NUXT_TELEMETRY_DISABLED=1
# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1 # RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1
RUN apk add --no-cache pnpm RUN apk add --no-cache pnpm 7zip
RUN pnpm install prisma@6.11.1 RUN pnpm install prisma@6.11.1
# init prisma to download all required files # init prisma to download all required files
RUN pnpm prisma init RUN pnpm prisma init
COPY --from=build-system /app/package.json ./ COPY --from=build-system /app/prisma.config.ts ./
COPY --from=build-system /app/.output ./app COPY --from=build-system /app/.output ./app
COPY --from=build-system /app/prisma ./prisma COPY --from=build-system /app/prisma ./prisma
COPY --from=build-system /app/build ./startup COPY --from=build-system /app/build ./startup

View File

@ -29,6 +29,23 @@
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 pt-8"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-4 pt-8">
<MultiItemSelector v-model="currentTags" :items="tags" /> <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> </div>
<!-- image carousel pick --> <!-- image carousel pick -->
@ -491,11 +508,38 @@ watch(
{ deep: true }, { 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(); const { t } = useI18n();
// I don't know why I split these fields off. // I don't know why I split these fields off.
const coreMetadataName = ref(game.value.mName); const coreMetadataName = ref(game.value.mName);
const coreMetadataDescription = ref(game.value.mShortDescription); const coreMetadataDescription = ref(game.value.mShortDescription);
const coreMetadataIconUrl = ref(useObject(game.value.mIconObjectId)); const coreMetadataIconUrl = ref(useObject(game.value.mIconObjectId));
const coreMetadataIconFileUpload = ref<FileList | undefined>(); const coreMetadataIconFileUpload = ref<FileList | undefined>();
const coreMetadataLoading = ref(false); const coreMetadataLoading = ref(false);
@ -561,7 +605,6 @@ function coreMetadataUpdate_wrapper() {
); );
}) })
.then((newGame) => { .then((newGame) => {
console.log(newGame);
if (!newGame) return; if (!newGame) return;
Object.assign(game.value, newGame); Object.assign(game.value, newGame);
coreMetadataIconUrl.value = useObject(newGame.mIconObjectId); coreMetadataIconUrl.value = useObject(newGame.mIconObjectId);

View File

@ -51,14 +51,19 @@
@update="() => updateVersionOrder()" @update="() => updateVersionOrder()"
> >
<template <template
#item="{ element: item }: { element: GameVersionModel }" #item="{ element: item }: { element: GameVersionModelWithSize }"
> >
<div <div
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between" class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between w-full flex"
> >
<div class="text-zinc-100 font-semibold"> <div class="text-zinc-100 font-semibold flex-none">
{{ item.versionName }} {{ item.versionName }}
</div> </div>
<div
class="text-right text-zinc-400 text-xs font-normal flex-auto pr-4"
>
{{ item.size && formatBytes(item.size) }}
</div>
<div class="text-zinc-400"> <div class="text-zinc-400">
{{ item.delta ? $t("library.admin.version.delta") : "" }} {{ item.delta ? $t("library.admin.version.delta") : "" }}
</div> </div>
@ -117,6 +122,7 @@ import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3"; import type { H3Error } from "h3";
import { ExclamationCircleIcon } from "@heroicons/vue/24/outline"; import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
import { formatBytes } from "~/server/internal/utils/files";
// TODO implement UI for this page // TODO implement UI for this page
@ -130,7 +136,11 @@ const canImport = computed(
() => hasDeleted.value || props.unimportedVersions.length > 0, () => hasDeleted.value || props.unimportedVersions.length > 0,
); );
type GameAndVersions = GameModel & { versions: GameVersionModel[] }; type GameVersionModelWithSize = GameVersionModel & { size: number };
type GameAndVersions = GameModel & {
versions: GameVersionModelWithSize[];
};
const game = defineModel<SerializeObject<GameAndVersions>>() as Ref< const game = defineModel<SerializeObject<GameAndVersions>>() as Ref<
SerializeObject<GameAndVersions> SerializeObject<GameAndVersions>
>; >;

View File

@ -0,0 +1,19 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="6" y1="11" x2="10" y2="11" />
<line x1="8" y1="9" x2="8" y2="13" />
<line x1="15" y1="12" x2="15.01" y2="12" />
<line x1="18" y1="10" x2="18.01" y2="10" />
<path
d="M17.32 5H6.68a4 4 0 00-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 003 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 019.828 16h4.344a2 2 0 011.414.586L17 18c.5.5 1 1 2 1a3 3 0 003-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0017.32 5z"
/>
</svg>
</template>

View File

@ -44,9 +44,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { XMarkIcon } from "@heroicons/vue/24/solid"; import { XMarkIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { NotificationModel } from "~/prisma/client/models"; import type { NotificationModel } from "~/prisma/client/models";
const props = defineProps<{ notification: NotificationModel }>(); const props = defineProps<{
notification: SerializeObject<NotificationModel>;
}>();
async function deleteMe() { async function deleteMe() {
await $dropFetch(`/api/v1/notifications/:id`, { await $dropFetch(`/api/v1/notifications/:id`, {

View File

@ -0,0 +1,45 @@
<template>
<h2 v-if="title" class="text-lg mb-4 w-full">{{ title }}</h2>
<div class="flex flex-col xl:flex-row gap-4">
<div class="relative flex grow max-w-[12rem]">
<svg class="aspect-square grow relative inline" viewBox="0 0 100 100">
<PieChartPieSlice
v-for="slice in slices"
:key="`${slice.percentage}-${slice.totalPercentage}`"
:slice="slice"
/>
</svg>
<div class="absolute inset-0 bg-zinc-900 rounded-full m-12" />
</div>
<ul class="flex flex-col gap-y-1 justify-center text-left">
<li
v-for="slice in slices"
:key="slice.value"
class="text-sm inline-flex items-center gap-x-1"
>
<span
class="size-3 inline-block rounded-sm"
:class="CHART_COLOURS[slice.color].bg"
/>
{{
$t("common.labelValueColon", {
label: slice.label,
value: slice.value,
})
}}
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { generateSlices } from "~/components/PieChart/utils";
import type { SliceData } from "~/components/PieChart/types";
const { data, title = undefined } = defineProps<{
data: SliceData[];
title?: string | undefined;
}>();
const slices = generateSlices(data);
</script>

View File

@ -0,0 +1,35 @@
<template>
<path
v-if="slice.percentage !== 0 && slice.percentage !== 100"
:class="[CHART_COLOURS[slice.color].fill]"
:d="`
M ${slice.start}
A ${slice.radius},${slice.radius} 0 ${getFlags(slice.percentage)} ${polarToCartesian(slice.center, slice.radius, percent2Degrees(slice.totalPercentage))}
L ${slice.center}
z
`"
stroke-width="2"
/>
<circle
v-if="slice.percentage === 100"
:r="slice.radius"
:cx="slice.center.x"
:cy="slice.center.y"
:class="[CHART_COLOURS[slice.color].fill]"
stroke-width="2"
/>
</template>
<script setup lang="ts">
import type { Slice } from "~/components/PieChart/types";
import {
getFlags,
percent2Degrees,
polarToCartesian,
} from "~/components/PieChart/utils";
import { CHART_COLOURS } from "~/utils/colors";
const { slice } = defineProps<{
slice: Slice;
}>();
</script>

19
components/PieChart/types.d.ts vendored Normal file
View File

@ -0,0 +1,19 @@
import type Tuple from "~/utils/tuple";
import type { ChartColour } from "~/utils/colors";
export type Slice = {
start: Tuple;
center: Tuple;
percentage: number;
totalPercentage: number;
radius: number;
color: ChartColour;
label: string;
value: number;
};
export type SliceData = {
value: number;
color?: ChartColour;
label: string;
};

View File

@ -0,0 +1,50 @@
import Tuple from "~/utils/tuple";
import type { Slice, SliceData } from "~/components/PieChart/types";
import { sum, lastItem } from "~/utils/array";
export const START = new Tuple(50, 10);
export const CENTER = new Tuple(50, 50);
export const RADIUS = 40;
export const polarToCartesian = (
center: Tuple,
radius: number,
angleInDegrees: number,
) => {
const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180;
const x = center.x + radius * Math.cos(angleInRadians);
const y = center.y + radius * Math.sin(angleInRadians);
return new Tuple(x, y);
};
export const percent2Degrees = (percentage: number) => (360 * percentage) / 100;
export function generateSlices(data: SliceData[]): Slice[] {
return data.reduce((accumulator, currentValue, index, array) => {
const percentage =
(currentValue.value * 100) / sum(array.map((slice) => slice.value));
return [
...accumulator,
{
start: accumulator.length
? polarToCartesian(
CENTER,
RADIUS,
percent2Degrees(lastItem(accumulator).totalPercentage),
)
: START,
radius: RADIUS,
percentage: percentage,
totalPercentage:
sum(accumulator.map((element) => element.percentage)) + percentage,
center: CENTER,
color: PIE_COLOURS[index % PIE_COLOURS.length],
label: currentValue.label,
value: currentValue.value,
},
];
}, [] as Slice[]);
}
export const getFlags = (percentage: number) =>
percentage > 50 ? new Tuple(1, 1) : new Tuple(0, 1);

View File

@ -0,0 +1,31 @@
<template>
<div
:class="[
'relative h-5 rounded-xl overflow-hidden',
CHART_COLOURS[backgroundColor].bg,
]"
>
<div
:style="{ width: `${percentage}%` }"
:class="['transition-all h-full', CHART_COLOURS[color].bg]"
/>
<span
class="absolute inset-0 flex items-center justify-center text-blue-200 text-sm font-bold font-display"
>
{{ $t("tasks.admin.progress", [Math.round(percentage * 10) / 10]) }}
</span>
</div>
</template>
<script setup lang="ts">
import { type ChartColour, CHART_COLOURS } from "~/utils/colors";
const {
percentage,
color = "blue",
backgroundColor = "zinc",
} = defineProps<{
percentage: number;
color?: ChartColour;
backgroundColor?: ChartColour;
}>();
</script>

View File

@ -0,0 +1,43 @@
<template>
<table v-if="items.length > 0" class="w-full mt-4 space-y-6">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody class="divide-y divide-white/10">
<tr v-for="item in items" :key="`${item.rank}-${item.name}`">
<td
class="my-2 size-7 rounded-sm bg-zinc-950 ring ring-zinc-800 inline-flex items-center justify-center font-bold font-display text-blue-500"
>
{{ item.rank }}
</td>
<td class="w-full font-bold px-2">{{ item.name }}</td>
<td
class="text-right text-sm font-semibold text-zinc-500 whitespace-nowrap"
>
{{ item.value }}
</td>
</tr>
</tbody>
</table>
<p
v-else
class="w-full p-2 text-center uppercase text-sm font-display font-bold text-zinc-700"
>
{{ $t("common.noData") }}
</p>
</template>
<script lang="ts" setup>
export type RankItem = {
rank: number;
name: string;
value: string;
};
const { items } = defineProps<{
items: RankItem[];
}>();
</script>

193
components/SourceTable.vue Normal file
View File

@ -0,0 +1,193 @@
<template>
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table class="min-w-full divide-y divide-zinc-700">
<thead>
<tr>
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3"
>
{{ $t("common.name") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("type") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("library.admin.sources.working") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("options") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("library.admin.sources.totalSpace") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("library.admin.sources.freeSpace") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("library.admin.sources.utilizationPercentage") }}
</th>
<th
v-if="editSource || deleteSource"
scope="col"
class="relative py-3.5 pl-3 pr-4 sm:pr-3"
>
<span class="sr-only">{{ $t("actions") }}</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(source, sourceIdx) in sources"
:key="source.id"
class="even:bg-zinc-800"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
>
{{ source.name }}
</td>
<td
class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400 inline-flex gap-x-1 items-center"
>
<component
:is="optionsMetadata[source.backend].icon"
class="size-5 text-zinc-400"
/>
{{ optionsMetadata[source.backend].title }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<CheckIcon v-if="source.working" class="size-5 text-green-500" />
<XMarkIcon v-else class="size-5 text-red-500" />
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ source.options }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ source.fsStats && formatBytes(source.fsStats.totalSpace) }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ source.fsStats && formatBytes(source.fsStats.freeSpace) }}
</td>
<td
class="align-middle flex flex-cols-5 whitespace-nowrap px-3 py-4 text-sm text-zinc-400"
>
<div class="flex-auto content-right">
<ProgressBar
v-if="source.fsStats"
:percentage="
getPercentage(
source.fsStats.freeSpace,
source.fsStats.totalSpace,
)
"
:color="
getBarColor(
getPercentage(
source.fsStats.freeSpace,
source.fsStats.totalSpace,
),
)
"
background-color="slate"
/>
</div>
</td>
<td
v-if="editSource || deleteSource"
class="relative whitespace-nowrap py-4 pl-3 pr-3 text-right text-sm font-medium space-x-2"
>
<button
v-if="editSource"
class="text-blue-500 hover:text-blue-400"
@click="() => editSource(sourceIdx)"
>
{{ $t("common.edit") }}
<span class="sr-only">
{{ $t("chars.srComma", [source.name]) }}
</span>
</button>
<button
v-if="deleteSource"
class="text-red-500 hover:text-red-400"
@click="() => deleteSource(sourceIdx)"
>
{{ $t("delete") }}
<span class="sr-only">
{{ $t("chars.srComma", [source.name]) }}
</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get";
import type { LibraryBackend } from "~/prisma/client/enums";
import { BackwardIcon, CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import { DropLogo } from "#components";
import { formatBytes } from "~/server/internal/utils/files";
import { getBarColor } from "~/utils/colors";
const {
sources,
deleteSource = undefined,
editSource = undefined,
} = defineProps<{
sources: WorkingLibrarySource[];
summaryMode?: boolean;
deleteSource?: (id: number) => void;
editSource?: (id: number) => void;
}>();
const { t } = useI18n();
const optionsMetadata: {
[key in LibraryBackend]: {
title: string;
description: string;
docsLink: string;
icon: Component;
};
} = {
Filesystem: {
title: t("library.admin.sources.fsTitle"),
description: t("library.admin.sources.fsDesc"),
docsLink: "https://docs.droposs.org/docs/library#drop-style",
icon: DropLogo,
},
FlatFilesystem: {
title: t("library.admin.sources.fsFlatTitle"),
description: t("library.admin.sources.fsFlatDesc"),
docsLink: "https://docs.droposs.org/docs/library#flat-style-or-compat",
icon: BackwardIcon,
},
};
const getPercentage = (value: number, total: number) =>
((total - value) * 100) / total;
</script>

View File

@ -1,3 +1,12 @@
<i18n>
{
"en": {
"↓": "↓",
"↑": "↑"
}
}
</i18n>
<template> <template>
<div> <div>
<div> <div>
@ -180,7 +189,7 @@
> >
{{ option.name }} {{ option.name }}
<span v-if="currentSort === option.param"> <span v-if="currentSort === option.param">
{{ sortOrder === 'asc' ? '↑' : '↓' }} {{ sortOrder === "asc" ? $t("↑") : $t("↓") }}
</span> </span>
</button> </button>
</MenuItem> </MenuItem>
@ -500,11 +509,10 @@ await updateGames(filterQuery.value, true);
function handleSortClick(option: StoreSortOption, event: MouseEvent) { function handleSortClick(option: StoreSortOption, event: MouseEvent) {
event.stopPropagation(); event.stopPropagation();
if (currentSort.value === option.param) { if (currentSort.value === option.param) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'; sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
} else { } else {
currentSort.value = option.param; currentSort.value = option.param;
sortOrder.value = option.param === 'name' ? 'asc' : 'desc'; sortOrder.value = option.param === "name" ? "asc" : "desc";
} }
} }
</script> </script>

View File

@ -0,0 +1,52 @@
<template>
<div
:class="[
'border border-zinc-800 rounded-xl h-full px-6 py-4 relative bg-zinc-950/30',
{ 'min-h-50 pb-15': link, 'lg:pb-4': !link },
]"
>
<h1
v-if="props.title"
:class="[
'font-semibold text-lg w-full',
{ 'mb-3': !props.subtitle && link },
]"
>
{{ props.title }}
<div v-if="rightTitle" class="float-right">{{ props.rightTitle }}</div>
</h1>
<h2
v-if="props.subtitle"
:class="['text-zinc-400 text-sm w-full', { 'mb-3': link }]"
>
{{ props.subtitle }}
<div v-if="rightTitle" class="float-right">{{ props.rightTitle }}</div>
</h2>
<slot />
<div v-if="props.link" class="absolute bottom-5 right-5">
<NuxtLink
:to="props.link.url"
class="transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
>
{{ props.link.label }}
<ArrowRightIcon class="h-4 w-4" aria-hidden="true" />
</NuxtLink>
</div>
</div>
</template>
<script lang="ts" setup>
import { ArrowRightIcon } from "@heroicons/vue/20/solid";
const props = defineProps<{
title?: string;
subtitle?: string;
rightTitle?: string;
link?: {
url: string;
label: string;
};
}>();
</script>

View File

@ -46,7 +46,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { SerializeObject } from "nitropack";
import type { NotificationModel } from "~/prisma/client/models"; import type { NotificationModel } from "~/prisma/client/models";
const props = defineProps<{ notifications: Array<NotificationModel> }>(); const props = defineProps<{
notifications: Array<SerializeObject<NotificationModel>>;
}>();
</script> </script>

View File

@ -1,4 +1,3 @@
import type { RouteLocationNormalized } from "vue-router";
import type { NavigationItem } from "./types"; import type { NavigationItem } from "./types";
export const useCurrentNavigationIndex = ( export const useCurrentNavigationIndex = (
@ -9,7 +8,7 @@ export const useCurrentNavigationIndex = (
const currentNavigation = ref(-1); const currentNavigation = ref(-1);
function calculateCurrentNavIndex(to: RouteLocationNormalized) { function calculateCurrentNavIndex(to: typeof route) {
const validOptions = navigation const validOptions = navigation
.map((e, i) => ({ ...e, index: i })) .map((e, i) => ({ ...e, index: i }))
.filter((e) => to.fullPath.startsWith(e.prefix)); .filter((e) => to.fullPath.startsWith(e.prefix));

View File

@ -1,12 +1,16 @@
import type { SerializeObject } from "nitropack";
import type { NotificationModel } from "~/prisma/client/models"; import type { NotificationModel } from "~/prisma/client/models";
const ws = new WebSocketHandler("/api/v1/notifications/ws"); const ws = new WebSocketHandler("/api/v1/notifications/ws");
export const useNotifications = () => export const useNotifications = () =>
useState<Array<NotificationModel>>("notifications", () => []); useState<Array<SerializeObject<NotificationModel>>>(
"notifications",
() => [],
);
ws.listen((e) => { ws.listen((e) => {
const notification = JSON.parse(e) as NotificationModel; const notification = JSON.parse(e) as SerializeObject<NotificationModel>;
const notifications = useNotifications(); const notifications = useNotifications();
notifications.value.push(notification); notifications.value.push(notification);
}); });

View File

@ -2,6 +2,7 @@
import withNuxt from "./.nuxt/eslint.config.mjs"; import withNuxt from "./.nuxt/eslint.config.mjs";
import eslintConfigPrettier from "eslint-config-prettier/flat"; import eslintConfigPrettier from "eslint-config-prettier/flat";
import vueI18n from "@intlify/eslint-plugin-vue-i18n"; import vueI18n from "@intlify/eslint-plugin-vue-i18n";
import noPrismaDelete from "./rules/no-prisma-delete.mts";
export default withNuxt([ export default withNuxt([
eslintConfigPrettier, eslintConfigPrettier,
@ -19,6 +20,7 @@ export default withNuxt([
}, },
], ],
"@intlify/vue-i18n/no-missing-keys": "error", "@intlify/vue-i18n/no-missing-keys": "error",
"drop/no-prisma-delete": "error",
}, },
settings: { settings: {
"vue-i18n": { "vue-i18n": {
@ -29,5 +31,8 @@ export default withNuxt([
messageSyntaxVersion: "^11.0.0", messageSyntaxVersion: "^11.0.0",
}, },
}, },
plugins: {
drop: { rules: { "no-prisma-delete": noPrismaDelete } },
},
}, },
]); ]);

View File

@ -13,6 +13,7 @@
"all": "View all {arrow}", "all": "View all {arrow}",
"desc": "View and manage your notifications.", "desc": "View and manage your notifications.",
"markAllAsRead": "Mark all as read", "markAllAsRead": "Mark all as read",
"clear": "Clear notifications",
"markAsRead": "Mark as read", "markAsRead": "Mark as read",
"none": "No notifications", "none": "No notifications",
"notifications": "Notifications", "notifications": "Notifications",
@ -117,7 +118,9 @@
"servers": "Servers", "servers": "Servers",
"srLoading": "Loading…", "srLoading": "Loading…",
"tags": "Tags", "tags": "Tags",
"today": "Today" "today": "Today",
"labelValueColon": "{label}: {value}",
"noData": "No data"
}, },
"delete": "Delete", "delete": "Delete",
"drop": { "drop": {
@ -268,6 +271,8 @@
"store": "Store", "store": "Store",
"tokens": "API tokens" "tokens": "API tokens"
}, },
"home": "Home",
"library": "Library",
"tasks": "Tasks", "tasks": "Tasks",
"users": "Users" "users": "Users"
}, },
@ -276,7 +281,24 @@
}, },
"helpUsTranslate": "Help us translate Drop {arrow}", "helpUsTranslate": "Help us translate Drop {arrow}",
"highest": "highest", "highest": "highest",
"home": "Home", "home": {
"admin": {
"title": "Home",
"subheader": "Instance summary",
"games": "Games",
"librarySources": "Library sources",
"version": "Version",
"activeInactiveUsers": "Active/inactive users",
"activeUsers": "Active users",
"inactiveUsers": "Inactive users",
"goToUsers": "Go to users",
"users": "Users",
"biggestGamesToDownload": "Biggest games to download",
"latestVersionOnly": "Latest version only",
"biggestGamesOnServer": "Biggest games on server",
"allVersionsCombined": "All versions combined"
}
},
"library": { "library": {
"addGames": "All Games", "addGames": "All Games",
"addToLib": "Add to Library", "addToLib": "Add to Library",
@ -292,6 +314,7 @@
"deleteImage": "Delete image", "deleteImage": "Delete image",
"editGameDescription": "Game Description", "editGameDescription": "Game Description",
"editGameName": "Game Name", "editGameName": "Game Name",
"editReleaseDate": "Release Date",
"imageCarousel": "Image Carousel", "imageCarousel": "Image Carousel",
"imageCarouselDescription": "Customise what images and what order are shown on the store page.", "imageCarouselDescription": "Customise what images and what order are shown on the store page.",
"imageCarouselEmpty": "No images added to the carousel yet.", "imageCarouselEmpty": "No images added to the carousel yet.",
@ -423,7 +446,11 @@
"namePlaceholder": "My New Source", "namePlaceholder": "My New Source",
"sources": "Library Sources", "sources": "Library Sources",
"typeDesc": "The type of your source. Changes the required options.", "typeDesc": "The type of your source. Changes the required options.",
"working": "Working?" "working": "Working?",
"freeSpace": "Free space",
"totalSpace": "Total space",
"utilizationPercentage": "Utilization percentage",
"percentage": "{number}%"
}, },
"subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.", "subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.",
"title": "Libraries", "title": "Libraries",
@ -553,6 +580,7 @@
"openFeatured": "Star games in Admin Library {arrow}", "openFeatured": "Star games in Admin Library {arrow}",
"platform": "Platform | Platform | Platforms", "platform": "Platform | Platform | Platforms",
"publishers": "Publishers | Publisher | Publishers", "publishers": "Publishers | Publisher | Publishers",
"size": "Size",
"rating": "Rating", "rating": "Rating",
"readLess": "Click to read less", "readLess": "Click to read less",
"readMore": "Click to read more", "readMore": "Click to read more",

View File

@ -174,9 +174,14 @@ import { XMarkIcon } from "@heroicons/vue/24/solid";
const i18nHead = useLocaleHead(); const i18nHead = useLocaleHead();
const navigation: Array<NavigationItem & { icon: Component }> = [ const navigation: Array<NavigationItem & { icon: Component }> = [
{ label: $t("home"), route: "/admin", prefix: "/admin", icon: HomeIcon },
{ {
label: $t("userHeader.links.library"), label: $t("header.admin.home"),
route: "/admin",
prefix: "/admin",
icon: HomeIcon,
},
{
label: $t("header.admin.library"),
route: "/admin/library", route: "/admin/library",
prefix: "/admin/library", prefix: "/admin/library",
icon: ServerStackIcon, icon: ServerStackIcon,

View File

@ -1,10 +1,10 @@
<template> <template>
<div v-if="!noWrapper" class="flex flex-col w-full min-h-screen bg-zinc-900"> <div v-if="!noWrapper" class="flex flex-col w-full min-h-screen bg-zinc-900">
<UserHeader class="z-50" hydrate-on-idle /> <LazyUserHeader class="z-50" hydrate-on-idle />
<div class="grow flex"> <div class="grow flex">
<NuxtPage /> <NuxtPage />
</div> </div>
<UserFooter class="z-50" hydrate-on-interaction /> <LazyUserFooter class="z-50" hydrate-on-interaction />
</div> </div>
<div v-else class="flex w-full min-h-screen bg-zinc-900"> <div v-else class="flex w-full min-h-screen bg-zinc-900">
<NuxtPage /> <NuxtPage />

View File

@ -175,6 +175,9 @@ export default defineNuxtConfig({
}, },
i18n: { i18n: {
bundle: {
optimizeTranslationDirective: false,
},
defaultLocale: "en-us", defaultLocale: "en-us",
strategy: "no_prefix", strategy: "no_prefix",
experimental: { experimental: {

View File

@ -1,6 +1,6 @@
{ {
"name": "drop", "name": "drop",
"version": "0.3.3", "version": "0.3.4",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
@ -21,7 +21,7 @@
}, },
"dependencies": { "dependencies": {
"@discordapp/twemoji": "^16.0.1", "@discordapp/twemoji": "^16.0.1",
"@drop-oss/droplet": "3.2.0", "@drop-oss/droplet": "3.5.0",
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5", "@heroicons/vue": "^2.1.5",
"@lobomfz/prismark": "0.0.3", "@lobomfz/prismark": "0.0.3",
@ -37,6 +37,7 @@
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"cheerio": "^1.0.0", "cheerio": "^1.0.0",
"cookie-es": "^2.0.0", "cookie-es": "^2.0.0",
"dotenv": "^17.2.3",
"fast-fuzzy": "^1.12.0", "fast-fuzzy": "^1.12.0",
"file-type-mime": "^0.4.3", "file-type-mime": "^0.4.3",
"jdenticon": "^3.3.0", "jdenticon": "^3.3.0",
@ -47,7 +48,7 @@
"nuxt-security": "2.2.0", "nuxt-security": "2.2.0",
"pino": "^9.7.0", "pino": "^9.7.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"prisma": "^6.11.1", "prisma": "6.11.1",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"semver": "^7.7.1", "semver": "^7.7.1",
"stream-mime-type": "^2.0.0", "stream-mime-type": "^2.0.0",
@ -65,7 +66,6 @@
"@nuxt/eslint": "^1.3.0", "@nuxt/eslint": "^1.3.0",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@types/bcryptjs": "^3.0.0",
"@types/luxon": "^3.6.2", "@types/luxon": "^3.6.2",
"@types/node": "^22.13.16", "@types/node": "^22.13.16",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",
@ -87,8 +87,5 @@
"vue3-carousel": "^0.16.0" "vue3-carousel": "^0.16.0"
} }
}, },
"prisma": {
"schema": "./prisma"
},
"packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b" "packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b"
} }

View File

@ -1,12 +1,15 @@
<template> <template>
<div> <div>
<div class="border-b border-zinc-800 pb-4 w-full"> <div class="border-b border-zinc-800 pb-4 w-full">
<div class="flex items-center justify-between w-full"> <div
class="gap-2 flex flex-col lg:flex-row lg:items-center justify-between w-full"
>
<h2 <h2
class="text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl" class="text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
> >
{{ $t("account.notifications.notifications") }} {{ $t("account.notifications.notifications") }}
</h2> </h2>
<div class="inline-flex gap-x-2">
<button <button
:disabled="notifications.length === 0" :disabled="notifications.length === 0"
class="inline-flex items-center justify-center gap-x-2 rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-zinc-800 disabled:hover:scale-100 disabled:hover:shadow-none" class="inline-flex items-center justify-center gap-x-2 rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-zinc-800 disabled:hover:scale-100 disabled:hover:shadow-none"
@ -15,6 +18,15 @@
<CheckIcon class="size-4" /> <CheckIcon class="size-4" />
{{ $t("account.notifications.markAllAsRead") }} {{ $t("account.notifications.markAllAsRead") }}
</button> </button>
<button
:disabled="notifications.length === 0"
class="inline-flex items-center justify-center gap-x-2 rounded-md bg-red-800 px-3 py-2 text-sm font-semibold text-red-100 shadow-sm transition-all duration-200 hover:bg-red-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-red-800 disabled:hover:scale-100 disabled:hover:shadow-none"
@click="clearNotifications"
>
<TrashIcon class="size-4" />
{{ $t("account.notifications.clear") }}
</button>
</div>
</div> </div>
<p <p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8" class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
@ -31,7 +43,7 @@
:class="{ 'opacity-75': notification.read }" :class="{ 'opacity-75': notification.read }"
> >
<div class="p-6"> <div class="p-6">
<div class="flex items-start justify-between"> <div class="flex flex-col lg:flex-row items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<h3 class="text-base font-semibold text-zinc-100"> <h3 class="text-base font-semibold text-zinc-100">
{{ notification.title }} {{ notification.title }}
@ -52,7 +64,9 @@
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
<div class="ml-4 flex flex-shrink-0 items-center gap-x-2"> <div
class="mt-4 lg:mt-0 lg:ml-4 flex flex-shrink-0 items-center gap-x-2"
>
<span class="text-xs text-zinc-500"> <span class="text-xs text-zinc-500">
<RelativeTime :date="notification.created" /> <RelativeTime :date="notification.created" />
</span> </span>
@ -106,22 +120,12 @@ useHead({
}); });
// Fetch notifications // Fetch notifications
const notifications = ref<SerializeObject<NotificationModel>[]>([]); const notifications = useNotifications();
async function fetchNotifications() {
const { data } = await useFetch("/api/v1/notifications");
notifications.value = data.value || [];
}
// Initial fetch
await fetchNotifications();
// Mark a notification as read // Mark a notification as read
async function markAsRead(id: string) { async function markAsRead(id: string) {
await $dropFetch(`/api/v1/notifications/${id}/read`, { method: "POST" }); await $dropFetch(`/api/v1/notifications/${id}/read`, { method: "POST" });
const notification = notifications.value.find( const notification = notifications.value.find((n) => n.id === id);
(n: SerializeObject<NotificationModel>) => n.id === id,
);
if (notification) { if (notification) {
notification.read = true; notification.read = true;
} }
@ -129,12 +133,21 @@ async function markAsRead(id: string) {
// Mark all notifications as read // Mark all notifications as read
async function markAllAsRead() { async function markAllAsRead() {
await $dropFetch("/api/v1/notifications/readall", { method: "POST" }); await $dropFetch("/api/v1/notifications/readall", {
notifications.value.forEach( method: "POST",
(notification: SerializeObject<NotificationModel>) => { failTitle: "Failed to read all notifications",
});
notifications.value.forEach((notification) => {
notification.read = true; notification.read = true;
}, });
); }
async function clearNotifications() {
await $dropFetch("/api/v1/notifications/clear", {
method: "POST",
failTitle: "Failed to clear notifications",
});
notifications.value = [];
} }
// Delete a notification // Delete a notification

View File

@ -1,6 +1,147 @@
<template><div /></template> <template>
<div class="space-y-4">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-zinc-100">
{{ t("home.admin.title") }}
</h1>
<p class="mt-2 text-base text-zinc-400">
{{ t("home.admin.subheader") }}
</p>
</div>
</div>
<main
class="mx-auto max-w-md lg:max-w-none md:max-w-none w-full py-2 text-zinc-100"
>
<div class="grid grid-cols-6 gap-4">
<div class="col-span-6 lg:col-span-1 md:col-span-3 row-span-1">
<TileWithLink>
<div class="h-full flex">
<div class="flex-1 my-auto">
<DropLogo />
</div>
<div
class="flex-6 lg:flex-2 my-auto text-center flex lg:inline mx-4"
>
<div class="text-2xl flex-1 font-bold">{{ version }}</div>
<div class="text-xs flex-1 text-left lg:text-center">
{{ t("home.admin.version") }}
</div>
</div>
</div>
</TileWithLink>
</div>
<div class="col-span-6 lg:col-span-1 md:col-span-3">
<TileWithLink>
<div class="h-full flex">
<div class="flex-1 my-auto">
<GamepadIcon />
</div>
<div
class="flex-6 lg:flex-2 my-auto text-center flex lg:inline mx-4"
>
<div class="text-3xl flex-1 font-bold">{{ gameCount }}</div>
<div class="text-xs flex-1 text-left lg:text-center">
{{ t("home.admin.games") }}
</div>
</div>
</div>
</TileWithLink>
</div>
<div
class="col-span-6 lg:col-span-1 md:col-span-3 row-span-1 lg:col-start-1 lg:row-start-2"
>
<TileWithLink>
<div class="h-full flex">
<div class="flex-1 my-auto">
<ServerStackIcon />
</div>
<div
class="flex-6 lg:flex-2 my-auto text-center flex lg:inline mx-4"
>
<div class="text-3xl flex-1 font-bold">
{{ sources.length }}
</div>
<div class="text-xs flex-1 text-left lg:text-center">
{{ t("home.admin.librarySources") }}
</div>
</div>
</div>
</TileWithLink>
</div>
<div
class="col-span-6 lg:col-span-1 md:col-span-3 row-span-1 lg:col-start-2 lg:row-start-2"
>
<TileWithLink>
<div class="h-full flex">
<div class="flex-1 my-auto">
<UserGroupIcon />
</div>
<div
class="flex-6 lg:flex-2 my-auto text-center flex lg:inline mx-4"
>
<div class="text-3xl flex-1 font-bold">
{{ userStats.userCount }}
</div>
<div class="text-xs flex-1 text-left lg:text-center">
{{ t("home.admin.users") }}
</div>
</div>
</div>
</TileWithLink>
</div>
<div class="col-span-6 row-span-1 lg:col-span-2 lg:row-span-2">
<TileWithLink
:link="{
url: '/admin/users',
label: t('home.admin.goToUsers'),
}"
:title="t('home.admin.activeInactiveUsers')"
>
<PieChart :data="pieChartData" />
</TileWithLink>
</div>
<div class="col-span-6">
<TileWithLink
title="Library"
:link="{ url: '/admin/library', label: 'Go to library' }"
>
<SourceTable :sources="sources" />
</TileWithLink>
</div>
<div class="col-span-6 lg:col-span-2">
<TileWithLink
:title="t('home.admin.biggestGamesToDownload')"
:subtitle="t('home.admin.latestVersionOnly')"
>
<RankingList :items="biggestGamesLatest.map(gameToRankItem)" />
</TileWithLink>
</div>
<div class="col-span-6 lg:col-span-2">
<TileWithLink
:title="t('home.admin.biggestGamesOnServer')"
:subtitle="t('home.admin.allVersionsCombined')"
>
<RankingList :items="biggestGamesCombined.map(gameToRankItem)" />
</TileWithLink>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { formatBytes } from "~/server/internal/utils/files";
import GamepadIcon from "~/components/Icons/GamepadIcon.vue";
import DropLogo from "~/components/DropLogo.vue";
import { ServerStackIcon, UserGroupIcon } from "@heroicons/vue/24/outline";
import type { GameSize } from "~/server/internal/gamesize";
import type { RankItem } from "~/components/RankingList.vue";
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
}); });
@ -8,4 +149,29 @@ definePageMeta({
useHead({ useHead({
title: "Home", title: "Home",
}); });
const { t } = useI18n();
const {
version,
gameCount,
sources,
userStats,
biggestGamesLatest,
biggestGamesCombined,
} = await $dropFetch("/api/v1/admin/home");
const gameToRankItem = (game: GameSize, rank: number): RankItem => ({
rank: rank + 1,
name: game.gameName,
value: formatBytes(game.size),
});
const pieChartData = [
{
label: t("home.admin.inactiveUsers"),
value: userStats.userCount - userStats.activeSessions,
},
{ label: t("home.admin.activeUsers"), value: userStats.activeSessions },
];
</script> </script>

View File

@ -18,99 +18,12 @@
</button> </button>
</div> </div>
</div> </div>
<div class="mt-8 flow-root"> <div class="mt-4 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> <SourceTable
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> :sources="sources"
<table class="min-w-full divide-y divide-zinc-700"> :edit-source="edit"
<thead> :delete-source="deleteSource"
<tr>
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3"
>
{{ $t("common.name") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("type") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("library.admin.sources.working") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("options") }}
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-3">
<span class="sr-only">{{ $t("common.edit") }}</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(source, sourceIdx) in sources"
:key="source.id"
class="even:bg-zinc-800"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
>
{{ source.name }}
</td>
<td
class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400 inline-flex gap-x-1 items-center"
>
<component
:is="optionsMetadata[source.backend].icon"
class="size-5 text-zinc-400"
/> />
{{ optionsMetadata[source.backend].title }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<CheckIcon
v-if="source.working"
class="size-5 text-green-500"
/>
<XMarkIcon v-else class="size-5 text-red-500" />
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ source.options }}
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-3 text-right text-sm font-medium space-x-2"
>
<button
class="text-blue-500 hover:text-blue-400"
@click="() => edit(sourceIdx)"
>
{{ $t("common.edit") }}
<span class="sr-only">
{{ $t("chars.srComma", [source.name]) }}
</span>
</button>
<button
class="text-red-500 hover:text-red-400"
@click="() => deleteSource(sourceIdx)"
>
{{ $t("delete") }}
<span class="sr-only">
{{ $t("chars.srComma", [source.name]) }}
</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div> </div>
<ModalTemplate v-model="actionSourceOpen"> <ModalTemplate v-model="actionSourceOpen">
@ -313,7 +226,7 @@ import {
XCircleIcon, XCircleIcon,
ArrowTopRightOnSquareIcon, ArrowTopRightOnSquareIcon,
} from "@heroicons/vue/20/solid"; } from "@heroicons/vue/20/solid";
import { BackwardIcon, CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline"; import { BackwardIcon } from "@heroicons/vue/24/outline";
import { FetchError } from "ofetch"; import { FetchError } from "ofetch";
import type { Component } from "vue"; import type { Component } from "vue";
import type { LibraryBackend } from "~/prisma/client/enums"; import type { LibraryBackend } from "~/prisma/client/enums";

View File

@ -2,7 +2,7 @@
<div class="flex flex-col"> <div class="flex flex-col">
<!-- tabs--> <!-- tabs-->
<div> <div>
<div class="border-b border-gray-200 dark:border-white/10"> <div class="border-b border-white/10">
<nav class="-mb-px flex gap-x-2" aria-label="Tabs"> <nav class="-mb-px flex gap-x-2" aria-label="Tabs">
<NuxtLink <NuxtLink
v-for="(tab, tabIdx) in navigation" v-for="(tab, tabIdx) in navigation"
@ -10,8 +10,8 @@
:href="tab.route" :href="tab.route"
:class="[ :class="[
currentNavigationIndex == tabIdx currentNavigationIndex == tabIdx
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' ? 'border-blue-400 text-blue-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-300', : 'border-transparent text-gray-400 hover:border-white/20 hover:text-gray-300',
'group inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium', 'group inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium',
]" ]"
:aria-current="tab ? 'page' : undefined" :aria-current="tab ? 'page' : undefined"
@ -20,8 +20,8 @@
:is="tab.icon" :is="tab.icon"
:class="[ :class="[
currentNavigationIndex == tabIdx currentNavigationIndex == tabIdx
? 'text-blue-500 dark:text-blue-400' ? 'text-blue-400'
: 'text-gray-400 group-hover:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-400', : 'text-gray-500 group-hover:text-gray-400',
'mr-2 -ml-0.5 size-5', 'mr-2 -ml-0.5 size-5',
]" ]"
aria-hidden="true" aria-hidden="true"

View File

@ -53,18 +53,7 @@
:log="parseTaskLog(task.log.at(-(idx + 1)))" :log="parseTaskLog(task.log.at(-(idx + 1)))"
/> />
</div> </div>
<div class="relative h-5 rounded-xl bg-zinc-950 overflow-hidden"> <ProgressBar :percentage="task.progress" />
<div
:style="{ width: `${task.progress}%` }"
class="transition-all bg-blue-600 h-full"
/>
<span
class="absolute inset-0 flex items-center justify-center text-blue-200 text-sm font-bold font-display"
>{{
$t("tasks.admin.progress", [Math.round(task.progress * 10) / 10])
}}</span
>
</div>
</div> </div>
<div v-else role="status" class="w-full flex items-center justify-center"> <div v-else role="status" class="w-full flex items-center justify-center">
<svg <svg

View File

@ -86,6 +86,27 @@
> >
</td> </td>
</tr> </tr>
<tr>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
>
{{ $t("store.size") }}
</td>
<td
v-if="size"
class="whitespace-nowrap inline-flex gap-x-4 px-3 py-4 text-sm text-zinc-400"
>
{{ formatBytes(size) }}
</td>
<td
v-else
class="whitespace-nowrap inline-flex gap-x-4 px-3 py-4 text-sm text-zinc-400 italic"
>
<span class="font-semibold text-blue-600">{{
$t("store.commingSoon")
}}</span>
</td>
</tr>
<tr> <tr>
<td <td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3" class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
@ -246,13 +267,14 @@ import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
import { StarIcon } from "@heroicons/vue/24/solid"; import { StarIcon } from "@heroicons/vue/24/solid";
import { micromark } from "micromark"; import { micromark } from "micromark";
import type { PlatformClient } from "~/composables/types"; import type { PlatformClient } from "~/composables/types";
import { formatBytes } from "~/server/internal/utils/files";
const route = useRoute(); const route = useRoute();
const gameId = route.params.id.toString(); const gameId = route.params.id.toString();
const user = useUser(); const user = useUser();
const { game, rating } = await $dropFetch(`/api/v1/games/${gameId}`); const { game, rating, size } = await $dropFetch(`/api/v1/games/${gameId}`);
// Preview description (first 30 lines) // Preview description (first 30 lines)
const showPreview = ref(true); const showPreview = ref(true);

3691
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,10 @@
onlyBuiltDependencies:
- "@prisma/client"
- "@prisma/engines"
- "@tailwindcss/oxide"
- esbuild
- prisma
overrides: overrides:
droplet: link:../../.local/share/pnpm/global/5/node_modules/@drop-oss/droplet droplet: link:../../.local/share/pnpm/global/5/node_modules/@drop-oss/droplet

10
prisma.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { config } from "dotenv";
import type { PrismaConfig } from "prisma";
import path from "node:path";
config();
export default {
schema: path.join("prisma"),
earlyAccess: true,
} satisfies PrismaConfig;

View File

@ -0,0 +1,34 @@
import type { TSESLint } from "@typescript-eslint/utils";
export default {
meta: {
type: "problem",
docs: {
description: "Don't use Prisma error-prone .delete function",
},
messages: {
noPrismaDelete:
"Prisma .delete(...) function is used. Use .deleteMany(..) and check count instead.",
},
schema: [],
},
create(context) {
return {
CallExpression: function (node) {
// @ts-expect-error It ain't typing properly
const funcId = node.callee.property;
if (!funcId || funcId.name !== "delete") return;
// @ts-expect-error It ain't typing properly
const tableExpr = node.callee.object;
if (!tableExpr) return;
const prismaExpr = tableExpr.object;
if (!prismaExpr || prismaExpr.name !== "prisma") return;
context.report({
node,
messageId: "noPrismaDelete",
});
},
};
},
defaultOptions: [],
} satisfies TSESLint.RuleModule<"noPrismaDelete">;

View File

@ -17,6 +17,10 @@ export default defineEventHandler<{
const body = await readDropValidatedBody(h3, DeleteInvite); const body = await readDropValidatedBody(h3, DeleteInvite);
await prisma.invitation.delete({ where: { id: body.id } }); const { count } = await prisma.invitation.deleteMany({
where: { id: body.id },
});
if (count == 0)
throw createError({ statusCode: 404, message: "Invitation not found." });
return {}; return {};
}); });

View File

@ -1,5 +1,5 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import libraryManager from "~/server/internal/library";
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["game:delete"]); const allowed = await aclManager.allowSystemACL(h3, ["game:delete"]);
@ -7,11 +7,7 @@ export default defineEventHandler(async (h3) => {
const gameId = getRouterParam(h3, "id")!; const gameId = getRouterParam(h3, "id")!;
await prisma.game.delete({ await libraryManager.deleteGame(gameId);
where: {
id: gameId,
},
});
return {}; return {};
}); });

View File

@ -1,3 +1,4 @@
import type { GameVersion } from "~/prisma/client/client";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library"; import libraryManager from "~/server/internal/library";
@ -28,10 +29,22 @@ export default defineEventHandler(async (h3) => {
if (!game || !game.libraryId) if (!game || !game.libraryId)
throw createError({ statusCode: 404, statusMessage: "Game ID not found" }); throw createError({ statusCode: 404, statusMessage: "Game ID not found" });
const getGameVersionSize = async (version: GameVersion) => {
const size = await libraryManager.getGameVersionSize(
gameId,
version.versionName,
);
return { ...version, size };
};
const gameWithVersionSize = {
...game,
versions: await Promise.all(game.versions.map(getGameVersionSize)),
};
const unimportedVersions = await libraryManager.fetchUnimportedGameVersions( const unimportedVersions = await libraryManager.fetchUnimportedGameVersions(
game.libraryId, game.libraryId,
game.libraryPath, game.libraryPath,
); );
return { game, unimportedVersions }; return { game: gameWithVersionSize, unimportedVersions };
}); });

View File

@ -1,7 +1,7 @@
import { type } from "arktype"; import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import libraryManager from "~/server/internal/library";
const DeleteVersion = type({ const DeleteVersion = type({
id: "string", id: "string",
@ -20,15 +20,7 @@ export default defineEventHandler<{ body: typeof DeleteVersion }>(
const gameId = body.id.toString(); const gameId = body.id.toString();
const version = body.versionName.toString(); const version = body.versionName.toString();
await prisma.gameVersion.delete({ await libraryManager.deleteGameVersion(gameId, version);
where: {
gameId_versionName: {
gameId: gameId,
versionName: version,
},
},
});
return {}; return {};
}, },
); );

View File

@ -0,0 +1,27 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import { systemConfig } from "~/server/internal/config/sys-conf";
import libraryManager from "~/server/internal/library";
import userStatsManager from "~/server/internal/userstats";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["game:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const sources = await libraryManager.fetchLibraries();
const userStats = await userStatsManager.getUserStats();
const biggestGamesCombined =
await libraryManager.getBiggestGamesCombinedVersions(5);
const biggestGamesLatest =
await libraryManager.getBiggestGamesLatestVersions(5);
return {
gameCount: await prisma.game.count(),
version: systemConfig.getDropVersion(),
userStats,
sources,
biggestGamesLatest,
biggestGamesCombined,
};
});

View File

@ -18,11 +18,13 @@ export default defineEventHandler<{ body: typeof DeleteLibrarySource.infer }>(
const body = await readDropValidatedBody(h3, DeleteLibrarySource); const body = await readDropValidatedBody(h3, DeleteLibrarySource);
await prisma.library.delete({ const { count } = await prisma.library.deleteMany({
where: { where: {
id: body.id, id: body.id,
}, },
}); });
if (count == 0)
throw createError({ statusCode: 404, message: "Library not found." });
libraryManager.removeLibrary(body.id); libraryManager.removeLibrary(body.id);
}, },

View File

@ -2,7 +2,10 @@ import type { LibraryModel } from "~/prisma/client/models";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import libraryManager from "~/server/internal/library"; import libraryManager from "~/server/internal/library";
export type WorkingLibrarySource = LibraryModel & { working: boolean }; export type WorkingLibrarySource = LibraryModel & {
working: boolean;
fsStats?: { freeSpace: number; totalSpace: number } | undefined;
};
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, [ const allowed = await aclManager.allowSystemACL(h3, [

View File

@ -3,8 +3,8 @@ import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library"; import libraryManager from "~/server/internal/library";
import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get";
import { libraryConstructors } from "~/server/plugins/05.library-init"; import { libraryConstructors } from "~/server/plugins/05.library-init";
import type { WorkingLibrarySource } from "./index.get";
const UpdateLibrarySource = type({ const UpdateLibrarySource = type({
id: "string", id: "string",
@ -49,8 +49,8 @@ export default defineEventHandler<{ body: typeof UpdateLibrarySource.infer }>(
}, },
}); });
await libraryManager.removeLibrary(source.id); libraryManager.removeLibrary(source.id);
await libraryManager.addLibrary(newLibrary); libraryManager.addLibrary(newLibrary);
const workingSource: WorkingLibrarySource = { const workingSource: WorkingLibrarySource = {
...updatedSource, ...updatedSource,

View File

@ -6,7 +6,7 @@ import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library"; import libraryManager from "~/server/internal/library";
import { libraryConstructors } from "~/server/plugins/05.library-init"; import { libraryConstructors } from "~/server/plugins/05.library-init";
import type { WorkingLibrarySource } from "./index.get"; import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get";
const CreateLibrarySource = type({ const CreateLibrarySource = type({
name: "string", name: "string",
@ -52,11 +52,12 @@ export default defineEventHandler<{ body: typeof CreateLibrarySource.infer }>(
}, },
}); });
await libraryManager.addLibrary(library); libraryManager.addLibrary(library);
const workingSource: WorkingLibrarySource = { const workingSource: WorkingLibrarySource = {
...source, ...source,
working: true, working: true,
fsStats: library.fsStats(),
}; };
return workingSource; return workingSource;

View File

@ -13,10 +13,10 @@ export default defineEventHandler(async (h3) => {
statusMessage: "No id in router params", statusMessage: "No id in router params",
}); });
const deleted = await prisma.aPIToken.delete({ const { count } = await prisma.aPIToken.deleteMany({
where: { id: id, mode: APITokenMode.System }, where: { id: id, mode: APITokenMode.System },
})!; })!;
if (!deleted) if (count == 0)
throw createError({ statusCode: 404, statusMessage: "Token not found" }); throw createError({ statusCode: 404, statusMessage: "Token not found" });
return; return;

View File

@ -1,6 +1,7 @@
import { defineEventHandler, createError } from "h3"; import { defineEventHandler, createError } from "h3";
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
import userStatsManager from "~/server/internal/userstats";
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["user:delete"]); const allowed = await aclManager.allowSystemACL(h3, ["user:delete"]);
@ -26,6 +27,8 @@ export default defineEventHandler(async (h3) => {
if (!user) if (!user)
throw createError({ statusCode: 404, statusMessage: "User not found." }); throw createError({ statusCode: 404, statusMessage: "User not found." });
// eslint-disable-next-line drop/no-prisma-delete
await prisma.user.delete({ where: { id: userId } }); await prisma.user.delete({ where: { id: userId } });
await userStatsManager.deleteUser();
return { success: true }; return { success: true };
}); });

View File

@ -6,6 +6,7 @@ import objectHandler from "~/server/internal/objects";
import { type } from "arktype"; import { type } from "arktype";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { throwingArktype } from "~/server/arktype"; import { throwingArktype } from "~/server/arktype";
import userStatsManager from "~/server/internal/userstats";
export const SharedRegisterValidator = type({ export const SharedRegisterValidator = type({
username: "string >= 5", username: "string >= 5",
@ -83,8 +84,9 @@ export default defineEventHandler<{
user: true, user: true,
}, },
}), }),
prisma.invitation.delete({ where: { id: user.invitation } }), prisma.invitation.deleteMany({ where: { id: user.invitation } }),
]); ]);
await userStatsManager.addUser();
return linkMec.user; return linkMec.user;
}); });

View File

@ -42,7 +42,7 @@ export default defineEventHandler(async (h3) => {
) )
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
statusMessage: "Invalid capabilities.", message: "Invalid capabilities.",
}); });
if ( if (

View File

@ -1,5 +1,6 @@
import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
export default defineClientEventHandler(async (h3) => { export default defineClientEventHandler(async (h3) => {
const query = getQuery(h3); const query = getQuery(h3);
@ -26,5 +27,8 @@ export default defineClientEventHandler(async (h3) => {
statusMessage: "Game version not found", statusMessage: "Game version not found",
}); });
return gameVersion; return {
...gameVersion,
size: libraryManager.getGameVersionSize(id, version),
};
}); });

View File

@ -17,21 +17,10 @@ export default defineClientEventHandler(async (h3) => {
orderBy: { orderBy: {
versionIndex: "desc", // Latest one first versionIndex: "desc", // Latest one first
}, },
omit: {
dropletManifest: true,
},
}); });
const mappedVersions = versions return versions;
.map((version) => {
if (!version.dropletManifest) return undefined;
const newVersion = { ...version, dropletManifest: undefined };
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore idk why we delete an undefined object
delete newVersion.dropletManifest;
return {
...newVersion,
};
})
.filter((e) => e);
return mappedVersions;
}); });

View File

@ -38,16 +38,14 @@ export default defineClientEventHandler(
if (!game) if (!game)
throw createError({ statusCode: 400, statusMessage: "Invalid game ID" }); throw createError({ statusCode: 400, statusMessage: "Invalid game ID" });
const save = await prisma.saveSlot.delete({ const { count } = await prisma.saveSlot.deleteMany({
where: { where: {
id: {
userId: user.id, userId: user.id,
gameId: gameId, gameId: gameId,
index: slotIndex, index: slotIndex,
}, },
},
}); });
if (!save) if (count == 0)
throw createError({ statusCode: 404, statusMessage: "Save not found" }); throw createError({ statusCode: 404, statusMessage: "Save not found" });
}, },
); );

View File

@ -1,5 +1,6 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["store:read"]); const userId = await aclManager.getUserIdACL(h3, ["store:read"]);
@ -51,5 +52,7 @@ export default defineEventHandler(async (h3) => {
}, },
}); });
return { game, rating }; const size = await libraryManager.getGameVersionSize(game.id);
return { game, rating, size };
}); });

View File

@ -20,14 +20,14 @@ export default defineEventHandler(async (h3) => {
userIds.push("system"); userIds.push("system");
} }
const notification = await prisma.notification.delete({ const { count } = await prisma.notification.deleteMany({
where: { where: {
id: notificationId, id: notificationId,
userId: { in: userIds }, userId: { in: userIds },
}, },
}); });
if (!notification) if (count == 0)
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
statusMessage: "Invalid notification ID", statusMessage: "Invalid notification ID",

View File

@ -0,0 +1,25 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["notifications:mark"]);
if (!userId) throw createError({ statusCode: 403 });
const acls = await aclManager.fetchAllACLs(h3);
if (!acls)
throw createError({
statusCode: 500,
statusMessage: "Got userId but no ACLs - what?",
});
await prisma.notification.deleteMany({
where: {
userId,
acls: {
hasSome: acls,
},
},
});
return;
});

View File

@ -13,10 +13,10 @@ export default defineEventHandler(async (h3) => {
statusMessage: "No id in router params", statusMessage: "No id in router params",
}); });
const deleted = await prisma.aPIToken.delete({ const { count } = await prisma.aPIToken.deleteMany({
where: { id: id, userId: userId, mode: APITokenMode.User }, where: { id: id, userId: userId, mode: APITokenMode.User },
})!; })!;
if (!deleted) if (count == 0)
throw createError({ statusCode: 404, statusMessage: "Token not found" }); throw createError({ statusCode: 404, statusMessage: "Token not found" });
return; return;

View File

@ -66,6 +66,7 @@ export class OIDCManager {
async create() { async create() {
const wellKnownUrl = process.env.OIDC_WELLKNOWN as string | undefined; const wellKnownUrl = process.env.OIDC_WELLKNOWN as string | undefined;
const scopes = process.env.OIDC_SCOPES as string | undefined;
let configuration: OIDCWellKnown; let configuration: OIDCWellKnown;
if (wellKnownUrl) { if (wellKnownUrl) {
const response: OIDCWellKnown = await $fetch<OIDCWellKnown>(wellKnownUrl); const response: OIDCWellKnown = await $fetch<OIDCWellKnown>(wellKnownUrl);
@ -77,6 +78,9 @@ export class OIDCManager {
) { ) {
throw new Error("Well known response was invalid"); throw new Error("Well known response was invalid");
} }
if (scopes) {
response.scopes_supported = scopes.split(",");
}
configuration = response; configuration = response;
} else { } else {
@ -85,7 +89,6 @@ export class OIDCManager {
| undefined; | undefined;
const tokenEndpoint = process.env.OIDC_TOKEN as string | undefined; const tokenEndpoint = process.env.OIDC_TOKEN as string | undefined;
const userinfoEndpoint = process.env.OIDC_USERINFO as string | undefined; const userinfoEndpoint = process.env.OIDC_USERINFO as string | undefined;
const scopes = process.env.OIDC_SCOPES as string | undefined;
if ( if (
!authorizationEndpoint || !authorizationEndpoint ||

View File

@ -8,6 +8,7 @@ import type {
} from "./capabilities"; } from "./capabilities";
import capabilityManager from "./capabilities"; import capabilityManager from "./capabilities";
import type { PeerImpl } from "../tasks"; import type { PeerImpl } from "../tasks";
import userStatsManager from "~/server/internal/userstats";
export enum AuthMode { export enum AuthMode {
Callback = "callback", Callback = "callback",
@ -136,7 +137,7 @@ export class ClientHandler {
statusCode: 400, statusCode: 400,
statusMessage: "Client has not connected yet. Please try again later.", statusMessage: "Client has not connected yet. Please try again later.",
}); });
await client.peer.send( client.peer.send(
JSON.stringify({ type: "token", value: `${clientId}/${token}` }), JSON.stringify({ type: "token", value: `${clientId}/${token}` }),
); );
} }
@ -166,6 +167,7 @@ export class ClientHandler {
lastConnected: new Date(), lastConnected: new Date(),
}, },
}); });
await userStatsManager.cacheUserSessions();
for (const [capability, configuration] of Object.entries( for (const [capability, configuration] of Object.entries(
metadata.data.capabilities, metadata.data.capabilities,
@ -183,14 +185,19 @@ export class ClientHandler {
} }
async removeClient(id: string) { async removeClient(id: string) {
const client = await prisma.client.findUnique({ where: { id } });
if (!client) return false;
const ca = useCertificateAuthority(); const ca = useCertificateAuthority();
await ca.blacklistClient(id); await ca.blacklistClient(id);
// eslint-disable-next-line drop/no-prisma-delete
await prisma.client.delete({ await prisma.client.delete({
where: { where: {
id, id,
}, },
}); });
await userStatsManager.cacheUserStats();
return true;
} }
} }

View File

@ -4,6 +4,8 @@ class SystemConfig {
private libraryFolder = process.env.LIBRARY ?? "./.data/library"; private libraryFolder = process.env.LIBRARY ?? "./.data/library";
private dataFolder = process.env.DATA ?? "./.data/data"; private dataFolder = process.env.DATA ?? "./.data/data";
private metadataTimeout = parseInt(process.env.METADATA_TIMEOUT ?? "5000");
private externalUrl = normalizeUrl( private externalUrl = normalizeUrl(
process.env.EXTERNAL_URL ?? "http://localhost:3000", process.env.EXTERNAL_URL ?? "http://localhost:3000",
{ stripWWW: false }, { stripWWW: false },
@ -28,6 +30,10 @@ class SystemConfig {
return this.dataFolder; return this.dataFolder;
} }
getMetadataTimeout() {
return this.metadataTimeout;
}
getDropVersion() { getDropVersion() {
return this.dropVersion; return this.dropVersion;
} }

View File

@ -1,5 +1,6 @@
import type { GameVersionModel } from "~/prisma/client/models"; import type { GameVersionModel } from "~/prisma/client/models";
import prisma from "../db/database"; import prisma from "../db/database";
import { sum } from "~/utils/array";
export type DropChunk = { export type DropChunk = {
permissions: number; permissions: number;
@ -102,6 +103,14 @@ class ManifestGenerator {
return manifest; return manifest;
} }
calculateManifestSize(manifest: DropManifest) {
return sum(
Object.values(manifest)
.map((chunk) => chunk.lengths)
.flat(),
);
}
} }
export const manifestGenerator = new ManifestGenerator(); export const manifestGenerator = new ManifestGenerator();

View File

@ -0,0 +1,236 @@
import cacheHandler from "../cache";
import prisma from "../db/database";
import manifestGenerator from "../downloads/manifest";
import { sum } from "../../../utils/array";
import type { Game, GameVersion } from "~/prisma/client/client";
export type GameSize = {
gameName: string;
size: number;
gameId: string;
};
export type VersionSize = GameSize & {
latest: boolean;
};
type VersionsSizes = {
[versionName: string]: VersionSize;
};
type GameVersionsSize = {
[gameId: string]: VersionsSizes;
};
class GameSizeManager {
private gameVersionsSizesCache =
cacheHandler.createCache<GameVersionsSize>("gameVersionsSizes");
// All versions sizes combined
private gameSizesCache = cacheHandler.createCache<GameSize>("gameSizes");
private async clearGameVersionsSizesCache() {
(await this.gameVersionsSizesCache.getKeys()).map((key) =>
this.gameVersionsSizesCache.remove(key),
);
}
private async clearGameSizesCache() {
(await this.gameSizesCache.getKeys()).map((key) =>
this.gameSizesCache.remove(key),
);
}
// All versions of a game combined
async getCombinedGameSize(gameId: string) {
const versions = await prisma.gameVersion.findMany({
where: { gameId },
});
const sizes = await Promise.all(
versions.map((version) =>
manifestGenerator.calculateManifestSize(
JSON.parse(version.dropletManifest as string),
),
),
);
return sum(sizes);
}
async getGameVersionSize(
gameId: string,
versionName?: string,
): Promise<number | null> {
if (!versionName) {
const version = await prisma.gameVersion.findFirst({
where: { gameId },
orderBy: {
versionIndex: "desc",
},
});
if (!version) {
return null;
}
versionName = version.versionName;
}
const manifest = await manifestGenerator.generateManifest(
gameId,
versionName,
);
if (!manifest) {
return null;
}
return manifestGenerator.calculateManifestSize(manifest);
}
private async isLatestVersion(
gameVersions: GameVersion[],
version: GameVersion,
): Promise<boolean> {
return gameVersions.length > 0
? gameVersions[0].versionName === version.versionName
: false;
}
async getBiggestGamesLatestVersion(top: number): Promise<VersionSize[]> {
const gameIds = await this.gameVersionsSizesCache.getKeys();
const latestGames = await Promise.all(
gameIds.map(async (gameId) => {
const versionsSizes = await this.gameVersionsSizesCache.get(gameId);
if (!versionsSizes) {
return null;
}
const latestVersionName = Object.keys(versionsSizes).find(
(versionName) => versionsSizes[versionName].latest,
);
if (!latestVersionName) {
return null;
}
return versionsSizes[latestVersionName] || null;
}),
);
return latestGames
.filter((game) => game !== null)
.sort((gameA, gameB) => gameB.size - gameA.size)
.slice(0, top);
}
async isGameVersionsSizesCacheEmpty() {
return (await this.gameVersionsSizesCache.getKeys()).length === 0;
}
async isGameSizesCacheEmpty() {
return (await this.gameSizesCache.getKeys()).length === 0;
}
async cacheAllCombinedGames() {
await this.clearGameSizesCache();
const games = await prisma.game.findMany({ include: { versions: true } });
await Promise.all(games.map((game) => this.cacheCombinedGame(game)));
}
async cacheCombinedGame(game: Game) {
const size = await this.getCombinedGameSize(game.id);
if (!size) {
this.gameSizesCache.remove(game.id);
return;
}
const gameSize = {
size,
gameName: game.mName,
gameId: game.id,
};
await this.gameSizesCache.set(game.id, gameSize);
}
async cacheAllGameVersions() {
await this.clearGameVersionsSizesCache();
const games = await prisma.game.findMany({
include: {
versions: {
orderBy: {
versionIndex: "desc",
},
take: 1,
},
},
});
await Promise.all(games.map((game) => this.cacheGameVersion(game)));
}
async cacheGameVersion(
game: Game & { versions: GameVersion[] },
versionName?: string,
) {
const cacheVersion = async (version: GameVersion) => {
const size = await this.getGameVersionSize(game.id, version.versionName);
if (!version.versionName || !size) {
return;
}
const versionsSizes = {
[version.versionName]: {
size,
gameName: game.mName,
gameId: game.id,
latest: await this.isLatestVersion(game.versions, version),
},
};
const allVersionsSizes =
(await this.gameVersionsSizesCache.get(game.id)) || {};
await this.gameVersionsSizesCache.set(game.id, {
...allVersionsSizes,
...versionsSizes,
});
};
if (versionName) {
const version = await prisma.gameVersion.findFirst({
where: { gameId: game.id, versionName },
});
if (!version) {
return;
}
cacheVersion(version);
return;
}
if ("versions" in game) {
await Promise.all(game.versions.map(cacheVersion));
}
}
async getBiggestGamesAllVersions(top: number): Promise<GameSize[]> {
const gameIds = await this.gameSizesCache.getKeys();
const allGames = await Promise.all(
gameIds.map(async (gameId) => await this.gameSizesCache.get(gameId)),
);
return allGames
.filter((game) => game !== null)
.sort((gameA, gameB) => gameB.size - gameA.size)
.slice(0, top);
}
async deleteGameVersion(gameId: string, version: string) {
const game = await prisma.game.findFirst({ where: { id: gameId } });
if (game) {
await this.cacheCombinedGame(game);
}
const versionsSizes = await this.gameVersionsSizesCache.get(gameId);
if (!versionsSizes) {
return;
}
// Remove the version from the VersionsSizes object
const { [version]: _, ...updatedVersionsSizes } = versionsSizes;
await this.gameVersionsSizesCache.set(gameId, updatedVersionsSizes);
}
async deleteGame(gameId: string) {
this.gameSizesCache.remove(gameId);
this.gameVersionsSizesCache.remove(gameId);
}
}
export const manager = new GameSizeManager();
export default manager;

View File

@ -15,6 +15,8 @@ import { GameNotFoundError, type LibraryProvider } from "./provider";
import { logger } from "../logging"; import { logger } from "../logging";
import type { GameModel } from "~/prisma/client/models"; import type { GameModel } from "~/prisma/client/models";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get";
import gameSizeManager from "~/server/internal/gamesize";
export function createGameImportTaskId(libraryId: string, libraryPath: string) { export function createGameImportTaskId(libraryId: string, libraryPath: string) {
return createHash("md5") return createHash("md5")
@ -39,13 +41,19 @@ class LibraryManager {
this.libraries.delete(id); this.libraries.delete(id);
} }
async fetchLibraries() { async fetchLibraries(): Promise<WorkingLibrarySource[]> {
const libraries = await prisma.library.findMany({}); const libraries = await prisma.library.findMany({});
const libraryWithMetadata = libraries.map((e) => ({
...e, const libraryWithMetadata = libraries.map(async (library) => {
working: this.libraries.has(e.id), const theLibrary = this.libraries.get(library.id);
})); const working = this.libraries.has(library.id);
return libraryWithMetadata; return {
...library,
working,
fsStats: working ? theLibrary?.fsStats() : undefined,
};
});
return await Promise.all(libraryWithMetadata);
} }
async fetchGamesByLibrary() { async fetchGamesByLibrary() {
@ -97,7 +105,10 @@ class LibraryManager {
if (!game) return undefined; if (!game) return undefined;
try { try {
const versions = await provider.listVersions(libraryPath); const versions = await provider.listVersions(
libraryPath,
game.versions.map((v) => v.versionName),
);
const unimportedVersions = versions.filter( const unimportedVersions = versions.filter(
(e) => (e) =>
game.versions.findIndex((v) => v.versionName == e) == -1 && game.versions.findIndex((v) => v.versionName == e) == -1 &&
@ -334,6 +345,8 @@ class LibraryManager {
acls: ["system:import:version:read"], acls: ["system:import:version:read"],
}); });
await libraryManager.cacheCombinedGameSize(gameId);
await libraryManager.cacheGameVersionSize(gameId, versionName);
progress(100); progress(100);
}, },
}); });
@ -363,6 +376,66 @@ class LibraryManager {
if (!library) return undefined; if (!library) return undefined;
return await library.readFile(game, version, filename, options); return await library.readFile(game, version, filename, options);
} }
async deleteGameVersion(gameId: string, version: string) {
await prisma.gameVersion.deleteMany({
where: {
gameId: gameId,
versionName: version,
},
});
await gameSizeManager.deleteGameVersion(gameId, version);
}
async deleteGame(gameId: string) {
await prisma.game.deleteMany({
where: {
id: gameId,
},
});
await gameSizeManager.deleteGame(gameId);
}
async getGameVersionSize(
gameId: string,
versionName?: string,
): Promise<number | null> {
return gameSizeManager.getGameVersionSize(gameId, versionName);
}
async getBiggestGamesCombinedVersions(top: number) {
if (await gameSizeManager.isGameSizesCacheEmpty()) {
await gameSizeManager.cacheAllCombinedGames();
}
return gameSizeManager.getBiggestGamesAllVersions(top);
}
async getBiggestGamesLatestVersions(top: number) {
if (await gameSizeManager.isGameVersionsSizesCacheEmpty()) {
await gameSizeManager.cacheAllGameVersions();
}
return gameSizeManager.getBiggestGamesLatestVersion(top);
}
async cacheCombinedGameSize(gameId: string) {
const game = await prisma.game.findFirst({ where: { id: gameId } });
if (!game) {
return;
}
await gameSizeManager.cacheCombinedGame(game);
}
async cacheGameVersionSize(gameId: string, versionName: string) {
const game = await prisma.game.findFirst({
where: { id: gameId },
include: { versions: true },
});
if (!game) {
return;
}
await gameSizeManager.cacheGameVersion(game, versionName);
}
} }
export const libraryManager = new LibraryManager(); export const libraryManager = new LibraryManager();

View File

@ -24,7 +24,10 @@ export abstract class LibraryProvider<CFG> {
* @param game folder name of the game to list versions for * @param game folder name of the game to list versions for
* @returns list of version folder names * @returns list of version folder names
*/ */
abstract listVersions(game: string): Promise<string[]>; abstract listVersions(
game: string,
existingPaths?: string[],
): Promise<string[]>;
/** /**
* @param game folder name of the game * @param game folder name of the game
@ -57,6 +60,8 @@ export abstract class LibraryProvider<CFG> {
filename: string, filename: string,
options?: { start?: number; end?: number }, options?: { start?: number; end?: number },
): Promise<ReadableStream | undefined>; ): Promise<ReadableStream | undefined>;
abstract fsStats(): { freeSpace: number; totalSpace: number } | undefined;
} }
export class GameNotFoundError extends Error {} export class GameNotFoundError extends Error {}

View File

@ -8,6 +8,7 @@ import { LibraryBackend } from "~/prisma/client/enums";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import droplet, { DropletHandler } from "@drop-oss/droplet"; import droplet, { DropletHandler } from "@drop-oss/droplet";
import { fsStats } from "~/server/internal/utils/files";
export const FilesystemProviderConfig = type({ export const FilesystemProviderConfig = type({
baseDir: "string", baseDir: "string",
@ -53,11 +54,15 @@ export class FilesystemProvider
return folderDirs; return folderDirs;
} }
async listVersions(game: string): Promise<string[]> { async listVersions(
game: string,
ignoredVersions?: string[],
): Promise<string[]> {
const gameDir = path.join(this.config.baseDir, game); const gameDir = path.join(this.config.baseDir, game);
if (!fs.existsSync(gameDir)) throw new GameNotFoundError(); if (!fs.existsSync(gameDir)) throw new GameNotFoundError();
const versionDirs = fs.readdirSync(gameDir); const versionDirs = fs.readdirSync(gameDir);
const validVersionDirs = versionDirs.filter((e) => { const validVersionDirs = versionDirs.filter((e) => {
if (ignoredVersions && ignoredVersions.includes(e)) return false;
const fullDir = path.join(this.config.baseDir, game, e); const fullDir = path.join(this.config.baseDir, game, e);
return DROPLET_HANDLER.hasBackendForPath(fullDir); return DROPLET_HANDLER.hasBackendForPath(fullDir);
}); });
@ -108,18 +113,17 @@ export class FilesystemProvider
) { ) {
const filepath = path.join(this.config.baseDir, game, version); const filepath = path.join(this.config.baseDir, game, version);
if (!fs.existsSync(filepath)) return undefined; if (!fs.existsSync(filepath)) return undefined;
let stream; const stream = DROPLET_HANDLER.readFile(
while (!(stream instanceof ReadableStream)) {
const v = DROPLET_HANDLER.readFile(
filepath, filepath,
filename, filename,
options?.start ? BigInt(options.start) : undefined, options?.start ? BigInt(options.start) : undefined,
options?.end ? BigInt(options.end) : undefined, options?.end ? BigInt(options.end) : undefined,
); );
if (!v) return undefined;
stream = v.getStream() as ReadableStream<unknown>;
}
return stream; return stream;
} }
fsStats() {
return fsStats(this.config.baseDir);
}
} }

View File

@ -6,6 +6,7 @@ import fs from "fs";
import path from "path"; import path from "path";
import droplet from "@drop-oss/droplet"; import droplet from "@drop-oss/droplet";
import { DROPLET_HANDLER } from "./filesystem"; import { DROPLET_HANDLER } from "./filesystem";
import { fsStats } from "~/server/internal/utils/files";
export const FlatFilesystemProviderConfig = type({ export const FlatFilesystemProviderConfig = type({
baseDir: "string", baseDir: "string",
@ -111,6 +112,10 @@ export class FlatFilesystemProvider
); );
if (!stream) return undefined; if (!stream) return undefined;
return stream.getStream(); return stream;
}
fsStats() {
return fsStats(this.config.baseDir);
} }
} }

View File

@ -72,7 +72,7 @@ interface IGDBCompanyWebsite extends IGDBItem {
} }
interface IGDBCover extends IGDBItem { interface IGDBCover extends IGDBItem {
url: string; image_id: string;
} }
interface IGDBSearchStub extends IGDBItem { interface IGDBSearchStub extends IGDBItem {
@ -179,7 +179,7 @@ export class IGDBProvider implements MetadataProvider {
if (response.status !== 200) if (response.status !== 200)
throw new Error( 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; this.accessToken = response.data.access_token;
@ -187,7 +187,7 @@ export class IGDBProvider implements MetadataProvider {
seconds: response.data.expires_in, seconds: response.data.expires_in,
}); });
logger.info("IDGB done authorizing with twitch"); logger.info("IGDB done authorizing with twitch");
} }
private async refreshCredentials() { private async refreshCredentials() {
@ -246,39 +246,47 @@ export class IGDBProvider implements MetadataProvider {
return <T[]>response.data; 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) if (mediaID === undefined)
throw new Error( throw new Error(
`IGDB mediaID when getting item of type ${type} was undefined`, `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); 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; return result;
} }
private async getCoverURL(id: IGDBID) { 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) { 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) { 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) { private trimMessage(msg: string, len: number) {
@ -327,7 +335,7 @@ export class IGDBProvider implements MetadataProvider {
let icon = ""; let icon = "";
const cover = response[i].cover; const cover = response[i].cover;
if (cover !== undefined) { if (cover !== undefined) {
icon = await this.getCoverURL(cover); icon = await this.getIconURL(cover);
} else { } else {
icon = ""; icon = "";
} }
@ -355,23 +363,26 @@ export class IGDBProvider implements MetadataProvider {
const currentGame = (await this.request<IGDBGameFull>("games", body)).at(0); const currentGame = (await this.request<IGDBGameFull>("games", body)).at(0);
if (!currentGame) throw new Error("No game found on IGDB with that id"); 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; const cover = currentGame.cover;
if (cover !== undefined) { if (cover !== undefined) {
context?.logger.info("Found cover URL, using..."); context?.logger.info("Found cover URL, using...");
iconRaw = await this.getCoverURL(cover); iconRaw = await this.getIconURL(cover);
coverRaw = await this.getCoverURL(cover);
} else { } else {
context?.logger.info("Missing cover URL, using fallback..."); context?.logger.info("Missing cover URL, using fallback...");
iconRaw = jdenticon.toPng(id, 512); iconRaw = jdenticon.toPng(id, 512);
coverRaw = iconRaw;
} }
const icon = createObject(iconRaw); const icon = createObject(iconRaw);
const coverID = createObject(coverRaw);
let banner; let banner;
const images = [icon]; const images = [coverID];
for (const art of currentGame.artworks ?? []) { for (const art of currentGame.artworks ?? []) {
const objectId = createObject(await this.getArtworkURL(art)); const objectId = createObject(await this.getArtworkURL(art));
if (!banner) { if (!banner) {
@ -384,6 +395,11 @@ export class IGDBProvider implements MetadataProvider {
banner = createObject(jdenticon.toPng(id, 512)); 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); context?.progress(20);
const publishers: CompanyModel[] = []; const publishers: CompanyModel[] = [];
@ -452,13 +468,25 @@ export class IGDBProvider implements MetadataProvider {
const genres = await this.getGenres(currentGame.genres); 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 = { const metadata = {
id: currentGame.id.toString(), id: currentGame.id.toString(),
name: currentGame.name, name: currentGame.name,
shortDescription: deck, shortDescription,
description: currentGame.summary, description,
released, released,
genres, genres,
@ -471,7 +499,7 @@ export class IGDBProvider implements MetadataProvider {
icon, icon,
bannerId: banner, bannerId: banner,
coverId: icon, coverId: coverID,
images, images,
}; };

View File

@ -82,6 +82,10 @@ export class MetadataHandler {
// TODO: fix eslint error // TODO: fix eslint error
// eslint-disable-next-line no-async-promise-executor // eslint-disable-next-line no-async-promise-executor
>(async (resolve, reject) => { >(async (resolve, reject) => {
setTimeout(
() => reject(new Error("Timeout while fetching results")),
systemConfig.getMetadataTimeout(),
);
try { try {
const results = await provider.search(query); const results = await provider.search(query);
const mappedResults: InternalGameMetadataResult[] = results.map( const mappedResults: InternalGameMetadataResult[] = results.map(

View File

@ -117,6 +117,10 @@ interface SteamAppDetailsLarge extends SteamAppDetailsSmall {
filename: string; filename: string;
ordinal: number; ordinal: number;
}[]; }[];
mature_content_screenshots: {
filename: string;
ordinal: number;
}[];
}; };
full_description: string; full_description: string;
} }
@ -689,16 +693,20 @@ export class SteamProvider implements MetadataProvider {
context?.progress(40); context?.progress(40);
const images = [cover, banner]; const images = [cover, banner];
const screenshotCount = game.screenshots?.all_ages_screenshots?.length || 0;
context?.logger.info(`Processing ${screenshotCount} screenshots...`);
for (const image of game.screenshots?.all_ages_screenshots || []) { const screenshots = game.screenshots?.all_ages_screenshots || [];
screenshots.push(...(game.screenshots?.mature_content_screenshots || []));
screenshots.sort((a, b) => a.ordinal - b.ordinal);
context?.logger.info(`Processing ${screenshots.length} screenshots...`);
for (const image of screenshots) {
const imageUrl = this._getImageUrl(image.filename); const imageUrl = this._getImageUrl(image.filename);
images.push(createObject(imageUrl)); images.push(createObject(imageUrl));
} }
context?.logger.info( context?.logger.info(
`Image processing complete: icon, cover, banner and ${screenshotCount} screenshots`, `Image processing complete: icon, cover, banner and ${screenshots.length} screenshots`,
); );
context?.progress(50); context?.progress(50);

View File

@ -124,7 +124,10 @@ class NewsManager {
} }
async delete(id: string) { async delete(id: string) {
const article = await prisma.article.delete({ const article = await prisma.article.findUnique({ where: { id } });
if (!article) return false;
// eslint-disable-next-line drop/no-prisma-delete
await prisma.article.delete({
where: { id }, where: { id },
}); });
if (article.imageObjectId) { if (article.imageObjectId) {

View File

@ -259,16 +259,10 @@ class FsHashStore {
*/ */
async delete(id: ObjectReference) { async delete(id: ObjectReference) {
await this.cache.remove(id); await this.cache.remove(id);
await prisma.objectHash.deleteMany({
try {
// need to catch in case the object doesn't exist
await prisma.objectHash.delete({
where: { where: {
id, id,
}, },
}); });
} catch {
/* empty */
}
} }
} }

View File

@ -53,12 +53,16 @@ class ScreenshotManager {
* @param id * @param id
*/ */
async delete(id: string) { async delete(id: string) {
const deletedScreenshot = await prisma.screenshot.delete({ const screenshot = await prisma.screenshot.findUnique({ where: { id } });
if (!screenshot) return false;
// eslint-disable-next-line drop/no-prisma-delete
await prisma.screenshot.delete({
where: { where: {
id, id,
}, },
}); });
await objectHandler.deleteAsSystem(deletedScreenshot.objectId); await objectHandler.deleteAsSystem(screenshot.objectId);
return true;
} }
/** /**

View File

@ -43,12 +43,12 @@ export default function createDBSessionHandler(): SessionProvider {
}, },
async removeSession(token) { async removeSession(token) {
await cache.remove(token); await cache.remove(token);
await prisma.session.delete({ const { count } = await prisma.session.deleteMany({
where: { where: {
token, token,
}, },
}); });
return true; return count > 0;
}, },
async cleanupSessions() { async cleanupSessions() {
const now = new Date(); const now = new Date();

View File

@ -101,9 +101,7 @@ class UserLibraryManager {
async collectionRemove(gameId: string, collectionId: string, userId: string) { async collectionRemove(gameId: string, collectionId: string, userId: string) {
// Delete if exists // Delete if exists
return ( const { count } = await prisma.collectionEntry.deleteMany({
(
await prisma.collectionEntry.deleteMany({
where: { where: {
collectionId, collectionId,
gameId, gameId,
@ -111,9 +109,8 @@ class UserLibraryManager {
userId, userId,
}, },
}, },
}) });
).count > 0 return count > 0;
);
} }
async collectionCreate(name: string, userId: string) { async collectionCreate(name: string, userId: string) {
@ -133,12 +130,13 @@ class UserLibraryManager {
} }
async deleteCollection(collectionId: string) { async deleteCollection(collectionId: string) {
await prisma.collection.delete({ const { count } = await prisma.collection.deleteMany({
where: { where: {
id: collectionId, id: collectionId,
isDefault: false, isDefault: false,
}, },
}); });
return count > 0;
} }
} }

View File

@ -0,0 +1,68 @@
/*
Handles managing collections
*/
import cacheHandler from "../cache";
import prisma from "../db/database";
import { DateTime } from "luxon";
class UserStatsManager {
// Caches the user's core library
private userStatsCache = cacheHandler.createCache<number>("userStats");
async cacheUserSessions() {
const activeSessions =
(
await prisma.client.groupBy({
by: ["userId"],
where: {
id: { not: "system" },
lastConnected: {
gt: DateTime.now().minus({ months: 1 }).toISO(),
},
},
})
).length || 0;
await this.userStatsCache.set("activeSessions", activeSessions);
}
private async cacheUserCount() {
const userCount =
(await prisma.user.count({
where: { id: { not: "system" } },
})) || 0;
await this.userStatsCache.set("userCount", userCount);
}
async cacheUserStats() {
await this.cacheUserSessions();
await this.cacheUserCount();
}
async getUserStats() {
let activeSessions = await this.userStatsCache.get("activeSessions");
let userCount = await this.userStatsCache.get("userCount");
if (activeSessions === null || userCount === null) {
await this.cacheUserStats();
activeSessions = (await this.userStatsCache.get("activeSessions")) || 0;
userCount = (await this.userStatsCache.get("userCount")) || 0;
}
return { activeSessions, userCount };
}
async addUser() {
const userCount = (await this.userStatsCache.get("userCount")) || 0;
await this.userStatsCache.set("userCount", userCount + 1);
}
async deleteUser() {
const userCount = (await this.userStatsCache.get("userCount")) || 1;
await this.userStatsCache.set("userCount", userCount - 1);
await this.cacheUserSessions();
}
}
export const manager = new UserStatsManager();
export default manager;

View File

@ -0,0 +1,47 @@
import fs from "node:fs";
import nodePath from "node:path";
export function fsStats(folderPath: string) {
const stats = fs.statfsSync(folderPath);
const freeSpace = stats.bavail * stats.bsize;
const totalSpace = stats.blocks * stats.bsize;
return { freeSpace, totalSpace };
}
export function getFolderSize(folderPath: string): number {
const files = fs.readdirSync(folderPath, { withFileTypes: true });
const paths = files.map((file) => {
const path = nodePath.join(folderPath, file.name);
if (file.isDirectory()) {
return getFolderSize(path);
}
if (file.isFile()) {
return fs.statSync(path).size;
}
return 0;
});
return paths
.flat(Infinity)
.reduce(
(accumulator: number, currentValue: number) => accumulator + currentValue,
0,
);
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes >= 1024 && bytes < Math.pow(1024, 2)) {
return `${(bytes / 1024).toFixed(2)} KiB`;
}
if (bytes >= Math.pow(1024, 2) && bytes < Math.pow(1024, 3)) {
return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
}
if (bytes >= Math.pow(1024, 3) && bytes < Math.pow(1024, 4)) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`;
}
return `${(bytes / Math.pow(1024, 4)).toFixed(2)} TiB`;
}

View File

@ -2,6 +2,7 @@
// https://nuxt.com/docs/guide/concepts/typescript // https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json", "extends": "./.nuxt/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"exactOptionalPropertyTypes": false "exactOptionalPropertyTypes": false,
"allowImportingTsExtensions": true
} }
} }

6
utils/array.ts Normal file
View File

@ -0,0 +1,6 @@
export const sum = (array: number[]) =>
array.reduce((accumulator, currentValue) => accumulator + currentValue, 0);
export function lastItem<T>(array: T[]) {
return array[array.length - 1];
}

76
utils/colors.ts Normal file
View File

@ -0,0 +1,76 @@
export const CHART_COLOURS = {
// Bar colours
red: {
fill: "fill-red-700",
bg: "bg-red-700",
},
orange: {
fill: "fill-orange-800",
bg: "bg-orange-800",
},
blue: {
fill: "fill-blue-900",
bg: "bg-blue-900",
},
// Pie colours
lightblue: {
fill: "fill-blue-400",
bg: "bg-blue-400",
},
dropblue: {
fill: "fill-blue-600",
bg: "bg-blue-600",
},
green: {
fill: "fill-green-500",
bg: "bg-green-500",
},
yellow: {
fill: "fill-yellow-800",
bg: "bg-yellow-800",
},
purple: {
fill: "fill-purple-500",
bg: "bg-purple-500",
},
zinc: {
fill: "fill-zinc-950",
bg: "bg-zinc-950",
},
pink: {
fill: "fill-pink-800",
bg: "bg-pink-800",
},
lime: {
fill: "fill-lime-600",
bg: "bg-lime-600",
},
emerald: {
fill: "fill-emerald-500",
bg: "bg-emerald-500",
},
slate: {
fill: "fill-slate-800",
bg: "bg-slate-800",
},
};
export const PIE_COLOURS: ChartColour[] = [
"lightblue",
"dropblue",
"purple",
"emerald",
];
export type ChartColour = keyof typeof CHART_COLOURS;
export function getBarColor(percentage: number): ChartColour {
if (percentage <= 70) {
return "blue";
}
if (percentage > 70 && percentage <= 90) {
return "orange";
}
return "red";
}

13
utils/tuple.ts Normal file
View File

@ -0,0 +1,13 @@
export default class Tuple {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
toString() {
return `${this.x},${this.y}`;
}
}