feat:移除了弹窗,服务器添加sls
This commit is contained in:
487
apps/app-frontend/src/components/home/HomeCalendar.vue
Normal file
487
apps/app-frontend/src/components/home/HomeCalendar.vue
Normal 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>
|
||||
85
apps/app-frontend/src/components/home/HomeDailyChallenge.vue
Normal file
85
apps/app-frontend/src/components/home/HomeDailyChallenge.vue
Normal 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>
|
||||
952
apps/app-frontend/src/components/home/HomeDashboard.vue
Normal file
952
apps/app-frontend/src/components/home/HomeDashboard.vue
Normal 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>
|
||||
219
apps/app-frontend/src/components/home/HomeGreeting.vue
Normal file
219
apps/app-frontend/src/components/home/HomeGreeting.vue
Normal 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>
|
||||
@ -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>
|
||||
86
apps/app-frontend/src/components/home/HomeInstanceCard.vue
Normal file
86
apps/app-frontend/src/components/home/HomeInstanceCard.vue
Normal 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>
|
||||
@ -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>
|
||||
124
apps/app-frontend/src/components/home/HomeMinecraftNews.vue
Normal file
124
apps/app-frontend/src/components/home/HomeMinecraftNews.vue
Normal 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>
|
||||
337
apps/app-frontend/src/components/home/HomeMinimal.vue
Normal file
337
apps/app-frontend/src/components/home/HomeMinimal.vue
Normal 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>
|
||||
129
apps/app-frontend/src/components/home/HomePinnedInstances.vue
Normal file
129
apps/app-frontend/src/components/home/HomePinnedInstances.vue
Normal 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>
|
||||
356
apps/app-frontend/src/components/home/HomePinnedServers.vue
Normal file
356
apps/app-frontend/src/components/home/HomePinnedServers.vue
Normal 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>
|
||||
198
apps/app-frontend/src/components/home/HomePinnedWorlds.vue
Normal file
198
apps/app-frontend/src/components/home/HomePinnedWorlds.vue
Normal 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>
|
||||
186
apps/app-frontend/src/components/home/HomePlayInsights.vue
Normal file
186
apps/app-frontend/src/components/home/HomePlayInsights.vue
Normal 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>
|
||||
270
apps/app-frontend/src/components/home/HomeRecentWorlds.vue
Normal file
270
apps/app-frontend/src/components/home/HomeRecentWorlds.vue
Normal 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>
|
||||
462
apps/app-frontend/src/components/home/HomeShortcutWidget.vue
Normal file
462
apps/app-frontend/src/components/home/HomeShortcutWidget.vue
Normal 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>
|
||||
434
apps/app-frontend/src/components/home/HomeWidgetPickerModal.vue
Normal file
434
apps/app-frontend/src/components/home/HomeWidgetPickerModal.vue
Normal 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>
|
||||
208
apps/app-frontend/src/components/home/home-dashboard-runtime.ts
Normal file
208
apps/app-frontend/src/components/home/home-dashboard-runtime.ts
Normal 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
|
||||
}
|
||||
329
apps/app-frontend/src/components/home/home-dashboard.test.ts
Normal file
329
apps/app-frontend/src/components/home/home-dashboard.test.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
600
apps/app-frontend/src/components/home/home-dashboard.ts
Normal file
600
apps/app-frontend/src/components/home/home-dashboard.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
62
apps/app-frontend/src/components/home/home-utils.test.ts
Normal file
62
apps/app-frontend/src/components/home/home-utils.test.ts
Normal 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)
|
||||
})
|
||||
112
apps/app-frontend/src/components/home/home-utils.ts
Normal file
112
apps/app-frontend/src/components/home/home-utils.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user