19 Commits

Author SHA1 Message Date
2db8e753b7 update to nuxt 4 2025-09-20 11:21:53 +10:00
b4f9b77809 checkpoint: before migrating to nuxt v4 2025-09-20 11:21:51 +10:00
0b9a715bf2 Merge branch 'develop' into redistributable 2025-09-12 09:11:43 +10:00
5c1b0e6c1e feat: uninstall commands, new R UI 2025-09-07 17:30:24 +10:00
d84c70a05f fix: remove platform 2025-09-06 19:23:02 +10:00
bfd5c8e761 fix: redelete platform 2025-09-06 18:33:27 +10:00
3311aa7274 Merge branch 'develop' into redistributable 2025-09-06 18:32:09 +10:00
fcfc30e5df feat: import of custom platforms & file extensions 2025-09-06 18:29:04 +10:00
7266d0485b fix: update drop-base commit 2025-09-06 15:20:31 +10:00
cf3a458bdf feat: add server side redist patching 2025-08-28 11:14:38 +10:00
ca7a89bbcf feat: beginnings of platform & redist management 2025-08-27 19:52:36 +10:00
d323816b9e fix: sanitize svg uploads
... copilot suggested this

I feel dirty.
2025-08-27 12:21:17 +10:00
367d349a68 feat: add user platform filters to store view 2025-08-27 12:16:27 +10:00
8efddc07bc feat: partial user platform support + statusMessage -> message 2025-08-27 11:25:23 +10:00
3af00e085e fix: giantbomb logging bug 2025-08-25 16:22:53 +10:00
b7d685814b Merge branch 'develop' into redistributable 2025-08-25 16:19:48 +10:00
f1957a418c feat: import redists 2025-08-22 13:48:47 +10:00
322af0b4ca feat: rearchitecture of database schemas, migration reset, and #180 2025-08-20 20:35:50 +10:00
6853383e86 feat: database redist support 2025-08-20 11:50:59 +10:00
93 changed files with 2012 additions and 5052 deletions

View File

@ -5,8 +5,8 @@ on:
release: release:
types: [published] types: [published]
# This can be used to automatically publish nightlies at UTC nighttime # This can be used to automatically publish nightlies at UTC nighttime
#schedule: schedule:
# - cron: "0 2 * * *" # run at 2 AM UTC - cron: "0 2 * * *" # run at 2 AM UTC
jobs: jobs:
web: web:

View File

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

View File

@ -29,23 +29,6 @@
<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 -->
@ -508,38 +491,11 @@ 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);
@ -605,6 +561,7 @@ 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

@ -1,19 +0,0 @@
<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

@ -92,7 +92,7 @@ import type { Locale } from "vue-i18n";
const { showText = true } = defineProps<{ showText?: boolean }>(); const { showText = true } = defineProps<{ showText?: boolean }>();
const { locale: currLocale, setLocale, locales } = useI18n(); const { availableLocales, locale: currLocale, setLocale } = useI18n();
function changeLocale(locale: Locale) { function changeLocale(locale: Locale) {
setLocale(locale); setLocale(locale);
@ -102,7 +102,7 @@ function changeLocale(locale: Locale) {
useHead({ useHead({
htmlAttrs: { htmlAttrs: {
lang: locale, lang: locale,
dir: locales.value.find((l) => l.code === locale)?.dir || "ltr", // dir: availableLocales.find((l) => l === locale)?.dir || "ltr",
}, },
}); });
} }
@ -150,6 +150,6 @@ const wiredLocale = computed({
}, },
}); });
const currentLocaleInformation = computed(() => const currentLocaleInformation = computed(() =>
locales.value.find((e) => e.code == wiredLocale.value), availableLocales.find((e) => e == wiredLocale.value),
); );
</script> </script>

View File

@ -1,45 +0,0 @@
<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

@ -1,35 +0,0 @@
<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>

View File

@ -1,19 +0,0 @@
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

@ -1,50 +0,0 @@
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

@ -106,7 +106,7 @@ const emit = defineEmits<{
}>(); }>();
const props = defineProps<{ const props = defineProps<{
value?: string | undefined; value?: string;
guesses?: Array<{ platform: PlatformRenderable; filename: string }>; guesses?: Array<{ platform: PlatformRenderable; filename: string }>;
}>(); }>();

View File

@ -1,31 +0,0 @@
<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

@ -1,43 +0,0 @@
<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>

View File

@ -5,7 +5,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import AdminSourcesPage from "~/pages/admin/library/sources/index.vue"; import AdminSourcesPage from "~~/pages/admin/library/sources/index.vue";
const complete = defineModel<boolean>({ required: true }); const complete = defineModel<boolean>({ required: true });
// Only runs on component load, so it's fine // Only runs on component load, so it's fine

View File

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

View File

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

View File

@ -1,52 +0,0 @@
<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

@ -172,14 +172,9 @@ 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("header.admin.home"), label: $t("userHeader.links.library"),
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">
<LazyUserHeader class="z-50" hydrate-on-idle /> <UserHeader class="z-50" hydrate-on-idle />
<div class="grow flex"> <div class="grow flex">
<NuxtPage /> <NuxtPage />
</div> </div>
<LazyUserFooter class="z-50" hydrate-on-interaction /> <UserFooter 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

@ -1,147 +1,6 @@
<template> <template><div /></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 { RankItem } from "~/components/RankingList.vue";
import type { GameSize } from "~~/server/internal/gamesize";
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
}); });
@ -149,29 +8,4 @@ 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

@ -134,41 +134,34 @@
</div> </div>
</div> </div>
<!-- setup mode --> <!-- setup mode -->
<fieldset class="max-w-lg"> <SwitchGroup as="div" class="max-w-lg flex items-center justify-between">
<legend class="text-sm/6 font-semibold text-white"> <span class="flex flex-grow flex-col">
Select an import mode <SwitchLabel
</legend> as="span"
<div class="mt-2 grid grid-cols-1 gap-y-6 sm:grid-cols-2 sm:gap-x-4"> class="text-sm font-medium leading-6 text-zinc-100"
<label passive
v-for="mode in setupModes" >{{ $t("library.admin.import.version.setupMode") }}</SwitchLabel
:key="mode.id"
:aria-label="mode.title"
:aria-description="mode.description"
class="cursor-pointer group relative flex rounded-lg border border-white/10 bg-zinc-800/50 p-4 has-checked:bg-blue-500/10 has-checked:outline-2 has-checked:-outline-offset-2 has-checked:outline-blue-500 has-focus-visible:outline-3 has-focus-visible:-outline-offset-1 has-disabled:bg-gray-800 has-disabled:opacity-25"
> >
<input <SwitchDescription as="span" class="text-sm text-zinc-400">{{
type="radio" $t("library.admin.import.version.setupModeDesc")
name="mode" }}</SwitchDescription>
:value="mode.id" </span>
:checked="versionSettings.onlySetup === mode.value" <Switch
class="absolute inset-0 appearance-none opacity-0 focus:outline-none" v-model="versionSettings.onlySetup"
@click="versionSettings.onlySetup = mode.value" :class="[
/> versionSettings.onlySetup ? 'bg-blue-600' : 'bg-zinc-800',
<div class="flex-1"> 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
<span class="block text-sm font-medium text-white">{{ ]"
mode.title >
}}</span> <span
<span class="mt-1 block text-xs text-zinc-400">{{ aria-hidden="true"
mode.description :class="[
}}</span> versionSettings.onlySetup ? 'translate-x-5' : 'translate-x-0',
</div> 'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
<CheckCircleIcon ]"
class="invisible size-5 text-blue-500 group-has-checked:visible" />
aria-hidden="true" </Switch>
/> </SwitchGroup>
</label>
</div>
</fieldset>
<!-- launch commands --> <!-- launch commands -->
<div class="relative max-w-3xl"> <div class="relative max-w-3xl">
<label <label
@ -259,8 +252,7 @@
>Uninstall command</label >Uninstall command</label
> >
<p class="text-zinc-400 text-xs"> <p class="text-zinc-400 text-xs">
Executable to be run on uninstalling a game. Useful for installer-only Executable to be run on uninstalling a game. Useful for installer-only games.
games.
</p> </p>
<div class="mt-2"> <div class="mt-2">
<div <div
@ -309,8 +301,7 @@
</SwitchDescription> </SwitchDescription>
</span> </span>
<Switch <Switch
:model-value="versionSettings.delta || false" v-model="versionSettings.delta"
@update:model-value="(v) => (versionSettings.delta = v)"
:class="[ :class="[
versionSettings.delta ? 'bg-blue-600' : 'bg-zinc-800', versionSettings.delta ? 'bg-blue-600' : 'bg-zinc-800',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2', 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
@ -469,14 +460,10 @@ import {
} from "@headlessui/vue"; } from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/16/solid"; import { XCircleIcon } from "@heroicons/vue/16/solid";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid"; import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { import { PlusIcon, TrashIcon } from "@heroicons/vue/24/outline";
CheckCircleIcon,
PlusIcon,
TrashIcon,
} from "@heroicons/vue/24/outline";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid"; import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
import type { ImportGameVersion } from "~~/server/api/v1/admin/import/version/index.post"; import type { ImportVersion } from "~~/server/api/v1/admin/import/version/index.post";
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
@ -487,15 +474,14 @@ const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const gameId = route.params.id.toString(); const gameId = route.params.id.toString();
const versions = await $dropFetch( const versions = await $dropFetch(
`/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}&mode=game`, `/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`,
); );
const userPlatforms = await useAdminPlatforms(); const userPlatforms = await useAdminPlatforms();
const allPlatforms = renderPlatforms(userPlatforms); const allPlatforms = renderPlatforms(userPlatforms);
const currentlySelectedVersion = ref(-1); const currentlySelectedVersion = ref(-1);
const versionSettings = ref<Partial<typeof ImportVersion.infer>>({
const versionSettings = ref<Partial<ImportGameVersion>>({ id: gameId,
launches: [], launches: [],
onlySetup: false,
}); });
const versionGuesses = const versionGuesses =
@ -503,6 +489,7 @@ const versionGuesses =
Array<SerializeObject<{ platform: PlatformRenderable; filename: string }>> Array<SerializeObject<{ platform: PlatformRenderable; filename: string }>>
>(); >();
function updateLaunchCommand(idx: number, value: string) { function updateLaunchCommand(idx: number, value: string) {
versionSettings.value.launches![idx].launchCommand = value; versionSettings.value.launches![idx].launchCommand = value;
autosetPlatform(value); autosetPlatform(value);
@ -552,7 +539,7 @@ async function updateCurrentlySelectedVersion(value: number) {
const options = await $dropFetch( const options = await $dropFetch(
`/api/v1/admin/import/version/preload?id=${encodeURIComponent( `/api/v1/admin/import/version/preload?id=${encodeURIComponent(
gameId, gameId,
)}&version=${encodeURIComponent(version)}&mode=game`, )}&version=${encodeURIComponent(version)}`,
); );
versionGuesses.value = options.map((e) => ({ versionGuesses.value = options.map((e) => ({
...e, ...e,
@ -568,7 +555,6 @@ async function startImport() {
body: { body: {
id: gameId, id: gameId,
version: versions[currentlySelectedVersion.value], version: versions[currentlySelectedVersion.value],
mode: "game",
...versionSettings.value, ...versionSettings.value,
}, },
}); });
@ -585,26 +571,4 @@ function startImport_wrapper() {
importLoading.value = false; importLoading.value = false;
}); });
} }
const setupModes: Array<{
id: string;
value: boolean;
title: string;
description: string;
}> = [
{
id: "portable",
value: false,
title: "Portable",
description:
"This mode is for games that are designed to be launched directly from the install directory. Drop works best with these.",
},
{
id: "setup",
value: true,
title: "Installer",
description:
"Also known as 'setup-only', this mode is for installers that modify the system directly, and install to directories like Program Files.",
},
];
</script> </script>

View File

@ -158,7 +158,7 @@
</dl> </dl>
<div class="mt-4 flex flex-col gap-y-1"> <div class="mt-4 flex flex-col gap-y-1">
<NuxtLink <NuxtLink
:href="`/admin/library/${entry.urlPrefix}/${entry.id}`" :href="`/admin/library/g/${entry.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
> >
<i18n-t <i18n-t
@ -221,7 +221,7 @@
</dl> </dl>
<div class="mt-4 flex flex-col gap-y-1"> <div class="mt-4 flex flex-col gap-y-1">
<NuxtLink <NuxtLink
:href="`/admin/library/${entry.urlPrefix}/${entry.id}`" :href="`/admin/library/r/${entry.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
> >
<i18n-t <i18n-t
@ -261,7 +261,7 @@
</p> </p>
<p class="mt-3 text-sm md:ml-6 md:mt-0"> <p class="mt-3 text-sm md:ml-6 md:mt-0">
<NuxtLink <NuxtLink
:href="`/admin/library/${entry.urlPrefix}/${entry.id}/import`" :href="`/admin/library/g/${entry.id}/import`"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500" class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
> >
<i18n-t <i18n-t
@ -406,7 +406,6 @@ function clientSideTransformation<T, V extends keyof T, K extends string>(
toImport?: boolean; toImport?: boolean;
offline?: boolean; offline?: boolean;
}; };
urlPrefix: string,
} }
> { > {
return values.map((e) => { return values.map((e) => {
@ -419,7 +418,6 @@ function clientSideTransformation<T, V extends keyof T, K extends string>(
notifications: { notifications: {
offline: true, offline: true,
}, },
urlPrefix: type[0],
}; };
} }
@ -435,7 +433,6 @@ function clientSideTransformation<T, V extends keyof T, K extends string>(
}, },
hasNotifications: noVersions || toImport, hasNotifications: noVersions || toImport,
status: "online" as const, status: "online" as const,
urlPrefix: type[0],
}; };
}); });
} }

View File

@ -1,478 +0,0 @@
<template>
<div class="flex flex-col gap-y-4">
<Listbox
as="div"
:model-value="currentlySelectedVersion"
class="max-w-lg"
@update:model-value="(value) => updateCurrentlySelectedVersion(value)"
>
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">{{
$t("library.admin.import.version.version")
}}</ListboxLabel>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
>
<span v-if="currentlySelectedVersion != -1" class="block truncate">{{
versions[currentlySelectedVersion]
}}</span>
<span v-else class="block truncate text-zinc-600">{{
$t("library.admin.import.selectDir")
}}</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="(version, versionIdx) in versions"
:key="version"
v-slot="{ active, selected }"
as="template"
:value="versionIdx"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]"
>{{ version }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div v-if="versionGuesses" class="flex flex-col gap-4">
<!-- version name -->
<div class="max-w-lg">
<label
for="startup"
class="block text-sm font-medium leading-6 text-zinc-100"
>Version name</label
>
<p class="text-zinc-400 text-xs">
Shown to users when selecting what version to install.
</p>
<div class="mt-2">
<input
id="name"
v-model="versionSettings.name"
name="name"
type="text"
required
placeholder="my version name"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-950 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<!-- install command -->
<div class="max-w-lg">
<label
for="startup"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.import.version.setupCmd") }}</label
>
<p class="text-zinc-400 text-xs">
{{ $t("library.admin.import.version.setupDesc") }}
</p>
<div class="mt-2">
<div
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
>
<span
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
>
{{ $t("library.admin.import.version.installDir") }}
</span>
<PreloadSelector
:value="versionSettings.install"
:guesses="versionGuesses"
@update="(v) => updateInstallCommand(v)"
/>
<input
id="startup"
v-model="versionSettings.installArgs"
type="text"
name="startup"
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="--setup"
/>
</div>
</div>
</div>
<!-- launch commands -->
<div class="relative max-w-3xl">
<label
for="startup"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.import.version.launchCmd") }}</label
>
<p class="text-zinc-400 text-xs">
{{ $t("library.admin.import.version.launchDesc") }}
</p>
<div class="mt-2 ml-4 flex flex-col gap-y-2 items-start">
<div
v-for="(launch, launchIdx) in versionSettings.launches"
:key="launchIdx"
class="inline-flex items-center gap-x-2"
>
<input
id="launch-name"
v-model="launch.name"
type="text"
name="launch-name"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-950 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
placeholder="My Launch Command"
/>
<div
class="flex w-full rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
>
<span
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
>{{ $t("library.admin.import.version.installDir") }}</span
>
<PreloadSelector
:value="launch.launchCommand"
:guesses="versionGuesses"
@update="(v) => updateLaunchCommand(launchIdx, v)"
/>
<input
id="startup"
v-model="launch.launchArgs"
type="text"
name="startup"
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="--launch"
/>
</div>
<button
class="transition bg-zinc-800 rounded-sm aspect-square p-1 text-zinc-600 hover:text-red-600 hover:bg-red-600/20"
@click="() => versionSettings.launches!.splice(launchIdx, 1)"
>
<TrashIcon class="size-5" />
</button>
</div>
<p
v-if="versionSettings.launches!.length == 0"
class="uppercase font-display font-bold text-zinc-500 text-xs"
>
No launch commands
</p>
<LoadingButton
:loading="false"
class="inline-flex items-center gap-x-4"
@click="
() =>
versionSettings.launches!.push({
name: '',
description: '',
launchCommand: '',
launchArgs: '',
})
"
>
Add new <PlusIcon class="size-5" />
</LoadingButton>
</div>
</div>
<!-- uninstall command -->
<div class="max-w-lg">
<label
for="startup"
class="block text-sm font-medium leading-6 text-zinc-100"
>Uninstall command</label
>
<p class="text-zinc-400 text-xs">
Executable to be run on uninstalling a game. Useful for installer-only
games.
</p>
<div class="mt-2">
<div
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
>
<span
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
>
{{ $t("library.admin.import.version.installDir") }}
</span>
<PreloadSelector
:value="versionSettings.uninstall"
:guesses="versionGuesses"
@update="(v) => updateUninstallCommand(v)"
/>
<input
id="startup"
v-model="versionSettings.uninstallArgs"
type="text"
name="startup"
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="--uninstall"
/>
</div>
</div>
</div>
<PlatformSelector
v-model="versionSettings.platform"
class="max-w-lg"
:platforms="allPlatforms"
>
{{ $t("library.admin.import.version.platform") }}
</PlatformSelector>
<SwitchGroup as="div" class="flex items-center justify-between max-w-lg">
<span class="flex flex-grow flex-col">
<SwitchLabel
as="span"
class="text-sm font-medium leading-6 text-zinc-100"
passive
>
{{ $t("library.admin.import.version.updateMode") }}
</SwitchLabel>
<SwitchDescription as="span" class="text-sm text-zinc-400">
{{ $t("library.admin.import.version.updateModeDesc") }}
</SwitchDescription>
</span>
<Switch
:model-value="versionSettings.delta || false"
:class="[
versionSettings.delta ? 'bg-blue-600' : 'bg-zinc-800',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
]"
@update:model-value="(v) => (versionSettings.delta = v)"
>
<span
aria-hidden="true"
:class="[
versionSettings.delta ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
]"
/>
</Switch>
</SwitchGroup>
<Disclosure v-slot="{ open }" as="div" class="py-2 max-w-lg">
<dt>
<DisclosureButton
class="border-b border-zinc-600 pb-2 flex w-full items-start justify-between text-left text-zinc-100"
>
<span class="text-base/7 font-semibold">
{{ $t("library.admin.import.version.advancedOptions") }}
</span>
<span class="ml-6 flex h-7 items-center">
<ChevronUpIcon v-if="!open" class="size-6" aria-hidden="true" />
<ChevronDownIcon v-else class="size-6" aria-hidden="true" />
</span>
</DisclosureButton>
</dt>
<DisclosurePanel
as="dd"
class="bg-zinc-950/30 p-3 rounded-b-lg mt-2 flex flex-col gap-y-4"
>
<!-- UMU launcher configuration -->
<div class="text-zinc-400">
{{ $t("library.admin.import.version.noAdv") }}
</div>
</DisclosurePanel>
</Disclosure>
<LoadingButton
class="w-fit"
:loading="importLoading"
@click="startImport_wrapper"
>
{{ $t("library.admin.import.import") }}
</LoadingButton>
<div v-if="importError" class="mt-4 w-fit rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ importError }}
</h3>
</div>
</div>
</div>
</div>
<div
v-else-if="currentlySelectedVersion != -1"
role="status"
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
>
{{ $t("library.admin.import.version.loadingVersion") }}
<svg
aria-hidden="true"
class="w-6 h-6 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
Switch,
SwitchDescription,
SwitchGroup,
SwitchLabel,
Disclosure,
DisclosureButton,
DisclosurePanel,
} from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import {
PlusIcon,
TrashIcon,
} from "@heroicons/vue/24/outline";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { ImportRedistVersion } from "~~/server/api/v1/admin/import/version/index.post";
definePageMeta({
layout: "admin",
});
const router = useRouter();
const { t } = useI18n();
const route = useRoute();
const redistId = route.params.id.toString();
const versions = await $dropFetch(
`/api/v1/admin/import/version?id=${encodeURIComponent(redistId)}&mode=redist`,
);
const userPlatforms = await useAdminPlatforms();
const allPlatforms = renderPlatforms(userPlatforms);
const currentlySelectedVersion = ref(-1);
const versionSettings = ref<Partial<ImportRedistVersion>>({
launches: [],
});
const versionGuesses =
ref<
Array<SerializeObject<{ platform: PlatformRenderable; filename: string }>>
>();
function updateLaunchCommand(idx: number, value: string) {
versionSettings.value.launches![idx].launchCommand = value;
autosetPlatform(value);
}
function updateInstallCommand(value: string) {
versionSettings.value.install = value;
autosetPlatform(value);
}
function updateUninstallCommand(value: string) {
versionSettings.value.uninstall = value;
autosetPlatform(value);
}
function autosetPlatform(value: string) {
if (!versionGuesses.value) return;
if (versionSettings.value.platform) return;
const guessIndex = versionGuesses.value.findIndex(
(e) => e.filename === value,
);
if (guessIndex == -1) return;
versionSettings.value.platform =
versionGuesses.value[guessIndex].platform.param;
}
const importLoading = ref(false);
const importError = ref<string | undefined>();
async function updateCurrentlySelectedVersion(value: number) {
if (currentlySelectedVersion.value == value) return;
currentlySelectedVersion.value = value;
const version = versions[currentlySelectedVersion.value];
const options = await $dropFetch(
`/api/v1/admin/import/version/preload?id=${encodeURIComponent(
redistId,
)}&version=${encodeURIComponent(version)}&mode=redist`,
);
versionGuesses.value = options.map((e) => ({
...e,
platform: allPlatforms.find((v) => v.param === e.platform)!,
}));
versionSettings.value.name = version;
}
async function startImport() {
if (!versionSettings.value) return;
const taskId = await $dropFetch("/api/v1/admin/import/version", {
method: "POST",
body: {
id: redistId,
version: versions[currentlySelectedVersion.value],
mode: "redist",
...versionSettings.value,
},
});
router.push(`/admin/task/${taskId.taskId}`);
}
function startImport_wrapper() {
importLoading.value = true;
startImport()
.catch((error) => {
importError.value = error.message ?? t("errors.unknown");
})
.finally(() => {
importLoading.value = false;
});
}
</script>

View File

@ -18,12 +18,99 @@
</button> </button>
</div> </div>
</div> </div>
<div class="mt-4 flow-root"> <div class="mt-8 flow-root">
<SourceTable <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
:sources="sources" <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
:edit-source="edit" <table class="min-w-full divide-y divide-zinc-700">
:delete-source="deleteSource" <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="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">
@ -226,7 +313,7 @@ import {
XCircleIcon, XCircleIcon,
ArrowTopRightOnSquareIcon, ArrowTopRightOnSquareIcon,
} from "@heroicons/vue/20/solid"; } from "@heroicons/vue/20/solid";
import { BackwardIcon } from "@heroicons/vue/24/outline"; import { BackwardIcon, CheckIcon, XMarkIcon } 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

@ -53,7 +53,18 @@
:log="parseTaskLog(task.log.at(-(idx + 1)))" :log="parseTaskLog(task.log.at(-(idx + 1)))"
/> />
</div> </div>
<ProgressBar :percentage="task.progress" /> <div class="relative h-5 rounded-xl bg-zinc-950 overflow-hidden">
<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

@ -163,9 +163,9 @@ const scheduledTasks: {
name: "", name: "",
description: "", description: "",
}, },
"import:version": { debug: {
name: "", name: "Debug Task",
description: "", description: "Does debugging things.",
}, },
}; };

View File

@ -44,6 +44,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AuthMec } from "~~/prisma/client/enums"; import type { AuthMec } from "~~/prisma/client/enums";
import DropLogo from "~~/components/DropLogo.vue";
const { t } = useI18n(); const { t } = useI18n();
const enabledAuths = await $dropFetch("/api/v1/auth"); const enabledAuths = await $dropFetch("/api/v1/auth");

View File

@ -224,14 +224,14 @@ const scopes = [
href: "/docs/access/status", href: "/docs/access/status",
icon: UserGroupIcon, icon: UserGroupIcon,
}, },
clientData.capabilities["PeerAPI"] && { clientData.capabilities["peerAPI"] && {
name: "Access the Drop network", name: "Access the Drop network",
description: description:
"The client will be able to establish P2P connections with other users to enable features like download aggregation, Remote LAN play and P2P multiplayer.", "The client will be able to establish P2P connections with other users to enable features like download aggregation, Remote LAN play and P2P multiplayer.",
href: "/docs/access/network", href: "/docs/access/network",
icon: LockClosedIcon, icon: LockClosedIcon,
}, },
clientData.capabilities["CloudSaves"] && { clientData.capabilities["cloudSaves"] && {
name: "Upload and sync cloud saves", name: "Upload and sync cloud saves",
description: description:
"The client will be able to upload new cloud saves, and edit your existing ones.", "The client will be able to upload new cloud saves, and edit your existing ones.",

View File

@ -105,14 +105,14 @@ function input(index: number) {
function select(index: number) { function select(index: number) {
if (!codeElements.value) return; if (!codeElements.value) return;
if (index >= codeElements.value.length) return; if (index >= codeElements.value.length) return;
codeElements.value[index]!.select(); codeElements.value[index].select();
} }
function paste(index: number, event: ClipboardEvent) { function paste(index: number, event: ClipboardEvent) {
const newCode = event.clipboardData!.getData("text/plain"); const newCode = event.clipboardData!.getData("text/plain");
for (let i = 0; i < newCode.length && i < codeLength; i++) { for (let i = 0; i < newCode.length && i < codeLength; i++) {
code.value[i] = newCode[i]!; code.value[i] = newCode[i];
codeElements.value![i]!.focus(); codeElements.value![i].focus();
} }
event.preventDefault(); event.preventDefault();
} }

View File

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

View File

@ -110,7 +110,7 @@
> >
<div> <div>
<component <component
:is="actions[currentAction]!.page" :is="actions[currentAction].page"
v-model="actionsComplete[currentAction]" v-model="actionsComplete[currentAction]"
:token="bearerToken" :token="bearerToken"
/> />

View File

@ -93,27 +93,6 @@
> >
</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"
@ -211,7 +190,7 @@
{{ game.mShortDescription }} {{ game.mShortDescription }}
</p> </p>
<div class="mt-6 py-4 rounded"> <div class="mt-6 py-4 rounded">
<VueCarousel :items-to-show="1" :wrap-around="true"> <VueCarousel :items-to-show="1">
<VueSlide <VueSlide
v-for="image in game.mImageCarouselObjectIds" v-for="image in game.mImageCarouselObjectIds"
:key="image" :key="image"
@ -273,19 +252,13 @@
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline"; 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 { formatBytes } from "~~/server/internal/utils/files";
const route = useRoute(); const route = useRoute();
const gameId = route.params.id?.toString(); const gameId = route.params.id.toString();
if (!gameId)
throw createError({
statusCode: 404,
message: "Game not found",
fatal: true,
});
const user = useUser(); const user = useUser();
const { game, rating, platforms, size } = await $dropFetch(`/api/v1/games/${gameId}`);
const { game, rating, platforms } = await $dropFetch(`/api/v1/games/${gameId}`);
// Preview description (first 30 lines) // Preview description (first 30 lines)
const showPreview = ref(true); const showPreview = ref(true);

View File

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

View File

@ -1,76 +0,0 @@
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";
}

View File

@ -1,13 +0,0 @@
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}`;
}
}

View File

@ -1,206 +0,0 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div v-if="game && unimportedVersions">
<div class="grow flex flex-row gap-y-8">
<div class="grow w-full h-full px-6 py-4 flex flex-col"></div>
<div
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:block lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col gap-y-8 px-6 py-4"
>
<!-- version manager -->
<div>
<!-- version priority -->
<div>
<div class="border-b border-zinc-800 pb-3">
<div
class="flex flex-wrap items-center justify-between sm:flex-nowrap"
>
<h3
class="text-base font-semibold font-display leading-6 text-zinc-100"
>
{{ $t("library.admin.versionPriority") }}
<!-- import games button -->
<NuxtLink
:href="canImport ? `/admin/library/${game.id}/import` : ''"
type="button"
:class="[
canImport
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-blue-800/50',
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
]"
>
{{
canImport
? $t("library.admin.import.version.import")
: $t("library.admin.import.version.noVersions")
}}
</NuxtLink>
</h3>
</div>
</div>
<div class="mt-4 text-center w-full text-sm text-zinc-600">
{{ $t("lowest") }}
</div>
<draggable
:list="game.versions"
handle=".handle"
class="mt-2 space-y-4"
@update="() => updateVersionOrder()"
>
<template
#item="{ element: item }: { element: GameVersionModelWithSize }"
>
<div
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 flex-none">
{{ item.versionName }}
</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">
{{ item.delta ? $t("library.admin.version.delta") : "" }}
</div>
<div class="inline-flex items-center gap-x-2">
<component
:is="PLATFORM_ICONS[item.platform]"
class="size-6 text-blue-600"
/>
<Bars3Icon
class="cursor-move w-6 h-6 text-zinc-400 handle"
/>
<button @click="() => deleteVersion(item.versionName)">
<TrashIcon class="w-5 h-5 text-red-600" />
</button>
</div>
</div>
</template>
</draggable>
<div
v-if="game.versions.length == 0"
class="text-center font-bold text-zinc-400 my-3"
>
{{ $t("library.admin.version.noVersionsAdded") }}
</div>
<div class="mt-2 text-center w-full text-sm text-zinc-600">
{{ $t("highest") }}
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="grow w-full flex items-center justify-center">
<div class="flex flex-col items-center">
<ExclamationCircleIcon
class="h-12 w-12 text-red-600"
aria-hidden="true"
/>
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
{{ $t("library.admin.offlineTitle") }}
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-md">
{{ $t("library.admin.offline") }}
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { GameModel, GameVersionModel } from "~/prisma/client/models";
import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3";
import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
import { formatBytes } from "~/server/internal/utils/files";
// TODO implement UI for this page
const props = defineProps<{ unimportedVersions: string[] }>();
const { t } = useI18n();
const hasDeleted = ref(false);
const canImport = computed(
() => hasDeleted.value || props.unimportedVersions.length > 0,
);
type GameVersionModelWithSize = GameVersionModel & { size: number };
type GameAndVersions = GameModel & {
versions: GameVersionModelWithSize[];
};
const game = defineModel<SerializeObject<GameAndVersions>>() as Ref<
SerializeObject<GameAndVersions>
>;
if (!game.value)
throw createError({
statusCode: 500,
statusMessage: "Game not provided to editor component",
});
async function updateVersionOrder() {
try {
const newVersions = await $dropFetch("/api/v1/admin/game/version", {
method: "PATCH",
body: {
id: game.value.id,
versions: game.value.versions.map((e) => e.versionName),
},
});
game.value.versions = newVersions;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.version.order.title"),
description: t("errors.version.order.desc", {
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
}),
buttonText: t("common.close"),
},
(e, c) => c(),
);
}
}
async function deleteVersion(versionName: string) {
try {
await $dropFetch("/api/v1/admin/game/version", {
method: "DELETE",
body: {
id: game.value.id,
versionName: versionName,
},
});
game.value.versions.splice(
game.value.versions.findIndex((e) => e.versionName === versionName),
1,
);
hasDeleted.value = true;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.version.delete.title"),
description: t("errors.version.delete.desc", {
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
}),
buttonText: t("common.close"),
},
(e, c) => c(),
);
}
}
</script>

View File

@ -19,7 +19,6 @@ export default withNuxt([
}, },
], ],
"@intlify/vue-i18n/no-missing-keys": "error", "@intlify/vue-i18n/no-missing-keys": "error",
"vue/multi-word-component-names": "off",
}, },
settings: { settings: {
"vue-i18n": { "vue-i18n": {

View File

@ -117,9 +117,7 @@
"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": {
@ -270,8 +268,6 @@
"store": "Store", "store": "Store",
"tokens": "API tokens" "tokens": "API tokens"
}, },
"home": "Home",
"library": "Library",
"tasks": "Tasks", "tasks": "Tasks",
"users": "Users" "users": "Users"
}, },
@ -280,24 +276,7 @@
}, },
"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",
@ -313,7 +292,6 @@
"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.",
@ -445,11 +423,7 @@
"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",
@ -579,7 +553,6 @@
"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

@ -18,7 +18,6 @@ const twemojiJson = module.findPackageJSON(
if (!twemojiJson) { if (!twemojiJson) {
throw new Error("Could not find @discordapp/twemoji package."); throw new Error("Could not find @discordapp/twemoji package.");
} }
const svgSrcDir = path.join(path.dirname(twemojiJson), "dist", "svg");
// get drop version // get drop version
const dropVersion = getDropVersion(); const dropVersion = getDropVersion();
@ -75,13 +74,14 @@ export default defineNuxtConfig({
vite: { vite: {
plugins: [ plugins: [
tailwindcss(), // eslint-disable-next-line @typescript-eslint/no-explicit-any
tailwindcss() as any,
// only used in dev server, not build because nitro sucks // only used in dev server, not build because nitro sucks
// see build hook below // see build hook below
viteStaticCopy({ viteStaticCopy({
targets: [ targets: [
{ {
src: `${svgSrcDir}/*`, src: "node_modules/@discordapp/twemoji/dist/svg/*",
dest: "twemoji", dest: "twemoji",
}, },
], ],
@ -96,7 +96,7 @@ export default defineNuxtConfig({
// https://github.com/nuxt/nuxt/issues/18918#issuecomment-1925774964 // https://github.com/nuxt/nuxt/issues/18918#issuecomment-1925774964
// copy emojis to .output/public/twemoji // copy emojis to .output/public/twemoji
const targetDir = path.join(nitro.options.output.publicDir, "twemoji"); const targetDir = path.join(nitro.options.output.publicDir, "twemoji");
cpSync(svgSrcDir, targetDir, { cpSync(path.join(path.dirname(twemojiJson), "dist", "svg"), targetDir, {
recursive: true, recursive: true,
}); });
}, },
@ -163,11 +163,9 @@ export default defineNuxtConfig({
tsConfig: { tsConfig: {
compilerOptions: { compilerOptions: {
// Not having these options on is sloppy, but it's a task for later me
verbatimModuleSyntax: false, verbatimModuleSyntax: false,
strictNullChecks: true, strictNullChecks: true,
exactOptionalPropertyTypes: false, exactOptionalPropertyTypes: true,
//erasableSyntaxOnly: true,
noUncheckedIndexedAccess: false, noUncheckedIndexedAccess: false,
}, },
}, },
@ -263,7 +261,6 @@ export default defineNuxtConfig({
"https://www.giantbomb.com", "https://www.giantbomb.com",
"https://images.pcgamingwiki.com", "https://images.pcgamingwiki.com",
"https://images.igdb.com", "https://images.igdb.com",
"https://*.steamstatic.com",
], ],
}, },
strictTransportSecurity: false, strictTransportSecurity: false,

View File

@ -21,9 +21,10 @@
}, },
"dependencies": { "dependencies": {
"@discordapp/twemoji": "^16.0.1", "@discordapp/twemoji": "^16.0.1",
"@drop-oss/droplet": "3.2.0", "@drop-oss/droplet": "3.0.1",
"@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",
"@nuxt/fonts": "^0.11.0", "@nuxt/fonts": "^0.11.0",
"@nuxt/image": "^1.10.0", "@nuxt/image": "^1.10.0",
"@nuxtjs/i18n": "^9.5.5", "@nuxtjs/i18n": "^9.5.5",
@ -32,14 +33,14 @@
"@vueuse/nuxt": "13.6.0", "@vueuse/nuxt": "13.6.0",
"argon2": "^0.43.0", "argon2": "^0.43.0",
"arktype": "^2.1.10", "arktype": "^2.1.10",
"axios": "^1.12.0", "axios": "^1.7.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",
"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",
"jsdom": "^27.0.0", "jsdom": "^26.1.0",
"luxon": "^3.6.1", "luxon": "^3.6.1",
"micromark": "^4.0.1", "micromark": "^4.0.1",
"normalize-url": "^8.0.2", "normalize-url": "^8.0.2",
@ -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.14.0",
"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",
@ -87,8 +88,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"
} }

2600
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,4 @@
onlyBuiltDependencies: onlyBuiltDependencies:
- '@prisma/client'
- '@prisma/engines'
- '@tailwindcss/oxide'
- esbuild - esbuild
- prisma
shamefullyHoist: true shamefullyHoist: true

6
prisma.config.ts Normal file
View File

@ -0,0 +1,6 @@
import { defineConfig } from "prisma/config";
import path from "node:path";
export default defineConfig({
schema: path.join("prisma", "schema.prisma"),
});

View File

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

View File

@ -1,41 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[installRId]` on the table `LaunchOption` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[uninstallRId]` on the table `LaunchOption` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[installId]` on the table `RedistVersion` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[uninstallId]` on the table `RedistVersion` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "public"."GameTag_name_idx";
-- AlterTable
ALTER TABLE "public"."LaunchOption" ADD COLUMN "installRId" TEXT,
ADD COLUMN "uninstallRId" TEXT;
-- AlterTable
ALTER TABLE "public"."RedistVersion" ADD COLUMN "installId" TEXT,
ADD COLUMN "onlySetup" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "uninstallId" TEXT;
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "public"."GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
-- CreateIndex
CREATE UNIQUE INDEX "LaunchOption_installRId_key" ON "public"."LaunchOption"("installRId");
-- CreateIndex
CREATE UNIQUE INDEX "LaunchOption_uninstallRId_key" ON "public"."LaunchOption"("uninstallRId");
-- CreateIndex
CREATE UNIQUE INDEX "RedistVersion_installId_key" ON "public"."RedistVersion"("installId");
-- CreateIndex
CREATE UNIQUE INDEX "RedistVersion_uninstallId_key" ON "public"."RedistVersion"("uninstallId");
-- AddForeignKey
ALTER TABLE "public"."RedistVersion" ADD CONSTRAINT "RedistVersion_installId_fkey" FOREIGN KEY ("installId") REFERENCES "public"."LaunchOption"("launchId") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."RedistVersion" ADD CONSTRAINT "RedistVersion_uninstallId_fkey" FOREIGN KEY ("uninstallId") REFERENCES "public"."LaunchOption"("launchId") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -1,15 +0,0 @@
/*
Warnings:
- Added the required column `versionIndex` to the `RedistVersion` table without a default value. This is not possible if the table is not empty.
*/
-- DropIndex
DROP INDEX "public"."GameTag_name_idx";
-- AlterTable
ALTER TABLE "public"."RedistVersion" ADD COLUMN "delta" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "versionIndex" INTEGER NOT NULL;
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "public"."GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@ -1,18 +0,0 @@
/*
Warnings:
- You are about to drop the column `versionIndex` on the `GameVersion` table. All the data in the column will be lost.
- Added the required column `versionIndex` to the `Version` table without a default value. This is not possible if the table is not empty.
*/
-- DropIndex
DROP INDEX "public"."GameTag_name_idx";
-- AlterTable
ALTER TABLE "public"."GameVersion" DROP COLUMN "versionIndex";
-- AlterTable
ALTER TABLE "public"."Version" ADD COLUMN "versionIndex" INTEGER NOT NULL;
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "public"."GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@ -1,17 +0,0 @@
/*
Warnings:
- You are about to drop the column `hidden` on the `GameVersion` table. All the data in the column will be lost.
*/
-- DropIndex
DROP INDEX "public"."GameTag_name_idx";
-- AlterTable
ALTER TABLE "public"."GameVersion" DROP COLUMN "hidden";
-- AlterTable
ALTER TABLE "public"."Version" ADD COLUMN "hidden" BOOLEAN NOT NULL DEFAULT false;
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "public"."GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@ -49,11 +49,6 @@ model LaunchOption {
uninstallGId String? @unique uninstallGId String? @unique
uninstallGVersion GameVersion? @relation(name: "uninstall") uninstallGVersion GameVersion? @relation(name: "uninstall")
installRId String? @unique
installRVersion RedistVersion? @relation(name: "install_redist")
uninstallRId String? @unique
uninstallRVersion RedistVersion? @relation(name: "uninstall_redist")
name String name String
description String description String
@ -67,10 +62,8 @@ model Version {
versionPath String versionPath String
versionName String versionName String
versionIndex Int created DateTime @default(now())
created DateTime @default(now())
hidden Boolean @default(false)
gameId String? gameId String?
game Game? @relation(fields: [gameId], references: [id], map: "game_link", onDelete: Cascade, onUpdate: Cascade) game Game? @relation(fields: [gameId], references: [id], map: "game_link", onDelete: Cascade, onUpdate: Cascade)
@ -108,7 +101,9 @@ model GameVersion {
umuIdOverride String? umuIdOverride String?
delta Boolean @default(false) versionIndex Int
delta Boolean @default(false)
hidden Boolean @default(false)
platformId String platformId String
platform PlatformLink @relation(fields: [platformId], references: [id]) platform PlatformLink @relation(fields: [platformId], references: [id])
@ -130,17 +125,8 @@ model RedistVersion {
versionId String @id versionId String @id
version Version @relation(fields: [versionId], references: [versionId], onDelete: Cascade, onUpdate: Cascade) version Version @relation(fields: [versionId], references: [versionId], onDelete: Cascade, onUpdate: Cascade)
installId String? @unique
install LaunchOption? @relation(name: "install_redist", fields: [installId], references: [launchId])
uninstallId String? @unique
uninstall LaunchOption? @relation(name: "uninstall_redist", fields: [uninstallId], references: [launchId])
onlySetup Boolean @default(false)
launches LaunchOption[] launches LaunchOption[]
versionIndex Int
delta Boolean @default(false)
gameDependees GameVersion[] gameDependees GameVersion[]
dlcDependees DLCVersion[] dlcDependees DLCVersion[]

View File

@ -5,7 +5,6 @@ enum MetadataSource {
IGDB IGDB
Metacritic Metacritic
OpenCritic OpenCritic
Steam
} }
model Game { model Game {

View File

@ -10,6 +10,15 @@ generator client {
binaryTargets = ["native", "debian-openssl-3.0.x"] binaryTargets = ["native", "debian-openssl-3.0.x"]
} }
/**
* generator arktype {
* provider = "yarn prismark"
* output = "./validate"
* fileName = "schema.ts"
* nullish = true
* }
*/
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL") url = env("DATABASE_URL")

View File

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

View File

@ -23,7 +23,7 @@ export default defineEventHandler(async (h3) => {
install: true, install: true,
uninstall: true, uninstall: true,
launches: true, launches: true,
}, }
}, },
}, },
}, },
@ -34,24 +34,10 @@ export default defineEventHandler(async (h3) => {
if (!game || !game.libraryId) if (!game || !game.libraryId)
throw createError({ statusCode: 404, message: "Game ID not found" }); throw createError({ statusCode: 404, message: "Game ID not found" });
const getGameVersionSize = async (
version: Omit<(typeof game)["versions"][number], "dropletManifest">,
) => {
const size = await libraryManager.getGameVersionSize(
gameId,
version.versionId,
);
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: gameWithVersionSize, unimportedVersions }; return { game, unimportedVersions };
}); });

View File

@ -42,10 +42,10 @@ export default defineEventHandler<{
await objectHandler.deleteAsSystem(imageId); await objectHandler.deleteAsSystem(imageId);
if (game.mBannerObjectId === imageId) { if (game.mBannerObjectId === imageId) {
game.mBannerObjectId = game.mImageLibraryObjectIds[0] ?? ""; game.mBannerObjectId = game.mImageLibraryObjectIds[0];
} }
if (game.mCoverObjectId === imageId) { if (game.mCoverObjectId === imageId) {
game.mCoverObjectId = game.mImageLibraryObjectIds[0] ?? ""; game.mCoverObjectId = game.mImageLibraryObjectIds[0];
} }
const result = await prisma.game.update({ const result = await prisma.game.update({

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 libraryManager from "~~/server/internal/library"; import prisma from "~~/server/internal/db/database";
const DeleteVersion = type({ const DeleteVersion = type({
id: "string", id: "string",
@ -16,7 +16,11 @@ export default defineEventHandler<{ body: typeof DeleteVersion }>(
const body = await readDropValidatedBody(h3, DeleteVersion); const body = await readDropValidatedBody(h3, DeleteVersion);
await libraryManager.deleteGameVersion(body.id); await prisma.version.delete({
where: {
versionId: body.id,
},
});
return {}; return {};
}, },

View File

@ -21,7 +21,7 @@ export default defineEventHandler<{ body: typeof UpdateVersionOrder }>(
await prisma.$transaction( await prisma.$transaction(
versions.map((versionId, versionIndex) => versions.map((versionId, versionIndex) =>
prisma.version.update({ prisma.gameVersion.update({
where: { where: {
versionId, versionId,
}, },

View File

@ -1,27 +0,0 @@
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

@ -1,33 +1,29 @@
import { ArkErrors, type } from "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, { VersionImportModes } from "~~/server/internal/library"; import libraryManager from "~~/server/internal/library";
export const PreloadQuery = type({
id: "string",
mode: type.enumerated(...VersionImportModes),
});
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:version:read"]); const allowed = await aclManager.allowSystemACL(h3, ["import:version:read"]);
if (!allowed) throw createError({ statusCode: 403 }); if (!allowed) throw createError({ statusCode: 403 });
const rawQuery = await getQuery(h3); const query = await getQuery(h3);
const query = PreloadQuery(rawQuery); const gameId = query.id?.toString();
if (query instanceof ArkErrors) if (!gameId)
throw createError({ statusCode: 400, message: query.summary }); throw createError({
statusCode: 400,
const value: { libraryId: string; libraryPath: string } | undefined = message: "Missing id in request params",
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
(prisma[query.mode] as any).findUnique({
where: { id: query.id },
select: { libraryId: true, libraryPath: true },
}); });
if (!value) throw createError({ statusCode: 404, message: "Not found" });
const game = await prisma.game.findUnique({
where: { id: gameId },
select: { libraryId: true, libraryPath: true },
});
if (!game || !game.libraryId)
throw createError({ statusCode: 404, message: "Game not found" });
const unimportedVersions = await libraryManager.fetchUnimportedGameVersions( const unimportedVersions = await libraryManager.fetchUnimportedGameVersions(
value.libraryId, game.libraryId,
value.libraryPath, game.libraryPath,
); );
if (!unimportedVersions) if (!unimportedVersions)
throw createError({ statusCode: 400, message: "Invalid game ID" }); throw createError({ statusCode: 400, message: "Invalid game ID" });

View File

@ -1,7 +1,9 @@
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"; import libraryManager from "~~/server/internal/library";
import { convertIDToLink } from "~~/server/internal/platform/link";
export const LaunchCommands = type({ export const LaunchCommands = type({
name: "string > 0", name: "string > 0",
@ -10,18 +12,14 @@ export const LaunchCommands = type({
launchArgs: "string = ''", launchArgs: "string = ''",
}).array(); }).array();
const ImportVersionBase = type({ export const ImportVersion = type({
id: "string", id: "string",
version: "string", version: "string",
name: "string?", name: "string?",
platform: "string", platform: "string",
delta: "boolean = false",
});
const ImportGameVersion = type({
mode: "'game'",
onlySetup: "boolean = false", onlySetup: "boolean = false",
delta: "boolean = false",
umuId: "string = ''", umuId: "string = ''",
install: "string?", install: "string?",
@ -29,26 +27,7 @@ const ImportGameVersion = type({
launches: LaunchCommands, launches: LaunchCommands,
uninstall: "string?", uninstall: "string?",
uninstallArgs: "string?", uninstallArgs: "string?",
}); }).configure(throwingArktype);
const ImportRedistVersion = type({
mode: "'redist'",
install: "string?",
installArgs: "string?",
launches: LaunchCommands,
uninstall: "string?",
uninstallArgs: "string?",
});
export const ImportVersion = ImportVersionBase.and(
ImportGameVersion.or(ImportRedistVersion),
).configure(throwingArktype);
export type ImportGameVersion = typeof ImportVersionBase.infer &
typeof ImportGameVersion.infer;
export type ImportRedistVersion = typeof ImportVersionBase.infer &
typeof ImportRedistVersion.infer;
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:version:new"]); const allowed = await aclManager.allowSystemACL(h3, ["import:version:new"]);
@ -56,6 +35,43 @@ export default defineEventHandler(async (h3) => {
const body = await readDropValidatedBody(h3, ImportVersion); const body = await readDropValidatedBody(h3, ImportVersion);
const platform = await convertIDToLink(body.platform);
if (!platform)
throw createError({ statusCode: 400, message: "Invalid platform." });
if (body.delta) {
const validOverlayVersions = await prisma.gameVersion.count({
where: {
version: {
gameId: body.id,
},
delta: false,
platform,
},
});
if (validOverlayVersions == 0)
throw createError({
statusCode: 400,
message:
"Update mode requires a pre-existing version for this platform.",
});
}
if (body.onlySetup) {
if (!body.install)
throw createError({
statusCode: 400,
message: 'Install required in "setup mode".',
});
} else {
if (!body.delta && body.launches.length == 0)
throw createError({
statusCode: 400,
message:
"At least one launch command is required for non-delta versions",
});
}
// startup & delta require more complex checking logic // startup & delta require more complex checking logic
const taskId = await libraryManager.importVersion( const taskId = await libraryManager.importVersion(
body.id, body.id,

View File

@ -1,26 +1,22 @@
import { ArkErrors, type } from "arktype";
import aclManager from "~~/server/internal/acls"; import aclManager from "~~/server/internal/acls";
import libraryManager, { VersionImportModes } from "~~/server/internal/library"; import libraryManager from "~~/server/internal/library";
export const PreloadQuery = type({
id: "string",
version: "string",
mode: type.enumerated(...VersionImportModes),
});
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:version:read"]); const allowed = await aclManager.allowSystemACL(h3, ["import:version:read"]);
if (!allowed) throw createError({ statusCode: 403 }); if (!allowed) throw createError({ statusCode: 403 });
const rawQuery = await getQuery(h3); const query = await getQuery(h3);
const query = PreloadQuery(rawQuery); const gameId = query.id?.toString();
if (query instanceof ArkErrors) const versionName = query.version?.toString();
throw createError({ statusCode: 400, message: query.summary }); if (!gameId || !versionName)
throw createError({
statusCode: 400,
message: "Missing id or version in request params",
});
const preload = await libraryManager.fetchUnimportedVersionInformation( const preload = await libraryManager.fetchUnimportedVersionInformation(
query.id, gameId,
query.mode, versionName,
query.version,
); );
if (!preload) if (!preload)
throw createError({ throw createError({

View File

@ -2,10 +2,7 @@ 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 & { export type WorkingLibrarySource = LibraryModel & { working: boolean };
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 }>(
}, },
}); });
libraryManager.removeLibrary(source.id); await libraryManager.removeLibrary(source.id);
libraryManager.addLibrary(newLibrary); await 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 "~~/server/api/v1/admin/library/sources/index.get"; import type { WorkingLibrarySource } from "./index.get";
const CreateLibrarySource = type({ const CreateLibrarySource = type({
name: "string", name: "string",
@ -52,12 +52,11 @@ export default defineEventHandler<{ body: typeof CreateLibrarySource.infer }>(
}, },
}); });
libraryManager.addLibrary(library); await libraryManager.addLibrary(library);
const workingSource: WorkingLibrarySource = { const workingSource: WorkingLibrarySource = {
...source, ...source,
working: true, working: true,
fsStats: library.fsStats(),
}; };
return workingSource; return workingSource;

View File

@ -2,10 +2,11 @@ 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 taskHandler from "~~/server/internal/tasks"; import taskHandler from "~~/server/internal/tasks";
import { TASK_GROUPS } from "~~/server/internal/tasks/group"; import type { TaskGroup } from "~~/server/internal/tasks/group";
import { taskGroups } from "~~/server/internal/tasks/group";
const StartTask = type({ const StartTask = type({
taskGroup: type.enumerated(...TASK_GROUPS), taskGroup: type("string"),
}).configure(throwingArktype); }).configure(throwingArktype);
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
@ -13,8 +14,14 @@ export default defineEventHandler(async (h3) => {
if (!allowed) throw createError({ statusCode: 403 }); if (!allowed) throw createError({ statusCode: 403 });
const body = await readDropValidatedBody(h3, StartTask); const body = await readDropValidatedBody(h3, StartTask);
const taskGroup = body.taskGroup as TaskGroup;
if (!taskGroups[taskGroup])
throw createError({
statusCode: 400,
message: "Invalid task group.",
});
const task = await taskHandler.runTaskGroupByName(body.taskGroup); const task = await taskHandler.runTaskGroupByName(taskGroup);
if (!task) if (!task)
throw createError({ throw createError({
statusCode: 500, statusCode: 500,

View File

@ -1,7 +1,6 @@
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"]);
@ -28,6 +27,5 @@ export default defineEventHandler(async (h3) => {
throw createError({ statusCode: 404, message: "User not found." }); throw createError({ statusCode: 404, message: "User not found." });
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,7 +6,6 @@ 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",
@ -87,6 +86,5 @@ export default defineEventHandler<{
prisma.invitation.delete({ where: { id: user.invitation } }), prisma.invitation.delete({ where: { id: user.invitation } }),
]); ]);
await userStatsManager.addUser();
return linkMec.user; return linkMec.user;
}); });

View File

@ -1,20 +1,20 @@
import { type } from "arktype"; import { type } from "arktype";
import type { ClientCapabilities } from "~~/prisma/client/enums";
import { readDropValidatedBody, throwingArktype } from "~~/server/arktype"; import { readDropValidatedBody, throwingArktype } from "~~/server/arktype";
import type { import type {
CapabilityConfiguration, CapabilityConfiguration,
InternalClientCapability,
} from "~~/server/internal/clients/capabilities"; } from "~~/server/internal/clients/capabilities";
import capabilityManager, { import capabilityManager, {
validCapabilities, validCapabilities,
} from "~~/server/internal/clients/capabilities"; } from "~~/server/internal/clients/capabilities";
import clientHandler, { AuthModes } from "~~/server/internal/clients/handler"; import clientHandler, { AuthMode } from "~~/server/internal/clients/handler";
import { parsePlatform } from "~~/server/internal/utils/parseplatform"; import { parsePlatform } from "~~/server/internal/utils/parseplatform";
const ClientAuthInitiate = type({ const ClientAuthInitiate = type({
name: "string", name: "string",
platform: "string", platform: "string",
capabilities: "object", capabilities: "object",
mode: type.enumerated(...AuthModes).default("callback"), mode: type.valueOf(AuthMode).default(AuthMode.Callback),
}).configure(throwingArktype); }).configure(throwingArktype);
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
@ -32,7 +32,7 @@ export default defineEventHandler(async (h3) => {
}); });
const capabilityIterable = Object.entries(capabilities) as Array< const capabilityIterable = Object.entries(capabilities) as Array<
[ClientCapabilities, object] [InternalClientCapability, object]
>; >;
if ( if (
capabilityIterable.length > 0 && capabilityIterable.length > 0 &&

View File

@ -1,22 +1,39 @@
import { type } from "arktype"; import type { InternalClientCapability } from "~~/server/internal/clients/capabilities";
import { ClientCapabilities } from "~~/prisma/client/enums"; import capabilityManager, {
import { readDropValidatedBody, throwingArktype } from "~~/server/arktype"; validCapabilities,
import capabilityManager from "~~/server/internal/clients/capabilities"; } from "~~/server/internal/clients/capabilities";
import { defineClientEventHandler } from "~~/server/internal/clients/event-handler"; import { defineClientEventHandler } from "~~/server/internal/clients/event-handler";
import notificationSystem from "~~/server/internal/notifications"; import notificationSystem from "~~/server/internal/notifications";
const SetCapability = type({
capability: type.enumerated(...Object.values(ClientCapabilities)),
configuration: "object"
}).configure(throwingArktype);
export default defineClientEventHandler( export default defineClientEventHandler(
async (h3, { clientId, fetchClient, fetchUser }) => { async (h3, { clientId, fetchClient, fetchUser }) => {
const body = await readDropValidatedBody(h3, SetCapability); const body = await readBody(h3);
const rawCapability = body.capability;
const configuration = body.configuration;
if (!rawCapability || typeof rawCapability !== "string")
throw createError({
statusCode: 400,
message: "capability must be a string",
});
if (!configuration || typeof configuration !== "object")
throw createError({
statusCode: 400,
message: "configuration must be an object",
});
const capability = rawCapability as InternalClientCapability;
if (!validCapabilities.includes(capability))
throw createError({
statusCode: 400,
message: "Invalid capability.",
});
const isValid = await capabilityManager.validateCapabilityConfiguration( const isValid = await capabilityManager.validateCapabilityConfiguration(
body.capability, capability,
body.configuration, configuration,
); );
if (!isValid) if (!isValid)
throw createError({ throw createError({
@ -25,8 +42,8 @@ export default defineClientEventHandler(
}); });
await capabilityManager.upsertClientCapability( await capabilityManager.upsertClientCapability(
body.capability, capability,
body.configuration, configuration,
clientId, clientId,
); );
@ -34,9 +51,9 @@ export default defineClientEventHandler(
const user = await fetchUser(); const user = await fetchUser();
await notificationSystem.push(user.id, { await notificationSystem.push(user.id, {
nonce: `capability-${clientId}-${body.capability}`, nonce: `capability-${clientId}-${capability}`,
title: `"${client.name}" can now access ${body.capability}`, title: `"${client.name}" can now access ${capability}`,
description: `A device called "${client.name}" now has access to your ${body.capability}.`, description: `A device called "${client.name}" now has access to your ${capability}.`,
actions: ["Review|/account/devices"], actions: ["Review|/account/devices"],
acls: ["user:clients:read"], acls: ["user:clients:read"],
}); });

View File

@ -1,6 +1,5 @@
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);
@ -24,8 +23,5 @@ export default defineClientEventHandler(async (h3) => {
message: "Game version not found", message: "Game version not found",
}); });
return { return gameVersion;
...gameVersion,
size: libraryManager.getGameVersionSize(id, version),
};
}); });

View File

@ -10,17 +10,16 @@ export default defineClientEventHandler(async (h3) => {
message: "No ID in request query", message: "No ID in request query",
}); });
const versions = await prisma.version.findMany({ const versions = await prisma.gameVersion.findMany({
where: { where: {
gameId: id, version: {
gameId: id,
},
hidden: false, hidden: false,
}, },
orderBy: { orderBy: {
versionIndex: "desc", // Latest one first versionIndex: "desc", // Latest one first
}, },
include: {
gameVersions: true,
},
}); });
return versions; return versions;

View File

@ -1,7 +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 { convertIDsToPlatforms } from "~~/server/internal/platform/link"; import { convertIDsToPlatforms } from "~~/server/internal/platform/link";
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"]);
@ -73,7 +72,5 @@ export default defineEventHandler(async (h3) => {
const noVersionsGame = { ...game, versions: undefined }; const noVersionsGame = { ...game, versions: undefined };
const size = await libraryManager.getGameVersionSize(game.id); return { game: noVersionsGame, rating, platforms };
return { game: noVersionsGame, rating, platforms, size };
}); });

View File

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

View File

@ -24,7 +24,7 @@ export class CertificateAuthority {
let ca; let ca;
if (root === undefined) { if (root === undefined) {
const [cert, priv] = droplet.generateRootCa(); const [cert, priv] = droplet.generateRootCa();
const bundle: CertificateBundle = { priv: priv!, cert: cert! }; const bundle: CertificateBundle = { priv, cert };
await store.store("ca", bundle); await store.store("ca", bundle);
ca = new CertificateAuthority(store, bundle); ca = new CertificateAuthority(store, bundle);
} else { } else {
@ -50,8 +50,8 @@ export class CertificateAuthority {
caCertificate.priv, caCertificate.priv,
); );
const certBundle: CertificateBundle = { const certBundle: CertificateBundle = {
priv: priv!, priv,
cert: cert!, cert,
}; };
return certBundle; return certBundle;
} }

View File

@ -2,18 +2,28 @@ import type { EnumDictionary } from "../utils/types";
import prisma from "../db/database"; import prisma from "../db/database";
import { ClientCapabilities } from "~~/prisma/client/enums"; import { ClientCapabilities } from "~~/prisma/client/enums";
// These values are technically mapped to the database,
// but Typescript/Prisma doesn't let me link them
// They are also what are required by clients in the API
// BREAKING CHANGE
export enum InternalClientCapability {
PeerAPI = "peerAPI",
UserStatus = "userStatus",
CloudSaves = "cloudSaves",
TrackPlaytime = "trackPlaytime",
}
export const validCapabilities = Object.values(ClientCapabilities); export const validCapabilities = Object.values(InternalClientCapability);
export type CapabilityConfiguration = { export type CapabilityConfiguration = {
[ClientCapabilities.PeerAPI]: object; [InternalClientCapability.PeerAPI]: object;
[ClientCapabilities.UserStatus]: object; [InternalClientCapability.UserStatus]: object;
[ClientCapabilities.CloudSaves]: object; [InternalClientCapability.CloudSaves]: object;
}; };
class CapabilityManager { class CapabilityManager {
private validationFunctions: EnumDictionary< private validationFunctions: EnumDictionary<
ClientCapabilities, InternalClientCapability,
(configuration: object) => Promise<boolean> (configuration: object) => Promise<boolean>
> = { > = {
/* /*
@ -67,14 +77,14 @@ class CapabilityManager {
return valid; return valid;
}, },
*/ */
[ClientCapabilities.PeerAPI]: async () => true, [InternalClientCapability.PeerAPI]: async () => true,
[ClientCapabilities.UserStatus]: async () => true, // No requirements for user status [InternalClientCapability.UserStatus]: async () => true, // No requirements for user status
[ClientCapabilities.CloudSaves]: async () => true, // No requirements for cloud saves [InternalClientCapability.CloudSaves]: async () => true, // No requirements for cloud saves
[ClientCapabilities.TrackPlaytime]: async () => true, [InternalClientCapability.TrackPlaytime]: async () => true,
}; };
async validateCapabilityConfiguration( async validateCapabilityConfiguration(
capability: ClientCapabilities, capability: InternalClientCapability,
configuration: object, configuration: object,
) { ) {
const validationFunction = this.validationFunctions[capability]; const validationFunction = this.validationFunctions[capability];
@ -83,15 +93,15 @@ class CapabilityManager {
} }
async upsertClientCapability( async upsertClientCapability(
capability: ClientCapabilities, capability: InternalClientCapability,
rawCapabilityConfiguration: object, rawCapabilityConfiguration: object,
clientId: string, clientId: string,
) { ) {
const upsertFunctions: EnumDictionary< const upsertFunctions: EnumDictionary<
ClientCapabilities, InternalClientCapability,
() => Promise<void> | void () => Promise<void> | void
> = { > = {
[ClientCapabilities.PeerAPI]: async function () { [InternalClientCapability.PeerAPI]: async function () {
// const configuration =rawCapability as CapabilityConfiguration[InternalClientCapability.PeerAPI]; // const configuration =rawCapability as CapabilityConfiguration[InternalClientCapability.PeerAPI];
const currentClient = await prisma.client.findUnique({ const currentClient = await prisma.client.findUnique({
@ -129,10 +139,10 @@ class CapabilityManager {
}, },
}); });
}, },
[ClientCapabilities.UserStatus]: function (): Promise<void> | void { [InternalClientCapability.UserStatus]: function (): Promise<void> | void {
throw new Error("Function not implemented."); throw new Error("Function not implemented.");
}, },
[ClientCapabilities.CloudSaves]: async function () { [InternalClientCapability.CloudSaves]: async function () {
const currentClient = await prisma.client.findUnique({ const currentClient = await prisma.client.findUnique({
where: { id: clientId }, where: { id: clientId },
select: { select: {
@ -152,7 +162,7 @@ class CapabilityManager {
}, },
}); });
}, },
[ClientCapabilities.TrackPlaytime]: async function () { [InternalClientCapability.TrackPlaytime]: async function () {
const currentClient = await prisma.client.findUnique({ const currentClient = await prisma.client.findUnique({
where: { id: clientId }, where: { id: clientId },
select: { select: {

View File

@ -1,16 +1,18 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import prisma from "../db/database"; import prisma from "../db/database";
import type { ClientCapabilities, HardwarePlatform } from "~~/prisma/client/enums"; import type { HardwarePlatform } from "~~/prisma/client/enums";
import { useCertificateAuthority } from "~~/server/plugins/ca"; import { useCertificateAuthority } from "~~/server/plugins/ca";
import type { import type {
CapabilityConfiguration, CapabilityConfiguration,
InternalClientCapability,
} 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 const AuthModes = ["callback", "code"] as const; export enum AuthMode {
export type AuthMode = (typeof AuthModes)[number]; Callback = "callback",
Code = "code",
}
export interface ClientMetadata { export interface ClientMetadata {
name: string; name: string;
@ -60,9 +62,9 @@ export class ClientHandler {
}); });
switch (metadata.mode) { switch (metadata.mode) {
case "callback": case AuthMode.Callback:
return `/client/authorize/${clientId}`; return `/client/authorize/${clientId}`;
case "code": { case AuthMode.Code: {
const code = randomUUID() const code = randomUUID()
.replaceAll(/-/g, "") .replaceAll(/-/g, "")
.slice(0, 7) .slice(0, 7)
@ -134,7 +136,7 @@ export class ClientHandler {
statusCode: 400, statusCode: 400,
message: "Client has not connected yet. Please try again later.", message: "Client has not connected yet. Please try again later.",
}); });
client.peer.send( await client.peer.send(
JSON.stringify({ type: "token", value: `${clientId}/${token}` }), JSON.stringify({ type: "token", value: `${clientId}/${token}` }),
); );
} }
@ -164,13 +166,12 @@ 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,
)) { )) {
await capabilityManager.upsertClientCapability( await capabilityManager.upsertClientCapability(
capability as ClientCapabilities, capability as InternalClientCapability,
configuration, configuration,
client.id, client.id,
); );
@ -190,7 +191,6 @@ export class ClientHandler {
id, id,
}, },
}); });
await userStatsManager.cacheUserStats();
} }
} }

View File

@ -1,5 +1,4 @@
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;
@ -71,7 +70,6 @@ class ManifestGenerator {
select: { select: {
gameId: true, gameId: true,
dropletManifest: true, dropletManifest: true,
versionIndex: true,
}, },
}, },
}, },
@ -84,16 +82,16 @@ class ManifestGenerator {
// Start at the same index minus one, and keep grabbing them // Start at the same index minus one, and keep grabbing them
// until we run out or we hit something that isn't a delta // until we run out or we hit something that isn't a delta
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
for (let i = baseVersion.version.versionIndex - 1; true; i--) { for (let i = baseVersion.versionIndex - 1; true; i--) {
const currentVersion = await prisma.gameVersion.findFirst({ const currentVersion = await prisma.gameVersion.findFirst({
where: { where: {
version: { version: {
gameId: baseVersion.version.gameId!, gameId: baseVersion.version.gameId!,
versionIndex: i,
}, },
platform: { platform: {
id: baseVersion.platform.id, id: baseVersion.platform.id,
}, },
versionIndex: i,
}, },
include: { include: {
version: { version: {
@ -125,14 +123,6 @@ 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

@ -1,233 +0,0 @@
import { sum } from "~/utils/array";
import cacheHandler from "../cache";
import prisma from "../db/database";
import manifestGenerator from "../downloads/manifest";
import type { Game, Version } from "~~/prisma/client/client";
export type GameSize = {
gameName: string;
size: number;
gameId: string;
};
export type VersionSize = GameSize & {
latest: boolean;
};
type VersionsSizes = {
[versionId: 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.version.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,
versionId?: string,
): Promise<number | null> {
if (!versionId) {
const version = await prisma.version.findFirst({
where: { gameId },
orderBy: {
versionIndex: "desc",
},
});
if (!version) {
return null;
}
versionId = version.versionId;
}
const manifest = await manifestGenerator.generateManifest(versionId);
if (!manifest) {
return null;
}
return manifestGenerator.calculateManifestSize(manifest);
}
private async isLatestVersion(
gameVersions: Version[],
version: Version,
): Promise<boolean> {
return gameVersions.length > 0
? gameVersions[0].versionId === version.versionId
: 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(
(versionId) => versionsSizes[versionId].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: Version[] },
versionId?: string,
) {
const cacheVersion = async (version: Version) => {
const size = await this.getGameVersionSize(game.id, version.versionId);
if (!version.versionId || !size) {
return;
}
const versionsSizes = {
[version.versionId]: {
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 (versionId) {
const version = await prisma.version.findFirst({
where: { gameId: game.id, versionId },
});
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, versionId: 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 { [versionId]: _, ...updatedVersionsSizes } = versionsSizes;
await this.gameVersionsSizesCache.set(gameId, updatedVersionsSizes);
}
async deleteGame(gameId: string) {
this.gameSizesCache.remove(gameId);
this.gameVersionsSizesCache.remove(gameId);
}
}
export const gameSizeManager = new GameSizeManager();
export default gameSizeManager;

View File

@ -17,21 +17,7 @@ import type { ImportVersion } from "~~/server/api/v1/admin/import/version/index.
import type { import type {
GameVersionCreateInput, GameVersionCreateInput,
LaunchOptionCreateManyInput, LaunchOptionCreateManyInput,
VersionCreateInput,
VersionWhereInput,
} from "~~/prisma/client/models"; } from "~~/prisma/client/models";
import type { PlatformLink } from "~~/prisma/client/client";
import { convertIDToLink } from "../platform/link";
import type { WorkingLibrarySource } from "~~/server/api/v1/admin/library/sources/index.get";
import gameSizeManager from "../gamesize";
export const VersionImportModes = ["game", "redist"] as const;
export type VersionImportMode = (typeof VersionImportModes)[number];
const modeToLink: { [key in VersionImportMode]: string } = {
game: "g",
redist: "r",
};
export function createGameImportTaskId(libraryId: string, libraryPath: string) { export function createGameImportTaskId(libraryId: string, libraryPath: string) {
return createHash("md5") return createHash("md5")
@ -56,19 +42,13 @@ class LibraryManager {
this.libraries.delete(id); this.libraries.delete(id);
} }
async fetchLibraries(): Promise<WorkingLibrarySource[]> { async fetchLibraries() {
const libraries = await prisma.library.findMany({}); const libraries = await prisma.library.findMany({});
const libraryWithMetadata = libraries.map((e) => ({
const libraryWithMetadata = libraries.map(async (library) => { ...e,
const theLibrary = this.libraries.get(library.id); working: this.libraries.has(e.id),
const working = this.libraries.has(library.id); }));
return { return libraryWithMetadata;
...library,
working,
fsStats: working ? theLibrary?.fsStats() : undefined,
};
});
return await Promise.all(libraryWithMetadata);
} }
async fetchGamesByLibrary() { async fetchGamesByLibrary() {
@ -227,167 +207,20 @@ class LibraryManager {
return await this.fetchLibraryObjectWithStatus(redists); return await this.fetchLibraryObjectWithStatus(redists);
} }
private async fetchLibraryPath(
id: string,
mode: VersionImportMode,
platform?: PlatformLink,
): Promise<
| [
{ mName: string; libraryId: string; libraryPath: string } | null,
VersionWhereInput,
]
| undefined
> {
switch (mode) {
case "game":
return [
await prisma.game.findUnique({
where: { id },
select: { mName: true, libraryId: true, libraryPath: true },
}),
{ gameId: id, gameVersions: { some: { platform } } },
];
case "redist":
return [
await prisma.redist.findUnique({
where: { id },
select: { mName: true, libraryId: true, libraryPath: true },
}),
{ redistId: id },
];
}
return undefined;
}
private createVersionOptions(
id: string,
currentIndex: number,
metadata: typeof ImportVersion.infer,
): Omit<
VersionCreateInput,
"versionPath" | "versionName" | "dropletManifest"
> {
const installCreator = {
install: {
create: {
name: "",
description: "",
command: metadata.install!,
args: metadata.installArgs || "",
},
},
} satisfies Partial<GameVersionCreateInput>;
const uninstallCreator = {
uninstall: {
create: {
name: "",
description: "",
command: metadata.uninstall!,
args: metadata.uninstallArgs || "",
},
},
} satisfies Partial<GameVersionCreateInput>;
switch (metadata.mode) {
case "game": {
return {
versionIndex: currentIndex,
game: {
connect: {
id,
},
},
gameVersions: {
create: {
delta: metadata.delta,
umuIdOverride: metadata.umuId,
onlySetup: metadata.onlySetup,
launches: {
createMany: {
data: metadata.launches.map(
(v) =>
({
name: v.name,
description: v.description,
command: v.launchCommand,
args: v.launchArgs,
}) satisfies LaunchOptionCreateManyInput,
),
},
},
...(metadata.install ? installCreator : undefined),
...(metadata.uninstall ? uninstallCreator : undefined),
platform: {
connect: {
id: metadata.platform,
},
},
},
},
};
}
case "redist":
return {
versionIndex: currentIndex,
redist: {
connect: {
id,
},
},
redistVersions: {
create: {
versionIndex: currentIndex,
delta: metadata.delta,
launches: {
createMany: {
data: metadata.launches.map(
(v) =>
({
name: v.name,
description: v.description,
command: v.launchCommand,
args: v.launchArgs,
}) satisfies LaunchOptionCreateManyInput,
),
},
},
...(metadata.install ? installCreator : undefined),
...(metadata.uninstall ? uninstallCreator : undefined),
platform: {
connect: {
id: metadata.platform,
},
},
},
},
};
}
}
/** /**
* Fetches recommendations and extra data about the version. Doesn't actually check if it's been imported. * Fetches recommendations and extra data about the version. Doesn't actually check if it's been imported.
* @param id * @param gameId
* @param version * @param versionName
* @returns * @returns
*/ */
async fetchUnimportedVersionInformation( async fetchUnimportedVersionInformation(gameId: string, versionName: string) {
id: string, const game = await prisma.game.findUnique({
mode: VersionImportMode, where: { id: gameId },
version: string, select: { libraryPath: true, libraryId: true, mName: true },
) { });
const value = await this.fetchLibraryPath(id, mode); if (!game || !game.libraryId) return undefined;
if (!value?.[0] || !value[0].libraryId) return undefined;
const [libraryDetails] = value;
const library = this.libraries.get(libraryDetails.libraryId); const library = this.libraries.get(game.libraryId);
if (!library) return undefined; if (!library) return undefined;
const userPlatforms = await prisma.userPlatform.findMany({}); const userPlatforms = await prisma.userPlatform.findMany({});
@ -420,10 +253,7 @@ class LibraryManager {
match: number; match: number;
}> = []; }> = [];
const files = await library.versionReaddir( const files = await library.versionReaddir(game.libraryPath, versionName);
libraryDetails.libraryPath,
version,
);
for (const filename of files) { for (const filename of files) {
const basename = path.basename(filename); const basename = path.basename(filename);
const dotLocation = filename.lastIndexOf("."); const dotLocation = filename.lastIndexOf(".");
@ -432,7 +262,7 @@ class LibraryManager {
for (const [platform, checkExts] of Object.entries(fileExts)) { for (const [platform, checkExts] of Object.entries(fileExts)) {
for (const checkExt of checkExts) { for (const checkExt of checkExts) {
if (checkExt != ext) continue; if (checkExt != ext) continue;
const fuzzyValue = fuzzy(basename, libraryDetails.mName); const fuzzyValue = fuzzy(basename, game.mName);
options.push({ options.push({
filename, filename,
platform, platform,
@ -471,70 +301,32 @@ class LibraryManager {
*/ */
async importVersion( async importVersion(
id: string, gameId: string,
version: string, versionPath: string,
metadata: typeof ImportVersion.infer, metadata: typeof ImportVersion.infer,
) { ) {
const taskId = createVersionImportTaskId(id, version); const taskId = createVersionImportTaskId(gameId, versionPath);
if (metadata.mode === "game") { const game = await prisma.game.findUnique({
if (metadata.onlySetup) { where: { id: gameId },
if (!metadata.install) select: { mName: true, libraryId: true, libraryPath: true },
throw createError({
statusCode: 400,
message: "An install command is required in only-setup mode.",
});
} else {
if (!metadata.delta && metadata.launches.length == 0)
throw createError({
statusCode: 400,
message:
"At least one launch command is required in non-delta, non-setup mode.",
});
}
}
const platform = await convertIDToLink(metadata.platform);
if (!platform)
throw createError({ statusCode: 400, message: "Invalid platform." });
const value = await this.fetchLibraryPath(id, metadata.mode, platform);
if (!value || !value[0])
throw createError({
statusCode: 400,
message: `${metadata.mode} not found.`,
});
const [libraryDetails, idFilter] = value;
const library = this.libraries.get(libraryDetails.libraryId);
if (!library)
throw createError({
statusCode: 500,
message: "Library not found but exists in database?",
});
const currentIndex = await prisma.version.count({
where: { ...idFilter },
}); });
if (!game || !game.libraryId) return undefined;
if (metadata.delta && currentIndex == 0) const library = this.libraries.get(game.libraryId);
throw createError({ if (!library) return undefined;
statusCode: 400,
message:
"At least one pre-existing version of the same platform is required for delta mode.",
});
taskHandler.create({ taskHandler.create({
id: taskId, id: taskId,
taskGroup: "import:game", taskGroup: "import:game",
name: `Importing version "${metadata.name}" (${version}) for ${libraryDetails.mName}`, name: `Importing version "${metadata.name}" (${versionPath}) for ${game.mName}`,
acls: ["system:import:version:read"], acls: ["system:import:version:read"],
async run({ progress, logger }) { async run({ progress, logger }) {
// First, create the manifest via droplet. // First, create the manifest via droplet.
// This takes up 90% of our progress, so we wrap it in a *0.9 // This takes up 90% of our progress, so we wrap it in a *0.9
const manifest = await library.generateDropletManifest( const manifest = await library.generateDropletManifest(
libraryDetails.libraryPath, game.libraryPath,
version, versionPath,
(err, value) => { (err, value) => {
if (err) throw err; if (err) throw err;
progress(value * 0.9); progress(value * 0.9);
@ -547,35 +339,85 @@ class LibraryManager {
logger.info("Created manifest successfully!"); logger.info("Created manifest successfully!");
const currentIndex = await prisma.gameVersion.count({
where: { version: { gameId: gameId } },
});
const installCreator = {
install: {
create: {
name: "",
description: "",
command: metadata.install!,
args: metadata.installArgs || "",
},
},
} satisfies Partial<GameVersionCreateInput>;
const uninstallCreator = {
uninstall: {
create: {
name: "",
description: "",
command: metadata.uninstall!,
args: metadata.uninstallArgs || "",
},
},
} satisfies Partial<GameVersionCreateInput>;
// Then, create the database object // Then, create the database object
const createdVersion = await prisma.version.create({ await prisma.version.create({
data: { data: {
versionPath: version, gameId,
versionName: metadata.name ?? version, versionPath: versionPath,
versionName: metadata.name ?? versionPath,
dropletManifest: manifest, dropletManifest: manifest,
...libraryManager.createVersionOptions(id, currentIndex, metadata), gameVersions: {
create: {
versionIndex: currentIndex,
delta: metadata.delta,
umuIdOverride: metadata.umuId,
onlySetup: metadata.onlySetup,
launches: {
createMany: {
data: metadata.launches.map(
(v) =>
({
name: v.name,
description: v.description,
command: v.launchCommand,
args: v.launchArgs,
}) satisfies LaunchOptionCreateManyInput,
),
},
},
...(metadata.install ? installCreator : undefined),
...(metadata.uninstall ? uninstallCreator : undefined),
platform: {
connect: {
id: metadata.platform,
},
},
},
},
}, },
}); });
logger.info("Successfully created version!"); logger.info("Successfully created version!");
notificationSystem.systemPush({ notificationSystem.systemPush({
nonce: `version-create-${id}-${version}`, nonce: `version-create-${gameId}-${versionPath}`,
title: `'${libraryDetails.mName}' ('${version}') finished importing.`, title: `'${game.mName}' ('${versionPath}') finished importing.`,
description: `Drop finished importing version ${version} for ${libraryDetails.mName}.`, description: `Drop finished importing version ${versionPath} for ${game.mName}.`,
actions: [`View|/admin/library/${modeToLink[metadata.mode]}/${id}`], actions: [`View|/admin/library/g/${gameId}`],
acls: ["system:import:version:read"], acls: ["system:import:version:read"],
}); });
if (metadata.mode === "game") {
await libraryManager.cacheCombinedGameSize(id);
await libraryManager.cacheGameVersionSize(
id,
createdVersion.versionId,
);
}
progress(100); progress(100);
}, },
}); });
@ -605,73 +447,6 @@ 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(versionId: string) {
const version = await prisma.version.delete({
where: {
versionId,
},
include: {
game: true,
},
});
if (version.game) {
await gameSizeManager.deleteGameVersion(
version.game.id,
version.versionId,
);
}
}
async deleteGame(gameId: string) {
await prisma.game.delete({
where: {
id: gameId,
},
});
gameSizeManager.deleteGame(gameId);
}
async getGameVersionSize(
gameId: string,
versionId?: string,
): Promise<number | null> {
return gameSizeManager.getGameVersionSize(gameId, versionId);
}
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, versionId: string) {
const game = await prisma.game.findFirst({
where: { id: gameId },
include: { versions: true },
});
if (!game) {
return;
}
await gameSizeManager.cacheGameVersion(game, versionId);
}
} }
export const libraryManager = new LibraryManager(); export const libraryManager = new LibraryManager();

View File

@ -57,8 +57,6 @@ 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,7 +8,6 @@ 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",
@ -123,8 +122,4 @@ export class FilesystemProvider
return stream; return stream;
} }
fsStats() {
return fsStats(this.config.baseDir);
}
} }

View File

@ -6,7 +6,6 @@ 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",
@ -114,8 +113,4 @@ export class FlatFilesystemProvider
return stream.getStream(); return stream.getStream();
} }
fsStats() {
return fsStats(this.config.baseDir);
}
} }

View File

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

View File

@ -200,7 +200,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
return url.pathname.replace("/games/", "").replace(/\/$/, ""); return url.pathname.replace("/games/", "").replace(/\/$/, "");
} }
default: { default: {
logger.warn("Pcgamingwiki, unknown host: %s", url.hostname); logger.warn("Pcgamingwiki, unknown host", url.hostname);
return undefined; return undefined;
} }
} }
@ -234,7 +234,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
}); });
if (ratingObj instanceof type.errors) { if (ratingObj instanceof type.errors) {
logger.info( logger.info(
"pcgamingwiki: failed to properly get review rating: %s", "pcgamingwiki: failed to properly get review rating",
ratingObj.summary, ratingObj.summary,
); );
return undefined; return undefined;

File diff suppressed because it is too large Load Diff

View File

@ -123,7 +123,7 @@ export class FsObjectBackend extends ObjectBackend {
const metadataRaw = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); const metadataRaw = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
const metadata = objectMetadata(metadataRaw); const metadata = objectMetadata(metadataRaw);
if (metadata instanceof type.errors) { if (metadata instanceof type.errors) {
logger.error("FsObjectBackend#fetchMetadata: %s", metadata.summary); logger.error("FsObjectBackend#fetchMetadata", metadata.summary);
return undefined; return undefined;
} }
await this.metadataCache.set(id, metadata); await this.metadataCache.set(id, metadata);
@ -194,13 +194,11 @@ export class FsObjectBackend extends ObjectBackend {
try { try {
fs.rmSync(filePath); fs.rmSync(filePath);
cleanupLogger.info( cleanupLogger.info(
`[FsObjectBackend#cleanupMetadata]: Removed %s`, `[FsObjectBackend#cleanupMetadata]: Removed ${file}`,
file
); );
} catch (error) { } catch (error) {
cleanupLogger.error( cleanupLogger.error(
`[FsObjectBackend#cleanupMetadata]: Failed to remove %s: %s`, `[FsObjectBackend#cleanupMetadata]: Failed to remove ${file}`,
file,
error, error,
); );
} }

View File

@ -32,12 +32,15 @@ export const objectMetadata = type({
}); });
export type ObjectMetadata = typeof objectMetadata.infer; export type ObjectMetadata = typeof objectMetadata.infer;
export const ObjectPermissions = ["read", "write", "delete"] as const; export enum ObjectPermission {
export type ObjectPermission = (typeof ObjectPermissions)[number]; Read = "read",
Write = "write",
Delete = "delete",
}
export const ObjectPermissionPriority: Array<ObjectPermission> = [ export const ObjectPermissionPriority: Array<ObjectPermission> = [
"read", ObjectPermission.Read,
"write", ObjectPermission.Write,
"delete", ObjectPermission.Delete,
]; ];
export type Object = { mime: string; data: Source }; export type Object = { mime: string; data: Source };

View File

@ -1,32 +1,22 @@
export const TASK_GROUPS = [ export const taskGroups = {
"cleanup:invitations", "cleanup:invitations": {
"cleanup:objects", concurrency: false,
"cleanup:sessions", },
"check:update", "cleanup:objects": {
"import:game", concurrency: false,
"import:version", },
] as const; "cleanup:sessions": {
concurrency: false,
},
"check:update": {
concurrency: false,
},
"import:game": {
concurrency: true,
},
debug: {
concurrency: true,
},
} as const;
export type TaskGroup = (typeof TASK_GROUPS)[number]; export type TaskGroup = keyof typeof taskGroups;
export const TASK_GROUP_CONFIG: { [key in TaskGroup]: { concurrency: boolean } } =
{
"cleanup:invitations": {
concurrency: false
},
"cleanup:objects": {
concurrency: false
},
"cleanup:sessions": {
concurrency: false
},
"check:update": {
concurrency: false
},
"import:game": {
concurrency: true
},
"import:version": {
concurrency: true
}
};

View File

@ -7,7 +7,7 @@ import cleanupInvites from "./registry/invitations";
import cleanupSessions from "./registry/sessions"; import cleanupSessions from "./registry/sessions";
import checkUpdate from "./registry/update"; import checkUpdate from "./registry/update";
import cleanupObjects from "./registry/objects"; import cleanupObjects from "./registry/objects";
import { TASK_GROUP_CONFIG, type TaskGroup } from "./group"; import { taskGroups, type TaskGroup } from "./group";
import prisma from "../db/database"; import prisma from "../db/database";
import { type } from "arktype"; import { type } from "arktype";
import pino from "pino"; import pino from "pino";
@ -54,6 +54,7 @@ class TaskHandler {
"cleanup:invitations", "cleanup:invitations",
"cleanup:sessions", "cleanup:sessions",
"check:update", "check:update",
"debug",
]; ];
private weeklyScheduledTasks: TaskGroup[] = ["cleanup:objects"]; private weeklyScheduledTasks: TaskGroup[] = ["cleanup:objects"];
@ -82,7 +83,7 @@ class TaskHandler {
let logOffset: number = 0; let logOffset: number = 0;
// if taskgroup disallows concurrency // if taskgroup disallows concurrency
if (!TASK_GROUP_CONFIG[task.taskGroup].concurrency) { if (!taskGroups[task.taskGroup].concurrency) {
for (const existingTask of this.taskPool.values()) { for (const existingTask of this.taskPool.values()) {
// if a task is already running, we don't want to start another // if a task is already running, we don't want to start another
if (existingTask.taskGroup === task.taskGroup) { if (existingTask.taskGroup === task.taskGroup) {
@ -149,7 +150,7 @@ class TaskHandler {
} }
} catch (e) { } catch (e) {
// fallback: ignore or log error // fallback: ignore or log error
logger.error("Failed to parse log chunk %s", { logger.error("Failed to parse log chunk", {
error: e, error: e,
chunk: chunk, chunk: chunk,
}); });
@ -177,7 +178,7 @@ class TaskHandler {
const progress = (progress: number) => { const progress = (progress: number) => {
if (progress < 0 || progress > 100) { if (progress < 0 || progress > 100) {
logger.error("Progress must be between 0 and 100, actually %d", progress); logger.error("Progress must be between 0 and 100", { progress });
return; return;
} }
const taskEntry = this.taskPool.get(task.id); const taskEntry = this.taskPool.get(task.id);

View File

@ -1,6 +1,5 @@
// import { defineDropTask } from ".."; import { defineDropTask } from "..";
/*
export default defineDropTask({ export default defineDropTask({
buildId: () => `debug:${new Date().toISOString()}`, buildId: () => `debug:${new Date().toISOString()}`,
name: "Debug Task", name: "Debug Task",
@ -17,4 +16,3 @@ export default defineDropTask({
} }
}, },
}); });
*/

View File

@ -49,7 +49,7 @@ export default defineDropTask({
// if response failed somehow // if response failed somehow
if (!response.ok) { if (!response.ok) {
logger.info("Failed to check for update: %s", { logger.info("Failed to check for update ", {
status: response.status, status: response.status,
body: response.body, body: response.body,
}); });

View File

@ -1,68 +0,0 @@
/*
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

@ -1,47 +0,0 @@
import fs from "fs";
import nodePath from "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

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