feat: add discord rich presence editor window and interactive embed preview
This commit is contained in:
1 parent
e3abea8d83
commit
386c829455
30 files changed
+4102
-263
No files matched your search
@@ -4,7 +4,8 @@
|
|||||||
"description": "Capabilities for the main launcher and theme editor windows",
|
"description": "Capabilities for the main launcher and theme editor windows",
|
||||||
"windows": [
|
"windows": [
|
||||||
"main",
|
"main",
|
||||||
"theme-editor"
|
"theme-editor",
|
||||||
|
"presence-editor"
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
|||||||
@@ -1087,6 +1087,35 @@ pub struct PresenceButton {
|
|||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PresenceActivityPayload {
|
||||||
|
pub state: Option<String>,
|
||||||
|
pub details: Option<String>,
|
||||||
|
pub large_image_key: Option<String>,
|
||||||
|
pub large_image_text: Option<String>,
|
||||||
|
pub small_image_key: Option<Option<String>>,
|
||||||
|
pub small_image_text: Option<Option<String>>,
|
||||||
|
pub button1: Option<Option<presence::PresenceButton>>,
|
||||||
|
pub button2: Option<Option<presence::PresenceButton>>,
|
||||||
|
pub show_timestamp: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn presence_update_activity(activity: PresenceActivityPayload) {
|
||||||
|
presence::update_activity(
|
||||||
|
activity.state.as_deref(),
|
||||||
|
activity.details.as_deref(),
|
||||||
|
activity.large_image_key.as_deref(),
|
||||||
|
activity.large_image_text.as_deref(),
|
||||||
|
activity.small_image_key,
|
||||||
|
activity.small_image_text,
|
||||||
|
activity.button1,
|
||||||
|
activity.button2,
|
||||||
|
activity.show_timestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn presence_update_button(button: PresenceButton) {
|
pub async fn presence_update_button(button: PresenceButton) {
|
||||||
match (button.text.as_deref(), button.url.as_deref()) {
|
match (button.text.as_deref(), button.url.as_deref()) {
|
||||||
@@ -2168,6 +2197,9 @@ pub async fn open_theme_editor(app: AppHandle, theme_folder: Option<String>) ->
|
|||||||
let _ = window.unminimize();
|
let _ = window.unminimize();
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
|
if let Some(main) = app.get_webview_window("main") {
|
||||||
|
let _ = main.hide();
|
||||||
|
}
|
||||||
app.emit_to("theme-editor", "theme-editor-context", ())
|
app.emit_to("theme-editor", "theme-editor-context", ())
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
let _ = app.emit_to("main", "theme-editor-state", true);
|
let _ = app.emit_to("main", "theme-editor-state", true);
|
||||||
@@ -2194,14 +2226,125 @@ pub async fn open_theme_editor(app: AppHandle, theme_folder: Option<String>) ->
|
|||||||
let main_window = app.clone();
|
let main_window = app.clone();
|
||||||
window.on_window_event(move |event| {
|
window.on_window_event(move |event| {
|
||||||
if matches!(event, WindowEvent::Destroyed) {
|
if matches!(event, WindowEvent::Destroyed) {
|
||||||
|
if let Some(main) = main_window.get_webview_window("main") {
|
||||||
|
let _ = main.show();
|
||||||
|
let _ = main.unminimize();
|
||||||
|
let _ = main.set_focus();
|
||||||
|
}
|
||||||
let _ = main_window.emit_to("main", "theme-editor-state", false);
|
let _ = main_window.emit_to("main", "theme-editor-state", false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if let Some(main) = app.get_webview_window("main") {
|
||||||
|
let _ = main.hide();
|
||||||
|
}
|
||||||
let _ = app.emit_to("main", "theme-editor-state", true);
|
let _ = app.emit_to("main", "theme-editor-state", true);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Focus the theme editor window and keep the main launcher window hidden.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn focus_theme_editor(app: AppHandle) -> Result<(), String> {
|
||||||
|
if let Some(window) = app.get_webview_window("theme-editor") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
if let Some(main) = app.get_webview_window("main") {
|
||||||
|
let _ = main.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the theme editor window and restore the main launcher window.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn close_theme_editor(app: AppHandle) -> Result<(), String> {
|
||||||
|
if let Some(window) = app.get_webview_window("theme-editor") {
|
||||||
|
let _ = window.close();
|
||||||
|
}
|
||||||
|
if let Some(main) = app.get_webview_window("main") {
|
||||||
|
let _ = main.show();
|
||||||
|
let _ = main.unminimize();
|
||||||
|
let _ = main.set_focus();
|
||||||
|
}
|
||||||
|
let _ = app.emit_to("main", "theme-editor-state", false);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open (or focus) the discord presence embed editor in its own native window.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_presence_editor(app: AppHandle) -> Result<(), String> {
|
||||||
|
if let Some(window) = app.get_webview_window("presence-editor") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
if let Some(main) = app.get_webview_window("main") {
|
||||||
|
let _ = main.hide();
|
||||||
|
}
|
||||||
|
let _ = app.emit_to("main", "presence-editor-state", true);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let window =
|
||||||
|
WebviewWindowBuilder::new(&app, "presence-editor", WebviewUrl::App("index.html".into()))
|
||||||
|
.title("EZPPLauncher — Discord Rich Presence Editor")
|
||||||
|
.inner_size(1200.0, 800.0)
|
||||||
|
.min_inner_size(980.0, 650.0)
|
||||||
|
.resizable(true)
|
||||||
|
.center()
|
||||||
|
.build()
|
||||||
|
.map_err(|error| format!("Could not open the presence editor: {error}"))?;
|
||||||
|
|
||||||
|
let main_window = app.clone();
|
||||||
|
window.on_window_event(move |event| {
|
||||||
|
if matches!(event, WindowEvent::Destroyed) {
|
||||||
|
if let Some(main) = main_window.get_webview_window("main") {
|
||||||
|
let _ = main.show();
|
||||||
|
let _ = main.unminimize();
|
||||||
|
let _ = main.set_focus();
|
||||||
|
}
|
||||||
|
let _ = main_window.emit_to("main", "presence-editor-state", false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(main) = app.get_webview_window("main") {
|
||||||
|
let _ = main.hide();
|
||||||
|
}
|
||||||
|
let _ = app.emit_to("main", "presence-editor-state", true);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Focus the presence editor window and keep the main launcher window hidden.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn focus_presence_editor(app: AppHandle) -> Result<(), String> {
|
||||||
|
if let Some(window) = app.get_webview_window("presence-editor") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
if let Some(main) = app.get_webview_window("main") {
|
||||||
|
let _ = main.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the presence editor window and restore the main launcher window.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn close_presence_editor(app: AppHandle) -> Result<(), String> {
|
||||||
|
if let Some(window) = app.get_webview_window("presence-editor") {
|
||||||
|
let _ = window.close();
|
||||||
|
}
|
||||||
|
if let Some(main) = app.get_webview_window("main") {
|
||||||
|
let _ = main.show();
|
||||||
|
let _ = main.unminimize();
|
||||||
|
let _ = main.set_focus();
|
||||||
|
}
|
||||||
|
let _ = app.emit_to("main", "presence-editor-state", false);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Theme data requested by the advanced editor window when it starts or switches themes.
|
/// Theme data requested by the advanced editor window when it starts or switches themes.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn theme_editor_context(app: AppHandle) -> Result<ThemeEditorContext, String> {
|
pub fn theme_editor_context(app: AppHandle) -> Result<ThemeEditorContext, String> {
|
||||||
|
|||||||
+11
-5
@@ -18,18 +18,18 @@ pub mod theme_server;
|
|||||||
pub mod utils;
|
pub mod utils;
|
||||||
use crate::{
|
use crate::{
|
||||||
commands::{
|
commands::{
|
||||||
backup_theme_folder, check_for_corruption, config_all, config_clear, config_delete,
|
backup_theme_folder, check_for_corruption, close_presence_editor, close_theme_editor, config_all, config_clear, config_delete,
|
||||||
config_exists, config_get, config_set, download_and_extract_theme,
|
config_exists, config_get, config_set, download_and_extract_theme,
|
||||||
download_ezpp_launcher_update, download_ezpp_launcher_update_files, encrypt_string, exit,
|
download_ezpp_launcher_update, download_ezpp_launcher_update_files, encrypt_string, exit,
|
||||||
export_theme, extract_theme, file_size, find_osu_installation, get_beatmapsets_count,
|
export_theme, extract_theme, file_size, find_osu_installation, focus_presence_editor, focus_theme_editor, get_beatmapsets_count,
|
||||||
get_ezpp_launcher_update_files, get_hwid, get_launcher_version, get_osu_release_stream,
|
get_ezpp_launcher_update_files, get_hwid, get_launcher_version, get_osu_release_stream,
|
||||||
get_osu_skin, get_osu_version, get_platform, get_skins_count, has_net8, has_osuwinello,
|
get_osu_skin, get_osu_version, get_platform, get_skins_count, has_net8, has_osuwinello,
|
||||||
has_wmctrl, install_ezpp_launcher_update, installation_diagnostics,
|
has_wmctrl, install_ezpp_launcher_update, installation_diagnostics,
|
||||||
is_open_tablet_driver_path_valid, is_open_tablet_driver_running, is_osu_running,
|
is_open_tablet_driver_path_valid, is_open_tablet_driver_running, is_osu_running,
|
||||||
list_theme_backups, log_frontend, open_logs_folder, open_theme_editor, open_theme_folder,
|
list_theme_backups, log_frontend, open_logs_folder, open_presence_editor, open_theme_editor, open_theme_folder,
|
||||||
open_url_in_browser, opened_urls, optimize_theme_media, pick_osu_installation,
|
open_url_in_browser, opened_urls, optimize_theme_media, pick_osu_installation,
|
||||||
presence_connect, presence_disconnect, presence_is_connected, presence_update_button,
|
presence_connect, presence_disconnect, presence_is_connected, presence_update_activity,
|
||||||
presence_update_status, presence_update_user, read_theme_info, replace_ui_files,
|
presence_update_button, presence_update_status, presence_update_user, read_theme_info, replace_ui_files,
|
||||||
resolve_osu_folder, restore_theme_backup, run_open_tablet_driver, run_osu, run_osu_updater,
|
resolve_osu_folder, restore_theme_backup, run_open_tablet_driver, run_osu, run_osu_updater,
|
||||||
save_theme_project, set_logging_enabled, set_osu_config_values, set_osu_user_config_values,
|
save_theme_project, set_logging_enabled, set_osu_config_values, set_osu_user_config_values,
|
||||||
stop_open_tablet_driver, theme_asset_base, theme_editor_context, theme_preview_url,
|
stop_open_tablet_driver, theme_asset_base, theme_editor_context, theme_preview_url,
|
||||||
@@ -172,6 +172,7 @@ pub fn run() {
|
|||||||
presence_update_status,
|
presence_update_status,
|
||||||
presence_update_user,
|
presence_update_user,
|
||||||
presence_update_button,
|
presence_update_button,
|
||||||
|
presence_update_activity,
|
||||||
presence_is_connected,
|
presence_is_connected,
|
||||||
has_osuwinello,
|
has_osuwinello,
|
||||||
has_wmctrl,
|
has_wmctrl,
|
||||||
@@ -189,6 +190,11 @@ pub fn run() {
|
|||||||
export_theme,
|
export_theme,
|
||||||
open_theme_folder,
|
open_theme_folder,
|
||||||
open_theme_editor,
|
open_theme_editor,
|
||||||
|
focus_theme_editor,
|
||||||
|
close_theme_editor,
|
||||||
|
open_presence_editor,
|
||||||
|
focus_presence_editor,
|
||||||
|
close_presence_editor,
|
||||||
theme_editor_context,
|
theme_editor_context,
|
||||||
theme_asset_base,
|
theme_asset_base,
|
||||||
theme_preview_url,
|
theme_preview_url,
|
||||||
|
|||||||
+76
-11
@@ -6,13 +6,15 @@ use tauri::{AppHandle, RunEvent};
|
|||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot};
|
||||||
use tokio::time::{Duration, interval};
|
use tokio::time::{Duration, interval};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PresenceButton {
|
pub struct PresenceButton {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PresenceData {
|
pub struct PresenceData {
|
||||||
pub state: String,
|
pub state: String,
|
||||||
pub details: String,
|
pub details: String,
|
||||||
@@ -21,6 +23,8 @@ pub struct PresenceData {
|
|||||||
pub small_image_key: Option<String>,
|
pub small_image_key: Option<String>,
|
||||||
pub small_image_text: Option<String>,
|
pub small_image_text: Option<String>,
|
||||||
pub dynamic_button: Option<PresenceButton>,
|
pub dynamic_button: Option<PresenceButton>,
|
||||||
|
pub secondary_button: Option<PresenceButton>,
|
||||||
|
pub show_timestamp: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PresenceData {
|
impl Default for PresenceData {
|
||||||
@@ -32,7 +36,15 @@ impl Default for PresenceData {
|
|||||||
large_image_text: "EZPPFarm".to_string(),
|
large_image_text: "EZPPFarm".to_string(),
|
||||||
small_image_key: None,
|
small_image_key: None,
|
||||||
small_image_text: None,
|
small_image_text: None,
|
||||||
dynamic_button: None,
|
dynamic_button: Some(PresenceButton {
|
||||||
|
label: "Download the Launcher".to_string(),
|
||||||
|
url: "https://git.ez-pp.farm/EZPPFarm/EZPPLauncher/releases/latest".to_string(),
|
||||||
|
}),
|
||||||
|
secondary_button: Some(PresenceButton {
|
||||||
|
label: "Join EZPPFarm".to_string(),
|
||||||
|
url: "https://ez-pp.farm/discord".to_string(),
|
||||||
|
}),
|
||||||
|
show_timestamp: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,10 +174,13 @@ impl PresenceActor {
|
|||||||
let mut builder = ActivityBuilder::new()
|
let mut builder = ActivityBuilder::new()
|
||||||
.state(&self.data.state)
|
.state(&self.data.state)
|
||||||
.details(&self.data.details)
|
.details(&self.data.details)
|
||||||
.start_timestamp(self.start_timestamp as u64)
|
|
||||||
.large_image(&self.data.large_image_key)
|
.large_image(&self.data.large_image_key)
|
||||||
.large_text(&self.data.large_image_text);
|
.large_text(&self.data.large_image_text);
|
||||||
|
|
||||||
|
if self.data.show_timestamp {
|
||||||
|
builder = builder.start_timestamp(self.start_timestamp as u64);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(key) = &self.data.small_image_key {
|
if let Some(key) = &self.data.small_image_key {
|
||||||
builder = builder.small_image(key);
|
builder = builder.small_image(key);
|
||||||
}
|
}
|
||||||
@@ -174,14 +189,15 @@ impl PresenceActor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(btn) = &self.data.dynamic_button {
|
if let Some(btn) = &self.data.dynamic_button {
|
||||||
builder = builder.button(&btn.label, &btn.url);
|
if !btn.label.trim().is_empty() && !btn.url.trim().is_empty() {
|
||||||
} else {
|
builder = builder.button(&btn.label, &btn.url);
|
||||||
builder = builder.button(
|
}
|
||||||
"Download the Launcher",
|
}
|
||||||
"https://git.ez-pp.farm/EZPPFarm/EZPPLauncher/releases/latest",
|
if let Some(btn2) = &self.data.secondary_button {
|
||||||
);
|
if !btn2.label.trim().is_empty() && !btn2.url.trim().is_empty() {
|
||||||
|
builder = builder.button(&btn2.label, &btn2.url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
builder = builder.button("Join EZPPFarm", "https://ez-pp.farm/discord");
|
|
||||||
|
|
||||||
let activity = builder.build();
|
let activity = builder.build();
|
||||||
|
|
||||||
@@ -286,6 +302,55 @@ pub async fn has_presence() -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_activity(
|
||||||
|
state: Option<&str>,
|
||||||
|
details: Option<&str>,
|
||||||
|
large_image_key: Option<&str>,
|
||||||
|
large_image_text: Option<&str>,
|
||||||
|
small_image_key: Option<Option<String>>,
|
||||||
|
small_image_text: Option<Option<String>>,
|
||||||
|
button1: Option<Option<PresenceButton>>,
|
||||||
|
button2: Option<Option<PresenceButton>>,
|
||||||
|
show_timestamp: Option<bool>,
|
||||||
|
) {
|
||||||
|
let data = {
|
||||||
|
let mut guard = PRESENCE_DATA.lock().unwrap();
|
||||||
|
if let Some(s) = state {
|
||||||
|
guard.state = s.to_string();
|
||||||
|
}
|
||||||
|
if let Some(d) = details {
|
||||||
|
guard.details = d.to_string();
|
||||||
|
}
|
||||||
|
if let Some(img) = large_image_key {
|
||||||
|
guard.large_image_key = img.to_string();
|
||||||
|
}
|
||||||
|
if let Some(txt) = large_image_text {
|
||||||
|
guard.large_image_text = txt.to_string();
|
||||||
|
}
|
||||||
|
if let Some(key) = small_image_key {
|
||||||
|
guard.small_image_key = key;
|
||||||
|
}
|
||||||
|
if let Some(text) = small_image_text {
|
||||||
|
guard.small_image_text = text;
|
||||||
|
}
|
||||||
|
if let Some(b1) = button1 {
|
||||||
|
guard.dynamic_button = b1;
|
||||||
|
}
|
||||||
|
if let Some(b2) = button2 {
|
||||||
|
guard.secondary_button = b2;
|
||||||
|
}
|
||||||
|
if let Some(ts) = show_timestamp {
|
||||||
|
guard.show_timestamp = ts;
|
||||||
|
}
|
||||||
|
guard.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let tx = PRESENCE_TX.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tx.send(PresenceCommand::UpdateData(data)).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update_status(state: Option<&str>, details: Option<&str>, large_image_key: Option<&str>) {
|
pub fn update_status(state: Option<&str>, details: Option<&str>, large_image_key: Option<&str>) {
|
||||||
let data = {
|
let data = {
|
||||||
let mut guard = PRESENCE_DATA.lock().unwrap();
|
let mut guard = PRESENCE_DATA.lock().unwrap();
|
||||||
|
|||||||
+4
-4
@@ -25,8 +25,8 @@
|
|||||||
--secondary-foreground: oklch(40.06% 0.038 266.38);
|
--secondary-foreground: oklch(40.06% 0.038 266.38);
|
||||||
--muted: oklch(89.49% 0.023 256.59);
|
--muted: oklch(89.49% 0.023 256.59);
|
||||||
--muted-foreground: oklch(51.01% 0.028 265.36);
|
--muted-foreground: oklch(51.01% 0.028 265.36);
|
||||||
--accent: oklch(54.41% 0.186 230.5);
|
--accent: var(--primary);
|
||||||
--accent-foreground: oklch(100% 0 0);
|
--accent-foreground: var(--primary-foreground);
|
||||||
--destructive: oklch(51.68% 0.202 15.84);
|
--destructive: oklch(51.68% 0.202 15.84);
|
||||||
--destructive-foreground: oklch(100% 0 0);
|
--destructive-foreground: oklch(100% 0 0);
|
||||||
--border: oklch(78.52% 0.026 260.1);
|
--border: oklch(78.52% 0.026 260.1);
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
--secondary-foreground: oklch(89.24% 0.079 263.24);
|
--secondary-foreground: oklch(89.24% 0.079 263.24);
|
||||||
--muted: oklch(24.41% 0.048 271.21);
|
--muted: oklch(24.41% 0.048 271.21);
|
||||||
--muted-foreground: oklch(73.83% 0.057 265.65);
|
--muted-foreground: oklch(73.83% 0.057 265.65);
|
||||||
--accent: oklch(86.2% 0.131 300.42);
|
--accent: var(--primary);
|
||||||
--accent-foreground: oklch(20.22% 0.048 282.86);
|
--accent-foreground: var(--primary-foreground);
|
||||||
--destructive: oklch(77.23% 0.146 11.66);
|
--destructive: oklch(77.23% 0.146 11.66);
|
||||||
--destructive-foreground: oklch(20.22% 0.048 282.86);
|
--destructive-foreground: oklch(20.22% 0.048 282.86);
|
||||||
--border: oklch(27.7% 0.041 276.87);
|
--border: oklch(27.7% 0.041 276.87);
|
||||||
|
|||||||
@@ -345,7 +345,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="border-theme-800 bg-theme-950"
|
class="border-theme-800 bg-theme-950 hover:border-primary hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||||
onclick={copyReport}
|
onclick={copyReport}
|
||||||
>
|
>
|
||||||
<ClipboardCopy class="size-4" />
|
<ClipboardCopy class="size-4" />
|
||||||
@@ -354,7 +354,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="border-theme-800 bg-theme-950"
|
class="border-theme-800 bg-theme-950 hover:border-primary hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||||
onclick={runChecks}
|
onclick={runChecks}
|
||||||
disabled={checking}
|
disabled={checking}
|
||||||
>
|
>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -19,6 +19,8 @@
|
|||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import Slider from '$lib/components/ui/slider/slider.svelte';
|
import Slider from '$lib/components/ui/slider/slider.svelte';
|
||||||
import Textarea from '$lib/components/ui/textarea/textarea.svelte';
|
import Textarea from '$lib/components/ui/textarea/textarea.svelte';
|
||||||
|
import { config } from '$lib/config';
|
||||||
|
import { currentUserInfo } from '$lib/data';
|
||||||
import {
|
import {
|
||||||
defaultFontsourceOptions,
|
defaultFontsourceOptions,
|
||||||
downloadFontsourceFont,
|
downloadFontsourceFont,
|
||||||
@@ -28,6 +30,15 @@
|
|||||||
themeFontKey,
|
themeFontKey,
|
||||||
type FontsourceFont,
|
type FontsourceFont,
|
||||||
} from '$lib/fontsource';
|
} from '$lib/fontsource';
|
||||||
|
import {
|
||||||
|
launcherVersion,
|
||||||
|
onlineFriends,
|
||||||
|
osuBuild,
|
||||||
|
osuStream,
|
||||||
|
serverPing,
|
||||||
|
setupValues,
|
||||||
|
} from '$lib/global';
|
||||||
|
import { getLauncherVersion, getReleaseStream, getVersion } from '$lib/osuUtil';
|
||||||
import {
|
import {
|
||||||
THEME_CLOCK_VARIABLE_NAMES,
|
THEME_CLOCK_VARIABLE_NAMES,
|
||||||
isThemeClockVariable,
|
isThemeClockVariable,
|
||||||
@@ -80,6 +91,9 @@
|
|||||||
themesDirectory,
|
themesDirectory,
|
||||||
toSafeName,
|
toSafeName,
|
||||||
} from '$lib/themes';
|
} from '$lib/themes';
|
||||||
|
import type { EZPPUser, EZPPUserInfo } from '$lib/types';
|
||||||
|
import { currentUser } from '$lib/userAuthentication';
|
||||||
|
import { osuInstallationPath } from '$lib/userSettings';
|
||||||
import { formatBytes } from '$lib/utils';
|
import { formatBytes } from '$lib/utils';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -1766,12 +1780,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ThemeEditorGlobalSyncPayload = {
|
||||||
|
user?: EZPPUser | null;
|
||||||
|
info?: EZPPUserInfo | null;
|
||||||
|
friends?: number;
|
||||||
|
osuBuild?: string;
|
||||||
|
osuStream?: string;
|
||||||
|
serverPing?: number;
|
||||||
|
launcherVersion?: string;
|
||||||
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const observer = new ResizeObserver(updateCanvasScale);
|
const observer = new ResizeObserver(updateCanvasScale);
|
||||||
if (canvasHost) observer.observe(canvasHost);
|
if (canvasHost) observer.observe(canvasHost);
|
||||||
let unlisten: UnlistenFn | undefined;
|
let unlisten: UnlistenFn | undefined;
|
||||||
let unlistenClose: UnlistenFn | undefined;
|
let unlistenClose: UnlistenFn | undefined;
|
||||||
|
let unlistenGlobalSync: UnlistenFn | undefined;
|
||||||
listen('theme-editor-context', () => loadContext()).then((stop) => (unlisten = stop));
|
listen('theme-editor-context', () => loadContext()).then((stop) => (unlisten = stop));
|
||||||
|
listen<ThemeEditorGlobalSyncPayload>('sync-theme-editor-global-data', (event) => {
|
||||||
|
const data = event.payload;
|
||||||
|
if (!data) return;
|
||||||
|
if (data.user !== undefined) currentUser.set(data.user ?? undefined);
|
||||||
|
if (data.info !== undefined) currentUserInfo.set(data.info ?? undefined);
|
||||||
|
if (data.friends !== undefined) onlineFriends.set(data.friends);
|
||||||
|
if (data.osuBuild) osuBuild.set(data.osuBuild);
|
||||||
|
if (data.osuStream) osuStream.set(data.osuStream);
|
||||||
|
if (data.serverPing !== undefined) serverPing.set(data.serverPing);
|
||||||
|
if (data.launcherVersion) launcherVersion.set(data.launcherVersion);
|
||||||
|
}).then((stop) => (unlistenGlobalSync = stop));
|
||||||
|
|
||||||
|
emit('request-theme-editor-global-data').catch(() => {});
|
||||||
|
|
||||||
getCurrentWindow()
|
getCurrentWindow()
|
||||||
.onCloseRequested((event) => {
|
.onCloseRequested((event) => {
|
||||||
if (allowClose || !dirty) return;
|
if (allowClose || !dirty) return;
|
||||||
@@ -1780,11 +1819,37 @@
|
|||||||
})
|
})
|
||||||
.then((stop) => (unlistenClose = stop));
|
.then((stop) => (unlistenClose = stop));
|
||||||
loadContext();
|
loadContext();
|
||||||
|
getLauncherVersion()
|
||||||
|
.then((ver) => {
|
||||||
|
if (ver) launcherVersion.set(ver);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
const teardownValues = setupValues();
|
||||||
|
|
||||||
|
config
|
||||||
|
.value<string>('osu_installation_path')
|
||||||
|
.get('')
|
||||||
|
.then(async (path) => {
|
||||||
|
if (!path) return;
|
||||||
|
osuInstallationPath.set(path);
|
||||||
|
try {
|
||||||
|
const [stream, version] = await Promise.all([
|
||||||
|
getReleaseStream(path).catch(() => undefined),
|
||||||
|
getVersion(path).catch(() => undefined),
|
||||||
|
]);
|
||||||
|
if (stream) osuStream.set(stream);
|
||||||
|
if (version) osuBuild.set(version);
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
unlisten?.();
|
unlisten?.();
|
||||||
unlistenClose?.();
|
unlistenClose?.();
|
||||||
|
unlistenGlobalSync?.();
|
||||||
|
teardownValues?.();
|
||||||
stopHistoryTimer();
|
stopHistoryTimer();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -2928,7 +2993,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
allowClose = true;
|
allowClose = true;
|
||||||
await getCurrentWindow().close();
|
await invoke('close_theme_editor');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAndClose() {
|
async function saveAndClose() {
|
||||||
@@ -2937,14 +3002,14 @@
|
|||||||
clearDraft();
|
clearDraft();
|
||||||
allowClose = true;
|
allowClose = true;
|
||||||
closeDialogOpen = false;
|
closeDialogOpen = false;
|
||||||
await getCurrentWindow().close();
|
await invoke('close_theme_editor');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function discardAndClose() {
|
async function discardAndClose() {
|
||||||
clearDraft();
|
clearDraft();
|
||||||
allowClose = true;
|
allowClose = true;
|
||||||
closeDialogOpen = false;
|
closeDialogOpen = false;
|
||||||
await getCurrentWindow().close();
|
await invoke('close_theme_editor');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -5379,6 +5444,10 @@
|
|||||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||||
<ThemeElementInspector
|
<ThemeElementInspector
|
||||||
bind:element={definition.elements[selectedIndex]}
|
bind:element={definition.elements[selectedIndex]}
|
||||||
|
iconSet={definition.iconSet}
|
||||||
|
themeName={definition.name}
|
||||||
|
themeAuthor={definition.author}
|
||||||
|
themeVersion={definition.version}
|
||||||
{fontOptions}
|
{fontOptions}
|
||||||
{elements}
|
{elements}
|
||||||
{behaviors}
|
{behaviors}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import ThemeComponentEditor from '$lib/components/theme/ThemeComponentEditor.svelte';
|
import ThemeComponentEditor from '$lib/components/theme/ThemeComponentEditor.svelte';
|
||||||
import ThemeCssClasses from '$lib/components/theme/ThemeCssClasses.svelte';
|
import ThemeCssClasses from '$lib/components/theme/ThemeCssClasses.svelte';
|
||||||
import ThemeFilterControls from '$lib/components/theme/ThemeFilterControls.svelte';
|
import ThemeFilterControls from '$lib/components/theme/ThemeFilterControls.svelte';
|
||||||
|
import ThemeIconPicker from '$lib/components/theme/ThemeIconPicker.svelte';
|
||||||
import ThemeLinkedLogic from '$lib/components/theme/ThemeLinkedLogic.svelte';
|
import ThemeLinkedLogic from '$lib/components/theme/ThemeLinkedLogic.svelte';
|
||||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||||
import ThemeTextStyleControls from '$lib/components/theme/ThemeTextStyleControls.svelte';
|
import ThemeTextStyleControls from '$lib/components/theme/ThemeTextStyleControls.svelte';
|
||||||
@@ -14,11 +15,13 @@
|
|||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import Slider from '$lib/components/ui/slider/slider.svelte';
|
import Slider from '$lib/components/ui/slider/slider.svelte';
|
||||||
import Textarea from '$lib/components/ui/textarea/textarea.svelte';
|
import Textarea from '$lib/components/ui/textarea/textarea.svelte';
|
||||||
|
import { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from '$lib/themeData';
|
||||||
import type { ThemeFontChoice } from '$lib/themeFonts';
|
import type { ThemeFontChoice } from '$lib/themeFonts';
|
||||||
import { THEME_ICON_NAMES } from '$lib/themeIcons';
|
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
|
||||||
import {
|
import {
|
||||||
THEME_DATA_FIELDS,
|
THEME_DATA_FIELDS,
|
||||||
THEME_PARTICLE_PRESETS,
|
THEME_PARTICLE_PRESETS,
|
||||||
|
themeGroupAncestors,
|
||||||
type ThemeV2Behavior,
|
type ThemeV2Behavior,
|
||||||
type ThemeV2BehaviorTarget,
|
type ThemeV2BehaviorTarget,
|
||||||
type ThemeV2Element,
|
type ThemeV2Element,
|
||||||
@@ -48,6 +51,10 @@
|
|||||||
elements,
|
elements,
|
||||||
behaviors,
|
behaviors,
|
||||||
groups,
|
groups,
|
||||||
|
iconSet = DEFAULT_THEME_ICON_SET,
|
||||||
|
themeName = 'My Theme',
|
||||||
|
themeAuthor = '',
|
||||||
|
themeVersion = '1.0.0',
|
||||||
variables = [],
|
variables = [],
|
||||||
focusToken = 0,
|
focusToken = 0,
|
||||||
onreplace,
|
onreplace,
|
||||||
@@ -61,6 +68,10 @@
|
|||||||
elements: ThemeV2Element[];
|
elements: ThemeV2Element[];
|
||||||
behaviors: ThemeV2Behavior[];
|
behaviors: ThemeV2Behavior[];
|
||||||
groups: ThemeV2Group[];
|
groups: ThemeV2Group[];
|
||||||
|
iconSet?: ThemeIconSet;
|
||||||
|
themeName?: string;
|
||||||
|
themeAuthor?: string;
|
||||||
|
themeVersion?: string;
|
||||||
variables?: string[];
|
variables?: string[];
|
||||||
focusToken?: number;
|
focusToken?: number;
|
||||||
onreplace: (kind: 'image' | 'video' | 'lottie') => void;
|
onreplace: (kind: 'image' | 'video' | 'lottie') => void;
|
||||||
@@ -92,6 +103,70 @@
|
|||||||
node.setSelectionRange(caret, caret);
|
node.setSelectionRange(caret, caret);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentFieldLabel = $derived(
|
||||||
|
element.type === 'data'
|
||||||
|
? (THEME_DATA_FIELDS.find((f) => f.value === element.field)?.label ?? element.field)
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentRawValue = $derived(
|
||||||
|
element.type === 'data'
|
||||||
|
? resolveThemeDataValue(element.field, {
|
||||||
|
global: $themeGlobalData,
|
||||||
|
theme: {
|
||||||
|
name: themeName || 'Theme',
|
||||||
|
author: themeAuthor || '',
|
||||||
|
version: themeVersion || '1.0.0',
|
||||||
|
},
|
||||||
|
screen: 'home',
|
||||||
|
mode: element.mode,
|
||||||
|
})
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentSampleValue = $derived(
|
||||||
|
element.type === 'data' ? getThemeDataSampleValue(element.field, element.mode) : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentEffectiveValue = $derived(
|
||||||
|
element.type === 'data'
|
||||||
|
? currentRawValue && currentRawValue.trim() !== ''
|
||||||
|
? currentRawValue
|
||||||
|
: (element.defaultValue ?? '')
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const previewDisplayValue = $derived(
|
||||||
|
element.type === 'data'
|
||||||
|
? currentEffectiveValue && currentEffectiveValue.trim() !== ''
|
||||||
|
? currentEffectiveValue
|
||||||
|
: currentSampleValue
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const previewResult = $derived.by(() => {
|
||||||
|
if (element.type !== 'data') return '';
|
||||||
|
const template = element.text?.trim() ? element.text : '{value}';
|
||||||
|
const fallback = element.defaultValue ?? '';
|
||||||
|
return template
|
||||||
|
.replaceAll('{value}', previewDisplayValue)
|
||||||
|
.replaceAll('{defaultValue}', fallback)
|
||||||
|
.replaceAll('{fallback}', fallback);
|
||||||
|
});
|
||||||
|
|
||||||
|
const parentGroupAncestors = $derived(
|
||||||
|
element.groupId && groups ? themeGroupAncestors(groups, element) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
const parentGroupMorphs = $derived(parentGroupAncestors.some((group) => group.morph === true));
|
||||||
|
|
||||||
|
const parentGroupMorphDuration = $derived.by(() => {
|
||||||
|
for (const group of parentGroupAncestors) {
|
||||||
|
if (group.morphDuration && group.morphDuration > 0) return group.morphDuration;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-3 p-3">
|
<div class="space-y-3 p-3">
|
||||||
@@ -237,6 +312,63 @@
|
|||||||
Interactive
|
Interactive
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1.5"
|
||||||
|
title="Smoothly morph text and number changes using Torph."
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="element-morph"
|
||||||
|
checked={element.morph === true || (element.morph !== false && parentGroupMorphs)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (parentGroupMorphs) {
|
||||||
|
element.morph = checked === true ? true : false;
|
||||||
|
} else {
|
||||||
|
element.morph = checked === true;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label for="element-morph" class="text-[10px] font-normal text-muted-foreground">
|
||||||
|
Morph text (Torph)
|
||||||
|
</Label>
|
||||||
|
{#if parentGroupMorphs && element.morph !== false}
|
||||||
|
<span class="text-[9px] text-muted-foreground" title="Inherited from parent group"
|
||||||
|
>(group)</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if parentGroupMorphs && element.morph !== undefined}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-[9px] text-primary hover:underline ml-0.5"
|
||||||
|
onclick={() => delete element.morph}
|
||||||
|
title="Reset to inherit from group"
|
||||||
|
>
|
||||||
|
reset
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if element.morph === true || (element.morph !== false && parentGroupMorphs)}
|
||||||
|
<div class="w-full space-y-1 pt-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label for="element-morph-duration" class={label}>Morph duration</Label>
|
||||||
|
<span class="text-[10px] text-muted-foreground">
|
||||||
|
{element.morphDuration ?? (parentGroupMorphDuration || 400)}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="element-morph-duration"
|
||||||
|
class={field}
|
||||||
|
type="number"
|
||||||
|
min="50"
|
||||||
|
max="3000"
|
||||||
|
step="50"
|
||||||
|
value={element.morphDuration ?? (parentGroupMorphDuration || 400)}
|
||||||
|
oninput={(event) => {
|
||||||
|
const val = Number((event.currentTarget as HTMLInputElement).value);
|
||||||
|
element.morphDuration = Number.isFinite(val) && val > 0 ? val : 400;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</ThemeSection>
|
</ThemeSection>
|
||||||
|
|
||||||
<ThemeCssClasses
|
<ThemeCssClasses
|
||||||
@@ -259,7 +391,7 @@
|
|||||||
{#if element.type === 'text' || element.type === 'data'}
|
{#if element.type === 'text' || element.type === 'data'}
|
||||||
<ThemeSection title={element.type === 'data' ? 'Data' : 'Text'} contentClass="space-y-3 pt-2.5">
|
<ThemeSection title={element.type === 'data' ? 'Data' : 'Text'} contentClass="space-y-3 pt-2.5">
|
||||||
{#if element.type === 'data'}
|
{#if element.type === 'data'}
|
||||||
<div class="space-y-1">
|
<div class="space-y-1.5">
|
||||||
<Label for="element-data-field" class={label}>Value</Label>
|
<Label for="element-data-field" class={label}>Value</Label>
|
||||||
<Select.Root
|
<Select.Root
|
||||||
type="single"
|
type="single"
|
||||||
@@ -272,8 +404,7 @@
|
|||||||
id="element-data-field"
|
id="element-data-field"
|
||||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||||
>
|
>
|
||||||
{THEME_DATA_FIELDS.find((field) => field.value === element.field)?.label ??
|
{currentFieldLabel}
|
||||||
element.field}
|
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content class="border-theme-800 bg-theme-950">
|
<Select.Content class="border-theme-800 bg-theme-950">
|
||||||
{#each THEME_DATA_FIELDS as field (field.value)}
|
{#each THEME_DATA_FIELDS as field (field.value)}
|
||||||
@@ -281,6 +412,48 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
|
|
||||||
|
<!-- Current Evaluated Value Display -->
|
||||||
|
<div class="rounded-md border border-theme-800/80 bg-theme-950/80 p-2 space-y-1">
|
||||||
|
<div class="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||||
|
<span class="font-medium">Selected Value ({currentFieldLabel})</span>
|
||||||
|
{#if currentRawValue && currentRawValue.trim() !== ''}
|
||||||
|
<span
|
||||||
|
class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-medium text-emerald-400 border border-emerald-500/25"
|
||||||
|
>
|
||||||
|
Live value
|
||||||
|
</span>
|
||||||
|
{:else if element.defaultValue && element.defaultValue.trim() !== ''}
|
||||||
|
<span
|
||||||
|
class="rounded bg-amber-500/15 px-1.5 py-0.5 text-[9px] font-medium text-amber-400 border border-amber-500/25"
|
||||||
|
>
|
||||||
|
Default
|
||||||
|
</span>
|
||||||
|
{:else if currentSampleValue}
|
||||||
|
<span
|
||||||
|
class="rounded bg-sky-500/15 px-1.5 py-0.5 text-[9px] font-medium text-sky-400 border border-sky-500/25"
|
||||||
|
>
|
||||||
|
Sample preview
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="rounded bg-theme-800 px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
Empty
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="truncate font-mono text-xs font-semibold text-theme-50">
|
||||||
|
{previewDisplayValue ? previewDisplayValue : '(empty)'}
|
||||||
|
</span>
|
||||||
|
{#if !currentRawValue && (!element.defaultValue || element.defaultValue.trim() === '') && currentSampleValue}
|
||||||
|
<span class="text-[9px] text-muted-foreground shrink-0"> (sample preview) </span>
|
||||||
|
{:else if !currentRawValue && element.defaultValue}
|
||||||
|
<span class="text-[9px] text-muted-foreground shrink-0"> (using default) </span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if element.field.startsWith('user.') && element.field !== 'user.name' && element.field !== 'user.country'}
|
{#if element.field.startsWith('user.') && element.field !== 'user.name' && element.field !== 'user.country'}
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
@@ -322,6 +495,26 @@
|
|||||||
still work too.
|
still work too.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="element-data-default" class={label}>Default value</Label>
|
||||||
|
<Input
|
||||||
|
id="element-data-default"
|
||||||
|
class={field}
|
||||||
|
bind:value={element.defaultValue}
|
||||||
|
placeholder={element.field === 'user.name' ? 'e.g. Guest' : 'e.g. -'}
|
||||||
|
/>
|
||||||
|
<p class="text-[9px] leading-relaxed text-muted-foreground">
|
||||||
|
Fallback value used when the data is empty or unavailable (such as when logged out).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-theme-800/60 bg-theme-900/40 p-2 space-y-0.5">
|
||||||
|
<div class="text-[9px] uppercase tracking-wide text-muted-foreground font-semibold">
|
||||||
|
Result Preview
|
||||||
|
</div>
|
||||||
|
<div class="truncate font-mono text-xs font-semibold text-primary">
|
||||||
|
{previewResult || '(empty)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="element-text" class={label}>Text</Label>
|
<Label for="element-text" class={label}>Text</Label>
|
||||||
@@ -950,27 +1143,9 @@
|
|||||||
<ThemeSection title="Icon" contentClass="space-y-3 pt-2.5">
|
<ThemeSection title="Icon" contentClass="space-y-3 pt-2.5">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="element-icon" class={label}>Icon</Label>
|
<Label for="element-icon" class={label}>Icon</Label>
|
||||||
<Select.Root
|
<ThemeIconPicker bind:value={element.icon} {iconSet} />
|
||||||
type="single"
|
|
||||||
value={element.icon}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value) element.icon = value as typeof element.icon;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Select.Trigger
|
|
||||||
id="element-icon"
|
|
||||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
|
||||||
>
|
|
||||||
{element.icon}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content class="border-theme-800 bg-theme-950">
|
|
||||||
{#each THEME_ICON_NAMES as name (name)}
|
|
||||||
<Select.Item value={name} label={name} />
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
<p class="text-[9px] leading-relaxed text-muted-foreground">
|
<p class="text-[9px] leading-relaxed text-muted-foreground">
|
||||||
Icons follow the theme's icon set, so the same layer switches families with the theme.
|
Choose from over 17,000 icons. Launcher icons adapt to the theme's icon family.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
@@ -1033,25 +1208,7 @@
|
|||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="element-repeater-icon" class={label}>Icon</Label>
|
<Label for="element-repeater-icon" class={label}>Icon</Label>
|
||||||
<Select.Root
|
<ThemeIconPicker bind:value={element.icon} {iconSet} />
|
||||||
type="single"
|
|
||||||
value={element.icon ?? 'music'}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value) element.icon = value as typeof element.icon;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Select.Trigger
|
|
||||||
id="element-repeater-icon"
|
|
||||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
|
||||||
>
|
|
||||||
{element.icon ?? 'music'}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content class="border-theme-800 bg-theme-950">
|
|
||||||
{#each THEME_ICON_NAMES as name (name)}
|
|
||||||
<Select.Item value={name} label={name} />
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="element-repeater-color" class={label}>Color</Label>
|
<Label for="element-repeater-color" class={label}>Color</Label>
|
||||||
@@ -1439,26 +1596,7 @@
|
|||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="element-button-icon" class={label}>Icon</Label>
|
<Label for="element-button-icon" class={label}>Icon</Label>
|
||||||
<Select.Root
|
<ThemeIconPicker bind:value={element.icon} {iconSet} allowNone />
|
||||||
type="single"
|
|
||||||
value={element.icon ?? 'none'}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
element.icon = value && value !== 'none' ? (value as typeof element.icon) : undefined;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Select.Trigger
|
|
||||||
id="element-button-icon"
|
|
||||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
|
||||||
>
|
|
||||||
{element.icon ?? 'None'}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content class="border-theme-800 bg-theme-950">
|
|
||||||
<Select.Item value="none" label="None" />
|
|
||||||
{#each THEME_ICON_NAMES as name (name)}
|
|
||||||
<Select.Item value={name} label={name} />
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="element-button-radius" class={label}>Radius</Label>
|
<Label for="element-button-radius" class={label}>Radius</Label>
|
||||||
|
|||||||
@@ -114,6 +114,40 @@
|
|||||||
/>
|
/>
|
||||||
<Label for="group-locked" class="text-[10px] font-normal text-muted-foreground">Locked</Label>
|
<Label for="group-locked" class="text-[10px] font-normal text-muted-foreground">Locked</Label>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1.5"
|
||||||
|
title="Smoothly morph text and number changes across layers in this group using Torph."
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="group-morph"
|
||||||
|
checked={group.morph === true}
|
||||||
|
onCheckedChange={(checked) => (group.morph = checked === true)}
|
||||||
|
/>
|
||||||
|
<Label for="group-morph" class="text-[10px] font-normal text-muted-foreground">
|
||||||
|
Morph text (Torph)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{#if group.morph}
|
||||||
|
<div class="w-full space-y-1 pt-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label for="group-morph-duration" class={label}>Morph duration</Label>
|
||||||
|
<span class="text-[10px] text-muted-foreground">{group.morphDuration ?? 400}ms</span>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="group-morph-duration"
|
||||||
|
class={field}
|
||||||
|
type="number"
|
||||||
|
min="50"
|
||||||
|
max="3000"
|
||||||
|
step="50"
|
||||||
|
value={group.morphDuration ?? 400}
|
||||||
|
oninput={(event) => {
|
||||||
|
const val = Number((event.currentTarget as HTMLInputElement).value);
|
||||||
|
group.morphDuration = Number.isFinite(val) && val > 0 ? val : 400;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</ThemeSection>
|
</ThemeSection>
|
||||||
|
|
||||||
<ThemeCssClasses
|
<ThemeCssClasses
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { THEME_ICON_BODIES } from '$lib/themeIconData';
|
import { getThemeIconBody } from '$lib/themeIconCatalog';
|
||||||
import { DEFAULT_THEME_ICON_SET, type ThemeIconName, type ThemeIconSet } from '$lib/themeIcons';
|
import { DEFAULT_THEME_ICON_SET, type ThemeIconName, type ThemeIconSet } from '$lib/themeIcons';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -14,9 +14,7 @@
|
|||||||
class?: string;
|
class?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const icon = $derived(
|
const icon = $derived(getThemeIconBody(name, set));
|
||||||
THEME_ICON_BODIES[set]?.[name] ?? THEME_ICON_BODIES[DEFAULT_THEME_ICON_SET][name]
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Button from '$lib/components/ui/button/button.svelte';
|
||||||
|
import Input from '$lib/components/ui/input/input.svelte';
|
||||||
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
|
import {
|
||||||
|
getFullIconCatalog,
|
||||||
|
type IconPackKey,
|
||||||
|
type ThemeIconCatalogItem,
|
||||||
|
} from '$lib/themeIconCatalog';
|
||||||
|
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
|
||||||
|
import ThemeIcon from './ThemeIcon.svelte';
|
||||||
|
import { Check, ChevronDown, ChevronsUpDown, LayoutGrid, List, Search, X } from '@lucide/svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(undefined),
|
||||||
|
iconSet = DEFAULT_THEME_ICON_SET,
|
||||||
|
placeholder = 'Select icon...',
|
||||||
|
disabled = false,
|
||||||
|
allowNone = false,
|
||||||
|
onchange,
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
iconSet?: ThemeIconSet;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
allowNone?: boolean;
|
||||||
|
onchange?: (val: string | undefined) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let selectedPack = $state<IconPackKey | 'all'>('all');
|
||||||
|
let viewMode = $state<'grid' | 'list'>('grid');
|
||||||
|
let scrollContainer = $state<HTMLDivElement | null>(null);
|
||||||
|
let scrollTop = $state(0);
|
||||||
|
|
||||||
|
const containerHeight = 280;
|
||||||
|
const gridColumns = 5;
|
||||||
|
const gridRowHeight = 54;
|
||||||
|
const listRowHeight = 34;
|
||||||
|
const overscan = 4;
|
||||||
|
|
||||||
|
const rowHeight = $derived(viewMode === 'grid' ? gridRowHeight : listRowHeight);
|
||||||
|
|
||||||
|
const catalog = getFullIconCatalog();
|
||||||
|
|
||||||
|
const packTabs: { key: IconPackKey | 'all'; label: string; count: number }[] = [
|
||||||
|
{ key: 'all', label: 'All', count: catalog.length },
|
||||||
|
{ key: 'launcher', label: 'Launcher', count: 47 },
|
||||||
|
{ key: 'lucide', label: 'Lucide', count: 2078 },
|
||||||
|
{ key: 'phosphor', label: 'Phosphor', count: 9161 },
|
||||||
|
{ key: 'tabler', label: 'Tabler', count: 6305 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredItems = $derived.by(() => {
|
||||||
|
let items = catalog;
|
||||||
|
if (selectedPack !== 'all') {
|
||||||
|
items = items.filter((it) => it.pack === selectedPack);
|
||||||
|
}
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
if (q) {
|
||||||
|
items = items.filter(
|
||||||
|
(it) => it.name.toLowerCase().includes(q) || it.id.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
const numRows = $derived(
|
||||||
|
viewMode === 'grid' ? Math.ceil(filteredItems.length / gridColumns) : filteredItems.length
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalHeight = $derived(numRows * rowHeight);
|
||||||
|
|
||||||
|
const startIndex = $derived(Math.max(0, Math.floor(scrollTop / rowHeight) - overscan));
|
||||||
|
const endIndex = $derived(
|
||||||
|
Math.min(numRows, Math.ceil((scrollTop + containerHeight) / rowHeight) + overscan)
|
||||||
|
);
|
||||||
|
|
||||||
|
type VirtualRow = {
|
||||||
|
index: number;
|
||||||
|
items?: ThemeIconCatalogItem[];
|
||||||
|
item?: ThemeIconCatalogItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleRows = $derived.by(() => {
|
||||||
|
const rows: VirtualRow[] = [];
|
||||||
|
if (viewMode === 'grid') {
|
||||||
|
for (let i = startIndex; i < endIndex; i++) {
|
||||||
|
const rowItems = filteredItems.slice(i * gridColumns, (i + 1) * gridColumns);
|
||||||
|
rows.push({ index: i, items: rowItems });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = startIndex; i < endIndex; i++) {
|
||||||
|
const item = filteredItems[i];
|
||||||
|
if (item) {
|
||||||
|
rows.push({ index: i, item });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// When search, pack, or view mode changes, reset scroll to top
|
||||||
|
void searchQuery;
|
||||||
|
void selectedPack;
|
||||||
|
void viewMode;
|
||||||
|
if (scrollContainer) {
|
||||||
|
scrollContainer.scrollTop = 0;
|
||||||
|
scrollTop = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectIcon(id: string | undefined) {
|
||||||
|
if (id === 'none' || !id) {
|
||||||
|
value = undefined;
|
||||||
|
onchange?.(undefined);
|
||||||
|
} else {
|
||||||
|
value = id;
|
||||||
|
onchange?.(id);
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayName(val: string | undefined): string {
|
||||||
|
if (!val || val === 'none') return 'None';
|
||||||
|
if (val.includes(':')) {
|
||||||
|
return val.slice(val.indexOf(':') + 1);
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayPack(val: string | undefined): string | null {
|
||||||
|
if (!val || val === 'none') return null;
|
||||||
|
if (val.startsWith('lucide:')) return 'Lucide';
|
||||||
|
if (val.startsWith('ph:')) return 'Phosphor';
|
||||||
|
if (val.startsWith('tabler:')) return 'Tabler';
|
||||||
|
return 'Launcher';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root bind:open>
|
||||||
|
<Popover.Trigger
|
||||||
|
{disabled}
|
||||||
|
class="flex h-8 w-full items-center justify-between gap-2 rounded-md border border-theme-800 bg-theme-950 px-2.5 text-xs text-theme-50 transition-colors hover:bg-theme-900/60 focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{#if !value || value === 'none'}
|
||||||
|
<span class="flex items-center gap-2 text-muted-foreground truncate">
|
||||||
|
<span
|
||||||
|
class="flex size-4 items-center justify-center rounded border border-dashed border-theme-700 text-[10px]"
|
||||||
|
>✕</span
|
||||||
|
>
|
||||||
|
<span>{allowNone ? 'None' : placeholder}</span>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="flex items-center gap-2 min-w-0 truncate">
|
||||||
|
<span
|
||||||
|
class="flex size-5 shrink-0 items-center justify-center rounded bg-theme-900 border border-theme-800 text-theme-200"
|
||||||
|
>
|
||||||
|
<ThemeIcon name={value} set={iconSet} size={15} />
|
||||||
|
</span>
|
||||||
|
<span class="truncate font-mono text-xs text-theme-100">{displayName(value)}</span>
|
||||||
|
{#if displayPack(value)}
|
||||||
|
<span
|
||||||
|
class="rounded bg-theme-800/80 px-1 py-0.5 text-[8px] uppercase tracking-wide text-muted-foreground shrink-0"
|
||||||
|
>
|
||||||
|
{displayPack(value)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<ChevronsUpDown class="size-3.5 shrink-0 text-muted-foreground opacity-60" />
|
||||||
|
</Popover.Trigger>
|
||||||
|
|
||||||
|
<Popover.Content
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
sideOffset={4}
|
||||||
|
class="w-[360px] max-w-[95vw] rounded-lg border border-theme-800 bg-theme-950 p-0 text-theme-50 shadow-2xl"
|
||||||
|
>
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="border-b border-theme-800/80 p-2">
|
||||||
|
<div class="relative flex items-center">
|
||||||
|
<Search class="absolute left-2.5 size-3.5 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
bind:value={searchQuery}
|
||||||
|
placeholder="Search 17,000+ icons..."
|
||||||
|
class="h-8 pl-8 pr-7 text-xs border-theme-800 bg-theme-900/60 placeholder:text-muted-foreground"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
{#if searchQuery}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-2 text-muted-foreground hover:text-theme-100 transition-colors"
|
||||||
|
onclick={() => (searchQuery = '')}
|
||||||
|
>
|
||||||
|
<X class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pack Filter Tabs & View Toggle -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-1 border-b border-theme-800/60 bg-theme-900/30 px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1 overflow-x-auto no-scrollbar py-0.5">
|
||||||
|
{#each packTabs as tab (tab.key)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded px-2 py-0.5 text-[10px] font-medium transition-colors {selectedPack ===
|
||||||
|
tab.key
|
||||||
|
? 'bg-primary/20 text-primary border border-primary/40 font-semibold'
|
||||||
|
: 'text-muted-foreground hover:bg-theme-800/60 hover:text-theme-200 border border-transparent'}"
|
||||||
|
onclick={() => (selectedPack = tab.key)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-0.5 shrink-0 pl-1 border-l border-theme-800/60">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded p-1 text-muted-foreground hover:text-theme-100 transition-colors {viewMode ===
|
||||||
|
'grid'
|
||||||
|
? 'bg-theme-800 text-theme-100'
|
||||||
|
: ''}"
|
||||||
|
title="Grid view"
|
||||||
|
onclick={() => (viewMode = 'grid')}
|
||||||
|
>
|
||||||
|
<LayoutGrid class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded p-1 text-muted-foreground hover:text-theme-100 transition-colors {viewMode ===
|
||||||
|
'list'
|
||||||
|
? 'bg-theme-800 text-theme-100'
|
||||||
|
: ''}"
|
||||||
|
title="List view"
|
||||||
|
onclick={() => (viewMode = 'list')}
|
||||||
|
>
|
||||||
|
<List class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Allow "None" option if requested -->
|
||||||
|
{#if allowNone}
|
||||||
|
<div class="border-b border-theme-800/50 px-2 py-1 bg-theme-900/20">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between rounded px-2 py-1 text-xs transition-colors {value ===
|
||||||
|
'none' || !value
|
||||||
|
? 'bg-primary/20 text-primary font-medium'
|
||||||
|
: 'text-muted-foreground hover:bg-theme-800/60 hover:text-theme-100'}"
|
||||||
|
onclick={() => selectIcon('none')}
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="flex size-4 items-center justify-center rounded border border-dashed border-theme-700 text-[10px]"
|
||||||
|
>✕</span
|
||||||
|
>
|
||||||
|
<span>None (No icon)</span>
|
||||||
|
</span>
|
||||||
|
{#if value === 'none' || !value}
|
||||||
|
<Check class="size-3 text-primary" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Virtual Scrolling List/Grid -->
|
||||||
|
<div
|
||||||
|
bind:this={scrollContainer}
|
||||||
|
class="overflow-y-auto px-2 py-1 select-none"
|
||||||
|
style="height: {containerHeight}px;"
|
||||||
|
onscroll={(e) => {
|
||||||
|
scrollTop = (e.currentTarget as HTMLElement).scrollTop;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if filteredItems.length === 0}
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center py-12 text-center text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<p>No icons found</p>
|
||||||
|
{#if searchQuery}
|
||||||
|
<p class="text-[10px] text-muted-foreground/80 mt-0.5 font-mono">"{searchQuery}"</p>
|
||||||
|
{/if}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="mt-3 h-7 text-[10px]"
|
||||||
|
onclick={() => {
|
||||||
|
searchQuery = '';
|
||||||
|
selectedPack = 'all';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div style="height: {totalHeight}px; position: relative;">
|
||||||
|
<div
|
||||||
|
style="position: absolute; top: 0; left: 0; right: 0; transform: translateY({startIndex *
|
||||||
|
rowHeight}px);"
|
||||||
|
>
|
||||||
|
{#if viewMode === 'grid'}
|
||||||
|
{#each visibleRows as row (row.index)}
|
||||||
|
<div class="grid grid-cols-5 gap-1.5" style="height: {rowHeight}px;">
|
||||||
|
{#each row.items ?? [] as item (item.id)}
|
||||||
|
{@const isSelected =
|
||||||
|
item.id === value || (item.pack === 'launcher' && item.name === value)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group relative flex flex-col items-center justify-center rounded-md p-1 border transition-all text-center
|
||||||
|
{isSelected
|
||||||
|
? 'bg-primary/20 border-primary text-primary shadow-xs'
|
||||||
|
: 'border-transparent bg-theme-900/40 hover:bg-theme-800/80 hover:border-theme-700 text-theme-200 hover:text-theme-50'}"
|
||||||
|
title="{item.name} ({item.packLabel})"
|
||||||
|
onclick={() => selectIcon(item.id)}
|
||||||
|
>
|
||||||
|
<div class="flex size-5 items-center justify-center shrink-0">
|
||||||
|
<ThemeIcon name={item.id} set={iconSet} size={18} />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="mt-0.5 w-full truncate text-[9px] font-mono leading-tight opacity-75 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
{#if isSelected}
|
||||||
|
<div
|
||||||
|
class="absolute top-0.5 right-0.5 size-1.5 rounded-full bg-primary"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each visibleRows as row (row.index)}
|
||||||
|
{#if row.item}
|
||||||
|
{@const item = row.item}
|
||||||
|
{@const isSelected =
|
||||||
|
item.id === value || (item.pack === 'launcher' && item.name === value)}
|
||||||
|
<div style="height: {rowHeight}px;" class="flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full h-7 items-center justify-between rounded px-2 text-xs transition-colors
|
||||||
|
{isSelected
|
||||||
|
? 'bg-primary/20 text-primary font-medium'
|
||||||
|
: 'text-theme-200 hover:bg-theme-800/70 hover:text-theme-50'}"
|
||||||
|
title="{item.name} ({item.packLabel})"
|
||||||
|
onclick={() => selectIcon(item.id)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<div class="flex size-4.5 items-center justify-center shrink-0">
|
||||||
|
<ThemeIcon name={item.id} set={iconSet} size={16} />
|
||||||
|
</div>
|
||||||
|
<span class="truncate font-mono text-[11px]">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 shrink-0">
|
||||||
|
<span
|
||||||
|
class="text-[9px] uppercase px-1 py-0.2 rounded bg-theme-900/80 border border-theme-800/80 text-muted-foreground font-mono"
|
||||||
|
>
|
||||||
|
{item.packLabel}
|
||||||
|
</span>
|
||||||
|
{#if isSelected}
|
||||||
|
<Check class="size-3 text-primary" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer Stats Bar -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between border-t border-theme-800/70 bg-theme-900/40 px-3 py-1.5 text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span>{filteredItems.length.toLocaleString()} icons</span>
|
||||||
|
<span class="text-[9px]"
|
||||||
|
>Theme pack: <span class="capitalize text-theme-200">{iconSet}</span></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
@@ -61,10 +61,14 @@
|
|||||||
`font-family:${fontStack(bodyFont)}`,
|
`font-family:${fontStack(bodyFont)}`,
|
||||||
`--primary:${accent}`,
|
`--primary:${accent}`,
|
||||||
`--primary-foreground:${accentText}`,
|
`--primary-foreground:${accentText}`,
|
||||||
|
`--accent:${accent}`,
|
||||||
|
`--accent-foreground:${accentText}`,
|
||||||
`--ring:${accent}`,
|
`--ring:${accent}`,
|
||||||
`--color-primary:${accent}`,
|
`--color-primary:${accent}`,
|
||||||
`--color-primary-foreground:${accentText}`,
|
`--color-primary-foreground:${accentText}`,
|
||||||
`--color-primary-500:${accent}`,
|
`--color-primary-500:${accent}`,
|
||||||
|
`--color-accent:${accent}`,
|
||||||
|
`--color-accent-foreground:${accentText}`,
|
||||||
`--color-ring:${accent}`,
|
`--color-ring:${accent}`,
|
||||||
`--font-mono:${monoFontStack(monoFont)}`,
|
`--font-mono:${monoFontStack(monoFont)}`,
|
||||||
].join(';')
|
].join(';')
|
||||||
@@ -91,11 +95,13 @@
|
|||||||
<div class="rounded-[1.1rem] bg-primary/30 p-1.5 ring-1 ring-inset ring-white/15">
|
<div class="rounded-[1.1rem] bg-primary/30 p-1.5 ring-1 ring-inset ring-white/15">
|
||||||
<img class="pointer-events-none size-[43px]" src={Logo} alt="" draggable="false" />
|
<img class="pointer-events-none size-[43px]" src={Logo} alt="" draggable="false" />
|
||||||
</div>
|
</div>
|
||||||
<span
|
{#if variables.launcher_version !== undefined && variables.launcher_version !== ''}
|
||||||
class="rounded-full bg-primary px-2 py-0 text-[8px] font-semibold text-primary-foreground"
|
<span
|
||||||
>
|
class="rounded-full bg-primary px-2 py-0 text-[8px] font-semibold text-primary-foreground"
|
||||||
3.2.7
|
>
|
||||||
</span>
|
{variables.launcher_version}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
<div
|
<div
|
||||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] bg-primary p-3 text-primary-foreground ring-1 ring-inset ring-white/15"
|
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] bg-primary p-3 text-primary-foreground ring-1 ring-inset ring-white/15"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import ThemeCustomLayer from '$lib/components/theme/ThemeCustomLayer.svelte';
|
import ThemeCustomLayer from '$lib/components/theme/ThemeCustomLayer.svelte';
|
||||||
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
|
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
|
||||||
import ThemeLottieLayer from '$lib/components/theme/ThemeLottieLayer.svelte';
|
import ThemeLottieLayer from '$lib/components/theme/ThemeLottieLayer.svelte';
|
||||||
import { resolveThemeDataValue, themeGlobalData } from '$lib/themeData';
|
import { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from '$lib/themeData';
|
||||||
import { fontStack } from '$lib/themeFonts';
|
import { fontStack } from '$lib/themeFonts';
|
||||||
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
|
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
import { Boxes, ChevronRight, Move, RotateCw } from '@lucide/svelte';
|
import { Boxes, ChevronRight, Move, RotateCw } from '@lucide/svelte';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
import { TextMorph } from 'torph/svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
elements = [],
|
elements = [],
|
||||||
@@ -155,8 +156,32 @@
|
|||||||
mode: element.mode,
|
mode: element.mode,
|
||||||
now: clockNow,
|
now: clockNow,
|
||||||
});
|
});
|
||||||
|
const fallback = element.defaultValue ?? '';
|
||||||
|
const sample = interactive ? getThemeDataSampleValue(element.field, element.mode) : '';
|
||||||
|
const value =
|
||||||
|
raw && raw.trim() !== '' ? raw : fallback && fallback.trim() !== '' ? fallback : sample;
|
||||||
const template = element.text?.trim() ? element.text : '{value}';
|
const template = element.text?.trim() ? element.text : '{value}';
|
||||||
return interpolateThemeText(template.replaceAll('{value}', raw), variables);
|
return interpolateThemeText(
|
||||||
|
template
|
||||||
|
.replaceAll('{value}', value)
|
||||||
|
.replaceAll('{defaultValue}', fallback)
|
||||||
|
.replaceAll('{fallback}', fallback),
|
||||||
|
variables
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMorphEnabled = (element: ThemeV2Element): boolean => {
|
||||||
|
if (element.morph === true) return true;
|
||||||
|
if (element.morph === false) return false;
|
||||||
|
return ancestorsOf(element).some((group) => group.morph === true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const morphDuration = (element: ThemeV2Element): number => {
|
||||||
|
if (element.morphDuration && element.morphDuration > 0) return element.morphDuration;
|
||||||
|
for (const group of ancestorsOf(element)) {
|
||||||
|
if (group.morphDuration && group.morphDuration > 0) return group.morphDuration;
|
||||||
|
}
|
||||||
|
return 400;
|
||||||
};
|
};
|
||||||
|
|
||||||
const maskedAncestors = (element: ThemeV2Element) =>
|
const maskedAncestors = (element: ThemeV2Element) =>
|
||||||
@@ -1232,6 +1257,10 @@
|
|||||||
)}"
|
)}"
|
||||||
>
|
>
|
||||||
{#if element.type === 'text' || element.type === 'data'}
|
{#if element.type === 'text' || element.type === 'data'}
|
||||||
|
{@const content =
|
||||||
|
element.type === 'data'
|
||||||
|
? dataValue(element)
|
||||||
|
: interpolateThemeText(element.text, variables)}
|
||||||
<div
|
<div
|
||||||
class="h-full w-full overflow-hidden"
|
class="h-full w-full overflow-hidden"
|
||||||
style="display:flex;align-items:{element.verticalAlign === 'center'
|
style="display:flex;align-items:{element.verticalAlign === 'center'
|
||||||
@@ -1244,16 +1273,26 @@
|
|||||||
? 'flex-end'
|
? 'flex-end'
|
||||||
: 'flex-start'};text-align:{element.align ?? 'left'};"
|
: 'flex-start'};text-align:{element.align ?? 'left'};"
|
||||||
>
|
>
|
||||||
<span
|
{#if isMorphEnabled(element)}
|
||||||
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
|
<TextMorph
|
||||||
28}px;font-weight:{element.fontWeight ?? 600};line-height:{element.lineHeight ??
|
text={String(content ?? '')}
|
||||||
1.2};letter-spacing:{element.letterSpacing ??
|
duration={morphDuration(element)}
|
||||||
0}px;white-space:pre-wrap;overflow-wrap:anywhere;{themeTextStyleCss(element)}"
|
as="span"
|
||||||
>
|
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
|
||||||
{element.type === 'data'
|
28}px;font-weight:{element.fontWeight ?? 600};line-height:{element.lineHeight ??
|
||||||
? dataValue(element)
|
1.2};letter-spacing:{element.letterSpacing ??
|
||||||
: interpolateThemeText(element.text, variables)}
|
0}px;white-space:pre-wrap;overflow-wrap:anywhere;{themeTextStyleCss(element)}"
|
||||||
</span>
|
/>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
|
||||||
|
28}px;font-weight:{element.fontWeight ?? 600};line-height:{element.lineHeight ??
|
||||||
|
1.2};letter-spacing:{element.letterSpacing ??
|
||||||
|
0}px;white-space:pre-wrap;overflow-wrap:anywhere;{themeTextStyleCss(element)}"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if element.type === 'image'}
|
{:else if element.type === 'image'}
|
||||||
{#if element.tint}
|
{#if element.tint}
|
||||||
@@ -1420,14 +1459,27 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{#if gaugeLabel(element)}
|
{#if gaugeLabel(element)}
|
||||||
<span
|
{@const label = gaugeLabel(element)}
|
||||||
class="pointer-events-none absolute text-center"
|
{#if isMorphEnabled(element)}
|
||||||
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
|
<TextMorph
|
||||||
element.fontFamily
|
text={String(label ?? '')}
|
||||||
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
|
duration={morphDuration(element)}
|
||||||
>
|
as="span"
|
||||||
{gaugeLabel(element)}
|
class="pointer-events-none absolute text-center"
|
||||||
</span>
|
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
|
||||||
|
element.fontFamily
|
||||||
|
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute text-center"
|
||||||
|
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
|
||||||
|
element.fontFamily
|
||||||
|
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -1444,14 +1496,27 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
{#if gaugeLabel(element)}
|
{#if gaugeLabel(element)}
|
||||||
<span
|
{@const label = gaugeLabel(element)}
|
||||||
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
{#if isMorphEnabled(element)}
|
||||||
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
|
<TextMorph
|
||||||
element.fontFamily
|
text={String(label ?? '')}
|
||||||
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
|
duration={morphDuration(element)}
|
||||||
>
|
as="span"
|
||||||
{gaugeLabel(element)}
|
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||||
</span>
|
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
|
||||||
|
element.fontFamily
|
||||||
|
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||||
|
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
|
||||||
|
element.fontFamily
|
||||||
|
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1472,7 +1537,15 @@
|
|||||||
600};color:{element.color ?? '#ffffff'};text-transform:{element.textTransform ??
|
600};color:{element.color ?? '#ffffff'};text-transform:{element.textTransform ??
|
||||||
'none'};white-space:nowrap;"
|
'none'};white-space:nowrap;"
|
||||||
>
|
>
|
||||||
{interpolateThemeText(element.text, variables)}
|
{#if isMorphEnabled(element)}
|
||||||
|
<TextMorph
|
||||||
|
text={String(interpolateThemeText(element.text, variables) ?? '')}
|
||||||
|
duration={morphDuration(element)}
|
||||||
|
as="span"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
{interpolateThemeText(element.text, variables)}
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -1506,7 +1579,16 @@
|
|||||||
{#if element.icon}
|
{#if element.icon}
|
||||||
<ThemeIcon name={element.icon} set={iconSet} size="1.1em" />
|
<ThemeIcon name={element.icon} set={iconSet} size="1.1em" />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="truncate">{interpolateThemeText(element.label, variables)}</span>
|
{#if isMorphEnabled(element)}
|
||||||
|
<TextMorph
|
||||||
|
text={String(interpolateThemeText(element.label, variables) ?? '')}
|
||||||
|
duration={morphDuration(element)}
|
||||||
|
as="span"
|
||||||
|
class="truncate"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span class="truncate">{interpolateThemeText(element.label, variables)}</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{:else if element.type === 'path'}
|
{:else if element.type === 'path'}
|
||||||
{@const gradientVector = themeSvgGradientVector(element.gradientAngle ?? 135)}
|
{@const gradientVector = themeSvgGradientVector(element.gradientAngle ?? 135)}
|
||||||
@@ -1646,21 +1728,46 @@
|
|||||||
'#ff006e'};">{element.label}</span
|
'#ff006e'};">{element.label}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<span
|
{#if isMorphEnabled(element)}
|
||||||
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
|
<TextMorph
|
||||||
40}px;font-weight:{element.fontWeight ??
|
text={String(themeClockTime(element, now) ?? '')}
|
||||||
700};line-height:1.05;color:{element.color ??
|
duration={morphDuration(element)}
|
||||||
'#ffffff'};font-variant-numeric:tabular-nums;"
|
as="span"
|
||||||
>
|
class="block"
|
||||||
{themeClockTime(element, now)}
|
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
|
||||||
</span>
|
40}px;font-weight:{element.fontWeight ??
|
||||||
{#if element.showDate}
|
700};line-height:1.05;color:{element.color ??
|
||||||
|
'#ffffff'};font-variant-numeric:tabular-nums;"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
<span
|
<span
|
||||||
style="font-family:{fontStack(element.fontFamily)};font-size:{clockCaptionSize(
|
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
|
||||||
element
|
40}px;font-weight:{element.fontWeight ??
|
||||||
)}px;font-weight:500;color:{element.accentColor ?? '#ff006e'};"
|
700};line-height:1.05;color:{element.color ??
|
||||||
>{themeClockDate(now)}</span
|
'#ffffff'};font-variant-numeric:tabular-nums;"
|
||||||
>
|
>
|
||||||
|
{themeClockTime(element, now)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if element.showDate}
|
||||||
|
{#if isMorphEnabled(element)}
|
||||||
|
<TextMorph
|
||||||
|
text={String(themeClockDate(now) ?? '')}
|
||||||
|
duration={morphDuration(element)}
|
||||||
|
as="span"
|
||||||
|
class="block"
|
||||||
|
style="font-family:{fontStack(element.fontFamily)};font-size:{clockCaptionSize(
|
||||||
|
element
|
||||||
|
)}px;font-weight:500;color:{element.accentColor ?? '#ff006e'};"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
style="font-family:{fontStack(element.fontFamily)};font-size:{clockCaptionSize(
|
||||||
|
element
|
||||||
|
)}px;font-weight:500;color:{element.accentColor ?? '#ff006e'};"
|
||||||
|
>{themeClockDate(now)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1673,13 +1780,25 @@
|
|||||||
? 'flex-end'
|
? 'flex-end'
|
||||||
: 'center'};text-align:{element.align ?? 'center'};"
|
: 'center'};text-align:{element.align ?? 'center'};"
|
||||||
>
|
>
|
||||||
<span
|
{#if isMorphEnabled(element)}
|
||||||
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
|
<TextMorph
|
||||||
40}px;font-weight:{element.fontWeight ?? 700};color:{element.color ??
|
text={String(themeCountdownText(element, clockNow) ?? '')}
|
||||||
'#ffffff'};font-variant-numeric:tabular-nums;"
|
duration={morphDuration(element)}
|
||||||
>
|
as="span"
|
||||||
{themeCountdownText(element, clockNow)}
|
class="block"
|
||||||
</span>
|
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
|
||||||
|
40}px;font-weight:{element.fontWeight ?? 700};color:{element.color ??
|
||||||
|
'#ffffff'};font-variant-numeric:tabular-nums;"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
|
||||||
|
40}px;font-weight:{element.fontWeight ?? 700};color:{element.color ??
|
||||||
|
'#ffffff'};font-variant-numeric:tabular-nums;"
|
||||||
|
>
|
||||||
|
{themeCountdownText(element, clockNow)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Root from "./tooltip.svelte";
|
||||||
|
import Content from "./tooltip-content.svelte";
|
||||||
|
import Trigger from "./tooltip-trigger.svelte";
|
||||||
|
import Provider from "./tooltip-provider.svelte";
|
||||||
|
import Portal from "./tooltip-portal.svelte";
|
||||||
|
import Arrow from "./tooltip-arrow.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Trigger,
|
||||||
|
Provider,
|
||||||
|
Portal,
|
||||||
|
Arrow,
|
||||||
|
//
|
||||||
|
Root as Tooltip,
|
||||||
|
Content as TooltipContent,
|
||||||
|
Trigger as TooltipTrigger,
|
||||||
|
Provider as TooltipProvider,
|
||||||
|
Portal as TooltipPortal,
|
||||||
|
Arrow as TooltipArrow,
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
import { Tooltip as TooltipPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: TooltipPrimitive.ArrowProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TooltipPrimitive.Arrow
|
||||||
|
bind:ref
|
||||||
|
data-slot="tooltip-arrow"
|
||||||
|
class={cn('text-popover fill-popover', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||||
|
import TooltipPortal from "./tooltip-portal.svelte";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
sideOffset = 6,
|
||||||
|
portalProps,
|
||||||
|
...restProps
|
||||||
|
}: TooltipPrimitive.ContentProps & {
|
||||||
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof TooltipPortal>>;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TooltipPortal {...portalProps}>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
{sideOffset}
|
||||||
|
class={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border border-border px-3 py-1.5 text-xs shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
</TooltipPortal>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ...restProps }: TooltipPrimitive.PortalProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TooltipPrimitive.Portal {...restProps} />
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
delayDuration = 100,
|
||||||
|
...restProps
|
||||||
|
}: TooltipPrimitive.ProviderProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
{delayDuration}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: TooltipPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TooltipPrimitive.Trigger
|
||||||
|
bind:ref
|
||||||
|
data-slot="tooltip-trigger"
|
||||||
|
class={cn("", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { open = $bindable(false), ...restProps }: TooltipPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TooltipPrimitive.Root bind:open {...restProps} />
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import { config } from '$lib/config';
|
||||||
|
import { updateActivity } from '$lib/presence';
|
||||||
|
import { get, writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export type DiscordPresenceButtonConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiscordPresenceConfig = {
|
||||||
|
// Idle (Launcher) Configuration
|
||||||
|
idleDetails: string;
|
||||||
|
idleState: string;
|
||||||
|
idleLargeImageKey: string;
|
||||||
|
idleLargeImageText: string;
|
||||||
|
idleShowTime: boolean;
|
||||||
|
idleShowUserAvatar: boolean;
|
||||||
|
|
||||||
|
// Playing (osu!) Configuration
|
||||||
|
playingDetails: string;
|
||||||
|
playingState: string;
|
||||||
|
playingShowBeatmapCover: boolean;
|
||||||
|
playingLargeImageKey: string;
|
||||||
|
playingLargeImageText: string;
|
||||||
|
playingShowUserAvatar: boolean;
|
||||||
|
playingSmallImageText: string;
|
||||||
|
playingShowTime: boolean;
|
||||||
|
|
||||||
|
// Contextual Buttons
|
||||||
|
idleButton1: DiscordPresenceButtonConfig;
|
||||||
|
playingButton1: DiscordPresenceButtonConfig;
|
||||||
|
|
||||||
|
// Secondary Button
|
||||||
|
button2: DiscordPresenceButtonConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_DISCORD_PRESENCE_CONFIG: DiscordPresenceConfig = {
|
||||||
|
idleDetails: 'In Launcher',
|
||||||
|
idleState: 'Chilling...',
|
||||||
|
idleLargeImageKey: 'ezppfarm',
|
||||||
|
idleLargeImageText: 'EZPPFarm Launcher',
|
||||||
|
idleShowTime: true,
|
||||||
|
idleShowUserAvatar: true,
|
||||||
|
|
||||||
|
playingDetails: '[{mode}] {action}',
|
||||||
|
playingState: '{beatmap}',
|
||||||
|
playingShowBeatmapCover: true,
|
||||||
|
playingLargeImageKey: 'ezppfarm',
|
||||||
|
playingLargeImageText: '{beatmap}',
|
||||||
|
playingShowUserAvatar: true,
|
||||||
|
playingSmallImageText: '{username} (#{rank})',
|
||||||
|
playingShowTime: true,
|
||||||
|
|
||||||
|
idleButton1: {
|
||||||
|
enabled: true,
|
||||||
|
label: 'Download Launcher',
|
||||||
|
url: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher/releases/latest',
|
||||||
|
},
|
||||||
|
playingButton1: {
|
||||||
|
enabled: true,
|
||||||
|
label: 'View Beatmap',
|
||||||
|
url: '{beatmapUrl}',
|
||||||
|
},
|
||||||
|
button2: {
|
||||||
|
enabled: true,
|
||||||
|
label: 'Join EZPPFarm',
|
||||||
|
url: 'https://ez-pp.farm/discord',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiscordPresencePreset = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
config: Partial<DiscordPresenceConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DISCORD_PRESENCE_PRESETS: DiscordPresencePreset[] = [
|
||||||
|
{
|
||||||
|
name: 'EZPPFarm Default',
|
||||||
|
description: 'Standard presence with beatmap covers, actions, and official links.',
|
||||||
|
config: DEFAULT_DISCORD_PRESENCE_CONFIG,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Clean & Minimal',
|
||||||
|
description: 'Clean look without buttons or cluttered stats.',
|
||||||
|
config: {
|
||||||
|
idleDetails: 'In Launcher',
|
||||||
|
idleState: 'Chilling',
|
||||||
|
idleShowTime: true,
|
||||||
|
idleShowUserAvatar: false,
|
||||||
|
playingDetails: '{action}',
|
||||||
|
playingState: '{beatmap}',
|
||||||
|
playingShowBeatmapCover: true,
|
||||||
|
playingShowUserAvatar: false,
|
||||||
|
playingShowTime: true,
|
||||||
|
idleButton1: { enabled: false, label: '', url: '' },
|
||||||
|
playingButton1: { enabled: false, label: '', url: '' },
|
||||||
|
button2: { enabled: false, label: '', url: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Competitive Stats',
|
||||||
|
description: 'Highlights your rank, performance points, and live accuracy.',
|
||||||
|
config: {
|
||||||
|
idleDetails: '{username} · #{rank} ({pp}pp)',
|
||||||
|
idleState: 'Preparing to farm...',
|
||||||
|
idleShowTime: true,
|
||||||
|
idleShowUserAvatar: true,
|
||||||
|
playingDetails: '[{mode}] {action} · {accuracy}',
|
||||||
|
playingState: '{beatmap}',
|
||||||
|
playingShowBeatmapCover: true,
|
||||||
|
playingShowUserAvatar: true,
|
||||||
|
playingSmallImageText: '{username} (#{rank} · {pp}pp)',
|
||||||
|
playingShowTime: true,
|
||||||
|
idleButton1: {
|
||||||
|
enabled: true,
|
||||||
|
label: 'View Profile',
|
||||||
|
url: '{profileUrl}',
|
||||||
|
},
|
||||||
|
playingButton1: {
|
||||||
|
enabled: true,
|
||||||
|
label: 'View Beatmap',
|
||||||
|
url: '{beatmapUrl}',
|
||||||
|
},
|
||||||
|
button2: {
|
||||||
|
enabled: true,
|
||||||
|
label: 'View Profile',
|
||||||
|
url: '{profileUrl}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Streamer / Privacy',
|
||||||
|
description: 'Hides exact map titles, player rank, and avatar for privacy.',
|
||||||
|
config: {
|
||||||
|
idleDetails: 'osu!',
|
||||||
|
idleState: 'In Launcher',
|
||||||
|
idleShowTime: false,
|
||||||
|
idleShowUserAvatar: false,
|
||||||
|
playingDetails: 'Playing osu!',
|
||||||
|
playingState: 'Offline practice',
|
||||||
|
playingShowBeatmapCover: false,
|
||||||
|
playingShowUserAvatar: false,
|
||||||
|
playingLargeImageKey: 'ezppfarm',
|
||||||
|
playingLargeImageText: 'osu!',
|
||||||
|
playingSmallImageText: '',
|
||||||
|
playingShowTime: false,
|
||||||
|
idleButton1: { enabled: false, label: '', url: '' },
|
||||||
|
playingButton1: { enabled: false, label: '', url: '' },
|
||||||
|
button2: { enabled: false, label: '', url: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export type PresenceVariableContext = {
|
||||||
|
username?: string;
|
||||||
|
id?: string;
|
||||||
|
rank?: string | number;
|
||||||
|
pp?: string | number;
|
||||||
|
accuracy?: string | number;
|
||||||
|
mode?: string;
|
||||||
|
action?: string;
|
||||||
|
beatmap?: string;
|
||||||
|
artist?: string;
|
||||||
|
title?: string;
|
||||||
|
diff?: string;
|
||||||
|
version?: string;
|
||||||
|
beatmapUrl?: string;
|
||||||
|
profileUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const interpolatePresenceString = (
|
||||||
|
template: string,
|
||||||
|
context: PresenceVariableContext
|
||||||
|
): string => {
|
||||||
|
if (!template) return '';
|
||||||
|
return template
|
||||||
|
.replaceAll('{username}', context.username ?? 'Player')
|
||||||
|
.replaceAll(
|
||||||
|
'{rank}',
|
||||||
|
context.rank !== undefined && context.rank !== '' ? String(context.rank) : '-'
|
||||||
|
)
|
||||||
|
.replaceAll('{pp}', context.pp !== undefined && context.pp !== '' ? String(context.pp) : '0')
|
||||||
|
.replaceAll(
|
||||||
|
'{accuracy}',
|
||||||
|
context.accuracy !== undefined && context.accuracy !== '' ? String(context.accuracy) : '100%'
|
||||||
|
)
|
||||||
|
.replaceAll('{mode}', context.mode ?? 'osu!')
|
||||||
|
.replaceAll('{action}', context.action ?? 'Playing...')
|
||||||
|
.replaceAll('{beatmap}', context.beatmap ?? 'Idle...')
|
||||||
|
.replaceAll('{artist}', context.artist ?? '')
|
||||||
|
.replaceAll('{title}', context.title ?? '')
|
||||||
|
.replaceAll('{diff}', context.diff ?? '')
|
||||||
|
.replaceAll(
|
||||||
|
'{version}',
|
||||||
|
context.version !== undefined && context.version !== '' ? String(context.version) : ''
|
||||||
|
)
|
||||||
|
.replaceAll('{beatmapUrl}', context.beatmapUrl ?? 'https://ez-pp.farm')
|
||||||
|
.replaceAll(
|
||||||
|
'{profileUrl}',
|
||||||
|
context.profileUrl ??
|
||||||
|
(context.id ? `https://ez-pp.farm/u/${context.id}` : 'https://ez-pp.farm')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const discordPresenceCustomConfig = writable<DiscordPresenceConfig>(
|
||||||
|
DEFAULT_DISCORD_PRESENCE_CONFIG
|
||||||
|
);
|
||||||
|
|
||||||
|
export const loadDiscordPresenceConfig = async (): Promise<DiscordPresenceConfig> => {
|
||||||
|
try {
|
||||||
|
const raw = await config
|
||||||
|
.value<Partial<DiscordPresenceConfig>>('discord_presence_config')
|
||||||
|
.get({});
|
||||||
|
const merged: DiscordPresenceConfig = {
|
||||||
|
...DEFAULT_DISCORD_PRESENCE_CONFIG,
|
||||||
|
...raw,
|
||||||
|
idleButton1: { ...DEFAULT_DISCORD_PRESENCE_CONFIG.idleButton1, ...(raw?.idleButton1 ?? {}) },
|
||||||
|
playingButton1: {
|
||||||
|
...DEFAULT_DISCORD_PRESENCE_CONFIG.playingButton1,
|
||||||
|
...(raw?.playingButton1 ?? {}),
|
||||||
|
},
|
||||||
|
button2: { ...DEFAULT_DISCORD_PRESENCE_CONFIG.button2, ...(raw?.button2 ?? {}) },
|
||||||
|
};
|
||||||
|
discordPresenceCustomConfig.set(merged);
|
||||||
|
return merged;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_DISCORD_PRESENCE_CONFIG;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveDiscordPresenceConfig = async (
|
||||||
|
newConfig: DiscordPresenceConfig
|
||||||
|
): Promise<void> => {
|
||||||
|
discordPresenceCustomConfig.set(newConfig);
|
||||||
|
try {
|
||||||
|
await config.value<DiscordPresenceConfig>('discord_presence_config').set(newConfig);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save discord presence config:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IdlePresenceParams = {
|
||||||
|
user?: { id?: number | string; name?: string } | null;
|
||||||
|
version?: string;
|
||||||
|
rank?: number | string;
|
||||||
|
pp?: number | string;
|
||||||
|
accuracy?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlayingPresenceParams = {
|
||||||
|
user?: { id?: number | string; name?: string } | null;
|
||||||
|
mode?: string;
|
||||||
|
action?: string;
|
||||||
|
beatmap?: string;
|
||||||
|
artist?: string;
|
||||||
|
title?: string;
|
||||||
|
diff?: string;
|
||||||
|
version?: string;
|
||||||
|
beatmapCoverUrl?: string | null;
|
||||||
|
beatmapUrl?: string | null;
|
||||||
|
profileUrl?: string | null;
|
||||||
|
rank?: number | string;
|
||||||
|
pp?: number | string;
|
||||||
|
accuracy?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidHttpUrl = (url: string | undefined): boolean => {
|
||||||
|
if (!url) return false;
|
||||||
|
return url.startsWith('http://') || url.startsWith('https://');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pushIdlePresenceFromConfig = async (
|
||||||
|
params?: IdlePresenceParams,
|
||||||
|
cfg?: DiscordPresenceConfig
|
||||||
|
): Promise<void> => {
|
||||||
|
const currentCfg = cfg ?? get(discordPresenceCustomConfig);
|
||||||
|
const context: PresenceVariableContext = {
|
||||||
|
username: params?.user?.name,
|
||||||
|
id: params?.user?.id ? String(params.user.id) : undefined,
|
||||||
|
rank: params?.rank,
|
||||||
|
pp: params?.pp,
|
||||||
|
accuracy: params?.accuracy,
|
||||||
|
version: params?.version,
|
||||||
|
profileUrl: params?.user?.id ? `https://ez-pp.farm/u/${params.user.id}` : 'https://ez-pp.farm',
|
||||||
|
};
|
||||||
|
|
||||||
|
const details = interpolatePresenceString(currentCfg.idleDetails, context);
|
||||||
|
const state = interpolatePresenceString(currentCfg.idleState, context);
|
||||||
|
const largeImageKey = currentCfg.idleLargeImageKey || 'ezppfarm';
|
||||||
|
const largeImageText = interpolatePresenceString(currentCfg.idleLargeImageText, context);
|
||||||
|
|
||||||
|
let smallImageKey: string | null = null;
|
||||||
|
let smallImageText: string | null = null;
|
||||||
|
if (currentCfg.idleShowUserAvatar && params?.user?.id) {
|
||||||
|
smallImageKey = `https://a.ez-pp.farm/${params.user.id}`;
|
||||||
|
smallImageText = params.user.name ?? 'Player';
|
||||||
|
}
|
||||||
|
|
||||||
|
let button1: { label: string; url: string } | null = null;
|
||||||
|
if (currentCfg.idleButton1.enabled && currentCfg.idleButton1.label.trim()) {
|
||||||
|
const rawUrl = interpolatePresenceString(currentCfg.idleButton1.url, context);
|
||||||
|
if (isValidHttpUrl(rawUrl)) {
|
||||||
|
button1 = {
|
||||||
|
label: interpolatePresenceString(currentCfg.idleButton1.label, context),
|
||||||
|
url: rawUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let button2: { label: string; url: string } | null = null;
|
||||||
|
if (currentCfg.button2.enabled && currentCfg.button2.label.trim()) {
|
||||||
|
const rawUrl = interpolatePresenceString(currentCfg.button2.url, context);
|
||||||
|
if (isValidHttpUrl(rawUrl)) {
|
||||||
|
button2 = {
|
||||||
|
label: interpolatePresenceString(currentCfg.button2.label, context),
|
||||||
|
url: rawUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateActivity({
|
||||||
|
state,
|
||||||
|
details,
|
||||||
|
largeImageKey,
|
||||||
|
largeImageText,
|
||||||
|
smallImageKey,
|
||||||
|
smallImageText,
|
||||||
|
button1,
|
||||||
|
button2,
|
||||||
|
showTimestamp: currentCfg.idleShowTime,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to push idle Discord presence:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pushPlayingPresenceFromConfig = async (
|
||||||
|
params: PlayingPresenceParams,
|
||||||
|
cfg?: DiscordPresenceConfig
|
||||||
|
): Promise<void> => {
|
||||||
|
const currentCfg = cfg ?? get(discordPresenceCustomConfig);
|
||||||
|
const context: PresenceVariableContext = {
|
||||||
|
username: params.user?.name,
|
||||||
|
id: params.user?.id ? String(params.user.id) : undefined,
|
||||||
|
rank: params.rank,
|
||||||
|
pp: params.pp,
|
||||||
|
accuracy: params.accuracy,
|
||||||
|
mode: params.mode,
|
||||||
|
action: params.action,
|
||||||
|
beatmap: params.beatmap,
|
||||||
|
artist: params.artist,
|
||||||
|
title: params.title,
|
||||||
|
diff: params.diff,
|
||||||
|
version: params.version,
|
||||||
|
beatmapUrl: params.beatmapUrl ?? 'https://ez-pp.farm',
|
||||||
|
profileUrl:
|
||||||
|
params.profileUrl ??
|
||||||
|
(params.user?.id ? `https://ez-pp.farm/u/${params.user.id}` : 'https://ez-pp.farm'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const details = interpolatePresenceString(currentCfg.playingDetails, context);
|
||||||
|
const state = interpolatePresenceString(currentCfg.playingState, context);
|
||||||
|
|
||||||
|
let largeImageKey = currentCfg.playingLargeImageKey || 'ezppfarm';
|
||||||
|
if (currentCfg.playingShowBeatmapCover && params.beatmapCoverUrl) {
|
||||||
|
largeImageKey = params.beatmapCoverUrl;
|
||||||
|
}
|
||||||
|
const largeImageText = interpolatePresenceString(currentCfg.playingLargeImageText, context);
|
||||||
|
|
||||||
|
let smallImageKey: string | null = null;
|
||||||
|
let smallImageText: string | null = null;
|
||||||
|
if (currentCfg.playingShowUserAvatar && params.user?.id) {
|
||||||
|
smallImageKey = `https://a.ez-pp.farm/${params.user.id}`;
|
||||||
|
smallImageText = interpolatePresenceString(currentCfg.playingSmallImageText, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
let button1: { label: string; url: string } | null = null;
|
||||||
|
if (currentCfg.playingButton1.enabled && currentCfg.playingButton1.label.trim()) {
|
||||||
|
const rawUrl = interpolatePresenceString(currentCfg.playingButton1.url, context);
|
||||||
|
if (isValidHttpUrl(rawUrl)) {
|
||||||
|
button1 = {
|
||||||
|
label: interpolatePresenceString(currentCfg.playingButton1.label, context),
|
||||||
|
url: rawUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let button2: { label: string; url: string } | null = null;
|
||||||
|
if (currentCfg.button2.enabled && currentCfg.button2.label.trim()) {
|
||||||
|
const rawUrl = interpolatePresenceString(currentCfg.button2.url, context);
|
||||||
|
if (isValidHttpUrl(rawUrl)) {
|
||||||
|
button2 = {
|
||||||
|
label: interpolatePresenceString(currentCfg.button2.label, context),
|
||||||
|
url: rawUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateActivity({
|
||||||
|
state,
|
||||||
|
details,
|
||||||
|
largeImageKey,
|
||||||
|
largeImageText,
|
||||||
|
smallImageKey,
|
||||||
|
smallImageText,
|
||||||
|
button1,
|
||||||
|
button2,
|
||||||
|
showTimestamp: currentCfg.playingShowTime,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to push playing Discord presence:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
+25
-2
@@ -1,7 +1,29 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export type PresenceButtonPayload = {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PresenceActivityPayload = {
|
||||||
|
state?: string;
|
||||||
|
details?: string;
|
||||||
|
largeImageKey?: string;
|
||||||
|
largeImageText?: string;
|
||||||
|
smallImageKey?: string | null;
|
||||||
|
smallImageText?: string | null;
|
||||||
|
button1?: PresenceButtonPayload | null;
|
||||||
|
button2?: PresenceButtonPayload | null;
|
||||||
|
showTimestamp?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export const connect = async () => await invoke('presence_connect');
|
export const connect = async () => await invoke('presence_connect');
|
||||||
export const disconnect = async () => await invoke('presence_disconnect');
|
export const disconnect = async () => await invoke('presence_disconnect');
|
||||||
|
export const isConnected = async () => await invoke<boolean>('presence_is_connected');
|
||||||
|
|
||||||
|
export const updateActivity = async (activity: PresenceActivityPayload) =>
|
||||||
|
await invoke('presence_update_activity', { activity });
|
||||||
|
|
||||||
export const updateStatus = async (status: {
|
export const updateStatus = async (status: {
|
||||||
state: string;
|
state: string;
|
||||||
details: string;
|
details: string;
|
||||||
@@ -14,8 +36,9 @@ export const updateStatus = async (status: {
|
|||||||
largeImageKey: status.largeImageKey,
|
largeImageKey: status.largeImageKey,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateUser = async (user: { username: string; id?: string | null }) =>
|
export const updateUser = async (user: { username: string; id?: string | null }) =>
|
||||||
await invoke('presence_update_user', { user: { username: user.username, id: user.id } });
|
await invoke('presence_update_user', { user: { username: user.username, id: user.id } });
|
||||||
|
|
||||||
export const updateButton = async (button: { text?: string; url?: string }) =>
|
export const updateButton = async (button: { text?: string; url?: string }) =>
|
||||||
await invoke('presence_update_button', { button: button });
|
await invoke('presence_update_button', { button });
|
||||||
export const isConnected = async () => await invoke<boolean>('presence_is_connected');
|
|
||||||
+129
-28
@@ -82,12 +82,12 @@ export const themeGlobalData = derived(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const formatInteger = (value: number) => Math.round(value).toLocaleString('en-US');
|
export const formatInteger = (value: number) => Math.round(value).toLocaleString('en-US');
|
||||||
|
|
||||||
const formatAccuracy = (value: number) =>
|
export const formatAccuracy = (value: number) =>
|
||||||
`${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%`;
|
`${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%`;
|
||||||
|
|
||||||
const formatPlaytime = (seconds: number) => {
|
export const formatPlaytime = (seconds: number) => {
|
||||||
const totalMinutes = Math.max(0, Math.round(seconds / 60));
|
const totalMinutes = Math.max(0, Math.round(seconds / 60));
|
||||||
const hours = Math.floor(totalMinutes / 60);
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
const minutes = totalMinutes % 60;
|
const minutes = totalMinutes % 60;
|
||||||
@@ -95,54 +95,155 @@ const formatPlaytime = (seconds: number) => {
|
|||||||
return `${minutes}m`;
|
return `${minutes}m`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: Date) =>
|
export const formatDate = (date: Date) =>
|
||||||
new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date);
|
new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date);
|
||||||
|
|
||||||
/** Current value for a data layer field. Missing values render as an empty string. */
|
/**
|
||||||
export const resolveThemeDataValue = (field: ThemeV2DataField, context: ThemeDataContext) => {
|
* Realistic sample values displayed in the theme editor and preview thumbnails
|
||||||
const { global, theme, screen, mode = 'osu', now = new Date() } = context;
|
* when live launcher data is empty or unavailable (e.g. logged out or offline).
|
||||||
const stats = global.user.stats[mode];
|
*/
|
||||||
|
export const getThemeDataSampleValue = (
|
||||||
|
field: ThemeV2DataField,
|
||||||
|
mode: ThemeV2DataMode = 'osu'
|
||||||
|
): string => {
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case 'user.name':
|
case 'user.name':
|
||||||
return global.user.name;
|
return 'Player';
|
||||||
case 'user.country':
|
case 'user.country':
|
||||||
return global.user.country.toUpperCase();
|
return 'US';
|
||||||
case 'user.pp':
|
case 'user.pp':
|
||||||
return stats ? formatInteger(stats.pp) : '';
|
return mode === 'catch'
|
||||||
|
? '6,280'
|
||||||
|
: mode === 'taiko'
|
||||||
|
? '5,140'
|
||||||
|
: mode === 'mania'
|
||||||
|
? '8,920'
|
||||||
|
: '7,420';
|
||||||
case 'user.rank':
|
case 'user.rank':
|
||||||
return stats && stats.rank > 0 ? formatInteger(stats.rank) : '';
|
return mode === 'catch'
|
||||||
|
? '840'
|
||||||
|
: mode === 'taiko'
|
||||||
|
? '2,150'
|
||||||
|
: mode === 'mania'
|
||||||
|
? '412'
|
||||||
|
: '1,337';
|
||||||
case 'user.plays':
|
case 'user.plays':
|
||||||
return stats ? formatInteger(stats.plays) : '';
|
return mode === 'catch'
|
||||||
|
? '18,500'
|
||||||
|
: mode === 'taiko'
|
||||||
|
? '12,300'
|
||||||
|
: mode === 'mania'
|
||||||
|
? '35,800'
|
||||||
|
: '42,100';
|
||||||
case 'user.accuracy':
|
case 'user.accuracy':
|
||||||
return stats ? formatAccuracy(stats.accuracy) : '';
|
return '98.75%';
|
||||||
case 'user.playtime':
|
case 'user.playtime':
|
||||||
return stats ? formatPlaytime(stats.playtime) : '';
|
return '124h 30m';
|
||||||
case 'launcher.version':
|
case 'launcher.version':
|
||||||
return global.launcher.version;
|
return '';
|
||||||
case 'server.friends':
|
case 'server.friends':
|
||||||
return global.launcher.friends === undefined ? '' : formatInteger(global.launcher.friends);
|
return '12';
|
||||||
case 'osu.build':
|
case 'osu.build':
|
||||||
return global.osu.build ?? '';
|
return 'b20241030';
|
||||||
case 'osu.stream':
|
case 'osu.stream':
|
||||||
return global.osu.stream ?? '';
|
return 'Stable40';
|
||||||
case 'server.ping':
|
case 'server.ping':
|
||||||
return global.server.ping === undefined ? '' : `${Math.round(global.server.ping)} ms`;
|
return '24 ms';
|
||||||
case 'theme.name':
|
case 'theme.name':
|
||||||
return theme.name;
|
return 'Sample Theme';
|
||||||
case 'theme.author':
|
case 'theme.author':
|
||||||
return theme.author;
|
return 'Theme Creator';
|
||||||
case 'theme.version':
|
case 'theme.version':
|
||||||
return theme.version;
|
return '1.0.0';
|
||||||
case 'screen':
|
case 'screen':
|
||||||
return screen ? screen.charAt(0).toUpperCase() + screen.slice(1) : '';
|
return 'Home';
|
||||||
case 'clock.time':
|
case 'clock.time':
|
||||||
return String(themeClockValues(now).time ?? '');
|
return '14:30:00';
|
||||||
case 'clock.time12':
|
case 'clock.time12':
|
||||||
return String(themeClockValues(now).time12 ?? '');
|
return '2:30:00 PM';
|
||||||
case 'clock.date':
|
case 'clock.date':
|
||||||
return formatDate(now);
|
return formatDate(new Date());
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Current value for a data layer field. Missing values render as an empty string. */
|
||||||
|
export const resolveThemeDataValue = (
|
||||||
|
field: ThemeV2DataField,
|
||||||
|
context: ThemeDataContext,
|
||||||
|
options?: { preview?: boolean }
|
||||||
|
) => {
|
||||||
|
const { global, theme, screen, mode = 'osu', now = new Date() } = context;
|
||||||
|
const stats = global.user.stats[mode];
|
||||||
|
|
||||||
|
let value = '';
|
||||||
|
switch (field) {
|
||||||
|
case 'user.name':
|
||||||
|
value = global.user.name;
|
||||||
|
break;
|
||||||
|
case 'user.country':
|
||||||
|
value = global.user.country ? global.user.country.toUpperCase() : '';
|
||||||
|
break;
|
||||||
|
case 'user.pp':
|
||||||
|
value = stats ? formatInteger(stats.pp) : '';
|
||||||
|
break;
|
||||||
|
case 'user.rank':
|
||||||
|
value = stats && stats.rank > 0 ? formatInteger(stats.rank) : '';
|
||||||
|
break;
|
||||||
|
case 'user.plays':
|
||||||
|
value = stats ? formatInteger(stats.plays) : '';
|
||||||
|
break;
|
||||||
|
case 'user.accuracy':
|
||||||
|
value = stats ? formatAccuracy(stats.accuracy) : '';
|
||||||
|
break;
|
||||||
|
case 'user.playtime':
|
||||||
|
value = stats ? formatPlaytime(stats.playtime) : '';
|
||||||
|
break;
|
||||||
|
case 'launcher.version':
|
||||||
|
value = global.launcher.version;
|
||||||
|
break;
|
||||||
|
case 'server.friends':
|
||||||
|
value = global.launcher.friends === undefined ? '' : formatInteger(global.launcher.friends);
|
||||||
|
break;
|
||||||
|
case 'osu.build':
|
||||||
|
value = global.osu.build ?? '';
|
||||||
|
break;
|
||||||
|
case 'osu.stream':
|
||||||
|
value = global.osu.stream ?? '';
|
||||||
|
break;
|
||||||
|
case 'server.ping':
|
||||||
|
value = global.server.ping === undefined ? '' : `${Math.round(global.server.ping)} ms`;
|
||||||
|
break;
|
||||||
|
case 'theme.name':
|
||||||
|
value = theme.name;
|
||||||
|
break;
|
||||||
|
case 'theme.author':
|
||||||
|
value = theme.author;
|
||||||
|
break;
|
||||||
|
case 'theme.version':
|
||||||
|
value = theme.version;
|
||||||
|
break;
|
||||||
|
case 'screen':
|
||||||
|
value = screen ? screen.charAt(0).toUpperCase() + screen.slice(1) : '';
|
||||||
|
break;
|
||||||
|
case 'clock.time':
|
||||||
|
value = String(themeClockValues(now).time ?? '');
|
||||||
|
break;
|
||||||
|
case 'clock.time12':
|
||||||
|
value = String(themeClockValues(now).time12 ?? '');
|
||||||
|
break;
|
||||||
|
case 'clock.date':
|
||||||
|
value = formatDate(now);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
value = '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.preview && (!value || value.trim() === '')) {
|
||||||
|
return getThemeDataSampleValue(field, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { THEME_ICON_BODIES, type ThemeIconBody } from './themeIconData';
|
||||||
|
import {
|
||||||
|
DEFAULT_THEME_ICON_SET,
|
||||||
|
THEME_ICON_NAMES,
|
||||||
|
type ThemeIconName,
|
||||||
|
type ThemeIconSet,
|
||||||
|
} from './themeIcons';
|
||||||
|
import lucideCollection from '@iconify-json/lucide/icons.json';
|
||||||
|
import phCollection from '@iconify-json/ph/icons.json';
|
||||||
|
import tablerCollection from '@iconify-json/tabler/icons.json';
|
||||||
|
|
||||||
|
export type { ThemeIconBody };
|
||||||
|
|
||||||
|
export type IconPackKey = 'launcher' | 'lucide' | 'phosphor' | 'tabler';
|
||||||
|
|
||||||
|
export type ThemeIconCatalogItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
pack: IconPackKey;
|
||||||
|
packLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IconifyRawCollection = {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
icons: Record<string, { body: string; width?: number; height?: number }>;
|
||||||
|
aliases?: Record<string, { parent: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collections: Record<'lucide' | 'ph' | 'tabler', IconifyRawCollection> = {
|
||||||
|
lucide: lucideCollection as unknown as IconifyRawCollection,
|
||||||
|
ph: phCollection as unknown as IconifyRawCollection,
|
||||||
|
tabler: tablerCollection as unknown as IconifyRawCollection,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveFromCollection = (
|
||||||
|
collection: IconifyRawCollection,
|
||||||
|
id: string
|
||||||
|
): ThemeIconBody | undefined => {
|
||||||
|
const direct = collection.icons[id];
|
||||||
|
if (direct) {
|
||||||
|
return {
|
||||||
|
width: direct.width ?? collection.width ?? 24,
|
||||||
|
height: direct.height ?? collection.height ?? 24,
|
||||||
|
body: direct.body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const alias = collection.aliases?.[id];
|
||||||
|
if (alias) {
|
||||||
|
const parent = collection.icons[alias.parent];
|
||||||
|
if (parent) {
|
||||||
|
return {
|
||||||
|
width: parent.width ?? collection.width ?? 24,
|
||||||
|
height: parent.height ?? collection.height ?? 24,
|
||||||
|
body: parent.body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_ICON: ThemeIconBody = {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
body: '<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 8v4m0 4h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache for quickly resolving icon bodies
|
||||||
|
const bodyCache = new Map<string, ThemeIconBody>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve any icon by name (semantic launcher name, prefixed like `lucide:bell`,
|
||||||
|
* `ph:sword`, `tabler:star`, or unprefixed matching an active/fallback pack).
|
||||||
|
*/
|
||||||
|
export function getThemeIconBody(
|
||||||
|
name: string | undefined | null,
|
||||||
|
set: ThemeIconSet = DEFAULT_THEME_ICON_SET
|
||||||
|
): ThemeIconBody {
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return THEME_ICON_BODIES[set]?.home ?? THEME_ICON_BODIES[DEFAULT_THEME_ICON_SET].home;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${set}:${name}`;
|
||||||
|
const cached = bodyCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
// 1. Check if semantic launcher icon in THEME_ICON_BODIES
|
||||||
|
const semantic = (THEME_ICON_BODIES[set] as Record<string, ThemeIconBody> | undefined)?.[name];
|
||||||
|
if (semantic) {
|
||||||
|
bodyCache.set(cacheKey, semantic);
|
||||||
|
return semantic;
|
||||||
|
}
|
||||||
|
const semanticDefault = (
|
||||||
|
THEME_ICON_BODIES[DEFAULT_THEME_ICON_SET] as Record<string, ThemeIconBody> | undefined
|
||||||
|
)?.[name];
|
||||||
|
if (semanticDefault) {
|
||||||
|
bodyCache.set(cacheKey, semanticDefault);
|
||||||
|
return semanticDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check for pack prefix like "lucide:xxx", "ph:xxx" / "phosphor:xxx", "tabler:xxx"
|
||||||
|
if (name.includes(':')) {
|
||||||
|
const colonIdx = name.indexOf(':');
|
||||||
|
const prefix = name.slice(0, colonIdx).toLowerCase();
|
||||||
|
const iconId = name.slice(colonIdx + 1);
|
||||||
|
|
||||||
|
const packCol =
|
||||||
|
prefix === 'lucide'
|
||||||
|
? collections.lucide
|
||||||
|
: prefix === 'ph' || prefix === 'phosphor'
|
||||||
|
? collections.ph
|
||||||
|
: prefix === 'tabler'
|
||||||
|
? collections.tabler
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (packCol) {
|
||||||
|
const resolved = resolveFromCollection(packCol, iconId);
|
||||||
|
if (resolved) {
|
||||||
|
bodyCache.set(cacheKey, resolved);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try current set collection
|
||||||
|
const currentCollectionKey = set === 'phosphor' ? 'ph' : set === 'tabler' ? 'tabler' : 'lucide';
|
||||||
|
const directCurrent = resolveFromCollection(collections[currentCollectionKey], name);
|
||||||
|
if (directCurrent) {
|
||||||
|
bodyCache.set(cacheKey, directCurrent);
|
||||||
|
return directCurrent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Try other collections (lucide, ph, tabler)
|
||||||
|
const lucideResolved = resolveFromCollection(collections.lucide, name);
|
||||||
|
if (lucideResolved) {
|
||||||
|
bodyCache.set(cacheKey, lucideResolved);
|
||||||
|
return lucideResolved;
|
||||||
|
}
|
||||||
|
const phResolved = resolveFromCollection(collections.ph, name);
|
||||||
|
if (phResolved) {
|
||||||
|
bodyCache.set(cacheKey, phResolved);
|
||||||
|
return phResolved;
|
||||||
|
}
|
||||||
|
const tablerResolved = resolveFromCollection(collections.tabler, name);
|
||||||
|
if (tablerResolved) {
|
||||||
|
bodyCache.set(cacheKey, tablerResolved);
|
||||||
|
return tablerResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback icon
|
||||||
|
return FALLBACK_ICON;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cached full catalog
|
||||||
|
let fullCatalog: ThemeIconCatalogItem[] | null = null;
|
||||||
|
|
||||||
|
export function getFullIconCatalog(): ThemeIconCatalogItem[] {
|
||||||
|
if (fullCatalog) return fullCatalog;
|
||||||
|
|
||||||
|
const items: ThemeIconCatalogItem[] = [];
|
||||||
|
|
||||||
|
// 1. Semantic Launcher icons
|
||||||
|
for (const name of THEME_ICON_NAMES) {
|
||||||
|
items.push({
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
pack: 'launcher',
|
||||||
|
packLabel: 'Launcher',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Lucide icons
|
||||||
|
for (const name of Object.keys(collections.lucide.icons)) {
|
||||||
|
items.push({
|
||||||
|
id: `lucide:${name}`,
|
||||||
|
name,
|
||||||
|
pack: 'lucide',
|
||||||
|
packLabel: 'Lucide',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Phosphor icons
|
||||||
|
for (const name of Object.keys(collections.ph.icons)) {
|
||||||
|
items.push({
|
||||||
|
id: `ph:${name}`,
|
||||||
|
name,
|
||||||
|
pack: 'phosphor',
|
||||||
|
packLabel: 'Phosphor',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Tabler icons
|
||||||
|
for (const name of Object.keys(collections.tabler.icons)) {
|
||||||
|
items.push({
|
||||||
|
id: `tabler:${name}`,
|
||||||
|
name,
|
||||||
|
pack: 'tabler',
|
||||||
|
packLabel: 'Tabler',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fullCatalog = items;
|
||||||
|
return items;
|
||||||
|
}
|
||||||
@@ -76,12 +76,12 @@ export const THEME_ICON_NAMES = [
|
|||||||
'volume-off',
|
'volume-off',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ThemeIconName = (typeof THEME_ICON_NAMES)[number];
|
export type ThemeIconName = (typeof THEME_ICON_NAMES)[number] | (string & {});
|
||||||
|
|
||||||
const ICON_NAME_SET = new Set<string>(THEME_ICON_NAMES);
|
const ICON_NAME_SET = new Set<string>(THEME_ICON_NAMES);
|
||||||
|
|
||||||
export const isThemeIconName = (value: unknown): value is ThemeIconName =>
|
export const isThemeIconName = (value: unknown): value is ThemeIconName =>
|
||||||
typeof value === 'string' && ICON_NAME_SET.has(value);
|
typeof value === 'string' && value.trim().length > 0 && value.length <= 120;
|
||||||
|
|
||||||
/** A small representative row used by the editor's live icon preview. */
|
/** A small representative row used by the editor's live icon preview. */
|
||||||
export const THEME_ICON_PREVIEW_NAMES: ThemeIconName[] = [
|
export const THEME_ICON_PREVIEW_NAMES: ThemeIconName[] = [
|
||||||
|
|||||||
+14
-14
@@ -1,4 +1,5 @@
|
|||||||
import { resolveThemeDataValue, themeGlobalData } from './themeData';
|
import { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from './themeData';
|
||||||
|
import { getThemeIconBody } from './themeIconCatalog';
|
||||||
import { THEME_ICON_BODIES } from './themeIconData';
|
import { THEME_ICON_BODIES } from './themeIconData';
|
||||||
import {
|
import {
|
||||||
themeArcGeometry,
|
themeArcGeometry,
|
||||||
@@ -309,9 +310,7 @@ const loadImageAsset = (context: PreviewContext, path: string) => {
|
|||||||
|
|
||||||
const loadThemeIcon = (context: PreviewContext, name: string, color: string) => {
|
const loadThemeIcon = (context: PreviewContext, name: string, color: string) => {
|
||||||
const set = context.definition.iconSet ?? 'lucide';
|
const set = context.definition.iconSet ?? 'lucide';
|
||||||
const icon =
|
const icon = getThemeIconBody(name, set);
|
||||||
THEME_ICON_BODIES[set]?.[name as keyof (typeof THEME_ICON_BODIES)[typeof set]] ??
|
|
||||||
THEME_ICON_BODIES.lucide[name as keyof (typeof THEME_ICON_BODIES)['lucide']];
|
|
||||||
if (!icon) return Promise.resolve(undefined);
|
if (!icon) return Promise.resolve(undefined);
|
||||||
|
|
||||||
const key = `${set}|${name}|${color}`;
|
const key = `${set}|${name}|${color}`;
|
||||||
@@ -948,15 +947,6 @@ const drawSidebar = (context: PreviewContext, logo?: LoadedMedia) => {
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
fillRoundedRect(ctx, centerX - 14, 75, 28, 13, 6.5, accent);
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = accentText;
|
|
||||||
ctx.font = `600 8px ${quoteFamily('Sora Variable')}, ui-sans-serif, sans-serif`;
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillText('3.2.7', centerX, 82);
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
const navIcons = ['home', 'palette', 'sliders'] as const;
|
const navIcons = ['home', 'palette', 'sliders'] as const;
|
||||||
navIcons.forEach((icon, index) => {
|
navIcons.forEach((icon, index) => {
|
||||||
const y = 99 + index * 60;
|
const y = 99 + index * 60;
|
||||||
@@ -1372,8 +1362,18 @@ const drawDataElement = (context: PreviewContext, element: ThemeV2DataElement) =
|
|||||||
screen: 'home',
|
screen: 'home',
|
||||||
mode: element.mode,
|
mode: element.mode,
|
||||||
});
|
});
|
||||||
|
const fallback = element.defaultValue ?? '';
|
||||||
|
const sample = getThemeDataSampleValue(element.field, element.mode);
|
||||||
|
const value =
|
||||||
|
raw && raw.trim() !== '' ? raw : fallback && fallback.trim() !== '' ? fallback : sample;
|
||||||
const template = element.text?.trim() ? element.text : '{value}';
|
const template = element.text?.trim() ? element.text : '{value}';
|
||||||
const text = interpolateThemeText(template.replaceAll('{value}', raw), context.variables);
|
const text = interpolateThemeText(
|
||||||
|
template
|
||||||
|
.replaceAll('{value}', value)
|
||||||
|
.replaceAll('{defaultValue}', fallback)
|
||||||
|
.replaceAll('{fallback}', fallback),
|
||||||
|
context.variables
|
||||||
|
);
|
||||||
drawTextElement(context, { ...element, type: 'text', text } as ThemeV2TextElement);
|
drawTextElement(context, { ...element, type: 'text', text } as ThemeV2TextElement);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+30
-1
@@ -141,6 +141,10 @@ export type ThemeV2Group = ThemeV2Transform & {
|
|||||||
mask?: ThemeV2Mask;
|
mask?: ThemeV2Mask;
|
||||||
/** Space-separated CSS classes inherited by every layer in the group. */
|
/** Space-separated CSS classes inherited by every layer in the group. */
|
||||||
cssClasses?: string;
|
cssClasses?: string;
|
||||||
|
/** Smoothly morph text and number changes using torph across layers in this group. */
|
||||||
|
morph?: boolean;
|
||||||
|
/** Morph animation duration in milliseconds (default 400). */
|
||||||
|
morphDuration?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A shape or image used to clip a layer or group. */
|
/** A shape or image used to clip a layer or group. */
|
||||||
@@ -185,6 +189,10 @@ export type ThemeV2ElementBase = ThemeV2Transform & {
|
|||||||
mask?: ThemeV2Mask;
|
mask?: ThemeV2Mask;
|
||||||
/** Space-separated CSS classes added to the rendered layer wrapper. */
|
/** Space-separated CSS classes added to the rendered layer wrapper. */
|
||||||
cssClasses?: string;
|
cssClasses?: string;
|
||||||
|
/** Smoothly morph text and number changes using torph when content changes. */
|
||||||
|
morph?: boolean;
|
||||||
|
/** Morph animation duration in milliseconds (default 400). */
|
||||||
|
morphDuration?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ThemeV2TextDecoration =
|
export type ThemeV2TextDecoration =
|
||||||
@@ -263,6 +271,8 @@ export type ThemeV2DataElement = ThemeV2ElementBase &
|
|||||||
mode?: ThemeV2DataMode;
|
mode?: ThemeV2DataMode;
|
||||||
/** Template for the value; `{value}` is replaced. Defaults to `{value}`. */
|
/** Template for the value; `{value}` is replaced. Defaults to `{value}`. */
|
||||||
text: string;
|
text: string;
|
||||||
|
/** Fallback value when live data is missing or empty (e.g. when logged out). */
|
||||||
|
defaultValue?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ThemeV2ImageElement = ThemeV2ElementBase & {
|
export type ThemeV2ImageElement = ThemeV2ElementBase & {
|
||||||
@@ -755,6 +765,11 @@ const normalizeGroups = (raw: unknown): ThemeV2Group[] => {
|
|||||||
const filters = normalizeFilters(source.filters);
|
const filters = normalizeFilters(source.filters);
|
||||||
const cssClasses = themeClassNames(source.cssClasses);
|
const cssClasses = themeClassNames(source.cssClasses);
|
||||||
const mask = normalizeMask(source.mask);
|
const mask = normalizeMask(source.mask);
|
||||||
|
const morph = typeof source.morph === 'boolean' ? source.morph : undefined;
|
||||||
|
const morphDuration =
|
||||||
|
typeof source.morphDuration === 'number' && Number.isFinite(source.morphDuration)
|
||||||
|
? clamp(source.morphDuration, 400, 50, 5000)
|
||||||
|
: undefined;
|
||||||
const group: ThemeV2Group = {
|
const group: ThemeV2Group = {
|
||||||
id,
|
id,
|
||||||
name: typeof source.name === 'string' ? source.name.slice(0, 80) : undefined,
|
name: typeof source.name === 'string' ? source.name.slice(0, 80) : undefined,
|
||||||
@@ -769,6 +784,8 @@ const normalizeGroups = (raw: unknown): ThemeV2Group[] => {
|
|||||||
...(filters ? { filters } : {}),
|
...(filters ? { filters } : {}),
|
||||||
...(mask ? { mask } : {}),
|
...(mask ? { mask } : {}),
|
||||||
...(cssClasses ? { cssClasses } : {}),
|
...(cssClasses ? { cssClasses } : {}),
|
||||||
|
...(morph !== undefined ? { morph } : {}),
|
||||||
|
...(morphDuration !== undefined ? { morphDuration } : {}),
|
||||||
};
|
};
|
||||||
groups.push(group);
|
groups.push(group);
|
||||||
});
|
});
|
||||||
@@ -899,6 +916,11 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin
|
|||||||
'text'
|
'text'
|
||||||
);
|
);
|
||||||
const cssClasses = themeClassNames(source.cssClasses);
|
const cssClasses = themeClassNames(source.cssClasses);
|
||||||
|
const morph = typeof source.morph === 'boolean' ? source.morph : undefined;
|
||||||
|
const morphDuration =
|
||||||
|
typeof source.morphDuration === 'number' && Number.isFinite(source.morphDuration)
|
||||||
|
? clamp(source.morphDuration, 400, 50, 5000)
|
||||||
|
: undefined;
|
||||||
const base = {
|
const base = {
|
||||||
id: text(source.id, `element_${index + 1}`).slice(0, 64),
|
id: text(source.id, `element_${index + 1}`).slice(0, 64),
|
||||||
name: typeof source.name === 'string' ? source.name.slice(0, 80) : undefined,
|
name: typeof source.name === 'string' ? source.name.slice(0, 80) : undefined,
|
||||||
@@ -915,6 +937,8 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin
|
|||||||
filters: normalizeFilters(source.filters),
|
filters: normalizeFilters(source.filters),
|
||||||
mask: normalizeMask(source.mask),
|
mask: normalizeMask(source.mask),
|
||||||
...(cssClasses ? { cssClasses } : {}),
|
...(cssClasses ? { cssClasses } : {}),
|
||||||
|
...(morph !== undefined ? { morph } : {}),
|
||||||
|
...(morphDuration !== undefined ? { morphDuration } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type === 'text') {
|
if (type === 'text') {
|
||||||
@@ -933,6 +957,9 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin
|
|||||||
field: oneOf(source.field, DATA_FIELD_VALUES, 'user.name' as ThemeV2DataField),
|
field: oneOf(source.field, DATA_FIELD_VALUES, 'user.name' as ThemeV2DataField),
|
||||||
mode: oneOf(source.mode, ['osu', 'taiko', 'catch', 'mania'] as const, 'osu'),
|
mode: oneOf(source.mode, ['osu', 'taiko', 'catch', 'mania'] as const, 'osu'),
|
||||||
text: typeof source.text === 'string' ? source.text.slice(0, 400) : '{value}',
|
text: typeof source.text === 'string' ? source.text.slice(0, 400) : '{value}',
|
||||||
|
...(typeof source.defaultValue === 'string'
|
||||||
|
? { defaultValue: source.defaultValue.slice(0, 200) }
|
||||||
|
: {}),
|
||||||
...normalizeTextStyle(source),
|
...normalizeTextStyle(source),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1445,6 +1472,7 @@ export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): T
|
|||||||
type,
|
type,
|
||||||
field: 'user.name',
|
field: 'user.name',
|
||||||
text: 'Welcome, {value}!',
|
text: 'Welcome, {value}!',
|
||||||
|
defaultValue: 'Guest',
|
||||||
fontSize: 26,
|
fontSize: 26,
|
||||||
width: 420,
|
width: 420,
|
||||||
height: 60,
|
height: 60,
|
||||||
@@ -2258,10 +2286,11 @@ export const themeV2Css = (
|
|||||||
assetOverrides: Record<string, string> = {}
|
assetOverrides: Record<string, string> = {}
|
||||||
) => {
|
) => {
|
||||||
const accent = definition.accentColor;
|
const accent = definition.accentColor;
|
||||||
|
const accentText = accent ? readableTextColor(accent) : '#ffffff';
|
||||||
// `.dark` also defines the primary variables, so the override needs a
|
// `.dark` also defines the primary variables, so the override needs a
|
||||||
// selector that is at least as specific as `html.dark`.
|
// selector that is at least as specific as `html.dark`.
|
||||||
const accentCss = accent
|
const accentCss = accent
|
||||||
? `:root,:root.dark,:root.light{--primary:${accent};--primary-foreground:${readableTextColor(accent)};--ring:${accent};--color-primary:${accent};--color-primary-500:${accent};--color-primary-foreground:${readableTextColor(accent)};}`
|
? `:root,:root.dark,:root.light{--primary:${accent};--primary-foreground:${accentText};--accent:${accent};--accent-foreground:${accentText};--ring:${accent};--color-primary:${accent};--color-primary-500:${accent};--color-primary-foreground:${accentText};--color-accent:${accent};--color-accent-foreground:${accentText};}`
|
||||||
: '';
|
: '';
|
||||||
const body = definition.typography?.body?.trim();
|
const body = definition.typography?.body?.trim();
|
||||||
const heading = definition.typography?.heading?.trim();
|
const heading = definition.typography?.heading?.trim();
|
||||||
|
|||||||
+144
-5
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import Logo from '$assets/logo.png';
|
import Logo from '$assets/logo.png';
|
||||||
|
import DiscordPresenceEditor from '$lib/components/presence/DiscordPresenceEditor.svelte';
|
||||||
import ThemeEditorWindow from '$lib/components/theme/ThemeEditorWindow.svelte';
|
import ThemeEditorWindow from '$lib/components/theme/ThemeEditorWindow.svelte';
|
||||||
import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
|
import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
import Titlebar from '$lib/components/ui/titlebar/titlebar.svelte';
|
import Titlebar from '$lib/components/ui/titlebar/titlebar.svelte';
|
||||||
import { config } from '$lib/config';
|
import { config } from '$lib/config';
|
||||||
import { Config } from '$lib/config_old';
|
import { Config } from '$lib/config_old';
|
||||||
|
import { currentUserInfo } from '$lib/data';
|
||||||
import {
|
import {
|
||||||
active_custom_theme,
|
active_custom_theme,
|
||||||
currentLoadingInfo,
|
currentLoadingInfo,
|
||||||
@@ -20,10 +22,14 @@
|
|||||||
discordPresence,
|
discordPresence,
|
||||||
firstStartup,
|
firstStartup,
|
||||||
launcherVersion,
|
launcherVersion,
|
||||||
|
onlineFriends,
|
||||||
openTabletDriverEnabled,
|
openTabletDriverEnabled,
|
||||||
openTabletDriverPath,
|
openTabletDriverPath,
|
||||||
|
osuBuild,
|
||||||
|
osuStream,
|
||||||
platform,
|
platform,
|
||||||
presenceLoading,
|
presenceLoading,
|
||||||
|
serverPing,
|
||||||
setupValues,
|
setupValues,
|
||||||
theme_reload_token,
|
theme_reload_token,
|
||||||
trackingEnabled,
|
trackingEnabled,
|
||||||
@@ -41,6 +47,7 @@
|
|||||||
import { isThemeTrusted } from '$lib/themeTrust';
|
import { isThemeTrusted } from '$lib/themeTrust';
|
||||||
import { themeV2Css } from '$lib/themeV2';
|
import { themeV2Css } from '$lib/themeV2';
|
||||||
import { getDownloadableThemes, getThemes, loadTheme } from '$lib/themes';
|
import { getDownloadableThemes, getThemes, loadTheme } from '$lib/themes';
|
||||||
|
import { currentUser } from '$lib/userAuthentication';
|
||||||
import {
|
import {
|
||||||
cursorSmoothening,
|
cursorSmoothening,
|
||||||
customCursor,
|
customCursor,
|
||||||
@@ -50,19 +57,28 @@
|
|||||||
} from '$lib/userSettings';
|
} from '$lib/userSettings';
|
||||||
import { trackMediaContainer } from '$lib/utils';
|
import { trackMediaContainer } from '$lib/utils';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { Sparkles } from '@lucide/svelte';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { emit, listen } from '@tauri-apps/api/event';
|
||||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
|
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'buffer';
|
||||||
import { SemVer } from 'semver';
|
import { SemVer } from 'semver';
|
||||||
import { sileo } from 'sileo';
|
import { sileo } from 'sileo';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
let unlistenEditorState: (() => void) | undefined;
|
let unlistenEditorState: (() => void) | undefined;
|
||||||
onDestroy(() => unlistenEditorState?.());
|
let unlistenRequestEditorData: (() => void) | undefined;
|
||||||
|
let unlistenPresenceEditorState: (() => void) | undefined;
|
||||||
|
onDestroy(() => {
|
||||||
|
unlistenEditorState?.();
|
||||||
|
unlistenRequestEditorData?.();
|
||||||
|
unlistenPresenceEditorState?.();
|
||||||
|
});
|
||||||
|
|
||||||
if (browser) initLogging();
|
if (browser) initLogging();
|
||||||
|
|
||||||
@@ -75,7 +91,18 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const presenceEditorWindow = (() => {
|
||||||
|
if (!browser) return false;
|
||||||
|
try {
|
||||||
|
return getCurrentWindow().label === 'presence-editor';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
let unsupported_platform = $state<boolean>(false);
|
let unsupported_platform = $state<boolean>(false);
|
||||||
|
let editorOpen = $state(false);
|
||||||
|
let presenceEditorOpen = $state(false);
|
||||||
|
|
||||||
function disableReload() {
|
function disableReload() {
|
||||||
if (window.location.hostname !== 'tauri.localhost') {
|
if (window.location.hostname !== 'tauri.localhost') {
|
||||||
@@ -120,21 +147,71 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (editorWindow) return;
|
try {
|
||||||
|
const ver = await getLauncherVersion();
|
||||||
|
if (ver) launcherVersion.set(ver);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editorWindow || presenceEditorWindow) return;
|
||||||
|
|
||||||
// The editor is a separate WebView, so the launcher cannot observe its
|
// The editor is a separate WebView, so the launcher cannot observe its
|
||||||
// focus directly. Native code emits this state when the window is created
|
// focus directly. Native code emits this state when the window is created
|
||||||
// or destroyed, and the launcher pauses its own theme media while it is
|
// or destroyed, and the launcher pauses its own theme media while it is
|
||||||
// open so both previews cannot play over each other.
|
// open so both previews cannot play over each other.
|
||||||
|
const syncThemeEditorData = async () => {
|
||||||
|
try {
|
||||||
|
await emit('sync-theme-editor-global-data', {
|
||||||
|
user: get(currentUser),
|
||||||
|
info: get(currentUserInfo),
|
||||||
|
friends: get(onlineFriends),
|
||||||
|
osuBuild: get(osuBuild),
|
||||||
|
osuStream: get(osuStream),
|
||||||
|
serverPing: get(serverPing),
|
||||||
|
launcherVersion: get(launcherVersion),
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
unlistenRequestEditorData = await listen('request-theme-editor-global-data', () => {
|
||||||
|
syncThemeEditorData();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not listen for theme editor data requests', error);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
unlistenEditorState = await listen<boolean>('theme-editor-state', (event) => {
|
unlistenEditorState = await listen<boolean>('theme-editor-state', (event) => {
|
||||||
themeMediaSuspended.set(event.payload === true);
|
const isOpen = event.payload === true;
|
||||||
|
themeMediaSuspended.set(isOpen);
|
||||||
|
editorOpen = isOpen;
|
||||||
|
if (isOpen) syncThemeEditorData();
|
||||||
});
|
});
|
||||||
if (await WebviewWindow.getByLabel('theme-editor')) themeMediaSuspended.set(true);
|
if (await WebviewWindow.getByLabel('theme-editor')) {
|
||||||
|
themeMediaSuspended.set(true);
|
||||||
|
editorOpen = true;
|
||||||
|
syncThemeEditorData();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Could not sync the theme editor media state', error);
|
console.warn('Could not sync the theme editor media state', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
unlistenPresenceEditorState = await listen<boolean>('presence-editor-state', (event) => {
|
||||||
|
const isOpen = event.payload === true;
|
||||||
|
presenceEditorOpen = isOpen;
|
||||||
|
if (isOpen) syncThemeEditorData();
|
||||||
|
});
|
||||||
|
if (await WebviewWindow.getByLabel('presence-editor')) {
|
||||||
|
presenceEditorOpen = true;
|
||||||
|
syncThemeEditorData();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not sync the presence editor state', error);
|
||||||
|
}
|
||||||
|
|
||||||
window.Buffer = Buffer;
|
window.Buffer = Buffer;
|
||||||
|
|
||||||
disableReload();
|
disableReload();
|
||||||
@@ -320,7 +397,69 @@
|
|||||||
|
|
||||||
{#if editorWindow}
|
{#if editorWindow}
|
||||||
<ThemeEditorWindow />
|
<ThemeEditorWindow />
|
||||||
|
{:else if presenceEditorWindow}
|
||||||
|
<DiscordPresenceEditor standalone />
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if editorOpen}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-black/85 p-6 text-center select-none backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mb-4 flex size-14 items-center justify-center rounded-2xl bg-primary/20 text-primary ring-1 ring-inset ring-white/10"
|
||||||
|
>
|
||||||
|
<Sparkles class="size-7" />
|
||||||
|
</div>
|
||||||
|
<h2 class="text-base font-semibold text-white">Theme editor is open</h2>
|
||||||
|
<p class="mt-1.5 max-w-xs text-xs leading-relaxed text-muted-foreground">
|
||||||
|
The launcher is locked while editing themes. Finish your changes or close the editor to
|
||||||
|
resume.
|
||||||
|
</p>
|
||||||
|
<div class="mt-5 flex items-center gap-2.5">
|
||||||
|
<Button class="h-8 gap-1.5 px-3 text-xs" onclick={() => invoke('focus_theme_editor')}>
|
||||||
|
Bring Editor to Front
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
class="h-8 px-3 text-xs"
|
||||||
|
onclick={() => invoke('close_theme_editor')}
|
||||||
|
>
|
||||||
|
Close Editor
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if presenceEditorOpen}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-black/85 p-6 text-center select-none backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mb-4 flex size-14 items-center justify-center rounded-2xl bg-[#5865F2]/20 text-[#5865F2] ring-1 ring-inset ring-[#5865F2]/30"
|
||||||
|
>
|
||||||
|
<svg class="size-7 fill-current" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994.021-.041.001-.09-.041-.106a13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.929 1.793 8.18 1.793 12.061 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.894.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.028zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-base font-semibold text-white">Discord Rich Presence editor is open</h2>
|
||||||
|
<p class="mt-1.5 max-w-xs text-xs leading-relaxed text-muted-foreground">
|
||||||
|
The launcher is locked while editing your Rich Presence embed. Finish your changes or close
|
||||||
|
the editor to resume.
|
||||||
|
</p>
|
||||||
|
<div class="mt-5 flex items-center gap-2.5">
|
||||||
|
<Button class="h-8 gap-1.5 px-3 text-xs" onclick={() => invoke('focus_presence_editor')}>
|
||||||
|
Bring Editor to Front
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
class="h-8 px-3 text-xs"
|
||||||
|
onclick={() => invoke('close_presence_editor')}
|
||||||
|
>
|
||||||
|
Close Editor
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if $customCursor}
|
{#if $customCursor}
|
||||||
<OsuCursor smoothCursor={$cursorSmoothening} />
|
<OsuCursor smoothCursor={$cursorSmoothening} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
+91
-63
@@ -4,6 +4,7 @@
|
|||||||
import { ezppfarm } from '$lib/api/ezpp';
|
import { ezppfarm } from '$lib/api/ezpp';
|
||||||
import DiagnosticsPanel from '$lib/components/diagnostics/DiagnosticsPanel.svelte';
|
import DiagnosticsPanel from '$lib/components/diagnostics/DiagnosticsPanel.svelte';
|
||||||
import LogViewer from '$lib/components/logs/LogViewer.svelte';
|
import LogViewer from '$lib/components/logs/LogViewer.svelte';
|
||||||
|
import DiscordPresenceEditor from '$lib/components/presence/DiscordPresenceEditor.svelte';
|
||||||
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
|
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
|
||||||
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
|
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
@@ -20,6 +21,11 @@
|
|||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { config } from '$lib/config';
|
import { config } from '$lib/config';
|
||||||
import { currentUserInfo } from '$lib/data';
|
import { currentUserInfo } from '$lib/data';
|
||||||
|
import {
|
||||||
|
loadDiscordPresenceConfig,
|
||||||
|
pushIdlePresenceFromConfig,
|
||||||
|
pushPlayingPresenceFromConfig,
|
||||||
|
} from '$lib/discordPresenceConfig';
|
||||||
import { useDropZone } from '$lib/dropZone.svelte';
|
import { useDropZone } from '$lib/dropZone.svelte';
|
||||||
import {
|
import {
|
||||||
getGamemodeInt,
|
getGamemodeInt,
|
||||||
@@ -280,6 +286,7 @@
|
|||||||
let restoreCandidate = $state<{ theme: Theme; backup: ThemeBackup } | undefined>(undefined);
|
let restoreCandidate = $state<{ theme: Theme; backup: ThemeBackup } | undefined>(undefined);
|
||||||
let restoringTheme = $state(false);
|
let restoringTheme = $state(false);
|
||||||
let logViewerOpen = $state(false);
|
let logViewerOpen = $state(false);
|
||||||
|
let presenceEditorOpen = $state(false);
|
||||||
|
|
||||||
const themeFilterOptions = [
|
const themeFilterOptions = [
|
||||||
{ id: 'all', label: 'All themes', icon: 'all-themes' },
|
{ id: 'all', label: 'All themes', icon: 'all-themes' },
|
||||||
@@ -940,17 +947,7 @@
|
|||||||
const userStats = await ezppfarm.getUserInfo($currentUser.id, 'stats');
|
const userStats = await ezppfarm.getUserInfo($currentUser.id, 'stats');
|
||||||
const userStatus = await ezppfarm.getUserStatus($currentUser.id);
|
const userStatus = await ezppfarm.getUserStatus($currentUser.id);
|
||||||
if (userStatus?.player_status.online) {
|
if (userStatus?.player_status.online) {
|
||||||
let largeImageKey = 'ezppfarm';
|
let actionName = 'Idle...';
|
||||||
let details = 'Idle...';
|
|
||||||
let customButton: { text?: string; url?: string } = {
|
|
||||||
text: undefined,
|
|
||||||
url: undefined,
|
|
||||||
};
|
|
||||||
let state =
|
|
||||||
userStatus.player_status.status.info_text.length > 0
|
|
||||||
? userStatus.player_status.status.info_text
|
|
||||||
: ' ';
|
|
||||||
|
|
||||||
const gamemode = getModeAndTypeFromGamemode(userStatus.player_status.status.mode);
|
const gamemode = getModeAndTypeFromGamemode(userStatus.player_status.status.mode);
|
||||||
const gamemodeName = getGamemodeName(
|
const gamemodeName = getGamemodeName(
|
||||||
modeIntToStr(gamemode.mode),
|
modeIntToStr(gamemode.mode),
|
||||||
@@ -959,76 +956,75 @@
|
|||||||
|
|
||||||
switch (userStatus.player_status.status.action) {
|
switch (userStatus.player_status.status.action) {
|
||||||
case EZPPActionStatus.AFK:
|
case EZPPActionStatus.AFK:
|
||||||
details = 'AFK...';
|
actionName = 'AFK...';
|
||||||
state = ' ';
|
|
||||||
break;
|
break;
|
||||||
case EZPPActionStatus.PLAYING:
|
case EZPPActionStatus.PLAYING:
|
||||||
details = 'Playing...';
|
actionName = 'Playing...';
|
||||||
break;
|
break;
|
||||||
case EZPPActionStatus.EDITING:
|
case EZPPActionStatus.EDITING:
|
||||||
details = 'Editing...';
|
actionName = 'Editing...';
|
||||||
break;
|
break;
|
||||||
case EZPPActionStatus.MODDING:
|
case EZPPActionStatus.MODDING:
|
||||||
details = 'Modding...';
|
actionName = 'Modding...';
|
||||||
break;
|
break;
|
||||||
case EZPPActionStatus.MULTIPLAYER_SELECT:
|
case EZPPActionStatus.MULTIPLAYER_SELECT:
|
||||||
details = 'Multiplayer: Selecting a Beatmap...';
|
actionName = 'Multiplayer: Selecting a Beatmap...';
|
||||||
state = ' ';
|
|
||||||
break;
|
break;
|
||||||
case EZPPActionStatus.WATCHING:
|
case EZPPActionStatus.WATCHING:
|
||||||
details = 'Watching...';
|
actionName = 'Watching...';
|
||||||
break;
|
break;
|
||||||
case EZPPActionStatus.TESTING:
|
case EZPPActionStatus.TESTING:
|
||||||
details = 'Testing...';
|
actionName = 'Testing...';
|
||||||
break;
|
break;
|
||||||
case EZPPActionStatus.SUBMITTING:
|
case EZPPActionStatus.SUBMITTING:
|
||||||
details = 'Submitting...';
|
actionName = 'Submitting...';
|
||||||
break;
|
break;
|
||||||
case EZPPActionStatus.MULTIPLAYER_IDLE:
|
case EZPPActionStatus.MULTIPLAYER_IDLE:
|
||||||
details = 'Multiplayer: Idle...';
|
actionName = 'Multiplayer: Idle...';
|
||||||
state = ' ';
|
|
||||||
break;
|
break;
|
||||||
case EZPPActionStatus.MULTIPLAYER_PLAYING:
|
case EZPPActionStatus.MULTIPLAYER_PLAYING:
|
||||||
details = 'Multiplayer: Playing...';
|
actionName = 'Multiplayer: Playing...';
|
||||||
break;
|
break;
|
||||||
case EZPPActionStatus.DIRECT:
|
case EZPPActionStatus.DIRECT:
|
||||||
details = 'Browsing osu!direct...';
|
actionName = 'Browsing osu!direct...';
|
||||||
state = ' ';
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userStatus.player_status.status.beatmap !== null) {
|
let beatmapCoverImage: string | null = null;
|
||||||
const beatmapCoverImage = `https://assets.ppy.sh/beatmaps/${userStatus.player_status.status.beatmap.set_id}/covers/[email protected]`;
|
const beatmap = userStatus.player_status.status.beatmap;
|
||||||
const isValidImage = await urlIsValidImage(beatmapCoverImage);
|
let beatmapString =
|
||||||
if (isValidImage) largeImageKey = beatmapCoverImage;
|
userStatus.player_status.status.info_text.length > 0
|
||||||
if (userStatus.player_status.status.action !== EZPPActionStatus.DIRECT)
|
? userStatus.player_status.status.info_text
|
||||||
customButton = {
|
: ' ';
|
||||||
text: 'View Beatmap',
|
|
||||||
url: `https://ez-pp.farm/beatmapsets/${userStatus.player_status.status.beatmap.set_id}/${userStatus.player_status.status.beatmap.id}`,
|
if (beatmap !== null) {
|
||||||
};
|
beatmapString = `${beatmap.artist} - ${beatmap.title} [${beatmap.version}]`;
|
||||||
|
const coverCandidate = `https://assets.ppy.sh/beatmaps/${beatmap.set_id}/covers/[email protected]`;
|
||||||
|
const isValidImage = await urlIsValidImage(coverCandidate);
|
||||||
|
if (isValidImage) beatmapCoverImage = coverCandidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
details = `[${gamemodeName}] ${details}`;
|
|
||||||
try {
|
try {
|
||||||
const currentModeStats =
|
const currentModeStats =
|
||||||
userStats?.player.stats[userStatus.player_status.status.mode];
|
userStats?.player.stats[userStatus.player_status.status.mode];
|
||||||
let username = $currentUser.name;
|
|
||||||
|
|
||||||
if (currentModeStats && currentModeStats.rank > 0)
|
await pushPlayingPresenceFromConfig({
|
||||||
username += ` (#${currentModeStats.rank})`;
|
user: $currentUser,
|
||||||
|
mode: gamemodeName,
|
||||||
await Promise.all([
|
action: actionName,
|
||||||
presence.updateUser({
|
beatmap: beatmapString,
|
||||||
username,
|
artist: beatmap?.artist,
|
||||||
id: $currentUser.id.toFixed(),
|
title: beatmap?.title,
|
||||||
}),
|
diff: beatmap?.version,
|
||||||
presence.updateButton(customButton),
|
version: $launcherVersion || undefined,
|
||||||
presence.updateStatus({
|
beatmapCoverUrl: beatmapCoverImage,
|
||||||
details,
|
beatmapUrl: beatmap
|
||||||
state,
|
? `https://ez-pp.farm/beatmapsets/${beatmap.set_id}/${beatmap.id}`
|
||||||
largeImageKey,
|
: null,
|
||||||
}),
|
rank: currentModeStats?.rank,
|
||||||
]);
|
pp: currentModeStats?.pp,
|
||||||
|
accuracy: currentModeStats ? `${currentModeStats.acc.toFixed(2)}%` : undefined,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
@@ -1052,17 +1048,10 @@
|
|||||||
if (presenceUpdater) {
|
if (presenceUpdater) {
|
||||||
window.clearInterval(presenceUpdater);
|
window.clearInterval(presenceUpdater);
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await pushIdlePresenceFromConfig({
|
||||||
presence.updateUser({
|
user: $currentUser,
|
||||||
username: ' ',
|
version: $launcherVersion || undefined,
|
||||||
id: null,
|
});
|
||||||
}),
|
|
||||||
presence.updateStatus({
|
|
||||||
details: ' ',
|
|
||||||
state: 'Idle in Launcher...',
|
|
||||||
largeImageKey: 'ezppfarm',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
@@ -1356,6 +1345,18 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
loadDiscordPresenceConfig().then((cfg) => {
|
||||||
|
if ($discordPresence) {
|
||||||
|
pushIdlePresenceFromConfig(
|
||||||
|
{
|
||||||
|
user: $currentUser,
|
||||||
|
version: $launcherVersion || undefined,
|
||||||
|
},
|
||||||
|
cfg
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
animate(ezppLogo, {
|
animate(ezppLogo, {
|
||||||
opacity: [0, 1],
|
opacity: [0, 1],
|
||||||
scale: [0.95, 1],
|
scale: [0.95, 1],
|
||||||
@@ -2494,6 +2495,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-6 py-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<Label class="text-sm">Activity Embed Customization</Label>
|
||||||
|
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
||||||
|
Customize details, actions, templates, interactive buttons,
|
||||||
|
and preview live Discord cards.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 gap-1.5 px-3 text-xs shrink-0"
|
||||||
|
onclick={async () => {
|
||||||
|
try {
|
||||||
|
await invoke('open_presence_editor');
|
||||||
|
} catch {
|
||||||
|
presenceEditorOpen = true;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeIcon name="palette" set={iconSet} class="size-3.5" />
|
||||||
|
Customize Activity
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-start justify-between gap-8 py-3">
|
<div class="flex items-start justify-between gap-8 py-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<Label class="text-sm" for="setting-tracking"
|
<Label class="text-sm" for="setting-tracking"
|
||||||
@@ -3296,6 +3322,8 @@
|
|||||||
{#if logViewerOpen}
|
{#if logViewerOpen}
|
||||||
<LogViewer onclose={() => (logViewerOpen = false)} />
|
<LogViewer onclose={() => (logViewerOpen = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<DiscordPresenceEditor bind:open={presenceEditorOpen} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
Reference in new issue
Block a user