feat(frontend): main menu lists accounts with their avatars, switching opens the last chat of that account
This commit is contained in:
1 parent
3460246749
commit
a1d3552519
10 files changed
+338
-55
No files matched your search
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu } from "bits-ui";
|
||||
import AccountAvatar from "#lib/components/settings/AccountAvatar.svelte";
|
||||
import AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte";
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import { accountName } from "#lib/format/peer.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { theme } from "#lib/stores/theme.svelte.js";
|
||||
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||
|
||||
interface Entry {
|
||||
icon: string;
|
||||
label: MessageKey;
|
||||
panel: RightPanel;
|
||||
}
|
||||
|
||||
const DIGIT = /^Digit([1-9])$/;
|
||||
|
||||
const entries: Entry[] = [
|
||||
{ icon: "eye", label: "settings.watches", panel: "watches" },
|
||||
{ icon: "unmute", label: "settings.alerts", panel: "alerts" },
|
||||
{ icon: "data", label: "settings.storage", panel: "storage" },
|
||||
{ icon: "play-story", label: "settings.stories", panel: "stories-all" },
|
||||
{ icon: "cloud-download", label: "settings.jobs", panel: "jobs" },
|
||||
{ icon: "permissions", label: "settings.policy", panel: "policy" },
|
||||
];
|
||||
|
||||
let adding = $state(false);
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
const match = DIGIT.exec(event.code);
|
||||
if (!(event.altKey && match)) {
|
||||
return;
|
||||
}
|
||||
const account = accounts.list[Number(match[1]) - 1];
|
||||
if (account) {
|
||||
event.preventDefault();
|
||||
accounts.switchTo(account.account_id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({
|
||||
props,
|
||||
})}
|
||||
<Button
|
||||
{...props}
|
||||
aria-label={t("menu.open")}
|
||||
round
|
||||
smaller
|
||||
variant="translucent"
|
||||
>
|
||||
<Icon name="menu" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
align="start"
|
||||
class="bg-menu-content main-menu"
|
||||
sideOffset={6}
|
||||
>
|
||||
{#each accounts.list as account (account.account_id)}
|
||||
<DropdownMenu.Item
|
||||
class="bg-menu-item account-item"
|
||||
onSelect={() => accounts.switchTo(account.account_id)}
|
||||
>
|
||||
<AccountAvatar {account} size={1.75} />
|
||||
<span class="account-name">{accountName(account)}</span>
|
||||
{#if !account.is_active}
|
||||
<span class="account-tag">{t("menu.archived")}</span>
|
||||
{/if}
|
||||
{#if account.account_id === accounts.selectedId}
|
||||
<Icon class="account-check" name="check" size="1.125rem" />
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
<DropdownMenu.Item
|
||||
class="bg-menu-item"
|
||||
onSelect={() => {
|
||||
adding = true;
|
||||
}}
|
||||
>
|
||||
<Icon name="add" size="1.125rem" />
|
||||
{t("menu.addAccount")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator class="bg-menu-separator" />
|
||||
<DropdownMenu.Item
|
||||
class="bg-menu-item"
|
||||
onSelect={() => ui.openSettings()}
|
||||
>
|
||||
<Icon name="settings" size="1.125rem" />
|
||||
{t("settings.title")}
|
||||
</DropdownMenu.Item>
|
||||
{#each entries as entry (entry.panel)}
|
||||
<DropdownMenu.Item
|
||||
class="bg-menu-item"
|
||||
onSelect={() => ui.openPanel(entry.panel)}
|
||||
>
|
||||
<Icon name={entry.icon} size="1.125rem" />
|
||||
{t(entry.label)}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
<DropdownMenu.Separator class="bg-menu-separator" />
|
||||
<DropdownMenu.Item
|
||||
class="bg-menu-item"
|
||||
closeOnSelect={false}
|
||||
onSelect={() => theme.toggle()}
|
||||
>
|
||||
<Icon name="darkmode" size="1.125rem" />
|
||||
<span class="account-name">{t("menu.nightMode")}</span>
|
||||
{#if theme.resolved === "dark"}
|
||||
<Icon class="account-check" name="check" size="1.125rem" />
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<AddAccountDialog bind:open={adding} />
|
||||
|
||||
<style lang="scss">
|
||||
:global(.main-menu) {
|
||||
width: 17rem;
|
||||
max-height: calc(100dvh - 5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:global(.main-menu .account-item) {
|
||||
padding-block: 0.375rem;
|
||||
}
|
||||
|
||||
.account-name {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-tag {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
:global(.main-menu .account-check) {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { Account } from "#lib/api/types.js";
|
||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||
import { accountName } from "#lib/format/peer.js";
|
||||
|
||||
interface Props {
|
||||
account: Account;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { account, size }: Props = $props();
|
||||
|
||||
const avatar = $derived(
|
||||
account.tg_user_id === null
|
||||
? null
|
||||
: {
|
||||
account: account.account_id,
|
||||
id: account.tg_user_id,
|
||||
kind: "peer" as const,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<Avatar
|
||||
{avatar}
|
||||
colorKey={account.account_id}
|
||||
hasAvatar={avatar !== null}
|
||||
name={accountName(account)}
|
||||
{size}
|
||||
/>
|
||||
@@ -2,14 +2,14 @@
|
||||
import { Dialog } from "bits-ui";
|
||||
import { ripple } from "#lib/actions/ripple.js";
|
||||
import { ApiError } from "#lib/api/client.js";
|
||||
import AccountAvatar from "#lib/components/settings/AccountAvatar.svelte";
|
||||
import AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte";
|
||||
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
|
||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import { accountName } from "#lib/format/peer.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||
|
||||
const current = $derived(accounts.selected);
|
||||
const others = $derived(
|
||||
@@ -26,11 +26,13 @@
|
||||
let renaming = $state(false);
|
||||
let deviceName = $state("");
|
||||
let busy = $state(false);
|
||||
let failure = $state<string | null>(null);
|
||||
|
||||
const deviceLabel = $derived(current?.device_model ?? DEFAULT_DEVICE);
|
||||
|
||||
function openRename() {
|
||||
deviceName = deviceLabel;
|
||||
failure = null;
|
||||
renaming = true;
|
||||
}
|
||||
|
||||
@@ -41,14 +43,13 @@
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
failure = null;
|
||||
try {
|
||||
await accounts.renameDevice(current.account_id, value);
|
||||
renaming = false;
|
||||
toasts.success("Имя устройства обновлено");
|
||||
} catch (error) {
|
||||
toasts.error(
|
||||
error instanceof ApiError ? error.detail : "Не удалось переименовать"
|
||||
);
|
||||
failure =
|
||||
error instanceof ApiError ? error.detail : t("account.renameFailed");
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -59,12 +60,12 @@
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
failure = null;
|
||||
try {
|
||||
await accounts.logout(current.account_id);
|
||||
confirming = false;
|
||||
toasts.success("Сессия завершена");
|
||||
} catch {
|
||||
toasts.error("Не удалось выйти из аккаунта");
|
||||
failure = t("account.logoutFailed");
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -74,17 +75,13 @@
|
||||
<div class="my-account">
|
||||
{#if current}
|
||||
<div class="profile">
|
||||
<Avatar
|
||||
colorKey={current.account_id}
|
||||
name={accountName(current)}
|
||||
size={5}
|
||||
/>
|
||||
<AccountAvatar account={current} size={5} />
|
||||
<div class="name">{accountName(current)}</div>
|
||||
{#if current.phone}
|
||||
<div class="phone">+{current.phone}</div>
|
||||
{/if}
|
||||
{#if !current.is_active}
|
||||
<div class="inactive">Сессия завершена, доступен только архив</div>
|
||||
<div class="inactive">{t("account.inactive")}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -94,18 +91,14 @@
|
||||
{#each others as account (account.account_id)}
|
||||
<button
|
||||
class="switch-row"
|
||||
onclick={() => accounts.select(account.account_id)}
|
||||
onclick={() => accounts.switchTo(account.account_id)}
|
||||
type="button"
|
||||
use:ripple
|
||||
>
|
||||
<Avatar
|
||||
colorKey={account.account_id}
|
||||
name={accountName(account)}
|
||||
size={2.25}
|
||||
/>
|
||||
<AccountAvatar {account} size={2.25} />
|
||||
<span>{accountName(account)}</span>
|
||||
{#if !account.is_active}
|
||||
<span class="tag">архив</span>
|
||||
<span class="tag">{t("menu.archived")}</span>
|
||||
{/if}
|
||||
<Icon class="chevron" name="arrow-right" size="1rem" />
|
||||
</button>
|
||||
@@ -117,14 +110,14 @@
|
||||
{#if current?.is_active}
|
||||
<SettingsItem
|
||||
icon="active-sessions"
|
||||
label="Имя устройства"
|
||||
label={t("account.device")}
|
||||
onclick={openRename}
|
||||
value={deviceLabel}
|
||||
/>
|
||||
{/if}
|
||||
<SettingsItem
|
||||
icon="add-user"
|
||||
label="Добавить аккаунт"
|
||||
label={t("menu.addAccount")}
|
||||
onclick={() => {
|
||||
adding = true;
|
||||
}}
|
||||
@@ -132,8 +125,9 @@
|
||||
{#if current?.is_active}
|
||||
<SettingsItem
|
||||
icon="logout"
|
||||
label="Выйти из аккаунта"
|
||||
label={t("account.logout")}
|
||||
onclick={() => {
|
||||
failure = null;
|
||||
confirming = true;
|
||||
}}
|
||||
/>
|
||||
@@ -148,18 +142,14 @@
|
||||
<Dialog.Overlay class="dialog-overlay" />
|
||||
<Dialog.Content class="dialog-content">
|
||||
<header class="dialog-head">
|
||||
<Dialog.Title class="dialog-title">Имя устройства</Dialog.Title>
|
||||
<Dialog.Close aria-label="Закрыть" class="dialog-close">
|
||||
<Dialog.Title class="dialog-title">{t("account.device")}</Dialog.Title>
|
||||
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
|
||||
<Icon name="close" size="1.25rem" />
|
||||
</Dialog.Close>
|
||||
</header>
|
||||
<form onsubmit={rename}>
|
||||
<div class="dialog-body">
|
||||
<p class="confirm-text">
|
||||
Так сессия называется в списке устройств Telegram. Чтобы имя
|
||||
применилось, соединение переподключится — сбор сообщений прервётся
|
||||
на пару секунд.
|
||||
</p>
|
||||
<p class="confirm-text">{t("account.deviceHint")}</p>
|
||||
<div class="input-group">
|
||||
<input
|
||||
class="form-control"
|
||||
@@ -169,8 +159,11 @@
|
||||
type="text"
|
||||
bind:value={deviceName}
|
||||
>
|
||||
<label for="device-name">Название</label>
|
||||
<label for="device-name">{t("account.deviceName")}</label>
|
||||
</div>
|
||||
{#if failure}
|
||||
<p class="failure">{failure}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<Button
|
||||
@@ -179,7 +172,7 @@
|
||||
pill
|
||||
type="submit"
|
||||
>
|
||||
Сохранить
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -192,17 +185,22 @@
|
||||
<Dialog.Overlay class="dialog-overlay" />
|
||||
<Dialog.Content class="dialog-content">
|
||||
<header class="dialog-head">
|
||||
<Dialog.Title class="dialog-title">Выйти из аккаунта?</Dialog.Title>
|
||||
<Dialog.Close aria-label="Закрыть" class="dialog-close">
|
||||
<Dialog.Title class="dialog-title">
|
||||
{t("account.logoutTitle")}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
|
||||
<Icon name="close" size="1.25rem" />
|
||||
</Dialog.Close>
|
||||
</header>
|
||||
<div class="dialog-body">
|
||||
<p class="confirm-text">
|
||||
Сессия {current ? accountName(current) : ""} будет завершена в
|
||||
Telegram, новые сообщения перестанут собираться. Уже собранный архив
|
||||
останется доступным.
|
||||
{t("account.logoutText", {
|
||||
name: current ? accountName(current) : "",
|
||||
})}
|
||||
</p>
|
||||
{#if failure}
|
||||
<p class="failure">{failure}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<Button
|
||||
@@ -212,10 +210,10 @@
|
||||
pill
|
||||
variant="text"
|
||||
>
|
||||
Отмена
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button loading={busy} onclick={logout} pill variant="danger">
|
||||
Выйти
|
||||
{t("account.logoutAction")}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
@@ -292,6 +290,12 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.failure {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
:global(.switch-row .chevron) {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-icon-secondary);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { initials as toInitials } from "#lib/format/peer.js";
|
||||
|
||||
interface Props {
|
||||
avatar?: { kind: AvatarKind; id: number } | null;
|
||||
avatar?: { account?: number; kind: AvatarKind; id: number } | null;
|
||||
colorKey?: number;
|
||||
deleted?: boolean;
|
||||
hasAvatar?: boolean;
|
||||
@@ -41,7 +41,7 @@
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
loadAvatar(avatar.kind, avatar.id).then((resolved) => {
|
||||
loadAvatar(avatar.kind, avatar.id, avatar.account).then((resolved) => {
|
||||
if (active) {
|
||||
url = resolved;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user