From b9eef2aeaabbe29492d5e6f3855a2d03fd281b3a Mon Sep 17 00:00:00 2001 From: dest4590 Date: Tue, 27 Jan 2026 18:55:49 +0200 Subject: [PATCH 01/70] fix: missclick --- src-tauri/src/lib.rs | 85 +------------------------------------------- 1 file changed, 1 insertion(+), 84 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5593b698..2a205465 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -144,90 +144,7 @@ pub fn run() { commands::clients::delete_client, commands::clients::increment_client_counter, commands::clients::get_custom_clients, - commands::clients::add_custom_client,package org.collapseloader.atlas.domain.presets.entity; - -import jakarta.persistence.*; -import lombok.*; -import org.collapseloader.atlas.domain.users.entity.User; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; - -@Entity -@Table( - name = "presets", - indexes = { - @Index(name = "presets_owner_idx", columnList = "owner_id"), - @Index(name = "presets_public_idx", columnList = "is_public"), - @Index(name = "presets_created_idx", columnList = "created_at") - } -) -@Getter -@Setter -@ToString -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class Preset { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "owner_id", nullable = false) - @ToString.Exclude - private User owner; - - @Column(name = "name", nullable = false, length = 120) - private String name; - - @Column(length = 2048) - private String description; - - @Column(name = "is_public", nullable = false) - private boolean isPublic = true; - - @Embedded - private PresetTheme theme; - - @Column(name = "likes_count", nullable = false) - private long likesCount; - - @Column(name = "downloads_count", nullable = false) - private long downloadsCount; - - @Column(name = "comments_count", nullable = false) - private long commentsCount; - - @OneToMany(mappedBy = "preset", cascade = CascadeType.ALL, orphanRemoval = true) - @ToString.Exclude - @Builder.Default - private List likes = new ArrayList<>(); - - @OneToMany(mappedBy = "preset", cascade = CascadeType.ALL, orphanRemoval = true) - @ToString.Exclude - @Builder.Default - private List comments = new ArrayList<>(); - - @CreationTimestamp - @Column(name = "created_at", updatable = false) - private Instant createdAt; - - @UpdateTimestamp - @Column(name = "updated_at") - private Instant updatedAt; - - @PrePersist - private void ensureTheme() { - if (theme == null) { - theme = new PresetTheme(); - } - } -} - + commands::clients::add_custom_client, commands::clients::remove_custom_client, commands::clients::update_custom_client, commands::clients::launch_custom_client, From 024d625502ba4fa6bc5bde7995fd600a097d74a7 Mon Sep 17 00:00:00 2001 From: dest4590 Date: Tue, 27 Jan 2026 19:33:16 +0200 Subject: [PATCH 02/70] feat: fixed agent & roles parse + FRIENDS --- src-tauri/src/core/clients/internal/agent_overlay.rs | 11 +++++++++-- src/components/features/clients/ClientCard.vue | 5 ++--- src/i18n/locales/en.json | 10 +++++----- src/i18n/locales/ru.json | 10 +++++----- src/i18n/locales/ua.json | 10 +++++----- src/i18n/locales/zh_cn.json | 12 ++++++------ src/utils/roleBadge.ts | 2 +- 7 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/core/clients/internal/agent_overlay.rs b/src-tauri/src/core/clients/internal/agent_overlay.rs index 72daab9b..9b63783d 100644 --- a/src-tauri/src/core/clients/internal/agent_overlay.rs +++ b/src-tauri/src/core/clients/internal/agent_overlay.rs @@ -8,6 +8,13 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub message: Option, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct AgentOverlayInfo { pub agent_hash: String, @@ -180,12 +187,12 @@ impl AgentOverlayManager { return Err(format!("Backend returned error: {}", response.status())); } - let info: AgentOverlayInfo = response.json().await.map_err(|e| { + let info: ApiResponse = response.json().await.map_err(|e| { log_error!("Failed to parse agent/overlay info response: {}", e); format!("Failed to parse agent/overlay info: {e}") })?; - Ok(info) + Ok(info.data.ok_or_else(|| "No agent/overlay info found".to_string())?) } async fn download_file(url: &str, path: &PathBuf) -> Result<(), String> { diff --git a/src/components/features/clients/ClientCard.vue b/src/components/features/clients/ClientCard.vue index 9d79ca2b..7e0557af 100644 --- a/src/components/features/clients/ClientCard.vue +++ b/src/components/features/clients/ClientCard.vue @@ -1274,8 +1274,7 @@ onBeforeUnmount(() => { -
+
{
- {{ t('client.details.no_rating') }} + {{ t('client.details.login_to_rate') }}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4a14b5ab..7c25f86f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -281,11 +281,11 @@ "avatar_reset_failed": "Failed to reset avatar: {error}" }, "roles": { - "USER": "User", - "TESTER": "Tester", - "ADMIN": "Admin", - "DEVELOPER": "Developer", - "OWNER": "Owner" + "user": "User", + "tester": "Tester", + "admin": "Admin", + "developer": "Developer", + "owner": "Owner" }, "time": { "offline": "Offline", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 5ad1346d..caf229df 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -281,11 +281,11 @@ "avatar_reset_failed": "Не удалось сбросить аватар: {error}" }, "roles": { - "USER": "Пользователь", - "TESTER": "Тестер", - "ADMIN": "Админ", - "DEVELOPER": "Разработчик", - "OWNER": "Владелец" + "user": "Пользователь", + "tester": "Тестер", + "admin": "Админ", + "developer": "Разработчик", + "owner": "Владелец" }, "appLogs": { "autoScroll": "Автоматическая прокрутка", diff --git a/src/i18n/locales/ua.json b/src/i18n/locales/ua.json index 71e961f1..c4a52f8b 100644 --- a/src/i18n/locales/ua.json +++ b/src/i18n/locales/ua.json @@ -281,11 +281,11 @@ "avatar_reset_failed": "Не вдалося скинути аватар: {error}" }, "roles": { - "USER": "Користувач", - "TESTER": "Тестувальник", - "ADMIN": "Адміністратор", - "DEVELOPER": "Розробник", - "OWNER": "Власник" + "user": "Користувач", + "tester": "Тестувальник", + "admin": "Адміністратор", + "developer": "Розробник", + "owner": "Власник" }, "time": { "offline": "Офлайн", diff --git a/src/i18n/locales/zh_cn.json b/src/i18n/locales/zh_cn.json index 2f77037e..b92e9995 100644 --- a/src/i18n/locales/zh_cn.json +++ b/src/i18n/locales/zh_cn.json @@ -281,11 +281,11 @@ "avatar_reset_failed": "重置头像失败:{error}" }, "roles": { - "USER": "用户", - "TESTER": "测试员", - "ADMIN": "管理员", - "DEVELOPER": "开发者", - "OWNER": "所有者" + "user": "用户", + "tester": "测试员", + "admin": "管理员", + "developer": "开发者", + "owner": "所有者" }, "time": { "offline": "离线", @@ -1070,4 +1070,4 @@ "installation_complete": "安装完成" }, "login": "您需要登录您的帐户。" -} +} \ No newline at end of file diff --git a/src/utils/roleBadge.ts b/src/utils/roleBadge.ts index ebb34c94..cbb33ba8 100644 --- a/src/utils/roleBadge.ts +++ b/src/utils/roleBadge.ts @@ -9,7 +9,7 @@ export function getRoleBadge(role: string | null | undefined, t: (key: string) = owner: { class: 'badge badge-error', key: 'roles.owner' }, }; - const info = mapping[role] || { class: 'badge badge-outline', key: `roles.${role}` }; + const info = mapping[role.toLowerCase()] || { class: 'badge badge-outline', key: `roles.${role.toLowerCase()}` }; return { text: t(info.key), className: info.class }; } From 47747aa2b91ba4f7020bb188f03d525d3260035d Mon Sep 17 00:00:00 2001 From: dest4590 Date: Wed, 28 Jan 2026 01:45:35 +0200 Subject: [PATCH 03/70] feat: achivements, client and friends fix --- src-tauri/src/core/clients/client.rs | 7 - .../features/clients/ClientCard.vue | 63 +++----- .../features/profile/AchievementCard.vue | 65 ++++++++ src/composables/useUserStatus.ts | 22 ++- src/i18n/locales/en.json | 55 ++++++- src/i18n/locales/ru.json | 54 ++++++- src/i18n/locales/ua.json | 55 ++++++- src/i18n/locales/zh_cn.json | 57 ++++++- src/services/achievementService.ts | 30 ++++ src/services/userService.ts | 9 +- src/views/About.vue | 25 ++- src/views/AccountView.vue | 71 +++++++++ src/views/FriendsView.vue | 124 +++++++-------- src/views/News.vue | 57 ++++++- src/views/UserProfileView.vue | 145 +++++++++++++++--- 15 files changed, 680 insertions(+), 159 deletions(-) create mode 100644 src/components/features/profile/AchievementCard.vue create mode 100644 src/services/achievementService.ts diff --git a/src-tauri/src/core/clients/client.rs b/src-tauri/src/core/clients/client.rs index fd9c9a26..e581a9eb 100644 --- a/src-tauri/src/core/clients/client.rs +++ b/src-tauri/src/core/clients/client.rs @@ -962,13 +962,6 @@ impl Client { )); } - cmd.arg("--add-opens") - .arg("java.base/jdk.internal.misc=ALL-UNNAMED"); - cmd.arg("--add-opens").arg("java.base/sun.misc=ALL-UNNAMED"); - cmd.arg("--add-opens") - .arg("java.base/java.lang=ALL-UNNAMED"); - cmd.arg("--add-opens").arg("java.base/java.io=ALL-UNNAMED"); - cmd.arg("-cp").arg(classpath).arg(&self.main_class); if self.client_type == ClientType::Forge { diff --git a/src/components/features/clients/ClientCard.vue b/src/components/features/clients/ClientCard.vue index 7e0557af..cfb8132c 100644 --- a/src/components/features/clients/ClientCard.vue +++ b/src/components/features/clients/ClientCard.vue @@ -1260,53 +1260,34 @@ onBeforeUnmount(() => { class="stat-title text-[10px] font-bold uppercase tracking-widest opacity-60"> {{ t('client.details.rating') }}
-
-
- - -
- -
-
- +
+
+
+
-
- {{ t('client.details.login_to_rate') }} +
+ {{ ratingAvg.toFixed(1) }}/5 + + ({{ ratingCount }})
-
- {{ - ratingAvg.toFixed(1) - }}/5 - - ({{ - ratingCount - }}) +
+ {{ t('client.details.your_rating') }}: +
+ + +
+
+
+ {{ t('client.details.login_to_rate') }}
diff --git a/src/components/features/profile/AchievementCard.vue b/src/components/features/profile/AchievementCard.vue new file mode 100644 index 00000000..1f4fb602 --- /dev/null +++ b/src/components/features/profile/AchievementCard.vue @@ -0,0 +1,65 @@ + + + diff --git a/src/composables/useUserStatus.ts b/src/composables/useUserStatus.ts index cbaf02a7..07226c66 100644 --- a/src/composables/useUserStatus.ts +++ b/src/composables/useUserStatus.ts @@ -4,12 +4,13 @@ import { useStreamerMode } from './useStreamerMode'; interface StatusData { isOnline: boolean; - currentClientName?: string | null; + currentClientName: string | null; invisibleMode: boolean; lastSeen: string | null; username: string; nickname: string | null; lastStatusUpdate: string | null; + startedAt: string | null; } @@ -20,7 +21,8 @@ const globalStatus = reactive({ lastSeen: null, username: '', nickname: null, - lastStatusUpdate: null + lastStatusUpdate: null, + startedAt: null }); @@ -70,8 +72,8 @@ export function useUserStatus() { if (currentRequestId === lastRequestId) { updateLocalStatus(response); - console.log(`Status synced: ${globalStatus.isOnline ? 'online' : 'offline'}${globalStatus.invisibleMode ? ' (invisible)' : ''}`, - globalStatus.currentClientName ? `on client: ${globalStatus.currentClientName}` : ''); + console.log(`Status synced: ${globalStatus.isOnline ? 'online' : 'offline'}${globalStatus.invisibleMode ? ' (invisible)' : ''} `, + globalStatus.currentClientName ? `on client: ${globalStatus.currentClientName} ` : ''); } else { console.log('Ignoring stale status response'); } @@ -103,11 +105,15 @@ export function useUserStatus() { globalStatus.lastStatusUpdate = serverResponse.updated_at; globalStatus.lastSeen = globalStatus.isOnline ? null : serverResponse.updated_at; } + if (serverResponse.started_at) { + globalStatus.startedAt = serverResponse.started_at; + } const hasChanges = ( oldStatus.isOnline !== globalStatus.isOnline || oldStatus.currentClientName !== globalStatus.currentClientName || - oldStatus.invisibleMode !== globalStatus.invisibleMode + oldStatus.invisibleMode !== globalStatus.invisibleMode || + oldStatus.startedAt !== globalStatus.startedAt ); if (hasChanges) { @@ -136,7 +142,7 @@ export function useUserStatus() { }; const setPlayingClient = (clientName?: string | null, shouldQueue: boolean = true) => { - console.log(`Setting user playing client name: ${clientName}`); + console.log(`Setting user playing client name: ${clientName} `); globalStatus.isOnline = true; if (clientName !== undefined) globalStatus.currentClientName = clientName ?? null; if (shouldQueue) syncStatusToServer(true).catch(error => { @@ -145,7 +151,7 @@ export function useUserStatus() { }; const setInvisibleMode = (enable: boolean, shouldQueue: boolean = true) => { - console.log(`Setting invisible mode: ${enable ? 'enabled' : 'disabled'}`); + console.log(`Setting invisible mode: ${enable ? 'enabled' : 'disabled'} `); globalStatus.invisibleMode = enable; if (enable) { @@ -160,7 +166,7 @@ export function useUserStatus() { }; const setStreamerMode = (enable: boolean) => { - console.log(`Setting streamer mode: ${enable ? 'enabled' : 'disabled'}`); + console.log(`Setting streamer mode: ${enable ? 'enabled' : 'disabled'} `); streamer.setStreamerMode(enable); }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7c25f86f..78a16f5e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -962,6 +962,7 @@ "rating": "Rating", "no_rating": "No ratings yet", "login_to_rate": "Log in to rate", + "your_rating": "Your Rating", "source_link": "Source Link", "created": "Created", "changelog": "Changelog", @@ -1072,5 +1073,55 @@ "tooltip": "Happy Halloween! (Oct 25 - Nov 5)" } }, - "login": "You need to log in to your account." -} + "login": "You need to log in to your account.", + "achievements": { + "title": "Achievements", + "locked": "Locked", + "unlocked_at": "Unlocked {date}", + "login_to_unlock": "You need to be logged in to unlock this achievement.", + "secret_title": "Hidden Achievement", + "secret_description": "Keep playing to unlock this secret!", + "list": { + "WELCOME": { + "title": "Welcome!", + "description": "Download and update CollapseLoader." + }, + "PLAYED_1Hour": { + "title": "Getting Started", + "description": "Play for 1 hour." + }, + "PLAYED_10Hours": { + "title": "Dedicated", + "description": "Play for 10 hours." + }, + "FIRST_GAME": { + "title": "First Steps", + "description": "Launch your first client." + }, + "SECRET_FINDER": { + "title": "Secret Finder", + "description": "You found a secret!" + }, + "SOCIAL_BUTTERFLY": { + "title": "Social Butterfly", + "description": "Add a social link to your profile." + }, + "FREQUENT_FLYER": { + "title": "Frequent Flyer", + "description": "Launch clients 50 times." + }, + "PRESET_MAX": { + "title": "Fashionista", + "description": "Create and share a customization preset." + }, + "BETA_TESTER": { + "title": "Beta Tester", + "description": "Join the testing program." + }, + "COLLECTOR": { + "title": "Collector", + "description": "Install 5 different clients." + } + } + } +} \ No newline at end of file diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index caf229df..800f0fba 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -979,7 +979,8 @@ } }, "no_changelog_desc": "Обновления и изменения появятся здесь.", - "comments_tab": "Комментарии" + "comments_tab": "Комментарии", + "your_rating": "Ваш рейтинг" }, "comments": { "delete": "Удалить", @@ -1071,5 +1072,54 @@ "tooltip": "Хорошего хеллоувина! (25 окт - 5 ноя)" } }, - "login": "Вам необходимо войти в свою учетную запись." + "login": "Вам необходимо войти в свою учетную запись.", + "achievements": { + "title": "Достижения", + "locked": "Заблокировано", + "unlocked_at": "Разблокировано {date}", + "secret_title": "Скрытое достижение", + "secret_description": "Продолжайте играть, чтобы открыть этот секрет!", + "list": { + "WELCOME": { + "title": "Добро пожаловать!", + "description": "Скачайте и обновите CollapseLoader." + }, + "PLAYED_1Hour": { + "title": "Начало пути", + "description": "Играйте в течение 1 часа." + }, + "PLAYED_10Hours": { + "title": "Преданный игрок", + "description": "Играйте в течение 10 часов." + }, + "FIRST_GAME": { + "title": "Первые шаги", + "description": "Запустите свой первый клиент." + }, + "SECRET_FINDER": { + "title": "Искатель секретов", + "description": "Вы нашли секрет!" + }, + "SOCIAL_BUTTERFLY": { + "title": "Социальная бабочка", + "description": "Добавьте социальную ссылку в свой профиль." + }, + "FREQUENT_FLYER": { + "title": "Частый гость", + "description": "Запустите клиенты 50 раз." + }, + "PRESET_MAX": { + "title": "Модник", + "description": "Создайте и поделитесь пресетом кастомизации." + }, + "BETA_TESTER": { + "title": "Бета-тестер", + "description": "Присоединитесь к программе тестирования." + }, + "COLLECTOR": { + "title": "Коллекционер", + "description": "Установите 5 различных клиентов." + } + } + } } diff --git a/src/i18n/locales/ua.json b/src/i18n/locales/ua.json index c4a52f8b..72c3144f 100644 --- a/src/i18n/locales/ua.json +++ b/src/i18n/locales/ua.json @@ -978,7 +978,8 @@ } }, "no_changelog_desc": "Оновлення та зміни з'являться тут.", - "comments_tab": "Коментарі" + "comments_tab": "Коментарі", + "your_rating": "Ваш рейтинг" }, "comments": { "delete": "Видалити", @@ -1071,5 +1072,55 @@ "tooltip": "Гарного Хелловіна! (25 жовт - 5 лист)" } }, - "login": "Вам потрібно увійти у свій обліковий запис." + "login": "Вам потрібно увійти у свій обліковий запис.", + "achievements": { + "title": "Досягнення", + "locked": "Заблоковано", + "unlocked_at": "Розблоковано {date}", + "login_to_unlock": "Вам потрібно увійти в систему, щоб відкрити це досягнення.", + "secret_title": "Приховане досягнення", + "secret_description": "Продовжуйте грати, щоб відкрити цей секрет!", + "list": { + "WELCOME": { + "title": "Ласкаво просимо!", + "description": "Завантажте та оновіть CollapseLoader." + }, + "PLAYED_1Hour": { + "title": "Початок шляху", + "description": "Грайте протягом 1 години." + }, + "PLAYED_10Hours": { + "title": "Відданий гравець", + "description": "Грайте протягом 10 годин." + }, + "FIRST_GAME": { + "title": "Перші кроки", + "description": "Запустіть свій перший клієнт." + }, + "SECRET_FINDER": { + "title": "Шукач секретів", + "description": "Ви знайшли секрет!" + }, + "SOCIAL_BUTTERFLY": { + "title": "Соціальний метелик", + "description": "Додайте соціальне посилання у свій профіль." + }, + "FREQUENT_FLYER": { + "title": "Частий гість", + "description": "Запустіть клієнти 50 разів." + }, + "PRESET_MAX": { + "title": "Модник", + "description": "Створіть та поділіться пресетом кастомізації." + }, + "BETA_TESTER": { + "title": "Бета-тестер", + "description": "Приєднайтеся до програми тестування." + }, + "COLLECTOR": { + "title": "Колекціонер", + "description": "Встановіть 5 різних клієнтів." + } + } + } } diff --git a/src/i18n/locales/zh_cn.json b/src/i18n/locales/zh_cn.json index b92e9995..ad60c37d 100644 --- a/src/i18n/locales/zh_cn.json +++ b/src/i18n/locales/zh_cn.json @@ -965,7 +965,8 @@ } }, "no_changelog_desc": "更新与改动将显示在此处。", - "comments_tab": "评论" + "comments_tab": "评论", + "your_rating": "您的评价" }, "comments": { "delete": "删除", @@ -1069,5 +1070,55 @@ "download_complete": "下载完成", "installation_complete": "安装完成" }, - "login": "您需要登录您的帐户。" -} \ No newline at end of file + "login": "您需要登录您的帐户。", + "achievements": { + "title": "成就", + "locked": "未解锁", + "unlocked_at": "解锁于 {date}", + "login_to_unlock": "您需要登录才能解锁此成就。", + "secret_title": "隐藏成就", + "secret_description": "继续玩下去以揭开这个秘密!", + "list": { + "WELCOME": { + "title": "欢迎!", + "description": "下载并更新 CollapseLoader。" + }, + "PLAYED_1Hour": { + "title": "小试牛刀", + "description": "累计游玩 1 小时。" + }, + "PLAYED_10Hours": { + "title": "持之以恒", + "description": "累计游玩 10 小时。" + }, + "FIRST_GAME": { + "title": "迈出第一步", + "description": "启动您的第一个客户端。" + }, + "SECRET_FINDER": { + "title": "秘密发现者", + "description": "你发现了一个秘密!" + }, + "SOCIAL_BUTTERFLY": { + "title": "社交高手", + "description": "在个人资料中添加社交链接。" + }, + "FREQUENT_FLYER": { + "title": "常客", + "description": "启动客户端 50 次。" + }, + "PRESET_MAX": { + "title": "时尚达人", + "description": "创建并分享一个自定义预设。" + }, + "BETA_TESTER": { + "title": "内测玩家", + "description": "加入测试计划。" + }, + "COLLECTOR": { + "title": "收藏家", + "description": "安装 5 个不同的客户端。" + } + } + } +} diff --git a/src/services/achievementService.ts b/src/services/achievementService.ts new file mode 100644 index 00000000..6b5bdee1 --- /dev/null +++ b/src/services/achievementService.ts @@ -0,0 +1,30 @@ +import { apiClient } from './apiClient'; + +export interface Achievement { + id: number; + key: string; + icon: string; + hidden: boolean; + receivePercentage: number; +} + +export interface UserAchievement { + achievement: Achievement; + unlockedAt: string; +} + +class AchievementService { + async getAllAchievements(): Promise { + return await apiClient.get('/achievements'); + } + + async getUserAchievements(userId: number): Promise { + return await apiClient.get(`/achievements/users/${userId}`); + } + + async unlockAchievement(key: string): Promise { + return await apiClient.post(`/achievements/unlock/${key}`, {}); + } +} + +export const achievementService = new AchievementService(); diff --git a/src/services/userService.ts b/src/services/userService.ts index d1188d2d..d2ef8e06 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -39,6 +39,7 @@ export interface UserStatus { status: string; client_name: string | null; updated_at: string | null; + started_at?: string | null; } export interface ClientUserStatus { @@ -47,6 +48,7 @@ export interface ClientUserStatus { current_client: string | null; client_version?: string | null; updated_at: string | null; + started_at: string | null; raw_status: string | null; } @@ -69,6 +71,7 @@ export interface FriendRequest { export interface FriendRequestsBatch { sent: FriendRequest[]; received: FriendRequest[]; + blocked: FriendRequest[]; } export interface SearchUser { @@ -183,6 +186,7 @@ class UserService { current_client: status?.client_name ?? null, last_seen: isOnline ? null : updatedAt, updated_at: updatedAt, + started_at: status?.started_at ?? null, raw_status: raw, }; } @@ -377,6 +381,7 @@ class UserService { const requests: FriendRequestsBatch = { sent: (resp.requests?.sent || []).map((r: any) => this.mapFriendRequest(r)), received: (resp.requests?.received || []).map((r: any) => this.mapFriendRequest(r)), + blocked: (resp.requests?.blocked || []).map((r: any) => this.mapFriendRequest(r)), }; return { friends, requests }; @@ -435,7 +440,6 @@ class UserService { }; const consider = (r: FriendRequest) => { - if (r.status !== 'blocked') return; if (meId && r.requester.id === meId) add(r.addressee); else if (meId && r.addressee.id === meId) add(r.requester); else { @@ -444,8 +448,7 @@ class UserService { } }; - batch.requests.sent.forEach(consider); - batch.requests.received.forEach(consider); + batch.requests.blocked.forEach(consider); return blocked; } diff --git a/src/views/About.vue b/src/views/About.vue index 9d98e7df..ca7e38da 100644 --- a/src/views/About.vue +++ b/src/views/About.vue @@ -10,6 +10,8 @@ import IconGitHub from '../assets/icons/github.svg'; import IconTelegram from '../assets/icons/telegram.svg'; import IconDiscord from '../assets/icons/discord.svg'; import { CircleFadingArrowUp } from 'lucide-vue-next'; +import { achievementService } from '../services/achievementService'; +import { useUser } from '../composables/useUser'; const { t } = useI18n(); const LogoUrl = String(Logo); @@ -17,6 +19,7 @@ const IconGitHubUrl = String(IconGitHub); const IconTelegramUrl = String(IconTelegram); const IconDiscordUrl = String(IconDiscord); const { addToast } = useToast(); +const { isAuthenticated } = useUser(); const version = ref(''); const codename = ref(''); @@ -24,6 +27,26 @@ const commitHash = ref(''); const branch = ref(''); const development = ref(false); const isCheckingUpdates = ref(false); +const logoClicks = ref(0); + +const handleLogoClick = async () => { + logoClicks.value++; + if (logoClicks.value === 7) { + if (!isAuthenticated.value) { + addToast(t('achievements.login_to_unlock'), 'warning'); + logoClicks.value = 0; + return; + } + + try { + await achievementService.unlockAchievement('SECRET_FINDER'); + addToast(t('achievements.list.SECRET_FINDER.title') + ': ' + t('achievements.list.SECRET_FINDER.description'), 'success'); + } catch (error) { + console.error('Failed to unlock secret achievement:', error); + } + logoClicks.value = 0; + } +}; const getVersion = async () => { try { @@ -86,7 +109,7 @@ onMounted(async () => { diff --git a/src/components/presets/PresetColorPreview.vue b/src/components/presets/PresetColorPreview.vue new file mode 100644 index 00000000..a0141aa0 --- /dev/null +++ b/src/components/presets/PresetColorPreview.vue @@ -0,0 +1,25 @@ + + + diff --git a/src/components/presets/PresetGallery.vue b/src/components/presets/PresetGallery.vue index ec9a3da3..da15b808 100644 --- a/src/components/presets/PresetGallery.vue +++ b/src/components/presets/PresetGallery.vue @@ -5,63 +5,99 @@ {{ t('common.loading') }}
-
- +
+
+ + +
+
+ +
-
-
-
-

+
+
+
+

{{ p.title ?? p.name }}

-

- {{ t('marketplace.by_user', { name: getOwnerName(p) || '' }) }} +

+ {{ t('marketplace.by_author', { name: getOwnerName(p).toUpperCase() || '' }) }} +

+ + + +

+ {{ p.description }}

-

{{ p.description }}

-
- - - {{ p.likes_count }} + +
+ + + {{ p.likes_count || 0 }} - - - {{ p.downloads_count }} + + + {{ p.downloads_count || 0 }} - - {{ p.is_public ? t('marketplace.public_label') : t('marketplace.private_label') }} + + + {{ p.comments_count || 0 }}
-
- - - - - +

-
{{ t('marketplace.no_items') - }}
+
{{ t('marketplace.no_items') + }}
@@ -77,24 +113,28 @@ import { useModal } from '../../services/modalService'; import MarketplaceEditPresetModal from '../modals/social/presets/MarketplaceEditPresetModal.vue'; import PresetDetailsModal from '../modals/social/presets/PresetDetailsModal.vue'; import MarketplaceDeleteConfirmModal from '../modals/social/presets/MarketplaceDeleteConfirmModal.vue'; -import { Download, ThumbsUp } from 'lucide-vue-next'; +import { Download, ThumbsUp, MessageSquare, Search, Play, MoreVertical, Edit, Trash2, Eye, EyeOff } from 'lucide-vue-next'; import { buildPresetCreatePayload } from '../../utils/presetPayload'; +import PresetColorPreview from './PresetColorPreview.vue'; type MarketplacePresetView = MarketplacePreset & { liking?: boolean; }; +let debounceTimer: any = null; + function getThemeValues(preset: MarketplacePreset): MarketplaceTheme { return preset.theme || {}; } export default defineComponent({ name: 'PresetGallery', - components: { Download, ThumbsUp }, + components: { Download, ThumbsUp, MessageSquare, Search, Play, MoreVertical, Edit, Trash2, Eye, EyeOff, PresetColorPreview }, props: { ownerId: { type: Number, required: false } }, - setup(props) { + emits: ['show-user-profile'], + setup(props, { emit }) { const presets = ref([]); const loading = ref(true); const search = ref(''); @@ -104,11 +144,16 @@ export default defineComponent({ const { showModal, hideModal } = useModal(); + const sortBy = ref('newest'); + async function load() { loading.value = true; try { const params: any = {}; if (props.ownerId) params.owner = props.ownerId; + if (search.value.trim()) params.q = search.value.trim(); + params.sort = sortBy.value; + const data = await marketplaceService.listPresets(params); presets.value = data.map((preset) => ({ ...preset, liking: false })); } finally { @@ -116,16 +161,14 @@ export default defineComponent({ } } - const filteredPresets = computed(() => { - const q = search.value.trim().toLowerCase(); - if (!q) return presets.value; - return presets.value.filter((p) => { - const parts = [p.title ?? p.name, p.description, getOwnerName(p)] - .filter(Boolean) - .map((v) => String(v).toLowerCase()); - return parts.some((text) => text.includes(q)); - }); - }); + function debouncedLoad() { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + load(); + }, 300); + } + + const filteredPresets = computed(() => presets.value); function getOwnerName(p: MarketplacePreset): string { return p.author?.displayName ?? p.author?.username ?? p.owner_username ?? ''; @@ -225,7 +268,12 @@ export default defineComponent({ PresetDetailsModal, { title: t('marketplace.preset_details_title'), contentClass: 'wide' }, { id: p.id, onNavigate: (dir: 'prev' | 'next') => navigateFrom(p.id, dir) }, - {} + { + 'show-user-profile': (userId: number) => { + hideModal(id); + emit('show-user-profile', userId); + } + } ); } @@ -243,7 +291,12 @@ export default defineComponent({ PresetDetailsModal, { title: t('marketplace.preset_details_title'), contentClass: 'wide' }, { id: next.id, onNavigate: (d: 'prev' | 'next') => navigateFrom(next.id, d) }, - {} + { + 'show-user-profile': (userId: number) => { + hideModal(newId); + emit('show-user-profile', userId); + } + } ); } @@ -293,7 +346,10 @@ export default defineComponent({ toggleVisibility, askDelete, t, + sortBy, + debouncedLoad, getOwnerName, + load, }; } }); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 78a16f5e..05df6bf4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1042,6 +1042,10 @@ "private_label": "Private", "make_private": "Make private", "make_public": "Make public", + "sort_newest": "Newest", + "sort_popular": "Popular", + "sort_downloads": "Most Downloaded", + "sort_comments": "Most Commented", "edit_modal_title": "Edit Preset", "delete_confirm": "Are you sure you want to delete this preset? This action cannot be undone.", "share": "Share", @@ -1055,6 +1059,7 @@ "apply": "Apply", "like": "Like", "by_user": "by {name}", + "by_author": "OT {name}", "no_items": "No presets yet", "preset_details_title": "Preset Details", "preset_details": "Preset details", @@ -1082,10 +1087,6 @@ "secret_title": "Hidden Achievement", "secret_description": "Keep playing to unlock this secret!", "list": { - "WELCOME": { - "title": "Welcome!", - "description": "Download and update CollapseLoader." - }, "PLAYED_1Hour": { "title": "Getting Started", "description": "Play for 1 hour." diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 800f0fba..3878cbeb 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1041,6 +1041,10 @@ "private_label": "Приватный", "make_private": "Сделать приватным", "make_public": "Сделать публичным", + "sort_newest": "Новые", + "sort_popular": "Популярные", + "sort_downloads": "Много загрузок", + "sort_comments": "Много комментариев", "edit_modal_title": "Редактировать пресет", "delete_confirm": "Вы уверены, что хотите удалить этот пресет? Это действие нельзя отменить.", "share": "Поделиться", @@ -1054,7 +1058,8 @@ "apply": "Применить", "like": "Лайк", "by_user": "от {name}", - "no_items": "Пока нет пресетов", + "by_author": "ОТ {name}", + "no_items": "Пресеты еще не созданы", "preset_details_title": "Подробности пресета", "preset_details": "Детали пресета", "comments": "Комментарии", @@ -1080,10 +1085,6 @@ "secret_title": "Скрытое достижение", "secret_description": "Продолжайте играть, чтобы открыть этот секрет!", "list": { - "WELCOME": { - "title": "Добро пожаловать!", - "description": "Скачайте и обновите CollapseLoader." - }, "PLAYED_1Hour": { "title": "Начало пути", "description": "Играйте в течение 1 часа." diff --git a/src/i18n/locales/ua.json b/src/i18n/locales/ua.json index 72c3144f..db18de53 100644 --- a/src/i18n/locales/ua.json +++ b/src/i18n/locales/ua.json @@ -1041,6 +1041,10 @@ "private_label": "Приватний", "make_private": "Зробити приватним", "make_public": "Зробити публічним", + "sort_newest": "Нові", + "sort_popular": "Популярні", + "sort_downloads": "Багато завантажень", + "sort_comments": "Багато коментарів", "edit_modal_title": "Редагувати пресет", "delete_confirm": "Ви впевнені, що хочете видалити цей пресет? Цю дію не можна скасувати.", "share": "Поділитися", @@ -1054,6 +1058,7 @@ "apply": "Застосувати", "like": "Вподобати", "by_user": "від {name}", + "by_author": "ВІД {name}", "no_items": "Пресетів ще немає", "preset_details_title": "Деталі пресету", "preset_details": "Деталі пресету", @@ -1081,10 +1086,6 @@ "secret_title": "Приховане досягнення", "secret_description": "Продовжуйте грати, щоб відкрити цей секрет!", "list": { - "WELCOME": { - "title": "Ласкаво просимо!", - "description": "Завантажте та оновіть CollapseLoader." - }, "PLAYED_1Hour": { "title": "Початок шляху", "description": "Грайте протягом 1 години." diff --git a/src/i18n/locales/zh_cn.json b/src/i18n/locales/zh_cn.json index ad60c37d..01458c25 100644 --- a/src/i18n/locales/zh_cn.json +++ b/src/i18n/locales/zh_cn.json @@ -1079,10 +1079,6 @@ "secret_title": "隐藏成就", "secret_description": "继续玩下去以揭开这个秘密!", "list": { - "WELCOME": { - "title": "欢迎!", - "description": "下载并更新 CollapseLoader。" - }, "PLAYED_1Hour": { "title": "小试牛刀", "description": "累计游玩 1 小时。" diff --git a/src/views/Marketplace.vue b/src/views/Marketplace.vue index 72ed8ac6..b5e947b0 100644 --- a/src/views/Marketplace.vue +++ b/src/views/Marketplace.vue @@ -11,7 +11,7 @@
- +
From 2145aa4c3dfa7c79a62f0ed7e8ab2a4c0c72ae57 Mon Sep 17 00:00:00 2001 From: dest4590 Date: Wed, 28 Jan 2026 02:48:24 +0200 Subject: [PATCH 05/70] feat: implement user profile, friends management, and settings with internationalization and supporting backend services --- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/clients.rs | 24 +++- src-tauri/src/core/clients/manager.rs | 20 +++ src-tauri/src/core/storage/settings.rs | 2 + src-tauri/src/lib.rs | 61 ++++++++- src/components/presets/PresetGallery.vue | 9 +- src/composables/useAppInit.ts | 38 +++++- src/composables/useFriends.ts | 30 ++++- src/composables/useUser.ts | 9 ++ src/i18n/locales/en.json | 6 +- src/i18n/locales/ru.json | 6 +- src/i18n/locales/ua.json | 6 +- src/i18n/locales/zh_cn.json | 6 +- src/services/achievementService.ts | 9 +- src/services/settingsService.ts | 6 +- src/services/syncService.ts | 74 +++++++++-- src/services/userService.ts | 158 ++++++++++++++++++----- src/views/FriendsView.vue | 2 +- src/views/Settings.vue | 12 ++ src/views/UserProfileView.vue | 17 ++- 20 files changed, 424 insertions(+), 73 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f01ce5e5..bc994e8e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,7 +14,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2.5.3", features = [] } [dependencies] -tauri = { version = "2.9.5", features = ["devtools"] } +tauri = { version = "2.9.5", features = ["devtools", "tray-icon"] } tauri-plugin-opener = "2.5.3" tauri-plugin-notification = "2.3.3" tauri-plugin-deep-link = "2.4.6" diff --git a/src-tauri/src/commands/clients.rs b/src-tauri/src/commands/clients.rs index 06decd67..e2103fef 100644 --- a/src-tauri/src/commands/clients.rs +++ b/src-tauri/src/commands/clients.rs @@ -5,7 +5,7 @@ use core::clients::{ }; use serde::de::DeserializeOwned; use serde::Deserialize; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Manager, State}; use crate::core::{ clients::custom_clients::CustomClient, @@ -331,6 +331,17 @@ pub async fn launch_client( let options = LaunchOptions::new(app_handle.clone(), user_token.clone(), false); + let minimize_on_launch = { + let settings = SETTINGS.lock().unwrap(); + settings.minimize_to_tray_on_launch.value + }; + + if minimize_on_launch { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.hide(); + } + } + client.run(options, state.clients.manager.clone()).await } @@ -751,6 +762,17 @@ pub async fn launch_custom_client( let options = LaunchOptions::new(app_handle.clone(), user_token.clone(), true); + let minimize_on_launch = { + let settings = SETTINGS.lock().unwrap(); + settings.minimize_to_tray_on_launch.value + }; + + if minimize_on_launch { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.hide(); + } + } + client.run(options, state.clients.manager.clone()).await } diff --git a/src-tauri/src/core/clients/manager.rs b/src-tauri/src/core/clients/manager.rs index 26269802..2f701e31 100644 --- a/src-tauri/src/core/clients/manager.rs +++ b/src-tauri/src/core/clients/manager.rs @@ -215,6 +215,26 @@ impl ClientManager { }), ); + let minimize_to_tray_on_launch = { + let settings = crate::core::storage::settings::SETTINGS.lock().unwrap(); + settings.minimize_to_tray_on_launch.value + }; + + if minimize_to_tray_on_launch { + let running_clients = Client::get_running_clients(&Arc::new(Mutex::new(ClientManager { + clients: self.clients.clone(), + }))); + + let running_custom_clients = crate::core::clients::custom_clients::CustomClient::get_running_custom_clients(); + + if running_clients.is_empty() && running_custom_clients.is_empty() { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + } + Ok(()) } } diff --git a/src-tauri/src/core/storage/settings.rs b/src-tauri/src/core/storage/settings.rs index 0460be1d..fad6f786 100644 --- a/src-tauri/src/core/storage/settings.rs +++ b/src-tauri/src/core/storage/settings.rs @@ -125,6 +125,8 @@ define_settings! { hash_verify: Setting = (true, true), sync_client_settings: Setting = (true, true), dpi_bypass: Setting = (false, true), + minimize_to_tray_on_launch: Setting = (false, true), + close_to_tray: Setting = (false, true), } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2a205465..5b301c55 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,8 @@ use crate::{core::storage::data::APP_HANDLE, logging::Logger}; use std::sync::OnceLock; use std::sync::{Arc, Mutex}; use tauri::Manager; +use tauri::menu::{Menu, MenuItem}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use crate::core::{platform::check_platform_dependencies, utils::globals::CODENAME}; @@ -257,6 +259,53 @@ pub fn run() { Analytics::send_start_analytics(); }); + let show = MenuItem::with_id(app, "show", "Show", true, None::<&str>)?; + let hide = MenuItem::with_id(app, "hide", "Hide", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show, &hide, &quit])?; + + let _tray = TrayIconBuilder::new() + .icon(app.default_window_icon().unwrap().clone()) + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + "show" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + "hide" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.hide(); + } + } + "quit" => { + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + let is_visible = window.is_visible().unwrap_or(true); + if is_visible { + let _ = window.hide(); + } else { + let _ = window.show(); + let _ = window.set_focus(); + } + } + } + }) + .build(app)?; + #[cfg(target_os = "windows")] { use crate::core::utils::dpi; @@ -265,9 +314,15 @@ pub fn run() { Ok(()) }) - .on_window_event(|_window, event| { - if let tauri::WindowEvent::CloseRequested { .. } = event { - discord_rpc::shutdown(); + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + let settings = crate::core::storage::settings::SETTINGS.lock().unwrap(); + if settings.close_to_tray.value { + api.prevent_close(); + let _ = window.hide(); + } else { + discord_rpc::shutdown(); + } } }) .run(tauri::generate_context!()) diff --git a/src/components/presets/PresetGallery.vue b/src/components/presets/PresetGallery.vue index da15b808..57e1c8e8 100644 --- a/src/components/presets/PresetGallery.vue +++ b/src/components/presets/PresetGallery.vue @@ -131,7 +131,8 @@ export default defineComponent({ name: 'PresetGallery', components: { Download, ThumbsUp, MessageSquare, Search, Play, MoreVertical, Edit, Trash2, Eye, EyeOff, PresetColorPreview }, props: { - ownerId: { type: Number, required: false } + ownerId: { type: Number, required: false }, + initialPresets: { type: Array as () => MarketplacePresetView[], required: false } }, emits: ['show-user-profile'], setup(props, { emit }) { @@ -149,6 +150,12 @@ export default defineComponent({ async function load() { loading.value = true; try { + if (props.initialPresets && !search.value.trim() && sortBy.value === 'newest' && presets.value.length === 0) { + presets.value = props.initialPresets.map((preset) => ({ ...preset, liking: false })); + loading.value = false; + return; + } + const params: any = {}; if (props.ownerId) params.owner = props.ownerId; if (search.value.trim()) params.q = search.value.trim(); diff --git a/src/composables/useAppInit.ts b/src/composables/useAppInit.ts index 3f906558..22eab0a6 100644 --- a/src/composables/useAppInit.ts +++ b/src/composables/useAppInit.ts @@ -8,6 +8,7 @@ import {applyCursorForEvent, isHalloweenEvent} from '../utils/events'; import {useToast} from '../services/toastService'; import {globalUserStatus} from './useUserStatus'; import {useUser} from './useUser'; +import {userService} from '../services/userService'; import {globalFriends} from './useFriends'; import {updaterService} from '../services/updaterService'; import {syncService} from '../services/syncService'; @@ -26,8 +27,8 @@ export function useAppInit() { const { t, locale } = useI18n(); const { addToast } = useToast(); const { showModal } = useModal(); - const { loadUserData } = useUser(); - const { loadFriendsData } = globalFriends; + const { loadUserData, hydrateUser } = useUser(); + const { loadFriendsData, hydrateFriends } = globalFriends; const { initializeStatusSystem } = globalUserStatus; const showPreloader = ref(true); @@ -51,11 +52,35 @@ export function useAppInit() { if (!isAuthenticated || !isOnline.value) return; try { - await loadUserData(); + const initData = await userService.initializeUserFull(); + + const userInfo = { + id: initData.user.id, + username: initData.user.username, + email: initData.user.email, + role: initData.user.role, + created_at: initData.user.created_at, + updated_at: initData.user.updated_at, + last_login_at: initData.user.last_login_at ?? null, + }; + hydrateUser(initData.user.profile, userInfo); + + hydrateFriends(initData.friends); + initializeStatusSystem(); - await loadFriendsData(); + + await syncService.restoreFromInitData(initData); + } catch (error) { - console.error('Failed to initialize user data on startup:', error); + console.error('Failed to initialize user data (consolidated), falling back:', error); + try { + await loadUserData(); + initializeStatusSystem(); + await loadFriendsData(); + await syncService.checkAndRestoreOnStartup(); + } catch (fallbackError) { + console.error('Fallback initialization also failed:', fallbackError); + } } }; @@ -221,10 +246,9 @@ export function useAppInit() { await initializeUserDataWrapper(isAuthenticated.value); bootLogService.userDataSuccess(); - globalUserStatus.initializeStatusSystem(); bootLogService.syncInit(); - await syncService.checkAndRestoreOnStartup(); bootLogService.syncReady(); + } catch (error) { console.error('Failed to initialize user data on startup:', error); bootLogService.userDataFailed(); diff --git a/src/composables/useFriends.ts b/src/composables/useFriends.ts index 639cc35d..ca4d4940 100644 --- a/src/composables/useFriends.ts +++ b/src/composables/useFriends.ts @@ -222,12 +222,15 @@ export function useFriends() { const request = globalFriendsState.receivedRequests.find(req => req.id === requestId); if (request) { if (action === 'accept') { - globalFriendsState.friends.push({ - id: request.requester.id, - username: request.requester.username, - nickname: request.requester.nickname, - status: request.requester.status - }); + if (!globalFriendsState.friends.some(f => f.id === request.requester.id)) { + globalFriendsState.friends.push({ + id: request.requester.id, + username: request.requester.username, + nickname: request.requester.nickname, + avatar_url: request.requester.avatar_url, + status: request.requester.status + }); + } } const index = globalFriendsState.receivedRequests.indexOf(request); @@ -288,6 +291,19 @@ export function useFriends() { } }; + const hydrateFriends = (data: { friends: any[]; requests: { sent: any[]; received: any[]; blocked: any[] } }) => { + globalFriendsState.friends = (data.friends || []).map((f: any) => userService.mapFriend(f)); + globalFriendsState.sentRequests = (data.requests?.sent || []).map((r: any) => userService.mapFriendRequest(r)); + globalFriendsState.receivedRequests = (data.requests?.received || []).map((r: any) => userService.mapFriendRequest(r)); + globalFriendsState.isLoading = false; + globalFriendsState.isLoaded = true; + globalFriendsState.lastUpdated = new Date().toISOString(); + + if (!statusUpdateInterval.current) { + startStatusUpdates(); + } + }; + const stopStatusUpdates = (): void => { if (statusUpdateInterval.current) { @@ -321,6 +337,8 @@ export function useFriends() { refreshFriendsData, clearFriendsData, + hydrateFriends, + searchUsers, sendFriendRequest, respondToFriendRequest, diff --git a/src/composables/useUser.ts b/src/composables/useUser.ts index a9539c12..f9195c04 100644 --- a/src/composables/useUser.ts +++ b/src/composables/useUser.ts @@ -109,6 +109,14 @@ const clearUserData = (): void => { globalUserState.lastUpdated = null; }; +const hydrateUser = (profile: UserProfile, info: UserInfo): void => { + globalUserState.profile = profile; + globalUserState.info = info; + globalUserState.lastUpdated = new Date().toISOString(); + globalUserState.isLoaded = true; + globalUserState.isLoading = false; +}; + const refreshUserData = (): Promise => { return loadUserData(true); }; @@ -153,6 +161,7 @@ export function useUser() { loadUserData, updateUserProfile, clearUserData, + hydrateUser, refreshUserData, logout }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 05df6bf4..94268527 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -703,7 +703,9 @@ "irc_chat": "Chat with other CollapseLoader users in the IRC chat available in each client.", "hash_verify": "Enable hash verification to ensure downloaded clients are not corrupted. This helps keep your clients up to date.", "dpi_bypass": "Enable if a client download is stuck at 0%, the client won't start, or clients fail to load.", - "sync_client_settings": "Sync options, resourcepacks, and other settings across all clients." + "sync_client_settings": "Sync options, resourcepacks, and other settings across all clients.", + "minimize_to_tray_on_launch": "Minimize the app to system tray when a client is launched.", + "close_to_tray": "Minimize the app to system tray instead of closing when the close button is clicked." }, "ram_warning": {}, "telemetry": "Analytics", @@ -749,6 +751,8 @@ "restart_required": "Please restart the app for changes to take effect" }, "sync_client_settings": "Sync client settings", + "minimize_to_tray_on_launch": "Minimize to tray on launch", + "close_to_tray": "Close to tray", "reset_settings": "Reset settings", "sync_login_required": "Sign in to enable syncing", "not_signed_in": "You are not logged in" diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 3878cbeb..dac11f59 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -690,7 +690,9 @@ "irc_chat": "Общайтесь с другими пользователями CollapseLoader в IRC-чате в каждом клиенте.", "hash_verify": "Включите проверку хеша, чтобы убедиться, что загруженные клиенты не повреждены. Это помогает своевременно обновлять клиенты до последней версии.", "dpi_bypass": "Включайте если загрузка клиента застряла на 0%, клиент не запускается или клиенты не загружаются.", - "sync_client_settings": "Синхронизировать настройки, ресурспаки и другие параметры среди всех клиентов." + "sync_client_settings": "Синхронизировать настройки, ресурспаки и другие параметры среди всех клиентов.", + "minimize_to_tray_on_launch": "Сворачивать приложение в системный трей при запуске клиента.", + "close_to_tray": "Сворачивать приложение в системный трей вместо закрытия при нажатии кнопки закрытия." }, "ram_warning": {}, "telemetry": "Аналитика", @@ -736,6 +738,8 @@ "restart_required": "Перезапустите приложение, чтобы изменения вступили в силу" }, "sync_client_settings": "Синхронизировать настройки клиента", + "minimize_to_tray_on_launch": "Сворачивать в трей при запуске", + "close_to_tray": "Закрывать в трей", "reset_settings": "Сбросить настройки", "accounts_description": "Управляйте своими игровыми аккаунтами и легко переключайтесь между ними", "accounts_management": "Управление учетными записями", diff --git a/src/i18n/locales/ua.json b/src/i18n/locales/ua.json index db18de53..16e38254 100644 --- a/src/i18n/locales/ua.json +++ b/src/i18n/locales/ua.json @@ -687,7 +687,9 @@ "irc_chat": "Спілкуйтеся з іншими користувачами CollapseLoader в IRC-чаті, доступному в кожному клієнті.", "hash_verify": "Увімкніть перевірку хешу, щоб переконатися, що завантажені клієнти не пошкоджені. Це допомагає підтримувати ваші клієнти в актуальному стані.", "sync_client_settings": "Синхронізувати опції, ресурспаки та інші налаштування між усіма клієнтами.", - "dpi_bypass": "Увімкніть, якщо завантаження клієнта застрягло на 0%, клієнт не запускається або клієнти зовсім не завантажуються." + "dpi_bypass": "Увімкніть, якщо завантаження клієнта застрягло на 0%, клієнт не запускається або клієнти зовсім не завантажуються.", + "minimize_to_tray_on_launch": "Згортати додаток у системний трей при запуску клієнта.", + "close_to_tray": "Згортати додаток у системний трей замість закриття при натисканні кнопки закриття." }, "telemetry": "Аналітика", "open_data": "Відкрити папку з даними", @@ -732,6 +734,8 @@ "restart_required": "Будь ласка, перезапустіть застосунок, щоб зміни набули чинності" }, "sync_client_settings": "Синхронізувати налаштування клієнта", + "minimize_to_tray_on_launch": "Згортати в трей при запуску", + "close_to_tray": "Закривати в трей", "ram": { "warning": "Попередження: Ви вибрали {selectedRamMb} МБ оперативної пам'яті. Це перевищує рекомендований ліміт у 6 ГБ і може спричинити проблеми з продуктивністю або збій системи." }, diff --git a/src/i18n/locales/zh_cn.json b/src/i18n/locales/zh_cn.json index 01458c25..7fc2d3b2 100644 --- a/src/i18n/locales/zh_cn.json +++ b/src/i18n/locales/zh_cn.json @@ -676,7 +676,9 @@ "irc_chat": "在每个客户端中提供的 IRC 聊天中与其他 CollapseLoader 用户聊天。", "hash_verify": "启用哈希校验以确保下载的客户端未损坏。这有助于保持客户端最新。", "sync_client_settings": "在所有客户端之间同步选项、资源包及其他设置。", - "dpi_bypass": "如果客户端下载卡在 0%、客户端无法启动或客户端加载失败,则启用此功能。" + "dpi_bypass": "如果客户端下载卡在 0%、客户端无法启动或客户端加载失败,则启用此功能。", + "minimize_to_tray_on_launch": "启动客户端时将应用最小化到系统托盘。", + "close_to_tray": "点击关闭按钮时将应用最小化到系统托盘而不是关闭。" }, "telemetry": "分析", "open_data": "打开数据文件夹", @@ -721,6 +723,8 @@ "restart_required": "请重启应用以使更改生效" }, "sync_client_settings": "同步客户端设置", + "minimize_to_tray_on_launch": "启动时最小化到系统托盘", + "close_to_tray": "关闭到系统托盘", "ram": { "warning": "警告:您已选择 {selectedRamMb} MB 内存。此配置超过推荐的 6 GB 限制,可能导致性能问题或系统崩溃。" }, diff --git a/src/services/achievementService.ts b/src/services/achievementService.ts index 6b5bdee1..096225f6 100644 --- a/src/services/achievementService.ts +++ b/src/services/achievementService.ts @@ -14,8 +14,15 @@ export interface UserAchievement { } class AchievementService { + private achievementsCache: Achievement[] | null = null; + async getAllAchievements(): Promise { - return await apiClient.get('/achievements'); + if (this.achievementsCache) { + return this.achievementsCache; + } + const achievements = await apiClient.get('/achievements'); + this.achievementsCache = achievements; + return achievements; } async getUserAchievements(userId: number): Promise { diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index 5515964f..0f4d0573 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -30,7 +30,11 @@ class SettingsService { const loaded = await invoke('get_settings'); Object.keys(this.settings).forEach((k) => delete this.settings[k]); Object.entries(loaded || {}).forEach(([k, v]) => { - this.settings[k] = v; + if (v && typeof v === 'object' && 'value' in v && 'show' in v) { + this.settings[k] = v; + } else { + this.settings[k] = { value: v, show: true }; + } }); } catch (err) { console.error('Failed to load settings:', err); diff --git a/src/services/syncService.ts b/src/services/syncService.ts index 2016cab0..232eca60 100644 --- a/src/services/syncService.ts +++ b/src/services/syncService.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core'; -import { userService, type SyncData } from './userService'; +import { userService, type SyncData, type UserInitData } from './userService'; import { settingsService } from './settingsService'; import { globalUserStatus } from '../composables/useUserStatus'; @@ -102,6 +102,41 @@ class SyncService { } } + hydrateSyncStatus(data: UserInitData) { + if (!this.state.isOnline) return; + + try { + const settingsPref = data.preferences.find((p) => p.key === 'collapseloader.settings') || null; + + const maxIsoTimestamp = (timestamps: Array): string | null => { + let max: number | null = null; + let maxIso: string | null = null; + for (const ts of timestamps) { + if (!ts) continue; + const time = new Date(ts).getTime(); + if (Number.isNaN(time)) continue; + if (max === null || time > max) { + max = time; + maxIso = ts; + } + } + return maxIso; + }; + + const lastSync = maxIsoTimestamp([ + settingsPref?.updated_at ?? null, + ...data.favorites.map((f) => f.created_at ?? null), + ...data.accounts.map((a) => a.updated_at ?? null), + ]); + + this.state.lastSyncTime = lastSync; + this.state.hasCloudData = !!settingsPref || data.favorites.length > 0 || data.accounts.length > 0; + this.notifyListeners(); + } catch (error) { + console.error('Failed to hydrate sync status:', error); + } + } + async checkAndRestoreOnStartup(): Promise { if (!this.state.isOnline) { console.log('Offline - skipping startup sync check'); @@ -173,17 +208,23 @@ class SyncService { } } - async downloadFromCloud(): Promise { - if (!this.state.isOnline || this.state.isSyncing) return false; - if (!this.isAuthenticated()) return false; - - this.state.isSyncing = true; - this.notifyListeners(); + async restoreFromInitData(data: UserInitData): Promise { + if (!this.state.isOnline) return; + + this.hydrateSyncStatus(data); + + if (!this.state.hasCloudData) return; try { - const cloudData = await userService.downloadFromCloud(); + const syncData = userService.formatSyncData(data.preferences, data.favorites, data.accounts); + await this.restoreData(syncData); + } catch (error) { + console.error('Failed to restore from init data:', error); + } + } - if (!cloudData) return false; + private async restoreData(cloudData: SyncData): Promise { + if (!cloudData) return false; if (cloudData.settings_data && Object.keys(cloudData.settings_data).length > 0) { await settingsService.loadSettings(); @@ -240,6 +281,21 @@ class SyncService { this.state.hasCloudData = true; return true; + } + + async downloadFromCloud(): Promise { + if (!this.state.isOnline || this.state.isSyncing) return false; + if (!this.isAuthenticated()) return false; + + this.state.isSyncing = true; + this.notifyListeners(); + + try { + const cloudData = await userService.downloadFromCloud(); + + if (!cloudData) return false; + + return await this.restoreData(cloudData); } catch (error) { console.error('Failed to download from cloud:', error); throw error; diff --git a/src/services/userService.ts b/src/services/userService.ts index d2ef8e06..1d293d0f 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -10,15 +10,38 @@ export interface SocialLink { export interface UserProfile { id: number; nickname: string | null; - avatar_path: string | null; avatar_url: string | null; - avatar_updated_at: string | null; role: string | null; social_links: SocialLink[]; created_at: string; updated_at: string; } +export interface UserInitData { + user: { + id: number; + username: string; + email: string; + role: string; + created_at: string; + updated_at: string; + last_login_at: string | null; + profile: UserProfile; + status: UserStatus; + }; + preferences: UserPreference[]; + favorites: UserFavorite[]; + accounts: UserExternalAccount[]; + friends: { + friends: any[]; + requests: { + sent: any[]; + received: any[]; + blocked: any[]; + }; + }; +} + export interface UserInfo { id: number; username: string; @@ -56,6 +79,7 @@ export interface Friend { id: number; username: string; nickname: string | null; + avatar_url: string | null; status: ClientUserStatus; } @@ -91,6 +115,8 @@ export interface PublicUserProfile { avatar_url: string | null; social_links: SocialLink[]; role: string | null; + achievements?: any[]; + presets?: any[]; } export interface UserPreference { @@ -130,6 +156,31 @@ export interface SyncStatus { has_cloud_data: boolean; } +export interface UserInitData { + user: { + id: number; + username: string; + email: string; + role: string; + created_at: string; + updated_at: string; + last_login_at: string | null; + profile: UserProfile; + status: UserStatus; + }; + preferences: UserPreference[]; + favorites: UserFavorite[]; + accounts: UserExternalAccount[]; + friends: { + friends: any[]; + requests: { + sent: any[]; + received: any[]; + blocked: any[]; + }; + }; +} + const CACHE_KEY = 'userData'; const CACHE_EXPIRY_HOURS = 24; @@ -191,16 +242,17 @@ class UserService { }; } - private mapFriend(friend: any): Friend { + public mapFriend(friend: any): Friend { return { id: friend.id, username: friend.username, nickname: friend.nickname ?? null, + avatar_url: friend.avatar_url ?? null, status: this.mapStatus(friend.status), }; } - private mapFriendRequest(request: any): FriendRequest { + public mapFriendRequest(request: any): FriendRequest { return { id: request.id, requester: this.mapFriend(request.requester), @@ -319,8 +371,30 @@ class UserService { } } - async getUserProfile(userId: number): Promise { - const publicUser = await apiClient.get(`/users/${userId}`); + + async initializeUserFull(): Promise { + try { + const data = await apiClient.get('/users/init'); + const userInfo = { + id: data.user.id, + username: data.user.username, + email: data.user.email, + role: data.user.role, + created_at: data.user.created_at, + updated_at: data.user.updated_at, + last_login_at: data.user.last_login_at ?? null, + }; + this.setCachedData({ profile: data.user.profile, info: userInfo as UserInfo }); + return data; + } catch (error) { + console.error('Failed to initialize user (full):', error); + throw error; + } + } + + async getUserProfile(userId: number, include: string[] = []): Promise { + const query = include.length > 0 ? `?include=${include.join(',')}` : ''; + const publicUser = await apiClient.get(`/users/${userId}${query}`); const profile = publicUser.profile || null; const base: PublicUserProfile = { @@ -333,8 +407,14 @@ class UserService { avatar_url: profile?.avatar_url ?? null, social_links: profile?.social_links ?? [], role: profile?.role ?? null, + achievements: publicUser.achievements || [], + presets: publicUser.presets || [] }; + if (publicUser.friendship_status) { + return { ...base, friendship_status: publicUser.friendship_status }; + } + const token = localStorage.getItem('authToken'); if (!token) return base; @@ -580,6 +660,42 @@ class UserService { } } + formatSyncData( + preferences: UserPreference[], + favorites: UserFavorite[], + accounts: UserExternalAccount[] + ): SyncData { + const settingsPref = preferences.find((p) => p.key === SYNC_SETTINGS_PREF_KEY) || null; + + const favorites_data = (favorites || []) + .filter((f) => f.type === SYNC_FAVORITE_TYPE) + .map((f) => Number.parseInt(String(f.reference), 10)) + .filter((n) => Number.isFinite(n)); + + const accounts_data = (accounts || []) + .filter((a) => a.provider === SYNC_ACCOUNT_PROVIDER) + .map((a) => { + const meta: any = a.metadata && typeof a.metadata === 'object' ? a.metadata : {}; + return { + username: a.external_id, + tags: Array.isArray(meta.tags) ? meta.tags : ['cloud-sync'], + }; + }); + + const last_sync_timestamp = this.maxIsoTimestamp([ + settingsPref?.updated_at ?? null, + ...favorites.map((f) => f.created_at ?? null), + ...accounts.map((a) => a.updated_at ?? null), + ]); + + return { + settings_data: (settingsPref?.value ?? {}) as any, + favorites_data, + accounts_data, + last_sync_timestamp, + }; + } + async downloadFromCloud(): Promise { try { const [preferences, favorites, accounts] = await Promise.all([ @@ -588,35 +704,7 @@ class UserService { this.getExternalAccounts(), ]); - const settingsPref = preferences.find((p) => p.key === SYNC_SETTINGS_PREF_KEY) || null; - - const favorites_data = (favorites || []) - .filter((f) => f.type === SYNC_FAVORITE_TYPE) - .map((f) => Number.parseInt(String(f.reference), 10)) - .filter((n) => Number.isFinite(n)); - - const accounts_data = (accounts || []) - .filter((a) => a.provider === SYNC_ACCOUNT_PROVIDER) - .map((a) => { - const meta: any = a.metadata && typeof a.metadata === 'object' ? a.metadata : {}; - return { - username: a.external_id, - tags: Array.isArray(meta.tags) ? meta.tags : ['cloud-sync'], - }; - }); - - const last_sync_timestamp = this.maxIsoTimestamp([ - settingsPref?.updated_at ?? null, - ...favorites.map((f) => f.created_at ?? null), - ...accounts.map((a) => a.updated_at ?? null), - ]); - - return { - settings_data: (settingsPref?.value ?? {}) as any, - favorites_data, - accounts_data, - last_sync_timestamp, - }; + return this.formatSyncData(preferences, favorites, accounts); } catch (error) { console.error('Failed to download from cloud:', error); return null; diff --git a/src/views/FriendsView.vue b/src/views/FriendsView.vue index 857bb9b9..cf1cc607 100644 --- a/src/views/FriendsView.vue +++ b/src/views/FriendsView.vue @@ -147,7 +147,7 @@ let statusRefreshInterval: number | null = null; let fullDataRefreshInterval: number | null = null; onMounted(async () => { - await loadFriendsAndStatus(true); + await loadFriendsAndStatus(false); statusRefreshInterval = window.setInterval( updateStatuses, diff --git a/src/views/Settings.vue b/src/views/Settings.vue index 0ffcf1de..113a3230 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -27,6 +27,8 @@ import { UserIcon, LogIn, WifiOff, + Minimize2, + MousePointer2, } from 'lucide-vue-next'; import { useToast } from '../services/toastService'; import type { ToastPosition } from '../types/toast'; @@ -488,6 +490,14 @@ const getFormattedLabel = (key: string) => { return "DPI Bypass (Zapret by bol-van)"; } + if (key === 'minimize_to_tray_on_launch') { + return t('settings.minimize_to_tray_on_launch'); + } + + if (key === 'close_to_tray') { + return t('settings.close_to_tray'); + } + return words .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); @@ -729,6 +739,8 @@ const handleToastPositionChange = (position: ToastPosition) => { + + {{ getFormattedLabel(key) }}
diff --git a/src/views/UserProfileView.vue b/src/views/UserProfileView.vue index 0d958d02..37f224d5 100644 --- a/src/views/UserProfileView.vue +++ b/src/views/UserProfileView.vue @@ -261,7 +261,7 @@ @@ -151,9 +166,9 @@ export default defineComponent({ loading.value = true; try { if (props.initialPresets && !search.value.trim() && sortBy.value === 'newest' && presets.value.length === 0) { - presets.value = props.initialPresets.map((preset) => ({ ...preset, liking: false })); - loading.value = false; - return; + presets.value = props.initialPresets.map((preset) => ({ ...preset, liking: false })); + loading.value = false; + return; } const params: any = {}; @@ -206,6 +221,9 @@ export default defineComponent({ warningContent: theme.warningContent, error: theme.error, errorContent: theme.errorContent, + backgroundImage: theme.backgroundImage, + backgroundBlur: theme.backgroundBlur, + backgroundOpacity: theme.backgroundOpacity, } as any); } @@ -357,6 +375,7 @@ export default defineComponent({ debouncedLoad, getOwnerName, load, + getThemeValues, }; } }); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2b9b8cb3..e14fccaa 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -597,13 +597,7 @@ } }, "dark": "Dark", - "light": "Light", - "background_title": "Background", - "background_image": "Background Image URL", - "background_image_placeholder": "https://example.com/image.jpg", - "background_image_help": "Supports jpg, png, webp, data-urls", - "background_blur": "Blur", - "background_opacity": "Opacity" + "light": "Light" }, "logs": { "copy_logs": "Copy logs", @@ -1012,7 +1006,14 @@ "delete": "Delete" } }, - "customization": {}, + "customization": { + "background_title": "Background", + "background_image": "Background Image", + "background_image_placeholder": "https://example.com/image.jpg", + "background_image_help": "Supports jpg, png, webp, data-urls", + "background_blur": "Blur", + "background_opacity": "Opacity" + }, "updater": { "update_available": "Update Available", "current": "Current", @@ -1083,7 +1084,7 @@ "apply": "Apply", "like": "Like", "by_user": "by {name}", - "by_author": "OT {name}", + "by_author": "BY {name}", "no_items": "No presets yet", "preset_details_title": "Preset Details", "preset_details": "Preset details", diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json index 550c41b0..d4104cd7 100644 --- a/src/i18n/locales/pl.json +++ b/src/i18n/locales/pl.json @@ -597,13 +597,7 @@ } }, "dark": "Ciemny", - "light": "Jasny", - "background_blur": "Plama", - "background_image": "Adres URL obrazu tła", - "background_image_help": "Obsługuje jpg, png, webp, adresy URL danych", - "background_image_placeholder": "https://example.com/image.jpg", - "background_opacity": "Nieprzezroczystość", - "background_title": "Tło" + "light": "Jasny" }, "logs": { "copy_logs": "Kopiuj logi", @@ -1012,7 +1006,14 @@ "placeholder": "Napisz komentarz..." } }, - "customization": {}, + "customization": { + "background_title": "Tło", + "background_image": "Adres URL obrazu tła", + "background_image_help": "Obsługuje jpg, png, webp, adresy URL danych", + "background_image_placeholder": "https://example.com/image.jpg", + "background_blur": "Rozmycie", + "background_opacity": "Nieprzezroczystość" + }, "updater": { "update_available": "Dostępna aktualizacja", "current": "Bieżąca", @@ -1088,7 +1089,7 @@ "preset_picker_label": "Wybierz preset lokalny", "preset_picker_placeholder": "Wybierz preset...", "no_local_presets": "Nie masz jeszcze żadnych presetów", - "by_author": "OT {imię}", + "by_author": "PRZEZ {imię}", "sort_comments": "Najczęściej komentowane", "sort_downloads": "Najczęściej pobierane", "sort_newest": "Najnowszy", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e479991e..5cb4d751 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -597,13 +597,7 @@ } }, "dark": "Темная", - "light": "Светлая", - "background_title": "Задний фон", - "background_image": "URL Фонового изображения", - "background_image_placeholder": "https://example.com/image.jpg", - "background_image_help": "Поддерживает jpg, png, webp, data-urls", - "background_blur": "Размытие", - "background_opacity": "Прозрачность" + "light": "Светлая" }, "logs": { "copy_logs": "Копировать логи", @@ -1012,7 +1006,14 @@ "placeholder": "Напишите комментарий..." } }, - "customization": {}, + "customization": { + "background_title": "Фон", + "background_image": "URL изображения", + "background_image_placeholder": "https://example.com/image.jpg", + "background_image_help": "Поддерживает jpg, png, webp, data-urls", + "background_blur": "Размытие", + "background_opacity": "Прозрачность" + }, "updater": { "update_available": "Доступно обновление", "current": "Текущая", diff --git a/src/i18n/locales/ua.json b/src/i18n/locales/ua.json index 46a97b10..80172d8e 100644 --- a/src/i18n/locales/ua.json +++ b/src/i18n/locales/ua.json @@ -597,13 +597,7 @@ } }, "dark": "Темна", - "light": "Світла", - "background_blur": "Розмиття", - "background_image": "URL Фонового зображення", - "background_image_help": "Підтримує jpg, png, webp, data-urls", - "background_image_placeholder": "https://example.com/image.jpg", - "background_opacity": "Прозорість", - "background_title": "Задній фон" + "light": "Світла" }, "logs": { "copy_logs": "Копіювати логи", @@ -842,7 +836,7 @@ "account": "Акаунт", "about": "Про програму", "news": "Новини", - "customization": "Кастомізація", + "customization": "Фон", "custom_clients": "Власні клієнти", "sidebar_help": { "title": "Порада:", @@ -1011,7 +1005,14 @@ "placeholder": "Напишіть коментар..." } }, - "customization": {}, + "customization": { + "background_title": "Фон", + "background_image": "URL зображення", + "background_image_placeholder": "https://example.com/image.jpg", + "background_image_help": "Підтримує jpg, png, webp, data-urls", + "background_blur": "Розмиття", + "background_opacity": "Прозорість" + }, "updater": { "update_available": "Доступне оновлення", "current": "Поточна", diff --git a/src/i18n/locales/zh_cn.json b/src/i18n/locales/zh_cn.json index 04b8bed0..2ee6a899 100644 --- a/src/i18n/locales/zh_cn.json +++ b/src/i18n/locales/zh_cn.json @@ -597,13 +597,7 @@ } }, "dark": "黑暗的", - "light": "光", - "background_blur": "模糊", - "background_image": "背景图片网址", - "background_image_help": "支持 jpg、png、webp、data-url", - "background_image_placeholder": "https://example.com/image.jpg", - "background_opacity": "不透明度", - "background_title": "背景" + "light": "光" }, "errors": { "clients_load_failed": "加载客户端列表失败:{error}", @@ -1000,7 +994,14 @@ "placeholder": "写一条评论..." } }, - "customization": {}, + "customization": { + "background_title": "背景", + "background_image": "背景图片网址", + "background_image_placeholder": "https://example.com/image.jpg", + "background_image_help": "支持 jpg、png、webp、data-url", + "background_blur": "模糊", + "background_opacity": "不透明度" + }, "updater": { "update_available": "有可用更新", "current": "当前", @@ -1077,7 +1078,7 @@ "preset_picker_label": "选择本地预设", "preset_picker_placeholder": "选择一个预设...", "no_local_presets": "您还没有任何预设", - "by_author": "加时{姓名}", + "by_author": "作者:{name}", "sort_comments": "评论最多", "sort_downloads": "下载次数最多", "sort_newest": "最新", diff --git a/src/services/themeService.ts b/src/services/themeService.ts index 05eed45d..5a11218a 100644 --- a/src/services/themeService.ts +++ b/src/services/themeService.ts @@ -105,30 +105,29 @@ const applyPreset = () => { Object.entries(varMap).forEach(([key, cssVar]) => { const value = settings[key]; - if (value !== undefined && value !== null) { + + if (value !== undefined && value !== null && (typeof value === 'string' ? value.trim().length > 0 : true)) { let cssValue = String(value); if (key === 'backgroundBlur') cssValue = `${value}px`; - if (key === 'backgroundOpacity') cssValue = String(value); - if (key === 'backgroundImage' && value.trim().length > 0) { + if (key === 'backgroundImage') { cssValue = value.startsWith('http') || value.startsWith('data:') ? `url("${value}")` : value; - root.setAttribute('data-has-background', 'true'); - } else if (key === 'backgroundImage') { - root.removeAttribute('data-has-background'); - } - - if (typeof value === 'string' ? value.trim().length > 0 : true) { - root.style.setProperty(cssVar, cssValue); - } else { - root.style.removeProperty(cssVar); } + root.style.setProperty(cssVar, cssValue); } else { root.style.removeProperty(cssVar); - if (key === 'backgroundImage') { - root.removeAttribute('data-has-background'); - } } }); + const bgImage = presetSettings.backgroundImage; + const bgOpacity = presetSettings.backgroundOpacity; + const hasBg = bgImage && bgImage.trim().length > 0 && bgOpacity && bgOpacity > 0; + + if (hasBg) { + root.setAttribute('data-has-background', 'true'); + } else { + root.removeAttribute('data-has-background'); + } + let styleEl = document.getElementById('custom-theme-styles'); if (!styleEl) { styleEl = document.createElement('style'); @@ -272,7 +271,7 @@ const importPreset = (presetJSON: string): void => { warningContent: typeof parsed.warningContent === 'string' || parsed.warningContent === null ? parsed.warningContent : presetSettings.warningContent, error: typeof parsed.error === 'string' || parsed.error === null ? parsed.error : presetSettings.error, errorContent: typeof parsed.errorContent === 'string' || parsed.errorContent === null ? parsed.errorContent : presetSettings.errorContent, - + backgroundImage: typeof parsed.backgroundImage === 'string' || parsed.backgroundImage === null ? parsed.backgroundImage : presetSettings.backgroundImage, backgroundBlur: typeof parsed.backgroundBlur === 'number' || parsed.backgroundBlur === null ? parsed.backgroundBlur : presetSettings.backgroundBlur, backgroundOpacity: typeof parsed.backgroundOpacity === 'number' || parsed.backgroundOpacity === null ? parsed.backgroundOpacity : presetSettings.backgroundOpacity diff --git a/src/views/Customization.vue b/src/views/Customization.vue index b49abebb..d3e13d7b 100644 --- a/src/views/Customization.vue +++ b/src/views/Customization.vue @@ -73,7 +73,7 @@
+ }}
+ }}
+ }}
+ }}
+ }}
+ }}
+ }}
+ }}
+ }}
+ }}
-

{{ t('theme.background_title', - 'Background') }}

+

{{ t('customization.background_title') + }}

+ t('customization.background_image') }}
-

{{ t('theme.background_image_help', - 'Supports jpg, png, webp, data-urls') }}

+

{{ t('customization.background_image_help') + }}

- + {{ backgroundBlur ?? 0 }}px
+ t('customization.background_opacity') }} {{ backgroundOpacity ?? 100 }}%
+ }} { + let (folder, _) = client.get_launch_paths().map_err(|e| e.to_string())?; + let root = DATA.root_dir.lock().unwrap().clone(); + let relative = folder + .strip_prefix(&root) + .map_err(|_| "Client folder is outside of root directory".to_string())?; + relative.join("mods") + } + }; + + let mods_folder_str = mods_folder_relative + .to_str() + .ok_or_else(|| "Invalid mods folder path".to_string())?; + + DATA.download_to_folder(&url, mods_folder_str).await?; + + log_info!( + "Successfully installed mod: {} to {}", + filename, + mods_folder_str + ); + + Ok(()) +} + +#[tauri::command] +pub async fn list_installed_mods(id: u32, state: State<'_, AppState>) -> Result, String> { + let client = get_client_by_id(id, &state.clients.manager)?; + + let mods_folder = match client.client_type { + ClientType::Fabric | ClientType::Forge | ClientType::Default => { + let (folder, _) = client.get_launch_paths().map_err(|e| e.to_string())?; + folder.join("mods") + } + }; + + if !mods_folder.exists() { + return Ok(Vec::new()); + } + + let mut mods = Vec::new(); + let mut entries = tokio::fs::read_dir(mods_folder) + .await + .map_err(|e| format!("Failed to read mods directory: {}", e))?; + + while let Some(entry) = entries.next_entry().await.map_err(|e| e.to_string())? { + let path = entry.path(); + if path.is_file() { + if let Some(filename) = path.file_name().and_then(|n| n.to_str()) { + if filename.ends_with(".jar") { + mods.push(filename.to_string()); + } + } + } + } + + Ok(mods) +} + #[tauri::command] pub async fn stop_custom_client(id: u32, state: State<'_, AppState>) -> Result<(), String> { log_info!("Attempting to stop custom client with ID: {}", id); diff --git a/src-tauri/src/core/clients/client.rs b/src-tauri/src/core/clients/client.rs index 2b666fe3..7a6c24ca 100644 --- a/src-tauri/src/core/clients/client.rs +++ b/src-tauri/src/core/clients/client.rs @@ -70,7 +70,6 @@ fn is_minecraft_version_dir_name(name: &str) -> bool { .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) } - fn collect_jars_recursive(dir: &Path, skip_root_mc_version_dirs: bool) -> Vec { let mut jars = Vec::new(); @@ -153,7 +152,9 @@ impl Meta { .file_name() .and_then(|n| n.to_str()) .unwrap_or(filename); - DATA.root_dir.lock().unwrap() + DATA.root_dir + .lock() + .unwrap() .join(Data::get_filename(filename)) .join(MODS_FOLDER) .join(jar_basename) @@ -164,7 +165,9 @@ impl Meta { .file_name() .and_then(|n| n.to_str()) .unwrap_or(filename); - DATA.root_dir.lock().unwrap() + DATA.root_dir + .lock() + .unwrap() .join(Data::get_filename(filename)) .join(MODS_FOLDER) .join(jar_basename) @@ -298,15 +301,22 @@ impl Client { } fn java_executable_path(&self) -> PathBuf { - DATA.root_dir.lock().unwrap() + DATA.root_dir + .lock() + .unwrap() .join(self.jdk_folder_name()) .join("bin") .join(format!("java{FILE_EXTENSION}")) } - fn get_launch_paths(&self) -> Result<(PathBuf, PathBuf), String> { + pub fn get_launch_paths(&self) -> Result<(PathBuf, PathBuf), String> { if self.meta.is_custom { - let folder = DATA.root_dir.lock().unwrap().join(CUSTOM_CLIENTS_FOLDER).join(&self.name); + let folder = DATA + .root_dir + .lock() + .unwrap() + .join(CUSTOM_CLIENTS_FOLDER) + .join(&self.name); let jar = folder.join(&self.filename); return Ok((folder, jar)); } @@ -337,11 +347,15 @@ impl Client { let safe_ver = sanitize_version_for_paths(&self.version); match self.client_type { ClientType::Fabric => DATA - .root_dir.lock().unwrap() + .root_dir + .lock() + .unwrap() .join(MINECRAFT_VERSIONS_FOLDER) .join(format!("fabric_{}.jar", safe_ver)), ClientType::Forge => DATA - .root_dir.lock().unwrap() + .root_dir + .lock() + .unwrap() .join(MINECRAFT_VERSIONS_FOLDER) .join(format!("forge_{}.jar", safe_ver)), ClientType::Default => self.get_launch_paths().unwrap_or_default().1, @@ -479,7 +493,12 @@ impl Client { if let Some(mods) = &self.dependencies { let client_base = Data::get_filename(&self.filename); let mods_folder_rel = format!("{client_base}{MAIN_SEPARATOR}{MODS_FOLDER}"); - let mods_folder_abs = DATA.root_dir.lock().unwrap().join(&client_base).join(MODS_FOLDER); + let mods_folder_abs = DATA + .root_dir + .lock() + .unwrap() + .join(&client_base) + .join(MODS_FOLDER); for req in mods { let name = req.name.clone(); @@ -591,19 +610,29 @@ impl Client { }; check_and_queue(self.jdk_folder_name(), &self.jdk_zip_name()); - check_and_queue(if self.client_type == ClientType::Fabric { ASSETS_FABRIC_FOLDER } else { ASSETS_FOLDER }, if self.client_type == ClientType::Fabric { ASSETS_FABRIC_ZIP } else { ASSETS_ZIP }); - - let (libs_zip, libs_folder, natives_zip, natives_folder) = - if self.is_legacy_client() { - ( - LIBRARIES_LEGACY_ZIP, - LIBRARIES_LEGACY_FOLDER, - NATIVES_LEGACY_ZIP, - NATIVES_LEGACY_FOLDER, - ) + check_and_queue( + if self.client_type == ClientType::Fabric { + ASSETS_FABRIC_FOLDER } else { - (LIBRARIES_ZIP, LIBRARIES_FOLDER, NATIVES_ZIP, NATIVES_FOLDER) - }; + ASSETS_FOLDER + }, + if self.client_type == ClientType::Fabric { + ASSETS_FABRIC_ZIP + } else { + ASSETS_ZIP + }, + ); + + let (libs_zip, libs_folder, natives_zip, natives_folder) = if self.is_legacy_client() { + ( + LIBRARIES_LEGACY_ZIP, + LIBRARIES_LEGACY_FOLDER, + NATIVES_LEGACY_ZIP, + NATIVES_LEGACY_FOLDER, + ) + } else { + (LIBRARIES_ZIP, LIBRARIES_FOLDER, NATIVES_ZIP, NATIVES_FOLDER) + }; check_and_queue(libs_folder, libs_zip); @@ -642,7 +671,9 @@ impl Client { let dest_filename = remote.rsplit('/').next().unwrap_or(&remote); let local_path = DATA - .root_dir.lock().unwrap() + .root_dir + .lock() + .unwrap() .join(MINECRAFT_VERSIONS_FOLDER) .join(dest_filename); @@ -703,7 +734,12 @@ impl Client { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let bin_dir = DATA.root_dir.lock().unwrap().join(self.jdk_folder_name()).join("bin"); + let bin_dir = DATA + .root_dir + .lock() + .unwrap() + .join(self.jdk_folder_name()) + .join("bin"); if bin_dir.exists() { if let Ok(entries) = std::fs::read_dir(&bin_dir) { for entry in entries.flatten() { @@ -741,7 +777,11 @@ impl Client { LIBRARIES_FABRIC_FOLDER, sanitize_version_for_paths(&self.version) ); - let versioned_dir = DATA.root_dir.lock().unwrap().join(versioned_zip.replace(".zip", "")); + let versioned_dir = DATA + .root_dir + .lock() + .unwrap() + .join(versioned_zip.replace(".zip", "")); if !dir_has_any_jars(&versioned_dir, false) { log_info!("Downloading versioned Fabric libraries: {}", versioned_zip); @@ -781,7 +821,9 @@ impl Client { cp_parts.push(self.get_minecraft_jar_path()); let v_libs = DATA - .root_dir.lock().unwrap() + .root_dir + .lock() + .unwrap() .join(LIBRARIES_FABRIC_FOLDER) .join(sanitize_version_for_paths(&self.version)); cp_parts.extend(collect_jars_recursive(&v_libs, false)); @@ -885,9 +927,14 @@ impl Client { let natives_path = if self.is_legacy_client() { if IS_LINUX { - DATA.root_dir.lock().unwrap().join(NATIVES_LEGACY_LINUX_FOLDER) + DATA.root_dir + .lock() + .unwrap() + .join(NATIVES_LEGACY_LINUX_FOLDER) } else { - DATA.root_dir.lock().unwrap() + DATA.root_dir + .lock() + .unwrap() .join(format!("{}{}", NATIVES_FOLDER, LEGACY_SUFFIX)) } } else if self.meta.is_new { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8a56cbee..3b508e64 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -202,6 +202,8 @@ pub fn run() { commands::irc::connect_irc, commands::irc::disconnect_irc, commands::irc::send_irc_message, + commands::clients::install_mod_from_url, + commands::clients::list_installed_mods, ]) .setup(|app| { #[cfg(desktop)] diff --git a/src/components/features/clients/ClientCard.vue b/src/components/features/clients/ClientCard.vue index 3e856263..95439d53 100644 --- a/src/components/features/clients/ClientCard.vue +++ b/src/components/features/clients/ClientCard.vue @@ -1197,13 +1197,13 @@ onBeforeUnmount(() => { {{ t('home.download') - }} + }} {{ t('home.unavailable') - }} + }} + class="absolute inset-0 flex items-center justify-center opacity-0 translate-y-3 transition-all duration-300 group-hover:opacity-100 group-hover:translate-y-0"> {{ client.meta.size || '0' }} MB @@ -1215,6 +1215,7 @@ onBeforeUnmount(() => { {{ clientIsRunning ? t('home.stop') : t('home.launch') }} +
@@ -1361,7 +1362,7 @@ onBeforeUnmount(() => { {{ clientDetails.source_link - }} + }}
@@ -1405,7 +1406,7 @@ onBeforeUnmount(() => { class="timeline-end timeline-box shadow-sm bg-base-100/40 w-full mb-2 border border-base-content/10">
v{{ entry.version - }} + }}
@@ -1425,10 +1426,10 @@ onBeforeUnmount(() => {

{{ t('client.details.no_changelog') - }}

+ }}

{{ t('client.details.no_changelog_desc') - }}

+ }}

@@ -1481,7 +1482,7 @@ onBeforeUnmount(() => { {{ t('login') }} {{ Math.min(newCommentText.length, MAX_COMMENT_LENGTH) - }}/{{ MAX_COMMENT_LENGTH }} + }}/{{ MAX_COMMENT_LENGTH }}
@@ -1523,7 +1524,7 @@ onBeforeUnmount(() => {
{{ t('client.details.screenshot_viewer.controls.navigate') - }} + }}
{{ t('client.details.screenshot_viewer.controls.click_zoom') }}
{{ t('client.details.screenshot_viewer.controls.drag_pan') }}
diff --git a/src/components/modals/CustomModal.vue b/src/components/modals/CustomModal.vue index 3d9bab4b..a8b0f5e5 100644 --- a/src/components/modals/CustomModal.vue +++ b/src/components/modals/CustomModal.vue @@ -1,20 +1,31 @@