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:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
11
packages/ui/src/components/base/PageHeader.vue
Normal file
11
packages/ui/src/components/base/PageHeader.vue
Normal 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>
|
||||
5
packages/ui/src/components/base/StatItem.vue
Normal file
5
packages/ui/src/components/base/StatItem.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-1">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
@@ -119,7 +119,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
331
packages/ui/src/components/modal/OpenInAppModal.vue
Normal file
331
packages/ui/src/components/modal/OpenInAppModal.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
145
packages/ui/src/components/nav/PopupNotificationPanel.vue
Normal file
145
packages/ui/src/components/nav/PopupNotificationPanel.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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',
|
||||
|
||||
230
packages/ui/src/components/project/ProjectSidebarServerInfo.vue
Normal file
230
packages/ui/src/components/project/ProjectSidebarServerInfo.vue
Normal 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>
|
||||
28
packages/ui/src/components/project/ProjectSidebarTags.vue
Normal file
28
packages/ui/src/components/project/ProjectSidebarTags.vue
Normal 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>
|
||||
66
packages/ui/src/components/project/ServerProjectHeader.vue
Normal file
66
packages/ui/src/components/project/ServerProjectHeader.vue
Normal 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>
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
7
packages/ui/src/components/project/card/index.ts
Normal file
7
packages/ui/src/components/project/card/index.ts
Normal 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'
|
||||
@@ -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'
|
||||
|
||||
45
packages/ui/src/components/project/server/ServerDetails.vue
Normal file
45
packages/ui/src/components/project/server/ServerDetails.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
55
packages/ui/src/components/project/server/ServerPing.vue
Normal file
55
packages/ui/src/components/project/server/ServerPing.vue
Normal 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>
|
||||
@@ -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>
|
||||
26
packages/ui/src/components/project/server/ServerRegion.vue
Normal file
26
packages/ui/src/components/project/server/ServerRegion.vue
Normal 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>
|
||||
6
packages/ui/src/components/project/server/index.ts
Normal file
6
packages/ui/src/components/project/server/index.ts
Normal 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'
|
||||
86
packages/ui/src/components/search/SearchFilterGroup.vue
Normal file
86
packages/ui/src/components/search/SearchFilterGroup.vue
Normal 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>
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,10 @@ import { computed, type ComputedRef } from 'vue'
|
||||
import { injectI18n } from '../providers/i18n'
|
||||
import { LOCALES } from './i18n.ts'
|
||||
|
||||
export type Formatter = (value: Date | number | null | undefined, options?: FormatOptions) => string
|
||||
export type Formatter = (
|
||||
value: Date | number | null | string | undefined,
|
||||
options?: FormatOptions,
|
||||
) => string
|
||||
|
||||
export interface FormatOptions {
|
||||
roundingMode?: 'halfExpand' | 'floor' | 'ceil'
|
||||
@@ -26,7 +29,7 @@ export function useRelativeTime(): Formatter {
|
||||
formatters.set(locale.value, formatterRef)
|
||||
}
|
||||
|
||||
return (value: Date | number | null | undefined) => {
|
||||
return (value: Date | number | null | string | undefined) => {
|
||||
if (value == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
84
packages/ui/src/composables/sticky-observer.ts
Normal file
84
packages/ui/src/composables/sticky-observer.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useDebugLogger } from './debug-logger'
|
||||
|
||||
/**
|
||||
* Observes when a target element becomes "stuck" (i.e. its natural position has scrolled out of view).
|
||||
* Injects a zero-height sentinel element before the target and uses IntersectionObserver to detect
|
||||
* when the sentinel leaves the viewport.
|
||||
*/
|
||||
export function useStickyObserver(target: Ref<HTMLElement | null | undefined>, label?: string) {
|
||||
const debug = useDebugLogger(`sticky-observer${label ? `:${label}` : ''}`)
|
||||
const isStuck = ref(false)
|
||||
let sentinel: HTMLElement | null = null
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
debug('init, target value:', target.value)
|
||||
|
||||
watch(
|
||||
target,
|
||||
(el, oldEl) => {
|
||||
debug('watch fired, el:', el, 'oldEl:', oldEl)
|
||||
observer?.disconnect()
|
||||
sentinel?.remove()
|
||||
observer = null
|
||||
sentinel = null
|
||||
|
||||
if (el) {
|
||||
debug(
|
||||
'setting up sentinel, parent:',
|
||||
el.parentElement,
|
||||
'parentClasses:',
|
||||
el.parentElement?.className,
|
||||
)
|
||||
debug('el classes:', el.className)
|
||||
debug('el computed overflow:', getComputedStyle(el).overflow)
|
||||
debug(
|
||||
'parent computed overflow:',
|
||||
el.parentElement ? getComputedStyle(el.parentElement).overflow : 'no parent',
|
||||
)
|
||||
|
||||
sentinel = document.createElement('div')
|
||||
sentinel.style.height = '0'
|
||||
const parentGap = getComputedStyle(el.parentElement!).gap
|
||||
sentinel.style.marginBottom = parentGap ? `-${parentGap}` : '0'
|
||||
sentinel.setAttribute('aria-hidden', 'true')
|
||||
el.parentElement?.insertBefore(sentinel, el)
|
||||
|
||||
debug('sentinel inserted, sentinel parent:', sentinel.parentElement?.className)
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
const wasStuck = isStuck.value
|
||||
isStuck.value = !entry.isIntersecting
|
||||
if (wasStuck !== isStuck.value) {
|
||||
debug(
|
||||
'isStuck changed:',
|
||||
isStuck.value,
|
||||
'intersectionRatio:',
|
||||
entry.intersectionRatio,
|
||||
'boundingClientRect:',
|
||||
entry.boundingClientRect,
|
||||
)
|
||||
}
|
||||
},
|
||||
{ threshold: 0, rootMargin: '-1px 0px 0px 0px' },
|
||||
)
|
||||
observer.observe(sentinel)
|
||||
debug('observer started')
|
||||
} else {
|
||||
debug('el is null, no observer set up')
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
debug('unmounted, cleaning up')
|
||||
observer?.disconnect()
|
||||
sentinel?.remove()
|
||||
})
|
||||
|
||||
return { isStuck }
|
||||
}
|
||||
129
packages/ui/src/composables/virtual-scroll.ts
Normal file
129
packages/ui/src/composables/virtual-scroll.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
export interface VirtualScrollOptions {
|
||||
itemHeight: number
|
||||
bufferSize?: number
|
||||
enabled?: Ref<boolean>
|
||||
onNearEnd?: () => void
|
||||
nearEndThreshold?: number
|
||||
}
|
||||
|
||||
export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptions) {
|
||||
const { itemHeight, bufferSize = 5, enabled, onNearEnd, nearEndThreshold = 0.2 } = options
|
||||
|
||||
const listContainer = ref<HTMLElement | null>(null)
|
||||
const scrollContainer = ref<HTMLElement | Window | null>(null)
|
||||
const scrollTop = ref(0)
|
||||
const viewportHeight = ref(0)
|
||||
|
||||
const totalHeight = computed(() => items.value.length * itemHeight)
|
||||
|
||||
function findScrollableAncestor(element: HTMLElement | null): HTMLElement | Window {
|
||||
if (!element) return window
|
||||
|
||||
let current: HTMLElement | null = element.parentElement
|
||||
while (current) {
|
||||
const { overflowY } = getComputedStyle(current)
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') {
|
||||
return current
|
||||
}
|
||||
current = current.parentElement
|
||||
}
|
||||
return window
|
||||
}
|
||||
|
||||
function getScrollTop(container: HTMLElement | Window): number {
|
||||
return container instanceof Window ? window.scrollY : container.scrollTop
|
||||
}
|
||||
|
||||
function getViewportHeight(container: HTMLElement | Window): number {
|
||||
return container instanceof Window ? window.innerHeight : container.clientHeight
|
||||
}
|
||||
|
||||
function getContainerOffset(listEl: HTMLElement, container: HTMLElement | Window): number {
|
||||
if (container instanceof Window) {
|
||||
return listEl.getBoundingClientRect().top + window.scrollY
|
||||
}
|
||||
const listRect = listEl.getBoundingClientRect()
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
return listRect.top - containerRect.top + container.scrollTop
|
||||
}
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
if (enabled && !enabled.value) {
|
||||
return { start: 0, end: items.value.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 - bufferSize),
|
||||
end: Math.min(items.value.length, start + visibleCount + bufferSize * 2),
|
||||
}
|
||||
})
|
||||
|
||||
const visibleTop = computed(() =>
|
||||
enabled && !enabled.value ? 0 : visibleRange.value.start * itemHeight,
|
||||
)
|
||||
|
||||
const visibleItems = computed(() =>
|
||||
items.value.slice(visibleRange.value.start, visibleRange.value.end),
|
||||
)
|
||||
|
||||
function checkNearEnd() {
|
||||
if (!onNearEnd || !listContainer.value) return
|
||||
|
||||
const containerBottom = listContainer.value.getBoundingClientRect().bottom
|
||||
const remainingScroll = containerBottom - window.innerHeight
|
||||
|
||||
if (remainingScroll < viewportHeight.value * nearEndThreshold) {
|
||||
onNearEnd()
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (scrollContainer.value) {
|
||||
scrollTop.value = getScrollTop(scrollContainer.value)
|
||||
}
|
||||
checkNearEnd()
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
if (scrollContainer.value) {
|
||||
viewportHeight.value = getViewportHeight(scrollContainer.value)
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
const listEl = listContainer.value
|
||||
if (!listEl) return
|
||||
|
||||
const container = findScrollableAncestor(listEl)
|
||||
scrollContainer.value = container
|
||||
viewportHeight.value = getViewportHeight(container)
|
||||
scrollTop.value = getScrollTop(container)
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener('resize', handleResize, { passive: true })
|
||||
|
||||
onCleanup(() => {
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
listContainer,
|
||||
totalHeight,
|
||||
visibleRange,
|
||||
visibleTop,
|
||||
visibleItems,
|
||||
}
|
||||
}
|
||||
@@ -254,6 +254,18 @@
|
||||
"header.category.feature": {
|
||||
"defaultMessage": "Feature"
|
||||
},
|
||||
"header.category.minecraft-server-community": {
|
||||
"defaultMessage": "Community"
|
||||
},
|
||||
"header.category.minecraft-server-features": {
|
||||
"defaultMessage": "Features"
|
||||
},
|
||||
"header.category.minecraft-server-gameplay": {
|
||||
"defaultMessage": "Gameplay"
|
||||
},
|
||||
"header.category.minecraft-server-meta": {
|
||||
"defaultMessage": "Meta"
|
||||
},
|
||||
"header.category.performance-impact": {
|
||||
"defaultMessage": "Performance impact"
|
||||
},
|
||||
@@ -317,6 +329,42 @@
|
||||
"instances.modpack-card.unlink": {
|
||||
"defaultMessage": "Unlink modpack"
|
||||
},
|
||||
"instances.modpack-content-modal.back-button": {
|
||||
"defaultMessage": "Back"
|
||||
},
|
||||
"instances.modpack-content-modal.copy-link": {
|
||||
"defaultMessage": "Copy link"
|
||||
},
|
||||
"instances.modpack-content-modal.disable": {
|
||||
"defaultMessage": "Disable"
|
||||
},
|
||||
"instances.modpack-content-modal.empty-description": {
|
||||
"defaultMessage": "This modpack does not include any additional content."
|
||||
},
|
||||
"instances.modpack-content-modal.empty-title": {
|
||||
"defaultMessage": "No content found"
|
||||
},
|
||||
"instances.modpack-content-modal.enable": {
|
||||
"defaultMessage": "Enable"
|
||||
},
|
||||
"instances.modpack-content-modal.filter-all": {
|
||||
"defaultMessage": "All"
|
||||
},
|
||||
"instances.modpack-content-modal.header": {
|
||||
"defaultMessage": "Modpack content"
|
||||
},
|
||||
"instances.modpack-content-modal.loading": {
|
||||
"defaultMessage": "Loading content..."
|
||||
},
|
||||
"instances.modpack-content-modal.no-results": {
|
||||
"defaultMessage": "No projects match your search."
|
||||
},
|
||||
"instances.modpack-content-modal.search-placeholder": {
|
||||
"defaultMessage": "Search {count} projects"
|
||||
},
|
||||
"instances.modpack-content-modal.selected-count": {
|
||||
"defaultMessage": "{count} selected"
|
||||
},
|
||||
"instances.updater-modal.badge.current": {
|
||||
"defaultMessage": "Current"
|
||||
},
|
||||
@@ -500,6 +548,30 @@
|
||||
"modal.add-payment-method.title": {
|
||||
"defaultMessage": "Adding a payment method"
|
||||
},
|
||||
"modal.open-in-app.benefit.install": {
|
||||
"defaultMessage": "Automatically install required content"
|
||||
},
|
||||
"modal.open-in-app.benefit.launch": {
|
||||
"defaultMessage": "Launch the game straight into the server"
|
||||
},
|
||||
"modal.open-in-app.benefit.update": {
|
||||
"defaultMessage": "Keep files updated when the server changes"
|
||||
},
|
||||
"modal.open-in-app.close": {
|
||||
"defaultMessage": "Close"
|
||||
},
|
||||
"modal.open-in-app.get-app": {
|
||||
"defaultMessage": "Get Modrinth App"
|
||||
},
|
||||
"modal.open-in-app.opening-automatically": {
|
||||
"defaultMessage": "The Modrinth App will open automatically..."
|
||||
},
|
||||
"modal.open-in-app.title": {
|
||||
"defaultMessage": "Opening Modrinth App"
|
||||
},
|
||||
"modal.open-in-app.why-use": {
|
||||
"defaultMessage": "Why use the Modrinth App"
|
||||
},
|
||||
"notification.error.title": {
|
||||
"defaultMessage": "An error occurred"
|
||||
},
|
||||
@@ -812,15 +884,33 @@
|
||||
"project.about.links.issues": {
|
||||
"defaultMessage": "Report issues"
|
||||
},
|
||||
"project.about.links.site": {
|
||||
"defaultMessage": "Visit website"
|
||||
},
|
||||
"project.about.links.source": {
|
||||
"defaultMessage": "View source"
|
||||
},
|
||||
"project.about.links.store": {
|
||||
"defaultMessage": "Visit store page"
|
||||
},
|
||||
"project.about.links.title": {
|
||||
"defaultMessage": "Links"
|
||||
},
|
||||
"project.about.links.wiki": {
|
||||
"defaultMessage": "Visit wiki"
|
||||
},
|
||||
"project.about.server.copied": {
|
||||
"defaultMessage": "Copied!"
|
||||
},
|
||||
"project.about.server.copiedText": {
|
||||
"defaultMessage": "IP address copied to clipboard"
|
||||
},
|
||||
"project.about.server.latency": {
|
||||
"defaultMessage": "Latency"
|
||||
},
|
||||
"project.about.server.title": {
|
||||
"defaultMessage": "Server details"
|
||||
},
|
||||
"project.download-count-tooltip": {
|
||||
"defaultMessage": "{count} {count, plural, one {download} other {downloads}}"
|
||||
},
|
||||
@@ -893,9 +983,24 @@
|
||||
"project.follower-count-tooltip": {
|
||||
"defaultMessage": "{count} {count, plural, one {followers} other {followers}}"
|
||||
},
|
||||
"project.online-player-count": {
|
||||
"defaultMessage": "{count} {count, plural, one {online} other {online}}"
|
||||
},
|
||||
"project.recent-plays": {
|
||||
"defaultMessage": "{count} {count, plural, one {recent play} other {recent plays}}"
|
||||
},
|
||||
"project.server.ping.ms": {
|
||||
"defaultMessage": "{ping} ms"
|
||||
},
|
||||
"project.server.region.alt": {
|
||||
"defaultMessage": "Region: {regionCode}"
|
||||
},
|
||||
"project.settings.analytics.title": {
|
||||
"defaultMessage": "Analytics"
|
||||
},
|
||||
"project.settings.content.title": {
|
||||
"defaultMessage": "Content"
|
||||
},
|
||||
"project.settings.description.title": {
|
||||
"defaultMessage": "Description"
|
||||
},
|
||||
@@ -1004,6 +1109,9 @@
|
||||
"project.settings.notice.no-permission.title": {
|
||||
"defaultMessage": "No permission"
|
||||
},
|
||||
"project.settings.server.title": {
|
||||
"defaultMessage": "Server"
|
||||
},
|
||||
"project.settings.tags.title": {
|
||||
"defaultMessage": "Tags"
|
||||
},
|
||||
@@ -1361,39 +1469,75 @@
|
||||
"tag.category.adventure": {
|
||||
"defaultMessage": "Adventure"
|
||||
},
|
||||
"tag.category.adventure-mode": {
|
||||
"defaultMessage": "Adventure Mode"
|
||||
},
|
||||
"tag.category.anarchy": {
|
||||
"defaultMessage": "Anarchy"
|
||||
},
|
||||
"tag.category.atmosphere": {
|
||||
"defaultMessage": "Atmosphere"
|
||||
},
|
||||
"tag.category.audio": {
|
||||
"defaultMessage": "Audio"
|
||||
},
|
||||
"tag.category.battle-royale": {
|
||||
"defaultMessage": "Battle Royale"
|
||||
},
|
||||
"tag.category.bedwars": {
|
||||
"defaultMessage": "Bed Wars"
|
||||
},
|
||||
"tag.category.blocks": {
|
||||
"defaultMessage": "Blocks"
|
||||
},
|
||||
"tag.category.bloom": {
|
||||
"defaultMessage": "Bloom"
|
||||
},
|
||||
"tag.category.bosses": {
|
||||
"defaultMessage": "Bosses"
|
||||
},
|
||||
"tag.category.cartoon": {
|
||||
"defaultMessage": "Cartoon"
|
||||
},
|
||||
"tag.category.challenging": {
|
||||
"defaultMessage": "Challenging"
|
||||
},
|
||||
"tag.category.classes": {
|
||||
"defaultMessage": "Classes"
|
||||
},
|
||||
"tag.category.colored-lighting": {
|
||||
"defaultMessage": "Colored Lighting"
|
||||
},
|
||||
"tag.category.combat": {
|
||||
"defaultMessage": "Combat"
|
||||
},
|
||||
"tag.category.competitive": {
|
||||
"defaultMessage": "Competitive"
|
||||
},
|
||||
"tag.category.core-shaders": {
|
||||
"defaultMessage": "Core Shaders"
|
||||
},
|
||||
"tag.category.creative-mode": {
|
||||
"defaultMessage": "Creative Mode"
|
||||
},
|
||||
"tag.category.creator-community": {
|
||||
"defaultMessage": "Creator Community"
|
||||
},
|
||||
"tag.category.crossplay": {
|
||||
"defaultMessage": "Crossplay"
|
||||
},
|
||||
"tag.category.cursed": {
|
||||
"defaultMessage": "Cursed"
|
||||
},
|
||||
"tag.category.custom-content": {
|
||||
"defaultMessage": "Custom Content"
|
||||
},
|
||||
"tag.category.decoration": {
|
||||
"defaultMessage": "Decoration"
|
||||
},
|
||||
"tag.category.dungeons": {
|
||||
"defaultMessage": "Dungeons"
|
||||
},
|
||||
"tag.category.economy": {
|
||||
"defaultMessage": "Economy"
|
||||
},
|
||||
@@ -1406,6 +1550,9 @@
|
||||
"tag.category.equipment": {
|
||||
"defaultMessage": "Equipment"
|
||||
},
|
||||
"tag.category.factions": {
|
||||
"defaultMessage": "Factions"
|
||||
},
|
||||
"tag.category.fantasy": {
|
||||
"defaultMessage": "Fantasy"
|
||||
},
|
||||
@@ -1421,21 +1568,36 @@
|
||||
"tag.category.game-mechanics": {
|
||||
"defaultMessage": "Game Mechanics"
|
||||
},
|
||||
"tag.category.gens": {
|
||||
"defaultMessage": "Gens"
|
||||
},
|
||||
"tag.category.gui": {
|
||||
"defaultMessage": "GUI"
|
||||
},
|
||||
"tag.category.hardcore-mode": {
|
||||
"defaultMessage": "Hardcore Mode"
|
||||
},
|
||||
"tag.category.high": {
|
||||
"defaultMessage": "High"
|
||||
},
|
||||
"tag.category.items": {
|
||||
"defaultMessage": "Items"
|
||||
},
|
||||
"tag.category.keep-inventory": {
|
||||
"defaultMessage": "Keep Inventory"
|
||||
},
|
||||
"tag.category.kitchen-sink": {
|
||||
"defaultMessage": "Kitchen Sink"
|
||||
},
|
||||
"tag.category.kitpvp": {
|
||||
"defaultMessage": "Kit PVP"
|
||||
},
|
||||
"tag.category.library": {
|
||||
"defaultMessage": "Library"
|
||||
},
|
||||
"tag.category.lifesteal": {
|
||||
"defaultMessage": "Lifesteal"
|
||||
},
|
||||
"tag.category.lightweight": {
|
||||
"defaultMessage": "Lightweight"
|
||||
},
|
||||
@@ -1451,12 +1613,24 @@
|
||||
"tag.category.management": {
|
||||
"defaultMessage": "Management"
|
||||
},
|
||||
"tag.category.media": {
|
||||
"defaultMessage": "Media"
|
||||
},
|
||||
"tag.category.medium": {
|
||||
"defaultMessage": "Medium"
|
||||
},
|
||||
"tag.category.microgames": {
|
||||
"defaultMessage": "Microgames"
|
||||
},
|
||||
"tag.category.minigame": {
|
||||
"defaultMessage": "Minigame"
|
||||
},
|
||||
"tag.category.minigames": {
|
||||
"defaultMessage": "Minigames"
|
||||
},
|
||||
"tag.category.mmo": {
|
||||
"defaultMessage": "MMO"
|
||||
},
|
||||
"tag.category.mobs": {
|
||||
"defaultMessage": "Mobs"
|
||||
},
|
||||
@@ -1469,27 +1643,75 @@
|
||||
"tag.category.multiplayer": {
|
||||
"defaultMessage": "Multiplayer"
|
||||
},
|
||||
"tag.category.network": {
|
||||
"defaultMessage": "Network"
|
||||
},
|
||||
"tag.category.offline-mode": {
|
||||
"defaultMessage": "Offline Mode"
|
||||
},
|
||||
"tag.category.oneblock": {
|
||||
"defaultMessage": "One Block"
|
||||
},
|
||||
"tag.category.op": {
|
||||
"defaultMessage": "OP"
|
||||
},
|
||||
"tag.category.optimization": {
|
||||
"defaultMessage": "Optimization"
|
||||
},
|
||||
"tag.category.parkour": {
|
||||
"defaultMessage": "Parkour"
|
||||
},
|
||||
"tag.category.path-tracing": {
|
||||
"defaultMessage": "Path Tracing"
|
||||
},
|
||||
"tag.category.pbr": {
|
||||
"defaultMessage": "PBR"
|
||||
},
|
||||
"tag.category.personal-worlds": {
|
||||
"defaultMessage": "Personal Worlds"
|
||||
},
|
||||
"tag.category.plots": {
|
||||
"defaultMessage": "Plots"
|
||||
},
|
||||
"tag.category.pokemon": {
|
||||
"defaultMessage": "Pokemon"
|
||||
},
|
||||
"tag.category.potato": {
|
||||
"defaultMessage": "Potato"
|
||||
},
|
||||
"tag.category.prison": {
|
||||
"defaultMessage": "Prison"
|
||||
},
|
||||
"tag.category.pve": {
|
||||
"defaultMessage": "PVE"
|
||||
},
|
||||
"tag.category.pvp": {
|
||||
"defaultMessage": "PVP"
|
||||
},
|
||||
"tag.category.questing": {
|
||||
"defaultMessage": "Questing"
|
||||
},
|
||||
"tag.category.quests": {
|
||||
"defaultMessage": "Quests"
|
||||
},
|
||||
"tag.category.racing": {
|
||||
"defaultMessage": "Racing"
|
||||
},
|
||||
"tag.category.realistic": {
|
||||
"defaultMessage": "Realistic"
|
||||
},
|
||||
"tag.category.recording-smp": {
|
||||
"defaultMessage": "Recording SMP"
|
||||
},
|
||||
"tag.category.reflections": {
|
||||
"defaultMessage": "Reflections"
|
||||
},
|
||||
"tag.category.roleplay": {
|
||||
"defaultMessage": "Roleplay"
|
||||
},
|
||||
"tag.category.rpg": {
|
||||
"defaultMessage": "RPG"
|
||||
},
|
||||
"tag.category.screenshot": {
|
||||
"defaultMessage": "Screenshot"
|
||||
},
|
||||
@@ -1502,18 +1724,36 @@
|
||||
"tag.category.simplistic": {
|
||||
"defaultMessage": "Simplistic"
|
||||
},
|
||||
"tag.category.skyblock": {
|
||||
"defaultMessage": "Skyblock"
|
||||
},
|
||||
"tag.category.smp": {
|
||||
"defaultMessage": "SMP"
|
||||
},
|
||||
"tag.category.social": {
|
||||
"defaultMessage": "Social"
|
||||
},
|
||||
"tag.category.storage": {
|
||||
"defaultMessage": "Storage"
|
||||
},
|
||||
"tag.category.survival-mode": {
|
||||
"defaultMessage": "Survival Mode"
|
||||
},
|
||||
"tag.category.teams": {
|
||||
"defaultMessage": "Teams"
|
||||
},
|
||||
"tag.category.technical": {
|
||||
"defaultMessage": "Technical"
|
||||
},
|
||||
"tag.category.technology": {
|
||||
"defaultMessage": "Technology"
|
||||
},
|
||||
"tag.category.themed": {
|
||||
"defaultMessage": "Themed"
|
||||
},
|
||||
"tag.category.towns": {
|
||||
"defaultMessage": "Towns"
|
||||
},
|
||||
"tag.category.transportation": {
|
||||
"defaultMessage": "Transportation"
|
||||
},
|
||||
@@ -1526,6 +1766,12 @@
|
||||
"tag.category.vanilla-like": {
|
||||
"defaultMessage": "Vanilla Like"
|
||||
},
|
||||
"tag.category.whitelisted": {
|
||||
"defaultMessage": "Whitelisted"
|
||||
},
|
||||
"tag.category.world-resets": {
|
||||
"defaultMessage": "World Resets"
|
||||
},
|
||||
"tag.category.worldgen": {
|
||||
"defaultMessage": "World Generation"
|
||||
},
|
||||
|
||||
@@ -81,6 +81,8 @@ export function createContext<ContextValue>(
|
||||
export * from './api-client'
|
||||
export * from './i18n'
|
||||
export * from './page-context'
|
||||
export * from './popup-notifications'
|
||||
export * from './project-page'
|
||||
export * from './project-page-new'
|
||||
export * from './server-context'
|
||||
export * from './web-notifications'
|
||||
|
||||
79
packages/ui/src/providers/popup-notifications.ts
Normal file
79
packages/ui/src/providers/popup-notifications.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createContext } from '.'
|
||||
|
||||
export interface PopupNotificationButton {
|
||||
label: string
|
||||
action: () => void
|
||||
color?: 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'standard'
|
||||
}
|
||||
|
||||
export interface PopupNotification {
|
||||
id: string | number
|
||||
title: string
|
||||
text?: string
|
||||
type?: 'error' | 'warning' | 'success' | 'info'
|
||||
buttons?: PopupNotificationButton[]
|
||||
autoCloseMs?: number | null
|
||||
timer?: NodeJS.Timeout
|
||||
}
|
||||
|
||||
export abstract class AbstractPopupNotificationManager {
|
||||
protected readonly DEFAULT_AUTO_CLOSE_MS = 30 * 1000
|
||||
|
||||
abstract getNotifications(): PopupNotification[]
|
||||
|
||||
protected abstract addNotificationToStorage(notification: PopupNotification): void
|
||||
protected abstract removeNotificationFromStorage(id: string | number): void
|
||||
protected abstract clearAllNotificationsFromStorage(): void
|
||||
|
||||
addPopupNotification = (
|
||||
notification: Omit<PopupNotification, 'id' | 'timer'>,
|
||||
): PopupNotification => {
|
||||
const newNotification: PopupNotification = {
|
||||
...notification,
|
||||
id: Date.now() + Math.random(),
|
||||
}
|
||||
this.setNotificationTimer(newNotification)
|
||||
this.addNotificationToStorage(newNotification)
|
||||
return newNotification
|
||||
}
|
||||
|
||||
removeNotification = (id: string | number): void => {
|
||||
const notifications = this.getNotifications()
|
||||
const notification = notifications.find((n) => n.id === id)
|
||||
if (notification) {
|
||||
this.clearNotificationTimer(notification)
|
||||
this.removeNotificationFromStorage(id)
|
||||
}
|
||||
}
|
||||
|
||||
clearAllNotifications = (): void => {
|
||||
this.getNotifications().forEach((n) => this.clearNotificationTimer(n))
|
||||
this.clearAllNotificationsFromStorage()
|
||||
}
|
||||
|
||||
setNotificationTimer = (notification: PopupNotification): void => {
|
||||
if (!notification) return
|
||||
this.clearNotificationTimer(notification)
|
||||
|
||||
if (notification.autoCloseMs === null) return
|
||||
|
||||
const delay = notification.autoCloseMs ?? this.DEFAULT_AUTO_CLOSE_MS
|
||||
notification.timer = setTimeout(() => {
|
||||
this.removeNotification(notification.id)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
stopNotificationTimer = (notification: PopupNotification): void => {
|
||||
this.clearNotificationTimer(notification)
|
||||
}
|
||||
|
||||
private clearNotificationTimer(notification: PopupNotification): void {
|
||||
if (notification.timer) {
|
||||
clearTimeout(notification.timer)
|
||||
notification.timer = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const [injectPopupNotificationManager, providePopupNotificationManager] =
|
||||
createContext<AbstractPopupNotificationManager>('root', 'popupNotificationManager')
|
||||
62
packages/ui/src/providers/project-page-new.ts
Normal file
62
packages/ui/src/providers/project-page-new.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Labrinth } from '@modrinth/api-client/src/modules/types'
|
||||
import type { UseQueryReturnType } from '@tanstack/vue-query'
|
||||
import type { ComputedRef, Reactive, Ref } from 'vue'
|
||||
|
||||
import { createContext } from '.'
|
||||
|
||||
/**
|
||||
* Computed version with additional display properties
|
||||
*/
|
||||
export interface ComputedVersion extends Labrinth.Versions.v3.Version {
|
||||
displayUrlEnding: string
|
||||
primaryFile: Labrinth.Versions.v3.Version['files'][number] | null
|
||||
author: Labrinth.Projects.v3.TeamMember | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated user info for permission checks
|
||||
*/
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
username: string
|
||||
role: string
|
||||
avatar_url?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for the new project page system
|
||||
*/
|
||||
export interface NewProjectPageContext {
|
||||
// Core project data
|
||||
project: Ref<Labrinth.Projects.v3.Project>
|
||||
|
||||
// Async loaded data with loading states
|
||||
organization: Reactive<UseQueryReturnType<Labrinth.Organizations.v3.Organization | null, Error>>
|
||||
members: Reactive<UseQueryReturnType<Labrinth.Projects.v3.TeamMember[], Error>>
|
||||
versions: Reactive<UseQueryReturnType<ComputedVersion[], Error>>
|
||||
dependencies: Reactive<UseQueryReturnType<Labrinth.Projects.v3.ProjectDependencies, Error>>
|
||||
thread: Reactive<UseQueryReturnType<Labrinth.Threads.v3.Thread | null, Error>>
|
||||
|
||||
// Current authenticated user's membership (null if not a member)
|
||||
currentMember: ComputedRef<Labrinth.Projects.v3.TeamMember | null>
|
||||
|
||||
// Auth user for permission checks
|
||||
authUser: Ref<AuthUser | null>
|
||||
|
||||
// Link generation functions for cross-platform compatibility
|
||||
createUserLink: (user: Labrinth.Users.v3.User) => string
|
||||
createOrgLink: (org: Labrinth.Organizations.v3.Organization) => string
|
||||
createVersionLink: (version: ComputedVersion) => string
|
||||
|
||||
// Refresh functions
|
||||
refreshProject: () => Promise<void>
|
||||
refreshVersions: () => Promise<void>
|
||||
refreshMembers: () => Promise<void>
|
||||
refreshThread: () => Promise<void>
|
||||
|
||||
// Project base path for navigation
|
||||
basePath: Ref<string>
|
||||
}
|
||||
|
||||
export const [injectNewProjectPageContext, provideNewProjectPageContext] =
|
||||
createContext<NewProjectPageContext>('root', 'newProjectPageContext')
|
||||
@@ -26,6 +26,7 @@ export interface ProjectPageContext {
|
||||
|
||||
// Mutation functions
|
||||
patchProject: (data: Record<string, unknown>, quiet?: boolean) => Promise<boolean>
|
||||
patchProjectV3: (data: Record<string, unknown>, quiet?: boolean) => Promise<boolean>
|
||||
patchIcon: (icon: File) => Promise<boolean>
|
||||
setProcessing: () => Promise<void>
|
||||
createGalleryItem: (
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import {
|
||||
DownloadIcon,
|
||||
HeartIcon,
|
||||
SettingsIcon,
|
||||
ShareIcon,
|
||||
TrashIcon,
|
||||
UserIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import Combobox from '../../components/base/Combobox.vue'
|
||||
@@ -53,60 +45,26 @@ export const Disabled: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const IconSlot: Story = {
|
||||
export const WithSubLabels: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: 'download', label: 'Download', icon: DownloadIcon },
|
||||
{ value: 'share', label: 'Share', icon: ShareIcon },
|
||||
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
|
||||
{ type: 'divider' },
|
||||
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
|
||||
{ value: 'profile', label: 'Profile', icon: UserIcon },
|
||||
{ type: 'divider' },
|
||||
{ value: 'delete', label: 'Delete', icon: TrashIcon, disabled: true },
|
||||
],
|
||||
placeholder: 'Select an action',
|
||||
listbox: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const IconSlotSearchable: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: 'download', label: 'Download', icon: DownloadIcon },
|
||||
{ value: 'share', label: 'Share', icon: ShareIcon },
|
||||
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
|
||||
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
|
||||
{ value: 'profile', label: 'Profile', icon: UserIcon },
|
||||
{ value: 'delete', label: 'Delete', icon: TrashIcon },
|
||||
],
|
||||
placeholder: 'Select an action',
|
||||
searchable: true,
|
||||
searchPlaceholder: 'Search actions...',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithSelectedOption: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: '1', label: 'Option 1' },
|
||||
{ value: '2', label: 'Option 2' },
|
||||
{ value: '3', label: 'Option 3' },
|
||||
],
|
||||
modelValue: '2',
|
||||
options: [
|
||||
{ value: '1', label: 'Fabric', subLabel: 'Lightweight modding toolchain' },
|
||||
{ value: '2', label: 'Forge', subLabel: 'The original Minecraft modding API' },
|
||||
{ value: '3', label: 'NeoForge', subLabel: 'Community-driven Forge fork' },
|
||||
{ value: '4', label: 'Quilt', subLabel: 'The mod-loader that cares' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const WithSelectedOptionAndIcon: Story = {
|
||||
export const MixedSubLabels: Story = {
|
||||
args: {
|
||||
options: [
|
||||
{ value: 'download', label: 'Download', icon: DownloadIcon },
|
||||
{ value: 'share', label: 'Share', icon: ShareIcon },
|
||||
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
|
||||
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
|
||||
{ value: 'profile', label: 'Profile', icon: UserIcon },
|
||||
{ value: '1', label: 'Minecraft', subLabel: 'The base game' },
|
||||
{ value: '2', label: 'Fabric' },
|
||||
{ value: '3', label: 'Forge', subLabel: 'Supports most mods' },
|
||||
{ value: '4', label: 'NeoForge' },
|
||||
{ value: '5', label: 'Quilt', subLabel: 'Fabric-compatible' },
|
||||
],
|
||||
modelValue: 'favorite',
|
||||
showIconInSelected: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -18,6 +18,18 @@ export const Small: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const Medium: Story = {
|
||||
args: {
|
||||
size: 'medium',
|
||||
},
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
size: 'large',
|
||||
},
|
||||
}
|
||||
|
||||
export const MultipleFiles: Story = {
|
||||
args: {
|
||||
multiple: true,
|
||||
|
||||
618
packages/ui/src/stories/instances/ModpackContentModal.stories.ts
Normal file
618
packages/ui/src/stories/instances/ModpackContentModal.stories.ts
Normal file
@@ -0,0 +1,618 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '../../components/base/ButtonStyled.vue'
|
||||
import ModpackContentModal from '../../components/instances/modals/ModpackContentModal.vue'
|
||||
import type { ContentItem } from '../../components/instances/types'
|
||||
|
||||
// Sample modpack content items (representing mods included in a modpack)
|
||||
const sodiumItem: ContentItem = {
|
||||
file_name: 'sodium-fabric-0.8.2+mc1.21.1.jar',
|
||||
file_path: '',
|
||||
hash: '',
|
||||
size: 1024000,
|
||||
enabled: true,
|
||||
project_type: 'mod',
|
||||
project: {
|
||||
id: 'AANobbMI',
|
||||
slug: 'sodium',
|
||||
title: 'Sodium',
|
||||
icon_url:
|
||||
'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp',
|
||||
},
|
||||
version: {
|
||||
id: '59wygFUQ',
|
||||
version_number: 'mc1.21.1-0.8.2-fabric',
|
||||
file_name: 'sodium-fabric-0.8.2+mc1.21.1.jar',
|
||||
},
|
||||
owner: {
|
||||
id: 'DzLrfrbK',
|
||||
name: 'IMS',
|
||||
avatar_url: 'https://avatars3.githubusercontent.com/u/31803019?v=4',
|
||||
type: 'user',
|
||||
},
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
}
|
||||
|
||||
const lithiumItem: ContentItem = {
|
||||
file_name: 'lithium-fabric-0.14.3+mc1.21.1.jar',
|
||||
file_path: '',
|
||||
hash: '',
|
||||
size: 512000,
|
||||
enabled: true,
|
||||
project_type: 'mod',
|
||||
project: {
|
||||
id: 'gvQqBUqZ',
|
||||
slug: 'lithium',
|
||||
title: 'Lithium',
|
||||
icon_url:
|
||||
'https://cdn.modrinth.com/data/gvQqBUqZ/d6a1873d52b7d1c82b9a8d9b1889c9c1a29ae92d_96.webp',
|
||||
},
|
||||
version: {
|
||||
id: 'abc123',
|
||||
version_number: 'mc1.21.1-0.14.3',
|
||||
file_name: 'lithium-fabric-0.14.3+mc1.21.1.jar',
|
||||
},
|
||||
owner: {
|
||||
id: 'DzLrfrbK',
|
||||
name: 'IMS',
|
||||
avatar_url: 'https://avatars3.githubusercontent.com/u/31803019?v=4',
|
||||
type: 'user',
|
||||
},
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
}
|
||||
|
||||
const fabricApiItem: ContentItem = {
|
||||
file_name: 'fabric-api-0.141.3+26.1.jar',
|
||||
file_path: '',
|
||||
hash: '',
|
||||
size: 2048000,
|
||||
enabled: true,
|
||||
project_type: 'mod',
|
||||
project: {
|
||||
id: 'P7dR8mSH',
|
||||
slug: 'fabric-api',
|
||||
title: 'Fabric API',
|
||||
icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
|
||||
},
|
||||
version: {
|
||||
id: 'Lwa1Q6e4',
|
||||
version_number: '0.141.3+26.1',
|
||||
file_name: 'fabric-api-0.141.3+26.1.jar',
|
||||
},
|
||||
owner: {
|
||||
id: 'BZoBsPo6',
|
||||
name: 'FabricMC',
|
||||
avatar_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
|
||||
type: 'organization',
|
||||
},
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
}
|
||||
|
||||
const modMenuItem: ContentItem = {
|
||||
file_name: 'modmenu-16.0.0.jar',
|
||||
file_path: '',
|
||||
hash: '',
|
||||
size: 256000,
|
||||
enabled: true,
|
||||
project_type: 'mod',
|
||||
project: {
|
||||
id: 'mOgUt4GM',
|
||||
slug: 'modmenu',
|
||||
title: 'Mod Menu',
|
||||
icon_url: 'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png',
|
||||
},
|
||||
version: {
|
||||
id: 'QuU0ciaR',
|
||||
version_number: '16.0.0',
|
||||
file_name: 'modmenu-16.0.0.jar',
|
||||
},
|
||||
owner: {
|
||||
id: 'u2',
|
||||
name: 'Prospector',
|
||||
type: 'user',
|
||||
},
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
}
|
||||
|
||||
const irisItem: ContentItem = {
|
||||
file_name: 'iris-1.8.0+mc1.21.1.jar',
|
||||
file_path: '',
|
||||
hash: '',
|
||||
size: 1536000,
|
||||
enabled: true,
|
||||
project_type: 'mod',
|
||||
project: {
|
||||
id: 'YL57xq9U',
|
||||
slug: 'iris',
|
||||
title: 'Iris Shaders',
|
||||
icon_url: 'https://cdn.modrinth.com/data/YL57xq9U/icon.png',
|
||||
},
|
||||
version: {
|
||||
id: 'iris123',
|
||||
version_number: '1.8.0+mc1.21.1',
|
||||
file_name: 'iris-1.8.0+mc1.21.1.jar',
|
||||
},
|
||||
owner: {
|
||||
id: 'coderbot',
|
||||
name: 'coderbot',
|
||||
type: 'user',
|
||||
},
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
}
|
||||
|
||||
const entityModelFeaturesItem: ContentItem = {
|
||||
file_name: 'entity-model-features-fabric-2.4.1+mc1.21.1.jar',
|
||||
file_path: '',
|
||||
hash: '',
|
||||
size: 768000,
|
||||
enabled: true,
|
||||
project_type: 'mod',
|
||||
project: {
|
||||
id: 'emf123',
|
||||
slug: 'entity-model-features',
|
||||
title: '[EMF] Entity Model Features',
|
||||
icon_url:
|
||||
'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp',
|
||||
},
|
||||
version: {
|
||||
id: 'emfv1',
|
||||
version_number: '2.4.1',
|
||||
file_name: 'Entity_model_features_fabric_1.21.1-2.4.1.jar',
|
||||
},
|
||||
owner: {
|
||||
id: 'traben',
|
||||
name: 'Traben',
|
||||
type: 'user',
|
||||
},
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
}
|
||||
|
||||
const entityTextureFeaturesItem: ContentItem = {
|
||||
file_name: 'entity-texture-features-fabric-6.2.9+mc1.21.1.jar',
|
||||
file_path: '',
|
||||
hash: '',
|
||||
size: 640000,
|
||||
enabled: true,
|
||||
project_type: 'mod',
|
||||
project: {
|
||||
id: 'etf456',
|
||||
slug: 'entity-texture-features',
|
||||
title: '[ETF] Entity Texture Features',
|
||||
icon_url: 'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png',
|
||||
},
|
||||
version: {
|
||||
id: 'etfv1',
|
||||
version_number: '6.2.9',
|
||||
file_name: 'Entity_texture_features_fabric_1.21.1-6.2.9.jar',
|
||||
},
|
||||
owner: {
|
||||
id: 'traben',
|
||||
name: 'Traben',
|
||||
type: 'user',
|
||||
},
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
}
|
||||
|
||||
// Shader pack item
|
||||
const complementaryShaderItem: ContentItem = {
|
||||
file_name: 'ComplementaryReimagined_r5.3.zip',
|
||||
file_path: '',
|
||||
hash: '',
|
||||
size: 2048000,
|
||||
enabled: true,
|
||||
project_type: 'shader',
|
||||
project: {
|
||||
id: 'shader1',
|
||||
slug: 'complementary-reimagined',
|
||||
title: 'Complementary Reimagined',
|
||||
icon_url: 'https://cdn.modrinth.com/data/HVnmMxH1/icon.png',
|
||||
},
|
||||
version: {
|
||||
id: 'shaderv1',
|
||||
version_number: 'r5.3',
|
||||
file_name: 'ComplementaryReimagined_r5.3.zip',
|
||||
},
|
||||
owner: {
|
||||
id: 'emin',
|
||||
name: 'EminGT',
|
||||
type: 'user',
|
||||
},
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
}
|
||||
|
||||
const bslShaderItem: ContentItem = {
|
||||
file_name: 'BSL_v8.2.09.zip',
|
||||
file_path: '',
|
||||
hash: '',
|
||||
size: 1024000,
|
||||
enabled: true,
|
||||
project_type: 'shader',
|
||||
project: {
|
||||
id: 'shader2',
|
||||
slug: 'bsl-shaders',
|
||||
title: 'BSL Shaders',
|
||||
icon_url: 'https://cdn.modrinth.com/data/Q1vvjJYV/icon.png',
|
||||
},
|
||||
version: {
|
||||
id: 'shaderv2',
|
||||
version_number: 'v8.2.09',
|
||||
file_name: 'BSL_v8.2.09.zip',
|
||||
},
|
||||
owner: {
|
||||
id: 'capt',
|
||||
name: 'CaptTatsu',
|
||||
type: 'user',
|
||||
},
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
}
|
||||
|
||||
// Resource pack items
|
||||
const faithfulItem: ContentItem = {
|
||||
file_name: 'Faithful 32x - 1.21.zip',
|
||||
file_path: '',
|
||||
hash: '',
|
||||
size: 8192000,
|
||||
enabled: true,
|
||||
project_type: 'resourcepack',
|
||||
project: {
|
||||
id: 'rp1',
|
||||
slug: 'faithful-32x',
|
||||
title: 'Faithful 32x',
|
||||
icon_url: 'https://cdn.modrinth.com/data/tAnpCviC/icon.png',
|
||||
},
|
||||
version: {
|
||||
id: 'rpv1',
|
||||
version_number: '1.21',
|
||||
file_name: 'Faithful 32x - 1.21.zip',
|
||||
},
|
||||
owner: {
|
||||
id: 'faithful',
|
||||
name: 'Faithful Resource Pack',
|
||||
avatar_url: 'https://cdn.modrinth.com/data/tAnpCviC/icon.png',
|
||||
type: 'organization',
|
||||
},
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
}
|
||||
|
||||
const vanillaTweaksItem: ContentItem = {
|
||||
file_name: 'VanillaTweaks_r3.zip',
|
||||
file_path: '',
|
||||
hash: '',
|
||||
size: 512000,
|
||||
enabled: true,
|
||||
project_type: 'resourcepack',
|
||||
project: {
|
||||
id: 'rp2',
|
||||
slug: 'vanilla-tweaks',
|
||||
title: 'Vanilla Tweaks',
|
||||
icon_url: null,
|
||||
},
|
||||
version: {
|
||||
id: 'rpv2',
|
||||
version_number: 'r3',
|
||||
file_name: 'VanillaTweaks_r3.zip',
|
||||
},
|
||||
owner: {
|
||||
id: 'xisuma',
|
||||
name: 'Xisumavoid',
|
||||
type: 'user',
|
||||
},
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
}
|
||||
|
||||
const stayTrueItem: ContentItem = {
|
||||
file_name: 'Stay_True_1.21.zip',
|
||||
file_path: '',
|
||||
hash: '',
|
||||
size: 4096000,
|
||||
enabled: true,
|
||||
project_type: 'resourcepack',
|
||||
project: {
|
||||
id: 'rp3',
|
||||
slug: 'stay-true',
|
||||
title: 'Stay True',
|
||||
icon_url: 'https://cdn.modrinth.com/data/HVnmMxH1/icon.png',
|
||||
},
|
||||
version: {
|
||||
id: 'rpv3',
|
||||
version_number: '1.21',
|
||||
file_name: 'Stay_True_1.21.zip',
|
||||
},
|
||||
owner: {
|
||||
id: 'hallowed',
|
||||
name: 'HallowedST',
|
||||
type: 'user',
|
||||
},
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
}
|
||||
|
||||
// Mixed content (mods + shaders + resource packs)
|
||||
const mixedModpackContent: ContentItem[] = [
|
||||
sodiumItem,
|
||||
lithiumItem,
|
||||
fabricApiItem,
|
||||
modMenuItem,
|
||||
irisItem,
|
||||
entityModelFeaturesItem,
|
||||
entityTextureFeaturesItem,
|
||||
complementaryShaderItem,
|
||||
bslShaderItem,
|
||||
faithfulItem,
|
||||
vanillaTweaksItem,
|
||||
stayTrueItem,
|
||||
]
|
||||
|
||||
// Mods only
|
||||
const modsOnlyContent: ContentItem[] = [
|
||||
sodiumItem,
|
||||
lithiumItem,
|
||||
fabricApiItem,
|
||||
modMenuItem,
|
||||
irisItem,
|
||||
entityModelFeaturesItem,
|
||||
entityTextureFeaturesItem,
|
||||
]
|
||||
|
||||
// Large modpack content (40+ items for testing scrolling)
|
||||
const largeModpackContent: ContentItem[] = [
|
||||
...mixedModpackContent,
|
||||
...Array.from({ length: 35 }, (_, i) => ({
|
||||
...sodiumItem,
|
||||
file_name: `mod-${i + 1}-1.0.0.jar`,
|
||||
project: {
|
||||
id: `mod-${i + 1}`,
|
||||
slug: `mod-${i + 1}`,
|
||||
title: `Example Mod ${i + 1}`,
|
||||
icon_url:
|
||||
i % 3 === 0
|
||||
? sodiumItem.project!.icon_url
|
||||
: i % 3 === 1
|
||||
? fabricApiItem.project!.icon_url
|
||||
: modMenuItem.project!.icon_url,
|
||||
},
|
||||
version: {
|
||||
id: `v${i + 1}`,
|
||||
version_number: `1.${i}.0`,
|
||||
file_name: `mod-${i + 1}-1.0.0.jar`,
|
||||
},
|
||||
owner: i % 2 === 0 ? sodiumItem.owner : fabricApiItem.owner,
|
||||
})),
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'Instances/ModpackContentModal',
|
||||
component: ModpackContentModal,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies Meta<typeof ModpackContentModal>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// ============================================
|
||||
// Basic Examples
|
||||
// ============================================
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { ModpackContentModal, ButtonStyled },
|
||||
setup() {
|
||||
const modalRef = ref<InstanceType<typeof ModpackContentModal> | null>(null)
|
||||
const openModal = () => modalRef.value?.show(mixedModpackContent)
|
||||
return { modalRef, openModal }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="openModal">View Modpack Content (Mixed)</button>
|
||||
</ButtonStyled>
|
||||
<ModpackContentModal
|
||||
ref="modalRef"
|
||||
modpack-name="Cobblemon Official Modpack"
|
||||
modpack-icon-url="https://cdn.modrinth.com/data/5FFgwNNP/icon.png"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const ModsOnly: Story = {
|
||||
render: () => ({
|
||||
components: { ModpackContentModal, ButtonStyled },
|
||||
setup() {
|
||||
const modalRef = ref<InstanceType<typeof ModpackContentModal> | null>(null)
|
||||
const openModal = () => modalRef.value?.show(modsOnlyContent)
|
||||
return { modalRef, openModal }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="openModal">View Modpack Content (Mods Only)</button>
|
||||
</ButtonStyled>
|
||||
<ModpackContentModal ref="modalRef" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Loading State
|
||||
// ============================================
|
||||
|
||||
export const LoadingState: Story = {
|
||||
render: () => ({
|
||||
components: { ModpackContentModal, ButtonStyled },
|
||||
setup() {
|
||||
const modalRef = ref<InstanceType<typeof ModpackContentModal> | null>(null)
|
||||
const openModal = () => {
|
||||
modalRef.value?.showLoading()
|
||||
// Simulate loading delay
|
||||
setTimeout(() => {
|
||||
modalRef.value?.show(mixedModpackContent)
|
||||
}, 2000)
|
||||
}
|
||||
return { modalRef, openModal }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="openModal">View Content (With Loading)</button>
|
||||
</ButtonStyled>
|
||||
<ModpackContentModal
|
||||
ref="modalRef"
|
||||
modpack-name="Fabulously Optimized"
|
||||
modpack-icon-url="https://cdn.modrinth.com/data/1KVo5zza/icon.png"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Empty State
|
||||
// ============================================
|
||||
|
||||
export const EmptyContent: Story = {
|
||||
render: () => ({
|
||||
components: { ModpackContentModal, ButtonStyled },
|
||||
setup() {
|
||||
const modalRef = ref<InstanceType<typeof ModpackContentModal> | null>(null)
|
||||
const openModal = () => modalRef.value?.show([])
|
||||
return { modalRef, openModal }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="openModal">View Empty Modpack</button>
|
||||
</ButtonStyled>
|
||||
<ModpackContentModal ref="modalRef" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Large Content List
|
||||
// ============================================
|
||||
|
||||
export const LargeModpack: Story = {
|
||||
render: () => ({
|
||||
components: { ModpackContentModal, ButtonStyled },
|
||||
setup() {
|
||||
const modalRef = ref<InstanceType<typeof ModpackContentModal> | null>(null)
|
||||
const openModal = () => modalRef.value?.show(largeModpackContent)
|
||||
return { modalRef, openModal }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="openModal">View Large Modpack (47 items)</button>
|
||||
</ButtonStyled>
|
||||
<ModpackContentModal
|
||||
ref="modalRef"
|
||||
modpack-name="All the Mods 10"
|
||||
modpack-icon-url="https://cdn.modrinth.com/data/1KVo5zza/icon.png"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Search Functionality
|
||||
// ============================================
|
||||
|
||||
export const SearchDemo: Story = {
|
||||
render: () => ({
|
||||
components: { ModpackContentModal, ButtonStyled },
|
||||
setup() {
|
||||
const modalRef = ref<InstanceType<typeof ModpackContentModal> | null>(null)
|
||||
const openModal = () => modalRef.value?.show(mixedModpackContent)
|
||||
return { modalRef, openModal }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div class="flex flex-col gap-4 items-center">
|
||||
<p class="text-sm text-secondary max-w-md text-center">
|
||||
Click the button and try searching for "sodium", "shader", or "faithful" to test the search functionality.
|
||||
</p>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="openModal">Test Search</button>
|
||||
</ButtonStyled>
|
||||
<ModpackContentModal ref="modalRef" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Filter Demo
|
||||
// ============================================
|
||||
|
||||
export const FilterDemo: Story = {
|
||||
render: () => ({
|
||||
components: { ModpackContentModal, ButtonStyled },
|
||||
setup() {
|
||||
const modalRef = ref<InstanceType<typeof ModpackContentModal> | null>(null)
|
||||
const openModal = () => modalRef.value?.show(mixedModpackContent)
|
||||
return { modalRef, openModal }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div class="flex flex-col gap-4 items-center">
|
||||
<p class="text-sm text-secondary max-w-md text-center">
|
||||
Click the button and try the filter chips (Mods, Shaders, Resource Packs) to filter content by type.
|
||||
</p>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="openModal">Test Filters</button>
|
||||
</ButtonStyled>
|
||||
<ModpackContentModal ref="modalRef" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Mixed Owner Types
|
||||
// ============================================
|
||||
|
||||
export const MixedOwnerTypes: Story = {
|
||||
render: () => ({
|
||||
components: { ModpackContentModal, ButtonStyled },
|
||||
setup() {
|
||||
const modalRef = ref<InstanceType<typeof ModpackContentModal> | null>(null)
|
||||
// Mix of user and organization owners
|
||||
const mixedContent = [
|
||||
sodiumItem, // User owner
|
||||
fabricApiItem, // Organization owner
|
||||
modMenuItem, // User owner without avatar
|
||||
faithfulItem, // Organization owner
|
||||
]
|
||||
const openModal = () => modalRef.value?.show(mixedContent)
|
||||
return { modalRef, openModal }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div class="flex flex-col gap-4 items-center">
|
||||
<p class="text-sm text-secondary max-w-md text-center">
|
||||
Shows content with different owner types: users (circular avatar) and organizations (rounded + icon).
|
||||
</p>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="openModal">View Mixed Owners</button>
|
||||
</ButtonStyled>
|
||||
<ModpackContentModal ref="modalRef" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
153
packages/ui/src/stories/nav/NotificationPanel.stories.ts
Normal file
153
packages/ui/src/stories/nav/NotificationPanel.stories.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ButtonStyled from '../../components/base/ButtonStyled.vue'
|
||||
import NotificationPanel from '../../components/nav/NotificationPanel.vue'
|
||||
import { injectNotificationManager } from '../../providers'
|
||||
|
||||
const meta = {
|
||||
title: 'Nav/NotificationPanel',
|
||||
component: NotificationPanel,
|
||||
} satisfies Meta<typeof NotificationPanel>
|
||||
|
||||
export default meta
|
||||
|
||||
export const Default: StoryObj = {
|
||||
render: () => ({
|
||||
components: { NotificationPanel, ButtonStyled },
|
||||
setup() {
|
||||
const notificationManager = injectNotificationManager()
|
||||
|
||||
const showSuccess = () => {
|
||||
notificationManager.addNotification({
|
||||
title: 'Success',
|
||||
text: 'Your project has been published successfully.',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
|
||||
const showError = () => {
|
||||
notificationManager.addNotification({
|
||||
title: 'An error occurred',
|
||||
text: 'Failed to upload file. Please try again.',
|
||||
type: 'error',
|
||||
errorCode: 'ERR_UPLOAD_FAILED',
|
||||
})
|
||||
}
|
||||
|
||||
const showWarning = () => {
|
||||
notificationManager.addNotification({
|
||||
title: 'Warning',
|
||||
text: 'Your session is about to expire.',
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
|
||||
const showInfo = () => {
|
||||
notificationManager.addNotification({
|
||||
title: 'Info',
|
||||
text: 'A new version of the modpack is available.',
|
||||
type: 'info',
|
||||
})
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
notificationManager.clearAllNotifications()
|
||||
}
|
||||
|
||||
return { showSuccess, showError, showWarning, showInfo, clearAll }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled color="green">
|
||||
<button @click="showSuccess">Success</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="showError">Error</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="showWarning">Warning</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="blue">
|
||||
<button @click="showInfo">Info</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="clearAll">Clear All</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<NotificationPanel />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const LeftSide: StoryObj = {
|
||||
render: () => ({
|
||||
components: { NotificationPanel, ButtonStyled },
|
||||
setup() {
|
||||
const notificationManager = injectNotificationManager()
|
||||
notificationManager.setNotificationLocation('left')
|
||||
|
||||
const showSuccess = () => {
|
||||
notificationManager.addNotification({
|
||||
title: 'Success',
|
||||
text: 'Your project has been published successfully.',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
|
||||
const showError = () => {
|
||||
notificationManager.addNotification({
|
||||
title: 'An error occurred',
|
||||
text: 'Failed to upload file. Please try again.',
|
||||
type: 'error',
|
||||
errorCode: 'ERR_UPLOAD_FAILED',
|
||||
})
|
||||
}
|
||||
|
||||
const showWarning = () => {
|
||||
notificationManager.addNotification({
|
||||
title: 'Warning',
|
||||
text: 'Your session is about to expire.',
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
|
||||
const showInfo = () => {
|
||||
notificationManager.addNotification({
|
||||
title: 'Info',
|
||||
text: 'A new version of the modpack is available.',
|
||||
type: 'info',
|
||||
})
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
notificationManager.clearAllNotifications()
|
||||
}
|
||||
|
||||
return { showSuccess, showError, showWarning, showInfo, clearAll }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled color="green">
|
||||
<button @click="showSuccess">Success</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="showError">Error</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="showWarning">Warning</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="blue">
|
||||
<button @click="showInfo">Info</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="clearAll">Clear All</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<NotificationPanel />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
153
packages/ui/src/stories/nav/PopupNotificationPanel.stories.ts
Normal file
153
packages/ui/src/stories/nav/PopupNotificationPanel.stories.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ButtonStyled from '../../components/base/ButtonStyled.vue'
|
||||
import PopupNotificationPanel from '../../components/nav/PopupNotificationPanel.vue'
|
||||
import { injectPopupNotificationManager } from '../../providers'
|
||||
|
||||
const meta = {
|
||||
title: 'Nav/PopupNotificationPanel',
|
||||
component: PopupNotificationPanel,
|
||||
} satisfies Meta<typeof PopupNotificationPanel>
|
||||
|
||||
export default meta
|
||||
|
||||
export const Default: StoryObj = {
|
||||
render: () => ({
|
||||
components: { PopupNotificationPanel, ButtonStyled },
|
||||
setup() {
|
||||
const popupManager = injectPopupNotificationManager()
|
||||
|
||||
const showSuccess = () => {
|
||||
popupManager.addPopupNotification({
|
||||
title: 'Install complete',
|
||||
text: 'Complex Gaming [Cobblemon] is installed and ready to play.',
|
||||
type: 'success',
|
||||
buttons: [
|
||||
{
|
||||
label: 'Launch game',
|
||||
action: () => console.log('Launch game clicked'),
|
||||
color: 'brand',
|
||||
},
|
||||
{
|
||||
label: 'Instance',
|
||||
action: () => console.log('Instance clicked'),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const showError = () => {
|
||||
popupManager.addPopupNotification({
|
||||
title: 'Download failed',
|
||||
text: 'Failed to download the modpack. Please try again.',
|
||||
type: 'error',
|
||||
buttons: [
|
||||
{
|
||||
label: 'Retry',
|
||||
action: () => console.log('Retry clicked'),
|
||||
color: 'red',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const showWarning = () => {
|
||||
popupManager.addPopupNotification({
|
||||
title: 'Update available',
|
||||
text: "Modrinth App v2.1.0 is available now! Since you're on a metered network, we didn't automatically download it.",
|
||||
type: 'warning',
|
||||
autoCloseMs: null,
|
||||
buttons: [
|
||||
{
|
||||
label: 'Download (45 MB)',
|
||||
action: () => console.log('Download clicked'),
|
||||
color: 'brand',
|
||||
},
|
||||
{
|
||||
label: 'Changelog',
|
||||
action: () => console.log('Changelog clicked'),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const showInfo = () => {
|
||||
popupManager.addPopupNotification({
|
||||
title: 'Download complete',
|
||||
text: 'Modrinth App v2.1.0 has finished downloading. Reload to update now.',
|
||||
type: 'info',
|
||||
buttons: [
|
||||
{
|
||||
label: 'Reload',
|
||||
action: () => console.log('Reload clicked'),
|
||||
color: 'brand',
|
||||
},
|
||||
{
|
||||
label: 'Changelog',
|
||||
action: () => console.log('Changelog clicked'),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const showNoButtons = () => {
|
||||
popupManager.addPopupNotification({
|
||||
title: 'Heads up',
|
||||
text: 'This notification has no action buttons and will auto-close in 30 seconds.',
|
||||
type: 'info',
|
||||
})
|
||||
}
|
||||
|
||||
const showPermanent = () => {
|
||||
popupManager.addPopupNotification({
|
||||
title: 'Permanent notification',
|
||||
text: 'This notification will stay open until manually dismissed.',
|
||||
type: 'warning',
|
||||
autoCloseMs: null,
|
||||
})
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
popupManager.clearAllNotifications()
|
||||
}
|
||||
|
||||
return {
|
||||
showSuccess,
|
||||
showError,
|
||||
showWarning,
|
||||
showInfo,
|
||||
showNoButtons,
|
||||
showPermanent,
|
||||
clearAll,
|
||||
}
|
||||
},
|
||||
template: /* html */ `
|
||||
<div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled color="green">
|
||||
<button @click="showSuccess">Install Complete</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="showError">Download Failed</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="showWarning">Update Available (Permanent)</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="blue">
|
||||
<button @click="showInfo">Download Complete</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="showNoButtons">No Buttons</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="showPermanent">Permanent</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="clearAll">Clear All</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<PopupNotificationPanel />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ProjectSidebarTags from '../../components/project/ProjectSidebarTags.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Project/ProjectSidebarTags',
|
||||
component: ProjectSidebarTags,
|
||||
render: (args) => ({
|
||||
components: { ProjectSidebarTags },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: /* html */ `
|
||||
<ProjectSidebarTags v-bind="args" />
|
||||
`,
|
||||
}),
|
||||
} satisfies Meta<typeof ProjectSidebarTags>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
project: {
|
||||
categories: ['adventure', 'technology', 'magic'],
|
||||
additional_categories: ['decoration', 'storage'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const CategoriesOnly: Story = {
|
||||
args: {
|
||||
project: {
|
||||
categories: ['adventure', 'technology'],
|
||||
additional_categories: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
project: {
|
||||
categories: [],
|
||||
additional_categories: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ProjectSidebarCompatibility from '../../components/project/ProjectSidebarCompatibility.vue'
|
||||
|
||||
const gameVersions: GameVersionTag[] = [
|
||||
{ version: '1.21.4', version_type: 'release', date: '2024-12-03', major: true },
|
||||
{ version: '1.21.3', version_type: 'release', date: '2024-10-23', major: false },
|
||||
{ version: '1.21.2', version_type: 'release', date: '2024-09-18', major: false },
|
||||
{ version: '1.21.1', version_type: 'release', date: '2024-08-08', major: true },
|
||||
{ version: '1.21', version_type: 'release', date: '2024-06-13', major: true },
|
||||
{ version: '1.20.6', version_type: 'release', date: '2024-04-29', major: false },
|
||||
{ version: '1.20.4', version_type: 'release', date: '2023-12-07', major: true },
|
||||
{ version: '1.20.2', version_type: 'release', date: '2023-09-21', major: true },
|
||||
{ version: '1.20.1', version_type: 'release', date: '2023-06-12', major: true },
|
||||
{ version: '1.20', version_type: 'release', date: '2023-06-07', major: true },
|
||||
]
|
||||
|
||||
const loaders: PlatformTag[] = [
|
||||
{ icon: '', name: 'fabric', supported_project_types: ['mod'] },
|
||||
{ icon: '', name: 'forge', supported_project_types: ['mod'] },
|
||||
{ icon: '', name: 'neoforge', supported_project_types: ['mod'] },
|
||||
{ icon: '', name: 'quilt', supported_project_types: ['mod'] },
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'Sidebar/ProjectSidebarCompatibility',
|
||||
component: ProjectSidebarCompatibility,
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div style="max-width: 300px"><story /></div>',
|
||||
}),
|
||||
],
|
||||
} satisfies Meta<typeof ProjectSidebarCompatibility>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
project: {
|
||||
actualProjectType: 'mod',
|
||||
project_type: 'mod',
|
||||
loaders: ['fabric', 'forge', 'neoforge'],
|
||||
client_side: 'required',
|
||||
server_side: 'optional',
|
||||
versions: [{ game_versions: ['1.21.4', '1.21.3', '1.21.1', '1.20.4', '1.20.1'] }],
|
||||
},
|
||||
tags: {
|
||||
gameVersions,
|
||||
loaders,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const ClientSideOnly: StoryObj = {
|
||||
render: () => ({
|
||||
components: { ProjectSidebarCompatibility },
|
||||
template: /* html */ `
|
||||
<div style="max-width: 300px">
|
||||
<ProjectSidebarCompatibility
|
||||
:project="{
|
||||
actualProjectType: 'mod',
|
||||
project_type: 'mod',
|
||||
loaders: ['fabric'],
|
||||
client_side: 'required',
|
||||
server_side: 'unsupported',
|
||||
versions: [{ game_versions: ['1.21.4', '1.21.1'] }],
|
||||
}"
|
||||
:tags="tags"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
setup() {
|
||||
return { tags: { gameVersions, loaders } }
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export const ServerSideOnly: StoryObj = {
|
||||
render: () => ({
|
||||
components: { ProjectSidebarCompatibility },
|
||||
template: /* html */ `
|
||||
<div style="max-width: 300px">
|
||||
<ProjectSidebarCompatibility
|
||||
:project="{
|
||||
actualProjectType: 'mod',
|
||||
project_type: 'mod',
|
||||
loaders: ['fabric', 'forge'],
|
||||
client_side: 'unsupported',
|
||||
server_side: 'required',
|
||||
versions: [{ game_versions: ['1.20.1', '1.20.4'] }],
|
||||
}"
|
||||
:tags="tags"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
setup() {
|
||||
return { tags: { gameVersions, loaders } }
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export const ResourcePack: StoryObj = {
|
||||
render: () => ({
|
||||
components: { ProjectSidebarCompatibility },
|
||||
template: /* html */ `
|
||||
<div style="max-width: 300px">
|
||||
<ProjectSidebarCompatibility
|
||||
:project="{
|
||||
actualProjectType: 'resourcepack',
|
||||
project_type: 'resourcepack',
|
||||
loaders: ['minecraft'],
|
||||
client_side: 'required',
|
||||
server_side: 'unsupported',
|
||||
versions: [{ game_versions: ['1.21.4', '1.21.3', '1.21.1', '1.21', '1.20.6', '1.20.4'] }],
|
||||
}"
|
||||
:tags="tags"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
setup() {
|
||||
return { tags: { gameVersions, loaders } }
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export const Modpack: StoryObj = {
|
||||
render: () => ({
|
||||
components: { ProjectSidebarCompatibility },
|
||||
template: /* html */ `
|
||||
<div style="max-width: 300px">
|
||||
<ProjectSidebarCompatibility
|
||||
:project="{
|
||||
actualProjectType: 'modpack',
|
||||
project_type: 'modpack',
|
||||
loaders: ['fabric'],
|
||||
client_side: 'required',
|
||||
server_side: 'required',
|
||||
versions: [{ game_versions: ['1.21.4'] }],
|
||||
}"
|
||||
:tags="tags"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
setup() {
|
||||
return { tags: { gameVersions, loaders } }
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ProjectSidebarCreators from '../../components/project/ProjectSidebarCreators.vue'
|
||||
|
||||
const mockMembers = [
|
||||
{
|
||||
id: 'member-1',
|
||||
role: 'Owner',
|
||||
is_owner: true,
|
||||
accepted: true,
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'jellysquid3',
|
||||
avatar_url: 'https://avatars.githubusercontent.com/u/31803019?v=4',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'member-2',
|
||||
role: 'Developer',
|
||||
is_owner: false,
|
||||
accepted: true,
|
||||
user: {
|
||||
id: 'user-2',
|
||||
username: 'modder42',
|
||||
avatar_url: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'member-3',
|
||||
role: 'Artist',
|
||||
is_owner: false,
|
||||
accepted: true,
|
||||
user: {
|
||||
id: 'user-3',
|
||||
username: 'pixelartist',
|
||||
avatar_url: '',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const mockOrganization = {
|
||||
id: 'org-1',
|
||||
slug: 'caffeine-mc',
|
||||
name: 'CaffeineMC',
|
||||
icon_url: 'https://avatars.githubusercontent.com/u/74333534?v=4',
|
||||
avatar_url: 'https://avatars.githubusercontent.com/u/74333534?v=4',
|
||||
members: [
|
||||
{
|
||||
id: 'member-1',
|
||||
role: 'Owner',
|
||||
is_owner: true,
|
||||
accepted: true,
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'jellysquid3',
|
||||
avatar_url: 'https://avatars.githubusercontent.com/u/31803019?v=4',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const noopLink = (s: string) => `/${s}`
|
||||
|
||||
const meta = {
|
||||
title: 'Sidebar/ProjectSidebarCreators',
|
||||
component: ProjectSidebarCreators,
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div style="max-width: 300px"><story /></div>',
|
||||
}),
|
||||
],
|
||||
} satisfies Meta<typeof ProjectSidebarCreators>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
members: mockMembers,
|
||||
orgLink: noopLink,
|
||||
userLink: (username: string) => `/user/${username}`,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithOrganization: Story = {
|
||||
args: {
|
||||
members: mockMembers,
|
||||
organization: mockOrganization,
|
||||
orgLink: (slug: string) => `/organization/${slug}`,
|
||||
userLink: (username: string) => `/user/${username}`,
|
||||
},
|
||||
}
|
||||
|
||||
export const SingleMember: Story = {
|
||||
args: {
|
||||
members: [mockMembers[0]],
|
||||
orgLink: noopLink,
|
||||
userLink: (username: string) => `/user/${username}`,
|
||||
},
|
||||
}
|
||||
|
||||
export const ExternalLinks: Story = {
|
||||
args: {
|
||||
members: mockMembers,
|
||||
organization: mockOrganization,
|
||||
orgLink: (slug: string) => `/organization/${slug}`,
|
||||
userLink: (username: string) => `/user/${username}`,
|
||||
linkTarget: '_blank',
|
||||
},
|
||||
}
|
||||
116
packages/ui/src/stories/sidebar/ProjectSidebarDetails.stories.ts
Normal file
116
packages/ui/src/stories/sidebar/ProjectSidebarDetails.stories.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ProjectSidebarDetails from '../../components/project/ProjectSidebarDetails.vue'
|
||||
|
||||
const now = new Date()
|
||||
const daysAgo = (days: number) => new Date(now.getTime() - days * 86400000).toISOString()
|
||||
|
||||
const meta = {
|
||||
title: 'Sidebar/ProjectSidebarDetails',
|
||||
component: ProjectSidebarDetails,
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div style="max-width: 300px"><story /></div>',
|
||||
}),
|
||||
],
|
||||
} satisfies Meta<typeof ProjectSidebarDetails>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
project: {
|
||||
id: 'project-1',
|
||||
published: daysAgo(365),
|
||||
updated: daysAgo(3),
|
||||
approved: daysAgo(360),
|
||||
queued: '',
|
||||
status: 'approved',
|
||||
license: {
|
||||
id: 'LGPL-3.0-only',
|
||||
url: 'https://www.gnu.org/licenses/lgpl-3.0.html',
|
||||
},
|
||||
},
|
||||
linkTarget: '_blank',
|
||||
hasVersions: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const AllRightsReserved: Story = {
|
||||
args: {
|
||||
project: {
|
||||
id: 'project-2',
|
||||
published: daysAgo(180),
|
||||
updated: daysAgo(7),
|
||||
approved: daysAgo(175),
|
||||
queued: '',
|
||||
status: 'approved',
|
||||
license: {
|
||||
id: 'LicenseRef-All-Rights-Reserved',
|
||||
url: '',
|
||||
},
|
||||
},
|
||||
linkTarget: '_blank',
|
||||
hasVersions: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomLicense: Story = {
|
||||
args: {
|
||||
project: {
|
||||
id: 'project-3',
|
||||
published: daysAgo(90),
|
||||
updated: daysAgo(1),
|
||||
approved: daysAgo(85),
|
||||
queued: '',
|
||||
status: 'approved',
|
||||
license: {
|
||||
id: 'LicenseRef-My-Custom-License',
|
||||
url: 'https://example.com/license',
|
||||
},
|
||||
},
|
||||
linkTarget: '_blank',
|
||||
hasVersions: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const Processing: Story = {
|
||||
args: {
|
||||
project: {
|
||||
id: 'project-4',
|
||||
published: daysAgo(2),
|
||||
updated: '',
|
||||
approved: '',
|
||||
queued: daysAgo(1),
|
||||
status: 'processing',
|
||||
license: {
|
||||
id: 'MIT',
|
||||
url: 'https://opensource.org/licenses/MIT',
|
||||
},
|
||||
},
|
||||
linkTarget: '_blank',
|
||||
hasVersions: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const HiddenLicense: Story = {
|
||||
args: {
|
||||
project: {
|
||||
id: 'project-5',
|
||||
published: daysAgo(30),
|
||||
updated: daysAgo(5),
|
||||
approved: daysAgo(28),
|
||||
queued: '',
|
||||
status: 'approved',
|
||||
license: {
|
||||
id: 'MIT',
|
||||
url: '',
|
||||
},
|
||||
},
|
||||
linkTarget: '_blank',
|
||||
hasVersions: true,
|
||||
hideLicense: true,
|
||||
},
|
||||
}
|
||||
110
packages/ui/src/stories/sidebar/ProjectSidebarLinks.stories.ts
Normal file
110
packages/ui/src/stories/sidebar/ProjectSidebarLinks.stories.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ProjectSidebarLinks from '../../components/project/ProjectSidebarLinks.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Sidebar/ProjectSidebarLinks',
|
||||
component: ProjectSidebarLinks,
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div style="max-width: 300px"><story /></div>',
|
||||
}),
|
||||
],
|
||||
} satisfies Meta<typeof ProjectSidebarLinks>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
project: {
|
||||
issues_url: 'https://github.com/example/mod/issues',
|
||||
source_url: 'https://github.com/example/mod',
|
||||
wiki_url: 'https://wiki.example.com',
|
||||
discord_url: 'https://discord.gg/example',
|
||||
site_url: 'https://example.com',
|
||||
donation_urls: [
|
||||
{ id: 'patreon', url: 'https://patreon.com/example' },
|
||||
{ id: 'ko-fi', url: 'https://ko-fi.com/example' },
|
||||
],
|
||||
},
|
||||
linkTarget: '_blank',
|
||||
},
|
||||
}
|
||||
|
||||
export const AllDonationTypes: StoryObj = {
|
||||
render: () => ({
|
||||
components: { ProjectSidebarLinks },
|
||||
template: /* html */ `
|
||||
<div style="max-width: 300px">
|
||||
<ProjectSidebarLinks
|
||||
:project="project"
|
||||
link-target="_blank"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
setup() {
|
||||
return {
|
||||
project: {
|
||||
issues_url: 'https://github.com/example/mod/issues',
|
||||
source_url: 'https://github.com/example/mod',
|
||||
wiki_url: '',
|
||||
discord_url: '',
|
||||
donation_urls: [
|
||||
{ id: 'bmac', url: 'https://buymeacoffee.com/example' },
|
||||
{ id: 'patreon', url: 'https://patreon.com/example' },
|
||||
{ id: 'paypal', url: 'https://paypal.me/example' },
|
||||
{ id: 'ko-fi', url: 'https://ko-fi.com/example' },
|
||||
{ id: 'github', url: 'https://github.com/sponsors/example' },
|
||||
{ id: 'open-collective', url: 'https://opencollective.com/example' },
|
||||
{ id: 'other', url: 'https://example.com/donate' },
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export const LinksOnly: Story = {
|
||||
args: {
|
||||
project: {
|
||||
issues_url: 'https://github.com/example/mod/issues',
|
||||
source_url: 'https://github.com/example/mod',
|
||||
wiki_url: 'https://wiki.example.com',
|
||||
discord_url: 'https://discord.gg/example',
|
||||
site_url: 'https://example.com',
|
||||
donation_urls: [],
|
||||
},
|
||||
linkTarget: '_blank',
|
||||
},
|
||||
}
|
||||
|
||||
export const DonationsOnly: Story = {
|
||||
args: {
|
||||
project: {
|
||||
issues_url: '',
|
||||
source_url: '',
|
||||
wiki_url: '',
|
||||
discord_url: '',
|
||||
donation_urls: [
|
||||
{ id: 'patreon', url: 'https://patreon.com/example' },
|
||||
{ id: 'github', url: 'https://github.com/sponsors/example' },
|
||||
],
|
||||
},
|
||||
linkTarget: '_blank',
|
||||
},
|
||||
}
|
||||
|
||||
export const MinimalLinks: Story = {
|
||||
args: {
|
||||
project: {
|
||||
issues_url: '',
|
||||
source_url: 'https://github.com/example/mod',
|
||||
wiki_url: '',
|
||||
discord_url: 'https://discord.gg/example',
|
||||
donation_urls: [],
|
||||
},
|
||||
linkTarget: '_blank',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ProjectSidebarServerInfo from '../../components/project/ProjectSidebarServerInfo.vue'
|
||||
|
||||
const gameVersions: GameVersionTag[] = [
|
||||
{ version: '1.21.4', version_type: 'release', date: '2024-12-03', major: true },
|
||||
{ version: '1.21.3', version_type: 'release', date: '2024-10-23', major: false },
|
||||
{ version: '1.21.1', version_type: 'release', date: '2024-08-08', major: true },
|
||||
{ version: '1.21', version_type: 'release', date: '2024-06-13', major: true },
|
||||
{ version: '1.20.4', version_type: 'release', date: '2023-12-07', major: true },
|
||||
{ version: '1.20.1', version_type: 'release', date: '2023-06-12', major: true },
|
||||
]
|
||||
|
||||
const loaders: PlatformTag[] = [
|
||||
{ icon: '', name: 'fabric', supported_project_types: ['mod'] },
|
||||
{ icon: '', name: 'forge', supported_project_types: ['mod'] },
|
||||
]
|
||||
|
||||
const tags = { gameVersions, loaders }
|
||||
|
||||
type Project = Labrinth.Projects.v3.Project
|
||||
|
||||
const meta = {
|
||||
title: 'Sidebar/ProjectSidebarServerInfo',
|
||||
component: ProjectSidebarServerInfo,
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div style="max-width: 300px"><story /></div>',
|
||||
}),
|
||||
],
|
||||
} satisfies Meta<typeof ProjectSidebarServerInfo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
projectV3: {
|
||||
minecraft_java_server: {
|
||||
address: 'play.example.com',
|
||||
content: {
|
||||
kind: 'vanilla',
|
||||
recommended_game_version: '1.21.4',
|
||||
supported_game_versions: ['1.21.4', '1.21.3', '1.21.1'],
|
||||
},
|
||||
ping: null,
|
||||
},
|
||||
minecraft_server: {
|
||||
country: 'US',
|
||||
languages: ['English'],
|
||||
},
|
||||
} as unknown as Project,
|
||||
tags,
|
||||
ping: 42,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithRequiredContent: Story = {
|
||||
args: {
|
||||
projectV3: {
|
||||
minecraft_java_server: {
|
||||
address: 'mc.modrinth.com',
|
||||
content: {
|
||||
kind: 'modpack',
|
||||
version_id: 'abc123',
|
||||
},
|
||||
ping: null,
|
||||
},
|
||||
minecraft_server: {
|
||||
country: 'DE',
|
||||
languages: ['English', 'German'],
|
||||
},
|
||||
} as unknown as Project,
|
||||
tags,
|
||||
requiredContent: {
|
||||
name: 'Better MC [FABRIC] - BMC4',
|
||||
versionNumber: 'v32.1',
|
||||
icon: 'https://cdn.modrinth.com/data/shrsKXYP/f68f3d07878e3cd26e33c1e379b85cdfc0e85a6d_96.webp',
|
||||
onclickName: () => {
|
||||
alert('Go to modpack project')
|
||||
},
|
||||
onclickVersion: () => {
|
||||
alert('Go to modpack version')
|
||||
},
|
||||
onclickDownload: () => {
|
||||
alert('Download content')
|
||||
},
|
||||
},
|
||||
recommendedVersion: '1.21.4',
|
||||
supportedVersions: ['1.21.4', '1.21.1', '1.20.4'],
|
||||
ping: 85,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithRequiredContentDownload: Story = {
|
||||
args: {
|
||||
projectV3: {
|
||||
minecraft_java_server: {
|
||||
address: 'mc.modrinth.com',
|
||||
content: {
|
||||
kind: 'modpack',
|
||||
version_id: 'abc123',
|
||||
},
|
||||
ping: null,
|
||||
},
|
||||
minecraft_server: {
|
||||
country: 'DE',
|
||||
languages: ['English', 'German'],
|
||||
},
|
||||
} as unknown as Project,
|
||||
tags,
|
||||
requiredContent: {
|
||||
name: 'Better MC [FABRIC] - BMC4',
|
||||
versionNumber: 'v32.1',
|
||||
icon: 'https://cdn.modrinth.com/data/shrsKXYP/f68f3d07878e3cd26e33c1e379b85cdfc0e85a6d_96.webp',
|
||||
onclickName: () => {
|
||||
alert('Go to modpack project')
|
||||
},
|
||||
onclickVersion: () => {
|
||||
alert('Go to modpack version')
|
||||
},
|
||||
onclickDownload: () => {
|
||||
alert('Download content')
|
||||
},
|
||||
},
|
||||
recommendedVersion: '1.21.4',
|
||||
supportedVersions: ['1.21.4', '1.21.1', '1.20.4'],
|
||||
ping: 85,
|
||||
},
|
||||
}
|
||||
|
||||
export const IPOnly: Story = {
|
||||
args: {
|
||||
projectV3: {
|
||||
minecraft_java_server: {
|
||||
address: 'play.hypixel.net',
|
||||
content: {
|
||||
kind: 'vanilla',
|
||||
supported_game_versions: [],
|
||||
},
|
||||
ping: null,
|
||||
},
|
||||
} as unknown as Project,
|
||||
tags,
|
||||
},
|
||||
}
|
||||
|
||||
export const OfflineServer: Story = {
|
||||
args: {
|
||||
projectV3: {
|
||||
minecraft_java_server: {
|
||||
address: 'offline.example.com',
|
||||
content: {
|
||||
kind: 'vanilla',
|
||||
recommended_game_version: '1.21.4',
|
||||
supported_game_versions: ['1.21.4'],
|
||||
},
|
||||
ping: null,
|
||||
},
|
||||
} as unknown as Project,
|
||||
tags,
|
||||
ping: 0,
|
||||
},
|
||||
}
|
||||
|
||||
export const HighLatency: Story = {
|
||||
args: {
|
||||
projectV3: {
|
||||
minecraft_java_server: {
|
||||
address: 'faraway.example.com',
|
||||
content: {
|
||||
kind: 'vanilla',
|
||||
recommended_game_version: '1.21.4',
|
||||
supported_game_versions: ['1.21.4', '1.21.1'],
|
||||
},
|
||||
ping: null,
|
||||
},
|
||||
minecraft_server: {
|
||||
country: 'JP',
|
||||
languages: ['Japanese', 'English'],
|
||||
},
|
||||
} as unknown as Project,
|
||||
tags,
|
||||
ping: 350,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithLanguages: Story = {
|
||||
args: {
|
||||
projectV3: {
|
||||
minecraft_java_server: {
|
||||
address: 'eu.example.com',
|
||||
content: {
|
||||
kind: 'vanilla',
|
||||
recommended_game_version: '1.21.4',
|
||||
supported_game_versions: ['1.21.4'],
|
||||
},
|
||||
ping: null,
|
||||
},
|
||||
minecraft_server: {
|
||||
country: 'FR',
|
||||
languages: ['French', 'English', 'Spanish', 'German', 'Portuguese'],
|
||||
},
|
||||
} as unknown as Project,
|
||||
tags,
|
||||
ping: 28,
|
||||
},
|
||||
}
|
||||
@@ -393,6 +393,14 @@ export const commonMessages = defineMessages({
|
||||
id: 'project.follower-count-tooltip',
|
||||
defaultMessage: '{count} {count, plural, one {followers} other {followers}}',
|
||||
},
|
||||
projectOnlinePlayerCount: {
|
||||
id: 'project.online-player-count',
|
||||
defaultMessage: '{count} {count, plural, one {online} other {online}}',
|
||||
},
|
||||
projectRecentPlays: {
|
||||
id: 'project.recent-plays',
|
||||
defaultMessage: '{count} {count, plural, one {recent play} other {recent plays}}',
|
||||
},
|
||||
})
|
||||
|
||||
export const formFieldLabels = defineMessages({
|
||||
@@ -665,6 +673,10 @@ export const commonProjectSettingsMessages = defineMessages({
|
||||
id: 'project.settings.analytics.title',
|
||||
defaultMessage: 'Analytics',
|
||||
},
|
||||
content: {
|
||||
id: 'project.settings.content.title',
|
||||
defaultMessage: 'Content',
|
||||
},
|
||||
description: {
|
||||
id: 'project.settings.description.title',
|
||||
defaultMessage: 'Description',
|
||||
@@ -709,6 +721,10 @@ export const commonProjectSettingsMessages = defineMessages({
|
||||
id: 'project.settings.upload.title',
|
||||
defaultMessage: 'Upload',
|
||||
},
|
||||
server: {
|
||||
id: 'project.settings.server.title',
|
||||
defaultMessage: 'Server',
|
||||
},
|
||||
versions: {
|
||||
id: 'project.settings.versions.title',
|
||||
defaultMessage: 'Versions',
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from './game-modes'
|
||||
export * from './notices'
|
||||
export * from './savable'
|
||||
export * from './search'
|
||||
export * from './server-search'
|
||||
export * from './tag-messages'
|
||||
export * from './truncate'
|
||||
export * from './vue-children'
|
||||
|
||||
@@ -40,6 +40,7 @@ export function useSavable<T extends Record<string, unknown>>(
|
||||
saving.value = true
|
||||
try {
|
||||
await save(changes.value)
|
||||
currentValues.value = data()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type BaseOption = {
|
||||
toggle_group?: string
|
||||
icon?: string | Component
|
||||
query_value?: string
|
||||
group?: string
|
||||
}
|
||||
|
||||
export type FilterOption = BaseOption &
|
||||
@@ -65,7 +66,15 @@ export interface GameVersion {
|
||||
major: boolean
|
||||
}
|
||||
|
||||
export type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader' | 'datapack' | 'plugin'
|
||||
export type ProjectType =
|
||||
| 'mod'
|
||||
| 'modpack'
|
||||
| 'resourcepack'
|
||||
| 'shader'
|
||||
| 'datapack'
|
||||
| 'plugin'
|
||||
| 'server'
|
||||
| 'project'
|
||||
|
||||
const ALL_PROJECT_TYPES: ProjectType[] = [
|
||||
'mod',
|
||||
@@ -74,6 +83,7 @@ const ALL_PROJECT_TYPES: ProjectType[] = [
|
||||
'shader',
|
||||
'datapack',
|
||||
'plugin',
|
||||
'server',
|
||||
]
|
||||
|
||||
export interface Tags {
|
||||
@@ -469,10 +479,10 @@ export function useSearch(
|
||||
Add environment facets, separate from the rest because it oddly depends on the combination
|
||||
of filters selected to determine which facets to add.
|
||||
*/
|
||||
const client = currentFilters.value.some(
|
||||
const client = filterValues.some(
|
||||
(filter) => filter.type === 'environment' && filter.option === 'client',
|
||||
)
|
||||
const server = currentFilters.value.some(
|
||||
const server = filterValues.some(
|
||||
(filter) => filter.type === 'environment' && filter.option === 'server',
|
||||
)
|
||||
andFacets.push(...createEnvironmentFacets(client, server))
|
||||
|
||||
362
packages/ui/src/utils/server-search.ts
Normal file
362
packages/ui/src/utils/server-search.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { getCategoryIcon } from '@modrinth/assets'
|
||||
import { computed, type Ref, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useVIntl } from '../composables/i18n'
|
||||
import type { FilterType, FilterValue, SortType, Tags } from './search'
|
||||
import { formatCategory, formatCategoryHeader } from './tag-messages'
|
||||
|
||||
const SERVER_CATEGORY_ICON_MAP: Record<string, string> = {
|
||||
'adventure-mode': 'compass',
|
||||
anarchy: 'skull',
|
||||
'battle-royale': 'target',
|
||||
bedwars: 'bed-double',
|
||||
bosses: 'crown',
|
||||
classes: 'badge',
|
||||
competitive: 'trophy',
|
||||
'creative-mode': 'palette',
|
||||
'creator-community': 'clapperboard',
|
||||
crossplay: 'gamepad-2',
|
||||
'custom-content': 'blocks',
|
||||
dungeons: 'castle',
|
||||
factions: 'flag',
|
||||
gens: 'pickaxe',
|
||||
'hardcore-mode': 'heart-crack',
|
||||
'keep-inventory': 'backpack',
|
||||
kitpvp: 'sword',
|
||||
lifesteal: 'heart-pulse',
|
||||
media: 'film',
|
||||
microgames: 'grid-3x3',
|
||||
minigames: 'dices',
|
||||
mmo: 'globe',
|
||||
network: 'network',
|
||||
'offline-mode': 'wifi-off',
|
||||
oneblock: 'square',
|
||||
op: 'zap',
|
||||
parkour: 'footprints',
|
||||
'personal-worlds': 'house',
|
||||
plots: 'map-pinned',
|
||||
pokemon: 'paw-print',
|
||||
prison: 'lock',
|
||||
pve: 'shield',
|
||||
pvp: 'swords',
|
||||
questing: 'scroll-text',
|
||||
racing: 'gauge',
|
||||
'recording-smp': 'camera',
|
||||
roleplay: 'theater',
|
||||
rpg: 'wand-sparkles',
|
||||
skyblock: 'cloud',
|
||||
smp: 'users',
|
||||
'survival-mode': 'tree-pine',
|
||||
teams: 'handshake',
|
||||
technical: 'terminal',
|
||||
towns: 'building-2',
|
||||
whitelisted: 'badge-check',
|
||||
'world-resets': 'refresh-ccw',
|
||||
}
|
||||
|
||||
export const SERVER_COUNTRIES = [
|
||||
{ code: 'US', name: 'United States' },
|
||||
{ code: 'GB', name: 'United Kingdom' },
|
||||
{ code: 'DE', name: 'Germany' },
|
||||
{ code: 'FR', name: 'France' },
|
||||
{ code: 'NL', name: 'Netherlands' },
|
||||
{ code: 'PL', name: 'Poland' },
|
||||
{ code: 'RU', name: 'Russia' },
|
||||
{ code: 'BR', name: 'Brazil' },
|
||||
{ code: 'CA', name: 'Canada' },
|
||||
{ code: 'AU', name: 'Australia' },
|
||||
{ code: 'SE', name: 'Sweden' },
|
||||
{ code: 'FI', name: 'Finland' },
|
||||
{ code: 'SG', name: 'Singapore' },
|
||||
{ code: 'JP', name: 'Japan' },
|
||||
{ code: 'KR', name: 'South Korea' },
|
||||
{ code: 'TR', name: 'Turkey' },
|
||||
{ code: 'IN', name: 'India' },
|
||||
{ code: 'ZA', name: 'South Africa' },
|
||||
]
|
||||
|
||||
export const SERVER_LANGUAGES = [
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'de', name: 'German' },
|
||||
{ code: 'fr', name: 'French' },
|
||||
{ code: 'es', name: 'Spanish' },
|
||||
{ code: 'pt', name: 'Portuguese' },
|
||||
{ code: 'ru', name: 'Russian' },
|
||||
{ code: 'zh', name: 'Chinese' },
|
||||
{ code: 'ja', name: 'Japanese' },
|
||||
{ code: 'ko', name: 'Korean' },
|
||||
{ code: 'nl', name: 'Dutch' },
|
||||
{ code: 'pl', name: 'Polish' },
|
||||
{ code: 'it', name: 'Italian' },
|
||||
{ code: 'tr', name: 'Turkish' },
|
||||
{ code: 'sv', name: 'Swedish' },
|
||||
{ code: 'fi', name: 'Finnish' },
|
||||
]
|
||||
|
||||
export const SERVER_SORT_TYPES: SortType[] = [
|
||||
{ display: 'Relevance', name: 'relevance' },
|
||||
{ display: 'Verified Plays', name: 'minecraft_java_server.verified_plays_2w' },
|
||||
{ display: 'Players', name: 'minecraft_java_server.ping.data.players_online' },
|
||||
{ display: 'Followers', name: 'follows' },
|
||||
{ display: 'Date Published', name: 'date_created' },
|
||||
{ display: 'Date Updated', name: 'date_modified' },
|
||||
]
|
||||
|
||||
const FILTER_FIELD_MAP: Record<string, string> = {
|
||||
server_content_type: 'minecraft_java_server.content.kind',
|
||||
server_game_version: 'minecraft_java_server.content.supported_game_versions',
|
||||
server_country: 'minecraft_server.country',
|
||||
server_language: 'minecraft_server.languages',
|
||||
}
|
||||
|
||||
function getFilterField(filterId: string): string | undefined {
|
||||
if (filterId.startsWith('server_category_')) return 'categories'
|
||||
return FILTER_FIELD_MAP[filterId]
|
||||
}
|
||||
|
||||
export function useServerSearch(opts: {
|
||||
tags: Ref<Tags>
|
||||
query: Ref<string>
|
||||
maxResults: Ref<number>
|
||||
currentPage: Ref<number>
|
||||
}) {
|
||||
const { tags, query, maxResults, currentPage } = opts
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const serverCurrentSortType = ref<SortType>(SERVER_SORT_TYPES[0])
|
||||
const serverCurrentFilters = ref<FilterValue[]>([])
|
||||
const serverToggledGroups = ref<string[]>([])
|
||||
|
||||
const serverFilterTypes = computed<FilterType[]>(() => {
|
||||
const categoryFilters: Record<string, FilterType> = {}
|
||||
for (const c of (tags.value?.categories ?? []).filter(
|
||||
(c) => c.project_type === 'minecraft_java_server',
|
||||
)) {
|
||||
const filterTypeId = `server_category_${c.header}`
|
||||
if (!categoryFilters[filterTypeId]) {
|
||||
categoryFilters[filterTypeId] = {
|
||||
id: filterTypeId,
|
||||
formatted_name: formatCategoryHeader(formatMessage, c.header),
|
||||
supported_project_types: ['server'],
|
||||
display: 'all',
|
||||
query_param: 'sc',
|
||||
supports_negative_filter: true,
|
||||
searchable: false,
|
||||
options: [],
|
||||
}
|
||||
}
|
||||
categoryFilters[filterTypeId].options.push({
|
||||
id: c.name,
|
||||
formatted_name: formatCategory(formatMessage, c.name),
|
||||
icon: getCategoryIcon(SERVER_CATEGORY_ICON_MAP[c.name] ?? c.name),
|
||||
method: 'or' as const,
|
||||
value: c.name,
|
||||
})
|
||||
}
|
||||
|
||||
const featuresFilter = categoryFilters['server_category_minecraft_server_features']
|
||||
if (featuresFilter) {
|
||||
featuresFilter.options.sort((a, b) => {
|
||||
if (a.id === 'pokemon') return -1
|
||||
if (b.id === 'pokemon') return 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'server_content_type',
|
||||
formatted_name: 'Type',
|
||||
supported_project_types: ['server'],
|
||||
display: 'all',
|
||||
query_param: 'sct',
|
||||
supports_negative_filter: false,
|
||||
searchable: false,
|
||||
options: [
|
||||
{ id: 'vanilla', formatted_name: 'Vanilla', method: 'or', value: 'vanilla' },
|
||||
{ id: 'modpack', formatted_name: 'Modded', method: 'or', value: 'modpack' },
|
||||
],
|
||||
},
|
||||
...[
|
||||
'minecraft_server_features',
|
||||
'minecraft_server_gameplay',
|
||||
'minecraft_server_meta',
|
||||
'minecraft_server_community',
|
||||
]
|
||||
.map((h) => categoryFilters[`server_category_${h}`])
|
||||
.filter(Boolean),
|
||||
{
|
||||
id: 'server_game_version',
|
||||
formatted_name: 'Game Version',
|
||||
supported_project_types: ['server'],
|
||||
display: 'scrollable',
|
||||
query_param: 'sgv',
|
||||
supports_negative_filter: false,
|
||||
searchable: true,
|
||||
options: (tags.value?.gameVersions ?? []).map((gv) => ({
|
||||
id: gv.version,
|
||||
toggle_group: gv.version_type !== 'release' ? 'all_versions' : undefined,
|
||||
method: 'or' as const,
|
||||
value: gv.version,
|
||||
query_value: gv.version,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: 'server_country',
|
||||
formatted_name: 'Country',
|
||||
supported_project_types: ['server'],
|
||||
display: 'scrollable',
|
||||
query_param: 'sco',
|
||||
supports_negative_filter: true,
|
||||
searchable: true,
|
||||
options: SERVER_COUNTRIES.map((c) => ({
|
||||
id: c.code,
|
||||
formatted_name: c.name,
|
||||
method: 'or' as const,
|
||||
value: c.code,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: 'server_language',
|
||||
formatted_name: 'Language',
|
||||
supported_project_types: ['server'],
|
||||
display: 'scrollable',
|
||||
query_param: 'sl',
|
||||
supports_negative_filter: false,
|
||||
searchable: true,
|
||||
options: SERVER_LANGUAGES.map((l) => ({
|
||||
id: l.code,
|
||||
formatted_name: l.name,
|
||||
method: 'or' as const,
|
||||
value: l.code,
|
||||
})),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const newFilters = computed(() => {
|
||||
const parts = ['project_types = minecraft_java_server']
|
||||
|
||||
for (const filterType of serverFilterTypes.value) {
|
||||
const field = getFilterField(filterType.id)
|
||||
if (!field) continue
|
||||
const matched = serverCurrentFilters.value.filter((f) => f.type === filterType.id)
|
||||
const included = matched.filter((f) => !f.negative)
|
||||
const excluded = matched.filter((f) => f.negative)
|
||||
if (included.length > 0) {
|
||||
const values = included.map((f) => `"${f.option}"`).join(', ')
|
||||
parts.push(`${field} IN [${values}]`)
|
||||
}
|
||||
if (excluded.length > 0) {
|
||||
const values = excluded.map((f) => `"${f.option}"`).join(', ')
|
||||
parts.push(`${field} NOT IN [${values}]`)
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' AND ')
|
||||
})
|
||||
|
||||
const serverRequestParams = computed(() => {
|
||||
const params = [`limit=${maxResults.value}`, `index=${serverCurrentSortType.value.name}`]
|
||||
if (query.value) params.push(`query=${encodeURIComponent(query.value)}`)
|
||||
const offset = (currentPage.value - 1) * maxResults.value
|
||||
if (offset > 0) params.push(`offset=${offset}`)
|
||||
params.push(`new_filters=${encodeURIComponent(newFilters.value)}`)
|
||||
return `?${params.join('&')}`
|
||||
})
|
||||
|
||||
function readServerQueryParams() {
|
||||
const q = route.query
|
||||
|
||||
if (q.q) {
|
||||
query.value = String(q.q)
|
||||
}
|
||||
|
||||
if (q.ss) {
|
||||
serverCurrentSortType.value =
|
||||
SERVER_SORT_TYPES.find((s) => s.name === String(q.ss)) ?? SERVER_SORT_TYPES[0]
|
||||
}
|
||||
|
||||
if (q.m) {
|
||||
maxResults.value = Number(q.m)
|
||||
}
|
||||
|
||||
if (q.page) {
|
||||
currentPage.value = Number(q.page)
|
||||
}
|
||||
|
||||
for (const filterType of serverFilterTypes.value) {
|
||||
const paramValue = q[filterType.query_param]
|
||||
if (!paramValue) continue
|
||||
|
||||
const values =
|
||||
typeof paramValue === 'string'
|
||||
? [paramValue]
|
||||
: paramValue.filter((v): v is string => v !== null)
|
||||
|
||||
for (const value of values) {
|
||||
const isNegative = value.startsWith('!')
|
||||
const cleanValue = isNegative ? value.slice(1) : value
|
||||
const option = filterType.options.find((o) => o.id === cleanValue)
|
||||
if (option) {
|
||||
serverCurrentFilters.value.push({
|
||||
type: filterType.id,
|
||||
option: option.id,
|
||||
negative: isNegative,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createServerPageParams(): Record<string, string | string[]> {
|
||||
const items: Record<string, string[]> = {}
|
||||
|
||||
if (query.value) {
|
||||
items.q = [query.value]
|
||||
}
|
||||
|
||||
for (const filterValue of serverCurrentFilters.value) {
|
||||
const type = serverFilterTypes.value.find((t) => t.id === filterValue.type)
|
||||
if (type) {
|
||||
const value = filterValue.negative ? `!${filterValue.option}` : filterValue.option
|
||||
if (items[type.query_param]) {
|
||||
items[type.query_param].push(value)
|
||||
} else {
|
||||
items[type.query_param] = [value]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (serverCurrentSortType.value.name !== 'relevance') {
|
||||
items.ss = [serverCurrentSortType.value.name]
|
||||
}
|
||||
|
||||
if (maxResults.value !== 20) {
|
||||
items.m = [String(maxResults.value)]
|
||||
}
|
||||
|
||||
if (currentPage.value > 1) {
|
||||
items.page = [String(currentPage.value)]
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
readServerQueryParams()
|
||||
|
||||
return {
|
||||
serverCurrentSortType,
|
||||
serverCurrentFilters,
|
||||
serverToggledGroups,
|
||||
serverSortTypes: SERVER_SORT_TYPES,
|
||||
serverFilterTypes,
|
||||
newFilters,
|
||||
serverRequestParams,
|
||||
createServerPageParams,
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,14 @@ export const categoryMessages = defineMessages({
|
||||
id: 'tag.category.adventure',
|
||||
defaultMessage: 'Adventure',
|
||||
},
|
||||
'adventure-mode': {
|
||||
id: 'tag.category.adventure-mode',
|
||||
defaultMessage: 'Adventure Mode',
|
||||
},
|
||||
anarchy: {
|
||||
id: 'tag.category.anarchy',
|
||||
defaultMessage: 'Anarchy',
|
||||
},
|
||||
atmosphere: {
|
||||
id: 'tag.category.atmosphere',
|
||||
defaultMessage: 'Atmosphere',
|
||||
@@ -170,6 +178,14 @@ export const categoryMessages = defineMessages({
|
||||
id: 'tag.category.audio',
|
||||
defaultMessage: 'Audio',
|
||||
},
|
||||
'battle-royale': {
|
||||
id: 'tag.category.battle-royale',
|
||||
defaultMessage: 'Battle Royale',
|
||||
},
|
||||
bedwars: {
|
||||
id: 'tag.category.bedwars',
|
||||
defaultMessage: 'Bed Wars',
|
||||
},
|
||||
blocks: {
|
||||
id: 'tag.category.blocks',
|
||||
defaultMessage: 'Blocks',
|
||||
@@ -178,6 +194,10 @@ export const categoryMessages = defineMessages({
|
||||
id: 'tag.category.bloom',
|
||||
defaultMessage: 'Bloom',
|
||||
},
|
||||
bosses: {
|
||||
id: 'tag.category.bosses',
|
||||
defaultMessage: 'Bosses',
|
||||
},
|
||||
cartoon: {
|
||||
id: 'tag.category.cartoon',
|
||||
defaultMessage: 'Cartoon',
|
||||
@@ -186,6 +206,10 @@ export const categoryMessages = defineMessages({
|
||||
id: 'tag.category.challenging',
|
||||
defaultMessage: 'Challenging',
|
||||
},
|
||||
classes: {
|
||||
id: 'tag.category.classes',
|
||||
defaultMessage: 'Classes',
|
||||
},
|
||||
'colored-lighting': {
|
||||
id: 'tag.category.colored-lighting',
|
||||
defaultMessage: 'Colored Lighting',
|
||||
@@ -194,18 +218,42 @@ export const categoryMessages = defineMessages({
|
||||
id: 'tag.category.combat',
|
||||
defaultMessage: 'Combat',
|
||||
},
|
||||
competitive: {
|
||||
id: 'tag.category.competitive',
|
||||
defaultMessage: 'Competitive',
|
||||
},
|
||||
'core-shaders': {
|
||||
id: 'tag.category.core-shaders',
|
||||
defaultMessage: 'Core Shaders',
|
||||
},
|
||||
'creative-mode': {
|
||||
id: 'tag.category.creative-mode',
|
||||
defaultMessage: 'Creative Mode',
|
||||
},
|
||||
'creator-community': {
|
||||
id: 'tag.category.creator-community',
|
||||
defaultMessage: 'Creator Community',
|
||||
},
|
||||
crossplay: {
|
||||
id: 'tag.category.crossplay',
|
||||
defaultMessage: 'Crossplay',
|
||||
},
|
||||
cursed: {
|
||||
id: 'tag.category.cursed',
|
||||
defaultMessage: 'Cursed',
|
||||
},
|
||||
'custom-content': {
|
||||
id: 'tag.category.custom-content',
|
||||
defaultMessage: 'Custom Content',
|
||||
},
|
||||
decoration: {
|
||||
id: 'tag.category.decoration',
|
||||
defaultMessage: 'Decoration',
|
||||
},
|
||||
dungeons: {
|
||||
id: 'tag.category.dungeons',
|
||||
defaultMessage: 'Dungeons',
|
||||
},
|
||||
economy: {
|
||||
id: 'tag.category.economy',
|
||||
defaultMessage: 'Economy',
|
||||
@@ -222,6 +270,10 @@ export const categoryMessages = defineMessages({
|
||||
id: 'tag.category.equipment',
|
||||
defaultMessage: 'Equipment',
|
||||
},
|
||||
factions: {
|
||||
id: 'tag.category.factions',
|
||||
defaultMessage: 'Factions',
|
||||
},
|
||||
fantasy: {
|
||||
id: 'tag.category.fantasy',
|
||||
defaultMessage: 'Fantasy',
|
||||
@@ -242,10 +294,18 @@ export const categoryMessages = defineMessages({
|
||||
id: 'tag.category.game-mechanics',
|
||||
defaultMessage: 'Game Mechanics',
|
||||
},
|
||||
gens: {
|
||||
id: 'tag.category.gens',
|
||||
defaultMessage: 'Gens',
|
||||
},
|
||||
gui: {
|
||||
id: 'tag.category.gui',
|
||||
defaultMessage: 'GUI',
|
||||
},
|
||||
'hardcore-mode': {
|
||||
id: 'tag.category.hardcore-mode',
|
||||
defaultMessage: 'Hardcore Mode',
|
||||
},
|
||||
high: {
|
||||
id: 'tag.category.high',
|
||||
defaultMessage: 'High',
|
||||
@@ -254,14 +314,26 @@ export const categoryMessages = defineMessages({
|
||||
id: 'tag.category.items',
|
||||
defaultMessage: 'Items',
|
||||
},
|
||||
'keep-inventory': {
|
||||
id: 'tag.category.keep-inventory',
|
||||
defaultMessage: 'Keep Inventory',
|
||||
},
|
||||
'kitchen-sink': {
|
||||
id: 'tag.category.kitchen-sink',
|
||||
defaultMessage: 'Kitchen Sink',
|
||||
},
|
||||
kitpvp: {
|
||||
id: 'tag.category.kitpvp',
|
||||
defaultMessage: 'Kit PVP',
|
||||
},
|
||||
library: {
|
||||
id: 'tag.category.library',
|
||||
defaultMessage: 'Library',
|
||||
},
|
||||
lifesteal: {
|
||||
id: 'tag.category.lifesteal',
|
||||
defaultMessage: 'Lifesteal',
|
||||
},
|
||||
lightweight: {
|
||||
id: 'tag.category.lightweight',
|
||||
defaultMessage: 'Lightweight',
|
||||
@@ -282,14 +354,30 @@ export const categoryMessages = defineMessages({
|
||||
id: 'tag.category.management',
|
||||
defaultMessage: 'Management',
|
||||
},
|
||||
media: {
|
||||
id: 'tag.category.media',
|
||||
defaultMessage: 'Media',
|
||||
},
|
||||
medium: {
|
||||
id: 'tag.category.medium',
|
||||
defaultMessage: 'Medium',
|
||||
},
|
||||
microgames: {
|
||||
id: 'tag.category.microgames',
|
||||
defaultMessage: 'Microgames',
|
||||
},
|
||||
minigame: {
|
||||
id: 'tag.category.minigame',
|
||||
defaultMessage: 'Minigame',
|
||||
},
|
||||
minigames: {
|
||||
id: 'tag.category.minigames',
|
||||
defaultMessage: 'Minigames',
|
||||
},
|
||||
mmo: {
|
||||
id: 'tag.category.mmo',
|
||||
defaultMessage: 'MMO',
|
||||
},
|
||||
mobs: {
|
||||
id: 'tag.category.mobs',
|
||||
defaultMessage: 'Mobs',
|
||||
@@ -306,10 +394,30 @@ export const categoryMessages = defineMessages({
|
||||
id: 'tag.category.multiplayer',
|
||||
defaultMessage: 'Multiplayer',
|
||||
},
|
||||
network: {
|
||||
id: 'tag.category.network',
|
||||
defaultMessage: 'Network',
|
||||
},
|
||||
'offline-mode': {
|
||||
id: 'tag.category.offline-mode',
|
||||
defaultMessage: 'Offline Mode',
|
||||
},
|
||||
oneblock: {
|
||||
id: 'tag.category.oneblock',
|
||||
defaultMessage: 'One Block',
|
||||
},
|
||||
op: {
|
||||
id: 'tag.category.op',
|
||||
defaultMessage: 'OP',
|
||||
},
|
||||
optimization: {
|
||||
id: 'tag.category.optimization',
|
||||
defaultMessage: 'Optimization',
|
||||
},
|
||||
parkour: {
|
||||
id: 'tag.category.parkour',
|
||||
defaultMessage: 'Parkour',
|
||||
},
|
||||
'path-tracing': {
|
||||
id: 'tag.category.path-tracing',
|
||||
defaultMessage: 'Path Tracing',
|
||||
@@ -318,22 +426,66 @@ export const categoryMessages = defineMessages({
|
||||
id: 'tag.category.pbr',
|
||||
defaultMessage: 'PBR',
|
||||
},
|
||||
'personal-worlds': {
|
||||
id: 'tag.category.personal-worlds',
|
||||
defaultMessage: 'Personal Worlds',
|
||||
},
|
||||
plots: {
|
||||
id: 'tag.category.plots',
|
||||
defaultMessage: 'Plots',
|
||||
},
|
||||
pokemon: {
|
||||
id: 'tag.category.pokemon',
|
||||
defaultMessage: 'Pokemon',
|
||||
},
|
||||
potato: {
|
||||
id: 'tag.category.potato',
|
||||
defaultMessage: 'Potato',
|
||||
},
|
||||
prison: {
|
||||
id: 'tag.category.prison',
|
||||
defaultMessage: 'Prison',
|
||||
},
|
||||
pve: {
|
||||
id: 'tag.category.pve',
|
||||
defaultMessage: 'PVE',
|
||||
},
|
||||
pvp: {
|
||||
id: 'tag.category.pvp',
|
||||
defaultMessage: 'PVP',
|
||||
},
|
||||
questing: {
|
||||
id: 'tag.category.questing',
|
||||
defaultMessage: 'Questing',
|
||||
},
|
||||
quests: {
|
||||
id: 'tag.category.quests',
|
||||
defaultMessage: 'Quests',
|
||||
},
|
||||
racing: {
|
||||
id: 'tag.category.racing',
|
||||
defaultMessage: 'Racing',
|
||||
},
|
||||
realistic: {
|
||||
id: 'tag.category.realistic',
|
||||
defaultMessage: 'Realistic',
|
||||
},
|
||||
'recording-smp': {
|
||||
id: 'tag.category.recording-smp',
|
||||
defaultMessage: 'Recording SMP',
|
||||
},
|
||||
reflections: {
|
||||
id: 'tag.category.reflections',
|
||||
defaultMessage: 'Reflections',
|
||||
},
|
||||
roleplay: {
|
||||
id: 'tag.category.roleplay',
|
||||
defaultMessage: 'Roleplay',
|
||||
},
|
||||
rpg: {
|
||||
id: 'tag.category.rpg',
|
||||
defaultMessage: 'RPG',
|
||||
},
|
||||
screenshot: {
|
||||
id: 'tag.category.screenshot',
|
||||
defaultMessage: 'Screenshot',
|
||||
@@ -350,6 +502,14 @@ export const categoryMessages = defineMessages({
|
||||
id: 'tag.category.simplistic',
|
||||
defaultMessage: 'Simplistic',
|
||||
},
|
||||
skyblock: {
|
||||
id: 'tag.category.skyblock',
|
||||
defaultMessage: 'Skyblock',
|
||||
},
|
||||
smp: {
|
||||
id: 'tag.category.smp',
|
||||
defaultMessage: 'SMP',
|
||||
},
|
||||
social: {
|
||||
id: 'tag.category.social',
|
||||
defaultMessage: 'Social',
|
||||
@@ -358,6 +518,18 @@ export const categoryMessages = defineMessages({
|
||||
id: 'tag.category.storage',
|
||||
defaultMessage: 'Storage',
|
||||
},
|
||||
'survival-mode': {
|
||||
id: 'tag.category.survival-mode',
|
||||
defaultMessage: 'Survival Mode',
|
||||
},
|
||||
teams: {
|
||||
id: 'tag.category.teams',
|
||||
defaultMessage: 'Teams',
|
||||
},
|
||||
technical: {
|
||||
id: 'tag.category.technical',
|
||||
defaultMessage: 'Technical',
|
||||
},
|
||||
technology: {
|
||||
id: 'tag.category.technology',
|
||||
defaultMessage: 'Technology',
|
||||
@@ -366,6 +538,10 @@ export const categoryMessages = defineMessages({
|
||||
id: 'tag.category.themed',
|
||||
defaultMessage: 'Themed',
|
||||
},
|
||||
towns: {
|
||||
id: 'tag.category.towns',
|
||||
defaultMessage: 'Towns',
|
||||
},
|
||||
transportation: {
|
||||
id: 'tag.category.transportation',
|
||||
defaultMessage: 'Transportation',
|
||||
@@ -382,6 +558,14 @@ export const categoryMessages = defineMessages({
|
||||
id: 'tag.category.vanilla-like',
|
||||
defaultMessage: 'Vanilla Like',
|
||||
},
|
||||
whitelisted: {
|
||||
id: 'tag.category.whitelisted',
|
||||
defaultMessage: 'Whitelisted',
|
||||
},
|
||||
'world-resets': {
|
||||
id: 'tag.category.world-resets',
|
||||
defaultMessage: 'World Resets',
|
||||
},
|
||||
worldgen: {
|
||||
id: 'tag.category.worldgen',
|
||||
defaultMessage: 'World Generation',
|
||||
@@ -428,6 +612,22 @@ export const categoryHeaderMessages = defineMessages({
|
||||
id: 'header.category.performance-impact',
|
||||
defaultMessage: 'Performance impact',
|
||||
},
|
||||
minecraft_server_community: {
|
||||
id: 'header.category.minecraft-server-community',
|
||||
defaultMessage: 'Community',
|
||||
},
|
||||
minecraft_server_features: {
|
||||
id: 'header.category.minecraft-server-features',
|
||||
defaultMessage: 'Features',
|
||||
},
|
||||
minecraft_server_gameplay: {
|
||||
id: 'header.category.minecraft-server-gameplay',
|
||||
defaultMessage: 'Gameplay',
|
||||
},
|
||||
minecraft_server_meta: {
|
||||
id: 'header.category.minecraft-server-meta',
|
||||
defaultMessage: 'Meta',
|
||||
},
|
||||
})
|
||||
|
||||
export function getTagMessage(
|
||||
|
||||
Reference in New Issue
Block a user