feat: linked server instances (#5221)

* ping queue with tests

* mc ping server info + timeout

* sqlx prepare

* tombi fmt

* tombi fmt

* allow querying server ping data

* fix shear

* wip: resolve comments with pings

* Switch to Redis for server pings

* tombi fmt

* fix compile error

* clear cache on project ping, add server store link

* Schema changes

* Improve server messages for app pinging

* synthetic server project version for search indexing

* wip: clean up server ping, background tasks

* fix migration to sync with main, propagate background task errors

* wip: server modpack content query, components in search

* wip: massive component query refactor

* fix more defaults stuff

* sqlx

* fix serde deser flatten

* fix search indexing not showing fields

* remove leftover prompt

* fix import

* add diff detection for version dependencies without version_id/project_id

* move servers tab to end

* hide app nav tabs if only one tab

* fix undefined property

* on click link for server side bar info

* show recommended & supported versions for vanilla

* fix how install.js installs instance with modpack content title instead of server project title and dont fetch icon when installing to existing instance

* use large play button instance

* show update success instead of launching right into the game

* add global installing server project state

* add comment

* small change: open discover to modpack

* implement ping server projects for latency in app

* add projectV3 to nag context for moderation package

* fix play server project button when instance is launched

* add ping to project header

* wip: server verified plays

* server verified plays compiling

* queue up server plays in batches

* report server plays improved in frontend

* fixes to tracking server joins

* fix: server project detection to do loose null check

* fix server projects showing license

* fix empty server info card

* fix server projects links title

* Fix backend impl for server player count analytics

* fix: allow for links to be set to empty

* hook up server recent plays

* cargo sqlx prepare

* add project sidebar stories

* feat: update project sidebar server info card to new design

* update server project header and project card

* feat: add hide label for project cards

* feat: add tags sidebar card

* small fix to keep color consistent

* fix: remove required content tab from server project page

* many small fixes

* handle locking server instance content

* fix hiding modal after saving server compatibility version

* copy content card item and table from content tab update branch

* fix nav tabs active tag

* fix switching between server instance vs regular instance persisted invalid state

* fix a lot of the bugginess of navtabs when theres hidden/shown tabs between instances. match frontend nav tabs

* hook up backend searchfor frontend in websiet

* fix: server project card tags

* hook up search v3 in app backend for app frontend

* Don't return missing components in project query

* Add game versions to server filters

* move reporting server joins to backend

* send account UUID along with server play analytics

* update java server ping schema

* feat: implement use server search for search sorting and filter facets

* pnpm prepr

* fix game version filter facet

* fix: allow java and bedrock addresses to be deleted

* feat: hook up languages

* Default deserialize `ProjectSerial`

* feat: show server project tags

* small fix on languages multi select

* also default java server content

* fix: update compatibility modal not closing after successful upload

* remove play button in website discovery for servers

* reenable fence in app backend

* update online/offline tag

* add online status indicator pulsing

* revert pulsing

* disable link for custom modpack project and show tooltip

* change modpack to modded type

* update ip address entire button to be clickable

* polish server info card styles

* make offline tag red and properly hook up online tag

* move server related settings into own tab

* fix setting project compatibility resets unsaved changes

* fix javaServerPatchaData wiping content field

* updates to compatibility card, add download button and display supported versions better

* fix unsaved changes popup for tags

* remove console.log

* fix incorrect project type in projects in dashboard

* fix: savable.ts to reset currentValues to data() after save

* upload server banner as gallery image with title == "__mc_server_banner__" and filter it from frontend gallery

* fix error handling and helper text copy

* ensure gallery banners are filtered in app backend gallery display

* add grouped filters for search

* add query params for server search

* feat: deep linking to open server project page then open install to play

* fix search in app frontend

* fix: server project showing offline

* fix: profile create error app backend

Here's what was happening and the fix:

Root cause: In create.rs:107, profile_create assumed the icon_path parameter was always a local filename relative to the caches directory. It did caches_dir().join(icon) which produced a path like ...\caches\https://staging-cdn.modrinth.com/... — the colons in https:// are illegal in Windows paths (OS error 123).

The frontend's installServerProject and createVanillaInstance in install.js:290 both pass project.icon_url (a full URL) directly as the icon parameter.

Fix: Modified profile_create to detect when the icon parameter is a URL (starts with http:// or https://). When it is, it downloads the icon via fetch(), extracts the filename from the URL path, and passes the downloaded bytes and filename to set_icon() which hashes and caches it properly. The existing local-file path continues to work as before.

* pass undefined instead of unknown for modpack content modal

* fix: wrong way to determine offline status

* delete required content page placeholder

* fix: redirect running function instead of passing function

* add in wiki page

* fix diffs which have unknown project/filename

* pnpm prepr

* feat: add handling for "stop" instance state for server project card and page play button

* fix updating modpack shouldn't launch right into game

* small fix on external icon

* fix refresh search causing infinite rerender i.e. maximum call stack size exceeded

watch(route) → watch(() => [route.query.i, route.query.ai, route.path]) (line 102): The deep watch on the entire Vue Router route object was the most likely cause of the stack overflow. Vue Router's route object contains matched records with component definitions and other deeply nested structures. Deep-watching it triggers recursive traversal on every route change (including those from router.replace() inside refreshSearch()). Now it only watches the specific properties that updateInstanceContext() actually needs.

ref → shallowRef for serverHits and serverPings (line 189-190): The v3 search results can be deeply nested objects (minecraft_java_server.ping.data, content, etc.). Using shallowRef prevents Vue from creating deep reactive proxies on these objects, which is consistent with how results already uses shallowRef on line 295.

Re-entrance guard + try/catch on refreshSearch() (line 310): The watcher calls refreshSearch() without awaiting, so state changes during the async execution could trigger the watcher again, causing concurrent calls. The guard prevents overlapping calls, and the try/catch ensures loading.value = false is always reached (fixing the infinite loading).

* don't require auth token for logging server play

* fetch latest server player count from redis instead of search doc

* remove components. in search facet

* Category and search sort fixes

* add logging for refreshSearch in browse.vue

* fix: use windows.history.replace instead of router.replace due to vue production bug and remove logs

* fix: server refresh search reactivity

* fix: type errors

* conquer the type errors in Browse.vue

* update search input background

* fix tags location

* slight change to color

* feat: add linked to modpack project for regular modpack instances

* feat: installation tab updates

* fix: copy ip missing hover effect

* feat: implement category and countries negative filters

* fix servers tab label in profile page

* implement add server to instance

* feat: implement allow editing server instances

* update installation settings to handle vanilla server instance case

* hide servers tab when installing content to instance

* add sorting for user installed content to be top of list in content

* update categories filters from one group filter card to separate filters cards

* add active scale

* fix offline server showing online

* update language display

* update tooltip

* hide navtabs if theres only one tab

* fix: modpack content name truncate in project card

* feat: add server projects to moderation queue

* update redirect middleware no longer needs projectV3

* update comment

* fix: server tags labels

* feat: add the mf icons finally

* Revert "update redirect middleware no longer needs projectV3"

This reverts commit 1289cb52869185abe1481dfb6b0c00c0233bf59e.

* fix open in browser

* revert any handling for handling base linked modpack content for content tab

* update instance online players to be client ping

* fix showing modpack/loader version for server instance in installation settings

* server projects are not marked as modpacks

* skip license check for server projects

* feat: add the concept of linked worlds for server instances and keep in sync with server project

* fix: router.push doesn't add history state, use nagivateTo instead

* fix: get server modpack content wrong link

* update some categories to default collapse

* small fixes

* optional languages & bedrock

* move creator below tags

* sort linked worlds to be first

* add red orange and green ping variants

* bring back content tab

* add download button in required content in app

* fix: server info card loading

* fix: brief flash of normal project before server project stuff loads in

* misc fixes

* invalidate project v3

* fix unused imports

* Quick pass for moderation related changes (#5429)

* filter certain nags out from server projects.

* move add-links nag to links.ts

* first few server related nags

* moderation checklist groundwork

* Prevent undefined stage from appearing on servers.

* add projectV3 to shouldShow callback

* Filter buttons by server project type

* fix, revert private use msg, adjust server & link nags

* starting tags + servers msg

* fix no projectV3

* fix: router.push doesn't add history state, use nagivateTo instead

* Tags nag works with servers now

* support servers' v3 exclusive links

* reupload, and status messages + nag tweaks.

* fixes

* Update tags.vue warning for server projects.

* don't suggest adding a bedrock IP

* Tweak phrasing on servers alert msg

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>

* only show unique tags in project card

* add projectV3 to cache purge

* fix type: add projectV3 to cache purge

* update caching behaviour for installing

* max 3 plays per user

* accept date_modified and date_created for sorting

* add locking environment filter for server instance and update copy

* custom pack button only shows when needed (#5444)

* expose server pinging route to frontend

* feat: add server field validation with pinging on unfocus

* improve pinging logs

* try another pinging crate

* small fixes

* prefill published project id for updating published project

* fix running app bar for mac

* cargo sqlx prepare

* fix app login avatar

* pnpm prepr

* fix download menu for mac

* FIX CI

* fix lint errors

* cargo fmt

* fix toml

* fix more lint

* add server copy

* more lint

* fix any types

* also ping unlisted and private servers

* fix lint

* remove option for showTypeSelector

* fix cannot read user from undefined

* pnpm prepr

* update pinging to make it better

* update copy

* fix login cache issue

* add project select default icon

* fix: minecraft_java_server not redirecting

* pnpm prepr

* fix required content card in project page for custom modpack

* fix app project cards custom modpacks

* update pre-collapsed for app frontend

* don't send server projects to discord webhook

* add lock icon to linked world managed by server project

* pnpm prepr

* make automod msgs on server projects private

* fix pagination for server projects tab

* fix recent plays copy

* fix sync linked world with server project

* pnpm prepr

* add 0.11.0 changelog

* update date

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: aecsocket <aecsocket@tutanota.com>
Co-authored-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
This commit is contained in:
Truman Gao
2026-03-02 15:38:09 -08:00
committed by GitHub
parent 51066c476a
commit 51ceb9d851
318 changed files with 19891 additions and 4524 deletions

View File

@@ -6,7 +6,7 @@
@click="() => (forceOpen ? undefined : toggledOpen ? close() : open())"
>
<slot name="button" :open="isOpen">
<div class="flex items-center gap-1 w-full">
<div class="flex items-center gap-1 w-full text-contrast">
<slot name="title" :open="isOpen" />
<DropdownIcon
v-if="!forceOpen"

View File

@@ -45,49 +45,37 @@
</svg>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
<script setup lang="ts">
import { computed, ref, useTemplateRef, watch } from 'vue'
const pixelated = ref(false)
const img = ref(null)
const img = useTemplateRef<HTMLImageElement>('img')
const failed = ref(false)
const props = defineProps({
src: {
type: String,
default: null,
const props = withDefaults(
defineProps<{
src?: string | null
alt?: string
size?: string
circle?: boolean
noShadow?: boolean
loading?: 'eager' | 'lazy'
raised?: boolean
tintBy?: string | null
}>(),
{
src: null,
alt: '',
size: '2rem',
circle: false,
noShadow: false,
loading: 'eager',
raised: false,
tintBy: null,
},
alt: {
type: String,
default: '',
},
size: {
type: String,
default: '2rem',
},
circle: {
type: Boolean,
default: false,
},
noShadow: {
type: Boolean,
default: false,
},
loading: {
type: String,
default: 'eager',
},
raised: {
type: Boolean,
default: false,
},
tintBy: {
type: String,
default: null,
},
})
)
const LEGACY_PRESETS = {
const LEGACY_PRESETS: Record<string, string> = {
xxs: '1.25rem',
xs: '2.5rem',
sm: '3rem',
@@ -125,7 +113,7 @@ const tint = computed(() => {
}
})
function hash(str) {
function hash(str: string): number {
let hash = 0
for (let i = 0, len = str.length; i < len; i++) {
const chr = str.charCodeAt(i)

View File

@@ -4,7 +4,7 @@
ref="triggerRef"
role="button"
tabindex="0"
class="relative cursor-pointer flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-button-bg px-4 py-2.5 text-left transition-all duration-200 text-button-text hover:bg-button-bgHover active:bg-button-bgActive"
class="relative flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-button-bg px-4 py-2.5 text-left transition-all duration-200 text-button-text"
:class="[
triggerClasses,
{
@@ -12,6 +12,7 @@
'rounded-b-none': shouldRoundBottomCorners,
'rounded-t-none': shouldRoundTopCorners,
'cursor-not-allowed opacity-50': disabled,
'cursor-pointer hover:bg-button-bgHover active:bg-button-bgActive': !disabled,
},
]"
:aria-expanded="isOpen"
@@ -99,12 +100,21 @@
<slot :name="`option-${item.value}`" :item="item">
<div class="flex items-center gap-2">
<component :is="item.icon" v-if="item.icon" class="h-5 w-5" />
<span
class="font-semibold leading-tight"
:class="item.value === modelValue ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
<div class="flex flex-col gap-1.5">
<span
class="font-semibold leading-tight"
:class="item.value === modelValue ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
<span
v-if="item.subLabel"
class="text-sm"
:class="item.value === modelValue ? 'text-contrast' : 'text-secondary'"
>
{{ item.subLabel }}
</span>
</div>
</div>
</slot>
</component>
@@ -138,6 +148,7 @@ import StyledInput from './StyledInput.vue'
export interface ComboboxOption<T> {
value: T
label: string
subLabel?: string
icon?: Component
disabled?: boolean
class?: string

View File

@@ -1,25 +1,35 @@
<template>
<div
class="grid grid-cols-1 gap-x-8 gap-y-6 border-0 border-b border-solid border-divider pb-4 lg:grid-cols-[1fr_auto]"
class="grid grid-cols-[1fr_auto] max-lg:gap-x-8 gap-y-6 border-0 border-b border-solid border-divider pb-4"
>
<div class="flex gap-4">
<div class="flex gap-4 w-full">
<slot name="icon" />
<div class="flex flex-col gap-1">
<div class="flex flex-wrap items-center gap-2">
<h1 class="m-0 text-2xl font-extrabold leading-none text-contrast">
<slot name="title" />
</h1>
<slot name="title-suffix" />
<div class="flex flex-col gap-2 justify-center w-full">
<div class="flex justify-between items-start gap-2">
<div class="flex flex-col gap-1.5 justify-center">
<div class="flex flex-wrap items-center gap-2">
<h1 class="m-0 text-2xl font-semibold leading-none text-contrast">
<slot name="title" />
</h1>
<slot name="title-suffix" />
</div>
<p v-if="$slots.summary" class="m-0 line-clamp-2 max-w-[40rem] empty:hidden">
<slot name="summary" />
</p>
</div>
<div v-if="$slots.summary" class="flex gap-2 items-start max-lg:hidden">
<slot name="actions" />
</div>
</div>
<p v-if="$slots.summary" class="m-0 line-clamp-2 max-w-[40rem] empty:hidden">
<slot name="summary" />
</p>
<div v-if="$slots.stats" class="mt-auto flex flex-wrap gap-4 empty:hidden">
<div v-if="$slots.stats" class="flex flex-wrap gap-3 empty:hidden">
<slot name="stats" />
</div>
<div class="flex gap-2 items-start lg:hidden">
<slot name="actions" />
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 items-center">
<div v-if="!$slots.summary" class="flex gap-2 items-start max-lg:hidden">
<slot name="actions" />
</div>
</div>

View File

@@ -2,7 +2,7 @@
<label
:class="[
'flex flex-col items-center justify-center cursor-pointer border-2 border-dashed bg-surface-4 text-contrast transition-colors',
size === 'small' ? 'p-5' : 'p-12',
size === 'small' ? 'p-5' : size === 'medium' ? 'p-10' : 'p-12',
size === 'small' ? 'gap-2' : 'gap-4',
size === 'small' ? 'rounded-2xl' : 'rounded-3xl',
isDragOver ? 'border-purple' : 'border-surface-5',
@@ -69,12 +69,12 @@ const props = withDefaults(
maxSize?: number | null
shouldAlwaysReset?: boolean
disabled?: boolean
size?: 'small' | 'standard'
size?: 'small' | 'medium' | 'large'
}>(),
{
primaryPrompt: 'Drop files here or click to upload',
secondaryPrompt: 'Only supported file types will be accepted',
size: 'standard',
size: 'large',
},
)

View File

@@ -20,9 +20,9 @@ onUnmounted(() => {
<template>
<Transition name="floating-action-bar" appear>
<div v-if="shown" class="floating-action-bar fixed w-full z-10 left-0 p-4 bottom-0">
<div v-if="shown" class="floating-action-bar fixed w-full z-10 left-0 p-10 bottom-0">
<div
class="flex items-center gap-2 rounded-2xl bg-bg-raised border-2 border-divider border-solid mx-auto max-w-[77rem] p-4"
class="flex items-center gap-2 rounded-2xl bg-bg-raised border-2 border-divider border-solid mx-auto max-w-[75rem] p-4"
>
<slot />
</div>

View File

@@ -7,6 +7,7 @@
:closable="true"
:close-on-click-outside="false"
:width="resolvedMaxWidth"
:fade="fade"
:disable-close="resolveCtxFn(currentStage.disableClose, context)"
>
<template #title>
@@ -74,6 +75,7 @@
<ButtonStyled v-if="leftButtonConfig" type="outlined">
<button
class="!border-surface-5"
:class="leftButtonConfig.buttonClass"
:disabled="leftButtonConfig.disabled"
@click="leftButtonConfig.onClick"
>
@@ -82,7 +84,11 @@
</button>
</ButtonStyled>
<ButtonStyled v-if="rightButtonConfig" :color="rightButtonConfig.color">
<button :disabled="rightButtonConfig.disabled" @click="rightButtonConfig.onClick">
<button
:disabled="rightButtonConfig.disabled"
:class="rightButtonConfig.buttonClass"
@click="rightButtonConfig.onClick"
>
<component
:is="rightButtonConfig.icon"
v-if="rightButtonConfig.iconPosition === 'before'"
@@ -114,6 +120,7 @@ export interface StageButtonConfig {
color?: InstanceType<typeof ButtonStyled>['$props']['color']
disabled?: boolean
iconClass?: string | null
buttonClass?: string | null
onClick?: () => void
}
@@ -125,6 +132,7 @@ export interface StageConfigInput<T> {
title: MaybeCtxFn<T, string>
skip?: MaybeCtxFn<T, boolean>
hideStageInBreadcrumb?: MaybeCtxFn<T, boolean>
// Determines whether this stage shows the progress bar
nonProgressStage?: MaybeCtxFn<T, boolean>
cannotNavigateForward?: MaybeCtxFn<T, boolean>
disableClose?: MaybeCtxFn<T, boolean>
@@ -145,6 +153,7 @@ const props = defineProps<{
context: T
breadcrumbs?: boolean
fitContent?: boolean
fade?: 'standard' | 'warning' | 'danger'
}>()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')

View File

@@ -0,0 +1,11 @@
<template>
<div class="grid grid-cols-[min-content_1fr_auto] gap-4">
<slot name="icon" />
<div class="flex flex-col gap-1.5">
<slot name="title" />
<slot name="summary" />
<slot name="stats" />
</div>
<slot name="actions" />
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="flex items-center gap-1">
<slot />
</div>
</template>

View File

@@ -58,6 +58,7 @@ export { default as SettingsLabel } from './SettingsLabel.vue'
export { default as SimpleBadge } from './SimpleBadge.vue'
export { default as Slider } from './Slider.vue'
export { default as SmartClickable } from './SmartClickable.vue'
export { default as StatItem } from './StatItem.vue'
export { default as StyledInput } from './StyledInput.vue'
export type { TableColumn } from './Table.vue'
export { default as Table } from './Table.vue'

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { DownloadIcon, MoreVerticalIcon, OrganizationIcon, TrashIcon } from '@modrinth/assets'
import { type ComponentPublicInstance, computed, getCurrentInstance, ref } from 'vue'
import { computed, getCurrentInstance, ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { useVIntl } from '../../composables/i18n'
@@ -8,6 +8,7 @@ import { commonMessages } from '../../utils/common-messages'
import { truncatedTooltip } from '../../utils/truncate'
import AutoLink from '../base/AutoLink.vue'
import Avatar from '../base/Avatar.vue'
import BulletDivider from '../base/BulletDivider.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import Checkbox from '../base/Checkbox.vue'
import type { Option as OverflowMenuOption } from '../base/OverflowMenu.vue'
@@ -21,23 +22,29 @@ interface Props {
project: ContentCardProject
projectLink?: string | RouteLocationRaw
version?: ContentCardVersion
versionLink?: string | RouteLocationRaw
owner?: ContentOwner
enabled?: boolean
hasUpdate?: boolean
overflowOptions?: OverflowMenuOption[]
disabled?: boolean
showCheckbox?: boolean
hideDelete?: boolean
hideActions?: boolean
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
projectLink: undefined,
version: undefined,
versionLink: undefined,
owner: undefined,
enabled: undefined,
hasUpdate: false,
overflowOptions: undefined,
disabled: false,
showCheckbox: false,
hideDelete: false,
hideActions: false,
})
const selected = defineModel<boolean>('selected')
@@ -52,96 +59,133 @@ const instance = getCurrentInstance()
const hasDeleteListener = computed(() => typeof instance?.vnode.props?.onDelete === 'function')
const hasUpdateListener = computed(() => typeof instance?.vnode.props?.onUpdate === 'function')
const titleRef = ref<ComponentPublicInstance | null>(null)
const MAX_FILENAME_LENGTH = 42
function truncateMiddle(str: string, maxLength: number): string {
if (str.length <= maxLength) return str
const ellipsis = '...'
const charsToShow = maxLength - ellipsis.length
const frontChars = Math.ceil(charsToShow / 2)
const backChars = Math.floor(charsToShow / 2)
return str.slice(0, frontChars) + ellipsis + str.slice(-backChars)
}
const versionNumberRef = ref<HTMLElement | null>(null)
const fileNameRef = ref<HTMLElement | null>(null)
</script>
<template>
<div
class="grid h-[74px] items-center gap-4 px-4"
:class="[
{ 'opacity-50': disabled },
showCheckbox
? 'grid-cols-[auto_1fr_1fr] md:grid-cols-[auto_1fr_335px_1fr]'
: 'grid-cols-[1fr_1fr] md:grid-cols-[1fr_335px_1fr]',
]"
class="flex h-[74px] items-center justify-between gap-4 px-3"
:class="{ 'opacity-50': disabled }"
>
<Checkbox
v-if="showCheckbox"
:model-value="selected ?? false"
:disabled="disabled"
class="shrink-0"
@update:model-value="selected = $event"
/>
<div class="flex min-w-0 items-center gap-3">
<Avatar
:src="project.icon_url"
:alt="project.title"
size="3rem"
no-shadow
class="shrink-0 rounded-2xl border border-surface-5"
<div
class="flex min-w-0 items-center gap-4"
:class="
hideActions ? 'flex-1' : 'flex-1 @[800px]:w-[350px] @[800px]:shrink-0 @[800px]:flex-none'
"
>
<Checkbox
v-if="showCheckbox"
:model-value="selected ?? false"
:disabled="disabled"
class="shrink-0"
@update:model-value="selected = $event"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<AutoLink
ref="titleRef"
v-tooltip="truncatedTooltip(titleRef?.$el, project.title)"
:target="
typeof projectLink === 'string' && projectLink.startsWith('http') ? '_blank' : undefined
"
:to="projectLink"
class="truncate font-semibold leading-6 text-contrast !decoration-contrast"
:class="{ 'hover:underline': projectLink }"
>
{{ project.title }}
</AutoLink>
<AutoLink
v-if="owner"
:target="
typeof owner.link === 'string' && owner.link.startsWith('http') ? '_blank' : undefined
"
:to="owner.link"
class="flex items-center gap-1 !decoration-secondary"
:class="{ 'hover:underline': owner.link }"
>
<Avatar
:src="owner.avatar_url"
:alt="owner.name"
size="1.5rem"
:circle="owner.type === 'user'"
no-shadow
class="shrink-0"
/>
<OrganizationIcon v-if="owner.type === 'organization'" class="size-4 text-secondary" />
<span class="text-sm leading-5 text-secondary">{{ owner.name }}</span>
</AutoLink>
<div class="flex min-w-0 items-center gap-3">
<Avatar
:src="project.icon_url"
:alt="project.title"
size="3rem"
no-shadow
class="shrink-0 rounded-2xl border border-surface-5"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<AutoLink
:target="
typeof projectLink === 'string' && projectLink.startsWith('http')
? '_blank'
: undefined
"
:to="projectLink"
class="truncate font-semibold leading-6 text-contrast !decoration-contrast"
:class="{ 'hover:underline': projectLink }"
>
{{ project.title }}
</AutoLink>
<div class="flex min-w-0 items-center gap-1">
<AutoLink
v-if="owner"
:target="
typeof owner.link === 'string' && owner.link.startsWith('http')
? '_blank'
: undefined
"
:to="owner.link"
class="flex shrink-0 items-center gap-1 !decoration-secondary"
:class="{ 'hover:underline': owner.link }"
>
<OrganizationIcon
v-if="owner.type === 'organization'"
class="size-4 text-secondary"
/>
<Avatar
:src="owner.avatar_url"
:alt="owner.name"
size="1.5rem"
:circle="owner.type === 'user'"
no-shadow
class="shrink-0"
/>
<span class="text-sm leading-5 text-secondary">{{ owner.name }}</span>
</AutoLink>
<template v-if="version">
<BulletDivider class="shrink-0 @[800px]:hidden" />
<AutoLink
:target="
typeof versionLink === 'string' && versionLink.startsWith('http')
? '_blank'
: undefined
"
:to="versionLink"
class="truncate text-sm leading-5 text-secondary !decoration-secondary @[800px]:hidden"
:class="{ 'hover:underline': versionLink }"
>
{{ version.version_number }}
</AutoLink>
</template>
</div>
</div>
</div>
</div>
<div class="hidden flex-col justify-center gap-0.5 md:flex">
<div
class="hidden flex-col gap-0.5 @[800px]:flex"
:class="hideActions ? 'flex-1' : 'w-[335px] min-w-0'"
>
<template v-if="version">
<span class="font-medium leading-6 text-contrast">{{ version.version_number }}</span>
<span
v-tooltip="version.file_name.length > MAX_FILENAME_LENGTH ? version.file_name : undefined"
class="leading-6 text-secondary"
<AutoLink
v-tooltip="truncatedTooltip(versionNumberRef, version.version_number)"
:target="
typeof versionLink === 'string' && versionLink.startsWith('http') ? '_blank' : undefined
"
:to="versionLink"
class="inline-flex min-w-0 font-medium leading-6 text-contrast !decoration-contrast"
:class="{ 'hover:underline': versionLink, 'cursor-pointer': versionLink }"
>
{{ truncateMiddle(version.file_name, MAX_FILENAME_LENGTH) }}
<span ref="versionNumberRef" class="truncate">{{
version.version_number.slice(0, Math.ceil(version.version_number.length / 2))
}}</span>
<span class="shrink-0">{{
version.version_number.slice(Math.ceil(version.version_number.length / 2))
}}</span>
</AutoLink>
<span
v-tooltip="truncatedTooltip(fileNameRef, version.file_name)"
class="flex min-w-0 leading-6 text-secondary"
>
<span ref="fileNameRef" class="truncate">{{
version.file_name.slice(0, Math.ceil(version.file_name.length / 2))
}}</span>
<span class="shrink-0">{{
version.file_name.slice(Math.ceil(version.file_name.length / 2))
}}</span>
</span>
</template>
</div>
<div class="flex items-center justify-end gap-2">
<div v-if="!hideActions" class="flex min-w-[160px] shrink-0 items-center justify-end gap-2">
<slot name="additionalButtonsLeft" />
<!-- Fixed width container to reserve space for update button -->
@@ -169,10 +213,11 @@ function truncateMiddle(str: string, maxLength: number): string {
:model-value="enabled"
:disabled="disabled"
small
class="mr-2 my-auto"
@update:model-value="(val) => emit('update:enabled', val as boolean)"
/>
<ButtonStyled v-if="hasDeleteListener" circular type="transparent">
<ButtonStyled v-if="hasDeleteListener && !props.hideDelete" circular type="transparent">
<button
v-tooltip="formatMessage(commonMessages.deleteLabel)"
:disabled="disabled"

View File

@@ -1,111 +1,90 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
import { computed, getCurrentInstance, ref, toRef } from 'vue'
import { useVIntl } from '../../composables/i18n'
import { useStickyObserver } from '../../composables/sticky-observer'
import { useVirtualScroll } from '../../composables/virtual-scroll'
import { commonMessages } from '../../utils/common-messages'
import Checkbox from '../base/Checkbox.vue'
import ContentCardItem from './ContentCardItem.vue'
import type { ContentCardTableItem } from './types'
import type {
ContentCardTableItem,
ContentCardTableSortColumn,
ContentCardTableSortDirection,
} from './types'
const { formatMessage } = useVIntl()
const BUFFER_SIZE = 5
interface Props {
items: ContentCardTableItem[]
showSelection?: boolean
sortable?: boolean
sortBy?: ContentCardTableSortColumn
sortDirection?: ContentCardTableSortDirection
virtualized?: boolean
hideDelete?: boolean
hideHeader?: boolean
flat?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showSelection: false,
sortable: false,
sortBy: undefined,
sortDirection: 'asc',
virtualized: true,
hideDelete: false,
hideHeader: false,
flat: false,
})
const stickyHeaderRef = ref<HTMLElement | null>(null)
const { isStuck } = useStickyObserver(stickyHeaderRef, 'ContentCardTable')
const selectedIds = defineModel<string[]>('selectedIds', { default: () => [] })
const emit = defineEmits<{
'update:enabled': [id: string, value: boolean]
delete: [id: string]
update: [id: string]
sort: [column: ContentCardTableSortColumn, direction: ContentCardTableSortDirection]
}>()
// Virtualization state
const listContainer = ref<HTMLElement | null>(null)
const scrollContainer = ref<HTMLElement | Window | null>(null)
const scrollTop = ref(0)
const viewportHeight = ref(0)
const itemHeight = 74
// Check if any actions are available
const instance = getCurrentInstance()
const hasDeleteListener = computed(() => typeof instance?.vnode.props?.onDelete === 'function')
const hasUpdateListener = computed(() => typeof instance?.vnode.props?.onUpdate === 'function')
const hasEnabledListener = computed(
() => typeof instance?.vnode.props?.['onUpdate:enabled'] === 'function',
)
const totalHeight = computed(() => props.items.length * itemHeight)
const hasAnyActions = computed(() => {
// Check if there are listeners for actions
const hasListeners =
(hasDeleteListener.value && !props.hideDelete) ||
hasUpdateListener.value ||
hasEnabledListener.value
// Find the nearest scrollable ancestor
function findScrollableAncestor(element: HTMLElement | null): HTMLElement | Window {
if (!element) return window
// Check if any items have overflow options or updates
const hasItemActions = props.items.some(
(item) =>
(item.overflowOptions && item.overflowOptions.length > 0) ||
item.hasUpdate ||
item.enabled !== undefined,
)
let current: HTMLElement | null = element.parentElement
while (current) {
const style = getComputedStyle(current)
const overflowY = style.overflowY
const isScrollable =
(overflowY === 'auto' || overflowY === 'scroll') &&
current.scrollHeight > current.clientHeight
if (isScrollable) {
return current
}
current = current.parentElement
}
return window
}
function getScrollTop(container: HTMLElement | Window): number {
if (container instanceof Window) {
return window.scrollY
}
return container.scrollTop
}
function getViewportHeight(container: HTMLElement | Window): number {
if (container instanceof Window) {
return window.innerHeight
}
return container.clientHeight
}
function getContainerOffset(listEl: HTMLElement, container: HTMLElement | Window): number {
if (container instanceof Window) {
return listEl.getBoundingClientRect().top + window.scrollY
}
// For element containers, get the offset relative to the scroll container
const listRect = listEl.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
return listRect.top - containerRect.top + container.scrollTop
}
const visibleRange = computed(() => {
if (!props.virtualized) {
return { start: 0, end: props.items.length }
}
if (!listContainer.value || !scrollContainer.value) return { start: 0, end: 0 }
const containerOffset = getContainerOffset(listContainer.value, scrollContainer.value)
const relativeScrollTop = Math.max(0, scrollTop.value - containerOffset)
const start = Math.floor(relativeScrollTop / itemHeight)
const visibleCount = Math.ceil(viewportHeight.value / itemHeight)
return {
start: Math.max(0, start - BUFFER_SIZE),
end: Math.min(props.items.length, start + visibleCount + BUFFER_SIZE * 2),
}
return hasListeners || hasItemActions
})
const visibleTop = computed(() => (props.virtualized ? visibleRange.value.start * itemHeight : 0))
const visibleItems = computed(() =>
props.items.slice(visibleRange.value.start, visibleRange.value.end),
// Virtualization
const { listContainer, totalHeight, visibleRange, visibleTop, visibleItems } = useVirtualScroll(
toRef(props, 'items'),
{
itemHeight: 74,
bufferSize: 5,
enabled: toRef(props, 'virtualized'),
},
)
// Expose for perf monitoring
@@ -114,34 +93,6 @@ defineExpose({
visibleItems,
})
function handleScroll() {
if (scrollContainer.value) {
scrollTop.value = getScrollTop(scrollContainer.value)
}
}
function handleResize() {
if (scrollContainer.value) {
viewportHeight.value = getViewportHeight(scrollContainer.value)
}
}
onMounted(() => {
scrollContainer.value = findScrollableAncestor(listContainer.value)
viewportHeight.value = getViewportHeight(scrollContainer.value)
scrollTop.value = getScrollTop(scrollContainer.value)
scrollContainer.value.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', handleResize, { passive: true })
})
onUnmounted(() => {
if (scrollContainer.value) {
scrollContainer.value.removeEventListener('scroll', handleScroll)
}
window.removeEventListener('resize', handleResize)
})
// Selection logic
const allSelected = computed(() => {
if (props.items.length === 0) return false
@@ -153,7 +104,7 @@ const someSelected = computed(() => {
})
function toggleSelectAll() {
if (allSelected.value) {
if (allSelected.value || someSelected.value) {
selectedIds.value = []
} else {
selectedIds.value = props.items.map((item) => item.id)
@@ -173,35 +124,85 @@ function toggleItemSelection(itemId: string, selected: boolean) {
function isItemSelected(itemId: string): boolean {
return selectedIds.value.includes(itemId)
}
function handleSort(column: ContentCardTableSortColumn) {
if (!props.sortable) return
const newDirection: ContentCardTableSortDirection =
props.sortBy === column && props.sortDirection === 'asc' ? 'desc' : 'asc'
emit('sort', column, newDirection)
}
</script>
<template>
<div class="overflow-hidden rounded-[20px] border border-solid border-surface-3">
<div
class="@container border border-solid border-surface-4 shadow-sm overflow-clip"
:class="[flat ? '' : 'rounded-[20px]', isStuck || hideHeader ? 'border-t-0' : '']"
>
<div
class="grid h-12 items-center gap-4 bg-surface-3 px-4"
:class="
showSelection
? 'grid-cols-[auto_1fr_1fr] md:grid-cols-[auto_1fr_335px_1fr]'
: 'grid-cols-[1fr_1fr] md:grid-cols-[1fr_335px_1fr]'
"
v-if="!hideHeader"
ref="stickyHeaderRef"
class="sticky top-0 z-10 flex h-12 items-center justify-between gap-4 bg-surface-3 px-3"
:class="[
flat || isStuck ? 'rounded-none' : 'rounded-t-[20px]',
isStuck
? 'transition-[border-radius] duration-100 border-0 border-y border-solid border-surface-4 shadow-md before:pointer-events-none before:absolute before:inset-x-0 before:-top-4 before:h-5 before:bg-surface-3'
: '',
]"
>
<Checkbox
v-if="showSelection"
:model-value="allSelected"
:indeterminate="someSelected"
class="shrink-0"
@update:model-value="toggleSelectAll"
/>
<div
class="flex min-w-0 items-center gap-4"
:class="
hasAnyActions
? 'flex-1 @[800px]:w-[350px] @[800px]:shrink-0 @[800px]:flex-none'
: 'flex-1'
"
>
<Checkbox
v-if="showSelection"
:model-value="allSelected"
:indeterminate="someSelected"
class="shrink-0"
@update:model-value="toggleSelectAll"
/>
<span class="font-semibold text-contrast">
{{ formatMessage(commonMessages.projectLabel) }}
</span>
<button
v-if="sortable"
class="flex items-center gap-1.5 font-semibold text-secondary"
@click="handleSort('project')"
>
{{ formatMessage(commonMessages.projectLabel) }}
<ChevronUpIcon v-if="sortBy === 'project' && sortDirection === 'asc'" class="size-4" />
<ChevronDownIcon
v-else-if="sortBy === 'project' && sortDirection === 'desc'"
class="size-4"
/>
</button>
<span v-else class="font-semibold text-secondary">{{
formatMessage(commonMessages.projectLabel)
}}</span>
</div>
<span class="hidden font-semibold text-secondary md:block">
{{ formatMessage(commonMessages.versionLabel) }}
</span>
<div class="hidden @[800px]:flex" :class="hasAnyActions ? 'w-[335px] min-w-0' : 'flex-1'">
<button
v-if="sortable"
class="flex items-center gap-1.5 font-semibold text-secondary"
@click="handleSort('version')"
>
{{ formatMessage(commonMessages.versionLabel) }}
<ChevronUpIcon v-if="sortBy === 'version' && sortDirection === 'asc'" class="size-4" />
<ChevronDownIcon
v-else-if="sortBy === 'version' && sortDirection === 'desc'"
class="size-4"
/>
</button>
<span v-else class="font-semibold text-secondary">{{
formatMessage(commonMessages.versionLabel)
}}</span>
</div>
<div class="text-right">
<div v-if="hasAnyActions" class="min-w-[160px] shrink-0 text-right">
<span class="font-semibold text-secondary">{{
formatMessage(commonMessages.actionsLabel)
}}</span>
@@ -211,7 +212,8 @@ function isItemSelected(itemId: string): boolean {
<div
v-if="items.length > 0 && virtualized"
ref="listContainer"
class="relative w-full rounded-b-[20px]"
class="relative w-full"
:class="flat ? '' : 'rounded-b-[20px]'"
:style="{ minHeight: `${totalHeight}px` }"
>
<div class="absolute w-full" :style="{ top: `${visibleTop}px` }">
@@ -222,17 +224,20 @@ function isItemSelected(itemId: string): boolean {
:project="item.project"
:project-link="item.projectLink"
:version="item.version"
:version-link="item.versionLink"
:owner="item.owner"
:enabled="item.enabled"
:has-update="item.hasUpdate"
:overflow-options="item.overflowOptions"
:disabled="item.disabled"
:show-checkbox="showSelection"
:hide-delete="hideDelete"
:hide-actions="!hasAnyActions"
:selected="isItemSelected(item.id)"
:class="[
(visibleRange.start + idx) % 2 === 1 ? 'bg-surface-1.5' : 'bg-surface-2',
'border-t border-solid border-[1px] border-surface-3',
visibleRange.start + idx === items.length - 1 ? 'rounded-b-[20px] !border-none' : '',
'border-0 border-t border-solid border-surface-4',
visibleRange.start + idx === items.length - 1 && !flat ? 'rounded-b-[20px]' : '',
]"
@update:selected="(val) => toggleItemSelection(item.id, val ?? false)"
@update:enabled="(val) => emit('update:enabled', item.id, val)"
@@ -249,7 +254,7 @@ function isItemSelected(itemId: string): boolean {
</div>
</div>
<div v-else-if="items.length > 0" ref="listContainer" class="rounded-b-[20px]">
<div v-else-if="items.length > 0" ref="listContainer" :class="flat ? '' : 'rounded-b-[20px]'">
<ContentCardItem
v-for="(item, index) in items"
:key="item.id"
@@ -257,17 +262,20 @@ function isItemSelected(itemId: string): boolean {
:project="item.project"
:project-link="item.projectLink"
:version="item.version"
:version-link="item.versionLink"
:owner="item.owner"
:enabled="item.enabled"
:has-update="item.hasUpdate"
:overflow-options="item.overflowOptions"
:disabled="item.disabled"
:show-checkbox="showSelection"
:hide-delete="hideDelete"
:hide-actions="!hasAnyActions"
:selected="isItemSelected(item.id)"
:class="[
index % 2 === 1 ? 'bg-surface-1.5' : 'bg-surface-2',
'border-t border-solid border-surface-3',
index === items.length - 1 ? 'rounded-b-[20px] !border-none' : '',
'border-0 border-t border-solid border-surface-4',
index === items.length - 1 && !flat ? 'rounded-b-[20px]' : '',
]"
@update:selected="(val) => toggleItemSelection(item.id, val ?? false)"
@update:enabled="(val) => emit('update:enabled', item.id, val)"
@@ -283,7 +291,11 @@ function isItemSelected(itemId: string): boolean {
</ContentCardItem>
</div>
<div v-else class="flex items-center justify-center rounded-b-[20px] py-12">
<div
v-else
class="flex items-center justify-center py-12"
:class="flat ? '' : 'rounded-b-[20px]'"
>
<slot name="empty">
<span class="text-secondary">{{ formatMessage(commonMessages.noItemsLabel) }}</span>
</slot>

View File

@@ -7,6 +7,7 @@ export { default as ContentCardTable } from './ContentCardTable.vue'
export { default as ContentCard } from './ContentCardItem.vue'
export { default as ContentModpackCard } from './ContentModpackCard.vue'
// export { default as ContentUpdaterModal } from './modals/ContentUpdaterModal.vue'
export { default as ModpackContentModal } from './modals/ModpackContentModal.vue'
export type {
ContentCardProject,
ContentCardTableItem,

View File

@@ -0,0 +1,505 @@
<script setup lang="ts">
import {
BoxIcon,
FilterIcon,
GlassesIcon,
PaintbrushIcon,
PowerIcon,
PowerOffIcon,
SearchIcon,
SpinnerIcon,
} from '@modrinth/assets'
import { formatProjectType } from '@modrinth/utils'
import Fuse from 'fuse.js'
import { computed, nextTick, ref, watchSyncEffect } from 'vue'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils/common-messages'
import Avatar from '../../base/Avatar.vue'
import BulletDivider from '../../base/BulletDivider.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import Checkbox from '../../base/Checkbox.vue'
import FloatingActionBar from '../../base/FloatingActionBar.vue'
import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue'
import ContentCardTable from '../ContentCardTable.vue'
import type { ContentCardTableItem, ContentItem } from '../types'
const { formatMessage } = useVIntl()
interface Props {
modpackName?: string
modpackIconUrl?: string
enableToggle?: boolean
}
const props = withDefaults(defineProps<Props>(), {
modpackName: undefined,
modpackIconUrl: undefined,
enableToggle: false,
})
const emit = defineEmits<{
'update:enabled': [item: ContentItem, value: boolean]
}>()
const messages = defineMessages({
header: {
id: 'instances.modpack-content-modal.header',
defaultMessage: 'Modpack content',
},
searchPlaceholder: {
id: 'instances.modpack-content-modal.search-placeholder',
defaultMessage: 'Search {count} projects',
},
loading: {
id: 'instances.modpack-content-modal.loading',
defaultMessage: 'Loading content...',
},
emptyTitle: {
id: 'instances.modpack-content-modal.empty-title',
defaultMessage: 'No content found',
},
emptyDescription: {
id: 'instances.modpack-content-modal.empty-description',
defaultMessage: 'This modpack does not include any additional content.',
},
noResults: {
id: 'instances.modpack-content-modal.no-results',
defaultMessage: 'No projects match your search.',
},
backButton: {
id: 'instances.modpack-content-modal.back-button',
defaultMessage: 'Back',
},
allFilter: {
id: 'instances.modpack-content-modal.filter-all',
defaultMessage: 'All',
},
copyLink: {
id: 'instances.modpack-content-modal.copy-link',
defaultMessage: 'Copy link',
},
selectedCount: {
id: 'instances.modpack-content-modal.selected-count',
defaultMessage: '{count} selected',
},
enable: {
id: 'instances.modpack-content-modal.enable',
defaultMessage: 'Enable',
},
disable: {
id: 'instances.modpack-content-modal.disable',
defaultMessage: 'Disable',
},
})
export interface ModpackContentModalState {
items: ContentItem[]
searchQuery: string
selectedFilters: string[]
scrollTop: number
}
const modal = ref<InstanceType<typeof NewModal>>()
const scrollContainer = ref<HTMLElement | null>(null)
const items = ref<ContentItem[]>([])
const loading = ref(false)
const searchQuery = ref('')
const selectedFilters = ref<string[]>([])
const selectedIds = ref<string[]>([])
const selectedItems = computed(() =>
items.value.filter((item) => selectedIds.value.includes(item.file_name)),
)
const allSelected = computed(() => {
if (filteredItems.value.length === 0) return false
return filteredItems.value.every((item) => selectedIds.value.includes(item.file_name))
})
const someSelected = computed(() => {
return (
filteredItems.value.some((item) => selectedIds.value.includes(item.file_name)) &&
!allSelected.value
)
})
function toggleSelectAll() {
if (allSelected.value || someSelected.value) {
selectedIds.value = []
} else {
selectedIds.value = filteredItems.value.map((item) => item.file_name)
}
}
const fuse = new Fuse<ContentItem>([], {
keys: ['project.title', 'owner.name', 'file_name'],
threshold: 0.4,
distance: 100,
})
watchSyncEffect(() => fuse.setCollection(items.value))
const filterOptions = computed(() => {
const frequency = items.value.reduce(
(map, item) => {
map[item.project_type] = (map[item.project_type] || 0) + 1
return map
},
{} as Record<string, number>,
)
// Sort by frequency (most common first)
return Object.entries(frequency)
.sort(([, a], [, b]) => b - a)
.map(([type]) => ({
id: type,
label: formatProjectType(type) + 's',
}))
})
const stats = computed(() => {
const counts: Record<string, number> = {}
for (const item of items.value) {
counts[item.project_type] = (counts[item.project_type] || 0) + 1
}
return counts
})
function toggleFilter(filterId: string) {
const index = selectedFilters.value.indexOf(filterId)
if (index === -1) {
selectedFilters.value.push(filterId)
} else {
selectedFilters.value.splice(index, 1)
}
}
const typeFilteredCount = computed(() => {
if (selectedFilters.value.length === 0) return items.value.length
return items.value.filter((item) => selectedFilters.value.includes(item.project_type)).length
})
const filteredItems = computed(() => {
const query = searchQuery.value.trim()
let result: ContentItem[]
if (query) {
result = fuse.search(query).map(({ item }) => item)
} else {
result = [...items.value].sort((a, b) => {
const nameA = a.project?.title ?? a.file_name
const nameB = b.project?.title ?? b.file_name
return nameA.toLowerCase().localeCompare(nameB.toLowerCase())
})
}
// Apply type filters
if (selectedFilters.value.length > 0) {
result = result.filter((item) => selectedFilters.value.includes(item.project_type))
}
return result
})
const tableItems = computed<ContentCardTableItem[]>(() =>
filteredItems.value.map((item) => ({
id: item.file_name,
project: item.project ?? {
id: item.file_name,
slug: null,
title: item.file_name,
icon_url: null,
},
projectLink: item.project?.id ? `/project/${item.project.id}` : undefined,
version: item.version ?? {
id: item.file_name,
version_number: 'Unknown',
file_name: item.file_name,
},
owner: item.owner
? {
...item.owner,
link: `https://modrinth.com/${item.owner.type}/${item.owner.id}`,
}
: undefined,
...(props.enableToggle ? { enabled: item.enabled } : {}),
})),
)
function getTypeIcon(type: string) {
switch (type) {
case 'mod':
return BoxIcon
case 'shaderpack':
case 'shader':
return GlassesIcon
case 'resourcepack':
return PaintbrushIcon
default:
return BoxIcon
}
}
function handleEnabledChange(fileName: string, value: boolean) {
const item = items.value.find((i) => i.file_name === fileName)
if (!item) return
emit('update:enabled', item, value)
}
function bulkEnable() {
for (const item of selectedItems.value) {
emit('update:enabled', item, true)
}
selectedIds.value = []
}
function bulkDisable() {
for (const item of selectedItems.value) {
emit('update:enabled', item, false)
}
selectedIds.value = []
}
function show(contentItems: ContentItem[]) {
items.value = contentItems
searchQuery.value = ''
selectedFilters.value = []
selectedIds.value = []
loading.value = false
modal.value?.show()
}
function showLoading() {
items.value = []
searchQuery.value = ''
selectedFilters.value = []
selectedIds.value = []
loading.value = true
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function getState(): ModpackContentModalState | null {
if (!items.value.length) return null
return {
items: items.value,
searchQuery: searchQuery.value,
selectedFilters: [...selectedFilters.value],
scrollTop: scrollContainer.value?.scrollTop ?? 0,
}
}
async function restore(state: ModpackContentModalState) {
items.value = state.items
searchQuery.value = state.searchQuery
selectedFilters.value = state.selectedFilters
loading.value = false
modal.value?.show()
await nextTick()
if (scrollContainer.value) {
scrollContainer.value.scrollTop = state.scrollTop
}
}
defineExpose({ show, showLoading, hide, getState, restore })
</script>
<template>
<NewModal
ref="modal"
:max-width="'min(928px, calc(95vw - 10rem))'"
:width="'min(928px, calc(95vw - 10rem))'"
no-padding
>
<template #title>
<Avatar
v-if="props.modpackIconUrl"
:src="props.modpackIconUrl"
size="3rem"
:tint-by="props.modpackName"
/>
<span class="text-lg font-extrabold text-contrast">
{{ formatMessage(messages.header) }}
</span>
</template>
<div class="flex flex-col h-[min(600px,calc(95vh-10rem))]">
<div class="flex flex-col gap-4 px-6 py-4 border-b border-solid border-0 border-surface-4">
<StyledInput
v-model="searchQuery"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchPlaceholder, { count: typeFilteredCount })"
clearable
/>
<!-- Filters -->
<div v-if="filterOptions.length > 1" class="flex items-center gap-2">
<FilterIcon class="size-5 text-secondary shrink-0" />
<div class="flex flex-wrap items-center gap-1.5">
<button
class="rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-colors"
:class="
selectedFilters.length === 0
? 'border-green bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
"
@click="selectedFilters = []"
>
{{ formatMessage(messages.allFilter) }}
</button>
<button
v-for="option in filterOptions"
:key="option.id"
class="rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-colors"
:class="
selectedFilters.includes(option.id)
? 'border-green bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
"
@click="toggleFilter(option.id)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<!-- Content area -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<!-- Loading state -->
<div
v-if="loading"
class="flex flex-col items-center justify-center flex-1 gap-2 text-secondary"
>
<SpinnerIcon class="size-8 animate-spin" />
<span class="text-sm">{{ formatMessage(messages.loading) }}</span>
</div>
<!-- Empty state -->
<div
v-else-if="items.length === 0"
class="flex flex-col items-center justify-center flex-1 gap-2 text-center p-8"
>
<span class="text-xl font-semibold text-contrast">
{{ formatMessage(messages.emptyTitle) }}
</span>
<span class="text-secondary">{{ formatMessage(messages.emptyDescription) }}</span>
</div>
<!-- No search results -->
<div
v-else-if="filteredItems.length === 0"
class="flex flex-col items-center justify-center flex-1 gap-2 text-center p-8"
>
<span class="text-secondary">{{ formatMessage(messages.noResults) }}</span>
</div>
<!-- Content table -->
<div v-else class="@container flex-1 min-h-0 flex flex-col">
<div
class="flex h-12 shrink-0 items-center justify-between gap-4 border-0 border-b border-solid border-surface-4 bg-surface-3 px-3"
>
<div
class="flex min-w-0 items-center gap-4"
:class="
props.enableToggle
? 'flex-1 @[800px]:w-[350px] @[800px]:shrink-0 @[800px]:flex-none'
: 'flex-1'
"
>
<Checkbox
v-if="props.enableToggle"
:model-value="allSelected"
:indeterminate="someSelected"
class="shrink-0"
@update:model-value="toggleSelectAll"
/>
<span class="font-semibold text-secondary">{{
formatMessage(commonMessages.projectLabel)
}}</span>
</div>
<div
class="hidden @[800px]:flex"
:class="props.enableToggle ? 'w-[335px] min-w-0' : 'flex-1'"
>
<span class="font-semibold text-secondary">{{
formatMessage(commonMessages.versionLabel)
}}</span>
</div>
<div v-if="props.enableToggle" class="min-w-[160px] shrink-0 text-right">
<span class="font-semibold text-secondary">{{
formatMessage(commonMessages.actionsLabel)
}}</span>
</div>
</div>
<div ref="scrollContainer" class="flex-1 min-h-0 overflow-y-auto">
<ContentCardTable
v-model:selected-ids="selectedIds"
:items="tableItems"
:show-selection="props.enableToggle"
hide-delete
hide-header
flat
@update:enabled="(id, val) => handleEnabledChange(id, val)"
/>
</div>
</div>
</div>
<!-- Footer -->
<div
class="flex items-center justify-between px-6 py-4 border-t border-solid border-0 border-surface-4 shrink-0"
>
<!-- Stats -->
<div class="flex items-center gap-2">
<template v-for="(count, type, idx) in stats" :key="type">
<BulletDivider v-if="idx > 0" />
<div class="flex items-center gap-1.5">
<component :is="getTypeIcon(type as string)" class="size-5 text-secondary" />
<span class="font-medium text-primary">
{{ count }} {{ formatProjectType(type as string) }}{{ count !== 1 ? 's' : '' }}
</span>
</div>
</template>
</div>
</div>
</div>
<FloatingActionBar
v-if="props.enableToggle"
:shown="selectedItems.length > 0"
style="--left-bar-width: 0px; --right-bar-width: 0px"
>
<div class="flex items-center gap-0.5">
<span class="px-4 py-2.5 text-base font-semibold text-contrast">
{{ formatMessage(messages.selectedCount, { count: selectedItems.length }) }}
</span>
<div class="mx-1 h-6 w-px bg-surface-5" />
<ButtonStyled type="transparent">
<button class="!text-primary" @click="selectedIds = []">
{{ formatMessage(commonMessages.clearButton) }}
</button>
</ButtonStyled>
</div>
<div class="ml-auto flex items-center gap-0.5">
<ButtonStyled v-if="selectedItems.every((m) => !m.enabled)" type="transparent">
<button @click="bulkEnable">
<PowerIcon />
{{ formatMessage(messages.enable) }}
</button>
</ButtonStyled>
<ButtonStyled v-else type="transparent">
<button @click="bulkDisable">
<PowerOffIcon />
{{ formatMessage(messages.disable) }}
</button>
</ButtonStyled>
</div>
</FloatingActionBar>
</NewModal>
</template>

View File

@@ -10,6 +10,7 @@ export type ContentCardProject = Pick<
export type ContentCardVersion = Pick<Labrinth.Versions.v2.Version, 'id' | 'version_number'> & {
file_name: string
date_published?: string
}
export interface ContentOwner {
@@ -25,6 +26,7 @@ export interface ContentCardTableItem {
project: ContentCardProject
projectLink?: string | RouteLocationRaw
version?: ContentCardVersion
versionLink?: string | RouteLocationRaw
owner?: ContentOwner
enabled?: boolean
disabled?: boolean
@@ -32,6 +34,24 @@ export interface ContentCardTableItem {
overflowOptions?: OverflowMenuOption[]
}
export type ContentCardTableSortColumn = 'project' | 'version'
export type ContentCardTableSortDirection = 'asc' | 'desc'
/** Content item returned from the app backend API - maps to ContentCardTableItem for display */
export interface ContentItem extends Omit<
ContentCardTableItem,
'id' | 'projectLink' | 'disabled' | 'overflowOptions'
> {
file_name: string
file_path?: string
hash?: string
size?: number
project_type: string
has_update: boolean
update_version_id: string | null
date_added?: string
}
export type ContentModpackCardProject = Pick<
Labrinth.Projects.v2.Project,
'id' | 'slug' | 'title' | 'icon_url' | 'description' | 'downloads' | 'followers'

View File

@@ -119,7 +119,6 @@
</div>
</div>
</div>
<div v-else></div>
</template>
<script setup lang="ts">

View File

@@ -0,0 +1,331 @@
<template>
<div v-if="open" class="open-in-app-modal">
<div :class="{ shown: visible }" class="fullscreen-overlay" @click="hide" />
<div class="modal-content" :class="{ shown: visible }">
<div class="flex flex-col items-center gap-6">
<div class="flex flex-col gap-6">
<div v-if="countdown > 0" class="countdown-container">
<svg class="countdown-svg" viewBox="0 0 100 100">
<circle
class="stroke-surface-4"
cx="50"
cy="50"
r="45"
fill="none"
stroke-width="6"
/>
<circle
class="countdown-progress"
cx="50"
cy="50"
r="45"
fill="none"
stroke-width="6"
:stroke-dasharray="circumference"
:stroke-dashoffset="strokeDashoffset"
stroke-linecap="round"
/>
</svg>
<span class="countdown-number">{{ countdown }}</span>
</div>
<h2 class="m-0 text-3xl font-bold text-contrast text-center">
{{ formatMessage(messages.openingApp) }}
</h2>
<div
class="flex flex-col items-center gap-4 bg-surface-3 rounded-3xl border border-solid border-surface-5 p-6"
>
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3 w-full">
<Avatar :src="serverProject.icon" :alt="serverProject.name" size="48px" />
<div class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ serverProject.name }}</span>
<div class="flex items-center gap-2 text-secondary">
<ServerOnlinePlayers
:online="serverProject.numPlayers ?? 0"
:status-online="serverProject.statusOnline"
/>
<ServerRegion v-if="serverProject.region" :region="serverProject.region" />
</div>
</div>
</div>
<div class="flex flex-col text-left gap-3">
<span class="font-semibold text-contrast">{{
formatMessage(messages.whyUseApp)
}}</span>
<div class="flex flex-col gap-2">
<div class="flex text-base gap-2 items-center">
<div
class="w-5 h-5 border border-solid rounded-full flex items-center justify-center border-brand bg-brand-highlight text-brand"
>
<CheckIcon />
</div>
<span>{{ formatMessage(messages.benefitLaunch) }}</span>
</div>
<div class="flex text-base gap-2 items-center">
<div
class="w-5 h-5 border border-solid rounded-full flex items-center justify-center border-brand bg-brand-highlight text-brand"
>
<CheckIcon />
</div>
<span>{{ formatMessage(messages.benefitInstall) }}</span>
</div>
<div class="flex text-base gap-2 items-center">
<div
class="w-5 h-5 border border-solid rounded-full flex items-center justify-center border-brand bg-brand-highlight text-brand"
>
<CheckIcon />
</div>
<span>{{ formatMessage(messages.benefitUpdate) }}</span>
</div>
</div>
</div>
</div>
</div>
<span v-if="countdown > 0" class="text-secondary">{{
formatMessage(messages.openingAutomatically)
}}</span>
<div v-else class="grid grid-cols-2 gap-2 w-full">
<ButtonStyled class="flex-1">
<button @click="hide">
<XIcon />
{{ formatMessage(messages.close) }}
</button>
</ButtonStyled>
<ButtonStyled color="green" class="flex-1">
<a href="https://modrinth.com/app" target="_blank" rel="noopener noreferrer">
<DownloadIcon />
{{ formatMessage(messages.getApp) }}
</a>
</ButtonStyled>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckIcon, DownloadIcon, XIcon } from '@modrinth/assets'
import { computed, nextTick, onUnmounted, ref } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { Avatar, ButtonStyled } from '../base'
import ServerOnlinePlayers from '../project/server/ServerOnlinePlayers.vue'
import ServerRegion from '../project/server/ServerRegion.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
openingApp: {
id: 'modal.open-in-app.title',
defaultMessage: 'Opening Modrinth App',
},
whyUseApp: {
id: 'modal.open-in-app.why-use',
defaultMessage: 'Why use the Modrinth App',
},
benefitLaunch: {
id: 'modal.open-in-app.benefit.launch',
defaultMessage: 'Launch the game straight into the server',
},
benefitInstall: {
id: 'modal.open-in-app.benefit.install',
defaultMessage: 'Automatically install required content',
},
benefitUpdate: {
id: 'modal.open-in-app.benefit.update',
defaultMessage: 'Keep files updated when the server changes',
},
openingAutomatically: {
id: 'modal.open-in-app.opening-automatically',
defaultMessage: 'The Modrinth App will open automatically...',
},
close: {
id: 'modal.open-in-app.close',
defaultMessage: 'Close',
},
getApp: {
id: 'modal.open-in-app.get-app',
defaultMessage: 'Get Modrinth App',
},
})
export interface ServerProject {
name: string
slug?: string
numPlayers?: number
icon?: string
statusOnline?: boolean
region?: string
}
const open = ref(false)
const visible = ref(false)
const countdown = ref(3)
const countdownProgress = ref(1)
let countdownInterval: ReturnType<typeof setInterval> | null = null
let progressInterval: ReturnType<typeof setInterval> | null = null
const circumference = 2 * Math.PI * 45
const strokeDashoffset = computed(() => {
return circumference * (1 - countdownProgress.value)
})
const serverProject = ref<ServerProject>({
name: '',
slug: '',
numPlayers: 0,
icon: undefined,
statusOnline: false,
region: '',
})
const appLink = computed(() => {
return `modrinth://server/${serverProject.value.slug}`
})
function startCountdown() {
countdown.value = 3
countdownProgress.value = 1
const totalDuration = 3000
const progressUpdateInterval = 16
const progressDecrement = progressUpdateInterval / totalDuration
progressInterval = setInterval(() => {
countdownProgress.value = Math.max(0, countdownProgress.value - progressDecrement)
}, progressUpdateInterval)
countdownInterval = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
stopCountdown()
}
}, 1000)
}
function stopCountdown() {
if (countdownInterval) {
clearInterval(countdownInterval)
countdownInterval = null
}
if (progressInterval) {
clearInterval(progressInterval)
progressInterval = null
}
}
interface ShowOpenInAppOptions {
serverProject: ServerProject
}
async function show(options: ShowOpenInAppOptions) {
serverProject.value = options.serverProject
await nextTick()
window.open(appLink.value, '_self')
open.value = true
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleKeyDown)
setTimeout(() => {
visible.value = true
startCountdown()
}, 50)
}
function hide() {
visible.value = false
document.body.style.overflow = ''
window.removeEventListener('keydown', handleKeyDown)
stopCountdown()
setTimeout(() => {
open.value = false
}, 300)
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
hide()
}
}
onUnmounted(() => {
stopCountdown()
})
defineExpose({ show, hide, open })
</script>
<style lang="scss" scoped>
.open-in-app-modal {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
justify-content: center;
align-items: center;
}
.fullscreen-overlay {
position: fixed;
inset: 0;
background: linear-gradient(to bottom, rgba(66, 131, 92, 0.23) 0%, rgba(17, 35, 43, 0.4) 97%);
backdrop-filter: blur(12px);
opacity: 0;
transition: opacity 0.3s ease-out;
cursor: pointer;
&.shown {
opacity: 1;
}
}
.modal-content {
position: relative;
z-index: 1;
padding: 2.5rem;
opacity: 0;
transform: scale(0.95);
transition: all 0.3s ease-out;
&.shown {
opacity: 1;
transform: scale(1);
}
}
.countdown-container {
position: relative;
width: 120px;
height: 120px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.countdown-svg {
position: absolute;
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.countdown-bg {
stroke: var(--surface-4);
}
.countdown-progress {
stroke: var(--color-green);
transition: stroke-dashoffset 0.05s linear;
}
.countdown-number {
font-size: 3rem;
font-weight: 700;
color: var(--color-contrast);
z-index: 1;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { type Component, nextTick, ref } from 'vue'
import { type Component, computed, nextTick, ref } from 'vue'
import { type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { useScrollIndicator } from '../../composables/scroll-indicator'
@@ -12,13 +12,16 @@ export type Tab<Props> = {
content: Component<Props>
props?: Props
badge?: MessageDescriptor
shown?: boolean
}
defineProps<{
const props = defineProps<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tabs: Tab<any>[]
}>()
const visibleTabs = computed(() => props.tabs.filter((tab) => tab.shown !== false))
const selectedTab = ref(0)
const scrollContainer = ref<HTMLElement | null>(null)
@@ -38,7 +41,7 @@ defineExpose({ selectedTab, setTab })
class="flex flex-col gap-1 border-solid pr-4 border-0 border-r-[1px] border-divider min-w-[200px]"
>
<button
v-for="(tab, index) in tabs"
v-for="(tab, index) in visibleTabs"
:key="index"
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-all ${selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
@click="() => setTab(index)"
@@ -75,7 +78,10 @@ defineExpose({ selectedTab, setTab })
class="w-[600px] h-[500px] overflow-y-auto px-4"
@scroll="checkScrollState"
>
<component :is="tabs[selectedTab].content" v-bind="tabs[selectedTab].props ?? {}" />
<component
:is="visibleTabs[selectedTab].content"
v-bind="visibleTabs[selectedTab].props ?? {}"
/>
</div>
<Transition

View File

@@ -1,6 +1,8 @@
export { default as ConfirmModal } from './ConfirmModal.vue'
export { default as Modal } from './Modal.vue'
export { default as NewModal } from './NewModal.vue'
export type { ServerProject as OpenInAppModalServerProject } from './OpenInAppModal.vue'
export { default as OpenInAppModal } from './OpenInAppModal.vue'
export { default as ShareModal } from './ShareModal.vue'
export type { Tab as TabbedModalTab } from './TabbedModal.vue'
export { default as TabbedModal } from './TabbedModal.vue'

View File

@@ -0,0 +1,145 @@
<template>
<div
class="popup-notification-group experimental-styles-within"
:class="{
'has-sidebar': hasSidebar,
}"
>
<transition-group name="popup-notifs">
<div
v-for="item in notifications"
:key="item.id"
class="popup-notification-wrapper"
@mouseenter="stopTimer(item)"
@mouseleave="setNotificationTimer(item)"
>
<div
class="flex w-full flex-col gap-3 overflow-hidden rounded-2xl bg-bg-raised shadow-xl border-surface-5 border-solid border p-4"
>
<div class="flex flex-col gap-2 w-full">
<div class="flex items-center justify-between gap-2.5">
<div class="flex items-center gap-2">
<div
class="flex items-center"
:class="{
'text-red': item.type === 'error',
'text-orange': item.type === 'warning',
'text-contrast': item.type === 'success',
'text-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
}"
>
<IssuesIcon v-if="item.type === 'warning'" class="h-5 w-5" />
<CheckCircleIcon v-else-if="item.type === 'success'" class="h-5 w-5" />
<XCircleIcon v-else-if="item.type === 'error'" class="h-5 w-5" />
<InfoIcon v-else class="h-5 w-5" />
</div>
<div class="text-contrast font-semibold m-0 grow">
{{ item.title }}
</div>
</div>
<ButtonStyled size="small" type="transparent" circular>
<button @click="dismiss(item.id)">
<XIcon />
</button>
</ButtonStyled>
</div>
<span v-if="item.text" class="text-primary">
{{ item.text }}
</span>
</div>
<div v-if="item.buttons?.length" class="flex gap-1.5">
<ButtonStyled
v-for="(btn, idx) in item.buttons"
:key="idx"
:color="btn.color || (idx === 0 ? 'brand' : undefined)"
>
<button @click="handleButtonClick(item.id, btn)">
{{ btn.label }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</transition-group>
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon, InfoIcon, IssuesIcon, XCircleIcon, XIcon } from '@modrinth/assets'
import { computed } from 'vue'
import {
injectPopupNotificationManager,
type PopupNotification,
type PopupNotificationButton,
} from '../../providers'
import ButtonStyled from '../base/ButtonStyled.vue'
const popupNotificationManager = injectPopupNotificationManager()
const notifications = computed<PopupNotification[]>(() =>
popupNotificationManager.getNotifications(),
)
const stopTimer = (n: PopupNotification) => popupNotificationManager.stopNotificationTimer(n)
const setNotificationTimer = (n: PopupNotification) =>
popupNotificationManager.setNotificationTimer(n)
const dismiss = (id: string | number) => popupNotificationManager.removeNotification(id)
function handleButtonClick(id: string | number, btn: PopupNotificationButton) {
btn.action()
popupNotificationManager.removeNotification(id)
}
withDefaults(
defineProps<{
hasSidebar?: boolean
}>(),
{
hasSidebar: false,
},
)
</script>
<style scoped>
.popup-notification-group {
position: fixed;
top: calc(var(--top-bar-height, 3rem) + 1.5rem);
right: 1.5rem;
z-index: 200;
width: 400px;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.popup-notification-group.has-sidebar {
right: calc(var(--right-bar-width, 0px) + 1.5rem);
}
@media screen and (max-width: 500px) {
.popup-notification-group {
width: calc(100% - 1.5rem);
right: 0.75rem;
}
}
.popup-notification-group .popup-notification-wrapper {
width: 100%;
}
.popup-notifs-enter-active,
.popup-notifs-leave-active,
.popup-notifs-move {
transition: all 0.3s ease-in-out;
}
.popup-notifs-enter-from {
opacity: 0;
transform: translateX(100%) scale(0.8);
}
.popup-notifs-leave-to {
opacity: 0;
transform: translateX(100%) scale(0.8);
}
</style>

View File

@@ -1,3 +1,4 @@
export { default as Breadcrumbs } from './Breadcrumbs.vue'
export { default as NotificationPanel } from './NotificationPanel.vue'
export { default as PagewideBanner } from './PagewideBanner.vue'
export { default as PopupNotificationPanel } from './PopupNotificationPanel.vue'

View File

@@ -14,6 +14,7 @@
</template>
<script lang="ts" setup>
import { PackageIcon } from '@modrinth/assets'
import { useDebounceFn } from '@vueuse/core'
import { defineAsyncComponent, h, ref, watch } from 'vue'
@@ -54,6 +55,8 @@ const props = withDefaults(
disabled?: boolean
/** Maximum number of results to show */
limit?: number
/** Project IDs to exclude from results */
excludeProjectIds?: string[]
}>(),
{
placeholder: 'Select project',
@@ -75,6 +78,25 @@ const searchResultsCache = ref<Map<string, SearchHit>>(new Map())
const { labrinth } = injectModrinthClient()
function hitToOption(hit: SearchHit): ComboboxOption<string> {
return {
label: hit.title,
value: hit.project_id,
icon: hit.icon_url
? defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: hit.icon_url,
alt: hit.title,
class: 'h-5 w-5 rounded',
}),
}),
)
: PackageIcon,
}
}
// Watch for external changes to projectId to update selectedProject
watch(
projectId,
@@ -84,26 +106,32 @@ watch(
return
}
let hit: SearchHit | null = null
if (searchResultsCache.value.has(newId)) {
selectedProject.value = searchResultsCache.value.get(newId) || null
return
hit = searchResultsCache.value.get(newId) || null
} else {
try {
const project = await labrinth.projects_v2.get(newId)
if (project) {
hit = {
project_id: project.id,
title: project.title,
icon_url: project.icon_url ?? undefined,
project_type: project.project_type,
slug: project.slug,
}
searchResultsCache.value.set(project.id, hit)
}
} catch {
selectedProject.value = null
return
}
}
try {
const project = await labrinth.projects_v2.get(newId)
if (project) {
const hit: SearchHit = {
project_id: project.id,
title: project.title,
icon_url: project.icon_url ?? undefined,
project_type: project.project_type,
slug: project.slug,
}
searchResultsCache.value.set(project.id, hit)
selectedProject.value = hit
}
} catch {
selectedProject.value = null
selectedProject.value = hit
if (hit && !options.value.some((o) => o.value === hit!.project_id)) {
options.value = [hitToOption(hit), ...options.value]
}
},
{ immediate: true },
@@ -134,10 +162,11 @@ const search = async (query: string) => {
const allHits = [...resultsByProjectId.hits, ...results.hits]
const seenIds = new Set<string>()
const excludeSet = new Set(props.excludeProjectIds ?? [])
const uniqueHits: SearchHit[] = []
for (const hit of allHits) {
if (!seenIds.has(hit.project_id)) {
if (!seenIds.has(hit.project_id) && !excludeSet.has(hit.project_id)) {
seenIds.add(hit.project_id)
uniqueHits.push(hit)
// Cache the hit for later lookup
@@ -145,20 +174,7 @@ const search = async (query: string) => {
}
}
options.value = uniqueHits.map((hit) => ({
label: hit.title,
value: hit.project_id,
icon: defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: hit.icon_url,
alt: hit.title,
class: 'h-5 w-5 rounded',
}),
}),
),
}))
options.value = uniqueHits.map(hitToOption)
} catch (error: unknown) {
const err = error as { data?: { description?: string } }
addNotification({

View File

@@ -13,45 +13,47 @@
{{ project.description }}
</template>
<template #stats>
<div
v-tooltip="
capitalizeString(
formatMessage(commonMessages.projectDownloads, {
count: formatNumber(project.downloads, false),
}),
)
"
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold cursor-help"
>
<DownloadIcon class="h-6 w-6 text-secondary" />
{{ formatNumber(project.downloads) }}
</div>
<div
v-tooltip="
capitalizeString(
formatMessage(commonMessages.projectFollowers, {
count: formatNumber(project.followers, false),
}),
)
"
class="flex items-center gap-2 border-0 border-solid border-divider pr-4 cursor-help"
:class="{ 'md:border-r': project.categories.length > 0 }"
>
<HeartIcon class="h-6 w-6 text-secondary" />
<span class="font-semibold">
{{ formatNumber(project.followers) }}
</span>
</div>
<div v-if="project.categories.length > 0" class="hidden items-center gap-2 md:flex">
<TagsIcon class="h-6 w-6 text-secondary" />
<div class="flex flex-wrap gap-2">
<TagItem
v-for="(category, index) in project.categories"
:key="index"
:action="() => router.push(`/${project.project_type}s?f=categories:${category}`)"
>
<FormattedTag :tag="category" />
</TagItem>
<div class="flex items-center gap-3">
<div
v-tooltip="
capitalizeString(
formatMessage(commonMessages.projectDownloads, {
count: formatNumber(project.downloads, false),
}),
)
"
class="flex items-center gap-2 font-semibold cursor-help"
>
<DownloadIcon class="h-6 w-6 text-secondary" />
{{ formatNumber(project.downloads) }}
</div>
<div
v-tooltip="
capitalizeString(
formatMessage(commonMessages.projectFollowers, {
count: formatNumber(project.followers, false),
}),
)
"
class="flex items-center gap-2 cursor-help"
:class="{ 'md:border-r': project.categories.length > 0 }"
>
<HeartIcon class="h-6 w-6 text-secondary" />
<span class="font-semibold">
{{ formatNumber(project.followers) }}
</span>
</div>
<div v-if="project.categories.length > 0" class="hidden items-center gap-2 md:flex">
<TagsIcon class="h-6 w-6 text-secondary" />
<div class="flex flex-wrap gap-2">
<TagItem
v-for="(category, index) in project.categories"
:key="index"
:action="() => router.push(`/${project.project_type}s?f=categories:${category}`)"
>
<FormattedTag :tag="category" />
</TagItem>
</div>
</div>
</div>
</template>

View File

@@ -180,7 +180,7 @@
class="z-[1] flex cursor-help items-center gap-1 text-nowrap font-medium xl:self-center"
>
<CalendarIcon class="xl:hidden" />
{{ formatRelativeTime(version.date_published) }}
{{ formatRelativeTime(new Date(version.date_published)) }}
</div>
<div
class="pointer-events-none z-[1] flex items-center gap-1 font-medium xl:self-center"

View File

@@ -106,6 +106,8 @@ import {
} from '../../composables/i18n'
const { formatMessage } = useVIntl()
// TODO: anything in this component that uses the router will not work in the app. and this component is used in the app.
// fix is to replace any router stuff with click handlers and pass in the handlers as props from the parent component
const router = useRouter()
type EnvironmentValue = 'optional' | 'required' | 'unsupported' | 'unknown'

View File

@@ -29,9 +29,7 @@
>
<Avatar :src="member.user.avatar_url" :alt="member.user.username" size="32px" circle />
<div class="flex flex-col">
<span
class="grid grid-cols-[1fr_auto] w-full flex-nowrap items-center gap-1 group-hover:underline"
>
<span class="flex w-full flex-nowrap items-center gap-1 group-hover:underline">
<span class="min-w-0 overflow-hidden truncate">{{ member.user.username }}</span>
<CrownIcon
v-if="member.is_owner"
@@ -86,7 +84,9 @@ const props = defineProps<{
// Members should be an array of all members, without the accepted ones, and with the user with the Owner role at the start
// The rest of the members should be sorted by role, then by name
const sortedMembers = computed(() => {
const acceptedMembers = props.members.filter((x) => x.accepted === undefined || x.accepted)
const acceptedMembers = (props.members ?? []).filter(
(x) => x.accepted === undefined || x.accepted,
)
const owner = acceptedMembers.find((x) =>
props.organization
? props.organization.members.some(

View File

@@ -2,7 +2,7 @@
<div class="flex flex-col gap-3">
<h2 class="text-lg m-0">{{ formatMessage(commonMessages.detailsLabel) }}</h2>
<div class="flex flex-col gap-3 font-semibold [&>div]:flex [&>div]:gap-2 [&>div]:items-center">
<div>
<div v-if="!props.hideLicense">
<BookTextIcon aria-hidden="true" />
<div>
<IntlFormatted :message-id="messages.licensed">
@@ -107,6 +107,7 @@ const props = defineProps<{
}
linkTarget: string
hasVersions: boolean
hideLicense?: boolean
}>()
const createdDate = computed(() =>

View File

@@ -5,6 +5,8 @@
project.source_url ||
project.wiki_url ||
project.discord_url ||
project.site_url ||
projectV3?.link_urls.store?.url ||
project.donation_urls.length > 0
"
class="flex flex-col gap-3"
@@ -53,9 +55,34 @@
{{ formatMessage(messages.discord) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<a
v-if="projectV3?.link_urls.site?.url"
:href="projectV3?.link_urls.site?.url"
:target="linkTarget"
rel="noopener nofollow ugc"
>
<GlobeIcon aria-hidden="true" />
{{ formatMessage(messages.site) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<a
v-if="projectV3?.link_urls.store?.url"
:href="projectV3?.link_urls.store?.url"
:target="linkTarget"
rel="noopener nofollow ugc"
>
<StoreIcon aria-hidden="true" />
{{ formatMessage(messages.store) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<hr
v-if="
(project.issues_url || project.source_url || project.wiki_url || project.discord_url) &&
(project.issues_url ||
project.source_url ||
project.wiki_url ||
project.discord_url ||
projectV3?.link_urls.site?.url ||
projectV3?.link_urls.store?.url) &&
project.donation_urls.length > 0
"
class="w-full border-button-border my-0.5"
@@ -88,18 +115,21 @@
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
BuyMeACoffeeIcon,
CodeIcon,
CurrencyIcon,
DiscordIcon,
ExternalIcon,
GlobeIcon,
HeartIcon,
IssuesIcon,
KoFiIcon,
OpenCollectiveIcon,
PatreonIcon,
PayPalIcon,
StoreIcon,
WikiIcon,
} from '@modrinth/assets'
@@ -113,11 +143,14 @@ defineProps<{
source_url: string
wiki_url: string
discord_url: string
site_url?: string
store_url?: string
donation_urls: {
id: string
url: string
}[]
}
projectV3?: Labrinth.Projects.v3.Project
linkTarget: string
}>()
@@ -142,6 +175,14 @@ const messages = defineMessages({
id: 'project.about.links.discord',
defaultMessage: 'Join Discord server',
},
site: {
id: 'project.about.links.site',
defaultMessage: 'Visit website',
},
store: {
id: 'project.about.links.store',
defaultMessage: 'Visit store page',
},
donateGeneric: {
id: 'project.about.links.donate.generic',
defaultMessage: 'Donate',

View File

@@ -0,0 +1,230 @@
<template>
<div v-if="hasContent" class="flex flex-col gap-3">
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
<div
v-if="ipAddress"
v-tooltip="`Copy Java IP: ${ipAddress}`"
class="bg-button-bg flex gap-2 justify-between rounded-2xl items-center px-3 pr-1.5 h-12 cursor-pointer hover:bg-button-bg-hover hover:brightness-125 transition-all active:scale-95"
@click="handleCopyIP"
>
<div class="font-semibold truncate">
{{ ipAddress }}
</div>
<div class="w-9 h-9 grid place-content-center">
<CopyIcon class="shrink-0" />
</div>
</div>
<section v-if="requiredContent" class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">Required content</h3>
<ServerModpackContentCard
:name="requiredContent.name"
:version-number="requiredContent.versionNumber ?? ''"
:icon="requiredContent.icon"
:onclick-name="requiredContent.onclickName"
:onclick-version="requiredContent.onclickVersion"
:onclick-download="requiredContent.onclickDownload"
:show-custom-modpack-tooltip="requiredContent.showCustomModpackTooltip"
/>
</section>
<section v-if="recommendedVersions.length" class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">Minecraft: Java Edition</h3>
<div class="flex flex-wrap gap-1.5">
<TagItem
v-for="version in formatVersionsForDisplay(recommendedVersions, tags.gameVersions)"
:key="`recommended-tag-${version}`"
>
{{ version }}
<template v-if="supportedVersions.length > 0"> (Recommended) </template>
</TagItem>
<TagItem
v-for="version in formatVersionsForDisplay(supportedVersionsList, tags.gameVersions)"
:key="`supported-tag-${version}`"
>
{{ version }}
</TagItem>
<TagItem
v-for="loader in loaders ?? []"
:key="`loader-${loader}`"
class="border !border-solid border-surface-5"
:style="`--_color: var(--color-platform-${loader})`"
>
<component :is="getLoaderIcon(loader)" v-if="getLoaderIcon(loader)" />
<FormattedTag :tag="loader" enforce-type="loader" />
</TagItem>
</div>
</section>
<section v-if="props.ping !== undefined || country" class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">Country</h3>
<div class="flex flex-wrap gap-1.5 items-center">
<ServerRegion v-if="country" :region="country" />
<ServerPing :ping="props.ping" :status-online="props.statusOnline" />
</div>
</section>
<section v-if="languages.length > 0" class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">Languages</h3>
<div class="flex flex-wrap gap-1.5">
<TagItem v-for="language in languages" :key="`${language}`">
{{ languageDisplay.find((l) => l.value === language)?.label ?? language }}
</TagItem>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { CopyIcon, getLoaderIcon } from '@modrinth/assets'
import { formatVersionsForDisplay, type GameVersionTag, type PlatformTag } from '@modrinth/utils'
import { computed } from 'vue'
import { defineMessages, useVIntl } from '../../composables'
import { injectNotificationManager } from '../../providers'
import FormattedTag from '../base/FormattedTag.vue'
import TagItem from '../base/TagItem.vue'
import ServerModpackContentCard from './server/ServerModpackContentCard.vue'
import ServerPing from './server/ServerPing.vue'
import ServerRegion from './server/ServerRegion.vue'
interface RequiredContent {
name: string
versionNumber?: string
icon?: string
onclickName?: () => void
onclickVersion?: () => void
onclickDownload?: () => void
showCustomModpackTooltip?: boolean
}
interface Props {
projectV3: Labrinth.Projects.v3.Project | null
tags: {
gameVersions: GameVersionTag[]
loaders: PlatformTag[]
}
requiredContent?: RequiredContent | null
recommendedVersion?: string | null
supportedVersions?: string[]
loaders?: string[]
ping?: number
statusOnline?: boolean
}
const props = withDefaults(defineProps<Props>(), {
requiredContent: null,
recommendedVersion: null,
supportedVersions: () => [],
loaders: () => [],
ping: undefined,
})
const ipAddress = computed(() => props.projectV3?.minecraft_java_server?.address ?? '')
const languages = computed(() => props.projectV3?.minecraft_server?.languages ?? [])
const country = computed(() => props.projectV3?.minecraft_server?.country)
const recommendedVersions = computed(() => {
if (props.recommendedVersion) return [props.recommendedVersion]
const content = props.projectV3?.minecraft_java_server?.content
if (content?.kind === 'vanilla' && content.recommended_game_version) {
return [content.recommended_game_version]
}
return []
})
const hasContent = computed(
() =>
!!ipAddress.value ||
!!props.requiredContent ||
recommendedVersions.value.length > 0 ||
supportedVersionsList.value.length > 0 ||
languages.value.length > 0 ||
props.ping !== undefined,
)
const supportedVersionsList = computed(() => {
if (props.supportedVersions.length > 0) return props.supportedVersions
const content = props.projectV3?.minecraft_java_server?.content
if (content?.kind === 'vanilla' && content.supported_game_versions?.length) {
return content.supported_game_versions.filter((v): v is string => !!v)
}
return []
})
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
function handleCopyIP() {
navigator.clipboard.writeText(ipAddress.value).then(() => {
addNotification({
type: 'success',
title: formatMessage(messages.copied),
text: formatMessage(messages.copiedText),
})
})
}
const languageDisplay = [
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Spanish' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'fr', label: 'French' },
{ value: 'de', label: 'German' },
{ value: 'it', label: 'Italian' },
{ value: 'nl', label: 'Dutch' },
{ value: 'ru', label: 'Russian' },
{ value: 'uk', label: 'Ukrainian' },
{ value: 'pl', label: 'Polish' },
{ value: 'cs', label: 'Czech' },
{ value: 'sk', label: 'Slovak' },
{ value: 'hu', label: 'Hungarian' },
{ value: 'ro', label: 'Romanian' },
{ value: 'bg', label: 'Bulgarian' },
{ value: 'hr', label: 'Croatian' },
{ value: 'sr', label: 'Serbian' },
{ value: 'el', label: 'Greek' },
{ value: 'tr', label: 'Turkish' },
{ value: 'ar', label: 'Arabic' },
{ value: 'he', label: 'Hebrew' },
{ value: 'hi', label: 'Hindi' },
{ value: 'bn', label: 'Bengali' },
{ value: 'ur', label: 'Urdu' },
{ value: 'zh', label: 'Chinese' },
{ value: 'ja', label: 'Japanese' },
{ value: 'ko', label: 'Korean' },
{ value: 'th', label: 'Thai' },
{ value: 'vi', label: 'Vietnamese' },
{ value: 'id', label: 'Indonesian' },
{ value: 'ms', label: 'Malay' },
{ value: 'tl', label: 'Filipino' },
{ value: 'sv', label: 'Swedish' },
{ value: 'no', label: 'Norwegian' },
{ value: 'da', label: 'Danish' },
{ value: 'fi', label: 'Finnish' },
{ value: 'lt', label: 'Lithuanian' },
{ value: 'lv', label: 'Latvian' },
{ value: 'et', label: 'Estonian' },
]
const messages = defineMessages({
copied: {
id: `project.about.server.copied`,
defaultMessage: 'Copied!',
},
copiedText: {
id: `project.about.server.copiedText`,
defaultMessage: 'IP address copied to clipboard',
},
title: {
id: `project.about.server.title`,
defaultMessage: 'Server details',
},
latency: {
id: `project.about.server.latency`,
defaultMessage: 'Latency',
},
})
</script>

View File

@@ -0,0 +1,28 @@
<template>
<div v-if="allTags.length > 0" class="flex flex-col gap-3">
<h2 class="text-lg m-0">Tags</h2>
<div class="flex flex-wrap gap-1">
<TagItem v-for="tag in allTags" :key="tag">
<FormattedTag :tag="tag" />
</TagItem>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import FormattedTag from '../base/FormattedTag.vue'
import TagItem from '../base/TagItem.vue'
const props = defineProps<{
project: {
categories: string[]
additional_categories: string[]
}
}>()
const allTags = computed(() => [
...props.project.categories,
...props.project.additional_categories,
])
</script>

View File

@@ -0,0 +1,66 @@
<template>
<ContentPageHeader>
<template #icon>
<Avatar :src="project.icon_url" :alt="project.title" size="96px" />
</template>
<template #title>
{{ project.title }}
</template>
<template #title-suffix>
<ProjectStatusBadge v-if="member || project.status !== 'approved'" :status="project.status" />
</template>
<template #summary>
{{ project.description }}
</template>
<template #stats>
<div class="flex items-center gap-3 gap-y-1 flex-wrap">
<ServerDetails
:online-players="playersOnline"
:status-online="statusOnline"
:recent-plays="javaServer?.verified_plays_4w ?? 0"
/>
<div v-if="project.categories.length > 0" class="hidden items-center gap-2 md:flex">
<div class="flex gap-2">
<TagItem
v-for="(category, index) in project.categories"
:key="index"
:action="() => router.push(`/${project.project_type}s?f=categories:${category}`)"
>
<FormattedTag :tag="category" />
</TagItem>
</div>
</div>
</div>
</template>
<template #actions>
<slot name="actions" />
</template>
</ContentPageHeader>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import type { Project } from '@modrinth/utils'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import Avatar from '../base/Avatar.vue'
import ContentPageHeader from '../base/ContentPageHeader.vue'
import FormattedTag from '../base/FormattedTag.vue'
import TagItem from '../base/TagItem.vue'
import ProjectStatusBadge from './ProjectStatusBadge.vue'
import ServerDetails from './server/ServerDetails.vue'
const router = useRouter()
const { project, projectV3, member } = defineProps<{
project: Project
projectV3: Labrinth.Projects.v3.Project | null
member?: boolean
ping?: number
}>()
const javaServer = computed(() => projectV3?.minecraft_java_server)
const javaServerPingData = computed(() => projectV3?.minecraft_java_server?.ping?.data)
const playersOnline = computed(() => javaServerPingData.value?.players_online ?? 0)
const statusOnline = computed(() => !!javaServerPingData.value)
</script>

View File

@@ -49,6 +49,16 @@
</div>
<div class="mt-auto flex flex-col gap-3 flex-wrap overflow-hidden justify-between grow">
<div class="flex items-center gap-1 flex-wrap overflow-hidden">
<ServerDetails
v-if="isServerProject"
:region="serverRegionCode"
:online-players="serverOnlinePlayers"
:recent-plays="serverRecentPlays"
:ping="serverPing"
:status-online="serverStatusOnline"
:hide-online-players-label="true"
:hide-recent-plays-label="true"
/>
<ProjectCardEnvironment
v-if="environment"
:client-side="environment.clientSide"
@@ -59,7 +69,15 @@
:tags="tags"
:exclude-loaders="excludeLoaders"
:deprioritized-tags="deprioritizedTags"
:max-tags="6 + (!!environment ? 0 : 1)"
:max-tags="(maxTags || 6) + (!!environment ? 0 : 1)"
/>
<ServerModpackContent
v-if="serverModpackContent"
:name="serverModpackContent.name"
:icon="serverModpackContent.icon"
:onclick="serverModpackContent.onclick"
:show-custom-modpack-tooltip="serverModpackContent.showCustomModpackTooltip"
class="text-primary"
/>
</div>
<div
@@ -115,19 +133,39 @@
<ProjectCardDate v-if="date && autoDisplayDate" :type="autoDisplayDate" :date="date" />
</div>
<div class="mt-auto flex items-center gap-3 grid-project-card-list__tags">
<div class="flex items-center gap-1 flex-wrap">
<ProjectCardEnvironment
v-if="environment"
:client-side="environment.clientSide"
:server-side="environment.serverSide"
<div class="flex items-center gap-2 w-full">
<ServerDetails
v-if="isServerProject"
:region="serverRegionCode"
:online-players="serverOnlinePlayers"
:status-online="serverStatusOnline"
:recent-plays="serverRecentPlays"
:ping="serverPing"
:hide-online-players-label="true"
:hide-recent-plays-label="true"
/>
<ProjectCardTags
v-if="tags"
:tags="tags"
:extra-tags="extraTags"
:exclude-loaders="excludeLoaders"
:deprioritized-tags="deprioritizedTags"
:max-tags="(!!$slots.actions ? 4 : 5) + (!!environment ? 0 : 1)"
<div class="flex items-center gap-1">
<ProjectCardEnvironment
v-if="environment"
:client-side="environment.clientSide"
:server-side="environment.serverSide"
/>
<ProjectCardTags
v-if="tags"
:tags="tags"
:extra-tags="extraTags"
:exclude-loaders="excludeLoaders"
:deprioritized-tags="deprioritizedTags"
:max-tags="(maxTags || (!!$slots.actions ? 4 : 5)) + (!!environment ? 0 : 1)"
/>
</div>
<ServerModpackContent
v-if="serverModpackContent"
:name="serverModpackContent.name"
:icon="serverModpackContent.icon"
:onclick="serverModpackContent.onclick"
:show-custom-modpack-tooltip="serverModpackContent.showCustomModpackTooltip"
class="text-primary"
/>
</div>
</div>
@@ -136,14 +174,15 @@
</template>
<script setup lang="ts">
import { Avatar } from '@modrinth/ui'
import type { ProjectStatus } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { AutoLink } from '../../base'
import { AutoLink, Avatar } from '../../base'
import { SmartClickable } from '../../base/index.ts'
import ProjectStatusBadge from '../ProjectStatusBadge.vue'
import ServerDetails from '../server/ServerDetails.vue'
import ServerModpackContent from '../server/ServerModpackContent.vue'
import ProjectCardAuthor from './ProjectCardAuthor.vue'
import ProjectCardDate from './ProjectCardDate.vue'
import ProjectCardEnvironment, {
@@ -177,10 +216,23 @@ const props = defineProps<{
dateUpdated?: string
datePublished?: string
displayedDate?: 'updated' | 'published'
serverRegionCode?: string
serverOnlinePlayers?: number
serverStatusOnline?: boolean
serverRecentPlays?: number
serverPing?: number
serverModpackContent?: {
name: string
icon?: string
onclick?: () => void
showCustomModpackTooltip?: boolean
}
isServerProject?: boolean
banner?: string
color?: string | number
environment?: ProjectCardEnvironmentProps
status?: ProjectStatus
maxTags?: number
}>()
const baseCardStyle =

View File

@@ -9,6 +9,10 @@ function isLoader(tag: string) {
return getTagMessage(tag, 'loader') !== undefined
}
function uniqueSorted(tags?: string[]) {
return tags ? sortTagsForDisplay([...new Set(tags)]) : undefined
}
const props = withDefaults(
defineProps<{
tags: string[]
@@ -25,10 +29,8 @@ const props = withDefaults(
},
)
const sortedTags = computed(() => (props.tags ? sortTagsForDisplay(props.tags) : undefined))
const sortedExtraTags = computed(() =>
props.extraTags ? sortTagsForDisplay(props.extraTags) : undefined,
)
const sortedTags = computed(() => uniqueSorted(props.tags))
const sortedExtraTags = computed(() => uniqueSorted(props.extraTags))
const filteredTags = computed(() => {
if (!sortedTags.value) {
return undefined
@@ -40,8 +42,10 @@ const filteredTags = computed(() => {
const visibleTags = computed(() => filteredTags.value?.slice(0, props.maxTags))
const overflowTags = computed(() => [
...(props.tags.filter((x) => !visibleTags.value?.includes(x)) ?? []),
...(sortedExtraTags.value ?? []),
...new Set([
...(props.tags.filter((x) => !visibleTags.value?.includes(x)) ?? []),
...(sortedExtraTags.value?.filter((x) => !visibleTags.value?.includes(x)) ?? []),
]),
])
</script>

View File

@@ -0,0 +1,7 @@
export { default as ProjectCard } from './ProjectCard.vue'
export { default as ProjectCardAuthor } from './ProjectCardAuthor.vue'
export { default as ProjectCardDate } from './ProjectCardDate.vue'
export { default as ProjectCardEnvironment } from './ProjectCardEnvironment.vue'
export { default as ProjectCardStats } from './ProjectCardStats.vue'
export { default as ProjectCardTags } from './ProjectCardTags.vue'
export { default as ProjectCardTitle } from './ProjectCardTitle.vue'

View File

@@ -1,10 +1,11 @@
// Settings
export * from './server'
export * from './settings'
// Other
export { default as ProjectCard } from './card/ProjectCard.vue'
export { default as ProjectBackgroundGradient } from './ProjectBackgroundGradient.vue'
export { default as ProjectCardList } from './ProjectCardList.vue'
export { default as ProjectCombobox } from './ProjectCombobox.vue'
export { default as ProjectHeader } from './ProjectHeader.vue'
export { default as ProjectPageDescription } from './ProjectPageDescription.vue'
export { default as ProjectPageVersions } from './ProjectPageVersions.vue'
@@ -12,5 +13,8 @@ export { default as ProjectSidebarCompatibility } from './ProjectSidebarCompatib
export { default as ProjectSidebarCreators } from './ProjectSidebarCreators.vue'
export { default as ProjectSidebarDetails } from './ProjectSidebarDetails.vue'
export { default as ProjectSidebarLinks } from './ProjectSidebarLinks.vue'
export { default as ProjectSidebarServerInfo } from './ProjectSidebarServerInfo.vue'
export { default as ProjectSidebarTags } from './ProjectSidebarTags.vue'
export { default as ProjectStatusBadge } from './ProjectStatusBadge.vue'
export { default as ServerProjectHeader } from './ServerProjectHeader.vue'
export { default as TagsOverflow } from './TagsOverflow.vue'

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import ServerModpackContent from './ServerModpackContent.vue'
import ServerOnlinePlayers from './ServerOnlinePlayers.vue'
import ServerPing from './ServerPing.vue'
import ServerRecentPlays from './ServerRecentPlays.vue'
import ServerRegion from './ServerRegion.vue'
defineProps<{
onlinePlayers?: number
recentPlays?: number
region?: string
ping?: number
statusOnline?: boolean
modpackContent?: {
name: string
icon?: string
link?: string
}
hideOnlinePlayersLabel?: boolean
hideRecentPlaysLabel?: boolean
}>()
</script>
<template>
<div class="empty:hidden flex items-center gap-2">
<ServerOnlinePlayers
v-if="onlinePlayers !== undefined"
:online="onlinePlayers"
:status-online="statusOnline"
:hide-label="hideOnlinePlayersLabel"
/>
<ServerRecentPlays
v-if="recentPlays !== undefined"
:recent-plays="recentPlays"
:hide-label="hideRecentPlaysLabel"
/>
<ServerRegion v-if="region" :region="region" />
<ServerPing v-if="ping && statusOnline" :ping="ping" />
<ServerModpackContent
v-if="modpackContent"
:name="modpackContent.name"
:icon="modpackContent.icon"
:link="modpackContent.link"
/>
</div>
</template>

View File

@@ -0,0 +1,24 @@
<template>
<div
v-tooltip="showCustomModpackTooltip ? 'This project uses a custom modpack' : name"
class="flex gap-1.5 items-center flex-shrink overflow-hidden smart-clickable:allow-pointer-events"
:class="[onclick ? 'hover:underline cursor-pointer' : '']"
@click="onclick"
>
<Avatar :src="icon" size="24px" />
<span class="truncate font-medium">
{{ name }}
</span>
</div>
</template>
<script setup lang="ts">
import Avatar from '../../base/Avatar.vue'
defineProps<{
name: string
icon?: string
onclick?: () => void
showCustomModpackTooltip?: boolean
}>()
</script>

View File

@@ -0,0 +1,47 @@
<template>
<div class="flex gap-1.5 items-center justify-between px-3 pr-1.5 py-1.5 rounded-2xl bg-bg">
<div class="grid grid-cols-[auto_1fr] gap-1.5 items-center">
<Avatar :src="icon" size="34px" class="!rounded-xl !shadow-none" />
<div class="flex flex-col items-start overflow-hidden">
<div
v-tooltip="showCustomModpackTooltip ? 'This project uses a custom modpack' : name"
class="truncate font-semibold text-sm max-w-full"
:class="onclickName ? 'hover:underline cursor-pointer' : ''"
@click="onclickName"
>
{{ name }}
</div>
<div
v-tooltip="versionNumber"
class="truncate font-medium text-sm max-w-full"
:class="onclickVersion ? 'hover:underline cursor-pointer' : ''"
@click="onclickVersion"
>
{{ versionNumber }}
</div>
</div>
</div>
<ButtonStyled v-if="onclickDownload" circular type="transparent">
<button v-tooltip="'Download modpack'" @click="onclickDownload">
<DownloadIcon />
</button>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { DownloadIcon } from '@modrinth/assets/generated-icons'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
defineProps<{
name: string
versionNumber: string
icon?: string
onclickName?: () => void
onclickVersion?: () => void
onclickDownload?: () => void
showCustomModpackTooltip?: boolean
}>()
</script>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { OnlineIndicatorIcon } from '@modrinth/assets'
import { formatNumber } from '../../../../../utils'
import { useVIntl } from '../../../composables'
import { commonMessages } from '../../../utils'
import { StatItem } from '../../base'
const { formatMessage } = useVIntl()
defineProps<{
online: number
hideLabel?: boolean
statusOnline?: boolean
}>()
</script>
<template>
<StatItem
v-tooltip="`${formatNumber(online, true)} players online`"
class="smart-clickable:allow-pointer-events"
>
<OnlineIndicatorIcon
:style="{
'--_color-inner': statusOnline ? 'var(--color-brand)' : 'var(--color-red)',
'--_color-outer': statusOnline
? 'var(--color-green-highlight)'
: 'var(--color-red-highlight)',
}"
/>
{{
hideLabel
? formatNumber(online, false)
: formatMessage(commonMessages.projectOnlinePlayerCount, {
count: formatNumber(online, false),
})
}}
</StatItem>
</template>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { SignalIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { defineMessage, useVIntl } from '../../../composables'
import { TagItem } from '../../base'
const props = defineProps<{
ping?: number
statusOnline?: boolean
}>()
const pingMessage = defineMessage({
id: 'project.server.ping.ms',
defaultMessage: '{ping} ms',
})
const { formatMessage } = useVIntl()
const pingClass = computed(() => {
if (props.ping === undefined) {
return 'border-brand bg-highlight-green text-brand'
}
if (props.ping < 100) {
return 'border-brand bg-highlight-green text-brand'
}
if (props.ping < 250) {
return 'border-brand-orange bg-highlight-orange text-orange'
}
return 'border-red bg-highlight-red text-red'
})
</script>
<template>
<TagItem
v-if="ping || statusOnline"
class="border !border-solid !font-medium w-max"
:class="pingClass"
>
<template v-if="ping !== undefined">
{{ formatMessage(pingMessage, { ping }) }}
</template>
<template v-else>
<SignalIcon />
Online
</template>
</TagItem>
<TagItem
v-else
v-tooltip="'Server is offline'"
class="border !border-solid border-red bg-highlight-red text-red smart-clickable:allow-pointer-events w-max"
>
<SignalIcon />
Offline
</TagItem>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { PlayIcon } from '@modrinth/assets'
import { formatNumber } from '../../../../../utils'
import { useVIntl } from '../../../composables'
import { commonMessages } from '../../../utils'
import { StatItem } from '../../base'
const { formatMessage } = useVIntl()
defineProps<{
recentPlays: number
hideLabel?: boolean
}>()
</script>
<template>
<StatItem
v-tooltip="
`${formatNumber(recentPlays, true)} recent play${recentPlays === 1 ? '' : 's'} from Modrinth in the past 2 weeks`
"
class="smart-clickable:allow-pointer-events"
>
<PlayIcon />
{{
hideLabel
? formatNumber(recentPlays, true)
: formatMessage(commonMessages.projectRecentPlays, {
count: formatNumber(recentPlays, true),
})
}}
</StatItem>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessage, useVIntl } from '../../../composables'
const { region } = defineProps<{
region: string
}>()
const { formatMessage } = useVIntl()
const alt = defineMessage({
id: 'project.server.region.alt',
defaultMessage: 'Region: {regionCode}',
})
const regionLower = computed(() => region.toLowerCase())
</script>
<template>
<img
v-tooltip="`Server hosted in ${region}`"
:src="`https://flagcdn.com/${regionLower}.svg`"
:alt="formatMessage(alt, { regionCode: regionLower })"
class="h-4 aspect-[3/2] border-[1px] border-surface-5 border-solid shrink-0 rounded-[3px] object-cover smart-clickable:allow-pointer-events"
/>
</template>

View File

@@ -0,0 +1,6 @@
export { default as ServerDetails } from './ServerDetails.vue'
export { default as ServerModpackContent } from './ServerModpackContent.vue'
export { default as ServerOnlinePlayers } from './ServerOnlinePlayers.vue'
export { default as ServerPing } from './ServerPing.vue'
export { default as ServerRecentPlays } from './ServerRecentPlays.vue'
export { default as ServerRegion } from './ServerRegion.vue'

View File

@@ -0,0 +1,86 @@
<template>
<div class="flex flex-col">
<button
class="flex w-full items-center gap-1 border-none bg-transparent px-2 py-1.5 text-left text-sm font-bold tracking-wide text-primary transition-colors cursor-pointer"
@click="open = !open"
>
<span>{{ groupName }}</span>
<DropdownIcon
class="ml-auto size-4 shrink-0 transition-transform duration-200"
:class="{ 'rotate-180': open }"
/>
</button>
<div class="accordion-content" :class="{ open }">
<div>
<div class="flex flex-col gap-1 ml-2">
<SearchFilterOption
v-for="option in options"
:key="option.id"
:option="option"
:included="included(option)"
:excluded="excluded(option)"
:supports-negative-filter="supportsNegativeFilter"
@toggle="(o) => emit('toggle', o)"
@toggle-exclude="(o) => emit('toggleExclude', o)"
>
<span
v-if="option.icon"
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
>
<div v-if="typeof option.icon === 'string'" class="h-4 w-4" v-html="option.icon" />
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm">
{{ option.formatted_name ?? option.id }}
</span>
</SearchFilterOption>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets'
import { ref } from 'vue'
import type { FilterOption } from '../../utils/search'
import SearchFilterOption from './SearchFilterOption.vue'
defineProps<{
groupName: string
options: FilterOption[]
supportsNegativeFilter: boolean
included: (option: FilterOption) => boolean
excluded: (option: FilterOption) => boolean
}>()
const emit = defineEmits<{
toggle: [option: FilterOption]
toggleExclude: [option: FilterOption]
}>()
const open = ref(false)
</script>
<style scoped>
.accordion-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease-in-out;
}
@media (prefers-reduced-motion) {
.accordion-content {
transition: none !important;
}
}
.accordion-content.open {
grid-template-rows: 1fr;
}
.accordion-content > div {
overflow: hidden;
}
</style>

View File

@@ -75,38 +75,58 @@
autocomplete="off"
clearable
size="small"
input-class="!bg-button-bg"
wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
/>
<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">
<span
v-if="option.icon"
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
:style="iconStyle(option)"
>
<div v-if="typeof option.icon === 'string'" class="h-4 w-4" v-html="option.icon" />
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot>
</SearchFilterOption>
<template v-if="groupedOptions">
<SearchFilterGroup
v-for="[groupName, options] in groupedOptions"
:key="`${filterType.id}-group-${groupName}`"
:group-name="groupName"
:options="options"
:supports-negative-filter="filterType.supports_negative_filter"
:included="isIncluded"
:excluded="isExcluded"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
/>
</template>
<template v-else>
<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">
<span
v-if="option.icon"
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
:style="iconStyle(option)"
>
<div
v-if="typeof option.icon === 'string'"
class="h-4 w-4"
v-html="option.icon"
/>
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot>
</SearchFilterOption>
</template>
<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]"
@@ -166,6 +186,7 @@ import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
import Accordion from '../base/Accordion.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import { Checkbox, ScrollablePanel, StyledInput } from '../index'
import SearchFilterGroup from './SearchFilterGroup.vue'
import SearchFilterOption from './SearchFilterOption.vue'
const { formatMessage } = useVIntl()
@@ -224,6 +245,20 @@ const visibleOptions = computed(() =>
}),
)
const hasGroups = computed(() => visibleOptions.value.some((o) => o.group))
const groupedOptions = computed(() => {
if (!hasGroups.value) return null
const groups = new Map<string, FilterOption[]>()
for (const option of visibleOptions.value) {
const groupName = option.group ?? ''
if (!groups.has(groupName)) {
groups.set(groupName, [])
}
groups.get(groupName)!.push(option)
}
return groups
})
const hasProvidedFilter = computed(() =>
props.providedFilters.some((filter) => filter.type === props.filterType.id),
)