1
0

feat: add support for multiple account types in database

This commit is contained in:
2025-07-16 20:33:58 +03:00
parent 3f606a08aa
commit 5a10292add
11 changed files with 274 additions and 105 deletions
@@ -1,17 +1,9 @@
<template> <template>
<div <div v-if="mode !== 'isolated'" ref="button"
v-if="mode !== 'isolated'"
ref="button"
class="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2" class="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2"
:class="{ expanded: mode === 'expanded' }" :class="{ expanded: mode === 'expanded' }" @click="toggleMenu">
@click="toggleMenu" <Avatar size="36px" :src="selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
> " />
<Avatar
size="36px"
:src="
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
"
/>
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<span> <span>
<component :is="getAccountType(selectedAccount)" v-if="selectedAccount" class="vector-icon" /> <component :is="getAccountType(selectedAccount)" v-if="selectedAccount" class="vector-icon" />
@@ -32,30 +24,23 @@
</h4> </h4>
<p>Selected</p> <p>Selected</p>
</div> </div>
<Button <Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.profile.id)">
v-tooltip="'Log out'"
icon-only
color="raised"
@click="logout(selectedAccount.profile.id)"
>
<TrashIcon /> <TrashIcon />
</Button> </Button>
</div> </div>
<div v-else class="login-section account"> <div v-else class="login-section account">
<h4>Not signed in</h4> <h4>Not signed in</h4>
<Button <Button v-tooltip="'Log via Microsoft'" :disabled="microsoftLoginDisabled" icon-only @click="login()">
v-tooltip="'Log in'" <MicrosoftIcon v-if="!microsoftLoginDisabled" />
:disabled="loginDisabled"
icon-only
color="primary"
@click="login()"
>
<MicrosoftIcon v-if="!loginDisabled"/>
<SpinnerIcon v-else class="animate-spin" /> <SpinnerIcon v-else class="animate-spin" />
</Button> </Button>
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()"> <Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
<PirateIcon /> <PirateIcon />
</Button> </Button>
<Button v-tooltip="'Log via Ely.by'" icon-only @click="loginViaElyBy()">
<ElyByIcon v-if="!elybyLoginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
</Button>
</div> </div>
<div v-if="displayAccounts.length > 0" class="account-group"> <div v-if="displayAccounts.length > 0" class="account-group">
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row"> <div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
@@ -72,50 +57,85 @@
</div> </div>
</div> </div>
<div v-if="accounts.length > 0" class="login-section account centered"> <div v-if="accounts.length > 0" class="login-section account centered">
<Button v-tooltip="'Log in'" icon-only @click="login()"> <Button v-tooltip="'Log via Microsoft'" icon-only @click="login()">
<MicrosoftIcon /> <MicrosoftIcon v-if="!microsoftLoginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
</Button> </Button>
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()"> <Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
<PirateIcon /> <PirateIcon />
</Button> </Button>
<Button v-tooltip="'Log via Ely.by'" icon-only @click="loginViaElyBy()">
<ElyByIcon v-if="!elybyLoginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
</Button>
</div> </div>
</Card> </Card>
</transition> </transition>
<ModalWrapper ref="loginOfflineModal" class="modal" header="Add new offline account"> <ModalWrapper ref="addOfflineModal" class="modal" header="Add new offline account">
<div class="modal-body"> <div class="flex flex-col gap-4 px-6 py-5">
<div class="label">Enter offline username</div> <label class="label">Enter your player name</label>
<input type="text" v-model="playerName" placeholder="Provide offline player name" /> <input
<Button icon-only color="secondary" @click="offlineLoginFinally()"> type="text"
Continue v-model="offlinePlayerName"
</Button> placeholder="Your player name here..."
class="input"
/>
<div class="mt-6 ml-auto">
<Button
icon-only
color="primary"
@click="addOfflineProfile()"
class="continue-button"
>
Login
</Button>
</div>
</div> </div>
</ModalWrapper> </ModalWrapper>
<ModalWrapper ref="loginErrorModal" class="modal" header="Error while proceed"> <ModalWrapper ref="inputErrorModal" class="modal" header="Error while proceeding">
<div class="modal-body"> <div class="flex flex-col gap-4 px-6 py-5">
<div class="label">Error occurred while adding offline account</div> <label class="text-base font-medium text-red-700">
<Button color="primary" @click="retryOfflineLogin()"> An error occurred while adding the offline account. Please follow the instructions below.
Try again </label>
</Button>
<ul class="list-disc list-inside text-sm space-y-1">
<li>Check that you have entered the correct player name.</li>
<li>
Player name must be at least {{ minOfflinePlayerNameLength }} characters long and no more than
{{ maxOfflinePlayerNameLength }} characters.
</li>
</ul>
<div class="mt-6 ml-auto">
<Button
color="primary"
@click="retryAddOfflineProfile"
class="retry-button"
>
Try again
</Button>
</div>
</div> </div>
</ModalWrapper> </ModalWrapper>
<ModalWrapper ref="unexpectedErrorModal" class="modal" header="Ошибка"> <ModalWrapper ref="exceptionErrorModal" class="modal" header="Unexpected error occurred">
<div class="modal-body"> <div class="modal-body">
<div class="label">Unexcepted error</div> <label class="label">An unexpected error has occurred. Please try again later.</label>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<script setup> <script setup>
import { import {
DropdownIcon, DropdownIcon,
PlusIcon,
TrashIcon, TrashIcon,
LogInIcon,
PirateIcon as Offline, PirateIcon as Offline,
MicrosoftIcon as License, MicrosoftIcon as License,
ElyByIcon as ElyBy,
MicrosoftIcon, MicrosoftIcon,
PirateIcon, PirateIcon,
SpinnerIcon } from '@modrinth/assets' ElyByIcon,
SpinnerIcon
} from '@modrinth/assets'
import { Avatar, Button, Card } from '@modrinth/ui' import { Avatar, Button, Card } from '@modrinth/ui'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import { import {
@@ -145,48 +165,80 @@ defineProps({
const emit = defineEmits(['change']) const emit = defineEmits(['change'])
const accounts = ref({}) const accounts = ref({})
const loginDisabled = ref(false) const microsoftLoginDisabled = ref(false)
const elybyLoginDisabled = ref(false)
const defaultUser = ref() const defaultUser = ref()
const loginOfflineModal = ref(null)
const loginErrorModal = ref(null)
const unexpectedErrorModal = ref(null)
const playerName = ref('')
async function tryOfflineLogin() { // [AR] Feature // [AR] Feature
loginOfflineModal.value.show() const addOfflineModal = ref(null)
const inputErrorModal = ref(null)
const exceptionErrorModal = ref(null)
const offlinePlayerName = ref('')
const minOfflinePlayerNameLength = 2
const maxOfflinePlayerNameLength = 20
// [AR] • Feature
function getAccountType(account) {
switch (account.account_type) {
case 'microsoft':
return License
case 'pirate':
return Offline
case 'elyby':
return ElyBy
}
} }
async function offlineLoginFinally() { // [AR] Feature // [AR] Feature
const name = playerName.value function showOfflineLoginModal() {
if (name.length > 1 && name.length < 20 && name !== '') { addOfflineModal.value?.show()
const loggedIn = await offline_login(name).catch(handleError) }
loginOfflineModal.value.hide()
if (loggedIn) { // [AR] • Feature
await setAccount(loggedIn) function retryAddOfflineProfile() {
inputErrorModal.value?.hide()
showOfflineLoginModal()
}
// [AR] • Feature
async function addOfflineProfile() {
const name = offlinePlayerName.value.trim()
const isValidName = name.length >= minOfflinePlayerNameLength && name.length <= maxOfflinePlayerNameLength
if (!isValidName) {
addOfflineModal.value?.hide()
inputErrorModal.value?.show()
offlinePlayerName.value = ''
return
}
try {
const result = await offline_login(name)
addOfflineModal.value?.hide()
if (result) {
await setAccount(result)
await refreshValues() await refreshValues()
} else { } else {
unexpectedErrorModal.value.show() exceptionErrorModal.value?.show()
} }
playerName.value = '' } catch (error) {
} else { handleError(error)
playerName.value = '' exceptionErrorModal.value?.show()
loginOfflineModal.value.hide() } finally {
loginErrorModal.value.show() offlinePlayerName.value = ''
} }
} }
function retryOfflineLogin() { // [AR] Feature // [AR] Feature
loginErrorModal.value.hide() // TODO:
tryOfflineLogin() async function loginViaElyBy() {
elybyLoginDisabled.value = true
console.log("Login via Ely.by clicked!")
elybyLoginDisabled.value = false
} }
function getAccountType(account) { // [AR] Feature
if (account.access_token != "null" && account.access_token != null && account.access_token != "") {
return License
} else {
return Offline
}
}
const equippedSkin = ref(null) const equippedSkin = ref(null)
const headUrlCache = ref(new Map()) const headUrlCache = ref(new Map())
@@ -212,13 +264,13 @@ async function refreshValues() {
} }
function setLoginDisabled(value) { function setLoginDisabled(value) {
loginDisabled.value = value microsoftLoginDisabled.value = value
} }
defineExpose({ defineExpose({
refreshValues, refreshValues,
setLoginDisabled, setLoginDisabled,
loginDisabled, loginDisabled: microsoftLoginDisabled,
}) })
await refreshValues() await refreshValues()
@@ -264,7 +316,7 @@ async function setAccount(account) {
} }
async function login() { async function login() {
loginDisabled.value = true microsoftLoginDisabled.value = true
const loggedIn = await login_flow().catch(handleSevereError) const loggedIn = await login_flow().catch(handleSevereError)
if (loggedIn) { if (loggedIn) {
@@ -273,7 +325,7 @@ async function login() {
} }
trackEvent('AccountLogIn') trackEvent('AccountLogIn')
loginDisabled.value = false microsoftLoginDisabled.value = false
} }
const logout = async (id) => { const logout = async (id) => {
+1 -1
View File
@@ -41,7 +41,7 @@
] ]
}, },
"productName": "AstralRinth App", "productName": "AstralRinth App",
"version": "0.10.304", "version": "0.10.305",
"mainBinaryName": "AstralRinth App", "mainBinaryName": "AstralRinth App",
"identifier": "AstralRinthApp", "identifier": "AstralRinthApp",
"plugins": { "plugins": {
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT\n uuid, active, username, access_token, refresh_token, expires\n FROM minecraft_users\n WHERE active = TRUE\n ", "query": "\n SELECT\n uuid, active, username, access_token, refresh_token, expires, account_type\n FROM minecraft_users\n WHERE active = TRUE\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -32,6 +32,11 @@
"name": "expires", "name": "expires",
"ordinal": 5, "ordinal": 5,
"type_info": "Integer" "type_info": "Integer"
},
{
"name": "account_type",
"ordinal": 6,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -43,8 +48,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "bf7d47350092d87c478009adaab131168e87bb37aa65c2156ad2cb6198426d8c" "hash": "57214178fb3a0ccd8f67457e9732a706cbc4a4f5190c9320d1ad6111b9711d63"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT\n uuid, active, username, access_token, refresh_token, expires\n FROM minecraft_users\n ", "query": "\n SELECT\n uuid, active, username, access_token, refresh_token, expires, account_type\n FROM minecraft_users\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -32,6 +32,11 @@
"name": "expires", "name": "expires",
"ordinal": 5, "ordinal": 5,
"type_info": "Integer" "type_info": "Integer"
},
{
"name": "account_type",
"ordinal": 6,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -43,8 +48,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "727e3e1bc8625bbcb833920059bb8cea926ac6c65d613904eff1d740df30acda" "hash": "5c803f3d90c147210e8e7a7a6d7234d3801bc38c23e1e02fbd8fa08ae51e8f08"
} }
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires, account_type)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ON CONFLICT (uuid) DO UPDATE SET\n active = $2,\n username = $3,\n access_token = $4,\n refresh_token = $5,\n expires = $6,\n account_type = $7\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "8f7d4406ddae4a158eabb20fc6a8ffb21c4e22c7ff33459df5049ee441fa0467"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (uuid) DO UPDATE SET\n active = $2,\n username = $3,\n access_token = $4,\n refresh_token = $5,\n expires = $6\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "d719cf2f6f87c5ea7ea6ace2d6a1828ee58a724f06a91633b8a40b4e04d0b9a0"
}
@@ -0,0 +1,5 @@
-- [AR] - SQL Migration
ALTER TABLE minecraft_users ADD COLUMN account_type varchar(32) NOT NULL DEFAULT 'unknown';
UPDATE minecraft_users SET account_type = 'microsoft' WHERE access_token != 'null';
UPDATE minecraft_users SET account_type = 'pirate' WHERE access_token == 'null';
@@ -131,6 +131,7 @@ where
expires: legacy_credentials.expires, expires: legacy_credentials.expires,
active: minecraft_auth.default_user == Some(uuid) active: minecraft_auth.default_user == Some(uuid)
|| minecraft_users_len == 1, || minecraft_users_len == 1,
account_type: legacy_credentials.account_type,
} }
.upsert(exec) .upsert(exec)
.await?; .await?;
@@ -518,6 +519,7 @@ struct LegacyCredentials {
pub access_token: String, pub access_token: String,
pub refresh_token: String, pub refresh_token: String,
pub expires: DateTime<Utc>, pub expires: DateTime<Utc>,
pub account_type: String,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
+37 -5
View File
@@ -191,6 +191,7 @@ pub async fn login_finish(
expires: oauth_token.date expires: oauth_token.date
+ Duration::seconds(oauth_token.value.expires_in as i64), + Duration::seconds(oauth_token.value.expires_in as i64),
active: true, active: true,
account_type: AccountType::Microsoft.as_lowercase_str(),
}; };
// During login, we need to fetch the online profile at least once to get the // During login, we need to fetch the online profile at least once to get the
@@ -229,6 +230,7 @@ pub async fn offline_auth(
refresh_token: refresh_token, refresh_token: refresh_token,
expires: Utc::now() + Duration::days(365 * 99), expires: Utc::now() + Duration::days(365 * 99),
active: true, active: true,
account_type: AccountType::Pirate.as_lowercase_str(),
}; };
credentials.offline_profile = MinecraftProfile { credentials.offline_profile = MinecraftProfile {
@@ -242,6 +244,30 @@ pub async fn offline_auth(
Ok(credentials) Ok(credentials)
} }
/// [AR] • Feature
#[derive(Deserialize, Debug)]
pub enum AccountType {
Unknown,
Microsoft,
Pirate,
ElyBy,
}
impl AccountType {
fn as_str(&self) -> &'static str {
match self {
AccountType::Unknown => "Unknown",
AccountType::Microsoft => "Microsoft",
AccountType::Pirate => "Pirate",
AccountType::ElyBy => "ElyBy",
}
}
fn as_lowercase_str(&self) -> String {
self.as_str().to_lowercase()
}
}
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct Credentials { pub struct Credentials {
/// The offline profile of the user these credentials are for. /// The offline profile of the user these credentials are for.
@@ -255,6 +281,7 @@ pub struct Credentials {
pub refresh_token: String, pub refresh_token: String,
pub expires: DateTime<Utc>, pub expires: DateTime<Utc>,
pub active: bool, pub active: bool,
pub account_type: String,
} }
/// An entry in the player profile cache, keyed by player UUID. /// An entry in the player profile cache, keyed by player UUID.
@@ -480,7 +507,7 @@ impl Credentials {
let res = sqlx::query!( let res = sqlx::query!(
" "
SELECT SELECT
uuid, active, username, access_token, refresh_token, expires uuid, active, username, access_token, refresh_token, expires, account_type
FROM minecraft_users FROM minecraft_users
WHERE active = TRUE WHERE active = TRUE
" "
@@ -503,6 +530,7 @@ impl Credentials {
.single() .single()
.unwrap_or_else(Utc::now), .unwrap_or_else(Utc::now),
active: x.active == 1, active: x.active == 1,
account_type: x.account_type,
}; };
credentials.refresh(exec).await.ok(); credentials.refresh(exec).await.ok();
Some(credentials) Some(credentials)
@@ -517,7 +545,7 @@ impl Credentials {
let res = sqlx::query!( let res = sqlx::query!(
" "
SELECT SELECT
uuid, active, username, access_token, refresh_token, expires uuid, active, username, access_token, refresh_token, expires, account_type
FROM minecraft_users FROM minecraft_users
" "
) )
@@ -537,6 +565,7 @@ impl Credentials {
.single() .single()
.unwrap_or_else(Utc::now), .unwrap_or_else(Utc::now),
active: x.active == 1, active: x.active == 1,
account_type: x.account_type,
}; };
async move { async move {
@@ -572,14 +601,15 @@ impl Credentials {
sqlx::query!( sqlx::query!(
" "
INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires) INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires, account_type)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (uuid) DO UPDATE SET ON CONFLICT (uuid) DO UPDATE SET
active = $2, active = $2,
username = $3, username = $3,
access_token = $4, access_token = $4,
refresh_token = $5, refresh_token = $5,
expires = $6 expires = $6,
account_type = $7
", ",
uuid, uuid,
self.active, self.active,
@@ -587,6 +617,7 @@ impl Credentials {
self.access_token, self.access_token,
self.refresh_token, self.refresh_token,
expires, expires,
self.account_type,
) )
.execute(exec) .execute(exec)
.await?; .await?;
@@ -649,6 +680,7 @@ impl Serialize for Credentials {
ser.serialize_field("refresh_token", &self.refresh_token)?; ser.serialize_field("refresh_token", &self.refresh_token)?;
ser.serialize_field("expires", &self.expires)?; ser.serialize_field("expires", &self.expires)?;
ser.serialize_field("active", &self.active)?; ser.serialize_field("active", &self.active)?;
ser.serialize_field("account_type", &self.account_type)?;
ser.end() ser.end()
} }
} }
+64
View File
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="10mm"
height="10mm"
viewBox="0 0 10 10"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
sodipodi:docname="elyby-icon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="20.48"
inkscape:cx="13.891602"
inkscape:cy="18.09082"
inkscape:window-width="1776"
inkscape:window-height="1236"
inkscape:window-x="244"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#00c600;stroke-width:0.264999;paint-order:markers fill stroke;fill-opacity:1"
id="rect1"
width="10"
height="10"
x="0"
y="0"
rx="1"
ry="1" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:9.61119px;font-family:Arial;-inkscape-font-specification:'Arial, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke-width:0.80219;paint-order:markers fill stroke"
x="1.0449646"
y="9.1731787"
id="text1"
inkscape:label="text1"
transform="scale(1.1466469,0.87210806)"><tspan
sodipodi:role="line"
id="tspan1"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:9.61119px;font-family:Arial;-inkscape-font-specification:'Arial, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;fill-opacity:1;stroke-width:0.802193"
x="1.0449646"
y="9.1731787">E</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+2
View File
@@ -89,6 +89,7 @@ import _PirateIcon from './icons/pirate.svg?component'
import _MicrosoftIcon from './icons/microsoft.svg?component' import _MicrosoftIcon from './icons/microsoft.svg?component'
import _PirateShipIcon from './icons/pirate-ship.svg?component' import _PirateShipIcon from './icons/pirate-ship.svg?component'
import _AstralRinthLogo from './icons/astralrinth-logo.svg?component' import _AstralRinthLogo from './icons/astralrinth-logo.svg?component'
import _ElyByIcon from './icons/elyby-icon.svg?component'
// [AR] Feature. Exports // [AR] Feature. Exports
@@ -96,6 +97,7 @@ export const PirateIcon = _PirateIcon
export const MicrosoftIcon = _MicrosoftIcon export const MicrosoftIcon = _MicrosoftIcon
export const PirateShipIcon = _PirateShipIcon export const PirateShipIcon = _PirateShipIcon
export const AstralRinthLogo = _AstralRinthLogo export const AstralRinthLogo = _AstralRinthLogo
export const ElyByIcon = _ElyByIcon
// Skin Models // Skin Models
export { default as ClassicPlayerModel } from './models/classic-player.gltf?url' export { default as ClassicPlayerModel } from './models/classic-player.gltf?url'