Merge commit '81ec068747a39e927c42273011252daaa58f1e14' into feature-clean
@@ -41,7 +41,7 @@
|
||||
{
|
||||
"name": "display_claims!: serde_json::Value",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25\n ",
|
||||
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 25
|
||||
"Right": 27
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "26e3ed8680f6c492b03b458aabfb3f94fddc753b343ef705263188945d0e578d"
|
||||
"hash": "759e4ffe30ebc4f8602256cb419ef15732d84bcebb9ca15225dbabdc0f46ba2d"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated\n FROM settings\n ",
|
||||
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar\n FROM settings\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -127,6 +127,16 @@
|
||||
"name": "migrated",
|
||||
"ordinal": 24,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "feature_flags",
|
||||
"ordinal": 25,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "toggle_sidebar",
|
||||
"ordinal": 26,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -157,8 +167,10 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8e19c9cdb0aaa48509724e82f6e8f212c9cd2112fdba77cfeee206025af47761"
|
||||
"hash": "d90a2f2f823fc546661a94af07249758c5ca82db396268bca5087bac88f733d9"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "theseus"
|
||||
version = "0.8.9"
|
||||
version = "0.9.2"
|
||||
authors = ["Jai A <jaiagr+gpg@pm.me>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE settings ADD COLUMN toggle_sidebar INTEGER NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE settings ADD COLUMN feature_flags JSONB NOT NULL default '{}';
|
||||
@@ -6,4 +6,4 @@
|
||||
"fix": "cargo fmt && cargo clippy --fix",
|
||||
"test": "cargo test"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
35
packages/app-lib/src/api/friends.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use crate::state::{FriendsSocket, UserFriend, UserStatus};
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn friends() -> crate::Result<Vec<UserFriend>> {
|
||||
let state = crate::State::get().await?;
|
||||
let friends =
|
||||
FriendsSocket::friends(&state.pool, &state.api_semaphore).await?;
|
||||
|
||||
Ok(friends)
|
||||
}
|
||||
|
||||
pub async fn friend_statuses() -> crate::Result<Vec<UserStatus>> {
|
||||
let state = crate::State::get().await?;
|
||||
let statuses = state.friends_socket.friend_statuses();
|
||||
|
||||
Ok(statuses)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn add_friend(user_id: &str) -> crate::Result<()> {
|
||||
let state = crate::State::get().await?;
|
||||
FriendsSocket::add_friend(user_id, &state.pool, &state.api_semaphore)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn remove_friend(user_id: &str) -> crate::Result<()> {
|
||||
let state = crate::State::get().await?;
|
||||
FriendsSocket::remove_friend(user_id, &state.pool, &state.api_semaphore)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -289,7 +289,7 @@ pub async fn delete_logs_by_filename(
|
||||
};
|
||||
|
||||
let path = logs_folder.join(filename);
|
||||
io::remove_dir_all(&path).await?;
|
||||
io::remove_file(&path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! API for interacting with Theseus
|
||||
pub mod cache;
|
||||
pub mod friends;
|
||||
pub mod handler;
|
||||
pub mod jre;
|
||||
pub mod logs;
|
||||
@@ -19,7 +20,7 @@ pub mod data {
|
||||
Hooks, JavaVersion, LinkedData, MemorySettings, ModLoader,
|
||||
ModrinthCredentials, Organization, ProcessMetadata, ProfileFile,
|
||||
Project, ProjectType, SearchResult, SearchResults, Settings,
|
||||
TeamMember, Theme, User, Version, WindowSize,
|
||||
TeamMember, Theme, User, UserFriend, UserStatus, Version, WindowSize,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::state::ModrinthCredentials;
|
||||
|
||||
#[tracing::instrument]
|
||||
pub fn authenticate_begin_flow() -> &'static str {
|
||||
pub fn authenticate_begin_flow() -> String {
|
||||
crate::state::get_login_url()
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ pub async fn authenticate_finish_flow(
|
||||
.await?;
|
||||
|
||||
creds.upsert(&state.pool).await?;
|
||||
state
|
||||
.friends_socket
|
||||
.connect(&state.pool, &state.api_semaphore, &state.process_manager)
|
||||
.await?;
|
||||
|
||||
Ok(creds)
|
||||
}
|
||||
@@ -30,6 +34,7 @@ pub async fn logout() -> crate::Result<()> {
|
||||
|
||||
if let Some(current) = current {
|
||||
ModrinthCredentials::remove(¤t.user_id, &state.pool).await?;
|
||||
state.friends_socket.disconnect().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -44,11 +44,11 @@ pub enum PackFileHash {
|
||||
|
||||
impl From<String> for PackFileHash {
|
||||
fn from(s: String) -> Self {
|
||||
return match s.as_str() {
|
||||
match s.as_str() {
|
||||
"sha1" => PackFileHash::Sha1,
|
||||
"sha512" => PackFileHash::Sha512,
|
||||
_ => PackFileHash::Unknown(s),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +324,6 @@ pub async fn generate_pack_from_file(
|
||||
|
||||
/// Sets generated profile attributes to the pack ones (using profile::edit)
|
||||
/// This includes the pack name, icon, game version, loader version, and loader
|
||||
|
||||
pub async fn set_profile_information(
|
||||
profile_path: String,
|
||||
description: &CreatePackDescription,
|
||||
|
||||
@@ -25,7 +25,6 @@ use super::install_from::{
|
||||
/// Wrapper around install_pack_files that generates a pack creation description, and
|
||||
/// attempts to install the pack files. If it fails, it will remove the profile (fail safely)
|
||||
/// Install a modpack from a mrpack file (a modrinth .zip format)
|
||||
|
||||
pub async fn install_zipped_mrpack(
|
||||
location: CreatePackLocation,
|
||||
profile_path: String,
|
||||
@@ -68,7 +67,6 @@ pub async fn install_zipped_mrpack(
|
||||
|
||||
/// Install all pack files from a description
|
||||
/// Does not remove the profile if it fails
|
||||
|
||||
pub async fn install_zipped_mrpack_files(
|
||||
create_pack: CreatePack,
|
||||
ignore_lock: bool,
|
||||
|
||||
@@ -118,7 +118,7 @@ pub async fn profile_create(
|
||||
&state.file_watcher,
|
||||
&state.directories,
|
||||
)
|
||||
.await?;
|
||||
.await;
|
||||
|
||||
profile.upsert(&state.pool).await?;
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
//! Configuration structs
|
||||
|
||||
// pub const MODRINTH_URL: &str = "https://staging.modrinth.com/";
|
||||
// pub const MODRINTH_API_URL: &str = "https://staging-api.modrinth.com/v2/";
|
||||
// pub const MODRINTH_API_URL_V3: &str = "https://staging-api.modrinth.com/v3/";
|
||||
|
||||
pub const MODRINTH_URL: &str = "https://modrinth.com/";
|
||||
pub const MODRINTH_API_URL: &str = "https://api.modrinth.com/v2/";
|
||||
pub const MODRINTH_API_URL_V3: &str = "https://api.modrinth.com/v3/";
|
||||
|
||||
pub const MODRINTH_SOCKET_URL: &str = "wss://api.modrinth.com/";
|
||||
|
||||
pub const META_URL: &str = "https://launcher-meta.modrinth.com/";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::LoadingBarId;
|
||||
use super::{FriendPayload, LoadingBarId};
|
||||
use crate::event::{
|
||||
CommandPayload, EventError, LoadingBar, LoadingBarType, ProcessPayloadType,
|
||||
ProfilePayloadType,
|
||||
@@ -44,7 +44,6 @@ const CLI_PROGRESS_BAR_TOTAL: u64 = 1000;
|
||||
/// total is the total amount of work to be done- all emissions will be considered a fraction of this value (should be 1 or 100 for simplicity)
|
||||
/// title is the title of the loading bar
|
||||
/// The app will wait for this loading bar to finish before exiting, as it is considered safe.
|
||||
|
||||
pub async fn init_loading(
|
||||
bar_type: LoadingBarType,
|
||||
total: f64,
|
||||
@@ -56,7 +55,6 @@ pub async fn init_loading(
|
||||
|
||||
/// An unsafe loading bar can be created without adding it to the SafeProcesses list,
|
||||
/// meaning that the app won't ask to wait for it to finish before exiting.
|
||||
|
||||
pub async fn init_loading_unsafe(
|
||||
bar_type: LoadingBarType,
|
||||
total: f64,
|
||||
@@ -298,6 +296,20 @@ pub async fn emit_profile(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_friend(payload: FriendPayload) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get()?;
|
||||
event_state
|
||||
.app
|
||||
.emit("friend", payload)
|
||||
.map_err(EventError::from)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// loading_join! macro
|
||||
// loading_join!(key: Option<&LoadingBarId>, total: f64, message: Option<&str>; task1, task2, task3...)
|
||||
// This will submit a loading event with the given message for each task as they complete
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Theseus state management system
|
||||
use crate::state::UserStatus;
|
||||
use dashmap::DashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
@@ -256,3 +257,13 @@ pub enum EventError {
|
||||
#[error("Tauri error: {0}")]
|
||||
TauriError(#[from] tauri::Error),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[serde(tag = "event")]
|
||||
pub enum FriendPayload {
|
||||
FriendRequest { from: String },
|
||||
UserOffline { id: String },
|
||||
StatusUpdate { user_status: UserStatus },
|
||||
StatusSync,
|
||||
}
|
||||
|
||||
@@ -681,6 +681,11 @@ pub async fn launch_minecraft(
|
||||
.set_activity(&format!("{} {}", selected_phrase, 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
|
||||
|
||||
@@ -474,7 +474,11 @@ impl CacheValue {
|
||||
| CacheValue::DonationPlatforms(_) => DEFAULT_ID.to_string(),
|
||||
|
||||
CacheValue::FileHash(hash) => {
|
||||
format!("{}-{}", hash.size, hash.path.replace(".disabled", ""))
|
||||
format!(
|
||||
"{}-{}",
|
||||
hash.size,
|
||||
hash.path.trim_end_matches(".disabled")
|
||||
)
|
||||
}
|
||||
CacheValue::FileUpdate(hash) => {
|
||||
format!("{}-{}-{}", hash.hash, hash.loader, hash.game_version)
|
||||
|
||||
@@ -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?;
|
||||
|
||||
349
packages/app-lib/src/state/friends.rs
Normal file
@@ -0,0 +1,349 @@
|
||||
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::RwLock;
|
||||
|
||||
type WriteSocket =
|
||||
Arc<RwLock<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,
|
||||
pub friend_id: String,
|
||||
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(RwLock::new(None)),
|
||||
user_statuses: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
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_socket?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.write().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(bytes) => {
|
||||
if let Some(write) = write_handle
|
||||
.write()
|
||||
.await
|
||||
.as_mut()
|
||||
{
|
||||
let _ = write
|
||||
.send(Message::Pong(bytes))
|
||||
.await;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
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) => {
|
||||
tracing::error!("Error handling message from websocket server: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut w = write_handle.write().await;
|
||||
*w = None;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Error connecting to friends socket: {e:?}"
|
||||
);
|
||||
|
||||
return Err(crate::Error::from(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn socket_loop() -> crate::Result<()> {
|
||||
let state = crate::State::get().await?;
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
let mut last_connection = Utc::now();
|
||||
let mut last_ping = Utc::now();
|
||||
|
||||
loop {
|
||||
let connected = {
|
||||
let read = state.friends_socket.write.read().await;
|
||||
read.is_some()
|
||||
};
|
||||
|
||||
if !connected
|
||||
&& Utc::now().signed_duration_since(last_connection)
|
||||
> chrono::Duration::seconds(30)
|
||||
{
|
||||
last_connection = Utc::now();
|
||||
last_ping = Utc::now();
|
||||
let _ = state
|
||||
.friends_socket
|
||||
.connect(
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
&state.process_manager,
|
||||
)
|
||||
.await;
|
||||
} else if connected
|
||||
&& Utc::now().signed_duration_since(last_ping)
|
||||
> chrono::Duration::seconds(10)
|
||||
{
|
||||
last_ping = Utc::now();
|
||||
let mut write = state.friends_socket.write.write().await;
|
||||
if let Some(write) = write.as_mut() {
|
||||
let _ = write.send(Message::Ping(Vec::new())).await;
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn disconnect(&self) -> crate::Result<()> {
|
||||
let mut write_lock = self.write.write().await;
|
||||
if let Some(ref mut write_half) = *write_lock {
|
||||
write_half.close().await?;
|
||||
*write_lock = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn update_status(
|
||||
&self,
|
||||
profile_name: Option<String>,
|
||||
) -> crate::Result<()> {
|
||||
let mut write_lock = self.write.write().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(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
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
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub fn friend_statuses(&self) -> Vec<UserStatus> {
|
||||
self.user_statuses
|
||||
.iter()
|
||||
.map(|x| x.value().clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(exec, semaphore))]
|
||||
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(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(exec, semaphore))]
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
|
||||
pub(crate) async fn watch_profiles_init(
|
||||
watcher: &FileWatcher,
|
||||
dirs: &DirectoryInfo,
|
||||
) -> crate::Result<()> {
|
||||
) {
|
||||
if let Ok(profiles_dir) = std::fs::read_dir(dirs.profiles_dir()) {
|
||||
for profile_dir in profiles_dir {
|
||||
if let Ok(file_name) = profile_dir.map(|x| x.file_name()) {
|
||||
@@ -96,20 +96,18 @@ pub(crate) async fn watch_profiles_init(
|
||||
continue;
|
||||
};
|
||||
|
||||
watch_profile(file_name, watcher, dirs).await?;
|
||||
watch_profile(file_name, watcher, dirs).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn watch_profile(
|
||||
profile_path: &str,
|
||||
watcher: &FileWatcher,
|
||||
dirs: &DirectoryInfo,
|
||||
) -> crate::Result<()> {
|
||||
) {
|
||||
let profile_path = dirs.profiles_dir().join(profile_path);
|
||||
|
||||
if profile_path.exists() && profile_path.is_dir() {
|
||||
@@ -120,15 +118,25 @@ pub(crate) async fn watch_profile(
|
||||
let path = profile_path.join(folder);
|
||||
|
||||
if !path.exists() && !path.is_symlink() {
|
||||
crate::util::io::create_dir_all(&path).await?;
|
||||
if let Err(e) = crate::util::io::create_dir_all(&path).await {
|
||||
tracing::error!(
|
||||
"Failed to create directory for watcher {path:?}: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let mut watcher = watcher.write().await;
|
||||
watcher.watcher().watch(&path, RecursiveMode::Recursive)?;
|
||||
if let Err(e) =
|
||||
watcher.watcher().watch(&path, RecursiveMode::Recursive)
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to watch directory for watcher {path:?}: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn crash_task(path: String) {
|
||||
|
||||
@@ -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,
|
||||
@@ -81,6 +87,16 @@ impl State {
|
||||
if let Err(e) = res {
|
||||
tracing::error!("Error running discord RPC: {e}");
|
||||
}
|
||||
|
||||
let _ = state
|
||||
.friends_socket
|
||||
.connect(
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
&state.process_manager,
|
||||
)
|
||||
.await;
|
||||
let _ = FriendsSocket::socket_loop().await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
@@ -89,7 +105,10 @@ impl State {
|
||||
/// Get the current launcher state, waiting for initialization
|
||||
pub async fn get() -> crate::Result<Arc<Self>> {
|
||||
if !LAUNCHER_STATE.initialized() {
|
||||
while !LAUNCHER_STATE.initialized() {}
|
||||
tracing::error!("Attempted to get state before it is initialized - this should never happen!");
|
||||
while !LAUNCHER_STATE.initialized() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Arc::clone(
|
||||
@@ -103,10 +122,12 @@ impl State {
|
||||
|
||||
#[tracing::instrument]
|
||||
async fn initialize_state() -> crate::Result<Arc<Self>> {
|
||||
tracing::info!("Connecting to app database");
|
||||
let pool = db::connect().await?;
|
||||
|
||||
legacy_converter::migrate_legacy_data(&pool).await?;
|
||||
|
||||
tracing::info!("Fetching app settings");
|
||||
let mut settings = Settings::get(&pool).await?;
|
||||
|
||||
let fetch_semaphore =
|
||||
@@ -116,6 +137,7 @@ impl State {
|
||||
let api_semaphore =
|
||||
FetchSemaphore(Semaphore::new(settings.max_concurrent_downloads));
|
||||
|
||||
tracing::info!("Initializing directories");
|
||||
DirectoryInfo::move_launcher_directory(
|
||||
&mut settings,
|
||||
&pool,
|
||||
@@ -126,8 +148,13 @@ impl State {
|
||||
|
||||
let discord_rpc = DiscordGuard::init()?;
|
||||
|
||||
tracing::info!("Initializing file watcher");
|
||||
let file_watcher = fs_watcher::init_watcher().await?;
|
||||
fs_watcher::watch_profiles_init(&file_watcher, &directories).await?;
|
||||
fs_watcher::watch_profiles_init(&file_watcher, &directories).await;
|
||||
|
||||
let process_manager = ProcessManager::new();
|
||||
|
||||
let friends_socket = FriendsSocket::new();
|
||||
|
||||
Ok(Arc::new(Self {
|
||||
directories,
|
||||
@@ -135,7 +162,8 @@ impl State {
|
||||
io_semaphore,
|
||||
api_semaphore,
|
||||
discord_rpc,
|
||||
process_manager: ProcessManager::new(),
|
||||
process_manager,
|
||||
friends_socket,
|
||||
pool,
|
||||
file_watcher,
|
||||
}))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::config::MODRINTH_API_URL;
|
||||
use crate::config::{MODRINTH_API_URL, MODRINTH_URL};
|
||||
use crate::state::{CacheBehaviour, CachedEntry};
|
||||
use crate::util::fetch::{fetch_advanced, FetchSemaphore};
|
||||
use chrono::{DateTime, Duration, TimeZone, Utc};
|
||||
@@ -190,8 +190,8 @@ impl ModrinthCredentials {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_login_url() -> &'static str {
|
||||
"https:/modrinth.com/auth/sign-in?launcher=true"
|
||||
pub fn get_login_url() -> String {
|
||||
format!("{MODRINTH_URL}auth/sign-in?launcher=true")
|
||||
}
|
||||
|
||||
pub async fn finish_login_flow(
|
||||
|
||||
@@ -206,6 +206,8 @@ impl Process {
|
||||
|
||||
let _ = state.discord_rpc.clear_to_default(true).await;
|
||||
|
||||
let _ = state.friends_socket.update_status(None).await;
|
||||
|
||||
// If in tauri, window should show itself again after process exists if it was hidden
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
|
||||
@@ -193,7 +193,7 @@ impl ProjectType {
|
||||
ProjectType::Mod => "mod",
|
||||
ProjectType::DataPack => "datapack",
|
||||
ProjectType::ResourcePack => "resourcepack",
|
||||
ProjectType::ShaderPack => "shaderpack",
|
||||
ProjectType::ShaderPack => "shader",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,7 +664,7 @@ impl Profile {
|
||||
path: format!(
|
||||
"{}/{folder}/{}",
|
||||
self.path,
|
||||
file_name.replace(".disabled", "")
|
||||
file_name.trim_end_matches(".disabled")
|
||||
),
|
||||
file_name: file_name.to_string(),
|
||||
project_type,
|
||||
@@ -725,8 +725,9 @@ impl Profile {
|
||||
let info_index = file_info.iter().position(|x| x.hash == hash.hash);
|
||||
let file = info_index.map(|x| file_info.remove(x));
|
||||
|
||||
if let Some(initial_file_index) =
|
||||
keys.iter().position(|x| x.path == hash.path)
|
||||
if let Some(initial_file_index) = keys
|
||||
.iter()
|
||||
.position(|x| x.path == hash.path.trim_end_matches(".disabled"))
|
||||
{
|
||||
let initial_file = keys.remove(initial_file_index);
|
||||
|
||||
@@ -890,7 +891,7 @@ impl Profile {
|
||||
let path = crate::api::profile::get_full_path(profile_path).await?;
|
||||
|
||||
let new_path = if project_path.ends_with(".disabled") {
|
||||
project_path.replace(".disabled", "")
|
||||
project_path.trim_end_matches(".disabled").to_string()
|
||||
} else {
|
||||
format!("{project_path}.disabled")
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Theseus settings file
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Types
|
||||
/// Global Theseus settings
|
||||
@@ -13,10 +15,10 @@ pub struct Settings {
|
||||
pub collapsed_navigation: bool,
|
||||
pub advanced_rendering: bool,
|
||||
pub native_decorations: bool,
|
||||
pub toggle_sidebar: bool,
|
||||
|
||||
pub telemetry: bool,
|
||||
pub discord_rpc: bool,
|
||||
pub developer_mode: bool,
|
||||
pub personalized_ads: bool,
|
||||
|
||||
pub onboarded: bool,
|
||||
@@ -32,6 +34,16 @@ pub struct Settings {
|
||||
pub custom_dir: Option<String>,
|
||||
pub prev_custom_dir: Option<String>,
|
||||
pub migrated: bool,
|
||||
|
||||
pub developer_mode: bool,
|
||||
pub feature_flags: HashMap<FeatureFlag, bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, Hash, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FeatureFlag {
|
||||
PagePath,
|
||||
ProjectBackground,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
@@ -48,7 +60,7 @@ impl Settings {
|
||||
json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,
|
||||
mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,
|
||||
hook_pre_launch, hook_wrapper, hook_post_exit,
|
||||
custom_dir, prev_custom_dir, migrated
|
||||
custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar
|
||||
FROM settings
|
||||
"
|
||||
)
|
||||
@@ -63,6 +75,7 @@ impl Settings {
|
||||
collapsed_navigation: res.collapsed_navigation == 1,
|
||||
advanced_rendering: res.advanced_rendering == 1,
|
||||
native_decorations: res.native_decorations == 1,
|
||||
toggle_sidebar: res.toggle_sidebar == 1,
|
||||
telemetry: res.telemetry == 1,
|
||||
discord_rpc: res.discord_rpc == 1,
|
||||
developer_mode: res.developer_mode == 1,
|
||||
@@ -95,6 +108,11 @@ impl Settings {
|
||||
custom_dir: res.custom_dir,
|
||||
prev_custom_dir: res.prev_custom_dir,
|
||||
migrated: res.migrated == 1,
|
||||
feature_flags: res
|
||||
.feature_flags
|
||||
.as_ref()
|
||||
.and_then(|x| serde_json::from_str(x).ok())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -108,6 +126,7 @@ impl Settings {
|
||||
let default_page = self.default_page.as_str();
|
||||
let extra_launch_args = serde_json::to_string(&self.extra_launch_args)?;
|
||||
let custom_env_vars = serde_json::to_string(&self.custom_env_vars)?;
|
||||
let feature_flags = serde_json::to_string(&self.feature_flags)?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
@@ -143,7 +162,10 @@ impl Settings {
|
||||
|
||||
custom_dir = $23,
|
||||
prev_custom_dir = $24,
|
||||
migrated = $25
|
||||
migrated = $25,
|
||||
|
||||
toggle_sidebar = $26,
|
||||
feature_flags = $27
|
||||
",
|
||||
max_concurrent_writes,
|
||||
max_concurrent_downloads,
|
||||
@@ -169,7 +191,9 @@ impl Settings {
|
||||
self.hooks.post_exit,
|
||||
self.custom_dir,
|
||||
self.prev_custom_dir,
|
||||
self.migrated
|
||||
self.migrated,
|
||||
self.toggle_sidebar,
|
||||
feature_flags
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
@@ -185,6 +209,7 @@ pub enum Theme {
|
||||
Dark,
|
||||
Light,
|
||||
Oled,
|
||||
System,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
@@ -193,6 +218,7 @@ impl Theme {
|
||||
Theme::Dark => "dark",
|
||||
Theme::Light => "light",
|
||||
Theme::Oled => "oled",
|
||||
Theme::System => "system",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +227,7 @@ impl Theme {
|
||||
"dark" => Theme::Dark,
|
||||
"light" => Theme::Light,
|
||||
"oled" => Theme::Oled,
|
||||
"system" => Theme::System,
|
||||
_ => Theme::Dark,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,19 @@ pub async fn fetch_advanced(
|
||||
let result = req.send().await;
|
||||
match result {
|
||||
Ok(x) => {
|
||||
if x.status().is_server_error() {
|
||||
if attempt <= FETCH_ATTEMPTS {
|
||||
continue;
|
||||
} else {
|
||||
return Err(crate::Error::from(
|
||||
crate::ErrorKind::OtherError(
|
||||
"Server error when fetching content"
|
||||
.to_string(),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = if let Some((bar, total)) = &loading_bar {
|
||||
let length = x.content_length();
|
||||
if let Some(total_size) = length {
|
||||
@@ -145,7 +158,7 @@ pub async fn fetch_advanced(
|
||||
if let Some(sha1) = sha1 {
|
||||
let hash = sha1_async(bytes.clone()).await?;
|
||||
if &*hash != sha1 {
|
||||
if attempt <= 3 {
|
||||
if attempt <= FETCH_ATTEMPTS {
|
||||
continue;
|
||||
} else {
|
||||
return Err(crate::ErrorKind::HashError(
|
||||
@@ -159,13 +172,13 @@ pub async fn fetch_advanced(
|
||||
|
||||
tracing::trace!("Done downloading URL {url}");
|
||||
return Ok(bytes);
|
||||
} else if attempt <= 3 {
|
||||
} else if attempt <= FETCH_ATTEMPTS {
|
||||
continue;
|
||||
} else if let Err(err) = bytes {
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
Err(_) if attempt <= 3 => continue,
|
||||
Err(_) if attempt <= FETCH_ATTEMPTS => continue,
|
||||
Err(err) => {
|
||||
return Err(err.into());
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg width="512" height="514" viewBox="0 0 512 514" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M503.16 323.56C514.55 281.47 515.32 235.91 503.2 190.76C466.57 54.2299 326.04 -26.8001 189.33 9.77991C83.8101 38.0199 11.3899 128.07 0.689941 230.47H43.99C54.29 147.33 113.74 74.7298 199.75 51.7098C306.05 23.2598 415.13 80.6699 453.17 181.38L411.03 192.65C391.64 145.8 352.57 111.45 306.3 96.8198L298.56 140.66C335.09 154.13 364.72 184.5 375.56 224.91C391.36 283.8 361.94 344.14 308.56 369.17L320.09 412.16C390.25 383.21 432.4 310.3 422.43 235.14L464.41 223.91C468.91 252.62 467.35 281.16 460.55 308.07L503.16 323.56Z" fill="var(--color-brand)"/>
|
||||
<path d="M321.99 504.22C185.27 540.8 44.7501 459.77 8.11011 323.24C3.84011 307.31 1.17 291.33 0 275.46H43.27C44.36 287.37 46.4699 299.35 49.6799 311.29C53.0399 323.8 57.45 335.75 62.79 347.07L101.38 323.92C98.1299 316.42 95.39 308.6 93.21 300.47C69.17 210.87 122.41 118.77 212.13 94.7601C229.13 90.2101 246.23 88.4401 262.93 89.1501L255.19 133C244.73 133.05 234.11 134.42 223.53 137.25C157.31 154.98 118.01 222.95 135.75 289.09C136.85 293.16 138.13 297.13 139.59 300.99L188.94 271.38L174.07 231.95L220.67 184.08L279.57 171.39L296.62 192.38L269.47 219.88L245.79 227.33L228.87 244.72L237.16 267.79C237.16 267.79 253.95 285.63 253.98 285.64L277.7 279.33L294.58 260.79L331.44 249.12L342.42 273.82L304.39 320.45L240.66 340.63L212.08 308.81L162.26 338.7C187.8 367.78 226.2 383.93 266.01 380.56L277.54 423.55C218.13 431.41 160.1 406.82 124.05 361.64L85.6399 384.68C136.25 451.17 223.84 484.11 309.61 461.16C371.35 444.64 419.4 402.56 445.42 349.38L488.06 364.88C457.17 431.16 398.22 483.82 321.99 504.22Z" fill="var(--color-brand)"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M503.16 323.56C514.55 281.47 515.32 235.91 503.2 190.76C466.57 54.2299 326.04 -26.8001 189.33 9.77991C83.8101 38.0199 11.3899 128.07 0.689941 230.47H43.99C54.29 147.33 113.74 74.7298 199.75 51.7098C306.05 23.2598 415.13 80.6699 453.17 181.38L411.03 192.65C391.64 145.8 352.57 111.45 306.3 96.8198L298.56 140.66C335.09 154.13 364.72 184.5 375.56 224.91C391.36 283.8 361.94 344.14 308.56 369.17L320.09 412.16C390.25 383.21 432.4 310.3 422.43 235.14L464.41 223.91C468.91 252.62 467.35 281.16 460.55 308.07L503.16 323.56Z" fill="currentColor"/>
|
||||
<path d="M321.99 504.22C185.27 540.8 44.7501 459.77 8.11011 323.24C3.84011 307.31 1.17 291.33 0 275.46H43.27C44.36 287.37 46.4699 299.35 49.6799 311.29C53.0399 323.8 57.45 335.75 62.79 347.07L101.38 323.92C98.1299 316.42 95.39 308.6 93.21 300.47C69.17 210.87 122.41 118.77 212.13 94.7601C229.13 90.2101 246.23 88.4401 262.93 89.1501L255.19 133C244.73 133.05 234.11 134.42 223.53 137.25C157.31 154.98 118.01 222.95 135.75 289.09C136.85 293.16 138.13 297.13 139.59 300.99L188.94 271.38L174.07 231.95L220.67 184.08L279.57 171.39L296.62 192.38L269.47 219.88L245.79 227.33L228.87 244.72L237.16 267.79C237.16 267.79 253.95 285.63 253.98 285.64L277.7 279.33L294.58 260.79L331.44 249.12L342.42 273.82L304.39 320.45L240.66 340.63L212.08 308.81L162.26 338.7C187.8 367.78 226.2 383.93 266.01 380.56L277.54 423.55C218.13 431.41 160.1 406.82 124.05 361.64L85.6399 384.68C136.25 451.17 223.84 484.11 309.61 461.16C371.35 444.64 419.4 402.56 445.42 349.38L488.06 364.88C457.17 431.16 398.22 483.82 321.99 504.22Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
5
packages/assets/external/kofi.svg
vendored
@@ -1 +1,4 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Ko-fi</title><path d="M23.881 8.948c-.773-4.085-4.859-4.593-4.859-4.593H.723c-.604 0-.679.798-.679.798s-.082 7.324-.022 11.822c.164 2.424 2.586 2.672 2.586 2.672s8.267-.023 11.966-.049c2.438-.426 2.683-2.566 2.658-3.734 4.352.24 7.422-2.831 6.649-6.916zm-11.062 3.511c-1.246 1.453-4.011 3.976-4.011 3.976s-.121.119-.31.023c-.076-.057-.108-.09-.108-.09-.443-.441-3.368-3.049-4.034-3.954-.709-.965-1.041-2.7-.091-3.71.951-1.01 3.005-1.086 4.363.407 0 0 1.565-1.782 3.468-.963 1.904.82 1.832 3.011.723 4.311zm6.173.478c-.928.116-1.682.028-1.682.028V7.284h1.77s1.971.551 1.971 2.638c0 1.913-.985 2.667-2.059 3.015z" fill="currentColor" /></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" xml:space="preserve">
|
||||
<path fill="currentColor" d="M18.208 2.922c-1.558-.155-2.649-.208-6.857-.208-2.7 0-4.986.026-6.83.259C2.08 3.285.001 5.155.001 8.61H0c0 3.506.182 6.129 1.585 8.493 1.585 2.701 4.234 4.181 7.663 4.181h.831c4.208 0 6.494-2.234 7.636-4a9.441 9.441 0 0 0 1.091-2.339C21.792 14.689 24 12.221 24 9.207v-.415c0-3.246-2.13-5.506-5.792-5.87zm3.844 6.311c0 2.156-1.793 3.843-3.871 3.843h-.935l-.157.65c-.207 1.013-.596 1.818-1.039 2.545-.908 1.428-2.545 3.064-5.921 3.064h-.804c-2.572 0-4.832-.883-6.078-3.194-1.09-2-1.298-4.155-1.298-7.506h-.001c0-2.181.858-3.402 3.014-3.714 1.532-.233 3.558-.259 6.389-.259 4.208 0 5.091.051 6.572.182 2.623.311 4.13 1.585 4.13 4v.389z"/>
|
||||
<path fill="currentColor" d="M17.248 10.429c0 .312.234.546.649.546 1.325 0 2.052-.753 2.052-2s-.727-2.026-2.052-2.026c-.416 0-.649.234-.649.546v2.934zM4.495 10.273c0 1.532.857 2.857 1.948 3.896.727.701 1.87 1.429 2.649 1.896a1.47 1.47 0 0 0 1.507 0c.78-.468 1.922-1.195 2.623-1.896 1.117-1.039 1.974-2.363 1.974-3.896 0-1.663-1.246-3.143-3.039-3.143-1.065 0-1.792.546-2.338 1.299-.494-.754-1.246-1.299-2.312-1.299-1.818 0-3.013 1.481-3.012 3.143"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 718 B After Width: | Height: | Size: 1.2 KiB |
1
packages/assets/icons/coffee.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-coffee"><path d="M10 2v2"/><path d="M14 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/><path d="M6 2v2"/></svg>
|
||||
|
After Width: | Height: | Size: 371 B |
1
packages/assets/icons/gauge.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-gauge"><path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/></svg>
|
||||
|
After Width: | Height: | Size: 277 B |
@@ -1,4 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14.414" height="12.162" viewBox="0 0 14.414 12.162">
|
||||
<path d="M7.667,14.333,3,9.667m0,0L7.667,5M3,9.667H15" transform="translate(-1.586 -3.586)" fill="none"
|
||||
stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>
|
||||
|
Before Width: | Height: | Size: 303 B After Width: | Height: | Size: 266 B |
1
packages/assets/icons/lock-open.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-lock-open"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>
|
||||
|
After Width: | Height: | Size: 311 B |
1
packages/assets/icons/manage.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-settings-2"><path d="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></svg>
|
||||
|
After Width: | Height: | Size: 320 B |
11
packages/assets/icons/maximize.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 24 24">
|
||||
<!-- Generator: Adobe Illustrator 28.6.0, SVG Export Plug-In . SVG Version: 1.2.0 Build 709) -->
|
||||
<g>
|
||||
<g id="Layer_1">
|
||||
<g id="Layer_1-2" data-name="Layer_1">
|
||||
<path d="M21,3H3v18h18V3Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 454 B |
1
packages/assets/icons/minimize.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minus"><line x1="5" x2="19" y1="12" y2="12"/></svg>
|
||||
|
After Width: | Height: | Size: 253 B |
1
packages/assets/icons/minus.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minus"><path d="M5 12h14"/></svg>
|
||||
|
After Width: | Height: | Size: 235 B |
1
packages/assets/icons/monitor.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>
|
||||
|
After Width: | Height: | Size: 314 B |
1
packages/assets/icons/package.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-package"><path d="M16.5 9.4 7.55 4.24"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.29 7 12 12 20.71 7"/><line x1="12" x2="12" y1="22" y2="12"/></svg>
|
||||
|
After Width: | Height: | Size: 461 B |
10
packages/assets/icons/restore.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 24 24">
|
||||
<!-- Generator: Adobe Illustrator 28.6.0, SVG Export Plug-In . SVG Version: 1.2.0 Build 709) -->
|
||||
<g>
|
||||
<g id="Layer_1">
|
||||
<rect x="3" y="6.3" width="14.7" height="14.7" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/>
|
||||
<polygon points="21 3 21 17.7 17.7 17.7 17.7 6.3 6.3 6.3 6.3 3 21 3" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 595 B |
@@ -1,4 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14.414" height="12.162" viewBox="0 0 14.414 12.162">
|
||||
<path d="M7.667,14.333,3,9.667m0,0L7.667,5M3,9.667H15" transform="translate(16 15.748) rotate(180)" fill="none"
|
||||
stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
|
Before Width: | Height: | Size: 311 B After Width: | Height: | Size: 266 B |
20
packages/assets/icons/spinner.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg
|
||||
width="24" height="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
opacity="0.25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
opacity="0.75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 404 B |
1
packages/assets/icons/timer.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="10" x2="14" y1="2" y2="2"/><line x1="12" x2="15" y1="14" y2="11"/><circle cx="12" cy="14" r="8"/></svg>
|
||||
|
After Width: | Height: | Size: 295 B |
1
packages/assets/icons/unlink.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-unlink"><path d="m18.84 12.25 1.72-1.71h-.02a5.004 5.004 0 0 0-.12-7.07 5.006 5.006 0 0 0-6.95 0l-1.72 1.71"/><path d="m5.17 11.75-1.71 1.71a5.004 5.004 0 0 0 .12 7.07 5.006 5.006 0 0 0 6.95 0l1.71-1.71"/><line x1="8" x2="8" y1="2" y2="5"/><line x1="2" x2="5" y1="8" y2="8"/><line x1="16" x2="16" y1="19" y2="22"/><line x1="19" x2="22" y1="16" y2="16"/></svg>
|
||||
|
After Width: | Height: | Size: 561 B |
1
packages/assets/icons/unplug.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-unplug"><path d="m19 5 3-3"/><path d="m2 22 3-3"/><path d="M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z"/><path d="M7.5 13.5 10 11"/><path d="M10.5 16.5 13 14"/><path d="m12 6 6 6 2.3-2.3a2.4 2.4 0 0 0 0-3.4l-2.6-2.6a2.4 2.4 0 0 0-3.4 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 473 B |
@@ -52,6 +52,7 @@ import _ClearIcon from './icons/clear.svg?component'
|
||||
import _ClientIcon from './icons/client.svg?component'
|
||||
import _ClipboardCopyIcon from './icons/clipboard-copy.svg?component'
|
||||
import _CodeIcon from './icons/code.svg?component'
|
||||
import _CoffeeIcon from './icons/coffee.svg?component'
|
||||
import _CoinsIcon from './icons/coins.svg?component'
|
||||
import _CollectionIcon from './icons/collection.svg?component'
|
||||
import _CompassIcon from './icons/compass.svg?component'
|
||||
@@ -75,6 +76,7 @@ import _FilterXIcon from './icons/filter-x.svg?component'
|
||||
import _FolderOpenIcon from './icons/folder-open.svg?component'
|
||||
import _FolderSearchIcon from './icons/folder-search.svg?component'
|
||||
import _GapIcon from './icons/gap.svg?component'
|
||||
import _GaugeIcon from './icons/gauge.svg?component'
|
||||
import _GameIcon from './icons/game.svg?component'
|
||||
import _GitHubIcon from './icons/github.svg?component'
|
||||
import _GlassesIcon from './icons/glasses.svg?component'
|
||||
@@ -99,11 +101,17 @@ 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 _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 _MonitorIcon from './icons/monitor.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'
|
||||
@@ -111,6 +119,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'
|
||||
@@ -122,6 +131,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'
|
||||
@@ -136,6 +146,7 @@ import _SlashIcon from './icons/slash.svg?component'
|
||||
import _SortAscendingIcon from './icons/sort-asc.svg?component'
|
||||
import _SortDescendingIcon from './icons/sort-desc.svg?component'
|
||||
import _SparklesIcon from './icons/sparkles.svg?component'
|
||||
import _SpinnerIcon from './icons/spinner.svg?component'
|
||||
import _StarIcon from './icons/star.svg?component'
|
||||
import _StopCircleIcon from './icons/stop-circle.svg?component'
|
||||
import _SunIcon from './icons/sun.svg?component'
|
||||
@@ -150,6 +161,8 @@ import _RedoIcon from './icons/redo.svg?component'
|
||||
import _UnknownIcon from './icons/unknown.svg?component'
|
||||
import _UnknownDonationIcon from './icons/unknown-donation.svg?component'
|
||||
import _UpdatedIcon from './icons/updated.svg?component'
|
||||
import _UnlinkIcon from './icons/unlink.svg?component'
|
||||
import _UnplugIcon from './icons/unplug.svg?component'
|
||||
import _UploadIcon from './icons/upload.svg?component'
|
||||
import _UserIcon from './icons/user.svg?component'
|
||||
import _UserPlusIcon from './icons/user-plus.svg?component'
|
||||
@@ -170,6 +183,7 @@ import _CPUIcon from './icons/cpu.svg?component'
|
||||
import _DBIcon from './icons/db.svg?component'
|
||||
import _LoaderIcon from './icons/loader.svg?component'
|
||||
import _ImportIcon from './icons/import.svg?component'
|
||||
import _TimerIcon from './icons/timer.svg?component'
|
||||
|
||||
// Editor Icons
|
||||
import _BoldIcon from './icons/bold.svg?component'
|
||||
@@ -243,6 +257,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
|
||||
@@ -267,6 +282,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
|
||||
@@ -291,11 +307,17 @@ export const LinkIcon = _LinkIcon
|
||||
export const ListIcon = _ListIcon
|
||||
export const ListEndIcon = _ListEndIcon
|
||||
export const LockIcon = _LockIcon
|
||||
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 MonitorIcon = _MonitorIcon
|
||||
export const MonitorSmartphoneIcon = _MonitorSmartphoneIcon
|
||||
export const MoonIcon = _MoonIcon
|
||||
export const MoreHorizontalIcon = _MoreHorizontalIcon
|
||||
@@ -303,6 +325,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
|
||||
@@ -314,6 +337,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
|
||||
@@ -327,6 +351,8 @@ export const ShieldIcon = _ShieldIcon
|
||||
export const SlashIcon = _SlashIcon
|
||||
export const SortAscendingIcon = _SortAscendingIcon
|
||||
export const SortDescendingIcon = _SortDescendingIcon
|
||||
export const SparklesIcon = _SparklesIcon
|
||||
export const SpinnerIcon = _SpinnerIcon
|
||||
export const StarIcon = _StarIcon
|
||||
export const StopCircleIcon = _StopCircleIcon
|
||||
export const SunIcon = _SunIcon
|
||||
@@ -341,6 +367,8 @@ export const RedoIcon = _RedoIcon
|
||||
export const UnknownIcon = _UnknownIcon
|
||||
export const UnknownDonationIcon = _UnknownDonationIcon
|
||||
export const UpdatedIcon = _UpdatedIcon
|
||||
export const UnlinkIcon = _UnlinkIcon
|
||||
export const UnplugIcon = _UnplugIcon
|
||||
export const UploadIcon = _UploadIcon
|
||||
export const UserIcon = _UserIcon
|
||||
export const UserPlusIcon = _UserPlusIcon
|
||||
@@ -372,4 +400,4 @@ export const DBIcon = _DBIcon
|
||||
export const LoaderIcon = _LoaderIcon
|
||||
export const ImportIcon = _ImportIcon
|
||||
export const CardIcon = _CardIcon
|
||||
export const SparklesIcon = _SparklesIcon
|
||||
export const TimerIcon = _TimerIcon
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-custom": "workspace:*",
|
||||
"tsconfig": "workspace:*",
|
||||
"vue": "^3.4.31"
|
||||
"vue": "^3.5.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,6 +494,14 @@ a,
|
||||
}
|
||||
}
|
||||
|
||||
.btn-dropdown-animation svg:last-child {
|
||||
transition: transform 0.125s ease-in-out;
|
||||
}
|
||||
|
||||
.v-popper--shown .btn-dropdown-animation svg:last-child {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
grid-gap: var(--gap-sm);
|
||||
@@ -772,7 +780,7 @@ a,
|
||||
box-sizing: content-box;
|
||||
min-height: 32px;
|
||||
height: 32px;
|
||||
width: 52px;
|
||||
min-width: 52px;
|
||||
max-width: 52px;
|
||||
border-radius: var(--radius-max);
|
||||
display: inline-block;
|
||||
@@ -818,15 +826,17 @@ a,
|
||||
.v-popper__inner {
|
||||
background: var(--color-tooltip-bg) !important;
|
||||
color: var(--color-tooltip-text) !important;
|
||||
padding: 5px 10px 4px !important;
|
||||
padding: 0.5rem 0.5rem !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
box-shadow: var(--shadow-floating) !important;
|
||||
font-size: 0.9rem !important;
|
||||
filter: drop-shadow(5px 5px 0.8rem rgba(0, 0, 0, 0.35));
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.v-popper__arrow-outer,
|
||||
.v-popper__arrow-inner {
|
||||
border-color: var(--color-tooltip-bg) !important;
|
||||
border-color: var(--color-tooltip-bg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -834,7 +844,8 @@ a,
|
||||
|
||||
.markdown-body {
|
||||
h1:first-child {
|
||||
margin-top: 0;
|
||||
margin-block-start: 0;
|
||||
padding-block-start: 0;
|
||||
}
|
||||
|
||||
blockquote,
|
||||
@@ -860,10 +871,16 @@ a,
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
padding: 10px 0 5px;
|
||||
border-bottom: 1px solid var(--color-gray);
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -882,6 +899,7 @@ a,
|
||||
padding: 0 1em;
|
||||
color: var(--color-base);
|
||||
border-left: 0.25em solid var(--color-button-bg);
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -900,6 +918,10 @@ a,
|
||||
}
|
||||
}
|
||||
|
||||
a:active > img {
|
||||
scale: 0.98;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
@@ -1182,3 +1204,120 @@ select {
|
||||
border-top-left-radius: var(--radius-md) !important;
|
||||
border-top-right-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
.v-popper--theme-dropdown,
|
||||
.v-popper--theme-dropdown.v-popper--theme-ribbit-popout {
|
||||
.v-popper__inner {
|
||||
border: 1px solid var(--color-button-bg) !important;
|
||||
padding: var(--gap-sm) !important;
|
||||
width: fit-content !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
background-color: var(--color-raised-bg) !important;
|
||||
box-shadow: var(--shadow-floating) !important;
|
||||
}
|
||||
|
||||
.v-popper__arrow-outer {
|
||||
border-color: var(--color-button-bg) !important;
|
||||
}
|
||||
|
||||
.v-popper__arrow-inner {
|
||||
border-color: var(--color-raised-bg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement='bottom-end'] .v-popper__wrapper {
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement='top-end'] .v-popper__wrapper {
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement='bottom-start'] .v-popper__wrapper {
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.v-popper__popper[data-popper-placement='top-start'] .v-popper__wrapper {
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
|
||||
.v-popper__popper.v-popper__popper--show-from .v-popper__wrapper {
|
||||
transform: scale(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.v-popper__popper.v-popper__popper--show-to .v-popper__wrapper {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
transition:
|
||||
transform 0.125s ease-in-out,
|
||||
opacity 0.125s ease-in-out;
|
||||
}
|
||||
|
||||
.v-popper__popper.v-popper__popper--hide-from .v-popper__wrapper {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
transition: transform 0.0625s;
|
||||
}
|
||||
|
||||
.v-popper__popper.v-popper__popper--hide-to .v-popper__wrapper {
|
||||
//transform: scale(.9);
|
||||
}
|
||||
|
||||
.preview-radio {
|
||||
width: 100% !important;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-divider);
|
||||
background-color: var(--color-button-bg);
|
||||
color: var(--color-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
outline: 2px solid transparent;
|
||||
|
||||
&.selected {
|
||||
color: var(--color-contrast);
|
||||
|
||||
.label {
|
||||
.radio {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
background-color: var(--color-bg);
|
||||
padding: 1.5rem;
|
||||
outline: 2px solid transparent;
|
||||
width: 100%;
|
||||
|
||||
.example-card {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
outline: 2px solid transparent;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
flex-grow: 1;
|
||||
padding: var(--gap-md) var(--gap-lg);
|
||||
|
||||
.radio {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
color: var(--color-secondary);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,14 @@
|
||||
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-button-border: rgba(161, 161, 161, 0.35);
|
||||
--color-scrollbar: #96a2b0;
|
||||
|
||||
--color-divider: #babfc5;
|
||||
--color-divider-dark: #c8cdd3;
|
||||
|
||||
--color-base: hsl(221, 39%, 11%);
|
||||
--color-secondary: #6b7280;
|
||||
--color-contrast: #1a202c;
|
||||
@@ -53,6 +28,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 +50,67 @@ 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%);
|
||||
|
||||
--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;
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -78,8 +120,12 @@ html {
|
||||
--color-raised-bg: #26292f;
|
||||
--color-super-raised-bg: #40434a;
|
||||
--color-button-bg: hsl(222, 13%, 30%);
|
||||
--color-button-border: rgba(193, 190, 209, 0.12);
|
||||
--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 +145,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 +165,31 @@ 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%);
|
||||
|
||||
--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;
|
||||
}
|
||||
|
||||
.oled-mode {
|
||||
@@ -122,4 +199,76 @@ html {
|
||||
--color-button-bg: #222329;
|
||||
|
||||
--color-ad: #0d1828;
|
||||
|
||||
--brand-gradient-bg: linear-gradient(
|
||||
0deg,
|
||||
rgba(22, 66, 51, 0.15) 0%,
|
||||
rgba(55, 137, 73, 0.1) 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-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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.dark-mode:not(.oled-mode),
|
||||
.dark:not(.oled-mode) {
|
||||
.experimental-styles-within,
|
||||
&.experimental-styles-within {
|
||||
@extend .dark-experiments;
|
||||
}
|
||||
}
|
||||
|
||||
.experimental-styles-within {
|
||||
.dark-mode:not(.oled-mode),
|
||||
.dark:not(.oled-mode) {
|
||||
@extend .dark-experiments;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Daedalus
|
||||
# Daedalus
|
||||
|
||||
Daedalus (the rust library) is a library providing model structs and methods for requesting and parsing things
|
||||
from Minecraft and other mod loaders meta APIs.
|
||||
from Minecraft and other mod loaders meta APIs.
|
||||
|
||||
@@ -7,4 +7,4 @@
|
||||
"dev": "cargo run",
|
||||
"test": "cargo test"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['custom/vue'],
|
||||
}
|
||||
22
packages/ui/eslint.config.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createConfigForNuxt } from '@nuxt/eslint-config/flat'
|
||||
import { fixupPluginRules } from '@eslint/compat'
|
||||
import turboPlugin from 'eslint-plugin-turbo'
|
||||
|
||||
export default createConfigForNuxt().append([
|
||||
{
|
||||
name: 'turbo',
|
||||
plugins: {
|
||||
turbo: fixupPluginRules(turboPlugin),
|
||||
},
|
||||
rules: {
|
||||
'turbo/no-undeclared-env-vars': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'modrinth',
|
||||
rules: {
|
||||
'vue/html-self-closing': 'off',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -1 +1,3 @@
|
||||
export * from './src/components/index'
|
||||
export { commonMessages, commonSettingsMessages } from './src/utils/common-messages'
|
||||
export * from './src/utils/search'
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-custom": "workspace:*",
|
||||
"tsconfig": "workspace:*",
|
||||
"vue": "^3.4.31"
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "4.3.0",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.3.2",
|
||||
@@ -29,13 +31,15 @@
|
||||
"@types/markdown-it": "^14.1.1",
|
||||
"apexcharts": "^3.44.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"floating-vue": "2.0.0-beta.24",
|
||||
"floating-vue": "^5.2.2",
|
||||
"highlight.js": "^11.9.0",
|
||||
"markdown-it": "^13.0.2",
|
||||
"qrcode.vue": "^3.4.1",
|
||||
"vue-multiselect": "3.0.0",
|
||||
"vue-select": "4.0.0-beta.6",
|
||||
"vue-typed-virtual-list": "^1.0.10",
|
||||
"vue3-apexcharts": "^1.4.4",
|
||||
"xss": "^1.0.14"
|
||||
}
|
||||
},
|
||||
"web-types": "../../web-types.json"
|
||||
}
|
||||
|
||||
94
packages/ui/src/components/base/Accordion.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<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,
|
||||
buttonClass: null,
|
||||
contentClass: null,
|
||||
titleWrapperClass: null,
|
||||
},
|
||||
)
|
||||
|
||||
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>
|
||||
22
packages/ui/src/components/base/AutoLink.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<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<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
to: any
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
@@ -2,8 +2,8 @@
|
||||
<img
|
||||
v-if="src"
|
||||
ref="img"
|
||||
:style="`--_size: ${cssSize}`"
|
||||
class="`experimental-styles-within avatar"
|
||||
:style="`--_size: ${cssSize}`"
|
||||
:class="{
|
||||
circle: circle,
|
||||
'no-shadow': noShadow,
|
||||
@@ -18,8 +18,9 @@
|
||||
<svg
|
||||
v-else
|
||||
class="`experimental-styles-within avatar"
|
||||
:style="`--_size: ${cssSize}`"
|
||||
:style="`--_size: ${cssSize}${tint ? `;--_tint:oklch(50% 75% ${tint})` : ''}`"
|
||||
:class="{
|
||||
tint: tint,
|
||||
circle: circle,
|
||||
'no-shadow': noShadow,
|
||||
raised: raised,
|
||||
@@ -44,7 +45,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const pixelated = ref(false)
|
||||
const img = ref(null)
|
||||
@@ -78,6 +79,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
tintBy: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const LEGACY_PRESETS = {
|
||||
@@ -97,6 +102,24 @@ function updatePixelated() {
|
||||
pixelated.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const tint = computed(() => {
|
||||
if (props.tintBy) {
|
||||
return hash(props.tintBy) % 360
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
function hash(str) {
|
||||
let hash = 0
|
||||
for (let i = 0, len = str.length; i < len; i++) {
|
||||
const chr = str.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + chr
|
||||
hash |= 0
|
||||
}
|
||||
return hash
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -108,13 +131,14 @@ function updatePixelated() {
|
||||
background-color: var(--color-button-bg);
|
||||
object-fit: contain;
|
||||
border-radius: calc(16 / 96 * var(--_size));
|
||||
position: relative;
|
||||
|
||||
&.circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&:not(.no-shadow) {
|
||||
box-shadow: var(--shadow-inset-lg), var(--shadow-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
&.no-shadow {
|
||||
@@ -128,5 +152,9 @@ function updatePixelated() {
|
||||
&.raised {
|
||||
background-color: var(--color-raised-bg);
|
||||
}
|
||||
|
||||
&.tint {
|
||||
background-color: color-mix(in oklch, var(--color-button-bg) 100%, var(--_tint) 5%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ModrinthIcon,
|
||||
ScaleIcon,
|
||||
@@ -172,16 +172,10 @@ const messages = defineMessages({
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
defineProps<{
|
||||
type: string
|
||||
color?: string
|
||||
}>()
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.version-badge {
|
||||
|
||||
@@ -126,7 +126,7 @@ function setColorFill(
|
||||
|
||||
const colorVariables = computed(() => {
|
||||
if (props.highlighted) {
|
||||
let colors = {
|
||||
const colors = {
|
||||
bg:
|
||||
props.highlightedStyle === 'main-nav-primary'
|
||||
? 'var(--color-brand-highlight)'
|
||||
@@ -137,7 +137,7 @@ const colorVariables = computed(() => {
|
||||
? 'var(--color-brand)'
|
||||
: 'var(--color-contrast)',
|
||||
}
|
||||
let hoverColors = JSON.parse(JSON.stringify(colors))
|
||||
const hoverColors = JSON.parse(JSON.stringify(colors))
|
||||
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_icon: ${colors.icon}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_hover-icon: ${hoverColors.icon};`
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ const colorVariables = computed(() => {
|
||||
}
|
||||
|
||||
/* Searches up to 4 children deep for valid button */
|
||||
.btn-wrapper :deep(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper :slotted(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child,
|
||||
.btn-wrapper :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
|
||||
@@ -194,7 +195,7 @@ const colorVariables = computed(() => {
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child {
|
||||
@apply flex flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight];
|
||||
@apply flex cursor-pointer flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight];
|
||||
transition:
|
||||
scale 0.125s ease-in-out,
|
||||
background-color 0.25s ease-in-out,
|
||||
@@ -202,6 +203,7 @@ const colorVariables = computed(() => {
|
||||
|
||||
svg:first-child {
|
||||
color: var(--_icon, var(--_text));
|
||||
transition: color 0.25s ease-in-out;
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
@@ -219,9 +221,15 @@ const colorVariables = computed(() => {
|
||||
|
||||
&:not([disabled]):not([disabled='true']):not(.disabled) {
|
||||
@apply active:scale-95 hover:brightness-125 focus-visible:brightness-125 hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text];
|
||||
|
||||
&:hover svg:first-child,
|
||||
&:focus-visible svg:first-child {
|
||||
color: var(--_hover-icon, var(--_hover-text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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 +242,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 +265,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 +276,7 @@ const colorVariables = computed(() => {
|
||||
}
|
||||
|
||||
> :not(:last-child) {
|
||||
:deep(:is(button, a, .button-like):first-child),
|
||||
:slotted(:is(button, a, .button-like):first-child),
|
||||
:slotted(*) > :is(button, a, .button-like):first-child,
|
||||
:slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
|
||||
|
||||
@@ -6,25 +6,26 @@
|
||||
@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 -->
|
||||
<p v-if="label" aria-hidden="true">
|
||||
<p v-if="label" aria-hidden="true" class="checkbox-label">
|
||||
{{ label }}
|
||||
</p>
|
||||
<slot v-else />
|
||||
</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;
|
||||
@@ -132,4 +138,8 @@ function toggle() {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
color: var(--color-base);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -54,7 +54,7 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
created() {
|
||||
if (this.items.length > 0 && this.neverEmpty) {
|
||||
if (this.items.length > 0 && this.neverEmpty && !this.modelValue) {
|
||||
this.selected = this.items[0]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<router-link v-if="isLink" :to="to">
|
||||
<slot />
|
||||
</router-link>
|
||||
<span v-else>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
to: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isLink: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-x-8 gap-y-6 border-0 border-b border-solid border-button-bg pb-6 lg:grid-cols-[1fr_auto]"
|
||||
class="grid grid-cols-1 gap-x-8 gap-y-6 border-0 border-b border-solid border-divider pb-4 lg:grid-cols-[1fr_auto]"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<slot name="icon" />
|
||||
@@ -11,10 +11,10 @@
|
||||
</h1>
|
||||
<slot name="title-suffix" />
|
||||
</div>
|
||||
<p class="m-0 line-clamp-2 max-w-[40rem]">
|
||||
<p class="m-0 line-clamp-2 max-w-[40rem] empty:hidden">
|
||||
<slot name="summary" />
|
||||
</p>
|
||||
<div class="mt-auto flex flex-wrap gap-4">
|
||||
<div class="mt-auto flex flex-wrap gap-4 empty:hidden">
|
||||
<slot name="stats" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,13 @@
|
||||
}"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<span>{{ selectedOption }}</span>
|
||||
<div>
|
||||
<slot :selected="selectedOption">
|
||||
<span>
|
||||
{{ selectedOption }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
<DropdownIcon class="arrow" :class="{ rotate: dropdownVisible }" />
|
||||
</div>
|
||||
<div class="options-wrapper" :class="{ down: !renderUp, up: renderUp }">
|
||||
|
||||
113
packages/ui/src/components/base/LoadingIndicator.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="w-full flex items-center justify-center flex-col gap-2">
|
||||
<div class="title">Loading</div>
|
||||
<div class="placeholder"></div>
|
||||
<div class="placeholder"></div>
|
||||
<div class="placeholder"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
<style scoped>
|
||||
.title {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
font-weight: bold;
|
||||
color: var(--color-contrast);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
animation: dots 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
25% {
|
||||
content: '';
|
||||
}
|
||||
50% {
|
||||
content: '.';
|
||||
}
|
||||
75% {
|
||||
content: '..';
|
||||
}
|
||||
0%,
|
||||
100% {
|
||||
content: '...';
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
height: 4rem;
|
||||
opacity: 0.25;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-raised-bg);
|
||||
animation: pop 4s ease-in-out infinite;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(
|
||||
-45deg,
|
||||
transparent 30%,
|
||||
rgba(196, 217, 237, 0.075) 50%,
|
||||
transparent 70%
|
||||
);
|
||||
animation: shimmer 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&:nth-child(2)::before {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(3)::before {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
&:nth-child(4)::before {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
from {
|
||||
opacity: 0.25;
|
||||
border-color: transparent;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
border-color: var(--color-button-bg);
|
||||
}
|
||||
to {
|
||||
opacity: 0.25;
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
transform: translateX(-80%);
|
||||
}
|
||||
50%,
|
||||
to {
|
||||
transform: translateX(80%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,7 @@
|
||||
:disabled="disabled"
|
||||
:position="position"
|
||||
:direction="direction"
|
||||
:dropdown-id="dropdownId"
|
||||
@open="
|
||||
() => {
|
||||
searchQuery = ''
|
||||
@@ -15,15 +16,15 @@
|
||||
<slot />
|
||||
<DropdownIcon class="h-5 w-5 text-secondary" />
|
||||
<template #menu>
|
||||
<div class="iconified-input mb-2 w-full" v-if="search">
|
||||
<div v-if="search" class="iconified-input mb-2 w-full">
|
||||
<label for="search-input" hidden>Search...</label>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input
|
||||
id="search-input"
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search..."
|
||||
type="text"
|
||||
ref="searchInput"
|
||||
@keydown.enter="
|
||||
() => {
|
||||
toggleOption(filteredOptions[0])
|
||||
@@ -40,7 +41,7 @@
|
||||
class="!w-full"
|
||||
:color="manyValues.includes(option) ? 'secondary' : 'default'"
|
||||
>
|
||||
<slot name="option" :option="option">{{ displayName(option) }}</slot>
|
||||
<slot name="option" :option="option">{{ displayName?.(option) }}</slot>
|
||||
<CheckIcon
|
||||
class="h-5 w-5 text-contrast ml-auto transition-opacity"
|
||||
:class="{ 'opacity-0': !manyValues.includes(option) }"
|
||||
@@ -56,7 +57,7 @@
|
||||
class="!w-full"
|
||||
:color="manyValues.includes(option) ? 'secondary' : 'default'"
|
||||
>
|
||||
<slot name="option" :option="option">{{ displayName(option) }}</slot>
|
||||
<slot name="option" :option="option">{{ displayName?.(option) }}</slot>
|
||||
<CheckIcon
|
||||
class="h-5 w-5 text-contrast ml-auto transition-opacity"
|
||||
:class="{ 'opacity-0': !manyValues.includes(option) }"
|
||||
@@ -69,7 +70,7 @@
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DropdownIcon, SearchIcon, XIcon } from '@modrinth/assets'
|
||||
import { CheckIcon, DropdownIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, PopoutMenu, Button } from '../index'
|
||||
import { computed, ref } from 'vue'
|
||||
import ScrollablePanel from './ScrollablePanel.vue'
|
||||
@@ -85,6 +86,7 @@ const props = withDefaults(
|
||||
direction?: string
|
||||
displayName?: (option: Option) => string
|
||||
search?: boolean
|
||||
dropdownId?: string
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
@@ -92,6 +94,7 @@ const props = withDefaults(
|
||||
direction: 'auto',
|
||||
displayName: (option: Option) => option as string,
|
||||
search: false,
|
||||
dropdownId: null,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -368,7 +368,6 @@ 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'
|
||||
@@ -654,7 +653,7 @@ function cleanUrl(input: string): string {
|
||||
// Attempt to validate and parse the URL
|
||||
try {
|
||||
url = new URL(input)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
throw new Error('Invalid URL. Make sure the URL is well-formed.')
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
defineProps({
|
||||
sidebar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const notifications = ref([])
|
||||
|
||||
defineExpose({
|
||||
@@ -90,6 +97,10 @@ function stopTimer(notif) {
|
||||
z-index: 99999999;
|
||||
width: 300px;
|
||||
|
||||
&.has-sidebar {
|
||||
right: 325px;
|
||||
}
|
||||
|
||||
.vue-notification-wrapper {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
ref="dropdown"
|
||||
v-bind="$attrs"
|
||||
:disabled="disabled"
|
||||
:position="position"
|
||||
:direction="direction"
|
||||
:dropdown-id="dropdownId"
|
||||
:tooltip="tooltip"
|
||||
>
|
||||
<slot></slot>
|
||||
<template #menu>
|
||||
@@ -17,10 +17,12 @@
|
||||
<Button
|
||||
v-else
|
||||
:key="`option-${option.id}`"
|
||||
v-tooltip="option.tooltip"
|
||||
:color="option.color ? option.color : 'default'"
|
||||
:hover-filled="option.hoverFilled"
|
||||
:hover-filled-only="option.hoverFilledOnly"
|
||||
transparent
|
||||
:v-close-popper="!option.remainOnClick"
|
||||
:action="
|
||||
option.action
|
||||
? (event) => {
|
||||
@@ -33,6 +35,7 @@
|
||||
"
|
||||
:link="option.link ? option.link : null"
|
||||
:external="option.external ? option.external : false"
|
||||
:disabled="option.disabled"
|
||||
@click="
|
||||
() => {
|
||||
if (option.link && !option.remainOnClick) {
|
||||
@@ -50,7 +53,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { type Ref, ref } from 'vue'
|
||||
import Button from './Button.vue'
|
||||
import PopoutMenu from './PopoutMenu.vue'
|
||||
|
||||
@@ -80,22 +83,24 @@ interface Item extends BaseOption {
|
||||
hoverFilled?: boolean
|
||||
hoverFilledOnly?: boolean
|
||||
remainOnClick?: boolean
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
type Option = Divider | Item
|
||||
|
||||
const props = withDefaults(
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -103,12 +108,17 @@ defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const dropdown = ref(null)
|
||||
const dropdown: Ref<InstanceType<typeof PopoutMenu> | null> = ref(null)
|
||||
|
||||
const close = () => {
|
||||
console.log('closing!')
|
||||
dropdown.value.hide()
|
||||
dropdown.value?.hide()
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
dropdown.value?.show()
|
||||
}
|
||||
|
||||
defineExpose({ open, close })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -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,27 +31,35 @@
|
||||
: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>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { GapIcon, ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets'
|
||||
import Button from './Button.vue'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -68,7 +80,7 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const pages = computed(() => {
|
||||
let pages: ('-' | number)[] = []
|
||||
const pages: ('-' | number)[] = []
|
||||
|
||||
const first = 1
|
||||
const last = props.count
|
||||
|
||||
@@ -1,275 +1,91 @@
|
||||
<template>
|
||||
<div ref="dropdown" class="popup-container" tabindex="-1" :aria-expanded="dropdownVisible">
|
||||
<button
|
||||
v-bind="$attrs"
|
||||
ref="dropdownButton"
|
||||
:class="{ 'popout-open': dropdownVisible }"
|
||||
:tabindex="tabInto ? -1 : 0"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<Dropdown
|
||||
ref="dropdown"
|
||||
no-auto-focus
|
||||
:aria-id="dropdownId || null"
|
||||
placement="bottom-end"
|
||||
@apply-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: hideFunction }">
|
||||
<button class="dummy-button" @focusin="hideAndFocusTrigger(hideFunction)"></button>
|
||||
<div ref="menu" class="contents">
|
||||
<slot name="menu"> </slot>
|
||||
</div>
|
||||
<button class="dummy-button" @focusin="hideAndFocusTrigger(hideFunction)"></button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
import { ref } 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>
|
||||
|
||||
22
packages/ui/src/components/base/PreviewSelectButton.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { RadioButtonIcon, RadioButtonChecked } from '@modrinth/assets'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
checked: boolean
|
||||
}>(),
|
||||
{
|
||||
checked: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="" role="button" @click="() => {}">
|
||||
<slot name="preview" />
|
||||
<div>
|
||||
<RadioButtonIcon v-if="!checked" class="w-4 h-4" />
|
||||
<RadioButtonChecked v-else class="w-4 h-4" />
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -74,7 +74,7 @@ import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime.js'
|
||||
import { defineComponent } from 'vue'
|
||||
import Categories from '../search/Categories.vue'
|
||||
import Badge from './Badge.vue'
|
||||
import Badge from './SimpleBadge.vue'
|
||||
import Avatar from './Avatar.vue'
|
||||
import EnvironmentIndicator from './EnvironmentIndicator.vue'
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="scrollable-pane-wrapper" :class="{ 'max-height': !props.noMaxHeight }">
|
||||
<div class="scrollable-pane-wrapper">
|
||||
<div
|
||||
class="wrapper-wrapper"
|
||||
:class="{
|
||||
'top-fade': !scrollableAtTop && !props.noMaxHeight,
|
||||
'bottom-fade': !scrollableAtBottom && !props.noMaxHeight,
|
||||
'top-fade': !scrollableAtTop && !props.disableScrolling,
|
||||
'bottom-fade': !scrollableAtBottom && !props.disableScrolling,
|
||||
}"
|
||||
>
|
||||
<div ref="scrollablePane" class="scrollable-pane" @scroll="onScroll">
|
||||
@@ -19,10 +19,10 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
noMaxHeight?: boolean
|
||||
disableScrolling?: boolean
|
||||
}>(),
|
||||
{
|
||||
noMaxHeight: false,
|
||||
disableScrolling: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -49,7 +49,7 @@ onUnmounted(() => {
|
||||
})
|
||||
function updateFade(scrollTop, offsetHeight, scrollHeight) {
|
||||
scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight
|
||||
scrollableAtTop.value = scrollTop === 0
|
||||
scrollableAtTop.value = scrollTop <= 0
|
||||
}
|
||||
function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
|
||||
updateFade(scrollTop, offsetHeight, scrollHeight)
|
||||
@@ -61,46 +61,33 @@ 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;
|
||||
}
|
||||
&::before {
|
||||
top: 0;
|
||||
background-image: linear-gradient(
|
||||
var(--scrollable-pane-bg, var(--color-raised-bg)),
|
||||
transparent
|
||||
|
||||
&.bottom-fade {
|
||||
mask-image: linear-gradient(
|
||||
rgb(0 0 0 / 100%) calc(100% - var(--_fade-height)),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
&::after {
|
||||
bottom: 0;
|
||||
background-image: linear-gradient(
|
||||
|
||||
&.top-fade.bottom-fade {
|
||||
mask-image: linear-gradient(
|
||||
transparent,
|
||||
var(--scrollable-pane-bg, var(--color-raised-bg))
|
||||
rgb(0 0 0 / 100%) var(--_fade-height),
|
||||
rgb(0 0 0 / 100%) calc(100% - var(--_fade-height)),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -113,26 +100,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>
|
||||
|
||||
16
packages/ui/src/components/base/SimpleBadge.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<span class="inline-flex items-center gap-1 font-semibold text-secondary">
|
||||
<component :is="icon" v-if="icon" :aria-hidden="true" class="shrink-0" />
|
||||
{{ formattedName }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
icon?: Component
|
||||
formattedName: string
|
||||
color?: 'brand' | 'green' | 'blue' | 'purple' | 'orange' | 'red'
|
||||
}>()
|
||||
</script>
|
||||
20
packages/ui/src/components/base/TagItem.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<button
|
||||
v-if="action"
|
||||
class="bg-[--_bg-color,var(--color-button-bg)] px-2 py-1 leading-none rounded-full font-semibold text-sm inline-flex items-center gap-1 text-[--_color,var(--color-secondary)] [&>svg]:shrink-0 [&>svg]:h-4 [&>svg]:w-4 border-none transition-transform active:scale-[0.95] cursor-pointer hover:underline"
|
||||
@click="action"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
class="bg-[--_bg-color,var(--color-button-bg)] px-2 py-1 leading-none rounded-full font-semibold text-sm inline-flex items-center gap-1 text-[--_color,var(--color-secondary)] [&>svg]:shrink-0 [&>svg]:h-4 [&>svg]:w-4"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
action?: (event: MouseEvent) => void
|
||||
}>()
|
||||
</script>
|
||||
441
packages/ui/src/components/base/TeleportDropdownMenu.vue
Normal file
@@ -0,0 +1,441 @@
|
||||
<template>
|
||||
<div
|
||||
ref="dropdown"
|
||||
data-pyro-dropdown
|
||||
tabindex="0"
|
||||
role="combobox"
|
||||
:aria-expanded="dropdownVisible"
|
||||
class="relative inline-block h-9 w-full max-w-80"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@mousedown.prevent
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<div
|
||||
data-pyro-dropdown-trigger
|
||||
class="duration-50 flex h-full w-full cursor-pointer select-none items-center justify-between gap-4 rounded-xl bg-button-bg px-4 py-2 shadow-sm transition-all ease-in-out"
|
||||
:class="triggerClasses"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<span>{{ selectedOption }}</span>
|
||||
<DropdownIcon
|
||||
class="transition-transform duration-200 ease-in-out"
|
||||
:class="{ 'rotate-180': dropdownVisible }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<transition
|
||||
enter-active-class="transition-opacity duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="dropdownVisible"
|
||||
ref="optionsContainer"
|
||||
data-pyro-dropdown-options
|
||||
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg"
|
||||
:class="{
|
||||
'rounded-b-xl': !isRenderingUp,
|
||||
'rounded-t-xl': isRenderingUp,
|
||||
}"
|
||||
:style="positionStyle"
|
||||
@keydown.stop="handleDropdownKeyDown"
|
||||
>
|
||||
<div
|
||||
class="overflow-y-auto"
|
||||
:style="{ height: `${virtualListHeight}px` }"
|
||||
data-pyro-dropdown-options-virtual-scroller
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
|
||||
<div
|
||||
v-for="item in visibleOptions"
|
||||
:key="item.index"
|
||||
data-pyro-dropdown-option
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: `translateY(${item.index * ITEM_HEIGHT}px)`,
|
||||
width: '100%',
|
||||
height: `${ITEM_HEIGHT}px`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:ref="(el) => handleOptionRef(el as HTMLElement, item.index)"
|
||||
role="option"
|
||||
:tabindex="focusedOptionIndex === item.index ? 0 : -1"
|
||||
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out focus:border-none focus:outline-none"
|
||||
:class="{
|
||||
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
|
||||
'bg-bg-raised': focusedOptionIndex === item.index,
|
||||
}"
|
||||
:aria-selected="selectedValue === item.option"
|
||||
@click="selectOption(item.option, item.index)"
|
||||
@mouseover="focusedOptionIndex = item.index"
|
||||
@focus="focusedOptionIndex = item.index"
|
||||
>
|
||||
<input
|
||||
:id="`${name}-${item.index}`"
|
||||
v-model="radioValue"
|
||||
type="radio"
|
||||
:value="item.option"
|
||||
:name="name"
|
||||
class="hidden"
|
||||
/>
|
||||
<label :for="`${name}-${item.index}`" class="w-full cursor-pointer">
|
||||
{{ displayName(item.option) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="OptionValue extends string | number | Record<string, any>">
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
const ITEM_HEIGHT = 44
|
||||
const BUFFER_ITEMS = 5
|
||||
|
||||
interface Props {
|
||||
options: OptionValue[]
|
||||
name: string
|
||||
defaultValue?: OptionValue | null
|
||||
placeholder?: string | number | null
|
||||
modelValue?: OptionValue | null
|
||||
renderUp?: boolean
|
||||
disabled?: boolean
|
||||
displayName?: (option: OptionValue) => string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
defaultValue: null,
|
||||
placeholder: null,
|
||||
modelValue: null,
|
||||
renderUp: false,
|
||||
disabled: false,
|
||||
displayName: (option: OptionValue) => String(option),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'input' | 'update:modelValue', value: OptionValue): void
|
||||
(e: 'change', value: { option: OptionValue; index: number }): void
|
||||
}>()
|
||||
|
||||
const dropdownVisible = ref(false)
|
||||
const selectedValue = ref<OptionValue | null>(props.modelValue || props.defaultValue)
|
||||
const focusedOptionIndex = ref<number | null>(null)
|
||||
const focusedOptionRef = ref<HTMLElement | null>(null)
|
||||
const dropdown = ref<HTMLElement | null>(null)
|
||||
const optionsContainer = ref<HTMLElement | null>(null)
|
||||
const scrollTop = ref(0)
|
||||
const isRenderingUp = ref(false)
|
||||
const virtualListHeight = ref(300)
|
||||
const lastFocusedElement = ref<HTMLElement | null>(null)
|
||||
|
||||
const positionStyle = ref<CSSProperties>({
|
||||
position: 'fixed',
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
width: '0px',
|
||||
zIndex: 999,
|
||||
})
|
||||
|
||||
const handleOptionRef = (el: HTMLElement | null, index: number) => {
|
||||
if (focusedOptionIndex.value === index) {
|
||||
focusedOptionRef.value = el
|
||||
}
|
||||
}
|
||||
|
||||
const onFocus = async () => {
|
||||
if (!props.disabled) {
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement
|
||||
dropdownVisible.value = true
|
||||
await updatePosition()
|
||||
nextTick(() => {
|
||||
dropdown.value?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onBlur = (event: FocusEvent) => {
|
||||
if (!isChildOfDropdown(event.relatedTarget as HTMLElement | null)) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
|
||||
let currentNode: HTMLElement | null = element
|
||||
while (currentNode) {
|
||||
if (currentNode === dropdown.value || currentNode === optionsContainer.value) {
|
||||
return true
|
||||
}
|
||||
currentNode = currentNode.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const totalHeight = computed(() => props.options.length * ITEM_HEIGHT)
|
||||
|
||||
const visibleOptions = computed(() => {
|
||||
const startIndex = Math.floor(scrollTop.value / ITEM_HEIGHT) - BUFFER_ITEMS
|
||||
const visibleCount = Math.ceil(virtualListHeight.value / ITEM_HEIGHT) + 2 * BUFFER_ITEMS
|
||||
|
||||
return Array.from({ length: visibleCount }, (_, i) => {
|
||||
const index = startIndex + i
|
||||
if (index >= 0 && index < props.options.length) {
|
||||
return {
|
||||
index,
|
||||
option: props.options[index],
|
||||
}
|
||||
}
|
||||
return null
|
||||
}).filter((item): item is { index: number; option: OptionValue } => item !== null)
|
||||
})
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
if (selectedValue.value !== null && selectedValue.value !== undefined) {
|
||||
return props.displayName(selectedValue.value as OptionValue)
|
||||
}
|
||||
return props.placeholder || 'Select an option'
|
||||
})
|
||||
|
||||
const radioValue = computed<OptionValue>({
|
||||
get() {
|
||||
return props.modelValue ?? selectedValue.value ?? ''
|
||||
},
|
||||
set(newValue: OptionValue) {
|
||||
emit('update:modelValue', newValue)
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
})
|
||||
|
||||
const triggerClasses = computed(() => ({
|
||||
'cursor-not-allowed opacity-50 grayscale': props.disabled,
|
||||
'rounded-b-none': dropdownVisible.value && !isRenderingUp.value && !props.disabled,
|
||||
'rounded-t-none': dropdownVisible.value && isRenderingUp.value && !props.disabled,
|
||||
}))
|
||||
|
||||
const updatePosition = async () => {
|
||||
if (!dropdown.value) return
|
||||
|
||||
await nextTick()
|
||||
const triggerRect = dropdown.value.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const margin = 8
|
||||
|
||||
const contentHeight = props.options.length * ITEM_HEIGHT
|
||||
const preferredHeight = Math.min(contentHeight, 300)
|
||||
|
||||
const spaceBelow = viewportHeight - triggerRect.bottom
|
||||
const spaceAbove = triggerRect.top
|
||||
|
||||
isRenderingUp.value = spaceBelow < preferredHeight && spaceAbove > spaceBelow
|
||||
|
||||
virtualListHeight.value = isRenderingUp.value
|
||||
? Math.min(spaceAbove - margin, preferredHeight)
|
||||
: Math.min(spaceBelow - margin, preferredHeight)
|
||||
|
||||
positionStyle.value = {
|
||||
position: 'fixed',
|
||||
left: `${triggerRect.left}px`,
|
||||
width: `${triggerRect.width}px`,
|
||||
zIndex: 999,
|
||||
...(isRenderingUp.value
|
||||
? { bottom: `${viewportHeight - triggerRect.top}px`, top: 'auto' }
|
||||
: { top: `${triggerRect.bottom}px`, bottom: 'auto' }),
|
||||
}
|
||||
}
|
||||
|
||||
const openDropdown = async () => {
|
||||
if (!props.disabled) {
|
||||
closeAllDropdowns()
|
||||
dropdownVisible.value = true
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement
|
||||
await updatePosition()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
updatePosition()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!props.disabled) {
|
||||
if (dropdownVisible.value) {
|
||||
closeDropdown()
|
||||
} else {
|
||||
openDropdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
if (dropdownVisible.value) {
|
||||
requestAnimationFrame(() => {
|
||||
updatePosition()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
scrollTop.value = target.scrollTop
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!dropdownVisible.value) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement
|
||||
toggleDropdown()
|
||||
}
|
||||
} else {
|
||||
handleDropdownKeyDown(event)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDropdownKeyDown = (event: KeyboardEvent) => {
|
||||
event.stopPropagation()
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
focusNextOption()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
focusPreviousOption()
|
||||
break
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
if (focusedOptionIndex.value !== null) {
|
||||
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeDropdown()
|
||||
break
|
||||
case 'Tab':
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) {
|
||||
focusPreviousOption()
|
||||
} else {
|
||||
focusNextOption()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const closeDropdown = () => {
|
||||
dropdownVisible.value = false
|
||||
focusedOptionIndex.value = null
|
||||
if (lastFocusedElement.value) {
|
||||
lastFocusedElement.value.focus()
|
||||
lastFocusedElement.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const closeAllDropdowns = () => {
|
||||
const event = new CustomEvent('close-all-dropdowns')
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
|
||||
const selectOption = (option: OptionValue, index: number) => {
|
||||
radioValue.value = option
|
||||
emit('change', { option, index })
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
const focusNextOption = () => {
|
||||
if (focusedOptionIndex.value === null) {
|
||||
focusedOptionIndex.value = 0
|
||||
} else {
|
||||
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length
|
||||
}
|
||||
scrollToFocused()
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const focusPreviousOption = () => {
|
||||
if (focusedOptionIndex.value === null) {
|
||||
focusedOptionIndex.value = props.options.length - 1
|
||||
} else {
|
||||
focusedOptionIndex.value =
|
||||
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length
|
||||
}
|
||||
scrollToFocused()
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToFocused = () => {
|
||||
if (focusedOptionIndex.value === null) return
|
||||
|
||||
const optionsElement = optionsContainer.value?.querySelector('.overflow-y-auto')
|
||||
if (!optionsElement) return
|
||||
|
||||
const targetScrollTop = focusedOptionIndex.value * ITEM_HEIGHT
|
||||
const scrollBottom = optionsElement.clientHeight
|
||||
|
||||
if (targetScrollTop < optionsElement.scrollTop) {
|
||||
optionsElement.scrollTop = targetScrollTop
|
||||
} else if (targetScrollTop + ITEM_HEIGHT > optionsElement.scrollTop + scrollBottom) {
|
||||
optionsElement.scrollTop = targetScrollTop - scrollBottom + ITEM_HEIGHT
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('scroll', handleResize, true)
|
||||
window.addEventListener('click', (event) => {
|
||||
if (!isChildOfDropdown(event.target as HTMLElement)) {
|
||||
closeDropdown()
|
||||
}
|
||||
})
|
||||
window.addEventListener('close-all-dropdowns', closeDropdown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('scroll', handleResize, true)
|
||||
window.removeEventListener('click', (event) => {
|
||||
if (!isChildOfDropdown(event.target as HTMLElement)) {
|
||||
closeDropdown()
|
||||
}
|
||||
})
|
||||
window.removeEventListener('close-all-dropdowns', closeDropdown)
|
||||
lastFocusedElement.value = null
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
)
|
||||
|
||||
watch(dropdownVisible, async (newValue) => {
|
||||
if (newValue) {
|
||||
await updatePosition()
|
||||
scrollTop.value = 0
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -75,7 +75,15 @@
|
||||
/> -->
|
||||
<div class="grid lg:grid-cols-5 grid-cols-3 gap-4">
|
||||
<button
|
||||
v-for="loader in ['Vanilla', 'Fabric', 'Forge', 'Quilt', 'NeoForge']"
|
||||
v-for="loader in [
|
||||
'Vanilla',
|
||||
'Fabric',
|
||||
'Forge',
|
||||
'Quilt',
|
||||
'NeoForge',
|
||||
'Paper',
|
||||
'Purpur',
|
||||
]"
|
||||
:key="loader"
|
||||
class="!h-24 btn flex !flex-col !items-center !justify-between !pt-4 !pb-3 !w-full"
|
||||
:style="{
|
||||
@@ -934,7 +942,6 @@ async function submitPayment() {
|
||||
|
||||
defineExpose({
|
||||
show: () => {
|
||||
// eslint-disable-next-line no-undef
|
||||
stripe = Stripe(props.publishableKey)
|
||||
|
||||
selectedPlan.value = 'yearly'
|
||||
|
||||
90
packages/ui/src/components/content/ContentListItem.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<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'
|
||||
|
||||
export interface ContentCreator {
|
||||
name: string
|
||||
type: 'user' | 'organization'
|
||||
id: string
|
||||
link?: string | RouteLocationRaw
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
linkProps?: any
|
||||
}
|
||||
|
||||
export interface ContentProject {
|
||||
id: string
|
||||
link?: string | RouteLocationRaw
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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<boolean>()
|
||||
</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 items-center">
|
||||
<slot name="actions" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
98
packages/ui/src/components/content/ContentListPanel.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<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'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: ContentItem<T>[]
|
||||
sortColumn: string
|
||||
sortAscending: boolean
|
||||
updateSort: (column: string) => void
|
||||
currentPage: number
|
||||
}>(),
|
||||
{},
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
const paginatedItems = computed(() =>
|
||||
props.items.slice((props.currentPage - 1) * 20, props.currentPage * 20),
|
||||
)
|
||||
</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">
|
||||
<ContentListItem
|
||||
v-for="(itemRef, index) in paginatedItems"
|
||||
:key="itemRef.filename"
|
||||
v-model="selectionStates[itemRef.filename]"
|
||||
:item="itemRef"
|
||||
:last="index === paginatedItems.length - 1"
|
||||
class="mb-2"
|
||||
@update:model-value="updateSelection"
|
||||
>
|
||||
<template #actions="{ item }">
|
||||
<slot name="actions" :item="item" />
|
||||
</template>
|
||||
</ContentListItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,4 +1,6 @@
|
||||
// Base content
|
||||
export { default as Accordion } from './base/Accordion.vue'
|
||||
export { default as AutoLink } from './base/AutoLink.vue'
|
||||
export { default as Avatar } from './base/Avatar.vue'
|
||||
export { default as Badge } from './base/Badge.vue'
|
||||
export { default as Button } from './base/Button.vue'
|
||||
@@ -6,7 +8,6 @@ export { default as ButtonStyled } from './base/ButtonStyled.vue'
|
||||
export { default as Card } from './base/Card.vue'
|
||||
export { default as Checkbox } from './base/Checkbox.vue'
|
||||
export { default as Chips } from './base/Chips.vue'
|
||||
export { default as ConditionalNuxtLink } from './base/ConditionalNuxtLink.vue'
|
||||
export { default as ContentPageHeader } from './base/ContentPageHeader.vue'
|
||||
export { default as CopyCode } from './base/CopyCode.vue'
|
||||
export { default as DoubleIcon } from './base/DoubleIcon.vue'
|
||||
@@ -14,6 +15,7 @@ export { default as DropArea } from './base/DropArea.vue'
|
||||
export { default as DropdownSelect } from './base/DropdownSelect.vue'
|
||||
export { default as EnvironmentIndicator } from './base/EnvironmentIndicator.vue'
|
||||
export { default as FileInput } from './base/FileInput.vue'
|
||||
export { default as LoadingIndicator } from './base/LoadingIndicator.vue'
|
||||
export { default as ManySelect } from './base/ManySelect.vue'
|
||||
export { default as MarkdownEditor } from './base/MarkdownEditor.vue'
|
||||
export { default as Notifications } from './base/Notifications.vue'
|
||||
@@ -21,10 +23,14 @@ 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 TagItem } from './base/TagItem.vue'
|
||||
export { default as TeleportDropdownMenu } from './base/TeleportDropdownMenu.vue'
|
||||
export { default as Toggle } from './base/Toggle.vue'
|
||||
|
||||
// Branding
|
||||
@@ -35,11 +41,16 @@ 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'
|
||||
export { default as ConfirmModal } from './modal/ConfirmModal.vue'
|
||||
export { default as ShareModal } from './modal/ShareModal.vue'
|
||||
export { default as TabbedModal } from './modal/TabbedModal.vue'
|
||||
export type { Tab as TabbedModalTab } from './modal/TabbedModal.vue'
|
||||
|
||||
// Navigation
|
||||
export { default as Breadcrumbs } from './nav/Breadcrumbs.vue'
|
||||
@@ -47,13 +58,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'
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :noblur="noblur" danger :on-hide="onHide">
|
||||
<NewModal ref="modal" :noblur="noblur" :danger="danger" :on-hide="onHide">
|
||||
<template #title>
|
||||
<slot name="title">
|
||||
<span class="font-extrabold text-contrast text-lg">{{ title }}</span>
|
||||
</slot>
|
||||
</template>
|
||||
<div>
|
||||
<div class="markdown-body" v-html="renderString(description)" />
|
||||
<div class="markdown-body max-w-[35rem]" v-html="renderString(description)" />
|
||||
<label v-if="hasToType" for="confirmation" class="confirmation-label">
|
||||
<span>
|
||||
<strong>To verify, type</strong>
|
||||
@@ -25,9 +25,9 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-6">
|
||||
<ButtonStyled color="red">
|
||||
<ButtonStyled :color="danger ? 'red' : 'brand'">
|
||||
<button :disabled="action_disabled" @click="proceed">
|
||||
<TrashIcon />
|
||||
<component :is="proceedIcon" />
|
||||
{{ proceedLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -68,6 +68,10 @@ const props = defineProps({
|
||||
default: 'No description defined',
|
||||
required: true,
|
||||
},
|
||||
proceedIcon: {
|
||||
type: Object,
|
||||
default: TrashIcon,
|
||||
},
|
||||
proceedLabel: {
|
||||
type: String,
|
||||
default: 'Proceed',
|
||||
@@ -76,6 +80,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
danger: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
onHide: {
|
||||
type: Function,
|
||||
default() {
|
||||
|
||||
@@ -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,13 @@
|
||||
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"
|
||||
data-tauri-drag-region
|
||||
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 +32,7 @@
|
||||
</slot>
|
||||
</div>
|
||||
<ButtonStyled v-if="closable" circular>
|
||||
<button @click="hide" aria-label="Close">
|
||||
<button v-tooltip="'Close'" aria-label="Close" @click="hide">
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -56,6 +57,7 @@ const props = withDefaults(
|
||||
closable?: boolean
|
||||
danger?: boolean
|
||||
closeOnEsc?: boolean
|
||||
closeOnClickOutside?: boolean
|
||||
warnOnClose?: boolean
|
||||
header?: string
|
||||
onHide?: () => void
|
||||
@@ -65,8 +67,10 @@ const props = withDefaults(
|
||||
type: true,
|
||||
closable: true,
|
||||
danger: false,
|
||||
closeOnClickOutside: true,
|
||||
closeOnEsc: true,
|
||||
warnOnClose: false,
|
||||
header: null,
|
||||
onHide: () => {},
|
||||
onShow: () => {},
|
||||
},
|
||||
@@ -161,7 +165,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;
|
||||
@@ -205,14 +215,14 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
visibility: visible;
|
||||
transform: translate(0, 0);
|
||||
|
||||
.modal-body {
|
||||
> .modal-body {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
> .modal-body {
|
||||
position: fixed;
|
||||
box-shadow: 4px 4px 26px 10px rgba(0, 0, 0, 0.08);
|
||||
max-height: calc(100% - 2 * var(--gap-lg));
|
||||
|
||||
48
packages/ui/src/components/modal/TabbedModal.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { type Component, ref } from 'vue'
|
||||
import { useVIntl, type MessageDescriptor } from '@vintl/vintl'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
export type Tab<Props> = {
|
||||
name: MessageDescriptor
|
||||
icon: Component
|
||||
content: Component<Props>
|
||||
props?: Props
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tabs: Tab<any>[]
|
||||
}>()
|
||||
|
||||
const selectedTab = ref(0)
|
||||
|
||||
function setTab(index: number) {
|
||||
selectedTab.value = index
|
||||
}
|
||||
|
||||
defineExpose({ selectedTab, setTab })
|
||||
</script>
|
||||
<template>
|
||||
<div class="grid grid-cols-[auto_1fr]">
|
||||
<div
|
||||
class="flex flex-col gap-1 border-solid pr-4 border-0 border-r-[1px] border-divider min-w-[200px]"
|
||||
>
|
||||
<button
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="index"
|
||||
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-all ${selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
|
||||
@click="() => (selectedTab = index)"
|
||||
>
|
||||
<component :is="tab.icon" class="w-4 h-4" />
|
||||
<span>{{ formatMessage(tab.name) }}</span>
|
||||
</button>
|
||||
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
<div class="w-[600px] h-[500px] overflow-y-auto px-4">
|
||||
<component :is="tabs[selectedTab].content" v-bind="tabs[selectedTab].props ?? {}" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
85
packages/ui/src/components/project/NewProjectCard.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="button-base p-4 bg-bg-raised rounded-xl flex gap-3 group">
|
||||
<div class="icon">
|
||||
<Avatar :src="project.icon_url" size="96px" class="search-icon" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 overflow-hidden">
|
||||
<div class="gap-2 overflow-hidden no-wrap text-ellipsis">
|
||||
<span class="text-lg font-extrabold text-contrast m-0 leading-none">{{
|
||||
project.title
|
||||
}}</span>
|
||||
<span v-if="project.author" class="text-secondary"> by {{ project.author }}</span>
|
||||
</div>
|
||||
<div class="m-0 line-clamp-2">
|
||||
{{ project.description }}
|
||||
</div>
|
||||
<div class="mt-auto flex items-center gap-1 no-wrap">
|
||||
<TagsIcon class="h-4 w-4 shrink-0" />
|
||||
<div
|
||||
v-for="tag in categories"
|
||||
:key="tag"
|
||||
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
|
||||
>
|
||||
{{ formatCategory(tag) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 items-end shrink-0 ml-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<DownloadIcon class="shrink-0" />
|
||||
<span>
|
||||
{{ formatNumber(project.downloads) }}
|
||||
<span class="text-secondary">downloads</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<HeartIcon class="shrink-0" />
|
||||
<span>
|
||||
{{ formatNumber(project.follows ?? project.followers) }}
|
||||
<span class="text-secondary">followers</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-auto relative">
|
||||
<div
|
||||
:class="{
|
||||
'group-hover:-translate-y-3 group-hover:opacity-0 group-focus-within:opacity-0 group-hover:scale-95 group-focus-within:scale-95 transition-all':
|
||||
$slots.actions,
|
||||
}"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<HistoryIcon class="shrink-0" />
|
||||
<span>
|
||||
<span class="text-secondary">Updated</span>
|
||||
{{ dayjs(project.date_modified ?? project.updated).fromNow() }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="opacity-0 scale-95 translate-y-3 group-hover:translate-y-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:opacity-100 group-focus-within:scale-100 absolute bottom-0 right-0 transition-all w-fit"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TagsIcon, DownloadIcon, HeartIcon, HistoryIcon } from '@modrinth/assets'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import { formatNumber, formatCategory } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
<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>
|
||||
68
packages/ui/src/components/project/ProjectHeader.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<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 cursor-help"
|
||||
:class="{ 'md:border-r': project.categories.length > 0 }"
|
||||
>
|
||||
<HeartIcon class="h-6 w-6 text-secondary" />
|
||||
<span class="font-semibold">
|
||||
{{ formatNumber(project.followers) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="project.categories.length > 0" class="hidden items-center gap-2 md:flex">
|
||||
<TagsIcon class="h-6 w-6 text-secondary" />
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<TagItem v-for="(category, index) in project.categories" :key="index">
|
||||
{{ 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 Avatar from '../base/Avatar.vue'
|
||||
import ContentPageHeader from '../base/ContentPageHeader.vue'
|
||||
import { formatCategory, formatNumber, type Project } from '@modrinth/utils'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
import ProjectStatusBadge from './ProjectStatusBadge.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
project: Project
|
||||
member?: boolean
|
||||
}>(),
|
||||
{
|
||||
member: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="markdown-body" v-html="renderHighlightedString(description ?? '')" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { renderHighlightedString } from '@modrinth/utils'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
description: string
|
||||
}>(),
|
||||
{},
|
||||
)
|
||||
</script>
|
||||
296
packages/ui/src/components/project/ProjectPageVersions.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
<VersionFilterControl
|
||||
ref="versionFilters"
|
||||
:versions="versions"
|
||||
:game-versions="gameVersions"
|
||||
:base-id="`${baseId}-filter`"
|
||||
@update:query="updateQuery"
|
||||
/>
|
||||
<Pagination
|
||||
:page="currentPage"
|
||||
class="ml-auto mt-auto"
|
||||
:count="Math.ceil(filteredVersions.length / pageSize)"
|
||||
@switch-page="switchPage"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="versions.length > 0"
|
||||
class="flex flex-col gap-4 rounded-2xl bg-bg-raised px-6 pb-8 pt-4 supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[1fr_min-content] sm:px-8 supports-[grid-template-columns:subgrid]:sm:grid-cols-[min-content_auto_auto_auto_min-content] supports-[grid-template-columns:subgrid]:xl:grid-cols-[min-content_auto_auto_auto_auto_auto_min-content]"
|
||||
>
|
||||
<div class="versions-grid-row">
|
||||
<div class="w-9 max-sm:hidden"></div>
|
||||
<div class="text-sm font-bold text-contrast max-sm:hidden">Name</div>
|
||||
<div
|
||||
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
|
||||
>
|
||||
Game version
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
|
||||
>
|
||||
Platforms
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
|
||||
>
|
||||
Published
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
|
||||
>
|
||||
Downloads
|
||||
</div>
|
||||
<div class="text-sm font-bold text-contrast max-sm:hidden xl:collapse xl:hidden">
|
||||
Compatibility
|
||||
</div>
|
||||
<div class="text-sm font-bold text-contrast max-sm:hidden xl:collapse xl:hidden">Stats</div>
|
||||
<div class="w-9 max-sm:hidden"></div>
|
||||
</div>
|
||||
<template v-for="(version, index) in currentVersions" :key="index">
|
||||
<!-- Row divider -->
|
||||
<div
|
||||
class="versions-grid-row h-px w-full bg-button-bg"
|
||||
:class="{
|
||||
'max-sm:!hidden': index === 0,
|
||||
}"
|
||||
></div>
|
||||
<div class="versions-grid-row group relative">
|
||||
<AutoLink
|
||||
v-if="!!versionLink"
|
||||
class="absolute inset-[calc(-1rem-2px)_-2rem] before:absolute before:inset-0 before:transition-all before:content-[''] hover:before:backdrop-brightness-110"
|
||||
:to="versionLink?.(version)"
|
||||
/>
|
||||
<div class="flex flex-col justify-center gap-2 sm:contents">
|
||||
<div class="flex flex-row items-center gap-2 sm:contents">
|
||||
<div class="self-center">
|
||||
<div class="relative z-[1] cursor-pointer">
|
||||
<VersionChannelIndicator
|
||||
v-tooltip="`Toggle filter for ${version.version_type}`"
|
||||
:channel="version.version_type"
|
||||
@click="versionFilters?.toggleFilter('channel', version.version_type)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none relative z-[1] flex flex-col justify-center"
|
||||
:class="{
|
||||
'group-hover:underline': !!versionLink,
|
||||
}"
|
||||
>
|
||||
<div class="font-bold text-contrast">{{ version.version_number }}</div>
|
||||
<div class="text-xs font-medium">{{ version.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center gap-2 sm:contents">
|
||||
<div class="flex flex-row flex-wrap items-center gap-1 xl:contents">
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<TagItem
|
||||
v-for="gameVersion in formatVersionsForDisplay(
|
||||
version.game_versions,
|
||||
gameVersions,
|
||||
)"
|
||||
:key="`version-tag-${gameVersion}`"
|
||||
v-tooltip="`Toggle filter for ${gameVersion}`"
|
||||
class="z-[1]"
|
||||
:action="
|
||||
() => versionFilters?.toggleFilters('gameVersion', version.game_versions)
|
||||
"
|
||||
>
|
||||
{{ gameVersion }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<TagItem
|
||||
v-for="platform in version.loaders"
|
||||
:key="`platform-tag-${platform}`"
|
||||
v-tooltip="`Toggle filter for ${platform}`"
|
||||
class="z-[1]"
|
||||
:style="`--_color: var(--color-platform-${platform})`"
|
||||
:action="() => versionFilters?.toggleFilter('platform', platform)"
|
||||
>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<svg v-html="loaders.find((x) => x.name === platform)?.icon"></svg>
|
||||
{{ formatCategory(platform) }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col justify-center gap-1 max-sm:flex-row max-sm:justify-start max-sm:gap-3 xl:contents"
|
||||
>
|
||||
<div
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(version.date_published),
|
||||
time: new Date(version.date_published),
|
||||
})
|
||||
"
|
||||
class="z-[1] flex cursor-help items-center gap-1 text-nowrap font-medium xl:self-center"
|
||||
>
|
||||
<CalendarIcon class="xl:hidden" />
|
||||
{{ dayjs(version.date_published).fromNow() }}
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none z-[1] flex items-center gap-1 font-medium xl:self-center"
|
||||
>
|
||||
<DownloadIcon class="xl:hidden" />
|
||||
{{ formatNumber(version.downloads) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start justify-end gap-1 sm:items-center z-[1]">
|
||||
<slot name="actions" :version="version"></slot>
|
||||
</div>
|
||||
<div v-if="showFiles" class="tag-list pointer-events-none relative z-[1] col-span-full">
|
||||
<div
|
||||
v-for="(file, fileIdx) in version.files"
|
||||
:key="`platform-tag-${fileIdx}`"
|
||||
:class="`flex items-center gap-1 text-wrap rounded-full bg-button-bg px-2 py-0.5 text-xs font-medium ${file.primary || fileIdx === 0 ? 'bg-brand-highlight text-contrast' : 'text-primary'}`"
|
||||
>
|
||||
<StarIcon v-if="file.primary || fileIdx === 0" class="shrink-0" />
|
||||
{{ file.filename }} - {{ formatBytes(file.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex mt-3">
|
||||
<Pagination
|
||||
:page="currentPage"
|
||||
class="ml-auto"
|
||||
:count="Math.ceil(filteredVersions.length / pageSize)"
|
||||
@switch-page="switchPage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
formatBytes,
|
||||
formatCategory,
|
||||
formatNumber,
|
||||
formatVersionsForDisplay,
|
||||
type GameVersionTag,
|
||||
type PlatformTag,
|
||||
type Version,
|
||||
} from '@modrinth/utils'
|
||||
|
||||
import { commonMessages } from '../../utils/common-messages'
|
||||
import { CalendarIcon, DownloadIcon, StarIcon } from '@modrinth/assets'
|
||||
import { Pagination, VersionChannelIndicator, VersionFilterControl } from '../index'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { type Ref, ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import AutoLink from '../base/AutoLink.vue'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
type VersionWithDisplayUrlEnding = Version & {
|
||||
displayUrlEnding: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
baseId?: string
|
||||
project: {
|
||||
project_type: string
|
||||
slug?: string
|
||||
id: string
|
||||
}
|
||||
versions: VersionWithDisplayUrlEnding[]
|
||||
showFiles?: boolean
|
||||
currentMember?: boolean
|
||||
loaders: PlatformTag[]
|
||||
gameVersions: GameVersionTag[]
|
||||
versionLink?: (version: Version) => string
|
||||
}>(),
|
||||
{
|
||||
baseId: undefined,
|
||||
showFiles: false,
|
||||
currentMember: false,
|
||||
versionLink: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const currentPage: Ref<number> = ref(1)
|
||||
const pageSize: Ref<number> = ref(20)
|
||||
const versionFilters: Ref<InstanceType<typeof VersionFilterControl> | null> = ref(null)
|
||||
|
||||
const selectedGameVersions: Ref<string[]> = computed(
|
||||
() => versionFilters.value?.selectedGameVersions ?? [],
|
||||
)
|
||||
const selectedPlatforms: Ref<string[]> = computed(
|
||||
() => versionFilters.value?.selectedPlatforms ?? [],
|
||||
)
|
||||
const selectedChannels: Ref<string[]> = computed(() => versionFilters.value?.selectedChannels ?? [])
|
||||
|
||||
const filteredVersions = computed(() => {
|
||||
return props.versions.filter(
|
||||
(version) =>
|
||||
hasAnySelected(version.game_versions, selectedGameVersions.value) &&
|
||||
hasAnySelected(version.loaders, selectedPlatforms.value) &&
|
||||
isAnySelected(version.version_type, selectedChannels.value),
|
||||
)
|
||||
})
|
||||
|
||||
function hasAnySelected(values: string[], selected: string[]) {
|
||||
return selected.length === 0 || selected.some((value) => values.includes(value))
|
||||
}
|
||||
|
||||
function isAnySelected(value: string, selected: string[]) {
|
||||
return selected.length === 0 || selected.includes(value)
|
||||
}
|
||||
|
||||
const currentVersions = computed(() =>
|
||||
filteredVersions.value.slice(
|
||||
(currentPage.value - 1) * pageSize.value,
|
||||
currentPage.value * pageSize.value,
|
||||
),
|
||||
)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
if (route.query.page) {
|
||||
currentPage.value = Number(route.query.page) || 1
|
||||
}
|
||||
|
||||
function switchPage(page: number) {
|
||||
currentPage.value = page
|
||||
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
page: currentPage.value !== 1 ? currentPage.value : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function updateQuery(newQueries: Record<string, string | string[] | undefined | null>) {
|
||||
if (newQueries.page) {
|
||||
currentPage.value = Number(newQueries.page)
|
||||
} else if (newQueries.page === undefined) {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
...newQueries,
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.versions-grid-row {
|
||||
@apply grid grid-cols-[1fr_min-content] gap-4 supports-[grid-template-columns:subgrid]:col-span-full supports-[grid-template-columns:subgrid]:!grid-cols-subgrid sm:grid-cols-[min-content_1fr_1fr_1fr_min-content] xl:grid-cols-[min-content_1fr_1fr_1fr_1fr_1fr_min-content];
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<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.project_type === '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
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
versions: any[]
|
||||
}
|
||||
tags: {
|
||||
gameVersions: GameVersionTag[]
|
||||
loaders: PlatformTag[]
|
||||
}
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'project.about.compatibility.title',
|
||||
defaultMessage: 'Compatibility',
|
||||
},
|
||||
minecraftJava: {
|
||||
id: 'project.about.compatibility.game.minecraftJava',
|
||||
defaultMessage: 'Minecraft: Java Edition',
|
||||
},
|
||||
platforms: {
|
||||
id: 'project.about.compatibility.platforms',
|
||||
defaultMessage: 'Platforms',
|
||||
},
|
||||
environments: {
|
||||
id: 'project.about.compatibility.environments',
|
||||
defaultMessage: 'Supported environments',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
118
packages/ui/src/components/project/ProjectSidebarCreators.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
|
||||
<div class="flex flex-col gap-3 font-semibold">
|
||||
<template v-if="organization">
|
||||
<AutoLink
|
||||
class="flex gap-2 items-center w-fit text-primary leading-[1.2] group"
|
||||
:to="orgLink(organization.slug)"
|
||||
:target="linkTarget ?? null"
|
||||
>
|
||||
<Avatar :src="organization.icon_url" :alt="organization.name" size="32px" />
|
||||
<div class="flex flex-col flex-nowrap justify-center">
|
||||
<span class="group-hover:underline">
|
||||
{{ organization.name }}
|
||||
</span>
|
||||
<span class="text-secondary text-sm font-medium flex items-center gap-1"
|
||||
><OrganizationIcon /> Organization</span
|
||||
>
|
||||
</div>
|
||||
</AutoLink>
|
||||
<hr v-if="sortedMembers.length > 0" class="w-full border-button-border my-0.5" />
|
||||
</template>
|
||||
<AutoLink
|
||||
v-for="member in sortedMembers"
|
||||
:key="`member-${member.id}`"
|
||||
class="flex gap-2 items-center w-fit text-primary leading-[1.2] group"
|
||||
:to="userLink(member.user.username)"
|
||||
:target="linkTarget ?? null"
|
||||
>
|
||||
<Avatar :src="member.user.avatar_url" :alt="member.user.username" size="32px" circle />
|
||||
<div class="flex flex-col">
|
||||
<span class="flex flex-row flex-nowrap items-center gap-1 group-hover:underline">
|
||||
{{ member.user.username }}
|
||||
<CrownIcon
|
||||
v-if="member.is_owner"
|
||||
v-tooltip="formatMessage(messages.owner)"
|
||||
class="text-brand-orange"
|
||||
/>
|
||||
<ExternalIcon v-if="linkTarget === '_blank'" />
|
||||
</span>
|
||||
<span class="text-secondary text-sm font-medium">{{ member.role }}</span>
|
||||
</div>
|
||||
</AutoLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CrownIcon, ExternalIcon, OrganizationIcon } from '@modrinth/assets'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import AutoLink from '../base/AutoLink.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
type TeamMember = {
|
||||
id: string
|
||||
role: string
|
||||
is_owner: boolean
|
||||
accepted: boolean
|
||||
user: {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url: string
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
organization?: {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
icon_url: string
|
||||
avatar_url: string
|
||||
members: TeamMember[]
|
||||
} | null
|
||||
members: TeamMember[]
|
||||
orgLink: (slug: string) => string
|
||||
userLink: (username: string) => string
|
||||
linkTarget?: string
|
||||
}>()
|
||||
|
||||
// Members should be an array of all members, without the accepted ones, and with the user with the Owner role at the start
|
||||
// The rest of the members should be sorted by role, then by name
|
||||
const sortedMembers = computed(() => {
|
||||
const acceptedMembers = props.members.filter((x) => x.accepted === undefined || x.accepted)
|
||||
const owner = acceptedMembers.find((x) =>
|
||||
props.organization
|
||||
? props.organization.members.some(
|
||||
(orgMember) => orgMember.user.id === x.user.id && orgMember.is_owner,
|
||||
)
|
||||
: x.is_owner,
|
||||
)
|
||||
|
||||
const rest = acceptedMembers.filter((x) => !owner || x.user.id !== owner.user.id) || []
|
||||
|
||||
rest.sort((a, b) => {
|
||||
if (a.role === b.role) {
|
||||
return a.user.username.localeCompare(b.user.username)
|
||||
} else {
|
||||
return a.role.localeCompare(b.role)
|
||||
}
|
||||
})
|
||||
|
||||
return owner ? [owner, ...rest] : rest
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'project.about.creators.title',
|
||||
defaultMessage: 'Creators',
|
||||
},
|
||||
owner: {
|
||||
id: 'project.about.creators.owner',
|
||||
defaultMessage: 'Project owner',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
142
packages/ui/src/components/project/ProjectSidebarDetails.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
|
||||
<div class="flex flex-col gap-3 font-semibold [&>div]:flex [&>div]:gap-2 [&>div]:items-center">
|
||||
<div>
|
||||
<BookTextIcon aria-hidden="true" />
|
||||
<div>
|
||||
Licensed
|
||||
<a
|
||||
v-if="project.license.url"
|
||||
class="text-link hover:underline"
|
||||
:href="project.license.url"
|
||||
:target="linkTarget"
|
||||
rel="noopener nofollow ugc"
|
||||
>
|
||||
{{ licenseIdDisplay }}
|
||||
<ExternalIcon aria-hidden="true" class="external-icon ml-1 mt-[-1px] inline" />
|
||||
</a>
|
||||
<span
|
||||
v-else-if="
|
||||
project.license.id === 'LicenseRef-All-Rights-Reserved' ||
|
||||
!project.license.id.includes('LicenseRef')
|
||||
"
|
||||
>
|
||||
{{ licenseIdDisplay }}
|
||||
</span>
|
||||
<span v-else>{{ licenseIdDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="project.approved"
|
||||
v-tooltip="dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
|
||||
>
|
||||
<CalendarIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(messages.published, { date: publishedDate }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else v-tooltip="dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')">
|
||||
<CalendarIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(messages.created, { date: createdDate }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="project.status === 'processing' && project.queued"
|
||||
v-tooltip="dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
|
||||
>
|
||||
<ScaleIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(messages.submitted, { date: submittedDate }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasVersions && project.updated"
|
||||
v-tooltip="dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
|
||||
>
|
||||
<VersionIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(messages.updated, { date: updatedDate }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { BookTextIcon, CalendarIcon, ScaleIcon, VersionIcon, ExternalIcon } from '@modrinth/assets'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
import { computed } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
project: {
|
||||
id: string
|
||||
published: string
|
||||
updated: string
|
||||
approved: string
|
||||
queued: string
|
||||
status: string
|
||||
license: {
|
||||
id: string
|
||||
url: string
|
||||
}
|
||||
}
|
||||
linkTarget: string
|
||||
hasVersions: boolean
|
||||
}>()
|
||||
|
||||
const createdDate = computed(() =>
|
||||
props.project.published ? dayjs(props.project.published).fromNow() : 'unknown',
|
||||
)
|
||||
const submittedDate = computed(() =>
|
||||
props.project.queued ? dayjs(props.project.queued).fromNow() : 'unknown',
|
||||
)
|
||||
const publishedDate = computed(() =>
|
||||
props.project.approved ? dayjs(props.project.approved).fromNow() : 'unknown',
|
||||
)
|
||||
const updatedDate = computed(() =>
|
||||
props.project.updated ? dayjs(props.project.updated).fromNow() : 'unknown',
|
||||
)
|
||||
|
||||
const licenseIdDisplay = computed(() => {
|
||||
const id = props.project.license.id
|
||||
|
||||
if (id === 'LicenseRef-All-Rights-Reserved') {
|
||||
return 'ARR'
|
||||
} else if (id.includes('LicenseRef')) {
|
||||
return id.replaceAll('LicenseRef-', '').replaceAll('-', ' ')
|
||||
} else {
|
||||
return id
|
||||
}
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'project.about.details.title',
|
||||
defaultMessage: 'Details',
|
||||
},
|
||||
licensed: {
|
||||
id: 'project.about.details.licensed',
|
||||
defaultMessage: 'Licensed {license}',
|
||||
},
|
||||
created: {
|
||||
id: 'project.about.details.created',
|
||||
defaultMessage: 'Created {date}',
|
||||
},
|
||||
submitted: {
|
||||
id: 'project.about.details.submitted',
|
||||
defaultMessage: 'Submitted {date}',
|
||||
},
|
||||
published: {
|
||||
id: 'project.about.details.published',
|
||||
defaultMessage: 'Published {date}',
|
||||
},
|
||||
updated: {
|
||||
id: 'project.about.details.updated',
|
||||
defaultMessage: 'Updated {date}',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
173
packages/ui/src/components/project/ProjectSidebarLinks.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="
|
||||
project.issues_url ||
|
||||
project.source_url ||
|
||||
project.wiki_url ||
|
||||
project.discord_url ||
|
||||
project.donation_urls.length > 0
|
||||
"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
|
||||
<div
|
||||
class="flex flex-col gap-3 font-semibold [&>a]:flex [&>a]:gap-2 [&>a]:items-center [&>a]:w-fit [&>a]:text-primary [&>a]:leading-[1.2] [&>a:hover]:underline"
|
||||
>
|
||||
<a
|
||||
v-if="project.issues_url"
|
||||
:href="project.issues_url"
|
||||
:target="linkTarget"
|
||||
rel="noopener nofollow ugc"
|
||||
>
|
||||
<IssuesIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.issues) }}
|
||||
<ExternalIcon aria-hidden="true" class="external-icon" />
|
||||
</a>
|
||||
<a
|
||||
v-if="project.source_url"
|
||||
:href="project.source_url"
|
||||
:target="linkTarget"
|
||||
rel="noopener nofollow ugc"
|
||||
>
|
||||
<CodeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.source) }}
|
||||
<ExternalIcon aria-hidden="true" class="external-icon" />
|
||||
</a>
|
||||
<a
|
||||
v-if="project.wiki_url"
|
||||
:href="project.wiki_url"
|
||||
:target="linkTarget"
|
||||
rel="noopener nofollow ugc"
|
||||
>
|
||||
<WikiIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.wiki) }}
|
||||
<ExternalIcon aria-hidden="true" class="external-icon" />
|
||||
</a>
|
||||
<a
|
||||
v-if="project.discord_url"
|
||||
:href="project.discord_url"
|
||||
:target="linkTarget"
|
||||
rel="noopener nofollow ugc"
|
||||
>
|
||||
<DiscordIcon class="shrink" aria-hidden="true" />
|
||||
{{ formatMessage(messages.discord) }}
|
||||
<ExternalIcon aria-hidden="true" class="external-icon" />
|
||||
</a>
|
||||
<hr
|
||||
v-if="
|
||||
(project.issues_url || project.source_url || project.wiki_url || project.discord_url) &&
|
||||
project.donation_urls.length > 0
|
||||
"
|
||||
class="w-full border-button-border my-0.5"
|
||||
/>
|
||||
<a
|
||||
v-for="(donation, index) in project.donation_urls"
|
||||
:key="index"
|
||||
:href="donation.url"
|
||||
:target="linkTarget"
|
||||
rel="noopener nofollow ugc"
|
||||
>
|
||||
<BuyMeACoffeeIcon v-if="donation.id === 'bmac'" aria-hidden="true" />
|
||||
<PatreonIcon v-else-if="donation.id === 'patreon'" aria-hidden="true" />
|
||||
<KoFiIcon v-else-if="donation.id === 'ko-fi'" aria-hidden="true" />
|
||||
<PayPalIcon v-else-if="donation.id === 'paypal'" aria-hidden="true" />
|
||||
<OpenCollectiveIcon v-else-if="donation.id === 'open-collective'" aria-hidden="true" />
|
||||
<HeartIcon v-else-if="donation.id === 'github'" />
|
||||
<CurrencyIcon v-else />
|
||||
<span v-if="donation.id === 'bmac'">{{ formatMessage(messages.donateBmac) }}</span>
|
||||
<span v-else-if="donation.id === 'patreon'">{{
|
||||
formatMessage(messages.donatePatreon)
|
||||
}}</span>
|
||||
<span v-else-if="donation.id === 'paypal'">{{ formatMessage(messages.donatePayPal) }}</span>
|
||||
<span v-else-if="donation.id === 'ko-fi'">{{ formatMessage(messages.donateKoFi) }}</span>
|
||||
<span v-else-if="donation.id === 'github'">{{ formatMessage(messages.donateGithub) }}</span>
|
||||
<span v-else>{{ formatMessage(messages.donateGeneric) }}</span>
|
||||
<ExternalIcon aria-hidden="true" class="external-icon" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BuyMeACoffeeIcon,
|
||||
CodeIcon,
|
||||
CurrencyIcon,
|
||||
DiscordIcon,
|
||||
ExternalIcon,
|
||||
HeartIcon,
|
||||
IssuesIcon,
|
||||
KoFiIcon,
|
||||
OpenCollectiveIcon,
|
||||
PatreonIcon,
|
||||
PayPalIcon,
|
||||
WikiIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
defineProps<{
|
||||
project: {
|
||||
issues_url: string
|
||||
source_url: string
|
||||
wiki_url: string
|
||||
discord_url: string
|
||||
donation_urls: {
|
||||
id: string
|
||||
url: string
|
||||
}[]
|
||||
}
|
||||
linkTarget: string
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'project.about.links.title',
|
||||
defaultMessage: 'Links',
|
||||
},
|
||||
issues: {
|
||||
id: 'project.about.links.issues',
|
||||
defaultMessage: 'Report issues',
|
||||
},
|
||||
source: {
|
||||
id: 'project.about.links.source',
|
||||
defaultMessage: 'View source',
|
||||
},
|
||||
wiki: {
|
||||
id: 'project.about.links.wiki',
|
||||
defaultMessage: 'Visit wiki',
|
||||
},
|
||||
discord: {
|
||||
id: 'project.about.links.discord',
|
||||
defaultMessage: 'Join Discord server',
|
||||
},
|
||||
donateGeneric: {
|
||||
id: 'project.about.links.donate.generic',
|
||||
defaultMessage: 'Donate',
|
||||
},
|
||||
donateGitHub: {
|
||||
id: 'project.about.links.donate.github',
|
||||
defaultMessage: 'Sponsor on GitHub',
|
||||
},
|
||||
donateBmac: {
|
||||
id: 'project.about.links.donate.bmac',
|
||||
defaultMessage: 'Buy Me a Coffee',
|
||||
},
|
||||
donatePatreon: {
|
||||
id: 'project.about.links.donate.patreon',
|
||||
defaultMessage: 'Donate on Patreon',
|
||||
},
|
||||
donatePayPal: {
|
||||
id: 'project.about.links.donate.paypal',
|
||||
defaultMessage: 'Donate on PayPal',
|
||||
},
|
||||
donateKoFi: {
|
||||
id: 'project.about.links.donate.kofi',
|
||||
defaultMessage: 'Donate on Ko-fi',
|
||||
},
|
||||
donateGithub: {
|
||||
id: 'project.about.links.donate.github',
|
||||
defaultMessage: 'Sponsor on GitHub',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
106
packages/ui/src/components/project/ProjectStatusBadge.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<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>
|
||||
106
packages/ui/src/components/search/BrowseFiltersPanel.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<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(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const filters: FilterType<any>[] = [
|
||||
{
|
||||
id: 'platform',
|
||||
formatted_name: 'Platform',
|
||||
options:
|
||||
props.platforms
|
||||
.filter((x) => x.default && x.supported_project_types.includes('modpack'))
|
||||
.map((x) => ({
|
||||
id: x.name,
|
||||
formatted_name: x.formatted_name,
|
||||
data: x,
|
||||
})) || [],
|
||||
},
|
||||
{
|
||||
id: 'gameVersion',
|
||||
formatted_name: 'Game version',
|
||||
options:
|
||||
props.gameVersions
|
||||
?.filter((x) => x.major && x.version_type === 'release')
|
||||
.map((x) => ({
|
||||
id: x.version,
|
||||
formatted_name: x.version,
|
||||
data: x,
|
||||
})) || [],
|
||||
},
|
||||
]
|
||||
|
||||
return filters
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
97
packages/ui/src/components/search/SearchFilterControl.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="experimental-styles-within flex flex-wrap items-center gap-1 empty:hidden">
|
||||
<TagItem
|
||||
v-if="selectedItems.length > 1"
|
||||
class="transition-transform active:scale-[0.95]"
|
||||
:action="clearFilters"
|
||||
>
|
||||
<XCircleIcon />
|
||||
Clear all filters
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-for="selectedItem in selectedItems"
|
||||
:key="`remove-filter-${selectedItem.type}-${selectedItem.option}`"
|
||||
:action="() => removeFilter(selectedItem)"
|
||||
>
|
||||
<XIcon />
|
||||
<BanIcon v-if="selectedItem.negative" class="text-brand-red" />
|
||||
{{ selectedItem.formatted_name ?? selectedItem.option }}
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-for="providedItem in items.filter((x) => x.provided)"
|
||||
:key="`provided-filter-${providedItem.type}-${providedItem.option}`"
|
||||
v-tooltip="formatMessage(providedMessage ?? defaultProvidedMessage)"
|
||||
:style="{ '--_bg-color': `var(--color-raised-bg)` }"
|
||||
>
|
||||
<LockIcon />
|
||||
{{ providedItem.formatted_name ?? providedItem.option }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XCircleIcon, XIcon, LockIcon, BanIcon } from '@modrinth/assets'
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
import type { FilterValue, FilterType, FilterOption } from '../../utils/search'
|
||||
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
filters: FilterType[]
|
||||
providedFilters: FilterValue[]
|
||||
overriddenProvidedFilterTypes: string[]
|
||||
providedMessage?: MessageDescriptor
|
||||
}>()
|
||||
|
||||
const defaultProvidedMessage = defineMessage({
|
||||
id: 'search.filter.locked.default',
|
||||
defaultMessage: 'Filter locked',
|
||||
})
|
||||
|
||||
type Item = {
|
||||
type: string
|
||||
option: string
|
||||
negative?: boolean
|
||||
formatted_name?: string
|
||||
provided: boolean
|
||||
}
|
||||
|
||||
function filterMatches(type: FilterType, option: FilterOption, list: FilterValue[]) {
|
||||
return list.some((provided) => provided.type === type.id && provided.option === option.id)
|
||||
}
|
||||
|
||||
const items: ComputedRef<Item[]> = computed(() => {
|
||||
return props.filters.flatMap((type) =>
|
||||
type.options
|
||||
.filter(
|
||||
(option) =>
|
||||
filterMatches(type, option, selectedFilters.value) ||
|
||||
filterMatches(type, option, props.providedFilters),
|
||||
)
|
||||
.map((option) => ({
|
||||
type: type.id,
|
||||
option: option.id,
|
||||
negative: selectedFilters.value.find((x) => x.type === type.id && x.option === option.id)
|
||||
?.negative,
|
||||
provided: filterMatches(type, option, props.providedFilters),
|
||||
formatted_name: option.formatted_name,
|
||||
})),
|
||||
)
|
||||
})
|
||||
|
||||
const selectedItems = computed(() => items.value.filter((x) => !x.provided))
|
||||
|
||||
function removeFilter(filter: Item) {
|
||||
selectedFilters.value = selectedFilters.value.filter(
|
||||
(x) => x.type !== filter.type || x.option !== filter.option,
|
||||
)
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
selectedFilters.value = []
|
||||
}
|
||||
</script>
|
||||
64
packages/ui/src/components/search/SearchFilterOption.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="search-filter-option group flex gap-1 items-center">
|
||||
<button
|
||||
:class="`flex border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-2 [@media(hover:hover)]:py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98] ${included ? 'bg-brand-highlight text-contrast hover:brightness-125' : excluded ? 'bg-highlight-red text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg focus-visible:bg-button-bg [&>svg.check-icon]:hover:text-brand [&>svg.check-icon]:focus-visible:text-brand'}`"
|
||||
@click="() => emit('toggle', option)"
|
||||
>
|
||||
<slot> </slot>
|
||||
<BanIcon
|
||||
v-if="excluded"
|
||||
:class="`filter-action-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${excluded ? '' : '[@media(hover:hover)]:opacity-0'}`"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckIcon
|
||||
v-else
|
||||
:class="`filter-action-icon check-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${included ? '' : '[@media(hover:hover)]:opacity-0'}`"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="supportsNegativeFilter && !excluded"
|
||||
class="w-px h-[1.75rem] bg-button-bg [@media(hover:hover)]:contents"
|
||||
:class="{ 'opacity-0': included }"
|
||||
></div>
|
||||
<button
|
||||
v-if="supportsNegativeFilter && !excluded"
|
||||
v-tooltip="excluded ? 'Remove exclusion' : 'Exclude'"
|
||||
class="flex border-none cursor-pointer items-center justify-center gap-2 rounded-xl bg-transparent px-2 py-1 text-sm font-semibold text-secondary [@media(hover:hover)]:opacity-0 transition-all hover:bg-button-bg hover:text-red focus-visible:bg-button-bg focus-visible:text-red active:scale-[0.96]"
|
||||
@click="() => emit('toggleExclude', option)"
|
||||
>
|
||||
<BanIcon class="filter-action-icon h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BanIcon, CheckIcon } from '@modrinth/assets'
|
||||
import type { FilterOption } from '../../utils/search'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
option: FilterOption
|
||||
included: boolean
|
||||
excluded: boolean
|
||||
supportsNegativeFilter?: boolean
|
||||
}>(),
|
||||
{
|
||||
supportsNegativeFilter: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [option: FilterOption]
|
||||
toggleExclude: [option: FilterOption]
|
||||
}>()
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.search-filter-option:hover,
|
||||
.search-filter-option:has(button:focus-visible) {
|
||||
button,
|
||||
.filter-action-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
334
packages/ui/src/components/search/SearchSidebarFilter.vue
Normal file
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<Accordion
|
||||
v-bind="$attrs"
|
||||
ref="accordion"
|
||||
:button-class="buttonClass ?? 'flex flex-col gap-2 justify-start items-start'"
|
||||
:content-class="contentClass"
|
||||
title-wrapper-class="flex flex-col gap-2 justify-start items-start"
|
||||
:open-by-default="!locked && (openByDefault !== undefined ? openByDefault : true)"
|
||||
>
|
||||
<template #title>
|
||||
<slot name="header" :filter="filterType">
|
||||
<h2>{{ filterType.formatted_name }}</h2>
|
||||
</slot>
|
||||
</template>
|
||||
<template
|
||||
v-if="
|
||||
locked ||
|
||||
(!!accordion &&
|
||||
!accordion.isOpen &&
|
||||
(selectedFilterOptions.length > 0 || selectedNegativeFilterOptions.length > 0))
|
||||
"
|
||||
#summary
|
||||
>
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
<div
|
||||
v-for="option in selectedFilterOptions"
|
||||
:key="`selected-filter-${filterType.id}-${option}`"
|
||||
class="flex gap-1 text-xs bg-button-bg px-2 py-0.5 rounded-full font-bold text-secondary w-fit shrink-0 items-center"
|
||||
>
|
||||
{{ option.formatted_name ?? option.id }}
|
||||
</div>
|
||||
<div
|
||||
v-for="option in selectedNegativeFilterOptions"
|
||||
:key="`excluded-filter-${filterType.id}-${option}`"
|
||||
class="flex gap-1 text-xs bg-button-bg px-2 py-0.5 rounded-full font-bold text-secondary w-fit shrink-0 items-center"
|
||||
>
|
||||
<BanIcon class="text-brand-red" /> {{ option.formatted_name ?? option.id }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="locked" #default>
|
||||
<div class="flex flex-col gap-2 p-3 border-dashed border-2 rounded-2xl border-divider mx-2">
|
||||
<p class="m-0 font-bold items-center">
|
||||
<slot :name="`locked-${filterType.id}`">
|
||||
{{ formatMessage(messages.lockedTitle, { type: filterType.formatted_name }) }}
|
||||
</slot>
|
||||
</p>
|
||||
<p class="m-0 text-secondary text-sm">
|
||||
{{ formatMessage(messages.lockedDescription) }}
|
||||
</p>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="w-fit"
|
||||
@click="
|
||||
() => {
|
||||
overriddenProvidedFilterTypes.push(filterType.id)
|
||||
}
|
||||
"
|
||||
>
|
||||
<LockOpenIcon />
|
||||
{{ formatMessage(messages.unlockFilterButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else #default>
|
||||
<div v-if="filterType.searchable" class="iconified-input mx-2 my-1 !flex">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input
|
||||
:id="`search-${filterType.id}`"
|
||||
v-model="query"
|
||||
class="!min-h-9 text-sm"
|
||||
type="text"
|
||||
:placeholder="`Search...`"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Button v-if="query" class="r-btn" aria-label="Clear search" @click="() => (query = '')">
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
|
||||
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
|
||||
<SearchFilterOption
|
||||
v-for="option in visibleOptions"
|
||||
:key="`${filterType.id}-${option}`"
|
||||
:option="option"
|
||||
:included="isIncluded(option)"
|
||||
:excluded="isExcluded(option)"
|
||||
:supports-negative-filter="filterType.supports_negative_filter"
|
||||
:class="{
|
||||
'mr-3': scrollable,
|
||||
}"
|
||||
@toggle="toggleFilter"
|
||||
@toggle-exclude="toggleNegativeFilter"
|
||||
>
|
||||
<slot name="option" :filter="filterType" :option="option">
|
||||
<div v-if="typeof option.icon === 'string'" class="h-4 w-4" v-html="option.icon" />
|
||||
<component :is="option.icon" v-else-if="option.icon" class="h-4 w-4" />
|
||||
<span class="truncate text-sm">{{ option.formatted_name ?? option.id }}</span>
|
||||
</slot>
|
||||
</SearchFilterOption>
|
||||
<button
|
||||
v-if="filterType.display === 'expandable'"
|
||||
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
|
||||
@click="showMore = !showMore"
|
||||
>
|
||||
<DropdownIcon
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="{ 'rotate-180': showMore }"
|
||||
/>
|
||||
<span class="truncate text-sm">{{ showMore ? 'Show fewer' : 'Show more' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</ScrollablePanel>
|
||||
<div :class="innerPanelClass ? innerPanelClass : ''" class="empty:hidden">
|
||||
<Checkbox
|
||||
v-for="group in filterType.toggle_groups"
|
||||
:key="`toggle-group-${group.id}`"
|
||||
class="mx-2"
|
||||
:model-value="groupEnabled(group.id)"
|
||||
:label="`${group.formatted_name}`"
|
||||
@update:model-value="toggleGroup(group.id)"
|
||||
/>
|
||||
<div v-if="hasProvidedFilter" class="mt-2 mx-1">
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="w-fit"
|
||||
@click="
|
||||
() => {
|
||||
overriddenProvidedFilterTypes = overriddenProvidedFilterTypes.filter(
|
||||
(id) => id !== filterType.id,
|
||||
)
|
||||
accordion?.close()
|
||||
clearFilters()
|
||||
}
|
||||
"
|
||||
>
|
||||
<UpdatedIcon />
|
||||
<slot name="sync-button">
|
||||
{{ formatMessage(messages.syncFilterButton) }}
|
||||
</slot>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Accordion>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Accordion from '../base/Accordion.vue'
|
||||
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
|
||||
import {
|
||||
BanIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
UpdatedIcon,
|
||||
LockOpenIcon,
|
||||
DropdownIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Button, Checkbox, ScrollablePanel } from '../index'
|
||||
import { computed, ref } from 'vue'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import SearchFilterOption from './SearchFilterOption.vue'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
|
||||
const toggledGroups = defineModel<string[]>('toggledGroups', { required: true })
|
||||
const overriddenProvidedFilterTypes = defineModel<string[]>('overriddenProvidedFilterTypes', {
|
||||
required: false,
|
||||
default: [],
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
filterType: FilterType
|
||||
buttonClass?: string
|
||||
contentClass?: string
|
||||
innerPanelClass?: string
|
||||
openByDefault?: boolean
|
||||
providedFilters: FilterValue[]
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const query = ref('')
|
||||
const showMore = ref(false)
|
||||
|
||||
const accordion = ref<InstanceType<typeof Accordion> | null>()
|
||||
|
||||
const selectedFilterOptions = computed(() =>
|
||||
props.filterType.options.filter((option) =>
|
||||
locked.value ? isProvided(option, false) : isIncluded(option),
|
||||
),
|
||||
)
|
||||
const selectedNegativeFilterOptions = computed(() =>
|
||||
props.filterType.options.filter((option) =>
|
||||
locked.value ? isProvided(option, true) : isExcluded(option),
|
||||
),
|
||||
)
|
||||
const visibleOptions = computed(() =>
|
||||
props.filterType.options
|
||||
.filter((option) => isVisible(option) || isIncluded(option) || isExcluded(option))
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (props.filterType.display === 'expandable') {
|
||||
const aDefault = props.filterType.default_values.includes(a.id)
|
||||
const bDefault = props.filterType.default_values.includes(b.id)
|
||||
|
||||
if (aDefault && !bDefault) {
|
||||
return -1
|
||||
} else if (!aDefault && bDefault) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}),
|
||||
)
|
||||
|
||||
const hasProvidedFilter = computed(() =>
|
||||
props.providedFilters.some((filter) => filter.type === props.filterType.id),
|
||||
)
|
||||
const locked = computed(
|
||||
() =>
|
||||
hasProvidedFilter.value && !overriddenProvidedFilterTypes.value.includes(props.filterType.id),
|
||||
)
|
||||
|
||||
const scrollable = computed(
|
||||
() => visibleOptions.value.length >= 10 && props.filterType.display === 'scrollable',
|
||||
)
|
||||
|
||||
function groupEnabled(group: string) {
|
||||
return toggledGroups.value.includes(group)
|
||||
}
|
||||
|
||||
function toggleGroup(group: string) {
|
||||
if (toggledGroups.value.includes(group)) {
|
||||
toggledGroups.value = toggledGroups.value.filter((x) => x !== group)
|
||||
} else {
|
||||
toggledGroups.value.push(group)
|
||||
}
|
||||
}
|
||||
|
||||
function isIncluded(filter: FilterOption) {
|
||||
return selectedFilters.value.some((value) => value.option === filter.id && !value.negative)
|
||||
}
|
||||
|
||||
function isExcluded(filter: FilterOption) {
|
||||
return selectedFilters.value.some((value) => value.option === filter.id && value.negative)
|
||||
}
|
||||
|
||||
function isVisible(filter: FilterOption) {
|
||||
const filterKey = filter.formatted_name?.toLowerCase() ?? filter.id.toLowerCase()
|
||||
const matchesQuery = !query.value || filterKey.includes(query.value.toLowerCase())
|
||||
|
||||
if (props.filterType.display === 'expandable') {
|
||||
return matchesQuery && (showMore.value || props.filterType.default_values.includes(filter.id))
|
||||
}
|
||||
|
||||
if (filter.toggle_group) {
|
||||
return toggledGroups.value.includes(filter.toggle_group) && matchesQuery
|
||||
} else {
|
||||
return matchesQuery
|
||||
}
|
||||
}
|
||||
|
||||
function isProvided(filter: FilterOption, negative: boolean) {
|
||||
return props.providedFilters.some(
|
||||
(x) => x.type === props.filterType.id && x.option === filter.id && !x.negative === !negative,
|
||||
)
|
||||
}
|
||||
|
||||
type FilterState = 'include' | 'exclude' | 'ignore'
|
||||
|
||||
function toggleFilter(filter: FilterOption) {
|
||||
setFilter(filter, isIncluded(filter) || isExcluded(filter) ? 'ignore' : 'include')
|
||||
}
|
||||
|
||||
function toggleNegativeFilter(filter: FilterOption) {
|
||||
setFilter(filter, isExcluded(filter) ? 'ignore' : 'exclude')
|
||||
}
|
||||
|
||||
function setFilter(filter: FilterOption, state: FilterState) {
|
||||
const newFilters = selectedFilters.value.filter((selected) => selected.option !== filter.id)
|
||||
|
||||
const baseValues = {
|
||||
type: props.filterType.id,
|
||||
option: filter.id,
|
||||
}
|
||||
|
||||
if (state === 'include') {
|
||||
newFilters.push({
|
||||
...baseValues,
|
||||
negative: false,
|
||||
})
|
||||
} else if (state === 'exclude') {
|
||||
newFilters.push({
|
||||
...baseValues,
|
||||
negative: true,
|
||||
})
|
||||
}
|
||||
|
||||
selectedFilters.value = newFilters
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
selectedFilters.value = selectedFilters.value.filter(
|
||||
(filter) => filter.type !== props.filterType.id,
|
||||
)
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
unlockFilterButton: {
|
||||
id: 'search.filter.locked.default.unlock',
|
||||
defaultMessage: 'Unlock filter',
|
||||
},
|
||||
syncFilterButton: {
|
||||
id: 'search.filter.locked.default.sync',
|
||||
defaultMessage: 'Sync filter',
|
||||
},
|
||||
lockedTitle: {
|
||||
id: 'search.filter.locked.default.title',
|
||||
defaultMessage: '{type} is locked',
|
||||
},
|
||||
lockedDescription: {
|
||||
id: 'search.filter.locked.default.description',
|
||||
defaultMessage: 'Unlocking this filter may allow you to install incompatible content.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
145
packages/ui/src/components/settings/ThemeSelector.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup>
|
||||
import { MoonIcon, RadioButtonChecked, RadioButtonIcon, SunIcon } from '@modrinth/assets'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
defineProps({
|
||||
updateColorTheme: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
currentTheme: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
themeOptions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
systemThemeColor: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const colorTheme = defineMessages({
|
||||
title: {
|
||||
id: 'settings.display.theme.title',
|
||||
defaultMessage: 'Color theme',
|
||||
},
|
||||
description: {
|
||||
id: 'settings.display.theme.description',
|
||||
defaultMessage: 'Select your preferred color theme for Modrinth on this device.',
|
||||
},
|
||||
system: {
|
||||
id: 'settings.display.theme.system',
|
||||
defaultMessage: 'Sync with system',
|
||||
},
|
||||
light: {
|
||||
id: 'settings.display.theme.light',
|
||||
defaultMessage: 'Light',
|
||||
},
|
||||
dark: {
|
||||
id: 'settings.display.theme.dark',
|
||||
defaultMessage: 'Dark',
|
||||
},
|
||||
oled: {
|
||||
id: 'settings.display.theme.oled',
|
||||
defaultMessage: 'OLED',
|
||||
},
|
||||
retro: {
|
||||
id: 'settings.display.theme.retro',
|
||||
defaultMessage: 'Retro',
|
||||
},
|
||||
preferredLight: {
|
||||
id: 'settings.display.theme.preferred-light-theme',
|
||||
defaultMessage: 'Preferred light theme',
|
||||
},
|
||||
preferredDark: {
|
||||
id: 'settings.display.theme.preferred-dark-theme',
|
||||
defaultMessage: 'Preferred dark theme',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-options mt-4">
|
||||
<button
|
||||
v-for="option in themeOptions"
|
||||
:key="option"
|
||||
class="preview-radio button-base"
|
||||
:class="{ selected: currentTheme === option }"
|
||||
@click="() => updateColorTheme(option)"
|
||||
>
|
||||
<div class="preview" :class="`${option === 'system' ? systemThemeColor : option}-mode`">
|
||||
<div class="example-card card card">
|
||||
<div class="example-icon"></div>
|
||||
<div class="example-text-1"></div>
|
||||
<div class="example-text-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonChecked v-if="currentTheme === option" class="radio" />
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
{{ colorTheme[option] ? formatMessage(colorTheme[option]) : option }}
|
||||
<SunIcon
|
||||
v-if="'light' === option"
|
||||
v-tooltip="formatMessage(colorTheme.preferredLight)"
|
||||
class="theme-icon"
|
||||
/>
|
||||
<MoonIcon
|
||||
v-else-if="'dark' === option"
|
||||
v-tooltip="formatMessage(colorTheme.preferredDark)"
|
||||
class="theme-icon"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.theme-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(10rem, 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>
|
||||