Files
Modrinth-plus/packages/ui/src/components/modal/OpenInAppModal.vue
Jerozgen 58c1e225c8 Sort filters and add translations for servers (#5493)
* Translate and sort server filters

* Set team_members to unknown[]

* Additional fixes after merge

* Additional translations

* Replace "IP" with "server address"

* Prioritize English and user language
2026-03-17 19:56:01 +00:00

329 lines
8.2 KiB
Vue

<template>
<div v-if="open" class="open-in-app-modal">
<div :class="{ shown: visible }" class="fullscreen-overlay" @click="hide" />
<div class="modal-content" :class="{ shown: visible }">
<div class="flex flex-col items-center gap-6">
<div class="flex flex-col gap-6">
<div v-if="countdown > 0" class="countdown-container">
<svg class="countdown-svg" viewBox="0 0 100 100">
<circle
class="stroke-surface-4"
cx="50"
cy="50"
r="45"
fill="none"
stroke-width="6"
/>
<circle
class="countdown-progress"
cx="50"
cy="50"
r="45"
fill="none"
stroke-width="6"
:stroke-dasharray="circumference"
:stroke-dashoffset="strokeDashoffset"
stroke-linecap="round"
/>
</svg>
<span class="countdown-number">{{ countdown }}</span>
</div>
<h2 class="m-0 text-3xl font-bold text-contrast text-center">
{{ formatMessage(messages.openingApp) }}
</h2>
<div
class="flex flex-col items-center gap-4 bg-surface-3 rounded-3xl border border-solid border-surface-5 p-6"
>
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3 w-full">
<Avatar :src="serverProject.icon" :alt="serverProject.name" size="48px" />
<div class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ serverProject.name }}</span>
<div class="flex items-center gap-2 text-secondary">
<ServerOnlinePlayers
:online="serverProject.numPlayers ?? 0"
:status-online="serverProject.statusOnline"
/>
<ServerRegion v-if="serverProject.region" :region="serverProject.region" />
</div>
</div>
</div>
<div class="flex flex-col text-left gap-3">
<span class="font-semibold text-contrast">{{
formatMessage(messages.whyUseApp)
}}</span>
<div class="flex flex-col gap-2">
<div class="flex text-base gap-2 items-center">
<div
class="w-5 h-5 border border-solid rounded-full flex items-center justify-center border-brand bg-brand-highlight text-brand"
>
<CheckIcon />
</div>
<span>{{ formatMessage(messages.benefitLaunch) }}</span>
</div>
<div class="flex text-base gap-2 items-center">
<div
class="w-5 h-5 border border-solid rounded-full flex items-center justify-center border-brand bg-brand-highlight text-brand"
>
<CheckIcon />
</div>
<span>{{ formatMessage(messages.benefitInstall) }}</span>
</div>
<div class="flex text-base gap-2 items-center">
<div
class="w-5 h-5 border border-solid rounded-full flex items-center justify-center border-brand bg-brand-highlight text-brand"
>
<CheckIcon />
</div>
<span>{{ formatMessage(messages.benefitUpdate) }}</span>
</div>
</div>
</div>
</div>
</div>
<span v-if="countdown > 0" class="text-secondary">{{
formatMessage(messages.openingAutomatically)
}}</span>
<div v-else class="grid grid-cols-2 gap-2 w-full">
<ButtonStyled class="flex-1">
<button @click="hide">
<XIcon />
{{ formatMessage(commonMessages.closeButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="green" class="flex-1">
<a href="https://modrinth.com/app" target="_blank" rel="noopener noreferrer">
<DownloadIcon />
{{ formatMessage(messages.getApp) }}
</a>
</ButtonStyled>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckIcon, DownloadIcon, XIcon } from '@modrinth/assets'
import { commonMessages } from '@modrinth/ui'
import { computed, nextTick, onUnmounted, ref } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { Avatar, ButtonStyled } from '../base'
import ServerOnlinePlayers from '../project/server/ServerOnlinePlayers.vue'
import ServerRegion from '../project/server/ServerRegion.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
openingApp: {
id: 'modal.open-in-app.title',
defaultMessage: 'Opening Modrinth App',
},
whyUseApp: {
id: 'modal.open-in-app.why-use',
defaultMessage: 'Why use the Modrinth App',
},
benefitLaunch: {
id: 'modal.open-in-app.benefit.launch',
defaultMessage: 'Launch the game straight into the server',
},
benefitInstall: {
id: 'modal.open-in-app.benefit.install',
defaultMessage: 'Automatically install required content',
},
benefitUpdate: {
id: 'modal.open-in-app.benefit.update',
defaultMessage: 'Keep files updated when the server changes',
},
openingAutomatically: {
id: 'modal.open-in-app.opening-automatically',
defaultMessage: 'The Modrinth App will open automatically...',
},
getApp: {
id: 'modal.open-in-app.get-app',
defaultMessage: 'Get Modrinth App',
},
})
export interface ServerProject {
name: string
slug?: string
numPlayers?: number
icon?: string
statusOnline?: boolean
region?: string
}
const open = ref(false)
const visible = ref(false)
const countdown = ref(3)
const countdownProgress = ref(1)
let countdownInterval: ReturnType<typeof setInterval> | null = null
let progressInterval: ReturnType<typeof setInterval> | null = null
const circumference = 2 * Math.PI * 45
const strokeDashoffset = computed(() => {
return circumference * (1 - countdownProgress.value)
})
const serverProject = ref<ServerProject>({
name: '',
slug: '',
numPlayers: 0,
icon: undefined,
statusOnline: false,
region: '',
})
const appLink = computed(() => {
return `modrinth://server/${serverProject.value.slug}`
})
function startCountdown() {
countdown.value = 3
countdownProgress.value = 1
const totalDuration = 3000
const progressUpdateInterval = 16
const progressDecrement = progressUpdateInterval / totalDuration
progressInterval = setInterval(() => {
countdownProgress.value = Math.max(0, countdownProgress.value - progressDecrement)
}, progressUpdateInterval)
countdownInterval = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
stopCountdown()
}
}, 1000)
}
function stopCountdown() {
if (countdownInterval) {
clearInterval(countdownInterval)
countdownInterval = null
}
if (progressInterval) {
clearInterval(progressInterval)
progressInterval = null
}
}
interface ShowOpenInAppOptions {
serverProject: ServerProject
}
async function show(options: ShowOpenInAppOptions) {
serverProject.value = options.serverProject
await nextTick()
window.open(appLink.value, '_self')
open.value = true
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleKeyDown)
setTimeout(() => {
visible.value = true
startCountdown()
}, 50)
}
function hide() {
visible.value = false
document.body.style.overflow = ''
window.removeEventListener('keydown', handleKeyDown)
stopCountdown()
setTimeout(() => {
open.value = false
}, 300)
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
hide()
}
}
onUnmounted(() => {
stopCountdown()
})
defineExpose({ show, hide, open })
</script>
<style lang="scss" scoped>
.open-in-app-modal {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
justify-content: center;
align-items: center;
}
.fullscreen-overlay {
position: fixed;
inset: 0;
background: linear-gradient(to bottom, rgba(66, 131, 92, 0.23) 0%, rgba(17, 35, 43, 0.4) 97%);
backdrop-filter: blur(12px);
opacity: 0;
transition: opacity 0.3s ease-out;
cursor: pointer;
&.shown {
opacity: 1;
}
}
.modal-content {
position: relative;
z-index: 1;
padding: 2.5rem;
opacity: 0;
transform: scale(0.95);
transition: all 0.3s ease-out;
&.shown {
opacity: 1;
transform: scale(1);
}
}
.countdown-container {
position: relative;
width: 120px;
height: 120px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.countdown-svg {
position: absolute;
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.countdown-bg {
stroke: var(--surface-4);
}
.countdown-progress {
stroke: var(--color-green);
transition: stroke-dashoffset 0.05s linear;
}
.countdown-number {
font-size: 3rem;
font-weight: 700;
color: var(--color-contrast);
z-index: 1;
}
</style>