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