mirror of
https://github.com/BillyOutlast/drop.git
synced 2026-02-04 08:41:17 +01:00
* First iteration on the new PieChart component * #128 Adds new admin home page * Fixes code after merging conflicts * Removes empty file * Uses real data for admin home page, and improves style * Reverts debugging code * Defines missing variable * Caches user stats data for admin home page * Typo * Styles improvements * Invalidates cache on signup/signin * Implements top 5 biggest games * Improves styling * Improves style * Using generateManifest to get the proper size * Reading data from cache * Removes unnecessary import * Improves caching mechanism for game sizes * Removes lint errors * Replaces piechart tooltip with colors in legend * Fixes caching * Fixes caching and slight improvement on pie chart colours * Fixes a few bugs related to caching * Fixes bug where app signin didn't refresh cache * feat: style improvements * fix: lint --------- Co-authored-by: DecDuck <declanahofmeyr@gmail.com>
This commit is contained in:
@@ -605,7 +605,6 @@ function coreMetadataUpdate_wrapper() {
|
||||
);
|
||||
})
|
||||
.then((newGame) => {
|
||||
console.log(newGame);
|
||||
if (!newGame) return;
|
||||
Object.assign(game.value, newGame);
|
||||
coreMetadataIconUrl.value = useObject(newGame.mIconObjectId);
|
||||
|
||||
@@ -51,14 +51,19 @@
|
||||
@update="() => updateVersionOrder()"
|
||||
>
|
||||
<template
|
||||
#item="{ element: item }: { element: GameVersionModel }"
|
||||
#item="{ element: item }: { element: GameVersionModelWithSize }"
|
||||
>
|
||||
<div
|
||||
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between"
|
||||
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between w-full flex"
|
||||
>
|
||||
<div class="text-zinc-100 font-semibold">
|
||||
<div class="text-zinc-100 font-semibold flex-none">
|
||||
{{ item.versionName }}
|
||||
</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>
|
||||
@@ -117,6 +122,7 @@ 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
|
||||
|
||||
@@ -130,7 +136,11 @@ const canImport = computed(
|
||||
() => hasDeleted.value || props.unimportedVersions.length > 0,
|
||||
);
|
||||
|
||||
type GameAndVersions = GameModel & { versions: GameVersionModel[] };
|
||||
type GameVersionModelWithSize = GameVersionModel & { size: number };
|
||||
|
||||
type GameAndVersions = GameModel & {
|
||||
versions: GameVersionModelWithSize[];
|
||||
};
|
||||
const game = defineModel<SerializeObject<GameAndVersions>>() as Ref<
|
||||
SerializeObject<GameAndVersions>
|
||||
>;
|
||||
|
||||
19
components/Icons/GamepadIcon.vue
Normal file
19
components/Icons/GamepadIcon.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="6" y1="11" x2="10" y2="11" />
|
||||
<line x1="8" y1="9" x2="8" y2="13" />
|
||||
<line x1="15" y1="12" x2="15.01" y2="12" />
|
||||
<line x1="18" y1="10" x2="18.01" y2="10" />
|
||||
<path
|
||||
d="M17.32 5H6.68a4 4 0 00-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 003 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 019.828 16h4.344a2 2 0 011.414.586L17 18c.5.5 1 1 2 1a3 3 0 003-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0017.32 5z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
45
components/PieChart/PieChart.vue
Normal file
45
components/PieChart/PieChart.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<h2 v-if="title" class="text-lg mb-4 w-full">{{ title }}</h2>
|
||||
<div class="flex flex-col xl:flex-row gap-4">
|
||||
<div class="relative flex grow max-w-[12rem]">
|
||||
<svg class="aspect-square grow relative inline" viewBox="0 0 100 100">
|
||||
<PieChartPieSlice
|
||||
v-for="slice in slices"
|
||||
:key="`${slice.percentage}-${slice.totalPercentage}`"
|
||||
:slice="slice"
|
||||
/>
|
||||
</svg>
|
||||
<div class="absolute inset-0 bg-zinc-900 rounded-full m-12" />
|
||||
</div>
|
||||
<ul class="flex flex-col gap-y-1 justify-center text-left">
|
||||
<li
|
||||
v-for="slice in slices"
|
||||
:key="slice.value"
|
||||
class="text-sm inline-flex items-center gap-x-1"
|
||||
>
|
||||
<span
|
||||
class="size-3 inline-block rounded-sm"
|
||||
:class="CHART_COLOURS[slice.color].bg"
|
||||
/>
|
||||
{{
|
||||
$t("common.labelValueColon", {
|
||||
label: slice.label,
|
||||
value: slice.value,
|
||||
})
|
||||
}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { generateSlices } from "~/components/PieChart/utils";
|
||||
import type { SliceData } from "~/components/PieChart/types";
|
||||
|
||||
const { data, title = undefined } = defineProps<{
|
||||
data: SliceData[];
|
||||
title?: string | undefined;
|
||||
}>();
|
||||
|
||||
const slices = generateSlices(data);
|
||||
</script>
|
||||
35
components/PieChart/PieSlice.vue
Normal file
35
components/PieChart/PieSlice.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<path
|
||||
v-if="slice.percentage !== 0 && slice.percentage !== 100"
|
||||
:class="[CHART_COLOURS[slice.color].fill]"
|
||||
:d="`
|
||||
M ${slice.start}
|
||||
A ${slice.radius},${slice.radius} 0 ${getFlags(slice.percentage)} ${polarToCartesian(slice.center, slice.radius, percent2Degrees(slice.totalPercentage))}
|
||||
L ${slice.center}
|
||||
z
|
||||
`"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle
|
||||
v-if="slice.percentage === 100"
|
||||
:r="slice.radius"
|
||||
:cx="slice.center.x"
|
||||
:cy="slice.center.y"
|
||||
:class="[CHART_COLOURS[slice.color].fill]"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Slice } from "~/components/PieChart/types";
|
||||
import {
|
||||
getFlags,
|
||||
percent2Degrees,
|
||||
polarToCartesian,
|
||||
} from "~/components/PieChart/utils";
|
||||
import { CHART_COLOURS } from "~/utils/colors";
|
||||
|
||||
const { slice } = defineProps<{
|
||||
slice: Slice;
|
||||
}>();
|
||||
</script>
|
||||
19
components/PieChart/types.d.ts
vendored
Normal file
19
components/PieChart/types.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
import type Tuple from "~/utils/tuple";
|
||||
import type { ChartColour } from "~/utils/colors";
|
||||
|
||||
export type Slice = {
|
||||
start: Tuple;
|
||||
center: Tuple;
|
||||
percentage: number;
|
||||
totalPercentage: number;
|
||||
radius: number;
|
||||
color: ChartColour;
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type SliceData = {
|
||||
value: number;
|
||||
color?: ChartColour;
|
||||
label: string;
|
||||
};
|
||||
50
components/PieChart/utils.ts
Normal file
50
components/PieChart/utils.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import Tuple from "~/utils/tuple";
|
||||
import type { Slice, SliceData } from "~/components/PieChart/types";
|
||||
import { sum, lastItem } from "~/utils/array";
|
||||
|
||||
export const START = new Tuple(50, 10);
|
||||
export const CENTER = new Tuple(50, 50);
|
||||
export const RADIUS = 40;
|
||||
|
||||
export const polarToCartesian = (
|
||||
center: Tuple,
|
||||
radius: number,
|
||||
angleInDegrees: number,
|
||||
) => {
|
||||
const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180;
|
||||
const x = center.x + radius * Math.cos(angleInRadians);
|
||||
const y = center.y + radius * Math.sin(angleInRadians);
|
||||
return new Tuple(x, y);
|
||||
};
|
||||
|
||||
export const percent2Degrees = (percentage: number) => (360 * percentage) / 100;
|
||||
|
||||
export function generateSlices(data: SliceData[]): Slice[] {
|
||||
return data.reduce((accumulator, currentValue, index, array) => {
|
||||
const percentage =
|
||||
(currentValue.value * 100) / sum(array.map((slice) => slice.value));
|
||||
return [
|
||||
...accumulator,
|
||||
{
|
||||
start: accumulator.length
|
||||
? polarToCartesian(
|
||||
CENTER,
|
||||
RADIUS,
|
||||
percent2Degrees(lastItem(accumulator).totalPercentage),
|
||||
)
|
||||
: START,
|
||||
radius: RADIUS,
|
||||
percentage: percentage,
|
||||
totalPercentage:
|
||||
sum(accumulator.map((element) => element.percentage)) + percentage,
|
||||
center: CENTER,
|
||||
color: PIE_COLOURS[index % PIE_COLOURS.length],
|
||||
label: currentValue.label,
|
||||
value: currentValue.value,
|
||||
},
|
||||
];
|
||||
}, [] as Slice[]);
|
||||
}
|
||||
|
||||
export const getFlags = (percentage: number) =>
|
||||
percentage > 50 ? new Tuple(1, 1) : new Tuple(0, 1);
|
||||
31
components/ProgressBar.vue
Normal file
31
components/ProgressBar.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'relative h-5 rounded-xl overflow-hidden',
|
||||
CHART_COLOURS[backgroundColor].bg,
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:style="{ width: `${percentage}%` }"
|
||||
:class="['transition-all h-full', CHART_COLOURS[color].bg]"
|
||||
/>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center text-blue-200 text-sm font-bold font-display"
|
||||
>
|
||||
{{ $t("tasks.admin.progress", [Math.round(percentage * 10) / 10]) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type ChartColour, CHART_COLOURS } from "~/utils/colors";
|
||||
const {
|
||||
percentage,
|
||||
color = "blue",
|
||||
backgroundColor = "zinc",
|
||||
} = defineProps<{
|
||||
percentage: number;
|
||||
color?: ChartColour;
|
||||
backgroundColor?: ChartColour;
|
||||
}>();
|
||||
</script>
|
||||
43
components/RankingList.vue
Normal file
43
components/RankingList.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<table v-if="items.length > 0" class="w-full mt-4 space-y-6">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/10">
|
||||
<tr v-for="item in items" :key="`${item.rank}-${item.name}`">
|
||||
<td
|
||||
class="my-2 size-7 rounded-sm bg-zinc-950 ring ring-zinc-800 inline-flex items-center justify-center font-bold font-display text-blue-500"
|
||||
>
|
||||
{{ item.rank }}
|
||||
</td>
|
||||
<td class="w-full font-bold px-2">{{ item.name }}</td>
|
||||
<td
|
||||
class="text-right text-sm font-semibold text-zinc-500 whitespace-nowrap"
|
||||
>
|
||||
{{ item.value }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p
|
||||
v-else
|
||||
class="w-full p-2 text-center uppercase text-sm font-display font-bold text-zinc-700"
|
||||
>
|
||||
{{ $t("common.noData") }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
export type RankItem = {
|
||||
rank: number;
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
const { items } = defineProps<{
|
||||
items: RankItem[];
|
||||
}>();
|
||||
</script>
|
||||
193
components/SourceTable.vue
Normal file
193
components/SourceTable.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
<table class="min-w-full divide-y divide-zinc-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3"
|
||||
>
|
||||
{{ $t("common.name") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
{{ $t("type") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
{{ $t("library.admin.sources.working") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
{{ $t("options") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
{{ $t("library.admin.sources.totalSpace") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
{{ $t("library.admin.sources.freeSpace") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
{{ $t("library.admin.sources.utilizationPercentage") }}
|
||||
</th>
|
||||
<th
|
||||
v-if="editSource || deleteSource"
|
||||
scope="col"
|
||||
class="relative py-3.5 pl-3 pr-4 sm:pr-3"
|
||||
>
|
||||
<span class="sr-only">{{ $t("actions") }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(source, sourceIdx) in sources"
|
||||
:key="source.id"
|
||||
class="even:bg-zinc-800"
|
||||
>
|
||||
<td
|
||||
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
||||
>
|
||||
{{ source.name }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400 inline-flex gap-x-1 items-center"
|
||||
>
|
||||
<component
|
||||
:is="optionsMetadata[source.backend].icon"
|
||||
class="size-5 text-zinc-400"
|
||||
/>
|
||||
{{ optionsMetadata[source.backend].title }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
<CheckIcon v-if="source.working" class="size-5 text-green-500" />
|
||||
<XMarkIcon v-else class="size-5 text-red-500" />
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
{{ source.options }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
{{ source.fsStats && formatBytes(source.fsStats.totalSpace) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
{{ source.fsStats && formatBytes(source.fsStats.freeSpace) }}
|
||||
</td>
|
||||
<td
|
||||
class="align-middle flex flex-cols-5 whitespace-nowrap px-3 py-4 text-sm text-zinc-400"
|
||||
>
|
||||
<div class="flex-auto content-right">
|
||||
<ProgressBar
|
||||
v-if="source.fsStats"
|
||||
:percentage="
|
||||
getPercentage(
|
||||
source.fsStats.freeSpace,
|
||||
source.fsStats.totalSpace,
|
||||
)
|
||||
"
|
||||
:color="
|
||||
getBarColor(
|
||||
getPercentage(
|
||||
source.fsStats.freeSpace,
|
||||
source.fsStats.totalSpace,
|
||||
),
|
||||
)
|
||||
"
|
||||
background-color="slate"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
v-if="editSource || deleteSource"
|
||||
class="relative whitespace-nowrap py-4 pl-3 pr-3 text-right text-sm font-medium space-x-2"
|
||||
>
|
||||
<button
|
||||
v-if="editSource"
|
||||
class="text-blue-500 hover:text-blue-400"
|
||||
@click="() => editSource(sourceIdx)"
|
||||
>
|
||||
{{ $t("common.edit") }}
|
||||
<span class="sr-only">
|
||||
{{ $t("chars.srComma", [source.name]) }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="deleteSource"
|
||||
class="text-red-500 hover:text-red-400"
|
||||
@click="() => deleteSource(sourceIdx)"
|
||||
>
|
||||
{{ $t("delete") }}
|
||||
<span class="sr-only">
|
||||
{{ $t("chars.srComma", [source.name]) }}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get";
|
||||
import type { LibraryBackend } from "~/prisma/client/enums";
|
||||
import { BackwardIcon, CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
|
||||
import { DropLogo } from "#components";
|
||||
import { formatBytes } from "~/server/internal/utils/files";
|
||||
import { getBarColor } from "~/utils/colors";
|
||||
|
||||
const {
|
||||
sources,
|
||||
deleteSource = undefined,
|
||||
editSource = undefined,
|
||||
} = defineProps<{
|
||||
sources: WorkingLibrarySource[];
|
||||
summaryMode?: boolean;
|
||||
deleteSource?: (id: number) => void;
|
||||
editSource?: (id: number) => void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const optionsMetadata: {
|
||||
[key in LibraryBackend]: {
|
||||
title: string;
|
||||
description: string;
|
||||
docsLink: string;
|
||||
icon: Component;
|
||||
};
|
||||
} = {
|
||||
Filesystem: {
|
||||
title: t("library.admin.sources.fsTitle"),
|
||||
description: t("library.admin.sources.fsDesc"),
|
||||
docsLink: "https://docs.droposs.org/docs/library#drop-style",
|
||||
icon: DropLogo,
|
||||
},
|
||||
FlatFilesystem: {
|
||||
title: t("library.admin.sources.fsFlatTitle"),
|
||||
description: t("library.admin.sources.fsFlatDesc"),
|
||||
docsLink: "https://docs.droposs.org/docs/library#flat-style-or-compat",
|
||||
icon: BackwardIcon,
|
||||
},
|
||||
};
|
||||
|
||||
const getPercentage = (value: number, total: number) =>
|
||||
((total - value) * 100) / total;
|
||||
</script>
|
||||
52
components/TileWithLink.vue
Normal file
52
components/TileWithLink.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'border border-zinc-800 rounded-xl h-full px-6 py-4 relative bg-zinc-950/30',
|
||||
{ 'min-h-50 pb-15': link, 'lg:pb-4': !link },
|
||||
]"
|
||||
>
|
||||
<h1
|
||||
v-if="props.title"
|
||||
:class="[
|
||||
'font-semibold text-lg w-full',
|
||||
{ 'mb-3': !props.subtitle && link },
|
||||
]"
|
||||
>
|
||||
{{ props.title }}
|
||||
<div v-if="rightTitle" class="float-right">{{ props.rightTitle }}</div>
|
||||
</h1>
|
||||
<h2
|
||||
v-if="props.subtitle"
|
||||
:class="['text-zinc-400 text-sm w-full', { 'mb-3': link }]"
|
||||
>
|
||||
{{ props.subtitle }}
|
||||
<div v-if="rightTitle" class="float-right">{{ props.rightTitle }}</div>
|
||||
</h2>
|
||||
|
||||
<slot />
|
||||
|
||||
<div v-if="props.link" class="absolute bottom-5 right-5">
|
||||
<NuxtLink
|
||||
:to="props.link.url"
|
||||
class="transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
|
||||
>
|
||||
{{ props.link.label }}
|
||||
<ArrowRightIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ArrowRightIcon } from "@heroicons/vue/20/solid";
|
||||
|
||||
const props = defineProps<{
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
rightTitle?: string;
|
||||
link?: {
|
||||
url: string;
|
||||
label: string;
|
||||
};
|
||||
}>();
|
||||
</script>
|
||||
Reference in New Issue
Block a user