Merge branch 'dev' into feat/email-editing

This commit is contained in:
limes
2026-08-29 22:58:29 +02:00
31 changed files with 217 additions and 172 deletions

View File

@@ -116,7 +116,7 @@ Thanks to the dedicated work by developer [Arian Kordi](https://github.com/arian
While the last few months had a lot of focus on internal changes and Juxtaposition, game servers also saw a lot of attention!
- [Dani](https://github.com/DaniElectra) has begun implementing the [`MessageDelivery` and `Messaging` protocols into our "common" module](https://github.com/PretendoNetwork/nex-protocols-common-go/pull/54). Once complete, these protocols should be widely available to all games
- [Dani](https://github.com/DaniElectra) Has begun [implementing the `MatchmakeExtension::GetPlayingSession`](https://github.com/PretendoNetwork/nex-protocols-common-go/pull/55) NEX method for use in multiplayer games
- [Dani](https://github.com/DaniElectra) Has begun [implementing the `MatchmakeExtension::GetPlayingSession`](https://github.com/PretendoNetwork/nex-protocols-common-go/pull/55) NEX method for use in multiplayer games
![The Lobby area in Splatoon 1.](/assets/images/blogposts/december-25-2025/image6.png)
*Splatoon, for its part, calls GetPlayingSession every time you enter the multiplayer lobby.*
- [Trace](https://github.com/TraceEntertains) updated [Yo Kai Watch Blasters](https://github.com/PretendoNetwork/yo-kai-watch-blasters/pull/5) to the latest server libraries, including the above mentioned changes by [Dani](https://github.com/DaniElectra). With this update friend rooms and trading are now functional

View File

@@ -156,6 +156,7 @@ export default defineNuxtConfig({
{ name: 'application-name', content: 'Pretendo Network' },
{ name: 'msapplication-TileColor', content: '#1b1f3b' },
{ name: 'theme-color', content: '#1b1f3b' },
{ property: 'og:title', content: 'Pretendo Network' },
{
property: 'og:description',
content:

View File

@@ -21,11 +21,11 @@ const errors: Record<string, ApiErrorCodes> = {
'INVALID_ARGUMENT: Two or more punctuation characters cannot be used in a row': 'USERNAME_INVALID_CHARS',
'INVALID_ARGUMENT: PNID already in use': 'USERNAME_IN_USE',
'INVALID_ARGUMENT: Mii name too long': 'MIINAME_TOO_LONG',
'INVALID_ARGUMENT: Password must be between 6 and 16 characters long': 'INVALID_PASSWORD_INPUT',
'INVALID_ARGUMENT: Password cannot be the same as username': 'INVALID_PASSWORD_INPUT',
'INVALID_ARGUMENT: Password must have combination of letters, numbers, and/or punctuation characters': 'INVALID_PASSWORD_INPUT',
'INVALID_ARGUMENT: Password may not have 3 repeating characters': 'INVALID_PASSWORD_INPUT',
'INVALID_ARGUMENT: Passwords do not match': 'INVALID_PASSWORD_NO_MATCH'
'INVALID_ARGUMENT: Password must be between 6 and 16 characters long': 'PASSWORD_INVALID_LENGTH',
'INVALID_ARGUMENT: Password cannot be the same as username': 'PASSWORD_NOT_USERNAME',
'INVALID_ARGUMENT: Password must have combination of letters, numbers, and/or punctuation characters': 'PASSWORD_NEEDS_CHARS',
'INVALID_ARGUMENT: Password may not have 3 repeating characters': 'PASSWORD_REPEATED_CHARS',
'INVALID_ARGUMENT: Passwords do not match': 'PASSWORDS_DO_NOT_MATCH'
};
function getCutoffDateForAge(today: Date, age: number) {

View File

@@ -1,19 +1,45 @@
import { ResetPasswordSchema } from '~~/shared/api-types';
import type { ApiErrorCodes } from '~~/shared/errors';
const errors: Record<string, ApiErrorCodes> = {
'Missing token': 'INVALID_INPUT',
'Invalid token': 'INVALID_INPUT',
'Token expired': 'INVALID_INPUT',
'Invalid token. No user found': 'INVALID_INPUT',
'Must enter a password': 'PASSWORD_INVALID_LENGTH',
'Password is too long': 'PASSWORD_INVALID_LENGTH',
'Password is too short': 'PASSWORD_INVALID_LENGTH',
'Password cannot be the same as username': 'PASSWORD_NOT_USERNAME',
'Password must have combination of letters, numbers, and/or punctuation characters': 'PASSWORD_NEEDS_CHARS',
'Password may not have 3 repeating characters': 'PASSWORD_REPEATED_CHARS',
'Passwords do not match': 'PASSWORDS_DO_NOT_MATCH'
};
export default defineEventHandler(async (event): Promise<void> => {
const body = await readZodBody(event, ResetPasswordSchema);
const apiFetch = useHttpApi(event);
// The GRPC version requires a login token, which the user doesnt have when resetting password, so we're using the HTTP api
await apiFetch('/v1/reset-password', {
method: 'POST',
body: JSON.stringify({
password: body.password,
password_confirm: body.passwordConfirm,
token: body.resetToken
}),
headers: {
'Content-type': 'application/json'
try {
await apiFetch('/v1/reset-password', {
method: 'POST',
body: JSON.stringify({
password: body.password,
password_confirm: body.passwordConfirm,
token: body.resetToken
}),
headers: {
'Content-type': 'application/json'
}
});
} catch (err: any) {
const data = err?.data;
let errorCode: ApiErrorCodes = 'UNHANDLED_ERROR';
if (typeof data === 'object' && data?.error) {
errorCode = errors[data.error] ?? 'UNHANDLED_ERROR';
}
});
throw createApiError(errorCode);
}
});

View File

@@ -0,0 +1,18 @@
import type { H3Error } from 'h3';
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('error', (err, { event }) => {
const error = err as Partial<H3Error>;
const statusCode = error.statusCode ?? 500;
// Skip expected client errors (404, 401, 422, redirects, ...).
if (statusCode < 500) {
return;
}
const where = event ? `${event.method} ${event.path}` : 'non-request';
const tag = error.unhandled ? '[unhandled]' : '';
console.error(`[server error] ${tag} ${statusCode} ${where}:`, err);
});
});

View File

@@ -27,7 +27,11 @@ export function useHttpApi(event: H3Event, token?: string): HttpApiFetch {
if (response.statusCode >= 400) {
const err = new Error(`Request failed with ${response.statusCode}`);
try {
(err as any).data = await response.body.text();
if (response.headers['content-type']?.includes('application/json')) {
(err as any).data = await response.body.json();
} else {
(err as any).data = await response.body.text();
}
} catch {
// It's already errored, we don't need to know the body
}

View File

@@ -16,8 +16,11 @@ const apiErrorCodes = {
USERNAME_INVALID_CHARS: 'Username contains invalid characters',
USERNAME_IN_USE: 'PNID already in use',
MIINAME_TOO_LONG: 'Mii name too long',
INVALID_PASSWORD_INPUT: 'Password must be between 6 and 16 characters long',
INVALID_PASSWORD_NO_MATCH: 'Passwords do not match',
PASSWORD_INVALID_LENGTH: 'Password must be between 6 and 16 characters long',
PASSWORD_NOT_USERNAME: 'Password cannot be the same as your username',
PASSWORD_NEEDS_CHARS: 'Password must have combination of letters, numbers, and/or punctuation characters',
PASSWORDS_DO_NOT_MATCH: 'Passwords do not match',
PASSWORD_REPEATED_CHARS: 'Password may not have 3 repeating characters',
ACCOUNT_DELETED: 'Account has been deleted',
INVALID_ACCESS_LEVEL: 'Invalid access level',
BANNED: 'Account is banned',
@@ -48,8 +51,11 @@ export const apiErrorCodeStatus: Record<ApiErrorCodes, number> = {
UNDER_THIRTEEN: 400,
ACCOUNT_DELETED: 400,
INVALID_EMAIL: 400,
INVALID_PASSWORD_INPUT: 400,
INVALID_PASSWORD_NO_MATCH: 400,
PASSWORD_INVALID_LENGTH: 400,
PASSWORD_NOT_USERNAME: 400,
PASSWORD_NEEDS_CHARS: 400,
PASSWORDS_DO_NOT_MATCH: 400,
PASSWORD_REPEATED_CHARS: 400,
MIINAME_TOO_LONG: 400,
USERNAME_IN_USE: 400,
USERNAME_INVALID_CHARS: 400,

View File

@@ -0,0 +1,36 @@
import type { UseSeoMetaInput } from '@unhead/vue';
export type CustomSeoMetaOptions = UseSeoMetaInput & {
subsection?: string;
};
export default function (args?: CustomSeoMetaOptions) {
let newTitleTemplate = '';
switch (args?.subsection) {
case ('account'): {
newTitleTemplate = 'Account | Pretendo Network';
break;
}
case ('terms'): {
newTitleTemplate = 'Terms | Pretendo Network';
break;
}
case ('blog'): {
newTitleTemplate = 'Blog | Pretendo Network';
break;
}
case ('docs'): {
newTitleTemplate = 'Docs | Pretendo Network';
break;
}
default: {
newTitleTemplate = 'Pretendo Network';
}
}
const newTitle = args?.title ? `${args?.title} | ${newTitleTemplate}` : newTitleTemplate;
useHead({ title: newTitle });
useSeoMeta({ ...args, title: newTitle, ogTitle: newTitle, twitterTitle: newTitle, ogDescription: args?.description, twitterDescription: args?.description, twitterImage: args?.ogImage });
}

View File

@@ -3,9 +3,11 @@ import { watchImmediate } from '@vueuse/core';
import DefaultLayout from './layouts/default.vue';
const { error } = defineProps<{ error: any }>();
watchImmediate([error], () => {
console.error(error);
});
if (import.meta.client) {
watchImmediate([() => error], () => {
console.error(error);
});
}
</script>
<template>

View File

@@ -1,9 +1,4 @@
<script setup lang="ts">
useHead({
titleTemplate: (titleChunk) => {
return titleChunk ? `${titleChunk} | Pretendo Network` : 'Pretendo Network';
}
});
</script>
<template>

View File

@@ -3,14 +3,6 @@
const path = useRoute().fullPath;
const isSidebarOpen = ref(false);
useHead({
titleTemplate: (titleChunk) => {
return titleChunk
? `${titleChunk} | Docs | Pretendo Network`
: 'Docs | Pretendo Network';
}
});
</script>
<template>

View File

@@ -224,7 +224,7 @@
"nickname": "昵称",
"birthDate": "生日",
"gender": "性别",
"country": "国家/地区",
"country": "国家",
"timezone": "时区",
"serverEnv": "服务器环境",
"production": "生产环境",
@@ -244,7 +244,8 @@
"no_newsletter_notice": "电子报告目前不可用,请稍后再来查看。",
"userSettings": "用户设置",
"no_signins_notice": "目前没有任何登录历史。请稍后再试!",
"no_edit_from_dashboard": "目前无法从网站修改 PNID 设置。请通过已绑定的主机上修改用户设置。"
"no_edit_from_dashboard": "目前无法从网站修改 PNID 设置。请通过已绑定的主机上修改用户设置。",
"region": "地区"
},
"upgrade": "升级账号",
"unavailable": "无法使用",
@@ -309,7 +310,7 @@
"clickToSet": "点击设置",
"favorite": "设为最爱",
"sharing": "共享",
"copying": "复制",
"copying": "允许复制",
"save": "保存",
"saveCaption": "保存你的 Mii 虚拟形象会删除之前的 Mii 虚拟形象。",
"noCanvas": "您的浏览器不支持 canvas 元素。",

View File

@@ -180,7 +180,7 @@
"profile": "個人檔案",
"birthDate": "生日",
"gender": "性別",
"country": "國家/地區",
"country": "國家",
"timezone": "時區",
"serverEnv": "伺服器環境",
"production": "生產",
@@ -204,7 +204,8 @@
"passwordPrompt": "輸入你的 PNID 密碼以下載 Cemu 檔案",
"no_signins_notice": "登入歷史紀錄目前未記錄。請稍後再來查看!",
"no_newsletter_notice": "電子報告目前無法使用。請稍後再來查看!",
"no_edit_from_dashboard": "目前無法從網站編輯 PNID 設定。請通過已連結的主機上編輯使用者設定。"
"no_edit_from_dashboard": "目前無法從網站編輯 PNID 設定。請通過已連結的主機上編輯使用者設定。",
"region": "地區"
},
"upgrade": "升級帳號",
"unavailable": "無法使用",
@@ -307,7 +308,7 @@
"miiEditor": {
"birthday": "生日",
"clickToSet": "點觸設定",
"copying": "複製",
"copying": "允許複製",
"corruptedData": "找到已損壞的 Mii 資料。是否要從頭開始?",
"creator": "創作者",
"favorite": "設為最愛",

View File

@@ -48,6 +48,8 @@ const { execute, isLoading } = useAsync({
});
}
});
customSeoMeta({ subsection: 'account', title: 'Forgot password' });
</script>
<template>

View File

@@ -154,9 +154,7 @@ const { execute: executeUnlinkDiscord, isLoading: isLoadingUnlink } = useAsync({
}
});
useHead({
title: `Account`
});
customSeoMeta({ subsection: 'account' });
</script>
<template>
@@ -166,15 +164,27 @@ useHead({
>
<div class="account-sidebar">
<div class="user">
<a
href="/account/miieditor"
class="mii"
>
<img
:src="profile.mii?.imageUrl"
alt="Mii image"
<div class="mii-wrapper">
<a
href="/account/miieditor"
class="mii"
>
</a>
<img
:src="profile.mii?.imageUrl"
alt="Mii image"
>
</a>
<a
class="edit-button"
href="/account/miieditor"
>
<Icon
name="ph:pencil"
size="20"
/>
</a>
</div>
<p class="miiname">
{{ profile.mii?.name }}
</p>
@@ -635,36 +645,41 @@ useHead({
border-color: #5aff15;
}
.account-sidebar .user .mii-wrapper {
position: relative;
}
.account-sidebar .user a.mii {
position: relative;
display: block;
width: 128px;
height: 128px;
overflow: hidden;
border-radius: 100%;
border-radius: 9999px;
background: var(--bg-shade-3);
}
.account-sidebar .user a.mii::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
no-repeat center/50% url("@/public/assets/images/edit.svg"),
rgba(55, 60, 101, 0.7);
opacity: 0;
transition: opacity 150ms;
}
.account-sidebar .user a.mii:hover::after {
opacity: 1;
.account-sidebar .user a.mii:hover,
.account-sidebar .user .mii-wrapper a.edit-button:hover,
.account-sidebar .user:has(a.edit-button:hover) a.mii,
.account-sidebar .user:has(a.mii:hover) a.edit-button {
background: var(--bg-shade-4);
}
.account-sidebar .user .mii {
width: 100%;
height: 100%;
.account-sidebar .user .mii-wrapper a.edit-button {
color: inherit;
position: absolute;
right: 2px;
bottom: 2px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-shade-3);
width: 32px;
height: 32px;
border: 2px solid var(--bg-shade-1);
border-radius: 9999px;
}
.account-sidebar .buttons {
display: grid;
grid-auto-flow: row;

View File

@@ -47,6 +47,8 @@ const { execute, isLoading } = useAsync({
});
}
});
customSeoMeta({ subsection: 'account', title: 'Login' });
</script>
<template>

View File

@@ -15,9 +15,7 @@ definePageMeta({
const { data: profile } = await useApiFetch('/api/auth/me');
useHead({
title: 'Mii Editor | Pretendo Network'
});
customSeoMeta({ subsection: 'account', title: 'Mii Editor' });
const beforeUnload = (e: BeforeUnloadEvent) => {
e?.preventDefault();

View File

@@ -55,6 +55,8 @@ const { execute, isLoading } = useAsync({
});
}
});
customSeoMeta({ subsection: 'account', title: 'Register' });
</script>
<template>

View File

@@ -30,6 +30,8 @@ const { execute, isLoading } = useAsync({
});
}
});
customSeoMeta({ subsection: 'account', title: 'Reset Password' });
</script>
<template>

View File

@@ -81,10 +81,7 @@ function hasSubscription(priceId: string) {
profile.value?.stripeTier && priceId === profile.value.stripeTier.priceId
);
}
useHead({
title: `Upgrade`
});
customSeoMeta({ subsection: 'account', title: 'Upgrade' });
</script>
<template>

View File

@@ -8,16 +8,11 @@ if (!post.value) {
throw createError({ statusCode: 404, statusMessage: 'Page Not Found' });
}
useHead({
title: `${post.value.title} | Blog`,
meta: [
{ property: 'description', content: post.value.caption },
{ property: 'og:description', content: post.value.caption },
{ property: 'og:image', content: post.value.cover_image },
{ property: 'og:image:alt', content: '' },
{ name: 'twitter:description', content: post.value.caption },
{ name: 'twitter:image', content: post.value.cover_image }
]
customSeoMeta({
subsection: 'blog',
title: post.value.title,
description: post.value.caption,
ogImage: post.value.cover_image
});
</script>

View File

@@ -1,13 +1,12 @@
<script setup lang="ts">
useHead({
title: 'Blog'
});
customSeoMeta({ subsection: 'blog' });
/* eslint-disable vue/no-v-html -- we might wanna avoid this by rewriting the locales to use variables */
const { data: allPosts } = await useAsyncData('blog', () => queryCollection('blog').all());
const posts = computed(() => allPosts.value?.filter(p => !p.path.startsWith('/blog/_')).sort((a, b) => {
return new Date(b.date).getTime() - new Date(a.date).getTime();
}));
</script>
<template>

View File

@@ -7,15 +7,10 @@ const { data: doc } = await useAsyncData(`docs-${slug}`, () => {
if (!doc.value) {
throw createError({ statusCode: 404, statusMessage: 'Page Not Found' });
}
useHead({
title: `${doc.value.title}`,
meta: [
{ property: 'description', content: doc.value.description },
{ property: 'og:description', content: doc.value.description },
{ property: 'og:image:alt', content: '' },
{ name: 'twitter:description', content: doc.value.description }
]
customSeoMeta({
subsection: 'docs',
title: doc.value.title,
description: doc.value.description
});
definePageMeta({

View File

@@ -8,14 +8,10 @@ if (!error.value) {
throw createError({ statusCode: 404, statusMessage: 'Page Not Found' });
}
useHead({
title: `${error.value.code} | Docs`,
meta: [
{ property: 'description', content: error.value.message },
{ property: 'og:description', content: error.value.message },
{ property: 'og:image:alt', content: '' },
{ name: 'twitter:description', content: error.value.message }
]
customSeoMeta({
subsection: 'docs',
title: error.value.code,
description: error.value.message
});
definePageMeta({

View File

@@ -36,25 +36,10 @@ const { data: searchResults } = await useAsyncData(
);
const results = computed(() => searchResults.value ?? []);
useHead({
customSeoMeta({
subsection: 'docs',
title: 'Search',
meta: [
{
name: 'description',
content:
'Got an error code? Find solutions here.'
},
{
name: 'og:description',
content:
'Got an error code? Find solutions here.'
},
{
name: 'twitter:description',
content:
'Got an error code? Find solutions here.'
}
]
description: 'Got an error code? Find solutions here.'
});
definePageMeta({

View File

@@ -1,23 +1,9 @@
<script setup lang="ts">
useHead({
customSeoMeta({
subsection: 'docs',
title: 'Install',
meta: [
{
name: 'description',
content:
'Learn how to set up Pretendo on a variety of consoles and emulators.'
},
{
name: 'og:description',
content:
'Learn how to set up Pretendo on a variety of consoles and emulators.'
},
{
name: 'twitter:description',
content:
'Learn how to set up Pretendo on a variety of consoles and emulators.'
}
]
description: 'Learn how to set up Pretendo Network on a variety of consoles and emulators.'
});
definePageMeta({

View File

@@ -1,23 +1,8 @@
<script setup lang="ts">
useHead({
meta: [
{
name: 'description',
content:
'Learn how to set up Pretendo on a variety of consoles and emulators.'
},
{
name: 'og:description',
content:
'Learn how to set up Pretendo on a variety of consoles and emulators.'
},
{
name: 'twitter:description',
content:
'Learn how to set up Pretendo on a variety of consoles and emulators.'
}
]
customSeoMeta({
subsection: 'docs',
description: 'Learn how to set up Pretendo Network on a variety of consoles and emulators.'
});
definePageMeta({

View File

@@ -32,6 +32,8 @@ function arraySplit<T>(array: Array<T>, groupCount: number): Array<Array<T>> {
function titleSuffixHandler(path: string) {
return te(path) ? t(path) : null;
}
customSeoMeta();
</script>
<template>

View File

@@ -4,6 +4,7 @@ useHead({
link: [{ rel: 'stylesheet', href: 'https://use.typekit.net/gok5tsu.css' }]
});
customSeoMeta();
</script>
<template>

View File

@@ -1,6 +1,11 @@
<script setup lang="ts">
const { t } = useI18n();
customSeoMeta({
title: t('progressPage.title'),
description: t('progressPage.description')
});
const progress = await useFetch('/api/progress');
const projects = computed(() => progress.data.value?.items ?? []);
const donations = computed(() => progress.data.value?.donations);

View File

@@ -8,14 +8,10 @@ if (!termContent.value) {
throw createError({ statusCode: 404, statusMessage: 'Page Not Found' });
}
useHead({
title: `${termContent.value.title} | Terms`,
meta: [
{ property: 'description', content: termContent.value.description },
{ property: 'og:description', content: termContent.value.description },
{ property: 'og:image:alt', content: '' },
{ name: 'twitter:description', content: termContent.value.description }
]
customSeoMeta({
subsection: 'terms',
title: termContent.value.title,
description: termContent.value.description
});
</script>