feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,487 @@
<script setup lang="ts">
import {
CalendarIcon,
ChevronLeftIcon,
ChevronRightIcon,
PlayIcon,
StopCircleIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import {
type DailyPlaytime,
type DailyPlaytimeEntry,
get_daily_playtime,
get_daily_playtime_details,
kill,
run,
} from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { handleSevereError } from '@/store/error'
import type { HomeWidgetSize } from './home-dashboard'
import {
buildHeatmapDays,
dateFromKey,
endOfPeriod,
getPlaytimeLevel,
shiftPeriod,
startOfPeriod,
toDateKey,
} from './home-utils'
const props = defineProps<{
instances: GameInstance[]
dashboardSize?: HomeWidgetSize | null
}>()
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const handleMinecraftLaunchError = useMinecraftLaunchError()
const { instanceRevision, runningInstanceIds } = useHomeDashboardRuntime()
const formatPeriod = useFormatDateTime({ month: 'long', year: 'numeric' })
const formatWeekday = useFormatDateTime({ weekday: 'narrow' })
const formatDetailDate = useFormatDateTime({ month: 'long', day: 'numeric' })
const formatFullDate = useFormatDateTime({ dateStyle: 'full' })
const messages = defineMessages({
calendar: { id: 'app.home.calendar.title', defaultMessage: 'Calendar' },
thisMonth: { id: 'app.home.calendar.this-month', defaultMessage: 'This month' },
previousMonth: { id: 'app.home.calendar.previous', defaultMessage: 'Previous month' },
nextMonth: { id: 'app.home.calendar.next', defaultMessage: 'Next month' },
playedOn: { id: 'app.home.calendar.played-on', defaultMessage: 'On {date} you played:' },
noActivity: {
id: 'app.home.calendar.no-activity',
defaultMessage: 'No playtime recorded on this day.',
},
playInstance: { id: 'app.home.calendar.play', defaultMessage: 'Play' },
stopInstance: { id: 'app.home.calendar.stop', defaultMessage: 'Stop' },
minutes: { id: 'app.home.playtime.minutes', defaultMessage: '{minutes}m' },
hoursMinutes: { id: 'app.home.playtime.hours-minutes', defaultMessage: '{hours}h {minutes}m' },
seconds: { id: 'app.home.playtime.seconds', defaultMessage: '{seconds}s' },
sessions: {
id: 'app.home.playtime.sessions',
defaultMessage: '{count, plural, one {# successful launch} other {# successful launches}}',
},
mostPlayed: { id: 'app.home.playtime.most-played', defaultMessage: 'Most played: {name}' },
})
const todayKey = toDateKey(new Date())
const anchor = ref(new Date())
const selectedKey = ref(todayKey)
const dailyPlaytime = ref<DailyPlaytime[]>([])
const dayDetails = ref<DailyPlaytimeEntry[]>([])
const activeTooltip = ref<{
dateKey: string
lines: string[]
left: number
top: number
} | null>(null)
const periodStart = computed(() => startOfPeriod(anchor.value, 'month'))
const periodEnd = computed(() => endOfPeriod(anchor.value, 'month'))
const periodLabel = computed(() => formatPeriod(periodStart.value))
const days = computed(() => buildHeatmapDays(anchor.value, 'month'))
const weekdayLabels = computed(() =>
Array.from({ length: 7 }, (_, index) => formatWeekday(new Date(2024, 0, index + 1, 12))),
)
const dailyByDate = computed(() => new Map(dailyPlaytime.value.map((entry) => [entry.date, entry])))
function heatmapLevelClass(dateKey: string): string {
const level = getPlaytimeLevel(dailyByDate.value.get(dateKey)?.played_seconds ?? 0)
return level === 0 ? 'bg-surface-4' : `home-calendar-level-${level}`
}
const canGoForward = computed(() => toDateKey(periodEnd.value) < todayKey)
const instanceById = computed(
() => new Map(props.instances.map((instance) => [instance.id, instance])),
)
const selectedDateLabel = computed(() => formatDetailDate(dateFromKey(selectedKey.value)))
const detailRows = computed(() =>
dayDetails.value.map((entry) => ({
entry,
instance: instanceById.value.get(entry.instance_id),
})),
)
function formatDuration(seconds: number): string {
const roundedSeconds = Math.max(0, Math.round(seconds))
const hours = Math.floor(roundedSeconds / 3600)
const minutes = Math.floor((roundedSeconds % 3600) / 60)
if (hours > 0) return formatMessage(messages.hoursMinutes, { hours, minutes })
if (minutes > 0) return formatMessage(messages.minutes, { minutes })
return formatMessage(messages.seconds, { seconds: roundedSeconds })
}
function tooltipLinesFor(dateKey: string): string[] {
const entry = dailyByDate.value.get(dateKey)
const lines = [formatFullDate(dateFromKey(dateKey))]
if (entry && entry.played_seconds > 0) {
lines.push(
formatDuration(entry.played_seconds),
formatMessage(messages.sessions, { count: entry.session_count }),
)
if (entry.top_instance_name) {
lines.push(formatMessage(messages.mostPlayed, { name: entry.top_instance_name }))
}
} else {
lines.push(formatMessage(messages.noActivity))
}
return lines
}
function showTooltip(event: PointerEvent | FocusEvent) {
const target =
event.target instanceof Element ? event.target.closest<HTMLElement>('[data-date-key]') : null
const dateKey = target?.dataset.dateKey
if (!target || !dateKey) {
activeTooltip.value = null
return
}
const rect = target.getBoundingClientRect()
const halfWidth = Math.min(144, Math.max(0, (window.innerWidth - 24) / 2))
activeTooltip.value = {
dateKey,
lines: tooltipLinesFor(dateKey),
left: Math.min(
Math.max(rect.left + rect.width / 2, 12 + halfWidth),
window.innerWidth - 12 - halfWidth,
),
top: rect.top - 8,
}
}
async function refreshPlaytime() {
dailyPlaytime.value = await get_daily_playtime(
toDateKey(periodStart.value),
toDateKey(periodEnd.value),
).catch((error): DailyPlaytime[] => {
handleError(error)
return []
})
}
async function refreshDayDetails() {
dayDetails.value = await get_daily_playtime_details(selectedKey.value).catch(
(error): DailyPlaytimeEntry[] => {
handleError(error)
return []
},
)
}
function movePeriod(amount: number) {
activeTooltip.value = null
anchor.value = shiftPeriod(anchor.value, 'month', amount)
}
function goToThisMonth() {
activeTooltip.value = null
anchor.value = new Date()
selectedKey.value = todayKey
}
function selectDay(dateKey: string) {
if (dateKey > todayKey) return
selectedKey.value = dateKey
}
async function playInstance(instance: GameInstance) {
try {
await run(instance.id)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeCalendar',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id,
instance_name: instance.name,
})
if (!handled) handleSevereError(error, { instanceId: instance.id })
}
}
async function stopInstance(instance: GameInstance) {
await kill(instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeCalendar',
})
}
watch(() => anchor.value.getTime(), refreshPlaytime, { immediate: true })
watch(selectedKey, refreshDayDetails, { immediate: true })
watch(instanceRevision, async () => {
await refreshPlaytime()
await refreshDayDetails()
})
</script>
<template>
<section class="flex min-w-0 min-h-0 h-full flex-col gap-2.5 overflow-hidden p-2">
<header class="flex min-w-0 h-8 flex-none items-center gap-2">
<div class="home-calendar-title flex min-w-0 items-center gap-2">
<CalendarIcon class="size-5 shrink-0 text-brand" aria-hidden="true" />
<h2>{{ formatMessage(messages.calendar) }}</h2>
</div>
<div class="ml-auto flex min-w-0 items-center gap-0.5">
<ButtonStyled circular size="small" type="transparent">
<button v-tooltip="formatMessage(messages.previousMonth)" @click="movePeriod(-1)">
<ChevronLeftIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" size="small" class="home-calendar-period min-w-0">
<button v-tooltip="formatMessage(messages.thisMonth)" @click="goToThisMonth">
{{ periodLabel }}
</button>
</ButtonStyled>
<ButtonStyled circular size="small" type="transparent">
<button
v-tooltip="formatMessage(messages.nextMonth)"
:disabled="!canGoForward"
@click="movePeriod(1)"
>
<ChevronRightIcon />
</button>
</ButtonStyled>
</div>
</header>
<div
class="flex-none"
@pointerover="showTooltip"
@pointerleave="activeTooltip = null"
@focusin="showTooltip"
@focusout="activeTooltip = null"
>
<div
class="mb-1 grid grid-cols-7 gap-[0.1875rem] text-center text-xs font-semibold text-secondary"
aria-hidden="true"
>
<span v-for="(weekday, index) in weekdayLabels" :key="index">{{ weekday }}</span>
</div>
<div class="grid grid-cols-7 gap-[0.1875rem]" role="grid" :aria-label="formatMessage(messages.calendar)">
<button
v-for="day in days"
:key="day.dateKey"
type="button"
class="home-calendar-cell h-[1.4rem] border border-solid rounded-[var(--radius-sm)] text-[0.6875rem] font-semibold outline-none p-0"
:class="{
'text-contrast': !(day.inPeriod && day.dateKey > todayKey),
'text-secondary opacity-50 cursor-default':
day.inPeriod && day.dateKey > todayKey,
'cursor-default': !day.inPeriod,
'cursor-pointer': day.inPeriod && day.dateKey <= todayKey,
'border-transparent': !(day.inPeriod && day.dateKey === todayKey),
'border-brand': day.inPeriod && day.dateKey === todayKey,
'bg-transparent': !(day.inPeriod && day.dateKey <= todayKey),
'home-calendar-cell-selected': day.inPeriod && day.dateKey === selectedKey,
[heatmapLevelClass(day.dateKey)]: day.inPeriod && day.dateKey <= todayKey,
}"
:tabindex="day.inPeriod && day.dateKey <= todayKey ? 0 : -1"
:disabled="!day.inPeriod || day.dateKey > todayKey"
:data-date-key="day.inPeriod && day.dateKey <= todayKey ? day.dateKey : undefined"
:aria-label="day.inPeriod ? day.dateKey : undefined"
:aria-pressed="day.inPeriod ? day.dateKey === selectedKey : undefined"
:aria-describedby="
activeTooltip?.dateKey === day.dateKey ? 'home-calendar-tooltip' : undefined
"
role="gridcell"
@click="selectDay(day.dateKey)"
>
<span v-if="day.inPeriod" aria-hidden="true">{{ day.date.getDate() }}</span>
</button>
</div>
</div>
<div
class="flex min-w-0 min-h-[3.75rem] flex-1 flex-col gap-1.5 overflow-y-auto border-t border-divider pt-2.5"
>
<h3 class="m-0 text-sm font-bold text-contrast">
{{ formatMessage(messages.playedOn, { date: selectedDateLabel }) }}
</h3>
<p v-if="dayDetails.length === 0" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.noActivity) }}
</p>
<ul v-else class="m-0 flex list-none flex-col p-0">
<li
v-for="row in detailRows"
:key="row.entry.instance_id"
class="group flex min-w-0 items-center gap-2.5 rounded-lg px-1.5 py-1.5 transition-colors hover:bg-button-bg"
>
<InstanceIcon
:icon-path="row.instance?.icon_path"
:instance-id="row.entry.instance_id"
:loader="row.instance?.loader"
size="36px"
class="shrink-0"
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="truncate text-sm font-semibold text-contrast">
{{ row.instance?.name ?? row.entry.instance_name }}
</span>
<span class="truncate text-xs text-secondary">
{{ formatDuration(row.entry.played_seconds) }}
</span>
</div>
<div v-if="row.instance" class="ml-auto shrink-0">
<ButtonStyled
v-if="runningInstanceIds.includes(row.instance.id)"
circular
size="small"
type="transparent"
>
<button
v-tooltip="formatMessage(messages.stopInstance)"
class="!text-red"
@click="stopInstance(row.instance)"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else circular size="small" type="transparent">
<button
v-tooltip="formatMessage(messages.playInstance)"
class="!text-brand opacity-60 transition-opacity group-hover:opacity-100"
@click="playInstance(row.instance)"
>
<PlayIcon />
</button>
</ButtonStyled>
</div>
</li>
</ul>
</div>
</section>
<Teleport to="body">
<Transition name="home-calendar-tooltip">
<div
v-if="activeTooltip"
id="home-calendar-tooltip"
class="home-calendar-tooltip"
role="tooltip"
:style="{ left: `${activeTooltip.left}px`, top: `${activeTooltip.top}px` }"
>
<strong>{{ activeTooltip.lines[0] }}</strong>
<span v-for="line in activeTooltip.lines.slice(1)" :key="line">{{ line }}</span>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.home-calendar-title h2 {
overflow: hidden;
margin: 0;
color: var(--color-contrast);
font-size: 1rem;
font-weight: 700;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-calendar-period :deep(button) {
max-width: 7.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-calendar-cell {
transition:
box-shadow 100ms ease,
background-color 100ms ease;
}
.home-calendar-cell:focus-visible {
box-shadow: 0 0 0 2px var(--color-brand);
}
.home-calendar-cell-selected {
box-shadow: 0 0 0 2px var(--color-brand);
}
.home-calendar-level-1 {
background: color-mix(in oklab, var(--color-brand) 28%, var(--surface-4));
}
.home-calendar-level-2 {
background: color-mix(in oklab, var(--color-brand) 48%, var(--surface-4));
}
.home-calendar-level-3 {
background: color-mix(in oklab, var(--color-brand) 70%, var(--surface-4));
}
.home-calendar-level-4 {
background: var(--color-brand);
color: var(--color-accent-contrast);
}
.home-calendar-tooltip {
position: fixed;
z-index: 1000;
display: flex;
max-width: 18rem;
transform: translate(-50%, -100%);
flex-direction: column;
gap: 0.125rem;
pointer-events: none;
padding: 0.5rem 0.625rem;
border: 1px solid var(--surface-5);
border-radius: var(--radius-sm);
background: var(--color-tooltip-bg);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
color: var(--color-tooltip-text);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.4;
}
.home-calendar-tooltip::after {
position: absolute;
top: 100%;
left: 50%;
width: 0.5rem;
height: 0.5rem;
transform: translate(-50%, -50%) rotate(45deg);
border-right: 1px solid var(--surface-5);
border-bottom: 1px solid var(--surface-5);
background: var(--color-tooltip-bg);
content: '';
}
.home-calendar-tooltip strong {
font-weight: 700;
}
.home-calendar-tooltip-enter-active,
.home-calendar-tooltip-leave-active {
transition:
opacity 100ms ease,
transform 100ms ease;
}
.home-calendar-tooltip-enter-from,
.home-calendar-tooltip-leave-to {
transform: translate(-50%, calc(-100% + 0.25rem));
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.home-calendar-tooltip-enter-active,
.home-calendar-tooltip-leave-active {
transition: none;
}
}
</style>

View File

@ -0,0 +1,85 @@
<script setup lang="ts">
import { SparklesIcon, UpdatedIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { type ChallengeDifficulty, dailyChallenges } from '@/data/daily-challenges'
import { stableGreetingIndex, toDateKey } from './home-utils'
const { formatMessage, locale } = useVIntl()
const messages = defineMessages({
dailyChallenge: { id: 'app.home.challenge.title', defaultMessage: 'Daily challenge' },
shuffle: { id: 'app.home.challenge.shuffle', defaultMessage: 'Try another' },
easy: { id: 'app.home.challenge.easy', defaultMessage: 'Easy' },
medium: { id: 'app.home.challenge.medium', defaultMessage: 'Medium' },
hard: { id: 'app.home.challenge.hard', defaultMessage: 'Hard' },
})
const difficultyMessages = {
easy: messages.easy,
medium: messages.medium,
hard: messages.hard,
} as const
const dailyIndex = stableGreetingIndex(
`daily-challenge:${toDateKey(new Date())}`,
dailyChallenges.length,
)
const challengeIndex = ref(dailyIndex)
const challenge = computed(() => dailyChallenges[challengeIndex.value])
const challengeText = computed(() => {
const lowerLocale = locale.value.toLowerCase()
if (lowerLocale == 'zh-tw') {
return challenge.value.text['zh-TW']
} else {
return lowerLocale.startsWith('zh')
? challenge.value.text['zh-CN']
: challenge.value.text['en-US']
}
})
const difficultyDotClass: Record<ChallengeDifficulty, string> = {
easy: 'bg-brand-green',
medium: 'bg-orange',
hard: 'bg-red',
}
function shuffleChallenge() {
if (dailyChallenges.length < 2) return
let next = challengeIndex.value
while (next === challengeIndex.value) {
next = Math.floor(Math.random() * dailyChallenges.length)
}
challengeIndex.value = next
}
</script>
<template>
<section
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
>
<div class="flex items-center gap-2">
<SparklesIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
<h2 class="m-0 truncate text-lg">
{{ formatMessage(messages.dailyChallenge) }}
</h2>
<ButtonStyled circular size="small" type="transparent" class="ml-auto">
<button v-tooltip="formatMessage(messages.shuffle)" @click="shuffleChallenge">
<UpdatedIcon />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-sm leading-relaxed text-primary">{{ challengeText }}</p>
<div class="flex items-center gap-1.5 text-xs text-secondary">
<span
class="size-2 rounded-full"
:class="difficultyDotClass[challenge.difficulty]"
aria-hidden="true"
/>
{{ formatMessage(difficultyMessages[challenge.difficulty]) }}
</div>
</section>
</template>

View File

@ -0,0 +1,952 @@
<script setup lang="ts">
import {
ChevronDownIcon,
ChevronUpIcon,
ExpandIcon,
GripVerticalIcon,
ListIcon,
MoreVerticalIcon,
PencilIcon,
PlusIcon,
RefreshCwIcon,
TrashIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
OverflowMenu,
useVIntl,
} from '@modrinth/ui'
import { useElementSize } from '@vueuse/core'
import { computed, ref, shallowRef, watch } from 'vue'
import Draggable from 'vuedraggable'
import {
addHomeWidget,
enableFreeHomeDashboard,
findNearestFreeHomeWidgetPosition,
getHomeGridColumnCount,
getHomeWidgetDimensions,
getHomeWidgetSpan,
HOME_RECENT_DEFAULT_LIMIT,
HOME_RECENT_LIMIT_OPTIONS,
HOME_WIDGET_GRID_GAP,
HOME_WIDGET_GRID_ROW_HEIGHT,
HOME_WIDGET_SIZE_OPTIONS,
type HomeDashboardConfig,
type HomeGreetingFont,
type HomeGreetingMode,
type HomeWidgetLayout,
type HomeWidgetPlacement,
type HomeWidgetPosition,
type HomeWidgetSize,
moveHomeWidget,
packHomeWidgets,
removeHomeWidget,
replaceHomeDashboardWidgets,
resizeHomeWidget,
setHomeDashboardLayout,
setHomeGreetingOptions,
setHomeRecentLimit,
setHomeWidgetPosition,
} from '@/components/home/home-dashboard'
import { provideHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import HomeCalendar from '@/components/home/HomeCalendar.vue'
import HomeGreeting from '@/components/home/HomeGreeting.vue'
import HomeGreetingSettingsModal from '@/components/home/HomeGreetingSettingsModal.vue'
import HomePinnedInstances from '@/components/home/HomePinnedInstances.vue'
import HomePinnedServers from '@/components/home/HomePinnedServers.vue'
import HomePinnedWorlds from '@/components/home/HomePinnedWorlds.vue'
import HomeRecentWorlds from '@/components/home/HomeRecentWorlds.vue'
import HomeShortcutWidget from '@/components/home/HomeShortcutWidget.vue'
import HomeWidgetPickerModal from '@/components/home/HomeWidgetPickerModal.vue'
import type { GameInstance } from '@/helpers/types'
const props = defineProps<{
config: HomeDashboardConfig
instances: GameInstance[]
playerName: string | null
editing: boolean
}>()
const emit = defineEmits<{
change: [config: HomeDashboardConfig]
reset: []
}>()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
provideHomeDashboardRuntime(handleError)
const editing = computed(() => props.editing)
const isFreeLayout = computed(() => props.config.layout === 'free')
const gridContainer = ref<HTMLElement>()
const widgetPicker = ref<InstanceType<typeof HomeWidgetPickerModal>>()
const greetingSettings = ref<InstanceType<typeof HomeGreetingSettingsModal>>()
const replacingWidgetId = ref<string | null>(null)
const dragging = ref(false)
const draggableWidgets = ref<HomeWidgetPlacement[]>([])
const previewPositions = ref<Record<string, HomeWidgetPosition>>({})
const { width } = useElementSize(gridContainer)
const columnCount = computed(() => getHomeGridColumnCount(width.value))
const widgetsForPacking = computed(() =>
editing.value ? draggableWidgets.value : props.config.widgets,
)
const packedWidgets = computed(() => packHomeWidgets(widgetsForPacking.value, columnCount.value))
const packedById = computed(() => new Map(packedWidgets.value.map((widget) => [widget.id, widget])))
const freeDrag = shallowRef<{
id: string
pointerId: number
startClientX: number
startClientY: number
startPosition: HomeWidgetPosition
target: HTMLElement
article: HTMLElement
deltaX: number
deltaY: number
frame: number | null
} | null>(null)
const freeGridColumnPitch = computed(
() => getHomeWidgetDimensions('1x1', columnCount.value, width.value).width + HOME_WIDGET_GRID_GAP,
)
const freeGridRowPitch = HOME_WIDGET_GRID_ROW_HEIGHT + HOME_WIDGET_GRID_GAP
const resolvedFreePositions = computed(() => {
const positions: Record<string, HomeWidgetPosition> = {}
const positioned: HomeWidgetPlacement[] = []
const activeId = freeDrag.value?.id
const orderedWidgets = activeId
? [
...props.config.widgets.filter((widget) => widget.id !== activeId),
...props.config.widgets.filter((widget) => widget.id === activeId),
]
: props.config.widgets
for (const widget of orderedWidgets) {
const position = findNearestFreeHomeWidgetPosition(
positioned,
widget,
rawFreeWidgetPosition(widget),
columnCount.value,
)
positions[widget.id] = position
positioned.push({ ...widget, position })
}
return positions
})
const freeContentRows = computed(() =>
props.config.widgets.reduce((lastRow, widget) => {
const position = freeWidgetPosition(widget)
return Math.max(lastRow, position.row + getHomeWidgetSpan(widget.size, columnCount.value).rows)
}, 0),
)
const freeCanvasHeight = computed(() => {
if (!props.config.widgets.length) return 0
return Math.max(480, freeContentRows.value * freeGridRowPitch)
})
const dashboardGridStyle = computed(() =>
isFreeLayout.value
? {
height: `${freeCanvasHeight.value}px`,
'--home-free-grid-column-pitch': `${freeGridColumnPitch.value}px`,
'--home-free-grid-row-pitch': `${freeGridRowPitch}px`,
}
: { gridTemplateColumns: `repeat(${columnCount.value}, minmax(0, 1fr))` },
)
const messages = defineMessages({
add: { id: 'app.home.widgets.add', defaultMessage: 'Add widget' },
options: { id: 'app.home.widgets.options', defaultMessage: 'Widget options' },
moveEarlier: { id: 'app.home.widgets.move-earlier', defaultMessage: 'Move earlier' },
moveLater: { id: 'app.home.widgets.move-later', defaultMessage: 'Move later' },
remove: { id: 'app.home.widgets.remove', defaultMessage: 'Remove widget' },
drag: { id: 'app.home.widgets.drag', defaultMessage: 'Drag to move widget' },
replace: { id: 'app.home.widgets.replace', defaultMessage: 'Replace target' },
empty: { id: 'app.home.widgets.empty', defaultMessage: 'Add a widget to build your Home.' },
size: { id: 'app.home.widgets.size', defaultMessage: 'Size {size}' },
recentItems: {
id: 'app.home.widgets.recent-items',
defaultMessage: 'Show {count} recent items',
},
greetingSettings: {
id: 'app.home.greeting.settings.title',
defaultMessage: 'Customize greeting',
},
})
watch(
() => props.config.widgets,
(widgets) => {
if (!dragging.value) {
draggableWidgets.value = [...widgets]
previewPositions.value = Object.fromEntries(
widgets.flatMap((widget) =>
widget.position ? [[widget.id, widget.position] as const] : [],
),
)
}
},
{ immediate: true, deep: true },
)
function widgetStyle(widget: HomeWidgetPlacement) {
if (isFreeLayout.value) {
const position = freeWidgetPosition(widget)
const dimensions = getWidgetDimensions(widget)
return {
left: `${position.column * freeGridColumnPitch.value}px`,
top: `${position.row * freeGridRowPitch}px`,
width: `${dimensions.width}px`,
height: `${dimensions.height}px`,
}
}
const packed = packedById.value.get(widget.id)
if (!packed) return undefined
if (editing.value && dragging.value) {
return {
gridColumn: `span ${packed.effectiveColumns}`,
gridRow: `span ${packed.effectiveRows}`,
}
}
return {
gridColumn: `${packed.column} / span ${packed.effectiveColumns}`,
gridRow: `${packed.row} / span ${packed.effectiveRows}`,
}
}
function getWidgetDimensions(widget: HomeWidgetPlacement) {
return getHomeWidgetDimensions(widget.size, columnCount.value, width.value)
}
function defaultFreeWidgetPosition(widget: HomeWidgetPlacement): HomeWidgetPosition {
const packed = packedById.value.get(widget.id)
if (!packed) return { column: 0, row: 0 }
return {
column: packed.column - 1,
row: packed.row - 1,
}
}
function rawFreeWidgetPosition(widget: HomeWidgetPlacement): HomeWidgetPosition {
const position =
previewPositions.value[widget.id] ?? widget.position ?? defaultFreeWidgetPosition(widget)
const span = getHomeWidgetSpan(widget.size, columnCount.value)
return {
column: Math.min(
Math.max(0, Math.round(position.column)),
Math.max(0, columnCount.value - span.columns),
),
row: Math.max(0, Math.round(position.row)),
}
}
function freeWidgetPosition(widget: HomeWidgetPlacement): HomeWidgetPosition {
return resolvedFreePositions.value[widget.id] ?? rawFreeWidgetPosition(widget)
}
function startFreeWidgetDrag(event: PointerEvent, widget: HomeWidgetPlacement) {
if (!editing.value || !isFreeLayout.value || event.button !== 0) return
event.preventDefault()
const target = event.currentTarget as HTMLElement
const article = target.closest<HTMLElement>('.home-widget')
if (!article) return
const position = freeWidgetPosition(widget)
target.setPointerCapture(event.pointerId)
freeDrag.value = {
id: widget.id,
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startPosition: position,
target,
article,
deltaX: 0,
deltaY: 0,
frame: null,
}
dragging.value = true
}
function updateFreeWidgetDrag(event: PointerEvent, widget: HomeWidgetPlacement) {
const current = freeDrag.value
if (!current || current.id !== widget.id || current.pointerId !== event.pointerId) return
const dimensions = getWidgetDimensions(widget)
const startLeft = current.startPosition.column * freeGridColumnPitch.value
const startTop = current.startPosition.row * freeGridRowPitch
current.deltaX = Math.min(
Math.max(event.clientX - current.startClientX, -startLeft),
Math.max(-startLeft, width.value - dimensions.width - startLeft),
)
current.deltaY = Math.max(event.clientY - current.startClientY, -startTop)
if (current.frame !== null) return
current.frame = window.requestAnimationFrame(() => {
current.frame = null
if (freeDrag.value !== current) return
current.article.style.transform = `translate3d(${current.deltaX}px, ${current.deltaY}px, 0)`
})
}
function finishFreeWidgetDrag(event: PointerEvent, widget: HomeWidgetPlacement) {
const current = freeDrag.value
if (!current || current.id !== widget.id || current.pointerId !== event.pointerId) return
if (current.target.hasPointerCapture(event.pointerId)) {
current.target.releasePointerCapture(event.pointerId)
}
if (current.frame !== null) window.cancelAnimationFrame(current.frame)
current.article.style.transform = ''
const position = findNearestFreeHomeWidgetPosition(
props.config.widgets.map((candidate) => ({
...candidate,
position: freeWidgetPosition(candidate),
})),
widget,
{
column: current.startPosition.column + Math.round(current.deltaX / freeGridColumnPitch.value),
row: current.startPosition.row + Math.round(current.deltaY / freeGridRowPitch),
},
columnCount.value,
)
previewPositions.value = { ...previewPositions.value, [widget.id]: position }
freeDrag.value = null
dragging.value = false
emit('change', setHomeWidgetPosition(props.config, widget.id, position))
}
function moveFreeWidgetWithKeyboard(event: KeyboardEvent, widget: HomeWidgetPlacement) {
if (!editing.value || !isFreeLayout.value) return
const movement = {
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1],
}[event.key]
if (!movement) return
event.preventDefault()
const current = freeWidgetPosition(widget)
const position = findNearestFreeHomeWidgetPosition(
props.config.widgets.map((candidate) => ({
...candidate,
position: freeWidgetPosition(candidate),
})),
widget,
{
column: current.column + movement[0],
row: current.row + movement[1],
},
columnCount.value,
)
previewPositions.value = { ...previewPositions.value, [widget.id]: position }
emit('change', setHomeWidgetPosition(props.config, widget.id, position))
}
function startWidgetDrag() {
dragging.value = true
}
function finishWidgetDrag() {
dragging.value = false
const reordered = [...draggableWidgets.value]
const unchanged = reordered.every(
(widget, index) => widget.id === props.config.widgets[index]?.id,
)
if (!unchanged) emit('change', replaceHomeDashboardWidgets(props.config, reordered))
}
function effectiveSize(widget: HomeWidgetPlacement): HomeWidgetSize {
const packed = packedById.value.get(widget.id)
return packed
? (`${packed.effectiveColumns}x${packed.effectiveRows}` as HomeWidgetSize)
: widget.size
}
function openWidgetPicker() {
replacingWidgetId.value = null
widgetPicker.value?.show()
}
function addWidget(widget: HomeWidgetPlacement) {
const replacingId = replacingWidgetId.value
replacingWidgetId.value = null
if (!replacingId) {
const placement = isFreeLayout.value
? { ...widget, position: { column: 0, row: freeContentRows.value } }
: widget
emit('change', addHomeWidget(props.config, placement))
return
}
emit(
'change',
replaceHomeDashboardWidgets(
props.config,
props.config.widgets.map((current) =>
current.id === replacingId
? {
...widget,
id: current.id,
size: current.size,
...(current.position ? { position: current.position } : {}),
}
: current,
),
),
)
}
function replaceWidgetTarget(widget: HomeWidgetPlacement) {
replacingWidgetId.value = widget.id
widgetPicker.value?.show(widget.kind)
}
function removeWidget(id: string) {
emit('change', removeHomeWidget(props.config, id))
}
function resizeWidget(id: string, size: HomeWidgetSize) {
let config = resizeHomeWidget(props.config, id, size)
if (isFreeLayout.value) {
const widget = config.widgets.find((candidate) => candidate.id === id)
if (widget) {
const position = findNearestFreeHomeWidgetPosition(
config.widgets.map((candidate) => ({
...candidate,
position: freeWidgetPosition(candidate),
})),
widget,
freeWidgetPosition(widget),
columnCount.value,
)
config = setHomeWidgetPosition(config, id, position)
}
}
emit('change', config)
}
function setRecentLimit(id: string, limit: (typeof HOME_RECENT_LIMIT_OPTIONS)[number]) {
emit('change', setHomeRecentLimit(props.config, id, limit))
}
function openGreetingSettings(widget: HomeWidgetPlacement) {
greetingSettings.value?.show(widget)
}
function saveGreetingSettings(
id: string,
mode: HomeGreetingMode,
text: string,
font: HomeGreetingFont,
fontSize: number,
) {
emit('change', setHomeGreetingOptions(props.config, id, mode, text, font, fontSize))
}
function moveWidget(index: number, direction: -1 | 1) {
emit('change', moveHomeWidget(props.config, index, direction))
}
function widgetOptions(widget: HomeWidgetPlacement, index: number) {
const sizeOptions = HOME_WIDGET_SIZE_OPTIONS[widget.kind]
return [
...(widget.kind === 'greeting'
? [
{
id: 'greeting-settings',
icon: PencilIcon,
action: () => openGreetingSettings(widget),
},
{ divider: true },
]
: []),
...(widget.kind === 'recent'
? [
...HOME_RECENT_LIMIT_OPTIONS.map((limit) => ({
id: `recent-limit-${limit}`,
icon: ListIcon,
disabled: (widget.options?.recentLimit ?? HOME_RECENT_DEFAULT_LIMIT) === limit,
action: () => setRecentLimit(widget.id, limit),
})),
{ divider: true },
]
: []),
...(sizeOptions.length > 1
? [
...sizeOptions.map((size) => ({
id: `size-${size}`,
icon: ExpandIcon,
disabled: widget.size === size,
action: () => resizeWidget(widget.id, size),
})),
{ divider: true },
]
: []),
...(widget.target
? [
{
id: 'replace',
icon: RefreshCwIcon,
action: () => replaceWidgetTarget(widget),
},
{ divider: true },
]
: []),
...(!isFreeLayout.value
? [
{
id: 'move-earlier',
icon: ChevronUpIcon,
disabled: index === 0,
action: () => moveWidget(index, -1),
},
{
id: 'move-later',
icon: ChevronDownIcon,
disabled: index === props.config.widgets.length - 1,
action: () => moveWidget(index, 1),
},
{ divider: true },
]
: []),
{ id: 'remove', icon: TrashIcon, color: 'red' as const, action: () => removeWidget(widget.id) },
]
}
function setLayout(layout: HomeWidgetLayout) {
if (layout === props.config.layout) return
emit(
'change',
layout === 'free'
? enableFreeHomeDashboard(props.config, columnCount.value)
: setHomeDashboardLayout(props.config, 'grid'),
)
}
defineExpose({ openWidgetPicker, setLayout })
</script>
<template>
<HomeWidgetPickerModal ref="widgetPicker" :instances="instances" @add="addWidget" />
<HomeGreetingSettingsModal
ref="greetingSettings"
:player-name="playerName"
@save="saveGreetingSettings"
/>
<section class="home-dashboard p-6 pb-20" :class="{ 'is-dragging': dragging }">
<div ref="gridContainer" class="mx-auto w-full max-w-[96rem]">
<Draggable
:list="draggableWidgets"
item-key="id"
tag="div"
class="home-dashboard-grid"
:class="{
'is-editing': editing,
'is-dragging': dragging,
'is-free': isFreeLayout,
'has-widgets': config.widgets.length > 0,
}"
:style="dashboardGridStyle"
handle=".home-widget-drag-handle"
:disabled="!editing || isFreeLayout"
:animation="80"
:swap-threshold="0.2"
:invert-swap="true"
:inverted-swap-threshold="0.65"
:empty-insert-threshold="12"
:force-fallback="true"
:fallback-on-body="false"
:fallback-tolerance="0"
:scroll="true"
:scroll-sensitivity="96"
:scroll-speed="24"
:bubble-scroll="true"
ghost-class="home-widget-ghost !border-2 !border-dashed !border-brand !bg-brand-highlight !shadow-none opacity-[0.45]"
chosen-class="home-widget-chosen"
drag-class="home-widget-drag"
fallback-class="home-widget-fallback"
data-onboarding-id="home-widget-grid"
@start="startWidgetDrag"
@end="finishWidgetDrag"
>
<template #item="{ element: widget, index }">
<article
class="home-widget"
:class="{ 'is-free-dragging': freeDrag?.id === widget.id }"
:data-widget-kind="widget.kind"
:style="widgetStyle(widget)"
>
<div v-if="editing" class="home-widget-edit-bar">
<button
v-tooltip="formatMessage(messages.drag)"
type="button"
class="home-widget-drag-handle"
@pointerdown="startFreeWidgetDrag($event, widget)"
@pointermove="updateFreeWidgetDrag($event, widget)"
@pointerup="finishFreeWidgetDrag($event, widget)"
@pointercancel="finishFreeWidgetDrag($event, widget)"
@keydown="moveFreeWidgetWithKeyboard($event, widget)"
>
<GripVerticalIcon />
</button>
<span class="home-widget-size-label">{{ widget.size }}</span>
<div class="home-widget-options">
<ButtonStyled circular size="small" type="transparent">
<OverflowMenu
:options="widgetOptions(widget, index)"
:tooltip="formatMessage(messages.options)"
>
<MoreVerticalIcon />
<template #greeting-settings>
<PencilIcon /> {{ formatMessage(messages.greetingSettings) }}
</template>
<template
v-for="limit in HOME_RECENT_LIMIT_OPTIONS"
#[`recent-limit-${limit}`]
:key="`recent-limit-${limit}`"
>
<ListIcon />
{{ formatMessage(messages.recentItems, { count: limit }) }}
</template>
<template
v-for="size in HOME_WIDGET_SIZE_OPTIONS[widget.kind]"
#[`size-${size}`]
:key="size"
>
<ExpandIcon /> {{ formatMessage(messages.size, { size }) }}
</template>
<template #move-earlier>
<ChevronUpIcon /> {{ formatMessage(messages.moveEarlier) }}
</template>
<template #move-later>
<ChevronDownIcon /> {{ formatMessage(messages.moveLater) }}
</template>
<template #replace>
<RefreshCwIcon /> {{ formatMessage(messages.replace) }}
</template>
<template #remove>
<TrashIcon /> {{ formatMessage(messages.remove) }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
<div class="home-widget-content min-w-0 min-h-0 flex-1 overflow-hidden p-4">
<HomeGreeting
v-if="widget.kind === 'greeting'"
:player-name="playerName"
:dashboard-size="effectiveSize(widget)"
:greeting-mode="widget.options?.greetingMode"
:greeting-text="widget.options?.greetingText"
:greeting-font="widget.options?.greetingFont"
:greeting-font-size="widget.options?.greetingFontSize"
/>
<HomeRecentWorlds
v-else-if="widget.kind === 'recent'"
:instances="instances"
:dashboard-size="effectiveSize(widget)"
:limit="widget.options?.recentLimit"
dashboard
/>
<HomeCalendar
v-else-if="widget.kind === 'calendar'"
:instances="instances"
:dashboard-size="effectiveSize(widget)"
/>
<HomePinnedInstances
v-else-if="widget.kind === 'pinned-instances'"
:instances="instances"
:dashboard-size="effectiveSize(widget)"
dashboard
/>
<HomePinnedWorlds
v-else-if="widget.kind === 'pinned-worlds'"
:instances="instances"
:dashboard-size="effectiveSize(widget)"
dashboard
/>
<HomePinnedServers
v-else-if="widget.kind === 'pinned-servers'"
:instances="instances"
:dashboard-size="effectiveSize(widget)"
dashboard
/>
<HomeShortcutWidget
v-else
:placement="widget"
:instances="instances"
:dashboard-size="effectiveSize(widget)"
/>
</div>
</article>
</template>
</Draggable>
<div
v-if="config.widgets.length === 0"
class="flex min-h-64 flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-divider text-center"
>
<p class="m-0 text-secondary">{{ formatMessage(messages.empty) }}</p>
<ButtonStyled>
<button @click="openWidgetPicker"><PlusIcon /> {{ formatMessage(messages.add) }}</button>
</ButtonStyled>
</div>
</div>
</section>
</template>
<style scoped>
.home-dashboard {
min-width: 0;
container-type: inline-size;
}
.home-dashboard-grid {
display: grid;
grid-auto-rows: 10rem;
align-items: stretch;
gap: 1rem;
}
.home-dashboard-grid.is-editing.is-dragging {
grid-auto-flow: dense;
}
.home-dashboard-grid.is-free {
position: relative;
display: block;
}
.home-dashboard-grid.is-free.is-editing::before {
position: absolute;
inset: 0;
content: '';
border: 1px solid color-mix(in srgb, var(--color-divider) 55%, transparent);
border-radius: var(--radius-lg);
background-image:
linear-gradient(
to right,
color-mix(in srgb, var(--color-divider) 45%, transparent) 1px,
transparent 1px
),
linear-gradient(
to bottom,
color-mix(in srgb, var(--color-divider) 45%, transparent) 1px,
transparent 1px
);
background-size:
var(--home-free-grid-column-pitch) var(--home-free-grid-row-pitch),
var(--home-free-grid-column-pitch) var(--home-free-grid-row-pitch);
pointer-events: none;
}
.home-dashboard-grid.is-free.has-widgets {
min-height: 30rem;
}
.home-dashboard-grid.is-free .home-widget {
position: absolute;
}
.home-widget {
position: relative;
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
border: 1px solid transparent;
border-radius: var(--radius-lg);
background: var(--color-raised-bg);
box-shadow: var(--shadow-card);
transition:
border-color 120ms ease,
box-shadow 120ms ease,
filter 120ms ease;
}
.home-widget[data-widget-kind='greeting'] {
border-color: transparent;
background: transparent;
box-shadow: none;
}
.home-dashboard-grid:not(.is-editing)
.home-widget:is(
[data-widget-kind='instance'],
[data-widget-kind='world'],
[data-widget-kind='server']
):hover {
filter: brightness(var(--hover-brightness));
}
.home-widget-edit-bar {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 12;
display: flex;
max-width: calc(100% - 1rem);
height: 2.25rem;
align-items: center;
gap: 0;
padding: 0.125rem;
border: 1px solid var(--color-divider);
border-radius: var(--radius-lg);
background: var(--color-raised-bg);
box-shadow: var(--shadow-button);
overflow: hidden;
opacity: 0.9;
transition:
box-shadow 120ms ease,
opacity 120ms ease;
}
.home-widget-drag-handle {
display: inline-flex;
width: 2rem;
height: 2rem;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--color-secondary);
cursor: grab;
touch-action: none;
transition:
background-color 100ms ease,
color 100ms ease;
}
.home-widget-size-label {
width: 0;
overflow: hidden;
color: var(--color-secondary);
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
opacity: 0;
white-space: nowrap;
transition:
width 120ms ease,
margin 120ms ease,
opacity 120ms ease;
}
.home-widget-options {
display: flex;
min-width: 0;
width: 0;
flex: 0 0 auto;
overflow: hidden;
opacity: 0;
pointer-events: none;
transition:
width 120ms ease,
opacity 120ms ease;
}
.home-widget:hover .home-widget-edit-bar,
.home-widget:focus-within .home-widget-edit-bar {
box-shadow: var(--shadow-card);
opacity: 1;
}
.home-widget:hover .home-widget-size-label,
.home-widget:focus-within .home-widget-size-label {
width: 2.25rem;
margin-left: 0.25rem;
opacity: 1;
}
.home-widget:hover .home-widget-options,
.home-widget:focus-within .home-widget-options {
width: 2rem;
opacity: 1;
pointer-events: auto;
}
.home-widget-drag-handle:hover,
.home-widget-drag-handle:focus-visible {
background: var(--color-button-bg);
color: var(--color-contrast);
outline: none;
}
.home-widget-drag-handle:active {
cursor: grabbing;
}
.home-widget[data-widget-kind='instance'] .home-widget-content,
.home-widget[data-widget-kind='world'] .home-widget-content,
.home-widget[data-widget-kind='server'] .home-widget-content {
padding: 0;
}
.home-widget-content > :deep(*) {
height: 100%;
min-height: 0;
}
.home-widget-ghost > * {
opacity: 0;
}
.home-widget-chosen {
border-color: var(--color-brand);
box-shadow: 0 0 0 4px var(--color-brand-shadow);
}
.home-widget-drag,
.home-widget-fallback,
.home-widget.is-free-dragging {
z-index: 1000 !important;
border-color: var(--color-brand);
box-shadow: var(--shadow-card);
cursor: grabbing;
opacity: 0.98;
will-change: transform;
}
.home-dashboard-grid.is-editing .home-widget {
border-color: transparent;
}
.home-dashboard-grid.is-editing .home-widget[data-widget-kind='greeting'] {
border-color: var(--color-divider);
border-style: dashed;
}
.home-dashboard-grid.is-editing .home-widget-content {
pointer-events: none;
}
.home-dashboard-grid.is-editing
.home-widget:is(
[data-widget-kind='instance'],
[data-widget-kind='world'],
[data-widget-kind='server']
):hover,
.home-dashboard-grid.is-editing
.home-widget:is(
[data-widget-kind='instance'],
[data-widget-kind='world'],
[data-widget-kind='server']
):focus-within {
border-color: var(--color-divider);
box-shadow: var(--shadow-card);
}
.home-dashboard.is-dragging,
.home-dashboard.is-dragging * {
user-select: none;
}
@media (prefers-reduced-motion: reduce) {
.home-dashboard-grid {
scroll-behavior: auto;
}
}
</style>

View File

@ -0,0 +1,219 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onUnmounted, ref } from 'vue'
import {
HOME_GREETING_DEFAULT_FONT,
HOME_GREETING_DEFAULT_FONT_SIZE,
HOME_GREETING_DEFAULT_MODE,
type HomeGreetingFont,
type HomeGreetingMode,
type HomeWidgetSize,
} from './home-dashboard'
import { getTimeBucket, stableGreetingIndex } from './home-utils'
const props = withDefaults(
defineProps<{
playerName?: string | null
variant?: 'standard' | 'minimal'
dashboardSize?: HomeWidgetSize | null
greetingMode?: HomeGreetingMode
greetingText?: string
greetingFont?: HomeGreetingFont
greetingFontSize?: number
}>(),
{
playerName: null,
variant: 'standard',
dashboardSize: null,
greetingMode: HOME_GREETING_DEFAULT_MODE,
greetingText: '',
greetingFont: HOME_GREETING_DEFAULT_FONT,
greetingFontSize: HOME_GREETING_DEFAULT_FONT_SIZE,
},
)
const { formatMessage, locale } = useVIntl()
const now = ref(new Date())
const messages = defineMessages({
withPlayer: {
id: 'app.home.greeting.with-player',
defaultMessage: 'Welcome back, {name}. {greeting}',
},
welcomeWithPlayer: {
id: 'app.home.greeting.welcome-with-player',
defaultMessage: 'Welcome back, {name}.',
},
welcome: {
id: 'app.home.greeting.welcome',
defaultMessage: 'Welcome back.',
},
minimalWithPlayer: {
id: 'app.home.greeting.minimal.with-player',
defaultMessage: '{greeting}, {name}',
},
minimalLateNight: {
id: 'app.home.greeting.minimal.late-night',
defaultMessage: 'Good evening',
},
minimalDawn: {
id: 'app.home.greeting.minimal.dawn',
defaultMessage: 'Good morning',
},
minimalMorning: {
id: 'app.home.greeting.minimal.morning',
defaultMessage: 'Good morning',
},
minimalAfternoon: {
id: 'app.home.greeting.minimal.afternoon',
defaultMessage: 'Good afternoon',
},
minimalEvening: {
id: 'app.home.greeting.minimal.evening',
defaultMessage: 'Good evening',
},
minimalNight: {
id: 'app.home.greeting.minimal.night',
defaultMessage: 'Good evening',
},
'late-night': {
id: 'app.home.greeting.late-night',
defaultMessage:
'The moon is still working overtime.\nA quiet world is waiting.\nNight shifts build great stories.\nOne more block before dawn?\nThe stars have your server covered.\nLate hours, legendary saves.\nThe torchlight looks especially good now.\nYour next adventure is still awake.\nThe caves are calmer after midnight.\nA peaceful spawn point awaits.\nThe night belongs to patient builders.\nKeep the soundtrack low and the ideas loud.\nEvery great base starts with one block.\nThe End can wait, unless it cannot.\nA small session still counts.\nThe world has been saved for you.',
},
dawn: {
id: 'app.home.greeting.dawn',
defaultMessage:
'First light, fresh chunks.\nA new day is loading in.\nThe sunrise buff is active.\nMorning worlds feel brand new.\nCoffee first, diamonds second.\nYour base missed you overnight.\nA calm start makes a fine adventure.\nThe overworld is waking up.\nFresh air, fresh resource packs.\nToday is a good day to explore.\nThe village is already open for trade.\nA quiet morning suits a big build.\nNew day, new coordinates.\nThe creepers are not morning people either.\nStart small and see where it goes.\nYour next session is ready when you are.',
},
morning: {
id: 'app.home.greeting.morning',
defaultMessage:
'Good morning, adventurer.\nThe day is full of unexplored chunks.\nA fine time for a fresh start.\nYour tools are ready for the day.\nThe overworld has excellent plans.\nBuild something your future self will love.\nA new session is a clean canvas.\nThe sun is up and so are the villagers.\nLet today be a little more blocky.\nThe mines have been suspiciously quiet.\nYour next project is only one launch away.\nA good morning for a good world.\nThere is always room for one more idea.\nThe crafting table is on standby.\nThe map is waiting for new markers.\nSettle in and make some progress.',
},
afternoon: {
id: 'app.home.greeting.afternoon',
defaultMessage:
'Afternoon break, excellent timing.\nA short session can become a great one.\nThe world is ready for your next move.\nTime to check on that half-finished build.\nA little exploration goes a long way.\nThe next biome is calling.\nYour inventory has been waiting patiently.\nThe village market is still open.\nA good hour for a focused project.\nThe redstone probably behaves today.\nYour pickaxe is ready to work.\nA new route is waiting beyond spawn.\nThe afternoon is made for side quests.\nOne quick visit to your world?\nThe next chapter starts here.\nTake a moment and make something.',
},
evening: {
id: 'app.home.greeting.evening',
defaultMessage:
"Evening is prime building time.\nThe day is winding down; the world is opening up.\nA familiar world makes a good landing spot.\nTime to return to your favorite project.\nThe sunset looks better from a new tower.\nYour base lights are waiting.\nA relaxed session sounds about right.\nThe villagers are closing shop soon.\nYour next build deserves an evening glow.\nA good time to wander without a plan.\nThe campfire is already lit.\nOne more room for the base?\nThe horizon is looking especially inviting.\nA quiet night starts with a good world.\nThe next block is yours to place.\nMake tonight's progress count.",
},
night: {
id: 'app.home.greeting.night',
defaultMessage:
'The night shift is ready.\nA good evening for familiar worlds.\nYour favorite instance is waiting nearby.\nThe stars are out; the plans are in.\nA calm session can end the day well.\nThe world is quieter after dark.\nTime to put a few more blocks in place.\nYour base is glowing in the distance.\nThe next adventure starts at sunset.\nA night well spent has a good save file.\nThe campfire crackles, somewhere.\nThe moon makes every build look dramatic.\nA little Minecraft before tomorrow.\nYour worlds are ready for a visit.\nThe night is still young, in chunks.\nSettle in for a well-earned session.',
},
})
const minimalGreetingMessages = {
'late-night': messages.minimalLateNight,
dawn: messages.minimalDawn,
morning: messages.minimalMorning,
afternoon: messages.minimalAfternoon,
evening: messages.minimalEvening,
night: messages.minimalNight,
}
const greeting = computed(() => {
const bucket = getTimeBucket(now.value)
const variants = formatMessage(messages[bucket]).split('\n').filter(Boolean)
const seed = `${locale.value}:${now.value.toDateString()}:${bucket}:${props.playerName ?? ''}`
return variants[stableGreetingIndex(seed, variants.length)] ?? ''
})
const minimalGreeting = computed(() =>
formatMessage(minimalGreetingMessages[getTimeBucket(now.value)]),
)
const automaticWelcome = computed(() =>
props.playerName
? formatMessage(messages.welcomeWithPlayer, { name: props.playerName })
: formatMessage(messages.welcome),
)
const dateLabel = computed(() =>
new Intl.DateTimeFormat(locale.value, {
weekday: 'long',
month: 'long',
day: 'numeric',
}).format(now.value),
)
const heading = computed(() => {
if (props.variant === 'minimal') {
return props.playerName
? formatMessage(messages.minimalWithPlayer, {
name: props.playerName,
greeting: minimalGreeting.value,
})
: minimalGreeting.value
}
const customText = props.greetingText.trim()
if (props.greetingMode === 'text') return customText || greeting.value
if (props.greetingMode === 'text-and-greeting') {
return `${customText || automaticWelcome.value} ${greeting.value}`
}
return greeting.value
})
const greetingFontFamilies: Record<HomeGreetingFont, string> = {
sans: 'var(--font-standard)',
minecraft: "'bundled-minecraft-font-mrapp', monospace",
mono: 'var(--mono-font)',
serif: "Georgia, 'Times New Roman', serif",
}
const headingStyle = computed(() =>
props.dashboardSize
? {
'--home-greeting-font-family': greetingFontFamilies[props.greetingFont],
'--home-greeting-font-size': `${props.greetingFontSize}px`,
}
: undefined,
)
const updateClock = () => {
now.value = new Date()
}
const timer = window.setInterval(updateClock, 60_000)
onUnmounted(() => window.clearInterval(timer))
</script>
<template>
<header
class="home-greeting flex min-w-0 flex-col"
:class="
variant === 'minimal'
? 'items-center gap-3 text-center'
: dashboardSize
? 'h-full justify-center gap-2'
: 'gap-1 py-2'
"
>
<span v-if="variant !== 'minimal' && dashboardSize" class="text-xs font-bold leading-none tracking-normal text-secondary">
{{ dateLabel }}
</span>
<h1
class="m-0 max-w-full break-words font-extrabold text-contrast"
:class="dashboardSize ? 'home-greeting-heading' : 'text-2xl'"
:style="headingStyle"
>
{{ heading }}
</h1>
<div v-if="variant === 'minimal'" class="h-0.5 w-8 rounded-full bg-brand" aria-hidden="true" />
</header>
</template>
<style scoped>
.home-greeting-heading {
max-width: 44rem;
font-family: var(--home-greeting-font-family, var(--font-standard));
font-size: var(--home-greeting-font-size, 1.375rem);
line-height: 1.35;
}
</style>

View File

@ -0,0 +1,249 @@
<script setup lang="ts">
import { MessageIcon, SaveIcon, SparklesIcon, TextCursorInputIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Combobox,
commonMessages,
defineMessages,
NewModal,
Slider,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
import {
HOME_GREETING_DEFAULT_FONT,
HOME_GREETING_DEFAULT_FONT_SIZE,
HOME_GREETING_DEFAULT_MODE,
HOME_GREETING_FONT_SIZE_MAX,
HOME_GREETING_FONT_SIZE_MIN,
type HomeGreetingFont,
type HomeGreetingMode,
type HomeWidgetPlacement,
} from '@/components/home/home-dashboard'
import HomeGreeting from '@/components/home/HomeGreeting.vue'
const props = defineProps<{
playerName: string | null
}>()
const emit = defineEmits<{
save: [id: string, mode: HomeGreetingMode, text: string, font: HomeGreetingFont, fontSize: number]
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const textInput = ref<InstanceType<typeof StyledInput>>()
const widgetId = ref('')
const mode = ref<HomeGreetingMode>(HOME_GREETING_DEFAULT_MODE)
const text = ref('')
const font = ref<HomeGreetingFont>(HOME_GREETING_DEFAULT_FONT)
const fontSize = ref(HOME_GREETING_DEFAULT_FONT_SIZE)
const messages = defineMessages({
title: { id: 'app.home.greeting.settings.title', defaultMessage: 'Customize greeting' },
modeLabel: { id: 'app.home.greeting.settings.mode', defaultMessage: 'Display style' },
greetingMode: {
id: 'app.home.greeting.settings.mode.greeting',
defaultMessage: 'Greeting only',
},
greetingModeDescription: {
id: 'app.home.greeting.settings.mode.greeting-description',
defaultMessage: 'Show a rotating greeting based on the time of day.',
},
textAndGreetingMode: {
id: 'app.home.greeting.settings.mode.text-and-greeting',
defaultMessage: 'Text + greeting',
},
textAndGreetingModeDescription: {
id: 'app.home.greeting.settings.mode.text-and-greeting-description',
defaultMessage: 'Put your own message before the rotating greeting.',
},
textMode: {
id: 'app.home.greeting.settings.mode.text',
defaultMessage: 'Custom text only',
},
textModeDescription: {
id: 'app.home.greeting.settings.mode.text-description',
defaultMessage: 'Replace the automatic greeting with your own message.',
},
textLabel: { id: 'app.home.greeting.settings.text', defaultMessage: 'Custom text' },
prefixPlaceholder: {
id: 'app.home.greeting.settings.prefix-placeholder',
defaultMessage: 'Welcome back, {name}.',
},
textPlaceholder: {
id: 'app.home.greeting.settings.text-placeholder',
defaultMessage: 'The next adventure starts here.',
},
textFallback: {
id: 'app.home.greeting.settings.text-fallback',
defaultMessage: 'Leave this empty to use the current automatic greeting.',
},
preview: { id: 'app.home.greeting.settings.preview', defaultMessage: 'Preview' },
fontLabel: { id: 'app.home.greeting.settings.font', defaultMessage: 'Font' },
fontSizeLabel: { id: 'app.home.greeting.settings.font-size', defaultMessage: 'Font size' },
fontSans: { id: 'app.home.greeting.settings.font.sans', defaultMessage: 'Launcher' },
fontMinecraft: { id: 'app.home.greeting.settings.font.minecraft', defaultMessage: 'Minecraft' },
fontMono: { id: 'app.home.greeting.settings.font.mono', defaultMessage: 'Monospace' },
fontSerif: { id: 'app.home.greeting.settings.font.serif', defaultMessage: 'Serif' },
})
const modeOptions = computed(() => [
{
id: 'greeting' as const,
label: formatMessage(messages.greetingMode),
description: formatMessage(messages.greetingModeDescription),
icon: SparklesIcon,
},
{
id: 'text-and-greeting' as const,
label: formatMessage(messages.textAndGreetingMode),
description: formatMessage(messages.textAndGreetingModeDescription),
icon: MessageIcon,
},
{
id: 'text' as const,
label: formatMessage(messages.textMode),
description: formatMessage(messages.textModeDescription),
icon: TextCursorInputIcon,
},
])
const placeholder = computed(() =>
mode.value === 'text-and-greeting'
? formatMessage(messages.prefixPlaceholder, { name: props.playerName ?? 'Steve' })
: formatMessage(messages.textPlaceholder),
)
const fontOptions = computed(() => [
{ value: 'sans' as const, label: formatMessage(messages.fontSans) },
{ value: 'minecraft' as const, label: formatMessage(messages.fontMinecraft) },
{ value: 'mono' as const, label: formatMessage(messages.fontMono) },
{ value: 'serif' as const, label: formatMessage(messages.fontSerif) },
])
function selectMode(nextMode: HomeGreetingMode) {
mode.value = nextMode
if (nextMode !== 'greeting') void nextTick(() => textInput.value?.focus())
}
function show(widget: HomeWidgetPlacement) {
widgetId.value = widget.id
mode.value = widget.options?.greetingMode ?? HOME_GREETING_DEFAULT_MODE
text.value = widget.options?.greetingText ?? ''
font.value = widget.options?.greetingFont ?? HOME_GREETING_DEFAULT_FONT
fontSize.value = widget.options?.greetingFontSize ?? HOME_GREETING_DEFAULT_FONT_SIZE
modal.value?.show()
}
function save() {
emit('save', widgetId.value, mode.value, text.value, font.value, fontSize.value)
modal.value?.hide()
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" width="560px" max-width="560px">
<div class="flex min-w-0 flex-col gap-5">
<section class="flex min-w-0 flex-col gap-2">
<h3 class="m-0 text-sm font-semibold text-contrast">
{{ formatMessage(messages.modeLabel) }}
</h3>
<div class="grid grid-cols-3 overflow-hidden rounded-lg border border-solid border-divider">
<button
v-for="option in modeOptions"
:key="option.id"
type="button"
class="flex min-h-28 cursor-pointer flex-col items-start gap-2 border-0 border-r border-solid border-divider bg-transparent p-3 text-left last:border-r-0 hover:bg-button-bg focus-visible:z-[1] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
:class="{ 'bg-button-bg': mode === option.id }"
:aria-pressed="mode === option.id"
@click="selectMode(option.id)"
>
<component
:is="option.icon"
class="size-5"
:class="mode === option.id ? 'text-brand' : 'text-secondary'"
aria-hidden="true"
/>
<strong class="text-sm text-contrast">{{ option.label }}</strong>
<span class="text-xs leading-4 text-secondary">{{ option.description }}</span>
</button>
</div>
</section>
<label v-if="mode !== 'greeting'" class="flex min-w-0 flex-col gap-2">
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.textLabel)
}}</span>
<StyledInput
ref="textInput"
v-model="text"
multiline
:rows="2"
:maxlength="120"
:placeholder="placeholder"
wrapper-class="w-full"
/>
<span class="text-xs text-secondary">{{ formatMessage(messages.textFallback) }}</span>
</label>
<section class="grid min-w-0 grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] gap-5">
<label class="flex min-w-0 flex-col gap-2">
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.fontLabel)
}}</span>
<Combobox v-model="font" :options="fontOptions" />
</label>
<label class="flex min-w-0 flex-col gap-2">
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.fontSizeLabel)
}}</span>
<Slider
v-model="fontSize"
:min="HOME_GREETING_FONT_SIZE_MIN"
:max="HOME_GREETING_FONT_SIZE_MAX"
:step="1"
unit="px"
/>
</label>
</section>
<section class="flex min-w-0 flex-col gap-2">
<h3 class="m-0 text-sm font-semibold text-contrast">
{{ formatMessage(messages.preview) }}
</h3>
<div class="min-h-28 rounded-lg bg-button-bg px-4 py-3">
<HomeGreeting
:player-name="playerName"
:greeting-mode="mode"
:greeting-text="text"
:greeting-font="font"
:greeting-font-size="fontSize"
dashboard-size="2x1"
/>
</div>
</section>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="save">
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>

View File

@ -0,0 +1,86 @@
<script setup lang="ts">
import { MoreVerticalIcon, PinIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, OverflowMenu, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import Instance from '@/components/ui/Instance.vue'
import type { GameInstance } from '@/helpers/types'
type InstanceCardLayout = 'spotlight' | 'row' | 'tile'
const props = withDefaults(
defineProps<{
instance: GameInstance
pinned: boolean
playing?: boolean
layout?: InstanceCardLayout
}>(),
{
playing: false,
layout: 'row',
},
)
const emit = defineEmits<{
'pinned-change': [instance: GameInstance, pinned: boolean]
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
pin: { id: 'app.home.instances.pin', defaultMessage: 'Pin to Home' },
unpin: { id: 'app.home.instances.unpin', defaultMessage: 'Unpin from Home' },
})
const compact = computed(() => props.layout !== 'tile')
const menuOptions = computed(() => [
{
id: props.pinned ? 'unpin' : 'pin',
action: () => emit('pinned-change', props.instance, !props.pinned),
},
])
</script>
<template>
<div class="home-instance-card relative min-w-0" :data-layout="layout" :data-compact="compact">
<Instance
:instance="instance"
:compact="compact"
:flat="true"
:playing="playing"
:first="layout === 'spotlight'"
/>
<div class="home-instance-menu" @click.stop>
<ButtonStyled circular size="small" type="transparent">
<OverflowMenu
:options="menuOptions"
:tooltip="formatMessage(pinned ? messages.unpin : messages.pin)"
>
<MoreVerticalIcon />
<template #pin><PinIcon /> {{ formatMessage(messages.pin) }}</template>
<template #unpin>
<PinIcon class="rotate-45" /> {{ formatMessage(messages.unpin) }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</template>
<style scoped>
.home-instance-card[data-compact='true'] {
padding-right: 2.25rem;
}
.home-instance-menu {
position: absolute;
top: 0.25rem;
right: 0.25rem;
z-index: 2;
}
.home-instance-card[data-compact='true'] .home-instance-menu {
top: 50%;
right: 0;
transform: translateY(-50%);
}
</style>

View File

@ -0,0 +1,88 @@
<script setup lang="ts">
import { CheckIcon } from '@modrinth/assets'
import { defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { nextTick, ref } from 'vue'
import InstancePickerList from '@/components/ui/instance/InstancePickerList.vue'
import type { GameInstance } from '@/helpers/types'
defineProps<{
instances: GameInstance[]
selectedInstanceId?: string | null
}>()
const emit = defineEmits<{
select: [instance: GameInstance]
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const instancePicker = ref<InstanceType<typeof InstancePickerList>>()
const messages = defineMessages({
title: {
id: 'app.home.minimal.picker.title',
defaultMessage: 'Choose a Home instance',
},
search: {
id: 'app.home.minimal.picker.search',
defaultMessage: 'Search instances',
},
noInstances: {
id: 'app.home.minimal.picker.no-instances',
defaultMessage: 'No instances available',
},
noResults: {
id: 'app.home.minimal.picker.no-results',
defaultMessage: 'No matching instances',
},
select: {
id: 'app.home.minimal.picker.select',
defaultMessage: 'Choose {name}',
},
})
function show() {
instancePicker.value?.reset()
modal.value?.show()
void nextTick(() => instancePicker.value?.focus())
}
function selectInstance(instance: GameInstance) {
emit('select', instance)
modal.value?.hide()
}
defineExpose({ show })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
max-width="560px"
width="min(560px, calc(100vw - 2rem))"
scrollable
max-content-height="min(36rem, 70vh)"
>
<div class="flex min-w-0 flex-col gap-4">
<InstancePickerList
ref="instancePicker"
:instances="instances"
:search-placeholder="formatMessage(messages.search)"
:no-instances-message="formatMessage(messages.noInstances)"
:no-matches-message="formatMessage(messages.noResults)"
:select-label="(instance) => formatMessage(messages.select, { name: instance.name })"
@select="selectInstance"
>
<template #action="{ instance }">
<CheckIcon
v-if="instance.id === selectedInstanceId"
class="size-5 shrink-0 text-brand"
aria-hidden="true"
/>
</template>
</InstancePickerList>
</div>
</NewModal>
</template>

View File

@ -0,0 +1,124 @@
<script setup lang="ts">
import { ExternalIcon, NewspaperIcon } from '@modrinth/assets'
import {
defineMessages,
injectNotificationManager,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref } from 'vue'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { get_minecraft_news, type MinecraftNewsItem } from '@/helpers/mc_news'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const { offline } = useNetworkStatus()
const formatDate = useFormatDateTime({ dateStyle: 'medium' })
const messages = defineMessages({
news: { id: 'app.home.news.title', defaultMessage: 'Minecraft news' },
openArticle: { id: 'app.home.news.open-article', defaultMessage: 'Read on minecraft.net' },
})
const NEWS_COUNT = 12
const NEWS_SKELETON_COUNT = 4
const newsItems = ref<MinecraftNewsItem[]>([])
const loading = ref(true)
const htmlDecoder = document.createElement('textarea')
get_minecraft_news(NEWS_COUNT)
.then((items) => {
newsItems.value = items.map((item) => ({
...item,
title: decodeHtmlEntities(item.title),
}))
})
.catch(() => {
newsItems.value = []
})
.finally(() => {
loading.value = false
})
const visible = computed(() => !offline.value && (loading.value || newsItems.value.length > 0))
function newsDateLabel(item: MinecraftNewsItem): string | null {
if (!item.date) return null
const parsed = new Date(item.date)
return Number.isNaN(parsed.getTime()) ? null : formatDate(parsed)
}
function decodeHtmlEntities(value: string): string {
htmlDecoder.innerHTML = value
return htmlDecoder.value
}
async function openArticle(item: MinecraftNewsItem) {
try {
await openUrl(item.read_more_url)
} catch (error) {
handleError(error)
}
}
</script>
<template>
<section
v-if="visible"
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
>
<div class="flex items-center gap-2">
<NewspaperIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
<h2 class="m-0 truncate text-lg">
{{ formatMessage(messages.news) }}
</h2>
</div>
<ul v-if="loading" class="m-0 flex list-none flex-col gap-1.5 p-0" aria-hidden="true">
<li
v-for="index in NEWS_SKELETON_COUNT"
:key="index"
class="flex animate-pulse items-center gap-2.5 px-1.5 py-1.5"
>
<div class="h-9 w-16 shrink-0 rounded-lg bg-button-bg" />
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<div class="h-3 w-full rounded bg-button-bg" />
<div class="h-3 w-1/2 rounded bg-button-bg" />
</div>
</li>
</ul>
<ul v-else class="m-0 flex list-none flex-col p-0">
<li v-for="item in newsItems" :key="`${item.date ?? ''}:${item.title}`" class="group min-w-0">
<button
v-tooltip="formatMessage(messages.openArticle)"
type="button"
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-1.5 py-1.5 text-left transition-colors hover:bg-button-bg"
@click="openArticle(item)"
>
<img
v-if="item.image_url"
:src="item.image_url"
alt=""
class="h-9 w-16 shrink-0 rounded-lg object-cover"
loading="lazy"
/>
<div v-else class="h-9 w-16 shrink-0 rounded-lg bg-button-bg" />
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="line-clamp-2 text-sm font-semibold leading-snug text-contrast">
{{ item.title }}
</span>
<span v-if="newsDateLabel(item)" class="truncate text-xs text-secondary">
{{ newsDateLabel(item) }}
</span>
</div>
<ExternalIcon
class="size-3.5 shrink-0 text-secondary opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
aria-hidden="true"
/>
</button>
</li>
</ul>
</section>
</template>

View File

@ -0,0 +1,337 @@
<script setup lang="ts">
import {
DownloadIcon,
GameIcon,
ListIcon,
PlayIcon,
PlusIcon,
SpinnerIcon,
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Card,
commonMessages,
defineMessages,
injectNotificationManager,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import dayjs from 'dayjs'
import { computed, onUnmounted, ref, watch } from 'vue'
import HomeGreeting from '@/components/home/HomeGreeting.vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { handleSevereError } from '@/store/error'
const props = defineProps<{
instances: GameInstance[]
selectedInstanceId?: string | null
playerName?: string | null
}>()
const emit = defineEmits<{
choose: []
create: []
}>()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const { handleError } = injectNotificationManager()
const handleMinecraftLaunchError = useMinecraftLaunchError()
const { offline } = useNetworkStatus()
const messages = defineMessages({
chooseInstance: {
id: 'app.home.minimal.choose-instance',
defaultMessage: 'Choose instance',
},
changeInstance: {
id: 'app.home.minimal.change-instance',
defaultMessage: 'Change Home instance',
},
createInstance: {
id: 'app.home.instances.create',
defaultMessage: 'Create instance',
},
noInstances: {
id: 'app.home.instances.empty',
defaultMessage: 'No instances yet',
},
loading: {
id: 'app.instance.loading',
defaultMessage: 'Instance is loading...',
},
played: {
id: 'app.instance.played',
defaultMessage: 'Played {time}',
},
neverPlayed: {
id: 'app.instance.never-played',
defaultMessage: 'Never played',
},
offlineInstalledOnly: {
id: 'app.instance.offline-installed-only',
defaultMessage: 'Offline mode can only launch fully downloaded instances.',
},
})
const selectedInstance = computed(() =>
props.instances.find((instance) => instance.id === props.selectedInstanceId),
)
const running = ref(false)
const loading = ref(false)
const currentEvent = ref<string | null>(null)
const installed = computed(() => selectedInstance.value?.install_stage === 'installed')
const installing = computed(
() => selectedInstance.value?.install_stage.includes('installing') ?? false,
)
const busy = computed(
() => loading.value || installing.value || (currentEvent.value === 'launched' && !running.value),
)
const lastPlayed = computed(() => {
if (!selectedInstance.value?.last_played) return formatMessage(messages.neverPlayed)
return formatMessage(messages.played, {
time: formatRelativeTime(dayjs(selectedInstance.value.last_played).toISOString()),
})
})
async function refreshProcessState() {
if (!selectedInstance.value) {
running.value = false
return
}
const processes = await get_by_instance_id(selectedInstance.value.id).catch((error) => {
handleError(error)
return []
})
running.value = processes.length > 0
}
async function playInstance() {
const instance = selectedInstance.value
if (!instance) return
loading.value = true
try {
await run(instance.id)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeMinimal',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id,
instance_name: instance.name,
})
if (!handled) handleSevereError(error, { instanceId: instance.id })
} finally {
loading.value = false
await refreshProcessState()
}
}
async function stopInstance() {
const instance = selectedInstance.value
if (!instance) return
await kill(instance.id).catch(handleError)
running.value = false
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeMinimal',
})
}
async function installInstance() {
const instance = selectedInstance.value
if (!instance) return
loading.value = true
try {
if (
instance.install_stage !== 'pack_installed' &&
(instance.link?.type === 'modrinth_modpack' ||
instance.link?.type === 'server_project_modpack')
) {
await install_pack_to_existing_instance(instance.id, {
type: 'fromVersionId',
project_id: instance.link.project_id ?? instance.link.server_project_id ?? '',
version_id: instance.link.version_id ?? instance.link.content_version_id ?? '',
title: instance.name,
})
} else {
await install_existing_instance(instance.id, false)
}
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
}
watch(
() => props.selectedInstanceId,
() => {
currentEvent.value = null
void refreshProcessState()
},
)
await refreshProcessState()
const unlistenProcess = await process_listener((event: { instance_id: string; event: string }) => {
if (event.instance_id !== selectedInstance.value?.id) return
currentEvent.value = event.event
if (event.event === 'finished') running.value = false
else void refreshProcessState()
})
onUnmounted(() => {
unlistenProcess()
})
</script>
<template>
<section
data-onboarding-id="home-instances"
class="minimal-home-stage flex min-w-0 items-center justify-center px-6 pb-14 pt-8"
>
<div class="flex w-full max-w-3xl flex-col items-center text-center">
<HomeGreeting :player-name="playerName" variant="minimal" />
<template v-if="selectedInstance">
<Card class="mb-0 mt-10 w-full text-left">
<div
class="grid min-w-0 grid-cols-1 items-center gap-5 sm:grid-cols-[minmax(0,1fr)_auto]"
>
<router-link
:to="`/instance/${encodeURIComponent(selectedInstance.id)}`"
class="group flex min-w-0 items-center gap-5 rounded-lg text-inherit no-underline focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
>
<InstanceIcon
class="size-20 shrink-0 transition-transform group-hover:scale-[1.03]"
:icon-path="selectedInstance.icon_path"
:instance-id="selectedInstance.id"
:loader="selectedInstance.loader"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<h2 class="m-0 truncate text-xl font-bold text-contrast group-hover:underline">
{{ selectedInstance.name }}
</h2>
<div class="flex min-w-0 flex-wrap gap-x-4 gap-y-1 text-sm text-secondary">
<span class="flex min-w-0 items-center gap-1.5 capitalize">
<GameIcon class="size-4 shrink-0" aria-hidden="true" />
<span class="truncate">
{{ selectedInstance.loader }} {{ selectedInstance.game_version }}
</span>
</span>
<span class="flex min-w-0 items-center gap-1.5">
<TimerIcon class="size-4 shrink-0" aria-hidden="true" />
<span class="truncate">{{ lastPlayed }}</span>
</span>
</div>
</div>
</router-link>
<div class="flex min-h-11 shrink-0 items-center justify-end gap-2">
<ButtonStyled v-if="running" color="red" size="large">
<button class="w-36 justify-center" @click="stopInstance">
<StopCircleIcon aria-hidden="true" />
<span class="truncate">{{ formatMessage(commonMessages.stopButton) }}</span>
</button>
</ButtonStyled>
<ButtonStyled v-else-if="busy" size="large">
<button class="w-36 justify-center" disabled>
<SpinnerIcon class="animate-spin" aria-hidden="true" />
<span class="truncate">
{{
formatMessage(installing ? commonMessages.installingLabel : messages.loading)
}}
</span>
</button>
</ButtonStyled>
<ButtonStyled v-else-if="installed" color="brand" size="large">
<button class="w-36 justify-center" @click="playInstance">
<PlayIcon class="translate-x-px" aria-hidden="true" />
<span class="truncate">{{ formatMessage(commonMessages.playButton) }}</span>
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" size="large">
<button
v-tooltip="offline ? formatMessage(messages.offlineInstalledOnly) : undefined"
class="w-36 justify-center"
:disabled="offline"
@click="installInstance"
>
<DownloadIcon aria-hidden="true" />
<span class="truncate">{{ formatMessage(commonMessages.installButton) }}</span>
</button>
</ButtonStyled>
<ButtonStyled circular size="large" type="transparent">
<button
v-tooltip="formatMessage(messages.changeInstance)"
:aria-label="formatMessage(messages.changeInstance)"
@click="emit('choose')"
>
<ListIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</div>
</Card>
</template>
<template v-else>
<Card class="mb-0 mt-10 w-full text-left">
<div class="flex min-w-0 flex-wrap items-center gap-4">
<div
class="flex size-16 shrink-0 items-center justify-center rounded-lg bg-button-bg text-secondary"
>
<ListIcon class="size-7" aria-hidden="true" />
</div>
<div class="min-w-48 flex-1">
<h2 class="m-0 text-lg font-bold text-contrast">
{{
formatMessage(
instances.length > 0 ? messages.chooseInstance : messages.noInstances,
)
}}
</h2>
</div>
<ButtonStyled color="brand" size="large">
<button v-if="instances.length > 0" @click="emit('choose')">
<ListIcon aria-hidden="true" />
{{ formatMessage(messages.chooseInstance) }}
</button>
<button v-else @click="emit('create')">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.createInstance) }}
</button>
</ButtonStyled>
</div>
</Card>
</template>
</div>
</section>
</template>
<style scoped>
.minimal-home-stage {
min-height: calc(100vh - var(--top-bar-height) - 4rem);
}
</style>

View File

@ -0,0 +1,129 @@
<script setup lang="ts">
import { GridIcon, RightArrowIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import type { HomeWidgetSize } from '@/components/home/home-dashboard'
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import HomeInstanceCard from '@/components/home/HomeInstanceCard.vue'
import { set_pinned } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
const props = defineProps<{
instances: GameInstance[]
dashboard?: boolean
dashboardSize?: HomeWidgetSize | null
}>()
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const { runningInstanceIds } = useHomeDashboardRuntime()
const messages = defineMessages({
pinnedInstances: {
id: 'app.home.instances.pinned',
defaultMessage: 'Pinned instances',
},
emptyPinned: {
id: 'app.home.instances.pinned-empty',
defaultMessage: 'Pin an instance from its card menu or the library to keep it here.',
},
viewAllInstances: {
id: 'app.home.instances.view-all',
defaultMessage: 'View all instances',
},
})
const pinnedInstances = computed(() =>
props.instances
.filter((instance) => instance.pinned_at)
.slice()
.sort((a, b) => new Date(b.pinned_at ?? 0).getTime() - new Date(a.pinned_at ?? 0).getTime()),
)
const cardLayout = computed(() => {
if (props.dashboardSize === '1x1') return 'spotlight' as const
if (props.dashboardSize === '2x2') return 'tile' as const
return 'row' as const
})
async function updatePinned(instance: GameInstance, pinned: boolean) {
await set_pinned(instance.id, pinned).catch(handleError)
}
</script>
<template>
<section class="home-pinned-instances flex min-w-0 min-h-0 h-full flex-col gap-3" :data-size="dashboardSize">
<div class="home-widget-heading flex min-w-0 h-8 flex-none items-center gap-2">
<h2>
{{ formatMessage(messages.pinnedInstances) }}
</h2>
<ButtonStyled v-if="dashboardSize !== '1x1'" type="transparent" size="small" class="ml-auto">
<router-link to="/library">
<span v-if="dashboardSize === '2x2'">{{ formatMessage(messages.viewAllInstances) }}</span>
<RightArrowIcon aria-hidden="true" />
</router-link>
</ButtonStyled>
</div>
<div v-if="pinnedInstances.length > 0" class="home-instance-list grid min-w-0 min-h-0 flex-1 grid-auto-rows-max gap-1 overflow-x-hidden overflow-y-auto pr-1">
<HomeInstanceCard
v-for="instance in pinnedInstances"
:key="instance.id"
:instance="instance"
:pinned="true"
:layout="cardLayout"
:playing="runningInstanceIds.includes(instance.id)"
@pinned-change="updatePinned"
/>
</div>
<div v-else class="home-widget-empty">
<GridIcon aria-hidden="true" />
<span>{{ formatMessage(messages.emptyPinned) }}</span>
</div>
</section>
</template>
<style scoped>
.home-widget-heading h2 {
min-width: 0;
overflow: hidden;
margin: 0;
color: var(--color-contrast);
font-size: 1rem;
font-weight: 700;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-pinned-instances[data-size='2x1'] .home-instance-list,
.home-pinned-instances[data-size='2x2'] .home-instance-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 0.5rem;
}
.home-pinned-instances[data-size='1x1'] {
gap: 0.375rem;
}
.home-pinned-instances[data-size='1x1'] .home-widget-heading {
height: 1.5rem;
}
.home-widget-empty {
display: flex;
max-width: 22rem;
margin: auto;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: var(--color-secondary);
font-size: 0.8125rem;
line-height: 1.4;
text-align: center;
}
.home-widget-empty svg {
width: 1.5rem;
height: 1.5rem;
opacity: 0.7;
}
</style>

View File

@ -0,0 +1,356 @@
<script setup lang="ts">
import {
MoreVerticalIcon,
NoSignalIcon,
PinIcon,
PlayIcon,
ServerIcon,
SignalIcon,
SpinnerIcon,
StopCircleIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
defineMessages,
injectNotificationManager,
OverflowMenu,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import type { HomeWidgetSize } from '@/components/home/home-dashboard'
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { kill } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import {
type ServerWorld,
PROTECTED_SERVER_ADDRESS,
set_world_display_status,
start_join_server,
type WorldWithInstance,
} from '@/helpers/worlds'
import { handleSevereError } from '@/store/error'
const props = defineProps<{
instances: GameInstance[]
dashboard?: boolean
dashboardSize?: HomeWidgetSize | null
}>()
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const handleMinecraftLaunchError = useMinecraftLaunchError()
const runtime = useHomeDashboardRuntime()
const { favoriteWorlds, runningInstanceIds } = runtime
const messages = defineMessages({
pinnedServers: {
id: 'app.home.servers.pinned',
defaultMessage: 'Pinned servers',
},
emptyServers: {
id: 'app.home.servers.empty',
defaultMessage: 'Favorite a server and it will be pinned here.',
},
playersOnline: {
id: 'app.home.servers.players-online',
defaultMessage: '{online}/{max} online',
},
offline: {
id: 'app.home.servers.offline',
defaultMessage: 'Offline',
},
join: {
id: 'app.home.servers.join',
defaultMessage: 'Join server',
},
stop: {
id: 'app.home.servers.stop',
defaultMessage: 'Stop',
},
unpin: {
id: 'app.home.servers.unpin',
defaultMessage: 'Unpin from Home',
},
moreOptions: {
id: 'app.home.servers.more-options',
defaultMessage: 'More options',
},
protectedServerName: {
id: 'app.home.servers.protected-name',
defaultMessage: 'Starlight Server',
},
})
const startingServerKey = ref<string | null>(null)
const instanceById = computed(
() => new Map(props.instances.map((instance) => [instance.id, instance])),
)
const servers = computed(() => {
const favoriteServers = favoriteWorlds.value.flatMap((world) => {
if (world.type !== 'server' || world.address === PROTECTED_SERVER_ADDRESS) return []
const instance = instanceById.value.get(world.instance_id)
return instance ? [{ instance, world: world as ServerWorld & WorldWithInstance }] : []
})
const protectedInstance =
props.instances.find((instance) => instance.install_stage === 'installed') ?? props.instances[0]
if (!protectedInstance) return favoriteServers
const protectedServer: ServerWorld & WorldWithInstance = {
instance_id: protectedInstance.id,
name: formatMessage(messages.protectedServerName),
last_played: undefined,
icon: undefined,
display_status: 'favorite',
type: 'server',
index: -1,
address: PROTECTED_SERVER_ADDRESS,
pack_status: 'prompt',
}
return [{ instance: protectedInstance, world: protectedServer }, ...favoriteServers]
})
function serverKey(world: ServerWorld & WorldWithInstance): string {
return `${world.instance_id}:${world.address}`
}
function dataFor(world: ServerWorld & WorldWithInstance) {
return runtime.getServerData(world.instance_id, world.address)
}
async function joinServer(world: ServerWorld & WorldWithInstance, instance: GameInstance) {
const key = serverKey(world)
startingServerKey.value = key
try {
await start_join_server(world.instance_id, world.address)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedServer',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id,
instance_name: instance.name,
})
if (!handled) handleSevereError(error, { instanceId: instance.id })
} finally {
startingServerKey.value = null
}
}
async function stopInstance(instance: GameInstance) {
await kill(instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedServer',
})
}
async function unpinServer(world: ServerWorld & WorldWithInstance) {
await set_world_display_status(world.instance_id, 'server', world.address, 'normal').catch(
handleError,
)
await runtime.refreshFavorites()
}
</script>
<template>
<section
class="home-pinned-servers flex min-w-0 min-h-0 h-full flex-col gap-3"
:data-size="dashboardSize"
>
<div class="home-widget-heading flex min-w-0 h-8 flex-none items-center gap-2">
<ServerIcon class="size-5 shrink-0 text-brand" aria-hidden="true" />
<h2>{{ formatMessage(messages.pinnedServers) }}</h2>
</div>
<div v-if="servers.length === 0" class="home-widget-empty">
<ServerIcon aria-hidden="true" />
<span>{{ formatMessage(messages.emptyServers) }}</span>
</div>
<ul
v-else
class="home-server-list grid min-w-0 min-h-0 flex-1 grid-auto-rows-max gap-1 m-0 overflow-x-hidden overflow-y-auto pr-1 list-none"
>
<li
v-for="server in servers"
:key="serverKey(server.world)"
class="home-server-row group hover:bg-button-bg focus-within:bg-button-bg"
>
<div class="relative shrink-0">
<Avatar
:src="dataFor(server.world).status?.favicon ?? (server.world.icon || undefined)"
:tint-by="server.world.address"
size="36px"
/>
<span
class="absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-solid border-bg-raised"
:class="
dataFor(server.world).refreshing
? 'animate-pulse bg-secondary'
: dataFor(server.world).status
? 'bg-brand-green'
: 'bg-red'
"
aria-hidden="true"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="truncate text-sm font-semibold text-contrast">
{{ server.world.name }}
</span>
<span class="truncate text-xs text-secondary">{{ server.world.address }}</span>
<span
v-if="dataFor(server.world).status"
class="flex min-w-0 items-center gap-1 text-xs text-secondary"
>
<SignalIcon class="size-3 shrink-0" aria-hidden="true" />
<span class="truncate">
{{
formatMessage(messages.playersOnline, {
online: dataFor(server.world).status?.players?.online ?? 0,
max: dataFor(server.world).status?.players?.max ?? 0,
})
}}
</span>
</span>
<span
v-else-if="dataFor(server.world).refreshing"
class="truncate text-xs text-secondary"
>
{{ server.world.address }}
</span>
<span v-else class="flex min-w-0 items-center gap-1 text-xs text-secondary">
<NoSignalIcon class="size-3 shrink-0" aria-hidden="true" />
<span class="truncate">{{ formatMessage(messages.offline) }}</span>
</span>
</div>
<div class="ml-auto flex shrink-0 items-center gap-0.5">
<ButtonStyled
v-if="runningInstanceIds.includes(server.instance.id)"
circular
size="small"
type="transparent"
>
<button
v-tooltip="formatMessage(messages.stop)"
class="!text-red"
@click="stopInstance(server.instance)"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else circular size="small" type="transparent">
<button
v-tooltip="formatMessage(messages.join)"
class="!text-brand opacity-60 transition-opacity group-hover:opacity-100"
:disabled="startingServerKey === serverKey(server.world)"
@click="joinServer(server.world, server.instance)"
>
<SpinnerIcon
v-if="startingServerKey === serverKey(server.world)"
class="animate-spin"
/>
<PlayIcon v-else />
</button>
</ButtonStyled>
<ButtonStyled
v-if="server.world.address !== PROTECTED_SERVER_ADDRESS"
circular
size="small"
type="transparent"
class="home-server-menu"
>
<OverflowMenu
:options="[
{
id: 'unpin',
action: () => unpinServer(server.world),
},
]"
:tooltip="formatMessage(messages.moreOptions)"
>
<MoreVerticalIcon />
<template #unpin>
<PinIcon class="rotate-45" aria-hidden="true" />
{{ formatMessage(messages.unpin) }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</li>
</ul>
</section>
</template>
<style scoped>
.home-widget-heading h2 {
min-width: 0;
overflow: hidden;
margin: 0;
color: var(--color-contrast);
font-size: 1rem;
font-weight: 700;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-server-row {
display: flex;
min-width: 0;
align-items: center;
gap: 0.625rem;
padding: 0.5rem;
border-radius: 6px;
transition: background-color 120ms ease;
}
.home-pinned-servers[data-size='2x1'] .home-server-list,
.home-pinned-servers[data-size='2x2'] .home-server-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 0.5rem;
}
.home-pinned-servers[data-size='1x1'] {
gap: 0.375rem;
}
.home-pinned-servers[data-size='1x1'] .home-widget-heading {
height: 1.5rem;
}
.home-pinned-servers[data-size='1x1'] .home-server-row {
gap: 0.5rem;
padding: 0.375rem;
}
.home-pinned-servers[data-size='1x1'] .home-server-menu {
display: none;
}
.home-widget-empty {
display: flex;
max-width: 20rem;
margin: auto;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: var(--color-secondary);
font-size: 0.8125rem;
line-height: 1.4;
text-align: center;
}
.home-widget-empty svg {
width: 1.5rem;
height: 1.5rem;
opacity: 0.7;
}
</style>

View File

@ -0,0 +1,198 @@
<script setup lang="ts">
import { GameIcon } from '@modrinth/assets'
import { defineMessages, GAME_MODES, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { getHomeWidgetCardDensity, type HomeWidgetSize } from '@/components/home/home-dashboard'
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { kill, run } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import {
getWorldIdentifier,
hasWorldQuickPlaySupport,
start_join_singleplayer_world,
type WorldWithInstance,
} from '@/helpers/worlds'
import { handleSevereError } from '@/store/error'
const props = defineProps<{
instances: GameInstance[]
dashboard?: boolean
dashboardSize?: HomeWidgetSize | null
}>()
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const handleMinecraftLaunchError = useMinecraftLaunchError()
const runtime = useHomeDashboardRuntime()
const { favoriteWorlds, gameVersions, runningInstanceIds } = runtime
const messages = defineMessages({
pinnedWorlds: {
id: 'app.home.worlds.pinned',
defaultMessage: 'Pinned worlds',
},
emptyWorlds: {
id: 'app.home.worlds.empty',
defaultMessage: 'Favorite a world and it will be pinned here.',
},
})
const startingWorldKey = ref<string | null>(null)
const playingWorldKey = ref<string | null>(null)
const instanceById = computed(
() => new Map(props.instances.map((instance) => [instance.id, instance])),
)
const favorites = computed(() =>
favoriteWorlds.value.flatMap((world) => {
if (world.type !== 'singleplayer') return []
const instance = instanceById.value.get(world.instance_id)
return instance ? [{ instance, world }] : []
}),
)
const worldDensity = computed(() => getHomeWidgetCardDensity(props.dashboardSize))
function favoriteKey(world: WorldWithInstance): string {
return `${world.instance_id}:${world.type}:${getWorldIdentifier(world)}`
}
watch(runningInstanceIds, (instanceIds) => {
if (playingWorldKey.value && !instanceIds.includes(playingWorldKey.value.split(':', 1)[0])) {
playingWorldKey.value = null
}
})
async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
if (world.type !== 'singleplayer') return
const key = favoriteKey(world)
startingWorldKey.value = key
try {
await start_join_singleplayer_world(world.instance_id, world.path)
playingWorldKey.value = key
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedWorld',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id,
instance_name: instance.name,
})
if (!handled) handleSevereError(error, { instanceId: instance.id })
} finally {
startingWorldKey.value = null
}
}
async function playInstance(instance: GameInstance) {
try {
await run(instance.id)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedWorld',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id,
instance_name: instance.name,
})
if (!handled) handleSevereError(error, { instanceId: instance.id })
}
}
async function stopInstance(instance: GameInstance) {
await kill(instance.id).catch(handleError)
playingWorldKey.value = null
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedWorld',
})
}
</script>
<template>
<section class="home-pinned-worlds flex min-w-0 min-h-0 h-full flex-col gap-3" :data-size="dashboardSize">
<div class="home-widget-heading flex min-w-0 h-8 flex-none items-center gap-2">
<h2>{{ formatMessage(messages.pinnedWorlds) }}</h2>
</div>
<div v-if="favorites.length > 0" class="home-world-list flex min-w-0 min-h-0 flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto pr-1">
<WorldItem
v-for="favorite in favorites"
:key="favoriteKey(favorite.world)"
:world="favorite.world"
:playing-instance="runningInstanceIds.includes(favorite.instance.id)"
:playing-world="playingWorldKey === favoriteKey(favorite.world)"
:starting-instance="startingWorldKey === favoriteKey(favorite.world)"
:supports-world-quick-play="
hasWorldQuickPlaySupport(gameVersions, favorite.instance.game_version)
"
:game-mode="
favorite.world.type === 'singleplayer' ? GAME_MODES[favorite.world.game_mode] : undefined
"
:instance-id="favorite.instance.id"
:instance-name="favorite.instance.name"
:instance-icon="favorite.instance.icon_path"
:instance-loader="favorite.instance.loader"
:shortcut-instance-id="favorite.instance.id"
:flat="dashboard"
:dashboard-density="worldDensity"
@play="joinWorld(favorite.world, favorite.instance)"
@play-instance="playInstance(favorite.instance)"
@stop="stopInstance(favorite.instance)"
@update="runtime.refreshFavorites"
/>
</div>
<div v-else class="home-widget-empty">
<GameIcon aria-hidden="true" />
<span>{{ formatMessage(messages.emptyWorlds) }}</span>
</div>
</section>
</template>
<style scoped>
.home-widget-heading h2 {
min-width: 0;
overflow: hidden;
margin: 0;
color: var(--color-contrast);
font-size: 1rem;
font-weight: 700;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-pinned-worlds[data-size='1x1'] {
gap: 0.375rem;
}
.home-pinned-worlds[data-size='1x1'] .home-widget-heading {
height: 1.5rem;
}
.home-widget-empty {
display: flex;
max-width: 20rem;
margin: auto;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: var(--color-secondary);
font-size: 0.8125rem;
line-height: 1.4;
text-align: center;
}
.home-widget-empty svg {
width: 1.5rem;
height: 1.5rem;
opacity: 0.7;
}
</style>

View File

@ -0,0 +1,186 @@
<script setup lang="ts">
import { ChartIcon, ClockIcon, GameIcon, TrendingUpIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onUnmounted, ref } from 'vue'
import { process_listener } from '@/helpers/events'
import { type DailyPlaytime, get_daily_playtime } from '@/helpers/instance'
import { toDateKey } from './home-utils'
const { formatMessage } = useVIntl()
const messages = defineMessages({
insights: { id: 'app.home.insights.title', defaultMessage: 'Play insights' },
thisWeek: { id: 'app.home.insights.this-week', defaultMessage: 'This week: {duration}' },
thisWeekMore: {
id: 'app.home.insights.this-week-more',
defaultMessage: 'This week: {duration} ({percent}% more than last week)',
},
thisWeekLess: {
id: 'app.home.insights.this-week-less',
defaultMessage: 'This week: {duration} ({percent}% less than last week)',
},
thisWeekSame: {
id: 'app.home.insights.this-week-same',
defaultMessage: 'This week: {duration} (same as last week)',
},
streak: {
id: 'app.home.insights.streak',
defaultMessage: '{days, plural, one {# day played in a row} other {# days played in a row}}',
},
weekTop: { id: 'app.home.insights.week-top', defaultMessage: 'Most played: {name}' },
empty: {
id: 'app.home.insights.empty',
defaultMessage: 'Play something and your stats will show up here.',
},
minutes: { id: 'app.home.playtime.minutes', defaultMessage: '{minutes}m' },
hoursMinutes: { id: 'app.home.playtime.hours-minutes', defaultMessage: '{hours}h {minutes}m' },
seconds: { id: 'app.home.playtime.seconds', defaultMessage: '{seconds}s' },
})
const HISTORY_DAYS = 90
const dailyPlaytime = ref<DailyPlaytime[]>([])
function shiftedDate(base: Date, days: number): Date {
const result = new Date(base)
result.setDate(result.getDate() + days)
return result
}
function startOfWeek(date: Date): Date {
return shiftedDate(date, -((date.getDay() + 6) % 7))
}
async function refreshPlaytime() {
const today = new Date()
dailyPlaytime.value = await get_daily_playtime(
toDateKey(shiftedDate(today, -HISTORY_DAYS)),
toDateKey(today),
).catch((): DailyPlaytime[] => [])
}
const dailyByDate = computed(() => new Map(dailyPlaytime.value.map((entry) => [entry.date, entry])))
function rangeSeconds(start: Date, days: number): number {
let total = 0
for (let offset = 0; offset < days; offset++) {
total += dailyByDate.value.get(toDateKey(shiftedDate(start, offset)))?.played_seconds ?? 0
}
return total
}
const weekStart = computed(() => startOfWeek(new Date()))
const thisWeekSeconds = computed(() => rangeSeconds(weekStart.value, 7))
const lastWeekSeconds = computed(() => rangeSeconds(shiftedDate(weekStart.value, -7), 7))
const streakDays = computed(() => {
const today = new Date()
let start = 0
if (!(dailyByDate.value.get(toDateKey(today))?.played_seconds ?? 0)) {
start = 1
}
let days = 0
for (let offset = start; offset <= HISTORY_DAYS; offset++) {
if (dailyByDate.value.get(toDateKey(shiftedDate(today, -offset)))?.played_seconds ?? 0) {
days += 1
} else {
break
}
}
return days
})
const weekTopInstance = computed(() => {
const totals = new Map<string, number>()
for (let offset = 0; offset < 7; offset++) {
const entry = dailyByDate.value.get(toDateKey(shiftedDate(weekStart.value, offset)))
if (entry?.top_instance_name && entry.played_seconds > 0) {
totals.set(
entry.top_instance_name,
(totals.get(entry.top_instance_name) ?? 0) + entry.played_seconds,
)
}
}
let topName: string | null = null
let topSeconds = 0
for (const [name, seconds] of totals) {
if (seconds > topSeconds) {
topName = name
topSeconds = seconds
}
}
return topName
})
const hasAnyPlaytime = computed(() => dailyPlaytime.value.some((entry) => entry.played_seconds > 0))
function formatDuration(seconds: number): string {
const roundedSeconds = Math.max(0, Math.round(seconds))
const hours = Math.floor(roundedSeconds / 3600)
const minutes = Math.floor((roundedSeconds % 3600) / 60)
if (hours > 0) return formatMessage(messages.hoursMinutes, { hours, minutes })
if (minutes > 0) return formatMessage(messages.minutes, { minutes })
return formatMessage(messages.seconds, { seconds: roundedSeconds })
}
const thisWeekLine = computed(() => {
const duration = formatDuration(thisWeekSeconds.value)
if (lastWeekSeconds.value === 0) {
return formatMessage(messages.thisWeek, { duration })
}
const percent = Math.round(
(Math.abs(thisWeekSeconds.value - lastWeekSeconds.value) / lastWeekSeconds.value) * 100,
)
if (percent === 0) return formatMessage(messages.thisWeekSame, { duration })
return formatMessage(
thisWeekSeconds.value > lastWeekSeconds.value ? messages.thisWeekMore : messages.thisWeekLess,
{ duration, percent },
)
})
await refreshPlaytime()
const unlistenProcesses = await process_listener(async (event: { event: string }) => {
if (event.event === 'finished') {
await refreshPlaytime()
}
})
onUnmounted(() => {
unlistenProcesses()
})
</script>
<template>
<section
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
>
<div class="flex items-center gap-2">
<ChartIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
<h2 class="m-0 truncate text-lg">
{{ formatMessage(messages.insights) }}
</h2>
</div>
<p v-if="!hasAnyPlaytime" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<ul v-else class="m-0 flex list-none flex-col gap-2 p-0">
<li class="flex min-w-0 items-center gap-2 text-sm text-primary">
<ClockIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
<span class="min-w-0">{{ thisWeekLine }}</span>
</li>
<li v-if="streakDays > 0" class="flex min-w-0 items-center gap-2 text-sm text-primary">
<TrendingUpIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
<span class="min-w-0">{{ formatMessage(messages.streak, { days: streakDays }) }}</span>
</li>
<li v-if="weekTopInstance" class="flex min-w-0 items-center gap-2 text-sm text-primary">
<GameIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
<span class="min-w-0 truncate">
{{ formatMessage(messages.weekTop, { name: weekTopInstance }) }}
</span>
</li>
</ul>
</section>
</template>

View File

@ -0,0 +1,270 @@
<script setup lang="ts">
import { HistoryIcon } from '@modrinth/assets'
import { defineMessages, GAME_MODES, injectNotificationManager, useVIntl } from '@modrinth/ui'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import {
getHomeWidgetCardDensity,
HOME_RECENT_DEFAULT_LIMIT,
type HomeRecentLimit,
type HomeWidgetSize,
} from '@/components/home/home-dashboard'
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { kill, run } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import {
getWorldIdentifier,
hasServerQuickPlaySupport,
hasWorldQuickPlaySupport,
type ServerWorld,
start_join_server,
start_join_singleplayer_world,
type WorldWithInstance,
} from '@/helpers/worlds'
import { handleSevereError } from '@/store/error'
const props = withDefaults(
defineProps<{
instances: GameInstance[]
dashboard?: boolean
dashboardSize?: HomeWidgetSize | null
limit?: HomeRecentLimit
}>(),
{
limit: HOME_RECENT_DEFAULT_LIMIT,
},
)
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const handleMinecraftLaunchError = useMinecraftLaunchError()
const runtime = useHomeDashboardRuntime()
const { gameVersions, recentWorlds, runningInstanceIds } = runtime
const messages = defineMessages({
recentTitle: {
id: 'app.home.recent.title',
defaultMessage: 'Start from your recent projects',
},
emptyRecent: {
id: 'app.home.recent.empty',
defaultMessage: 'No recent activity yet.',
},
})
type RecentItem =
| { type: 'world'; last_played: Dayjs; instance: GameInstance; world: WorldWithInstance }
| { type: 'instance'; last_played: Dayjs; instance: GameInstance }
const startingWorldKey = ref<string | null>(null)
const playingWorldKey = ref<string | null>(null)
const instanceById = computed(
() => new Map(props.instances.map((instance) => [instance.id, instance])),
)
const recentItems = computed<RecentItem[]>(() => {
const worldItems: RecentItem[] = recentWorlds.value.flatMap((world) => {
const instance = instanceById.value.get(world.instance_id)
if (!instance || !world.last_played) return []
return [{ type: 'world', last_played: dayjs(world.last_played), instance, world }]
})
const coveredInstanceIds = new Set(worldItems.map((item) => item.instance.id))
const instanceItems: RecentItem[] = props.instances
.filter((instance) => instance.last_played && !coveredInstanceIds.has(instance.id))
.map((instance) => ({
type: 'instance',
last_played: dayjs(instance.last_played),
instance,
}))
return [...worldItems, ...instanceItems]
.sort((a, b) => b.last_played.diff(a.last_played))
.slice(0, props.limit)
})
const itemDensity = computed(() => getHomeWidgetCardDensity(props.dashboardSize))
function worldKey(world: WorldWithInstance): string {
return `${world.instance_id}:${world.type}:${getWorldIdentifier(world)}`
}
function serverDataFor(world: WorldWithInstance) {
return world.type === 'server'
? runtime.getServerData(world.instance_id, world.address)
: undefined
}
watch(runningInstanceIds, (instanceIds) => {
if (playingWorldKey.value && !instanceIds.includes(playingWorldKey.value.split(':', 1)[0])) {
playingWorldKey.value = null
}
})
async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
const key = worldKey(world)
startingWorldKey.value = key
try {
if (world.type === 'server') {
await start_join_server(world.instance_id, world.address)
} else {
await start_join_singleplayer_world(world.instance_id, world.path)
}
playingWorldKey.value = key
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeRecentWorld',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id,
instance_name: instance.name,
})
if (!handled) handleSevereError(error, { instanceId: instance.id })
} finally {
startingWorldKey.value = null
}
}
async function playInstance(instance: GameInstance) {
try {
await run(instance.id)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeRecentWorld',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id,
instance_name: instance.name,
})
if (!handled) handleSevereError(error, { instanceId: instance.id })
}
}
async function stopInstance(instance: GameInstance) {
await kill(instance.id).catch(handleError)
playingWorldKey.value = null
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeRecentWorld',
})
}
</script>
<template>
<section class="home-recent-worlds flex min-w-0 min-h-0 h-full flex-col gap-3" :data-size="dashboardSize">
<div class="home-widget-heading flex min-w-0 h-8 flex-none items-center gap-2">
<h2>{{ formatMessage(messages.recentTitle) }}</h2>
</div>
<div v-if="recentItems.length > 0" class="home-recent-list flex min-w-0 min-h-0 flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto pr-1">
<template
v-for="item in recentItems"
:key="item.type === 'world' ? worldKey(item.world) : `${item.instance.id}:instance`"
>
<WorldItem
v-if="item.type === 'world'"
:world="item.world"
:playing-instance="runningInstanceIds.includes(item.instance.id)"
:playing-world="playingWorldKey === worldKey(item.world)"
:starting-instance="startingWorldKey === worldKey(item.world)"
:supports-server-quick-play="
item.world.type === 'server' &&
hasServerQuickPlaySupport(gameVersions, item.instance.game_version)
"
:supports-world-quick-play="
item.world.type === 'singleplayer' &&
hasWorldQuickPlaySupport(gameVersions, item.instance.game_version)
"
:current-protocol="runtime.getProtocolVersion(item.instance.id)"
:refreshing="
item.world.type === 'server' ? serverDataFor(item.world)?.refreshing : undefined
"
:server-status="
item.world.type === 'server' ? serverDataFor(item.world)?.status : undefined
"
:rendered-motd="
item.world.type === 'server' ? serverDataFor(item.world)?.renderedMotd : undefined
"
:game-mode="
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
"
:instance-id="item.instance.id"
:instance-name="item.instance.name"
:instance-icon="item.instance.icon_path"
:instance-loader="item.instance.loader"
:shortcut-instance-id="item.instance.id"
:flat="dashboard"
:dashboard-density="itemDensity"
@play="joinWorld(item.world, item.instance)"
@play-instance="playInstance(item.instance)"
@stop="stopInstance(item.instance)"
@refresh="
item.world.type === 'server'
? runtime.refreshServer(item.instance.id, (item.world as ServerWorld).address, true)
: undefined
"
@update="runtime.refreshRecentWorlds"
/>
<InstanceItem
v-else
:instance="item.instance"
:last-played="item.last_played"
:flat="dashboard"
:playing="runningInstanceIds.includes(item.instance.id)"
:dashboard-density="itemDensity"
/>
</template>
</div>
<div v-else class="home-widget-empty">
<HistoryIcon aria-hidden="true" />
<span>{{ formatMessage(messages.emptyRecent) }}</span>
</div>
</section>
</template>
<style scoped>
.home-widget-heading h2 {
min-width: 0;
overflow: hidden;
margin: 0;
color: var(--color-contrast);
font-size: 1rem;
font-weight: 700;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-recent-worlds[data-size='2x1'] {
gap: 0.5rem;
}
.home-widget-empty {
display: flex;
max-width: 20rem;
margin: auto;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: var(--color-secondary);
font-size: 0.8125rem;
line-height: 1.4;
text-align: center;
}
.home-widget-empty svg {
width: 1.5rem;
height: 1.5rem;
opacity: 0.7;
}
</style>

View File

@ -0,0 +1,462 @@
<script setup lang="ts">
import {
BoxIcon,
GameIcon,
IssuesIcon,
NoSignalIcon,
PlayIcon,
ServerIcon,
SignalIcon,
SpinnerIcon,
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
GAME_MODES,
injectNotificationManager,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import type { HomeWidgetPlacement, HomeWidgetSize } from '@/components/home/home-dashboard'
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { kill, run } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import {
getWorldIdentifier,
hasServerQuickPlaySupport,
hasWorldQuickPlaySupport,
start_join_server,
start_join_singleplayer_world,
type World,
} from '@/helpers/worlds'
import { handleSevereError } from '@/store/error'
const props = defineProps<{
placement: HomeWidgetPlacement
instances: GameInstance[]
dashboardSize: HomeWidgetSize
}>()
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const handleMinecraftLaunchError = useMinecraftLaunchError()
const runtime = useHomeDashboardRuntime()
const { gameVersions, runningInstanceIds } = runtime
const world = ref<World | null>(null)
const starting = ref(false)
const loadingTarget = ref(false)
const messages = defineMessages({
unavailable: {
id: 'app.home.widgets.unavailable',
defaultMessage: 'Content unavailable',
},
played: { id: 'app.instance.played', defaultMessage: 'Played {time}' },
neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' },
instance: { id: 'app.home.shortcut.kind.instance', defaultMessage: 'Instance' },
world: { id: 'app.home.shortcut.kind.world', defaultMessage: 'World' },
server: { id: 'app.home.shortcut.kind.server', defaultMessage: 'Server' },
offline: { id: 'app.home.shortcut.server.offline', defaultMessage: 'Server offline' },
playersOnline: {
id: 'app.home.shortcut.server.players-online',
defaultMessage: '{count} online',
},
hardcore: { id: 'instance.worlds.hardcore', defaultMessage: 'Hardcore mode' },
noServerQuickPlay: {
id: 'instance.worlds.no_server_quick_play',
defaultMessage: 'Direct server join is unavailable for this Minecraft version.',
},
noWorldQuickPlay: {
id: 'instance.worlds.no_singleplayer_quick_play',
defaultMessage: 'Direct world launch is unavailable for this Minecraft version.',
},
})
const instance = computed(() =>
props.instances.find((candidate) => candidate.id === props.placement.target?.instanceId),
)
const missing = computed(
() => !instance.value || (props.placement.kind !== 'instance' && !world.value),
)
const serverData = computed(() =>
instance.value && world.value?.type === 'server'
? runtime.getServerData(instance.value.id, world.value.address)
: undefined,
)
const isRunning = computed(() =>
instance.value ? runningInstanceIds.value.includes(instance.value.id) : false,
)
const versionLabel = computed(() => {
if (!instance.value) return ''
const loader = instance.value.loader === 'vanilla' ? 'Minecraft' : instance.value.loader
return `${loader} ${instance.value.game_version}`
})
const lastPlayedLabel = computed(() => {
const lastPlayed = world.value?.last_played ?? instance.value?.last_played
return lastPlayed
? formatMessage(messages.played, {
time: formatRelativeTime(dayjs(lastPlayed).toISOString()),
})
: formatMessage(messages.neverPlayed)
})
const shortcutTitle = computed(
() =>
(props.placement.kind === 'instance' ? instance.value?.name : world.value?.name) ??
props.placement.target?.fallbackLabel ??
'',
)
const shortcutRoute = computed(() => {
if (!instance.value) return '/'
if (!world.value) return `/instance/${encodeURIComponent(instance.value.id)}`
return `/instance/${encodeURIComponent(instance.value.id)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world.value))}`
})
const shortcutIcon = computed(() => {
if (!world.value) return undefined
return world.value.type === 'server'
? (serverData.value?.status?.favicon ?? world.value.icon)
: world.value.icon
})
const kindLabel = computed(() =>
formatMessage(
props.placement.kind === 'instance'
? messages.instance
: props.placement.kind === 'world'
? messages.world
: messages.server,
),
)
const kindIcon = computed(() =>
props.placement.kind === 'instance'
? BoxIcon
: props.placement.kind === 'world'
? GameIcon
: ServerIcon,
)
const primaryLabel = computed(() => {
if (!world.value) return versionLabel.value
if (world.value.type === 'singleplayer') {
return world.value.hardcore
? formatMessage(messages.hardcore)
: formatMessage(GAME_MODES[world.value.game_mode].message)
}
if (serverData.value?.refreshing) return formatMessage(commonMessages.loadingLabel)
if (!serverData.value?.status) return formatMessage(messages.offline)
return formatMessage(messages.playersOnline, {
count: serverData.value.status.players?.online ?? 0,
})
})
const secondaryLabel = computed(() => {
if (world.value?.type === 'server') return world.value.address
if (world.value) return `${instance.value?.name ?? ''} · ${lastPlayedLabel.value}`
return lastPlayedLabel.value
})
const statusIcon = computed(() => {
if (world.value?.type !== 'server') return world.value ? GameIcon : TimerIcon
return serverData.value?.status ? SignalIcon : NoSignalIcon
})
const supportsQuickPlay = computed(() => {
if (!world.value || !instance.value) return true
return world.value.type === 'server'
? hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version)
: hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version)
})
const playTooltip = computed(() => {
if (supportsQuickPlay.value) return formatMessage(commonMessages.playButton)
return formatMessage(
world.value?.type === 'server' ? messages.noServerQuickPlay : messages.noWorldQuickPlay,
)
})
async function refreshTarget(force = false) {
world.value = null
const target = props.placement.target
if (!target || props.placement.kind === 'instance' || !instance.value) return
loadingTarget.value = true
try {
const available = await runtime.getInstanceWorlds(target.instanceId, force)
world.value =
available.find((candidate) =>
candidate.type === 'server'
? props.placement.kind === 'server' && candidate.address === target.address
: props.placement.kind === 'world' && candidate.path === target.path,
) ?? null
if (world.value?.type === 'server') {
await runtime.refreshServer(target.instanceId, world.value.address, force)
}
} finally {
loadingTarget.value = false
}
}
async function playInstance(targetInstance: GameInstance) {
starting.value = true
try {
await run(targetInstance.id)
trackEvent('InstanceStart', {
loader: targetInstance.loader,
game_version: targetInstance.game_version,
source: 'HomeInstanceWidget',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: targetInstance.id,
instance_name: targetInstance.name,
})
if (!handled) handleSevereError(error, { instanceId: targetInstance.id })
} finally {
starting.value = false
}
}
async function playWorld() {
if (!instance.value || !world.value) return
starting.value = true
try {
if (world.value.type === 'server') {
await start_join_server(instance.value.id, world.value.address)
} else {
await start_join_singleplayer_world(instance.value.id, world.value.path)
}
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: 'HomeShortcutWidget',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.value.id,
instance_name: instance.value.name,
})
if (!handled) handleSevereError(error, { instanceId: instance.value.id })
} finally {
starting.value = false
}
}
async function playShortcut() {
if (!instance.value) return
if (world.value) await playWorld()
else await playInstance(instance.value)
}
async function stopInstance() {
if (!instance.value) return
await kill(instance.value.id).catch(handleError)
}
watch(
() => [props.placement, props.instances] as const,
() => refreshTarget(),
{
immediate: true,
deep: true,
},
)
</script>
<template>
<div
class="home-shortcut-widget min-w-0 min-h-0 h-full"
:data-size="dashboardSize"
:data-kind="placement.kind"
>
<div
v-if="loadingTarget"
class="flex min-w-0 min-h-0 h-full flex-col items-center justify-center gap-2 p-4 box-border text-center"
>
<SpinnerIcon class="size-6 animate-spin text-secondary" aria-hidden="true" />
<span class="text-sm text-secondary">{{ formatMessage(commonMessages.loadingLabel) }}</span>
</div>
<div
v-else-if="missing"
class="flex min-w-0 min-h-0 h-full flex-col items-center justify-center gap-2 p-4 box-border text-center"
>
<IssuesIcon class="size-6 text-secondary" aria-hidden="true" />
<strong class="max-w-full truncate text-contrast">{{
placement.target?.fallbackLabel
}}</strong>
<span class="text-sm text-secondary">{{ formatMessage(messages.unavailable) }}</span>
</div>
<div v-else class="home-shortcut-card grid min-w-0 min-h-0 h-full overflow-hidden">
<router-link
class="home-shortcut-visual relative flex min-w-0 min-h-0 items-center justify-center overflow-hidden bg-button-bg text-secondary no-underline"
:to="shortcutRoute"
tabindex="-1"
>
<component
:is="kindIcon"
class="home-shortcut-watermark absolute -bottom-3 right-3 size-[4.5rem] opacity-[0.08]"
aria-hidden="true"
/>
<Avatar
v-if="shortcutIcon"
class="home-shortcut-icon relative z-10 flex-none shadow-[var(--shadow-card)]"
:src="shortcutIcon"
:size="dashboardSize === '2x1' ? '72px' : '44px'"
/>
<InstanceIcon
v-else-if="instance"
class="home-shortcut-icon relative z-10 flex-none shadow-[var(--shadow-card)]"
:icon-path="instance.icon_path"
:instance-id="instance.id"
:loader="instance.loader"
:size="dashboardSize === '2x1' ? '72px' : '44px'"
/>
</router-link>
<div class="home-shortcut-body relative flex min-w-0 min-h-0 items-stretch">
<router-link
class="home-shortcut-copy flex min-w-0 flex-1 flex-col text-inherit no-underline"
:to="shortcutRoute"
>
<span
class="home-shortcut-kind flex min-w-0 items-center gap-[0.3rem] text-secondary text-[0.6875rem] font-bold leading-none"
>
<component :is="kindIcon" aria-hidden="true" />
{{ kindLabel }}
</span>
<strong
class="home-shortcut-title min-w-0 truncate text-contrast font-[750]"
>{{ shortcutTitle }}</strong
>
<span
class="home-shortcut-meta home-shortcut-primary flex min-w-0 items-center gap-[0.35rem] truncate text-xs font-semibold leading-[1.2] text-secondary"
>
<SpinnerIcon
v-if="world?.type === 'server' && serverData?.refreshing"
class="animate-spin"
aria-hidden="true"
/>
<component :is="statusIcon" v-else aria-hidden="true" />
{{ primaryLabel }}
</span>
<span
class="home-shortcut-meta home-shortcut-secondary flex min-w-0 items-center gap-[0.35rem] truncate text-xs font-semibold leading-[1.2] text-secondary"
>
<TimerIcon v-if="world?.type !== 'server'" aria-hidden="true" />
<ServerIcon v-else aria-hidden="true" />
{{ secondaryLabel }}
</span>
</router-link>
<div class="absolute bottom-3 right-3 z-[2]">
<ButtonStyled v-if="isRunning" circular size="small" color="red">
<button v-tooltip="formatMessage(commonMessages.stopButton)" @click="stopInstance">
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else circular size="small" color="brand">
<button
v-tooltip="playTooltip"
:disabled="starting || !supportsQuickPlay"
@click="playShortcut"
>
<SpinnerIcon v-if="starting" class="animate-spin" />
<PlayIcon v-else class="translate-x-px" />
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.home-shortcut-copy:focus-visible {
border-radius: 6px;
outline: 4px solid var(--color-brand-shadow);
outline-offset: 2px;
}
.home-shortcut-kind svg,
.home-shortcut-meta svg {
width: 0.8rem;
height: 0.8rem;
flex: 0 0 auto;
}
.home-shortcut-copy:hover .home-shortcut-title {
text-decoration: underline;
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-card {
grid-template-rows: 3.75rem minmax(0, 1fr);
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-visual {
justify-content: flex-start;
padding: 0 0.875rem;
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-watermark {
right: 0.5rem;
bottom: -1.25rem;
width: 4rem;
height: 4rem;
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-body {
padding: 0.625rem 0.75rem 0.75rem;
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-copy {
padding-right: 2.5rem;
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-kind {
display: none;
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-title {
font-size: 0.9375rem;
line-height: 1.2;
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-primary {
margin-top: 0.3rem;
}
.home-shortcut-widget[data-size='1x1'] .home-shortcut-secondary {
display: none;
}
.home-shortcut-widget[data-size='2x1'] .home-shortcut-card {
grid-template-columns: minmax(8.5rem, 0.8fr) minmax(0, 1.65fr);
}
.home-shortcut-widget[data-size='2x1'] .home-shortcut-body {
padding: 1rem;
}
.home-shortcut-widget[data-size='2x1'] .home-shortcut-copy {
justify-content: center;
padding-right: 3rem;
}
.home-shortcut-widget[data-size='2x1'] .home-shortcut-title {
margin-top: 0.4rem;
font-size: 1.125rem;
line-height: 1.25;
}
.home-shortcut-widget[data-size='2x1'] .home-shortcut-primary {
margin-top: 0.65rem;
}
.home-shortcut-widget[data-size='2x1'] .home-shortcut-secondary {
margin-top: 0.3rem;
}
</style>

View File

@ -0,0 +1,434 @@
<script setup lang="ts">
import {
CalendarIcon,
ChevronLeftIcon,
ChevronRightIcon,
CollectionIcon,
GameIcon,
GridIcon,
HistoryIcon,
LayoutTemplateIcon,
LinkIcon,
SearchIcon,
ServerIcon,
UserIcon,
} from '@modrinth/assets'
import { ButtonStyled, defineMessages, NewModal, StyledInput, useVIntl } from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
import type { HomeWidgetKind, HomeWidgetPlacement } from '@/components/home/home-dashboard'
import {
HOME_GREETING_DEFAULT_MODE,
HOME_RECENT_DEFAULT_LIMIT,
HOME_WIDGET_DEFAULT_SIZE,
} from '@/components/home/home-dashboard'
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import type { GameInstance } from '@/helpers/types'
import type { World } from '@/helpers/worlds'
const props = defineProps<{
instances: GameInstance[]
}>()
const emit = defineEmits<{
add: [widget: HomeWidgetPlacement]
}>()
const { formatMessage, locale } = useVIntl()
const runtime = useHomeDashboardRuntime()
const modal = ref<InstanceType<typeof NewModal>>()
const searchInput = ref<InstanceType<typeof StyledInput>>()
const searchQuery = ref('')
const selectedKind = ref<HomeWidgetKind | null>(null)
const selectedInstance = ref<GameInstance | null>(null)
const worlds = ref<World[]>([])
const loadingWorlds = ref(false)
const messages = defineMessages({
title: { id: 'app.home.widgets.add-title', defaultMessage: 'Add widget' },
search: { id: 'app.home.widgets.search', defaultMessage: 'Search' },
back: { id: 'app.home.widgets.back', defaultMessage: 'Back' },
noResults: { id: 'app.home.widgets.no-results', defaultMessage: 'No matching items' },
loading: { id: 'app.home.widgets.loading', defaultMessage: 'Loading...' },
overviewGroup: { id: 'app.home.widgets.group.overview', defaultMessage: 'Overview' },
collectionsGroup: {
id: 'app.home.widgets.group.collections',
defaultMessage: 'Pinned collections',
},
shortcutsGroup: {
id: 'app.home.widgets.group.shortcuts',
defaultMessage: 'Single-item shortcuts',
},
greeting: { id: 'app.home.widgets.greeting', defaultMessage: 'Greeting' },
greetingDescription: {
id: 'app.home.widgets.greeting-description',
defaultMessage: 'A personal welcome that changes throughout the day.',
},
recent: { id: 'app.home.widgets.recent', defaultMessage: 'Recently played' },
recentDescription: {
id: 'app.home.widgets.recent-description',
defaultMessage: 'Resume the worlds and instances you played most recently.',
},
calendar: { id: 'app.home.widgets.calendar', defaultMessage: 'Calendar' },
calendarDescription: {
id: 'app.home.widgets.calendar-description',
defaultMessage: 'See the month and your play activity at a glance.',
},
pinnedInstances: {
id: 'app.home.widgets.pinned-instances',
defaultMessage: 'All pinned instances',
},
pinnedInstancesDescription: {
id: 'app.home.widgets.pinned-instances-description',
defaultMessage: 'Automatically collects every instance pinned to Home.',
},
pinnedWorlds: {
id: 'app.home.widgets.pinned-worlds',
defaultMessage: 'All favorite worlds',
},
pinnedWorldsDescription: {
id: 'app.home.widgets.pinned-worlds-description',
defaultMessage: 'Automatically collects favorite singleplayer worlds.',
},
pinnedServers: {
id: 'app.home.widgets.pinned-servers',
defaultMessage: 'All favorite servers',
},
pinnedServersDescription: {
id: 'app.home.widgets.pinned-servers-description',
defaultMessage: 'Automatically collects favorite multiplayer servers.',
},
instance: { id: 'app.home.widgets.instance', defaultMessage: 'Single instance' },
instanceDescription: {
id: 'app.home.widgets.instance-description',
defaultMessage: 'Choose one instance for a dedicated launch shortcut.',
},
world: { id: 'app.home.widgets.world', defaultMessage: 'Single world' },
worldDescription: {
id: 'app.home.widgets.world-description',
defaultMessage: 'Choose one world for a dedicated play shortcut.',
},
server: { id: 'app.home.widgets.server', defaultMessage: 'Single server' },
serverDescription: {
id: 'app.home.widgets.server-description',
defaultMessage: 'Choose one server for a dedicated join shortcut.',
},
chooseInstance: {
id: 'app.home.widgets.choose-instance',
defaultMessage: 'Choose an instance',
},
chooseWorld: { id: 'app.home.widgets.choose-world', defaultMessage: 'Choose a world' },
chooseServer: { id: 'app.home.widgets.choose-server', defaultMessage: 'Choose a server' },
})
const catalogSections = computed(() => [
{
id: 'overview',
label: formatMessage(messages.overviewGroup),
icon: LayoutTemplateIcon,
items: [
{
kind: 'greeting' as const,
label: formatMessage(messages.greeting),
description: formatMessage(messages.greetingDescription),
icon: UserIcon,
},
{
kind: 'recent' as const,
label: formatMessage(messages.recent),
description: formatMessage(messages.recentDescription),
icon: HistoryIcon,
},
{
kind: 'calendar' as const,
label: formatMessage(messages.calendar),
description: formatMessage(messages.calendarDescription),
icon: CalendarIcon,
},
],
},
{
id: 'collections',
label: formatMessage(messages.collectionsGroup),
icon: CollectionIcon,
items: [
{
kind: 'pinned-instances' as const,
label: formatMessage(messages.pinnedInstances),
description: formatMessage(messages.pinnedInstancesDescription),
icon: GridIcon,
},
{
kind: 'pinned-worlds' as const,
label: formatMessage(messages.pinnedWorlds),
description: formatMessage(messages.pinnedWorldsDescription),
icon: GameIcon,
},
{
kind: 'pinned-servers' as const,
label: formatMessage(messages.pinnedServers),
description: formatMessage(messages.pinnedServersDescription),
icon: ServerIcon,
},
],
},
{
id: 'shortcuts',
label: formatMessage(messages.shortcutsGroup),
icon: LinkIcon,
items: [
{
kind: 'instance' as const,
label: formatMessage(messages.instance),
description: formatMessage(messages.instanceDescription),
icon: GridIcon,
},
{
kind: 'world' as const,
label: formatMessage(messages.world),
description: formatMessage(messages.worldDescription),
icon: GameIcon,
},
{
kind: 'server' as const,
label: formatMessage(messages.server),
description: formatMessage(messages.serverDescription),
icon: ServerIcon,
},
],
},
])
const filteredInstances = computed(() => {
const query = searchQuery.value.trim().toLocaleLowerCase(locale.value)
return props.instances.filter((instance) =>
query ? instance.name.toLocaleLowerCase(locale.value).includes(query) : true,
)
})
const filteredWorlds = computed(() => {
const type = selectedKind.value === 'server' ? 'server' : 'singleplayer'
const query = searchQuery.value.trim().toLocaleLowerCase(locale.value)
return worlds.value.filter(
(world) =>
world.type === type && (!query || world.name.toLocaleLowerCase(locale.value).includes(query)),
)
})
const pickerTitle = computed(() => {
if (!selectedKind.value) return formatMessage(messages.title)
if (!selectedInstance.value) return formatMessage(messages.chooseInstance)
return formatMessage(
selectedKind.value === 'server' ? messages.chooseServer : messages.chooseWorld,
)
})
function show(kind: HomeWidgetKind | null = null) {
selectedKind.value = kind
selectedInstance.value = null
worlds.value = []
searchQuery.value = ''
modal.value?.show()
if (kind) void nextTick(() => searchInput.value?.focus())
}
function addWidget(widget: HomeWidgetPlacement) {
emit('add', widget)
modal.value?.hide()
}
function chooseKind(kind: HomeWidgetKind) {
if (kind !== 'instance' && kind !== 'world' && kind !== 'server') {
addWidget({
id: crypto.randomUUID(),
kind,
size: HOME_WIDGET_DEFAULT_SIZE[kind],
...(kind === 'recent' ? { options: { recentLimit: HOME_RECENT_DEFAULT_LIMIT } } : {}),
...(kind === 'greeting' ? { options: { greetingMode: HOME_GREETING_DEFAULT_MODE } } : {}),
})
return
}
selectedKind.value = kind
searchQuery.value = ''
void nextTick(() => searchInput.value?.focus())
}
async function chooseInstance(instance: GameInstance) {
if (selectedKind.value === 'instance') {
addWidget({
id: crypto.randomUUID(),
kind: 'instance',
size: HOME_WIDGET_DEFAULT_SIZE.instance,
target: { instanceId: instance.id, fallbackLabel: instance.name },
})
return
}
selectedInstance.value = instance
searchQuery.value = ''
loadingWorlds.value = true
worlds.value = await runtime.getInstanceWorlds(instance.id)
loadingWorlds.value = false
}
function chooseWorld(world: World) {
if (!selectedInstance.value || (world.type !== 'server' && world.type !== 'singleplayer')) return
const kind = world.type === 'server' ? 'server' : 'world'
addWidget({
id: crypto.randomUUID(),
kind,
size: HOME_WIDGET_DEFAULT_SIZE[kind],
target: {
instanceId: selectedInstance.value.id,
...(world.type === 'server' ? { address: world.address } : { path: world.path }),
fallbackLabel: world.name,
},
})
}
function goBack() {
searchQuery.value = ''
if (selectedInstance.value) {
selectedInstance.value = null
worlds.value = []
} else {
selectedKind.value = null
}
}
defineExpose({ show })
</script>
<template>
<NewModal
ref="modal"
:header="pickerTitle"
max-width="640px"
width="min(640px, calc(100vw - 2rem))"
scrollable
max-content-height="min(38rem, 72vh)"
>
<div class="flex min-w-0 flex-col gap-4">
<div v-if="selectedKind" class="flex min-w-0 items-center gap-3">
<ButtonStyled circular size="small" type="transparent">
<button
v-tooltip="formatMessage(messages.back)"
type="button"
:aria-label="formatMessage(messages.back)"
@click="goBack"
>
<ChevronLeftIcon />
</button>
</ButtonStyled>
<div v-if="selectedInstance" class="flex min-w-0 items-center gap-2">
<InstanceIcon
class="size-8 shrink-0"
:icon-path="selectedInstance.icon_path"
:instance-id="selectedInstance.id"
:loader="selectedInstance.loader"
/>
<span class="truncate text-sm font-semibold text-contrast">{{
selectedInstance.name
}}</span>
</div>
</div>
<div v-if="!selectedKind" class="flex min-w-0 flex-col gap-5">
<section v-for="section in catalogSections" :key="section.id" class="min-w-0">
<h3 class="mb-2 mt-0 flex items-center gap-2 px-1 text-sm font-semibold text-secondary">
<component :is="section.icon" class="size-4" aria-hidden="true" />
{{ section.label }}
</h3>
<div class="overflow-hidden rounded-lg border border-solid border-divider bg-bg-raised">
<button
v-for="item in section.items"
:key="item.kind"
type="button"
class="group flex min-h-16 w-full cursor-pointer items-center gap-3 border-0 border-b border-solid border-divider bg-transparent px-3 py-2 text-left text-primary transition-colors last:border-b-0 hover:bg-button-bg focus-visible:z-[1] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
@click="chooseKind(item.kind)"
>
<span
class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-button-bg text-secondary transition-colors group-hover:text-brand"
>
<component :is="item.icon" class="size-5" aria-hidden="true" />
</span>
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
<strong class="truncate text-sm text-contrast">{{ item.label }}</strong>
<span class="line-clamp-2 text-xs leading-5 text-secondary">{{
item.description
}}</span>
</span>
<ChevronRightIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
</button>
</div>
</section>
</div>
<template v-else>
<StyledInput
ref="searchInput"
v-model="searchQuery"
type="search"
:icon="SearchIcon"
:placeholder="formatMessage(messages.search)"
wrapper-class="w-full"
clearable
/>
<p v-if="loadingWorlds" class="m-0 py-8 text-center text-sm text-secondary">
{{ formatMessage(messages.loading) }}
</p>
<ul
v-else
class="m-0 flex list-none flex-col overflow-hidden rounded-lg border border-solid border-divider bg-bg-raised p-0"
>
<li
v-for="item in selectedInstance ? filteredWorlds : filteredInstances"
:key="'id' in item ? item.id : item.type === 'server' ? item.address : item.path"
class="min-w-0 border-0 border-b border-solid border-divider last:border-b-0"
>
<button
type="button"
class="flex min-h-16 w-full cursor-pointer items-center gap-3 border-0 bg-transparent px-3 py-2 text-left transition-colors hover:bg-button-bg focus-visible:z-[1] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
@click="
selectedInstance ? chooseWorld(item as World) : chooseInstance(item as GameInstance)
"
>
<InstanceIcon
v-if="'id' in item"
class="size-9 shrink-0"
:icon-path="item.icon_path"
:instance-id="item.id"
:loader="item.loader"
/>
<ServerIcon v-else-if="item.type === 'server'" class="size-5 shrink-0" />
<GameIcon v-else class="size-5 shrink-0" />
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
<strong class="truncate text-sm text-contrast">{{ item.name }}</strong>
<span v-if="'id' in item" class="truncate text-xs capitalize text-secondary">
{{ item.loader }} · {{ item.game_version }}
</span>
<span v-else-if="item.type === 'server'" class="truncate text-xs text-secondary">
{{ item.address }}
</span>
<span v-else class="truncate text-xs text-secondary">
{{ selectedInstance?.name }}
</span>
</span>
<ChevronRightIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
</button>
</li>
</ul>
<p
v-if="
!loadingWorlds &&
(selectedInstance ? filteredWorlds.length === 0 : filteredInstances.length === 0)
"
class="m-0 py-8 text-center text-sm text-secondary"
>
{{ formatMessage(messages.noResults) }}
</p>
</template>
</div>
</NewModal>
</template>

View File

@ -0,0 +1,208 @@
import type { GameVersion } from '@modrinth/ui'
import type { InjectionKey, Ref } from 'vue'
import { inject, onUnmounted, provide, reactive, ref } from 'vue'
import { instance_listener, process_listener } from '@/helpers/events'
import { get_all } from '@/helpers/process'
import { get_game_versions } from '@/helpers/tags'
import {
get_favorite_worlds,
get_instance_protocol_version,
get_instance_worlds,
get_recent_worlds,
type ProtocolVersion,
refreshServerData,
type ServerData,
type World,
type WorldWithInstance,
} from '@/helpers/worlds'
type ErrorHandler = (error: unknown) => void
export type HomeDashboardRuntime = {
favoriteWorlds: Ref<WorldWithInstance[]>
recentWorlds: Ref<WorldWithInstance[]>
runningInstanceIds: Ref<string[]>
gameVersions: Ref<GameVersion[]>
instanceRevision: Ref<number>
refreshFavorites: () => Promise<void>
refreshRecentWorlds: () => Promise<void>
getInstanceWorlds: (instanceId: string, force?: boolean) => Promise<World[]>
getServerData: (instanceId: string, address: string) => ServerData
getProtocolVersion: (instanceId: string) => ProtocolVersion | null | undefined
refreshServer: (instanceId: string, address: string, force?: boolean) => Promise<void>
}
const HOME_DASHBOARD_RUNTIME_KEY: InjectionKey<HomeDashboardRuntime> =
Symbol('home-dashboard-runtime')
function serverKey(instanceId: string, address: string) {
return `${instanceId}:${address}`
}
export function provideHomeDashboardRuntime(handleError: ErrorHandler): HomeDashboardRuntime {
const favoriteWorlds = ref<WorldWithInstance[]>([])
const recentWorlds = ref<WorldWithInstance[]>([])
const runningInstanceIds = ref<string[]>([])
const gameVersions = ref<GameVersion[]>([])
const instanceRevision = ref(0)
const worldsByInstance = reactive<Record<string, World[]>>({})
const serverData = reactive<Record<string, ServerData>>({})
const protocolVersions = reactive<Record<string, ProtocolVersion | null>>({})
const loadedWorlds = new Set<string>()
const loadedServers = new Set<string>()
const worldRequests = new Map<string, Promise<World[]>>()
const serverRequests = new Map<string, Promise<void>>()
const protocolRequests = new Map<string, Promise<ProtocolVersion | null>>()
const unlisteners: Array<() => void> = []
let disposed = false
async function refreshRunningInstances() {
try {
runningInstanceIds.value = (await get_all()).map((process) => process.instance_id)
} catch (error) {
handleError(error)
}
}
async function ensureProtocolVersion(instanceId: string) {
if (Object.hasOwn(protocolVersions, instanceId)) return protocolVersions[instanceId]
const pending = protocolRequests.get(instanceId)
if (pending) return pending
const request = get_instance_protocol_version(instanceId)
.catch(() => null)
.then((protocolVersion) => {
protocolVersions[instanceId] = protocolVersion
return protocolVersion
})
.finally(() => protocolRequests.delete(instanceId))
protocolRequests.set(instanceId, request)
return request
}
function getServerData(instanceId: string, address: string) {
return (serverData[serverKey(instanceId, address)] ??= { refreshing: true })
}
async function refreshServer(instanceId: string, address: string, force = false) {
const key = serverKey(instanceId, address)
if (!force && loadedServers.has(key)) return
const pending = serverRequests.get(key)
if (pending) return pending
const request = (async () => {
const protocolVersion = await ensureProtocolVersion(instanceId)
await refreshServerData(getServerData(instanceId, address), protocolVersion, address)
loadedServers.add(key)
})().finally(() => serverRequests.delete(key))
serverRequests.set(key, request)
return request
}
function warmServerData(worlds: WorldWithInstance[]) {
for (const world of worlds) {
if (world.type === 'server') void refreshServer(world.instance_id, world.address)
}
}
async function refreshFavorites() {
try {
favoriteWorlds.value = await get_favorite_worlds()
warmServerData(favoriteWorlds.value)
} catch (error) {
handleError(error)
favoriteWorlds.value = []
}
}
async function refreshRecentWorlds() {
try {
recentWorlds.value = await get_recent_worlds(8, ['normal', 'favorite'])
warmServerData(recentWorlds.value)
} catch (error) {
handleError(error)
recentWorlds.value = []
}
}
async function getInstanceWorlds(instanceId: string, force = false) {
if (!force && loadedWorlds.has(instanceId)) return worldsByInstance[instanceId] ?? []
const pending = worldRequests.get(instanceId)
if (pending) return pending
const request = get_instance_worlds(instanceId)
.then((worlds) => {
worldsByInstance[instanceId] = worlds
loadedWorlds.add(instanceId)
return worlds
})
.catch((error) => {
handleError(error)
return worldsByInstance[instanceId] ?? []
})
.finally(() => worldRequests.delete(instanceId))
worldRequests.set(instanceId, request)
return request
}
void get_game_versions()
.then((versions) => {
gameVersions.value = versions
})
.catch(() => undefined)
void refreshRunningInstances()
void refreshFavorites()
void refreshRecentWorlds()
void process_listener(refreshRunningInstances)
.then((unlisten) => {
if (disposed) unlisten()
else unlisteners.push(unlisten)
})
.catch(handleError)
void instance_listener(async (event: { instance_id?: string }) => {
if (event.instance_id) {
loadedWorlds.delete(event.instance_id)
Reflect.deleteProperty(worldsByInstance, event.instance_id)
Reflect.deleteProperty(protocolVersions, event.instance_id)
for (const key of loadedServers) {
if (key.startsWith(`${event.instance_id}:`)) loadedServers.delete(key)
}
}
instanceRevision.value += 1
await Promise.all([refreshFavorites(), refreshRecentWorlds()])
})
.then((unlisten) => {
if (disposed) unlisten()
else unlisteners.push(unlisten)
})
.catch(handleError)
onUnmounted(() => {
disposed = true
for (const unlisten of unlisteners) unlisten()
})
const runtime: HomeDashboardRuntime = {
favoriteWorlds,
recentWorlds,
runningInstanceIds,
gameVersions,
instanceRevision,
refreshFavorites,
refreshRecentWorlds,
getInstanceWorlds,
getServerData,
getProtocolVersion: (instanceId) => protocolVersions[instanceId],
refreshServer,
}
provide(HOME_DASHBOARD_RUNTIME_KEY, runtime)
return runtime
}
export function useHomeDashboardRuntime() {
const runtime = inject(HOME_DASHBOARD_RUNTIME_KEY)
if (!runtime) throw new Error('Home dashboard runtime was not provided')
return runtime
}

View File

@ -0,0 +1,329 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
addHomeWidget,
createDefaultHomeDashboard,
createHomeDashboardSaveQueue,
enableFreeHomeDashboard,
findNearestFreeHomeWidgetPosition,
getHomeGridColumnCount,
getHomeWidgetDimensions,
getHomeWidgetSpan,
moveHomeWidget,
normalizeHomeDashboard,
packHomeWidgets,
removeHomeWidget,
replaceHomeDashboardWidgets,
resizeHomeWidget,
setHomeDashboardLayout,
setHomeGreetingOptions,
setHomeRecentLimit,
setHomeWidgetPosition,
} from './home-dashboard.ts'
test('derives one to four columns from the dashboard container width', () => {
assert.equal(getHomeGridColumnCount(0), 1)
assert.equal(getHomeGridColumnCount(495), 1)
assert.equal(getHomeGridColumnCount(496), 2)
assert.equal(getHomeGridColumnCount(752), 3)
assert.equal(getHomeGridColumnCount(2000), 4)
})
test('derives responsive widget dimensions from the current grid', () => {
assert.deepEqual(getHomeWidgetDimensions('2x2', 4, 1008), {
width: 496,
height: 336,
})
assert.deepEqual(getHomeWidgetDimensions('2x1', 1, 320), {
width: 320,
height: 160,
})
assert.deepEqual(getHomeWidgetDimensions('3x2', 4, 1008), {
width: 752,
height: 336,
})
})
test('temporarily clamps wide widgets without changing their preferred size', () => {
const config = createDefaultHomeDashboard()
const preferredSize = config.widgets[0].size
assert.deepEqual(getHomeWidgetSpan('2x2', 1), { columns: 1, rows: 2 })
assert.deepEqual(getHomeWidgetSpan('2x2', 2), { columns: 2, rows: 2 })
packHomeWidgets(config.widgets, 1)
packHomeWidgets(config.widgets, 4)
assert.equal(config.widgets[0].size, preferredSize)
})
test('adds, reorders, resizes, and removes independent placements', () => {
const original = createDefaultHomeDashboard()
const pinnedInstances = original.widgets.find((widget) => widget.kind === 'pinned-instances')!
const duplicate = { ...pinnedInstances, id: 'duplicate-pinned-instances' }
const added = addHomeWidget(original, duplicate)
assert.equal(added.widgets.length, original.widgets.length + 1)
assert.equal(added.widgets.filter((widget) => widget.kind === 'pinned-instances').length, 2)
const moved = moveHomeWidget(added, added.widgets.length - 1, -1)
assert.equal(moved.widgets.at(-2)?.id, duplicate.id)
const resized = resizeHomeWidget(moved, duplicate.id, '1x1')
assert.equal(resized.widgets.find((widget) => widget.id === duplicate.id)?.size, '1x1')
const removed = removeHomeWidget(resized, duplicate.id)
assert.deepEqual(removed.widgets, original.widgets)
})
test('accepts draggable order and restores the complete default layout', () => {
const original = createDefaultHomeDashboard()
const reversed = replaceHomeDashboardWidgets(original, [...original.widgets].reverse())
assert.deepEqual(
reversed.widgets.map((widget) => widget.id),
original.widgets.map((widget) => widget.id).reverse(),
)
assert.deepEqual(
createDefaultHomeDashboard().widgets.map(({ kind, size }) => ({ kind, size })),
[
{ kind: 'greeting', size: '2x1' },
{ kind: 'calendar', size: '1x2' },
{ kind: 'recent', size: '2x2' },
{ kind: 'pinned-worlds', size: '1x2' },
{ kind: 'pinned-servers', size: '2x2' },
{ kind: 'pinned-instances', size: '2x1' },
],
)
})
test('enables free layout from packed positions and preserves manual coordinates', () => {
const original = createDefaultHomeDashboard()
const positioned = setHomeWidgetPosition(original, original.widgets[0].id, { column: 1, row: 3 })
const free = enableFreeHomeDashboard(positioned, 4)
assert.equal(free.layout, 'free')
assert.deepEqual(free.widgets[0].position, { column: 1, row: 3 })
assert.deepEqual(free.widgets[1].position, { column: 2, row: 0 })
assert.equal(setHomeDashboardLayout(free, 'grid').layout, 'grid')
})
test('snaps manual placement to the nearest open cell without moving other widgets', () => {
const free = enableFreeHomeDashboard(createDefaultHomeDashboard(), 4)
const before = free.widgets.map((widget) => widget.position)
const moving = free.widgets.at(-1)!
const occupied = free.widgets[0].position!
const resolved = findNearestFreeHomeWidgetPosition(free.widgets, moving, occupied, 4)
assert.notDeepEqual(resolved, occupied)
assert.deepEqual(
free.widgets.map((widget) => widget.position),
before,
)
})
test('rolls back the latest failed save and reports the error', async () => {
const original = createDefaultHomeDashboard()
const changed = removeHomeWidget(original, original.widgets[0].id)
let current = changed
const errors: unknown[] = []
const queue = createHomeDashboardSaveQueue(
async () => {
throw new Error('save failed')
},
(config) => {
current = config
},
(error) => errors.push(error),
)
await queue.enqueue(changed, original)
await queue.flush()
assert.deepEqual(current, original)
assert.equal(errors.length, 1)
})
test('normalizes a saved layout when entering Home again', () => {
const saved = createDefaultHomeDashboard()
const restored = normalizeHomeDashboard(JSON.parse(JSON.stringify(saved)))
assert.deepEqual(restored, saved)
})
test('normalizes legacy layouts and persisted free positions', () => {
const legacy = normalizeHomeDashboard({
version: 1,
widgets: [{ id: 'legacy', kind: 'calendar', size: '1x2' }],
})
const free = normalizeHomeDashboard({
version: 1,
layout: 'free',
widgets: [
{ id: 'valid', kind: 'calendar', size: '1x2', position: { column: 2, row: 4 } },
{ id: 'invalid', kind: 'calendar', size: '1x2', position: { column: -10, row: 'top' } },
],
})
assert.equal(legacy?.layout, 'grid')
assert.equal(free?.layout, 'free')
assert.deepEqual(free?.widgets[0].position, { column: 2, row: 4 })
assert.equal(free?.widgets[1].position, undefined)
})
test('packs widgets into the earliest available cells', () => {
const config = createDefaultHomeDashboard()
const packed = packHomeWidgets(config.widgets, 4)
assert.deepEqual(
packed.map(({ column, row, effectiveColumns, effectiveRows }) => ({
column,
row,
effectiveColumns,
effectiveRows,
})),
[
{ column: 1, row: 1, effectiveColumns: 2, effectiveRows: 1 },
{ column: 3, row: 1, effectiveColumns: 1, effectiveRows: 2 },
{ column: 1, row: 2, effectiveColumns: 2, effectiveRows: 2 },
{ column: 4, row: 1, effectiveColumns: 1, effectiveRows: 2 },
{ column: 3, row: 3, effectiveColumns: 2, effectiveRows: 2 },
{ column: 1, row: 4, effectiveColumns: 2, effectiveRows: 1 },
],
)
})
test('normalizes malformed sizes while retaining duplicate widgets', () => {
const normalized = normalizeHomeDashboard({
version: 1,
widgets: [
{ id: 'one', kind: 'calendar', size: '9x9' },
{ id: 'two', kind: 'calendar', size: '1x1' },
{ id: 'three', kind: 'world', size: '1x1', target: { instanceId: 'a' } },
],
})
assert.deepEqual(
normalized?.widgets.map(({ kind, size }) => ({ kind, size })),
[
{ kind: 'calendar', size: '1x2' },
{ kind: 'calendar', size: '1x2' },
],
)
})
test('normalizes every persisted calendar placement to the fixed 1x2 size', () => {
const normalized = normalizeHomeDashboard({
version: 1,
widgets: [
{ id: 'small', kind: 'calendar', size: '1x1' },
{ id: 'wide', kind: 'calendar', size: '2x2' },
],
})
assert.deepEqual(
normalized?.widgets.map((widget) => widget.size),
['1x2', '1x2'],
)
})
test('normalizes every persisted greeting placement to the fixed 2x1 size', () => {
const normalized = normalizeHomeDashboard({
version: 1,
widgets: [
{ id: 'small', kind: 'greeting', size: '1x1' },
{
id: 'tall',
kind: 'greeting',
size: '1x2',
options: { greetingMode: 'text', greetingText: ' Ready to play ' },
},
],
})
assert.deepEqual(
normalized?.widgets.map((widget) => widget.size),
['2x1', '2x1'],
)
assert.deepEqual(
normalized?.widgets.map((widget) => widget.options),
[
{ greetingMode: 'greeting', greetingFont: 'sans', greetingFontSize: 22 },
{
greetingMode: 'text',
greetingText: 'Ready to play',
greetingFont: 'sans',
greetingFontSize: 22,
},
],
)
assert.deepEqual(
resizeHomeWidget(normalized!, 'small', '1x1').widgets.map((widget) => widget.size),
['2x1', '2x1'],
)
assert.deepEqual(
setHomeGreetingOptions(normalized!, 'small', 'text-and-greeting', ' Hi ', 'minecraft', 29)
.widgets[0].options,
{
greetingMode: 'text-and-greeting',
greetingText: 'Hi',
greetingFont: 'minecraft',
greetingFontSize: 29,
},
)
})
test('normalizes greeting font settings and clamps font size', () => {
const normalized = normalizeHomeDashboard({
version: 1,
widgets: [
{
id: 'large',
kind: 'greeting',
size: '2x1',
options: { greetingFont: 'serif', greetingFontSize: 80 },
},
{
id: 'invalid',
kind: 'greeting',
size: '2x1',
options: { greetingFont: 'comic-sans', greetingFontSize: 'large' },
},
],
})!
assert.deepEqual(
normalized.widgets.map((widget) => ({
font: widget.options?.greetingFont,
fontSize: widget.options?.greetingFontSize,
})),
[
{ font: 'serif', fontSize: 32 },
{ font: 'sans', fontSize: 22 },
],
)
})
test('normalizes and updates recently played item limits', () => {
const normalized = normalizeHomeDashboard({
version: 1,
widgets: [
{ id: 'legacy', kind: 'recent', size: '2x2' },
{ id: 'valid', kind: 'recent', size: '2x1', options: { recentLimit: 8 } },
{ id: 'invalid', kind: 'recent', size: '1x2', options: { recentLimit: 99 } },
],
})!
assert.deepEqual(
normalized.widgets.map((widget) => widget.options?.recentLimit),
[4, 8, 4],
)
assert.equal(setHomeRecentLimit(normalized, 'legacy', 6).widgets[0].options?.recentLimit, 6)
})
test('accepts and resizes the recent widget to 3-column layouts', () => {
const normalized = normalizeHomeDashboard({
version: 1,
widgets: [{ id: 'recent', kind: 'recent', size: '3x2' }],
})!
const config = createDefaultHomeDashboard()
const recent = config.widgets.find((widget) => widget.kind === 'recent')!
assert.equal(normalized.widgets[0].size, '3x2')
assert.equal(
resizeHomeWidget(config, recent.id, '3x1').widgets.find((widget) => widget.id === recent.id)
?.size,
'3x1',
)
})

View File

@ -0,0 +1,600 @@
export const HOME_DASHBOARD_VERSION = 1 as const
export const HOME_WIDGET_LAYOUTS = ['grid', 'free'] as const
export const HOME_WIDGET_STANDARD_SIZES = ['1x1', '2x1', '1x2', '2x2'] as const
export const HOME_WIDGET_SIZES = [...HOME_WIDGET_STANDARD_SIZES, '3x1', '3x2'] as const
export const HOME_WIDGET_GRID_GAP = 16
export const HOME_WIDGET_GRID_ROW_HEIGHT = 160
export const HOME_RECENT_LIMIT_OPTIONS = [2, 4, 6, 8] as const
export const HOME_RECENT_DEFAULT_LIMIT = 4
export const HOME_GREETING_MODES = ['greeting', 'text-and-greeting', 'text'] as const
export const HOME_GREETING_DEFAULT_MODE = 'greeting'
export const HOME_GREETING_FONTS = ['sans', 'minecraft', 'mono', 'serif'] as const
export const HOME_GREETING_DEFAULT_FONT = 'sans'
export const HOME_GREETING_FONT_SIZE_MIN = 16
export const HOME_GREETING_FONT_SIZE_MAX = 32
export const HOME_GREETING_DEFAULT_FONT_SIZE = 22
export type HomeWidgetSize = (typeof HOME_WIDGET_SIZES)[number]
export type HomeWidgetLayout = (typeof HOME_WIDGET_LAYOUTS)[number]
export type HomeRecentLimit = (typeof HOME_RECENT_LIMIT_OPTIONS)[number]
export type HomeGreetingMode = (typeof HOME_GREETING_MODES)[number]
export type HomeGreetingFont = (typeof HOME_GREETING_FONTS)[number]
export type HomeWidgetKind =
| 'greeting'
| 'recent'
| 'calendar'
| 'pinned-instances'
| 'pinned-worlds'
| 'pinned-servers'
| 'instance'
| 'world'
| 'server'
export type HomeWidgetTarget = {
instanceId: string
path?: string
address?: string
fallbackLabel: string
}
export type HomeWidgetOptions = {
recentLimit?: HomeRecentLimit
greetingMode?: HomeGreetingMode
greetingText?: string
greetingFont?: HomeGreetingFont
greetingFontSize?: number
}
export type HomeWidgetPosition = {
column: number
row: number
}
export type HomeWidgetPlacement = {
id: string
kind: HomeWidgetKind
size: HomeWidgetSize
target?: HomeWidgetTarget
options?: HomeWidgetOptions
position?: HomeWidgetPosition
}
export type HomeDashboardConfig = {
version: typeof HOME_DASHBOARD_VERSION
layout: HomeWidgetLayout
widgets: HomeWidgetPlacement[]
}
export type PackedHomeWidget = HomeWidgetPlacement & {
column: number
row: number
effectiveColumns: number
effectiveRows: number
}
export type HomeDashboardSaveQueue = {
enqueue: (config: HomeDashboardConfig, rollback: HomeDashboardConfig) => Promise<void>
flush: () => Promise<void>
}
export const HOME_WIDGET_SIZE_OPTIONS: Record<HomeWidgetKind, readonly HomeWidgetSize[]> = {
greeting: ['2x1'],
recent: ['2x1', '2x2', '3x1', '3x2'],
calendar: ['1x2'],
'pinned-instances': HOME_WIDGET_STANDARD_SIZES,
'pinned-worlds': HOME_WIDGET_STANDARD_SIZES,
'pinned-servers': HOME_WIDGET_STANDARD_SIZES,
instance: ['1x1', '2x1'],
world: ['1x1', '2x1'],
server: ['1x1', '2x1'],
}
export const HOME_WIDGET_DEFAULT_SIZE: Record<HomeWidgetKind, HomeWidgetSize> = {
greeting: '2x1',
recent: '2x2',
calendar: '1x2',
'pinned-instances': '2x2',
'pinned-worlds': '1x2',
'pinned-servers': '1x2',
instance: '1x1',
world: '1x1',
server: '1x1',
}
export function getHomeWidgetCardDensity(
dashboardSize: HomeWidgetSize | null | undefined,
): 'compact' | 'comfortable' {
return dashboardSize === '1x1' ||
dashboardSize === '1x2' ||
dashboardSize === '2x1' ||
dashboardSize === '2x2'
? 'compact'
: 'comfortable'
}
const HOME_WIDGET_KINDS = new Set<HomeWidgetKind>(
Object.keys(HOME_WIDGET_DEFAULT_SIZE) as HomeWidgetKind[],
)
function createPlacement(
kind: HomeWidgetKind,
size = HOME_WIDGET_DEFAULT_SIZE[kind],
): HomeWidgetPlacement {
return {
id: crypto.randomUUID(),
kind,
size,
...(kind === 'recent' ? { options: { recentLimit: HOME_RECENT_DEFAULT_LIMIT } } : {}),
...(kind === 'greeting'
? {
options: {
greetingMode: HOME_GREETING_DEFAULT_MODE,
greetingFont: HOME_GREETING_DEFAULT_FONT,
greetingFontSize: HOME_GREETING_DEFAULT_FONT_SIZE,
},
}
: {}),
}
}
export function createDefaultHomeDashboard(includeRecent = true): HomeDashboardConfig {
return {
version: HOME_DASHBOARD_VERSION,
layout: 'grid',
widgets: [
createPlacement('greeting'),
createPlacement('calendar'),
...(includeRecent ? [createPlacement('recent')] : []),
createPlacement('pinned-worlds'),
createPlacement('pinned-servers', '2x2'),
createPlacement('pinned-instances', '2x1'),
],
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function normalizeTarget(value: unknown): HomeWidgetTarget | undefined {
if (!isRecord(value) || typeof value.instanceId !== 'string' || !value.instanceId)
return undefined
if (typeof value.fallbackLabel !== 'string' || !value.fallbackLabel) return undefined
return {
instanceId: value.instanceId,
...(typeof value.path === 'string' ? { path: value.path } : {}),
...(typeof value.address === 'string' ? { address: value.address } : {}),
fallbackLabel: value.fallbackLabel,
}
}
function normalizePosition(value: unknown): HomeWidgetPosition | undefined {
if (!isRecord(value)) return undefined
if (typeof value.column !== 'number' || !Number.isFinite(value.column)) return undefined
if (typeof value.row !== 'number' || !Number.isFinite(value.row)) return undefined
return {
column: Math.min(100, Math.max(0, Math.round(value.column))),
row: Math.min(10_000, Math.max(0, Math.round(value.row))),
}
}
function normalizeOptions(kind: HomeWidgetKind, value: unknown): HomeWidgetOptions | undefined {
if (kind === 'recent') {
const recentLimit =
isRecord(value) && HOME_RECENT_LIMIT_OPTIONS.includes(value.recentLimit as HomeRecentLimit)
? (value.recentLimit as HomeRecentLimit)
: HOME_RECENT_DEFAULT_LIMIT
return { recentLimit }
}
if (kind === 'greeting') {
const greetingMode =
isRecord(value) && HOME_GREETING_MODES.includes(value.greetingMode as HomeGreetingMode)
? (value.greetingMode as HomeGreetingMode)
: HOME_GREETING_DEFAULT_MODE
const greetingText =
isRecord(value) && typeof value.greetingText === 'string'
? value.greetingText.trim().slice(0, 120)
: ''
const greetingFont =
isRecord(value) && HOME_GREETING_FONTS.includes(value.greetingFont as HomeGreetingFont)
? (value.greetingFont as HomeGreetingFont)
: HOME_GREETING_DEFAULT_FONT
const greetingFontSize = normalizeGreetingFontSize(
isRecord(value) ? value.greetingFontSize : undefined,
)
return {
greetingMode,
...(greetingText ? { greetingText } : {}),
greetingFont,
greetingFontSize,
}
}
return undefined
}
function normalizeGreetingFontSize(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return HOME_GREETING_DEFAULT_FONT_SIZE
}
return Math.min(
HOME_GREETING_FONT_SIZE_MAX,
Math.max(HOME_GREETING_FONT_SIZE_MIN, Math.round(value)),
)
}
export function normalizeHomeDashboard(value: unknown): HomeDashboardConfig | null {
if (
!isRecord(value) ||
value.version !== HOME_DASHBOARD_VERSION ||
!Array.isArray(value.widgets)
) {
return null
}
const usedIds = new Set<string>()
const layout = HOME_WIDGET_LAYOUTS.includes(value.layout as HomeWidgetLayout)
? (value.layout as HomeWidgetLayout)
: 'grid'
const widgets = value.widgets.flatMap((candidate): HomeWidgetPlacement[] => {
if (!isRecord(candidate) || typeof candidate.kind !== 'string') return []
if (!HOME_WIDGET_KINDS.has(candidate.kind as HomeWidgetKind)) return []
const kind = candidate.kind as HomeWidgetKind
const target = normalizeTarget(candidate.target)
const options = normalizeOptions(kind, candidate.options)
const position = normalizePosition(candidate.position)
if ((kind === 'instance' || kind === 'world' || kind === 'server') && !target) return []
if (kind === 'world' && !target?.path) return []
if (kind === 'server' && !target?.address) return []
let id = typeof candidate.id === 'string' && candidate.id ? candidate.id : crypto.randomUUID()
if (usedIds.has(id)) id = crypto.randomUUID()
usedIds.add(id)
const requestedSize = typeof candidate.size === 'string' ? candidate.size : ''
const size = HOME_WIDGET_SIZE_OPTIONS[kind].includes(requestedSize as HomeWidgetSize)
? (requestedSize as HomeWidgetSize)
: HOME_WIDGET_DEFAULT_SIZE[kind]
return [
{
id,
kind,
size,
...(target ? { target } : {}),
...(options ? { options } : {}),
...(position ? { position } : {}),
},
]
})
return { version: HOME_DASHBOARD_VERSION, layout, widgets }
}
export function replaceHomeDashboardWidgets(
config: HomeDashboardConfig,
widgets: HomeWidgetPlacement[],
): HomeDashboardConfig {
return { ...config, widgets }
}
export function addHomeWidget(
config: HomeDashboardConfig,
widget: HomeWidgetPlacement,
): HomeDashboardConfig {
return replaceHomeDashboardWidgets(config, [...config.widgets, widget])
}
export function setHomeDashboardLayout(
config: HomeDashboardConfig,
layout: HomeWidgetLayout,
): HomeDashboardConfig {
return { ...config, layout }
}
export function setHomeWidgetPosition(
config: HomeDashboardConfig,
id: string,
position: HomeWidgetPosition,
): HomeDashboardConfig {
return replaceHomeDashboardWidgets(
config,
config.widgets.map((widget) => (widget.id === id ? { ...widget, position } : widget)),
)
}
export function removeHomeWidget(config: HomeDashboardConfig, id: string): HomeDashboardConfig {
return replaceHomeDashboardWidgets(
config,
config.widgets.filter((widget) => widget.id !== id),
)
}
export function resizeHomeWidget(
config: HomeDashboardConfig,
id: string,
size: HomeWidgetSize,
): HomeDashboardConfig {
return replaceHomeDashboardWidgets(
config,
config.widgets.map((widget) =>
widget.id === id && HOME_WIDGET_SIZE_OPTIONS[widget.kind].includes(size)
? { ...widget, size }
: widget,
),
)
}
export function setHomeRecentLimit(
config: HomeDashboardConfig,
id: string,
recentLimit: HomeRecentLimit,
): HomeDashboardConfig {
return replaceHomeDashboardWidgets(
config,
config.widgets.map((widget) =>
widget.id === id && widget.kind === 'recent'
? { ...widget, options: { ...widget.options, recentLimit } }
: widget,
),
)
}
export function setHomeGreetingOptions(
config: HomeDashboardConfig,
id: string,
greetingMode: HomeGreetingMode,
greetingText: string,
greetingFont: HomeGreetingFont,
greetingFontSize: number,
): HomeDashboardConfig {
const normalizedText = greetingText.trim().slice(0, 120)
const normalizedFont = HOME_GREETING_FONTS.includes(greetingFont)
? greetingFont
: HOME_GREETING_DEFAULT_FONT
return replaceHomeDashboardWidgets(
config,
config.widgets.map((widget) =>
widget.id === id && widget.kind === 'greeting'
? {
...widget,
options: {
greetingMode,
...(normalizedText ? { greetingText: normalizedText } : {}),
greetingFont: normalizedFont,
greetingFontSize: normalizeGreetingFontSize(greetingFontSize),
},
}
: widget,
),
)
}
export function moveHomeWidget(
config: HomeDashboardConfig,
index: number,
direction: -1 | 1,
): HomeDashboardConfig {
const target = index + direction
if (
index < 0 ||
index >= config.widgets.length ||
target < 0 ||
target >= config.widgets.length
) {
return config
}
const widgets = [...config.widgets]
const [widget] = widgets.splice(index, 1)
widgets.splice(target, 0, widget)
return replaceHomeDashboardWidgets(config, widgets)
}
export function createHomeDashboardSaveQueue(
persist: (config: HomeDashboardConfig) => Promise<void>,
onRollback: (config: HomeDashboardConfig) => void,
onError: (error: unknown) => void,
): HomeDashboardSaveQueue {
let queue = Promise.resolve()
let version = 0
return {
enqueue(config, rollback) {
const operationVersion = ++version
queue = queue.then(async () => {
try {
await persist(config)
} catch (error) {
if (operationVersion === version) onRollback(rollback)
onError(error)
}
})
return queue
},
flush: () => queue,
}
}
export function getHomeGridColumnCount(width: number): number {
const minimumColumnWidth = 240
const gap = 16
return Math.min(
4,
Math.max(1, Math.floor((Math.max(0, width) + gap) / (minimumColumnWidth + gap))),
)
}
export function getHomeWidgetDimensions(
size: HomeWidgetSize,
columnCount: number,
containerWidth: number,
) {
const columns = Math.max(1, columnCount)
const span = getHomeWidgetSpan(size, columns)
const columnWidth = Math.max(0, (containerWidth - HOME_WIDGET_GRID_GAP * (columns - 1)) / columns)
return {
width: columnWidth * span.columns + HOME_WIDGET_GRID_GAP * (span.columns - 1),
height: HOME_WIDGET_GRID_ROW_HEIGHT * span.rows + HOME_WIDGET_GRID_GAP * (span.rows - 1),
}
}
export function getHomeWidgetSpan(size: HomeWidgetSize, columnCount: number) {
const [columns, rows] = size.split('x').map(Number)
return {
columns: Math.min(columns, Math.max(1, columnCount)),
rows,
}
}
function homeWidgetRect(
widget: HomeWidgetPlacement,
position: HomeWidgetPosition,
columnCount: number,
) {
const span = getHomeWidgetSpan(widget.size, columnCount)
return {
left: position.column,
top: position.row,
right: position.column + span.columns,
bottom: position.row + span.rows,
}
}
function homeWidgetRectsOverlap(
left: ReturnType<typeof homeWidgetRect>,
right: ReturnType<typeof homeWidgetRect>,
) {
return (
left.left < right.right &&
left.right > right.left &&
left.top < right.bottom &&
left.bottom > right.top
)
}
export function findNearestFreeHomeWidgetPosition(
widgets: readonly HomeWidgetPlacement[],
movingWidget: HomeWidgetPlacement,
desiredPosition: HomeWidgetPosition,
columnCount: number,
): HomeWidgetPosition {
const columns = Math.max(1, columnCount)
const movingSpan = getHomeWidgetSpan(movingWidget.size, columns)
const desired = {
column: Math.min(
Math.max(0, Math.round(desiredPosition.column)),
Math.max(0, columns - movingSpan.columns),
),
row: Math.max(0, Math.round(desiredPosition.row)),
}
const occupied = widgets.flatMap((widget) =>
widget.id !== movingWidget.id && widget.position
? [homeWidgetRect(widget, widget.position, columns)]
: [],
)
const isAvailable = (position: HomeWidgetPosition) => {
const candidate = homeWidgetRect(movingWidget, position, columns)
return occupied.every((rect) => !homeWidgetRectsOverlap(candidate, rect))
}
if (isAvailable(desired)) return desired
const lastOccupiedRow = occupied.reduce((last, rect) => Math.max(last, rect.bottom), 0)
const lastSearchRow = Math.max(
desired.row + widgets.length * 2,
lastOccupiedRow + movingSpan.rows,
)
let closest: HomeWidgetPosition | null = null
let closestDistance = Number.POSITIVE_INFINITY
for (let row = 0; row <= lastSearchRow; row += 1) {
for (let column = 0; column <= columns - movingSpan.columns; column += 1) {
const candidate = { column, row }
if (!isAvailable(candidate)) continue
const distance = Math.abs(column - desired.column) + Math.abs(row - desired.row)
if (distance >= closestDistance) continue
closest = candidate
closestDistance = distance
}
}
return closest ?? desired
}
export function packHomeWidgets(
widgets: readonly HomeWidgetPlacement[],
columnCount: number,
): PackedHomeWidget[] {
const columns = Math.max(1, columnCount)
const occupied: boolean[][] = []
const fits = (column: number, row: number, width: number, height: number) => {
if (column + width > columns) return false
for (let y = row; y < row + height; y += 1) {
for (let x = column; x < column + width; x += 1) {
if (occupied[y]?.[x]) return false
}
}
return true
}
return widgets.map((widget) => {
const span = getHomeWidgetSpan(widget.size, columns)
let row = 0
let column = 0
while (!fits(column, row, span.columns, span.rows)) {
column += 1
if (column >= columns) {
column = 0
row += 1
}
}
for (let y = row; y < row + span.rows; y += 1) {
occupied[y] ??= Array.from({ length: columns }, () => false)
for (let x = column; x < column + span.columns; x += 1) occupied[y][x] = true
}
return {
...widget,
column: column + 1,
row: row + 1,
effectiveColumns: span.columns,
effectiveRows: span.rows,
}
})
}
export function enableFreeHomeDashboard(
config: HomeDashboardConfig,
columnCount: number,
): HomeDashboardConfig {
const columns = Math.max(1, columnCount)
const packedById = new Map(
packHomeWidgets(config.widgets, columns).map((widget) => [widget.id, widget]),
)
return {
...config,
layout: 'free',
widgets: config.widgets.map((widget) => {
if (widget.position) return widget
const packed = packedById.get(widget.id)
if (!packed) return widget
return {
...widget,
position: {
column: packed.column - 1,
row: packed.row - 1,
},
}
}),
}
}

View File

@ -0,0 +1,62 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildHeatmapDays,
getActivePlayerName,
getPlaytimeLevel,
getTimeBucket,
stableGreetingIndex,
toDateKey,
} from './home-utils.ts'
test('uses the six local greeting time buckets', () => {
const hour = (value: number) => new Date(2026, 6, 25, value, 0)
assert.equal(getTimeBucket(hour(0)), 'late-night')
assert.equal(getTimeBucket(hour(5)), 'dawn')
assert.equal(getTimeBucket(hour(8)), 'morning')
assert.equal(getTimeBucket(hour(12)), 'afternoon')
assert.equal(getTimeBucket(hour(17)), 'evening')
assert.equal(getTimeBucket(hour(21)), 'night')
assert.equal(
stableGreetingIndex('2026-07-25:morning', 16),
stableGreetingIndex('2026-07-25:morning', 16),
)
assert.equal(stableGreetingIndex('any', 0), 0)
})
test('builds Monday-first month and year heatmap grids', () => {
const month = buildHeatmapDays(new Date(2026, 1, 17, 12), 'month')
assert.equal(month[0]?.date.getDay(), 1)
assert.equal(month.at(-1)?.date.getDay(), 0)
assert.equal(month.filter((day) => day.inPeriod).length, 28)
assert.equal(month.find((day) => day.inPeriod)?.dateKey, '2026-02-01')
const year = buildHeatmapDays(new Date(2024, 6, 1, 12), 'year')
assert.equal(year[0]?.date.getDay(), 1)
assert.equal(year.at(-1)?.date.getDay(), 0)
assert.equal(year.filter((day) => day.inPeriod).length, 366)
})
test('maps playtime thresholds and missing days deterministically', () => {
assert.deepEqual(
[0, 1, 30 * 60, 30 * 60 + 1, 90 * 60, 90 * 60 + 1, 180 * 60, 180 * 60 + 1].map(
getPlaytimeLevel,
),
[0, 1, 1, 2, 2, 3, 3, 4],
)
assert.equal(toDateKey(new Date(2026, 6, 25, 12)), '2026-07-25')
})
test('uses only active online accounts for player greetings', () => {
const accounts = [
{ account_type: 'offline', profile: { id: 'offline', name: 'Local player' } },
{ account_type: 'microsoft', profile: { id: 'microsoft', name: 'Alex' } },
{ account_type: 'yggdrasil', profile: { id: 'yggdrasil', name: 'Steve' } },
]
assert.equal(getActivePlayerName('microsoft', accounts), 'Alex')
assert.equal(getActivePlayerName('yggdrasil', accounts), 'Steve')
assert.equal(getActivePlayerName('offline', accounts), null)
assert.equal(getActivePlayerName(undefined, accounts), null)
assert.equal(getActivePlayerName('missing', accounts), null)
})

View File

@ -0,0 +1,112 @@
export type HomeTimeBucket = 'late-night' | 'dawn' | 'morning' | 'afternoon' | 'evening' | 'night'
export type PlaytimeView = 'month' | 'year'
export type HeatmapDay = {
date: Date
dateKey: string
inPeriod: boolean
}
export type MinecraftAccountLike = {
account_type?: string
profile?: {
id?: string
name?: string
}
}
export function getTimeBucket(date: Date): HomeTimeBucket {
const hour = date.getHours()
if (hour < 5) return 'late-night'
if (hour < 8) return 'dawn'
if (hour < 12) return 'morning'
if (hour < 17) return 'afternoon'
if (hour < 21) return 'evening'
return 'night'
}
export function stableGreetingIndex(seed: string, count: number): number {
if (count <= 0) return 0
let hash = 0
for (const character of seed) {
hash = (hash * 31 + character.charCodeAt(0)) | 0
}
return Math.abs(hash) % count
}
export function toDateKey(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function dateFromKey(dateKey: string): Date {
const [year, month, day] = dateKey.split('-').map(Number)
return new Date(year, month - 1, day, 12)
}
export function startOfPeriod(anchor: Date, view: PlaytimeView): Date {
return view === 'month'
? new Date(anchor.getFullYear(), anchor.getMonth(), 1, 12)
: new Date(anchor.getFullYear(), 0, 1, 12)
}
export function endOfPeriod(anchor: Date, view: PlaytimeView): Date {
return view === 'month'
? new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0, 12)
: new Date(anchor.getFullYear(), 11, 31, 12)
}
export function shiftPeriod(anchor: Date, view: PlaytimeView, amount: number): Date {
return view === 'month'
? new Date(anchor.getFullYear(), anchor.getMonth() + amount, 1, 12)
: new Date(anchor.getFullYear() + amount, 0, 1, 12)
}
export function buildHeatmapDays(anchor: Date, view: PlaytimeView): HeatmapDay[] {
const periodStart = startOfPeriod(anchor, view)
const periodEnd = endOfPeriod(anchor, view)
const periodStartKey = toDateKey(periodStart)
const periodEndKey = toDateKey(periodEnd)
const gridStart = new Date(periodStart)
gridStart.setDate(periodStart.getDate() - ((periodStart.getDay() + 6) % 7))
const gridEnd = new Date(periodEnd)
gridEnd.setDate(periodEnd.getDate() + ((7 - gridEnd.getDay()) % 7))
const days: HeatmapDay[] = []
const cursor = new Date(gridStart)
while (cursor <= gridEnd) {
const date = new Date(cursor)
const dateKey = toDateKey(date)
days.push({
date,
dateKey,
inPeriod: dateKey >= periodStartKey && dateKey <= periodEndKey,
})
cursor.setDate(cursor.getDate() + 1)
}
return days
}
export function getPlaytimeLevel(seconds: number): number {
if (seconds <= 0) return 0
if (seconds <= 30 * 60) return 1
if (seconds <= 90 * 60) return 2
if (seconds <= 180 * 60) return 3
return 4
}
export function getActivePlayerName(
selectedUser: string | null | undefined,
accounts: readonly MinecraftAccountLike[],
): string | null {
if (!selectedUser) return null
const account = accounts.find(
(candidate) =>
candidate.profile?.id === selectedUser &&
(candidate.account_type === 'microsoft' || candidate.account_type === 'yggdrasil'),
)
return account?.profile?.name ?? null
}