mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-16 17:51:17 +10:00
Rearchitecture for v0.4.0 (#197)
* feat: database redist support * feat: rearchitecture of database schemas, migration reset, and #180 * feat: import redists * fix: giantbomb logging bug * feat: partial user platform support + statusMessage -> message * feat: add user platform filters to store view * fix: sanitize svg uploads ... copilot suggested this I feel dirty. * feat: beginnings of platform & redist management * feat: add server side redist patching * fix: update drop-base commit * feat: import of custom platforms & file extensions * fix: redelete platform * fix: remove platform * feat: uninstall commands, new R UI * checkpoint: before migrating to nuxt v4 * update to nuxt 4 * fix: fixes for Nuxt v4 update * fix: remaining type issues * feat: initial feedback to import other kinds of versions * working commit * fix: lint * feat: redist import
This commit is contained in:
341
app/pages/store/[id]/index.vue
Normal file
341
app/pages/store/[id]/index.vue
Normal file
@ -0,0 +1,341 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div
|
||||
class="mx-auto bg-zinc-950 w-full relative flex flex-col justify-center pt-32 xl:pt-24 z-10 overflow-hidden"
|
||||
>
|
||||
<!-- banner image -->
|
||||
<div class="absolute flex top-0 h-fit inset-x-0 h-12 -z-[20] pb-4">
|
||||
<img
|
||||
:src="useObject(game.mBannerObjectId)"
|
||||
class="blur-sm w-full h-auto"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-b from-transparent to-80% to-zinc-950"
|
||||
/>
|
||||
</div>
|
||||
<!-- main page -->
|
||||
<div
|
||||
:class="[
|
||||
'max-w-7xl w-full min-h-screen mx-auto px-5 py-4 sm:px-16 sm:py-12 rounded-xl', // layout stuff
|
||||
'bg-zinc-950/90 backdrop-blur-[500px] backdrop-saturate-200 backdrop-brightness-200', // make a soft, colourful glow background
|
||||
]"
|
||||
>
|
||||
<h1
|
||||
class="text-3xl md:text-5xl font-bold font-display text-zinc-100 pb-4 border-b border-zinc-800"
|
||||
>
|
||||
{{ game.mName }}
|
||||
</h1>
|
||||
|
||||
<div class="mt-8 grid grid-cols-1 lg:grid-cols-4 gap-10">
|
||||
<div
|
||||
class="col-start-1 lg:col-start-4 flex flex-col gap-y-6 items-center"
|
||||
>
|
||||
<img
|
||||
class="transition-all duration-300 hover:scale-105 hover:rotate-[-1deg] w-64 h-auto rounded gameCover"
|
||||
:src="useObject(game.mCoverObjectId)"
|
||||
:alt="game.mName"
|
||||
/>
|
||||
<div class="flex items-center gap-x-2">
|
||||
<AddLibraryButton :game-id="game.id" />
|
||||
</div>
|
||||
<NuxtLink
|
||||
v-if="user?.admin"
|
||||
:href="`/admin/library/g/${game.id}`"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display 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 duration-200 hover:scale-105 active:scale-95"
|
||||
>
|
||||
{{ $t("store.openAdminDashboard") }}
|
||||
<ArrowTopRightOnSquareIcon
|
||||
class="-mr-0.5 h-7 w-7 p-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NuxtLink>
|
||||
<table class="min-w-full">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
||||
>
|
||||
{{ $t("store.released") }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
<time datetime="game.mReleased">
|
||||
{{ $d(new Date(game.mReleased), "short") }}
|
||||
</time>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
||||
>
|
||||
{{ $t("store.platform", platforms.length) }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap inline-flex gap-x-4 px-3 py-4 text-sm text-zinc-400"
|
||||
>
|
||||
<IconsPlatform
|
||||
v-for="platform in platforms"
|
||||
:key="typeof platform === 'string' ? platform : platform.id"
|
||||
:platform="
|
||||
typeof platform === 'string' ? platform : platform.id
|
||||
"
|
||||
:fallback="
|
||||
typeof platform === 'string'
|
||||
? undefined
|
||||
: platform.iconSvg
|
||||
"
|
||||
class="size-8 text-blue-600"
|
||||
/>
|
||||
<span
|
||||
v-if="platforms.length == 0"
|
||||
class="font-display uppercase font-bold text-zinc-700"
|
||||
>{{ $t("store.commingSoon") }}</span
|
||||
>
|
||||
</td>
|
||||
</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>
|
||||
<td
|
||||
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
||||
>
|
||||
{{ $t("store.rating") }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap flex flex-row items-center gap-x-1 px-3 py-4 text-sm text-zinc-400"
|
||||
>
|
||||
<StarIcon
|
||||
v-for="(value, idx) in ratingArray"
|
||||
:key="idx"
|
||||
:class="[
|
||||
value ? 'text-yellow-600' : 'text-zinc-600',
|
||||
'w-4 h-4',
|
||||
]"
|
||||
/>
|
||||
<span class="text-zinc-600">{{
|
||||
$t("store.reviews", [rating._sum.mReviewCount ?? 0])
|
||||
}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="whitespace-nowrap align-top py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
||||
>
|
||||
{{ $t("store.tags") }}
|
||||
</td>
|
||||
<td class="flex flex-col gap-1 px-3 py-4 text-sm text-zinc-400">
|
||||
<NuxtLink
|
||||
v-for="tag in game.tags"
|
||||
:key="tag.id"
|
||||
:href="`/store/t/${tag.id}`"
|
||||
class="w-min hover:underline hover:text-zinc-100 whitespace-nowrap"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</NuxtLink>
|
||||
<span
|
||||
v-if="game.tags.length == 0"
|
||||
class="text-zinc-700 font-bold uppercase font-display"
|
||||
>{{ $t("store.noTags") }}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="whitespace-nowrap align-top py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
||||
>
|
||||
{{ $t("store.developers", game.developers.length) }}
|
||||
</td>
|
||||
<td class="flex flex-col px-3 py-4 text-sm text-zinc-400">
|
||||
<NuxtLink
|
||||
v-for="developer in game.developers"
|
||||
:key="developer.id"
|
||||
:href="`/store/c/${developer.id}`"
|
||||
class="w-min hover:underline hover:text-zinc-100 whitespace-nowrap"
|
||||
>
|
||||
{{ developer.mName }}
|
||||
</NuxtLink>
|
||||
<span
|
||||
v-if="game.developers.length == 0"
|
||||
class="text-zinc-700 font-bold uppercase font-display"
|
||||
>{{ $t("store.noDevelopers") }}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="whitespace-nowrap align-top py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
||||
>
|
||||
{{ $t("store.publishers", game.publishers.length) }}
|
||||
</td>
|
||||
<td class="flex flex-col px-3 py-4 text-sm text-zinc-400">
|
||||
<NuxtLink
|
||||
v-for="publisher in game.publishers"
|
||||
:key="publisher.id"
|
||||
:href="`/store/c/${publisher.id}`"
|
||||
class="w-min hover:underline hover:text-zinc-100 whitespace-nowrap"
|
||||
>
|
||||
{{ publisher.mName }}
|
||||
</NuxtLink>
|
||||
<span
|
||||
v-if="game.publishers.length == 0"
|
||||
class="text-zinc-700 font-bold uppercase font-display"
|
||||
>{{ $t("store.noPublishers") }}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="row-start-2 lg:row-start-1 lg:col-span-3">
|
||||
<p class="text-lg text-zinc-400">
|
||||
{{ game.mShortDescription }}
|
||||
</p>
|
||||
<div class="mt-6 py-4 rounded">
|
||||
<VueCarousel :items-to-show="1" :wrap-around="true">
|
||||
<VueSlide
|
||||
v-for="image in game.mImageCarouselObjectIds"
|
||||
:key="image"
|
||||
>
|
||||
<img
|
||||
class="w-fit h-48 lg:h-96 rounded"
|
||||
:src="useObject(image)"
|
||||
/>
|
||||
</VueSlide>
|
||||
<VueSlide v-if="game.mImageCarouselObjectIds.length == 0">
|
||||
<div
|
||||
class="h-48 lg:h-96 aspect-[1/2] flex items-center justify-center text-zinc-700 font-bold font-display"
|
||||
>
|
||||
{{ $t("store.noImages") }}
|
||||
</div>
|
||||
</VueSlide>
|
||||
|
||||
<template #addons>
|
||||
<VueNavigation />
|
||||
<CarouselPagination class="py-2 px-12" />
|
||||
</template>
|
||||
</VueCarousel>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
v-if="showPreview"
|
||||
class="mt-12 prose prose-invert prose-blue max-w-none"
|
||||
v-html="previewHTML"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="mt-12 prose prose-invert prose-blue max-w-none"
|
||||
v-html="descriptionHTML"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="showReadMore"
|
||||
class="mt-8 w-full inline-flex items-center gap-x-6"
|
||||
@click="() => (showPreview = !showPreview)"
|
||||
>
|
||||
<div class="grow h-[1px] bg-zinc-700 rounded-full" />
|
||||
<span
|
||||
class="uppercase text-sm font-semibold font-display text-zinc-600"
|
||||
>{{
|
||||
showPreview ? $t("store.readMore") : $t("store.readLess")
|
||||
}}</span
|
||||
>
|
||||
<div class="grow h-[1px] bg-zinc-700 rounded-full" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
|
||||
import { StarIcon } from "@heroicons/vue/24/solid";
|
||||
import { micromark } from "micromark";
|
||||
import { formatBytes } from "~~/server/internal/utils/files";
|
||||
|
||||
const route = useRoute();
|
||||
const gameId = route.params.id?.toString();
|
||||
if (!gameId)
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: "Game not found",
|
||||
fatal: true,
|
||||
});
|
||||
|
||||
const user = useUser();
|
||||
const { game, rating, platforms, size } = await $dropFetch(`/api/v1/games/${gameId}`);
|
||||
|
||||
// Preview description (first 30 lines)
|
||||
const showPreview = ref(true);
|
||||
const gameDescriptionCharacters = game.mDescription.split("");
|
||||
|
||||
// First new line after x characters
|
||||
const descriptionSplitIndex = gameDescriptionCharacters.findIndex(
|
||||
(v, i, arr) => {
|
||||
// If we're at the last element, we return true.
|
||||
// So we don't have to handle a -1 from this findIndex
|
||||
if (i + 1 == arr.length) return true;
|
||||
if (i < 500) return false;
|
||||
if (v != "\n") return false;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
const previewDescription = gameDescriptionCharacters
|
||||
.slice(0, descriptionSplitIndex + 1) // Slice a character after
|
||||
.join("");
|
||||
const previewHTML = micromark(previewDescription);
|
||||
|
||||
const descriptionHTML = micromark(game.mDescription);
|
||||
|
||||
const showReadMore = previewHTML != descriptionHTML;
|
||||
|
||||
// const rating = Math.round(game.mReviewRating * 5);
|
||||
const averageRating = Math.round((rating._avg.mReviewRating ?? 0) * 5);
|
||||
const ratingArray = Array(5)
|
||||
.fill(null)
|
||||
.map((_, i) => i + 1 <= averageRating);
|
||||
|
||||
useHead({
|
||||
title: game.mName,
|
||||
link: [{ rel: "icon", href: useObject(game.mIconObjectId) }],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
view-transition-name: header;
|
||||
}
|
||||
img.gameCover {
|
||||
view-transition-name: selected-game;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
::view-transition-old(header),
|
||||
::view-transition-new(header) {
|
||||
width: auto;
|
||||
}
|
||||
</style>
|
||||
65
app/pages/store/c/[id]/index.vue
Normal file
65
app/pages/store/c/[id]/index.vue
Normal file
@ -0,0 +1,65 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div class="w-full overflow-x-hidden">
|
||||
<div class="relative overflow-hidden bg-zinc-900">
|
||||
<!-- Decorative background image and gradient -->
|
||||
<div aria-hidden="true" class="absolute inset-0">
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<img
|
||||
:src="useObject(company.mBannerObjectId)"
|
||||
alt=""
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-zinc-900/75" />
|
||||
<div class="absolute inset-0 bg-linear-to-t from-zinc-900" />
|
||||
</div>
|
||||
|
||||
<!-- Callout -->
|
||||
<section
|
||||
aria-labelledby="sale-heading"
|
||||
class="relative mx-auto flex max-w-7xl flex-col items-center px-4 pt-32 pb-8 text-center sm:px-6 lg:px-8"
|
||||
>
|
||||
<div class="mx-auto max-w-2xl lg:max-w-none">
|
||||
<h2
|
||||
id="sale-heading"
|
||||
class="text-4xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl lg:text-6xl"
|
||||
>
|
||||
{{ company.mName }}
|
||||
</h2>
|
||||
<p class="mx-auto line-clamp-3 mt-4 max-w-xl text-xl text-zinc-400">
|
||||
{{ company.mShortDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<StoreView
|
||||
:extra-options="[
|
||||
{
|
||||
name: 'Company',
|
||||
param: 'companyActions',
|
||||
multiple: true,
|
||||
options: [
|
||||
{ name: 'Published', param: 'published' },
|
||||
{ name: 'Developed', param: 'developed' },
|
||||
],
|
||||
},
|
||||
]"
|
||||
:params="{
|
||||
company: company.id,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const companyId = route.params.id;
|
||||
|
||||
const { company } = await $dropFetch(`/api/v1/companies/${companyId}`);
|
||||
|
||||
useHead({
|
||||
title: company.mName,
|
||||
link: [{ rel: "icon", href: useObject(company.mLogoObjectId) }],
|
||||
});
|
||||
</script>
|
||||
104
app/pages/store/index.vue
Normal file
104
app/pages/store/index.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="w-full flex flex-col overflow-x-hidden">
|
||||
<!-- Hero section -->
|
||||
<VueCarousel
|
||||
v-if="recent.length > 0"
|
||||
:wrap-around="true"
|
||||
:items-to-show="1"
|
||||
:autoplay="15 * 1000"
|
||||
:transition="500"
|
||||
:pause-autoplay-on-hover="true"
|
||||
class="store-carousel"
|
||||
>
|
||||
<VueSlide v-for="game in recent" :key="game.id">
|
||||
<div class="w-full h-full relative">
|
||||
<div class="absolute inset-0">
|
||||
<img
|
||||
:src="useObject(game.mBannerObjectId)"
|
||||
alt=""
|
||||
class="size-full object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="relative flex items-center justify-center w-full h-full bg-zinc-900/75 px-6 py-32 sm:px-12 sm:py-40 lg:px-16"
|
||||
>
|
||||
<div class="relative text-center">
|
||||
<h3 class="text-base/7 font-semibold text-blue-300">
|
||||
{{ $t("store.featured") }}
|
||||
</h3>
|
||||
<h2
|
||||
class="text-3xl font-bold tracking-tight text-white sm:text-5xl"
|
||||
>
|
||||
{{ game.mName }}
|
||||
</h2>
|
||||
<p
|
||||
class="mt-3 text-lg text-zinc-300 line-clamp-2 max-w-xl mx-auto"
|
||||
>
|
||||
{{ game.mShortDescription }}
|
||||
</p>
|
||||
<div>
|
||||
<div
|
||||
class="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-4 w-fit mx-auto"
|
||||
>
|
||||
<NuxtLink
|
||||
:href="`/store/${game.id}`"
|
||||
class="block w-full rounded-md border border-transparent bg-white px-8 py-3 text-base font-medium text-gray-900 hover:bg-gray-100 sm:w-auto duration-200 hover:scale-105"
|
||||
>{{ $t("store.lookAt") }}</NuxtLink
|
||||
>
|
||||
<AddLibraryButton :game-id="game.id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VueSlide>
|
||||
|
||||
<template #addons>
|
||||
<CarouselPagination class="py-2" :items="recent" />
|
||||
</template>
|
||||
</VueCarousel>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex flex-col items-center justify-center bg-zinc-950/50 px-6 py-32 sm:px-12 sm:py-40 lg:px-16 gap-4"
|
||||
>
|
||||
<h2
|
||||
class="uppercase text-xl font-bold tracking-tight text-zinc-700 sm:text-3xl"
|
||||
>
|
||||
{{ $t("store.noFeatured") }}
|
||||
</h2>
|
||||
<NuxtLink
|
||||
v-if="user?.admin"
|
||||
to="/admin/library"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display 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 duration-200 hover:scale-105 active:scale-95"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="store.openFeatured"
|
||||
tag="span"
|
||||
scope="global"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
>
|
||||
<template #arrow>
|
||||
<ArrowTopRightOnSquareIcon class="size-4" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<StoreView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
const recent = await $dropFetch("/api/v1/store/featured");
|
||||
|
||||
const user = useUser();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
useHead({
|
||||
title: t("store.title"),
|
||||
});
|
||||
</script>
|
||||
48
app/pages/store/t/[id]/index.vue
Normal file
48
app/pages/store/t/[id]/index.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div class="w-full overflow-x-hidden">
|
||||
<div class="relative overflow-hidden bg-zinc-900">
|
||||
<!-- Decorative background image and gradient -->
|
||||
<div aria-hidden="true" class="absolute inset-0">
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<img alt="" class="size-full object-cover" />
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-zinc-900/75" />
|
||||
<div class="absolute inset-0 bg-linear-to-t from-zinc-900" />
|
||||
</div>
|
||||
|
||||
<!-- Callout -->
|
||||
<section
|
||||
aria-labelledby="sale-heading"
|
||||
class="relative mx-auto flex max-w-7xl flex-col items-center px-4 pt-32 pb-8 text-center sm:px-6 lg:px-8"
|
||||
>
|
||||
<div class="mx-auto max-w-2xl lg:max-w-none">
|
||||
<h2
|
||||
id="sale-heading"
|
||||
class="text-4xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl lg:text-6xl"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</h2>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<StoreView
|
||||
:prefilled="{
|
||||
tags: {
|
||||
[tag.id]: true,
|
||||
} as any,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const tagId = route.params.id;
|
||||
|
||||
const { tag } = await $dropFetch(`/api/v1/tags/${tagId}`);
|
||||
|
||||
useHead({
|
||||
title: tag.name,
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user