953 lines
27 KiB
Vue
953 lines
27 KiB
Vue
<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>
|