335 lines
12 KiB
Vue
335 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { ChevronRightIcon, SearchIcon, StarIcon } from '@modrinth/assets'
|
|
import {
|
|
Card,
|
|
defineMessages,
|
|
DropdownSelect,
|
|
EmptyState,
|
|
NewButton,
|
|
StyledInput,
|
|
TagItem,
|
|
useVIntl,
|
|
} from '@modrinth/ui'
|
|
import { computed, ref, watch } from 'vue'
|
|
import { RouterLink, useRouter } from 'vue-router'
|
|
|
|
import gradientTextToolCover from '@/assets/lab/gradient-text-tool-cover.png'
|
|
import modTranslationCover from '@/assets/lab/mod-translation-cover.png'
|
|
import recipeGeneratorToolCover from '@/assets/lab/recipe-generator-tool-cover.png'
|
|
import schematicPreviewToolCover from '@/assets/lab/schematic-preview-cover.png'
|
|
import seedMapToolCover from '@/assets/lab/seed-map-tool-cover.png'
|
|
import skinEditorToolCover from '@/assets/lab/skin-editor-tool-cover.png'
|
|
import {
|
|
getLabCategoryFilter,
|
|
getLabFavoriteFilter,
|
|
getLabFavoriteToolIds,
|
|
type LabCategoryFilter,
|
|
type LabFavoriteFilter,
|
|
setLabCategoryFilter,
|
|
setLabFavoriteFilter,
|
|
setLabFavoriteToolIds,
|
|
} from '@/helpers/lab-preferences'
|
|
import { labTools } from '@/lab/registry'
|
|
|
|
const { formatMessage } = useVIntl()
|
|
const router = useRouter()
|
|
const search = ref('')
|
|
const category = ref<LabCategoryFilter>(getLabCategoryFilter())
|
|
const favoriteFilter = ref<LabFavoriteFilter>(getLabFavoriteFilter())
|
|
const favoriteToolIds = ref<string[]>(getLabFavoriteToolIds())
|
|
const toolCoverImages: Record<string, string> = {
|
|
'gradient-text': gradientTextToolCover,
|
|
'recipe-generator': recipeGeneratorToolCover,
|
|
'schematic-preview': schematicPreviewToolCover,
|
|
'seed-map': seedMapToolCover,
|
|
'mod-translation': modTranslationCover,
|
|
'skin-editor': skinEditorToolCover,
|
|
}
|
|
|
|
const messages = defineMessages({
|
|
title: { id: 'app.lab.title', defaultMessage: 'Lab' },
|
|
toolCount: { id: 'app.lab.tool-count', defaultMessage: '{count} tool' },
|
|
toolCountPlural: { id: 'app.lab.tool-count-plural', defaultMessage: '{count} tools' },
|
|
search: { id: 'app.lab.search', defaultMessage: 'Search tools' },
|
|
allTools: { id: 'app.lab.category.all', defaultMessage: 'All tools' },
|
|
creation: { id: 'app.lab.category.creation', defaultMessage: 'Creation' },
|
|
maintenance: { id: 'app.lab.category.maintenance', defaultMessage: 'Maintenance' },
|
|
world: { id: 'app.lab.category.world', defaultMessage: 'World' },
|
|
enter: { id: 'app.lab.enter', defaultMessage: 'Enter' },
|
|
favoriteFilterAll: { id: 'app.lab.favorite-filter.all', defaultMessage: 'All' },
|
|
favoriteFilterFavorite: {
|
|
id: 'app.lab.favorite-filter.favorite',
|
|
defaultMessage: 'Favorited',
|
|
},
|
|
favoriteFilterUnfavorite: {
|
|
id: 'app.lab.favorite-filter.unfavorite',
|
|
defaultMessage: 'Not favorited',
|
|
},
|
|
favoriteAdd: { id: 'app.lab.favorite.add', defaultMessage: 'Add to favorites' },
|
|
favoriteRemove: { id: 'app.lab.favorite.remove', defaultMessage: 'Remove from favorites' },
|
|
noResults: { id: 'app.lab.no-results', defaultMessage: 'No tools match your search.' },
|
|
noFavorites: {
|
|
id: 'app.lab.no-favorites',
|
|
defaultMessage: 'You have not favorited any tools yet.',
|
|
},
|
|
gradientTextTitle: {
|
|
id: 'app.lab.gradient-text.title',
|
|
defaultMessage: 'Gradient text generator',
|
|
},
|
|
gradientTextDescription: {
|
|
id: 'app.lab.gradient-text.description',
|
|
defaultMessage: 'Create Minecraft-ready gradient text without a browser.',
|
|
},
|
|
recipeGeneratorTitle: {
|
|
id: 'app.lab.recipe-generator.title',
|
|
defaultMessage: 'Recipe generator',
|
|
},
|
|
recipeGeneratorDescription: {
|
|
id: 'app.lab.recipe-generator.description',
|
|
defaultMessage: 'Create Minecraft Java data pack recipes from local item and tag data.',
|
|
},
|
|
seedMapTitle: { id: 'app.lab.seed-map.title', defaultMessage: 'Seed map' },
|
|
seedMapDescription: {
|
|
id: 'app.lab.seed-map.description',
|
|
defaultMessage: 'Explore a Minecraft seed locally with biomes, structures, and saved markers.',
|
|
},
|
|
schematicPreviewTitle: {
|
|
id: 'app.lab.schematic-preview.title',
|
|
defaultMessage: 'Schematic workshop',
|
|
},
|
|
schematicPreviewDescription: {
|
|
id: 'app.lab.schematic-preview.description',
|
|
defaultMessage: 'Quickly preview and edit your schematics.',
|
|
},
|
|
modTranslationTitle: {
|
|
id: 'app.lab.mod-translation.title',
|
|
defaultMessage: 'Mod translation',
|
|
},
|
|
modTranslationDescription: {
|
|
id: 'app.lab.mod-translation.description',
|
|
defaultMessage: 'Translate any Minecraft mod JAR into Simplified Chinese.',
|
|
},
|
|
skinEditorTitle: { id: 'app.lab.skin-editor.title', defaultMessage: 'Skin editor' },
|
|
skinEditorDescription: {
|
|
id: 'app.lab.skin-editor.description',
|
|
defaultMessage: 'Create and edit Minecraft player skins locally.',
|
|
},
|
|
})
|
|
|
|
const categoryOptions: LabCategoryFilter[] = ['all', 'creation', 'maintenance', 'world']
|
|
const favoriteFilterOptions: LabFavoriteFilter[] = ['all', 'favorite', 'unfavorite']
|
|
|
|
watch(category, (value) => setLabCategoryFilter(value))
|
|
watch(favoriteFilter, (value) => setLabFavoriteFilter(value))
|
|
watch(favoriteToolIds, (ids) => setLabFavoriteToolIds(ids))
|
|
|
|
function isFavorite(toolId: string) {
|
|
return favoriteToolIds.value.includes(toolId)
|
|
}
|
|
|
|
function toggleFavorite(toolId: string) {
|
|
favoriteToolIds.value = isFavorite(toolId)
|
|
? favoriteToolIds.value.filter((id) => id !== toolId)
|
|
: [...favoriteToolIds.value, toolId]
|
|
}
|
|
|
|
const visibleTools = computed(() => {
|
|
const normalizedSearch = search.value.trim().toLocaleLowerCase()
|
|
const favoriteSet = new Set(favoriteToolIds.value)
|
|
|
|
const filtered = labTools.filter((tool) => {
|
|
const matchingCategory = category.value === 'all' || tool.category === category.value
|
|
const matchingFavorite =
|
|
favoriteFilter.value === 'all' ||
|
|
(favoriteFilter.value === 'favorite' && favoriteSet.has(tool.id)) ||
|
|
(favoriteFilter.value === 'unfavorite' && !favoriteSet.has(tool.id))
|
|
const matchingSearch =
|
|
!normalizedSearch ||
|
|
[toolTitle(tool.id, tool.title), toolDescription(tool.id, tool.description)]
|
|
.join(' ')
|
|
.toLocaleLowerCase()
|
|
.includes(normalizedSearch)
|
|
|
|
return matchingCategory && matchingFavorite && matchingSearch
|
|
})
|
|
|
|
const favorited = filtered.filter((tool) => favoriteSet.has(tool.id))
|
|
const unfavorited = filtered.filter((tool) => !favoriteSet.has(tool.id))
|
|
return [...favorited, ...unfavorited]
|
|
})
|
|
|
|
const emptyHeading = computed(() => {
|
|
if (favoriteFilter.value === 'favorite' && favoriteToolIds.value.length === 0) {
|
|
return formatMessage(messages.noFavorites)
|
|
}
|
|
return formatMessage(messages.noResults)
|
|
})
|
|
|
|
function toolTitle(toolId: string, fallback: string) {
|
|
if (toolId === 'skin-editor') return formatMessage(messages.skinEditorTitle)
|
|
if (toolId === 'gradient-text') return formatMessage(messages.gradientTextTitle)
|
|
if (toolId === 'recipe-generator') return formatMessage(messages.recipeGeneratorTitle)
|
|
if (toolId === 'seed-map') return formatMessage(messages.seedMapTitle)
|
|
if (toolId === 'schematic-preview') return formatMessage(messages.schematicPreviewTitle)
|
|
if (toolId === 'mod-translation') return formatMessage(messages.modTranslationTitle)
|
|
return fallback
|
|
}
|
|
|
|
function toolDescription(toolId: string, fallback: string) {
|
|
if (toolId === 'skin-editor') return formatMessage(messages.skinEditorDescription)
|
|
if (toolId === 'gradient-text') return formatMessage(messages.gradientTextDescription)
|
|
if (toolId === 'recipe-generator') return formatMessage(messages.recipeGeneratorDescription)
|
|
if (toolId === 'seed-map') return formatMessage(messages.seedMapDescription)
|
|
if (toolId === 'schematic-preview') return formatMessage(messages.schematicPreviewDescription)
|
|
if (toolId === 'mod-translation') return formatMessage(messages.modTranslationDescription)
|
|
return fallback
|
|
}
|
|
|
|
function toolOnboardingId(toolId: string) {
|
|
return ['gradient-text', 'recipe-generator', 'seed-map', 'schematic-preview'].includes(toolId)
|
|
? `lab-${toolId}-card`
|
|
: undefined
|
|
}
|
|
|
|
function toolIconClasses(toolId: string) {
|
|
if (toolId === 'seed-map') return 'bg-highlight-green text-brand'
|
|
return 'bg-brand-highlight text-brand'
|
|
}
|
|
|
|
function categoryLabel(value: LabCategoryFilter) {
|
|
if (value === 'all') return formatMessage(messages.allTools)
|
|
return formatMessage(messages[value])
|
|
}
|
|
|
|
function favoriteFilterLabel(value: LabFavoriteFilter) {
|
|
if (value === 'all') return formatMessage(messages.favoriteFilterAll)
|
|
if (value === 'favorite') return formatMessage(messages.favoriteFilterFavorite)
|
|
return formatMessage(messages.favoriteFilterUnfavorite)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<main class="flex w-full flex-col gap-6 p-6">
|
|
<header class="flex min-w-0 items-start justify-between gap-4">
|
|
<div class="min-w-0">
|
|
<h1 class="m-0 text-2xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
|
|
<p class="m-0 mt-1 text-sm text-secondary">
|
|
{{
|
|
formatMessage(labTools.length === 1 ? messages.toolCount : messages.toolCountPlural, {
|
|
count: labTools.length,
|
|
})
|
|
}}
|
|
</p>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="flex flex-wrap gap-2" aria-label="Lab tool filters">
|
|
<StyledInput
|
|
v-model="search"
|
|
:icon="SearchIcon"
|
|
:placeholder="formatMessage(messages.search)"
|
|
clearable
|
|
wrapper-class="min-w-[14rem] flex-1"
|
|
/>
|
|
<DropdownSelect
|
|
v-model="category"
|
|
:options="categoryOptions"
|
|
:display-name="categoryLabel"
|
|
name="Lab category"
|
|
class="w-48 max-[576px]:w-full"
|
|
/>
|
|
<DropdownSelect
|
|
v-model="favoriteFilter"
|
|
:options="favoriteFilterOptions"
|
|
:display-name="favoriteFilterLabel"
|
|
name="Lab favorite filter"
|
|
class="w-48 max-[576px]:w-full"
|
|
/>
|
|
</div>
|
|
|
|
<section
|
|
v-if="visibleTools.length"
|
|
aria-label="Lab tools"
|
|
data-onboarding-id="lab-tools"
|
|
class="flex flex-col gap-3"
|
|
>
|
|
<Card
|
|
v-for="tool in visibleTools"
|
|
:key="tool.id"
|
|
class="!m-0 relative flex items-end gap-4 !p-4 transition-[border-color,filter] duration-200 hover:border-surface-5 hover:brightness-[1.05]"
|
|
>
|
|
<button
|
|
class="absolute right-4 top-4 z-20 flex size-8 items-center justify-center rounded-md text-secondary transition-colors hover:text-brand focus-visible:outline-none"
|
|
:aria-label="
|
|
isFavorite(tool.id)
|
|
? formatMessage(messages.favoriteRemove)
|
|
: formatMessage(messages.favoriteAdd)
|
|
"
|
|
:aria-pressed="isFavorite(tool.id)"
|
|
@click="toggleFavorite(tool.id)"
|
|
>
|
|
<StarIcon
|
|
class="size-5"
|
|
:class="isFavorite(tool.id) ? 'fill-brand text-brand' : 'text-secondary'"
|
|
/>
|
|
</button>
|
|
|
|
<RouterLink
|
|
:to="tool.route"
|
|
aria-hidden="true"
|
|
tabindex="-1"
|
|
class="shrink-0 rounded-[var(--radius-lg)] focus-visible:outline-none"
|
|
>
|
|
<div
|
|
class="relative aspect-[2/1] w-56 overflow-hidden rounded-[var(--radius-lg)] bg-surface-2 max-[576px]:w-36"
|
|
>
|
|
<img
|
|
v-if="toolCoverImages[tool.id]"
|
|
:src="toolCoverImages[tool.id]"
|
|
alt=""
|
|
class="absolute inset-0 h-full w-full object-cover"
|
|
/>
|
|
<div
|
|
v-else
|
|
class="flex h-full items-center justify-center"
|
|
:class="toolIconClasses(tool.id)"
|
|
>
|
|
<component :is="tool.icon" class="size-8" />
|
|
</div>
|
|
</div>
|
|
</RouterLink>
|
|
|
|
<div class="flex min-w-0 flex-1 flex-col">
|
|
<RouterLink
|
|
:to="tool.route"
|
|
:data-onboarding-id="toolOnboardingId(tool.id)"
|
|
class="min-w-0 rounded-[var(--radius-lg)] text-inherit no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
|
>
|
|
<h2 class="m-0 line-clamp-1 pr-10 text-lg font-bold leading-tight text-contrast">
|
|
{{ toolTitle(tool.id, tool.title) }}
|
|
</h2>
|
|
<p class="m-0 mt-1 line-clamp-2 text-sm leading-5 text-secondary">
|
|
{{ toolDescription(tool.id, tool.description) }}
|
|
</p>
|
|
</RouterLink>
|
|
<div class="mt-auto flex items-center justify-between gap-3 pt-4">
|
|
<TagItem>{{ categoryLabel(tool.category) }}</TagItem>
|
|
<NewButton
|
|
type="colored"
|
|
color="brand"
|
|
size="sm"
|
|
class="min-w-20 justify-between px-3"
|
|
@click="router.push(tool.route)"
|
|
>
|
|
<ChevronRightIcon />
|
|
{{ formatMessage(messages.enter) }}
|
|
</NewButton>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</section>
|
|
|
|
<EmptyState v-else type="no-search-result" :heading="emptyHeading" aria-live="polite" />
|
|
</main>
|
|
</template>
|