mirror of
https://github.com/PretendoNetwork/website.git
synced 2026-09-14 03:47:25 -05:00
Merge pull request #450 from PretendoNetwork/feat/account-editing
feat: account editing on web
This commit is contained in:
@@ -67,7 +67,7 @@ services:
|
||||
volumes:
|
||||
- "./assets/garage-init.sh:/etc/init.sh"
|
||||
account:
|
||||
image: ghcr.io/pretendonetwork/account:sha-edb4b02
|
||||
image: ghcr.io/pretendonetwork/account:sha-81cbacf
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- net
|
||||
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@discordjs/rest": "^2.6.3",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
"@internationalized/date": "^3.12.3",
|
||||
"@nuxt/content": "^3.4.0",
|
||||
"@nuxt/eslint": "^1.3.0",
|
||||
"@nuxt/fonts": "^0.14.0",
|
||||
@@ -18,7 +19,7 @@
|
||||
"@nuxtjs/i18n": "^10.6.0",
|
||||
"@pinia/nuxt": "^1.0.1",
|
||||
"@pretendonetwork/error-codes": "^1.2.2",
|
||||
"@pretendonetwork/grpc": "^2.5.4",
|
||||
"@pretendonetwork/grpc": "^2.5.7",
|
||||
"@pretendonetwork/mii-js": "^1.0.11",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
@@ -5346,9 +5347,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@pretendonetwork/grpc": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.5.4.tgz",
|
||||
"integrity": "sha512-spjg6sOSP8z+9T9vqR9RM5guJb7UHYQlFBfb6p6WxpaYHsXlmVIRPeQlR7NTkgpNzARV/4QVpqerBbtLDvN4oA==",
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.5.7.tgz",
|
||||
"integrity": "sha512-HmWyBxm/Om6S5k+PYLxVU/MaRcSUEV0NAfKccy4i02ZJTZccCJRRKiSBl3WfXSYE+TJNJhSoURGK56E1i8CXqA==",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.2.2",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"dependencies": {
|
||||
"@discordjs/rest": "^2.6.3",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
"@internationalized/date": "^3.12.3",
|
||||
"@nuxt/content": "^3.4.0",
|
||||
"@nuxt/eslint": "^1.3.0",
|
||||
"@nuxt/fonts": "^0.14.0",
|
||||
@@ -24,7 +25,7 @@
|
||||
"@nuxtjs/i18n": "^10.6.0",
|
||||
"@pinia/nuxt": "^1.0.1",
|
||||
"@pretendonetwork/error-codes": "^1.2.2",
|
||||
"@pretendonetwork/grpc": "^2.5.4",
|
||||
"@pretendonetwork/grpc": "^2.5.7",
|
||||
"@pretendonetwork/mii-js": "^1.0.11",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
|
||||
@@ -1,19 +1,48 @@
|
||||
import { ClientError } from 'nice-grpc';
|
||||
import { AccountUpdateSchema } from '~~/shared/api-types';
|
||||
import type { ApiErrorCodes } from '~~/shared/errors';
|
||||
|
||||
const bucket = createRatelimitBucket({
|
||||
id: 'account-update',
|
||||
points: 100,
|
||||
durationSec: 5 * 60, // 5 minutes
|
||||
blockDurationSec: 1 * 60 * 60 // 1 hour
|
||||
});
|
||||
|
||||
const errors: Record<string, ApiErrorCodes> = {
|
||||
'INVALID_ARGUMENT: Must be one of: prod, test, dev': 'INVALID_ACCESS_LEVEL',
|
||||
'PERMISSION_DENIED: Banned': 'BANNED',
|
||||
'INVALID_ARGUMENT: Do not have permission to enter this environment': 'INSUFFICIENT_ACCESS_LEVEL',
|
||||
'INVALID_ARGUMENT: Must be a valid date formatted as: YYYY-MM-DD': 'INVALID_DATE',
|
||||
'INVALID_ARGUMENT: Must be one of: F, M': 'INVALID_GENDER',
|
||||
'INVALID_ARGUMENT: Invalid region': 'INVALID_REGION',
|
||||
'INVALID_ARGUMENT: Invalid timezone': 'INVALID_TIMEZONE',
|
||||
'INVALID_ARGUMENT: Invalid mii data': 'INVALID_MII_DATA'
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event): Promise<void> => {
|
||||
await enforceRatelimit(event, bucket);
|
||||
|
||||
const body = await readZodBody(event, AccountUpdateSchema);
|
||||
const auth = enforceLoggedIn(event);
|
||||
const apiFetch = useHttpApi(event, auth.token);
|
||||
const grpc = useApiGrpcWithToken(event, auth.token);
|
||||
|
||||
// There's no equivalent GRPC endpoint to use, so we're using the HTTP api
|
||||
await apiFetch('/v1/user', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
try {
|
||||
await grpc.updateUserData({
|
||||
gender: body.gender,
|
||||
birthday: body.birthday,
|
||||
region: body.region,
|
||||
timezone: body.timezone,
|
||||
mii: body.mii,
|
||||
environment: body.environment
|
||||
}),
|
||||
headers: {
|
||||
'Content-type': 'application/json'
|
||||
serverAccessLevel: body.serverAccessLevel
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ClientError) {
|
||||
const errorCode = errors[error.details];
|
||||
if (errorCode) {
|
||||
throw createApiError(errorCode);
|
||||
}
|
||||
}
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ export default defineEventHandler(async (event): Promise<GetApiAuthMe> => {
|
||||
birthday: data.birthday,
|
||||
gender: data.gender,
|
||||
country: data.country,
|
||||
region: data.region,
|
||||
timezone: data.timezone,
|
||||
emailAddress: data.emailAddress,
|
||||
serverAccessLevel: data.serverAccessLevel as any,
|
||||
|
||||
@@ -53,6 +53,6 @@ export function useLegacyApiGrpcWithToken(event: H3Event, token: string): Client
|
||||
return getGrpcClient(event, APIDefinition, token);
|
||||
}
|
||||
|
||||
export function useApiGrpcWithToken(event: H3Event, token: string): Client<APIDefinition> {
|
||||
return getGrpcClient(event, APIDefinition, token);
|
||||
export function useApiGrpcWithToken(event: H3Event, token: string): Client<ApiServiceDefinition> {
|
||||
return getGrpcClient(event, ApiServiceDefinition, token);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export type GetApiAuthMe = {
|
||||
birthday: string;
|
||||
gender: string;
|
||||
country: string;
|
||||
region: number;
|
||||
timezone: string;
|
||||
emailAddress: string;
|
||||
serverAccessLevel: 'dev' | 'test' | 'prod';
|
||||
@@ -107,12 +108,12 @@ export const RegisterSchema = z.object({
|
||||
export type ApiAuthRegisterRequest = z.infer<typeof RegisterSchema>;
|
||||
|
||||
export const AccountUpdateSchema = z.object({
|
||||
mii: z.object({
|
||||
name: z.string(),
|
||||
primary: z.enum(['Y', 'N']),
|
||||
data: z.string()
|
||||
}).optional(),
|
||||
environment: z.enum(['prod', 'test', 'dev']).optional()
|
||||
birthday: z.string().optional(),
|
||||
mii: z.string().optional(),
|
||||
serverAccessLevel: z.enum(['prod', 'test', 'dev']).optional(),
|
||||
gender: z.enum(['F', 'M']).optional(),
|
||||
region: z.number().optional(),
|
||||
timezone: z.string().optional()
|
||||
});
|
||||
export type ApiAccountUpdateRequest = z.infer<typeof AccountUpdateSchema>;
|
||||
|
||||
|
||||
@@ -18,7 +18,15 @@ const apiErrorCodes = {
|
||||
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',
|
||||
ACCOUNT_DELETED: 'Account has been deleted'
|
||||
ACCOUNT_DELETED: 'Account has been deleted',
|
||||
INVALID_ACCESS_LEVEL: 'Invalid access level',
|
||||
BANNED: 'Account is banned',
|
||||
INSUFFICIENT_ACCESS_LEVEL: 'Do not have permission to enter this environment',
|
||||
INVALID_DATE: 'Invalid date',
|
||||
INVALID_GENDER: 'Invalid gender',
|
||||
INVALID_REGION: 'Invalid region',
|
||||
INVALID_TIMEZONE: 'Invalid timezone',
|
||||
INVALID_MII_DATA: 'Invalid mii data'
|
||||
} as const;
|
||||
|
||||
export type ApiErrorCodes = keyof typeof apiErrorCodes;
|
||||
@@ -43,7 +51,15 @@ export const apiErrorCodeStatus: Record<ApiErrorCodes, number> = {
|
||||
USERNAME_IN_USE: 400,
|
||||
USERNAME_INVALID_CHARS: 400,
|
||||
USERNAME_TOO_LONG: 400,
|
||||
USERNAME_TOO_SHORT: 400
|
||||
USERNAME_TOO_SHORT: 400,
|
||||
INVALID_ACCESS_LEVEL: 400,
|
||||
BANNED: 403,
|
||||
INSUFFICIENT_ACCESS_LEVEL: 403,
|
||||
INVALID_DATE: 400,
|
||||
INVALID_GENDER: 400,
|
||||
INVALID_REGION: 400,
|
||||
INVALID_TIMEZONE: 400,
|
||||
INVALID_MII_DATA: 400
|
||||
};
|
||||
|
||||
export function getTextForApiErrorCode(code: ApiErrorCodes): string {
|
||||
|
||||
@@ -138,7 +138,7 @@ div.modal-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
|
||||
z-index: 100;
|
||||
}
|
||||
@@ -146,11 +146,12 @@ div.modal-wrapper.hidden {
|
||||
display: none;
|
||||
}
|
||||
div.modal {
|
||||
background: var(--bg-shade-3);
|
||||
background: var(--bg-shade-1);
|
||||
padding: 48px;
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
width: min(660px, 90%);
|
||||
max-width: min(660px, 90%);
|
||||
width: 100vw;
|
||||
box-sizing: border-box;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
43687
src/assets/json/regions.json
Normal file
43687
src/assets/json/regions.json
Normal file
File diff suppressed because it is too large
Load Diff
14121
src/assets/json/timezones.json
Normal file
14121
src/assets/json/timezones.json
Normal file
File diff suppressed because it is too large
Load Diff
749
src/components/AccountInfoEditModal.vue
Normal file
749
src/components/AccountInfoEditModal.vue
Normal file
@@ -0,0 +1,749 @@
|
||||
<script setup lang="ts">
|
||||
import { Label, Dialog, Select, DatePicker } from 'reka-ui/namespaced';
|
||||
import { parseDate } from '@internationalized/date';
|
||||
import {
|
||||
getLocalizedRegionTimezones,
|
||||
getLocalizedCountryList,
|
||||
getLocalizedRegionList,
|
||||
regionIdToCountryId,
|
||||
countryIdToUndefinedRegionId
|
||||
} from '@/utils/localizeConsole';
|
||||
import type { ApiAccountUpdateRequest, GetApiAuthMe } from '~~/shared/api-types';
|
||||
|
||||
const toasts = useToasts();
|
||||
const { locale } = useI18n();
|
||||
|
||||
const { profile } = defineProps<{
|
||||
profile: GetApiAuthMe;
|
||||
dialogContainer: HTMLElement | null;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [];
|
||||
}>();
|
||||
|
||||
const newBirthday = shallowRef(parseDate(profile.birthday || ''));
|
||||
const newCountry = ref(regionIdToCountryId(profile.region || 0));
|
||||
const newRegion = ref(profile.region);
|
||||
const newTimezone = ref(profile.timezone);
|
||||
|
||||
const newLocalizedTimezoneList = computed(() => {
|
||||
return getLocalizedRegionTimezones(locale.value, newRegion.value);
|
||||
});
|
||||
|
||||
const localizedCountryList = computed(() => {
|
||||
return getLocalizedCountryList(locale.value);
|
||||
});
|
||||
|
||||
const newLocalizedRegionList = computed(() => {
|
||||
return getLocalizedRegionList(locale.value, newRegion.value);
|
||||
});
|
||||
|
||||
watch(newCountry, () => {
|
||||
// if a new country has been picked, set the region to its 'Undefined' region.
|
||||
newRegion.value = countryIdToUndefinedRegionId(newCountry.value);
|
||||
});
|
||||
|
||||
watch(newRegion, (o, n) => {
|
||||
if (regionIdToCountryId(o) === regionIdToCountryId(n)) {
|
||||
// same country so we leave the timezone as is, else we might override the user's choice.
|
||||
return;
|
||||
}
|
||||
|
||||
newTimezone.value = newLocalizedTimezoneList.value?.[0]?.area || '';
|
||||
});
|
||||
|
||||
const { execute: executeUpdateUserData, isLoading: isLoadingUpdateUserData } =
|
||||
useAsync({
|
||||
async handler() {
|
||||
const updateBody: ApiAccountUpdateRequest = {
|
||||
birthday: newBirthday.value.toString(),
|
||||
region: newRegion.value,
|
||||
timezone: newTimezone.value
|
||||
};
|
||||
await apiFetch('/api/account/update', {
|
||||
method: 'PATCH',
|
||||
body: updateBody
|
||||
});
|
||||
|
||||
open.value = false;
|
||||
|
||||
emit('change');
|
||||
},
|
||||
onError(error) {
|
||||
const err = getApiError(error);
|
||||
toasts.publish({
|
||||
type: 'error',
|
||||
text: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog.Root v-model:open="open">
|
||||
<Dialog.Trigger as-child>
|
||||
<slot />
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal :to="dialogContainer ?? undefined">
|
||||
<Dialog.Overlay />
|
||||
<Dialog.Content
|
||||
class="modal"
|
||||
@interact-outside="(e) => {
|
||||
if (isLoadingUpdateUserData) {
|
||||
return e.preventDefault();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<Dialog.Title>
|
||||
{{ $t("account.settings.settingCards.userSettings") }}.
|
||||
</Dialog.Title>
|
||||
<div class="fieldsets-container">
|
||||
<fieldset class="account-edit">
|
||||
<Label
|
||||
class="birthday"
|
||||
for="birthday"
|
||||
>
|
||||
{{ $t("account.settings.settingCards.birthDate") }}</Label>
|
||||
<DatePicker.Root
|
||||
v-model="newBirthday"
|
||||
:locale="locale"
|
||||
granularity="day"
|
||||
close-on-select
|
||||
>
|
||||
<DatePicker.Field
|
||||
v-slot="{ segments }"
|
||||
class="date-field"
|
||||
>
|
||||
<template
|
||||
v-for="item in segments"
|
||||
:key="item.part"
|
||||
>
|
||||
<DatePicker.Input
|
||||
v-if="item.part === 'literal'"
|
||||
:part="item.part"
|
||||
class="date-field-literal"
|
||||
>
|
||||
{{ item.value }}
|
||||
</DatePicker.Input>
|
||||
<DatePicker.Input
|
||||
v-else
|
||||
:part="item.part"
|
||||
class="date-field-segment"
|
||||
>
|
||||
{{ item.value }}
|
||||
</DatePicker.Input>
|
||||
</template>
|
||||
|
||||
<DatePicker.Trigger class="calendar-popover-trigger">
|
||||
<Icon
|
||||
name="ph:calendar-dots"
|
||||
class="date-icon"
|
||||
/>
|
||||
</DatePicker.Trigger>
|
||||
</DatePicker.Field>
|
||||
|
||||
<DatePicker.Content
|
||||
align="end"
|
||||
class="calendar-popover-content"
|
||||
:portal="{
|
||||
disabled: true
|
||||
}"
|
||||
>
|
||||
<DatePicker.Arrow class="calendar-popover-arrow" />
|
||||
<DatePicker.Calendar
|
||||
v-slot="{ weekDays, grid }"
|
||||
class="calendar"
|
||||
>
|
||||
<DatePicker.Header class="calendar-header">
|
||||
<DatePicker.Prev
|
||||
class="calendar-nav-button"
|
||||
>
|
||||
<Icon
|
||||
name="ph:caret-left"
|
||||
class="date-icon"
|
||||
/>
|
||||
</DatePicker.Prev>
|
||||
|
||||
<DatePicker.Heading class="calendar-heading" />
|
||||
<DatePicker.Next
|
||||
class="calendar-nav-button"
|
||||
>
|
||||
<Icon
|
||||
name="ph:caret-right"
|
||||
class="date-icon"
|
||||
/>
|
||||
</DatePicker.Next>
|
||||
</DatePicker.Header>
|
||||
<div
|
||||
class="calendar-wrapper"
|
||||
>
|
||||
<DatePicker.Grid
|
||||
v-for="month in grid"
|
||||
:key="month.value.toString()"
|
||||
class="calendar-grid"
|
||||
>
|
||||
<DatePicker.GridHead>
|
||||
<DatePicker.GridRow class="calendar-grid-row">
|
||||
<DatePicker.HeadCell
|
||||
v-for="day in weekDays"
|
||||
:key="day"
|
||||
class="calendar-head-cell"
|
||||
>
|
||||
{{ day }}
|
||||
</DatePicker.HeadCell>
|
||||
</DatePicker.GridRow>
|
||||
</DatePicker.GridHead>
|
||||
<DatePicker.GridBody>
|
||||
<DatePicker.GridRow
|
||||
v-for="(weekDates, index) in month.rows"
|
||||
:key="`weekDate-${index}`"
|
||||
class="calendar-grid-row"
|
||||
>
|
||||
<DatePicker.Cell
|
||||
v-for="weekDate in weekDates"
|
||||
:key="weekDate.toString()"
|
||||
:date="weekDate"
|
||||
class="calendar-cell"
|
||||
>
|
||||
<DatePicker.CellTrigger
|
||||
:day="weekDate"
|
||||
:month="month.value"
|
||||
class="calendar-cell-trigger"
|
||||
/>
|
||||
</DatePicker.Cell>
|
||||
</DatePicker.GridRow>
|
||||
</DatePicker.GridBody>
|
||||
</DatePicker.Grid>
|
||||
</div>
|
||||
</DatePicker.Calendar>
|
||||
</DatePicker.Content>
|
||||
</DatePicker.Root>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="account-edit">
|
||||
<Label
|
||||
class="country"
|
||||
for="country"
|
||||
>
|
||||
{{ $t("account.settings.settingCards.country") }}</Label>
|
||||
<Select.Root v-model="newCountry">
|
||||
<Select.Trigger
|
||||
class="select-trigger"
|
||||
aria-label="Select country"
|
||||
>
|
||||
<Select.Value />
|
||||
<Icon name="ph:caret-down" />
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Content
|
||||
class="select-content"
|
||||
position="popper"
|
||||
>
|
||||
<Select.ScrollUpButton class="select-scrollbutton">
|
||||
<Icon name="ph:caret-up" />
|
||||
</Select.ScrollUpButton>
|
||||
|
||||
<Select.Viewport class="select-viewport">
|
||||
<Select.Item
|
||||
v-for="c in localizedCountryList"
|
||||
:key="c.id"
|
||||
class="select-item"
|
||||
:value="c.id"
|
||||
>
|
||||
<Select.ItemIndicator class="select-itemindicator">
|
||||
<Icon name="ph:check" />
|
||||
</Select.ItemIndicator>
|
||||
<Select.ItemText>
|
||||
{{ c.name }}
|
||||
</Select.ItemText>
|
||||
</Select.Item>
|
||||
</Select.Viewport>
|
||||
|
||||
<Select.ScrollDownButton class="select-scrollbutton">
|
||||
<Icon name="ph:caret-down" />
|
||||
</Select.ScrollDownButton>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="account-edit">
|
||||
<Label
|
||||
class="region"
|
||||
for="region"
|
||||
>{{
|
||||
$t("account.settings.settingCards.region")
|
||||
}}</Label>
|
||||
<Select.Root v-model="newRegion">
|
||||
<Select.Trigger
|
||||
class="select-trigger"
|
||||
aria-label="Select region"
|
||||
>
|
||||
<Select.Value />
|
||||
<Icon name="ph:caret-down" />
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Content
|
||||
class="select-content"
|
||||
position="popper"
|
||||
>
|
||||
<Select.ScrollUpButton class="select-scrollbutton">
|
||||
<Icon name="ph:caret-up" />
|
||||
</Select.ScrollUpButton>
|
||||
|
||||
<Select.Viewport class="select-viewport">
|
||||
<Select.Item
|
||||
v-for="r in newLocalizedRegionList"
|
||||
:key="r.id"
|
||||
class="select-item"
|
||||
:value="r.id"
|
||||
>
|
||||
<Select.ItemIndicator class="select-itemindicator">
|
||||
<Icon name="ph:check" />
|
||||
</Select.ItemIndicator>
|
||||
<Select.ItemText>
|
||||
{{ r.name }}
|
||||
</Select.ItemText>
|
||||
</Select.Item>
|
||||
</Select.Viewport>
|
||||
|
||||
<Select.ScrollDownButton class="select-scrollbutton">
|
||||
<Icon name="ph:caret-down" />
|
||||
</Select.ScrollDownButton>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="account-edit">
|
||||
<Label
|
||||
class="timezone"
|
||||
for="timezone"
|
||||
>
|
||||
{{ $t("account.settings.settingCards.timezone") }}</Label>
|
||||
<Select.Root v-model="newTimezone">
|
||||
<Select.Trigger
|
||||
class="select-trigger"
|
||||
aria-label="Select timezone"
|
||||
>
|
||||
<Select.Value />
|
||||
<Icon name="ph:caret-down" />
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Content
|
||||
class="select-content"
|
||||
position="popper"
|
||||
>
|
||||
<Select.ScrollUpButton class="select-scrollbutton">
|
||||
<Icon name="ph:caret-up" />
|
||||
</Select.ScrollUpButton>
|
||||
|
||||
<Select.Viewport class="select-viewport">
|
||||
<Select.Item
|
||||
v-for="tz in newLocalizedTimezoneList"
|
||||
:key="tz.area"
|
||||
class="select-item"
|
||||
:value="tz.area"
|
||||
>
|
||||
<Select.ItemIndicator class="select-itemindicator">
|
||||
<Icon name="ph:check" />
|
||||
</Select.ItemIndicator>
|
||||
<Select.ItemText>
|
||||
{{ tz.area.replaceAll("_", " ") }} - {{ tz.name }}
|
||||
</Select.ItemText>
|
||||
</Select.Item>
|
||||
</Select.Viewport>
|
||||
|
||||
<Select.ScrollDownButton class="select-scrollbutton">
|
||||
<Icon name="ph:caret-down" />
|
||||
</Select.ScrollDownButton>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="modal-button-wrapper">
|
||||
<Dialog.Close
|
||||
class="cancel"
|
||||
:disabled="isLoadingUpdateUserData"
|
||||
>
|
||||
{{ $t("modals.cancel") }}
|
||||
</Dialog.Close>
|
||||
<button
|
||||
class="action"
|
||||
:disabled="isLoadingUpdateUserData"
|
||||
@click="executeUpdateUserData"
|
||||
>
|
||||
<Loader v-if="isLoadingUpdateUserData" />
|
||||
<span v-else>{{
|
||||
$t("modals.confirm")
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal p.noundo {
|
||||
font-weight: bold;
|
||||
color: var(--text-shade-3);
|
||||
}
|
||||
|
||||
fieldset {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
height: min-content;
|
||||
padding: 0;
|
||||
gap: .25rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.fieldsets-container {
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-auto-rows: 1fr;
|
||||
gap: 1rem
|
||||
}
|
||||
|
||||
div.modal input {
|
||||
background-color: var(--bg-shade-2);
|
||||
}
|
||||
|
||||
.select-trigger,
|
||||
.select-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
line-height: 1;
|
||||
height: 3em;
|
||||
background-color: var(--bg-shade-3);
|
||||
color: var(--text-shade-3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.select-item {
|
||||
background: none;
|
||||
}
|
||||
.select-trigger:hover {
|
||||
background-color: var(--bg-shade-4);
|
||||
}
|
||||
.select-trigger:focus {
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
}
|
||||
|
||||
:deep(.select-content) {
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-shade-3);
|
||||
margin: .25rem 0;
|
||||
border-radius: 6px;
|
||||
max-height: min(var(--reka-select-content-available-height), 420px);
|
||||
height: fit-content;
|
||||
width: var(--reka-select-trigger-width);
|
||||
box-shadow:
|
||||
0px 10px 38px -10px rgba(22, 23, 24, 0.4),
|
||||
0px 10px 20px -15px rgba(22, 23, 24, 0.2);
|
||||
}
|
||||
|
||||
:deep(.select-content[data-state='open']),
|
||||
:deep(.date-picker-content[data-state='open']),
|
||||
:deep(div:has(> .calendar-popover-content[data-state='open'])) {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
.select-item {
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 35px 0 25px;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
color: var(--text-shade-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.select-item[data-disabled] {
|
||||
color: var(--text-shade-1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.select-item[data-highlighted] {
|
||||
outline: none;
|
||||
background-color: var(--bg-shade-4);
|
||||
color: var(--text-shade-3);
|
||||
}
|
||||
.select-item[data-state='checked'] {
|
||||
color: var(--text-shade-3);
|
||||
}
|
||||
|
||||
.select-label {
|
||||
padding: 0 25px;
|
||||
font-size: 12px;
|
||||
line-height: 25px;
|
||||
color: var(--mauve-11);
|
||||
}
|
||||
|
||||
.select-separator {
|
||||
height: 1px;
|
||||
background-color: var(--grass-6);
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.select-itemindicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 25px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.select-scrollbutton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 25px;
|
||||
background-color: var(--bg-shade-3);
|
||||
color: var(--grass-11);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.date-field {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
height: 3rem;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
background-color: var(--bg-shade-3);
|
||||
user-select: none;
|
||||
color: var(--text-shade-3);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.date-field[data-invalid] {
|
||||
border: 1px solid var(--red-shade-2);
|
||||
}
|
||||
|
||||
.date-field-literal {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.date-field-segment {
|
||||
padding: 0.25rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.date-field-segment:hover{
|
||||
background-color: var(--bg-shade-4);
|
||||
}
|
||||
|
||||
.date-field-segment:focus {
|
||||
background-color: var(--bg-shade-4);
|
||||
outline: 2px solid var(--text-shade-3)
|
||||
}
|
||||
|
||||
.date-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.calendar {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.calendar-nav-button {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-nav-button:hover {
|
||||
color: var(--text-shade-2);
|
||||
}
|
||||
|
||||
.calendar-heading {
|
||||
font-weight: 500;
|
||||
color: 15px;
|
||||
}
|
||||
|
||||
.calendar-wrapper {
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
margin-top: 0.25rem;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.calendar-grid-row {
|
||||
display: grid;
|
||||
margin-bottom: 0.25rem;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.calendar-head-cell {
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: var(--text-shade-1);
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.calendar-cell {
|
||||
position: relative;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.calendar-cell-trigger {
|
||||
display: flex;
|
||||
position: relative;
|
||||
padding: 0.25rem .5rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-width: 1px;
|
||||
border-color: transparent;
|
||||
outline-style: none;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-shade-3);
|
||||
white-space: nowrap;
|
||||
background-color: transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.calendar-cell-trigger:hover {
|
||||
border-color: var(--text-shade-3);
|
||||
}
|
||||
|
||||
.calendar-cell-trigger:focus {
|
||||
box-shadow: 0 0 0 2px var(--text-shade-3);;
|
||||
}
|
||||
|
||||
.calendar-cell-trigger[data-selected] {
|
||||
background-color: var(--text-shade-3);
|
||||
color: var(--bg-shade-3);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.calendar-cell-trigger[data-selected]::before {
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.calendar-cell-trigger[data-outside-view] {
|
||||
color: var(--text-shade-1);
|
||||
}
|
||||
|
||||
.calendar-popover-trigger {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.calendar-popover-trigger:focus {
|
||||
box-shadow: 0 0 0 2px #000000;
|
||||
}
|
||||
|
||||
:deep(.calendar-popover-content) {
|
||||
border-radius: 4px;
|
||||
padding: 24px;
|
||||
width: 260px;
|
||||
background-color: var(--bg-shade-3-5);
|
||||
color: var(--text-shade-3);
|
||||
box-shadow: hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px;
|
||||
animation-duration: 400ms;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
:deep(.calendar-popover-content):focus {
|
||||
box-shadow: hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px,
|
||||
0 0 0 2px var(--grass-7);
|
||||
}
|
||||
:deep(.calendar-popover-content)[data-state='open'][data-side='top'] {
|
||||
animation-name: slideDownAndFade;
|
||||
}
|
||||
:deep(.calendar-popover-content)[data-state='open'][data-side='right'] {
|
||||
animation-name: slideLeftAndFade;
|
||||
}
|
||||
:deep(.calendar-popover-content)[data-state='open'][data-side='bottom'] {
|
||||
animation-name: slideUpAndFade;
|
||||
}
|
||||
:deep(.calendar-popover-content)[data-state='open'][data-side='left'] {
|
||||
animation-name: slideRightAndFade;
|
||||
}
|
||||
|
||||
:deep(.calendar-popover-arrow) {
|
||||
fill: var(--bg-shade-3-5);
|
||||
}
|
||||
|
||||
@keyframes slideUpAndFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideRightAndFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDownAndFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideLeftAndFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -223,7 +223,8 @@
|
||||
"nickname": "Nickname",
|
||||
"birthDate": "Birth date",
|
||||
"gender": "Gender",
|
||||
"country": "Country/region",
|
||||
"country": "Country",
|
||||
"region": "Region",
|
||||
"timezone": "Timezone",
|
||||
"serverEnv": "Server environment",
|
||||
"production": "Production",
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
/* eslint-disable vue/no-v-html -- locale files still have raw html */
|
||||
import { watchImmediate } from '@vueuse/core';
|
||||
import { AlertDialog } from 'reka-ui/namespaced';
|
||||
import {
|
||||
getLocalizedTimezoneString,
|
||||
regionIdToLocalizedNames
|
||||
} from '@/utils/localizeConsole';
|
||||
import type { ApiAccountUpdateRequest } from '~~/shared/api-types';
|
||||
|
||||
const { locale } = useI18n();
|
||||
@@ -60,18 +64,33 @@ const { data: connections, refresh: refreshConnections } = await useApiFetch(
|
||||
|
||||
const dialogContainer = ref(null);
|
||||
const deleteModalOpen = ref(false);
|
||||
const editModalOpen = ref(false);
|
||||
const selectedServerEnv = ref<'dev' | 'test' | 'prod' | undefined>(profile.value?.serverAccessLevel);
|
||||
const profileEditModalOpen = ref(false);
|
||||
const emailEditModalOpen = ref(false);
|
||||
const selectedServerEnv = ref<'dev' | 'test' | 'prod' | undefined>(
|
||||
profile.value?.serverAccessLevel
|
||||
);
|
||||
|
||||
const timezoneString = computed(() => {
|
||||
return getLocalizedTimezoneString(
|
||||
locale.value,
|
||||
profile.value?.region,
|
||||
profile.value?.timezone
|
||||
);
|
||||
});
|
||||
|
||||
const regionStrings = computed(() => {
|
||||
return regionIdToLocalizedNames(locale.value, profile.value?.region);
|
||||
});
|
||||
|
||||
const {
|
||||
execute: executeUpdateServerEnvironment,
|
||||
isLoading: isLoadingUpdateServerEnv
|
||||
} = useAsync({
|
||||
async handler(env: ApiAccountUpdateRequest['environment']) {
|
||||
async handler(env: ApiAccountUpdateRequest['serverAccessLevel']) {
|
||||
await apiFetch('/api/account/update', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
environment: env
|
||||
serverAccessLevel: env
|
||||
} satisfies ApiAccountUpdateRequest
|
||||
});
|
||||
await refresh();
|
||||
@@ -135,16 +154,6 @@ const { execute: executeUnlinkDiscord, isLoading: isLoadingUnlink } = useAsync({
|
||||
}
|
||||
});
|
||||
|
||||
function parseBirthday(iso: string): string {
|
||||
const s = iso.split('-');
|
||||
|
||||
const y = Number(s[0]);
|
||||
const m = Number(s[1]) - 1;
|
||||
const d = Number(s[2]);
|
||||
|
||||
return new Date(Date.UTC(y, m, d)).toLocaleDateString(locale as unknown as string);
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: `Account`
|
||||
});
|
||||
@@ -219,9 +228,7 @@ useHead({
|
||||
<AlertDialog.Overlay />
|
||||
<AlertDialog.Content class="modal">
|
||||
<AlertDialog.Title>
|
||||
{{
|
||||
$t("account.settings.delete.modalTitle")
|
||||
}}?
|
||||
{{ $t("account.settings.delete.modalTitle") }}?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description class="modal-caption">
|
||||
<p style="white-space: pre-line">
|
||||
@@ -253,19 +260,176 @@ useHead({
|
||||
</AlertDialog.Root>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog.Root v-model:open="editModalOpen">
|
||||
<div class="settings-wrapper">
|
||||
<h2
|
||||
id="user-settings"
|
||||
class="section-header"
|
||||
>
|
||||
{{ $t("account.settings.settingCards.userSettings") }}
|
||||
|
||||
<div class="settings-wrapper">
|
||||
<h2
|
||||
id="user-settings"
|
||||
class="section-header"
|
||||
>
|
||||
{{ $t("account.settings.settingCards.userSettings") }}
|
||||
</h2>
|
||||
<div class="setting-card">
|
||||
<h2 class="header">
|
||||
{{ $t("account.settings.settingCards.profile") }}
|
||||
</h2>
|
||||
|
||||
<AccountInfoEditModal
|
||||
v-model="profileEditModalOpen"
|
||||
:profile="profile"
|
||||
:dialog-container="dialogContainer"
|
||||
@change="refresh"
|
||||
>
|
||||
<button class="edit">
|
||||
<Icon
|
||||
name="ph:pencil"
|
||||
size="26"
|
||||
/>
|
||||
</button>
|
||||
</AccountInfoEditModal>
|
||||
|
||||
<ul class="setting-list">
|
||||
<li>
|
||||
<p class="label">
|
||||
{{ $t("account.settings.settingCards.birthDate") }}
|
||||
</p>
|
||||
<p class="value">
|
||||
<ClientOnly>
|
||||
{{ new Date(profile.birthday).toLocaleDateString(undefined, { timeZone: 'UTC'}) }}
|
||||
</ClientOnly>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="label">
|
||||
{{ $t("account.settings.settingCards.timezone") }}
|
||||
</p>
|
||||
<p class="value">
|
||||
{{ timezoneString }}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="label">
|
||||
{{ $t("account.settings.settingCards.country") }}
|
||||
</p>
|
||||
<p class="value">
|
||||
{{ regionStrings?.country }}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="label">
|
||||
{{ $t("account.settings.settingCards.region") }}
|
||||
</p>
|
||||
<p class="value">
|
||||
{{ regionStrings?.region }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="setting-card">
|
||||
<h2 class="header">
|
||||
{{ $t("account.settings.settingCards.serverEnv") }}
|
||||
</h2>
|
||||
<fieldset
|
||||
:disabled="
|
||||
profile.serverAccessLevel === 'prod' && profile.accessLevel < 1
|
||||
"
|
||||
>
|
||||
<form
|
||||
id="server"
|
||||
class="server-selection"
|
||||
>
|
||||
<input
|
||||
id="prod"
|
||||
v-model="selectedServerEnv"
|
||||
type="radio"
|
||||
value="prod"
|
||||
>
|
||||
<label for="prod">
|
||||
<Icon
|
||||
name="ph:cube"
|
||||
size="36"
|
||||
/>
|
||||
<h2>{{ $t("account.settings.settingCards.production") }}</h2>
|
||||
</label>
|
||||
|
||||
<input
|
||||
v-if="
|
||||
profile.serverAccessLevel === 'test' || profile.accessLevel > 0
|
||||
"
|
||||
id="test"
|
||||
v-model="selectedServerEnv"
|
||||
type="radio"
|
||||
value="test"
|
||||
>
|
||||
<label
|
||||
v-if="
|
||||
profile.serverAccessLevel === 'test' || profile.accessLevel > 0
|
||||
"
|
||||
for="test"
|
||||
>
|
||||
<Icon
|
||||
name="ph:flask"
|
||||
size="36"
|
||||
/>
|
||||
<h2>{{ $t("account.settings.settingCards.beta") }}</h2>
|
||||
</label>
|
||||
|
||||
<input
|
||||
v-if="
|
||||
profile.accessLevel === 3 || profile.serverAccessLevel === 'dev'
|
||||
"
|
||||
id="dev"
|
||||
v-model="selectedServerEnv"
|
||||
type="radio"
|
||||
value="dev"
|
||||
>
|
||||
|
||||
<label
|
||||
v-if="
|
||||
profile.accessLevel === 3 || profile.serverAccessLevel === 'dev'
|
||||
"
|
||||
for="dev"
|
||||
>
|
||||
<Icon
|
||||
name="ph:code"
|
||||
size="36"
|
||||
/>
|
||||
<h2>Dev</h2>
|
||||
</label>
|
||||
</form>
|
||||
</fieldset>
|
||||
|
||||
<button
|
||||
v-if="selectedServerEnv !== profile.serverAccessLevel"
|
||||
id="save-server-selection"
|
||||
class="button secondary"
|
||||
@click.prevent="
|
||||
() => executeUpdateServerEnvironment(selectedServerEnv)
|
||||
"
|
||||
>
|
||||
<Loader v-if="isLoadingUpdateServerEnv" />
|
||||
<span v-else>Save</span>
|
||||
</button>
|
||||
<p
|
||||
v-html="
|
||||
profile.accessLevel < 1
|
||||
? $t('account.settings.settingCards.upgradePrompt')
|
||||
: $t('account.settings.settingCards.hasAccessPrompt')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
id="security"
|
||||
class="section-header"
|
||||
>
|
||||
{{ $t("account.settings.settingCards.signInSecurity") }}
|
||||
</h2>
|
||||
<AlertDialog.Root v-model:open="emailEditModalOpen">
|
||||
<div class="setting-card">
|
||||
<h2 class="header">
|
||||
{{ $t("account.settings.settingCards.profile") }}
|
||||
{{ $t("account.account") }}
|
||||
</h2>
|
||||
|
||||
<AlertDialog.Trigger class="edit">
|
||||
<Icon
|
||||
name="ph:pencil"
|
||||
@@ -292,160 +456,6 @@ useHead({
|
||||
</div>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
<ul class="setting-list">
|
||||
<li>
|
||||
<p class="label">
|
||||
{{ $t("account.settings.settingCards.nickname") }}
|
||||
</p>
|
||||
<p class="value">
|
||||
{{ profile.mii?.name }}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="label">
|
||||
{{ $t("account.settings.settingCards.birthDate") }}
|
||||
</p>
|
||||
<p class="value">
|
||||
{{ parseBirthday(profile.birthday) }}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="label">
|
||||
{{ $t("account.settings.settingCards.gender") }}
|
||||
</p>
|
||||
<p class="value">
|
||||
{{ profile.gender }}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="label">
|
||||
{{ $t("account.settings.settingCards.country") }}
|
||||
</p>
|
||||
<p class="value">
|
||||
{{ profile.country }}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="label">
|
||||
{{ $t("account.settings.settingCards.timezone") }}
|
||||
</p>
|
||||
<p class="value">
|
||||
{{ profile.timezone.replaceAll('_', ' ') }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="setting-card">
|
||||
<h2 class="header">
|
||||
{{ $t("account.settings.settingCards.serverEnv") }}
|
||||
</h2>
|
||||
<fieldset
|
||||
:disabled="
|
||||
profile.serverAccessLevel === 'prod' && profile.accessLevel < 1
|
||||
"
|
||||
>
|
||||
<form
|
||||
id="server"
|
||||
class="server-selection"
|
||||
>
|
||||
<input
|
||||
id="prod"
|
||||
v-model="selectedServerEnv"
|
||||
type="radio"
|
||||
value="prod"
|
||||
>
|
||||
<label for="prod">
|
||||
<Icon
|
||||
name="ph:cube"
|
||||
size="36"
|
||||
/>
|
||||
<h2>{{ $t("account.settings.settingCards.production") }}</h2>
|
||||
</label>
|
||||
|
||||
<input
|
||||
v-if="
|
||||
profile.serverAccessLevel === 'test' ||
|
||||
profile.accessLevel > 0
|
||||
"
|
||||
id="test"
|
||||
v-model="selectedServerEnv"
|
||||
type="radio"
|
||||
value="test"
|
||||
>
|
||||
<label
|
||||
v-if="
|
||||
profile.serverAccessLevel === 'test' ||
|
||||
profile.accessLevel > 0
|
||||
"
|
||||
for="test"
|
||||
>
|
||||
<Icon
|
||||
name="ph:flask"
|
||||
size="36"
|
||||
/>
|
||||
<h2>{{ $t("account.settings.settingCards.beta") }}</h2>
|
||||
</label>
|
||||
|
||||
<input
|
||||
v-if="profile.accessLevel === 3 || profile.serverAccessLevel === 'dev'"
|
||||
id="dev"
|
||||
v-model="selectedServerEnv"
|
||||
type="radio"
|
||||
value="dev"
|
||||
>
|
||||
|
||||
<label
|
||||
v-if="profile.accessLevel === 3 || profile.serverAccessLevel === 'dev'"
|
||||
for="dev"
|
||||
>
|
||||
<Icon
|
||||
name="ph:code"
|
||||
size="36"
|
||||
/>
|
||||
<h2>Dev</h2>
|
||||
</label>
|
||||
</form>
|
||||
</fieldset>
|
||||
|
||||
<button
|
||||
v-if="
|
||||
selectedServerEnv !== profile.serverAccessLevel
|
||||
"
|
||||
id="save-server-selection"
|
||||
class="button secondary"
|
||||
@click.prevent="
|
||||
() => executeUpdateServerEnvironment(selectedServerEnv)
|
||||
"
|
||||
>
|
||||
<Loader v-if="isLoadingUpdateServerEnv" />
|
||||
<span v-else>Save</span>
|
||||
</button>
|
||||
<p
|
||||
v-html="
|
||||
profile.accessLevel < 1
|
||||
? $t('account.settings.settingCards.upgradePrompt')
|
||||
: $t('account.settings.settingCards.hasAccessPrompt')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
id="security"
|
||||
class="section-header"
|
||||
>
|
||||
{{ $t("account.settings.settingCards.signInSecurity") }}
|
||||
</h2>
|
||||
<div class="setting-card">
|
||||
<h2 class="header">
|
||||
{{ $t("account.account") }}
|
||||
</h2>
|
||||
<AlertDialog.Trigger class="edit">
|
||||
<Icon
|
||||
name="ph:pencil"
|
||||
size="26"
|
||||
/>
|
||||
</AlertDialog.Trigger>
|
||||
<ul class="setting-list">
|
||||
<li>
|
||||
<p class="label">
|
||||
@@ -466,83 +476,88 @@ useHead({
|
||||
</ul>
|
||||
<p>{{ $t("account.settings.settingCards.passwordResetNotice") }}</p>
|
||||
</div>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<div class="setting-card sign-in-history">
|
||||
<h2 class="header">
|
||||
{{ $t("account.settings.settingCards.signInHistory") }}
|
||||
</h2>
|
||||
<p>{{ $t("account.settings.settingCards.no_signins_notice") }}</p>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
id="other"
|
||||
class="section-header"
|
||||
>
|
||||
{{ $t("account.settings.settingCards.otherSettings") }}
|
||||
<div class="setting-card sign-in-history">
|
||||
<h2 class="header">
|
||||
{{ $t("account.settings.settingCards.signInHistory") }}
|
||||
</h2>
|
||||
<div class="setting-card">
|
||||
<h2 class="header">
|
||||
{{ $t("account.settings.settingCards.discord") }}
|
||||
</h2>
|
||||
<p>{{ $t("account.settings.settingCards.no_signins_notice") }}</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="profile.discordId"
|
||||
class="discord-profile"
|
||||
>
|
||||
{{ $t("account.settings.settingCards.connectedToDiscord") }}
|
||||
<img
|
||||
:style="{ height: '25px', width: '25px', borderRadius: '100px' }"
|
||||
:src="connections?.discord?.avatarUrl ?? '#'"
|
||||
>@{{ connections?.discord?.username }}.
|
||||
</p>
|
||||
<h2
|
||||
id="other"
|
||||
class="section-header"
|
||||
>
|
||||
{{ $t("account.settings.settingCards.otherSettings") }}
|
||||
</h2>
|
||||
<div class="setting-card">
|
||||
<h2 class="header">
|
||||
{{ $t("account.settings.settingCards.discord") }}
|
||||
</h2>
|
||||
|
||||
<button
|
||||
v-if="profile.discordId"
|
||||
id="remove-discord-connection"
|
||||
class="button secondary"
|
||||
@click="executeUnlinkDiscord"
|
||||
<p
|
||||
v-if="profile.discordId"
|
||||
class="discord-profile"
|
||||
>
|
||||
{{ $t("account.settings.settingCards.connectedToDiscord") }}
|
||||
<img
|
||||
:style="{ height: '25px', width: '25px', borderRadius: '100px' }"
|
||||
:src="connections?.discord?.avatarUrl ?? '#'"
|
||||
>@{{ connections?.discord?.username }}.
|
||||
</p>
|
||||
|
||||
<button
|
||||
v-if="profile.discordId"
|
||||
id="remove-discord-connection"
|
||||
class="button secondary"
|
||||
@click="executeUnlinkDiscord"
|
||||
>
|
||||
<Loader v-if="isLoadingUnlink" />
|
||||
<span v-else>{{
|
||||
$t("account.settings.settingCards.removeDiscord")
|
||||
}}</span>
|
||||
</button>
|
||||
<p v-else>
|
||||
{{ $t("account.settings.settingCards.noDiscordLinked") }}
|
||||
<NuxtLink
|
||||
:style="{ cursor: 'pointer' }"
|
||||
@click="executeLinkDiscord"
|
||||
>
|
||||
<Loader v-if="isLoadingUnlink" />
|
||||
<Loader v-if="isLoadingLink" />
|
||||
<span v-else>{{
|
||||
$t("account.settings.settingCards.removeDiscord")
|
||||
$t("account.settings.settingCards.linkDiscord")
|
||||
}}</span>
|
||||
</button>
|
||||
<p v-else>
|
||||
{{ $t("account.settings.settingCards.noDiscordLinked") }}
|
||||
<NuxtLink
|
||||
:style="{ cursor: 'pointer' }"
|
||||
@click="executeLinkDiscord"
|
||||
>
|
||||
<Loader v-if="isLoadingLink" />
|
||||
<span v-else>{{
|
||||
$t("account.settings.settingCards.linkDiscord")
|
||||
}}</span>
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="setting-card">
|
||||
<h2 class="header">
|
||||
{{ $t("account.settings.settingCards.newsletter") }}
|
||||
</h2>
|
||||
<p>{{ $t("account.settings.settingCards.no_newsletter_notice") }}</p>
|
||||
<!--
|
||||
<div class="setting-card">
|
||||
<h2 class="header">
|
||||
{{ $t("account.settings.settingCards.newsletter") }}
|
||||
</h2>
|
||||
<p>{{ $t("account.settings.settingCards.no_newsletter_notice") }}</p>
|
||||
<!--
|
||||
<form id="other">
|
||||
<input type="checkbox" id="marketing" name="marketing" {{#if account.flags.marketing}}checked{{/if}}>
|
||||
<label for="marketing">{{ locale.account.settings.settingCards.newsletterPrompt }}</label>
|
||||
</form>
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialog.Root>
|
||||
</div>
|
||||
<div
|
||||
id="delete-account"
|
||||
:class="{
|
||||
'modal-wrapper': true,
|
||||
hidden: !(deleteModalOpen || editModalOpen),
|
||||
hidden: !(
|
||||
deleteModalOpen ||
|
||||
profileEditModalOpen ||
|
||||
emailEditModalOpen
|
||||
),
|
||||
}"
|
||||
>
|
||||
<div ref="dialogContainer" />
|
||||
<div class="modal-binder">
|
||||
<div ref="dialogContainer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -762,6 +777,27 @@ useHead({
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.modal-wrapper {
|
||||
padding: 1.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-binder {
|
||||
height: 100%;
|
||||
flex-grow: 9;
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.modal-binder > div {
|
||||
max-height: 100%;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
position: relative;
|
||||
height: min-content;
|
||||
|
||||
@@ -285,7 +285,7 @@ const { isLoading: isSaving, execute } = useAsync({
|
||||
return apiFetch('/api/account/update', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
mii: { name: mii.value.miiName, primary: 'Y', data: mii.value.encode().toString('base64') }
|
||||
mii: mii.value.encode().toString('base64')
|
||||
} satisfies ApiAccountUpdateRequest
|
||||
});
|
||||
},
|
||||
|
||||
185
src/utils/localizeConsole.ts
Normal file
185
src/utils/localizeConsole.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import regions from '@/assets/json/regions.json';
|
||||
import timezoneData from '@/assets/json/timezones.json';
|
||||
|
||||
type Timezone = {
|
||||
area: string;
|
||||
language: string;
|
||||
name: string;
|
||||
utc_offset: string;
|
||||
order: string;
|
||||
};
|
||||
type TimezoneLanguageCollection = Record<string, Array<Timezone>>;
|
||||
type TimezoneCollection = Record<string, TimezoneLanguageCollection>;
|
||||
|
||||
const timezones = timezoneData as TimezoneCollection;
|
||||
|
||||
type languageName = 'japanese' | 'french' | 'german' | 'italian' | 'spanish' | 'korean' | 'dutch' | 'portuguese' | 'russian' | 'chinese_traditional' | 'chinese_simple' | 'english';
|
||||
type languageCode = 'ja' | 'fr' | 'de' | 'it' | 'es' | 'ko' | 'nl' | 'pt' | 'ru' | 'zh-Hant' | 'zh-Hans' | 'en';
|
||||
|
||||
export function webLocaleToConsoleLocale(lo: string): languageName {
|
||||
const convObj: Record<languageCode, languageName> = {
|
||||
'ja': 'japanese',
|
||||
'fr': 'french',
|
||||
'de': 'german',
|
||||
'it': 'italian',
|
||||
'es': 'spanish',
|
||||
'ko': 'korean',
|
||||
'nl': 'dutch',
|
||||
'pt': 'portuguese',
|
||||
'ru': 'russian',
|
||||
'zh-Hant': 'chinese_traditional',
|
||||
'zh-Hans': 'chinese_simple',
|
||||
'en': 'english'
|
||||
};
|
||||
|
||||
const l = lo.split('-')[0]!;
|
||||
|
||||
const correspondence = convObj[l as languageCode];
|
||||
|
||||
return correspondence || convObj[lo as languageCode] || convObj.en;
|
||||
}
|
||||
|
||||
export function webLocaleToTimezoneLocale(lo: string): languageCode {
|
||||
const l = lo.split('-');
|
||||
|
||||
switch (l[0]) {
|
||||
case 'ja':
|
||||
case 'fr':
|
||||
case 'de':
|
||||
case 'it':
|
||||
case 'es':
|
||||
case 'nl':
|
||||
case 'pt':
|
||||
case 'ru': {
|
||||
return l[0];
|
||||
}
|
||||
default: {
|
||||
return 'en';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function regionIdToCountryId(region: number) {
|
||||
return (region >>> 24) & 0xFF;
|
||||
}
|
||||
export function countryIdToUndefinedRegionId(region: number) {
|
||||
return region * (2 ** 24);
|
||||
}
|
||||
|
||||
export function regionIdToCountryObject(region: number) {
|
||||
const countryId = regionIdToCountryId(region);
|
||||
return regions.find(r => r.id === countryId);
|
||||
}
|
||||
|
||||
export function regionIdToObject(region: number) {
|
||||
return regions.flatMap(c => c.regions).find(r => r.id === region);
|
||||
}
|
||||
|
||||
export function regionIdToLocalizedNames(localeCode: string, region: number | undefined) {
|
||||
if (!localeCode || !region) {
|
||||
return;
|
||||
}
|
||||
|
||||
const l = webLocaleToConsoleLocale(localeCode);
|
||||
|
||||
const lCountryName = regionIdToCountryObject(region)?.translations[l];
|
||||
const lRegionName = regionIdToObject(region)?.translations[l];
|
||||
|
||||
return {
|
||||
country: lCountryName,
|
||||
region: lRegionName
|
||||
};
|
||||
}
|
||||
|
||||
export function getLocalizedRegionTimezones(localeCode: string, region: number | undefined) {
|
||||
if (!localeCode || !region) {
|
||||
return;
|
||||
}
|
||||
|
||||
const country = regionIdToCountryObject(region);
|
||||
|
||||
if (!country) {
|
||||
return;
|
||||
}
|
||||
|
||||
const l = webLocaleToTimezoneLocale(localeCode);
|
||||
|
||||
const countryTimezones = timezones[country.iso_code];
|
||||
|
||||
return countryTimezones?.[l] || countryTimezones?.en || countryTimezones?.ja;
|
||||
}
|
||||
|
||||
export function getLocalizedTimezoneString(localeCode: string | undefined, region: number | undefined, timezone: string | undefined) {
|
||||
if (!localeCode || !region || !timezone) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tList = getLocalizedRegionTimezones(localeCode, region);
|
||||
|
||||
let localizedTz = '';
|
||||
|
||||
const tzObj = tList?.find((v) => {
|
||||
return (v.area === timezone);
|
||||
});
|
||||
|
||||
if (tzObj) {
|
||||
localizedTz = `${timezone?.replaceAll('_', ' ')} - ${tzObj.name}`;
|
||||
}
|
||||
|
||||
return localizedTz;
|
||||
}
|
||||
|
||||
export function getLocalizedCountryList(localeCode: string) {
|
||||
const l = webLocaleToConsoleLocale(localeCode);
|
||||
|
||||
const lCountryList: {
|
||||
code: string;
|
||||
id: number;
|
||||
name: string;
|
||||
}[] = [];
|
||||
|
||||
regions.forEach(
|
||||
(r) => {
|
||||
lCountryList.push({
|
||||
code: r.iso_code,
|
||||
id: r.id,
|
||||
name: r.translations[l] || r.translations.english || r.name || r.translations.japanese
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
lCountryList.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return lCountryList;
|
||||
}
|
||||
|
||||
export function getLocalizedRegionList(localeCode: string, region: number | undefined) {
|
||||
if (!localeCode || !region) {
|
||||
return;
|
||||
}
|
||||
|
||||
const l = webLocaleToConsoleLocale(localeCode);
|
||||
const c = regionIdToCountryObject(region);
|
||||
|
||||
const lRegionList: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[] = [];
|
||||
|
||||
c?.regions.forEach(
|
||||
(r) => {
|
||||
lRegionList.push({
|
||||
id: r.id,
|
||||
name: r.translations[l] || r.translations.english || r.name || r.translations.japanese
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
lRegionList.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return lRegionList;
|
||||
}
|
||||
Reference in New Issue
Block a user