updated admin panel style

This commit is contained in:
onysd 2026-07-22 01:16:29 +03:00
parent 9844d78c5b
commit deae6c4a4a
26 changed files with 493 additions and 1356 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -3,9 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title>
<script type="module" crossorigin src="/assets/index-ybbM_ULl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BVA68zgn.css">
<link rel="icon" type="image/png" href="/logo.png" />
<title>OwpenGram Admin</title>
<script type="module" crossorigin src="/assets/index-CpvYOqis.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Da0KbwLb.css">
</head>
<body>
<div id="root"></div>

BIN
cmd/telesrv-admin/web/dist/logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title>
<link rel="icon" type="image/png" href="/logo.png" />
<title>OwpenGram Admin</title>
</head>
<body>
<div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -12,7 +12,7 @@ import {
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
import { LanguageSwitch, useI18n } from "../i18n";
import { useI18n } from "../i18n";
import { type Navigate, type RouteState, routeSubtitle, routeTitle } from "../routing";
import { AppLink } from "./AppLink";
@ -21,9 +21,9 @@ export function BootScreen() {
return (
<div className="boot-screen">
<div className="brand compact brand-elevated">
<span className="brand-mark">T</span>
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
<span>
<strong>telesrv</strong>
<strong>OwpenGram</strong>
<small>{t("app.adminConsole")}</small>
</span>
</div>
@ -64,9 +64,9 @@ export function Shell({
<div className="shell">
<aside className="sidebar">
<AppLink className="brand" href="/" navigate={navigate}>
<span className="brand-mark">T</span>
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
<span>
<strong>telesrv</strong>
<strong>OwpenGram</strong>
<small>{t("app.adminConsole")}</small>
</span>
</AppLink>
@ -123,7 +123,6 @@ export function Shell({
<h1>{routeTitle(route.path, t)}</h1>
</div>
<div className="topbar-actions">
<LanguageSwitch />
<span className="actor-pill">{t("layout.actor", { actor })}</span>
<button className="btn ghost icon-text" type="button" onClick={logout} title={t("layout.logout")}>
<LogOut size={16} /> {t("layout.logout")}

View file

@ -1,16 +1,12 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { createContext, useContext, useEffect, useMemo, type ReactNode } from "react";
export type Language = "en" | "zh" | "ru";
export type TranslationParams = Record<string, string | number | boolean>;
export type TFunction = (key: string, params?: TranslationParams) => string;
const storageKey = "telesrv.admin.lang";
const translations: Record<Language, Record<string, string>> = {
en: {
const translations: Record<string, string> = {
"app.adminConsole": "Admin Console",
"app.localAccess": "Local access",
"app.title": "telesrv admin",
"app.title": "OwpenGram Admin",
"common.actions": "Actions",
"common.admins": "Admins",
"common.backToList": "Back to list",
@ -81,9 +77,6 @@ const translations: Record<Language, Record<string, string>> = {
"layout.dryRun": "Dry-run",
"layout.actor": "Actor: {actor}",
"layout.logout": "Log out",
"language.en": "EN",
"language.zh": "中文",
"language.ru": "RU",
"login.heading": "Operations Admin",
"login.body": "Enter credentials to open the console.",
"login.secret": "Admin password or token",
@ -383,793 +376,26 @@ const translations: Record<Language, Record<string, string>> = {
"audit.dryRun": "Dry-run",
"audit.reason": "Reason",
"audit.time": "Time"
},
zh: {
"app.adminConsole": "管理控制台",
"app.localAccess": "本地访问",
"app.title": "telesrv 管理后台",
"common.actions": "操作",
"common.admins": "管理员",
"common.backToList": "返回列表",
"common.channel": "频道",
"common.channelOrGroup": "频道/群",
"common.clear": "清除",
"common.close": "关闭",
"common.count": "数量",
"common.deleted": "已删除",
"common.detail": "详情",
"common.device": "设备",
"common.disabled": "已禁用",
"common.enabled": "已启用",
"common.fromPeer": "From Peer",
"common.group": "群组",
"common.id": "ID",
"common.limit": "条数",
"common.loading": "加载中",
"common.member": "成员",
"common.members": "成员",
"common.messageId": "消息 ID",
"common.name": "姓名",
"common.no": "否",
"common.noResults": "无结果",
"common.none": "无",
"common.normal": "正常",
"common.operations": "操作",
"common.owner": "所属",
"common.platform": "平台",
"common.refresh": "刷新",
"common.search": "查询",
"common.sender": "发送方",
"common.status": "状态",
"common.survived": "存活",
"common.time": "时间",
"common.type": "类型",
"common.updatedAt": "更新时间",
"common.username": "用户名",
"common.valid": "有效",
"common.verified": "已认证",
"common.views": "浏览",
"common.yes": "是",
"route.accounts": "账号管理",
"route.accountsSubtitle": "控制台 / 账号",
"route.channels": "超级群与频道",
"route.channelsSubtitle": "控制台 / 频道",
"route.dashboard": "运维控制台",
"route.dashboardSubtitle": "控制台 / 总览",
"route.messages": "消息审计",
"route.messagesSubtitle": "控制台 / 消息",
"route.gifts": "星星礼物",
"route.giftsSubtitle": "控制台 / 星星礼物",
"layout.navigation": "导航",
"layout.primaryNav": "主导航",
"layout.dashboard": "总览",
"layout.accounts": "账号",
"layout.channels": "超级群/频道",
"layout.messages": "消息",
"layout.gifts": "礼物目录",
"layout.privateMessages": "私聊",
"layout.groupMessages": "群聊",
"layout.runtime": "运行状态",
"layout.adminBackend": "管理后台",
"layout.ready": "就绪",
"layout.pgRead": "PG 读取",
"layout.readOnly": "只读",
"layout.writeOps": "写操作",
"layout.dryRun": "预演",
"layout.actor": "操作者:{actor}",
"layout.logout": "退出",
"language.en": "EN",
"language.zh": "中文",
"language.ru": "RU",
"login.heading": "运维后台",
"login.body": "输入凭据后进入控制台。",
"login.secret": "管理员密码或 token",
"login.submit": "登录",
"login.submitting": "登录中",
"dashboard.eyebrow": "运行总览",
"dashboard.title": "控制台总览",
"dashboard.readPath": "读路径",
"dashboard.readPathValue": "PG 只读",
"dashboard.writePath": "写路径",
"dashboard.executionPolicy": "执行策略",
"dashboard.dryRunFirst": "先预演",
"dashboard.accountsText": "账号状态、会员、认证、会话。",
"dashboard.channelsText": "公开实体、成员计数、认证状态。",
"dashboard.messagesText": "消息盒、update、outbox 状态。",
"dashboard.strip.dryRun": "所有危险操作先预演",
"dashboard.strip.token": "浏览器不持有内部 token",
"dashboard.strip.pagination": "列表使用游标分页",
"dashboard.strip.snapshot": "详情页保留原始状态快照",
"account.pageTitle": "账号",
"account.queryResults": "查询结果",
"account.recentActive": "最近活跃账号",
"account.currentPage": "当前页账号",
"account.onlineDevices": "在线设备记录",
"account.premium": "会员",
"account.frozen": "冻结",
"account.searchPlaceholder": "用户 ID / 手机号 / 用户名",
"account.userID": "用户 ID",
"account.phone": "手机号",
"account.lastActive": "最近活跃",
"account.notVerified": "未认证",
"account.notPremium": "非会员",
"account.premiumUntil": "会员到期",
"account.starsBalance": "Stars 余额",
"account.startingGrantApplied": "初始赠送已发放",
"account.startingGrantPending": "初始赠送未触发",
"account.activeSessions": "授权设备",
"account.accountFlags": "账号标记",
"account.restriction": "限制状态",
"account.restricted": "已限制",
"account.createdAt": "创建时间",
"account.detailTitle": "账号 #{id}",
"account.profile": "账号档案",
"account.loadingDetail": "加载账号详情",
"account.waitingData": "等待数据",
"account.noUsername": "无用户名",
"account.noPhone": "无手机号",
"account.accountFrozen": "账号已冻结",
"account.accountActive": "账号正常",
"account.authorizationsTitle": "授权设备",
"account.authorizationsCount": "共 {count} 个授权",
"account.recentAdminOps": "最近后台操作",
"account.recent30Audit": "最近 30 条审计",
"account.actionDock": "账号操作",
"account.freezeAccount": "冻结账号",
"account.updateFreeze": "更新冻结信息",
"account.unfreezeAccount": "解冻账号",
"account.freezeSince": "冻结开始时间",
"account.freezeUntil": "申诉截止时间",
"account.freezeUntilAria": "账号冻结申诉截止时间",
"account.freezeAppealURL": "申诉链接",
"account.freezeAppealURLAria": "账号冻结申诉链接",
"account.premiumMonths": "会员时长(月)",
"account.premiumMonthsAria": "设置会员时长,单位月",
"account.setPremium": "设置会员",
"account.clearPremium": "取消会员",
"account.starsAmount": "赠送 Stars 数量",
"account.starsAmountAria": "设置要赠送的 Stars 数量",
"account.grantStars": "赠送 Stars",
"account.setVerified": "设置认证",
"account.clearVerified": "取消认证",
"channel.pageTitle": "超级群与频道",
"channel.recentUpdated": "最近更新",
"channel.currentPage": "当前页实体",
"channel.megagroups": "超级群",
"channel.broadcasts": "频道",
"channel.verifiedCount": "已认证",
"channel.searchPlaceholder": "频道 ID / 用户名 / 标题",
"channel.channelID": "频道 ID",
"channel.kind": "类型",
"channel.title": "标题",
"channel.pts": "PTS",
"channel.detailProfile": "频道档案",
"channel.loadingDetail": "加载频道详情",
"channel.creator": "创建者 {id}",
"channel.governance": "治理状态",
"channel.governanceValue": "封禁 {banned} / 踢出 {kicked}",
"channel.flags": "频道标记",
"channel.rawRow": "频道原始行",
"channel.rawRowText": "数据库只读快照",
"channel.actionDock": "频道操作",
"channel.setVerified": "设置认证",
"channel.clearVerified": "取消认证",
"channel.kind.broadcast": "频道",
"channel.kind.forum": "超级群/论坛",
"channel.kind.megagroup": "超级群",
"channel.kind.generic": "频道/群",
"messages.privateTitle": "私聊消息",
"messages.privateEyebrow": "私聊消息盒",
"messages.groupTitle": "群聊消息",
"messages.groupEyebrow": "超级群 / 频道消息",
"messages.selectPrivatePeers": "请先搜索并选择所属用户和对端用户",
"messages.selectChannel": "请先搜索并选择超级群或频道",
"messages.ownerUser": "所属用户",
"messages.peerUser": "对端用户",
"messages.beforeDatePlaceholder": "before_date 游标",
"messages.beforeIDPlaceholder": "before_msg_id 游标",
"messages.limitPlaceholder": "条数 <= 100",
"messages.searchMessages": "查询消息",
"messages.nextPage": "下一页",
"messages.currentPage": "当前页消息",
"messages.deleted": "已删除",
"messages.outgoing": "发出消息",
"messages.incoming": "收到",
"messages.ownerPeer": "所属 / 对端",
"messages.deleteSelected": "删除指定消息",
"messages.idsPlaceholder": "消息 ID,逗号分隔",
"messages.revoke": "同步撤回",
"messages.previewDelete": "预演删除",
"messages.clearHistory": "清空私聊历史",
"messages.maxIDPlaceholder": "max_id 截止消息",
"messages.maxBatchesPlaceholder": "max_batches 批次数",
"messages.justClear": "仅清本侧",
"messages.previewClearHistory": "预演清历史",
"messages.direction": "方向",
"messages.body": "正文",
"messages.privateDetailTitle": "消息 #{id}",
"messages.detailEyebrow": "消息详情",
"messages.backPrivate": "返回私聊消息",
"messages.backGroup": "返回群聊消息",
"messages.ownerPeerTitle": "所属 {owner} · 对端 {peer}",
"messages.senderSubtitle": "发送方 {sender} · {date}",
"messages.boxID": "消息盒 ID",
"messages.privateMessageID": "私聊消息 ID",
"messages.messageSender": "发送方",
"messages.messageBox": "消息盒",
"messages.dialogRow": "会话行",
"messages.privateRow": "私聊消息行",
"messages.channelMessageRow": "消息行",
"messages.channelRow": "频道行",
"messages.userUpdateEvents": "更新事件",
"messages.channelUpdateEvents": "频道更新事件",
"messages.eventJson": "事件 JSON",
"messages.dispatchOutbox": "分发队列",
"messages.messageBoxesSnapshot": "message_boxes 只读快照",
"messages.dialogSnapshot": "dialogs 只读快照",
"messages.privateSnapshot": "private_messages 只读快照",
"messages.channelMessagesSnapshot": "channel_messages 只读快照",
"messages.channelSnapshot": "channels 只读快照",
"messages.userEventsSource": "durable user_update_events",
"messages.channelEventsSource": "durable channel_update_events",
"messages.outboxSource": "在线/离线 dispatch_outbox",
"messages.attempts": "尝试",
"messages.deleteThis": "删除此消息",
"messages.groupDetailTitle": "群聊消息 #{id}",
"messages.channelGroupTitle": "频道/群 {id}",
"messages.mediaCount": "有媒体",
"messages.channelPosts": "频道帖子",
"messages.channelGroup": "频道 / 群",
"messages.pinned": "置顶",
"messages.channelPost": "频道帖子",
"gifts.pageTitle": "星星礼物目录",
"gifts.eyebrow": "目录、不可变版本与动画资源",
"gifts.total": "目录条目",
"gifts.enabled": "已启用",
"gifts.received": "已领取礼物",
"gifts.formats": "支持格式",
"gifts.add": "添加礼物",
"gifts.importAll": "导入全部官方礼物",
"gifts.searchPlaceholder": "搜索礼物 ID、标题或格式",
"gifts.listSummary": "显示 {shown} / {total} 项",
"gifts.idRevision": "ID / 版本",
"gifts.price": "售价 / 兑换",
"gifts.importTitle": "导入星星礼物",
"gifts.importEyebrow": "礼物目录操作",
"gifts.newRevision": "为礼物 #{id} 创建新版本",
"gifts.importHint": "支持 TGS 或纯 Lottie JSON;Lottie 会规范化并压缩成 TGS。",
"gifts.officialSource": "官方资源库",
"gifts.fileSource": "上传文件",
"gifts.officialHint": "从 data/official-gifts 的已校验快照中选择;完整 collectible 属性池会与礼物原子导入。",
"gifts.officialSearch": "搜索官方礼物 ID 或标题",
"gifts.officialSelect": "请选择官方礼物",
"gifts.officialRequired": "请先选择一个官方礼物",
"gifts.officialResults": "显示 {shown} / {total} 项",
"gifts.officialCategoryLabel": "官方礼物能力分类",
"gifts.officialCategory.all": "全部",
"gifts.officialCategory.upgrade": "可升级",
"gifts.officialCategory.craft": "可 Craft",
"gifts.officialCategory.basic": "不可升级",
"gifts.officialUnnamed": "未命名官方礼物 #{id}",
"gifts.officialAttributes": "{count} 个属性",
"gifts.canUpgrade": "可升级",
"gifts.cannotUpgrade": "不可升级",
"gifts.canCraft": "可 Craft",
"gifts.cannotCraft": "不可 Craft",
"gifts.officialEmpty": "当前分类和搜索条件下没有官方礼物。",
"gifts.includeCollectible": "完整导入 collectible 属性池(包含 crafted 模型)",
"gifts.animation": "动画文件",
"gifts.filePrompt": "拖放或选择 TGS / Lottie 文件",
"gifts.fileHint": "支持 TGS、JSON、Lottie,导入前会先进行校验",
"gifts.chooseFile": "选择文件",
"gifts.changeFile": "更换文件",
"gifts.title": "显示标题",
"gifts.titlePlaceholder": "例如:庆典星星",
"gifts.stars": "售价 Stars",
"gifts.convertStars": "可兑换 Stars",
"gifts.sortOrder": "排序值",
"gifts.reason": "审计原因",
"gifts.reasonPlaceholder": "简要说明本次导入礼物的原因",
"gifts.enableAfterImport": "导入后启用",
"gifts.validate": "Dry-run 校验",
"gifts.confirmImport": "确认导入",
"gifts.stepDetails": "文件与信息",
"gifts.stepValidate": "Dry-run 校验",
"gifts.stepImport": "确认导入",
"gifts.fileRequired": "请先选择 TGS 或 Lottie 文件",
"gifts.source": "来源",
"gifts.replace": "创建新版本",
"gifts.disable": "停用",
"gifts.enable": "启用",
"gifts.bulkSelected": "已选择 {count} 个",
"gifts.bulkSelectAll": "选择所有可见礼物",
"gifts.bulkSelectOne": "选择礼物 {id}",
"gifts.bulkEnable": "启用所选",
"gifts.bulkDisable": "停用所选",
"gifts.bulkStatusFailed": "{total} 个中有 {failed} 个失败",
"gifts.empty": "尚未导入星星礼物。",
"gifts.emptyHint": "从上方导入第一个动画,开始搭建礼物目录。",
"gifts.validationReady": "校验已通过",
"gifts.validationHint": "确认规范化后的元数据无误,再执行正式导入。",
"gifts.confirmState": "确认执行礼物 #{id} 的状态变更吗?",
"collectibles.manage": "属性池",
"collectibles.title": "Collectibles 属性池 · 礼物 #{id}",
"collectibles.eyebrow": "唯一礼物属性管理",
"collectibles.activeRevision": "已发布版本 {revision}",
"collectibles.published": "已发布",
"collectibles.noPool": "尚未发布 Collectibles 属性池",
"collectibles.noPoolHint": "发布模型、图案与背景后,客户端即可升级为唯一礼物。",
"collectibles.publishNew": "发布新的不可变版本",
"collectibles.immutableHint": "Dry-run 会校验全部文件和稀有度总和,通过后才切换为当前版本。",
"collectibles.upgradeStars": "升级价格 Stars",
"collectibles.supply": "唯一礼物总量",
"collectibles.slug": "公开 Slug 前缀",
"collectibles.models": "模型",
"collectibles.patterns": "图案",
"collectibles.backdrops": "背景",
"collectibles.model": "模型",
"collectibles.pattern": "图案",
"collectibles.backdrop": "背景",
"collectibles.rarity": "稀有度 ‰",
"collectibles.rarityHint": "Permille 是普通升级的相对权重,不要求每类合计正好为 1000。",
"collectibles.colorHint": "颜色会按 24 位 RGB 数值保存。",
"collectibles.addAttribute": "添加",
"collectibles.remove": "删除属性",
"collectibles.fileRequired": "每个模型和图案都必须选择 TGS 或 Lottie 文件。",
"collectibles.backdropID": "背景 ID",
"collectibles.color.center": "中心色",
"collectibles.color.edge": "边缘色",
"collectibles.color.pattern": "图案色",
"collectibles.color.text": "文字色",
"collectibles.validationReady": "属性池校验通过",
"collectibles.validationHint": "确认规范化资源无误后,即可发布这个不可变版本。",
"collectibles.publish": "发布版本",
"messages.msgIDsInvalid": "消息 ID 无效",
"auth.device": "设备",
"auth.platform": "平台",
"auth.ip": "IP",
"auth.lastActive": "最近活跃",
"auth.revokeCurrent": "撤销当前",
"auth.keepCurrent": "保留当前",
"auth.revokeAll": "撤销全部设备",
"picker.userPlaceholder": "搜索 user_id / phone / username",
"picker.channelPlaceholder": "搜索 channel_id / username / title",
"picker.verified": "认证",
"picker.regular": "普通",
"action.reasonRequired": "请填写操作原因",
"action.flow": "操作流程",
"action.close": "关闭",
"action.stepReason": "填写原因",
"action.stepDryRun": "预演检查",
"action.stepConfirm": "确认执行",
"action.reason": "操作原因",
"action.reasonPlaceholder": "说明本次操作原因",
"action.requestPreview": "请求预览",
"action.result": "操作结果",
"action.commandID": "命令 ID",
"action.status": "状态",
"action.dryRun": "预演",
"action.runAgain": "重新预演",
"action.runDry": "先预演",
"action.confirm": "确认执行",
"audit.id": "ID",
"audit.commandID": "命令 ID",
"audit.action": "动作",
"audit.actor": "操作者",
"audit.status": "状态",
"audit.dryRun": "预演",
"audit.reason": "原因",
"audit.time": "时间"
},
ru: {
"app.adminConsole": "Панель администратора",
"app.localAccess": "Локальный доступ",
"app.title": "telesrv admin",
"common.actions": "Действия",
"common.admins": "Администраторы",
"common.backToList": "Назад к списку",
"common.channel": "Канал",
"common.channelOrGroup": "Канал / Группа",
"common.clear": "Очистить",
"common.close": "Закрыть",
"common.count": "Количество",
"common.deleted": "Удалено",
"common.detail": "Детали",
"common.device": "Устройство",
"common.disabled": "Отключено",
"common.enabled": "Включено",
"common.fromPeer": "От пира",
"common.group": "Группа",
"common.id": "ID",
"common.limit": "Лимит",
"common.loading": "Загрузка...",
"common.member": "Участник",
"common.members": "Участники",
"common.messageId": "ID сообщения",
"common.name": "Имя",
"common.no": "Нет",
"common.noResults": "Нет результатов",
"common.none": "Нет",
"common.normal": "Обычный",
"common.operations": "Операции",
"common.owner": "Владелец",
"common.platform": "Платформа",
"common.refresh": "Обновить",
"common.search": "Поиск",
"common.sender": "Отправитель",
"common.status": "Статус",
"common.survived": "Уцелело",
"common.time": "Время",
"common.type": "Тип",
"common.updatedAt": "Обновлено",
"common.username": "Имя пользователя",
"common.valid": "Действителен",
"common.verified": "Подтвержден",
"common.views": "Просмотры",
"common.yes": "Да",
"route.accounts": "Аккаунты",
"route.accountsSubtitle": "Консоль / Аккаунты",
"route.channels": "Супергруппы и каналы",
"route.channelsSubtitle": "Консоль / Каналы",
"route.dashboard": "Панель управления",
"route.dashboardSubtitle": "Консоль / Обзор",
"route.messages": "Аудит сообщений",
"route.messagesSubtitle": "Консоль / Сообщения",
"route.gifts": "Звездные подарки",
"route.giftsSubtitle": "Консоль / Звездные подарки",
"layout.navigation": "Навигация",
"layout.primaryNav": "Основное меню",
"layout.dashboard": "Обзор",
"layout.accounts": "Аккаунты",
"layout.channels": "Супергруппы / Каналы",
"layout.messages": "Сообщения",
"layout.gifts": "Звездные подарки",
"layout.privateMessages": "Личные",
"layout.groupMessages": "Группы",
"layout.runtime": "Среда выполнения",
"layout.adminBackend": "Админ-бэкенд",
"layout.ready": "Готов",
"layout.pgRead": "Чтение из PG",
"layout.readOnly": "Только чтение",
"layout.writeOps": "Операции записи",
"layout.dryRun": "Тестовый запуск",
"layout.actor": "Вход выполнен как: {actor}",
"layout.logout": "Выйти",
"language.en": "EN",
"language.zh": "中文",
"language.ru": "RU",
"login.heading": "Панель администратора",
"login.body": "Введите учетные данные для входа в консоль.",
"login.secret": "Пароль или токен администратора",
"login.submit": "Войти",
"login.submitting": "Вход...",
"dashboard.eyebrow": "Состояние системы",
"dashboard.title": "Обзор консоли",
"dashboard.readPath": "Путь чтения",
"dashboard.readPathValue": "PG только для чтения",
"dashboard.writePath": "Путь записи",
"dashboard.executionPolicy": "Политика выполнения",
"dashboard.dryRunFirst": "Сначала тестовый запуск",
"dashboard.accountsText": "Статус аккаунтов, премиум, верификация, сессии.",
"dashboard.channelsText": "Публичные каналы и группы, количество участников, статус верификации.",
"dashboard.messagesText": "Ящики сообщений, обновления, состояние исходящих.",
"dashboard.strip.dryRun": "Все опасные действия начинаются с тестового запуска",
"dashboard.strip.token": "Браузер никогда не сохраняет внутренние токены",
"dashboard.strip.pagination": "Списки используют курсорную пагинацию",
"dashboard.strip.snapshot": "Детальные страницы сохраняют моментальные снимки исходного состояния",
"account.pageTitle": "Аккаунты",
"account.queryResults": "Результаты поиска",
"account.recentActive": "Недавно активные аккаунты",
"account.currentPage": "Аккаунты на странице",
"account.onlineDevices": "Активные сессии устройств",
"account.premium": "Premium",
"account.frozen": "Заморожен",
"account.searchPlaceholder": "ID пользователя / телефон / имя пользователя",
"account.userID": "ID пользователя",
"account.phone": "Телефон",
"account.lastActive": "Последняя активность",
"account.notVerified": "Не подтвержден",
"account.notPremium": "Без Premium",
"account.premiumUntil": "Premium истекает",
"account.starsBalance": "Баланс Звезд",
"account.startingGrantApplied": "стартовый бонус начислен",
"account.startingGrantPending": "ожидает стартового бонуса",
"account.activeSessions": "Авторизованные устройства",
"account.accountFlags": "Флаги аккаунта",
"account.restriction": "Ограничение",
"account.restricted": "Ограничен",
"account.createdAt": "Создан",
"account.detailTitle": "Аккаунт #{id}",
"account.profile": "Профиль аккаунта",
"account.loadingDetail": "Загрузка данных аккаунта",
"account.waitingData": "Ожидание данных",
"account.noUsername": "Нет имени пользователя",
"account.noPhone": "Нет телефона",
"account.accountFrozen": "Аккаунт заморожен",
"account.accountActive": "Аккаунт активен",
"account.authorizationsTitle": "Авторизованные устройства",
"account.authorizationsCount": "Авторизаций: {count}",
"account.recentAdminOps": "Последние действия администратора",
"account.recent30Audit": "Последние 30 записей аудита",
"account.actionDock": "Действия с аккаунтом",
"account.freezeAccount": "Заморозить аккаунт",
"account.updateFreeze": "Обновить параметры заморозки",
"account.unfreezeAccount": "Разморозить аккаунт",
"account.freezeSince": "Заморожен с",
"account.freezeUntil": "Срок подачи апелляции",
"account.freezeUntilAria": "Срок подачи апелляции на заморозку",
"account.freezeAppealURL": "URL для апелляции",
"account.freezeAppealURLAria": "URL для апелляции на заморозку",
"account.premiumMonths": "Срок действия Premium (в месяцах)",
"account.premiumMonthsAria": "Указать срок действия Premium в месяцах",
"account.setPremium": "Выдать Premium",
"account.clearPremium": "Снять Premium",
"account.starsAmount": "Количество звёзд",
"account.starsAmountAria": "Указать количество начисляемых звёзд",
"account.grantStars": "Начислить звёзды",
"account.setVerified": "Подтвердить аккаунт",
"account.clearVerified": "Снять подтверждение",
"channel.pageTitle": "Супергруппы и каналы",
"channel.recentUpdated": "Недавно обновленные",
"channel.currentPage": "Объекты на странице",
"channel.megagroups": "Супергруппы",
"channel.broadcasts": "Каналы",
"channel.verifiedCount": "Подтверждено",
"channel.searchPlaceholder": "ID канала / имя пользователя / название",
"channel.channelID": "ID канала",
"channel.kind": "Тип",
"channel.title": "Название",
"channel.pts": "PTS",
"channel.detailProfile": "Профиль канала",
"channel.loadingDetail": "Загрузка данных канала",
"channel.creator": "Создатель: {id}",
"channel.governance": "Модерация",
"channel.governanceValue": "Заблокировано {banned} / Исключено {kicked}",
"channel.flags": "Флаги канала",
"channel.rawRow": "Исходная строка БД",
"channel.rawRowText": "Снимок базы данных только для чтения",
"channel.actionDock": "Действия с каналом",
"channel.setVerified": "Подтвердить канал",
"channel.clearVerified": "Снять подтверждение",
"channel.kind.broadcast": "Канал",
"channel.kind.forum": "Супергруппа / Форум",
"channel.kind.megagroup": "Супергруппа",
"channel.kind.generic": "Канал / Группа",
"messages.privateTitle": "Личные сообщения",
"messages.privateEyebrow": "Личные ящики сообщений",
"messages.groupTitle": "Групповые сообщения",
"messages.groupEyebrow": "Сообщения супергрупп и каналов",
"messages.selectPrivatePeers": "Сначала найдите и выберите владельца и собеседника",
"messages.selectChannel": "Сначала найдите и выберите супергруппу или канал",
"messages.ownerUser": "Пользователь-владелец",
"messages.peerUser": "Собеседник",
"messages.beforeDatePlaceholder": "курсор before_date",
"messages.beforeIDPlaceholder": "курсор before_msg_id",
"messages.limitPlaceholder": "лимит <= 100",
"messages.searchMessages": "Поиск сообщений",
"messages.nextPage": "Следующая страница",
"messages.currentPage": "Сообщения на странице",
"messages.deleted": "Удалено",
"messages.outgoing": "Исходящее",
"messages.incoming": "Входящее",
"messages.ownerPeer": "Владелец / Собеседник",
"messages.deleteSelected": "Указать и удалить выбранные сообщения",
"messages.idsPlaceholder": "ID сообщений через запятую",
"messages.revoke": "Удалить для обеих сторон",
"messages.previewDelete": "Тестовое удаление",
"messages.clearHistory": "Очистить историю личной переписки",
"messages.maxIDPlaceholder": "граница max_id",
"messages.maxBatchesPlaceholder": "max_batches",
"messages.justClear": "Очистить только у себя",
"messages.previewClearHistory": "Тестовая очистка истории",
"messages.direction": "Направление",
"messages.body": "Текст сообщения",
"messages.privateDetailTitle": "Сообщение #{id}",
"messages.detailEyebrow": "Детали сообщения",
"messages.backPrivate": "Назад к личным сообщениям",
"messages.backGroup": "Назад к групповым сообщениям",
"messages.ownerPeerTitle": "Владелец {owner} · Собеседник {peer}",
"messages.senderSubtitle": "Отправитель {sender} · {date}",
"messages.boxID": "ID ящика сообщений",
"messages.privateMessageID": "ID личного сообщения",
"messages.messageSender": "Отправитель сообщения",
"messages.messageBox": "Ящик сообщений",
"messages.dialogRow": "Строка диалога",
"messages.privateRow": "Строка личного сообщения",
"messages.channelMessageRow": "Строка сообщения канала",
"messages.channelRow": "Строка канала",
"messages.userUpdateEvents": "События обновления пользователей",
"messages.channelUpdateEvents": "События обновления каналов",
"messages.eventJson": "JSON события",
"messages.dispatchOutbox": "Очередь отправки (Outbox)",
"messages.messageBoxesSnapshot": "Снимок message_boxes только для чтения",
"messages.dialogSnapshot": "Снимок dialogs только для чтения",
"messages.privateSnapshot": "Снимок private_messages только для чтения",
"messages.channelMessagesSnapshot": "Снимок channel_messages только для чтения",
"messages.channelSnapshot": "Снимок channels только для чтения",
"messages.userEventsSource": "постоянные user_update_events",
"messages.channelEventsSource": "постоянные channel_update_events",
"messages.outboxSource": "онлайн/офлайн dispatch_outbox",
"messages.attempts": "Попытки",
"messages.deleteThis": "Удалить это сообщение",
"messages.groupDetailTitle": "Групповое сообщение #{id}",
"messages.channelGroupTitle": "Канал / Группа {id}",
"messages.mediaCount": "С медиафайлами",
"messages.channelPosts": "Посты канала",
"messages.channelGroup": "Канал / Группа",
"messages.pinned": "Закреплено",
"messages.channelPost": "Пост в канале",
"gifts.pageTitle": "Каталог звездных подарков",
"gifts.eyebrow": "Каталог, неизменяемые версии и файлы анимаций",
"gifts.total": "Подарков в каталоге",
"gifts.enabled": "Включено",
"gifts.received": "Полученные подарки",
"gifts.formats": "Поддерживаемые форматы",
"gifts.add": "Добавить подарок",
"gifts.importAll": "Импортировать все официальные подарки",
"gifts.searchPlaceholder": "Поиск по ID подарка, названию или формату",
"gifts.listSummary": "Показано {shown} из {total}",
"gifts.idRevision": "ID / Версия",
"gifts.price": "Цена / Конвертация",
"gifts.importTitle": "Импорт звездного подарка",
"gifts.importEyebrow": "Управление каталогом подарков",
"gifts.newRevision": "Создать версию для подарка #{id}",
"gifts.importHint": "Загрузите файл TGS или обычный Lottie JSON. Lottie нормализуется и сжимается в формат TGS.",
"gifts.officialSource": "Официальный снимок",
"gifts.fileSource": "Загрузить файл",
"gifts.officialHint": "Выберите проверенный подарок из data/official-gifts. Полные пулы коллекционных предметов импортируются атомарно.",
"gifts.officialSearch": "Поиск по ID или названию официального подарка",
"gifts.officialSelect": "Выберите официальный подарок",
"gifts.officialRequired": "Сначала выберите официальный подарок",
"gifts.officialResults": "Показано {shown} из {total}",
"gifts.officialCategoryLabel": "Категория возможностей официального подарка",
"gifts.officialCategory.all": "Все",
"gifts.officialCategory.upgrade": "Можно улучшить",
"gifts.officialCategory.craft": "Можно создать",
"gifts.officialCategory.basic": "Нельзя улучшить",
"gifts.officialUnnamed": "Официальный подарок без названия #{id}",
"gifts.officialAttributes": "Атрибутов: {count}",
"gifts.canUpgrade": "Можно улучшить",
"gifts.cannotUpgrade": "Нельзя улучшить",
"gifts.canCraft": "Можно создать",
"gifts.cannotCraft": "Нельзя создать",
"gifts.officialEmpty": "Нет подарков, соответствующих категории и поиску.",
"gifts.includeCollectible": "Импортировать полный пул коллекционных предметов, включая созданные модели",
"gifts.animation": "Файл анимации",
"gifts.filePrompt": "Перетащите или выберите файл TGS / Lottie",
"gifts.fileHint": "TGS, JSON или Lottie · файл проверяется перед импортом",
"gifts.chooseFile": "Выбрать файл",
"gifts.changeFile": "Изменить файл",
"gifts.title": "Отображаемое название",
"gifts.titlePlaceholder": "например, Праздничная звезда",
"gifts.stars": "Цена в Звездах",
"gifts.convertStars": "Звезд при конвертации",
"gifts.sortOrder": "Порядок сортировки",
"gifts.reason": "Причина для аудита",
"gifts.reasonPlaceholder": "Кратко опишите причину импорта этого подарка",
"gifts.enableAfterImport": "Включить после импорта",
"gifts.validate": "Тестовая проверка",
"gifts.confirmImport": "Подтвердить импорт",
"gifts.stepDetails": "Файл и описание",
"gifts.stepValidate": "Тестовая проверка",
"gifts.stepImport": "Подтверждение импорта",
"gifts.fileRequired": "Сначала выберите файл TGS или Lottie",
"gifts.source": "Источник",
"gifts.replace": "Новая версия",
"gifts.disable": "Отключить",
"gifts.enable": "Включить",
"gifts.bulkSelected": "Выбрано: {count}",
"gifts.bulkSelectAll": "Выбрать все видимые подарки",
"gifts.bulkSelectOne": "Выбрать подарок {id}",
"gifts.bulkEnable": "Включить выбранные",
"gifts.bulkDisable": "Отключить выбранные",
"gifts.bulkStatusFailed": "{failed} из {total} не выполнено",
"gifts.empty": "Звездные подарки еще не импортированы.",
"gifts.emptyHint": "Импортируйте первую анимацию, чтобы начать наполнение каталога.",
"gifts.validationReady": "Проверка пройдена",
"gifts.validationHint": "Проверьте нормализованные метаданные и подтвердите импорт.",
"gifts.confirmState": "Применить проверенные изменения состояния к подарку #{id}?",
"collectibles.manage": "Пул атрибутов",
"collectibles.title": "Пул коллекционных предметов · Подарок #{id}",
"collectibles.eyebrow": "Уникальные атрибуты подарка",
"collectibles.activeRevision": "Опубликованная версия {revision}",
"collectibles.published": "Опубликовано",
"collectibles.noPool": "Нет опубликованного пула коллекционных предметов",
"collectibles.noPoolHint": "Опубликуйте модели, узоры и фоны для активации улучшений.",
"collectibles.publishNew": "Опубликовать новую неизменяемую версию",
"collectibles.immutableHint": "Тестовый запуск проверяет каждый файл и итоговые показатели редкости перед тем, как версия станет активной.",
"collectibles.upgradeStars": "Цена улучшения в Звездах",
"collectibles.supply": "Уникальный тираж",
"collectibles.slug": "Публичный префикс ссылки (slug)",
"collectibles.models": "Модели",
"collectibles.patterns": "Узоры",
"collectibles.backdrops": "Фоны",
"collectibles.model": "Модель",
"collectibles.pattern": "Узор",
"collectibles.backdrop": "Фон",
"collectibles.rarity": "Редкость ‰",
"collectibles.rarityHint": "Значения permille — это относительные веса обычного улучшения; их сумма не обязана равняться 1000.",
"collectibles.colorHint": "Цвета сохраняются как 24-битные RGB-значения.",
"collectibles.addAttribute": "Добавить",
"collectibles.remove": "Удалить атрибут",
"collectibles.fileRequired": "Для каждой модели и узора требуется файл TGS или Lottie.",
"collectibles.backdropID": "ID фона",
"collectibles.color.center": "Центр",
"collectibles.color.edge": "Край",
"collectibles.color.pattern": "Узор",
"collectibles.color.text": "Текст",
"collectibles.validationReady": "Пул атрибутов корректен",
"collectibles.validationHint": "Проверьте нормализованные ресурсы и опубликуйте эту неизменяемую версию.",
"collectibles.publish": "Опубликовать версию",
"messages.msgIDsInvalid": "Некорректные ID сообщений",
"auth.device": "Устройство",
"auth.platform": "Платформа",
"auth.ip": "IP-адрес",
"auth.lastActive": "Последняя активность",
"auth.revokeCurrent": "Отозвать текущую",
"auth.keepCurrent": "Оставить текущую",
"auth.revokeAll": "Разлогинить все устройства",
"picker.userPlaceholder": "Поиск по user_id / телефону / имени пользователя",
"picker.channelPlaceholder": "Поиск по channel_id / имени пользователя / названию",
"picker.verified": "Подтвержденные",
"picker.regular": "Обычные",
"action.reasonRequired": "Пожалуйста, укажите причину операции",
"action.flow": "Процесс выполнения",
"action.close": "Закрыть",
"action.stepReason": "Укажите причину",
"action.stepDryRun": "Тестовый запуск",
"action.stepConfirm": "Подтверждение выполнения",
"action.reason": "Причина операции",
"action.reasonPlaceholder": "Опишите, почему выполняется эта операция",
"action.requestPreview": "Запросить предпросмотр",
"action.result": "Результат действия",
"action.commandID": "ID команды",
"action.status": "Статус",
"action.dryRun": "Тестовый запуск",
"action.runAgain": "Запустить тестовый запуск снова",
"action.runDry": "Сначала выполните тестовый запуск",
"action.confirm": "Подтвердить выполнение",
"audit.id": "ID",
"audit.commandID": "ID команды",
"audit.action": "Действие",
"audit.actor": "Исполнитель",
"audit.status": "Статус",
"audit.dryRun": "Тестовый запуск",
"audit.reason": "Причина",
"audit.time": "Время"
}
};
type I18nContextValue = {
lang: Language;
setLang: (lang: Language) => void;
t: TFunction;
};
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({ children }: { children: ReactNode }) {
const [lang, setLang] = useState<Language>(() => initialLanguage());
useEffect(() => {
try {
localStorage.setItem(storageKey, lang);
} catch {
// Language persistence is best-effort.
}
const langAttr = lang === "zh" ? "zh-CN" : lang === "ru" ? "ru" : "en";
document.documentElement.lang = langAttr;
document.documentElement.lang = "en";
document.documentElement.dir = "ltr";
document.documentElement.setAttribute("translate", "no");
document.body.classList.add("notranslate");
document.title = translate(lang, "app.title");
}, [lang]);
document.title = translate("app.title");
}, []);
const value = useMemo<I18nContextValue>(() => ({
lang,
setLang,
t: (key, params) => translate(lang, key, params)
}), [lang]);
t: (key, params) => translate(key, params)
}), []);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
@ -1182,73 +408,10 @@ export function useI18n(): I18nContextValue {
return value;
}
export function LanguageSwitch() {
const { lang, setLang, t } = useI18n();
return (
<div className="language-switch" role="group" aria-label="Language">
{(["en", "zh", "ru"] as const).map((item) => (
<button
key={item}
className={lang === item ? "active" : ""}
type="button"
aria-pressed={lang === item}
onClick={() => setLang(item)}
>
{t(`language.${item}`)}
</button>
))}
</div>
);
}
export function translate(lang: Language, key: string, params?: TranslationParams): string {
const value = translations[lang][key] ?? translations.en[key] ?? key;
export function translate(key: string, params?: TranslationParams): string {
const value = translations[key] ?? key;
if (!params) {
return value;
}
return value.replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? ""));
}
function initialLanguage(): Language {
try {
const requested = normalizeLanguage(new URLSearchParams(window.location.search).get("lang"));
if (requested) {
return requested;
}
} catch {
// URLSearchParams can fail in unusual embedded contexts.
}
try {
const saved = normalizeLanguage(localStorage.getItem(storageKey));
if (saved) {
return saved;
}
} catch {
// Storage is optional.
}
const browserLanguages = navigator.languages?.length ? navigator.languages : [navigator.language];
for (const item of browserLanguages) {
const normalized = normalizeLanguage(item);
if (normalized) {
return normalized;
}
}
return "en";
}
function normalizeLanguage(value: string | null | undefined): Language | null {
if (!value) {
return null;
}
const tag = value.trim().toLowerCase().replace("_", "-");
if (tag === "zh" || tag.startsWith("zh-")) {
return "zh";
}
if (tag === "en" || tag.startsWith("en-")) {
return "en";
}
if (tag === "ru" || tag.startsWith("ru-")) {
return "ru";
}
return null;
}

View file

@ -2,7 +2,7 @@ import type { FormEvent } from "react";
import { useState } from "react";
import { api, errorMessage } from "../api";
import { Alert } from "../components/ui";
import { LanguageSwitch, useI18n } from "../i18n";
import { useI18n } from "../i18n";
export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
const { t } = useI18n();
@ -29,14 +29,13 @@ export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
<section className="login-panel">
<div className="login-head">
<div className="brand brand-elevated">
<span className="brand-mark">T</span>
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
<span>
<strong>telesrv</strong>
<strong>OwpenGram</strong>
<small>{t("app.adminConsole")}</small>
</span>
</div>
<div className="login-head-actions">
<LanguageSwitch />
<span className="login-chip">{t("app.localAccess")}</span>
</div>
</div>

View file

@ -1,24 +1,31 @@
@font-face { font-family: "Plus Jakarta Sans"; font-style: normal; font-weight: 400; font-display: swap; src: url("/fonts/plus-jakarta-sans-400.woff2") format("woff2"); }
@font-face { font-family: "Plus Jakarta Sans"; font-style: normal; font-weight: 500; font-display: swap; src: url("/fonts/plus-jakarta-sans-500.woff2") format("woff2"); }
@font-face { font-family: "Plus Jakarta Sans"; font-style: normal; font-weight: 600; font-display: swap; src: url("/fonts/plus-jakarta-sans-600.woff2") format("woff2"); }
@font-face { font-family: "Plus Jakarta Sans"; font-style: normal; font-weight: 700; font-display: swap; src: url("/fonts/plus-jakarta-sans-700.woff2") format("woff2"); }
@font-face { font-family: "Plus Jakarta Sans"; font-style: normal; font-weight: 800; font-display: swap; src: url("/fonts/plus-jakarta-sans-800.woff2") format("woff2"); }
:root {
color-scheme: light;
--bg: #f3f5f7;
--bg: #f7f9fc;
--panel: #ffffff;
--panel-subtle: #f8fafb;
--panel-strong: #eef2f5;
--line: #d9e1e8;
--line-strong: #c2ccd6;
--text: #101828;
--muted: #667085;
--muted-2: #98a2b3;
--brand: #176d61;
--brand-2: #245b9d;
--panel-subtle: #f7f9fc;
--panel-strong: #f1f5f9;
--line: #e2e8f0;
--line-strong: #cbd5e1;
--text: #0f1720;
--muted: #64748b;
--muted-2: #94a3b8;
--brand: #2563eb;
--brand-2: #38bdf8;
--grad: linear-gradient(135deg, #38bdf8 0%, #2563eb 55%, #1e40af 100%);
--good: #167447;
--warn: #a15c07;
--danger: #b42318;
--sidebar: #11161d;
--sidebar-soft: #1b222b;
--sidebar-line: #2c3541;
--focus: rgba(23, 109, 97, 0.16);
--shadow: 0 18px 52px rgba(16, 24, 40, 0.14);
--sidebar: #08080e;
--sidebar-soft: #12121a;
--sidebar-line: #222228;
--focus: rgba(37, 99, 235, 0.16);
--shadow: 0 28px 70px -36px rgba(5, 5, 8, 0.35);
}
* {
@ -35,7 +42,7 @@ body {
margin: 0;
color: var(--text);
background: var(--bg);
font: 13px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font: 13px/1.45 "Plus Jakarta Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
@ -81,20 +88,18 @@ a {
justify-content: center;
}
.brand-elevated .brand-mark {
box-shadow: 0 8px 24px rgba(23, 109, 97, 0.26);
}
.brand-mark {
display: grid;
width: 34px;
height: 34px;
place-items: center;
color: #ffffff;
background: var(--brand);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
font-weight: 800;
}
.brand-mark img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.brand strong {
@ -261,37 +266,6 @@ a {
gap: 8px;
}
.language-switch {
display: inline-flex;
min-height: 30px;
align-items: center;
padding: 2px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 999px;
}
.language-switch button {
min-width: 42px;
min-height: 24px;
padding: 0 9px;
color: var(--muted);
background: transparent;
border: 0;
border-radius: 999px;
cursor: pointer;
font-weight: 800;
}
.language-switch button.active {
color: #ffffff;
background: var(--brand);
}
.language-switch button:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.actor-pill {
display: inline-flex;

View file

@ -110,8 +110,8 @@
height: 38px;
place-items: center;
color: var(--brand);
background: #edf7f4;
border: 1px solid #c9e2dc;
background: #eaf2fd;
border: 1px solid #c7dcf9;
border-radius: 8px;
}
@ -217,9 +217,9 @@
align-items: center;
gap: 8px;
padding: 7px 9px;
color: #0f3f38;
background: #eef8f5;
border: 1px solid #b9dcd3;
color: #1e3a8a;
background: #eaf2fe;
border: 1px solid #c3d9f7;
border-radius: 7px;
}
@ -291,7 +291,7 @@
.picker-row:hover,
.picker-row.selected {
background: #f3f8f6;
background: #eef4ff;
}
.picker-row strong,
@ -410,7 +410,7 @@ textarea:focus {
}
.btn.primary:hover:not(:disabled) {
background: #12594f;
background: #1d4ed8;
}
.btn.ghost {

View file

@ -265,12 +265,12 @@
flex: 0 0 auto;
place-items: center;
color: var(--brand);
background: #eaf6f3;
border: 1px solid #c7e3dc;
background: #eaf2fd;
border: 1px solid #c7dcf9;
}
.gift-format-chips { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.gift-format-chips span { padding: 4px 8px; color: #33645d; background: #eef8f5; border: 1px solid #cfe5df; border-radius: 999px; font-size: 10px; font-weight: 800; letter-spacing: .02em; }
.gift-format-chips span { padding: 4px 8px; color: #1e40af; background: #eaf2fe; border: 1px solid #cbdcf7; border-radius: 999px; font-size: 10px; font-weight: 800; letter-spacing: .02em; }
.gift-list-summary { margin-left: auto; color: var(--muted); font-size: 11px; font-weight: 700; }
@ -284,12 +284,12 @@
.official-gift-categories { display: flex; flex-wrap: wrap; gap: 7px; }
.official-gift-categories button {
display: inline-flex; align-items: center; gap: 7px; min-height: 32px; padding: 5px 10px;
color: #49605c; background: #f7faf9; border: 1px solid #d7e2df; border-radius: 999px;
color: #3f5a78; background: #f5f8fd; border: 1px solid #d7e2f4; border-radius: 999px;
font: inherit; font-size: 11px; font-weight: 800; cursor: pointer;
transition: color .15s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease;
}
.official-gift-categories button:hover { color: var(--brand); border-color: #9fc9c0; }
.official-gift-categories button.active { color: #ffffff; background: var(--brand); border-color: var(--brand); box-shadow: 0 4px 12px rgba(23, 109, 97, .17); }
.official-gift-categories button:hover { color: var(--brand); border-color: #9dc3f5; }
.official-gift-categories button.active { color: #ffffff; background: var(--brand); border-color: var(--brand); box-shadow: 0 4px 12px rgba(37, 99, 235, .17); }
.official-gift-categories button span {
display: grid; min-width: 20px; height: 20px; padding: 0 5px; place-items: center;
color: inherit; background: rgba(255,255,255,.65); border-radius: 999px; font-size: 10px;
@ -298,20 +298,20 @@
.official-gift-list {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; max-height: 314px;
min-height: 126px; padding: 8px; overflow: auto; border: 1px solid var(--line); border-radius: 14px;
background: #f6f9f8; scrollbar-gutter: stable;
background: #f5f8fd; scrollbar-gutter: stable;
}
.official-gift-option {
display: grid; min-width: 0; gap: 8px; padding: 11px 12px; text-align: left; color: var(--text);
background: #ffffff; border: 1px solid #dce6e3; border-radius: 11px; cursor: pointer;
box-shadow: 0 1px 2px rgba(32, 54, 50, .03);
background: #ffffff; border: 1px solid #dbe6f7; border-radius: 11px; cursor: pointer;
box-shadow: 0 1px 2px rgba(30, 41, 82, .03);
transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
}
.official-gift-option:hover { border-color: #9fc9c0; box-shadow: 0 5px 14px rgba(32, 76, 68, .08); transform: translateY(-1px); }
.official-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(23, 109, 97, .12), 0 5px 14px rgba(32, 76, 68, .08); }
.official-gift-option:hover { border-color: #9dc3f5; box-shadow: 0 5px 14px rgba(30, 64, 175, .08); transform: translateY(-1px); }
.official-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(37, 99, 235, .12), 0 5px 14px rgba(30, 64, 175, .08); }
.official-gift-option-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: baseline; }
.official-gift-option-head strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.official-gift-option-head .mono { color: var(--muted); font-size: 9px; }
.official-gift-option-meta { display: flex; flex-wrap: wrap; gap: 10px; color: #667773; font-size: 10px; font-weight: 700; }
.official-gift-option-meta { display: flex; flex-wrap: wrap; gap: 10px; color: #5b6b85; font-size: 10px; font-weight: 700; }
.official-gift-capabilities { display: flex; flex-wrap: wrap; gap: 5px; }
.official-gift-capabilities > span {
padding: 3px 7px; border: 1px solid transparent; border-radius: 999px; font-size: 9px; font-weight: 850; letter-spacing: .01em;
@ -342,21 +342,21 @@
padding: 12px 14px;
color: var(--text);
background: #ffffff;
border: 1px dashed #b7ccc8;
border: 1px dashed #b7c9e8;
border-radius: 10px;
cursor: pointer;
transition: border-color .16s ease, background .16s ease, box-shadow .16s ease;
}
.gift-file-picker:hover,
.gift-file-picker.has-file { background: #f8fcfb; border-color: var(--brand); box-shadow: 0 0 0 2px rgba(23, 109, 97, .05); }
.gift-file-picker.has-file { background: #f5f9ff; border-color: var(--brand); box-shadow: 0 0 0 2px rgba(37, 99, 235, .05); }
.gift-file-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.gift-file-icon { width: 40px; height: 40px; border-radius: 9px; }
.gift-file-copy { display: grid; min-width: 0; gap: 2px; }
.gift-field-label { color: var(--muted); font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
.gift-file-copy strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.gift-file-copy small { color: var(--muted); font-size: 11px; font-weight: 500; }
.gift-file-action { padding: 7px 10px; color: var(--brand); background: #f0f8f6; border: 1px solid #c7e3dc; border-radius: 7px; font-size: 11px; font-weight: 800; }
.gift-file-action { padding: 7px 10px; color: var(--brand); background: #eef4ff; border: 1px solid #c7dcf9; border-radius: 7px; font-size: 11px; font-weight: 800; }
.gift-fields-grid {
display: grid;
@ -386,7 +386,7 @@
}
.gift-fields-grid input:focus,
.gift-reason-field input:focus { border-color: #77b6aa; box-shadow: 0 0 0 3px rgba(23, 109, 97, .08); outline: none; }
.gift-reason-field input:focus { border-color: #7bb4f0; box-shadow: 0 0 0 3px rgba(37, 99, 235, .08); outline: none; }
.gift-switch { display: inline-flex; align-items: center; gap: 9px; color: #344054; font-size: 12px; font-weight: 700; cursor: pointer; }
.gift-switch input { position: absolute; width: 1px; height: 1px; opacity: 0; }
@ -394,14 +394,14 @@
.gift-switch-track span { width: 15px; height: 15px; background: #ffffff; border-radius: 50%; box-shadow: 0 1px 3px rgba(16, 24, 40, .22); transition: transform .16s ease; }
.gift-switch input:checked + .gift-switch-track { background: var(--brand); }
.gift-switch input:checked + .gift-switch-track span { transform: translateX(15px); }
.gift-switch input:focus-visible + .gift-switch-track { outline: 3px solid rgba(23, 109, 97, .16); outline-offset: 2px; }
.gift-switch input:focus-visible + .gift-switch-track { outline: 3px solid rgba(37, 99, 235, .16); outline-offset: 2px; }
.gift-validation { overflow: hidden; color: #d5fff5; background: #173631; border: 1px solid #24564e; border-radius: 9px; }
.gift-validation-head { display: flex; align-items: center; gap: 9px; padding: 10px 12px; color: #e3fff9; background: rgba(255, 255, 255, .035); border-bottom: 1px solid rgba(255, 255, 255, .09); }
.gift-validation-head div { display: grid; gap: 2px; }
.gift-validation-head span { color: #99cfc4; font-size: 10px; }
.gift-validation pre { max-height: 180px; overflow: auto; margin: 0; padding: 11px 12px; color: #d5fff5; font-size: 11px; }
.gift-animation-shell { position: relative; display: grid; min-height: 210px; place-items: center; background: radial-gradient(circle, #f9f3ff, #eef8f5); }
.gift-animation-shell { position: relative; display: grid; min-height: 210px; place-items: center; background: radial-gradient(circle, #f9f3ff, #eaf2fe); }
.gift-animation { width: 200px; height: 200px; }
.gift-animation canvas { width: 100% !important; height: 100% !important; }
.gift-play { position: absolute; right: 8px; bottom: 8px; display: grid; width: 30px; height: 30px; place-items: center; color: var(--text); background: rgba(255,255,255,.9); border: 1px solid var(--line); border-radius: 50%; }

View file

@ -90,7 +90,7 @@
.command-step.active {
color: var(--brand);
border-color: #a9d8ce;
border-color: #a9c8f7;
}
.command-step.done {
@ -201,8 +201,8 @@
align-items: center;
padding: 0 8px;
color: var(--brand);
background: #edf7f4;
border: 1px solid #c9e2dc;
background: #eaf2fd;
border: 1px solid #c7dcf9;
border-radius: 999px;
font-size: 12px;
}