Settings refactor and redesign (#1669)

* new settings work

* Polishing work on settings refactor

* Run intl:extract

* List view -> Rows view

* Remove current preferred system theme indicator to make the themes fit on one line

* Remove extra margin on top of navstack
This commit is contained in:
Prospector
2024-04-09 11:18:56 -07:00
committed by GitHub
parent ae2d83c8aa
commit 4c2565826f
22 changed files with 1242 additions and 378 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-monitor-smartphone"><path d="M18 8V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h8"/><path d="M10 19v-3.96 3.15"/><path d="M7 19h5"/><rect width="6" height="10" x="16" y="12" rx="2"/></svg>

After

Width:  |  Height:  |  Size: 393 B

View File

@@ -25,6 +25,7 @@
border-radius: var(--size-rounded-card); border-radius: var(--size-rounded-card);
padding: var(--spacing-card-lg); padding: var(--spacing-card-lg);
gap: var(--spacing-card-md); gap: var(--spacing-card-md);
outline: 1px solid transparent;
.label { .label {
color: var(--color-heading); color: var(--color-heading);
@@ -65,6 +66,7 @@
margin-bottom: var(--spacing-card-md); margin-bottom: var(--spacing-card-md);
outline: 2px solid transparent; outline: 2px solid transparent;
outline-offset: -2px;
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
@@ -687,7 +689,7 @@ tr.button-transparent {
} }
color: var(--color-text) !important; color: var(--color-text) !important;
outline: 2px solid transparent; outline: none !important;
input { input {
background: transparent; background: transparent;
@@ -706,6 +708,7 @@ tr.button-transparent {
cursor: pointer; cursor: pointer;
padding-left: 7px; padding-left: 7px;
padding-top: 10px; padding-top: 10px;
outline: 2px solid transparent;
transition: background-color 0.1s ease-in-out; transition: background-color 0.1s ease-in-out;
@@ -753,8 +756,13 @@ tr.button-transparent {
border-bottom-left-radius: var(--size-rounded-sm); border-bottom-left-radius: var(--size-rounded-sm);
border-bottom-right-radius: var(--size-rounded-sm); border-bottom-right-radius: var(--size-rounded-sm);
box-shadow: var(--shadow-inset-sm), var(--shadow-floating); box-shadow: var(--shadow-inset-sm), var(--shadow-floating);
outline: 2px solid transparent;
.multiselect__element { .multiselect__element {
.multiselect__option {
outline: 1px solid transparent;
}
.multiselect__option--highlight { .multiselect__option--highlight {
background: var(--color-button-bg-active); background: var(--color-button-bg-active);
color: var(--color-text-dark); color: var(--color-text-dark);
@@ -894,31 +902,6 @@ tr.button-transparent {
} }
} }
.card {
position: relative;
min-height: var(--font-size-2xl);
padding: var(--spacing-card-md) var(--spacing-card-lg);
background: var(--color-raised-bg);
border-radius: var(--size-rounded-card);
outline: 2px solid transparent;
margin-bottom: var(--spacing-card-md);
box-shadow: var(--shadow-card);
.card__overlay {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
align-items: flex-end;
grid-gap: 0.5rem;
z-index: 2;
}
}
.card-divider { .card-divider {
background-color: var(--color-divider); background-color: var(--color-divider);
border: none; border: none;
@@ -1082,6 +1065,7 @@ button {
transition: box-shadow 0.1s ease-in-out; transition: box-shadow 0.1s ease-in-out;
overflow: hidden; overflow: hidden;
max-width: 100%; max-width: 100%;
outline: 2px solid transparent;
.text-input-wrapper__before { .text-input-wrapper__before {
display: flex; display: flex;
@@ -1102,9 +1086,10 @@ button {
textarea { textarea {
border-radius: 0; border-radius: 0;
background-color: transparent; background-color: transparent;
box-shadow: unset; box-shadow: unset !important;
padding-left: 0; padding-left: 0;
flex-grow: 1; flex-grow: 1;
outline: none !important;
} }
&:focus, &:focus,

View File

@@ -288,6 +288,29 @@ html {
--color-ad: #0d1828; --color-ad: #0d1828;
} }
.retro-mode {
--color-bg: #191917;
--color-raised-bg: #1d1e1b;
--color-button-bg: #3a3b38;
--color-base: #c3c4b3;
--color-secondary: #777a74;
--color-contrast: #e6e2d1;
--color-brand: #4d9227;
--color-brand-highlight: #25421e;
--color-ad: var(--color-brand-highlight);
--color-ad-raised: var(--color-brand);
--color-ad-contrast: black;
--color-ad-highlight: var(--color-brand);
--color-red: rgb(232, 32, 13);
--color-orange: rgb(232, 141, 13);
--color-green: rgb(60, 219, 54);
--color-blue: rgb(9, 159, 239);
--color-purple: rgb(139, 129, 230);
--color-gray: #718096;
}
body { body {
// Defaults // Defaults
background-color: var(--color-bg); background-color: var(--color-bg);
@@ -457,3 +480,21 @@ a:focus-visible,
outline: 0.25rem solid #ea80ff; outline: 0.25rem solid #ea80ff;
border-radius: 0.25rem; border-radius: 0.25rem;
} }
// OMORPHIA FIXES
.card {
outline-offset: -2px;
}
input {
outline: 2px solid transparent !important;
}
.button-animation {
transition: opacity 0.5s ease-in-out, filter 0.2s ease-in-out, transform 0.05s ease-in-out,
outline-width 0.2s ease-in-out;
}
.button-transparent {
box-shadow: none;
}

View File

@@ -1,7 +1,7 @@
<template> <template>
<div <div
class="checkbox-outer button-within" class="checkbox-outer button-within"
:class="{ disabled }" :class="{ disabled, checked: modelValue }"
role="presentation" role="presentation"
@click="toggle" @click="toggle"
> >
@@ -82,6 +82,12 @@ export default {
&.disabled { &.disabled {
cursor: not-allowed; cursor: not-allowed;
} }
&.checked {
outline: 2px solid transparent;
outline-offset: 4px;
border-radius: 0.25rem;
}
} }
.checkbox { .checkbox {

View File

@@ -24,6 +24,7 @@ const ariaLabelByType = computed(() => `Banner with ${props.messageType} message
border-radius: var(--size-rounded-card); border-radius: var(--size-rounded-card);
overflow: hidden; overflow: hidden;
outline: 2px solid transparent; outline: 2px solid transparent;
outline-offset: -2px;
margin-bottom: var(--spacing-card-md); margin-bottom: var(--spacing-card-md);

View File

@@ -119,6 +119,7 @@ export default {
overflow-y: auto; overflow-y: auto;
width: 600px; width: 600px;
pointer-events: auto; pointer-events: auto;
outline: 3px solid transparent;
.header { .header {
display: flex; display: flex;

View File

@@ -134,6 +134,12 @@ export default {
&.router-link-exact-active { &.router-link-exact-active {
color: var(--color-text); color: var(--color-text);
&:not(:focus-visible) {
outline: 2px solid transparent;
outline-offset: 6px;
border-radius: 0.25rem;
}
&::after { &::after {
opacity: 1; opacity: 1;
} }
@@ -157,6 +163,8 @@ export default {
transition: all ease-in-out 0.2s; transition: all ease-in-out 0.2s;
border-radius: var(--size-rounded-max); border-radius: var(--size-rounded-max);
background-color: var(--color-brand); background-color: var(--color-brand);
outline: 2px solid transparent;
outline-offset: -2px;
@media (prefers-reduced-motion) { @media (prefers-reduced-motion) {
transition: none !important; transition: none !important;

View File

@@ -19,6 +19,10 @@ ul {
list-style-type: none; list-style-type: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
> :first-child {
margin-top: 0;
}
} }
li { li {

View File

@@ -70,6 +70,7 @@ export default {
box-shadow: none; box-shadow: none;
padding: 0; padding: 0;
width: 100%; width: 100%;
outline: none;
:where(.nav-link) { :where(.nav-link) {
--text-color: var(--color-text); --text-color: var(--color-text);
@@ -94,6 +95,9 @@ export default {
} }
&.router-link-exact-active { &.router-link-exact-active {
outline: 2px solid transparent;
border-radius: 0.25rem;
.nav-content { .nav-content {
color: var(--color-button-text-active); color: var(--color-button-text-active);
background-color: var(--color-button-bg); background-color: var(--color-button-bg);

View File

@@ -133,6 +133,7 @@ a {
background: var(--color-brand); background: var(--color-brand);
color: var(--color-brand-inverted); color: var(--color-brand-inverted);
cursor: default; cursor: default;
outline: 2px solid transparent;
} }
&.paginate.disabled { &.paginate.disabled {

View File

@@ -17,6 +17,7 @@ export const useCosmetics = () =>
developerMode: false, developerMode: false,
notUsingBlockers: false, notUsingBlockers: false,
hideModrinthAppPromos: false, hideModrinthAppPromos: false,
preferredDarkTheme: 'dark',
searchDisplayMode: { searchDisplayMode: {
mod: 'list', mod: 'list',
plugin: 'list', plugin: 'list',

View File

@@ -24,6 +24,7 @@ export const useTheme = () =>
export const updateTheme = (value, updatePreference = false) => { export const updateTheme = (value, updatePreference = false) => {
const theme = useTheme() const theme = useTheme()
const cosmetics = useCosmetics()
const themeCookie = useCookie('color-mode', { const themeCookie = useCookie('color-mode', {
maxAge: 60 * 60 * 24 * 365 * 10, maxAge: 60 * 60 * 24 * 365 * 10,
@@ -40,7 +41,7 @@ export const updateTheme = (value, updatePreference = false) => {
if (colorSchemeQueryList.matches) { if (colorSchemeQueryList.matches) {
theme.value.value = 'light' theme.value.value = 'light'
} else { } else {
theme.value.value = 'dark' theme.value.value = cosmetics.value.preferredDarkTheme
} }
} else { } else {
theme.value.value = value theme.value.value = value
@@ -53,3 +54,5 @@ export const updateTheme = (value, updatePreference = false) => {
themeCookie.value = theme.value themeCookie.value = theme.value
} }
export const DARK_THEMES = ['dark', 'oled', 'retro']

View File

@@ -433,6 +433,7 @@ import ModalCreation from '~/components/ui/ModalCreation.vue'
import Avatar from '~/components/ui/Avatar.vue' import Avatar from '~/components/ui/Avatar.vue'
import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts' import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts'
import { commonMessages } from '~/utils/common-messages.ts' import { commonMessages } from '~/utils/common-messages.ts'
import { DARK_THEMES } from '~/composables/theme.js'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -724,7 +725,10 @@ function toggleBrowseMenu() {
} }
} }
function changeTheme() { function changeTheme() {
updateTheme(app.$colorMode.value === 'dark' ? 'light' : 'dark', true) updateTheme(
DARK_THEMES.includes(app.$colorMode.value) ? 'light' : cosmetics.value.preferredDarkTheme,
true
)
} }
function hideStagingBanner() { function hideStagingBanner() {
@@ -781,6 +785,15 @@ function hideStagingBanner() {
a { a {
align-items: center; align-items: center;
display: flex; display: flex;
&:not(:focus-visible) {
outline: none;
&.router-link-exact-active {
outline: 2px solid transparent;
border-radius: 0.25rem;
}
}
} }
.small-logo { .small-logo {
@@ -909,6 +922,7 @@ function hideStagingBanner() {
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 0; padding: 0;
outline: none;
.user-icon { .user-icon {
height: 2rem; height: 2rem;
@@ -959,6 +973,7 @@ function hideStagingBanner() {
display: flex; display: flex;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
width: 100%; width: 100%;
outline: none;
.icon { .icon {
margin-right: 0.5rem; margin-right: 0.5rem;
@@ -969,6 +984,7 @@ function hideStagingBanner() {
&.router-link-exact-active { &.router-link-exact-active {
color: var(--color-button-text-active); color: var(--color-button-text-active);
background-color: var(--color-button-bg); background-color: var(--color-button-bg);
outline: 2px solid transparent;
&.primary-color { &.primary-color {
color: var(--color-button-text-active); color: var(--color-button-text-active);

View File

@@ -191,6 +191,9 @@
"button.sign-out": { "button.sign-out": {
"message": "Sign out" "message": "Sign out"
}, },
"button.upload-image": {
"message": "Upload image"
},
"collection.button.delete-icon": { "collection.button.delete-icon": {
"message": "Delete icon" "message": "Delete icon"
}, },
@@ -285,7 +288,10 @@
"message": "Grid view" "message": "Grid view"
}, },
"input.view.list": { "input.view.list": {
"message": "List view" "message": "Rows view"
},
"label.changes-saved": {
"message": "Changes saved"
}, },
"label.collections": { "label.collections": {
"message": "Collections" "message": "Collections"
@@ -779,6 +785,114 @@
"scopes.versionWrite.label": { "scopes.versionWrite.label": {
"message": "Write versions" "message": "Write versions"
}, },
"settings.account.title": {
"message": "Account and security"
},
"settings.appearance.title": {
"message": "Appearance"
},
"settings.applications.title": {
"message": "Your applications"
},
"settings.authorized-apps.title": {
"message": "Authorized apps"
},
"settings.display.banner.developer-mode.button": {
"message": "Deactivate developer mode"
},
"settings.display.banner.developer-mode.description": {
"message": "<strong>Developer mode</strong> is active. This will allow you to view the internal IDs of various things throughout Modrinth that may be helpful if you're a developer using the Modrinth API. Click on the Modrinth logo at the bottom of the page 5 times to toggle developer mode."
},
"settings.display.flags.description": {
"message": "Enable or disable certain features on this device."
},
"settings.display.flags.title": {
"message": "Feature flags"
},
"settings.display.project-list-layouts.datapack": {
"message": "Data Packs page"
},
"settings.display.project-list-layouts.description": {
"message": "Select your preferred layout for each page that displays project lists on this device."
},
"settings.display.project-list-layouts.mod": {
"message": "Mods page"
},
"settings.display.project-list-layouts.modpack": {
"message": "Modpacks page"
},
"settings.display.project-list-layouts.plugin": {
"message": "Plugins page"
},
"settings.display.project-list-layouts.resourcepack": {
"message": "Resource Packs page"
},
"settings.display.project-list-layouts.shader": {
"message": "Shaders page"
},
"settings.display.project-list-layouts.title": {
"message": "Project list layouts"
},
"settings.display.project-list-layouts.user": {
"message": "User profile pages"
},
"settings.display.sidebar.advanced-rendering.description": {
"message": "Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering."
},
"settings.display.sidebar.advanced-rendering.title": {
"message": "Advanced rendering"
},
"settings.display.sidebar.external-links-new-tab.description": {
"message": "Make links which go outside of Modrinth open in a new tab. No matter this setting, links on the same domain and in Markdown descriptions will open in the same tab, and links on ads and edit pages will open in a new tab."
},
"settings.display.sidebar.external-links-new-tab.title": {
"message": "Open external links in new tab"
},
"settings.display.sidebar.hide-app-promos.description": {
"message": "Hides the \"Get Modrinth App\" buttons from primary navigation. The Modrinth App page can still be found on the landing page or in the footer."
},
"settings.display.sidebar.hide-app-promos.title": {
"message": "Hide Modrinth App promotions"
},
"settings.display.sidebar.right-aligned-project-sidebar.description": {
"message": "Aligns the project details sidebar to the right of the page's content."
},
"settings.display.sidebar.right-aligned-project-sidebar.title": {
"message": "Right-aligned project sidebar"
},
"settings.display.sidebar.right-aligned-search-sidebar.description": {
"message": "Aligns the search filters sidebar to the right of the search results."
},
"settings.display.sidebar.right-aligned-search-sidebar.title": {
"message": "Right-aligned search sidebar"
},
"settings.display.theme.dark": {
"message": "Dark"
},
"settings.display.theme.description": {
"message": "Select your preferred color theme for Modrinth on this device."
},
"settings.display.theme.light": {
"message": "Light"
},
"settings.display.theme.oled": {
"message": "OLED"
},
"settings.display.theme.preferred-dark-theme": {
"message": "Preferred dark theme"
},
"settings.display.theme.preferred-light-theme": {
"message": "Preferred light theme"
},
"settings.display.theme.retro": {
"message": "Retro"
},
"settings.display.theme.system": {
"message": "Sync with system"
},
"settings.display.theme.title": {
"message": "Color theme"
},
"settings.language.categories.auto": { "settings.language.categories.auto": {
"message": "Automatic" "message": "Automatic"
}, },
@@ -858,10 +972,7 @@
"message": "Edit personal access token" "message": "Edit personal access token"
}, },
"settings.pats.title": { "settings.pats.title": {
"message": "PATs" "message": "Personal access tokens"
},
"settings.pats.title.long": {
"message": "Personal Access Tokens"
}, },
"settings.pats.token.action.edit": { "settings.pats.token.action.edit": {
"message": "Edit token" "message": "Edit token"
@@ -881,6 +992,36 @@
"settings.pats.token.never-used": { "settings.pats.token.never-used": {
"message": "Never used" "message": "Never used"
}, },
"settings.profile.bio.description": {
"message": "A short description to tell everyone a little bit about you."
},
"settings.profile.bio.title": {
"message": "Bio"
},
"settings.profile.description": {
"message": "Your profile information is publicly viewable on Modrinth and through the <docs-link>Modrinth API</docs-link>."
},
"settings.profile.profile-info": {
"message": "Profile information"
},
"settings.profile.profile-picture.reset": {
"message": "Reset"
},
"settings.profile.profile-picture.title": {
"message": "Profile picture"
},
"settings.profile.title": {
"message": "Public profile"
},
"settings.profile.username.description": {
"message": "A unique case-insensitive name to identify your profile."
},
"settings.profile.username.title": {
"message": "Username"
},
"settings.profile.visit-profile": {
"message": "Visit your profile"
},
"settings.sessions.action.revoke-session": { "settings.sessions.action.revoke-session": {
"message": "Revoke session" "message": "Revoke session"
}, },

View File

@@ -502,6 +502,7 @@ export default defineNuxtComponent({
border-radius: var(--size-rounded-sm); border-radius: var(--size-rounded-sm);
overflow: hidden; overflow: hidden;
margin-top: var(--spacing-card-md); margin-top: var(--spacing-card-md);
outline: 1px solid transparent;
.grid-table__row { .grid-table__row {
display: contents; display: contents;

View File

@@ -1,65 +1,116 @@
<template> <template>
<div class="normal-page"> <div>
<div class="normal-page__sidebar"> <div class="normal-page no-sidebar">
<aside class="universal-card"> <h1>{{ formatMessage(commonMessages.settingsLabel) }}</h1>
<h1>Settings</h1>
<NavStack>
<NavStackItem link="/settings" label="Appearance">
<PaintbrushIcon />
</NavStackItem>
<NavStackItem v-if="isStaging" link="/settings/language" label="Language">
<LanguagesIcon />
</NavStackItem>
<template v-if="auth.user">
<h3>User settings</h3>
<NavStackItem link="/settings/account" label="Account">
<UserIcon />
</NavStackItem>
<NavStackItem link="/settings/authorizations" label="Authorizations">
<UsersIcon />
</NavStackItem>
<NavStackItem link="/settings/sessions" :label="formatMessage(messages.sessionsTitle)">
<ShieldIcon />
</NavStackItem>
</template>
<template v-if="auth.user">
<h3>Developer Settings</h3>
<NavStackItem link="/settings/pats" :label="formatMessage(messages.patsTitle)">
<KeyIcon />
</NavStackItem>
<NavStackItem link="/settings/applications" label="Applications">
<ServerIcon />
</NavStackItem>
</template>
</NavStack>
</aside>
</div> </div>
<div class="normal-page__content"> <div class="normal-page">
<NuxtPage :route="route" /> <div class="normal-page__sidebar">
<aside class="universal-card">
<NavStack>
<h3>Display</h3>
<NavStackItem link="/settings" :label="formatMessage(messages.appearanceTitle)">
<PaintBrushIcon />
</NavStackItem>
<NavStackItem
v-if="isStaging"
link="/settings/language"
:label="formatMessage(messages.languageTitle)"
>
<LanguagesIcon />
</NavStackItem>
<template v-if="auth.user">
<h3>Account</h3>
<NavStackItem link="/settings/profile" :label="formatMessage(messages.profileTitle)">
<UserIcon />
</NavStackItem>
<NavStackItem link="/settings/account" :label="formatMessage(messages.accountTitle)">
<ShieldIcon />
</NavStackItem>
<NavStackItem
link="/settings/authorizations"
:label="formatMessage(messages.authorizedAppsTitle)"
>
<GridIcon />
</NavStackItem>
<NavStackItem
link="/settings/sessions"
:label="formatMessage(messages.sessionsTitle)"
>
<MonitorSmartphoneIcon />
</NavStackItem>
</template>
<template v-if="auth.user">
<h3>Developer</h3>
<NavStackItem link="/settings/pats" :label="formatMessage(messages.patsTitle)">
<KeyIcon />
</NavStackItem>
<NavStackItem
link="/settings/applications"
:label="formatMessage(messages.applicationsTitle)"
>
<ServerIcon />
</NavStackItem>
</template>
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<NuxtPage :route="route" />
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { UsersIcon, ServerIcon } from 'omorphia' import {
UsersIcon,
ServerIcon,
GridIcon,
PaintBrushIcon,
UserIcon,
ShieldIcon,
KeyIcon,
LanguagesIcon,
} from 'omorphia'
import NavStack from '~/components/ui/NavStack.vue' import NavStack from '~/components/ui/NavStack.vue'
import NavStackItem from '~/components/ui/NavStackItem.vue' import NavStackItem from '~/components/ui/NavStackItem.vue'
import MonitorSmartphoneIcon from '~/assets/images/utils/monitor-smartphone.svg'
import PaintbrushIcon from '~/assets/images/utils/paintbrush.svg' import { commonMessages } from '~/utils/common-messages.ts'
import UserIcon from '~/assets/images/utils/user.svg'
import ShieldIcon from '~/assets/images/utils/shield.svg'
import KeyIcon from '~/assets/images/utils/key.svg'
import LanguagesIcon from '~/assets/images/utils/languages.svg'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const messages = defineMessages({ const messages = defineMessages({
appearanceTitle: {
id: 'settings.appearance.title',
defaultMessage: 'Appearance',
},
languageTitle: {
id: 'settings.language.title',
defaultMessage: 'Language',
},
profileTitle: {
id: 'settings.profile.title',
defaultMessage: 'Public profile',
},
accountTitle: {
id: 'settings.account.title',
defaultMessage: 'Account and security',
},
authorizedAppsTitle: {
id: 'settings.authorized-apps.title',
defaultMessage: 'Authorized apps',
},
sessionsTitle: { sessionsTitle: {
id: 'settings.sessions.title', id: 'settings.sessions.title',
defaultMessage: 'Sessions', defaultMessage: 'Sessions',
}, },
patsTitle: { patsTitle: {
id: 'settings.pats.title', id: 'settings.pats.title',
defaultMessage: 'PATs', defaultMessage: 'Personal access tokens',
},
applicationsTitle: {
id: 'settings.applications.title',
defaultMessage: 'Your applications',
}, },
}) })

View File

@@ -290,14 +290,6 @@
</div> </div>
</div> </div>
</Modal> </Modal>
<section class="universal-card">
<h2>User profile</h2>
<p>Visit your user profile to edit your profile information.</p>
<NuxtLink class="iconified-button" :to="`/user/${auth.user.username}`">
<UserIcon /> Visit your profile
</NuxtLink>
</section>
<section class="universal-card"> <section class="universal-card">
<h2>Account security</h2> <h2>Account security</h2>

View File

@@ -1,96 +1,152 @@
<template> <template>
<div> <div>
<MessageBanner v-if="cosmetics.developerMode" message-type="warning" class="developer-message">
<CodeIcon />
<IntlFormatted :message-id="developerModeBanner.description">
<template #strong="{ children }">
<strong>
<component :is="() => normalizeChildren(children)" />
</strong>
</template>
</IntlFormatted>
<Button :action="() => disableDeveloperMode()">
{{ formatMessage(developerModeBanner.deactivate) }}
</Button>
</MessageBanner>
<section class="universal-card"> <section class="universal-card">
<h2>Themes</h2> <h2>{{ formatMessage(colorTheme.title) }}</h2>
<div class="adjacent-input"> <p>{{ formatMessage(colorTheme.description) }}</p>
<label for="theme-selector"> <div class="theme-options">
<span class="label__title">Color theme</span> <button
<span class="label__description">Change the global site color theme.</span> v-for="option in themeOptions"
</label> :key="option"
<div> class="preview-radio button-base"
<Multiselect :class="{ selected: theme.preference === option }"
id="theme-selector" @click="() => updateColorTheme(option)"
v-model="$colorMode.preference" >
:options="['system', 'light', 'dark', 'oled']" <div class="preview" :class="`${option === 'system' ? systemTheme : option}-mode`">
:custom-label=" <div class="example-card card card">
(value) => <div class="example-icon"></div>
value === 'oled' ? 'OLED' : value.charAt(0).toUpperCase() + value.slice(1) <div class="example-text-1"></div>
" <div class="example-text-2"></div>
:searchable="false" </div>
:close-on-select="true" </div>
:show-labels="false" <div class="label">
:allow-empty="false" <RadioButtonChecked v-if="theme.preference === option" class="radio" />
@update:model-value="(value) => updateTheme(value, true)" <RadioButtonIcon v-else class="radio" />
/> {{ colorTheme[option] ? formatMessage(colorTheme[option]) : option }}
<SunIcon
v-if="'light' === option"
v-tooltip="formatMessage(colorTheme.preferredLight)"
class="theme-icon"
/>
<MoonIcon
v-else-if="cosmetics.preferredDarkTheme === option"
v-tooltip="formatMessage(colorTheme.preferredDark)"
class="theme-icon"
/>
</div>
</button>
</div>
</section>
<section class="universal-card">
<h2>{{ formatMessage(projectListLayouts.title) }}</h2>
<p>{{ formatMessage(projectListLayouts.description) }}</p>
<div class="project-lists">
<div v-for="projectType in listTypes" :key="projectType.id + '-project-list-layouts'">
<div class="label">
<div class="label__title">
{{
projectListLayouts[projectType.id]
? formatMessage(projectListLayouts[projectType.id])
: projectType.id
}}
</div>
</div>
<div class="project-list-layouts">
<button
class="preview-radio button-base"
:class="{ selected: cosmetics.searchDisplayMode[projectType.id] === 'list' }"
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'list')"
>
<div class="preview">
<div class="layout-list-mode">
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
</div>
</div>
<div class="label">
<RadioButtonChecked
v-if="cosmetics.searchDisplayMode[projectType.id] === 'list'"
class="radio"
/>
<RadioButtonIcon v-else class="radio" />
Rows
</div>
</button>
<button
class="preview-radio button-base"
:class="{ selected: cosmetics.searchDisplayMode[projectType.id] === 'grid' }"
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'grid')"
>
<div class="preview">
<div class="layout-grid-mode">
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
</div>
</div>
<div class="label">
<RadioButtonChecked
v-if="cosmetics.searchDisplayMode[projectType.id] === 'grid'"
class="radio"
/>
<RadioButtonIcon v-else class="radio" />
Grid
</div>
</button>
<button
class="preview-radio button-base"
:class="{ selected: cosmetics.searchDisplayMode[projectType.id] === 'gallery' }"
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'gallery')"
>
<div class="preview">
<div class="layout-gallery-mode">
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
</div>
</div>
<div class="label">
<RadioButtonChecked
v-if="cosmetics.searchDisplayMode[projectType.id] === 'gallery'"
class="radio"
/>
<RadioButtonIcon v-else class="radio" />
Gallery
</div>
</button>
</div>
</div> </div>
</div> </div>
<div class="adjacent-input small">
<label for="search-layout-toggle">
<span class="label__title">Search sidebar on the right</span>
<span class="label__description"
>Enabling this will put the search page's filters sidebar on the right side.</span
>
</label>
<input
id="search-layout-toggle"
v-model="cosmetics.searchLayout"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmetics"
/>
</div>
<div class="adjacent-input small">
<label for="project-layout-toggle">
<span class="label__title">Project sidebar on the right</span>
<span class="label__description"
>Enabling this will put the project pages' info sidebars on the right side.</span
>
</label>
<input
id="project-layout-toggle"
v-model="cosmetics.projectLayout"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmetics"
/>
</div>
</section> </section>
<section class="universal-card"> <section class="universal-card">
<h2>Project list display mode</h2> <h2>{{ formatMessage(featureFlags.title) }}</h2>
<div <p>{{ formatMessage(featureFlags.description) }}</p>
v-for="projectType in listTypes"
:key="projectType.id + '-display-mode-selector'"
class="adjacent-input"
>
<label :for="projectType.id + '-search-display-mode'">
<span class="label__title">{{ projectType.name }} display mode</span>
<span class="label__description"
>Change the display view for {{ projectType.display }}.</span
>
</label>
<Multiselect
:id="projectType + '-search-display-mode'"
v-model="cosmetics.searchDisplayMode[projectType.id]"
:options="tags.projectViewModes"
:custom-label="$capitalizeString"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
@update:model-value="saveCosmetics"
/>
</div>
</section>
<section class="universal-card">
<h2>Feature flags</h2>
<div class="adjacent-input small"> <div class="adjacent-input small">
<label for="advanced-rendering"> <label for="advanced-rendering">
<span class="label__title">Advanced rendering</span> <span class="label__title">
<span class="label__description" {{ formatMessage(featureFlags.advancedRenderingTitle) }}
>Enables advanced rendering such as blur effects that may cause performance issues </span>
without hardware-accelerated rendering.</span <span class="label__description">
> {{ formatMessage(featureFlags.advancedRenderingDescription) }}
</span>
</label> </label>
<input <input
id="advanced-rendering" id="advanced-rendering"
@@ -102,11 +158,11 @@
</div> </div>
<div class="adjacent-input small"> <div class="adjacent-input small">
<label for="external-links-new-tab"> <label for="external-links-new-tab">
<span class="label__title">Open external links in new tab</span> <span class="label__title">
{{ formatMessage(featureFlags.externalLinksNewTabTitle) }}
</span>
<span class="label__description"> <span class="label__description">
Make links which go outside of Modrinth open in a new tab. No matter this setting, links {{ formatMessage(featureFlags.externalLinksNewTabDescription) }}
on the same domain and in Markdown descriptions will open in the same tab, and links on
ads and edit pages will open in a new tab.
</span> </span>
</label> </label>
<input <input
@@ -119,10 +175,11 @@
</div> </div>
<div class="adjacent-input small"> <div class="adjacent-input small">
<label for="modrinth-app-promos"> <label for="modrinth-app-promos">
<span class="label__title">Hide Modrinth App promotions</span> <span class="label__title">
{{ formatMessage(featureFlags.hideModrinthAppPromosTitle) }}
</span>
<span class="label__description"> <span class="label__description">
Hides the "Get Modrinth App" buttons from primary navigation. The Modrinth App page can {{ formatMessage(featureFlags.hideModrinthAppPromosDescription) }}
still be found on the landing page or in the footer.
</span> </span>
</label> </label>
<input <input
@@ -133,34 +190,447 @@
@change="saveCosmetics" @change="saveCosmetics"
/> />
</div> </div>
<div class="adjacent-input small">
<label for="search-layout-toggle">
<span class="label__title">
{{ formatMessage(featureFlags.rightAlignedSearchSidebarTitle) }}
</span>
<span class="label__description">
{{ formatMessage(featureFlags.rightAlignedSearchSidebarDescription) }}
</span>
</label>
<input
id="search-layout-toggle"
v-model="cosmetics.searchLayout"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmetics"
/>
</div>
<div class="adjacent-input small">
<label for="project-layout-toggle">
<span class="label__title">
{{ formatMessage(featureFlags.rightAlignedProjectSidebarTitle) }}
</span>
<span class="label__description">
{{ formatMessage(featureFlags.rightAlignedProjectSidebarDescription) }}
</span>
</label>
<input
id="project-layout-toggle"
v-model="cosmetics.projectLayout"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmetics"
/>
</div>
</section> </section>
</div> </div>
</template> </template>
<script setup> <script setup>
import { Multiselect } from 'vue-multiselect' import { CodeIcon, Button, RadioButtonIcon, RadioButtonChecked, SunIcon, MoonIcon } from 'omorphia'
import { formatProjectType } from '~/plugins/shorthands.js' import { formatProjectType } from '~/plugins/shorthands.js'
import MessageBanner from '~/components/ui/MessageBanner.vue'
import { DARK_THEMES } from '~/composables/theme.js'
useHead({ useHead({
title: 'Display settings - Modrinth', title: 'Display settings - Modrinth',
}) })
const { formatMessage } = useVIntl()
const developerModeBanner = defineMessages({
description: {
id: 'settings.display.banner.developer-mode.description',
defaultMessage:
"<strong>Developer mode</strong> is active. This will allow you to view the internal IDs of various things throughout Modrinth that may be helpful if you're a developer using the Modrinth API. Click on the Modrinth logo at the bottom of the page 5 times to toggle developer mode.",
},
deactivate: {
id: 'settings.display.banner.developer-mode.button',
defaultMessage: 'Deactivate developer mode',
},
})
const colorTheme = defineMessages({
title: {
id: 'settings.display.theme.title',
defaultMessage: 'Color theme',
},
description: {
id: 'settings.display.theme.description',
defaultMessage: 'Select your preferred color theme for Modrinth on this device.',
},
system: {
id: 'settings.display.theme.system',
defaultMessage: 'Sync with system',
},
light: {
id: 'settings.display.theme.light',
defaultMessage: 'Light',
},
dark: {
id: 'settings.display.theme.dark',
defaultMessage: 'Dark',
},
oled: {
id: 'settings.display.theme.oled',
defaultMessage: 'OLED',
},
retro: {
id: 'settings.display.theme.retro',
defaultMessage: 'Retro',
},
preferredLight: {
id: 'settings.display.theme.preferred-light-theme',
defaultMessage: 'Preferred light theme',
},
preferredDark: {
id: 'settings.display.theme.preferred-dark-theme',
defaultMessage: 'Preferred dark theme',
},
})
const projectListLayouts = defineMessages({
title: {
id: 'settings.display.project-list-layouts.title',
defaultMessage: 'Project list layouts',
},
description: {
id: 'settings.display.project-list-layouts.description',
defaultMessage:
'Select your preferred layout for each page that displays project lists on this device.',
},
mod: {
id: 'settings.display.project-list-layouts.mod',
defaultMessage: 'Mods page',
},
plugin: {
id: 'settings.display.project-list-layouts.plugin',
defaultMessage: 'Plugins page',
},
datapack: {
id: 'settings.display.project-list-layouts.datapack',
defaultMessage: 'Data Packs page',
},
shader: {
id: 'settings.display.project-list-layouts.shader',
defaultMessage: 'Shaders page',
},
resourcepack: {
id: 'settings.display.project-list-layouts.resourcepack',
defaultMessage: 'Resource Packs page',
},
modpack: {
id: 'settings.display.project-list-layouts.modpack',
defaultMessage: 'Modpacks page',
},
user: {
id: 'settings.display.project-list-layouts.user',
defaultMessage: 'User profile pages',
},
})
const featureFlags = defineMessages({
title: {
id: 'settings.display.flags.title',
defaultMessage: 'Feature flags',
},
description: {
id: 'settings.display.flags.description',
defaultMessage: 'Enable or disable certain features on this device.',
},
advancedRenderingTitle: {
id: 'settings.display.sidebar.advanced-rendering.title',
defaultMessage: 'Advanced rendering',
},
advancedRenderingDescription: {
id: 'settings.display.sidebar.advanced-rendering.description',
defaultMessage:
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.',
},
externalLinksNewTabTitle: {
id: 'settings.display.sidebar.external-links-new-tab.title',
defaultMessage: 'Open external links in new tab',
},
externalLinksNewTabDescription: {
id: 'settings.display.sidebar.external-links-new-tab.description',
defaultMessage:
'Make links which go outside of Modrinth open in a new tab. No matter this setting, links on the same domain and in Markdown descriptions will open in the same tab, and links on ads and edit pages will open in a new tab.',
},
hideModrinthAppPromosTitle: {
id: 'settings.display.sidebar.hide-app-promos.title',
defaultMessage: 'Hide Modrinth App promotions',
},
hideModrinthAppPromosDescription: {
id: 'settings.display.sidebar.hide-app-promos.description',
defaultMessage:
'Hides the "Get Modrinth App" buttons from primary navigation. The Modrinth App page can still be found on the landing page or in the footer.',
},
rightAlignedSearchSidebarTitle: {
id: 'settings.display.sidebar.right-aligned-search-sidebar.title',
defaultMessage: 'Right-aligned search sidebar',
},
rightAlignedSearchSidebarDescription: {
id: 'settings.display.sidebar.right-aligned-search-sidebar.description',
defaultMessage: 'Aligns the search filters sidebar to the right of the search results.',
},
rightAlignedProjectSidebarTitle: {
id: 'settings.display.sidebar.right-aligned-project-sidebar.title',
defaultMessage: 'Right-aligned project sidebar',
},
rightAlignedProjectSidebarDescription: {
id: 'settings.display.sidebar.right-aligned-project-sidebar.description',
defaultMessage: "Aligns the project details sidebar to the right of the page's content.",
},
})
const cosmetics = useCosmetics() const cosmetics = useCosmetics()
const tags = useTags() const tags = useTags()
const systemTheme = ref('light')
const theme = useTheme()
const themeOptions = computed(() => {
const options = ['system', 'light', 'dark', 'oled']
if (cosmetics.value.developerMode || theme.value.preference === 'retro') {
options.push('retro')
}
return options
})
onMounted(() => {
updateSystemTheme()
})
function updateSystemTheme() {
const colorSchemeQueryList = window.matchMedia('(prefers-color-scheme: light)')
if (colorSchemeQueryList.matches) {
systemTheme.value = 'light'
} else {
systemTheme.value = cosmetics.value.preferredDarkTheme
}
}
function updateColorTheme(value) {
if (DARK_THEMES.includes(value)) {
cosmetics.value.preferredDarkTheme = value
saveCosmetics()
updateSystemTheme()
}
updateTheme(value, true)
}
function disableDeveloperMode() {
cosmetics.value.developerMode = !cosmetics.value.developerMode
saveCosmetics()
addNotification({
group: 'main',
title: 'Developer mode deactivated',
text: 'Developer mode has been disabled',
type: 'success',
})
}
const listTypes = computed(() => { const listTypes = computed(() => {
const types = tags.value.projectTypes.map((type) => { const types = tags.value.projectTypes.map((type) => {
return { return {
id: type.id, id: type.id,
name: formatProjectType(type.id) + ' search', name: formatProjectType(type.id) + 's',
display: 'the ' + formatProjectType(type.id).toLowerCase() + 's search page', display: 'the ' + formatProjectType(type.id).toLowerCase() + 's search page',
} }
}) })
types.push({ types.push({
id: 'user', id: 'user',
name: 'User page', name: 'User profiles',
display: 'user pages', display: 'user pages',
}) })
return types return types
}) })
</script> </script>
<style scoped lang="scss">
.preview-radio {
width: 100%;
border-radius: var(--radius-md);
padding: 0;
overflow: hidden;
border: 1px solid var(--color-divider);
background-color: var(--color-button-bg);
color: var(--color-base);
display: flex;
flex-direction: column;
outline: 2px solid transparent;
&.selected {
color: var(--color-contrast);
.label {
.radio {
color: var(--color-brand);
}
.theme-icon {
color: var(--color-text);
}
}
}
.preview {
background-color: var(--color-bg);
padding: 1.5rem;
outline: 2px solid transparent;
.example-card {
margin: 0;
padding: 1rem;
outline: 2px solid transparent;
min-height: 0;
}
}
.label {
display: flex;
align-items: center;
text-align: left;
flex-grow: 1;
padding: var(--gap-md) var(--gap-lg);
.radio {
margin-right: 0.5rem;
}
.theme-icon {
color: var(--color-secondary);
margin-left: 0.25rem;
}
}
}
.theme-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12.5rem, 1fr));
gap: var(--gap-lg);
.preview .example-card {
margin: 0;
padding: 1rem;
display: grid;
grid-template: 'icon text1' 'icon text2';
grid-template-columns: auto 1fr;
gap: 0.5rem;
outline: 2px solid transparent;
.example-icon {
grid-area: icon;
width: 2rem;
height: 2rem;
background-color: var(--color-button-bg);
border-radius: var(--radius-sm);
outline: 2px solid transparent;
}
.example-text-1,
.example-text-2 {
height: 0.5rem;
border-radius: var(--radius-sm);
outline: 2px solid transparent;
}
.example-text-1 {
grid-area: text1;
width: 100%;
background-color: var(--color-base);
}
.example-text-2 {
grid-area: text2;
width: 60%;
background-color: var(--color-secondary);
}
}
}
.project-lists {
display: flex;
flex-direction: column;
gap: var(--gap-md);
> :first-child .label__title {
margin-top: 0;
}
.preview {
--_layout-width: 7rem;
--_layout-height: 4.5rem;
--_layout-gap: 0.25rem;
.example-card {
border-radius: 0.5rem;
width: var(--_layout-width);
height: calc((var(--_layout-height) - 3 * var(--_layout-gap)) / 4);
padding: 0;
}
.layout-list-mode {
display: grid;
grid-template-columns: 1fr;
gap: var(--_layout-gap);
}
.layout-grid-mode {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--_layout-gap);
.example-card {
width: calc((var(--_layout-width) - 2 * var(--_layout-gap)) / 3);
height: calc((var(--_layout-height) - var(--_layout-gap)) / 2);
}
}
.layout-gallery-mode {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--_layout-gap);
.example-card {
width: calc((var(--_layout-width) - var(--_layout-gap)) / 2);
height: calc((var(--_layout-height) - var(--_layout-gap)) / 2);
}
}
}
}
.project-list-layouts {
display: flex;
gap: var(--gap-lg);
width: fit-content;
.preview-radio .example-card {
border: 2px solid transparent;
}
.preview-radio.selected .example-card {
border-color: var(--color-brand);
background-color: var(--color-brand-highlight);
}
.preview {
display: flex;
align-items: center;
justify-content: center;
}
}
.developer-message {
svg {
vertical-align: middle;
margin-bottom: 2px;
margin-right: 0.5rem;
}
.btn {
margin-top: var(--gap-sm);
}
}
</style>

View File

@@ -74,7 +74,7 @@
<div class="header__row"> <div class="header__row">
<div class="header__title"> <div class="header__title">
<h2>{{ formatMessage(messages.longTitle) }}</h2> <h2>{{ formatMessage(messages.title) }}</h2>
</div> </div>
<button <button
class="btn btn-primary" class="btn btn-primary"
@@ -264,11 +264,7 @@ const deleteModalMessages = defineMessages({
const messages = defineMessages({ const messages = defineMessages({
title: { title: {
id: 'settings.pats.title', id: 'settings.pats.title',
defaultMessage: 'PATs', defaultMessage: 'Personal access tokens',
},
longTitle: {
id: 'settings.pats.title.long',
defaultMessage: 'Personal Access Tokens',
}, },
description: { description: {
id: 'settings.pats.description', id: 'settings.pats.description',

246
pages/settings/profile.vue Normal file
View File

@@ -0,0 +1,246 @@
<template>
<div>
<section class="card">
<h2>{{ formatMessage(messages.title) }}</h2>
<p>
<IntlFormatted :message-id="messages.description">
<template #docs-link="{ children }">
<a href="https://docs.modrinth.com/" target="_blank" class="text-link">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</p>
<label>
<span class="label__title">{{ formatMessage(messages.profilePicture) }}</span>
</label>
<div class="avatar-changer">
<Avatar
:src="previewImage ? previewImage : avatarUrl"
size="md"
circle
:alt="auth.user.username"
/>
<div class="input-stack">
<FileInput
:max-size="262144"
:show-icon="true"
class="btn"
:prompt="formatMessage(commonMessages.uploadImageButton)"
accept="image/png,image/jpeg,image/gif,image/webp"
@change="showPreviewImage"
>
<UploadIcon />
</FileInput>
<Button
v-if="previewImage"
:action="
() => {
icon = null
previewImage = null
}
"
>
<UndoIcon />
{{ formatMessage(messages.profilePictureReset) }}
</Button>
</div>
</div>
<label for="username-field">
<span class="label__title">{{ formatMessage(messages.usernameTitle) }}</span>
<span class="label__description">
{{ formatMessage(messages.usernameDescription) }}
</span>
</label>
<input id="username-field" v-model="username" type="text" />
<label for="bio-field">
<span class="label__title">{{ formatMessage(messages.bioTitle) }}</span>
<span class="label__description">
{{ formatMessage(messages.bioDescription) }}
</span>
</label>
<textarea id="bio-field" v-model="bio" type="text" />
<div v-if="hasUnsavedChanges" class="input-group">
<Button color="primary" :action="() => saveChanges()">
<SaveIcon /> {{ formatMessage(commonMessages.saveChangesButton) }}
</Button>
<Button :action="() => cancel()">
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</Button>
</div>
<div v-else class="input-group">
<Button disabled color="primary" :action="() => saveChanges()">
<SaveIcon />
{{
saved
? formatMessage(commonMessages.changesSavedLabel)
: formatMessage(commonMessages.saveChangesButton)
}}
</Button>
<Button :link="`/user/${auth.user.username}`">
<UserIcon /> {{ formatMessage(messages.visitProfile) }}
</Button>
</div>
</section>
</div>
</template>
<script setup>
import {
Button,
UserIcon,
SaveIcon,
Avatar,
FileInput,
UploadIcon,
UndoIcon,
XIcon,
} from 'omorphia'
import { commonMessages } from '~/utils/common-messages'
useHead({
title: 'Account settings - Modrinth',
})
definePageMeta({
middleware: 'auth',
})
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'settings.profile.profile-info',
defaultMessage: 'Profile information',
},
description: {
id: 'settings.profile.description',
defaultMessage:
'Your profile information is publicly viewable on Modrinth and through the <docs-link>Modrinth API</docs-link>.',
},
profilePicture: {
id: 'settings.profile.profile-picture.title',
defaultMessage: 'Profile picture',
},
profilePictureReset: {
id: 'settings.profile.profile-picture.reset',
defaultMessage: 'Reset',
},
usernameTitle: {
id: 'settings.profile.username.title',
defaultMessage: 'Username',
},
usernameDescription: {
id: 'settings.profile.username.description',
defaultMessage: 'A unique case-insensitive name to identify your profile.',
},
bioTitle: {
id: 'settings.profile.bio.title',
defaultMessage: 'Bio',
},
bioDescription: {
id: 'settings.profile.bio.description',
defaultMessage: 'A short description to tell everyone a little bit about you.',
},
visitProfile: {
id: 'settings.profile.visit-profile',
defaultMessage: 'Visit your profile',
},
})
const auth = await useAuth()
const username = ref(auth.value.user.username)
const bio = ref(auth.value.user.bio)
const avatarUrl = ref(auth.value.user.avatar_url)
const icon = shallowRef(null)
const previewImage = shallowRef(null)
const saved = ref(false)
const hasUnsavedChanges = computed(
() =>
username.value !== auth.value.user.username ||
bio.value !== auth.value.user.bio ||
previewImage.value
)
function showPreviewImage(files) {
const reader = new FileReader()
icon.value = files[0]
reader.readAsDataURL(icon.value)
reader.onload = (event) => {
previewImage.value = event.target.result
}
}
function cancel() {
icon.value = null
previewImage.value = null
username.value = auth.value.user.username
bio.value = auth.value.user.bio
}
async function saveChanges() {
startLoading()
try {
if (icon.value) {
await useBaseFetch(
`user/${auth.value.user.id}/icon?ext=${
icon.value.type.split('/')[icon.value.type.split('/').length - 1]
}`,
{
method: 'PATCH',
body: icon.value,
}
)
icon.value = null
previewImage.value = null
}
const body = {}
if (auth.value.user.username !== username.value) {
body.username = username.value
}
if (auth.value.user.bio !== bio.value) {
body.bio = bio.value
}
await useBaseFetch(`user/${auth.value.user.id}`, {
method: 'PATCH',
body,
})
await useAuth(auth.value.token)
avatarUrl.value = auth.value.user.avatar_url
saved.value = true
} catch (err) {
addNotification({
group: 'main',
title: 'An error occurred',
text: err
? err.data
? err.data.description
? err.data.description
: err.data
: err
: 'aaaaahhh',
type: 'error',
})
}
stopLoading()
}
</script>
<style lang="scss" scoped>
.avatar-changer {
display: flex;
gap: var(--gap-lg);
margin-top: var(--gap-md);
}
textarea {
height: 6rem;
width: 40rem;
margin-bottom: var(--gap-lg);
}
</style>

View File

@@ -4,12 +4,7 @@
<CollectionCreateModal ref="modal_collection_creation" /> <CollectionCreateModal ref="modal_collection_creation" />
<div class="user-header-wrapper"> <div class="user-header-wrapper">
<div class="user-header"> <div class="user-header">
<Avatar <Avatar :src="user.avatar_url" size="md" circle :alt="user.username" />
:src="previewImage ? previewImage : user.avatar_url"
size="md"
circle
:alt="user.username"
/>
<h1 class="username"> <h1 class="username">
{{ user.username }} {{ user.username }}
</h1> </h1>
@@ -22,25 +17,14 @@
{{ user.username }} {{ user.username }}
</h1> </h1>
<div class="card__overlay"> <div class="card__overlay">
<FileInput <NuxtLink
v-if="isEditing" v-if="auth.user && auth.user.id === user.id"
:max-size="262144" to="/settings/profile"
:show-icon="true"
:prompt="formatMessage(messages.profileUploadAvatarInput)"
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image iconified-button"
@change="showPreviewImage"
>
<UploadIcon />
</FileInput>
<button
v-else-if="auth.user && auth.user.id === user.id"
class="iconified-button" class="iconified-button"
@click="isEditing = true"
> >
<EditIcon /> <EditIcon />
{{ formatMessage(commonMessages.editButton) }} {{ formatMessage(commonMessages.editButton) }}
</button> </NuxtLink>
<button <button
v-else-if="auth.user" v-else-if="auth.user"
class="iconified-button" class="iconified-button"
@@ -54,123 +38,85 @@
{{ formatMessage(messages.profileReportButton) }} {{ formatMessage(messages.profileReportButton) }}
</nuxt-link> </nuxt-link>
</div> </div>
<template v-if="isEditing"> <div class="sidebar__item">
<div class="inputs universal-labels"> <Badge v-if="tags.staffRoles.includes(user.role)" :type="user.role" />
<label for="user-username"> <Badge v-else-if="projects.length > 0" type="creator" />
<span class="label__title"> </div>
{{ formatMessage(messages.profileEditUsernameLabel) }} <span v-if="user.bio" class="sidebar__item bio">{{ user.bio }}</span>
</span> <hr class="card-divider" />
</label> <div class="primary-stat">
<input id="user-username" v-model="user.username" maxlength="39" type="text" /> <DownloadIcon class="primary-stat__icon" aria-hidden="true" />
<label for="user-bio"> <div class="primary-stat__text">
<span class="label__title"> <IntlFormatted
{{ formatMessage(messages.profileEditBioLabel) }} :message-id="messages.profileDownloadsStats"
</span> :values="{ count: formatCompactNumber(sumDownloads) }"
</label>
<div class="textarea-wrapper">
<textarea id="user-bio" v-model="user.bio" maxlength="160" />
</div>
</div>
<div class="button-group">
<button
class="iconified-button"
@click="
() => {
isEditing = false
user = JSON.parse(JSON.stringify(auth.user))
previewImage = null
icon = null
}
"
> >
<CrossIcon /> {{ formatMessage(commonMessages.cancelButton) }} <template #stat="{ children }">
</button> <span class="primary-stat__counter">
<button class="iconified-button brand-button" @click="saveChanges"> <component :is="() => normalizeChildren(children)" />
<SaveIcon /> {{ formatMessage(commonMessages.saveButton) }} </span>
</button> </template>
</IntlFormatted>
</div> </div>
</template> </div>
<template v-else> <div class="primary-stat">
<div class="sidebar__item"> <HeartIcon class="primary-stat__icon" aria-hidden="true" />
<Badge v-if="tags.staffRoles.includes(user.role)" :type="user.role" /> <div class="primary-stat__text">
<Badge v-else-if="projects.length > 0" type="creator" /> <IntlFormatted
</div> :message-id="messages.profileProjectsFollowersStats"
<span v-if="user.bio" class="sidebar__item bio">{{ user.bio }}</span> :values="{ count: formatCompactNumber(sumFollows) }"
<hr class="card-divider" />
<div class="primary-stat">
<DownloadIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
<IntlFormatted
:message-id="messages.profileDownloadsStats"
:values="{ count: formatCompactNumber(sumDownloads) }"
>
<template #stat="{ children }">
<span class="primary-stat__counter">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</div>
</div>
<div class="primary-stat">
<HeartIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
<IntlFormatted
:message-id="messages.profileProjectsFollowersStats"
:values="{ count: formatCompactNumber(sumFollows) }"
>
<template #stat="{ children }">
<span class="primary-stat__counter">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</div>
</div>
<div class="stats-block__item secondary-stat">
<SunriseIcon class="secondary-stat__icon" aria-hidden="true" />
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(user.created),
time: new Date(user.created),
})
"
class="secondary-stat__text date"
> >
{{ <template #stat="{ children }">
formatMessage(messages.profileJoinedAt, { ago: formatRelativeTime(user.created) }) <span class="primary-stat__counter">
}} <component :is="() => normalizeChildren(children)" />
</span> </span>
</template>
</IntlFormatted>
</div> </div>
</div>
<div class="stats-block__item secondary-stat">
<SunriseIcon class="secondary-stat__icon" aria-hidden="true" />
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(user.created),
time: new Date(user.created),
})
"
class="secondary-stat__text date"
>
{{
formatMessage(messages.profileJoinedAt, { ago: formatRelativeTime(user.created) })
}}
</span>
</div>
<hr class="card-divider" />
<div class="stats-block__item secondary-stat">
<UserIcon class="secondary-stat__icon" aria-hidden="true" />
<span class="secondary-stat__text">
<IntlFormatted :message-id="messages.profileUserId">
<template #~id>
<CopyCode :text="user.id" />
</template>
</IntlFormatted>
</span>
</div>
<template v-if="organizations.length > 0">
<hr class="card-divider" /> <hr class="card-divider" />
<div class="stats-block__item secondary-stat"> <div class="stats-block__item">
<UserIcon class="secondary-stat__icon" aria-hidden="true" /> <IntlFormatted :message-id="messages.profileOrganizations" />
<span class="secondary-stat__text"> <div class="organizations-grid">
<IntlFormatted :message-id="messages.profileUserId"> <nuxt-link
<template #~id> v-for="org in organizations"
<CopyCode :text="user.id" /> :key="org.id"
</template> v-tooltip="org.name"
</IntlFormatted> class="organization"
</span> :to="`/organization/${org.slug}`"
</div> >
<template v-if="organizations.length > 0"> <Avatar :src="org.icon_url" :alt="'Icon for ' + org.name" size="xs" />
<hr class="card-divider" /> </nuxt-link>
<div class="stats-block__item">
<IntlFormatted :message-id="messages.profileOrganizations" />
<div class="organizations-grid">
<nuxt-link
v-for="org in organizations"
:key="org.id"
v-tooltip="org.name"
class="organization"
:to="`/organization/${org.slug}`"
>
<Avatar :src="org.icon_url" :alt="'Icon for ' + org.name" size="xs" />
</nuxt-link>
</div>
</div> </div>
</template> </div>
</template> </template>
</div> </div>
</div> </div>
@@ -347,14 +293,10 @@ import UpToDate from '~/assets/images/illustrations/up_to_date.svg'
import UserIcon from '~/assets/images/utils/user.svg' import UserIcon from '~/assets/images/utils/user.svg'
import EditIcon from '~/assets/images/utils/edit.svg' import EditIcon from '~/assets/images/utils/edit.svg'
import HeartIcon from '~/assets/images/utils/heart.svg' import HeartIcon from '~/assets/images/utils/heart.svg'
import CrossIcon from '~/assets/images/utils/x.svg'
import SaveIcon from '~/assets/images/utils/save.svg'
import GridIcon from '~/assets/images/utils/grid.svg' import GridIcon from '~/assets/images/utils/grid.svg'
import ListIcon from '~/assets/images/utils/list.svg' import ListIcon from '~/assets/images/utils/list.svg'
import ImageIcon from '~/assets/images/utils/image.svg' import ImageIcon from '~/assets/images/utils/image.svg'
import UploadIcon from '~/assets/images/utils/upload.svg'
import WorldIcon from '~/assets/images/utils/world.svg' import WorldIcon from '~/assets/images/utils/world.svg'
import FileInput from '~/components/ui/FileInput.vue'
import ModalCreation from '~/components/ui/ModalCreation.vue' import ModalCreation from '~/components/ui/ModalCreation.vue'
import NavRow from '~/components/ui/NavRow.vue' import NavRow from '~/components/ui/NavRow.vue'
import CopyCode from '~/components/ui/CopyCode.vue' import CopyCode from '~/components/ui/CopyCode.vue'
@@ -553,61 +495,6 @@ const sumFollows = computed(() => {
return sum return sum
}) })
const isEditing = ref(false)
const icon = shallowRef(null)
const previewImage = shallowRef(null)
function showPreviewImage(files) {
const reader = new FileReader()
icon.value = files[0]
reader.readAsDataURL(icon.value)
reader.onload = (event) => {
previewImage.value = event.target.result
}
}
async function saveChanges() {
startLoading()
try {
if (icon.value) {
await useBaseFetch(
`user/${auth.value.user.id}/icon?ext=${
icon.value.type.split('/')[icon.value.type.split('/').length - 1]
}`,
{
method: 'PATCH',
body: icon.value,
}
)
}
const reqData = {
email: user.value.email,
bio: user.value.bio,
}
if (user.value.username !== auth.value.user.username) {
reqData.username = user.value.username
}
await useBaseFetch(`user/${auth.value.user.id}`, {
method: 'PATCH',
body: reqData,
})
await useAuth(auth.value.token)
isEditing.value = false
} catch (err) {
console.error(err)
data.$notify({
group: 'main',
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
function cycleSearchDisplayMode() { function cycleSearchDisplayMode() {
cosmetics.value.searchDisplayMode.user = data.$cycleValue( cosmetics.value.searchDisplayMode.user = data.$cycleValue(
cosmetics.value.searchDisplayMode.user, cosmetics.value.searchDisplayMode.user,

View File

@@ -15,6 +15,10 @@ export const commonMessages = defineMessages({
id: 'button.continue', id: 'button.continue',
defaultMessage: 'Continue', defaultMessage: 'Continue',
}, },
changesSavedLabel: {
id: 'label.changes-saved',
defaultMessage: 'Changes saved',
},
createAProjectButton: { createAProjectButton: {
id: 'button.create-a-project', id: 'button.create-a-project',
defaultMessage: 'Create a project', defaultMessage: 'Create a project',
@@ -65,7 +69,7 @@ export const commonMessages = defineMessages({
}, },
listInputView: { listInputView: {
id: 'input.view.list', id: 'input.view.list',
defaultMessage: 'List view', defaultMessage: 'Rows view',
}, },
moderationLabel: { moderationLabel: {
id: 'label.moderation', id: 'label.moderation',
@@ -123,6 +127,10 @@ export const commonMessages = defineMessages({
id: 'label.unlisted', id: 'label.unlisted',
defaultMessage: 'Unlisted', defaultMessage: 'Unlisted',
}, },
uploadImageButton: {
id: 'button.upload-image',
defaultMessage: 'Upload image',
},
visibilityLabel: { visibilityLabel: {
id: 'label.visibility', id: 'label.visibility',
defaultMessage: 'Visibility', defaultMessage: 'Visibility',