feat: mii editor UI

This commit is contained in:
limes
2025-09-01 05:02:34 +02:00
parent 21ae7a4db9
commit 65b4bf64aa
12 changed files with 23095 additions and 2239 deletions

View File

@@ -2,4 +2,4 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}
}

View File

@@ -1,3 +1,5 @@
import { nodePolyfills } from 'vite-plugin-node-polyfills';
export default defineNuxtConfig({
compatibilityDate: '2024-11-01',
devtools: { enabled: true },
@@ -9,6 +11,17 @@ export default defineNuxtConfig({
}
},
vite: {
server: {
allowedHosts: ['pretendo.network']
},
plugins: [
nodePolyfills({
include: ['buffer', 'crypto', 'stream'] // we need these for the mii editor
})
]
},
modules: [
'@nuxt/eslint',
'@nuxt/fonts',
@@ -58,6 +71,9 @@ export default defineNuxtConfig({
strategy: 'no_prefix',
defaultLocale: 'en-US',
vueI18n: '../i18n.config.ts',
bundle: {
optimizeTranslationDirective: false
},
locales: [
{ code: 'ar-AR', name: 'العربية', file: 'ar_AR.json' },
{ code: 'ast', name: 'Asturianu', file: 'ast.json' },

8999
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@
"build": "nuxt build",
"prepare": "nuxt prepare",
"dev": "nuxt dev",
"dev:host": "nuxt dev --host",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
@@ -19,15 +20,24 @@
"@nuxt/fonts": "^0.11.1",
"@nuxt/icon": "^1.11.0",
"@nuxtjs/i18n": "^9.5.3",
"@pretendonetwork/mii-js": "^1.0.5",
"base64-arraybuffer": "^1.0.2",
"eslint": "^9.24.0",
"feed": "^4.2.2",
"node-stdlib-browser": "^1.2.0",
"nuxt": "^3.16.2",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"overrides": {
"vite": "^6.3.2"
},
"devDependencies": {
"@iconify-json/fa": "^1.2.2",
"@iconify-json/ph": "^1.2.2",
"@pretendonetwork/eslint-config": "^0.0.8",
"eslint-plugin-vue": "^10.0.0",
"sass-embedded": "^1.86.3"
"sass-embedded": "^1.86.3",
"vite-plugin-node-polyfills": "^0.24.0"
}
}

View File

@@ -23,6 +23,7 @@
--green-shade-0: #37a985;
--green-shade-1: #59c9a5;
--red-shade-2: #ff4580;
--red-shade-1: #a9375b;
--yellow-shade-1: #ffd966;
@@ -38,7 +39,7 @@ body,
width: 100%;
max-width: 100vw;
position: relative; /* This fixes overflow-x not hiding on Safari on mobile */
overflow-x: hidden;
overflow: hidden;
margin: 0;
color: var(--text-shade-3);
justify-content: center;
@@ -231,11 +232,11 @@ input[type="range"] {
input[type="range"]::-webkit-slider-runnable-track {
background: var(--bg-shade-3);
height: 1rem;
border-radius: 1rem;
border-radius: .25rem;
}
input[type="range"]::-moz-range-track {
background: var(--bg-shade-3);
border-radius: 1rem;
border-radius: .25rem;
height: 1rem;
}
input[type="range"]::-webkit-slider-thumb {
@@ -243,15 +244,15 @@ input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 1.5rem;
height: 3rem;
margin-top: -1rem;
margin-top: -1.25rem;
background-color: var(--accent-shade-1);
border-radius: 0.5rem;
border-radius: .5rem;
}
input[type="range"]::-moz-range-thumb {
width: 1.5rem;
height: 3rem;
border: none;
border-radius: 0.5rem;
border-radius: .5rem;
background-color: var(--accent-shade-1);
}
input[type="range"]:focus {

View File

@@ -0,0 +1,317 @@
<script lang="ts" setup>
const { day, month } = defineProps<{ day: number; month: number }>();
defineEmits(['change']);
const { t, locale } = useI18n();
const open = ref(false);
const tempDay = ref(day);
const tempMonth = ref(month === 0 ? 1 : month);
const validTempDate = computed(() => {
return checkvalidDate(tempDay.value, tempMonth.value);
});
const daysInTempMonth = computed(() => {
return getMaxDaysInMonth(tempMonth.value);
});
/* gets the number of days in a month, in a leap year. months are 0-indexed and setting the day field to 0 returns the last day of the month prior.
* 2004 was chosen because it's a leap year and it's my birth year :3 */
function getMaxDaysInMonth(month: number) {
return new Date(2004, month, 0).getDate();
}
function monthToString(month: number) {
return new Date(2004, month - 1, 15).toLocaleDateString(locale.value, {
month: 'long'
});
}
function checkvalidDate(day: number, month: number) {
if (month < 1 || month > 12) {
return false;
} else if (day < 1 || day > getMaxDaysInMonth(month)) {
return false;
} else {
return true;
}
}
const dateString = computed(() => {
if (!checkvalidDate(day, month)) {
return t('miiEditor.clickToSet');
}
const birthdate = new Date(2004, month - 1, day);
return birthdate.toLocaleDateString(undefined, {
month: '2-digit',
day: '2-digit'
});
});
</script>
<template>
<div class="date-selector-wrapper">
<div
:class="{'date-selector': true, 'open': open }"
>
<template v-if="open">
<div class="header">
<button
:class="{
previous: true,
'page-btn': true,
disabled: tempMonth === 1,
}"
:disabled="tempMonth === 1"
@click.prevent="() => {
tempMonth--;
}"
>
<Icon
name="ph:caret-left"
size="20"
/>
</button>
<span class="month-name">{{ monthToString(tempMonth) }}</span>
<button
:class="{
next: true,
'page-btn': true,
disabled: tempMonth === 12,
}"
:disabled="tempMonth === 12"
@click.prevent="() => {
tempMonth++;
}"
>
<Icon
name="ph:caret-right"
size="20"
/>
</button>
</div>
<fieldset class="datepicker-day-grid">
<template
v-for="i in [
...Array(daysInTempMonth)
.keys()
.map((j) => j + 1),
]"
:key="i"
>
<input
:id="`datepicker-day-${i}`"
:value="i"
type="radio"
:name="`datepicker-days`"
:checked="i === tempDay && month === tempMonth"
@click="
() => {
tempDay = i;
$emit('change', { day: tempDay, month: tempMonth})
}
"
>
<label :for="`datepicker-day-${i}`"><span>{{ i }}</span></label>
</template>
</fieldset>
</template>
</div>
<div
:class="{ input: true, 'invalid-date': !validTempDate, open: open }"
@click="!open && (open = true)"
>
<button
class="trash action-button"
@click.prevent.stop="() => {
$emit('change', { day: 0, month: 0})
open = false
}"
>
<Icon
name="ph:trash-fill"
size="24"
/>
</button>
<span class="date-string">{{ dateString }}</span>
<Icon
class="icon"
name="ph:calendar-dots-fill"
size="24"
/>
<button
class="confirm action-button"
@click.prevent.stop="() => {
open = false
}"
>
<Icon
name="ph:check-circle-fill"
size="24"
/>
</button>
</div>
</div>
</template>
<style lang="scss" scoped>
.date-selector-wrapper {
position: relative;
display: grid;
user-select: none;
}
.date-selector {
display: grid;
justify-content: center;
position: absolute;
width: 100%;
max-height: 0;
height: 0;
overflow-y: hidden;
box-sizing: border-box;
bottom: 100%;
background-color: var(--bg-shade-0);
border: 1px solid var(--bg-shade-3);
border-bottom: 0;
border-radius: .5rem .5rem 0 0;
opacity: 0;
z-index: 2;
transition: max-height 400ms, opacity 200ms;
}
.date-selector.open {
opacity: 1;
height: auto;
max-height: 24rem;
}
.date-selector.open::after {
content: "";
position: absolute;
display: block;
height: 1px;
width: 90%;
left: 5%;
bottom: 0;
background: var(--bg-shade-3);
}
.date-selector .header {
display: grid;
grid-template-columns: auto 1fr auto;
text-align: center;
padding: 1rem .75rem .25rem .75rem;
}
.date-selector .header button {
background: none;
padding: .25rem;
height: 20px;
}
.date-selector .header button.disabled {
color: var(--text-shade-0)
}
.datepicker-day-grid {
appearance: none;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: .25rem;
border: none;
padding: .75rem;
margin: 0;
}
.datepicker-day-grid input[type="radio"] {
display: none;
}
.datepicker-day-grid input[type="radio"] + label {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: .25rem;
padding: .25rem .5rem;
background: none;
height: fit-content;
color: var(--text-shade-0);
width: 1rem;
font-size: inherit;
aspect-ratio: 1;
}
.datepicker-day-grid input[type="radio"]:checked + label {
color: var(--text-shade-3);
font-weight: 700;
background: var(--accent-shade-1);
}
.input {
position: relative;
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: center;
box-sizing: border-box;
gap: 0.5rem;
background-color: var(--bg-shade-3);
border: none;
border-radius: 4px;
padding: 12px;
cursor: pointer;
color: var(--text-shade-3);
border: 0 solid var(--bg-shade-3);
transition: background 200ms, color 200ms, border-radius 200ms;
}
.input.open {
cursor: unset;
justify-content: space-between;
color: var(--text-shade-3);
background-color: var(--bg-shade-0);
border: 1px solid var(--bg-shade-3);
border-top: 0;
border-radius: 0 0 .5rem .5rem;
padding: 12px 11px 11px 11px;
}
.input .icon {
position: absolute;
right: 11px;
}
.input.open .icon {
display: none;
}
.input .action-button {
display: none;
background: none;
padding: 0;
height: 24px;
}
.input.open .action-button {
display: inline-block;
}
.input .action-button.trash {
color: var(--red-shade-2);
}
.input.invalid-date {
color: var(--text-shade-1);
}
/*
.subtab.info .birthdate.monthFirst .month {
order: 0;
}
.subtab.info .birthdate.monthFirst span {
order: 1;
}
.subtab.info .birthdate.monthFirst .day {
order: 2;
}*/
</style>

5
src/layouts/slotOnly.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<div id="root">
<slot />
</div>
</template>

View File

@@ -408,6 +408,21 @@
"progress": "<span>$${totd}</span> of <span>$${goald}</span>/month, <span>${perc}%</span> of the monthly goal.",
"upgradePush": "To become a subscriber and gain access to cool perks, visit the <a href=\"/account/upgrade\">upgrade page</a>."
},
"miiEditor": {
"nickname":"Nickname",
"creator": "Creator",
"birthday": "Birthday",
"clickToSet": "Click to set",
"favorite": "Favorite",
"sharing": "Sharing",
"copying": "Copying",
"save": "Save",
"saveCaption": "Saving your Mii will kill the previous one.",
"noCanvas": "Your browser does not support the canvas element.",
"corruptedData": "Found corrupted Mii data. Would you like to start from scratch?",
"miiSaved": "Your Mii has been updated! Changes may take some time to reflect across all platforms and services.",
"loading": "Loading Mii editor..."
},
"localizationPage": {
"title": "Let's localize",
"description": "Paste a link to a publicly accessible JSON locale to test it on the website",

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 525 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 459 KiB

319
src/utils/miieditor.ts Normal file
View File

@@ -0,0 +1,319 @@
export interface Slider {
name: string;
min: number;
max: number;
}
export interface Subtab {
name: string;
type?: 'color' | 'slider' | 'component';
values?: number[][] | string[][] | boolean[][] | Slider[][];
}
export interface Tab {
name: string; subTabs?: Subtab[];
}
export const miiEditorJSON: {
tabs: Tab[];
} = {
tabs: [
{
name: 'faceType',
subTabs: [
{ name: 'faceType', values: [[0, 1, 8, 2, 3, 9, 4, 5, 10, 6, 7, 11]] },
{
name: 'makeupType',
values: [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]]
},
{
name: 'wrinklesType',
values: [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]]
},
{
name: 'skinColor',
type: 'color',
values: [
['#FFCE98', '#FFBA64', '#FF7C3E', '#FFB184', '#CA5326', '#752E17 ']
]
}
]
},
{
name: 'hairType',
subTabs: [
{
name: 'hairType',
values: [
[33, 47, 40, 37, 32, 107, 48, 51, 55, 70, 44, 66],
[52, 50, 38, 49, 43, 31, 56, 68, 62, 115, 76, 119],
[64, 81, 116, 121, 22, 58, 60, 87, 125, 117, 73, 75],
[42, 89, 57, 54, 80, 34, 23, 86, 88, 118, 39, 36],
[45, 67, 59, 65, 41, 30, 12, 16, 10, 82, 128, 129],
[14, 95, 105, 100, 6, 20, 93, 102, 27, 4, 17, 110],
[123, 8, 106, 72, 3, 21, 0, 98, 63, 90, 11, 120],
[5, 74, 108, 94, 124, 25, 99, 69, 35, 13, 122, 113],
[53, 24, 85, 83, 71, 131, 96, 101, 29, 7, 15, 112],
[79, 1, 109, 127, 91, 26, 61, 103, 2, 77, 18, 92],
[84, 9, 19, 130, 97, 104, 46, 78, 28, 114, 126, 111]
]
},
{
name: 'hairColor',
type: 'color',
values: [
[
'#0A0A09',
'#562D1B',
'#772314',
'#9C481E',
'#988B8D',
'#664C1A',
'#AA6822',
'#FEB454'
]
]
}
]
},
{
name: 'eyebrowType',
subTabs: [
{
name: 'eyebrowType',
values: [
[6, 0, 12, 1, 9, 19, 7, 21, 8, 17, 5, 4],
[11, 10, 2, 3, 14, 20, 15, 13, 22, 18, 16, 23]
]
},
{
name: 'eyebrowPosition',
type: 'slider',
values: [[
{ name: 'eyebrowYPosition', min: 3, max: 18 },
{ name: 'eyebrowSpacing', min: 0, max: 12 },
{ name: 'eyebrowRotation', min: 0, max: 11 },
{ name: 'eyebrowScale', min: 0, max: 8 },
{ name: 'eyebrowVerticalStretch', min: 0, max: 6 }
]]
},
{
name: 'eyebrowColor',
type: 'color',
values: [
[
'#0A0A09',
'#562D1B',
'#772314',
'#9C481E',
'#988B8D',
'#664C1A',
'#AA6822',
'#FEB454'
]
]
}
]
},
{
name: 'eyeType',
subTabs: [
{
name: 'eyeType',
values: [
[2, 4, 0, 8, 39, 17, 1, 26, 16, 15, 27, 20],
[33, 11, 19, 32, 9, 12, 23, 34, 21, 25, 40, 35],
[5, 41, 13, 36, 37, 6, 24, 30, 31, 18, 28, 46],
[7, 44, 38, 42, 45, 29, 3, 43, 22, 10, 14, 47],
[48, 49, 50, 53, 59, 56, 54, 58, 57, 55, 51, 52]
]
},
{
name: 'eyePosition',
type: 'slider',
values: [[
{ name: 'eyeYPosition', min: 0, max: 18 },
{ name: 'eyeSpacing', min: 0, max: 12 },
{ name: 'eyeRotation', min: 0, max: 7 },
{ name: 'eyeScale', min: 0, max: 7 },
{ name: 'eyeVerticalStretch', min: 0, max: 6 }
]]
},
{
name: 'eyeColor',
type: 'color',
values: [
['#070606', '#887E75', '#814631', '#796A34', '#565BA4', '#4C8260']
]
}
]
},
{
name: 'noseType',
subTabs: [
{
name: 'noseType',
values: [
[1, 10, 2, 3, 6, 0, 5, 4, 8, 9, 7, 11],
[13, 14, 12, 17, 16, 15]
]
},
{
name: 'nosePosition',
type: 'slider',
values: [[
{ name: 'noseYPosition', min: 0, max: 18 },
{ name: 'noseScale', min: 0, max: 8 }
]]
}
]
},
{
name: 'mouthType',
subTabs: [
{
name: 'mouthType',
values: [
[23, 1, 19, 21, 22, 5, 0, 8, 10, 16, 6, 13],
[7, 9, 2, 17, 3, 4, 15, 11, 20, 18, 14, 12],
[27, 30, 24, 25, 29, 28, 26, 35, 31, 34, 33, 32]
]
},
{
name: 'mouthPosition',
type: 'slider',
values: [[
{ name: 'mouthYPosition', min: 0, max: 18 },
{ name: 'mouthScale', min: 0, max: 8 },
{ name: 'mouthHorizontalStretch', min: 0, max: 6 }
]]
},
{
name: 'mouthColor',
type: 'color',
values: [['#FF5D0C', '#FF110C', '#FF524B', '#FFAA77', '#181312']]
}
]
},
{
name: 'glassesType',
subTabs: [
{
name: 'glassesType',
values: [[0, 1, 2, 3, 4, 5, 6, 7, 8]]
},
{
name: 'glassesPosition',
type: 'slider',
values: [[
{ name: 'glassesYPosition', min: 0, max: 20 },
{ name: 'glassesScale', min: 0, max: 7 }
]]
},
{
name: 'glassesColor',
type: 'color',
values: [
['#000000', '#7A4011', '#D51208', '#29376E', '#D56D00', '#987F6E']
]
}
]
},
{
name: 'beardType',
subTabs: [
{
name: 'beardType',
values: [[0, 1, 2, 3, 4, 5]]
},
{
name: 'mustacheType',
values: [[0, 1, 2, 3, 4, 5]]
},
{
name: 'facialHairPosition',
type: 'slider',
values: [[
{ name: 'mustacheYPosition', min: 0, max: 16 },
{ name: 'mustacheScale', min: 0, max: 8 }
]]
},
{
name: 'facialHairColor',
type: 'color',
values: [
[
'#0A0A09',
'#562D1B',
'#772314',
'#9C481E',
'#988B8D',
'#664C1A',
'#AA6822',
'#FEB454'
]
]
}
]
},
{
name: 'moleEnabled',
subTabs: [
{
name: 'moleEnabled',
values: [[false, true]]
},
{
name: 'molePosition',
type: 'slider',
values: [[
{ name: 'moleYPosition', min: 0, max: 30 },
{ name: 'moleXPosition', min: 0, max: 16 },
{ name: 'moleScale', min: 0, max: 8 }
]]
}
]
},
{
name: 'miisc',
subTabs: [
{
name: 'size',
type: 'slider',
values: [[
{ name: 'height', min: 0, max: 127 },
{ name: 'build', min: 0, max: 127 }
]]
},
{
name: 'gender',
values: [[0, 1]]
},
{
name: 'favoriteColor',
type: 'color',
values: [
[
'#FF2216',
'#FF7A1A',
'#FFED22',
'#91F321',
'#008332',
'#0D50BE',
'#49BBE4',
'#FF6381',
'#8B2DB2',
'#573D18',
'#FFFFFF',
'#1D1A15'
]
]
}
]
},
{
name: 'save', subTabs: [{ name: 'info', type: 'component' }, { name: 'save', type: 'component' }]
}
]
};