feat: server management in app (#5628)

* start new server settings tabs

* update properties tab to match design

* better stying in general tab

* feat: add suffix input for hostname field

* implement tables for allocations and DNS records

* add tags for dns record type

* small gap adjustment

* polish advanced page

* adjust properties page hierarchy

* fix searching properties, empty state and projection radius appearing

* pnpm prepr

* update copy to match designs

* fix suffix input component

* style fixes and match heading size

* small fix

* fix search allocations placeholder

* adjust table styles

* move all installation settings helper text to below input

* update icon to use overflow menu buttons

* fix modal to be consistent

* open advanced properties when search

* remove other and custom properties, and update styles

* remove hide/show all java versions

* handle mc 26

* refactor: move server settings pages into /ui and add app ServerSettingsModal

* hook up server pages for app

* add server page header to app

* hook up server settings modal

* use large size

* fix card box shadow style

* fix hostname input for app

* fix app/website card containers

* implement external tabs for billing and admin billing

* fix save banner fixed to parent instead of page body

* remove unused prop to FriendsList causing warning in app

* fix client-only not available for app

* fix bottom cut off

* wire node auth

* implement full copy buttons

* dedup copy button tailwind styles

* fix hover class not working in @apply

* fix spacing

* fix error validation styles

* apply consistent styles and spacing

* feat: update hosting server card (#5609)

* fix type errors

* fix some stylesheets not imported for storybook

* add server listing stories

* add fix for frontend stylesheet imports

* remove props.

* convert copy code to use tailwind

* update server listing component styles

* update server info label styles

* start status/player count info label, more style updates and fixes

* add new server card buttons

* hook up server cards and implement updated styles

* hook up on download button

* fix tauri throwing error when api returns 204 No Content

* hook up purchase server modal in app

* fix upgrading state loading icon

* pnpm prepr

* filter out servers past 30 days after cancellation

* do not apply opacity on lock or spiner icons

* fix disabled server icon background

* update pending change stage

* handle known suspension states

* refactor: reduce code duplication for server listing

* update disabled state text color

* fix loading icon color

* clean up copy

* fix disabled opacity for server card

* update server listing files kept to be countdown

* implement resubscribe modal

* implement proper provisioning state for resubscribe

* fix duplicate attribute and pnpm prepr

* feat: add shared UI package auth DI

* feat: update purchase server flow (#5714)

* implement server list empty state component

* fix stories and adjust spacing

* implement select plan design refresh

* implement auth for empty server list

* use refs instead of reactive

* pnpm prepr

* fix auth usage for empty servers list

* move app auth provider setup to src/providers/setup

* pnpm prepr

* fix max height

* style fix

* fix getCreds no auth is blocking api client

* implement servers guest plan modal and signin which redirects back to modal's next step

* refactor guest plan select logic into provider

* implement sign in or create account popup

* remove force empty serverList

* add download button for suspended mod and generic

* add handling for when user logs out

* QA pass style fixes

* more consistent page styles

* fix duplicate export

* refactor: remove all fallback stuff from resubscribe modal

* implement shared download latest backup util

* i18n pass

* pnpm prepr

* fix region being selected if ping failed

* pnpm prepr

* feat: servers in app finalization (#5744)

* feat: start on shared console implementation into logs and overview pages

* fix: terminal gap issues

* feat: swap word wrap for full screen

* fix: stats cards alignment

* fix: stats

* feat: fix console clear + remove copy

* fix: lint

* fix: use reset not clear

* feat: shared server header & overview page for app and website (#5736)

* feat: implement shared server header for app and website

* feat: implement wrapped overview page with shared composable and hook it up

* pnpm prepr

* fix: bugs

* qa: cleanup

* feat: root.vue shared layout

* feat: delete old options pages + fix discovery frontend

* fix: discovery

* fix: misc style/layout issues

* fix page padding

* fix: modal height jankiness

* feat: implement server install content in app and server setup modal with DI

* fix: spacing

* remove servers in app feature flag

* Revert "remove servers in app feature flag"

This reverts commit 86e284c4bdd6fa42c3c8fbaf1efbec41f4d1c6d2.

* fix: qa

* feat: remove legacy components from apps/frontend/src/components/ui/servers

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>

* qa pass (#5738)

* fix: qa

* feat: qa

* fix: server icon fetch fails due to global node auth race condition overriding each other

* fix: lint

* fix: server icon upload/sync and centralize logic

* fix: server settings modal not closing for server reset

* fix: better server sorting

* feat: copy address in server listing card

* fix: notification panel in modal and when overlapping with action bar

* fix: empty server list empty state flashing when refresh, fixed by adding isReady auth flag

* feat: use floating action bar for save banner

* fix: saving state in save bar

* fix: edit server icon styling

* fix: confirm modal to have consistent buttons

* feat: loading animation for server panel + caching improvements for app

* pnpm prepr

* feat: search page deduplication (#5754)

* fix: action bar behind modal

* fix: remove warning modal for stopping

* fix: server cards states

* we hate webkit we hate webkit

* fix: update allocation creation to not use modal

* fix: properties tab spacing and styles

* feat: add files tab copy

* fix: advanced properties icon

* fix: remove back to all servers link

* feat: add files tab link in copy

* fix: server header styles to be consistent with instance

* fix: add header icons back

* feat: update instance settings icon to be consistent

* fix: icon container

* feat: upload state persistence across tabs

* fix: server labels text wrapping

* fix: use surface-5 border

* fix: loading spinner showing with onboarding below

* feat: new server button shows purchase modal in website

* fix: billing page not showing quarterly interval

* fix: server downgrade not showing updated subscription notification

* fix: server settings invalidate saved state and remove server context provider since its already provided in the page

* pnpm prepr

* add stripe publishable key to app build

* feat: console highlighting

* fix: rename servers title to modrinth hosting

* feat: search fix

* fix: qa/styles

* fix: ip click active and remove power dont ask again

* fix: qa

* feat: highlighting fix console

* fix: disable conflicts action

* fix: error dismiss bug

* feat: modal clarification

* fix: files perms issue

* fix: lint

* feat: modal fix

* enable show uptime

* fix: add loading state to edit server icon

* fix: notification panel take in has sidebar from settings

* fix: consistency pass on app settings

* fix: consistency pass on instance settings

* pnpm prepr

* fix: nagivate to billing button in app to go to website

* fix: stripe return url in app causing app to open modrinth.com in tauri

* refactor: better show polling UI code

* fix: new server polling comparison to use server ids instead of length

* fix: buttonstyled story

* fix: button styling

* fix: content.vue regression

* feat: project url redirects

* fix: breadcrumbs

* fix: purchase with newly added card

* fix: console ordering problems

* fix: app-frontend missing env config and staging environment

* fix: log syncing for instances and server panel accidentally

* fix: QA issues

* fix: server page loading state

* fix: stats card logic

* fix: lint

* fix: qa

* fix: console height padding

* fix: terminal padding + loading indicator

* feat: update medal server listing styling

* fix: no upgrade button for medal server listing in app

* fix: go to overview instead of content tab after onboarding

* fix: qa

* fix: teleport modals to body

* fix: logs tab + qa

* fix: local storage for user preferences

* fix: qa loading indic

* feat: considitonal debug and trace

* fix: jump to top on install bug

* feat: swap out server hard drive icon to server stack icon

* feat: servers in app feature flag default true

* fix: highlight row ufll

* fix: webkit thing onto a tag

* fix: input field

* fix: clear fix

* fix: lint

* fix: fmt

* feat: improve share modal and bring it back for sharing log

* pnpm prepr

* fix: menu overflowing

* feat: remove servers in app feature flag

* fix: server stat charts no longer showing color

* fix: library nav no primary state

* fix: better modal height and width

* fix: highlighting bugs

* fix: empty states

* fix: delay import to fix overview page slow load on MacOS

* fix: medal server listing too bright on light mode

* fix: admon analysis + fix logs

* fix: bug

* fix: clear purchase intent from sign-in after closing modal

* performance: improve server manage stats loading by splitting reactivity

* fix: deploy + admon + disable highlighting

* fix: clippy

---------

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

* feat: temp wrangler

* fix: lint

* fix: logs upload

* fix: console empty state and admon regressions

* fix: fields

* feat: log deleting + prefetch for Logs.vue

* feat: move delete before share

* feat: clear endpoint

* feat: we ball!

---------

Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Truman Gao
2026-04-12 15:38:08 -06:00
committed by GitHub
parent a2a97d1313
commit 693a371d61
278 changed files with 15974 additions and 12608 deletions

View File

@@ -1,12 +1,14 @@
<template>
<div
class="flex size-full flex-col bg-surface-2 overflow-hidden rounded-[20px] border border-solid border-surface-4"
class="flex w-full flex-col bg-surface-2 overflow-hidden rounded-[20px] border border-solid border-surface-4"
:style="!fullscreen && componentHeight ? { minHeight: componentHeight + 'px' } : {}"
:class="{ 'h-full': fullscreen }"
>
<div class="relative min-h-0 pb-1 flex-1 overflow-hidden">
<div ref="containerRef" class="size-full pl-2" />
<div v-if="!isAtBottom" class="absolute bottom-4 right-4">
<ButtonStyled circular type="highlight">
<button class="!shadow-none" aria-label="Scroll to bottom" @click="scrollToBottom">
<div ref="wrapperRef" class="relative min-h-0 flex-1 overflow-hidden pb-2 pt-1">
<div ref="containerRef" class="size-full" />
<div v-if="!isAtBottom" class="absolute bottom-4 right-4 z-10">
<ButtonStyled circular type="highlight" size="large">
<button class="!shadow-2xl" aria-label="Scroll to bottom" @click="scrollToBottom">
<ChevronDownIcon />
</button>
</ButtonStyled>
@@ -14,12 +16,14 @@
</div>
<div
v-if="showInput"
class="border-t border-solid border-b-0 border-x-0 border-surface-5 bg-surface-3 p-4"
ref="inputRef"
class="border-t border-solid border-b-0 border-x-0 border-surface-4 bg-surface-3 p-4"
>
<StyledInput
v-model="commandInput"
:icon="TerminalSquareIcon"
placeholder="Send a command"
:placeholder="disableInput ? 'Server is not running' : 'Send a command'"
:disabled="disableInput"
wrapper-class="w-full"
input-class="!h-10"
@keydown.enter="submitCommand"
@@ -31,7 +35,7 @@
<script setup lang="ts">
import { ChevronDownIcon, TerminalSquareIcon } from '@modrinth/assets'
import type { Terminal } from '@xterm/xterm'
import { ref } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
@@ -41,27 +45,173 @@ const props = withDefaults(
defineProps<{
scrollback?: number
showInput?: boolean
disableInput?: boolean
fullscreen?: boolean
emptyStateType?: 'server' | 'instance'
}>(),
{
scrollback: 10000,
scrollback: Infinity,
showInput: false,
disableInput: false,
fullscreen: false,
emptyStateType: undefined,
},
)
const FROG = [
'\x1B[32m _ _ \x1B[37m',
'\x1B[32m (o)--(o) \x1B[37m',
'\x1B[32m /.______.\\\x1B[37m',
'\x1B[32m \\________/ \x1B[37m',
'\x1B[32m ./ \\. \x1B[37m',
'\x1B[32m ( . , )\x1B[37m',
'\x1B[32m \\ \\_\\\\ //_/ /\x1B[37m',
'\x1B[32m ~~ ~~ ~~\x1B[37m',
]
const EMPTY_STATE_BUBBLES: Record<string, string[]> = {
server: [
' __________________________________________________',
' / Welcome to your \x1B[32mModrinth Server\x1B[37m! \\',
'| Press the green start button to start your server! |',
' \\____________________________________________________/',
],
instance: [
' _____________________________________________________________',
' / Start your instance in the top right to start \\',
'| recieving live logs! |',
' \\_____________________________________________________________/',
],
}
const emit = defineEmits<{
command: [command: string]
ready: [terminal: Terminal]
}>()
const containerRef = ref<HTMLElement | null>(null)
const wrapperRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLElement | null>(null)
const commandInput = ref('')
const componentHeight = ref(0)
const { terminal, searchAddon, isAtBottom, write, writeln, clear, reset, fit, scrollToBottom } =
useTerminal({
container: containerRef,
scrollback: props.scrollback,
onReady: (term) => emit('ready', term),
})
const snappedHeight = ref<number | null>(null)
const showingEmptyState = ref(false)
const {
terminal,
searchAddon,
isAtBottom,
write,
writeln,
clear,
reset,
fit: rawFit,
scrollToBottom,
} = useTerminal({
container: containerRef,
scrollback: props.scrollback,
onReady: (term) => {
nextTick(() => {
updateComponentHeight()
snapToRows()
})
emit('ready', term)
},
onResize: () => {
updateComponentHeight()
},
})
function writeEmptyState() {
if (!terminal.value || !props.emptyStateType) return
terminal.value.reset()
const bubble = EMPTY_STATE_BUBBLES[props.emptyStateType]
if (bubble) {
for (const line of [...bubble, ...FROG]) {
terminal.value.writeln(line)
}
}
showingEmptyState.value = true
}
function clearEmptyState() {
if (!showingEmptyState.value) return
terminal.value?.reset()
showingEmptyState.value = false
}
function getWrapperMargins() {
if (!wrapperRef.value) return 0
const style = getComputedStyle(wrapperRef.value)
return parseFloat(style.marginTop) + parseFloat(style.marginBottom)
}
function snapToRows() {
if (!props.fullscreen) {
snappedHeight.value = null
return
}
const screen = containerRef.value?.querySelector('.xterm-screen') as HTMLElement | null
if (!screen) {
snappedHeight.value = null
return
}
const inputH = inputRef.value?.offsetHeight ?? 0
const borderW = 2
snappedHeight.value = screen.offsetHeight + getWrapperMargins() + inputH + borderW
}
let resizeDebounce: ReturnType<typeof setTimeout> | null = null
function handleWindowResize() {
if (!props.fullscreen) return
if (resizeDebounce) clearTimeout(resizeDebounce)
snappedHeight.value = null
resizeDebounce = setTimeout(() => {
rawFit()
nextTick(() => snapToRows())
}, 50)
}
onMounted(() => {
window.addEventListener('resize', handleWindowResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleWindowResize)
if (resizeDebounce) clearTimeout(resizeDebounce)
})
function fit() {
rawFit()
snapToRows()
}
watch(
() => props.fullscreen,
() => {
if (props.fullscreen) {
nextTick(() => {
rawFit()
nextTick(() => snapToRows())
})
} else {
snappedHeight.value = null
componentHeight.value = 0
}
},
)
function updateComponentHeight() {
const screen = containerRef.value?.querySelector('.xterm-screen') as HTMLElement | null
if (!screen) return
const screenH = screen.offsetHeight
const inputH = inputRef.value?.offsetHeight ?? 0
const borderW = 2
componentHeight.value = screenH + getWrapperMargins() + inputH + borderW
}
const submitCommand = () => {
const cmd = commandInput.value.trim()
@@ -81,6 +231,9 @@ defineExpose({
searchAddon,
isAtBottom,
commandInput,
showingEmptyState,
writeEmptyState,
clearEmptyState,
})
</script>
@@ -89,13 +242,39 @@ defineExpose({
height: 100% !important;
}
.xterm .xterm-scrollable-element {
height: 100% !important;
.xterm-viewport {
background-color: var(--surface-2) !important;
}
.xterm .xterm-screen {
min-height: 100% !important;
margin-left: auto !important;
margin-right: auto !important;
width: 100%;
margin-left: 8px;
margin-right: auto;
}
.xterm .xterm-rows {
position: relative;
z-index: 7;
}
.xterm .xterm-decoration-container {
overflow: visible !important;
}
.xterm .xterm-decoration-container > div {
box-sizing: content-box !important;
margin-left: -12px !important;
padding-left: 12px !important;
padding-right: 12px !important;
}
.xterm-scrollable-element > .scrollbar.vertical {
width: 8px !important;
}
.xterm-scrollable-element > .scrollbar.vertical > div {
width: 6px !important;
border-radius: 8px !important;
contain: layout style !important;
}
</style>

View File

@@ -204,6 +204,9 @@ const colorVariables = computed(() => {
if (props.type === 'outlined' || props.type === 'transparent') {
colors.bg = 'transparent'
if (props.hoverColorFill === 'none') {
hoverColors.bg = 'transparent'
}
colors = setColorFill(colors, props.colorFill === 'auto' ? 'text' : props.colorFill)
hoverColors = setColorFill(
hoverColors,
@@ -263,8 +266,10 @@ const fontSize = computed(() => {
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child {
@apply flex cursor-pointer flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight] whitespace-nowrap;
@apply flex cursor-pointer flex-row items-center justify-center border-solid border border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight] whitespace-nowrap;
box-shadow: var(--_box-shadow, inset 0 0 0 transparent);
-webkit-font-smoothing: antialiased;
will-change: filter;
transition:
scale 0.125s ease-in-out,
background-color 0.25s ease-in-out,

View File

@@ -0,0 +1,196 @@
<template>
<Transition name="collapsible-admonition">
<div
v-if="!dismissed"
:data-type="type"
class="collapsible-admonition flex flex-col rounded-2xl border border-solid text-contrast overflow-hidden"
>
<div
class="flex w-full cursor-pointer items-center gap-6 p-4"
:class="headerBgClasses[type]"
@click="expanded = !expanded"
>
<div class="flex flex-1 items-center gap-3">
<TriangleAlertIcon :class="['h-5 w-5 flex-none', iconClasses[type]]" />
<span class="text-base font-semibold text-contrast">
<slot name="header">{{ header }}</slot>
</span>
</div>
<div class="flex items-center gap-2">
<ButtonStyled circular type="highlight-colored-text" :color="buttonColors[type]">
<button aria-label="Toggle" @click.stop="expanded = !expanded">
<ChevronDownIcon
class="h-4 w-4 transition-transform duration-300"
:class="expanded && 'rotate-180'"
/>
</button>
</ButtonStyled>
<ButtonStyled
v-if="dismissible"
circular
type="highlight-colored-text"
:color="buttonColors[type]"
>
<button aria-label="Dismiss" @click.stop="handleDismiss">
<XIcon class="h-4 w-4" />
</button>
</ButtonStyled>
</div>
</div>
<div
class="grid transition-[grid-template-rows] duration-300 ease-in-out"
:class="expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
>
<div class="overflow-hidden">
<slot>
<div
v-for="(item, index) in items"
:key="index"
class="collapsible-admonition__item collapsible-admonition__item--bordered flex flex-col gap-1 p-4"
>
<p class="m-0 text-base font-semibold text-contrast">
{{ item.title }}
</p>
<div
v-for="(desc, di) in item.descriptions"
:key="di"
class="flex items-start gap-1.5"
>
<LightBulbIcon :class="['mt-0.5 h-5 w-5 flex-none', iconClasses[type]]" />
<span class="text-base text-contrast/85">{{ desc }}</span>
</div>
</div>
</slot>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ChevronDownIcon, LightBulbIcon, TriangleAlertIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ButtonStyled from './ButtonStyled.vue'
export interface CollapsibleAdmonitionItem {
title: string
descriptions?: string[]
}
withDefaults(
defineProps<{
type?: 'info' | 'warning' | 'critical' | 'success'
header?: string
items?: CollapsibleAdmonitionItem[]
dismissible?: boolean
}>(),
{
type: 'critical',
header: '',
items: () => [],
dismissible: false,
},
)
const emit = defineEmits<{
dismiss: []
}>()
const expanded = defineModel<boolean>({ default: false })
const dismissed = ref(false)
function handleDismiss() {
dismissed.value = true
emit('dismiss')
}
const headerBgClasses = {
info: 'bg-bg-blue',
warning: 'bg-bg-orange',
critical: 'bg-bg-red',
success: 'bg-bg-green',
}
const iconClasses = {
info: 'text-brand-blue',
warning: 'text-brand-orange',
critical: 'text-brand-red',
success: 'text-brand-green',
}
const buttonColors: Record<string, 'blue' | 'orange' | 'red' | 'green'> = {
info: 'blue',
warning: 'orange',
critical: 'red',
success: 'green',
}
</script>
<style scoped>
.collapsible-admonition[data-type='critical'] {
border-color: rgba(255, 73, 110, 0.6);
}
.collapsible-admonition[data-type='critical'] .collapsible-admonition__item {
background: rgba(255, 73, 110, 0.1);
}
.collapsible-admonition[data-type='critical'] .collapsible-admonition__item--bordered {
border-top: 1px solid rgba(255, 73, 110, 0.6);
}
.collapsible-admonition[data-type='info'] {
border-color: rgba(47, 158, 255, 0.6);
}
.collapsible-admonition[data-type='info'] .collapsible-admonition__item {
background: rgba(47, 158, 255, 0.1);
}
.collapsible-admonition[data-type='info'] .collapsible-admonition__item--bordered {
border-top: 1px solid rgba(47, 158, 255, 0.6);
}
.collapsible-admonition[data-type='warning'] {
border-color: rgba(255, 163, 71, 0.6);
}
.collapsible-admonition[data-type='warning'] .collapsible-admonition__item {
background: rgba(255, 163, 71, 0.1);
}
.collapsible-admonition[data-type='warning'] .collapsible-admonition__item--bordered {
border-top: 1px solid rgba(255, 163, 71, 0.6);
}
.collapsible-admonition[data-type='success'] {
border-color: rgba(27, 217, 106, 0.6);
}
.collapsible-admonition[data-type='success'] .collapsible-admonition__item {
background: rgba(27, 217, 106, 0.1);
}
.collapsible-admonition[data-type='success'] .collapsible-admonition__item--bordered {
border-top: 1px solid rgba(27, 217, 106, 0.6);
}
.collapsible-admonition-enter-active,
.collapsible-admonition-leave-active {
transition:
opacity 300ms ease-in-out,
transform 300ms ease-in-out;
}
.collapsible-admonition-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.collapsible-admonition-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

View File

@@ -1,45 +1,37 @@
<template>
<div class="flex flex-col gap-2 border-0 border-b border-solid border-divider pb-4">
<div class="grid grid-cols-[1fr_auto] gap-y-6">
<div class="flex gap-4 w-full">
<div class="flex flex-wrap items-start gap-4">
<div class="flex min-w-0 flex-1 gap-4">
<slot name="icon" />
<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 max-w-[44rem] empty:hidden"
:class="[disableLineClamp ? '' : 'line-clamp-2']"
>
<slot name="summary" />
</p>
</div>
<div v-if="$slots.summary" class="flex gap-2 items-start max-md:hidden">
<slot name="actions" />
<div class="flex min-w-0 flex-col gap-2 justify-center">
<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 max-w-[44rem] empty:hidden"
:class="[disableLineClamp ? '' : 'line-clamp-2']"
>
<slot name="summary" />
</p>
</div>
<div v-if="$slots.stats" class="flex flex-wrap gap-3 empty:hidden max-md:hidden">
<slot name="stats" />
</div>
</div>
</div>
<div v-if="!$slots.summary" class="flex gap-2 items-start max-md:hidden">
<div class="flex flex-wrap gap-2 items-center">
<slot name="actions" />
</div>
</div>
<div class="flex justify-between">
<div v-if="$slots.stats" class="flex flex-wrap gap-3 empty:hidden md:hidden">
<div v-if="$slots.stats" class="flex justify-between md:hidden">
<div class="flex flex-wrap gap-3 empty:hidden">
<slot name="stats" />
</div>
<div class="flex gap-2 items-start self-end md:hidden">
<slot name="actions" />
</div>
</div>
</div>
</template>

View File

@@ -1,5 +1,9 @@
<template>
<button class="code" :class="{ copied }" :title="formatMessage(copiedMessage)" @click="copyText">
<button
class="!m-0 inline-flex w-fit select-text items-center gap-2 rounded-[10px] bg-[var(--color-button-bg)] px-2 py-1 font-mono text-sm text-primary transition-[opacity,filter,transform,outline] duration-200 ease-in-out hover:brightness-[1.25] active:scale-95 active:brightness-[0.8] motion-reduce:transition-none [&>svg]:h-[1em] [&>svg]:w-[1em]"
:title="formatMessage(copiedMessage)"
@click="copyText"
>
<span>{{ text }}</span>
<CheckIcon v-if="copied" />
<ClipboardCopyIcon v-else />
@@ -27,42 +31,3 @@ async function copyText() {
copied.value = true
}
</script>
<style lang="scss" scoped>
.code {
color: var(--color-text);
display: inline-flex;
grid-gap: 0.5rem;
font-family: var(--mono-font);
font-size: var(--font-size-sm);
margin: 0;
padding: 0.25rem 0.5rem;
background-color: var(--color-button-bg);
width: fit-content;
border-radius: 10px;
user-select: text;
transition:
opacity 0.5s ease-in-out,
filter 0.2s ease-in-out,
transform 0.05s ease-in-out,
outline 0.2s ease-in-out;
@media (prefers-reduced-motion) {
transition: none !important;
}
svg {
width: 1em;
height: 1em;
}
&:hover {
filter: brightness(0.85);
}
&:active {
transform: scale(0.95);
filter: brightness(0.8);
}
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div class="flex flex-wrap items-center gap-1.5">
<FilterIcon class="size-5 text-secondary" />
<button
:class="pillClass(modelValue.length === 0)"
:aria-pressed="modelValue.length === 0"
@click="modelValue = []"
>
<slot name="all"> All </slot>
</button>
<button
v-for="option in options"
:key="option.id"
:class="pillClass(modelValue.includes(option.id))"
:aria-pressed="modelValue.includes(option.id)"
@click="toggle(option.id)"
>
{{ option.label }}
</button>
</div>
</template>
<script setup lang="ts">
import { FilterIcon } from '@modrinth/assets'
export interface FilterPillOption {
id: string
label: string
}
const modelValue = defineModel<string[]>({ required: true })
defineProps<{
options: FilterPillOption[]
}>()
function pillClass(active: boolean) {
return [
'cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]',
active
? 'border-green bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5',
]
}
function toggle(id: string) {
if (modelValue.value.includes(id)) {
modelValue.value = modelValue.value.filter((f) => f !== id)
} else {
modelValue.value = [...modelValue.value, id]
}
}
</script>

View File

@@ -61,7 +61,7 @@ onUnmounted(() => {
<Transition name="floating-action-bar" appear>
<div
v-if="shown"
class="floating-action-bar drop-shadow-2xl fixed z-10 p-4 bottom-0"
class="floating-action-bar drop-shadow-2xl fixed z-[21] p-4 bottom-0"
aria-live="polite"
>
<div

View File

@@ -2,7 +2,7 @@
<nav
v-if="filteredLinks.length > 1"
ref="scrollContainer"
class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold drop-shadow-xl"
:class="{ 'shadow-sm': mode === 'navigation' }"
>
<template v-if="mode === 'navigation'">

View File

@@ -1,7 +1,7 @@
<template>
<div class="overflow-hidden rounded-2xl border border-solid border-surface-3">
<table class="w-full border-separate border-spacing-0">
<thead>
<div class="overflow-hidden rounded-2xl border border-solid border-surface-5">
<table class="w-full table-fixed border-separate border-spacing-0 border-surface-5">
<thead class="">
<tr class="bg-surface-3">
<th v-if="showSelection" class="w-10 pl-4">
<Checkbox
@@ -50,7 +50,7 @@
:key="rowIndex"
:class="rowIndex % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
>
<td v-if="showSelection" class="w-10">
<td v-if="showSelection" class="w-10 border-solid border-0 border-t border-surface-5">
<Checkbox
:model-value="isSelected(row)"
class="shrink-0 p-4"
@@ -60,7 +60,7 @@
<td
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 first:pl-4 last:pr-4"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
:class="`text-${column.align ?? 'left'}`"
:style="column.width ? { width: column.width } : undefined"
>

View File

@@ -163,7 +163,7 @@ const calculateMenuPosition = () => {
const triggerRect = triggerRef.value.getBoundingClientRect()
const menuRect = menuRef.value.getBoundingClientRect()
const menuWidth = menuRect.width
const menuWidth = menuRect.width + 16
const menuHeight = menuRect.height
const margin = 8
@@ -180,10 +180,8 @@ const calculateMenuPosition = () => {
if (triggerRect.right - menuWidth >= margin) {
left = triggerRect.right - menuWidth
} else if (triggerRect.left + menuWidth + margin <= window.innerWidth) {
left = triggerRect.left
} else {
left = Math.max(margin, window.innerWidth - menuWidth - margin)
left = Math.max(margin, triggerRect.left)
}
return {

View File

@@ -14,6 +14,8 @@ export { default as Card } from './Card.vue'
export { default as Checkbox } from './Checkbox.vue'
export { default as Chips } from './Chips.vue'
export { default as Collapsible } from './Collapsible.vue'
export type { CollapsibleAdmonitionItem } from './CollapsibleAdmonition.vue'
export { default as CollapsibleAdmonition } from './CollapsibleAdmonition.vue'
export { default as CollapsibleRegion } from './CollapsibleRegion.vue'
export type { ComboboxOption } from './Combobox.vue'
export { default as Combobox } from './Combobox.vue'
@@ -29,6 +31,8 @@ export { default as ErrorInformationCard } from './ErrorInformationCard.vue'
export { default as FileInput } from './FileInput.vue'
export type { FilterBarOption } from './FilterBar.vue'
export { default as FilterBar } from './FilterBar.vue'
export type { FilterPillOption } from './FilterPills.vue'
export { default as FilterPills } from './FilterPills.vue'
export { default as FloatingActionBar } from './FloatingActionBar.vue'
export { default as FloatingPanel } from './FloatingPanel.vue'
export { default as FormattedTag } from './FormattedTag.vue'