Merge commit '90def724c28a2eacf173f4987e195dc14908f25d' into feature-clean

This commit is contained in:
2025-02-25 22:45:08 +03:00
83 changed files with 3417 additions and 1901 deletions

View File

@@ -214,34 +214,6 @@ impl DirectoryInfo {
}
}
fn is_same_disk(
old_dir: &Path,
new_dir: &Path,
) -> crate::Result<bool> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
Ok(old_dir.metadata()?.dev() == new_dir.metadata()?.dev())
}
#[cfg(windows)]
{
let old_dir = crate::util::io::canonicalize(old_dir)?;
let new_dir = crate::util::io::canonicalize(new_dir)?;
let old_component = old_dir.components().next();
let new_component = new_dir.components().next();
match (old_component, new_component) {
(
Some(std::path::Component::Prefix(old)),
Some(std::path::Component::Prefix(new)),
) => Ok(old.as_os_str() == new.as_os_str()),
_ => Ok(false),
}
}
}
fn get_disk_usage(path: &Path) -> crate::Result<Option<u64>> {
let path = crate::util::io::canonicalize(path)?;
@@ -340,7 +312,9 @@ impl DirectoryInfo {
let paths_len = paths.len();
if is_same_disk(&prev_dir, &move_dir).unwrap_or(false) {
if crate::util::io::is_same_disk(&prev_dir, &move_dir)
.unwrap_or(false)
{
let success_idxs = Arc::new(DashSet::new());
let loader_bar_id = Arc::new(&loader_bar_id);
@@ -364,7 +338,7 @@ impl DirectoryInfo {
})?;
}
crate::util::io::rename(
crate::util::io::rename_or_move(
&x.old,
&x.new,
)

View File

@@ -928,7 +928,8 @@ impl Profile {
format!("{project_path}.disabled")
};
io::rename(&path.join(project_path), &path.join(&new_path)).await?;
io::rename_or_move(&path.join(project_path), &path.join(&new_path))
.await?;
Ok(new_path)
}

View File

@@ -59,6 +59,19 @@ pub async fn read_dir(
})
}
// create_dir
pub async fn create_dir(
path: impl AsRef<std::path::Path>,
) -> Result<(), IOError> {
let path = path.as_ref();
tokio::fs::create_dir(path)
.await
.map_err(|e| IOError::IOPathError {
source: e,
path: path.to_string_lossy().to_string(),
})
}
// create_dir_all
pub async fn create_dir_all(
path: impl AsRef<std::path::Path>,
@@ -150,19 +163,72 @@ fn sync_write(
tmp_path.persist(path)?;
std::io::Result::Ok(())
}
pub fn is_same_disk(old_dir: &Path, new_dir: &Path) -> Result<bool, IOError> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
Ok(old_dir.metadata()?.dev() == new_dir.metadata()?.dev())
}
#[cfg(windows)]
{
let old_dir = canonicalize(old_dir)?;
let new_dir = canonicalize(new_dir)?;
let old_component = old_dir.components().next();
let new_component = new_dir.components().next();
match (old_component, new_component) {
(
Some(std::path::Component::Prefix(old)),
Some(std::path::Component::Prefix(new)),
) => Ok(old.as_os_str() == new.as_os_str()),
_ => Ok(false),
}
}
}
// rename
pub async fn rename(
pub async fn rename_or_move(
from: impl AsRef<std::path::Path>,
to: impl AsRef<std::path::Path>,
) -> Result<(), IOError> {
let from = from.as_ref();
let to = to.as_ref();
tokio::fs::rename(from, to)
.await
.map_err(|e| IOError::IOPathError {
source: e,
path: from.to_string_lossy().to_string(),
})
if to
.parent()
.map_or(Ok(false), |to_dir| is_same_disk(from, to_dir))?
{
tokio::fs::rename(from, to)
.await
.map_err(|e| IOError::IOPathError {
source: e,
path: from.to_string_lossy().to_string(),
})
} else {
move_recursive(from, to).await
}
}
#[async_recursion::async_recursion]
async fn move_recursive(from: &Path, to: &Path) -> Result<(), IOError> {
if from.is_file() {
copy(from, to).await?;
remove_file(from).await?;
return Ok(());
}
create_dir(to).await?;
let mut dir = read_dir(from).await?;
while let Some(entry) = dir.next_entry().await? {
let new_path = to.join(entry.file_name());
move_recursive(&entry.path(), &new_path).await?;
}
Ok(())
}
// copy

3
packages/assets/external/bluesky.svg vendored Normal file
View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.20232 2.85649C7.95386 4.92218 10.9135 9.11052 12.0001 11.3582C13.0868 9.11069 16.0462 4.92213 18.7978 2.85649C20.7832 1.36598 24 0.2127 24 3.88249C24 4.61539 23.5798 10.0393 23.3333 10.9198C22.4767 13.9812 19.355 14.762 16.5782 14.2894C21.432 15.1155 22.6667 17.8519 20.0001 20.5882C14.9357 25.785 12.7211 19.2843 12.1534 17.6186C12.0494 17.3132 12.0007 17.1703 12 17.2918C11.9993 17.1703 11.9506 17.3132 11.8466 17.6186C11.2791 19.2843 9.06454 25.7851 3.99987 20.5882C1.33323 17.8519 2.56794 15.1154 7.42179 14.2894C4.64492 14.762 1.5232 13.9812 0.666658 10.9198C0.420196 10.0392 0 4.61531 0 3.88249C0 0.2127 3.21689 1.36598 5.20218 2.85649H5.20232Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 789 B

15
packages/assets/external/github.svg vendored Normal file
View File

@@ -0,0 +1,15 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3972_7229)">
<g clip-path="url(#clip1_3972_7229)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.0358 0C18.6517 0 24 5.5 24 12.3042C24 17.7432 20.5731 22.3472 15.8192 23.9767C15.2248 24.0992 15.0071 23.712 15.0071 23.3862C15.0071 23.101 15.0267 22.1232 15.0267 21.1045C18.3549 21.838 19.0479 19.6378 19.0479 19.6378C19.5828 18.2118 20.3753 17.8452 20.3753 17.8452C21.4646 17.0915 20.2959 17.0915 20.2959 17.0915C19.0876 17.173 18.4536 18.3545 18.4536 18.3545C17.3841 20.2285 15.6607 19.699 14.9674 19.373C14.8685 18.5785 14.5513 18.0285 14.2146 17.723C16.8691 17.4377 19.6619 16.3785 19.6619 11.6523C19.6619 10.3078 19.1868 9.20775 18.434 8.35225C18.5527 8.04675 18.9688 6.7835 18.3149 5.09275C18.3149 5.09275 17.3047 4.76675 15.0269 6.35575C14.0517 6.08642 13.046 5.9494 12.0358 5.94825C11.0256 5.94825 9.99575 6.091 9.04482 6.35575C6.76677 4.76675 5.75657 5.09275 5.75657 5.09275C5.10269 6.7835 5.51902 8.04675 5.6378 8.35225C4.86514 9.20775 4.40963 10.3078 4.40963 11.6523C4.40963 16.3785 7.20245 17.4172 9.87674 17.723C9.44082 18.11 9.06465 18.8432 9.06465 20.0045C9.06465 21.6545 9.08425 22.9787 9.08425 23.386C9.08425 23.712 8.86629 24.0992 8.27216 23.977C3.5182 22.347 0.091347 17.7432 0.091347 12.3042C0.0717551 5.5 5.43967 0 12.0358 0Z" fill="currentColor"/>
</g>
</g>
<defs>
<clipPath id="clip0_3972_7229">
<rect width="24" height="24" fill="white"/>
</clipPath>
<clipPath id="clip1_3972_7229">
<rect width="24" height="24" fill="white" transform="matrix(-1 0 0 1 24 0)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M480,173.59c0-104.13-68.26-134.65-68.26-134.65C377.3,23.15,318.2,16.5,256.8,16h-1.51c-61.4.5-120.46,7.15-154.88,22.94,0,0-68.27,30.52-68.27,134.65,0,23.85-.46,52.35.29,82.59C34.91,358,51.11,458.37,145.32,483.29c43.43,11.49,80.73,13.89,110.76,12.24,54.47-3,85-19.42,85-19.42l-1.79-39.5s-38.93,12.27-82.64,10.77c-43.31-1.48-89-4.67-96-57.81a108.44,108.44,0,0,1-1-14.9,558.91,558.91,0,0,0,96.39,12.85c32.95,1.51,63.84-1.93,95.22-5.67,60.18-7.18,112.58-44.24,119.16-78.09C480.84,250.42,480,173.59,480,173.59ZM399.46,307.75h-50V185.38c0-25.8-10.86-38.89-32.58-38.89-24,0-36.06,15.53-36.06,46.24v67H231.16v-67c0-30.71-12-46.24-36.06-46.24-21.72,0-32.58,13.09-32.58,38.89V307.75h-50V181.67q0-38.65,19.75-61.39c13.6-15.15,31.4-22.92,53.51-22.92,25.58,0,44.95,9.82,57.75,29.48L256,147.69l12.45-20.85c12.81-19.66,32.17-29.48,57.75-29.48,22.11,0,39.91,7.77,53.51,22.92Q399.5,143,399.46,181.67Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M480,173.59c0-104.13-68.26-134.65-68.26-134.65C377.3,23.15,318.2,16.5,256.8,16h-1.51c-61.4.5-120.46,7.15-154.88,22.94,0,0-68.27,30.52-68.27,134.65,0,23.85-.46,52.35.29,82.59C34.91,358,51.11,458.37,145.32,483.29c43.43,11.49,80.73,13.89,110.76,12.24,54.47-3,85-19.42,85-19.42l-1.79-39.5s-38.93,12.27-82.64,10.77c-43.31-1.48-89-4.67-96-57.81a108.44,108.44,0,0,1-1-14.9,558.91,558.91,0,0,0,96.39,12.85c32.95,1.51,63.84-1.93,95.22-5.67,60.18-7.18,112.58-44.24,119.16-78.09C480.84,250.42,480,173.59,480,173.59ZM399.46,307.75h-50V185.38c0-25.8-10.86-38.89-32.58-38.89-24,0-36.06,15.53-36.06,46.24v67H231.16v-67c0-30.71-12-46.24-36.06-46.24-21.72,0-32.58,13.09-32.58,38.89V307.75h-50V181.67q0-38.65,19.75-61.39c13.6-15.15,31.4-22.92,53.51-22.92,25.58,0,44.95,9.82,57.75,29.48L256,147.69l12.45-20.85c12.81-19.66,32.17-29.48,57.75-29.48,22.11,0,39.91,7.77,53.51,22.92Q399.5,143,399.46,181.67Z" fill="currentColor"/></svg>

Before

Width:  |  Height:  |  Size: 962 B

After

Width:  |  Height:  |  Size: 983 B

10
packages/assets/external/tumblr.svg vendored Normal file
View File

@@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3466_5793)">
<path d="M18.7939 24H14.7856C11.1765 24 8.48678 22.1429 8.48678 17.7012V10.5855H5.20605V6.73219C8.81512 5.79709 10.3255 2.68972 10.4988 0H14.2471V6.10966H18.6205V10.5882H14.2471V16.7845C14.2471 18.6416 15.1848 19.2825 16.6768 19.2825H18.7939V24.0026V24Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_3466_5793">
<rect width="13.5878" height="24" fill="white" transform="translate(5.20605)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 562 B

View File

@@ -1 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"/></svg>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3564_5601)">
<path d="M18.9641 1.3335H22.6441L14.5641 10.3864L24.0041 22.6668H16.5961L10.7961 15.2041L4.15609 22.6668H0.476094L9.03609 12.9842L-0.00390625 1.3335H7.58809L12.8281 8.15072L18.9641 1.3335ZM17.6761 20.5414H19.7161L6.51609 3.38024H4.32409L17.6761 20.5414Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_3564_5601">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 526 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path d="M10 21.8c-1.3-.3-2.4-.7-3.5-1.5M17.6 3.7c1.1.7 2 1.6 2.7 2.7M2.2 10c.3-1.3.7-2.4 1.5-3.5M20.3 17.6c-.7 1.1-1.6 2-2.7 2.7M21.8 10.1c.2 1.3.2 2.5 0 3.8M6.5 3.6c1.1-.7 2.3-1.2 3.5-1.5M3.6 17.5c-.7-1.1-1.2-2.3-1.5-3.5"/>
<path d="M13.9 2.2c4.6.9 8.1 5 8.1 9.8s-3.4 8.9-8 9.8"/>
<path d="M12 6v6l4 2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 498 B

View File

@@ -13,14 +13,17 @@ import _SSOGoogleIcon from './external/sso/google.svg?component'
import _SSOMicrosoftIcon from './external/sso/microsoft.svg?component'
import _SSOSteamIcon from './external/sso/steam.svg?component'
import _AppleIcon from './external/apple.svg?component'
import _BlueskyIcon from './external/bluesky.svg?component'
import _BuyMeACoffeeIcon from './external/bmac.svg?component'
import _DiscordIcon from './external/discord.svg?component'
import _GithubIcon from './external/github.svg?component'
import _KoFiIcon from './external/kofi.svg?component'
import _MastodonIcon from './external/mastodon.svg?component'
import _OpenCollectiveIcon from './external/opencollective.svg?component'
import _PatreonIcon from './external/patreon.svg?component'
import _PayPalIcon from './external/paypal.svg?component'
import _RedditIcon from './external/reddit.svg?component'
import _TumblrIcon from './external/tumblr.svg?component'
import _TwitterIcon from './external/twitter.svg?component'
import _WindowsIcon from './external/windows.svg?component'
import _YouTubeIcon from './external/youtube.svg?component'
@@ -90,6 +93,7 @@ import _HeartHandshakeIcon from './icons/heart-handshake.svg?component'
import _HistoryIcon from './icons/history.svg?component'
import _HomeIcon from './icons/home.svg?component'
import _ImageIcon from './icons/image.svg?component'
import _InProgressIcon from './icons/in-progress.svg?component'
import _InfoIcon from './icons/info.svg?component'
import _IssuesIcon from './icons/issues.svg?component'
import _KeyIcon from './icons/key.svg?component'
@@ -225,7 +229,9 @@ export const SSOGoogleIcon = _SSOGoogleIcon
export const SSOMicrosoftIcon = _SSOMicrosoftIcon
export const SSOSteamIcon = _SSOSteamIcon
export const AppleIcon = _AppleIcon
export const BlueskyIcon = _BlueskyIcon
export const BuyMeACoffeeIcon = _BuyMeACoffeeIcon
export const GithubIcon = _GithubIcon
export const DiscordIcon = _DiscordIcon
export const KoFiIcon = _KoFiIcon
export const MastodonIcon = _MastodonIcon
@@ -234,6 +240,7 @@ export const PatreonIcon = _PatreonIcon
export const PayPalIcon = _PayPalIcon
export const PyroIcon = _PyroIcon
export const RedditIcon = _RedditIcon
export const TumblrIcon = _TumblrIcon
export const TwitterIcon = _TwitterIcon
export const WindowsIcon = _WindowsIcon
export const YouTubeIcon = _YouTubeIcon
@@ -300,6 +307,7 @@ export const HeartHandshakeIcon = _HeartHandshakeIcon
export const HistoryIcon = _HistoryIcon
export const HomeIcon = _HomeIcon
export const ImageIcon = _ImageIcon
export const InProgressIcon = _InProgressIcon
export const InfoIcon = _InfoIcon
export const IssuesIcon = _IssuesIcon
export const KeyIcon = _KeyIcon

View File

@@ -56,6 +56,11 @@
rgba(68, 182, 138, 0.175) 0%,
rgba(58, 250, 112, 0.125) 100%
);
--brand-gradient-strong-bg: linear-gradient(
270deg,
rgba(68, 182, 138, 0.175) 0%,
rgba(36, 225, 91, 0.12) 100%
);
--brand-gradient-button: rgba(255, 255, 255, 0.5);
--brand-gradient-border: rgba(32, 64, 32, 0.15);
--brand-gradient-fade-out-color: linear-gradient(to bottom, rgba(213, 235, 224, 0), #d0ece0 70%);
@@ -167,6 +172,7 @@ html {
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
--brand-gradient-bg: linear-gradient(0deg, rgba(14, 35, 19, 0.2) 0%, rgba(55, 137, 73, 0.1) 100%);
--brand-gradient-strong-bg: linear-gradient(270deg, #09110d 10%, #131f17 100%);
--brand-gradient-button: rgba(255, 255, 255, 0.08);
--brand-gradient-border: rgba(155, 255, 160, 0.08);
--brand-gradient-fade-out-color: linear-gradient(to bottom, rgba(24, 30, 31, 0), #171d1e 80%);
@@ -205,6 +211,15 @@ html {
rgba(22, 66, 51, 0.15) 0%,
rgba(55, 137, 73, 0.1) 100%
);
--brand-gradient-strong-bg: linear-gradient(
270deg,
rgba(9, 18, 14, 0.6) 10%,
rgba(19, 31, 23, 0.5) 100%
);
}
.retro-mode {
--brand-gradient-strong-bg: #3a3b38;
}
.experimental-styles-within {

View File

@@ -2,7 +2,7 @@
<div class="chips">
<Button
v-for="item in items"
:key="item"
:key="formatLabel(item)"
class="btn"
:class="{ selected: selected === item, capitalize: capitalize }"
@click="toggleItem(item)"
@@ -12,62 +12,39 @@
</Button>
</div>
</template>
<script setup>
<script setup lang="ts" generic="T">
import { CheckIcon } from '@modrinth/assets'
</script>
<script>
import { defineComponent } from 'vue'
import Button from './Button.vue'
export default defineComponent({
props: {
modelValue: {
required: true,
type: String,
},
items: {
required: true,
type: Array,
},
neverEmpty: {
default: true,
type: Boolean,
},
formatLabel: {
default: (x) => x,
type: Function,
},
capitalize: {
type: Boolean,
default: true,
},
const props = withDefaults(
defineProps<{
items: T[]
formatLabel?: (item: T) => string
neverEmpty?: boolean
capitalize?: boolean
}>(),
{
neverEmpty: true,
// Intentional any type, as this default should only be used for primitives (string or number)
formatLabel: (item) => item.toString(),
capitalize: true,
},
emits: ['update:modelValue'],
computed: {
selected: {
get() {
return this.modelValue
},
set(value) {
this.$emit('update:modelValue', value)
},
},
},
created() {
if (this.items.length > 0 && this.neverEmpty && !this.modelValue) {
this.selected = this.items[0]
}
},
methods: {
toggleItem(item) {
if (this.selected === item && !this.neverEmpty) {
this.selected = null
} else {
this.selected = item
}
},
},
})
)
const selected = defineModel<T | null>()
// If one always has to be selected, default to the first one
if (props.items.length > 0 && props.neverEmpty && !selected.value) {
selected.value = props.items[0]
}
function toggleItem(item: T) {
if (selected.value === item && !props.neverEmpty) {
selected.value = null
} else {
selected.value = item
}
}
</script>
<style lang="scss" scoped>

View File

@@ -0,0 +1,39 @@
<template>
<div class="accordion-content" :class="(baseClass ?? ``) + (collapsed ? `` : ` open`)">
<div v-bind="$attrs" :inert="collapsed">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
baseClass?: string
collapsed: boolean
}>()
defineOptions({
inheritAttrs: false,
})
</script>
<style scoped>
.accordion-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease-in-out;
}
@media (prefers-reduced-motion) {
.accordion-content {
transition: none !important;
}
}
.accordion-content.open {
grid-template-rows: 1fr;
}
.accordion-content > div {
overflow: hidden;
}
</style>

View File

@@ -29,6 +29,7 @@ async function copyText() {
<style lang="scss" scoped>
.code {
color: var(--color-text);
display: inline-flex;
grid-gap: 0.5rem;
font-family: var(--mono-font);

View File

@@ -225,7 +225,7 @@
</template>
</div>
<div class="preview">
<Toggle id="preview" v-model="previewMode" :checked="previewMode" />
<Toggle id="preview" v-model="previewMode" />
<label class="label" for="preview"> Preview </label>
</div>
</div>
@@ -263,31 +263,31 @@
</template>
<script setup lang="ts">
import { type Component, computed, ref, onMounted, onBeforeUnmount, toRef, watch } from 'vue'
import { type Component, computed, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue'
import { Compartment, EditorState } from '@codemirror/state'
import { EditorView, keymap, placeholder as cm_placeholder } from '@codemirror/view'
import { markdown } from '@codemirror/lang-markdown'
import { indentWithTab, historyKeymap, history } from '@codemirror/commands'
import { history, historyKeymap, indentWithTab } from '@codemirror/commands'
import {
AlignLeftIcon,
BoldIcon,
CodeIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
BoldIcon,
ImageIcon,
InfoIcon,
ItalicIcon,
ScanEyeIcon,
StrikethroughIcon,
CodeIcon,
LinkIcon,
ListBulletedIcon,
ListOrderedIcon,
TextQuoteIcon,
LinkIcon,
ImageIcon,
YouTubeIcon,
AlignLeftIcon,
PlusIcon,
XIcon,
ScanEyeIcon,
StrikethroughIcon,
TextQuoteIcon,
UploadIcon,
InfoIcon,
XIcon,
YouTubeIcon,
} from '@modrinth/assets'
import { markdownCommands, modrinthMarkdownEditorKeymap } from '@modrinth/utils/codemirror'
import { renderHighlightedString } from '@modrinth/utils/highlight'

View File

@@ -3,33 +3,17 @@
:id="id"
type="checkbox"
class="switch stylized-toggle"
:disabled="disabled"
:checked="checked"
@change="toggle"
@change="checked = !checked"
/>
</template>
<script>
export default {
props: {
id: {
type: String,
required: true,
},
modelValue: {
type: Boolean,
},
checked: {
type: Boolean,
required: true,
},
},
emits: ['update:modelValue'],
methods: {
toggle() {
if (!this.disabled) {
this.$emit('update:modelValue', !this.modelValue)
}
},
},
}
<script setup lang="ts">
defineProps<{
id?: string
disabled?: boolean
}>()
const checked = defineModel<boolean>()
</script>

View File

@@ -2,15 +2,16 @@
<NewModal ref="purchaseModal">
<template #title>
<span class="text-contrast text-xl font-extrabold">
<template v-if="product.metadata.type === 'midas'">Subscribe to Modrinth Plus!</template>
<template v-else-if="product.metadata.type === 'pyro'"
>Subscribe to Modrinth Servers!</template
>
<template v-if="productType === 'midas'">Subscribe to Modrinth+!</template>
<template v-else-if="productType === 'pyro'">
<template v-if="existingSubscription"> Upgrade server plan </template>
<template v-else> Subscribe to Modrinth Servers! </template>
</template>
<template v-else>Purchase product</template>
</span>
</template>
<div class="flex items-center gap-1 pb-4">
<template v-if="product.metadata.type === 'pyro' && !projectId">
<template v-if="productType === 'pyro' && !projectId">
<span
:class="{
'text-secondary': purchaseModalStep !== 0,
@@ -24,24 +25,20 @@
</template>
<span
:class="{
'text-secondary':
purchaseModalStep !== (product.metadata.type === 'pyro' && !projectId ? 1 : 0),
'font-bold':
purchaseModalStep === (product.metadata.type === 'pyro' && !projectId ? 1 : 0),
'text-secondary': purchaseModalStep !== (productType === 'pyro' && !projectId ? 1 : 0),
'font-bold': purchaseModalStep === (productType === 'pyro' && !projectId ? 1 : 0),
}"
>
{{ product.metadata.type === 'pyro' ? 'Billing' : 'Plan' }}
{{ productType === 'pyro' ? 'Billing' : 'Plan' }}
<span class="hidden sm:inline">{{
product.metadata.type === 'pyro' ? 'interval' : 'selection'
productType === 'pyro' ? 'interval' : 'selection'
}}</span>
</span>
<ChevronRightIcon class="h-5 w-5 text-secondary" />
<span
:class="{
'text-secondary':
purchaseModalStep !== (product.metadata.type === 'pyro' && !projectId ? 2 : 1),
'font-bold':
purchaseModalStep === (product.metadata.type === 'pyro' && !projectId ? 2 : 1),
'text-secondary': purchaseModalStep !== (productType === 'pyro' && !projectId ? 2 : 1),
'font-bold': purchaseModalStep === (productType === 'pyro' && !projectId ? 2 : 1),
}"
>
Payment
@@ -49,20 +46,18 @@
<ChevronRightIcon class="h-5 w-5 text-secondary" />
<span
:class="{
'text-secondary':
purchaseModalStep !== (product.metadata.type === 'pyro' && !projectId ? 3 : 2),
'font-bold':
purchaseModalStep === (product.metadata.type === 'pyro' && !projectId ? 3 : 2),
'text-secondary': purchaseModalStep !== (productType === 'pyro' && !projectId ? 3 : 2),
'font-bold': purchaseModalStep === (productType === 'pyro' && !projectId ? 3 : 2),
}"
>
Review
</span>
</div>
<div
v-if="product.metadata.type === 'pyro' && !projectId && purchaseModalStep === 0"
v-if="productType === 'pyro' && !projectId && purchaseModalStep === 0"
class="md:w-[600px] flex flex-col gap-4"
>
<div>
<div v-if="!existingSubscription">
<p class="my-2 text-lg font-bold">Configure your server</p>
<div class="flex flex-col gap-4">
<input v-model="serverName" placeholder="Server name" class="input" maxlength="48" />
@@ -105,13 +100,20 @@
</div>
<div v-if="customServer">
<div class="flex gap-2 items-center">
<p class="my-2 text-lg font-bold">Configure your RAM</p>
<p class="my-2 text-lg font-bold">
<template v-if="existingSubscription">Upgrade your RAM</template>
<template v-else>Configure your RAM</template>
</p>
<IssuesIcon
v-if="customServerConfig.ramInGb < 4"
v-tooltip="'This might not be enough resources for your Minecraft server.'"
class="h-6 w-6 text-orange"
/>
</div>
<p v-if="existingPlan" class="mt-1 mb-2 text-secondary">
Your current plan has <strong>{{ existingPlan.metadata.ram / 1024 }} GB RAM</strong> and
<strong>{{ existingPlan.metadata.cpu }} vCPUs</strong>.
</p>
<div class="flex flex-col gap-4">
<div class="flex w-full gap-2 items-center">
<Slider
@@ -166,6 +168,16 @@
>
<div>
<p class="my-2 text-lg font-bold">Choose billing interval</p>
<div v-if="existingPlan" class="flex flex-col gap-3 mb-4 text-secondary">
<p class="m-0">
The prices below reflect the new <strong>renewal cost</strong> of your upgraded
subscription.
</p>
<p class="m-0">
Today, you will be charged a prorated amount for the remainder of your current billing
cycle.
</p>
</div>
<div class="flex flex-col gap-4">
<div
v-for="([interval, rawPrice], index) in Object.entries(price.prices.intervals)"
@@ -228,7 +240,10 @@
v-if="purchaseModalStep === (mutatedProduct.metadata.type === 'pyro' && !projectId ? 3 : 2)"
class="md:w-[650px]"
>
<div v-if="mutatedProduct.metadata.type === 'pyro'" class="r-4 rounded-xl bg-bg p-4 mb-4">
<div
v-if="mutatedProduct.metadata.type === 'pyro' && !existingSubscription"
class="r-4 rounded-xl bg-bg p-4 mb-4"
>
<p class="my-2 text-lg font-bold text-primary">Server details</p>
<div class="flex items-center gap-4">
<img
@@ -248,12 +263,19 @@
<div class="r-4 rounded-xl bg-bg p-4">
<p class="my-2 text-lg font-bold text-primary">Purchase details</p>
<div class="mb-2 flex justify-between">
<span class="text-secondary"
>{{ mutatedProduct.metadata.type === 'midas' ? 'Modrinth+' : 'Modrinth Servers' }}
{{ selectedPlan }}</span
>
<span class="text-secondary text-end">
{{ formatPrice(locale, price.prices.intervals[selectedPlan], price.currency_code) }} /
<span class="text-secondary">
{{ mutatedProduct.metadata.type === 'midas' ? 'Modrinth+' : 'Modrinth Servers' }}
{{
existingPlan
? `(${dayjs(renewalDate).diff(dayjs(), 'days')} days prorated)`
: selectedPlan
}}
</span>
<span v-if="existingPlan" class="text-secondary text-end">
{{ formatPrice(locale, total - tax, price.currency_code) }}
</span>
<span v-else class="text-secondary text-end">
{{ formatPrice(locale, total - tax, price.currency_code) }} /
{{ selectedPlan }}
</span>
</div>
@@ -266,7 +288,7 @@
<div class="mt-4 flex justify-between border-0 border-t border-solid border-code-bg pt-4">
<span class="text-lg font-bold">Today's total</span>
<span class="text-lg font-extrabold text-primary text-end">
{{ formatPrice(locale, price.prices.intervals[selectedPlan], price.currency_code) }}
{{ formatPrice(locale, total, price.currency_code) }}
</span>
</div>
</div>
@@ -363,7 +385,8 @@
<br />
You'll be charged
{{ formatPrice(locale, price.prices.intervals[selectedPlan], price.currency_code) }} /
{{ selectedPlan }} plus applicable taxes starting today, until you cancel.
{{ selectedPlan }} plus applicable taxes starting
{{ existingPlan ? dayjs(renewalDate).format('MMMM D, YYYY') : 'today' }}, until you cancel.
<br />
You can cancel anytime from your settings page.
</p>
@@ -389,12 +412,19 @@
:disabled="
paymentLoading ||
(mutatedProduct.metadata.type === 'pyro' && !projectId && !serverName) ||
customAllowedToContinue
customNotAllowedToContinue ||
upgradeNotAllowedToContinue
"
@click="nextStep"
>
<RightArrowIcon />
{{ mutatedProduct.metadata.type === 'pyro' && !projectId ? 'Next' : 'Select' }}
<template v-if="customServer && customLoading">
<SpinnerIcon class="animate-spin" />
Checking availability...
</template>
<template v-else>
<RightArrowIcon />
{{ mutatedProduct.metadata.type === 'pyro' && !projectId ? 'Next' : 'Select' }}
</template>
</button>
</template>
<template
@@ -468,6 +498,7 @@
import { ref, computed, nextTick, reactive, watch } from 'vue'
import NewModal from '../modal/NewModal.vue'
import {
SpinnerIcon,
CardIcon,
CheckCircleIcon,
ChevronRightIcon,
@@ -487,6 +518,7 @@ import { useVIntl, defineMessages } from '@vintl/vintl'
import { Multiselect } from 'vue-multiselect'
import Checkbox from '../base/Checkbox.vue'
import Slider from '../base/Slider.vue'
import dayjs from 'dayjs'
import Admonition from '../base/Admonition.vue'
const { locale, formatMessage } = useVIntl()
@@ -562,8 +594,25 @@ const props = defineProps({
required: false,
default: '',
},
existingSubscription: {
type: Object,
required: false,
default: null,
},
existingPlan: {
type: Object,
required: false,
default: null,
},
renewalDate: {
type: String,
required: false,
default: null,
},
})
const productType = computed(() => (props.customServer ? 'pyro' : props.product.metadata.type))
const messages = defineMessages({
paymentMethodCardDisplay: {
id: 'omorphia.component.purchase_modal.payment_method_card_display',
@@ -645,7 +694,7 @@ const total = ref()
const serverName = ref(props.serverName || '')
const serverLoader = ref('Vanilla')
const eulaAccepted = ref(false)
const eulaAccepted = ref(!!props.existingSubscription)
const mutatedProduct = ref({ ...props.product })
const customMinRam = ref(0)
@@ -653,11 +702,15 @@ const customMaxRam = ref(0)
const customMatchingProduct = ref()
const customOutOfStock = ref(false)
const customLoading = ref(true)
const customAllowedToContinue = computed(
const customNotAllowedToContinue = computed(
() =>
props.customServer &&
!props.existingSubscription &&
(!customMatchingProduct.value || customLoading.value || customOutOfStock.value),
)
const upgradeNotAllowedToContinue = computed(
() => props.existingSubscription && (customOutOfStock.value || customLoading.value),
)
const customServerConfig = reactive({
ramInGb: 4,
@@ -670,7 +723,9 @@ const updateCustomServerProduct = () => {
(product) => product.metadata.ram === customServerConfig.ram,
)
if (customMatchingProduct.value) mutatedProduct.value = { ...customMatchingProduct.value }
if (customMatchingProduct.value) {
mutatedProduct.value = { ...customMatchingProduct.value }
}
}
let updateCustomServerStockTimeout = null
@@ -682,25 +737,38 @@ const updateCustomServerStock = async () => {
updateCustomServerStockTimeout = setTimeout(async () => {
if (props.fetchCapacityStatuses) {
const capacityStatus = await props.fetchCapacityStatuses(mutatedProduct.value)
if (capacityStatus.custom?.available === 0) {
customOutOfStock.value = true
if (props.existingSubscription) {
if (mutatedProduct.value) {
const capacityStatus = await props.fetchCapacityStatuses(
props.existingSubscription.metadata.id,
mutatedProduct.value,
)
customOutOfStock.value = capacityStatus.custom?.available === 0
console.log(capacityStatus)
}
} else {
customOutOfStock.value = false
const capacityStatus = await props.fetchCapacityStatuses(mutatedProduct.value)
customOutOfStock.value = capacityStatus.custom?.available === 0
}
customLoading.value = false
} else {
console.error('No fetchCapacityStatuses function provided.')
customOutOfStock.value = true
}
customLoading.value = false
}, 300)
}
if (props.customServer) {
function updateRamValues() {
const ramValues = props.product.map((product) => product.metadata.ram / 1024)
customMinRam.value = Math.min(...ramValues)
customMaxRam.value = Math.max(...ramValues)
customServerConfig.ramInGb = customMinRam.value
}
if (props.customServer) {
updateRamValues()
const updateProductAndStock = () => {
updateCustomServerProduct()
updateCustomServerStock()
@@ -880,16 +948,25 @@ async function refreshPayment(confirmationId, paymentMethodId) {
id: paymentMethodId,
}
const result = await props.sendBillingRequest({
charge: {
type: 'new',
product_id: mutatedProduct.value.id,
interval: selectedPlan.value,
},
existing_payment_intent: paymentIntentId.value,
metadata: metadata.value,
...base,
})
const result = await props.sendBillingRequest(
props.existingSubscription
? {
interval: selectedPlan.value,
cancelled: false,
product: mutatedProduct.value.id,
payment_method: paymentMethodId,
}
: {
charge: {
type: 'new',
product_id: mutatedProduct.value.id,
interval: selectedPlan.value,
},
existing_payment_intent: paymentIntentId.value,
metadata: metadata.value,
...base,
},
)
if (!paymentIntentId.value) {
paymentIntentId.value = result.payment_intent_id
@@ -903,10 +980,14 @@ async function refreshPayment(confirmationId, paymentMethodId) {
if (confirmationId) {
confirmationToken.value = confirmationId
inputtedPaymentMethod.value = result.payment_method
if (result.payment_method) {
inputtedPaymentMethod.value = result.payment_method
}
}
selectedPaymentMethod.value = result.payment_method
if (result.payment_method) {
selectedPaymentMethod.value = result.payment_method
}
} catch (err) {
props.onError(err)
}
@@ -930,9 +1011,13 @@ async function submitPayment() {
defineExpose({
show: () => {
if (props.customServer) {
updateRamValues()
}
stripe = Stripe(props.publishableKey)
selectedPlan.value = 'yearly'
selectedPlan.value = props.existingSubscription ? props.existingSubscription.interval : 'yearly'
serverName.value = props.serverName || ''
serverLoader.value = 'Vanilla'

View File

@@ -28,7 +28,7 @@
class="hidden sm:flex"
:class="{ 'cursor-help': dateTooltip }"
>
{{ relativeDate }}
{{ future ? formatMessage(messages.justNow) : relativeDate }}
</div>
<div v-else-if="entry.version" :class="{ 'cursor-help': dateTooltip }">
{{ longDate }}
@@ -66,11 +66,8 @@ const props = withDefaults(
)
const currentDate = ref(dayjs())
const recent = computed(
() =>
props.entry.date.isAfter(currentDate.value.subtract(1, 'week')) &&
props.entry.date.isBefore(currentDate.value),
)
const recent = computed(() => props.entry.date.isAfter(currentDate.value.subtract(1, 'week')))
const future = computed(() => props.entry.date.isAfter(currentDate.value))
const dateTooltip = computed(() => props.entry.date.format('MMMM D, YYYY [at] h:mm A'))
const relativeDate = computed(() => props.entry.date.fromNow())
@@ -94,6 +91,10 @@ const messages = defineMessages({
id: 'changelog.product.api',
defaultMessage: 'API',
},
justNow: {
id: 'changelog.justNow',
defaultMessage: 'Just now',
},
})
</script>
<style lang="scss" scoped>

View File

@@ -9,6 +9,7 @@ export { default as ButtonStyled } from './base/ButtonStyled.vue'
export { default as Card } from './base/Card.vue'
export { default as Checkbox } from './base/Checkbox.vue'
export { default as Chips } from './base/Chips.vue'
export { default as Collapsible } from './base/Collapsible.vue'
export { default as ContentPageHeader } from './base/ContentPageHeader.vue'
export { default as CopyCode } from './base/CopyCode.vue'
export { default as DoubleIcon } from './base/DoubleIcon.vue'

View File

@@ -10,7 +10,7 @@
<label v-if="hasToType" for="confirmation" class="confirmation-label">
<span>
<strong>To verify, type</strong>
<em class="confirmation-text">{{ confirmationText }}</em>
<em class="confirmation-text"> {{ confirmationText }} </em>
<strong>below:</strong>
</span>
</label>

View File

@@ -32,6 +32,9 @@
"button.upload-image": {
"defaultMessage": "Upload image"
},
"changelog.justNow": {
"defaultMessage": "Just now"
},
"changelog.product.api": {
"defaultMessage": "API"
},

View File

@@ -10,6 +10,85 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2025-02-25T10:20:00-08:00`,
product: 'servers',
body: `### Improvements
- Fixed server upgrades being allowed when out of stock, despite warning.`,
},
{
date: `2025-02-25T10:20:00-08:00`,
product: 'web',
body: `### Improvements
- Moved Minecraft brand disclaimer to bottom of footer.
- Improved clarity of the ongoing revenue period footnote on the Revenue page.
- Fixed collections without a summary being unable to be edited.`,
},
{
date: `2025-02-21T13:30:00-08:00`,
product: 'web',
body: `### Improvements
- Collections are now sorted by creation date. (Contributed by [worldwidepixel](https://github.com/modrinth/code/pull/3286))
- Collections are no longer required to have summaries. (Contributed by [Erb3](https://github.com/modrinth/code/pull/3281))
- Fixed padding issue on revenue page.
- Fixed last modified date on Rewards Program Info page. (Contributed by [IMB11](https://github.com/modrinth/code/pull/3287))`,
},
{
date: `2025-02-20T18:15:00-08:00`,
product: 'web',
body: `### Improvements
- Revenue page has been updated to more clearly display pending revenue and when it will be available to withdraw. (Contributed by [IMB11](https://github.com/modrinth/code/pull/3250))
- Footer will now be forced to the bottom of the page on short pages.
- Styling fixes to moderation checklist proof form.`,
},
{
date: `2025-02-19T22:20:00-08:00`,
product: 'web',
body: `### Added
- All-new site footer with more links, better organization, and a new aesthetic.
### Improvements
- Added Dallas location to Modrinth Servers landing page.
- Updated staff moderation checklist to be more visually consistent and more dynamic.`,
},
{
date: `2025-02-18T14:30:00-08:00`,
product: 'servers',
body: `### Added
- Links will now be detected in console line viewer modal.
### Improvements
- Initial loading of pages in the server panel are now up to 400% faster.
- Syncing and uploading new server icons no longer requires a full page refresh.
- Fix a case where opening the platform modal, closing it, and reopening it would cause the loader version to be unselected.
- Prevents an issue where, if crash log analysis fails, the Overview page would unrender.
- Suspended server listings now have a copy ID button.
- Fixed bugs from Modrinth Servers February Release.`,
},
{
date: `2025-02-16T19:10:00-08:00`,
product: 'web',
body: `### Improvements
- Fixed spacing issue on confirmation modals.`,
},
{
date: `2025-02-16T19:10:00-08:00`,
product: 'servers',
body: `### Improvements
- Check for availability before allowing a server upgrade.`,
},
{
date: `2025-02-12T19:10:00-08:00`,
product: 'web',
body: `### Improvements
- Servers out of stock link now links to Modrinth Discord instead of support page.`,
},
{
date: `2025-02-12T19:10:00-08:00`,
product: 'servers',
body: `### Added
- Added server upgrades to switch to a larger plan as an option in billing settings.`,
},
{
date: `2025-02-12T12:10:00-08:00`,
product: 'web',
@@ -44,6 +123,7 @@ const VERSIONS: VersionEntry[] = [
{
date: `2025-02-10T08:00:00-08:00`,
product: 'servers',
version: `February Release`,
body: `### Added
- You can now search and filter through your server's console in the Overview tab, jump to specific results to see the log in context, select them, and copy them.
- You can now drag and select any number of lines in the console, copy them. and view them formatted.
@@ -94,9 +174,25 @@ Contributed by [IMB11](https://github.com/modrinth/code/pull/1301).`,
{
date: `2025-01-10T09:00:00-08:00`,
product: 'servers',
body: `### Improvements
version: 'January Release',
body: `### Added
- Added drag & drop upload support for mod and plugin files on the content page.
- Added a button to upload files to the content page.
- Added extra info (file name, author) to each mod on the content page.
- Show number of mods in search box.
- Adds a "No mods/plugins found for your query!" message if nothing is found, with a button to show everything again.
### Improvements
- The content page layout has been enhanced, now showing the file name and author of each installed item.
- You can now upload directly from the content page, instead of having to go to the Files page.`,
- You can now upload directly from the content page, instead of having to go to the Files page.
- Auto-backup now lists options in a dropdown instead of number input.
- Auto-backup 'Save changes' button now disables when no changes are made and backups are off.
- Servers dropdowns now have rounded corners on the last elements for consistency.
- Added support for more suspension reasons.
- Will now show resubscribe button on servers when payment status is "failed" instead of just "cancelled".
- Tweak button styles for consistency.
- Only scroll to the top of the mod/plugin list when searching if already scrolled down.
- Tweak content page mobile UI.`,
},
{
date: `2025-01-10T09:00:00-08:00`,
@@ -104,6 +200,16 @@ Contributed by [IMB11](https://github.com/modrinth/code/pull/1301).`,
body: `### Improvements
- Tags on project pages are now clickable to view other projects with that tag (Contributed by [Neddo](https://github.com/modrinth/code/pull/3126))
- You can now send someone a link to the download interface with a specific version and loader selected, like so: https://modrinth.com/mod/sodium?version=1.21.2&loader=quilt#download (Contributed by [AwakenedRedstone](https://github.com/modrinth/code/pull/3138))`,
},
{
date: `2024-12-26T22:05:00-08:00`,
product: 'servers',
body: `### Added
- Added ability for users to clean install modpacks when switching versions.
### Improvements
- New status bar in ServerListing that shows suspension reasons/upgrade status.
- Displays a new screen for servers that are being upgraded.`,
},
{
date: `2024-12-25T14:00:00-08:00`,
@@ -144,6 +250,52 @@ Contributed by [IMB11](https://github.com/modrinth/code/pull/1301).`,
- Fixed “Database is locked” errors on devices with slow disks.
- Fixed a few edge cases where API downtime could lead to an invalid state.`,
},
{
date: `2024-12-21T16:00:00-08:00`,
product: 'servers',
body: `### Added
- Drag and drop anything in the file manager.
- Added file upload queue status bar.
- Added support for parallel file uploads to upload multiple files faster.
- Added ability to cancel in-progress file uploads.
- Creation dates are now displayed for files.
- Can now sort by most recently created files
- YAML and TOML files now support syntax highlighting
- Find and replace support in files editor
### Improvements
- Files list renders up to 200% faster.
- Image viewer performance improvements, improved UI, and better handling of large-to-display images.
- UI inconsistency fixes.
- When reinstalling the loader, the current Minecraft version is automatically selected.
- Allow user to clean install modpacks on the modpack search page.
- Fixed 'Change platform' button leading to the wrong page on a vanilla server.`,
},
{
date: `2024-12-11T22:18:45-08:00`,
product: 'servers',
version: `December Release`,
body: `### Added
- Expanded loader support to include **Paper** and **Purpur** servers, offering fully native plugin compatibility.
- A live chat button has been added to the bottom right of all server pages, making it easier for customers to contact our support team.
- Automatic backups are now *rolling*. This means older backups will be deleted to make space for new backups when a new one is being created. You can also now **lock** specific backups so that they don't get deleted by the automatic backup process.
- Users can now easily create backups before reinstalling a server with a different loader.
### Improvements
- The Platform options page has been completely redesigned to streamline user interactions and improve overall clarity.
- Suspended servers now display a clear "Suspended" status instead of a confusing "Connection lost" message, allowing users to easily check their billing information.
- The console has been internally reworked to improve responsiveness and prevent freezing during high-volume spam.
- Resolved CPU usage readings that previously exceeded 100% during high-load scenarios. CPU usage is now accurately normalized to a 0100% range across all cores.
- Corrected CPU limit settings for some servers, potentially improving performance by up to half a core.
- Fixed an issue preventing server reinstallation when at the maximum backup limit.
- Resolved installation and runtime problems with older Minecraft versions.
- Added missing dynamic system libraries to our images, ensuring compatibility with the vast majority of mods.
- Implemented several additional bug fixes and performance optimizations.
- Removed Herobrine.
### Known Issues
- Backups may occasionally take longer than expected or become stuck. If a backup is unresponsive, please submit a support inquiry, and we'll investigate further.`,
},
].map((x) => ({ ...x, date: dayjs(x.date) }) as VersionEntry)
export function getChangelog() {

View File

@@ -87,6 +87,17 @@ export const formatNumber = (number, abbreviate = true) => {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
export function formatDate(
date: dayjs.Dayjs,
options: Intl.DateTimeFormatOptions = {
month: 'long',
day: 'numeric',
year: 'numeric',
},
): string {
return date.toDate().toLocaleDateString(undefined, options)
}
export function formatMoney(number, abbreviate = false) {
const x = Number(number)
if (x >= 1000000 && abbreviate) {