Ваш профиль
Место, где у модели сохранения появляется исключение: правки ждут, действия — нет.
Что он доказывает
Экран настроек настаивал на одной модели сохранения на страницу. Этот — уточнение, и уточнение важнее правила:
Правки ждут «Сохранить». Действия происходят в момент нажатия.
Переименовать себя — правка: передумать можно, поэтому она ждёт. Закрыть сеанс на устройстве — действие: сеанса больше нет, и ставить его в очередь к кнопке значило бы соврать о том, что уже случилось.
Граница проходит не по «срочности». Она проходит по тому, осталось ли что-то, что можно пересмотреть потом.
Смена почты происходит не здесь
Введите новый адрес, нажмите кнопку — и ничего не изменится. Подтверждение уйдёт на адрес, которым вы пользуетесь сейчас, и смена будет ждать его.
Этот крюк — единственное, что стоит между учётной записью и тем, кто нашёл незапертый ноутбук. Профиль, меняющий адрес по «Сохранить», отдаёт учётную запись вместе со стулом.
Кнопка называется «Отправить подтверждение», а не «Сохранить», потому что делает именно это. Кнопка, называющая не тот исход, — дефект, выехавший в продакшен текстом.
Пустой аватар — не нейтральность
Без изображения экран показывает инициалы, и это тоже изображение, просто предсказуемое. Пустой кружок на месте человека убирает единственное, чем один участник отличается от другого в списке из девяти.
Это устройство отсюда не закрывается
У каждого чужого сеанса есть кнопка, у этого — бейдж. Элемент, выбрасывающий человека из экрана, на котором он стоит, — не действие, а ловушка. И «выйти везде» названо «на всех остальных», с числом сеансов, которые закончатся.
Использованные компоненты
Список строится из frontmatter и сверяется с исходником: компонент, добавленный в код и забытый в шапке, роняет гейт.
GrCardДанныеGrAvatarДанныеGrFormFieldФормыGrInputФормыGrButtonДействияGrAlertОбратная связьGrBadgeОбратная связьGrConfirmDialogСлои
Пакеты:
@feugene/granularity
Точка входа
Тот же файл, который здесь исполняется, — examples/profile/App.vue. Отдельной копии кода не существует.
<script setup lang="ts">
/**
* `/examples/profile` — профиль, работа C2.
*
* Экран настроек уже показал одну модель сохранения. Здесь она уточняется, и
* уточнение важнее самой модели:
*
* > **Правки ждут «Сохранить». Действия происходят сразу.**
*
* Выход с чужого устройства и запрос на смену почты — не настройки, а
* действия: после них передумывать нечего, и ставить их в очередь к кнопке
* значит врать о том, что уже случилось. Разница написана на экране, а не
* оставлена читателю на догадку.
*
* Смена почты вообще не происходит здесь. Она уходит письмом на **нынешний**
* адрес, и это единственная защита от того, кто дорвался до чужой открытой
* вкладки.
*/
import { computed, reactive, ref } from 'vue'
import { GrCard } from '@feugene/granularity/components/GrCard'
import { GrAvatar } from '@feugene/granularity/components/GrAvatar'
import { GrFormField } from '@feugene/granularity/components/GrFormField'
import { GrInput } from '@feugene/granularity/components/GrInput'
import { GrButton } from '@feugene/granularity/components/GrButton'
import { GrAlert } from '@feugene/granularity/components/GrAlert'
import { GrBadge } from '@feugene/granularity/components/GrBadge'
import { GrConfirmDialog } from '@feugene/granularity/components/GrConfirmDialog'
import type { ProfileData, Session } from './data'
const props = defineProps<{ data: ProfileData }>()
const saved = reactive({ name: 'Ada Lovelace' })
const draft = reactive({ ...saved })
const currentEmail = '[email protected]'
const newEmail = ref('')
const pendingEmail = ref('')
const sessions = reactive<Session[]>(props.data.sessions.list.map(item => ({ ...item })))
const revoking = ref(false)
const notice = ref('')
const dirty = computed(() => draft.name !== saved.name)
const others = computed(() => sessions.filter(session => !session.current))
function save() {
Object.assign(saved, draft)
notice.value = props.data.saved
}
function discard() {
Object.assign(draft, saved)
}
/**
* Смена почты — действие, а не правка.
*
* Поэтому она не ждёт «Сохранить» и не меняет адрес: она ставит смену в
* ожидание и отправляет письмо туда, где человек уже подтверждён.
*/
function requestEmailChange() {
if (!newEmail.value.trim())
return
pendingEmail.value = newEmail.value.trim()
newEmail.value = ''
}
/** Выход с устройства — тоже действие: он происходит сразу. */
function revoke(id: number) {
const index = sessions.findIndex(session => session.id === id)
if (index >= 0)
sessions.splice(index, 1)
notice.value = props.data.sessions.revoked
}
function revokeAll() {
for (let index = sessions.length - 1; index >= 0; index--) {
if (!sessions[index].current)
sessions.splice(index, 1)
}
revoking.value = false
notice.value = props.data.sessions.revoked
}
/** Инициалы — запасное изображение, а не пустое место. */
const initials = computed(() => draft.name
.split(/\s+/)
.slice(0, 2)
.map(part => part[0] ?? '')
.join('')
.toUpperCase())
</script>
<template>
<div class="screen">
<header>
<p class="screen__brand">{{ data.brand }}</p>
<h1 class="screen__title">{{ data.title }}</h1>
<p class="screen__subtitle">{{ data.subtitle }}</p>
<!-- Правило, отличающее правку от действия, стоит на экране. -->
<p class="screen__rule">{{ data.rule }}</p>
</header>
<GrAlert v-if="notice" tone="success" live="polite" :title="notice" />
<GrCard class="screen__panel">
<h2 class="screen__section">{{ data.identity.heading }}</h2>
<div class="screen__identity">
<!--
У изображения есть текстовая замена, а у отсутствующего — инициалы.
Пустой кружок на месте человека — не нейтральность, а потеря
единственного, чем один участник отличается от другого в списке.
-->
<GrAvatar :size="64" :alt="data.identity.avatarAlt">{{ initials }}</GrAvatar>
<div class="screen__identityText">
<p class="screen__label">{{ data.identity.avatar }}</p>
<p class="screen__hint">{{ data.identity.avatarHint }}</p>
<div class="screen__row">
<GrButton size="sm" variant="outline">{{ data.identity.replace }}</GrButton>
<GrButton size="sm" variant="ghost">{{ data.identity.remove }}</GrButton>
</div>
</div>
</div>
<GrFormField :label="data.identity.name" :hint="data.identity.nameHint">
<GrInput v-model="draft.name" />
</GrFormField>
</GrCard>
<GrCard class="screen__panel">
<h2 class="screen__section">{{ data.email.heading }}</h2>
<p class="screen__label">{{ data.email.current }}: <code>{{ currentEmail }}</code></p>
<GrAlert v-if="pendingEmail" tone="info" :title="data.email.pending" :description="pendingEmail">
<GrButton size="sm" variant="ghost" @click="pendingEmail = ''">{{ data.email.cancel }}</GrButton>
</GrAlert>
<GrFormField :label="data.email.label" :hint="data.email.hint">
<GrInput v-model="newEmail" type="email" autocomplete="email" />
</GrFormField>
<!--
Кнопка называет то, что произойдёт: уйдёт подтверждение. «Сохранить»
здесь было бы обещанием, которого экран не исполняет.
-->
<GrButton class="screen__self" variant="outline" @click="requestEmailChange">
{{ data.email.action }}
</GrButton>
</GrCard>
<GrCard class="screen__panel">
<h2 class="screen__section">{{ data.sessions.heading }}</h2>
<p class="screen__hint">{{ data.sessions.lead }}</p>
<ul class="screen__sessions">
<li v-for="session in sessions" :key="session.id">
<div>
<p class="screen__label">
{{ session.device }}
<GrBadge v-if="session.current" size="sm" tone="success">{{ data.sessions.thisDevice }}</GrBadge>
</p>
<p class="screen__hint">{{ session.place }} · {{ session.lastSeen }}</p>
</div>
<!-- Свой сеанс отсюда не закрывается: кнопка, выбрасывающая человека
из экрана, на котором он стоит, — не действие, а ловушка. -->
<GrButton v-if="!session.current" size="sm" variant="ghost" tone="danger" @click="revoke(session.id)">
{{ data.sessions.revoke }}
</GrButton>
</li>
</ul>
<GrButton
v-if="others.length"
class="screen__self"
variant="outline"
tone="danger"
@click="revoking = true"
>
{{ data.sessions.revokeAll }}
</GrButton>
</GrCard>
<div v-if="dirty" class="screen__bar" role="status">
<strong>{{ data.changed }}</strong>
<div class="screen__row">
<GrButton variant="ghost" @click="discard">{{ data.discard }}</GrButton>
<GrButton @click="save">{{ data.save }}</GrButton>
</div>
</div>
<GrConfirmDialog
v-model="revoking"
:title="data.sessions.revokeTitle"
:description="data.sessions.revokeDescription"
:confirm-text="data.sessions.revokeConfirm"
:cancel-text="data.sessions.keep"
confirm-tone="danger"
@confirm="revokeAll"
/>
</div>
</template>
<style scoped>
.screen {
display: grid;
align-content: start;
gap: var(--gr-space-4);
min-height: 100dvh;
padding: var(--gr-space-4);
background: var(--gr-bg);
color: var(--gr-fg);
}
.screen__brand {
margin: 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-sm);
}
.screen__title {
margin: var(--gr-space-1) 0 0;
font-size: var(--gr-text-xl);
font-weight: 600;
}
.screen__subtitle {
margin: var(--gr-space-1) 0 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-sm);
}
.screen__rule {
margin: var(--gr-space-2) 0 0;
max-width: 60ch;
font-size: var(--gr-text-sm);
}
.screen__panel {
display: grid;
align-content: start;
gap: var(--gr-space-3);
padding: var(--gr-space-4);
}
.screen__section {
margin: 0;
font-size: var(--gr-text-sm);
font-weight: 600;
}
.screen__identity {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--gr-space-4);
}
.screen__identityText {
display: grid;
gap: var(--gr-space-2);
}
.screen__label {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--gr-space-2);
margin: 0;
font-size: var(--gr-text-sm);
font-weight: 600;
}
.screen__hint {
margin: 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-xs);
}
.screen__row {
display: flex;
flex-wrap: wrap;
gap: var(--gr-space-2);
}
.screen__self {
justify-self: start;
}
.screen__sessions {
display: grid;
gap: var(--gr-space-3);
margin: 0;
padding: 0;
list-style: none;
}
.screen__sessions li {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--gr-space-3);
padding-block: var(--gr-space-2);
border-block-end: 1px solid var(--gr-brd);
}
.screen__bar {
position: sticky;
bottom: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--gr-space-3);
padding: var(--gr-space-3);
border: 1px solid var(--gr-brd);
border-radius: var(--gr-radius-md, 8px);
background: var(--gr-card, var(--gr-bg));
box-shadow: var(--gr-shadow-1);
}
@media (min-width: 768px) {
.screen {
padding: var(--gr-space-5);
}
}
</style>