App redesign (#2946)
* Start of app redesign * format * continue progress * Content page nearly done * Fix recursion issues with content page * Fix update all alignment * Discover page progress * Settings progress * Removed unlocked-size hack that breaks web * Revamp project page, refactor web project page to share code with app, fixed loading bar, misc UI/UX enhancements, update ko-fi logo, update arrow icons, fix web issues caused by floating-vue migration, fix tooltip issues, update web tooltips, clean up web hydration issues * Ads + run prettier * Begin auth refactor, move common messages to ui lib, add i18n extraction to all apps, begin Library refactor * fix ads not hiding when plus log in * rev lockfile changes/conflicts * Fix sign in page * Add generated * (mostly) Data driven search * Fix search mobile issue * profile fixes * Project versions page, fix typescript on UI lib and misc fixes * Remove unused gallery component * Fix linkfunction err * Search filter controls at top, localization for locked filters * Fix provided filter names * Fix navigating from instance browse to main browse * Friends frontend (#2995) * Friends system frontend * (almost) finish frontend * finish friends, fix lint * Fix lint --------- Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com> * Refresh macOS app icon * Update web search UI more * Fix link opens * Fix frontend build --------- Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com> Co-authored-by: Jai A <jaiagr+gpg@pm.me> Co-authored-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
@@ -22,11 +22,7 @@
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
"nullable": [false, false, false]
|
||||
},
|
||||
"hash": "1397c1825096fb402cdd3b5dae8cd3910b1719f433a0c34d40415dd7681ab272"
|
||||
}
|
||||
|
||||
@@ -27,12 +27,7 @@
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
"nullable": [false, false, false, false]
|
||||
},
|
||||
"hash": "18881c0c2ec1b0cc73fa13b4c242dfc577061b92479ce96ffb30a457939b5ffe"
|
||||
}
|
||||
|
||||
@@ -27,12 +27,7 @@
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
"nullable": [false, false, false, false]
|
||||
},
|
||||
"hash": "265f9c9ad992da0aeaf69c3f0077b54a186b98796ec549c9d891089ea33cf3fc"
|
||||
}
|
||||
|
||||
@@ -32,13 +32,7 @@
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
true,
|
||||
false
|
||||
]
|
||||
"nullable": [false, false, null, true, false]
|
||||
},
|
||||
"hash": "28b3e3132d75e551c1fa14b8d3be36adca581f8ad1b90f85d3ec3d92ec61e65e"
|
||||
}
|
||||
|
||||
@@ -27,12 +27,7 @@
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
"nullable": [false, false, false, false]
|
||||
},
|
||||
"hash": "6d7ebc0f233dc730fa8c99c750421065f5e35f321954a9d5ae9cde907d5ce823"
|
||||
}
|
||||
|
||||
@@ -47,16 +47,7 @@
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
"nullable": [false, false, false, false, false, false, false, null]
|
||||
},
|
||||
"hash": "6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf"
|
||||
}
|
||||
|
||||
@@ -37,14 +37,7 @@
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
"nullable": [false, false, false, false, false, false]
|
||||
},
|
||||
"hash": "727e3e1bc8625bbcb833920059bb8cea926ac6c65d613904eff1d740df30acda"
|
||||
}
|
||||
|
||||
@@ -37,14 +37,7 @@
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
"nullable": [false, false, false, false, false, false]
|
||||
},
|
||||
"hash": "bf7d47350092d87c478009adaab131168e87bb37aa65c2156ad2cb6198426d8c"
|
||||
}
|
||||
|
||||
@@ -6,4 +6,4 @@
|
||||
"fix": "cargo fmt && cargo clippy --fix",
|
||||
"test": "cargo test"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
35
packages/app-lib/src/api/friends.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use crate::state::{FriendsSocket, UserFriend, UserStatus};
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn friends() -> crate::Result<Vec<UserFriend>> {
|
||||
let state = crate::State::get().await?;
|
||||
let friends =
|
||||
FriendsSocket::friends(&state.pool, &state.api_semaphore).await?;
|
||||
|
||||
Ok(friends)
|
||||
}
|
||||
|
||||
pub async fn friend_statuses() -> crate::Result<Vec<UserStatus>> {
|
||||
let state = crate::State::get().await?;
|
||||
let statuses = state.friends_socket.friend_statuses();
|
||||
|
||||
Ok(statuses)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn add_friend(user_id: &str) -> crate::Result<()> {
|
||||
let state = crate::State::get().await?;
|
||||
FriendsSocket::add_friend(user_id, &state.pool, &state.api_semaphore)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn remove_friend(user_id: &str) -> crate::Result<()> {
|
||||
let state = crate::State::get().await?;
|
||||
FriendsSocket::remove_friend(user_id, &state.pool, &state.api_semaphore)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! API for interacting with Theseus
|
||||
pub mod cache;
|
||||
pub mod friends;
|
||||
pub mod handler;
|
||||
pub mod jre;
|
||||
pub mod logs;
|
||||
@@ -18,7 +19,7 @@ pub mod data {
|
||||
Hooks, JavaVersion, LinkedData, MemorySettings, ModLoader,
|
||||
ModrinthCredentials, Organization, ProcessMetadata, ProfileFile,
|
||||
Project, ProjectType, SearchResult, SearchResults, Settings,
|
||||
TeamMember, Theme, User, Version, WindowSize,
|
||||
TeamMember, Theme, User, UserFriend, UserStatus, Version, WindowSize,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::state::ModrinthCredentials;
|
||||
|
||||
#[tracing::instrument]
|
||||
pub fn authenticate_begin_flow() -> &'static str {
|
||||
pub fn authenticate_begin_flow() -> String {
|
||||
crate::state::get_login_url()
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ pub async fn authenticate_finish_flow(
|
||||
.await?;
|
||||
|
||||
creds.upsert(&state.pool).await?;
|
||||
state
|
||||
.friends_socket
|
||||
.connect(&state.pool, &state.api_semaphore, &state.process_manager)
|
||||
.await?;
|
||||
|
||||
Ok(creds)
|
||||
}
|
||||
@@ -30,6 +34,7 @@ pub async fn logout() -> crate::Result<()> {
|
||||
|
||||
if let Some(current) = current {
|
||||
ModrinthCredentials::remove(¤t.user_id, &state.pool).await?;
|
||||
state.friends_socket.disconnect().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
//! Configuration structs
|
||||
|
||||
// pub const MODRINTH_URL: &str = "https://staging.modrinth.com/";
|
||||
// pub const MODRINTH_API_URL: &str = "https://staging-api.modrinth.com/v2/";
|
||||
// pub const MODRINTH_API_URL_V3: &str = "https://staging-api.modrinth.com/v3/";
|
||||
|
||||
pub const MODRINTH_URL: &str = "https://modrinth.com/";
|
||||
pub const MODRINTH_API_URL: &str = "https://api.modrinth.com/v2/";
|
||||
pub const MODRINTH_API_URL_V3: &str = "https://api.modrinth.com/v3/";
|
||||
|
||||
pub const MODRINTH_SOCKET_URL: &str = "wss://api.modrinth.com/";
|
||||
|
||||
pub const META_URL: &str = "https://launcher-meta.modrinth.com/";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::LoadingBarId;
|
||||
use super::{FriendPayload, LoadingBarId};
|
||||
use crate::event::{
|
||||
CommandPayload, EventError, LoadingBar, LoadingBarType, ProcessPayloadType,
|
||||
ProfilePayloadType,
|
||||
@@ -296,6 +296,20 @@ pub async fn emit_profile(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_friend(payload: FriendPayload) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get()?;
|
||||
event_state
|
||||
.app
|
||||
.emit("friend", payload)
|
||||
.map_err(EventError::from)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// loading_join! macro
|
||||
// loading_join!(key: Option<&LoadingBarId>, total: f64, message: Option<&str>; task1, task2, task3...)
|
||||
// This will submit a loading event with the given message for each task as they complete
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Theseus state management system
|
||||
use crate::state::UserStatus;
|
||||
use dashmap::DashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
@@ -256,3 +257,13 @@ pub enum EventError {
|
||||
#[error("Tauri error: {0}")]
|
||||
TauriError(#[from] tauri::Error),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[serde(tag = "event")]
|
||||
pub enum FriendPayload {
|
||||
FriendRequest { from: String },
|
||||
UserOffline { id: String },
|
||||
StatusUpdate { user_status: UserStatus },
|
||||
StatusSync,
|
||||
}
|
||||
|
||||
@@ -677,6 +677,11 @@ pub async fn launch_minecraft(
|
||||
.set_activity(&format!("Playing {}", profile.name), true)
|
||||
.await;
|
||||
|
||||
let _ = state
|
||||
.friends_socket
|
||||
.update_status(Some(profile.name.clone()))
|
||||
.await;
|
||||
|
||||
// Create Minecraft child by inserting it into the state
|
||||
// This also spawns the process and prepares the subsequent processes
|
||||
state
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use crate::state::DirectoryInfo;
|
||||
use sqlx::migrate::MigrateDatabase;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use sqlx::sqlite::{
|
||||
SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions,
|
||||
};
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) async fn connect() -> crate::Result<Pool<Sqlite>> {
|
||||
let settings_dir = DirectoryInfo::get_initial_settings_dir().ok_or(
|
||||
@@ -20,9 +24,14 @@ pub(crate) async fn connect() -> crate::Result<Pool<Sqlite>> {
|
||||
Sqlite::create_database(&uri).await?;
|
||||
}
|
||||
|
||||
let conn_options = SqliteConnectOptions::from_str(&uri)?
|
||||
.busy_timeout(Duration::from_secs(30))
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.optimize_on_close(true, None);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(100)
|
||||
.connect(&uri)
|
||||
.connect_with(conn_options)
|
||||
.await?;
|
||||
|
||||
sqlx::migrate!().run(&pool).await?;
|
||||
|
||||
316
packages/app-lib/src/state/friends.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
use crate::config::{MODRINTH_API_URL_V3, MODRINTH_SOCKET_URL};
|
||||
use crate::data::ModrinthCredentials;
|
||||
use crate::event::emit::emit_friend;
|
||||
use crate::event::FriendPayload;
|
||||
use crate::state::{ProcessManager, Profile};
|
||||
use crate::util::fetch::{fetch_advanced, fetch_json, FetchSemaphore};
|
||||
use async_tungstenite::tokio::{connect_async, ConnectStream};
|
||||
use async_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use async_tungstenite::tungstenite::Message;
|
||||
use async_tungstenite::WebSocketStream;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use futures::stream::SplitSink;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use reqwest::header::HeaderValue;
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
type WriteSocket =
|
||||
Arc<Mutex<Option<SplitSink<WebSocketStream<ConnectStream>, Message>>>>;
|
||||
|
||||
pub struct FriendsSocket {
|
||||
write: WriteSocket,
|
||||
user_statuses: Arc<DashMap<String, UserStatus>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct UserFriend {
|
||||
pub id: String,
|
||||
// TODO: Remove this optional and serde alias on release
|
||||
pub friend_id: Option<String>,
|
||||
#[serde(alias = "pending")]
|
||||
pub accepted: bool,
|
||||
pub created: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ClientToServerMessage {
|
||||
StatusUpdate { profile_name: Option<String> },
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServerToClientMessage {
|
||||
StatusUpdate { status: UserStatus },
|
||||
UserOffline { id: String },
|
||||
FriendStatuses { statuses: Vec<UserStatus> },
|
||||
FriendRequest { from: String },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct UserStatus {
|
||||
pub user_id: String,
|
||||
pub profile_name: Option<String>,
|
||||
pub last_update: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Default for FriendsSocket {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl FriendsSocket {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
write: Arc::new(Mutex::new(None)),
|
||||
user_statuses: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect(
|
||||
&self,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
semaphore: &FetchSemaphore,
|
||||
process_manager: &ProcessManager,
|
||||
) -> crate::Result<()> {
|
||||
let credentials =
|
||||
ModrinthCredentials::get_and_refresh(exec, semaphore).await?;
|
||||
|
||||
if let Some(credentials) = credentials {
|
||||
let mut request = format!(
|
||||
"{MODRINTH_SOCKET_URL}_internal/launcher_heartbeat?code={}",
|
||||
credentials.session
|
||||
)
|
||||
.into_client_request()?;
|
||||
|
||||
let user_agent = format!(
|
||||
"modrinth/theseus/{} (support@modrinth.com)",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
request.headers_mut().insert(
|
||||
"User-Agent",
|
||||
HeaderValue::from_str(&user_agent).unwrap(),
|
||||
);
|
||||
|
||||
let res = connect_async(request).await;
|
||||
|
||||
match res {
|
||||
Ok((socket, _)) => {
|
||||
tracing::info!("Connected to friends socket");
|
||||
let (write, read) = socket.split();
|
||||
|
||||
{
|
||||
let mut write_lock = self.write.lock().await;
|
||||
*write_lock = Some(write);
|
||||
}
|
||||
|
||||
if let Some(process) = process_manager.get_all().first() {
|
||||
let profile =
|
||||
Profile::get(&process.profile_path, exec).await?;
|
||||
|
||||
if let Some(profile) = profile {
|
||||
let _ =
|
||||
self.update_status(Some(profile.name)).await;
|
||||
}
|
||||
}
|
||||
|
||||
let write_handle = self.write.clone();
|
||||
let statuses = self.user_statuses.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut read_stream = read;
|
||||
while let Some(msg_result) = read_stream.next().await {
|
||||
match msg_result {
|
||||
Ok(msg) => {
|
||||
let server_message = match msg {
|
||||
Message::Text(text) => {
|
||||
serde_json::from_str::<
|
||||
ServerToClientMessage,
|
||||
>(
|
||||
&text
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
Message::Binary(bytes) => {
|
||||
serde_json::from_slice::<
|
||||
ServerToClientMessage,
|
||||
>(
|
||||
&bytes
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
Message::Ping(_)
|
||||
| Message::Pong(_)
|
||||
| Message::Frame(_) => continue,
|
||||
Message::Close(_) => break,
|
||||
};
|
||||
|
||||
if let Some(server_message) = server_message
|
||||
{
|
||||
match server_message {
|
||||
ServerToClientMessage::StatusUpdate { status } => {
|
||||
statuses.insert(status.user_id.clone(), status.clone());
|
||||
let _ = emit_friend(FriendPayload::StatusUpdate { user_status: status }).await;
|
||||
},
|
||||
ServerToClientMessage::UserOffline { id } => {
|
||||
statuses.remove(&id);
|
||||
let _ = emit_friend(FriendPayload::UserOffline { id }).await;
|
||||
}
|
||||
ServerToClientMessage::FriendStatuses { statuses: new_statuses } => {
|
||||
statuses.clear();
|
||||
new_statuses.into_iter().for_each(|status| {
|
||||
statuses.insert(status.user_id.clone(), status);
|
||||
});
|
||||
let _ = emit_friend(FriendPayload::StatusSync).await;
|
||||
}
|
||||
ServerToClientMessage::FriendRequest { from } => {
|
||||
let _ = emit_friend(FriendPayload::FriendRequest { from }).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("WebSocket error: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut w = write_handle.lock().await;
|
||||
*w = None;
|
||||
|
||||
Self::reconnect_task();
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Error connecting to friends socket: {e:?}"
|
||||
);
|
||||
|
||||
Self::reconnect_task();
|
||||
|
||||
return Err(crate::Error::from(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reconnect_task() {
|
||||
tokio::task::spawn(async move {
|
||||
let res = async {
|
||||
let state = crate::State::get().await?;
|
||||
state
|
||||
.friends_socket
|
||||
.connect(
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
&state.process_manager,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok::<(), crate::Error>(())
|
||||
};
|
||||
|
||||
if let Err(e) = res.await {
|
||||
tracing::info!("Error reconnecting to friends socket: {e:?}");
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
FriendsSocket::reconnect_task();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn disconnect(&self) -> crate::Result<()> {
|
||||
let mut write_lock = self.write.lock().await;
|
||||
if let Some(ref mut write_half) = *write_lock {
|
||||
write_half.close().await?;
|
||||
*write_lock = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_status(
|
||||
&self,
|
||||
profile_name: Option<String>,
|
||||
) -> crate::Result<()> {
|
||||
let mut write_lock = self.write.lock().await;
|
||||
if let Some(ref mut write_half) = *write_lock {
|
||||
write_half
|
||||
.send(Message::Text(serde_json::to_string(
|
||||
&ClientToServerMessage::StatusUpdate { profile_name },
|
||||
)?))
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn friends(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<Vec<UserFriend>> {
|
||||
fetch_json(
|
||||
Method::GET,
|
||||
&format!("{MODRINTH_API_URL_V3}friends"),
|
||||
None,
|
||||
None,
|
||||
semaphore,
|
||||
exec,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn friend_statuses(&self) -> Vec<UserStatus> {
|
||||
self.user_statuses
|
||||
.iter()
|
||||
.map(|x| x.value().clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn add_friend(
|
||||
user_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<()> {
|
||||
fetch_advanced(
|
||||
Method::POST,
|
||||
&format!("{MODRINTH_API_URL_V3}friend/{user_id}"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
semaphore,
|
||||
exec,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_friend(
|
||||
user_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<()> {
|
||||
fetch_advanced(
|
||||
Method::DELETE,
|
||||
&format!("{MODRINTH_API_URL_V3}friend/{user_id}"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
semaphore,
|
||||
exec,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ pub use self::minecraft_auth::*;
|
||||
mod cache;
|
||||
pub use self::cache::*;
|
||||
|
||||
mod friends;
|
||||
pub use self::friends::*;
|
||||
|
||||
pub mod db;
|
||||
pub mod fs_watcher;
|
||||
mod mr_auth;
|
||||
@@ -60,6 +63,9 @@ pub struct State {
|
||||
/// Process manager
|
||||
pub process_manager: ProcessManager,
|
||||
|
||||
/// Friends socket
|
||||
pub friends_socket: FriendsSocket,
|
||||
|
||||
pub(crate) pool: SqlitePool,
|
||||
|
||||
pub(crate) file_watcher: FileWatcher,
|
||||
@@ -129,13 +135,21 @@ impl State {
|
||||
let file_watcher = fs_watcher::init_watcher().await?;
|
||||
fs_watcher::watch_profiles_init(&file_watcher, &directories).await?;
|
||||
|
||||
let process_manager = ProcessManager::new();
|
||||
|
||||
let friends_socket = FriendsSocket::new();
|
||||
friends_socket
|
||||
.connect(&pool, &fetch_semaphore, &process_manager)
|
||||
.await?;
|
||||
|
||||
Ok(Arc::new(Self {
|
||||
directories,
|
||||
fetch_semaphore,
|
||||
io_semaphore,
|
||||
api_semaphore,
|
||||
discord_rpc,
|
||||
process_manager: ProcessManager::new(),
|
||||
process_manager,
|
||||
friends_socket,
|
||||
pool,
|
||||
file_watcher,
|
||||
}))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::config::MODRINTH_API_URL;
|
||||
use crate::config::{MODRINTH_API_URL, MODRINTH_URL};
|
||||
use crate::state::{CacheBehaviour, CachedEntry};
|
||||
use crate::util::fetch::{fetch_advanced, FetchSemaphore};
|
||||
use chrono::{DateTime, Duration, TimeZone, Utc};
|
||||
@@ -190,8 +190,8 @@ impl ModrinthCredentials {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_login_url() -> &'static str {
|
||||
"https:/modrinth.com/auth/sign-in?launcher=true"
|
||||
pub fn get_login_url() -> String {
|
||||
format!("{MODRINTH_URL}auth/sign-in?launcher=true")
|
||||
}
|
||||
|
||||
pub async fn finish_login_flow(
|
||||
|
||||
@@ -206,6 +206,8 @@ impl Process {
|
||||
|
||||
let _ = state.discord_rpc.clear_to_default(true).await;
|
||||
|
||||
let _ = state.friends_socket.update_status(None).await;
|
||||
|
||||
// If in tauri, window should show itself again after process exists if it was hidden
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
|
||||
@@ -193,7 +193,7 @@ impl ProjectType {
|
||||
ProjectType::Mod => "mod",
|
||||
ProjectType::DataPack => "datapack",
|
||||
ProjectType::ResourcePack => "resourcepack",
|
||||
ProjectType::ShaderPack => "shaderpack",
|
||||
ProjectType::ShaderPack => "shader",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg width="512" height="514" viewBox="0 0 512 514" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M503.16 323.56C514.55 281.47 515.32 235.91 503.2 190.76C466.57 54.2299 326.04 -26.8001 189.33 9.77991C83.8101 38.0199 11.3899 128.07 0.689941 230.47H43.99C54.29 147.33 113.74 74.7298 199.75 51.7098C306.05 23.2598 415.13 80.6699 453.17 181.38L411.03 192.65C391.64 145.8 352.57 111.45 306.3 96.8198L298.56 140.66C335.09 154.13 364.72 184.5 375.56 224.91C391.36 283.8 361.94 344.14 308.56 369.17L320.09 412.16C390.25 383.21 432.4 310.3 422.43 235.14L464.41 223.91C468.91 252.62 467.35 281.16 460.55 308.07L503.16 323.56Z" fill="var(--color-brand)"/>
|
||||
<path d="M321.99 504.22C185.27 540.8 44.7501 459.77 8.11011 323.24C3.84011 307.31 1.17 291.33 0 275.46H43.27C44.36 287.37 46.4699 299.35 49.6799 311.29C53.0399 323.8 57.45 335.75 62.79 347.07L101.38 323.92C98.1299 316.42 95.39 308.6 93.21 300.47C69.17 210.87 122.41 118.77 212.13 94.7601C229.13 90.2101 246.23 88.4401 262.93 89.1501L255.19 133C244.73 133.05 234.11 134.42 223.53 137.25C157.31 154.98 118.01 222.95 135.75 289.09C136.85 293.16 138.13 297.13 139.59 300.99L188.94 271.38L174.07 231.95L220.67 184.08L279.57 171.39L296.62 192.38L269.47 219.88L245.79 227.33L228.87 244.72L237.16 267.79C237.16 267.79 253.95 285.63 253.98 285.64L277.7 279.33L294.58 260.79L331.44 249.12L342.42 273.82L304.39 320.45L240.66 340.63L212.08 308.81L162.26 338.7C187.8 367.78 226.2 383.93 266.01 380.56L277.54 423.55C218.13 431.41 160.1 406.82 124.05 361.64L85.6399 384.68C136.25 451.17 223.84 484.11 309.61 461.16C371.35 444.64 419.4 402.56 445.42 349.38L488.06 364.88C457.17 431.16 398.22 483.82 321.99 504.22Z" fill="var(--color-brand)"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M503.16 323.56C514.55 281.47 515.32 235.91 503.2 190.76C466.57 54.2299 326.04 -26.8001 189.33 9.77991C83.8101 38.0199 11.3899 128.07 0.689941 230.47H43.99C54.29 147.33 113.74 74.7298 199.75 51.7098C306.05 23.2598 415.13 80.6699 453.17 181.38L411.03 192.65C391.64 145.8 352.57 111.45 306.3 96.8198L298.56 140.66C335.09 154.13 364.72 184.5 375.56 224.91C391.36 283.8 361.94 344.14 308.56 369.17L320.09 412.16C390.25 383.21 432.4 310.3 422.43 235.14L464.41 223.91C468.91 252.62 467.35 281.16 460.55 308.07L503.16 323.56Z" fill="currentColor"/>
|
||||
<path d="M321.99 504.22C185.27 540.8 44.7501 459.77 8.11011 323.24C3.84011 307.31 1.17 291.33 0 275.46H43.27C44.36 287.37 46.4699 299.35 49.6799 311.29C53.0399 323.8 57.45 335.75 62.79 347.07L101.38 323.92C98.1299 316.42 95.39 308.6 93.21 300.47C69.17 210.87 122.41 118.77 212.13 94.7601C229.13 90.2101 246.23 88.4401 262.93 89.1501L255.19 133C244.73 133.05 234.11 134.42 223.53 137.25C157.31 154.98 118.01 222.95 135.75 289.09C136.85 293.16 138.13 297.13 139.59 300.99L188.94 271.38L174.07 231.95L220.67 184.08L279.57 171.39L296.62 192.38L269.47 219.88L245.79 227.33L228.87 244.72L237.16 267.79C237.16 267.79 253.95 285.63 253.98 285.64L277.7 279.33L294.58 260.79L331.44 249.12L342.42 273.82L304.39 320.45L240.66 340.63L212.08 308.81L162.26 338.7C187.8 367.78 226.2 383.93 266.01 380.56L277.54 423.55C218.13 431.41 160.1 406.82 124.05 361.64L85.6399 384.68C136.25 451.17 223.84 484.11 309.61 461.16C371.35 444.64 419.4 402.56 445.42 349.38L488.06 364.88C457.17 431.16 398.22 483.82 321.99 504.22Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
5
packages/assets/external/kofi.svg
vendored
@@ -1 +1,4 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Ko-fi</title><path d="M23.881 8.948c-.773-4.085-4.859-4.593-4.859-4.593H.723c-.604 0-.679.798-.679.798s-.082 7.324-.022 11.822c.164 2.424 2.586 2.672 2.586 2.672s8.267-.023 11.966-.049c2.438-.426 2.683-2.566 2.658-3.734 4.352.24 7.422-2.831 6.649-6.916zm-11.062 3.511c-1.246 1.453-4.011 3.976-4.011 3.976s-.121.119-.31.023c-.076-.057-.108-.09-.108-.09-.443-.441-3.368-3.049-4.034-3.954-.709-.965-1.041-2.7-.091-3.71.951-1.01 3.005-1.086 4.363.407 0 0 1.565-1.782 3.468-.963 1.904.82 1.832 3.011.723 4.311zm6.173.478c-.928.116-1.682.028-1.682.028V7.284h1.77s1.971.551 1.971 2.638c0 1.913-.985 2.667-2.059 3.015z" fill="currentColor" /></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" xml:space="preserve">
|
||||
<path fill="currentColor" d="M18.208 2.922c-1.558-.155-2.649-.208-6.857-.208-2.7 0-4.986.026-6.83.259C2.08 3.285.001 5.155.001 8.61H0c0 3.506.182 6.129 1.585 8.493 1.585 2.701 4.234 4.181 7.663 4.181h.831c4.208 0 6.494-2.234 7.636-4a9.441 9.441 0 0 0 1.091-2.339C21.792 14.689 24 12.221 24 9.207v-.415c0-3.246-2.13-5.506-5.792-5.87zm3.844 6.311c0 2.156-1.793 3.843-3.871 3.843h-.935l-.157.65c-.207 1.013-.596 1.818-1.039 2.545-.908 1.428-2.545 3.064-5.921 3.064h-.804c-2.572 0-4.832-.883-6.078-3.194-1.09-2-1.298-4.155-1.298-7.506h-.001c0-2.181.858-3.402 3.014-3.714 1.532-.233 3.558-.259 6.389-.259 4.208 0 5.091.051 6.572.182 2.623.311 4.13 1.585 4.13 4v.389z"/>
|
||||
<path fill="currentColor" d="M17.248 10.429c0 .312.234.546.649.546 1.325 0 2.052-.753 2.052-2s-.727-2.026-2.052-2.026c-.416 0-.649.234-.649.546v2.934zM4.495 10.273c0 1.532.857 2.857 1.948 3.896.727.701 1.87 1.429 2.649 1.896a1.47 1.47 0 0 0 1.507 0c.78-.468 1.922-1.195 2.623-1.896 1.117-1.039 1.974-2.363 1.974-3.896 0-1.663-1.246-3.143-3.039-3.143-1.065 0-1.792.546-2.338 1.299-.494-.754-1.246-1.299-2.312-1.299-1.818 0-3.013 1.481-3.012 3.143"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 718 B After Width: | Height: | Size: 1.2 KiB |
1
packages/assets/icons/coffee.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-coffee"><path d="M10 2v2"/><path d="M14 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/><path d="M6 2v2"/></svg>
|
||||
|
After Width: | Height: | Size: 371 B |
1
packages/assets/icons/gauge.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-gauge"><path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/></svg>
|
||||
|
After Width: | Height: | Size: 277 B |
@@ -1,4 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14.414" height="12.162" viewBox="0 0 14.414 12.162">
|
||||
<path d="M7.667,14.333,3,9.667m0,0L7.667,5M3,9.667H15" transform="translate(-1.586 -3.586)" fill="none"
|
||||
stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>
|
||||
|
Before Width: | Height: | Size: 303 B After Width: | Height: | Size: 266 B |
1
packages/assets/icons/manage.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-settings-2"><path d="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></svg>
|
||||
|
After Width: | Height: | Size: 320 B |
11
packages/assets/icons/maximize.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 24 24">
|
||||
<!-- Generator: Adobe Illustrator 28.6.0, SVG Export Plug-In . SVG Version: 1.2.0 Build 709) -->
|
||||
<g>
|
||||
<g id="Layer_1">
|
||||
<g id="Layer_1-2" data-name="Layer_1">
|
||||
<path d="M21,3H3v18h18V3Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 454 B |
1
packages/assets/icons/minimize.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minus"><line x1="5" x2="19" y1="12" y2="12"/></svg>
|
||||
|
After Width: | Height: | Size: 253 B |
1
packages/assets/icons/minus.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minus"><path d="M5 12h14"/></svg>
|
||||
|
After Width: | Height: | Size: 235 B |
1
packages/assets/icons/package.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-package"><path d="M16.5 9.4 7.55 4.24"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.29 7 12 12 20.71 7"/><line x1="12" x2="12" y1="22" y2="12"/></svg>
|
||||
|
After Width: | Height: | Size: 461 B |
10
packages/assets/icons/restore.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 24 24">
|
||||
<!-- Generator: Adobe Illustrator 28.6.0, SVG Export Plug-In . SVG Version: 1.2.0 Build 709) -->
|
||||
<g>
|
||||
<g id="Layer_1">
|
||||
<rect x="3" y="6.3" width="14.7" height="14.7" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/>
|
||||
<polygon points="21 3 21 17.7 17.7 17.7 17.7 6.3 6.3 6.3 6.3 3 21 3" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 595 B |
@@ -1,4 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14.414" height="12.162" viewBox="0 0 14.414 12.162">
|
||||
<path d="M7.667,14.333,3,9.667m0,0L7.667,5M3,9.667H15" transform="translate(16 15.748) rotate(180)" fill="none"
|
||||
stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
|
Before Width: | Height: | Size: 311 B After Width: | Height: | Size: 266 B |
@@ -52,6 +52,7 @@ import _ClearIcon from './icons/clear.svg?component'
|
||||
import _ClientIcon from './icons/client.svg?component'
|
||||
import _ClipboardCopyIcon from './icons/clipboard-copy.svg?component'
|
||||
import _CodeIcon from './icons/code.svg?component'
|
||||
import _CoffeeIcon from './icons/coffee.svg?component'
|
||||
import _CoinsIcon from './icons/coins.svg?component'
|
||||
import _CollectionIcon from './icons/collection.svg?component'
|
||||
import _CompassIcon from './icons/compass.svg?component'
|
||||
@@ -75,6 +76,7 @@ import _FilterXIcon from './icons/filter-x.svg?component'
|
||||
import _FolderOpenIcon from './icons/folder-open.svg?component'
|
||||
import _FolderSearchIcon from './icons/folder-search.svg?component'
|
||||
import _GapIcon from './icons/gap.svg?component'
|
||||
import _GaugeIcon from './icons/gauge.svg?component'
|
||||
import _GameIcon from './icons/game.svg?component'
|
||||
import _GitHubIcon from './icons/github.svg?component'
|
||||
import _GlassesIcon from './icons/glasses.svg?component'
|
||||
@@ -99,12 +101,16 @@ import _LinkIcon from './icons/link.svg?component'
|
||||
import _ListIcon from './icons/list.svg?component'
|
||||
import _ListEndIcon from './icons/list-end.svg?component'
|
||||
import _LockIcon from './icons/lock.svg?component'
|
||||
import _OpenLockIcon from './icons/lock-open.svg?component'
|
||||
import _LockOpenIcon from './icons/lock-open.svg?component'
|
||||
import _LogInIcon from './icons/log-in.svg?component'
|
||||
import _LogOutIcon from './icons/log-out.svg?component'
|
||||
import _MailIcon from './icons/mail.svg?component'
|
||||
import _ManageIcon from './icons/manage.svg?component'
|
||||
import _MaximizeIcon from './icons/maximize.svg?component'
|
||||
import _MessageIcon from './icons/message.svg?component'
|
||||
import _MicrophoneIcon from './icons/microphone.svg?component'
|
||||
import _MinimizeIcon from './icons/minimize.svg?component'
|
||||
import _MinusIcon from './icons/minus.svg?component'
|
||||
import _MonitorSmartphoneIcon from './icons/monitor-smartphone.svg?component'
|
||||
import _MoonIcon from './icons/moon.svg?component'
|
||||
import _MoreHorizontalIcon from './icons/more-horizontal.svg?component'
|
||||
@@ -112,6 +118,7 @@ import _MoreVerticalIcon from './icons/more-vertical.svg?component'
|
||||
import _NewspaperIcon from './icons/newspaper.svg?component'
|
||||
import _OmorphiaIcon from './icons/omorphia.svg?component'
|
||||
import _OrganizationIcon from './icons/organization.svg?component'
|
||||
import _PackageIcon from './icons/package.svg?component'
|
||||
import _PackageOpenIcon from './icons/package-open.svg?component'
|
||||
import _PackageClosedIcon from './icons/package-closed.svg?component'
|
||||
import _PaintBrushIcon from './icons/paintbrush.svg?component'
|
||||
@@ -123,6 +130,7 @@ import _RadioButtonChecked from './icons/radio-button-checked.svg?component'
|
||||
import _ReceiptTextIcon from './icons/receipt-text.svg?component'
|
||||
import _ReplyIcon from './icons/reply.svg?component'
|
||||
import _ReportIcon from './icons/report.svg?component'
|
||||
import _RestoreIcon from './icons/restore.svg?component'
|
||||
import _RightArrowIcon from './icons/right-arrow.svg?component'
|
||||
import _SaveIcon from './icons/save.svg?component'
|
||||
import _ScaleIcon from './icons/scale.svg?component'
|
||||
@@ -232,6 +240,7 @@ export const ClearIcon = _ClearIcon
|
||||
export const ClientIcon = _ClientIcon
|
||||
export const ClipboardCopyIcon = _ClipboardCopyIcon
|
||||
export const CodeIcon = _CodeIcon
|
||||
export const CoffeeIcon = _CoffeeIcon
|
||||
export const CoinsIcon = _CoinsIcon
|
||||
export const CollectionIcon = _CollectionIcon
|
||||
export const CompassIcon = _CompassIcon
|
||||
@@ -256,6 +265,7 @@ export const FilterXIcon = _FilterXIcon
|
||||
export const FolderOpenIcon = _FolderOpenIcon
|
||||
export const FolderSearchIcon = _FolderSearchIcon
|
||||
export const GapIcon = _GapIcon
|
||||
export const GaugeIcon = _GaugeIcon
|
||||
export const GameIcon = _GameIcon
|
||||
export const GitHubIcon = _GitHubIcon
|
||||
export const GlassesIcon = _GlassesIcon
|
||||
@@ -280,12 +290,16 @@ export const LinkIcon = _LinkIcon
|
||||
export const ListIcon = _ListIcon
|
||||
export const ListEndIcon = _ListEndIcon
|
||||
export const LockIcon = _LockIcon
|
||||
export const OpenLockIcon = _OpenLockIcon
|
||||
export const LockOpenIcon = _LockOpenIcon
|
||||
export const LogInIcon = _LogInIcon
|
||||
export const LogOutIcon = _LogOutIcon
|
||||
export const MailIcon = _MailIcon
|
||||
export const ManageIcon = _ManageIcon
|
||||
export const MaximizeIcon = _MaximizeIcon
|
||||
export const MessageIcon = _MessageIcon
|
||||
export const MicrophoneIcon = _MicrophoneIcon
|
||||
export const MinimizeIcon = _MinimizeIcon
|
||||
export const MinusIcon = _MinusIcon
|
||||
export const MonitorSmartphoneIcon = _MonitorSmartphoneIcon
|
||||
export const MoonIcon = _MoonIcon
|
||||
export const MoreHorizontalIcon = _MoreHorizontalIcon
|
||||
@@ -293,6 +307,7 @@ export const MoreVerticalIcon = _MoreVerticalIcon
|
||||
export const NewspaperIcon = _NewspaperIcon
|
||||
export const OmorphiaIcon = _OmorphiaIcon
|
||||
export const OrganizationIcon = _OrganizationIcon
|
||||
export const PackageIcon = _PackageIcon
|
||||
export const PackageOpenIcon = _PackageOpenIcon
|
||||
export const PackageClosedIcon = _PackageClosedIcon
|
||||
export const PaintBrushIcon = _PaintBrushIcon
|
||||
@@ -304,6 +319,7 @@ export const RadioButtonChecked = _RadioButtonChecked
|
||||
export const ReceiptTextIcon = _ReceiptTextIcon
|
||||
export const ReplyIcon = _ReplyIcon
|
||||
export const ReportIcon = _ReportIcon
|
||||
export const RestoreIcon = _RestoreIcon
|
||||
export const RightArrowIcon = _RightArrowIcon
|
||||
export const SaveIcon = _SaveIcon
|
||||
export const ScaleIcon = _ScaleIcon
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-custom": "workspace:*",
|
||||
"tsconfig": "workspace:*",
|
||||
"vue": "^3.4.31"
|
||||
"vue": "^3.5.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,6 +494,14 @@ a,
|
||||
}
|
||||
}
|
||||
|
||||
.btn-dropdown-animation svg:last-child {
|
||||
transition: transform 0.125s ease-in-out;
|
||||
}
|
||||
|
||||
.v-popper--shown .btn-dropdown-animation svg:last-child {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
grid-gap: var(--gap-sm);
|
||||
@@ -772,7 +780,7 @@ a,
|
||||
box-sizing: content-box;
|
||||
min-height: 32px;
|
||||
height: 32px;
|
||||
width: 52px;
|
||||
min-width: 52px;
|
||||
max-width: 52px;
|
||||
border-radius: var(--radius-max);
|
||||
display: inline-block;
|
||||
@@ -818,15 +826,17 @@ a,
|
||||
.v-popper__inner {
|
||||
background: var(--color-tooltip-bg) !important;
|
||||
color: var(--color-tooltip-text) !important;
|
||||
padding: 5px 10px 4px !important;
|
||||
padding: 0.5rem 0.5rem !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
box-shadow: var(--shadow-floating) !important;
|
||||
font-size: 0.9rem !important;
|
||||
filter: drop-shadow(5px 5px 0.8rem rgba(0, 0, 0, 0.35));
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.v-popper__arrow-outer,
|
||||
.v-popper__arrow-inner {
|
||||
border-color: var(--color-tooltip-bg) !important;
|
||||
border-color: var(--color-tooltip-bg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -834,7 +844,8 @@ a,
|
||||
|
||||
.markdown-body {
|
||||
h1:first-child {
|
||||
margin-top: 0;
|
||||
margin-block-start: 0;
|
||||
padding-block-start: 0;
|
||||
}
|
||||
|
||||
blockquote,
|
||||
@@ -860,10 +871,16 @@ a,
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
padding: 10px 0 5px;
|
||||
border-bottom: 1px solid var(--color-gray);
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -882,6 +899,7 @@ a,
|
||||
padding: 0 1em;
|
||||
color: var(--color-base);
|
||||
border-left: 0.25em solid var(--color-button-bg);
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -900,6 +918,10 @@ a,
|
||||
}
|
||||
}
|
||||
|
||||
a:active > img {
|
||||
scale: 0.98;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
@@ -1182,3 +1204,119 @@ select {
|
||||
border-top-left-radius: var(--radius-md) !important;
|
||||
border-top-right-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
.v-popper--theme-dropdown.v-popper--theme-ribbit-popout {
|
||||
.v-popper__inner {
|
||||
border: 1px solid var(--color-button-bg);
|
||||
padding: var(--gap-sm);
|
||||
width: fit-content;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-raised-bg);
|
||||
box-shadow: var(--shadow-floating);
|
||||
}
|
||||
|
||||
.v-popper__arrow-outer {
|
||||
border-color: var(--color-button-bg);
|
||||
}
|
||||
|
||||
.v-popper__arrow-inner {
|
||||
border-color: var(--color-raised-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement='bottom-end'] .v-popper__wrapper {
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement='top-end'] .v-popper__wrapper {
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement='bottom-start'] .v-popper__wrapper {
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement='top-start'] .v-popper__wrapper {
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
|
||||
.v-popper__popper.v-popper__popper--show-from .v-popper__wrapper {
|
||||
transform: scale(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.v-popper__popper.v-popper__popper--show-to .v-popper__wrapper {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
transition:
|
||||
transform 0.125s ease-in-out,
|
||||
opacity 0.125s ease-in-out;
|
||||
}
|
||||
|
||||
.v-popper__popper.v-popper__popper--hide-from .v-popper__wrapper {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
transition: transform 0.0625s;
|
||||
}
|
||||
|
||||
.v-popper__popper.v-popper__popper--hide-to .v-popper__wrapper {
|
||||
//transform: scale(.9);
|
||||
}
|
||||
|
||||
.preview-radio {
|
||||
width: 100% !important;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-divider);
|
||||
background-color: var(--color-button-bg);
|
||||
color: var(--color-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
outline: 2px solid transparent;
|
||||
|
||||
&.selected {
|
||||
color: var(--color-contrast);
|
||||
|
||||
.label {
|
||||
.radio {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
background-color: var(--color-bg);
|
||||
padding: 1.5rem;
|
||||
outline: 2px solid transparent;
|
||||
width: 100%;
|
||||
|
||||
.example-card {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
outline: 2px solid transparent;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
flex-grow: 1;
|
||||
padding: var(--gap-md) var(--gap-lg);
|
||||
|
||||
.radio {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
color: var(--color-secondary);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,13 @@
|
||||
html {
|
||||
@extend .light-mode;
|
||||
--dark-color-base: #b0bac5;
|
||||
--dark-color-contrast: #ecf9fb;
|
||||
|
||||
--gap-xs: 0.25rem;
|
||||
--gap-sm: 0.5rem;
|
||||
--gap-md: 0.75rem;
|
||||
--gap-lg: 1rem;
|
||||
--gap-xl: 1.5rem;
|
||||
|
||||
--radius-xs: 0.25rem;
|
||||
--radius-sm: 0.5rem;
|
||||
--radius-md: 0.75rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-xl: 1.25rem;
|
||||
--radius-max: 999999999px;
|
||||
|
||||
--color-tooltip-text: var(--color-base);
|
||||
--color-tooltip-bg: var(--color-button-bg);
|
||||
|
||||
--color-ad: rgba(125, 75, 162, 0.2);
|
||||
--color-ad-raised: rgba(190, 140, 243, 0.5);
|
||||
--color-ad-contrast: black;
|
||||
--color-ad-highlight: var(--color-purple);
|
||||
}
|
||||
|
||||
.light-mode,
|
||||
.light,
|
||||
:root[data-theme='light'] {
|
||||
.light-properties {
|
||||
--color-bg: #e5e7eb;
|
||||
--color-raised-bg: #ffffff;
|
||||
--color-super-raised-bg: #e9e9e9;
|
||||
--color-button-bg: hsl(220, 13%, 91%);
|
||||
--color-scrollbar: #96a2b0;
|
||||
|
||||
--color-divider: #babfc5;
|
||||
--color-divider-dark: #c8cdd3;
|
||||
|
||||
--color-base: hsl(221, 39%, 11%);
|
||||
--color-secondary: #6b7280;
|
||||
--color-contrast: #1a202c;
|
||||
@@ -53,6 +27,12 @@ html {
|
||||
--color-purple-highlight: rgba(142, 50, 243, 0.25);
|
||||
--color-gray-highlight: rgba(89, 91, 97, 0.25);
|
||||
|
||||
--color-red-bg: rgba(203, 34, 69, 0.1);
|
||||
--color-orange-bg: rgba(224, 131, 37, 0.1);
|
||||
--color-green-bg: rgba(0, 175, 92, 0.1);
|
||||
--color-blue-bg: rgba(31, 104, 192, 0.1);
|
||||
--color-purple-bg: rgba(142, 50, 243, 0.1);
|
||||
|
||||
--color-brand: var(--color-green);
|
||||
--color-brand-highlight: var(--color-green-highlight);
|
||||
--color-brand-shadow: rgba(0, 175, 92, 0.7);
|
||||
@@ -69,6 +49,52 @@ html {
|
||||
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
|
||||
|
||||
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
|
||||
|
||||
--brand-gradient-bg: linear-gradient(
|
||||
0deg,
|
||||
rgba(68, 182, 138, 0.175) 0%,
|
||||
rgba(58, 250, 112, 0.125) 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%);
|
||||
|
||||
--color-button-bg-selected: var(--color-brand);
|
||||
--color-button-text-selected: var(--color-accent-contrast);
|
||||
|
||||
--loading-bar-gradient: linear-gradient(to right, var(--color-brand) 0%, #00af5c 100%);
|
||||
}
|
||||
|
||||
html {
|
||||
@extend .light-properties;
|
||||
--dark-color-base: #b0bac5;
|
||||
--dark-color-contrast: #ecf9fb;
|
||||
|
||||
--gap-xs: 0.25rem;
|
||||
--gap-sm: 0.5rem;
|
||||
--gap-md: 0.75rem;
|
||||
--gap-lg: 1rem;
|
||||
--gap-xl: 1.5rem;
|
||||
|
||||
--radius-xs: 0.25rem;
|
||||
--radius-sm: 0.5rem;
|
||||
--radius-md: 0.75rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-xl: 1.25rem;
|
||||
--radius-max: 999999999px;
|
||||
|
||||
--color-tooltip-text: var(--dark-color-contrast);
|
||||
--color-tooltip-bg: #000;
|
||||
|
||||
--color-ad: rgba(125, 75, 162, 0.2);
|
||||
--color-ad-raised: rgba(190, 140, 243, 0.5);
|
||||
--color-ad-contrast: black;
|
||||
--color-ad-highlight: var(--color-purple);
|
||||
}
|
||||
|
||||
.light-mode,
|
||||
.light {
|
||||
@extend .light-properties;
|
||||
}
|
||||
|
||||
.dark-mode,
|
||||
@@ -80,6 +106,9 @@ html {
|
||||
--color-button-bg: hsl(222, 13%, 30%);
|
||||
--color-scrollbar: var(--color-button-bg);
|
||||
|
||||
--color-divider: var(--color-button-bg);
|
||||
--color-divider-dark: #646c75;
|
||||
|
||||
--color-base: var(--dark-color-base);
|
||||
--color-secondary: #96a2b0;
|
||||
--color-contrast: var(--dark-color-contrast);
|
||||
@@ -99,6 +128,12 @@ html {
|
||||
--color-purple-highlight: rgba(199, 138, 255, 0.25);
|
||||
--color-gray-highlight: rgba(159, 164, 179, 0.25);
|
||||
|
||||
--color-red-bg: rgba(255, 73, 110, 0.2);
|
||||
--color-orange-bg: rgba(255, 163, 71, 0.2);
|
||||
--color-green-bg: rgba(27, 217, 106, 0.2);
|
||||
--color-blue-bg: rgba(79, 156, 255, 0.2);
|
||||
--color-purple-bg: rgba(199, 138, 255, 0.2);
|
||||
|
||||
--color-brand: var(--color-green);
|
||||
--color-brand-highlight: rgba(27, 217, 106, 0.25);
|
||||
--color-brand-shadow: rgba(27, 217, 106, 0.7);
|
||||
@@ -113,6 +148,16 @@ html {
|
||||
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
|
||||
|
||||
--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-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%);
|
||||
|
||||
--color-button-bg-selected: var(--color-brand-highlight);
|
||||
--color-button-text-selected: var(--color-brand);
|
||||
|
||||
--loading-bar-gradient: linear-gradient(to right, var(--color-brand) 0%, #1ffa9a 100%);
|
||||
}
|
||||
|
||||
.oled-mode {
|
||||
@@ -122,4 +167,116 @@ html {
|
||||
--color-button-bg: #222329;
|
||||
|
||||
--color-ad: #0d1828;
|
||||
|
||||
--brand-gradient-bg: linear-gradient(
|
||||
0deg,
|
||||
rgba(22, 66, 51, 0.3) 0%,
|
||||
rgba(55, 137, 73, 0.15) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.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-button-text: initial !important;
|
||||
--color-button-bg-hover: initial !important;
|
||||
--color-button-text-hover: initial !important;
|
||||
--color-button-bg-active: initial !important;
|
||||
--color-button-text-active: 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
|
||||
}
|
||||
|
||||
.light-experiments {
|
||||
--color-bg: #ebebeb;
|
||||
--color-raised-bg: #ffffff;
|
||||
--color-button-bg: #f5f5f5;
|
||||
--color-base: #2c2e31;
|
||||
--color-secondary: #484d54;
|
||||
--color-accent-contrast: #ffffff;
|
||||
|
||||
--color-platform-fabric: #8a7b71;
|
||||
--color-platform-quilt: #8b61b4;
|
||||
--color-platform-forge: #5b6197;
|
||||
--color-platform-neoforge: #dc895c;
|
||||
--color-platform-liteloader: #4c90de;
|
||||
--color-platform-bukkit: #e78362;
|
||||
--color-platform-bungeecord: #c69e39;
|
||||
--color-platform-folia: #6aa54f;
|
||||
--color-platform-paper: #e67e7e;
|
||||
--color-platform-purpur: #7763a3;
|
||||
--color-platform-spigot: #cd7a21;
|
||||
--color-platform-velocity: #4b98b0;
|
||||
--color-platform-waterfall: #5f83cb;
|
||||
--color-platform-sponge: #c49528;
|
||||
|
||||
--color-button-border: rgba(161, 161, 161, 0.35);
|
||||
}
|
||||
|
||||
.light-mode,
|
||||
.light {
|
||||
.experimental-styles-within,
|
||||
&.experimental-styles-within {
|
||||
@extend .light-experiments;
|
||||
}
|
||||
}
|
||||
|
||||
.experimental-styles-within {
|
||||
.light-mode,
|
||||
.light {
|
||||
@extend .light-experiments;
|
||||
}
|
||||
}
|
||||
|
||||
.dark-experiments {
|
||||
--color-button-bg: #33363d;
|
||||
|
||||
--color-platform-fabric: #dbb69b;
|
||||
--color-platform-quilt: #c796f9;
|
||||
--color-platform-forge: #959eef;
|
||||
--color-platform-neoforge: #f99e6b;
|
||||
--color-platform-liteloader: #7ab0ee;
|
||||
--color-platform-bukkit: #f6af7b;
|
||||
--color-platform-bungeecord: #d2c080;
|
||||
--color-platform-folia: #a5e388;
|
||||
--color-platform-paper: #eeaaaa;
|
||||
--color-platform-purpur: #c3abf7;
|
||||
--color-platform-spigot: #f1cc84;
|
||||
--color-platform-velocity: #83d5ef;
|
||||
--color-platform-waterfall: #78a4fb;
|
||||
--color-platform-sponge: #f9e580;
|
||||
|
||||
--color-button-border: rgba(193, 190, 209, 0.12);
|
||||
}
|
||||
|
||||
.dark-mode,
|
||||
.dark {
|
||||
.experimental-styles-within,
|
||||
&.experimental-styles-within {
|
||||
@extend .dark-experiments;
|
||||
}
|
||||
}
|
||||
|
||||
.experimental-styles-within {
|
||||
.dark-mode,
|
||||
.dark {
|
||||
@extend .dark-experiments;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Daedalus
|
||||
# Daedalus
|
||||
|
||||
Daedalus (the rust library) is a library providing model structs and methods for requesting and parsing things
|
||||
from Minecraft and other mod loaders meta APIs.
|
||||
from Minecraft and other mod loaders meta APIs.
|
||||
|
||||
@@ -7,4 +7,4 @@
|
||||
"dev": "cargo run",
|
||||
"test": "cargo test"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['custom/vue'],
|
||||
}
|
||||
22
packages/ui/eslint.config.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createConfigForNuxt } from '@nuxt/eslint-config/flat'
|
||||
import { fixupPluginRules } from '@eslint/compat'
|
||||
import turboPlugin from 'eslint-plugin-turbo'
|
||||
|
||||
export default createConfigForNuxt().append([
|
||||
{
|
||||
name: 'turbo',
|
||||
plugins: {
|
||||
turbo: fixupPluginRules(turboPlugin),
|
||||
},
|
||||
rules: {
|
||||
'turbo/no-undeclared-env-vars': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'modrinth',
|
||||
rules: {
|
||||
'vue/html-self-closing': 'off',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -1 +1,3 @@
|
||||
export * from './src/components/index'
|
||||
export { commonMessages, commonSettingsMessages } from './src/utils/common-messages'
|
||||
export * from './src/utils/search'
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-custom": "workspace:*",
|
||||
"tsconfig": "workspace:*",
|
||||
"vue": "^3.4.31"
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "4.3.0",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.3.2",
|
||||
@@ -35,7 +37,9 @@
|
||||
"qrcode.vue": "^3.4.1",
|
||||
"vue-multiselect": "3.0.0",
|
||||
"vue-select": "4.0.0-beta.6",
|
||||
"vue-virtual-scroller": "v2.0.0-beta.8",
|
||||
"vue3-apexcharts": "^1.4.4",
|
||||
"xss": "^1.0.14"
|
||||
}
|
||||
},
|
||||
"web-types": "../../web-types.json"
|
||||
}
|
||||
|
||||
91
packages/ui/src/components/base/Accordion.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div v-bind="$attrs">
|
||||
<button
|
||||
v-if="!!slots.title"
|
||||
:class="buttonClass ?? 'flex flex-col gap-2'"
|
||||
@click="() => (isOpen ? close() : open())"
|
||||
>
|
||||
<slot name="button" :open="isOpen">
|
||||
<div class="flex items-center w-full">
|
||||
<slot name="title" />
|
||||
<DropdownIcon
|
||||
class="ml-auto size-5 transition-transform duration-300 shrink-0 text-contrast"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
/>
|
||||
</div>
|
||||
</slot>
|
||||
<slot name="summary" />
|
||||
</button>
|
||||
<div class="accordion-content" :class="{ open: isOpen }">
|
||||
<div>
|
||||
<div :class="contentClass ? contentClass : ''" :inert="!isOpen">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import { ref, useSlots } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
openByDefault?: boolean
|
||||
type?: 'standard' | 'outlined' | 'transparent'
|
||||
buttonClass?: string
|
||||
contentClass?: string
|
||||
titleWrapperClass?: string
|
||||
}>(),
|
||||
{
|
||||
type: 'standard',
|
||||
openByDefault: false,
|
||||
},
|
||||
)
|
||||
|
||||
const isOpen = ref(props.openByDefault)
|
||||
const emit = defineEmits(['onOpen', 'onClose'])
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
emit('onOpen')
|
||||
}
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
emit('onClose')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
isOpen,
|
||||
})
|
||||
|
||||
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>
|
||||
21
packages/ui/src/components/base/AutoLink.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<router-link v-if="to.path || to.query || to.startsWith('/')" :to="to" v-bind="$attrs">
|
||||
<slot />
|
||||
</router-link>
|
||||
<a v-else-if="to.startsWith('http')" :href="to" v-bind="$attrs">
|
||||
<slot />
|
||||
</a>
|
||||
<span v-else v-bind="$attrs">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
to: any
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
@@ -74,7 +74,7 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ModrinthIcon,
|
||||
ScaleIcon,
|
||||
@@ -172,16 +172,10 @@ const messages = defineMessages({
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
defineProps<{
|
||||
type: string
|
||||
color?: string
|
||||
}>()
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.version-badge {
|
||||
|
||||
@@ -126,7 +126,7 @@ function setColorFill(
|
||||
|
||||
const colorVariables = computed(() => {
|
||||
if (props.highlighted) {
|
||||
let colors = {
|
||||
const colors = {
|
||||
bg:
|
||||
props.highlightedStyle === 'main-nav-primary'
|
||||
? 'var(--color-brand-highlight)'
|
||||
@@ -137,7 +137,7 @@ const colorVariables = computed(() => {
|
||||
? 'var(--color-brand)'
|
||||
: 'var(--color-contrast)',
|
||||
}
|
||||
let hoverColors = JSON.parse(JSON.stringify(colors))
|
||||
const hoverColors = JSON.parse(JSON.stringify(colors))
|
||||
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_icon: ${colors.icon}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_hover-icon: ${hoverColors.icon};`
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ const colorVariables = computed(() => {
|
||||
}
|
||||
|
||||
/* Searches up to 4 children deep for valid button */
|
||||
.btn-wrapper :deep(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper :slotted(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child,
|
||||
.btn-wrapper :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
|
||||
@@ -194,7 +195,7 @@ const colorVariables = computed(() => {
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child {
|
||||
@apply flex flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight];
|
||||
@apply flex cursor-pointer flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight];
|
||||
transition:
|
||||
scale 0.125s ease-in-out,
|
||||
background-color 0.25s ease-in-out,
|
||||
@@ -202,6 +203,11 @@ const colorVariables = computed(() => {
|
||||
|
||||
svg:first-child {
|
||||
color: var(--_icon, var(--_text));
|
||||
transition: color 0.25s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover svg:first-child {
|
||||
color: var(--_hover-text);
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
@@ -222,6 +228,7 @@ const colorVariables = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.btn-wrapper.outline :deep(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper.outline :slotted(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper.outline :slotted(*) > :is(button, a, .button-like):first-child,
|
||||
.btn-wrapper.outline :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
|
||||
@@ -234,6 +241,7 @@ const colorVariables = computed(() => {
|
||||
}
|
||||
|
||||
/*noinspection CssUnresolvedCustomProperty*/
|
||||
.btn-wrapper :deep(:is(button, a, .button-like):first-child) > svg:first-child,
|
||||
.btn-wrapper :slotted(:is(button, a, .button-like):first-child) > svg:first-child,
|
||||
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child > svg:first-child,
|
||||
.btn-wrapper
|
||||
@@ -256,6 +264,7 @@ const colorVariables = computed(() => {
|
||||
gap: 1px;
|
||||
|
||||
> .btn-wrapper:not(:first-child) {
|
||||
:deep(:is(button, a, .button-like):first-child),
|
||||
:slotted(:is(button, a, .button-like):first-child),
|
||||
:slotted(*) > :is(button, a, .button-like):first-child,
|
||||
:slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
|
||||
@@ -266,6 +275,7 @@ const colorVariables = computed(() => {
|
||||
}
|
||||
|
||||
> :not(:last-child) {
|
||||
:deep(:is(button, a, .button-like):first-child),
|
||||
:slotted(:is(button, a, .button-like):first-child),
|
||||
:slotted(*) > :is(button, a, .button-like):first-child,
|
||||
:slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
@click="toggle"
|
||||
>
|
||||
<button
|
||||
class="checkbox"
|
||||
class="checkbox border-none"
|
||||
role="checkbox"
|
||||
:disabled="disabled"
|
||||
:class="{ checked: modelValue, collapsing: collapsingToggleStyle }"
|
||||
:aria-label="description"
|
||||
:aria-checked="modelValue"
|
||||
>
|
||||
<CheckIcon v-if="modelValue && !collapsingToggleStyle" aria-hidden="true" />
|
||||
<MinusIcon v-if="indeterminate" aria-hidden="true" />
|
||||
<CheckIcon v-else-if="modelValue && !collapsingToggleStyle" aria-hidden="true" />
|
||||
<DropdownIcon v-else-if="collapsingToggleStyle" aria-hidden="true" />
|
||||
</button>
|
||||
<!-- aria-hidden is set so screenreaders only use the <button>'s aria-label -->
|
||||
@@ -24,7 +25,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DropdownIcon } from '@modrinth/assets'
|
||||
import { CheckIcon, DropdownIcon, MinusIcon } from '@modrinth/assets'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [boolean]
|
||||
@@ -32,12 +33,13 @@ const emit = defineEmits<{
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
description: string
|
||||
description?: string
|
||||
modelValue: boolean
|
||||
clickEvent?: () => void
|
||||
collapsingToggleStyle?: boolean
|
||||
indeterminate?: boolean
|
||||
}>(),
|
||||
{
|
||||
label: '',
|
||||
@@ -46,6 +48,7 @@ const props = withDefaults(
|
||||
modelValue: false,
|
||||
clickEvent: () => {},
|
||||
collapsingToggleStyle: false,
|
||||
indeterminate: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -78,7 +81,6 @@ function toggle() {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
|
||||
min-width: 1rem;
|
||||
min-height: 1rem;
|
||||
@@ -95,10 +97,14 @@ function toggle() {
|
||||
|
||||
&.checked {
|
||||
background-color: var(--color-brand);
|
||||
|
||||
svg {
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--color-accent-contrast);
|
||||
color: var(--color-secondary);
|
||||
stroke-width: 0.2rem;
|
||||
height: 0.8rem;
|
||||
width: 0.8rem;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<router-link v-if="isLink" :to="to">
|
||||
<slot />
|
||||
</router-link>
|
||||
<span v-else>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
to: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isLink: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-x-8 gap-y-6 border-0 border-b border-solid border-button-bg pb-6 lg:grid-cols-[1fr_auto]"
|
||||
class="grid grid-cols-1 gap-x-8 gap-y-6 border-0 border-b border-solid border-divider pb-4 lg:grid-cols-[1fr_auto]"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<slot name="icon" />
|
||||
@@ -11,10 +11,10 @@
|
||||
</h1>
|
||||
<slot name="title-suffix" />
|
||||
</div>
|
||||
<p class="m-0 line-clamp-2 max-w-[40rem]">
|
||||
<p class="m-0 line-clamp-2 max-w-[40rem] empty:hidden">
|
||||
<slot name="summary" />
|
||||
</p>
|
||||
<div class="mt-auto flex flex-wrap gap-4">
|
||||
<div class="mt-auto flex flex-wrap gap-4 empty:hidden">
|
||||
<slot name="stats" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,13 @@
|
||||
}"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<span>{{ selectedOption }}</span>
|
||||
<div>
|
||||
<slot :selected="selectedOption">
|
||||
<span>
|
||||
{{ selectedOption }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
<DropdownIcon class="arrow" :class="{ rotate: dropdownVisible }" />
|
||||
</div>
|
||||
<div class="options-wrapper" :class="{ down: !renderUp, up: renderUp }">
|
||||
|
||||
113
packages/ui/src/components/base/LoadingIndicator.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="w-full flex items-center justify-center flex-col gap-2">
|
||||
<div class="title">Loading</div>
|
||||
<div class="placeholder"></div>
|
||||
<div class="placeholder"></div>
|
||||
<div class="placeholder"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
<style scoped>
|
||||
.title {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
font-weight: bold;
|
||||
color: var(--color-contrast);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
animation: dots 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
25% {
|
||||
content: '';
|
||||
}
|
||||
50% {
|
||||
content: '.';
|
||||
}
|
||||
75% {
|
||||
content: '..';
|
||||
}
|
||||
0%,
|
||||
100% {
|
||||
content: '...';
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
height: 4rem;
|
||||
opacity: 0.25;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-raised-bg);
|
||||
animation: pop 4s ease-in-out infinite;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(
|
||||
-45deg,
|
||||
transparent 30%,
|
||||
rgba(196, 217, 237, 0.075) 50%,
|
||||
transparent 70%
|
||||
);
|
||||
animation: shimmer 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&:nth-child(2)::before {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(3)::before {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
&:nth-child(4)::before {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
from {
|
||||
opacity: 0.25;
|
||||
border-color: transparent;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
border-color: var(--color-button-bg);
|
||||
}
|
||||
to {
|
||||
opacity: 0.25;
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
transform: translateX(-80%);
|
||||
}
|
||||
50%,
|
||||
to {
|
||||
transform: translateX(80%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,7 @@
|
||||
:disabled="disabled"
|
||||
:position="position"
|
||||
:direction="direction"
|
||||
:dropdown-id="dropdownId"
|
||||
@open="
|
||||
() => {
|
||||
searchQuery = ''
|
||||
@@ -15,15 +16,15 @@
|
||||
<slot />
|
||||
<DropdownIcon class="h-5 w-5 text-secondary" />
|
||||
<template #menu>
|
||||
<div class="iconified-input mb-2 w-full" v-if="search">
|
||||
<div v-if="search" class="iconified-input mb-2 w-full">
|
||||
<label for="search-input" hidden>Search...</label>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input
|
||||
id="search-input"
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search..."
|
||||
type="text"
|
||||
ref="searchInput"
|
||||
@keydown.enter="
|
||||
() => {
|
||||
toggleOption(filteredOptions[0])
|
||||
@@ -40,7 +41,7 @@
|
||||
class="!w-full"
|
||||
:color="manyValues.includes(option) ? 'secondary' : 'default'"
|
||||
>
|
||||
<slot name="option" :option="option">{{ displayName(option) }}</slot>
|
||||
<slot name="option" :option="option">{{ displayName?.(option) }}</slot>
|
||||
<CheckIcon
|
||||
class="h-5 w-5 text-contrast ml-auto transition-opacity"
|
||||
:class="{ 'opacity-0': !manyValues.includes(option) }"
|
||||
@@ -56,7 +57,7 @@
|
||||
class="!w-full"
|
||||
:color="manyValues.includes(option) ? 'secondary' : 'default'"
|
||||
>
|
||||
<slot name="option" :option="option">{{ displayName(option) }}</slot>
|
||||
<slot name="option" :option="option">{{ displayName?.(option) }}</slot>
|
||||
<CheckIcon
|
||||
class="h-5 w-5 text-contrast ml-auto transition-opacity"
|
||||
:class="{ 'opacity-0': !manyValues.includes(option) }"
|
||||
@@ -85,6 +86,7 @@ const props = withDefaults(
|
||||
direction?: string
|
||||
displayName?: (option: Option) => string
|
||||
search?: boolean
|
||||
dropdownId?: string
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
|
||||
@@ -368,7 +368,7 @@ onMounted(() => {
|
||||
if (clipboardData.files && clipboardData.files.length > 0 && props.onImageUpload) {
|
||||
// If the user is pasting a file, upload it if there's an included handler and insert the link.
|
||||
uploadImagesFromList(clipboardData.files)
|
||||
// eslint-disable-next-line func-names -- who the fuck did this?
|
||||
|
||||
.then(function (url) {
|
||||
const selection = markdownCommands.yankSelection(view)
|
||||
const altText = selection || 'Replace this with a description'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="vue-notification-group">
|
||||
<div class="vue-notification-group" :class="{ 'has-sidebar': sidebar }">
|
||||
<transition-group name="notifs">
|
||||
<div
|
||||
v-for="(item, index) in notifications"
|
||||
@@ -20,6 +20,13 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
sidebar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const notifications = ref([])
|
||||
|
||||
defineExpose({
|
||||
@@ -90,6 +97,10 @@ function stopTimer(notif) {
|
||||
z-index: 99999999;
|
||||
width: 300px;
|
||||
|
||||
&.has-sidebar {
|
||||
right: 325px;
|
||||
}
|
||||
|
||||
.vue-notification-wrapper {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
ref="dropdown"
|
||||
v-bind="$attrs"
|
||||
:disabled="disabled"
|
||||
:position="position"
|
||||
:direction="direction"
|
||||
:dropdown-id="dropdownId"
|
||||
:tooltip="tooltip"
|
||||
>
|
||||
<slot></slot>
|
||||
<template #menu>
|
||||
@@ -17,10 +17,12 @@
|
||||
<Button
|
||||
v-else
|
||||
:key="`option-${option.id}`"
|
||||
v-tooltip="option.tooltip"
|
||||
:color="option.color ? option.color : 'default'"
|
||||
:hover-filled="option.hoverFilled"
|
||||
:hover-filled-only="option.hoverFilledOnly"
|
||||
transparent
|
||||
:v-close-popper="!option.remainOnClick"
|
||||
:action="
|
||||
option.action
|
||||
? (event) => {
|
||||
@@ -33,6 +35,7 @@
|
||||
"
|
||||
:link="option.link ? option.link : null"
|
||||
:external="option.external ? option.external : false"
|
||||
:disabled="option.disabled"
|
||||
@click="
|
||||
() => {
|
||||
if (option.link && !option.remainOnClick) {
|
||||
@@ -80,6 +83,8 @@ interface Item extends BaseOption {
|
||||
hoverFilled?: boolean
|
||||
hoverFilledOnly?: boolean
|
||||
remainOnClick?: boolean
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
type Option = Divider | Item
|
||||
@@ -88,14 +93,14 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
options: Option[]
|
||||
disabled?: boolean
|
||||
position?: string
|
||||
direction?: string
|
||||
dropdownId?: string
|
||||
tooltip?: string
|
||||
}>(),
|
||||
{
|
||||
options: () => [],
|
||||
disabled: false,
|
||||
position: 'auto',
|
||||
direction: 'auto',
|
||||
dropdownId: null,
|
||||
tooltip: null,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -106,7 +111,6 @@ defineOptions({
|
||||
const dropdown = ref(null)
|
||||
|
||||
const close = () => {
|
||||
console.log('closing!')
|
||||
dropdown.value.hide()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
<div v-if="count > 1" class="flex items-center gap-1">
|
||||
<ButtonStyled v-if="page > 1" circular type="transparent">
|
||||
<a
|
||||
v-if="linkFunction"
|
||||
aria-label="Previous Page"
|
||||
:href="linkFunction(page - 1)"
|
||||
@click.prevent="switchPage(page - 1)"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</a>
|
||||
<button v-else aria-label="Previous Page" @click="switchPage(page - 1)">
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div
|
||||
v-for="(item, index) in pages"
|
||||
@@ -27,20 +31,29 @@
|
||||
:color="page === item ? 'brand' : 'standard'"
|
||||
:type="page === item ? 'standard' : 'transparent'"
|
||||
>
|
||||
<a :href="linkFunction(item)" @click.prevent="page !== item ? switchPage(item) : null">
|
||||
<a
|
||||
v-if="linkFunction"
|
||||
:href="linkFunction(item)"
|
||||
@click.prevent="page !== item ? switchPage(item) : null"
|
||||
>
|
||||
{{ item }}
|
||||
</a>
|
||||
<button v-else @click="page !== item ? switchPage(item) : null">{{ item }}</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<ButtonStyled v-if="page !== pages[pages.length - 1]" circular type="transparent">
|
||||
<a
|
||||
v-if="linkFunction"
|
||||
aria-label="Next Page"
|
||||
:href="linkFunction(page + 1)"
|
||||
@click.prevent="switchPage(page + 1)"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</a>
|
||||
<button v-else aria-label="Next Page" @click="switchPage(page + 1)">
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
@@ -68,7 +81,7 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const pages = computed(() => {
|
||||
let pages: ('-' | number)[] = []
|
||||
const pages: ('-' | number)[] = []
|
||||
|
||||
const first = 1
|
||||
const last = props.count
|
||||
|
||||
@@ -1,275 +1,91 @@
|
||||
<template>
|
||||
<div ref="dropdown" class="popup-container" tabindex="-1" :aria-expanded="dropdownVisible">
|
||||
<button
|
||||
v-bind="$attrs"
|
||||
ref="dropdownButton"
|
||||
:class="{ 'popout-open': dropdownVisible }"
|
||||
:tabindex="tabInto ? -1 : 0"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<Dropdown
|
||||
ref="dropdown"
|
||||
theme="ribbit-popout"
|
||||
no-auto-focus
|
||||
:aria-id="dropdownId || null"
|
||||
@hide="focusTrigger"
|
||||
@apply-show="focusMenuChild"
|
||||
>
|
||||
<button ref="trigger" v-bind="$attrs" v-tooltip="tooltip">
|
||||
<slot></slot>
|
||||
</button>
|
||||
<div
|
||||
class="popup-menu"
|
||||
:class="`position-${computedPosition}-${computedDirection} ${dropdownVisible ? 'visible' : ''}`"
|
||||
:inert="!tabInto && !dropdownVisible"
|
||||
>
|
||||
<slot name="menu"> </slot>
|
||||
</div>
|
||||
</div>
|
||||
<template #popper="{ hide }">
|
||||
<button class="dummy-button" @focusin="hideAndFocusTrigger(hide)"></button>
|
||||
<div ref="menu" class="contents">
|
||||
<slot name="menu"> </slot>
|
||||
</div>
|
||||
<button class="dummy-button" @focusin="hideAndFocusTrigger(hide)"></button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
import { ref, defineOptions } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
position: {
|
||||
const trigger = ref()
|
||||
const menu = ref()
|
||||
const dropdown = ref()
|
||||
|
||||
defineProps({
|
||||
dropdownId: {
|
||||
type: String,
|
||||
default: 'auto',
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
direction: {
|
||||
tooltip: {
|
||||
type: String,
|
||||
default: 'auto',
|
||||
},
|
||||
tabInto: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
function focusMenuChild() {
|
||||
setTimeout(() => {
|
||||
if (menu.value && menu.value.children && menu.value.children.length > 0) {
|
||||
menu.value.children[0].focus()
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function hideAndFocusTrigger(hide) {
|
||||
hide()
|
||||
focusTrigger()
|
||||
}
|
||||
|
||||
function focusTrigger() {
|
||||
trigger.value.focus()
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['open', 'close'])
|
||||
|
||||
const dropdownVisible = ref(false)
|
||||
const dropdown = ref(null)
|
||||
const dropdownButton = ref(null)
|
||||
const computedPosition = ref('bottom')
|
||||
const computedDirection = ref('left')
|
||||
|
||||
function updateDirection() {
|
||||
if (props.direction === 'auto') {
|
||||
if (dropdownButton.value) {
|
||||
const x = dropdownButton.value.getBoundingClientRect().left
|
||||
computedDirection.value = x < window.innerWidth / 2 ? 'right' : 'left'
|
||||
} else {
|
||||
computedDirection.value = 'left'
|
||||
}
|
||||
} else {
|
||||
computedDirection.value = props.direction
|
||||
}
|
||||
if (props.position === 'auto') {
|
||||
if (dropdownButton.value) {
|
||||
const y = dropdownButton.value.getBoundingClientRect().top
|
||||
computedPosition.value = y < window.innerHeight / 2 ? 'bottom' : 'top'
|
||||
} else {
|
||||
computedPosition.value = 'bottom'
|
||||
}
|
||||
} else {
|
||||
computedPosition.value = props.position
|
||||
}
|
||||
function hide() {
|
||||
dropdown.value.hide()
|
||||
}
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!props.disabled) {
|
||||
dropdownVisible.value = !dropdownVisible.value
|
||||
if (dropdownVisible.value) {
|
||||
emit('open')
|
||||
} else {
|
||||
dropdownButton.value.focus()
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
dropdownVisible.value = false
|
||||
dropdownButton.value.focus()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
dropdownVisible.value = true
|
||||
emit('open')
|
||||
function show() {
|
||||
dropdown.value.show()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (!dropdown.value) return
|
||||
|
||||
const isContextMenuClick = event.button === 2 || event.which === 3
|
||||
const elements = document.elementsFromPoint(event.clientX, event.clientY)
|
||||
if (
|
||||
(dropdown.value !== event.target &&
|
||||
!elements.includes(dropdown.value) &&
|
||||
!dropdown.value.contains(event.target)) ||
|
||||
isContextMenuClick
|
||||
) {
|
||||
dropdownVisible.value = false
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener('mouseup', handleClickOutside)
|
||||
window.addEventListener('resize', updateDirection)
|
||||
window.addEventListener('scroll', updateDirection)
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
updateDirection()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener('mouseup', handleClickOutside)
|
||||
window.removeEventListener('resize', updateDirection)
|
||||
window.removeEventListener('scroll', updateDirection)
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.popup-container {
|
||||
position: relative;
|
||||
|
||||
.popup-menu {
|
||||
--_animation-offset: -1rem;
|
||||
position: absolute;
|
||||
scale: 0.75;
|
||||
border: 1px solid var(--color-button-bg);
|
||||
padding: var(--gap-sm);
|
||||
width: fit-content;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-raised-bg);
|
||||
box-shadow: var(--shadow-floating);
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
transition:
|
||||
bottom 0.125s ease-in-out,
|
||||
top 0.125s ease-in-out,
|
||||
left 0.125s ease-in-out,
|
||||
right 0.125s ease-in-out,
|
||||
opacity 0.125s ease-in-out,
|
||||
scale 0.125s ease-in-out;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
&.position-bottom-left {
|
||||
top: calc(100% + var(--gap-sm) - 1rem);
|
||||
right: -1rem;
|
||||
}
|
||||
|
||||
&.position-bottom-right {
|
||||
top: calc(100% + var(--gap-sm) - 1rem);
|
||||
left: -1rem;
|
||||
}
|
||||
|
||||
&.position-top-left {
|
||||
bottom: calc(100% + var(--gap-sm) - 1rem);
|
||||
right: -1rem;
|
||||
}
|
||||
|
||||
&.position-top-right {
|
||||
bottom: calc(100% + var(--gap-sm) - 1rem);
|
||||
left: -1rem;
|
||||
}
|
||||
|
||||
&.position-left-up {
|
||||
bottom: -1rem;
|
||||
right: calc(100% + var(--gap-sm) - 1rem);
|
||||
}
|
||||
|
||||
&.position-left-down {
|
||||
top: -1rem;
|
||||
right: calc(100% + var(--gap-sm) - 1rem);
|
||||
}
|
||||
|
||||
&.position-right-up {
|
||||
bottom: -1rem;
|
||||
left: calc(100% + var(--gap-sm) - 1rem);
|
||||
}
|
||||
|
||||
&.position-right-down {
|
||||
top: -1rem;
|
||||
left: calc(100% + var(--gap-sm) - 1rem);
|
||||
}
|
||||
|
||||
&:not(.visible):not(:focus-within) {
|
||||
pointer-events: none;
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.visible,
|
||||
&:focus-within {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
|
||||
&.position-bottom-left {
|
||||
top: calc(100% + var(--gap-sm));
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&.position-bottom-right {
|
||||
top: calc(100% + var(--gap-sm));
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&.position-top-left {
|
||||
bottom: calc(100% + var(--gap-sm));
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&.position-top-right {
|
||||
bottom: calc(100% + var(--gap-sm));
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&.position-left-up {
|
||||
bottom: 0rem;
|
||||
right: calc(100% + var(--gap-sm));
|
||||
}
|
||||
|
||||
&.position-left-down {
|
||||
top: 0rem;
|
||||
right: calc(100% + var(--gap-sm));
|
||||
}
|
||||
|
||||
&.position-right-up {
|
||||
bottom: 0rem;
|
||||
left: calc(100% + var(--gap-sm));
|
||||
}
|
||||
|
||||
&.position-right-down {
|
||||
top: 0rem;
|
||||
left: calc(100% + var(--gap-sm));
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
<style scoped>
|
||||
.dummy-button {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
23
packages/ui/src/components/base/PreviewSelectButton.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { RadioButtonIcon, RadioButtonChecked } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
checked: boolean
|
||||
}>(),
|
||||
{
|
||||
checked: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="" role="button" @click="() => {}">
|
||||
<slot name="preview" />
|
||||
<div>
|
||||
<RadioButtonIcon v-if="!checked" class="w-4 h-4" />
|
||||
<RadioButtonChecked v-else class="w-4 h-4" />
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -74,7 +74,7 @@ import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime.js'
|
||||
import { defineComponent } from 'vue'
|
||||
import Categories from '../search/Categories.vue'
|
||||
import Badge from './Badge.vue'
|
||||
import Badge from './SimpleBadge.vue'
|
||||
import Avatar from './Avatar.vue'
|
||||
import EnvironmentIndicator from './EnvironmentIndicator.vue'
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="scrollable-pane-wrapper" :class="{ 'max-height': !props.noMaxHeight }">
|
||||
<div class="scrollable-pane-wrapper">
|
||||
<div
|
||||
class="wrapper-wrapper"
|
||||
:class="{
|
||||
'top-fade': !scrollableAtTop && !props.noMaxHeight,
|
||||
'bottom-fade': !scrollableAtBottom && !props.noMaxHeight,
|
||||
'top-fade': !scrollableAtTop && !props.disableScrolling,
|
||||
'bottom-fade': !scrollableAtBottom && !props.disableScrolling,
|
||||
}"
|
||||
>
|
||||
<div ref="scrollablePane" class="scrollable-pane" @scroll="onScroll">
|
||||
@@ -19,10 +19,10 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
noMaxHeight?: boolean
|
||||
disableScrolling?: boolean
|
||||
}>(),
|
||||
{
|
||||
noMaxHeight: false,
|
||||
disableScrolling: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -49,7 +49,7 @@ onUnmounted(() => {
|
||||
})
|
||||
function updateFade(scrollTop, offsetHeight, scrollHeight) {
|
||||
scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight
|
||||
scrollableAtTop.value = scrollTop === 0
|
||||
scrollableAtTop.value = scrollTop <= 0
|
||||
}
|
||||
function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
|
||||
updateFade(scrollTop, offsetHeight, scrollHeight)
|
||||
@@ -61,47 +61,26 @@ function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
&.max-height {
|
||||
max-height: 19rem;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper-wrapper {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
--_fade-height: 4rem;
|
||||
margin-bottom: var(--gap-sm);
|
||||
&.top-fade::before,
|
||||
&.bottom-fade::after {
|
||||
opacity: 1;
|
||||
|
||||
&.top-fade {
|
||||
mask-image: linear-gradient(transparent, rgb(0 0 0 / 100%) var(--_fade-height));
|
||||
}
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
left: 0;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.125s ease;
|
||||
height: var(--_fade-height);
|
||||
z-index: 1;
|
||||
|
||||
&.bottom-fade {
|
||||
mask-image: linear-gradient(rgb(0 0 0 / 100%) calc(100% - var(--_fade-height)), transparent 100%);
|
||||
}
|
||||
&::before {
|
||||
top: 0;
|
||||
background-image: linear-gradient(
|
||||
var(--scrollable-pane-bg, var(--color-raised-bg)),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
&::after {
|
||||
bottom: 0;
|
||||
background-image: linear-gradient(
|
||||
transparent,
|
||||
var(--scrollable-pane-bg, var(--color-raised-bg))
|
||||
);
|
||||
|
||||
&.top-fade.bottom-fade {
|
||||
mask-image: linear-gradient(transparent, rgb(0 0 0 / 100%) var(--_fade-height), rgb(0 0 0 / 100%) calc(100% - var(--_fade-height)), transparent 100%);
|
||||
}
|
||||
}
|
||||
.scrollable-pane {
|
||||
@@ -113,26 +92,5 @@ function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
|
||||
::-webkit-scrollbar {
|
||||
transition: all;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: var(--gap-md);
|
||||
border: 3px solid var(--color-bg);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--color-bg);
|
||||
border: 3px solid var(--color-bg);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-raised-bg);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 4px;
|
||||
border: 3px solid var(--color-bg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
18
packages/ui/src/components/base/SimpleBadge.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 font-semibold text-secondary"
|
||||
>
|
||||
<component :is="icon" v-if="icon" :aria-hidden="true" class="shrink-0" />
|
||||
{{ formattedName }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
icon?: Component
|
||||
formattedName: string
|
||||
color?: 'brand' | 'green' | 'blue' | 'purple' | 'orange' | 'red'
|
||||
}>()
|
||||
</script>
|
||||
20
packages/ui/src/components/base/TagItem.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<button
|
||||
v-if="action"
|
||||
class="bg-[--_bg-color,var(--color-button-bg)] px-2 py-1 leading-none rounded-full font-semibold text-sm inline-flex items-center gap-1 text-[--_color,var(--color-secondary)] [&>svg]:shrink-0 [&>svg]:h-4 [&>svg]:w-4 border-none transition-transform active:scale-[0.95] cursor-pointer hover:underline"
|
||||
@click="action"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
class="bg-[--_bg-color,var(--color-button-bg)] px-2 py-1 leading-none rounded-full font-semibold text-sm inline-flex items-center gap-1 text-[--_color,var(--color-secondary)] [&>svg]:shrink-0 [&>svg]:h-4 [&>svg]:w-4"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
action?: (event: MouseEvent) => void
|
||||
}>()
|
||||
</script>
|
||||
@@ -942,7 +942,7 @@ async function submitPayment() {
|
||||
|
||||
defineExpose({
|
||||
show: () => {
|
||||
// eslint-disable-next-line no-undef
|
||||
|
||||
stripe = Stripe(props.publishableKey)
|
||||
|
||||
selectedPlan.value = 'yearly'
|
||||
|
||||
91
packages/ui/src/components/content/ContentListItem.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import AutoLink from '../base/AutoLink.vue'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import Checkbox from '../base/Checkbox.vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { SlashIcon } from '@modrinth/assets'
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface ContentCreator {
|
||||
name: string
|
||||
type: 'user' | 'organization'
|
||||
id: string
|
||||
link?: string | RouteLocationRaw
|
||||
linkProps?: any
|
||||
}
|
||||
|
||||
export interface ContentProject {
|
||||
id: string
|
||||
link?: string | RouteLocationRaw
|
||||
linkProps?: any
|
||||
}
|
||||
|
||||
export interface ContentItem<T> {
|
||||
path: string
|
||||
disabled: boolean
|
||||
filename: string
|
||||
data: T
|
||||
|
||||
icon?: string
|
||||
title?: string
|
||||
project?: ContentProject
|
||||
creator?: ContentCreator
|
||||
|
||||
version?: string
|
||||
versionId?: string
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
item: ContentItem<T>
|
||||
last?: boolean
|
||||
}>(),
|
||||
{
|
||||
last: false,
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel()
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[min-content,4fr,3fr,2fr] gap-3 items-center p-2 h-[64px] border-solid border-0 border-b-button-bg relative"
|
||||
:class="{ 'border-b-[1px]': !last }"
|
||||
>
|
||||
<Checkbox v-model="model" :description="``" class="select-checkbox" />
|
||||
<div
|
||||
class="flex items-center gap-2 text-contrast font-medium"
|
||||
:class="{ 'opacity-50': item.disabled }"
|
||||
>
|
||||
<AutoLink :to="item.project?.link ?? ''" tabindex="-1" v-bind="item.project?.linkProps ?? {}">
|
||||
<Avatar :src="item.icon ?? ''" :class="{ grayscale: item.disabled }" size="48px" />
|
||||
</AutoLink>
|
||||
<div class="flex flex-col">
|
||||
<AutoLink :to="item.project?.link ?? ''" v-bind="item.project?.linkProps ?? {}">
|
||||
<div class="text-contrast line-clamp-1" :class="{ 'line-through': item.disabled }">
|
||||
{{ item.title ?? item.filename }}
|
||||
</div>
|
||||
</AutoLink>
|
||||
<AutoLink :to="item.creator?.link ?? ''" v-bind="item.creator?.linkProps ?? {}">
|
||||
<div class="line-clamp-1 break-all" :class="{ 'opacity-50': item.disabled }">
|
||||
<slot v-if="item.creator && item.creator.name" :item="item">
|
||||
<span class="text-secondary"> by {{ item.creator.name }} </span>
|
||||
</slot>
|
||||
</div>
|
||||
</AutoLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col max-w-60" :class="{ 'opacity-50': item.disabled }">
|
||||
<div v-if="item.version" class="line-clamp-1 break-all">
|
||||
<slot :creator="item.creator">
|
||||
{{ item.version }}
|
||||
</slot>
|
||||
</div>
|
||||
<div class="text-secondary text-xs line-clamp-1 break-all">{{ item.filename }}</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-1">
|
||||
<slot name="actions" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
102
packages/ui/src/components/content/ContentListPanel.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import Checkbox from '../base/Checkbox.vue'
|
||||
import ContentListItem from './ContentListItem.vue'
|
||||
import type { ContentItem } from './ContentListItem.vue'
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
// @ts-ignore
|
||||
import { RecycleScroller } from 'vue-virtual-scroller'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: ContentItem<T>[]
|
||||
sortColumn: string
|
||||
sortAscending: boolean
|
||||
updateSort: (column: string) => void
|
||||
}>(),
|
||||
{},
|
||||
)
|
||||
|
||||
const selectionStates: Ref<Record<string, boolean>> = ref({})
|
||||
const selected: Ref<string[]> = computed(() =>
|
||||
Object.keys(selectionStates.value).filter(
|
||||
(item) => selectionStates.value[item] && props.items.some((x) => x.filename === item),
|
||||
),
|
||||
)
|
||||
|
||||
const allSelected = ref(false)
|
||||
|
||||
const model = defineModel()
|
||||
|
||||
function updateSelection() {
|
||||
model.value = selected.value
|
||||
}
|
||||
|
||||
function setSelected(value: boolean) {
|
||||
if (value) {
|
||||
selectionStates.value = Object.fromEntries(props.items.map((item) => [item.filename, true]))
|
||||
} else {
|
||||
selectionStates.value = {}
|
||||
}
|
||||
updateSelection()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col grid-cols-[min-content,auto,auto,auto,auto]">
|
||||
<div
|
||||
:class="`${$slots.headers ? 'flex' : 'grid'} grid-cols-[min-content,4fr,3fr,2fr] gap-3 items-center px-2 pt-1 h-10 mb-3 text-contrast font-bold`"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="allSelected"
|
||||
class="select-checkbox"
|
||||
:indeterminate="selected.length > 0 && selected.length < items.length"
|
||||
@update:model-value="setSelected"
|
||||
/>
|
||||
<slot name="headers">
|
||||
<div class="flex items-center gap-2 cursor-pointer" @click="updateSort('Name')">
|
||||
Name
|
||||
<DropdownIcon
|
||||
v-if="sortColumn === 'Name'"
|
||||
class="transition-all transform"
|
||||
:class="{ 'rotate-180': sortAscending }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 max-w-60 cursor-pointer" @click="updateSort('Updated')">
|
||||
Updated
|
||||
<DropdownIcon
|
||||
v-if="sortColumn === 'Updated'"
|
||||
class="transition-all transform"
|
||||
:class="{ 'rotate-180': sortAscending }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="bg-bg-raised rounded-xl">
|
||||
<RecycleScroller
|
||||
v-slot="{ item, index }"
|
||||
:items="items"
|
||||
:item-size="64"
|
||||
disable-transform
|
||||
key-field="filename"
|
||||
style="height: 100%"
|
||||
>
|
||||
<ContentListItem
|
||||
v-model="selectionStates[item.filename]"
|
||||
:item="item"
|
||||
:last="props.items.length - 1 === index"
|
||||
class="mb-2"
|
||||
@update:model-value="updateSelection"
|
||||
>
|
||||
<template #actions="{ item }">
|
||||
<slot name="actions" :item="item" />
|
||||
</template>
|
||||
</ContentListItem>
|
||||
</RecycleScroller>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,4 +1,6 @@
|
||||
// Base content
|
||||
export { default as Accordion } from './base/Accordion.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'
|
||||
export { default as Button } from './base/Button.vue'
|
||||
@@ -6,7 +8,6 @@ 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 ConditionalNuxtLink } from './base/ConditionalNuxtLink.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'
|
||||
@@ -14,6 +15,7 @@ export { default as DropArea } from './base/DropArea.vue'
|
||||
export { default as DropdownSelect } from './base/DropdownSelect.vue'
|
||||
export { default as EnvironmentIndicator } from './base/EnvironmentIndicator.vue'
|
||||
export { default as FileInput } from './base/FileInput.vue'
|
||||
export { default as LoadingIndicator } from './base/LoadingIndicator.vue'
|
||||
export { default as ManySelect } from './base/ManySelect.vue'
|
||||
export { default as MarkdownEditor } from './base/MarkdownEditor.vue'
|
||||
export { default as Notifications } from './base/Notifications.vue'
|
||||
@@ -21,8 +23,10 @@ export { default as OverflowMenu } from './base/OverflowMenu.vue'
|
||||
export { default as Page } from './base/Page.vue'
|
||||
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 ScrollablePanel } from './base/ScrollablePanel.vue'
|
||||
export { default as SimpleBadge } from './base/SimpleBadge.vue'
|
||||
export { default as Slider } from './base/Slider.vue'
|
||||
export { default as StatItem } from './base/StatItem.vue'
|
||||
export { default as Toggle } from './base/Toggle.vue'
|
||||
@@ -35,6 +39,9 @@ export { default as TextLogo } from './brand/TextLogo.vue'
|
||||
export { default as Chart } from './chart/Chart.vue'
|
||||
export { default as CompactChart } from './chart/CompactChart.vue'
|
||||
|
||||
// Content
|
||||
export { default as ContentListPanel } from './content/ContentListPanel.vue'
|
||||
|
||||
// Modals
|
||||
export { default as NewModal } from './modal/NewModal.vue'
|
||||
export { default as Modal } from './modal/Modal.vue'
|
||||
@@ -47,13 +54,34 @@ export { default as NavItem } from './nav/NavItem.vue'
|
||||
export { default as NavRow } from './nav/NavRow.vue'
|
||||
export { default as NavStack } from './nav/NavStack.vue'
|
||||
|
||||
// Project
|
||||
export { default as NewProjectCard } from './project/NewProjectCard.vue'
|
||||
export { default as ProjectBackgroundGradient } from './project/ProjectBackgroundGradient.vue'
|
||||
export { default as ProjectHeader } from './project/ProjectHeader.vue'
|
||||
export { default as ProjectPageDescription } from './project/ProjectPageDescription.vue'
|
||||
export { default as ProjectPageVersions } from './project/ProjectPageVersions.vue'
|
||||
export { default as ProjectSidebarCompatibility } from './project/ProjectSidebarCompatibility.vue'
|
||||
export { default as ProjectSidebarCreators } from './project/ProjectSidebarCreators.vue'
|
||||
export { default as ProjectSidebarDetails } from './project/ProjectSidebarDetails.vue'
|
||||
export { default as ProjectSidebarLinks } from './project/ProjectSidebarLinks.vue'
|
||||
export { default as ProjectStatusBadge } from './project/ProjectStatusBadge.vue'
|
||||
|
||||
// Search
|
||||
export { default as BrowseFiltersPanel } from './search/BrowseFiltersPanel.vue'
|
||||
export { default as Categories } from './search/Categories.vue'
|
||||
export { default as SearchDropdown } from './search/SearchDropdown.vue'
|
||||
export { default as SearchFilter } from './search/SearchFilter.vue'
|
||||
export { default as SearchFilterControl } from './search/SearchFilterControl.vue'
|
||||
export { default as SearchFilterOption } from './search/SearchFilterOption.vue'
|
||||
export { default as SearchSidebarFilter } from './search/SearchSidebarFilter.vue'
|
||||
|
||||
// Billing
|
||||
export { default as PurchaseModal } from './billing/PurchaseModal.vue'
|
||||
|
||||
// Version
|
||||
export { default as VersionChannelIndicator } from './version/VersionChannelIndicator.vue'
|
||||
export { default as VersionFilterControl } from './version/VersionFilterControl.vue'
|
||||
export { default as VersionSummary } from './version/VersionSummary.vue'
|
||||
|
||||
// Settings
|
||||
export { default as ThemeSelector } from './settings/ThemeSelector.vue'
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:class="{ shown: visible }"
|
||||
class="tauri-overlay"
|
||||
data-tauri-drag-region
|
||||
@click="() => (closable ? hide() : {})"
|
||||
@click="() => (closeOnClickOutside && closable ? hide() : {})"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
@@ -16,12 +16,12 @@
|
||||
danger: danger,
|
||||
}"
|
||||
class="modal-overlay"
|
||||
@click="() => (closable ? hide() : {})"
|
||||
@click="() => (closeOnClickOutside && closable ? hide() : {})"
|
||||
/>
|
||||
<div class="modal-container experimental-styles-within" :class="{ shown: visible }">
|
||||
<div class="modal-body flex flex-col bg-bg-raised rounded-2xl">
|
||||
<div
|
||||
class="grid grid-cols-[auto_min-content] items-center gap-12 p-6 border-solid border-0 border-b-[1px] border-button-bg max-w-full"
|
||||
class="grid grid-cols-[auto_min-content] items-center gap-12 p-6 border-solid border-0 border-b-[1px] border-divider max-w-full"
|
||||
>
|
||||
<div class="flex text-wrap break-words items-center gap-3 min-w-0">
|
||||
<slot name="title">
|
||||
@@ -31,7 +31,7 @@
|
||||
</slot>
|
||||
</div>
|
||||
<ButtonStyled v-if="closable" circular>
|
||||
<button @click="hide" aria-label="Close">
|
||||
<button aria-label="Close" @click="hide">
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -56,6 +56,7 @@ const props = withDefaults(
|
||||
closable?: boolean
|
||||
danger?: boolean
|
||||
closeOnEsc?: boolean
|
||||
closeOnClickOutside?: boolean
|
||||
warnOnClose?: boolean
|
||||
header?: string
|
||||
onHide?: () => void
|
||||
@@ -65,6 +66,7 @@ const props = withDefaults(
|
||||
type: true,
|
||||
closable: true,
|
||||
danger: false,
|
||||
closeOnClickOutside: true,
|
||||
closeOnEsc: true,
|
||||
warnOnClose: false,
|
||||
onHide: () => {},
|
||||
@@ -161,7 +163,13 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-out;
|
||||
background: linear-gradient(to bottom, rgba(29, 48, 43, 0.52) 0%, rgba(14, 21, 26, 0.95) 100%);
|
||||
filter: blur(5px);
|
||||
//transform: translate(
|
||||
// calc((-50vw + var(--_mouse-x, 50vw) * 1px) / 2),
|
||||
// calc((-50vh + var(--_mouse-y, 50vh) * 1px) / 2)
|
||||
// )
|
||||
// scaleX(0.8) scaleY(0.5);
|
||||
border-radius: 180px;
|
||||
//filter: blur(5px);
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
|
||||
85
packages/ui/src/components/project/NewProjectCard.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="button-base p-4 bg-bg-raised rounded-xl flex gap-3 group">
|
||||
<div class="icon">
|
||||
<Avatar :src="project.icon_url" size="96px" class="search-icon" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 overflow-hidden">
|
||||
<div class="gap-2 overflow-hidden no-wrap text-ellipsis">
|
||||
<span class="text-lg font-extrabold text-contrast m-0 leading-none">{{
|
||||
project.title
|
||||
}}</span>
|
||||
<span v-if="project.author" class="text-secondary"> by {{ project.author }}</span>
|
||||
</div>
|
||||
<div class="m-0 line-clamp-2">
|
||||
{{ project.description }}
|
||||
</div>
|
||||
<div class="mt-auto flex items-center gap-1 no-wrap">
|
||||
<TagsIcon class="h-4 w-4 shrink-0" />
|
||||
<div
|
||||
v-for="tag in categories"
|
||||
:key="tag"
|
||||
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
|
||||
>
|
||||
{{ formatCategory(tag) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 items-end shrink-0 ml-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<DownloadIcon class="shrink-0" />
|
||||
<span>
|
||||
{{ formatNumber(project.downloads) }}
|
||||
<span class="text-secondary">downloads</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<HeartIcon class="shrink-0" />
|
||||
<span>
|
||||
{{ formatNumber(project.follows ?? project.followers) }}
|
||||
<span class="text-secondary">followers</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-auto relative">
|
||||
<div
|
||||
:class="{
|
||||
'group-hover:-translate-y-3 group-hover:opacity-0 group-focus-within:opacity-0 group-hover:scale-95 group-focus-within:scale-95 transition-all':
|
||||
$slots.actions,
|
||||
}"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<HistoryIcon class="shrink-0" />
|
||||
<span>
|
||||
<span class="text-secondary">Updated</span>
|
||||
{{ dayjs(project.date_modified ?? project.updated).fromNow() }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="opacity-0 scale-95 translate-y-3 group-hover:translate-y-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:opacity-100 group-focus-within:scale-100 absolute bottom-0 right-0 transition-all w-fit"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TagsIcon, DownloadIcon, HeartIcon, HistoryIcon } from '@modrinth/assets'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import { formatNumber, formatCategory } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div :style="`--_color: ${color}`" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
project: {
|
||||
body: string,
|
||||
color: number,
|
||||
}
|
||||
}>(),
|
||||
{
|
||||
},
|
||||
)
|
||||
|
||||
function clamp (value: number) {
|
||||
return Math.max(0, Math.min(255, value));
|
||||
}
|
||||
|
||||
function toHex (value: number) {
|
||||
return clamp(value).toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
function decimalToHexColor(decimal: number) {
|
||||
const r = (decimal >> 16) & 255;
|
||||
const g = (decimal >> 8) & 255;
|
||||
const b = decimal & 255;
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
|
||||
const color = computed(() => {
|
||||
return decimalToHexColor(props.project.color)
|
||||
})
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
div {
|
||||
width: 100%;
|
||||
height: 60rem;
|
||||
background: linear-gradient(to bottom, var(--_color), transparent);
|
||||
opacity: 0.075;
|
||||
}
|
||||
</style>
|
||||
74
packages/ui/src/components/project/ProjectHeader.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<ContentPageHeader>
|
||||
<template #icon>
|
||||
<Avatar :src="project.icon_url" :alt="project.title" size="96px" />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ project.title }}
|
||||
</template>
|
||||
<template #title-suffix>
|
||||
<ProjectStatusBadge
|
||||
v-if="member || project.status !== 'approved'"
|
||||
:status="project.status"
|
||||
/>
|
||||
</template>
|
||||
<template #summary>
|
||||
{{ project.description }}
|
||||
</template>
|
||||
<template #stats>
|
||||
<div
|
||||
v-tooltip="
|
||||
`${formatNumber(project.downloads, false)} download${project.downloads !== 1 ? 's' : ''}`
|
||||
"
|
||||
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold cursor-help"
|
||||
>
|
||||
<DownloadIcon class="h-6 w-6 text-secondary" />
|
||||
{{ formatNumber(project.downloads) }}
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="
|
||||
`${formatNumber(project.followers, false)} follower${project.downloads !== 1 ? 's' : ''}`
|
||||
"
|
||||
class="flex items-center gap-2 border-0 border-solid border-divider pr-4 md:border-r cursor-help"
|
||||
>
|
||||
<HeartIcon class="h-6 w-6 text-secondary" />
|
||||
<span class="font-semibold">
|
||||
{{ formatNumber(project.followers) }}
|
||||
</span>
|
||||
</div>
|
||||
<div 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"
|
||||
>
|
||||
{{ formatCategory(category) }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<slot name="actions" />
|
||||
</template>
|
||||
</ContentPageHeader>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, HeartIcon, TagsIcon } from '@modrinth/assets'
|
||||
import Badge from '../base/SimpleBadge.vue'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import ContentPageHeader from '../base/ContentPageHeader.vue'
|
||||
import { formatCategory, formatNumber, type Project } from '@modrinth/utils'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
import ProjectStatusBadge from './ProjectStatusBadge.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
project: Project
|
||||
member?: boolean
|
||||
}>(),
|
||||
{
|
||||
member: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="markdown-body" v-html="renderHighlightedString(description ?? '')" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
import { renderHighlightedString } from '@modrinth/utils'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
description: string,
|
||||
}>(),
|
||||
{
|
||||
},
|
||||
)
|
||||
</script>
|
||||
289
packages/ui/src/components/project/ProjectPageVersions.vue
Normal file
@@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
<VersionFilterControl
|
||||
ref="versionFilters"
|
||||
:versions="versions"
|
||||
:game-versions="gameVersions"
|
||||
:base-id="`${baseId}-filter`"
|
||||
@update:query="updateQuery"
|
||||
/>
|
||||
<Pagination
|
||||
:page="currentPage"
|
||||
class="ml-auto mt-auto"
|
||||
:count="Math.ceil(filteredVersions.length / pageSize)"
|
||||
@switch-page="switchPage"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="versions.length > 0"
|
||||
class="flex flex-col gap-4 rounded-2xl bg-bg-raised px-6 pb-8 pt-4 supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[1fr_min-content] sm:px-8 supports-[grid-template-columns:subgrid]:sm:grid-cols-[min-content_auto_auto_auto_min-content] supports-[grid-template-columns:subgrid]:xl:grid-cols-[min-content_auto_auto_auto_auto_auto_min-content]"
|
||||
>
|
||||
<div class="versions-grid-row">
|
||||
<div class="w-9 max-sm:hidden"></div>
|
||||
<div class="text-sm font-bold text-contrast max-sm:hidden">Name</div>
|
||||
<div
|
||||
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
|
||||
>
|
||||
Game version
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
|
||||
>
|
||||
Platforms
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
|
||||
>
|
||||
Published
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
|
||||
>
|
||||
Downloads
|
||||
</div>
|
||||
<div class="text-sm font-bold text-contrast max-sm:hidden xl:collapse xl:hidden">
|
||||
Compatibility
|
||||
</div>
|
||||
<div class="text-sm font-bold text-contrast max-sm:hidden xl:collapse xl:hidden">Stats</div>
|
||||
<div class="w-9 max-sm:hidden"></div>
|
||||
</div>
|
||||
<template v-for="(version, index) in currentVersions" :key="index">
|
||||
<!-- Row divider -->
|
||||
<div
|
||||
class="versions-grid-row h-px w-full bg-button-bg"
|
||||
:class="{
|
||||
'max-sm:!hidden': index === 0,
|
||||
}"
|
||||
></div>
|
||||
<div class="versions-grid-row group relative">
|
||||
<AutoLink
|
||||
v-if="!!versionLink"
|
||||
class="absolute inset-[calc(-1rem-2px)_-2rem] before:absolute before:inset-0 before:transition-all before:content-[''] hover:before:backdrop-brightness-110"
|
||||
:to="versionLink?.(version)"
|
||||
/>
|
||||
<div class="flex flex-col justify-center gap-2 sm:contents">
|
||||
<div class="flex flex-row items-center gap-2 sm:contents">
|
||||
<div class="self-center">
|
||||
<div class="relative z-[1] cursor-pointer">
|
||||
<VersionChannelIndicator
|
||||
v-tooltip="`Toggle filter for ${version.version_type}`"
|
||||
:channel="version.version_type"
|
||||
@click="versionFilters?.toggleFilter('channel', version.version_type)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none relative z-[1] flex flex-col justify-center"
|
||||
:class="{
|
||||
'group-hover:underline': !!versionLink,
|
||||
}"
|
||||
>
|
||||
<div class="font-bold text-contrast">{{ version.version_number }}</div>
|
||||
<div class="text-xs font-medium">{{ version.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center gap-2 sm:contents">
|
||||
<div class="flex flex-row flex-wrap items-center gap-1 xl:contents">
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<TagItem
|
||||
v-for="gameVersion in formatVersionsForDisplay(version.game_versions, gameVersions)"
|
||||
:key="`version-tag-${gameVersion}`"
|
||||
v-tooltip="`Toggle filter for ${gameVersion}`"
|
||||
class="z-[1]"
|
||||
:action="() => versionFilters?.toggleFilters('gameVersion', version.game_versions)"
|
||||
>
|
||||
{{ gameVersion }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<TagItem
|
||||
v-for="platform in version.loaders"
|
||||
:key="`platform-tag-${platform}`"
|
||||
v-tooltip="`Toggle filter for ${platform}`"
|
||||
class="z-[1]"
|
||||
:style="`--_color: var(--color-platform-${platform})`"
|
||||
:action="() => versionFilters?.toggleFilter('platform', platform)"
|
||||
>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<svg v-html="loaders.find((x) => x.name === platform)?.icon"></svg>
|
||||
{{ formatCategory(platform) }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col justify-center gap-1 max-sm:flex-row max-sm:justify-start max-sm:gap-3 xl:contents"
|
||||
>
|
||||
<div
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(version.date_published),
|
||||
time: new Date(version.date_published),
|
||||
})
|
||||
"
|
||||
class="z-[1] flex cursor-help items-center gap-1 text-nowrap font-medium xl:self-center"
|
||||
>
|
||||
<CalendarIcon class="xl:hidden" />
|
||||
{{ dayjs(version.date_published).fromNow() }}
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none z-[1] flex items-center gap-1 font-medium xl:self-center"
|
||||
>
|
||||
<DownloadIcon class="xl:hidden" />
|
||||
{{ formatNumber(version.downloads) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start justify-end gap-1 sm:items-center z-[1]">
|
||||
<slot name="actions" :version="version"></slot>
|
||||
</div>
|
||||
<div
|
||||
v-if="showFiles"
|
||||
class="tag-list pointer-events-none relative z-[1] col-span-full"
|
||||
>
|
||||
<div
|
||||
v-for="(file, fileIdx) in version.files"
|
||||
:key="`platform-tag-${fileIdx}`"
|
||||
:class="`flex items-center gap-1 text-wrap rounded-full bg-button-bg px-2 py-0.5 text-xs font-medium ${file.primary || fileIdx === 0 ? 'bg-brand-highlight text-contrast' : 'text-primary'}`"
|
||||
>
|
||||
<StarIcon v-if="file.primary || fileIdx === 0" class="shrink-0" />
|
||||
{{ file.filename }} - {{ formatBytes(file.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex mt-3">
|
||||
<Pagination
|
||||
:page="currentPage"
|
||||
class="ml-auto"
|
||||
:count="Math.ceil(filteredVersions.length / pageSize)"
|
||||
@switch-page="switchPage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
formatBytes,
|
||||
formatCategory, formatNumber,
|
||||
formatVersionsForDisplay,
|
||||
type GameVersionTag, type PlatformTag, type Version
|
||||
} from '@modrinth/utils'
|
||||
|
||||
import { commonMessages } from '../../utils/common-messages'
|
||||
import {
|
||||
CalendarIcon,
|
||||
DownloadIcon,
|
||||
StarIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Pagination, VersionChannelIndicator, VersionFilterControl } from '../index'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { type Ref, ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import AutoLink from '../base/AutoLink.vue'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
type VersionWithDisplayUrlEnding = Version & {
|
||||
displayUrlEnding: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
baseId?: string,
|
||||
project: {
|
||||
project_type: string
|
||||
slug?: string
|
||||
id: string
|
||||
},
|
||||
versions: VersionWithDisplayUrlEnding[],
|
||||
showFiles?: boolean,
|
||||
currentMember?: boolean,
|
||||
loaders: PlatformTag[],
|
||||
gameVersions: GameVersionTag[],
|
||||
versionLink?: (version: Version) => string,
|
||||
}>(),
|
||||
{
|
||||
baseId: undefined,
|
||||
showFiles: false,
|
||||
currentMember: false,
|
||||
versionLink: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const currentPage: Ref<number> = ref(1);
|
||||
const pageSize: Ref<number> = ref(20);
|
||||
const versionFilters: Ref<InstanceType<typeof VersionFilterControl> | null> = ref(null)
|
||||
|
||||
const selectedGameVersions: Ref<string[]> = computed(() => versionFilters.value?.selectedGameVersions ?? []);
|
||||
const selectedPlatforms: Ref<string[]> = computed(() => versionFilters.value?.selectedPlatforms ?? []);
|
||||
const selectedChannels: Ref<string[]> = computed(() => versionFilters.value?.selectedChannels ?? []);
|
||||
|
||||
const filteredVersions = computed(() => {
|
||||
return props.versions.filter(
|
||||
(version) =>
|
||||
hasAnySelected(version.game_versions, selectedGameVersions.value) &&
|
||||
hasAnySelected(version.loaders, selectedPlatforms.value) &&
|
||||
isAnySelected(version.version_type, selectedChannels.value)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
function hasAnySelected(values: string[], selected: string[]) {
|
||||
return selected.length === 0 || selected.some((value) => values.includes(value))
|
||||
}
|
||||
|
||||
function isAnySelected(value: string, selected: string[]) {
|
||||
return selected.length === 0 || selected.includes(value)
|
||||
}
|
||||
|
||||
const currentVersions = computed(() =>
|
||||
filteredVersions.value.slice((currentPage.value - 1) * pageSize.value, currentPage.value * pageSize.value));
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
if (route.query.page) {
|
||||
currentPage.value = Number(route.query.page) || 1;
|
||||
}
|
||||
|
||||
function switchPage(page: number) {
|
||||
currentPage.value = page;
|
||||
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
page: currentPage.value !== 1 ? currentPage.value : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function updateQuery(newQueries: Record<string, string | string[] | undefined | null>) {
|
||||
if (newQueries.page) {
|
||||
currentPage.value = Number(newQueries.page);
|
||||
} else if (newQueries.page === undefined) {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
...newQueries,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
<style scoped>
|
||||
.versions-grid-row {
|
||||
@apply grid grid-cols-[1fr_min-content] gap-4 supports-[grid-template-columns:subgrid]:col-span-full supports-[grid-template-columns:subgrid]:!grid-cols-subgrid sm:grid-cols-[min-content_1fr_1fr_1fr_min-content] xl:grid-cols-[min-content_1fr_1fr_1fr_1fr_1fr_min-content];
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div v-if="project.versions.length > 0" class="flex flex-col gap-3">
|
||||
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
|
||||
<section class="flex flex-col gap-2">
|
||||
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.minecraftJava) }}</h3>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<TagItem
|
||||
v-for="version in getVersionsToDisplay(project, tags.gameVersions)"
|
||||
:key="`version-tag-${version}`"
|
||||
>
|
||||
{{ version }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="project.project_type !== 'resourcepack'" class="flex flex-col gap-2">
|
||||
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.platforms) }}</h3>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<TagItem
|
||||
v-for="platform in project.loaders"
|
||||
:key="`platform-tag-${platform}`"
|
||||
:style="`--_color: var(--color-platform-${platform})`"
|
||||
>
|
||||
<svg v-html="tags.loaders.find((x) => x.name === platform).icon"></svg>
|
||||
{{ formatCategory(platform) }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-if="
|
||||
(project.actualProjectType === 'mod' || project.project_type === 'modpack') &&
|
||||
!(project.client_side === 'unsupported' && project.server_side === 'unsupported') &&
|
||||
!(project.client_side === 'unknown' && project.server_side === 'unknown')
|
||||
"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.environments) }}</h3>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<TagItem
|
||||
v-if="
|
||||
(project.client_side === 'required' && project.server_side !== 'required') ||
|
||||
(project.client_side === 'optional' && project.server_side === 'optional')
|
||||
"
|
||||
>
|
||||
<ClientIcon aria-hidden="true" />
|
||||
Client-side
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-if="
|
||||
(project.server_side === 'required' && project.client_side !== 'required') ||
|
||||
(project.client_side === 'optional' && project.server_side === 'optional')
|
||||
"
|
||||
>
|
||||
<ServerIcon aria-hidden="true" />
|
||||
Server-side
|
||||
</TagItem>
|
||||
<TagItem v-if="false">
|
||||
<UserIcon aria-hidden="true" />
|
||||
Singleplayer
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-if="
|
||||
project.project_type !== 'datapack' &&
|
||||
project.client_side !== 'unsupported' && project.server_side !== 'unsupported' && project.client_side !== 'unknown' && project.server_side !== 'unknown'
|
||||
"
|
||||
>
|
||||
<MonitorSmartphoneIcon aria-hidden="true" />
|
||||
Client and server
|
||||
</TagItem>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ClientIcon, MonitorSmartphoneIcon, ServerIcon, UserIcon } from '@modrinth/assets'
|
||||
import { formatCategory, getVersionsToDisplay } from '@modrinth/utils'
|
||||
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
type EnvironmentValue = 'optional' | 'required' | 'unsupported' | 'unknown'
|
||||
|
||||
defineProps<{
|
||||
project: {
|
||||
actualProjectType: string
|
||||
project_type: string
|
||||
loaders: string[]
|
||||
client_side: EnvironmentValue
|
||||
server_side: EnvironmentValue
|
||||
versions: any[]
|
||||
}
|
||||
tags: {
|
||||
gameVersions: GameVersionTag[]
|
||||
loaders: PlatformTag[]
|
||||
}
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'project.about.compatibility.title',
|
||||
defaultMessage: 'Compatibility',
|
||||
},
|
||||
minecraftJava: {
|
||||
id: 'project.about.compatibility.game.minecraftJava',
|
||||
defaultMessage: 'Minecraft: Java Edition',
|
||||
},
|
||||
platforms: {
|
||||
id: 'project.about.compatibility.platforms',
|
||||
defaultMessage: 'Platforms',
|
||||
},
|
||||
environments: {
|
||||
id: 'project.about.compatibility.environments',
|
||||
defaultMessage: 'Supported environments',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
118
packages/ui/src/components/project/ProjectSidebarCreators.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
|
||||
<div class="flex flex-col gap-3 font-semibold">
|
||||
<template v-if="organization">
|
||||
<AutoLink
|
||||
class="flex gap-2 items-center w-fit text-primary leading-[1.2] group"
|
||||
:to="orgLink(organization.slug)"
|
||||
:target="linkTarget ?? null"
|
||||
>
|
||||
<Avatar :src="organization.icon_url" :alt="organization.name" size="32px" />
|
||||
<div class="flex flex-col flex-nowrap justify-center">
|
||||
<span class="group-hover:underline">
|
||||
{{ organization.name }}
|
||||
</span>
|
||||
<span class="text-secondary text-sm font-medium flex items-center gap-1"
|
||||
><OrganizationIcon /> Organization</span
|
||||
>
|
||||
</div>
|
||||
</AutoLink>
|
||||
<hr v-if="sortedMembers.length > 0" class="w-full border-button-border my-0.5" />
|
||||
</template>
|
||||
<AutoLink
|
||||
v-for="member in sortedMembers"
|
||||
:key="`member-${member.id}`"
|
||||
class="flex gap-2 items-center w-fit text-primary leading-[1.2] group"
|
||||
:to="userLink(member.user.username)"
|
||||
:target="linkTarget ?? null"
|
||||
>
|
||||
<Avatar :src="member.user.avatar_url" :alt="member.user.username" size="32px" circle />
|
||||
<div class="flex flex-col">
|
||||
<span class="flex flex-row flex-nowrap items-center gap-1 group-hover:underline">
|
||||
{{ member.user.username }}
|
||||
<CrownIcon
|
||||
v-if="member.is_owner"
|
||||
v-tooltip="formatMessage(messages.owner)"
|
||||
class="text-brand-orange"
|
||||
/>
|
||||
<ExternalIcon v-if="linkTarget === '_blank'" />
|
||||
</span>
|
||||
<span class="text-secondary text-sm font-medium">{{ member.role }}</span>
|
||||
</div>
|
||||
</AutoLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CrownIcon, ExternalIcon, OrganizationIcon } from '@modrinth/assets'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import AutoLink from '../base/AutoLink.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
type TeamMember = {
|
||||
id: string
|
||||
role: string
|
||||
is_owner: boolean
|
||||
accepted: boolean
|
||||
user: {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url: string
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
organization?: {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
icon_url: string
|
||||
avatar_url: string
|
||||
members: TeamMember[]
|
||||
} | null
|
||||
members: TeamMember[]
|
||||
orgLink: (slug: string) => string
|
||||
userLink: (username: string) => string
|
||||
linkTarget?: string
|
||||
}>()
|
||||
|
||||
// Members should be an array of all members, without the accepted ones, and with the user with the Owner role at the start
|
||||
// The rest of the members should be sorted by role, then by name
|
||||
const sortedMembers = computed(() => {
|
||||
const acceptedMembers = props.members.filter((x) => x.accepted === undefined || x.accepted)
|
||||
const owner = acceptedMembers.find((x) =>
|
||||
props.organization
|
||||
? props.organization.members.some(
|
||||
(orgMember) => orgMember.user.id === x.user.id && orgMember.is_owner,
|
||||
)
|
||||
: x.is_owner,
|
||||
)
|
||||
|
||||
const rest = acceptedMembers.filter((x) => !owner || x.user.id !== owner.user.id) || []
|
||||
|
||||
rest.sort((a, b) => {
|
||||
if (a.role === b.role) {
|
||||
return a.user.username.localeCompare(b.user.username)
|
||||
} else {
|
||||
return a.role.localeCompare(b.role)
|
||||
}
|
||||
})
|
||||
|
||||
return owner ? [owner, ...rest] : rest
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'project.about.creators.title',
|
||||
defaultMessage: 'Creators',
|
||||
},
|
||||
owner: {
|
||||
id: 'project.about.creators.owner',
|
||||
defaultMessage: 'Project owner',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
142
packages/ui/src/components/project/ProjectSidebarDetails.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
|
||||
<div class="flex flex-col gap-3 font-semibold [&>div]:flex [&>div]:gap-2 [&>div]:items-center">
|
||||
<div>
|
||||
<BookTextIcon aria-hidden="true" />
|
||||
<div>
|
||||
Licensed
|
||||
<a
|
||||
v-if="project.license.url"
|
||||
class="text-link hover:underline"
|
||||
:href="project.license.url"
|
||||
:target="linkTarget"
|
||||
rel="noopener nofollow ugc"
|
||||
>
|
||||
{{ licenseIdDisplay }}
|
||||
<ExternalIcon aria-hidden="true" class="external-icon ml-1 mt-[-1px] inline" />
|
||||
</a>
|
||||
<span
|
||||
v-else-if="
|
||||
project.license.id === 'LicenseRef-All-Rights-Reserved' ||
|
||||
!project.license.id.includes('LicenseRef')
|
||||
"
|
||||
>
|
||||
{{ licenseIdDisplay }}
|
||||
</span>
|
||||
<span v-else>{{ licenseIdDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="project.approved"
|
||||
v-tooltip="dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
|
||||
>
|
||||
<CalendarIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(messages.published, { date: publishedDate }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else v-tooltip="dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')">
|
||||
<CalendarIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(messages.created, { date: createdDate }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="project.status === 'processing' && project.queued"
|
||||
v-tooltip="dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
|
||||
>
|
||||
<ScaleIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(messages.submitted, { date: submittedDate }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasVersions && project.updated"
|
||||
v-tooltip="dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
|
||||
>
|
||||
<VersionIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(messages.updated, { date: updatedDate }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { BookTextIcon, CalendarIcon, ScaleIcon, VersionIcon, ExternalIcon } from '@modrinth/assets'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
import { computed, ref } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
project: {
|
||||
id: string
|
||||
published: string
|
||||
updated: string
|
||||
approved: string
|
||||
queued: string
|
||||
status: string
|
||||
license: {
|
||||
id: string
|
||||
url: string
|
||||
}
|
||||
}
|
||||
linkTarget: string
|
||||
hasVersions: boolean
|
||||
}>()
|
||||
|
||||
const createdDate = computed(() =>
|
||||
props.project.published ? dayjs(props.project.published).fromNow() : 'unknown',
|
||||
)
|
||||
const submittedDate = computed(() =>
|
||||
props.project.queued ? dayjs(props.project.queued).fromNow() : 'unknown',
|
||||
)
|
||||
const publishedDate = computed(() =>
|
||||
props.project.approved ? dayjs(props.project.approved).fromNow() : 'unknown',
|
||||
)
|
||||
const updatedDate = computed(() =>
|
||||
props.project.updated ? dayjs(props.project.updated).fromNow() : 'unknown',
|
||||
)
|
||||
|
||||
const licenseIdDisplay = computed(() => {
|
||||
const id = props.project.license.id
|
||||
|
||||
if (id === 'LicenseRef-All-Rights-Reserved') {
|
||||
return 'ARR'
|
||||
} else if (id.includes('LicenseRef')) {
|
||||
return id.replaceAll('LicenseRef-', '').replaceAll('-', ' ')
|
||||
} else {
|
||||
return id
|
||||
}
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'project.about.details.title',
|
||||
defaultMessage: 'Details',
|
||||
},
|
||||
licensed: {
|
||||
id: 'project.about.details.licensed',
|
||||
defaultMessage: 'Licensed {license}',
|
||||
},
|
||||
created: {
|
||||
id: 'project.about.details.created',
|
||||
defaultMessage: 'Created {date}',
|
||||
},
|
||||
submitted: {
|
||||
id: 'project.about.details.submitted',
|
||||
defaultMessage: 'Submitted {date}',
|
||||
},
|
||||
published: {
|
||||
id: 'project.about.details.published',
|
||||
defaultMessage: 'Published {date}',
|
||||
},
|
||||
updated: {
|
||||
id: 'project.about.details.updated',
|
||||
defaultMessage: 'Updated {date}',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
173
packages/ui/src/components/project/ProjectSidebarLinks.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="
|
||||
project.issues_url ||
|
||||
project.source_url ||
|
||||
project.wiki_url ||
|
||||
project.discord_url ||
|
||||
project.donation_urls.length > 0
|
||||
"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
|
||||
<div
|
||||
class="flex flex-col gap-3 font-semibold [&>a]:flex [&>a]:gap-2 [&>a]:items-center [&>a]:w-fit [&>a]:text-primary [&>a]:leading-[1.2] [&>a:hover]:underline"
|
||||
>
|
||||
<a
|
||||
v-if="project.issues_url"
|
||||
:href="project.issues_url"
|
||||
:target="linkTarget"
|
||||
rel="noopener nofollow ugc"
|
||||
>
|
||||
<IssuesIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.issues) }}
|
||||
<ExternalIcon aria-hidden="true" class="external-icon" />
|
||||
</a>
|
||||
<a
|
||||
v-if="project.source_url"
|
||||
:href="project.source_url"
|
||||
:target="linkTarget"
|
||||
rel="noopener nofollow ugc"
|
||||
>
|
||||
<CodeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.source) }}
|
||||
<ExternalIcon aria-hidden="true" class="external-icon" />
|
||||
</a>
|
||||
<a
|
||||
v-if="project.wiki_url"
|
||||
:href="project.wiki_url"
|
||||
:target="linkTarget"
|
||||
rel="noopener nofollow ugc"
|
||||
>
|
||||
<WikiIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.wiki) }}
|
||||
<ExternalIcon aria-hidden="true" class="external-icon" />
|
||||
</a>
|
||||
<a
|
||||
v-if="project.discord_url"
|
||||
:href="project.discord_url"
|
||||
:target="linkTarget"
|
||||
rel="noopener nofollow ugc"
|
||||
>
|
||||
<DiscordIcon class="shrink" aria-hidden="true" />
|
||||
{{ formatMessage(messages.discord) }}
|
||||
<ExternalIcon aria-hidden="true" class="external-icon" />
|
||||
</a>
|
||||
<hr
|
||||
v-if="
|
||||
(project.issues_url || project.source_url || project.wiki_url || project.discord_url) &&
|
||||
project.donation_urls.length > 0
|
||||
"
|
||||
class="w-full border-button-border my-0.5"
|
||||
/>
|
||||
<a
|
||||
v-for="(donation, index) in project.donation_urls"
|
||||
:key="index"
|
||||
:href="donation.url"
|
||||
:target="linkTarget"
|
||||
rel="noopener nofollow ugc"
|
||||
>
|
||||
<BuyMeACoffeeIcon v-if="donation.id === 'bmac'" aria-hidden="true" />
|
||||
<PatreonIcon v-else-if="donation.id === 'patreon'" aria-hidden="true" />
|
||||
<KoFiIcon v-else-if="donation.id === 'ko-fi'" aria-hidden="true" />
|
||||
<PayPalIcon v-else-if="donation.id === 'paypal'" aria-hidden="true" />
|
||||
<OpenCollectiveIcon v-else-if="donation.id === 'open-collective'" aria-hidden="true" />
|
||||
<HeartIcon v-else-if="donation.id === 'github'" />
|
||||
<CurrencyIcon v-else />
|
||||
<span v-if="donation.id === 'bmac'">{{ formatMessage(messages.donateBmac) }}</span>
|
||||
<span v-else-if="donation.id === 'patreon'">{{
|
||||
formatMessage(messages.donatePatreon)
|
||||
}}</span>
|
||||
<span v-else-if="donation.id === 'paypal'">{{ formatMessage(messages.donatePayPal) }}</span>
|
||||
<span v-else-if="donation.id === 'ko-fi'">{{ formatMessage(messages.donateKoFi) }}</span>
|
||||
<span v-else-if="donation.id === 'github'">{{ formatMessage(messages.donateGithub) }}</span>
|
||||
<span v-else>{{ formatMessage(messages.donateGeneric) }}</span>
|
||||
<ExternalIcon aria-hidden="true" class="external-icon" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BuyMeACoffeeIcon,
|
||||
CodeIcon,
|
||||
CurrencyIcon,
|
||||
DiscordIcon,
|
||||
ExternalIcon,
|
||||
HeartIcon,
|
||||
IssuesIcon,
|
||||
KoFiIcon,
|
||||
OpenCollectiveIcon,
|
||||
PatreonIcon,
|
||||
PayPalIcon,
|
||||
WikiIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
defineProps<{
|
||||
project: {
|
||||
issues_url: string
|
||||
source_url: string
|
||||
wiki_url: string
|
||||
discord_url: string
|
||||
donation_urls: {
|
||||
id: string
|
||||
url: string
|
||||
}[]
|
||||
}
|
||||
linkTarget: string
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'project.about.links.title',
|
||||
defaultMessage: 'Links',
|
||||
},
|
||||
issues: {
|
||||
id: 'project.about.links.issues',
|
||||
defaultMessage: 'Report issues',
|
||||
},
|
||||
source: {
|
||||
id: 'project.about.links.source',
|
||||
defaultMessage: 'View source',
|
||||
},
|
||||
wiki: {
|
||||
id: 'project.about.links.wiki',
|
||||
defaultMessage: 'Visit wiki',
|
||||
},
|
||||
discord: {
|
||||
id: 'project.about.links.discord',
|
||||
defaultMessage: 'Join Discord server',
|
||||
},
|
||||
donateGeneric: {
|
||||
id: 'project.about.links.donate.generic',
|
||||
defaultMessage: 'Donate',
|
||||
},
|
||||
donateGitHub: {
|
||||
id: 'project.about.links.donate.github',
|
||||
defaultMessage: 'Sponsor on GitHub',
|
||||
},
|
||||
donateBmac: {
|
||||
id: 'project.about.links.donate.bmac',
|
||||
defaultMessage: 'Buy Me a Coffee',
|
||||
},
|
||||
donatePatreon: {
|
||||
id: 'project.about.links.donate.patreon',
|
||||
defaultMessage: 'Donate on Patreon',
|
||||
},
|
||||
donatePayPal: {
|
||||
id: 'project.about.links.donate.paypal',
|
||||
defaultMessage: 'Donate on PayPal',
|
||||
},
|
||||
donateKoFi: {
|
||||
id: 'project.about.links.donate.kofi',
|
||||
defaultMessage: 'Donate on Ko-fi',
|
||||
},
|
||||
donateGithub: {
|
||||
id: 'project.about.links.donate.github',
|
||||
defaultMessage: 'Sponsor on GitHub',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
105
packages/ui/src/components/project/ProjectStatusBadge.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<Badge :icon="metadata.icon" :formatted-name="metadata.formattedName" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
FileTextIcon,
|
||||
ArchiveIcon,
|
||||
UpdatedIcon,
|
||||
LockIcon,
|
||||
CalendarIcon,
|
||||
GlobeIcon,
|
||||
LinkIcon,
|
||||
UnknownIcon, XIcon
|
||||
} from '@modrinth/assets'
|
||||
import { useVIntl, defineMessage, type MessageDescriptor } from '@vintl/vintl'
|
||||
import type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import Badge from '../base/SimpleBadge.vue'
|
||||
import type { ProjectStatus } from '@modrinth/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
status: ProjectStatus
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const metadata = computed(() => ({
|
||||
icon: statusMetadata[props.status]?.icon ?? statusMetadata.unknown.icon,
|
||||
formattedName: formatMessage(statusMetadata[props.status]?.message ?? props.status),
|
||||
}))
|
||||
|
||||
const statusMetadata: Record<ProjectStatus, { icon?: Component, message: MessageDescriptor }> = {
|
||||
approved: {
|
||||
icon: GlobeIcon,
|
||||
message: defineMessage({
|
||||
id: 'project.visibility.public',
|
||||
defaultMessage: 'Public',
|
||||
}),
|
||||
},
|
||||
unlisted: {
|
||||
icon: LinkIcon,
|
||||
message: defineMessage({
|
||||
id: 'project.visibility.unlisted',
|
||||
defaultMessage: 'Unlisted',
|
||||
}),
|
||||
},
|
||||
withheld: {
|
||||
icon: LinkIcon,
|
||||
message: defineMessage({
|
||||
id: 'project.visibility.unlisted-by-staff',
|
||||
defaultMessage: 'Unlisted by staff',
|
||||
}),
|
||||
},
|
||||
private: {
|
||||
icon: LockIcon,
|
||||
message: defineMessage({
|
||||
id: 'project.visibility.private',
|
||||
defaultMessage: 'Private',
|
||||
}),
|
||||
},
|
||||
scheduled: {
|
||||
icon: CalendarIcon,
|
||||
message: defineMessage({
|
||||
id: 'project.visibility.scheduled',
|
||||
defaultMessage: 'Scheduled',
|
||||
}),
|
||||
},
|
||||
draft: {
|
||||
icon: FileTextIcon,
|
||||
message: defineMessage({
|
||||
id: 'project.visibility.draft',
|
||||
defaultMessage: 'Draft',
|
||||
}),
|
||||
},
|
||||
archived: {
|
||||
icon: ArchiveIcon,
|
||||
message: defineMessage({
|
||||
id: 'project.visibility.archived',
|
||||
defaultMessage: 'Archived',
|
||||
}),
|
||||
},
|
||||
rejected: {
|
||||
icon: XIcon,
|
||||
message: defineMessage({
|
||||
id: 'project.visibility.rejected',
|
||||
defaultMessage: 'Rejected',
|
||||
}),
|
||||
},
|
||||
processing: {
|
||||
icon: UpdatedIcon,
|
||||
message: defineMessage({
|
||||
id: 'project.visibility.under-review',
|
||||
defaultMessage: 'Under review',
|
||||
}),
|
||||
},
|
||||
unknown: {
|
||||
icon: UnknownIcon,
|
||||
message: defineMessage({
|
||||
id: 'project.visibility.unknown',
|
||||
defaultMessage: 'Unknown',
|
||||
}),
|
||||
},
|
||||
}
|
||||
</script>
|
||||
105
packages/ui/src/components/search/BrowseFiltersPanel.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div>
|
||||
<Accordion
|
||||
v-for="filter in filters"
|
||||
:key="filter.id"
|
||||
v-model="filters"
|
||||
v-bind="$attrs"
|
||||
:button-class="buttonClass"
|
||||
:content-class="contentClass"
|
||||
open-by-default
|
||||
>
|
||||
<template #title>
|
||||
<slot name="header" :filter="filter">
|
||||
<h2>{{ filter.formatted_name }}</h2>
|
||||
</slot>
|
||||
</template>
|
||||
<template #default>
|
||||
<template v-for="option in filter.options" :key="`${filter.id}-${option}`">
|
||||
<slot name="option" :filter="filter" :option="option">
|
||||
<div>
|
||||
{{ option.formatted_name }}
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
</template>
|
||||
</Accordion>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Accordion from '../base/Accordion.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface FilterOption<T> {
|
||||
id: string
|
||||
formatted_name: string
|
||||
data: T
|
||||
}
|
||||
|
||||
interface FilterType<T> {
|
||||
id: string
|
||||
formatted_name: string
|
||||
scrollable?: boolean
|
||||
options: FilterOption<T>[]
|
||||
}
|
||||
|
||||
interface GameVersion {
|
||||
version: string
|
||||
version_type: 'release' | 'snapshot' | 'alpha' | 'beta'
|
||||
date: string
|
||||
major: boolean
|
||||
}
|
||||
|
||||
type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader' | 'datapack' | 'plugin'
|
||||
|
||||
interface Platform {
|
||||
name: string
|
||||
icon: string
|
||||
supported_project_types: ProjectType[]
|
||||
default: boolean
|
||||
formatted_name: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
buttonClass?: string
|
||||
contentClass?: string
|
||||
gameVersions?: GameVersion[]
|
||||
platforms: Platform[]
|
||||
}>()
|
||||
|
||||
const filters = computed(() => {
|
||||
const filters: FilterType<any>[] = [
|
||||
{
|
||||
id: 'platform',
|
||||
formatted_name: 'Platform',
|
||||
options:
|
||||
props.platforms
|
||||
.filter((x) => x.default && x.supported_project_types.includes('modpack'))
|
||||
.map((x) => ({
|
||||
id: x.name,
|
||||
formatted_name: x.formatted_name,
|
||||
data: x,
|
||||
})) || [],
|
||||
},
|
||||
{
|
||||
id: 'gameVersion',
|
||||
formatted_name: 'Game version',
|
||||
options:
|
||||
props.gameVersions
|
||||
?.filter((x) => x.major && x.version_type === 'release')
|
||||
.map((x) => ({
|
||||
id: x.version,
|
||||
formatted_name: x.version,
|
||||
data: x,
|
||||
})) || [],
|
||||
},
|
||||
]
|
||||
|
||||
return filters
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
97
packages/ui/src/components/search/SearchFilterControl.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="experimental-styles-within flex flex-wrap items-center gap-1 empty:hidden">
|
||||
<TagItem
|
||||
v-if="selectedItems.length > 1"
|
||||
class="transition-transform active:scale-[0.95]"
|
||||
:action="clearFilters"
|
||||
>
|
||||
<XCircleIcon />
|
||||
Clear all filters
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-for="selectedItem in selectedItems"
|
||||
:key="`remove-filter-${selectedItem.type}-${selectedItem.option}`"
|
||||
:action="() => removeFilter(selectedItem)"
|
||||
>
|
||||
<XIcon />
|
||||
<BanIcon v-if="selectedItem.negative" class="text-brand-red" />
|
||||
{{ selectedItem.formatted_name ?? selectedItem.option }}
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-for="providedItem in items.filter((x) => x.provided)"
|
||||
:key="`provided-filter-${providedItem.type}-${providedItem.option}`"
|
||||
v-tooltip="formatMessage(providedMessage ?? defaultProvidedMessage)"
|
||||
:style="{ '--_bg-color': `var(--color-raised-bg)` }"
|
||||
>
|
||||
<LockIcon />
|
||||
{{ providedItem.formatted_name ?? providedItem.option }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XCircleIcon, XIcon, LockIcon, BanIcon } from '@modrinth/assets'
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
import type { FilterValue, FilterType, FilterOption } from '../../utils/search'
|
||||
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
filters: FilterType[]
|
||||
providedFilters: FilterValue[]
|
||||
overriddenProvidedFilterTypes: string[]
|
||||
providedMessage?: MessageDescriptor
|
||||
}>()
|
||||
|
||||
const defaultProvidedMessage = defineMessage({
|
||||
id: 'search.filter.locked.default',
|
||||
defaultMessage: 'Filter locked',
|
||||
})
|
||||
|
||||
type Item = {
|
||||
type: string
|
||||
option: string
|
||||
negative?: boolean
|
||||
formatted_name?: string
|
||||
provided: boolean
|
||||
}
|
||||
|
||||
function filterMatches(type: FilterType, option: FilterOption, list: FilterValue[]) {
|
||||
return list.some((provided) => provided.type === type.id && provided.option === option.id)
|
||||
}
|
||||
|
||||
const items: ComputedRef<Item[]> = computed(() => {
|
||||
return props.filters.flatMap((type) =>
|
||||
type.options
|
||||
.filter(
|
||||
(option) =>
|
||||
filterMatches(type, option, selectedFilters.value) ||
|
||||
filterMatches(type, option, props.providedFilters),
|
||||
)
|
||||
.map((option) => ({
|
||||
type: type.id,
|
||||
option: option.id,
|
||||
negative: selectedFilters.value.find((x) => x.type === type.id && x.option === option.id)
|
||||
?.negative,
|
||||
provided: filterMatches(type, option, props.providedFilters),
|
||||
formatted_name: option.formatted_name,
|
||||
})),
|
||||
)
|
||||
})
|
||||
|
||||
const selectedItems = computed(() => items.value.filter((x) => !x.provided))
|
||||
|
||||
function removeFilter(filter: Item) {
|
||||
selectedFilters.value = selectedFilters.value.filter(
|
||||
(x) => x.type !== filter.type || x.option !== filter.option,
|
||||
)
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
selectedFilters.value = []
|
||||
}
|
||||
</script>
|
||||
59
packages/ui/src/components/search/SearchFilterOption.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="search-filter-option group flex gap-1 items-center">
|
||||
<button
|
||||
:class="`flex border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-2 [@media(hover:hover)]:py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98] ${included ? 'bg-brand-highlight text-contrast hover:brightness-125' : excluded ? 'bg-highlight-red text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg focus-visible:bg-button-bg [&>svg.check-icon]:hover:text-brand [&>svg.check-icon]:focus-visible:text-brand'}`"
|
||||
@click="() => emit('toggle', option)"
|
||||
>
|
||||
<slot>
|
||||
</slot>
|
||||
<BanIcon
|
||||
v-if="excluded"
|
||||
:class="`filter-action-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${excluded ? '' : '[@media(hover:hover)]:opacity-0'}`"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckIcon
|
||||
v-else
|
||||
:class="`filter-action-icon check-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${included ? '' : '[@media(hover:hover)]:opacity-0'}`"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="supportsNegativeFilter && !excluded" class="w-px h-[1.75rem] bg-button-bg [@media(hover:hover)]:contents" :class="{ 'opacity-0': included }">
|
||||
</div>
|
||||
<button
|
||||
v-if="supportsNegativeFilter && !excluded"
|
||||
v-tooltip="excluded ? 'Remove exclusion' : 'Exclude'"
|
||||
class="flex border-none cursor-pointer items-center justify-center gap-2 rounded-xl bg-transparent px-2 py-1 text-sm font-semibold text-secondary [@media(hover:hover)]:opacity-0 transition-all hover:bg-button-bg hover:text-red focus-visible:bg-button-bg focus-visible:text-red active:scale-[0.96]"
|
||||
@click="() => emit('toggleExclude', option)"
|
||||
>
|
||||
<BanIcon class="filter-action-icon h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BanIcon, CheckIcon } from '@modrinth/assets'
|
||||
import type { FilterOption } from '../../utils/search'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
option: FilterOption
|
||||
included: boolean
|
||||
excluded: boolean
|
||||
supportsNegativeFilter?: boolean
|
||||
}>(), {
|
||||
supportsNegativeFilter: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [option: FilterOption]
|
||||
toggleExclude: [option: FilterOption]
|
||||
}>()
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.search-filter-option:hover,
|
||||
.search-filter-option:has(button:focus-visible) {
|
||||
button,
|
||||
.filter-action-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
334
packages/ui/src/components/search/SearchSidebarFilter.vue
Normal file
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<Accordion
|
||||
v-bind="$attrs"
|
||||
ref="accordion"
|
||||
:button-class="buttonClass ?? 'flex flex-col gap-2 justify-start items-start'"
|
||||
:content-class="contentClass"
|
||||
title-wrapper-class="flex flex-col gap-2 justify-start items-start"
|
||||
:open-by-default="!locked && (openByDefault !== undefined ? openByDefault : true)"
|
||||
>
|
||||
<template #title>
|
||||
<slot name="header" :filter="filterType">
|
||||
<h2>{{ filterType.formatted_name }}</h2>
|
||||
</slot>
|
||||
</template>
|
||||
<template
|
||||
v-if="
|
||||
locked ||
|
||||
(!!accordion &&
|
||||
!accordion.isOpen &&
|
||||
(selectedFilterOptions.length > 0 || selectedNegativeFilterOptions.length > 0))
|
||||
"
|
||||
#summary
|
||||
>
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
<div
|
||||
v-for="option in selectedFilterOptions"
|
||||
:key="`selected-filter-${filterType.id}-${option}`"
|
||||
class="flex gap-1 text-xs bg-button-bg px-2 py-0.5 rounded-full font-bold text-secondary w-fit shrink-0 items-center"
|
||||
>
|
||||
{{ option.formatted_name ?? option.id }}
|
||||
</div>
|
||||
<div
|
||||
v-for="option in selectedNegativeFilterOptions"
|
||||
:key="`excluded-filter-${filterType.id}-${option}`"
|
||||
class="flex gap-1 text-xs bg-button-bg px-2 py-0.5 rounded-full font-bold text-secondary w-fit shrink-0 items-center"
|
||||
>
|
||||
<BanIcon class="text-brand-red" /> {{ option.formatted_name ?? option.id }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="locked" #default>
|
||||
<div class="flex flex-col gap-2 p-3 border-dashed border-2 rounded-2xl border-divider mx-2">
|
||||
<p class="m-0 font-bold items-center">
|
||||
<slot :name="`locked-${filterType.id}`" >
|
||||
{{ formatMessage(messages.lockedTitle, { type: filterType.formatted_name }) }}
|
||||
</slot>
|
||||
</p>
|
||||
<p class="m-0 text-secondary text-sm">
|
||||
{{ formatMessage(messages.lockedDescription) }}
|
||||
</p>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="w-fit"
|
||||
@click="
|
||||
() => {
|
||||
overriddenProvidedFilterTypes.push(filterType.id)
|
||||
}
|
||||
"
|
||||
>
|
||||
<LockOpenIcon />
|
||||
{{ formatMessage(messages.unlockFilterButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else #default>
|
||||
<div v-if="filterType.searchable" class="iconified-input mx-2 my-1 !flex">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input
|
||||
:id="`search-${filterType.id}`"
|
||||
v-model="query"
|
||||
class="!min-h-9 text-sm"
|
||||
type="text"
|
||||
:placeholder="`Search...`"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Button v-if="query" class="r-btn" aria-label="Clear search" @click="() => (query = '')">
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
|
||||
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
|
||||
<SearchFilterOption
|
||||
v-for="option in visibleOptions"
|
||||
:key="`${filterType.id}-${option}`"
|
||||
:option="option"
|
||||
:included="isIncluded(option)"
|
||||
:excluded="isExcluded(option)"
|
||||
:supports-negative-filter="filterType.supports_negative_filter"
|
||||
:class="{
|
||||
'mr-3': scrollable,
|
||||
}"
|
||||
@toggle="toggleFilter"
|
||||
@toggle-exclude="toggleNegativeFilter"
|
||||
>
|
||||
<slot name="option" :filter="filterType" :option="option">
|
||||
<div v-if="typeof option.icon === 'string'" class="h-4 w-4" v-html="option.icon" />
|
||||
<component :is="option.icon" v-else-if="option.icon" class="h-4 w-4" />
|
||||
<span class="truncate text-sm">{{ option.formatted_name ?? option.id }}</span>
|
||||
</slot>
|
||||
</SearchFilterOption>
|
||||
<button
|
||||
v-if="filterType.display === 'expandable'"
|
||||
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
|
||||
@click="showMore = !showMore"
|
||||
>
|
||||
<DropdownIcon
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="{ 'rotate-180': showMore }"
|
||||
/>
|
||||
<span class="truncate text-sm">{{ showMore ? 'Show fewer' : 'Show more' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</ScrollablePanel>
|
||||
<div :class="innerPanelClass ? innerPanelClass : ''" class="empty:hidden">
|
||||
<Checkbox
|
||||
v-for="group in filterType.toggle_groups"
|
||||
:key="`toggle-group-${group.id}`"
|
||||
class="mx-2"
|
||||
:model-value="groupEnabled(group.id)"
|
||||
:label="`${group.formatted_name}`"
|
||||
@update:model-value="toggleGroup(group.id)"
|
||||
/>
|
||||
<div v-if="hasProvidedFilter" class="mt-2 mx-1">
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="w-fit"
|
||||
@click="
|
||||
() => {
|
||||
overriddenProvidedFilterTypes = overriddenProvidedFilterTypes.filter(
|
||||
(id) => id !== filterType.id,
|
||||
)
|
||||
accordion?.close()
|
||||
clearFilters()
|
||||
}
|
||||
"
|
||||
>
|
||||
<UpdatedIcon />
|
||||
<slot name="sync-button">
|
||||
{{ formatMessage(messages.syncFilterButton) }}
|
||||
</slot>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Accordion>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Accordion from '../base/Accordion.vue'
|
||||
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
|
||||
import {
|
||||
BanIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
UpdatedIcon,
|
||||
LockOpenIcon,
|
||||
DropdownIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Button, Checkbox, ScrollablePanel } from '../index'
|
||||
import { computed, ref } from 'vue'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import SearchFilterOption from './SearchFilterOption.vue'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
|
||||
const toggledGroups = defineModel<string[]>('toggledGroups', { required: true })
|
||||
const overriddenProvidedFilterTypes = defineModel<string[]>('overriddenProvidedFilterTypes', {
|
||||
required: false,
|
||||
default: [],
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
filterType: FilterType
|
||||
buttonClass?: string
|
||||
contentClass?: string
|
||||
innerPanelClass?: string
|
||||
openByDefault?: boolean
|
||||
providedFilters: FilterValue[]
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const query = ref('')
|
||||
const showMore = ref(false)
|
||||
|
||||
const accordion = ref<InstanceType<typeof Accordion> | null>()
|
||||
|
||||
const selectedFilterOptions = computed(() =>
|
||||
props.filterType.options.filter((option) =>
|
||||
locked.value ? isProvided(option, false) : isIncluded(option),
|
||||
),
|
||||
)
|
||||
const selectedNegativeFilterOptions = computed(() =>
|
||||
props.filterType.options.filter((option) =>
|
||||
locked.value ? isProvided(option, true) : isExcluded(option),
|
||||
),
|
||||
)
|
||||
const visibleOptions = computed(() =>
|
||||
props.filterType.options
|
||||
.filter((option) => isVisible(option) || isIncluded(option) || isExcluded(option))
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (props.filterType.display === 'expandable') {
|
||||
const aDefault = props.filterType.default_values.includes(a.id)
|
||||
const bDefault = props.filterType.default_values.includes(b.id)
|
||||
|
||||
if (aDefault && !bDefault) {
|
||||
return -1
|
||||
} else if (!aDefault && bDefault) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}),
|
||||
)
|
||||
|
||||
const hasProvidedFilter = computed(() =>
|
||||
props.providedFilters.some((filter) => filter.type === props.filterType.id),
|
||||
)
|
||||
const locked = computed(
|
||||
() =>
|
||||
hasProvidedFilter.value && !overriddenProvidedFilterTypes.value.includes(props.filterType.id),
|
||||
)
|
||||
|
||||
const scrollable = computed(
|
||||
() => visibleOptions.value.length >= 10 && props.filterType.display === 'scrollable',
|
||||
)
|
||||
|
||||
function groupEnabled(group: string) {
|
||||
return toggledGroups.value.includes(group)
|
||||
}
|
||||
|
||||
function toggleGroup(group: string) {
|
||||
if (toggledGroups.value.includes(group)) {
|
||||
toggledGroups.value = toggledGroups.value.filter((x) => x !== group)
|
||||
} else {
|
||||
toggledGroups.value.push(group)
|
||||
}
|
||||
}
|
||||
|
||||
function isIncluded(filter: FilterOption) {
|
||||
return selectedFilters.value.some((value) => value.option === filter.id && !value.negative)
|
||||
}
|
||||
|
||||
function isExcluded(filter: FilterOption) {
|
||||
return selectedFilters.value.some((value) => value.option === filter.id && value.negative)
|
||||
}
|
||||
|
||||
function isVisible(filter: FilterOption) {
|
||||
const filterKey = filter.formatted_name?.toLowerCase() ?? filter.id.toLowerCase()
|
||||
const matchesQuery = !query.value || filterKey.includes(query.value.toLowerCase())
|
||||
|
||||
if (props.filterType.display === 'expandable') {
|
||||
return matchesQuery && (showMore.value || props.filterType.default_values.includes(filter.id))
|
||||
}
|
||||
|
||||
if (filter.toggle_group) {
|
||||
return toggledGroups.value.includes(filter.toggle_group) && matchesQuery
|
||||
} else {
|
||||
return matchesQuery
|
||||
}
|
||||
}
|
||||
|
||||
function isProvided(filter: FilterOption, negative: boolean) {
|
||||
return props.providedFilters.some(
|
||||
(x) => x.type === props.filterType.id && x.option === filter.id && !x.negative === !negative,
|
||||
)
|
||||
}
|
||||
|
||||
type FilterState = 'include' | 'exclude' | 'ignore'
|
||||
|
||||
function toggleFilter(filter: FilterOption) {
|
||||
setFilter(filter, isIncluded(filter) || isExcluded(filter) ? 'ignore' : 'include')
|
||||
}
|
||||
|
||||
function toggleNegativeFilter(filter: FilterOption) {
|
||||
setFilter(filter, isExcluded(filter) ? 'ignore' : 'exclude')
|
||||
}
|
||||
|
||||
function setFilter(filter: FilterOption, state: FilterState) {
|
||||
const newFilters = selectedFilters.value.filter((selected) => selected.option !== filter.id)
|
||||
|
||||
const baseValues = {
|
||||
type: props.filterType.id,
|
||||
option: filter.id,
|
||||
}
|
||||
|
||||
if (state === 'include') {
|
||||
newFilters.push({
|
||||
...baseValues,
|
||||
negative: false,
|
||||
})
|
||||
} else if (state === 'exclude') {
|
||||
newFilters.push({
|
||||
...baseValues,
|
||||
negative: true,
|
||||
})
|
||||
}
|
||||
|
||||
selectedFilters.value = newFilters
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
selectedFilters.value = selectedFilters.value.filter(
|
||||
(filter) => filter.type !== props.filterType.id,
|
||||
)
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
unlockFilterButton: {
|
||||
id: 'search.filter.locked.default.unlock',
|
||||
defaultMessage: 'Unlock filter',
|
||||
},
|
||||
syncFilterButton: {
|
||||
id: 'search.filter.locked.default.sync',
|
||||
defaultMessage: 'Sync filter',
|
||||
},
|
||||
lockedTitle: {
|
||||
id: 'search.filter.locked.default.title',
|
||||
defaultMessage: '{type} is locked',
|
||||
},
|
||||
lockedDescription: {
|
||||
id: 'search.filter.locked.default.description',
|
||||
defaultMessage: 'Unlocking this filter may allow you to install incompatible content.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
145
packages/ui/src/components/settings/ThemeSelector.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup>
|
||||
import { MoonIcon, RadioButtonChecked, RadioButtonIcon, SunIcon } from '@modrinth/assets'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
defineProps({
|
||||
updateColorTheme: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
currentTheme: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
themeOptions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
systemThemeColor: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const colorTheme = defineMessages({
|
||||
title: {
|
||||
id: 'settings.display.theme.title',
|
||||
defaultMessage: 'Color theme',
|
||||
},
|
||||
description: {
|
||||
id: 'settings.display.theme.description',
|
||||
defaultMessage: 'Select your preferred color theme for Modrinth on this device.',
|
||||
},
|
||||
system: {
|
||||
id: 'settings.display.theme.system',
|
||||
defaultMessage: 'Sync with system',
|
||||
},
|
||||
light: {
|
||||
id: 'settings.display.theme.light',
|
||||
defaultMessage: 'Light',
|
||||
},
|
||||
dark: {
|
||||
id: 'settings.display.theme.dark',
|
||||
defaultMessage: 'Dark',
|
||||
},
|
||||
oled: {
|
||||
id: 'settings.display.theme.oled',
|
||||
defaultMessage: 'OLED',
|
||||
},
|
||||
retro: {
|
||||
id: 'settings.display.theme.retro',
|
||||
defaultMessage: 'Retro',
|
||||
},
|
||||
preferredLight: {
|
||||
id: 'settings.display.theme.preferred-light-theme',
|
||||
defaultMessage: 'Preferred light theme',
|
||||
},
|
||||
preferredDark: {
|
||||
id: 'settings.display.theme.preferred-dark-theme',
|
||||
defaultMessage: 'Preferred dark theme',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-options mt-4">
|
||||
<button
|
||||
v-for="option in themeOptions"
|
||||
:key="option"
|
||||
class="preview-radio button-base"
|
||||
:class="{ selected: currentTheme === option }"
|
||||
@click="() => updateColorTheme(option)"
|
||||
>
|
||||
<div class="preview" :class="`${option === 'system' ? systemThemeColor : option}-mode`">
|
||||
<div class="example-card card card">
|
||||
<div class="example-icon"></div>
|
||||
<div class="example-text-1"></div>
|
||||
<div class="example-text-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonChecked v-if="currentTheme === option" class="radio" />
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
{{ colorTheme[option] ? formatMessage(colorTheme[option]) : option }}
|
||||
<SunIcon
|
||||
v-if="'light' === option"
|
||||
v-tooltip="formatMessage(colorTheme.preferredLight)"
|
||||
class="theme-icon"
|
||||
/>
|
||||
<MoonIcon
|
||||
v-else-if="'dark' === option"
|
||||
v-tooltip="formatMessage(colorTheme.preferredDark)"
|
||||
class="theme-icon"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.theme-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12.5rem, 1fr));
|
||||
gap: var(--gap-lg);
|
||||
|
||||
.preview .example-card {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
grid-template: 'icon text1' 'icon text2';
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem;
|
||||
outline: 2px solid transparent;
|
||||
|
||||
.example-icon {
|
||||
grid-area: icon;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-color: var(--color-button-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
.example-text-1,
|
||||
.example-text-2 {
|
||||
height: 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
.example-text-1 {
|
||||
grid-area: text1;
|
||||
width: 100%;
|
||||
background-color: var(--color-base);
|
||||
}
|
||||
|
||||
.example-text-2 {
|
||||
grid-area: text2;
|
||||
width: 60%;
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
import { computed } from 'vue'
|
||||
import type { VersionChannel } from '@modrinth/utils'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = withDefaults(
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
channel: 'release' | 'beta' | 'alpha'
|
||||
channel: VersionChannel
|
||||
large?: boolean
|
||||
}>(),
|
||||
{
|
||||
|
||||
212
packages/ui/src/components/version/VersionFilterControl.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="experimental-styles-within flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<ManySelect
|
||||
v-model="selectedPlatforms"
|
||||
:options="filterOptions.platform"
|
||||
:dropdown-id="`${baseId}-platform`"
|
||||
@change="updateFilters"
|
||||
>
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
Platform
|
||||
<template #option="{ option }">
|
||||
{{ formatCategory(option) }}
|
||||
</template>
|
||||
</ManySelect>
|
||||
<ManySelect
|
||||
v-model="selectedGameVersions"
|
||||
:options="filterOptions.gameVersion"
|
||||
:dropdown-id="`${baseId}-game-version`"
|
||||
search
|
||||
@change="updateFilters"
|
||||
>
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
Game versions
|
||||
<template #footer>
|
||||
<Checkbox v-model="showSnapshots" class="mx-1" :label="`Show all versions`" />
|
||||
</template>
|
||||
</ManySelect>
|
||||
<ManySelect
|
||||
v-model="selectedChannels"
|
||||
:options="filterOptions.channel"
|
||||
:dropdown-id="`${baseId}-channel`"
|
||||
@change="updateFilters"
|
||||
>
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
Channels
|
||||
<template #option="{ option }">
|
||||
{{ option === 'release' ? 'Release' : option === 'beta' ? 'Beta' : 'Alpha' }}
|
||||
</template>
|
||||
</ManySelect>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1 empty:hidden">
|
||||
<TagItem
|
||||
v-if="selectedChannels.length + selectedGameVersions.length + selectedPlatforms.length > 1"
|
||||
class="transition-transform active:scale-[0.95]"
|
||||
:action="clearFilters"
|
||||
>
|
||||
<XCircleIcon />
|
||||
Clear all filters
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-for="channel in selectedChannels"
|
||||
:key="`remove-filter-${channel}`"
|
||||
:style="`--_color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'});--_bg-color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'}-highlight)`"
|
||||
:action="() =>toggleFilter('channel', channel)"
|
||||
>
|
||||
<XIcon />
|
||||
{{ channel.slice(0, 1).toUpperCase() + channel.slice(1) }}
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-for="version in selectedGameVersions"
|
||||
:key="`remove-filter-${version}`"
|
||||
:action="() =>toggleFilter('gameVersion', version)"
|
||||
>
|
||||
<XIcon />
|
||||
{{ version }}
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-for="platform in selectedPlatforms"
|
||||
:key="`remove-filter-${platform}`"
|
||||
:style="`--_color: var(--color-platform-${platform})`"
|
||||
:action="() => toggleFilter('platform', platform)"
|
||||
>
|
||||
<XIcon />
|
||||
{{ formatCategory(platform) }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FilterIcon, XCircleIcon, XIcon } from "@modrinth/assets";
|
||||
import { ManySelect, Checkbox } from "../index";
|
||||
import { type Version , formatCategory, type GameVersionTag } from '@modrinth/utils';
|
||||
import { ref, computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
versions: Version[]
|
||||
gameVersions: GameVersionTag[]
|
||||
baseId?: string
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["update:query"]);
|
||||
|
||||
const allChannels = ref(["release", "beta", "alpha"]);
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const showSnapshots = ref(false);
|
||||
|
||||
type FilterType = "channel" | "gameVersion" | "platform";
|
||||
type Filter = string;
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
const filters: Record<FilterType, Filter[]> = {
|
||||
channel: [],
|
||||
gameVersion: [],
|
||||
platform: [],
|
||||
};
|
||||
|
||||
const platformSet = new Set();
|
||||
const gameVersionSet = new Set();
|
||||
const channelSet = new Set();
|
||||
|
||||
for (const version of props.versions) {
|
||||
for (const loader of version.loaders) {
|
||||
platformSet.add(loader);
|
||||
}
|
||||
for (const gameVersion of version.game_versions) {
|
||||
gameVersionSet.add(gameVersion);
|
||||
}
|
||||
channelSet.add(version.version_type);
|
||||
}
|
||||
|
||||
if (channelSet.size > 0) {
|
||||
filters.channel = Array.from(channelSet) as Filter[];
|
||||
filters.channel.sort((a, b) => allChannels.value.indexOf(a) - allChannels.value.indexOf(b));
|
||||
}
|
||||
if (gameVersionSet.size > 0) {
|
||||
const gameVersions = props.gameVersions.filter((x) => gameVersionSet.has(x.version));
|
||||
|
||||
filters.gameVersion = gameVersions
|
||||
.filter((x) => (showSnapshots.value ? true : x.version_type === "release"))
|
||||
.map((x) => x.version);
|
||||
}
|
||||
if (platformSet.size > 0) {
|
||||
filters.platform = Array.from(platformSet) as Filter[];
|
||||
}
|
||||
|
||||
return filters;
|
||||
});
|
||||
|
||||
const selectedChannels = ref<string[]>([]);
|
||||
const selectedGameVersions = ref<string[]>([]);
|
||||
const selectedPlatforms = ref<string[]>([]);
|
||||
|
||||
selectedChannels.value = route.query.c ? getArrayOrString(route.query.c) : [];
|
||||
selectedGameVersions.value = route.query.g ? getArrayOrString(route.query.g) : [];
|
||||
selectedPlatforms.value = route.query.l ? getArrayOrString(route.query.l) : [];
|
||||
|
||||
async function toggleFilters(type: FilterType, filters: Filter[]) {
|
||||
for (const filter of filters) {
|
||||
await toggleFilter(type, filter, true);
|
||||
}
|
||||
|
||||
updateFilters();
|
||||
}
|
||||
|
||||
async function toggleFilter(type: FilterType, filter: Filter, bulk = false) {
|
||||
if (type === "channel") {
|
||||
selectedChannels.value = selectedChannels.value.includes(filter)
|
||||
? selectedChannels.value.filter((x) => x !== filter)
|
||||
: [...selectedChannels.value, filter];
|
||||
} else if (type === "gameVersion") {
|
||||
selectedGameVersions.value = selectedGameVersions.value.includes(filter)
|
||||
? selectedGameVersions.value.filter((x) => x !== filter)
|
||||
: [...selectedGameVersions.value, filter];
|
||||
} else if (type === "platform") {
|
||||
selectedPlatforms.value = selectedPlatforms.value.includes(filter)
|
||||
? selectedPlatforms.value.filter((x) => x !== filter)
|
||||
: [...selectedPlatforms.value, filter];
|
||||
}
|
||||
if (!bulk) {
|
||||
updateFilters();
|
||||
}
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
selectedChannels.value = [];
|
||||
selectedGameVersions.value = [];
|
||||
selectedPlatforms.value = [];
|
||||
|
||||
updateFilters();
|
||||
}
|
||||
|
||||
function updateFilters() {
|
||||
emit("update:query", {
|
||||
c: selectedChannels.value,
|
||||
g: selectedGameVersions.value,
|
||||
l: selectedPlatforms.value,
|
||||
page: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
toggleFilter,
|
||||
toggleFilters,
|
||||
selectedChannels,
|
||||
selectedGameVersions,
|
||||
selectedPlatforms,
|
||||
});
|
||||
|
||||
function getArrayOrString(x: string | string[]): string[] {
|
||||
if (typeof x === "string") {
|
||||
return [x];
|
||||
} else {
|
||||
return x;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
47
packages/ui/src/components/version/VersionSummary.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[min-content_auto_min-content_min-content] items-center gap-2 rounded-2xl border-[1px] border-divider bg-bg p-2"
|
||||
>
|
||||
<VersionChannelIndicator :channel="version.version_type" />
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h1 class="my-0 truncate text-nowrap text-base font-extrabold leading-none text-contrast">
|
||||
{{ version.version_number }}
|
||||
</h1>
|
||||
<p class="m-0 truncate text-nowrap text-xs font-semibold text-secondary">
|
||||
{{ version.name }}
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled color="brand">
|
||||
<a :href="downloadUrl" class="min-w-0" @click="emit('onDownload')">
|
||||
<DownloadIcon aria-hidden="true" /> Download
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<nuxt-link
|
||||
:to="`/project/${props.version.project_id}/version/${props.version.id}`"
|
||||
class="min-w-0"
|
||||
aria-label="Open project page"
|
||||
@click="emit('onNavigate')"
|
||||
>
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, VersionChannelIndicator } from "../index";
|
||||
import { DownloadIcon, ExternalIcon } from "@modrinth/assets";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
version: Version;
|
||||
}>();
|
||||
|
||||
const downloadUrl = computed(() => {
|
||||
const primary: VersionFile = props.version.files.find((x) => x.primary) || props.version.files[0];
|
||||
return primary.url;
|
||||
});
|
||||
|
||||
const emit = defineEmits(["onDownload", "onNavigate"]);
|
||||
</script>
|
||||
@@ -1,4 +1,112 @@
|
||||
{
|
||||
"button.cancel": {
|
||||
"defaultMessage": "Cancel"
|
||||
},
|
||||
"button.continue": {
|
||||
"defaultMessage": "Continue"
|
||||
},
|
||||
"button.copy-id": {
|
||||
"defaultMessage": "Copy ID"
|
||||
},
|
||||
"button.create-a-project": {
|
||||
"defaultMessage": "Create a project"
|
||||
},
|
||||
"button.edit": {
|
||||
"defaultMessage": "Edit"
|
||||
},
|
||||
"button.report": {
|
||||
"defaultMessage": "Report"
|
||||
},
|
||||
"button.save": {
|
||||
"defaultMessage": "Save"
|
||||
},
|
||||
"button.save-changes": {
|
||||
"defaultMessage": "Save changes"
|
||||
},
|
||||
"button.sign-in": {
|
||||
"defaultMessage": "Sign in"
|
||||
},
|
||||
"button.sign-out": {
|
||||
"defaultMessage": "Sign out"
|
||||
},
|
||||
"button.upload-image": {
|
||||
"defaultMessage": "Upload image"
|
||||
},
|
||||
"collection.label.private": {
|
||||
"defaultMessage": "Private"
|
||||
},
|
||||
"input.view.gallery": {
|
||||
"defaultMessage": "Gallery view"
|
||||
},
|
||||
"input.view.grid": {
|
||||
"defaultMessage": "Grid view"
|
||||
},
|
||||
"input.view.list": {
|
||||
"defaultMessage": "Rows view"
|
||||
},
|
||||
"label.changes-saved": {
|
||||
"defaultMessage": "Changes saved"
|
||||
},
|
||||
"label.collections": {
|
||||
"defaultMessage": "Collections"
|
||||
},
|
||||
"label.created-ago": {
|
||||
"defaultMessage": "Created {ago}"
|
||||
},
|
||||
"label.dashboard": {
|
||||
"defaultMessage": "Dashboard"
|
||||
},
|
||||
"label.delete": {
|
||||
"defaultMessage": "Delete"
|
||||
},
|
||||
"label.description": {
|
||||
"defaultMessage": "Description"
|
||||
},
|
||||
"label.error": {
|
||||
"defaultMessage": "Error"
|
||||
},
|
||||
"label.followed-projects": {
|
||||
"defaultMessage": "Followed projects"
|
||||
},
|
||||
"label.moderation": {
|
||||
"defaultMessage": "Moderation"
|
||||
},
|
||||
"label.notifications": {
|
||||
"defaultMessage": "Notifications"
|
||||
},
|
||||
"label.password": {
|
||||
"defaultMessage": "Password"
|
||||
},
|
||||
"label.public": {
|
||||
"defaultMessage": "Public"
|
||||
},
|
||||
"label.rejected": {
|
||||
"defaultMessage": "Rejected"
|
||||
},
|
||||
"label.scopes": {
|
||||
"defaultMessage": "Scopes"
|
||||
},
|
||||
"label.servers": {
|
||||
"defaultMessage": "Servers"
|
||||
},
|
||||
"label.settings": {
|
||||
"defaultMessage": "Settings"
|
||||
},
|
||||
"label.title": {
|
||||
"defaultMessage": "Title"
|
||||
},
|
||||
"label.unlisted": {
|
||||
"defaultMessage": "Unlisted"
|
||||
},
|
||||
"label.visibility": {
|
||||
"defaultMessage": "Visibility"
|
||||
},
|
||||
"label.visit-your-profile": {
|
||||
"defaultMessage": "Visit your profile"
|
||||
},
|
||||
"notification.error.title": {
|
||||
"defaultMessage": "An error occurred"
|
||||
},
|
||||
"omorphia.component.badge.label.accepted": {
|
||||
"defaultMessage": "Accepted"
|
||||
},
|
||||
@@ -115,5 +223,227 @@
|
||||
},
|
||||
"omorphia.component.purchase_modal.payment_method_type.visa": {
|
||||
"defaultMessage": "Visa"
|
||||
},
|
||||
"project-type.all": {
|
||||
"defaultMessage": "All"
|
||||
},
|
||||
"project.about.compatibility.environments": {
|
||||
"defaultMessage": "Supported environments"
|
||||
},
|
||||
"project.about.compatibility.game.minecraftJava": {
|
||||
"defaultMessage": "Minecraft: Java Edition"
|
||||
},
|
||||
"project.about.compatibility.platforms": {
|
||||
"defaultMessage": "Platforms"
|
||||
},
|
||||
"project.about.compatibility.title": {
|
||||
"defaultMessage": "Compatibility"
|
||||
},
|
||||
"project.about.creators.owner": {
|
||||
"defaultMessage": "Project owner"
|
||||
},
|
||||
"project.about.creators.title": {
|
||||
"defaultMessage": "Creators"
|
||||
},
|
||||
"project.about.details.created": {
|
||||
"defaultMessage": "Created {date}"
|
||||
},
|
||||
"project.about.details.licensed": {
|
||||
"defaultMessage": "Licensed {license}"
|
||||
},
|
||||
"project.about.details.published": {
|
||||
"defaultMessage": "Published {date}"
|
||||
},
|
||||
"project.about.details.submitted": {
|
||||
"defaultMessage": "Submitted {date}"
|
||||
},
|
||||
"project.about.details.title": {
|
||||
"defaultMessage": "Details"
|
||||
},
|
||||
"project.about.details.updated": {
|
||||
"defaultMessage": "Updated {date}"
|
||||
},
|
||||
"project.about.links.discord": {
|
||||
"defaultMessage": "Join Discord server"
|
||||
},
|
||||
"project.about.links.donate.bmac": {
|
||||
"defaultMessage": "Buy Me a Coffee"
|
||||
},
|
||||
"project.about.links.donate.generic": {
|
||||
"defaultMessage": "Donate"
|
||||
},
|
||||
"project.about.links.donate.github": {
|
||||
"defaultMessage": "Sponsor on GitHub"
|
||||
},
|
||||
"project.about.links.donate.kofi": {
|
||||
"defaultMessage": "Donate on Ko-fi"
|
||||
},
|
||||
"project.about.links.donate.patreon": {
|
||||
"defaultMessage": "Donate on Patreon"
|
||||
},
|
||||
"project.about.links.donate.paypal": {
|
||||
"defaultMessage": "Donate on PayPal"
|
||||
},
|
||||
"project.about.links.issues": {
|
||||
"defaultMessage": "Report issues"
|
||||
},
|
||||
"project.about.links.source": {
|
||||
"defaultMessage": "View source"
|
||||
},
|
||||
"project.about.links.title": {
|
||||
"defaultMessage": "Links"
|
||||
},
|
||||
"project.about.links.wiki": {
|
||||
"defaultMessage": "Visit wiki"
|
||||
},
|
||||
"project.versions.channel.alpha.symbol": {
|
||||
"defaultMessage": "A"
|
||||
},
|
||||
"project.versions.channel.beta.symbol": {
|
||||
"defaultMessage": "B"
|
||||
},
|
||||
"project.versions.channel.release.symbol": {
|
||||
"defaultMessage": "R"
|
||||
},
|
||||
"project.visibility.archived": {
|
||||
"defaultMessage": "Archived"
|
||||
},
|
||||
"project.visibility.draft": {
|
||||
"defaultMessage": "Draft"
|
||||
},
|
||||
"project.visibility.private": {
|
||||
"defaultMessage": "Private"
|
||||
},
|
||||
"project.visibility.public": {
|
||||
"defaultMessage": "Public"
|
||||
},
|
||||
"project.visibility.rejected": {
|
||||
"defaultMessage": "Rejected"
|
||||
},
|
||||
"project.visibility.scheduled": {
|
||||
"defaultMessage": "Scheduled"
|
||||
},
|
||||
"project.visibility.under-review": {
|
||||
"defaultMessage": "Under review"
|
||||
},
|
||||
"project.visibility.unknown": {
|
||||
"defaultMessage": "Unknown"
|
||||
},
|
||||
"project.visibility.unlisted": {
|
||||
"defaultMessage": "Unlisted"
|
||||
},
|
||||
"project.visibility.unlisted-by-staff": {
|
||||
"defaultMessage": "Unlisted by staff"
|
||||
},
|
||||
"search.filter.locked.default": {
|
||||
"defaultMessage": "Filter locked"
|
||||
},
|
||||
"search.filter.locked.default.description": {
|
||||
"defaultMessage": "Unlocking this filter may allow you to install incompatible content."
|
||||
},
|
||||
"search.filter.locked.default.sync": {
|
||||
"defaultMessage": "Sync filter"
|
||||
},
|
||||
"search.filter.locked.default.title": {
|
||||
"defaultMessage": "{type} is locked"
|
||||
},
|
||||
"search.filter.locked.default.unlock": {
|
||||
"defaultMessage": "Unlock filter"
|
||||
},
|
||||
"search.filter_type.environment": {
|
||||
"defaultMessage": "Environment"
|
||||
},
|
||||
"search.filter_type.environment.client": {
|
||||
"defaultMessage": "Client"
|
||||
},
|
||||
"search.filter_type.environment.server": {
|
||||
"defaultMessage": "Server"
|
||||
},
|
||||
"search.filter_type.game_version": {
|
||||
"defaultMessage": "Game version"
|
||||
},
|
||||
"search.filter_type.game_version.all_versions": {
|
||||
"defaultMessage": "Show all versions"
|
||||
},
|
||||
"search.filter_type.license": {
|
||||
"defaultMessage": "License"
|
||||
},
|
||||
"search.filter_type.license.open_source": {
|
||||
"defaultMessage": "Open source"
|
||||
},
|
||||
"search.filter_type.mod_loader": {
|
||||
"defaultMessage": "Loader"
|
||||
},
|
||||
"search.filter_type.modpack_loader": {
|
||||
"defaultMessage": "Loader"
|
||||
},
|
||||
"search.filter_type.plugin_loader": {
|
||||
"defaultMessage": "Loader"
|
||||
},
|
||||
"search.filter_type.plugin_platform": {
|
||||
"defaultMessage": "Platform"
|
||||
},
|
||||
"search.filter_type.project_id": {
|
||||
"defaultMessage": "Project ID"
|
||||
},
|
||||
"search.filter_type.shader_loader": {
|
||||
"defaultMessage": "Loader"
|
||||
},
|
||||
"settings.account.title": {
|
||||
"defaultMessage": "Account and security"
|
||||
},
|
||||
"settings.appearance.title": {
|
||||
"defaultMessage": "Appearance"
|
||||
},
|
||||
"settings.applications.title": {
|
||||
"defaultMessage": "Your applications"
|
||||
},
|
||||
"settings.authorized-apps.title": {
|
||||
"defaultMessage": "Authorized apps"
|
||||
},
|
||||
"settings.billing.title": {
|
||||
"defaultMessage": "Billing and subscriptions"
|
||||
},
|
||||
"settings.display.theme.dark": {
|
||||
"defaultMessage": "Dark"
|
||||
},
|
||||
"settings.display.theme.description": {
|
||||
"defaultMessage": "Select your preferred color theme for Modrinth on this device."
|
||||
},
|
||||
"settings.display.theme.light": {
|
||||
"defaultMessage": "Light"
|
||||
},
|
||||
"settings.display.theme.oled": {
|
||||
"defaultMessage": "OLED"
|
||||
},
|
||||
"settings.display.theme.preferred-dark-theme": {
|
||||
"defaultMessage": "Preferred dark theme"
|
||||
},
|
||||
"settings.display.theme.preferred-light-theme": {
|
||||
"defaultMessage": "Preferred light theme"
|
||||
},
|
||||
"settings.display.theme.retro": {
|
||||
"defaultMessage": "Retro"
|
||||
},
|
||||
"settings.display.theme.system": {
|
||||
"defaultMessage": "Sync with system"
|
||||
},
|
||||
"settings.display.theme.title": {
|
||||
"defaultMessage": "Color theme"
|
||||
},
|
||||
"settings.language.title": {
|
||||
"defaultMessage": "Language"
|
||||
},
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Personal access tokens"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Public profile"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sessions"
|
||||
},
|
||||
"tooltip.date-at-time": {
|
||||
"defaultMessage": "{date, date, long} at {time, time, short}"
|
||||
}
|
||||
}
|
||||
|
||||
195
packages/ui/src/utils/common-messages.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { defineMessages } from '@vintl/vintl'
|
||||
|
||||
export const commonMessages = defineMessages({
|
||||
allProjectType: {
|
||||
id: 'project-type.all',
|
||||
defaultMessage: 'All',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
collectionsLabel: {
|
||||
id: 'label.collections',
|
||||
defaultMessage: 'Collections',
|
||||
},
|
||||
continueButton: {
|
||||
id: 'button.continue',
|
||||
defaultMessage: 'Continue',
|
||||
},
|
||||
copyIdButton: {
|
||||
id: 'button.copy-id',
|
||||
defaultMessage: 'Copy ID',
|
||||
},
|
||||
changesSavedLabel: {
|
||||
id: 'label.changes-saved',
|
||||
defaultMessage: 'Changes saved',
|
||||
},
|
||||
createAProjectButton: {
|
||||
id: 'button.create-a-project',
|
||||
defaultMessage: 'Create a project',
|
||||
},
|
||||
createdAgoLabel: {
|
||||
id: 'label.created-ago',
|
||||
defaultMessage: 'Created {ago}',
|
||||
},
|
||||
dashboardLabel: {
|
||||
id: 'label.dashboard',
|
||||
defaultMessage: 'Dashboard',
|
||||
},
|
||||
dateAtTimeTooltip: {
|
||||
id: 'tooltip.date-at-time',
|
||||
defaultMessage: '{date, date, long} at {time, time, short}',
|
||||
},
|
||||
deleteLabel: {
|
||||
id: 'label.delete',
|
||||
defaultMessage: 'Delete',
|
||||
},
|
||||
descriptionLabel: {
|
||||
id: 'label.description',
|
||||
defaultMessage: 'Description',
|
||||
},
|
||||
editButton: {
|
||||
id: 'button.edit',
|
||||
defaultMessage: 'Edit',
|
||||
},
|
||||
errorLabel: {
|
||||
id: 'label.error',
|
||||
defaultMessage: 'Error',
|
||||
},
|
||||
errorNotificationTitle: {
|
||||
id: 'notification.error.title',
|
||||
defaultMessage: 'An error occurred',
|
||||
},
|
||||
followedProjectsLabel: {
|
||||
id: 'label.followed-projects',
|
||||
defaultMessage: 'Followed projects',
|
||||
},
|
||||
galleryInputView: {
|
||||
id: 'input.view.gallery',
|
||||
defaultMessage: 'Gallery view',
|
||||
},
|
||||
gridInputView: {
|
||||
id: 'input.view.grid',
|
||||
defaultMessage: 'Grid view',
|
||||
},
|
||||
listInputView: {
|
||||
id: 'input.view.list',
|
||||
defaultMessage: 'Rows view',
|
||||
},
|
||||
moderationLabel: {
|
||||
id: 'label.moderation',
|
||||
defaultMessage: 'Moderation',
|
||||
},
|
||||
notificationsLabel: {
|
||||
id: 'label.notifications',
|
||||
defaultMessage: 'Notifications',
|
||||
},
|
||||
privateLabel: {
|
||||
id: 'collection.label.private',
|
||||
defaultMessage: 'Private',
|
||||
},
|
||||
publicLabel: {
|
||||
id: 'label.public',
|
||||
defaultMessage: 'Public',
|
||||
},
|
||||
rejectedLabel: {
|
||||
id: 'label.rejected',
|
||||
defaultMessage: 'Rejected',
|
||||
},
|
||||
reportButton: {
|
||||
id: 'button.report',
|
||||
defaultMessage: 'Report',
|
||||
},
|
||||
passwordLabel: {
|
||||
id: 'label.password',
|
||||
defaultMessage: 'Password',
|
||||
},
|
||||
saveButton: {
|
||||
id: 'button.save',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
saveChangesButton: {
|
||||
id: 'button.save-changes',
|
||||
defaultMessage: 'Save changes',
|
||||
},
|
||||
scopesLabel: {
|
||||
id: 'label.scopes',
|
||||
defaultMessage: 'Scopes',
|
||||
},
|
||||
serversLabel: {
|
||||
id: 'label.servers',
|
||||
defaultMessage: 'Servers',
|
||||
},
|
||||
settingsLabel: {
|
||||
id: 'label.settings',
|
||||
defaultMessage: 'Settings',
|
||||
},
|
||||
signInButton: {
|
||||
id: 'button.sign-in',
|
||||
defaultMessage: 'Sign in',
|
||||
},
|
||||
signOutButton: {
|
||||
id: 'button.sign-out',
|
||||
defaultMessage: 'Sign out',
|
||||
},
|
||||
titleLabel: {
|
||||
id: 'label.title',
|
||||
defaultMessage: 'Title',
|
||||
},
|
||||
unlistedLabel: {
|
||||
id: 'label.unlisted',
|
||||
defaultMessage: 'Unlisted',
|
||||
},
|
||||
uploadImageButton: {
|
||||
id: 'button.upload-image',
|
||||
defaultMessage: 'Upload image',
|
||||
},
|
||||
visibilityLabel: {
|
||||
id: 'label.visibility',
|
||||
defaultMessage: 'Visibility',
|
||||
},
|
||||
visitYourProfile: {
|
||||
id: 'label.visit-your-profile',
|
||||
defaultMessage: 'Visit your profile',
|
||||
},
|
||||
})
|
||||
|
||||
export const commonSettingsMessages = defineMessages({
|
||||
appearance: {
|
||||
id: 'settings.appearance.title',
|
||||
defaultMessage: 'Appearance',
|
||||
},
|
||||
language: {
|
||||
id: 'settings.language.title',
|
||||
defaultMessage: 'Language',
|
||||
},
|
||||
profile: {
|
||||
id: 'settings.profile.title',
|
||||
defaultMessage: 'Public profile',
|
||||
},
|
||||
account: {
|
||||
id: 'settings.account.title',
|
||||
defaultMessage: 'Account and security',
|
||||
},
|
||||
authorizedApps: {
|
||||
id: 'settings.authorized-apps.title',
|
||||
defaultMessage: 'Authorized apps',
|
||||
},
|
||||
sessions: {
|
||||
id: 'settings.sessions.title',
|
||||
defaultMessage: 'Sessions',
|
||||
},
|
||||
pats: {
|
||||
id: 'settings.pats.title',
|
||||
defaultMessage: 'Personal access tokens',
|
||||
},
|
||||
applications: {
|
||||
id: 'settings.applications.title',
|
||||
defaultMessage: 'Your applications',
|
||||
},
|
||||
billing: {
|
||||
id: 'settings.billing.title',
|
||||
defaultMessage: 'Billing and subscriptions',
|
||||
},
|
||||
})
|
||||
608
packages/ui/src/utils/search.ts
Normal file
@@ -0,0 +1,608 @@
|
||||
import { type Ref , type Component, computed, readonly, ref } from 'vue';
|
||||
import { type LocationQueryRaw, type LocationQueryValue, useRoute } from 'vue-router'
|
||||
import { defineMessage, useVIntl } from '@vintl/vintl'
|
||||
import { formatCategory, formatCategoryHeader, sortByNameOrNumber } from '@modrinth/utils'
|
||||
import { ClientIcon, ServerIcon } from '@modrinth/assets'
|
||||
|
||||
type BaseOption = {
|
||||
id: string
|
||||
formatted_name?: string
|
||||
toggle_group?: string
|
||||
icon?: string | Component,
|
||||
query_value?: string,
|
||||
}
|
||||
|
||||
export type FilterOption = BaseOption & (
|
||||
{ method: 'or' | 'and', value: string, } |
|
||||
{ method: 'environment', environment: 'client' | 'server', }
|
||||
)
|
||||
|
||||
export type FilterType = {
|
||||
id: string,
|
||||
formatted_name: string,
|
||||
options: FilterOption[],
|
||||
supported_project_types: ProjectType[],
|
||||
query_param: string,
|
||||
supports_negative_filter: boolean
|
||||
toggle_groups?: {
|
||||
id: string,
|
||||
formatted_name: string,
|
||||
query_param?: string,
|
||||
}[],
|
||||
searchable: boolean,
|
||||
allows_custom_options?: 'and' | 'or',
|
||||
} & ({
|
||||
display: 'all' | 'scrollable' | 'none'
|
||||
} | {
|
||||
display: 'expandable',
|
||||
default_values: string[]
|
||||
})
|
||||
|
||||
export type FilterValue = {
|
||||
type: string,
|
||||
option: string,
|
||||
negative?: boolean,
|
||||
}
|
||||
|
||||
export interface GameVersion {
|
||||
version: string
|
||||
version_type: 'release' | 'snapshot' | 'alpha' | 'beta'
|
||||
date: string
|
||||
major: boolean
|
||||
}
|
||||
|
||||
export type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader' | 'datapack' | 'plugin'
|
||||
|
||||
const ALL_PROJECT_TYPES: ProjectType[] = [ 'mod', 'modpack', 'resourcepack', 'shader', 'datapack', 'plugin' ]
|
||||
|
||||
export interface Platform {
|
||||
name: string
|
||||
icon: string
|
||||
supported_project_types: ProjectType[]
|
||||
default: boolean
|
||||
formatted_name: string
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
icon: string
|
||||
name: string
|
||||
project_type: ProjectType
|
||||
header: string
|
||||
}
|
||||
|
||||
export interface Tags {
|
||||
gameVersions: GameVersion[]
|
||||
loaders: Platform[]
|
||||
categories: Category[]
|
||||
}
|
||||
|
||||
export interface SortType {
|
||||
display: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, providedFilters: Ref<FilterValue[]>) {
|
||||
const query = ref('')
|
||||
const maxResults = ref(20)
|
||||
|
||||
const sortTypes: readonly SortType[] = readonly([
|
||||
{ display: 'Relevance', name: 'relevance' },
|
||||
{ display: 'Downloads', name: 'downloads' },
|
||||
{ display: 'Followers', name: 'follows' },
|
||||
{ display: 'Date published', name: 'newest' },
|
||||
{ display: 'Date updated', name: 'updated' },
|
||||
])
|
||||
|
||||
const currentSortType: Ref<SortType> = ref({ name: 'relevance', display: 'Relevance' })
|
||||
|
||||
const route = useRoute()
|
||||
const currentPage = ref(1)
|
||||
|
||||
const currentFilters: Ref<FilterValue[]> = ref<FilterValue[]>([])
|
||||
const toggledGroups = ref<string[]>([])
|
||||
const overriddenProvidedFilterTypes = ref<string[]>([])
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
const filters = computed(() => {
|
||||
const categoryFilters: Record<string, FilterType> = {}
|
||||
for (const category of sortByNameOrNumber(tags.value.categories.slice(), ['header', 'name'])) {
|
||||
const filterTypeId = `category_${category.project_type}_${category.header}`
|
||||
if (!categoryFilters[filterTypeId]) {
|
||||
categoryFilters[filterTypeId] = {
|
||||
id: filterTypeId,
|
||||
formatted_name: formatCategoryHeader(category.header),
|
||||
supported_project_types: category.project_type === 'mod' ? ['mod', 'plugin', 'datapack'] : [category.project_type],
|
||||
display: 'all',
|
||||
query_param: category.header === 'resolutions' ? 'g' : 'f',
|
||||
supports_negative_filter: true,
|
||||
searchable: false,
|
||||
options: []
|
||||
}
|
||||
}
|
||||
categoryFilters[filterTypeId].options.push({
|
||||
id: category.name,
|
||||
formatted_name: formatCategory(category.name),
|
||||
icon: category.icon,
|
||||
value: `categories:${category.name}`,
|
||||
method: category.header === 'resolutions' ? 'or' : 'and'
|
||||
})
|
||||
}
|
||||
|
||||
const filterTypes: FilterType[] = [
|
||||
...Object.values(categoryFilters),
|
||||
{
|
||||
id: 'environment',
|
||||
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.environment', defaultMessage: 'Environment' })),
|
||||
supported_project_types: [ 'mod', 'modpack' ],
|
||||
display: 'all',
|
||||
query_param: 'e',
|
||||
supports_negative_filter: false,
|
||||
searchable: false,
|
||||
options: [
|
||||
{
|
||||
id: 'client',
|
||||
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.environment.client', defaultMessage: 'Client' })),
|
||||
icon: ClientIcon,
|
||||
method: 'environment',
|
||||
environment: 'client',
|
||||
},
|
||||
{
|
||||
id: 'server',
|
||||
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.environment.server', defaultMessage: 'Server' })),
|
||||
icon: ServerIcon,
|
||||
method: 'environment',
|
||||
environment: 'server',
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'game_version',
|
||||
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.game_version', defaultMessage: 'Game version' })),
|
||||
supported_project_types: ALL_PROJECT_TYPES,
|
||||
display: 'scrollable',
|
||||
query_param: 'v',
|
||||
supports_negative_filter: false,
|
||||
toggle_groups: [
|
||||
{
|
||||
id: 'all_versions',
|
||||
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.game_version.all_versions', defaultMessage: 'Show all versions' })),
|
||||
query_param: 'h'
|
||||
}
|
||||
],
|
||||
searchable: true,
|
||||
options: tags.value.gameVersions.map(gameVersion =>
|
||||
({
|
||||
id: gameVersion.version,
|
||||
toggle_group: gameVersion.version_type !== 'release' ? 'all_versions' : undefined,
|
||||
value: `versions:${gameVersion.version}`,
|
||||
query_value: gameVersion.version,
|
||||
method: 'or'
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: 'mod_loader',
|
||||
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.mod_loader', defaultMessage: 'Loader' })),
|
||||
supported_project_types: [ 'mod' ],
|
||||
display: 'expandable',
|
||||
query_param: 'g',
|
||||
supports_negative_filter: true,
|
||||
default_values: [ 'fabric', 'forge', 'neoforge', 'quilt' ],
|
||||
searchable: false,
|
||||
options: tags.value.loaders.filter((loader) => loader.supported_project_types.includes('mod') && !loader.supported_project_types.includes('plugin') && !loader.supported_project_types.includes('datapack')).map(loader => {
|
||||
return {
|
||||
id: loader.name,
|
||||
formatted_name: formatCategory(loader.name),
|
||||
icon: loader.icon,
|
||||
method: 'or',
|
||||
value: `categories:${loader.name}`,
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'modpack_loader',
|
||||
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.modpack_loader', defaultMessage: 'Loader' })),
|
||||
supported_project_types: [ 'modpack' ],
|
||||
display: 'all',
|
||||
query_param: 'g',
|
||||
supports_negative_filter: true,
|
||||
searchable: false,
|
||||
options: tags.value.loaders.filter((loader) => loader.supported_project_types.includes('modpack')).map(loader => {
|
||||
return {
|
||||
id: loader.name,
|
||||
formatted_name: formatCategory(loader.name),
|
||||
icon: loader.icon,
|
||||
method: 'or',
|
||||
value: `categories:${loader.name}`,
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'plugin_loader',
|
||||
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.plugin_loader', defaultMessage: 'Loader' })),
|
||||
supported_project_types: [ 'plugin' ],
|
||||
display: 'all',
|
||||
query_param: 'g',
|
||||
supports_negative_filter: true,
|
||||
searchable: false,
|
||||
options: tags.value.loaders.filter((loader) => loader.supported_project_types.includes('plugin') && !['bungeecord', 'waterfall', 'velocity'].includes(loader.name)).map(loader => {
|
||||
return {
|
||||
id: loader.name,
|
||||
formatted_name: formatCategory(loader.name),
|
||||
icon: loader.icon,
|
||||
method: 'or',
|
||||
value: `categories:${loader.name}`,
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'plugin_platform',
|
||||
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.plugin_platform', defaultMessage: 'Platform' })),
|
||||
supported_project_types: [ 'plugin' ],
|
||||
display: 'all',
|
||||
query_param: 'g',
|
||||
supports_negative_filter: true,
|
||||
searchable: false,
|
||||
options: tags.value.loaders.filter((loader) => ['bungeecord', 'waterfall', 'velocity'].includes(loader.name)).map(loader => {
|
||||
return {
|
||||
id: loader.name,
|
||||
formatted_name: formatCategory(loader.name),
|
||||
icon: loader.icon,
|
||||
method: 'or',
|
||||
value: `categories:${loader.name}`,
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'shader_loader',
|
||||
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.shader_loader', defaultMessage: 'Loader' })),
|
||||
supported_project_types: [ 'shader' ],
|
||||
display: 'all',
|
||||
query_param: 'g',
|
||||
supports_negative_filter: true,
|
||||
searchable: false,
|
||||
options: tags.value.loaders.filter((loader) => loader.supported_project_types.includes('shader')).map(loader => {
|
||||
return {
|
||||
id: loader.name,
|
||||
formatted_name: formatCategory(loader.name),
|
||||
icon: loader.icon,
|
||||
method: 'or',
|
||||
value: `categories:${loader.name}`,
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'license',
|
||||
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.license', defaultMessage: 'License' })),
|
||||
supported_project_types: [ 'mod', 'modpack', 'resourcepack', 'shader', 'plugin', 'datapack' ],
|
||||
query_param: 'l',
|
||||
supports_negative_filter: true,
|
||||
display: 'all',
|
||||
searchable: false,
|
||||
options: [
|
||||
{
|
||||
id: 'open_source',
|
||||
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.license.open_source', defaultMessage: 'Open source' })),
|
||||
method: 'and',
|
||||
value: 'open_source:true',
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'project_id',
|
||||
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.project_id', defaultMessage: 'Project ID' })),
|
||||
supported_project_types: ALL_PROJECT_TYPES,
|
||||
query_param: 'pid',
|
||||
supports_negative_filter: true,
|
||||
display: 'none',
|
||||
searchable: false,
|
||||
options: [],
|
||||
allows_custom_options: 'and'
|
||||
}
|
||||
]
|
||||
|
||||
return filterTypes.filter(filterType => filterType.supported_project_types.some(projectType => projectTypes.value.includes(projectType)))
|
||||
})
|
||||
|
||||
const facets = computed(() => {
|
||||
const validProvidedFilters = providedFilters.value.filter(providedFilter => !overriddenProvidedFilterTypes.value.includes(providedFilter.type))
|
||||
const filteredFilters = currentFilters.value.filter((userFilter) => !validProvidedFilters.some(providedFilter => providedFilter.type === userFilter.type))
|
||||
const filterValues = [...filteredFilters, ...validProvidedFilters]
|
||||
|
||||
const andFacets: string[][] = [];
|
||||
const orFacets: Record<string, string[]> = {};
|
||||
for (const filterValue of filterValues) {
|
||||
const type = filters.value.find(type => type.id === filterValue.type)
|
||||
if (!type) {
|
||||
console.error(`Filter type ${filterValue.type} not found`)
|
||||
continue
|
||||
}
|
||||
let option = type?.options.find(option => option.id === filterValue.option)
|
||||
if (!option && type.allows_custom_options) {
|
||||
option = {
|
||||
id: filterValue.option,
|
||||
formatted_name: filterValue.option,
|
||||
icon: undefined,
|
||||
method: type.allows_custom_options,
|
||||
value: filterValue.option,
|
||||
}
|
||||
} else if (!option) {
|
||||
console.error(`Filter option ${filterValue.option} not found`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (option.method === 'or' || option.method === 'and') {
|
||||
if (filterValue.negative) {
|
||||
andFacets.push([option.value.replace(':', '!=')]);
|
||||
} else {
|
||||
if (option.method === 'or') {
|
||||
if (!orFacets[type.id]) {
|
||||
orFacets[type.id] = []
|
||||
}
|
||||
orFacets[type.id].push(option.value);
|
||||
} else if (option.method === 'and') {
|
||||
andFacets.push([option.value]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object.values(orFacets).forEach((facets) => andFacets.push(facets))
|
||||
|
||||
/*
|
||||
Add environment facets, separate from the rest because it oddly depends on the combination
|
||||
of filters selected to determine which facets to add.
|
||||
*/
|
||||
const client = currentFilters.value
|
||||
.some((filter) => filter.type === 'environment' && filter.option === 'client')
|
||||
const server = currentFilters.value
|
||||
.some((filter) => filter.type === 'environment' && filter.option === 'server')
|
||||
andFacets.push(...createEnvironmentFacets(client, server))
|
||||
|
||||
const projectType = projectTypes.value.map((projectType) => `project_type:${projectType}`)
|
||||
if (andFacets.length > 0) {
|
||||
return [projectType, ...andFacets]
|
||||
} else {
|
||||
return [projectType]
|
||||
}
|
||||
})
|
||||
|
||||
const requestParams: Ref<string> = computed(() => {
|
||||
const params = [`limit=${maxResults.value}`, `index=${currentSortType.value.name}`]
|
||||
|
||||
if (query.value.length > 0) {
|
||||
params.push(`query=${encodeURIComponent(query.value)}`);
|
||||
}
|
||||
|
||||
params.push(`facets=${encodeURIComponent(JSON.stringify(facets.value))}`);
|
||||
|
||||
const offset = (currentPage.value - 1) * maxResults.value;
|
||||
if (currentPage.value !== 1) {
|
||||
params.push(`offset=${offset}`);
|
||||
}
|
||||
|
||||
return `?${params.join('&')}`;
|
||||
})
|
||||
|
||||
readQueryParams();
|
||||
|
||||
function readQueryParams() {
|
||||
const readParams = new Set<string>();
|
||||
|
||||
// Load legacy params
|
||||
loadQueryParam(['l'], (openSource) => {
|
||||
if (openSource === 'true' && !currentFilters.value.some(filter => filter.type === 'license' && filter.option === 'open_source')) {
|
||||
currentFilters.value.push({
|
||||
type: 'license',
|
||||
option: 'open_source',
|
||||
negative: false,
|
||||
});
|
||||
readParams.add('l');
|
||||
}
|
||||
});
|
||||
loadQueryParam(['nf'], (filter) => {
|
||||
const set = typeof filter === 'string' ? new Set([filter]) : new Set(filter)
|
||||
|
||||
typesLoop: for (const type of filters.value) {
|
||||
for (const option of type.options) {
|
||||
const value = getOptionValue(option, false);
|
||||
if (set.has(value) && !currentFilters.value.some(filter => filter.type === type.id && filter.option === option.id)) {
|
||||
currentFilters.value.push({
|
||||
type: type.id,
|
||||
option: option.id,
|
||||
negative: true,
|
||||
})
|
||||
readParams.add(type.query_param);
|
||||
|
||||
set.delete(value)
|
||||
|
||||
if (set.size === 0) {
|
||||
break typesLoop;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
loadQueryParam(['s'], (sort) => {
|
||||
currentSortType.value = sortTypes.find(sortType => sortType.name === sort) ?? sortTypes[0]
|
||||
readParams.add('s');
|
||||
})
|
||||
loadQueryParam(['m'], (count) => {
|
||||
maxResults.value = Number(count)
|
||||
readParams.add('m');
|
||||
})
|
||||
loadQueryParam(['o'], (offset) => {
|
||||
currentPage.value = Math.ceil(Number(offset) / maxResults.value) + 1
|
||||
readParams.add('o');
|
||||
})
|
||||
loadQueryParam(['page'], (page) => {
|
||||
currentPage.value = Number(page)
|
||||
readParams.add('page');
|
||||
})
|
||||
|
||||
for (const key of Object.keys(route.query).filter(key => !readParams.has(key))) {
|
||||
const type = filters.value.find(type => type.query_param === key)
|
||||
if (type) {
|
||||
const values = getParamValuesAsArray(route.query[key])
|
||||
|
||||
for (const value of values) {
|
||||
const negative = !value.includes(':') && value.includes('!=')
|
||||
const option = type.options.find(option => (getOptionValue(option, negative)) === value)
|
||||
|
||||
if (!option && type.allows_custom_options) {
|
||||
currentFilters.value.push({
|
||||
type: type.id,
|
||||
option: value.replace('!=', ':'),
|
||||
negative: negative
|
||||
})
|
||||
} else if (option) {
|
||||
currentFilters.value.push({
|
||||
type: type.id,
|
||||
option: option.id,
|
||||
negative: negative
|
||||
})
|
||||
} else {
|
||||
console.error(`Unknown filter option: ${value}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(`Unknown filter type: ${key}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPageParams(): LocationQueryRaw {
|
||||
const items: Record<string, string[]> = {};
|
||||
|
||||
if (query.value) {
|
||||
items.q = [query.value];
|
||||
}
|
||||
|
||||
currentFilters.value.forEach(filterValue => {
|
||||
const type = filters.value.find(type => type.id === filterValue.type)
|
||||
const option = type?.options.find((option) => option.id === filterValue.option)
|
||||
if (type && option) {
|
||||
const value = getOptionValue(option, filterValue.negative);
|
||||
if (items[type.query_param]) {
|
||||
items[type.query_param].push(value)
|
||||
} else {
|
||||
items[type.query_param] = [value]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
toggledGroups.value.forEach(groupId => {
|
||||
const group = filters.value
|
||||
.flatMap(filter => filter.toggle_groups)
|
||||
.find(group => group && group.id === groupId)
|
||||
|
||||
if (group && 'query_param' in group && group.query_param) {
|
||||
items[group.query_param] = [String(true)]
|
||||
}
|
||||
})
|
||||
|
||||
if (currentSortType.value.name !== "relevance") {
|
||||
items.s = [currentSortType.value.name];
|
||||
}
|
||||
if (maxResults.value !== 20) {
|
||||
items.m = [String(maxResults.value)];
|
||||
}
|
||||
if (currentPage.value > 1) {
|
||||
items.page = [String(currentPage.value)];
|
||||
}
|
||||
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function createPageParamsString(pageParams: Record<string, string | string[] | boolean | number>) {
|
||||
let url = ``;
|
||||
|
||||
Object.entries(pageParams).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(value => {
|
||||
url = addQueryParam(url, key, value)
|
||||
})
|
||||
} else {
|
||||
url = addQueryParam(url, key, value)
|
||||
}
|
||||
})
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
function loadQueryParam(params: string[], provider: ((param: LocationQueryValue | LocationQueryValue[]) => void)) {
|
||||
for (const param of params) {
|
||||
if (param in route.query) {
|
||||
provider(route.query[param]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// Selections
|
||||
query,
|
||||
currentSortType,
|
||||
currentFilters,
|
||||
toggledGroups,
|
||||
currentPage,
|
||||
maxResults,
|
||||
overriddenProvidedFilterTypes,
|
||||
|
||||
// Lists
|
||||
sortTypes,
|
||||
filters,
|
||||
|
||||
// Computed
|
||||
facets,
|
||||
requestParams,
|
||||
|
||||
// Functions
|
||||
createPageParams,
|
||||
createPageParamsString,
|
||||
}
|
||||
}
|
||||
|
||||
export function createEnvironmentFacets(client: boolean, server: boolean): string[][] {
|
||||
const facets: string[][] = [];
|
||||
if (client && server) {
|
||||
facets.push(["client_side:required"], ["server_side:required"])
|
||||
} else if (client) {
|
||||
facets.push(
|
||||
["client_side:optional", "client_side:required"],
|
||||
["server_side:optional", "server_side:unsupported"]
|
||||
);
|
||||
} else if (server) {
|
||||
facets.push(
|
||||
["client_side:optional", "client_side:unsupported"],
|
||||
["server_side:optional", "server_side:required"]
|
||||
);
|
||||
}
|
||||
return facets;
|
||||
}
|
||||
|
||||
function getOptionValue(option: FilterOption, negative?: boolean): string {
|
||||
let value = option.method === 'or' || option.method === 'and' ? option.value : option.id
|
||||
if (negative === true) {
|
||||
value = value.replace(':', '!=')
|
||||
}
|
||||
if (option.query_value) {
|
||||
value = option.query_value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function addQueryParam(existing: string, key: string, value: string | number | boolean) {
|
||||
return existing + `${!existing ? '?' : '&'}${key}=${encodeURIComponent(value)}`
|
||||
}
|
||||
|
||||
function getParamValuesAsArray(x: LocationQueryValue | LocationQueryValue[]): string[] {
|
||||
if (x === null) {
|
||||
return []
|
||||
} else if (typeof x === 'string') {
|
||||
return [x]
|
||||
} else {
|
||||
return x.filter(x => x !== null)
|
||||
}
|
||||
}
|
||||
2
packages/ui/src/vue-shims.d.ts
vendored
@@ -1,5 +1,5 @@
|
||||
declare module '*.vue' {
|
||||
import { defineComponent } from 'vue'
|
||||
import type { defineComponent } from 'vue'
|
||||
|
||||
const component: ReturnType<typeof defineComponent>
|
||||
export default component
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"extends": "tsconfig/vue.json",
|
||||
"include": [".", ".eslintrc.js"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
"exclude": ["dist", "build", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"lib": ["esnext", "dom"],
|
||||
"noImplicitAny": false
|
||||
}
|
||||
}
|
||||
|
||||
249
packages/utils/index.d.ts
vendored
@@ -1,249 +0,0 @@
|
||||
export const BASE62_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
type Base62Char = (typeof BASE62_CHARS)[number]
|
||||
|
||||
export function formatPrice(locale: string, price: number, currency: string): string
|
||||
|
||||
export function getCurrency(userCountry: string): string
|
||||
|
||||
declare global {
|
||||
type ModrinthId = `${Base62Char}`[]
|
||||
|
||||
type Environment = 'required' | 'optional' | 'unsupported' | 'unknown'
|
||||
|
||||
type RequestableStatus = 'approved' | 'archived' | 'unlisted' | 'private'
|
||||
type ApprovedStatus = RequestableStatus | 'scheduled'
|
||||
type UnapprovedStatus = 'draft' | 'processing' | 'rejected' | 'withheld'
|
||||
type ProjectStatus = ApprovedStatus | UnapprovedStatus | 'unknown'
|
||||
|
||||
type DonationPlatform =
|
||||
| { short: 'patreon'; name: 'Patreon' }
|
||||
| { short: 'bmac'; name: 'Buy Me A Coffee' }
|
||||
| { short: 'paypal'; name: 'PayPal' }
|
||||
| { short: 'github'; name: 'GitHub Sponsors' }
|
||||
| { short: 'ko-fi'; name: 'Ko-fi' }
|
||||
| { short: 'other'; name: 'Other' }
|
||||
|
||||
type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader'
|
||||
type MonetizationStatus = 'monetized' | 'demonetized' | 'force-demonetized'
|
||||
|
||||
type GameVersion = string
|
||||
type Platform = string
|
||||
type Category = string
|
||||
type CategoryOrPlatform = Category | Platform
|
||||
|
||||
interface DonationLink<T extends DonationPlatform> {
|
||||
id: T['short']
|
||||
platform: T['name']
|
||||
url: string
|
||||
}
|
||||
|
||||
interface GalleryImage {
|
||||
url: string
|
||||
featured: boolean
|
||||
created: string
|
||||
ordering: number
|
||||
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: ModrinthId
|
||||
project_type: ProjectType
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
status: ProjectStatus
|
||||
requested_status: RequestableStatus
|
||||
monetization_status: MonetizationStatus
|
||||
|
||||
body: string
|
||||
icon_url?: string
|
||||
color?: number
|
||||
|
||||
categories: Category[]
|
||||
additional_categories: Category[]
|
||||
|
||||
downloads: number
|
||||
followers: number
|
||||
|
||||
client_side: Environment
|
||||
server_side: Environment
|
||||
|
||||
team: ModrinthId
|
||||
thread_id: ModrinthId
|
||||
|
||||
issues_url?: string
|
||||
source_url?: string
|
||||
wiki_url?: string
|
||||
discord_url?: string
|
||||
donation_urls: DonationLink[]
|
||||
|
||||
published: string
|
||||
updated: string
|
||||
approved: string
|
||||
queued: string
|
||||
|
||||
game_versions: GameVersion[]
|
||||
loaders: Platform[]
|
||||
|
||||
versions: ModrinthId[]
|
||||
gallery?: GalleryImage[]
|
||||
|
||||
license: {
|
||||
id: string
|
||||
name
|
||||
string
|
||||
url?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
id: ModrinthId
|
||||
project_type: ProjectType
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
monetization_status: MonetizationStatus
|
||||
|
||||
icon_url?: string
|
||||
color?: number
|
||||
|
||||
categories: CategoryOrPlatform[]
|
||||
display_categories: CategoryOrPlatform[]
|
||||
versions: GameVersion[]
|
||||
latest_version: GameVersion
|
||||
|
||||
downloads: number
|
||||
follows: number
|
||||
|
||||
client_side: Environment
|
||||
server_side: Environment
|
||||
|
||||
author: string
|
||||
|
||||
date_created: string
|
||||
date_modified: string
|
||||
|
||||
gallery: string[]
|
||||
featured_gallery?: string[]
|
||||
|
||||
license: string
|
||||
}
|
||||
|
||||
type DependencyType = 'required' | 'optional' | 'incompatible' | 'embedded'
|
||||
|
||||
interface VersionDependency {
|
||||
dependency_type: DependencyType
|
||||
file_name?: string
|
||||
}
|
||||
|
||||
interface ProjectDependency {
|
||||
dependency_type: DependencyType
|
||||
project_id?: string
|
||||
}
|
||||
|
||||
interface FileDependency {
|
||||
dependency_type: DependencyType
|
||||
file_name?: string
|
||||
}
|
||||
|
||||
type Dependency = VersionDependency | ProjectDependency | FileDependency
|
||||
type VersionChannel = 'release' | 'beta' | 'alpha'
|
||||
type VersionStatus = 'listed' | 'archived' | 'draft' | 'unlisted' | 'scheduled' | 'unknown'
|
||||
type FileType = 'required-resource-pack' | 'optional-resource-pack'
|
||||
|
||||
interface VersionFileHash {
|
||||
sha512: string
|
||||
sha1: string
|
||||
}
|
||||
|
||||
interface VersionFile {
|
||||
hashes: VersionFileHash[]
|
||||
url: string
|
||||
filename: string
|
||||
primary: boolean
|
||||
size: number
|
||||
file_type?: FileType
|
||||
}
|
||||
|
||||
interface Version {
|
||||
name: string
|
||||
version_number: string
|
||||
changelog?: string
|
||||
dependencies: Dependency[]
|
||||
game_versions: GameVersion[]
|
||||
version_type: VersionChannel
|
||||
loaders: Platform[]
|
||||
featured: boolean
|
||||
status: VersionStatus
|
||||
id: ModrinthId
|
||||
project_id: ModrinthId
|
||||
author_id: ModrinthId
|
||||
date_published: string
|
||||
downloads: number
|
||||
files: VersionFile[]
|
||||
}
|
||||
|
||||
interface PayoutData {
|
||||
balance: number
|
||||
payout_wallet: 'paypal' | 'venmo'
|
||||
payout_wallet_type: 'email' | 'phone' | 'user_handle'
|
||||
payout_address: string
|
||||
}
|
||||
|
||||
type UserRole = 'admin' | 'moderator' | 'pyro' | 'developer'
|
||||
|
||||
enum UserBadge {
|
||||
MIDAS = 1 << 0,
|
||||
EARLY_MODPACK_ADOPTER = 1 << 1,
|
||||
EARLY_RESPACK_ADOPTER = 1 << 2,
|
||||
EARLY_PLUGIN_ADOPTER = 1 << 3,
|
||||
ALPHA_TESTER = 1 << 4,
|
||||
CONTRIBUTOR = 1 << 5,
|
||||
TRANSLATOR = 1 << 6,
|
||||
}
|
||||
|
||||
type UserBadges = number
|
||||
|
||||
interface User {
|
||||
username: string
|
||||
email?: string
|
||||
bio?: string
|
||||
payout_data?: PayoutData
|
||||
id: ModrinthId
|
||||
avatar_url: string
|
||||
created: string
|
||||
role: UserRole
|
||||
badges: UserBadges
|
||||
auth_providers?: string[]
|
||||
email_verified?: boolean
|
||||
has_password?: boolean
|
||||
has_totp?: boolean
|
||||
}
|
||||
|
||||
enum TeamMemberPermission {
|
||||
UPLOAD_VERSION = 1 << 0,
|
||||
DELETE_VERSION = 1 << 1,
|
||||
EDIT_DETAILS = 1 << 2,
|
||||
EDIT_BODY = 1 << 3,
|
||||
MANAGE_INVITES = 1 << 4,
|
||||
REMOVE_MEMBER = 1 << 5,
|
||||
EDIT_MEMBER = 1 << 6,
|
||||
DELETE_PROJECT = 1 << 7,
|
||||
VIEW_ANALYTICS = 1 << 8,
|
||||
VIEW_PAYOUTS = 1 << 9,
|
||||
}
|
||||
|
||||
type TeamMemberPermissions = number
|
||||
|
||||
interface TeamMember {
|
||||
team_id: ModrinthId
|
||||
user: User
|
||||
role: string
|
||||
permissions: TeamMemberPermissions
|
||||
accepted: boolean
|
||||
payouts_split: number
|
||||
ordering: number
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,5 @@ export * from './projects'
|
||||
export * from './users'
|
||||
export * from './utils'
|
||||
export * from './billing'
|
||||
|
||||
export * from './types'
|
||||
|
||||
@@ -1,74 +1,5 @@
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
export const getProjectTypeForDisplay = (type, categories, tags) => {
|
||||
if (type === 'mod') {
|
||||
const isPlugin = categories.some((category) => {
|
||||
return tags.loaderData.allPluginLoaders.includes(category)
|
||||
})
|
||||
const isMod = categories.some((category) => {
|
||||
return tags.loaderData.modLoaders.includes(category)
|
||||
})
|
||||
const isDataPack = categories.some((category) => {
|
||||
return tags.loaderData.dataPackLoaders.includes(category)
|
||||
})
|
||||
|
||||
if (isMod && isPlugin && isDataPack) {
|
||||
return 'mod, plugin, and data pack'
|
||||
} else if (isMod && isPlugin) {
|
||||
return 'mod and plugin'
|
||||
} else if (isMod && isDataPack) {
|
||||
return 'mod and data pack'
|
||||
} else if (isPlugin && isDataPack) {
|
||||
return 'plugin and data pack'
|
||||
} else if (isDataPack) {
|
||||
return 'data pack'
|
||||
} else if (isPlugin) {
|
||||
return 'plugin'
|
||||
}
|
||||
}
|
||||
|
||||
return type
|
||||
}
|
||||
|
||||
export const getProjectTypeForUrl = (type, loaders, tags) => {
|
||||
if (type === 'mod') {
|
||||
const isMod = loaders.some((category) => {
|
||||
return tags.loaderData.modLoaders.includes(category)
|
||||
})
|
||||
|
||||
const isPlugin = loaders.some((category) => {
|
||||
return tags.loaderData.allPluginLoaders.includes(category)
|
||||
})
|
||||
|
||||
const isDataPack = loaders.some((category) => {
|
||||
return tags.loaderData.dataPackLoaders.includes(category)
|
||||
})
|
||||
|
||||
if (isDataPack) {
|
||||
return 'datapack'
|
||||
} else if (isPlugin) {
|
||||
return 'plugin'
|
||||
} else if (isMod) {
|
||||
return 'mod'
|
||||
}
|
||||
return 'mod'
|
||||
}
|
||||
return type
|
||||
}
|
||||
|
||||
export const getProjectLink = (project) => {
|
||||
return `/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}`
|
||||
}
|
||||
|
||||
export const getVersionLink = (project, version) => {
|
||||
if (version) {
|
||||
return `${getProjectLink(project)}/version/${version.id}`
|
||||
}
|
||||
return getProjectLink(project)
|
||||
}
|
||||
|
||||
export const isApproved = (project) => {
|
||||
return project && APPROVED_PROJECT_STATUSES.includes(project.status)
|
||||
}
|
||||
@@ -104,3 +35,201 @@ export const PRIVATE_PROJECT_STATUSES = ['private', 'rejected', 'processing']
|
||||
export const REJECTED_PROJECT_STATUSES = ['rejected', 'withheld']
|
||||
export const UNDER_REVIEW_PROJECT_STATUSES = ['processing']
|
||||
export const DRAFT_PROJECT_STATUSES = ['draft']
|
||||
|
||||
export type GameVersionTag = {
|
||||
version: string
|
||||
version_type: string
|
||||
date: string
|
||||
major: boolean
|
||||
}
|
||||
|
||||
export type DisplayProjectType =
|
||||
| 'mod'
|
||||
| 'plugin'
|
||||
| 'datapack'
|
||||
| 'resourcepack'
|
||||
| 'modpack'
|
||||
| 'shader'
|
||||
|
||||
export type PlatformTag = {
|
||||
icon: string
|
||||
name: string
|
||||
supported_project_types: DisplayProjectType[]
|
||||
}
|
||||
|
||||
export function getVersionsToDisplay(project, allGameVersions: GameVersionTag[]) {
|
||||
return formatVersionsForDisplay(project.game_versions.slice(), allGameVersions)
|
||||
}
|
||||
|
||||
export function formatVersionsForDisplay(
|
||||
gameVersions: string[],
|
||||
allGameVersions: GameVersionTag[],
|
||||
) {
|
||||
const inputVersions = gameVersions.slice()
|
||||
const allVersions = allGameVersions.slice()
|
||||
|
||||
const allSnapshots = allVersions.filter((version) => version.version_type === 'snapshot')
|
||||
const allReleases = allVersions.filter((version) => version.version_type === 'release')
|
||||
const allLegacy = allVersions.filter(
|
||||
(version) => version.version_type !== 'snapshot' && version.version_type !== 'release',
|
||||
)
|
||||
|
||||
{
|
||||
const indices = allVersions.reduce((map, gameVersion, index) => {
|
||||
map[gameVersion.version] = index
|
||||
return map
|
||||
}, {})
|
||||
inputVersions.sort((a, b) => indices[a] - indices[b])
|
||||
}
|
||||
|
||||
const releaseVersions = inputVersions.filter((projVer) =>
|
||||
allReleases.some((gameVer) => gameVer.version === projVer),
|
||||
)
|
||||
|
||||
const dateString = allReleases.find((version) => version.version === releaseVersions[0])?.date
|
||||
|
||||
const latestReleaseVersionDate = dateString ? Date.parse(dateString) : 0
|
||||
const latestSnapshot = inputVersions.find((projVer) =>
|
||||
allSnapshots.some(
|
||||
(gameVer) =>
|
||||
gameVer.version === projVer &&
|
||||
(!latestReleaseVersionDate || latestReleaseVersionDate < Date.parse(gameVer.date)),
|
||||
),
|
||||
)
|
||||
|
||||
const allReleasesGrouped = groupVersions(
|
||||
allReleases.map((release) => release.version),
|
||||
false,
|
||||
)
|
||||
const projectVersionsGrouped = groupVersions(releaseVersions, true)
|
||||
|
||||
const releaseVersionsAsRanges = projectVersionsGrouped.map(({ major, minor }) => {
|
||||
if (minor.length === 1) {
|
||||
return formatMinecraftMinorVersion(major, minor[0])
|
||||
}
|
||||
|
||||
const range = allReleasesGrouped.find((x) => x.major === major)
|
||||
|
||||
if (range?.minor.every((value, index) => value === minor[index])) {
|
||||
return `${major}.x`
|
||||
}
|
||||
|
||||
return `${formatMinecraftMinorVersion(major, minor[0])}–${formatMinecraftMinorVersion(major, minor[minor.length - 1])}`
|
||||
})
|
||||
|
||||
const legacyVersionsAsRanges = groupConsecutiveIndices(
|
||||
inputVersions.filter((projVer) => allLegacy.some((gameVer) => gameVer.version === projVer)),
|
||||
allLegacy,
|
||||
)
|
||||
|
||||
let output = [...legacyVersionsAsRanges]
|
||||
|
||||
// show all snapshots if there's no release versions
|
||||
if (releaseVersionsAsRanges.length === 0) {
|
||||
const snapshotVersionsAsRanges = groupConsecutiveIndices(
|
||||
inputVersions.filter((projVer) =>
|
||||
allSnapshots.some((gameVer) => gameVer.version === projVer),
|
||||
),
|
||||
allSnapshots,
|
||||
)
|
||||
output = [...snapshotVersionsAsRanges, ...output]
|
||||
} else {
|
||||
output = [...releaseVersionsAsRanges, ...output]
|
||||
}
|
||||
|
||||
if (latestSnapshot && !output.includes(latestSnapshot)) {
|
||||
output = [latestSnapshot, ...output]
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
const mcVersionRegex = /^([0-9]+.[0-9]+)(.[0-9]+)?$/
|
||||
|
||||
type VersionRange = {
|
||||
major: string
|
||||
minor: number[]
|
||||
}
|
||||
|
||||
function groupVersions(versions: string[], consecutive = false) {
|
||||
return versions
|
||||
.slice()
|
||||
.reverse()
|
||||
.reduce((ranges: VersionRange[], version: string) => {
|
||||
const matchesVersion = version.match(mcVersionRegex)
|
||||
|
||||
if (matchesVersion) {
|
||||
const majorVersion = matchesVersion[1]
|
||||
const minorVersion = matchesVersion[2]
|
||||
const minorNumeric = minorVersion ? parseInt(minorVersion.replace('.', '')) : 0
|
||||
|
||||
const prevInRange = ranges.find(
|
||||
(x) => x.major === majorVersion && (!consecutive || x.minor.at(-1) === minorNumeric - 1),
|
||||
)
|
||||
if (prevInRange) {
|
||||
prevInRange.minor.push(minorNumeric)
|
||||
return ranges
|
||||
}
|
||||
|
||||
return [...ranges, { major: majorVersion, minor: [minorNumeric] }]
|
||||
}
|
||||
|
||||
return ranges
|
||||
}, [])
|
||||
.reverse()
|
||||
}
|
||||
|
||||
function groupConsecutiveIndices(versions: string[], referenceList: GameVersionTag[]) {
|
||||
if (!versions || versions.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const referenceMap = new Map()
|
||||
referenceList.forEach((item, index) => {
|
||||
referenceMap.set(item.version, index)
|
||||
})
|
||||
|
||||
const sortedList: string[] = versions
|
||||
.slice()
|
||||
.sort((a, b) => referenceMap.get(a) - referenceMap.get(b))
|
||||
|
||||
const ranges: string[] = []
|
||||
let start = sortedList[0]
|
||||
let previous = sortedList[0]
|
||||
|
||||
for (let i = 1; i < sortedList.length; i++) {
|
||||
const current = sortedList[i]
|
||||
if (referenceMap.get(current) !== referenceMap.get(previous) + 1) {
|
||||
ranges.push(validateRange(`${previous}–${start}`))
|
||||
start = current
|
||||
}
|
||||
previous = current
|
||||
}
|
||||
|
||||
ranges.push(validateRange(`${previous}–${start}`))
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
function validateRange(range: string): string {
|
||||
switch (range) {
|
||||
case 'rd-132211–b1.8.1':
|
||||
return 'All legacy versions'
|
||||
case 'a1.0.4–b1.8.1':
|
||||
return 'All alpha and beta versions'
|
||||
case 'a1.0.4–a1.2.6':
|
||||
return 'All alpha versions'
|
||||
case 'b1.0–b1.8.1':
|
||||
return 'All beta versions'
|
||||
case 'rd-132211–inf20100618':
|
||||
return 'All pre-alpha versions'
|
||||
}
|
||||
const splitRange = range.split('–')
|
||||
if (splitRange && splitRange[0] === splitRange[1]) {
|
||||
return splitRange[0]
|
||||
}
|
||||
return range
|
||||
}
|
||||
|
||||
function formatMinecraftMinorVersion(major: string, minor: number): string {
|
||||
return minor === 0 ? major : `${major}.${minor}`
|
||||
}
|
||||
|
||||
243
packages/utils/types.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
export const BASE62_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
export type Base62Char = (typeof BASE62_CHARS)[number]
|
||||
|
||||
export type ModrinthId = `${Base62Char}`[]
|
||||
|
||||
export type Environment = 'required' | 'optional' | 'unsupported' | 'unknown'
|
||||
|
||||
export type RequestableStatus = 'approved' | 'archived' | 'unlisted' | 'private'
|
||||
export type ApprovedStatus = RequestableStatus | 'scheduled'
|
||||
export type UnapprovedStatus = 'draft' | 'processing' | 'rejected' | 'withheld'
|
||||
export type ProjectStatus = ApprovedStatus | UnapprovedStatus | 'unknown'
|
||||
|
||||
export type DonationPlatform =
|
||||
| { short: 'patreon'; name: 'Patreon' }
|
||||
| { short: 'bmac'; name: 'Buy Me A Coffee' }
|
||||
| { short: 'paypal'; name: 'PayPal' }
|
||||
| { short: 'github'; name: 'GitHub Sponsors' }
|
||||
| { short: 'ko-fi'; name: 'Ko-fi' }
|
||||
| { short: 'other'; name: 'Other' }
|
||||
|
||||
export type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader'
|
||||
export type MonetizationStatus = 'monetized' | 'demonetized' | 'force-demonetized'
|
||||
|
||||
export type GameVersion = string
|
||||
export type Platform = string
|
||||
export type Category = string
|
||||
export type CategoryOrPlatform = Category | Platform
|
||||
|
||||
export interface DonationLink<T extends DonationPlatform> {
|
||||
id: T['short']
|
||||
platform: T['name']
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface GalleryImage {
|
||||
url: string
|
||||
featured: boolean
|
||||
created: string
|
||||
ordering: number
|
||||
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: ModrinthId
|
||||
project_type: ProjectType
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
status: ProjectStatus
|
||||
requested_status: RequestableStatus
|
||||
monetization_status: MonetizationStatus
|
||||
|
||||
body: string
|
||||
icon_url?: string
|
||||
color?: number
|
||||
|
||||
categories: Category[]
|
||||
additional_categories: Category[]
|
||||
|
||||
downloads: number
|
||||
followers: number
|
||||
|
||||
client_side: Environment
|
||||
server_side: Environment
|
||||
|
||||
team: ModrinthId
|
||||
thread_id: ModrinthId
|
||||
|
||||
issues_url?: string
|
||||
source_url?: string
|
||||
wiki_url?: string
|
||||
discord_url?: string
|
||||
donation_urls: DonationLink<DonationPlatform>[]
|
||||
|
||||
published: string
|
||||
updated: string
|
||||
approved: string
|
||||
queued: string
|
||||
|
||||
game_versions: GameVersion[]
|
||||
loaders: Platform[]
|
||||
|
||||
versions: ModrinthId[]
|
||||
gallery?: GalleryImage[]
|
||||
|
||||
license: {
|
||||
id: string
|
||||
name
|
||||
string
|
||||
url?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: ModrinthId
|
||||
project_type: ProjectType
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
monetization_status: MonetizationStatus
|
||||
|
||||
icon_url?: string
|
||||
color?: number
|
||||
|
||||
categories: CategoryOrPlatform[]
|
||||
display_categories: CategoryOrPlatform[]
|
||||
versions: GameVersion[]
|
||||
latest_version: GameVersion
|
||||
|
||||
downloads: number
|
||||
follows: number
|
||||
|
||||
client_side: Environment
|
||||
server_side: Environment
|
||||
|
||||
author: string
|
||||
|
||||
date_created: string
|
||||
date_modified: string
|
||||
|
||||
gallery: string[]
|
||||
featured_gallery?: string[]
|
||||
|
||||
license: string
|
||||
}
|
||||
|
||||
export type DependencyType = 'required' | 'optional' | 'incompatible' | 'embedded'
|
||||
|
||||
export interface VersionDependency {
|
||||
dependency_type: DependencyType
|
||||
file_name?: string
|
||||
}
|
||||
|
||||
export interface ProjectDependency {
|
||||
dependency_type: DependencyType
|
||||
project_id?: string
|
||||
}
|
||||
|
||||
export interface FileDependency {
|
||||
dependency_type: DependencyType
|
||||
file_name?: string
|
||||
}
|
||||
|
||||
export type Dependency = VersionDependency | ProjectDependency | FileDependency
|
||||
export type VersionChannel = 'release' | 'beta' | 'alpha'
|
||||
export type VersionStatus = 'listed' | 'archived' | 'draft' | 'unlisted' | 'scheduled' | 'unknown'
|
||||
export type FileType = 'required-resource-pack' | 'optional-resource-pack'
|
||||
|
||||
export interface VersionFileHash {
|
||||
sha512: string
|
||||
sha1: string
|
||||
}
|
||||
|
||||
export interface VersionFile {
|
||||
hashes: VersionFileHash[]
|
||||
url: string
|
||||
filename: string
|
||||
primary: boolean
|
||||
size: number
|
||||
file_type?: FileType
|
||||
}
|
||||
|
||||
export interface Version {
|
||||
name: string
|
||||
version_number: string
|
||||
changelog?: string
|
||||
dependencies: Dependency[]
|
||||
game_versions: GameVersion[]
|
||||
version_type: VersionChannel
|
||||
loaders: Platform[]
|
||||
featured: boolean
|
||||
status: VersionStatus
|
||||
id: ModrinthId
|
||||
project_id: ModrinthId
|
||||
author_id: ModrinthId
|
||||
date_published: string
|
||||
downloads: number
|
||||
files: VersionFile[]
|
||||
}
|
||||
|
||||
export interface PayoutData {
|
||||
balance: number
|
||||
payout_wallet: 'paypal' | 'venmo'
|
||||
payout_wallet_type: 'email' | 'phone' | 'user_handle'
|
||||
payout_address: string
|
||||
}
|
||||
|
||||
export type UserRole = 'admin' | 'moderator' | 'pyro' | 'developer'
|
||||
|
||||
export enum UserBadge {
|
||||
MIDAS = 1 << 0,
|
||||
EARLY_MODPACK_ADOPTER = 1 << 1,
|
||||
EARLY_RESPACK_ADOPTER = 1 << 2,
|
||||
EARLY_PLUGIN_ADOPTER = 1 << 3,
|
||||
ALPHA_TESTER = 1 << 4,
|
||||
CONTRIBUTOR = 1 << 5,
|
||||
TRANSLATOR = 1 << 6,
|
||||
}
|
||||
|
||||
export type UserBadges = number
|
||||
|
||||
export interface User {
|
||||
username: string
|
||||
email?: string
|
||||
bio?: string
|
||||
payout_data?: PayoutData
|
||||
id: ModrinthId
|
||||
avatar_url: string
|
||||
created: string
|
||||
role: UserRole
|
||||
badges: UserBadges
|
||||
auth_providers?: string[]
|
||||
email_verified?: boolean
|
||||
has_password?: boolean
|
||||
has_totp?: boolean
|
||||
}
|
||||
|
||||
export enum TeamMemberPermission {
|
||||
UPLOAD_VERSION = 1 << 0,
|
||||
DELETE_VERSION = 1 << 1,
|
||||
EDIT_DETAILS = 1 << 2,
|
||||
EDIT_BODY = 1 << 3,
|
||||
MANAGE_INVITES = 1 << 4,
|
||||
REMOVE_MEMBER = 1 << 5,
|
||||
EDIT_MEMBER = 1 << 6,
|
||||
DELETE_PROJECT = 1 << 7,
|
||||
VIEW_ANALYTICS = 1 << 8,
|
||||
VIEW_PAYOUTS = 1 << 9,
|
||||
}
|
||||
|
||||
export type TeamMemberPermissions = number
|
||||
|
||||
export interface TeamMember {
|
||||
team_id: ModrinthId
|
||||
user: User
|
||||
role: string
|
||||
permissions: TeamMemberPermissions
|
||||
accepted: boolean
|
||||
payouts_split: number
|
||||
ordering: number
|
||||
}
|
||||
@@ -296,3 +296,37 @@ export const acceptFileFromProjectType = (projectType) => {
|
||||
return '*'
|
||||
}
|
||||
}
|
||||
|
||||
// Sorts alphabetically, but correctly identifies 8x, 128x, 256x, etc
|
||||
// identifier[0], then if it ties, identifier[1], etc
|
||||
export const sortByNameOrNumber = (sortable, identifiers) => {
|
||||
sortable.sort((a, b) => {
|
||||
for (const identifier of identifiers) {
|
||||
const aNum = parseFloat(a[identifier])
|
||||
const bNum = parseFloat(b[identifier])
|
||||
if (isNaN(aNum) && isNaN(bNum)) {
|
||||
// Both are strings, sort alphabetically
|
||||
const stringComp = a[identifier].localeCompare(b[identifier])
|
||||
if (stringComp != 0) return stringComp
|
||||
} else if (!isNaN(aNum) && !isNaN(bNum)) {
|
||||
// Both are numbers, sort numerically
|
||||
const numComp = aNum - bNum
|
||||
if (numComp != 0) return numComp
|
||||
} else {
|
||||
// One is a number and one is a string, numbers go first
|
||||
const numStringComp = isNaN(aNum) ? 1 : -1
|
||||
if (numStringComp != 0) return numStringComp
|
||||
}
|
||||
}
|
||||
return 0
|
||||
})
|
||||
return sortable
|
||||
}
|
||||
|
||||
export const getArrayOrString = (x: string[] | string): string[] => {
|
||||
if (typeof x === 'string') {
|
||||
return [x]
|
||||
} else {
|
||||
return x
|
||||
}
|
||||
}
|
||||
|
||||