1
0

App redesign (#2946)

* Start of app redesign

* format

* continue progress

* Content page nearly done

* Fix recursion issues with content page

* Fix update all alignment

* Discover page progress

* Settings progress

* Removed unlocked-size hack that breaks web

* Revamp project page, refactor web project page to share code with app, fixed loading bar, misc UI/UX enhancements, update ko-fi logo, update arrow icons, fix web issues caused by floating-vue migration, fix tooltip issues, update web tooltips, clean up web hydration issues

* Ads + run prettier

* Begin auth refactor, move common messages to ui lib, add i18n extraction to all apps, begin Library refactor

* fix ads not hiding when plus log in

* rev lockfile changes/conflicts

* Fix sign in page

* Add generated

* (mostly) Data driven search

* Fix search mobile issue

* profile fixes

* Project versions page, fix typescript on UI lib and misc fixes

* Remove unused gallery component

* Fix linkfunction err

* Search filter controls at top, localization for locked filters

* Fix provided filter names

* Fix navigating from instance browse to main browse

* Friends frontend (#2995)

* Friends system frontend

* (almost) finish frontend

* finish friends, fix lint

* Fix lint

---------

Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com>

* Refresh macOS app icon

* Update web search UI more

* Fix link opens

* Fix frontend build

---------

Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
Co-authored-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
This commit is contained in:
Prospector
2024-12-11 19:54:18 -08:00
committed by GitHub
parent 6ec1dcf088
commit c39bb78e38
257 changed files with 15713 additions and 9475 deletions

View File

@@ -0,0 +1,105 @@
<template>
<div>
<Accordion
v-for="filter in filters"
:key="filter.id"
v-model="filters"
v-bind="$attrs"
:button-class="buttonClass"
:content-class="contentClass"
open-by-default
>
<template #title>
<slot name="header" :filter="filter">
<h2>{{ filter.formatted_name }}</h2>
</slot>
</template>
<template #default>
<template v-for="option in filter.options" :key="`${filter.id}-${option}`">
<slot name="option" :filter="filter" :option="option">
<div>
{{ option.formatted_name }}
</div>
</slot>
</template>
</template>
</Accordion>
</div>
</template>
<script setup lang="ts">
import Accordion from '../base/Accordion.vue'
import { computed } from 'vue'
interface FilterOption<T> {
id: string
formatted_name: string
data: T
}
interface FilterType<T> {
id: string
formatted_name: string
scrollable?: boolean
options: FilterOption<T>[]
}
interface GameVersion {
version: string
version_type: 'release' | 'snapshot' | 'alpha' | 'beta'
date: string
major: boolean
}
type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader' | 'datapack' | 'plugin'
interface Platform {
name: string
icon: string
supported_project_types: ProjectType[]
default: boolean
formatted_name: string
}
const props = defineProps<{
buttonClass?: string
contentClass?: string
gameVersions?: GameVersion[]
platforms: Platform[]
}>()
const filters = computed(() => {
const filters: FilterType<any>[] = [
{
id: 'platform',
formatted_name: 'Platform',
options:
props.platforms
.filter((x) => x.default && x.supported_project_types.includes('modpack'))
.map((x) => ({
id: x.name,
formatted_name: x.formatted_name,
data: x,
})) || [],
},
{
id: 'gameVersion',
formatted_name: 'Game version',
options:
props.gameVersions
?.filter((x) => x.major && x.version_type === 'release')
.map((x) => ({
id: x.version,
formatted_name: x.version,
data: x,
})) || [],
},
]
return filters
})
defineOptions({
inheritAttrs: false,
})
</script>

View File

@@ -0,0 +1,97 @@
<template>
<div class="experimental-styles-within flex flex-wrap items-center gap-1 empty:hidden">
<TagItem
v-if="selectedItems.length > 1"
class="transition-transform active:scale-[0.95]"
:action="clearFilters"
>
<XCircleIcon />
Clear all filters
</TagItem>
<TagItem
v-for="selectedItem in selectedItems"
:key="`remove-filter-${selectedItem.type}-${selectedItem.option}`"
:action="() => removeFilter(selectedItem)"
>
<XIcon />
<BanIcon v-if="selectedItem.negative" class="text-brand-red" />
{{ selectedItem.formatted_name ?? selectedItem.option }}
</TagItem>
<TagItem
v-for="providedItem in items.filter((x) => x.provided)"
:key="`provided-filter-${providedItem.type}-${providedItem.option}`"
v-tooltip="formatMessage(providedMessage ?? defaultProvidedMessage)"
:style="{ '--_bg-color': `var(--color-raised-bg)` }"
>
<LockIcon />
{{ providedItem.formatted_name ?? providedItem.option }}
</TagItem>
</div>
</template>
<script setup lang="ts">
import { XCircleIcon, XIcon, LockIcon, BanIcon } from '@modrinth/assets'
import { computed, type ComputedRef } from 'vue'
import TagItem from '../base/TagItem.vue'
import type { FilterValue, FilterType, FilterOption } from '../../utils/search'
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
const { formatMessage } = useVIntl()
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
const props = defineProps<{
filters: FilterType[]
providedFilters: FilterValue[]
overriddenProvidedFilterTypes: string[]
providedMessage?: MessageDescriptor
}>()
const defaultProvidedMessage = defineMessage({
id: 'search.filter.locked.default',
defaultMessage: 'Filter locked',
})
type Item = {
type: string
option: string
negative?: boolean
formatted_name?: string
provided: boolean
}
function filterMatches(type: FilterType, option: FilterOption, list: FilterValue[]) {
return list.some((provided) => provided.type === type.id && provided.option === option.id)
}
const items: ComputedRef<Item[]> = computed(() => {
return props.filters.flatMap((type) =>
type.options
.filter(
(option) =>
filterMatches(type, option, selectedFilters.value) ||
filterMatches(type, option, props.providedFilters),
)
.map((option) => ({
type: type.id,
option: option.id,
negative: selectedFilters.value.find((x) => x.type === type.id && x.option === option.id)
?.negative,
provided: filterMatches(type, option, props.providedFilters),
formatted_name: option.formatted_name,
})),
)
})
const selectedItems = computed(() => items.value.filter((x) => !x.provided))
function removeFilter(filter: Item) {
selectedFilters.value = selectedFilters.value.filter(
(x) => x.type !== filter.type || x.option !== filter.option,
)
}
async function clearFilters() {
selectedFilters.value = []
}
</script>

View File

@@ -0,0 +1,59 @@
<template>
<div class="search-filter-option group flex gap-1 items-center">
<button
:class="`flex border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-2 [@media(hover:hover)]:py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98] ${included ? 'bg-brand-highlight text-contrast hover:brightness-125' : excluded ? 'bg-highlight-red text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg focus-visible:bg-button-bg [&>svg.check-icon]:hover:text-brand [&>svg.check-icon]:focus-visible:text-brand'}`"
@click="() => emit('toggle', option)"
>
<slot>
</slot>
<BanIcon
v-if="excluded"
:class="`filter-action-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${excluded ? '' : '[@media(hover:hover)]:opacity-0'}`"
aria-hidden="true"
/>
<CheckIcon
v-else
:class="`filter-action-icon check-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${included ? '' : '[@media(hover:hover)]:opacity-0'}`"
aria-hidden="true"
/>
</button>
<div v-if="supportsNegativeFilter && !excluded" class="w-px h-[1.75rem] bg-button-bg [@media(hover:hover)]:contents" :class="{ 'opacity-0': included }">
</div>
<button
v-if="supportsNegativeFilter && !excluded"
v-tooltip="excluded ? 'Remove exclusion' : 'Exclude'"
class="flex border-none cursor-pointer items-center justify-center gap-2 rounded-xl bg-transparent px-2 py-1 text-sm font-semibold text-secondary [@media(hover:hover)]:opacity-0 transition-all hover:bg-button-bg hover:text-red focus-visible:bg-button-bg focus-visible:text-red active:scale-[0.96]"
@click="() => emit('toggleExclude', option)"
>
<BanIcon class="filter-action-icon h-4 w-4" aria-hidden="true" />
</button>
</div>
</template>
<script setup lang="ts">
import { BanIcon, CheckIcon } from '@modrinth/assets'
import type { FilterOption } from '../../utils/search'
withDefaults(defineProps<{
option: FilterOption
included: boolean
excluded: boolean
supportsNegativeFilter?: boolean
}>(), {
supportsNegativeFilter: false,
})
const emit = defineEmits<{
toggle: [option: FilterOption]
toggleExclude: [option: FilterOption]
}>()
</script>
<style scoped lang="scss">
.search-filter-option:hover,
.search-filter-option:has(button:focus-visible) {
button,
.filter-action-icon {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,334 @@
<template>
<Accordion
v-bind="$attrs"
ref="accordion"
:button-class="buttonClass ?? 'flex flex-col gap-2 justify-start items-start'"
:content-class="contentClass"
title-wrapper-class="flex flex-col gap-2 justify-start items-start"
:open-by-default="!locked && (openByDefault !== undefined ? openByDefault : true)"
>
<template #title>
<slot name="header" :filter="filterType">
<h2>{{ filterType.formatted_name }}</h2>
</slot>
</template>
<template
v-if="
locked ||
(!!accordion &&
!accordion.isOpen &&
(selectedFilterOptions.length > 0 || selectedNegativeFilterOptions.length > 0))
"
#summary
>
<div class="flex gap-1 flex-wrap">
<div
v-for="option in selectedFilterOptions"
:key="`selected-filter-${filterType.id}-${option}`"
class="flex gap-1 text-xs bg-button-bg px-2 py-0.5 rounded-full font-bold text-secondary w-fit shrink-0 items-center"
>
{{ option.formatted_name ?? option.id }}
</div>
<div
v-for="option in selectedNegativeFilterOptions"
:key="`excluded-filter-${filterType.id}-${option}`"
class="flex gap-1 text-xs bg-button-bg px-2 py-0.5 rounded-full font-bold text-secondary w-fit shrink-0 items-center"
>
<BanIcon class="text-brand-red" /> {{ option.formatted_name ?? option.id }}
</div>
</div>
</template>
<template v-if="locked" #default>
<div class="flex flex-col gap-2 p-3 border-dashed border-2 rounded-2xl border-divider mx-2">
<p class="m-0 font-bold items-center">
<slot :name="`locked-${filterType.id}`" >
{{ formatMessage(messages.lockedTitle, { type: filterType.formatted_name }) }}
</slot>
</p>
<p class="m-0 text-secondary text-sm">
{{ formatMessage(messages.lockedDescription) }}
</p>
<ButtonStyled>
<button
class="w-fit"
@click="
() => {
overriddenProvidedFilterTypes.push(filterType.id)
}
"
>
<LockOpenIcon />
{{ formatMessage(messages.unlockFilterButton) }}
</button>
</ButtonStyled>
</div>
</template>
<template v-else #default>
<div v-if="filterType.searchable" class="iconified-input mx-2 my-1 !flex">
<SearchIcon aria-hidden="true" />
<input
:id="`search-${filterType.id}`"
v-model="query"
class="!min-h-9 text-sm"
type="text"
:placeholder="`Search...`"
autocomplete="off"
/>
<Button v-if="query" class="r-btn" aria-label="Clear search" @click="() => (query = '')">
<XIcon aria-hidden="true" />
</Button>
</div>
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
<SearchFilterOption
v-for="option in visibleOptions"
:key="`${filterType.id}-${option}`"
:option="option"
:included="isIncluded(option)"
:excluded="isExcluded(option)"
:supports-negative-filter="filterType.supports_negative_filter"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
>
<slot name="option" :filter="filterType" :option="option">
<div v-if="typeof option.icon === 'string'" class="h-4 w-4" v-html="option.icon" />
<component :is="option.icon" v-else-if="option.icon" class="h-4 w-4" />
<span class="truncate text-sm">{{ option.formatted_name ?? option.id }}</span>
</slot>
</SearchFilterOption>
<button
v-if="filterType.display === 'expandable'"
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
@click="showMore = !showMore"
>
<DropdownIcon
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': showMore }"
/>
<span class="truncate text-sm">{{ showMore ? 'Show fewer' : 'Show more' }}</span>
</button>
</div>
</ScrollablePanel>
<div :class="innerPanelClass ? innerPanelClass : ''" class="empty:hidden">
<Checkbox
v-for="group in filterType.toggle_groups"
:key="`toggle-group-${group.id}`"
class="mx-2"
:model-value="groupEnabled(group.id)"
:label="`${group.formatted_name}`"
@update:model-value="toggleGroup(group.id)"
/>
<div v-if="hasProvidedFilter" class="mt-2 mx-1">
<ButtonStyled>
<button
class="w-fit"
@click="
() => {
overriddenProvidedFilterTypes = overriddenProvidedFilterTypes.filter(
(id) => id !== filterType.id,
)
accordion?.close()
clearFilters()
}
"
>
<UpdatedIcon />
<slot name="sync-button">
{{ formatMessage(messages.syncFilterButton) }}
</slot>
</button>
</ButtonStyled>
</div>
</div>
</template>
</Accordion>
</template>
<script setup lang="ts">
import Accordion from '../base/Accordion.vue'
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
import {
BanIcon,
SearchIcon,
XIcon,
UpdatedIcon,
LockOpenIcon,
DropdownIcon,
} from '@modrinth/assets'
import { Button, Checkbox, ScrollablePanel } from '../index'
import { computed, ref } from 'vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import SearchFilterOption from './SearchFilterOption.vue'
import { defineMessages, useVIntl } from '@vintl/vintl'
const { formatMessage } = useVIntl()
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
const toggledGroups = defineModel<string[]>('toggledGroups', { required: true })
const overriddenProvidedFilterTypes = defineModel<string[]>('overriddenProvidedFilterTypes', {
required: false,
default: [],
})
const props = defineProps<{
filterType: FilterType
buttonClass?: string
contentClass?: string
innerPanelClass?: string
openByDefault?: boolean
providedFilters: FilterValue[]
}>()
defineOptions({
inheritAttrs: false,
})
const query = ref('')
const showMore = ref(false)
const accordion = ref<InstanceType<typeof Accordion> | null>()
const selectedFilterOptions = computed(() =>
props.filterType.options.filter((option) =>
locked.value ? isProvided(option, false) : isIncluded(option),
),
)
const selectedNegativeFilterOptions = computed(() =>
props.filterType.options.filter((option) =>
locked.value ? isProvided(option, true) : isExcluded(option),
),
)
const visibleOptions = computed(() =>
props.filterType.options
.filter((option) => isVisible(option) || isIncluded(option) || isExcluded(option))
.slice()
.sort((a, b) => {
if (props.filterType.display === 'expandable') {
const aDefault = props.filterType.default_values.includes(a.id)
const bDefault = props.filterType.default_values.includes(b.id)
if (aDefault && !bDefault) {
return -1
} else if (!aDefault && bDefault) {
return 1
}
}
return 0
}),
)
const hasProvidedFilter = computed(() =>
props.providedFilters.some((filter) => filter.type === props.filterType.id),
)
const locked = computed(
() =>
hasProvidedFilter.value && !overriddenProvidedFilterTypes.value.includes(props.filterType.id),
)
const scrollable = computed(
() => visibleOptions.value.length >= 10 && props.filterType.display === 'scrollable',
)
function groupEnabled(group: string) {
return toggledGroups.value.includes(group)
}
function toggleGroup(group: string) {
if (toggledGroups.value.includes(group)) {
toggledGroups.value = toggledGroups.value.filter((x) => x !== group)
} else {
toggledGroups.value.push(group)
}
}
function isIncluded(filter: FilterOption) {
return selectedFilters.value.some((value) => value.option === filter.id && !value.negative)
}
function isExcluded(filter: FilterOption) {
return selectedFilters.value.some((value) => value.option === filter.id && value.negative)
}
function isVisible(filter: FilterOption) {
const filterKey = filter.formatted_name?.toLowerCase() ?? filter.id.toLowerCase()
const matchesQuery = !query.value || filterKey.includes(query.value.toLowerCase())
if (props.filterType.display === 'expandable') {
return matchesQuery && (showMore.value || props.filterType.default_values.includes(filter.id))
}
if (filter.toggle_group) {
return toggledGroups.value.includes(filter.toggle_group) && matchesQuery
} else {
return matchesQuery
}
}
function isProvided(filter: FilterOption, negative: boolean) {
return props.providedFilters.some(
(x) => x.type === props.filterType.id && x.option === filter.id && !x.negative === !negative,
)
}
type FilterState = 'include' | 'exclude' | 'ignore'
function toggleFilter(filter: FilterOption) {
setFilter(filter, isIncluded(filter) || isExcluded(filter) ? 'ignore' : 'include')
}
function toggleNegativeFilter(filter: FilterOption) {
setFilter(filter, isExcluded(filter) ? 'ignore' : 'exclude')
}
function setFilter(filter: FilterOption, state: FilterState) {
const newFilters = selectedFilters.value.filter((selected) => selected.option !== filter.id)
const baseValues = {
type: props.filterType.id,
option: filter.id,
}
if (state === 'include') {
newFilters.push({
...baseValues,
negative: false,
})
} else if (state === 'exclude') {
newFilters.push({
...baseValues,
negative: true,
})
}
selectedFilters.value = newFilters
}
function clearFilters() {
selectedFilters.value = selectedFilters.value.filter(
(filter) => filter.type !== props.filterType.id,
)
}
const messages = defineMessages({
unlockFilterButton: {
id: 'search.filter.locked.default.unlock',
defaultMessage: 'Unlock filter',
},
syncFilterButton: {
id: 'search.filter.locked.default.sync',
defaultMessage: 'Sync filter',
},
lockedTitle: {
id: 'search.filter.locked.default.title',
defaultMessage: '{type} is locked',
},
lockedDescription: {
id: 'search.filter.locked.default.description',
defaultMessage: 'Unlocking this filter may allow you to install incompatible content.',
},
})
</script>