18 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
76 changed files with 4512 additions and 1222 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

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

@ -3,16 +3,16 @@
"devices": { "devices": {
"capabilities": "Capacités", "capabilities": "Capacités",
"lastConnected": "Dernière Connexion", "lastConnected": "Dernière Connexion",
"noDevices": "Aucun appareil connecté à vôtre compte.", "noDevices": "Aucun appareil n'est connecté à vôtre compte.",
"platform": "Plateforme", "platform": "Plateforme",
"revoke": "Révoquer", "revoke": "Révoquer",
"subheader": "Gérer les appareils authorisés à accéder à votre compte Drop.", "subheader": "Gérer les appareils authorisés à accéder à votre compte Drop.",
"title": "Appareils" "title": "Appareils"
}, },
"notifications": { "notifications": {
"all": "Voir tout {arrow}", "all": "Tout voir {arrow}",
"desc": "Voir et gérer vos notifications.", "desc": "Voir et gérer vos notifications.",
"markAllAsRead": "Marquer tout comme lu", "markAllAsRead": "Tout marqué comme lu",
"markAsRead": "Marquer comme lu", "markAsRead": "Marquer comme lu",
"none": "Pas de notification", "none": "Pas de notification",
"notifications": "Notifications", "notifications": "Notifications",
@ -62,7 +62,6 @@
"description": "Utiliser un code pour vous connecter à votre client Drop si vous ne pouvez pas ouvrir un navigateur web sur votre appareil.", "description": "Utiliser un code pour vous connecter à votre client Drop si vous ne pouvez pas ouvrir un navigateur web sur votre appareil.",
"title": "Connecter votre client Drop" "title": "Connecter votre client Drop"
}, },
"confirmPassword": "Confirmez @:auth.password",
"displayName": "Nom d'Affichage", "displayName": "Nom d'Affichage",
"email": "Email", "email": "Email",
"password": "Mot de passe", "password": "Mot de passe",
@ -148,7 +147,6 @@
"auth": { "auth": {
"disabled": "Compte invalide or désactivé. Merci de contacter l'administrateur du serveur.", "disabled": "Compte invalide or désactivé. Merci de contacter l'administrateur du serveur.",
"invalidInvite": "Invitation invalide ou expirée", "invalidInvite": "Invitation invalide ou expirée",
"invalidPassState": "Le mot de passe enregistré est invalide. Merci de contacter l'administrateur du serveur.",
"invalidUserOrPass": "Nom d'utilisateur ou password invalide.", "invalidUserOrPass": "Nom d'utilisateur ou password invalide.",
"inviteIdRequired": "id est requis pour récupérer l'invitation", "inviteIdRequired": "id est requis pour récupérer l'invitation",
"method": { "method": {
@ -157,10 +155,6 @@
"usernameTaken": "Nom d'utilisateur déjà pris." "usernameTaken": "Nom d'utilisateur déjà pris."
}, },
"backHome": "{arrow} Retour a l'accueil", "backHome": "{arrow} Retour a l'accueil",
"externalUrl": {
"subtitle": "Ce message n'est visible qu'aux administrateurs.",
"title": "Accès via une EXTERNAL_URL différente. Veuillez consulter la documentation."
},
"game": { "game": {
"banner": { "banner": {
"description": "Drop a échoué a mettre à jour l'image de la bannière : {0}", "description": "Drop a échoué a mettre à jour l'image de la bannière : {0}",
@ -221,7 +215,6 @@
"revokeClient": "Échec de la révocation du client", "revokeClient": "Échec de la révocation du client",
"revokeClientFull": "Échec de la revocation du client {0}", "revokeClientFull": "Échec de la revocation du client {0}",
"signIn": "Se connecter {arrow}", "signIn": "Se connecter {arrow}",
"support": "Assistance Discord",
"unknown": "Une erreur inconnue est survenue", "unknown": "Une erreur inconnue est survenue",
"upload": { "upload": {
"description": "Drop n'a pas pu uploader le fichier : {0}", "description": "Drop n'a pas pu uploader le fichier : {0}",
@ -261,11 +254,7 @@
"admin": { "admin": {
"admin": "Administration", "admin": "Administration",
"metadata": "Méta", "metadata": "Méta",
"settings": { "settings": "Paramètres",
"store": "Store",
"title": "Paramètres",
"tokens": "API tokens"
},
"tasks": "Tâches", "tasks": "Tâches",
"users": "Utilisateurs" "users": "Utilisateurs"
}, },
@ -338,8 +327,6 @@
}, },
"withoutMetadata": "Importer sans les données méta" "withoutMetadata": "Importer sans les données méta"
}, },
"libraryHint": "Pas de bibliothèque configurée.",
"libraryHintDocsLink": "Qu'est-ce que cela veut dire ? {arrow}",
"metadata": { "metadata": {
"companies": { "companies": {
"action": "Gérer {arrow}", "action": "Gérer {arrow}",
@ -353,25 +340,15 @@
"description": "Les sociétés organisent les jeux par qui les a développer ou éditer.", "description": "Les sociétés organisent les jeux par qui les a développer ou éditer.",
"editor": { "editor": {
"action": "Ajouter un jeu {plus}", "action": "Ajouter un jeu {plus}",
"descriptionPlaceholder": "{'<'}description{'>'}",
"developed": "Développé", "developed": "Développé",
"libraryDescription": "Ajouter, supprimer ou personnaliser ce que cette société a développé et/ou publié.", "libraryDescription": "Ajouter, supprimer ou personnaliser ce que cette société a développé et/ou publié.",
"libraryTitle": "Bibliothèque de jeux", "libraryTitle": "Bibliothèque de jeux",
"noDescription": "(pas de description)", "noDescription": "(pas de description)",
"published": "Publié", "published": "Publié",
"uploadBanner": "Uploader bannière", "uploadBanner": "Uploader bannière",
"uploadIcon": "Uplader icône", "uploadIcon": "Uplader icône"
"websitePlaceholder": "{'<'}site web{'>'}"
}, },
"modals": { "modals": {
"createDescription": "Créez une société pour mieux organizer vos jeux.",
"createFieldDescription": "Description de la Société",
"createFieldDescriptionPlaceholder": "Un petit studio indépendant qui...",
"createFieldName": "Nom de la société",
"createFieldNamePlaceholder": "Ma nouvelle société...",
"createFieldWebsite": "Site web de la société",
"createFieldWebsitePlaceholder": "https://exemple com/",
"createTitle": "Créer une société",
"nameDescription": "Éditer le nom de la société. Ce nom est utilisé pour trouver les jeux nouvellement importés.", "nameDescription": "Éditer le nom de la société. Ce nom est utilisé pour trouver les jeux nouvellement importés.",
"nameTitle": "Éditer le nom de la société", "nameTitle": "Éditer le nom de la société",
"shortDeckDescription": "Éditer la description de la company. Cela n'affecte pas la description longue (markdown).", "shortDeckDescription": "Éditer la description de la company. Cela n'affecte pas la description longue (markdown).",
@ -407,15 +384,12 @@
"create": "Créer une source", "create": "Créer une source",
"createDesc": "Drop va utiliser cette source pour accéder à votre bibliothèque de jeux, et les rendre disponible.", "createDesc": "Drop va utiliser cette source pour accéder à votre bibliothèque de jeux, et les rendre disponible.",
"desc": "Configurer vos sources de bibliothèques où Drop va regarder pour les nouveaux jeux et versions à importer.", "desc": "Configurer vos sources de bibliothèques où Drop va regarder pour les nouveaux jeux et versions à importer.",
"documentationLink": "Documentation {arrow}",
"edit": "Éditer la source", "edit": "Éditer la source",
"fsDesc": "Importe les jeux à partir d'un chemin d'accès sur le disque. Cela requière une structure des dossiers basées sur la version, et qui supporte les jeux archivés.", "fsDesc": "Importe les jeux à partir d'un chemin d'accès sur le disque. Cela requière une structure des dossiers basées sur la version, et qui supporte les jeux archivés.",
"fsFlatDesc": "Importe les jeux à partir d'un chemin daccès sur le disque, mais sans le sous-dossier version séparé. Utile pour migrer une bibliothèque vers Drop.", "fsFlatDesc": "Importe les jeux à partir d'un chemin daccès sur le disque, mais sans le sous-dossier version séparé. Utile pour migrer une bibliothèque vers Drop.",
"fsFlatTitle": "Compatibilité",
"fsPath": "Chemin daccès", "fsPath": "Chemin daccès",
"fsPathDesc": "Un chemin daccès absolu à votre bibliothèque de jeux.", "fsPathDesc": "Un chemin daccès absolu à votre bibliothèque de jeux.",
"fsPathPlaceholder": "/mnt/jeux", "fsPathPlaceholder": "/mnt/jeux",
"fsTitle": "Drop-style",
"link": "Sources {arrow}", "link": "Sources {arrow}",
"nameDesc": "Le nom de votre source, pour référence.", "nameDesc": "Le nom de votre source, pour référence.",
"namePlaceholder": "Mes Nouvelle Source", "namePlaceholder": "Mes Nouvelle Source",
@ -473,7 +447,6 @@
"checkLater": "Vérifier plus tard pour les mises à jour.", "checkLater": "Vérifier plus tard pour les mises à jour.",
"delete": "Supprimer l'Article", "delete": "Supprimer l'Article",
"filter": { "filter": {
"all": "Tous les temps",
"month": "Ce mois", "month": "Ce mois",
"week": "Cette semaine", "week": "Cette semaine",
"year": "Cette année" "year": "Cette année"
@ -536,19 +509,15 @@
"store": { "store": {
"about": "À propos", "about": "À propos",
"commingSoon": "prochainement", "commingSoon": "prochainement",
"developers": "Développeurs | Développeur | Développeurs",
"exploreMore": "Explorer plus {arrow}", "exploreMore": "Explorer plus {arrow}",
"featured": "Mis en avant", "featured": "Mis en avant",
"images": "Images de Jeux", "images": "Images de Jeux",
"lookAt": "Découvrez le maintenant",
"noDevelopers": "Pas de développeur", "noDevelopers": "Pas de développeur",
"noFeatured": "PAS DE JEU MIS EN AVANT", "noGame": "pas de jeu",
"noGame": "PAS DE JEU",
"noImages": "Pas d'image", "noImages": "Pas d'image",
"noPublishers": "Pas d'éditeur.", "noPublishers": "Pas d'éditeur.",
"noTags": "Pas de tag", "noTags": "Pas de tag",
"openAdminDashboard": "Ouvrir dans le Tableau de Bord d'Administration", "openAdminDashboard": "Ouvrir dans le Tableau de Bord d'Administration",
"openFeatured": "Mettez des étoiles aux jeux dans l'administration de la bibliothèque {arrow}",
"platform": "Plateforme | Plateforme | Plateformes", "platform": "Plateforme | Plateforme | Plateformes",
"publishers": "Éditeurs | Éditeur | Éditeurs", "publishers": "Éditeurs | Éditeur | Éditeurs",
"rating": "Note", "rating": "Note",
@ -575,9 +544,7 @@
"back": "{arrow} Retour aux Tâches", "back": "{arrow} Retour aux Tâches",
"completedTasksTitle": "Tâches complétées", "completedTasksTitle": "Tâches complétées",
"dailyScheduledTitle": "Tâches quotidiennes planifiées", "dailyScheduledTitle": "Tâches quotidiennes planifiées",
"execute": "{arrow} Exécuter",
"noTasksRunning": "Pas de tâche en cours", "noTasksRunning": "Pas de tâche en cours",
"progress": "{0]%",
"runningTasksTitle": "Tâches en cours d'exécution", "runningTasksTitle": "Tâches en cours d'exécution",
"scheduled": { "scheduled": {
"checkUpdateDescription": "Vérifier si Drop a une mise à jour.", "checkUpdateDescription": "Vérifier si Drop a une mise à jour.",
@ -621,7 +588,6 @@
"description": "Drop supporte une variété de \"mécanismes d'authentification\". Lorsque vous les activez ou les désactivez, ils sont affichés sur la page de connection pour que les utilisateurs puissent les sélectionner. Cliquer sur le menu à points pour configurer le mécanisme d'authentification.", "description": "Drop supporte une variété de \"mécanismes d'authentification\". Lorsque vous les activez ou les désactivez, ils sont affichés sur la page de connection pour que les utilisateurs puissent les sélectionner. Cliquer sur le menu à points pour configurer le mécanisme d'authentification.",
"disabled": "Désactivé", "disabled": "Désactivé",
"enabled": "Activé", "enabled": "Activé",
"enabledKey": "Activée ?",
"oidc": "OpenID Connect", "oidc": "OpenID Connect",
"simple": "Simple (nom d'utilisateur/mot de passe)", "simple": "Simple (nom d'utilisateur/mot de passe)",
"srOpenOptions": "Ouvrir les options", "srOpenOptions": "Ouvrir les options",
@ -639,7 +605,7 @@
"createInvitation": "Créer invitation", "createInvitation": "Créer invitation",
"description": "L'authentification simple utilise un système d'invitations pour créer les utilisateurs. Tu peux créer une invitation et optionnellement spécifier le nom d'utilisateur ou email de cet utilisateur, et un lien magique sera généré un lien magique qui peut être utilisé pour créer le compte.", "description": "L'authentification simple utilise un système d'invitations pour créer les utilisateurs. Tu peux créer une invitation et optionnellement spécifier le nom d'utilisateur ou email de cet utilisateur, et un lien magique sera généré un lien magique qui peut être utilisé pour créer le compte.",
"expires": "Expire : {expiry}", "expires": "Expire : {expiry}",
"invitationTitle": "Invitations", "invitationTitle": "invitations",
"invite3Days": "3 jours", "invite3Days": "3 jours",
"invite6Months": "6 mois", "invite6Months": "6 mois",
"inviteAdminSwitchDescription": "Créer cet utilisateur en tant qu'adminstrateur", "inviteAdminSwitchDescription": "Créer cet utilisateur en tant qu'adminstrateur",

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

@ -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}`;
}
}