Merge commit '037cc86c1f520d8e89e721a631c9163d01c61070' into feature-clean

This commit is contained in:
2025-02-10 22:15:18 +03:00
118 changed files with 4847 additions and 2135 deletions

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus"
version = "0.9.2"
version = "0.9.3"
authors = ["Jai A <jaiagr+gpg@pm.me>"]
edition = "2021"

View File

@@ -180,9 +180,8 @@ pub async fn import_mmc(
instance_folder: String, // instance folder in mmc_base_path
profile_path: &str, // path to profile
) -> crate::Result<()> {
let mmc_instance_path = mmc_base_path
.join("instances")
.join(instance_folder.clone());
let mmc_instance_path =
mmc_base_path.join("instances").join(instance_folder);
let mmc_pack =
io::read_to_string(&mmc_instance_path.join("mmc-pack.json")).await?;
@@ -209,9 +208,18 @@ pub async fn import_mmc(
profile_path: profile_path.to_string(),
};
// Managed pack
let backup_name = "Imported Modpack".to_string();
let mut minecraft_folder = mmc_instance_path.join("minecraft");
if !minecraft_folder.is_dir() {
minecraft_folder = mmc_instance_path.join(".minecraft");
if !minecraft_folder.is_dir() {
return Err(crate::ErrorKind::InputError(
"Instance is missing Minecraft directory".to_string(),
)
.into());
}
}
// Managed pack
if instance_cfg.managed_pack.unwrap_or(false) {
match instance_cfg.managed_pack_type {
Some(MMCManagedPackType::Modrinth) => {
@@ -220,38 +228,26 @@ pub async fn import_mmc(
// Modrinth Managed Pack
// Kept separate as we may in the future want to add special handling for modrinth managed packs
let backup_name = "Imported Modrinth Modpack".to_string();
let minecraft_folder = mmc_base_path.join("instances").join(instance_folder).join(".minecraft");
import_mmc_unmanaged(profile_path, minecraft_folder, backup_name, description, mmc_pack).await?;
import_mmc_unmanaged(profile_path, minecraft_folder, "Imported Modrinth Modpack".to_string(), description, mmc_pack).await?;
}
Some(MMCManagedPackType::Flame) | Some(MMCManagedPackType::ATLauncher) => {
// For flame/atlauncher managed packs
// Treat as unmanaged, but with 'minecraft' folder instead of '.minecraft'
let minecraft_folder = mmc_base_path.join("instances").join(instance_folder).join("minecraft");
import_mmc_unmanaged(profile_path, minecraft_folder, backup_name, description, mmc_pack).await?;
import_mmc_unmanaged(profile_path, minecraft_folder, "Imported Modpack".to_string(), description, mmc_pack).await?;
},
Some(_) => {
// For managed packs that aren't modrinth, flame, atlauncher
// Treat as unmanaged
let backup_name = "ImportedModpack".to_string();
let minecraft_folder = mmc_base_path.join("instances").join(instance_folder).join(".minecraft");
import_mmc_unmanaged(profile_path, minecraft_folder, backup_name, description, mmc_pack).await?;
import_mmc_unmanaged(profile_path, minecraft_folder, "ImportedModpack".to_string(), description, mmc_pack).await?;
},
_ => return Err(crate::ErrorKind::InputError({
"Instance is managed, but managed pack type not specified in instance.cfg".to_string()
}).into())
_ => return Err(crate::ErrorKind::InputError("Instance is managed, but managed pack type not specified in instance.cfg".to_string()).into())
}
} else {
// Direclty import unmanaged pack
let backup_name = "Imported Modpack".to_string();
let minecraft_folder = mmc_base_path
.join("instances")
.join(instance_folder)
.join(".minecraft");
import_mmc_unmanaged(
profile_path,
minecraft_folder,
backup_name,
"Imported Modpack".to_string(),
description,
mmc_pack,
)

View File

@@ -13,13 +13,13 @@ use crate::util::io;
use crate::{profile, State};
use async_zip::base::read::seek::ZipFileReader;
use std::io::Cursor;
use std::path::{Component, PathBuf};
use super::install_from::{
generate_pack_from_file, generate_pack_from_version_id, CreatePack,
CreatePackLocation, PackFormat,
};
use crate::data::ProjectType;
use std::io::Cursor;
use std::path::{Component, PathBuf};
/// Install a pack
/// Wrapper around install_pack_files that generates a pack creation description, and
@@ -189,6 +189,7 @@ pub async fn install_zipped_mrpack_files(
.hashes
.get(&PackFileHash::Sha1)
.map(|x| &**x),
ProjectType::get_from_parent_folder(&path),
&state.pool,
)
.await?;
@@ -247,6 +248,7 @@ pub async fn install_zipped_mrpack_files(
&profile_path,
&new_path.to_string_lossy(),
None,
ProjectType::get_from_parent_folder(&new_path),
&state.pool,
)
.await?;

View File

@@ -9,7 +9,7 @@ use crate::pack::install_from::{
};
use crate::state::{
CacheBehaviour, CachedEntry, Credentials, JavaVersion, ProcessMetadata,
ProfileFile, ProjectType, SideType,
ProfileFile, ProfileInstallStage, ProjectType, SideType,
};
use crate::event::{emit::emit_profile, ProfilePayloadType};
@@ -225,7 +225,18 @@ pub async fn list() -> crate::Result<Vec<Profile>> {
#[tracing::instrument]
pub async fn install(path: &str, force: bool) -> crate::Result<()> {
if let Some(profile) = get(path).await? {
crate::launcher::install_minecraft(&profile, None, force).await?;
let result =
crate::launcher::install_minecraft(&profile, None, force).await;
if result.is_err()
&& profile.install_stage != ProfileInstallStage::Installed
{
edit(path, |prof| {
prof.install_stage = ProfileInstallStage::NotInstalled;
async { Ok(()) }
})
.await?;
}
result?;
} else {
return Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
.as_error());

View File

@@ -111,7 +111,7 @@ async fn replace_managed_modrinth(
ignore_lock: bool,
) -> crate::Result<()> {
crate::profile::edit(profile_path, |profile| {
profile.install_stage = ProfileInstallStage::Installing;
profile.install_stage = ProfileInstallStage::MinecraftInstalling;
async { Ok(()) }
})
.await?;

View File

@@ -7,11 +7,7 @@ use crate::state::{
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
};
use crate::util::io;
use crate::{
process,
state::{self as st},
State,
};
use crate::{process, state as st, State};
use chrono::Utc;
use daedalus as d;
use daedalus::minecraft::{RuleAction, VersionInfo};
@@ -202,7 +198,7 @@ pub async fn install_minecraft(
.await?;
crate::api::profile::edit(&profile.path, |prof| {
prof.install_stage = ProfileInstallStage::Installing;
prof.install_stage = ProfileInstallStage::MinecraftInstalling;
async { Ok(()) }
})
@@ -434,7 +430,7 @@ pub async fn launch_minecraft(
profile: &Profile,
) -> crate::Result<ProcessMetadata> {
if profile.install_stage == ProfileInstallStage::PackInstalling
|| profile.install_stage == ProfileInstallStage::Installing
|| profile.install_stage == ProfileInstallStage::MinecraftInstalling
{
return Err(crate::ErrorKind::LauncherError(
"Profile is still installing".to_string(),

View File

@@ -1,4 +1,5 @@
use crate::config::{META_URL, MODRINTH_API_URL, MODRINTH_API_URL_V3};
use crate::state::ProjectType;
use crate::util::fetch::{fetch_json, sha1_async, FetchSemaphore};
use chrono::{DateTime, Utc};
use dashmap::DashSet;
@@ -194,7 +195,7 @@ pub struct SearchEntry {
pub struct CachedFileUpdate {
pub hash: String,
pub game_version: String,
pub loader: String,
pub loaders: Vec<String>,
pub update_version_id: String,
}
@@ -203,6 +204,7 @@ pub struct CachedFileHash {
pub path: String,
pub size: u64,
pub hash: String,
pub project_type: Option<ProjectType>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -481,7 +483,12 @@ impl CacheValue {
)
}
CacheValue::FileUpdate(hash) => {
format!("{}-{}-{}", hash.hash, hash.loader, hash.game_version)
format!(
"{}-{}-{}",
hash.hash,
hash.loaders.join("+"),
hash.game_version
)
}
CacheValue::SearchResults(search) => search.search.clone(),
}
@@ -1240,6 +1247,9 @@ impl CachedEntry {
path: path.to_string(),
size,
hash,
project_type: ProjectType::get_from_parent_folder(
&full_path,
),
})
.get_entry(),
true,
@@ -1270,18 +1280,21 @@ impl CachedEntry {
if key.len() == 3 {
let hash = key[0];
let loader = key[1];
let loaders_key = key[1];
let game_version = key[2];
if let Some(values) =
filtered_keys.iter_mut().find(|x| {
x.0 .0 == loader && x.0 .1 == game_version
x.0 .0 == loaders_key && x.0 .1 == game_version
})
{
values.1.push(hash.to_string());
} else {
filtered_keys.push((
(loader.to_string(), game_version.to_string()),
(
loaders_key.to_string(),
game_version.to_string(),
),
vec![hash.to_string()],
))
}
@@ -1297,7 +1310,7 @@ impl CachedEntry {
format!("{}version_files/update", MODRINTH_API_URL);
let variations =
futures::future::try_join_all(filtered_keys.iter().map(
|((loader, game_version), hashes)| {
|((loaders_key, game_version), hashes)| {
fetch_json::<HashMap<String, Version>>(
Method::POST,
&version_update_url,
@@ -1305,7 +1318,7 @@ impl CachedEntry {
Some(serde_json::json!({
"algorithm": "sha1",
"hashes": hashes,
"loaders": [loader],
"loaders": loaders_key.split('+').collect::<Vec<_>>(),
"game_versions": [game_version]
})),
fetch_semaphore,
@@ -1317,7 +1330,7 @@ impl CachedEntry {
for (index, mut variation) in variations.into_iter().enumerate()
{
let ((loader, game_version), hashes) =
let ((loaders_key, game_version), hashes) =
&filtered_keys[index];
for hash in hashes {
@@ -1334,7 +1347,10 @@ impl CachedEntry {
CacheValue::FileUpdate(CachedFileUpdate {
hash: hash.clone(),
game_version: game_version.clone(),
loader: loader.clone(),
loaders: loaders_key
.split('+')
.map(|x| x.to_string())
.collect(),
update_version_id: version_id,
})
.get_entry(),
@@ -1343,7 +1359,9 @@ impl CachedEntry {
} else {
vals.push((
CacheValueType::FileUpdate.get_empty_entry(
format!("{hash}-{loader}-{game_version}"),
format!(
"{hash}-{loaders_key}-{game_version}"
),
),
true,
))
@@ -1450,6 +1468,7 @@ pub async fn cache_file_hash(
profile_path: &str,
path: &str,
known_hash: Option<&str>,
project_type: Option<ProjectType>,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let size = bytes.len();
@@ -1465,6 +1484,7 @@ pub async fn cache_file_hash(
path: format!("{}/{}", profile_path, path),
size: size as u64,
hash,
project_type,
})
.get_entry()],
exec,

View File

@@ -1,4 +1,4 @@
use crate::data::{Dependency, User, Version};
use crate::data::{Dependency, ProjectType, User, Version};
use crate::jre::check_jre;
use crate::prelude::ModLoader;
use crate::state;
@@ -226,6 +226,7 @@ where
path: file_name,
size: metadata.len(),
hash: sha1.clone(),
project_type: ProjectType::get_from_parent_folder(&full_path),
},
));
}
@@ -249,9 +250,9 @@ where
.metadata
.game_version
.clone(),
loader: mod_loader
loaders: vec![mod_loader
.as_str()
.to_string(),
.to_string()],
update_version_id:
update_version.id.clone(),
},
@@ -307,7 +308,7 @@ where
ProfileInstallStage::Installed
}
LegacyProfileInstallStage::Installing => {
ProfileInstallStage::Installing
ProfileInstallStage::MinecraftInstalling
}
LegacyProfileInstallStage::PackInstalling => {
ProfileInstallStage::PackInstalling

View File

@@ -178,19 +178,16 @@ pub async fn login_finish(
minecraft_entitlements(&minecraft_token.access_token).await?;
let profile = minecraft_profile(&minecraft_token.access_token).await?;
let profile_id = profile.id.unwrap_or_default();
let credentials = Credentials {
id: profile_id,
username: profile.name,
let mut credentials = Credentials {
id: Uuid::default(),
username: String::default(),
access_token: minecraft_token.access_token,
refresh_token: oauth_token.value.refresh_token,
expires: oauth_token.date
+ Duration::seconds(oauth_token.value.expires_in as i64),
active: true,
};
credentials.get_profile().await?;
credentials.upsert(exec).await?;
@@ -268,11 +265,22 @@ impl Credentials {
self.expires = oauth_token.date
+ Duration::seconds(oauth_token.value.expires_in as i64);
self.get_profile().await?;
self.upsert(exec).await?;
Ok(())
}
async fn get_profile(&mut self) -> crate::Result<()> {
let profile = minecraft_profile(&self.access_token).await?;
self.id = profile.id.unwrap_or_default();
self.username = profile.name;
Ok(())
}
#[tracing::instrument]
pub async fn get_default_credential(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,

View File

@@ -1,5 +1,7 @@
use super::settings::{Hooks, MemorySettings, WindowSize};
use crate::state::{cache_file_hash, CacheBehaviour, CachedEntry};
use crate::state::{
cache_file_hash, CacheBehaviour, CachedEntry, CachedFileHash,
};
use crate::util;
use crate::util::fetch::{write_cached_icon, FetchSemaphore, IoSemaphore};
use crate::util::io::{self};
@@ -9,7 +11,7 @@ use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::convert::TryFrom;
use std::convert::TryInto;
use std::path::{Path, PathBuf};
use std::path::Path;
// Represent a Minecraft instance.
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -51,7 +53,9 @@ pub enum ProfileInstallStage {
/// Profile is installed
Installed,
/// Profile's minecraft game is still installing
Installing,
MinecraftInstalling,
/// Pack is installed, but Minecraft installation has not begun
PackInstalled,
/// Profile created for pack, but the pack hasn't been fully installed yet
PackInstalling,
/// Profile is not installed
@@ -62,7 +66,8 @@ impl ProfileInstallStage {
pub fn as_str(&self) -> &'static str {
match *self {
Self::Installed => "installed",
Self::Installing => "installing",
Self::MinecraftInstalling => "minecraft_installing",
Self::PackInstalled => "pack_installed",
Self::PackInstalling => "pack_installing",
Self::NotInstalled => "not_installed",
}
@@ -71,7 +76,9 @@ impl ProfileInstallStage {
pub fn from_str(val: &str) -> Self {
match val {
"installed" => Self::Installed,
"installing" => Self::Installing,
"minecraft_installing" => Self::MinecraftInstalling,
"installing" => Self::MinecraftInstalling, // Backwards compatibility
"pack_installed" => Self::PackInstalled,
"pack_installing" => Self::PackInstalling,
"not_installed" => Self::NotInstalled,
_ => Self::NotInstalled,
@@ -146,7 +153,7 @@ pub struct FileMetadata {
pub version_id: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, Copy)]
#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ProjectType {
Mod,
@@ -176,7 +183,7 @@ impl ProjectType {
}
}
pub fn get_from_parent_folder(path: PathBuf) -> Option<Self> {
pub fn get_from_parent_folder(path: &Path) -> Option<Self> {
// Get parent folder
let path = path.parent()?.file_name()?;
match path.to_str()? {
@@ -206,6 +213,15 @@ impl ProjectType {
}
}
pub fn get_loaders(&self) -> &'static [&'static str] {
match self {
ProjectType::Mod => &["fabric", "forge", "quilt", "neoforge"],
ProjectType::DataPack => &["datapack"],
ProjectType::ResourcePack => &["vanilla", "canvas", "minecraft"],
ProjectType::ShaderPack => &["iris", "optifine"],
}
}
pub fn iterator() -> impl Iterator<Item = ProjectType> {
[
ProjectType::Mod,
@@ -538,11 +554,11 @@ impl Profile {
pub(crate) async fn refresh_all() -> crate::Result<()> {
let state = crate::State::get().await?;
let all = Self::get_all(&state.pool).await?;
let mut all = Self::get_all(&state.pool).await?;
let mut keys = vec![];
for profile in &all {
for profile in &mut all {
let path =
crate::api::profile::get_full_path(&profile.path).await?;
@@ -575,6 +591,17 @@ impl Profile {
}
}
}
if profile.install_stage == ProfileInstallStage::MinecraftInstalling
{
profile.install_stage = ProfileInstallStage::PackInstalled;
profile.upsert(&state.pool).await?;
} else if profile.install_stage
== ProfileInstallStage::PackInstalling
{
profile.install_stage = ProfileInstallStage::NotInstalled;
profile.upsert(&state.pool).await?;
}
}
let file_hashes = CachedEntry::get_file_hash_many(
@@ -587,17 +614,10 @@ impl Profile {
let file_updates = file_hashes
.iter()
.filter_map(|x| {
all.iter().find(|prof| x.path.contains(&prof.path)).map(
|profile| {
format!(
"{}-{}-{}",
x.hash,
profile.loader.as_str(),
profile.game_version
)
},
)
.filter_map(|file| {
all.iter()
.find(|prof| file.path.contains(&prof.path))
.map(|profile| Self::get_cache_key(file, profile))
})
.collect::<Vec<_>>();
@@ -690,14 +710,7 @@ impl Profile {
let file_updates = file_hashes
.iter()
.map(|x| {
format!(
"{}-{}-{}",
x.hash,
self.loader.as_str(),
self.game_version
)
})
.map(|x| Self::get_cache_key(x, self))
.collect::<Vec<_>>();
let file_hashes_ref =
@@ -773,6 +786,18 @@ impl Profile {
Ok(files)
}
fn get_cache_key(file: &CachedFileHash, profile: &Profile) -> String {
format!(
"{}-{}-{}",
file.hash,
file.project_type
.filter(|x| *x != ProjectType::Mod)
.map(|x| x.get_loaders().join("+"))
.unwrap_or_else(|| profile.loader.as_str().to_string()),
profile.game_version
)
}
#[tracing::instrument(skip(pool))]
pub async fn add_project_version(
profile_path: &str,
@@ -873,8 +898,15 @@ impl Profile {
let project_path =
format!("{}/{}", project_type.get_folder(), file_name);
cache_file_hash(bytes.clone(), profile_path, &project_path, hash, exec)
.await?;
cache_file_hash(
bytes.clone(),
profile_path,
&project_path,
hash,
Some(project_type),
exec,
)
.await?;
util::fetch::write(&path.join(&project_path), &bytes, io_semaphore)
.await?;

View File

@@ -208,23 +208,6 @@ html {
}
.experimental-styles-within {
// Reset deprecated properties
--color-icon: initial !important;
--color-text: initial !important;
--color-text-inactive: initial !important;
--color-text-dark: initial !important;
--color-heading: initial !important;
--color-text-inverted: initial !important;
--color-bg-inverted: initial !important;
--color-brand: var(--color-green) !important;
--color-brand-inverted: initial !important;
--tab-underline-hovered: initial !important;
--color-grey-link: inherit !important;
--color-grey-link-hover: inherit !important; // DEPRECATED, use filters in future
--color-grey-link-active: inherit !important; // DEPRECATED, use filters in future
--color-link: var(--color-blue) !important;
--color-link-hover: var(--color-blue) !important; // DEPRECATED, use filters in future
--color-link-active: var(--color-blue) !important; // DEPRECATED, use filters in future

View File

@@ -0,0 +1,58 @@
<template>
<div
:class="[
'flex rounded-2xl border-2 border-solid p-4 gap-4 font-semibold text-contrast',
typeClasses[type],
]"
>
<component
:is="icons[type]"
:class="['hidden h-8 w-8 flex-none sm:block', iconClasses[type]]"
/>
<div class="flex flex-col gap-2">
<div class="font-semibold">
<slot name="header">{{ header }}</slot>
</div>
<div class="font-normal">
<slot>{{ body }}</slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { InfoIcon, IssuesIcon, XCircleIcon } from '@modrinth/assets'
defineProps({
type: {
type: String as () => 'info' | 'warning' | 'critical',
default: 'info',
},
header: {
type: String,
default: '',
},
body: {
type: String,
default: '',
},
})
const typeClasses = {
info: 'border-blue bg-bg-blue',
warning: 'border-orange bg-bg-orange',
critical: 'border-brand-red bg-bg-red',
}
const iconClasses = {
info: 'text-blue',
warning: 'text-orange',
critical: 'text-brand-red',
}
const icons = {
info: InfoIcon,
warning: IssuesIcon,
critical: XCircleIcon,
}
</script>

View File

@@ -1,12 +1,14 @@
<template>
<ButtonStyled>
<PopoutMenu
v-if="options.length > 1"
v-if="options.length > 1 || showAlways"
v-bind="$attrs"
:disabled="disabled"
:position="position"
:direction="direction"
:dropdown-id="dropdownId"
:dropdown-class="dropdownClass"
:tooltip="tooltip"
@open="
() => {
searchQuery = ''
@@ -87,6 +89,9 @@ const props = withDefaults(
displayName?: (option: Option) => string
search?: boolean
dropdownId?: string
dropdownClass?: string
showAlways?: boolean
tooltip?: string
}>(),
{
disabled: false,
@@ -94,7 +99,10 @@ const props = withDefaults(
direction: 'auto',
displayName: (option: Option) => option as string,
search: false,
dropdownId: null,
dropdownId: '',
dropdownClass: '',
showAlways: false,
tooltip: '',
},
)

View File

@@ -300,8 +300,8 @@ import Chips from './Chips.vue'
const props = withDefaults(
defineProps<{
modelValue: string
disabled: boolean
headingButtons: boolean
disabled?: boolean
headingButtons?: boolean
/**
* @param file The file to upload
* @throws If the file is invalid or the upload fails
@@ -948,4 +948,8 @@ function openVideoModal() {
pointer-events: none;
cursor: not-allowed;
}
:deep(.cm-content) {
overflow: auto;
}
</style>

View File

@@ -10,7 +10,7 @@
<template #menu>
<template v-for="(option, index) in options.filter((x) => x.shown === undefined || x.shown)">
<div
v-if="option.divider"
v-if="isDivider(option)"
:key="`divider-${index}`"
class="h-px mx-3 my-2 bg-button-bg"
></div>
@@ -25,15 +25,15 @@
:v-close-popper="!option.remainOnClick"
:action="
option.action
? (event) => {
option.action(event)
? (event: MouseEvent) => {
option.action?.(event)
if (!option.remainOnClick) {
close()
}
}
: null
: undefined
"
:link="option.link ? option.link : null"
:link="option.link ? option.link : undefined"
:external="option.external ? option.external : false"
:disabled="option.disabled"
@click="
@@ -67,7 +67,7 @@ interface Divider extends BaseOption {
interface Item extends BaseOption {
id: string
action?: () => void
action?: (event?: MouseEvent) => void
link?: string
external?: boolean
color?:
@@ -99,8 +99,8 @@ withDefaults(
{
options: () => [],
disabled: false,
dropdownId: null,
tooltip: null,
dropdownId: undefined,
tooltip: undefined,
},
)
@@ -118,6 +118,10 @@ const open = () => {
dropdown.value?.show()
}
function isDivider(option: BaseOption): option is Divider {
return 'divider' in option
}
defineExpose({ open, close })
</script>

View File

@@ -4,6 +4,7 @@
no-auto-focus
:aria-id="dropdownId || null"
placement="bottom-end"
:class="dropdownClass"
@apply-hide="focusTrigger"
@apply-show="focusMenuChild"
>
@@ -34,6 +35,11 @@ defineProps({
default: null,
required: false,
},
dropdownClass: {
type: String,
default: null,
required: false,
},
tooltip: {
type: String,
default: null,

View File

@@ -0,0 +1,49 @@
<template>
<div :style="colorClasses" class="radial-header relative pb-1" v-bind="$attrs">
<slot />
</div>
<div class="radial-header-divider" />
</template>
<script setup lang="ts">
import { computed } from 'vue'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<{
color?: 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'gray'
}>(),
{
color: 'brand',
},
)
const colorClasses = computed(
() =>
`--_radial-bg: var(--color-${props.color}-highlight);--_radial-border: var(--color-${props.color});`,
)
</script>
<style scoped lang="scss">
.radial-header {
background-image: radial-gradient(50% 100% at 50% 100%, var(--_radial-bg) 10%, #ffffff00 100%);
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
bottom: 0;
background-image: linear-gradient(
90deg,
#ffffff00 0%,
var(--_radial-border) 50%,
#ffffff00 100%
);
width: 100%;
height: 1px;
opacity: 0.8;
}
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<div class="flex flex-wrap gap-2">
<button
v-for="(item, index) in items"
:key="`radio-button-${index}`"
class="p-0 py-2 px-2 border-0 flex gap-2 transition-all items-center cursor-pointer active:scale-95 hover:bg-button-bg rounded-xl"
:class="{
'text-contrast font-medium bg-button-bg': selected === item,
'text-primary bg-transparent': selected !== item,
}"
@click="selected = item"
>
<RadioButtonChecked v-if="selected === item" class="text-brand h-5 w-5" />
<RadioButtonIcon v-else class="h-5 w-5" />
<slot :item="item" />
</button>
</div>
</template>
<script setup lang="ts" generic="T">
import { RadioButtonIcon, RadioButtonChecked } from '@modrinth/assets'
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
modelValue: T
items: T[]
forceSelection?: boolean
}>(),
{
forceSelection: false,
},
)
const emit = defineEmits(['update:modelValue'])
const selected = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
},
})
if (props.items.length > 0 && props.forceSelection && !props.modelValue) {
selected.value = props.items[0]
}
</script>

View File

@@ -41,9 +41,12 @@
<input
ref="value"
:value="currentValue"
type="text"
type="number"
class="slider-input"
:disabled="disabled"
:min="min"
:max="max"
:step="step"
@change="onInput(($event.target as HTMLInputElement).value)"
/>
</div>

View File

@@ -137,32 +137,19 @@
<input v-model="customServerConfig.storageGbFormatted" disabled class="input" />
</div>
</div>
<div
v-else
class="flex justify-between rounded-2xl border-2 border-solid border-blue bg-bg-blue p-4 font-semibold text-contrast"
<Admonition
v-else-if="customOutOfStock && customMatchingProduct"
type="info"
header="This plan is currently out of stock"
>
<div class="flex w-full justify-between gap-2">
<div class="flex flex-row gap-4">
<InfoIcon class="hidden flex-none h-8 w-8 text-blue sm:block" />
<div v-if="customOutOfStock && customMatchingProduct" class="flex flex-col gap-2">
<div class="font-semibold">This plan is currently out of stock</div>
<div class="font-normal">
We are currently
<a :href="outOfStockUrl" class="underline" target="_blank">out of capacity</a>
for your selected RAM amount. Please try again later, or try a different amount.
</div>
</div>
<div v-else class="flex flex-col gap-2">
<div class="font-semibold">We can't seem to find your selected plan</div>
<div class="font-normal">
We are currently unable to find a server for your selected RAM amount. Please
try again later, or try a different amount.
</div>
</div>
</div>
</div>
</div>
We are currently
<a :href="outOfStockUrl" class="underline" target="_blank">out of capacity</a>
for your selected RAM amount. Please try again later, or try a different amount.
</Admonition>
<Admonition v-else type="info" header="We can't seem to find your selected plan">
We are currently unable to find a server for your selected RAM amount. Please try again
later, or try a different amount.
</Admonition>
<div class="flex items-center gap-2">
<InfoIcon class="hidden sm:block" />
@@ -500,6 +487,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 Admonition from '../base/Admonition.vue'
const { locale, formatMessage } = useVIntl()

View File

@@ -1,5 +1,6 @@
// Base content
export { default as Accordion } from './base/Accordion.vue'
export { default as Admonition } from './base/Admonition.vue'
export { default as AutoLink } from './base/AutoLink.vue'
export { default as Avatar } from './base/Avatar.vue'
export { default as Badge } from './base/Badge.vue'
@@ -25,6 +26,8 @@ export { default as Pagination } from './base/Pagination.vue'
export { default as PopoutMenu } from './base/PopoutMenu.vue'
export { default as PreviewSelectButton } from './base/PreviewSelectButton.vue'
export { default as ProjectCard } from './base/ProjectCard.vue'
export { default as RadialHeader } from './base/RadialHeader.vue'
export { default as RadioButtons } from './base/RadioButtons.vue'
export { default as ScrollablePanel } from './base/ScrollablePanel.vue'
export { default as SimpleBadge } from './base/SimpleBadge.vue'
export { default as Slider } from './base/Slider.vue'

View File

@@ -70,7 +70,7 @@ const props = withDefaults(
closeOnClickOutside: true,
closeOnEsc: true,
warnOnClose: false,
header: null,
header: undefined,
onHide: () => {},
onShow: () => {},
},

View File

@@ -37,7 +37,11 @@
<div v-if="project.categories.length > 0" class="hidden items-center gap-2 md:flex">
<TagsIcon class="h-6 w-6 text-secondary" />
<div class="flex flex-wrap gap-2">
<TagItem v-for="(category, index) in project.categories" :key="index">
<TagItem
v-for="(category, index) in project.categories"
:key="index"
:action="() => router.push(`/${project.project_type}s?f=categories:${category}`)"
>
{{ formatCategory(category) }}
</TagItem>
</div>
@@ -53,9 +57,12 @@ import { DownloadIcon, HeartIcon, TagsIcon } from '@modrinth/assets'
import Avatar from '../base/Avatar.vue'
import ContentPageHeader from '../base/ContentPageHeader.vue'
import { formatCategory, formatNumber, type Project } from '@modrinth/utils'
import { useRouter } from 'vue-router'
import TagItem from '../base/TagItem.vue'
import ProjectStatusBadge from './ProjectStatusBadge.vue'
const router = useRouter()
withDefaults(
defineProps<{
project: Project

View File

@@ -18,6 +18,7 @@
<TagItem
v-for="platform in project.loaders"
:key="`platform-tag-${platform}`"
:action="() => router.push(`/${project.project_type}s?g=categories:${platform}`)"
:style="`--_color: var(--color-platform-${platform})`"
>
<svg v-html="tags.loaders.find((x) => x.name === platform).icon"></svg>
@@ -78,9 +79,11 @@ import { ClientIcon, MonitorSmartphoneIcon, ServerIcon, UserIcon } from '@modrin
import { formatCategory, getVersionsToDisplay } from '@modrinth/utils'
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
import { useVIntl, defineMessages } from '@vintl/vintl'
import { useRouter } from 'vue-router'
import TagItem from '../base/TagItem.vue'
const { formatMessage } = useVIntl()
const router = useRouter()
type EnvironmentValue = 'optional' | 'required' | 'unsupported' | 'unknown'

View File

@@ -1,7 +1,7 @@
export const BASE62_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
export type Base62Char = (typeof BASE62_CHARS)[number]
export type ModrinthId = `${Base62Char}`[]
export type ModrinthId = string
export type Environment = 'required' | 'optional' | 'unsupported' | 'unknown'
@@ -241,3 +241,15 @@ export interface TeamMember {
payouts_split: number
ordering: number
}
export type Report = {
id: ModrinthId
item_id: ModrinthId
item_type: 'project' | 'version' | 'user'
report_type: string
reporter: ModrinthId
thread_id: ModrinthId
closed: boolean
created: string
body: string
}

View File

@@ -8,4 +8,8 @@ export const isStaff = (user) => {
return user && STAFF_ROLES.includes(user.role)
}
export const isAdmin = (user) => {
return user && user.role === 'admin'
}
export const STAFF_ROLES = ['moderator', 'admin']