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>
This commit is contained in:
Prospector
2024-12-11 19:54:18 -08:00
committed by GitHub
parent 6ec1dcf088
commit c39bb78e38
257 changed files with 15713 additions and 9475 deletions

View File

@@ -22,11 +22,7 @@
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
"nullable": [false, false, false]
},
"hash": "1397c1825096fb402cdd3b5dae8cd3910b1719f433a0c34d40415dd7681ab272"
}

View File

@@ -27,12 +27,7 @@
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false
]
"nullable": [false, false, false, false]
},
"hash": "18881c0c2ec1b0cc73fa13b4c242dfc577061b92479ce96ffb30a457939b5ffe"
}

View File

@@ -27,12 +27,7 @@
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false
]
"nullable": [false, false, false, false]
},
"hash": "265f9c9ad992da0aeaf69c3f0077b54a186b98796ec549c9d891089ea33cf3fc"
}

View File

@@ -32,13 +32,7 @@
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
null,
true,
false
]
"nullable": [false, false, null, true, false]
},
"hash": "28b3e3132d75e551c1fa14b8d3be36adca581f8ad1b90f85d3ec3d92ec61e65e"
}

View File

@@ -27,12 +27,7 @@
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false
]
"nullable": [false, false, false, false]
},
"hash": "6d7ebc0f233dc730fa8c99c750421065f5e35f321954a9d5ae9cde907d5ce823"
}

View File

@@ -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"
}

View File

@@ -37,14 +37,7 @@
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false,
false
]
"nullable": [false, false, false, false, false, false]
},
"hash": "727e3e1bc8625bbcb833920059bb8cea926ac6c65d613904eff1d740df30acda"
}

View File

@@ -37,14 +37,7 @@
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false,
false
]
"nullable": [false, false, false, false, false, false]
},
"hash": "bf7d47350092d87c478009adaab131168e87bb37aa65c2156ad2cb6198426d8c"
}

View File

@@ -6,4 +6,4 @@
"fix": "cargo fmt && cargo clippy --fix",
"test": "cargo test"
}
}
}

View 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(())
}

View File

@@ -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,
};
}

View File

@@ -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(&current.user_id, &state.pool).await?;
state.friends_socket.disconnect().await?;
}
Ok(())

View File

@@ -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/";

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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?;

View 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(())
}
}

View File

@@ -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,
}))

View File

@@ -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(

View File

@@ -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")]
{

View File

@@ -193,7 +193,7 @@ impl ProjectType {
ProjectType::Mod => "mod",
ProjectType::DataPack => "datapack",
ProjectType::ResourcePack => "resourcepack",
ProjectType::ShaderPack => "shaderpack",
ProjectType::ShaderPack => "shader",
}
}

View File

@@ -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

View File

@@ -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

View 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

View 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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -12,6 +12,6 @@
"eslint": "^8.57.0",
"eslint-config-custom": "workspace:*",
"tsconfig": "workspace:*",
"vue": "^3.4.31"
"vue": "^3.5.13"
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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.

View File

@@ -7,4 +7,4 @@
"dev": "cargo run",
"test": "cargo test"
}
}
}

View File

@@ -1,4 +0,0 @@
module.exports = {
root: true,
extends: ['custom/vue'],
}

View 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',
},
},
])

View File

@@ -1 +1,3 @@
export * from './src/components/index'
export { commonMessages, commonSettingsMessages } from './src/utils/common-messages'
export * from './src/utils/search'

View File

@@ -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"
}

View 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>

View 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>

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }">

View 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>

View File

@@ -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,

View File

@@ -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'

View File

@@ -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;

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -942,7 +942,7 @@ async function submitPayment() {
defineExpose({
show: () => {
// eslint-disable-next-line no-undef
stripe = Stripe(props.publishableKey)
selectedPlan.value = 'yearly'

View 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>

View 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>

View File

@@ -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'

View File

@@ -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;

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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
}>(),
{

View 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>

View 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>

View File

@@ -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}"
}
}

View 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',
},
})

View 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)
}
}

View File

@@ -1,5 +1,5 @@
declare module '*.vue' {
import { defineComponent } from 'vue'
import type { defineComponent } from 'vue'
const component: ReturnType<typeof defineComponent>
export default component

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -4,3 +4,5 @@ export * from './projects'
export * from './users'
export * from './utils'
export * from './billing'
export * from './types'

View File

@@ -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-132211b1.8.1':
return 'All legacy versions'
case 'a1.0.4b1.8.1':
return 'All alpha and beta versions'
case 'a1.0.4a1.2.6':
return 'All alpha versions'
case 'b1.0b1.8.1':
return 'All beta versions'
case 'rd-132211inf20100618':
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
View 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
}

View File

@@ -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
}
}