diff --git a/patches/0014-Browse-skins-from-Ely.by-NameMC-laby.net-and-crafty..patch b/patches/0014-Browse-skins-from-Ely.by-NameMC-laby.net-and-crafty..patch new file mode 100644 index 0000000..fb3ae34 --- /dev/null +++ b/patches/0014-Browse-skins-from-Ely.by-NameMC-laby.net-and-crafty..patch @@ -0,0 +1,1557 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Modrinth Enhanced +Date: Tue, 15 Sep 2026 11:27:41 +0200 +Subject: [PATCH] Browse skins from Ely.by, NameMC, laby.net and crafty.gg + +The skin page has a Browse tab next to the account's own skins. Picking a +skin there previews it on the big model, and Add skin sends it through +the same dialog a skin file goes through, so choosing the model, the +cape and an Ely.by account's upload all work as they already did. + +Ely.by's catalogue is browsed in the app: sort by best or newest, filter +by format, category, tags and uploader, and order what is loaded by +likes, wearers or views, all of which show on every tile. Its catalogue +has no text search, so players can be searched by name instead, showing +the skin each wears. Ely.by's robots.txt invites exactly this. + +NameMC, laby.net and crafty.gg are not browsed from the app. NameMC's +skin lists sit behind a Cloudflare bot check, laby.net has no public +API, and crafty.gg's terms forbid scraping. They open in a window of the +app to browse as usual, with each site's own search, filters and counts, +and the launcher only reads which page that window is on: the skin page +open there is fetched and previewed, from NameMC's image host, laby.net's +texture endpoint or crafty.gg's lookup for that one skin. +--- + .../src/components/ui/skin/SkinBrowser.vue | 606 ++++++++++++++++++ + apps/app-frontend/src/helpers/skin-browser.ts | 100 +++ + apps/app-frontend/src/pages/Skins.vue | 115 +++- + apps/app/build.rs | 13 + + apps/app/capabilities/plugins.json | 3 +- + apps/app/src/api/mod.rs | 1 + + apps/app/src/api/skin_browser.rs | 122 ++++ + apps/app/src/main.rs | 1 + + packages/app-lib/src/api/mod.rs | 1 + + packages/app-lib/src/api/skin_browser.rs | 373 +++++++++++ + 10 files changed, 1323 insertions(+), 12 deletions(-) + create mode 100644 apps/app-frontend/src/components/ui/skin/SkinBrowser.vue + create mode 100644 apps/app-frontend/src/helpers/skin-browser.ts + create mode 100644 apps/app/src/api/skin_browser.rs + create mode 100644 packages/app-lib/src/api/skin_browser.rs + +diff --git a/apps/app-frontend/src/components/ui/skin/SkinBrowser.vue b/apps/app-frontend/src/components/ui/skin/SkinBrowser.vue +new file mode 100644 +index 0000000..3d92c68 +--- /dev/null ++++ b/apps/app-frontend/src/components/ui/skin/SkinBrowser.vue +@@ -0,0 +1,606 @@ ++ ++ ++ ++ +diff --git a/apps/app-frontend/src/helpers/skin-browser.ts b/apps/app-frontend/src/helpers/skin-browser.ts +new file mode 100644 +index 0000000..ea86e86 +--- /dev/null ++++ b/apps/app-frontend/src/helpers/skin-browser.ts +@@ -0,0 +1,100 @@ ++/** ++ * Finding skins on other sites, for the skin page. ++ * ++ * Ely.by's catalogue is read directly. NameMC, laby.net and crafty.gg open in a ++ * window the player browses; the launcher only learns which skin page it is on. ++ */ ++import { invoke } from '@tauri-apps/api/core' ++import { listen, type UnlistenFn } from '@tauri-apps/api/event' ++ ++import type { SkinModel } from '@/helpers/skins' ++ ++export type SkinSite = 'namemc' | 'laby' | 'crafty' ++export type ElySort = 'best' | 'time' ++export type ElyModel = 'any' | 'old' | 'new' | 'slim' ++ ++export interface ElyCatalogueQuery { ++ sort: ElySort ++ model: ElyModel ++ kind: string ++ tags: string[] ++ uploader: string ++ page: number ++} ++ ++export interface ElyCatalogueSkin { ++ id: number ++ skin_url: string ++ is_slim: boolean ++ likes: number ++ wearers: number ++ views: number ++ tags: string[] ++} ++ ++export interface ElyCataloguePage { ++ items: ElyCatalogueSkin[] ++ current: number ++ last: number ++ total: number ++} ++ ++export interface ElyPlayer { ++ nickname: string ++ skin_url: string | null ++} ++ ++export interface SitePage { ++ site: SkinSite ++ id: string ++} ++ ++export interface SiteSkin { ++ texture: number[] ++ slim: boolean | null ++ likes: number | null ++ views: number | null ++ wearers: number | null ++} ++ ++/** A skin picked in the browser, previewed until it is added. */ ++export interface SkinBrowserPick { ++ texture: string ++ bytes: Uint8Array ++ variant: SkinModel ++} ++ ++/** The categories Ely.by's catalogue sorts skins into, under its own names. */ ++export const ELY_KINDS = [ ++ 'Comics', ++ 'Adventure', ++ 'Heroes', ++ 'Evildoers', ++ 'Weekend', ++ 'Characters', ++ 'Historical', ++ 'Fantasy', ++ 'Scientific', ++ 'Other', ++] as const ++ ++export async function getElyCatalogue(query: ElyCatalogueQuery): Promise { ++ return await invoke('plugin:skin-browser|skin_browser_ely_catalogue', { query }) ++} ++ ++export async function searchElyPlayers(term: string): Promise { ++ return await invoke('plugin:skin-browser|skin_browser_ely_players', { term }) ++} ++ ++export async function getSiteSkin(site: SkinSite, id: string): Promise { ++ return await invoke('plugin:skin-browser|skin_browser_site_skin', { site, id }) ++} ++ ++export async function openSkinSite(site: SkinSite): Promise { ++ await invoke('plugin:skin-browser|skin_browser_open_site', { site }) ++} ++ ++/** Calls `handler` whenever the browsing window moves onto or off a skin page. */ ++export function onSkinSitePage(handler: (page: SitePage | null) => void): Promise { ++ return listen('skin-browser-page', (event) => handler(event.payload)) ++} +diff --git a/apps/app-frontend/src/pages/Skins.vue b/apps/app-frontend/src/pages/Skins.vue +index bcd79ec..9a10f0e 100644 +--- a/apps/app-frontend/src/pages/Skins.vue ++++ b/apps/app-frontend/src/pages/Skins.vue +@@ -5,6 +5,7 @@ import { + EyeIcon, + InfoIcon, + LogInIcon, ++ PlusIcon, + RotateCounterClockwiseIcon, + ShirtIcon, + SpinnerIcon, +@@ -12,6 +13,7 @@ import { + } from '@modrinth/assets' + import { + Button, ++ Chips, + commonMessages, + ConfirmModal, + defineMessages, +@@ -33,6 +35,7 @@ import EarsModIcon from '@/assets/skins/ears-mod.png' + import type AccountsCard from '@/components/ui/AccountsCard.vue' + import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue' + import ElySkinModal from '@/components/ui/skin/ElySkinModal.vue' ++import SkinBrowser from '@/components/ui/skin/SkinBrowser.vue' + import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue' + import { useAppSettings } from '@/composables/use-app-settings.ts' + import { handleSevereError } from '@/composables/use-error.js' +@@ -52,6 +55,7 @@ import { + uploadElySkin, + wearElySkin, + } from '@/helpers/ely_skins' ++import type { SkinBrowserPick } from '@/helpers/skin-browser' + import type { Cape, Skin, SkinModel, SkinTextureUrl } from '@/helpers/skins.ts' + import { + determineModelType, +@@ -237,6 +241,18 @@ const messages = defineMessages({ + id: 'app.skins.ely.failed.title', + defaultMessage: 'Ely.by did not take the change', + }, ++ savedTab: { ++ id: 'app.skins.tab.saved', ++ defaultMessage: 'My skins', ++ }, ++ browseTab: { ++ id: 'app.skins.tab.browse', ++ defaultMessage: 'Browse', ++ }, ++ browseAddButton: { ++ id: 'app.skins.browse.add-button', ++ defaultMessage: 'Add skin', ++ }, + }) + + const editSkinModal = useTemplateRef('editSkinModal') +@@ -1032,6 +1048,28 @@ async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) { + } + } + ++/* ++ * Skins found elsewhere (see SkinBrowser.vue). A pick is only previewed until ++ * it is added, and adding goes through the same dialog a skin file does. ++ */ ++const skinsTab = ref<'saved' | 'browse'>('saved') ++const browsePick = ref(null) ++ ++watch(skinsTab, (tab) => { ++ if (tab === 'saved') browsePick.value = null ++}) ++ ++function skinsTabLabel(tab: 'saved' | 'browse') { ++ return formatMessage(tab === 'browse' ? messages.browseTab : messages.savedTab) ++} ++ ++async function addBrowsePick() { ++ const pick = browsePick.value ++ if (!pick) return ++ browsePick.value = null ++ await processSkinFileBuffer(pick.bytes) ++} ++ + /* + * Ely.by accounts. Ely.by keeps an account's skins in its public catalogue and + * has no API to change one, so the grid shows that catalogue, and changes are +@@ -1459,16 +1497,16 @@ await loadAccountSkins() + class="ml-5 mt-4 flex h-[calc(80vh-1rem)] items-center justify-center max-[700px]:h-[calc(50vh-1rem)]" + > + +-