feat: add admin telegram profile links and avatar preview
This commit is contained in:
@@ -59,10 +59,13 @@
|
||||
} from "../lib/admin/format.js";
|
||||
import {
|
||||
createGravatarCache,
|
||||
openTelegramProfileLink,
|
||||
userAvatarUrl,
|
||||
userDisplayName,
|
||||
userInitials,
|
||||
userSecondaryName,
|
||||
userTelegramProfileLink,
|
||||
userTelegramProfileLinkKind,
|
||||
} from "../lib/admin/users.js";
|
||||
|
||||
export let api;
|
||||
@@ -679,6 +682,9 @@
|
||||
{userDisplayName}
|
||||
{userSecondaryName}
|
||||
{userInitials}
|
||||
{userTelegramProfileLink}
|
||||
{userTelegramProfileLinkKind}
|
||||
{openTelegramProfileLink}
|
||||
{paymentStatusVariant}
|
||||
{trafficPercentValue}
|
||||
{trafficLeftLabel}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import {
|
||||
Copy,
|
||||
Eye,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
Send,
|
||||
Plus,
|
||||
@@ -36,6 +37,13 @@
|
||||
export let trafficOfLabel;
|
||||
export let userInitials = () => "";
|
||||
export let fmtDateShort = (v) => v;
|
||||
export let userTelegramProfileLink = () => "";
|
||||
export let userTelegramProfileLinkKind = () => "";
|
||||
export let openTelegramProfileLink = () => false;
|
||||
|
||||
let avatarPreviewOpen = false;
|
||||
let avatarPreviewUrl = "";
|
||||
let avatarPreviewName = "";
|
||||
|
||||
function pretty(val) {
|
||||
if (val === true) return at("yes", {}, "Да");
|
||||
@@ -67,9 +75,53 @@
|
||||
$: userLogsHasMore =
|
||||
Number(userLogsTotal || 0) > (Number(userLogsPage || 0) + 1) * Number(userLogsPageSize || 20);
|
||||
|
||||
$: openedUserAvatarUrl = openedUser ? resolvedAvatarUrl(openedUser) : "";
|
||||
$: openedUserTelegramProfileLink = openedUser ? userTelegramProfileLink(openedUser) : "";
|
||||
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
|
||||
$: openedUserTelegramProfileHint =
|
||||
openedUserTelegramProfileLinkKind === "id"
|
||||
? at(
|
||||
"user_open_tg_profile_id_hint",
|
||||
{},
|
||||
"Профиль будет открыт по Telegram ID. Telegram может заблокировать переход из-за настроек приватности пользователя или ограничений клиента."
|
||||
)
|
||||
: at("user_open_tg_profile_hint", {}, "Открыть профиль Telegram");
|
||||
|
||||
$: if (openedUser && userDetailTab === "logs" && !userLogsLoading && !userLogsLoaded) {
|
||||
usersStore.loadUserLogs(0);
|
||||
}
|
||||
|
||||
$: if (!openedUser) {
|
||||
avatarPreviewOpen = false;
|
||||
avatarPreviewUrl = "";
|
||||
avatarPreviewName = "";
|
||||
}
|
||||
|
||||
function openAvatarPreview() {
|
||||
if (!openedUserAvatarUrl || !openedUser) return;
|
||||
avatarPreviewUrl = openedUserAvatarUrl;
|
||||
avatarPreviewName = userDisplayName(openedUser);
|
||||
avatarPreviewOpen = true;
|
||||
}
|
||||
|
||||
function closeAvatarPreview() {
|
||||
avatarPreviewOpen = false;
|
||||
}
|
||||
|
||||
function openUserTelegramProfile() {
|
||||
if (!openedUserTelegramProfileLink) {
|
||||
usersStore.copyToClipboard(
|
||||
String(openedUser?.telegram_id || ""),
|
||||
at("user_tg_profile_unavailable", {}, "Ссылка на профиль Telegram недоступна")
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (openedUserTelegramProfileLinkKind === "id") {
|
||||
usersStore.sendTelegramProfileLink();
|
||||
return;
|
||||
}
|
||||
openTelegramProfileLink(openedUserTelegramProfileLink);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog
|
||||
@@ -89,18 +141,21 @@
|
||||
<div class="admin-user-dialog-body">
|
||||
<aside class="admin-user-aside">
|
||||
<div class="admin-user-summary">
|
||||
<span class="admin-avatar admin-avatar-lg">
|
||||
{#if resolvedAvatarUrl(openedUser)}
|
||||
<img
|
||||
src={resolvedAvatarUrl(openedUser)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-avatar admin-avatar-lg admin-avatar-preview-trigger"
|
||||
class:is-clickable={Boolean(openedUserAvatarUrl)}
|
||||
disabled={!openedUserAvatarUrl}
|
||||
onclick={openAvatarPreview}
|
||||
aria-label={at("user_avatar_open", {}, "Открыть аватар")}
|
||||
title={openedUserAvatarUrl ? at("user_avatar_open", {}, "Открыть аватар") : ""}
|
||||
>
|
||||
{#if openedUserAvatarUrl}
|
||||
<img src={openedUserAvatarUrl} alt="" loading="lazy" referrerpolicy="no-referrer" />
|
||||
{:else}
|
||||
<span>{userInitials(openedUser)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
<div class="admin-user-summary-meta">
|
||||
<strong>{userDisplayName(openedUser)}</strong>
|
||||
<small>{userSecondaryName(openedUser)}</small>
|
||||
@@ -120,6 +175,24 @@
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="admin-user-summary-actions">
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onclick={openUserTelegramProfile}
|
||||
disabled={!openedUserTelegramProfileLink}
|
||||
title={openedUserTelegramProfileHint}
|
||||
aria-label={at("user_open_tg_profile", {}, "Открыть профиль Telegram")}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{at("user_open_tg_profile", {}, "Открыть Telegram")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
{#if openedUserTelegramProfileLinkKind === "id"}
|
||||
<small class="admin-user-telegram-profile-note"
|
||||
>{openedUserTelegramProfileHint}</small
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -871,6 +944,25 @@
|
||||
{/if}
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={avatarPreviewOpen}
|
||||
title={avatarPreviewName || at("user_avatar_title", {}, "Аватар")}
|
||||
closeLabel={at("close", {}, "Закрыть")}
|
||||
onclose={closeAvatarPreview}
|
||||
class="admin-dialog admin-avatar-dialog"
|
||||
>
|
||||
{#if avatarPreviewUrl}
|
||||
<div class="admin-avatar-preview">
|
||||
<img
|
||||
src={avatarPreviewUrl}
|
||||
alt={avatarPreviewName}
|
||||
loading="eager"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={userMessageConfirmOpen}
|
||||
title={at("user_msg_confirm_title", {}, "Отправить сообщение пользователю?")}
|
||||
@@ -1054,6 +1146,78 @@
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
.admin-avatar-preview-trigger {
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
}
|
||||
.admin-avatar-preview-trigger img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.admin-avatar-preview-trigger:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.admin-avatar-preview-trigger.is-clickable {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.admin-avatar-preview-trigger.is-clickable:hover,
|
||||
.admin-avatar-preview-trigger.is-clickable:focus-visible {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
.admin-user-summary-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.admin-user-telegram-profile-note {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--admin-dim);
|
||||
line-height: 1.35;
|
||||
}
|
||||
:global(.admin-avatar-dialog) {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
width: min(920px, calc(100vw - 28px));
|
||||
height: min(820px, calc(100dvh - 28px));
|
||||
max-height: calc(100dvh - 28px);
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.admin-avatar-preview {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.admin-avatar-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--admin-border);
|
||||
background: var(--admin-surface-2);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
:global(.dialog:has(.admin-avatar-dialog)) {
|
||||
padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right))
|
||||
max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left));
|
||||
}
|
||||
:global(.admin-avatar-dialog) {
|
||||
width: calc(100vw - 16px);
|
||||
height: min(88dvh, calc(100dvh - 16px));
|
||||
max-height: calc(100dvh - 16px);
|
||||
border-radius: 18px;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
:global(.admin-user-dialog .admin-user-logs-tab) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -319,6 +319,28 @@ export function createUsersStore({ api, onToast, at }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTelegramProfileLink() {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
if (!s.openedUser) return;
|
||||
state.update((st) => ({ ...st, userActionBusy: true }));
|
||||
try {
|
||||
const res = await api(`/admin/users/${s.openedUser.user_id}/telegram-profile-link`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (res?.ok) {
|
||||
onToast(at("user_tg_profile_link_sent", {}, "Ссылка отправлена в Telegram"));
|
||||
} else {
|
||||
onToast(res?.error || at("user_tg_profile_link_failed", {}, "Не удалось отправить ссылку"));
|
||||
}
|
||||
} finally {
|
||||
state.update((st) => ({ ...st, userActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function extendUser() {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
@@ -510,6 +532,7 @@ export function createUsersStore({ api, onToast, at }) {
|
||||
sendUserMessage,
|
||||
requestSendUserMessage,
|
||||
previewUserMessage,
|
||||
sendTelegramProfileLink,
|
||||
extendUser,
|
||||
resetTrialUser,
|
||||
deleteUser,
|
||||
|
||||
@@ -25,6 +25,50 @@ export function userAvatarUrl(user) {
|
||||
return value && !value.startsWith("/api/account/avatar") ? value : "";
|
||||
}
|
||||
|
||||
export function userTelegramProfileLink(user) {
|
||||
const username = String(user?.username || "")
|
||||
.trim()
|
||||
.replace(/^@+/, "");
|
||||
if (username) return `https://t.me/${encodeURIComponent(username)}`;
|
||||
|
||||
const telegramId = Number(user?.telegram_id);
|
||||
if (Number.isFinite(telegramId) && telegramId > 0) {
|
||||
return `tg://user?id=${encodeURIComponent(String(Math.trunc(telegramId)))}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function userTelegramProfileLinkKind(user) {
|
||||
return String(user?.username || "").trim()
|
||||
? "username"
|
||||
: userTelegramProfileLink(user)
|
||||
? "id"
|
||||
: "";
|
||||
}
|
||||
|
||||
export function openTelegramProfileLink(link) {
|
||||
if (!link || typeof window === "undefined") return false;
|
||||
|
||||
const tg = window.Telegram?.WebApp;
|
||||
if (/^https:\/\/(?:t|telegram)\.me\//i.test(link) && typeof tg?.openTelegramLink === "function") {
|
||||
try {
|
||||
tg.openTelegramLink(link);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall through to the normal browser/deep-link handling below.
|
||||
}
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(link)) {
|
||||
window.open(link, "_blank", "noopener,noreferrer");
|
||||
return true;
|
||||
}
|
||||
|
||||
window.location.href = link;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createGravatarCache(onResolved = () => {}) {
|
||||
const cache = new Map();
|
||||
const pending = new Map();
|
||||
|
||||
Reference in New Issue
Block a user