-
Notifications
You must be signed in to change notification settings - Fork 64
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: seo improvements (closes #1385)
- Loading branch information
1 parent
14d070f
commit d826728
Showing
21 changed files
with
570 additions
and
304 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,358 @@ | ||
<script lang="ts" setup> | ||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue"; | ||
import { Tag } from "~/types/backend"; | ||
import type { Platform, PaginatedResultProject, PlatformVersion } from "~/types/backend"; | ||
const props = defineProps<{ | ||
platform?: Platform; | ||
platformName?: string; | ||
index?: boolean; | ||
}>(); | ||
const i18n = useI18n(); | ||
const route = useRoute("index"); | ||
const router = useRouter(); | ||
const sorters = [ | ||
{ id: "-stars", label: i18n.t("project.sorting.mostStars") }, | ||
{ id: "-recent_downloads", label: i18n.t("project.sorting.recentDownloads") }, | ||
{ id: "-downloads", label: i18n.t("project.sorting.mostDownloads") }, | ||
{ id: "-updated", label: i18n.t("project.sorting.recentlyUpdated") }, | ||
{ id: "-newest", label: i18n.t("project.sorting.newest") }, | ||
]; | ||
const toArray = (input: unknown) => (Array.isArray(input) ? input : input ? [input] : []); | ||
const filters = ref({ | ||
versions: toArray(route.query.version), | ||
categories: toArray(route.query.category), | ||
platform: (route.query.platform || undefined) as string | undefined, | ||
tags: toArray(route.query.tag), | ||
}); | ||
if (props.platform) { | ||
filters.value.platform = props.platform; | ||
} | ||
const activeSorter = ref<string>((route.query.sort as string) || "-stars"); | ||
const page = ref(route.query.page ? Number(route.query.page) : 0); | ||
const query = ref<string>((route.query.query as string) || ""); | ||
const projects = ref<PaginatedResultProject | null>(); | ||
const requestParams = computed(() => { | ||
const limit = 10; | ||
const params: Record<string, any> = { | ||
limit, | ||
offset: page.value * limit, | ||
version: filters.value.versions, | ||
category: filters.value.categories, | ||
platform: filters.value.platform !== null ? [filters.value.platform] : [], | ||
tag: filters.value.tags, | ||
}; | ||
if (query.value) { | ||
params.query = query.value; | ||
} | ||
if (activeSorter.value) { | ||
params.sort = activeSorter.value; | ||
} | ||
return params; | ||
}); | ||
const p = await useProjects(requestParams.value); | ||
if (p && p.value) { | ||
projects.value = p.value; | ||
await checkOffsetLargerCount(); | ||
} | ||
const projectList = ref(); | ||
function resetPage() { | ||
projectList.value.updatePage(0); | ||
} | ||
watch(filters, resetPage, { deep: true }); | ||
watch(query, resetPage); | ||
watch(activeSorter, resetPage); | ||
watchDebounced( | ||
requestParams, | ||
async () => { | ||
// dont want limit in url, its hardcoded in frontend | ||
// offset we dont want, we set page instead | ||
const { limit, offset, ...paramsWithoutLimit } = requestParams.value; | ||
// set the request params | ||
await router.replace({ query: { page: page.value, ...paramsWithoutLimit } }); | ||
// do the update | ||
return updateProjects(); | ||
}, | ||
{ deep: true, debounce: 250 } | ||
); | ||
async function updateProjects() { | ||
projects.value = await useApi<PaginatedResultProject>("projects", "get", requestParams.value); | ||
await checkOffsetLargerCount(); | ||
} | ||
// if somebody set page too high, lets reset it back | ||
async function checkOffsetLargerCount() { | ||
if (projects.value && projects.value.pagination?.offset !== 0 && projects.value.pagination?.offset > projects.value.pagination?.count) { | ||
page.value = 0; | ||
await updateProjects(); | ||
} | ||
} | ||
function versions(platform: Platform): PlatformVersion[] { | ||
const platformData = useBackendData.platforms?.get(platform); | ||
if (!platformData) { | ||
return []; | ||
} | ||
return platformData.platformVersions; | ||
} | ||
function updatePlatform(platform: any) { | ||
filters.value.platform = platform; | ||
const allowedVersion: PlatformVersion[] = versions(platform); | ||
filters.value.versions = filters.value.versions.filter((existingVersion) => { | ||
return allowedVersion.find((allowedNewVersion) => allowedNewVersion === existingVersion); | ||
}); | ||
} | ||
const config = useConfig(); | ||
const pageChangeScrollAnchor = ref<Element>(); | ||
const ssr = import.meta.server; | ||
useHead( | ||
useSeo( | ||
`Hangar - The best place to download ${!props.index ? props.platformName : "Minecraft"} plugins`, | ||
`Hangar allows you to find and download the best ${!props.index ? props.platformName : "Minecraft"} plugins for your Minecraft server`, | ||
route, | ||
null, | ||
[ | ||
{ | ||
type: "application/ld+json", | ||
children: JSON.stringify({ | ||
"@context": "https://schema.org", | ||
"@type": "WebSite", | ||
url: config.publicHost, | ||
potentialAction: { | ||
"@type": "SearchAction", | ||
target: config.publicHost + "/?q={search_term_string}", | ||
"query-input": "required name=search_term_string", | ||
}, | ||
}), | ||
key: "website", | ||
}, | ||
], | ||
true | ||
) | ||
); | ||
</script> | ||
|
||
<template> | ||
<div> | ||
<Container class="flex flex-col items-center gap-4"> | ||
<template v-if="index"> | ||
<h1 ref="pageChangeScrollAnchor" class="text-3xl font-bold uppercase text-center mt-4 flex flex-col"> | ||
<template v-if="ssr"> | ||
Find your favorite <strong class="highlight bg-gradient-to-r from-primary-500 to-primary-400 text-transparent">Paper</strong> plugins | ||
</template> | ||
<template v-else> | ||
Find your favorite | ||
<div class="h-[36px] overflow-hidden relative"> | ||
<span class="flex flex-col absolute w-full anim"> | ||
<strong class="highlight bg-gradient-to-r from-primary-500 to-primary-400 text-transparent">Paper</strong> | ||
<strong class="highlight bg-gradient-to-r from-primary-500 to-primary-400 text-transparent">Velocity</strong> | ||
<strong class="highlight bg-gradient-to-r from-primary-500 to-primary-400 text-transparent">Waterfall</strong> | ||
</span> | ||
</div> | ||
plugins | ||
</template> | ||
</h1> | ||
<div class="text-1xl text-center mb-2"> | ||
Hangar allows you to find and download the best Paper plugins, Velocity plugins or Waterfall plugins for your Minecraft server | ||
</div> | ||
</template> | ||
<template v-else> | ||
<h1 ref="pageChangeScrollAnchor" class="text-3xl font-bold uppercase text-center mt-4">Find your favorite {{ platformName }} plugins</h1> | ||
<div class="text-1xl text-center mb-2">Hangar allows you to find and download the best {{ platformName }} plugins for your Minecraft server</div> | ||
</template> | ||
<div v-if="!index" class="text-center"> | ||
Looking for more plugins? | ||
<div class="flex gap-3 mt-2 mb-2"> | ||
<Button v-if="platform != 'PAPER'" to="/paper">Download Paper plugins</Button> | ||
<Button v-if="platform != 'VELOCITY'" to="/velocity">Download Velocity Plugins</Button> | ||
<Button v-if="platform != 'WATERFALL'" to="/waterfall">Download Waterfall plugins</Button> | ||
</div> | ||
</div> | ||
<!-- Search Bar --> | ||
<div class="relative rounded-md flex shadow-md w-full max-w-screen-md"> | ||
<!-- Text Input --> | ||
<input | ||
v-model="query" | ||
name="query" | ||
class="rounded-l-md md:rounded-md p-4 basis-full min-w-0 dark:bg-gray-700" | ||
type="text" | ||
:placeholder="i18n.t('hangar.projectSearch.query', [projects?.pagination.count])" | ||
/> | ||
<div class="md:hidden flex"> | ||
<Menu> | ||
<MenuButton | ||
id="sort-button" | ||
class="bg-gradient-to-r from-primary-500 to-primary-400 rounded-r-md text-left font-semibold flex items-center gap-2 text-white p-2" | ||
> | ||
<span class="whitespace-nowrap">{{ i18n.t("hangar.projectSearch.sortBy") }}</span> | ||
<icon-mdi-sort-variant class="text-xl pointer-events-none" /> | ||
</MenuButton> | ||
<transition | ||
enter-active-class="transition duration-100 ease-out" | ||
enter-from-class="transform scale-95 opacity-0" | ||
enter-to-class="transform scale-100 opacity-100" | ||
leave-active-class="transition duration-75 ease-out" | ||
leave-from-class="transform scale-100 opacity-100" | ||
leave-to-class="transform scale-95 opacity-0" | ||
> | ||
<MenuItems | ||
class="absolute right-0 top-15 flex flex-col z-10 background-default filter shadow-default drop-shadow-md rounded border-top-primary border-t-3" | ||
> | ||
<MenuItem v-for="sorter in sorters" :key="sorter.id" v-slot="{ active }"> | ||
<button | ||
:class="{ | ||
'bg-gray-100 dark:bg-gray-700': active, | ||
'bg-gradient-to-r from-primary-500 to-primary-400 text-white': activeSorter === sorter.id, | ||
}" | ||
class="px-4 py-2 text-left" | ||
@click="activeSorter = sorter.id" | ||
> | ||
{{ sorter.label }} | ||
</button> | ||
</MenuItem> | ||
</MenuItems> | ||
</transition> | ||
</Menu> | ||
</div> | ||
</div> | ||
<div class="justify-center inline-flex gap-1 lt-md:hidden"> | ||
<div v-for="sorter in sorters" :key="sorter.id"> | ||
<button | ||
:class="{ 'bg-gradient-to-r from-primary-500 to-primary-400 text-white': activeSorter === sorter.id }" | ||
class="rounded-lg py-2 px-4 hover:(bg-gray-300 dark:bg-gray-700)" | ||
@click="activeSorter = sorter.id" | ||
> | ||
{{ sorter.label }} | ||
</button> | ||
</div> | ||
</div> | ||
</Container> | ||
<Container lg="flex items-start gap-6"> | ||
<!-- Projects --> | ||
<div v-if="projects" class="w-full min-w-0 mb-5 flex flex-col gap-2 lg:mb-0"> | ||
<h2 class="font-bold text-2xl mb-2">Projects</h2> | ||
<ProjectList ref="projectList" :projects="projects" :reset-anchor="pageChangeScrollAnchor" @update:page="(newPage) => (page = newPage)" /> | ||
</div> | ||
<!-- Sidebar --> | ||
<Card accent class="min-w-300px flex flex-col gap-4"> | ||
<h2 class="font-bold text-xl">Filters</h2> | ||
<div v-if="!platform" class="platforms"> | ||
<h3 class="font-bold mb-1"> | ||
{{ i18n.t("hangar.projectSearch.platforms") }} | ||
<span | ||
v-if="filters.platform" | ||
class="font-normal text-sm hover:(underline) text-gray-600 dark:text-gray-400" | ||
cursor="pointer" | ||
@click="filters.platform = undefined" | ||
> | ||
{{ i18n.t("hangar.projectSearch.clear") }} | ||
</span> | ||
</h3> | ||
<div class="flex flex-col gap-1"> | ||
<ul> | ||
<li v-for="platform in useVisiblePlatforms" :key="platform.enumName" class="inline-flex w-full"> | ||
<InputRadio :label="platform.name" :model-value="filters.platform" :value="platform.enumName" @update:model-value="updatePlatform"> | ||
<PlatformLogo :platform="platform.enumName" :size="24" class="mr-1" /> | ||
</InputRadio> | ||
</li> | ||
</ul> | ||
</div> | ||
</div> | ||
<div v-if="filters.platform" class="versions"> | ||
<h3 class="font-bold mb-1">{{ i18n.t("hangar.projectSearch.versions." + filters.platform) }}</h3> | ||
<div class="max-h-40 overflow-auto"> | ||
<VersionSelector v-model="filters.versions" :versions="versions(filters.platform)" :open="false" col /> | ||
</div> | ||
</div> | ||
<div class="tags"> | ||
<h3 class="font-bold mb-1">{{ i18n.t("hangar.projectSearch.tags") }}</h3> | ||
<div class="flex flex-col gap-1"> | ||
<InputCheckbox v-for="tag in Object.values(Tag)" :key="tag" v-model="filters.tags" :value="tag"> | ||
<template #label> | ||
<IconMdiPuzzleOutline v-if="tag === Tag.ADDON" /> | ||
<IconMdiBookshelf v-else-if="tag === Tag.LIBRARY" /> | ||
<IconMdiLeaf v-else-if="tag === Tag.SUPPORTS_FOLIA" /> | ||
<span class="ml-1">{{ i18n.t("project.settings.tags." + tag + ".title") }}</span> | ||
</template> | ||
</InputCheckbox> | ||
</div> | ||
</div> | ||
<div class="categories"> | ||
<h3 class="font-bold mb-1">{{ i18n.t("hangar.projectSearch.categories") }}</h3> | ||
<div class="flex flex-col gap-1"> | ||
<InputCheckbox | ||
v-for="category in useVisibleCategories" | ||
:key="category.apiName" | ||
v-model="filters.categories" | ||
:value="category.apiName" | ||
:label="i18n.t(category.title)" | ||
> | ||
<CategoryLogo :category="category.apiName" :size="22" class="mr-1" /> | ||
</InputCheckbox> | ||
</div> | ||
</div> | ||
</Card> | ||
</Container> | ||
<h2 class="text-2xl font-bold mt-8">Frequently asked Questions about Hangar (FAQ)</h2> | ||
<Card class="mt-4" itemscope itemprop="mainEntity" itemtype="https://schema.org/Question"> | ||
<h3 class="text-lg font-bold mb-1" itemprop="name">What is Hangar?</h3> | ||
<div itemscope itemprop="acceptedAnswer" itemtype="https://schema.org/Answer"> | ||
<div itemprop="text"> | ||
Hangar is the best place to download {{ platformName }} plugins. Created by the <Link href="https://papermc.io/team">PaperMC Team</Link>, we took | ||
great care that you can find the newest and best {{ platformName }} plugins. | ||
</div> | ||
</div> | ||
</Card> | ||
<Card class="mt-4" itemscope itemprop="mainEntity" itemtype="https://schema.org/Question"> | ||
<h3 class="text-lg font-bold mb-1" itemprop="name">How do I download {{ platformName }} plugins from Hangar?</h3> | ||
<div itemscope itemprop="acceptedAnswer" itemtype="https://schema.org/Answer"> | ||
<div itemprop="text"> | ||
To download {{ platformName }} plugins, simply use the search on this page to find the plugin you are looking for and download the | ||
{{ platformName }} plugin from the resource page. | ||
</div> | ||
</div> | ||
</Card> | ||
</div> | ||
</template> | ||
<style lang="scss" scoped> | ||
.anim { | ||
animation: anim 5s infinite; | ||
} | ||
.highlight { | ||
background-clip: text; | ||
} | ||
@keyframes anim { | ||
0%, | ||
30% { | ||
top: 0; | ||
} | ||
40%, | ||
60% { | ||
top: -36px; | ||
} | ||
70%, | ||
90% { | ||
top: -72px; | ||
} | ||
100% { | ||
top: 0; | ||
} | ||
} | ||
</style> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.