feat: webcore/old-web visual redesign
CI / Go (build + vet + test) (push) Canceled after 0s
CI / Web (build + lint) (push) Canceled after 0s

This commit is contained in:
vantablackTea
2026-09-28 13:02:58 +03:00
parent 5e30761742
commit 61183d3f89
17 changed files with 861 additions and 609 deletions
+1 -7
View File
@@ -4,14 +4,8 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0a0c11" />
<meta name="theme-color" content="#008080" />
<meta name="description" content="Пульс — лёгкий self-hosted мессенджер для близких" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Sora:wght@500;600;700&display=swap"
rel="stylesheet"
/>
<title>Пульс — мессенджер</title>
</head>
<body>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

+18 -11
View File
@@ -4,24 +4,31 @@ import ChatApp from './screens/ChatApp'
function Splash() {
return (
<div className="flex min-h-full flex-col items-center justify-center gap-5">
<div className="relative flex h-20 w-20 items-center justify-center">
<span className="absolute inset-0 animate-ping rounded-3xl bg-accent/20" aria-hidden="true" />
<span className="relative flex h-16 w-16 items-center justify-center rounded-3xl bg-gradient-to-br from-accent to-accent-strong text-white shadow-2xl shadow-accent/30">
<svg viewBox="0 0 24 24" className="h-8 w-8" fill="none" aria-hidden="true">
<div className="flex min-h-full items-center justify-center p-4">
<div className="bevel w-full max-w-xs bg-panel">
<div className="win-title px-1.5 py-1 text-xs font-bold tracking-wide">Пульс — Загрузка</div>
<div className="flex items-center gap-3 p-3">
<span className="bevel flex h-10 w-10 shrink-0 items-center justify-center bg-accent text-white">
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<path
d="M4 12.5h3l2-5 3.2 9 2.3-6 1.5 2H20"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.4"
strokeLinecap="square"
strokeLinejoin="miter"
/>
</svg>
</span>
<div className="min-w-0">
<p className="text-sm font-bold text-fg">Пульс</p>
<p className="text-[11px] text-muted">
Подключаемся<span className="blink">_</span>
</p>
</div>
</div>
<div className="bevel-in mx-3 mb-3 h-4 bg-surface p-0.5">
<div className="h-full w-1/2 animate-pulse-soft bg-accent" />
</div>
<div className="text-center">
<p className="font-display text-xl font-bold tracking-tight text-fg">Пульс</p>
<p className="mt-1 text-sm text-muted">Подключаемся…</p>
</div>
</div>
)
+5 -6
View File
@@ -23,16 +23,15 @@ export default function Avatar({ name, seed, size = 'md', online = false, classN
return (
<span className={`relative inline-flex shrink-0 ${className}`}>
<span
className={`inline-flex items-center justify-center rounded-full font-semibold text-white/95 shadow-inner ring-1 ring-white/10 ${SIZE_CLASSES[size]}`}
style={{ backgroundImage: `linear-gradient(145deg, ${background}, color-mix(in srgb, ${background} 62%, #000))` }}
className={`bevel pixelated inline-flex items-center justify-center font-bold text-white ${SIZE_CLASSES[size]}`}
style={{ backgroundColor: background, textShadow: '1px 1px 0 rgba(0,0,0,0.6)' }}
aria-hidden="true"
>
{initials({ display_name: name, username: name })}
</span>
{online && (
<span
className="absolute -right-0.5 -bottom-0.5 rounded-full border-2 border-panel bg-online"
style={{ width: '30%', height: '30%', minWidth: 9, minHeight: 9 }}
className="blink absolute -right-1 -bottom-1 h-3 w-3 border border-black bg-online"
aria-hidden="true"
/>
)}
@@ -52,8 +51,8 @@ export function GroupAvatar({ name, seed, size = 'md', className = '' }: GroupAv
return (
<span className={`relative inline-flex shrink-0 ${className}`}>
<span
className={`inline-flex items-center justify-center rounded-full text-white/95 shadow-inner ring-1 ring-white/10 ${SIZE_CLASSES[size]}`}
style={{ backgroundImage: `linear-gradient(145deg, ${background}, color-mix(in srgb, ${background} 62%, #000))` }}
className={`bevel pixelated inline-flex items-center justify-center text-white ${SIZE_CLASSES[size]}`}
style={{ backgroundColor: background, textShadow: '1px 1px 0 rgba(0,0,0,0.6)' }}
aria-hidden="true"
title={name}
>
+35 -34
View File
@@ -37,7 +37,7 @@ function TypingDots() {
{[0, 1, 2].map((dot) => (
<span
key={dot}
className="h-1.5 w-1.5 animate-bounce-dot rounded-full bg-accent"
className="h-1.5 w-1.5 animate-bounce-dot bg-online"
style={{ animationDelay: `${dot * 140}ms` }}
/>
))}
@@ -133,38 +133,38 @@ function Composer({ onSend, onTyping }: ComposerProps) {
const canSend = (value.trim().length > 0 || attachment !== null) && !uploading
return (
<div className="border-t border-line/80 bg-panel/70 px-3 py-3 backdrop-blur sm:px-5">
<div className="border-t-2 border-surface-3 bg-panel px-2 py-2 sm:px-3">
{attachment && (
<div className="mb-2 flex items-center gap-3 rounded-2xl border border-line bg-surface/80 p-2">
<div className="bevel mb-2 flex items-center gap-2 bg-surface p-1.5">
{isImage ? (
<img
src={attachment.thumb_url ?? attachment.url}
alt={attachment.filename}
className="h-14 w-14 shrink-0 rounded-xl object-cover"
className="pixelated h-12 w-12 shrink-0 object-cover"
/>
) : (
<span className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl bg-surface-3 text-accent">
<IconFile className="h-6 w-6" />
<span className="flex h-12 w-12 shrink-0 items-center justify-center bg-surface-2 text-accent">
<IconFile className="h-5 w-5" />
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-fg">{attachment.filename}</span>
<span className="block text-[11px] text-muted">{formatBytes(attachment.size)}</span>
<span className="block truncate text-xs font-bold text-fg">{attachment.filename}</span>
<span className="block font-mono text-[10px] text-muted">{formatBytes(attachment.size)}</span>
</span>
<button
type="button"
onClick={() => setAttachment(null)}
aria-label="Убрать вложение"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-muted transition hover:bg-surface-2 hover:text-danger"
className="retro-btn inline-flex h-7 w-7 items-center justify-center"
>
<IconClose className="h-4 w-4" />
<IconClose className="h-3.5 w-3.5" />
</button>
</div>
)}
{uploadError && <p className="mb-2 px-1 text-xs text-danger">{uploadError}</p>}
{uploadError && <p className="mb-1.5 px-1 text-[11px] text-danger">{uploadError}</p>}
<div className="flex items-end gap-2 rounded-2xl border border-line bg-surface/80 p-1.5 transition focus-within:border-accent/60 focus-within:ring-2 focus-within:ring-accent/20">
<div className="bevel-in flex items-end gap-1 bg-surface p-1">
<input
ref={fileInputRef}
type="file"
@@ -182,12 +182,12 @@ function Composer({ onSend, onTyping }: ComposerProps) {
disabled={uploading}
aria-label="Прикрепить файл"
title="Прикрепить файл"
className="mb-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-muted transition hover:bg-surface-2 hover:text-accent disabled:opacity-50"
className="retro-btn mb-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center"
>
{uploading ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-surface-3 border-t-accent" />
<span className="h-3.5 w-3.5 animate-spin border-2 border-surface-3 border-t-accent" />
) : (
<IconPaperclip className="h-5 w-5" />
<IconPaperclip className="h-4 w-4" />
)}
</button>
@@ -207,19 +207,20 @@ function Composer({ onSend, onTyping }: ComposerProps) {
}
}}
placeholder="Написать сообщение…"
className="scrollbar-slim max-h-42 flex-1 resize-none bg-transparent px-2.5 py-2 text-[0.9375rem] text-fg placeholder:text-faint focus:outline-none"
className="scrollbar-slim max-h-42 flex-1 resize-none bg-transparent px-2 py-1.5 text-sm text-fg placeholder:text-surface-3 focus:outline-none"
/>
<button
type="button"
onClick={submit}
disabled={!canSend}
aria-label="Отправить сообщение"
className="mb-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-accent-strong text-white shadow-lg shadow-accent/20 transition enabled:hover:brightness-110 enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-40"
className="retro-btn mb-0.5 inline-flex h-8 shrink-0 items-center gap-1 px-2 text-xs font-bold"
>
<IconSend className="h-4.5 w-4.5" />
<IconSend className="h-4 w-4" />
<span className="hidden sm:inline">Отправить</span>
</button>
</div>
<p className="mt-1.5 hidden px-1 text-[11px] text-faint sm:block">
<p className="mt-1 hidden px-1 font-mono text-[10px] text-muted sm:block">
Enter — отправить · Shift + Enter — новая строка
</p>
</div>
@@ -279,13 +280,13 @@ export default function ChatWindow({
if (!conversation) {
return (
<section className={`${className} flex-1 flex-col items-center justify-center gap-4 bg-panel/40 px-6 text-center`}>
<div className="flex h-16 w-16 items-center justify-center rounded-3xl border border-line bg-surface-2 text-accent">
<IconChat className="h-8 w-8" />
<section className={`${className} flex-1 flex-col items-center justify-center gap-3 bg-panel px-6 text-center`}>
<div className="bevel flex h-14 w-14 items-center justify-center bg-surface-2 text-accent">
<IconChat className="h-7 w-7" />
</div>
<div className="space-y-1">
<h2 className="font-display text-lg font-semibold text-fg">Выберите чат</h2>
<p className="max-w-xs text-sm text-muted">
<h2 className="text-sm font-bold text-fg">Выберите чат</h2>
<p className="max-w-xs text-xs text-muted">
Откройте диалог слева или начните новый — история и статусы появятся здесь.
</p>
</div>
@@ -308,15 +309,15 @@ export default function ChatWindow({
else if (peer) subtitle = 'не в сети'
return (
<section className={`${className} min-w-0 flex-1 flex-col bg-panel/40`}>
<header className="flex items-center gap-3 border-b border-line/80 bg-panel/70 px-3 py-2.5 backdrop-blur sm:px-5 sm:py-3">
<section className={`${className} min-w-0 flex-1 flex-col bg-panel`}>
<header className="flex items-center gap-2 border-b-2 border-surface-3 bg-panel px-2 py-1.5 sm:px-3">
<button
type="button"
onClick={onBack}
aria-label="Назад к списку чатов"
className="inline-flex h-9 w-9 items-center justify-center rounded-xl text-muted transition hover:bg-surface-2 hover:text-fg md:hidden"
className="retro-btn inline-flex h-7 w-7 items-center justify-center md:hidden"
>
<IconArrowLeft className="h-5 w-5" />
<IconArrowLeft className="h-4 w-4" />
</button>
{isGroup ? (
@@ -324,16 +325,16 @@ export default function ChatWindow({
) : peer ? (
<Avatar name={title} seed={peer.id} size="md" online={isOnline} />
) : (
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-2 font-semibold text-muted">
<span className="bevel flex h-10 w-10 items-center justify-center bg-surface-2 font-bold text-muted">
#
</span>
)}
<div className="min-w-0 flex-1">
<h2 className="truncate font-display text-[0.9375rem] font-semibold text-fg">{title}</h2>
<p className="flex items-center gap-1.5 truncate text-xs text-muted">
<h2 className="truncate text-sm font-bold text-fg">{title}</h2>
<p className="flex items-center gap-1.5 truncate text-[11px] text-muted">
{peerIsTyping && !isGroup && <TypingDots />}
<span className={peerIsTyping && !isGroup ? 'text-accent' : ''}>{subtitle}</span>
<span className={peerIsTyping && !isGroup ? 'text-online' : ''}>{subtitle}</span>
</p>
</div>
@@ -341,9 +342,9 @@ export default function ChatWindow({
<button
type="button"
onClick={() => setMembersOpen(true)}
className="inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-line bg-surface px-3 py-2 text-xs font-medium text-muted transition hover:border-accent/50 hover:text-accent"
className="retro-btn inline-flex shrink-0 items-center gap-1 px-2 py-1 text-[11px] font-bold"
>
<IconUsers className="h-4 w-4" />
<IconUsers className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Участники</span>
</button>
)}
+38 -53
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import Avatar from './Avatar'
import { IconClose, IconSearch, IconTrash, IconUserPlus } from './icons'
import { IconSearch, IconTrash, IconUserPlus } from './icons'
import { api } from '../lib/api'
import { userTitle } from '../lib/format'
import type { Member, User } from '../lib/types'
@@ -95,36 +95,25 @@ export default function MembersDialog({
})
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-0 backdrop-blur-sm sm:items-center sm:p-6">
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 p-0 sm:items-center sm:p-4">
<button type="button" aria-label="Закрыть" onClick={onClose} className="absolute inset-0 cursor-default" />
<div
role="dialog"
aria-modal="true"
aria-labelledby="members-title"
className="relative flex max-h-[85vh] w-full max-w-md animate-rise flex-col overflow-hidden rounded-t-3xl border border-line bg-panel shadow-2xl sm:rounded-3xl"
className="bevel relative flex max-h-[85vh] w-full max-w-md animate-rise flex-col bg-panel shadow-[4px_4px_0_rgba(0,0,0,0.4)]"
>
<div className="flex items-center justify-between border-b border-line/80 px-5 py-4">
<div>
<h2 id="members-title" className="font-display text-base font-semibold text-fg">
Участники
</h2>
<p className="text-xs text-muted">
{loading ? 'Загрузка…' : `${members.length} в группе`}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Закрыть"
className="inline-flex h-9 w-9 items-center justify-center rounded-xl text-muted transition hover:bg-surface-2 hover:text-fg"
>
<IconClose className="h-5 w-5" />
<div className="win-title flex shrink-0 items-center gap-1.5 px-1.5 py-1">
<span className="flex-1 truncate text-xs font-bold tracking-wide">Участники</span>
<button type="button" onClick={onClose} aria-label="Закрыть" className="win-btn">
×
</button>
</div>
<div className="px-5 pt-4">
<div className="px-2 pt-2">
<p className="mb-2 text-[11px] text-muted">{loading ? 'Загрузка…' : `${members.length} в группе`}</p>
<div className="relative">
<IconSearch className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-faint" />
<IconSearch className="pointer-events-none absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2 text-surface-3" />
<label htmlFor="member-search" className="sr-only">
Поиск пользователей
</label>
@@ -135,41 +124,40 @@ export default function MembersDialog({
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Добавить участника: имя или @username"
className="w-full rounded-xl border border-line bg-surface/70 py-2.5 pr-3 pl-9 text-sm text-fg placeholder:text-faint focus:border-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20"
className="retro-input w-full py-1.5 pr-2 pl-7 text-xs"
/>
</div>
{error && <p className="mt-2 text-xs text-danger">{error}</p>}
{error && <p className="mt-1.5 text-[11px] text-danger">{error}</p>}
</div>
<div className="scrollbar-slim flex-1 overflow-y-auto px-3 py-3">
<div className="scrollbar-slim flex-1 overflow-y-auto px-1.5 py-2">
{trimmed.length > 0 && (
<div className="mb-3">
<p className="px-2.5 pb-1 text-[11px] font-semibold tracking-wide text-faint uppercase">Найдены</p>
<div className="mb-2">
<p className="px-1.5 pb-1 font-mono text-[10px] font-bold tracking-wide text-muted uppercase">
Найдены
</p>
{searching ? (
<p className="px-2.5 py-2 text-sm text-muted">Поиск…</p>
<p className="px-1.5 py-1.5 text-xs text-muted">Поиск…</p>
) : candidates.length === 0 ? (
<p className="px-2.5 py-2 text-sm text-muted">Некого добавить</p>
<p className="px-1.5 py-1.5 text-xs text-muted">Некого добавить</p>
) : (
<ul className="space-y-0.5">
<ul>
{candidates.map((user) => {
const busy = busyId === user.id
return (
<li
key={user.id}
className="flex items-center gap-3 rounded-xl px-2.5 py-2 transition hover:bg-surface-2/70"
>
<li key={user.id} className="flex items-center gap-2 px-1.5 py-1.5 hover:bg-surface-2">
<Avatar name={userTitle(user)} seed={user.id} size="md" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-fg">{userTitle(user)}</p>
<p className="truncate text-xs text-muted">@{user.username}</p>
<p className="truncate text-xs font-bold text-fg">{userTitle(user)}</p>
<p className="truncate font-mono text-[10px] text-muted">@{user.username}</p>
</div>
<button
type="button"
onClick={() => void handleAdd(user)}
disabled={busy}
className="inline-flex items-center gap-1 rounded-lg border border-line px-2.5 py-1.5 text-xs font-medium text-muted transition hover:border-accent/50 hover:text-accent disabled:opacity-50"
className="retro-btn inline-flex items-center gap-1 px-2 py-1 text-[11px] font-bold"
>
<IconUserPlus className="h-4 w-4" />
<IconUserPlus className="h-3.5 w-3.5" />
{busy ? '…' : 'Добавить'}
</button>
</li>
@@ -180,41 +168,38 @@ export default function MembersDialog({
</div>
)}
<p className="px-2.5 pb-1 text-[11px] font-semibold tracking-wide text-faint uppercase">В группе</p>
<p className="px-1.5 pb-1 font-mono text-[10px] font-bold tracking-wide text-muted uppercase">В группе</p>
{loading ? (
<div className="space-y-1.5 p-1">
<div className="space-y-1 p-1">
{[0, 1, 2].map((row) => (
<div key={row} className="flex items-center gap-3 rounded-xl p-2.5">
<span className="h-10 w-10 animate-pulse-soft rounded-full bg-surface-2" />
<span className="h-3 w-1/2 animate-pulse-soft rounded-full bg-surface-2" />
<div key={row} className="flex items-center gap-2 p-1.5">
<span className="h-9 w-9 animate-pulse-soft bg-surface-2" />
<span className="h-2.5 w-1/2 animate-pulse-soft bg-surface-2" />
</div>
))}
</div>
) : (
<ul className="space-y-0.5">
<ul>
{members.map((member) => {
const isSelf = member.user.id === currentUser.id
const canRemove = member.role !== 'owner' && (isOwner || isSelf)
const busy = busyId === member.user.id
return (
<li
key={member.user.id}
className="flex items-center gap-3 rounded-xl px-2.5 py-2 transition hover:bg-surface-2/70"
>
<li key={member.user.id} className="flex items-center gap-2 px-1.5 py-1.5 hover:bg-surface-2">
<Avatar name={userTitle(member.user)} seed={member.user.id} size="md" />
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate text-sm font-semibold text-fg">
<p className="flex items-center gap-1.5 truncate text-xs font-bold text-fg">
<span className="truncate">{userTitle(member.user)}</span>
{member.role === 'owner' && (
<span className="shrink-0 rounded-full border border-accent/40 bg-accent-ghost px-2 py-0.5 text-[10px] font-semibold text-accent">
<span className="shrink-0 bg-accent px-1 py-0.5 font-mono text-[9px] font-bold text-white">
владелец
</span>
)}
{isSelf && member.role !== 'owner' && (
<span className="shrink-0 text-[10px] font-medium text-faint">вы</span>
<span className="shrink-0 font-mono text-[9px] text-muted">вы</span>
)}
</p>
<p className="truncate text-xs text-muted">@{member.user.username}</p>
<p className="truncate font-mono text-[10px] text-muted">@{member.user.username}</p>
</div>
{canRemove && (
<button
@@ -223,9 +208,9 @@ export default function MembersDialog({
disabled={busy}
aria-label={isSelf ? 'Покинуть группу' : 'Удалить участника'}
title={isSelf ? 'Покинуть группу' : 'Удалить участника'}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-muted transition hover:bg-surface-2 hover:text-danger disabled:opacity-50"
className="retro-btn inline-flex h-7 w-7 items-center justify-center text-danger"
>
<IconTrash className="h-4 w-4" />
<IconTrash className="h-3.5 w-3.5" />
</button>
)}
</li>
+52 -59
View File
@@ -17,6 +17,9 @@ interface MessageBubbleProps {
const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🔥'] as const
const OWN_COLOR = '#000080'
const PEER_COLOR = '#008000'
function MediaImage({ media }: { media: Media }) {
const [src, setSrc] = useState(media.thumb_url ?? media.url)
@@ -31,18 +34,12 @@ function MediaImage({ media }: { media: Media }) {
}, [media.thumb_url, media.url])
return (
<a
href={media.url}
target="_blank"
rel="noreferrer"
className="block overflow-hidden rounded-xl"
title={media.filename}
>
<a href={media.url} target="_blank" rel="noreferrer" className="bevel-in block bg-surface-2 p-1" title={media.filename}>
<img
src={src}
alt={media.filename}
loading="lazy"
className="max-h-72 w-full max-w-xs object-cover transition duration-300 hover:brightness-110"
className="pixelated max-h-72 w-full max-w-xs object-cover"
/>
</a>
)
@@ -54,14 +51,14 @@ function MediaFile({ media }: { media: Media }) {
href={media.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-3 rounded-xl border border-line/70 bg-surface/60 px-3 py-2.5 transition hover:border-accent/50"
className="bevel-in flex items-center gap-2 bg-surface-2 px-2 py-1.5"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-surface-3 text-accent">
<IconFile className="h-5 w-5" />
<span className="flex h-8 w-8 shrink-0 items-center justify-center bg-surface text-accent">
<IconFile className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-fg">{media.filename}</span>
<span className="block text-[11px] text-muted">{formatBytes(media.size)}</span>
<span className="block truncate text-xs font-bold text-fg">{media.filename}</span>
<span className="block font-mono text-[10px] text-muted">{formatBytes(media.size)}</span>
</span>
</a>
)
@@ -80,6 +77,7 @@ export default function MessageBubble({
const name = sender ? userTitle(sender) : `Пользователь #${message.sender_id}`
const isSending = message.status === 'sending'
const isError = message.status === 'error'
const accent = isOwn ? OWN_COLOR : PEER_COLOR
const grouped = useMemo(() => {
const map = new Map<string, number[]>()
@@ -96,23 +94,22 @@ export default function MessageBubble({
return (
<div
className={`group flex w-full animate-msg-in items-end gap-2.5 px-3 sm:px-5 ${isOwn ? 'justify-end' : 'justify-start'}`}
className={`group flex w-full animate-msg-in items-start gap-2 px-2 sm:px-3 ${isOwn ? 'justify-end' : 'justify-start'}`}
>
{!isOwn &&
(showAvatar ? (
<Avatar name={name} seed={message.sender_id} size="sm" className="mb-0.5" />
<Avatar name={name} seed={message.sender_id} size="sm" className="mt-0.5" />
) : (
<span className="w-8 shrink-0" aria-hidden="true" />
))}
<div className={`flex max-w-[82%] flex-col sm:max-w-[68%] ${isOwn ? 'items-end' : 'items-start'}`}>
{!isOwn && showName && <span className="mb-1 px-1 text-xs font-semibold text-muted">{name}</span>}
<div className={`flex max-w-[86%] flex-col sm:max-w-[70%] ${isOwn ? 'items-end' : 'items-start'}`}>
<div className="relative">
<div
className={`absolute -top-9 z-10 hidden items-center gap-0.5 rounded-full border border-line bg-panel/95 px-1.5 py-1 shadow-xl shadow-black/40 backdrop-blur group-hover:flex group-focus-within:flex ${
className={`absolute -top-8 z-10 hidden items-center gap-0.5 bg-panel p-0.5 group-hover:flex group-focus-within:flex ${
isOwn ? 'right-0' : 'left-0'
}`}
style={{ border: '2px solid', borderColor: '#dfdfdf #0a0a0a #0a0a0a #dfdfdf' }}
>
{QUICK_EMOJIS.map((emoji) => (
<button
@@ -120,53 +117,35 @@ export default function MessageBubble({
type="button"
onClick={() => onReact(message, emoji)}
aria-label={`Реакция ${emoji}`}
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-base transition hover:scale-110 hover:bg-surface-2"
className="inline-flex h-6 w-6 items-center justify-center text-sm hover:bg-accent-ghost"
>
{emoji}
</button>
))}
<span className="mx-0.5 h-5 w-px bg-line" aria-hidden="true" />
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full text-muted" aria-hidden="true">
<IconSmile className="h-4 w-4" />
<span className="mx-0.5 h-5 w-px bg-surface-3" aria-hidden="true" />
<span className="inline-flex h-6 w-6 items-center justify-center text-muted" aria-hidden="true">
<IconSmile className="h-3.5 w-3.5" />
</span>
</div>
<div
className={[
'relative rounded-2xl px-3.5 py-2 text-[0.9375rem] leading-relaxed shadow-sm',
isOwn
? 'rounded-br-md bg-gradient-to-br from-accent to-accent-strong text-white'
: 'rounded-bl-md border border-line/70 bg-surface-2 text-fg',
isError ? 'ring-1 ring-danger/60' : '',
isSending ? 'opacity-80' : '',
].join(' ')}
>
{media && (
<div className={message.body ? 'mb-2' : ''}>
{isImage ? <MediaImage media={media} /> : <MediaFile media={media} />}
</div>
<div className={`bevel bg-surface ${isError ? 'bg-[#ffecec]' : ''} ${isSending ? 'opacity-70' : ''}`}>
<div className="flex items-center gap-2 border-b border-surface-3 bg-surface-2 px-2 py-0.5">
{showName && (
<span className="truncate text-[11px] font-bold" style={{ color: accent }}>
{isOwn ? 'Вы' : name}
</span>
)}
{message.body && <p className="whitespace-pre-wrap break-words text-balance">{message.body}</p>}
<div
className={`mt-1 flex items-center justify-end gap-1 text-[10.5px] ${
isOwn ? 'text-white/70' : 'text-faint'
}`}
>
{message.edited_at && <span className="mr-auto pl-0.5">изменено</span>}
<time dateTime={message.created_at}>{formatTime(message.created_at)}</time>
<time className="ml-auto shrink-0 font-mono text-[10px] text-muted" dateTime={message.created_at}>
{formatTime(message.created_at)}
</time>
{isOwn && (
<span className="inline-flex items-center" aria-hidden="true">
{isSending && (
<span className="h-3 w-3 animate-spin rounded-full border border-white/40 border-t-white/90" />
)}
<span className="inline-flex shrink-0 items-center text-muted" aria-hidden="true">
{isSending && <span className="h-2.5 w-2.5 animate-spin border border-surface-3 border-t-accent" />}
{isError && (
<button
type="button"
onClick={() => onRetry(message)}
className="pointer-events-auto inline-flex items-center gap-1 rounded-full text-danger transition hover:text-red-300"
className="pointer-events-auto inline-flex items-center text-danger"
aria-label="Повторить отправку"
title="Не отправлено — нажмите, чтобы повторить"
>
@@ -178,6 +157,22 @@ export default function MessageBubble({
</span>
)}
</div>
<div className="px-2 py-1.5">
{media && (
<div className={message.body ? 'mb-1.5' : ''}>
{isImage ? <MediaImage media={media} /> : <MediaFile media={media} />}
</div>
)}
{message.body && (
<p className="text-sm leading-snug break-words whitespace-pre-wrap text-balance" style={{ color: accent }}>
{message.body}
</p>
)}
{message.edited_at && <span className="mt-0.5 block font-mono text-[10px] text-muted">изменено</span>}
</div>
</div>
</div>
@@ -191,21 +186,19 @@ export default function MessageBubble({
type="button"
onClick={() => onReact(message, emoji)}
aria-label={`Реакция ${emoji}: ${users.length}`}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition ${
mine
? 'border-accent/50 bg-accent-ghost text-accent'
: 'border-line bg-surface-2 text-muted hover:border-accent/40 hover:text-fg'
className={`inline-flex items-center gap-1 px-1.5 py-0.5 text-xs ${
mine ? 'bevel-in bg-accent-ghost' : 'retro-btn'
}`}
>
<span className="text-sm leading-none">{emoji}</span>
{users.length > 1 && <span className="font-semibold">{users.length}</span>}
{users.length > 1 && <span className="font-mono font-bold">{users.length}</span>}
</button>
)
})}
</div>
)}
{isError && <span className="mt-1 px-1 text-[11px] text-danger">Не отправлено</span>}
{isError && <span className="mt-1 text-[10px] text-danger">Не отправлено</span>}
</div>
</div>
)
+12 -14
View File
@@ -64,11 +64,11 @@ export default function MessageList({
if (loading) {
return (
<div className="flex flex-1 flex-col justify-end gap-3 px-5 py-4">
<div className="flex flex-1 flex-col justify-end gap-2 bg-surface px-3 py-3">
{[0, 1, 2, 3].map((row) => (
<div key={row} className={`flex ${row % 2 === 0 ? 'justify-start' : 'justify-end'}`}>
<div
className={`h-10 animate-pulse-soft rounded-2xl bg-surface-2 ${row % 2 === 0 ? 'w-48' : 'w-40'}`}
className={`h-9 animate-pulse-soft bg-surface-2 ${row % 2 === 0 ? 'w-48' : 'w-40'}`}
style={{ animationDelay: `${row * 90}ms` }}
/>
</div>
@@ -79,20 +79,18 @@ export default function MessageList({
if (error && messages.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
<p className="text-sm text-danger">{error}</p>
<div className="flex flex-1 flex-col items-center justify-center gap-2 bg-surface px-6 text-center">
<p className="text-xs text-danger">{error}</p>
</div>
)
}
if (messages.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-line bg-surface-2 text-2xl">
👋
</div>
<p className="font-display text-base font-semibold text-fg">Пока пусто</p>
<p className="max-w-xs text-sm text-muted">Напишите первое сообщение — начните разговор.</p>
<div className="flex flex-1 flex-col items-center justify-center gap-2 bg-surface px-6 text-center">
<div className="bevel flex h-12 w-12 items-center justify-center bg-surface-2 text-2xl">👋</div>
<p className="text-sm font-bold text-fg">Пока пусто</p>
<p className="max-w-xs text-xs text-muted">Напишите первое сообщение — начните разговор.</p>
</div>
)
}
@@ -104,11 +102,11 @@ export default function MessageList({
role="log"
aria-live="polite"
aria-label="Сообщения"
className="scrollbar-slim flex flex-1 flex-col gap-1.5 overflow-y-auto py-4"
className="scrollbar-slim flex flex-1 flex-col gap-1.5 overflow-y-auto bg-surface py-3"
>
{loadingOlder && (
<div className="flex justify-center py-2" aria-hidden="true">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-surface-3 border-t-accent" />
<span className="h-4 w-4 animate-spin border-2 border-surface-3 border-t-accent" />
</div>
)}
@@ -125,8 +123,8 @@ export default function MessageList({
return (
<div key={message.temp_id ?? message.id} className="flex flex-col gap-1.5">
{dayChanged && (
<div className="my-3 flex items-center justify-center">
<span className="rounded-full border border-line/70 bg-surface/80 px-3 py-1 text-[11px] font-medium text-muted backdrop-blur">
<div className="my-2 flex items-center justify-center">
<span className="bevel-in bg-surface-2 px-2 py-0.5 font-mono text-[10px] font-bold text-muted">
{formatDayDivider(message.created_at)}
</span>
</div>
+47 -52
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import Avatar from './Avatar'
import { IconClose, IconSearch, IconUserPlus, IconUsers } from './icons'
import { IconSearch, IconUserPlus, IconUsers } from './icons'
import { api } from '../lib/api'
import { userTitle } from '../lib/format'
import type { Contact, User } from '../lib/types'
@@ -145,43 +145,32 @@ export default function NewChatDialog({
const canCreate = groupName.trim().length > 0 && selectedIds.size > 0 && !creating
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-0 backdrop-blur-sm sm:items-center sm:p-6">
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 p-0 sm:items-center sm:p-4">
<button type="button" aria-label="Закрыть" onClick={onClose} className="absolute inset-0 cursor-default" />
<div
role="dialog"
aria-modal="true"
aria-labelledby="new-chat-title"
className="relative flex max-h-[85vh] w-full max-w-md animate-rise flex-col overflow-hidden rounded-t-3xl border border-line bg-panel shadow-2xl sm:rounded-3xl"
className="bevel relative flex max-h-[85vh] w-full max-w-md animate-rise flex-col bg-panel shadow-[4px_4px_0_rgba(0,0,0,0.4)]"
>
<div className="flex items-center justify-between border-b border-line/80 px-5 py-4">
<div>
<h2 id="new-chat-title" className="font-display text-base font-semibold text-fg">
Новый чат
</h2>
<p className="text-xs text-muted">
{mode === 'dm' ? 'Найдите человека или выберите из контактов' : 'Соберите группу и добавьте участников'}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Закрыть"
className="inline-flex h-9 w-9 items-center justify-center rounded-xl text-muted transition hover:bg-surface-2 hover:text-fg"
>
<IconClose className="h-5 w-5" />
<div className="win-title flex shrink-0 items-center gap-1.5 px-1.5 py-1">
<span className="flex-1 truncate text-xs font-bold tracking-wide">Новый чат</span>
<button type="button" onClick={onClose} aria-label="Закрыть" className="win-btn">
×
</button>
</div>
<div className="px-5 pt-4">
<div className="mb-3 grid grid-cols-2 gap-1 rounded-xl border border-line bg-surface/60 p-1">
<div className="px-2 pt-2">
<p className="mb-2 text-[11px] text-muted">
{mode === 'dm' ? 'Найдите человека или выберите из контактов' : 'Соберите группу и добавьте участников'}
</p>
<div className="mb-2 grid grid-cols-2 gap-1">
{(['dm', 'group'] as const).map((value) => (
<button
key={value}
type="button"
onClick={() => setMode(value)}
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition ${
mode === value ? 'bg-accent-ghost text-accent ring-1 ring-accent/30' : 'text-muted hover:text-fg'
}`}
className={`px-3 py-1.5 text-xs font-bold ${mode === value ? 'bevel-in bg-surface-2' : 'retro-btn'}`}
>
{value === 'dm' ? 'Личный чат' : 'Группа'}
</button>
@@ -189,8 +178,8 @@ export default function NewChatDialog({
</div>
{mode === 'group' && (
<div className="relative mb-3">
<IconUsers className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-faint" />
<div className="relative mb-2">
<IconUsers className="pointer-events-none absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2 text-surface-3" />
<label htmlFor="group-name" className="sr-only">
Название группы
</label>
@@ -201,13 +190,13 @@ export default function NewChatDialog({
onChange={(event) => setGroupName(event.target.value)}
placeholder="Название группы"
maxLength={80}
className="w-full rounded-xl border border-line bg-surface/70 py-2.5 pr-3 pl-9 text-sm text-fg placeholder:text-faint focus:border-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20"
className="retro-input w-full py-1.5 pr-2 pl-7 text-xs"
/>
</div>
)}
<div className="relative">
<IconSearch className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-faint" />
<IconSearch className="pointer-events-none absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2 text-surface-3" />
<label htmlFor="new-chat-search" className="sr-only">
Поиск пользователей
</label>
@@ -218,28 +207,28 @@ export default function NewChatDialog({
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Имя или @username"
className="w-full rounded-xl border border-line bg-surface/70 py-2.5 pr-3 pl-9 text-sm text-fg placeholder:text-faint focus:border-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20"
className="retro-input w-full py-1.5 pr-2 pl-7 text-xs"
/>
</div>
{error && <p className="mt-2 text-xs text-danger">{error}</p>}
{error && <p className="mt-1.5 text-[11px] text-danger">{error}</p>}
</div>
<div className="scrollbar-slim flex-1 overflow-y-auto px-3 py-3">
<div className="scrollbar-slim flex-1 overflow-y-auto px-1.5 py-2">
{(loadingContacts && trimmed.length === 0) || (searching && list.length === 0) ? (
<div className="space-y-1.5 p-1">
<div className="space-y-1 p-1">
{[0, 1, 2].map((row) => (
<div key={row} className="flex items-center gap-3 rounded-xl p-2.5">
<span className="h-10 w-10 animate-pulse-soft rounded-full bg-surface-2" />
<span className="h-3 w-1/2 animate-pulse-soft rounded-full bg-surface-2" />
<div key={row} className="flex items-center gap-2 p-1.5">
<span className="h-9 w-9 animate-pulse-soft bg-surface-2" />
<span className="h-2.5 w-1/2 animate-pulse-soft bg-surface-2" />
</div>
))}
</div>
) : list.length === 0 ? (
<div className="px-6 py-12 text-center text-sm text-muted">
<div className="px-4 py-10 text-center text-xs text-muted">
{trimmed.length > 0 ? 'Пользователи не найдены' : 'Список контактов пуст'}
</div>
) : (
<ul className="space-y-0.5">
<ul>
{list.map((user) => {
const isContact = contactIds.has(user.id)
const busy = busyId === user.id
@@ -247,8 +236,8 @@ export default function NewChatDialog({
return (
<li key={user.id}>
<div
className={`flex items-center gap-3 rounded-xl px-2.5 py-2 transition ${
mode === 'group' && selected ? 'bg-accent-ghost ring-1 ring-accent/30' : 'hover:bg-surface-2/70'
className={`flex items-center gap-2 px-1.5 py-1.5 ${
mode === 'group' && selected ? 'bevel-in bg-accent-ghost' : 'hover:bg-surface-2'
}`}
>
{mode === 'group' && (
@@ -257,19 +246,25 @@ export default function NewChatDialog({
onClick={() => toggleMember(user.id)}
aria-pressed={selected}
aria-label={selected ? 'Убрать из группы' : 'Добавить в группу'}
className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition ${
selected ? 'border-accent bg-accent text-white' : 'border-line bg-surface text-transparent'
className={`inline-flex h-4 w-4 shrink-0 items-center justify-center ${
selected ? 'bevel-in bg-accent text-white' : 'bevel-in bg-surface text-transparent'
}`}
>
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" aria-hidden="true">
<path d="m5 12.5 4.5 4.5L19 7" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
<svg viewBox="0 0 24 24" className="h-3 w-3" fill="none" aria-hidden="true">
<path
d="m5 12.5 4.5 4.5L19 7"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="square"
strokeLinejoin="miter"
/>
</svg>
</button>
)}
<Avatar name={userTitle(user)} seed={user.id} size="md" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-fg">{userTitle(user)}</p>
<p className="truncate text-xs text-muted">@{user.username}</p>
<p className="truncate text-xs font-bold text-fg">{userTitle(user)}</p>
<p className="truncate font-mono text-[10px] text-muted">@{user.username}</p>
</div>
{mode === 'dm' && (
<>
@@ -278,9 +273,9 @@ export default function NewChatDialog({
type="button"
onClick={() => void handleAdd(user)}
disabled={busy}
className="inline-flex items-center gap-1 rounded-lg border border-line px-2.5 py-1.5 text-xs font-medium text-muted transition hover:border-accent/50 hover:text-accent disabled:opacity-50"
className="retro-btn inline-flex items-center gap-1 px-2 py-1 text-[11px] font-bold"
>
<IconUserPlus className="h-4 w-4" />
<IconUserPlus className="h-3.5 w-3.5" />
Добавить
</button>
)}
@@ -288,7 +283,7 @@ export default function NewChatDialog({
type="button"
onClick={() => void handleStart(user)}
disabled={busy}
className="rounded-lg bg-gradient-to-br from-accent to-accent-strong px-3 py-1.5 text-xs font-semibold text-white transition hover:brightness-110 disabled:opacity-50"
className="retro-btn-primary px-2.5 py-1 text-[11px] font-bold"
>
{busy ? '…' : 'Написать'}
</button>
@@ -303,15 +298,15 @@ export default function NewChatDialog({
</div>
{mode === 'group' && (
<div className="flex items-center justify-between gap-3 border-t border-line/80 px-5 py-3">
<span className="text-xs text-muted">
Выбрано: <span className="font-semibold text-fg">{selectedIds.size}</span>
<div className="flex items-center justify-between gap-2 border-t-2 border-surface-3 px-2 py-2">
<span className="text-[11px] text-muted">
Выбрано: <span className="font-mono font-bold text-fg">{selectedIds.size}</span>
</span>
<button
type="button"
onClick={() => void handleCreateGroup()}
disabled={!canCreate}
className="rounded-xl bg-gradient-to-br from-accent to-accent-strong px-4 py-2 text-sm font-semibold text-white transition enabled:hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
className="retro-btn-primary px-3 py-1.5 text-xs font-bold"
>
{creating ? 'Создание…' : 'Создать группу'}
</button>
+26 -35
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import Avatar, { GroupAvatar } from './Avatar'
import { IconClose, IconSearch } from './icons'
import { IconSearch } from './icons'
import { api } from '../lib/api'
import { formatListTime, peerForConversation, userTitle } from '../lib/format'
import type { Conversation, SearchHit, User } from '../lib/types'
@@ -102,34 +102,25 @@ export default function SearchDialog({
}
return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 p-0 backdrop-blur-sm sm:p-6 sm:pt-[12vh]">
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-0 sm:p-4 sm:pt-[12vh]">
<button type="button" aria-label="Закрыть поиск" onClick={onClose} className="absolute inset-0 cursor-default" />
<div
role="dialog"
aria-modal="true"
aria-labelledby="search-title"
className="relative flex max-h-[80vh] w-full max-w-lg animate-rise flex-col overflow-hidden rounded-t-3xl border border-line bg-panel shadow-2xl sm:rounded-3xl"
className="bevel relative flex max-h-[80vh] w-full max-w-lg animate-rise flex-col bg-panel shadow-[4px_4px_0_rgba(0,0,0,0.4)]"
>
<div className="flex items-center justify-between border-b border-line/80 px-5 py-4">
<div>
<h2 id="search-title" className="font-display text-base font-semibold text-fg">
Поиск по сообщениям
</h2>
<p className="text-xs text-muted">Найдите текст в любом разговоре</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Закрыть"
className="inline-flex h-9 w-9 items-center justify-center rounded-xl text-muted transition hover:bg-surface-2 hover:text-fg"
>
<IconClose className="h-5 w-5" />
<div className="win-title flex shrink-0 items-center gap-1.5 px-1.5 py-1">
<span className="flex-1 truncate text-xs font-bold tracking-wide">Поиск по сообщениям</span>
<button type="button" onClick={onClose} aria-label="Закрыть" className="win-btn">
×
</button>
</div>
<div className="px-5 pt-4">
<div className="px-2 pt-2">
<p className="mb-2 text-[11px] text-muted">Найдите текст в любом разговоре</p>
<div className="relative">
<IconSearch className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-faint" />
<IconSearch className="pointer-events-none absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2 text-surface-3" />
<label htmlFor="message-search" className="sr-only">
Поиск по сообщениям
</label>
@@ -140,27 +131,27 @@ export default function SearchDialog({
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Введите минимум 2 символа"
className="w-full rounded-xl border border-line bg-surface/70 py-2.5 pr-3 pl-9 text-sm text-fg placeholder:text-faint focus:border-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20"
className="retro-input w-full py-1.5 pr-2 pl-7 text-xs"
/>
</div>
{error && <p className="mt-2 text-xs text-danger">{error}</p>}
{error && <p className="mt-1.5 text-[11px] text-danger">{error}</p>}
</div>
<div className="scrollbar-slim flex-1 overflow-y-auto px-3 py-3">
<div className="scrollbar-slim flex-1 overflow-y-auto px-1.5 py-2">
{tooShort && (
<div className="px-6 py-12 text-center text-sm text-muted">
<div className="px-4 py-10 text-center text-xs text-muted">
Введите минимум {MIN_QUERY} символа, чтобы начать поиск
</div>
)}
{!tooShort && loading && (
<div className="space-y-1.5 p-1">
<div className="space-y-1 p-1">
{[0, 1, 2].map((row) => (
<div key={row} className="flex items-center gap-3 rounded-xl p-2.5">
<span className="h-10 w-10 animate-pulse-soft rounded-full bg-surface-2" />
<span className="flex-1 space-y-2">
<span className="block h-3 w-1/3 animate-pulse-soft rounded-full bg-surface-2" />
<span className="block h-3 w-3/4 animate-pulse-soft rounded-full bg-surface-2/70" />
<div key={row} className="flex items-center gap-2 p-1.5">
<span className="h-9 w-9 animate-pulse-soft bg-surface-2" />
<span className="flex-1 space-y-1.5">
<span className="block h-2.5 w-1/3 animate-pulse-soft bg-surface-2" />
<span className="block h-2.5 w-3/4 animate-pulse-soft bg-surface-2" />
</span>
</div>
))}
@@ -168,11 +159,11 @@ export default function SearchDialog({
)}
{!tooShort && showEmpty && (
<div className="px-6 py-12 text-center text-sm text-muted">Ничего не найдено</div>
<div className="px-4 py-10 text-center text-xs text-muted">Ничего не найдено</div>
)}
{!tooShort && !loading && results.length > 0 && (
<ul className="space-y-0.5">
<ul>
{results.map((hit) => {
const title = titleFor(hit)
const group = isGroupHit(hit)
@@ -181,7 +172,7 @@ export default function SearchDialog({
<button
type="button"
onClick={() => handlePick(hit)}
className="flex w-full items-start gap-3 rounded-xl px-2.5 py-2.5 text-left transition hover:bg-surface-2/70"
className="flex w-full items-start gap-2 px-1.5 py-1.5 text-left hover:bg-surface-2"
>
{group ? (
<GroupAvatar name={title} seed={hit.conversation.id} size="md" />
@@ -190,12 +181,12 @@ export default function SearchDialog({
)}
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-semibold text-fg">{title}</span>
<span className="shrink-0 text-[11px] text-faint">
<span className="truncate text-xs font-bold text-fg">{title}</span>
<span className="shrink-0 font-mono text-[10px] text-muted">
{formatListTime(hit.message.created_at)}
</span>
</span>
<span className="mt-0.5 line-clamp-2 text-xs text-muted">{previewFor(hit)}</span>
<span className="mt-0.5 line-clamp-2 text-[11px] text-muted">{previewFor(hit)}</span>
</span>
</button>
</li>
+63 -70
View File
@@ -89,46 +89,47 @@ export default function Sidebar({
}, [conversations, query, peers, directory, currentUser.id])
return (
<aside className={`${className} w-full flex-col border-line/80 bg-panel/60 md:w-[340px] md:border-r`}>
<div className="flex items-center justify-between gap-3 px-4 pt-4 pb-3">
<div className="flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-accent-strong text-white shadow-lg shadow-accent/20">
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" aria-hidden="true">
<aside className={`${className} w-full flex-col bg-panel md:w-[340px] md:border-r-2 md:border-surface-3`}>
<div className="flex items-center justify-between gap-2 border-b border-surface-3 px-2 py-2">
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center bg-accent text-white">
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
<path
d="M4 12.5h3l2-5 3.2 9 2.3-6 1.5 2H20"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.4"
strokeLinecap="square"
strokeLinejoin="miter"
/>
</svg>
</span>
<span className="font-display text-lg font-bold tracking-tight text-fg">Пульс</span>
<span className="truncate text-sm font-bold text-fg">Пульс</span>
</div>
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={onSearch}
aria-label="Поиск по сообщениям"
title="Поиск по сообщениям"
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-line bg-surface text-muted transition hover:border-accent/50 hover:text-accent"
className="retro-btn inline-flex h-7 w-7 items-center justify-center"
>
<IconSearch className="h-5 w-5" />
<IconSearch className="h-4 w-4" />
</button>
<button
type="button"
onClick={onNewChat}
aria-label="Новый чат"
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-line bg-surface text-muted transition hover:border-accent/50 hover:text-accent"
title="Новый чат"
className="retro-btn inline-flex h-7 w-7 items-center justify-center"
>
<IconPlus className="h-5 w-5" />
<IconPlus className="h-4 w-4" />
</button>
</div>
</div>
<div className="px-4 pb-2">
<div className="px-2 py-2">
<div className="relative">
<IconSearch className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-faint" />
<IconSearch className="pointer-events-none absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2 text-surface-3" />
<label htmlFor="chat-search" className="sr-only">
Поиск по чатам
</label>
@@ -138,20 +139,24 @@ export default function Sidebar({
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Поиск"
className="w-full rounded-xl border border-line bg-surface/70 py-2 pr-3 pl-9 text-sm text-fg placeholder:text-faint focus:border-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20"
className="retro-input w-full py-1.5 pr-2 pl-7 text-xs"
/>
</div>
</div>
<div className="scrollbar-slim flex-1 overflow-y-auto px-2 py-1">
<div className="scrollbar-slim flex-1 overflow-y-auto px-1.5 py-1">
{loading && (
<div className="space-y-1.5 p-1">
<div className="space-y-1 p-1">
{[0, 1, 2, 3, 4].map((row) => (
<div key={row} className="flex animate-pulse-soft items-center gap-3 rounded-xl p-2.5" style={{ animationDelay: `${row * 70}ms` }}>
<span className="h-11 w-11 rounded-full bg-surface-2" />
<span className="flex-1 space-y-2">
<span className="block h-3 w-1/2 rounded-full bg-surface-2" />
<span className="block h-3 w-3/4 rounded-full bg-surface-2/70" />
<div
key={row}
className="flex animate-pulse-soft items-center gap-2 p-1.5"
style={{ animationDelay: `${row * 70}ms` }}
>
<span className="h-9 w-9 bg-surface-2" />
<span className="flex-1 space-y-1.5">
<span className="block h-2.5 w-1/2 bg-surface-2" />
<span className="block h-2.5 w-3/4 bg-surface-2" />
</span>
</div>
))}
@@ -159,39 +164,31 @@ export default function Sidebar({
)}
{!loading && error && (
<div className="m-2 rounded-2xl border border-line bg-surface/60 p-4 text-center">
<p className="text-sm text-danger">{error}</p>
<button
type="button"
onClick={onRetry}
className="mt-3 rounded-lg border border-line px-3 py-1.5 text-xs font-medium text-fg transition hover:border-accent/50 hover:text-accent"
>
<div className="bevel-in m-1 bg-surface px-3 py-3 text-center">
<p className="text-xs text-danger">{error}</p>
<button type="button" onClick={onRetry} className="retro-btn mt-2 px-3 py-1 text-xs font-bold">
Повторить
</button>
</div>
)}
{!loading && !error && items.length === 0 && (
<div className="flex flex-col items-center gap-3 px-6 py-12 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-line bg-surface-2 text-accent">
<div className="flex flex-col items-center gap-2 px-4 py-10 text-center">
<div className="bevel flex h-12 w-12 items-center justify-center bg-surface-2 text-accent">
<IconPlus className="h-6 w-6" />
</div>
<p className="text-sm text-muted">
<p className="text-xs text-muted">
{query ? 'Ничего не найдено' : 'Пока нет чатов. Начните первый разговор.'}
</p>
{!query && (
<button
type="button"
onClick={onNewChat}
className="rounded-xl bg-gradient-to-br from-accent to-accent-strong px-4 py-2 text-sm font-semibold text-white transition hover:brightness-110"
>
<button type="button" onClick={onNewChat} className="retro-btn px-3 py-1.5 text-xs font-bold">
Новый чат
</button>
)}
</div>
)}
<ul className="space-y-0.5">
<ul>
{!loading &&
!error &&
items.map(({ conversation, peer, isGroup, title, preview }) => {
@@ -206,8 +203,8 @@ export default function Sidebar({
type="button"
onClick={() => onSelect(conversation.id)}
aria-current={selected ? 'true' : undefined}
className={`flex w-full items-center gap-3 rounded-xl px-2.5 py-2.5 text-left transition ${
selected ? 'bg-accent-ghost ring-1 ring-accent/30' : 'hover:bg-surface-2/70'
className={`flex w-full items-center gap-2 px-1.5 py-1.5 text-left ${
selected ? 'bevel-in bg-accent-ghost' : 'hover:bg-surface-2'
}`}
>
{isGroup ? (
@@ -222,17 +219,15 @@ export default function Sidebar({
)}
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className={`truncate text-sm font-semibold ${selected ? 'text-fg' : 'text-fg/90'}`}>
{title}
</span>
<span className="shrink-0 text-[11px] text-faint">{formatListTime(lastAt)}</span>
<span className="truncate text-xs font-bold text-fg">{title}</span>
<span className="shrink-0 font-mono text-[10px] text-muted">{formatListTime(lastAt)}</span>
</span>
<span className="mt-0.5 flex items-center justify-between gap-2">
<span className={`truncate text-xs ${isTyping ? 'text-accent' : 'text-muted'}`}>
<span className={`truncate text-[11px] ${isTyping ? 'text-online' : 'text-muted'}`}>
{isTyping ? 'печатает…' : preview}
</span>
{unread > 0 && (
<span className="inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-accent px-1.5 text-[11px] font-bold text-white">
<span className="inline-flex h-4 min-w-4 shrink-0 items-center justify-center bg-danger px-1 font-mono text-[10px] font-bold text-white">
{unread > 99 ? '99+' : unread}
</span>
)}
@@ -246,25 +241,25 @@ export default function Sidebar({
</div>
{pushSupported && (
<div className="border-t border-line/80 px-4 py-2.5">
<div className="border-t border-surface-3 px-2 py-2">
{permission === 'granted' ? (
<div className="flex items-center gap-2.5">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-ghost text-accent">
<IconBell className="h-4 w-4" />
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center bg-accent-ghost text-accent">
<IconBell className="h-3.5 w-3.5" />
</span>
<span className="flex items-center gap-1.5 text-xs font-medium text-fg/90">
<IconCheck className="h-3.5 w-3.5 text-accent" />
<span className="flex items-center gap-1 text-[11px] font-bold text-fg">
<IconCheck className="h-3.5 w-3.5 text-online" />
Уведомления включены
</span>
</div>
) : permission === 'denied' ? (
<div className="flex items-start gap-2.5">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-surface-2 text-faint">
<IconBell className="h-4 w-4" />
<div className="flex items-start gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center bg-surface-2 text-surface-3">
<IconBell className="h-3.5 w-3.5" />
</span>
<span className="min-w-0">
<span className="block text-xs font-medium text-fg/80">Уведомления заблокированы</span>
<span className="mt-0.5 block text-[11px] leading-snug text-faint">
<span className="block text-[11px] font-bold text-fg">Уведомления заблокированы</span>
<span className="mt-0.5 block text-[10px] leading-snug text-muted">
Разрешите их в настройках браузера
</span>
</span>
@@ -274,38 +269,36 @@ export default function Sidebar({
type="button"
onClick={() => void handleEnablePush()}
disabled={subscribing}
className="flex w-full items-center gap-2.5 rounded-xl border border-line bg-surface px-3 py-2 text-left text-xs font-medium text-fg transition hover:border-accent/50 hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20 disabled:cursor-not-allowed disabled:opacity-60"
className="retro-btn flex w-full items-center gap-2 px-2 py-1.5 text-left text-[11px] font-bold"
>
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-ghost text-accent">
<IconBell className="h-4 w-4" />
<span className="flex h-6 w-6 items-center justify-center bg-accent-ghost text-accent">
<IconBell className="h-3.5 w-3.5" />
</span>
{subscribing ? 'Включаем…' : 'Включить уведомления'}
</button>
)}
{iosInstallRequired && permission !== 'granted' && (
<p className="mt-2 text-[11px] leading-snug text-faint">
<p className="mt-1.5 text-[10px] leading-snug text-muted">
На iOS уведомления работают только после добавления на экран «Домой».
</p>
)}
</div>
)}
<div className="flex items-center gap-3 border-t border-line/80 px-4 py-3">
<div className="flex items-center gap-2 border-t border-surface-3 px-2 py-2">
<Avatar name={currentUser.display_name || currentUser.username} seed={currentUser.id} size="md" online />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-fg">
{currentUser.display_name || currentUser.username}
</p>
<p className="truncate text-xs text-muted">@{currentUser.username}</p>
<p className="truncate text-xs font-bold text-fg">{currentUser.display_name || currentUser.username}</p>
<p className="truncate font-mono text-[10px] text-muted">@{currentUser.username}</p>
</div>
<button
type="button"
onClick={onLogout}
aria-label="Выйти"
title="Выйти"
className="inline-flex h-9 w-9 items-center justify-center rounded-xl text-muted transition hover:bg-surface-2 hover:text-danger"
className="retro-btn inline-flex h-7 w-7 items-center justify-center"
>
<IconLogout className="h-5 w-5" />
<IconLogout className="h-4 w-4" />
</button>
</div>
</aside>
+43 -43
View File
@@ -4,7 +4,7 @@ interface IconProps {
export function IconSend({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<svg className={className} viewBox="0 0 24 24" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<path
d="M4.6 11.2 19 4.4c.8-.4 1.6.4 1.3 1.2l-5.1 14.1c-.3.9-1.6.9-1.9 0l-1.6-4.7a1 1 0 0 0-.6-.6l-4.7-1.6c-.9-.3-.9-1.6.2-1.6Z"
fill="currentColor"
@@ -15,85 +15,85 @@ export function IconSend({ className }: IconProps) {
export function IconSearch({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<svg className={className} viewBox="0 0 24 24" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" stroke="currentColor" strokeWidth="1.7" />
<path d="m16 16 4 4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
<path d="m16 16 4 4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="square" />
</svg>
)
}
export function IconPlus({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<svg className={className} viewBox="0 0 24 24" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="square" />
</svg>
)
}
export function IconLogout({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<svg className={className} viewBox="0 0 24 24" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<path
d="M9 5H6.5A1.5 1.5 0 0 0 5 6.5v11A1.5 1.5 0 0 0 6.5 19H9"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinecap="square"
/>
<path d="M15 8.5 18.5 12 15 15.5M18 12H9" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
<path d="M15 8.5 18.5 12 15 15.5M18 12H9" stroke="currentColor" strokeWidth="1.7" strokeLinecap="square" strokeLinejoin="miter" />
</svg>
)
}
export function IconClose({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m6 6 12 12M18 6 6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<svg className={className} viewBox="0 0 24 24" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<path d="m6 6 12 12M18 6 6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="square" />
</svg>
)
}
export function IconArrowLeft({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
<svg className={className} viewBox="0 0 24 24" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="square" strokeLinejoin="miter" />
</svg>
)
}
export function IconCheck({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m5 12.5 4.5 4.5L19 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<svg className={className} viewBox="0 0 24 24" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<path d="m5 12.5 4.5 4.5L19 7" stroke="currentColor" strokeWidth="2" strokeLinecap="square" strokeLinejoin="miter" />
</svg>
)
}
export function IconCheckCheck({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m2 12.5 4.5 4.5L15 8.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="m9.5 16.5 1 1L22 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<svg className={className} viewBox="0 0 24 24" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<path d="m2 12.5 4.5 4.5L15 8.5" stroke="currentColor" strokeWidth="2" strokeLinecap="square" strokeLinejoin="miter" />
<path d="m9.5 16.5 1 1L22 6" stroke="currentColor" strokeWidth="2" strokeLinecap="square" strokeLinejoin="miter" />
</svg>
)
}
export function IconAlert({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<svg className={className} viewBox="0 0 24 24" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.7" />
<path d="M12 7.5v5M12 16h.01" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" />
<path d="M12 7.5v5M12 16h.01" stroke="currentColor" strokeWidth="1.9" strokeLinecap="square" />
</svg>
)
}
export function IconChat({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<svg className={className} viewBox="0 0 24 24" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<path
d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v7a2.5 2.5 0 0 1-2.5 2.5H9l-4.2 3.4A.7.7 0 0 1 4 18.8V6.5Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
strokeLinejoin="miter"
/>
</svg>
)
@@ -101,33 +101,33 @@ export function IconChat({ className }: IconProps) {
export function IconUserPlus({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<svg className={className} viewBox="0 0 24 24" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<circle cx="10" cy="8" r="3.4" stroke="currentColor" strokeWidth="1.6" />
<path d="M4.5 19a5.5 5.5 0 0 1 11 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M18.5 8v6M15.5 11h6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M4.5 19a5.5 5.5 0 0 1 11 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="square" />
<path d="M18.5 8v6M15.5 11h6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="square" />
</svg>
)
}
export function IconUsers({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<svg className={className} viewBox="0 0 24 24" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<circle cx="9" cy="8.5" r="3.2" stroke="currentColor" strokeWidth="1.6" />
<path d="M3.5 19a5.5 5.5 0 0 1 11 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M16 5.6a3.2 3.2 0 0 1 0 5.8M17.5 13.6A5.5 5.5 0 0 1 20.5 19" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M3.5 19a5.5 5.5 0 0 1 11 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="square" />
<path d="M16 5.6a3.2 3.2 0 0 1 0 5.8M17.5 13.6A5.5 5.5 0 0 1 20.5 19" stroke="currentColor" strokeWidth="1.6" strokeLinecap="square" />
</svg>
)
}
export function IconPaperclip({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<svg className={className} viewBox="0 0 24 24" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<path
d="M8.5 12.5 14 7a3 3 0 0 1 4.2 4.2l-6.6 6.6a5 5 0 0 1-7.1-7.1l6.4-6.4"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
strokeLinecap="square"
strokeLinejoin="miter"
/>
</svg>
)
@@ -135,48 +135,48 @@ export function IconPaperclip({ className }: IconProps) {
export function IconFile({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<svg className={className} viewBox="0 0 24 24" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<path
d="M6 3.5h7l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
strokeLinejoin="miter"
/>
<path d="M13 3.5v5h5" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M13 3.5v5h5" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="miter" />
</svg>
)
}
export function IconTrash({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 7h14M10 7V5.5A1.5 1.5 0 0 1 11.5 4h1A1.5 1.5 0 0 1 14 5.5V7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M6.5 7l.8 12a1.5 1.5 0 0 0 1.5 1.4h6.4a1.5 1.5 0 0 0 1.5-1.4l.8-12" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M10.5 11v6M13.5 11v6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<svg className={className} viewBox="0 0 24 24" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<path d="M5 7h14M10 7V5.5A1.5 1.5 0 0 1 11.5 4h1A1.5 1.5 0 0 1 14 5.5V7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="square" />
<path d="M6.5 7l.8 12a1.5 1.5 0 0 0 1.5 1.4h6.4a1.5 1.5 0 0 0 1.5-1.4l.8-12" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="miter" />
<path d="M10.5 11v6M13.5 11v6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="square" />
</svg>
)
}
export function IconBell({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<svg className={className} viewBox="0 0 24 24" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<path
d="M6 9.5a6 6 0 0 1 12 0c0 3.2.7 5 1.4 6.1.3.5 0 1.1-.6 1.1H5.2c-.6 0-.9-.6-.6-1.1C5.3 14.5 6 12.7 6 9.5Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
strokeLinejoin="miter"
/>
<path d="M10 19.5a2 2 0 0 0 4 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M10 19.5a2 2 0 0 0 4 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="square" />
</svg>
)
}
export function IconSmile({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<svg className={className} viewBox="0 0 24 24" fill="none" shapeRendering="crispEdges" aria-hidden="true">
<circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.6" />
<path d="M8.5 14.2a4.2 4.2 0 0 0 7 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M9.3 9.5h.01M14.7 9.5h.01" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" />
<path d="M8.5 14.2a4.2 4.2 0 0 0 7 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="square" />
<path d="M9.3 9.5h.01M14.7 9.5h.01" stroke="currentColor" strokeWidth="2.1" strokeLinecap="square" />
</svg>
)
}
+330 -60
View File
@@ -1,36 +1,45 @@
@import "tailwindcss";
/* ==========================================================================
Webcore / Old Web theme — Windows 98 + early GeoCities/Y2K
Palette, bevels, retro scrollbars, desktop wallpaper, animations.
========================================================================== */
@theme {
--font-sans: "Manrope", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-display: "Sora", "Manrope", ui-sans-serif, system-ui, sans-serif;
--font-sans: "MS Sans Serif", "Tahoma", "Verdana", Geneva, sans-serif;
--font-display: "MS Sans Serif", "Tahoma", "Verdana", Geneva, sans-serif;
--font-mono: "Courier New", "Lucida Console", monospace;
--color-ink: #080a0f;
--color-panel: #0e1118;
--color-surface: #141924;
--color-surface-2: #1b212e;
--color-surface-3: #252d3d;
--color-line: #222a38;
--color-fg: #e9ecf4;
--color-muted: #8a93a8;
--color-faint: #5a6377;
--color-accent: #5b8cff;
--color-accent-strong: #3f6dff;
--color-accent-ghost: #172242;
--color-online: #34d399;
--color-away: #fbbf24;
--color-danger: #f87171;
/* Classic Win98 palette */
--color-ink: #008080; /* desktop teal */
--color-panel: #c0c0c0; /* classic gray chrome */
--color-surface: #ffffff; /* white work area */
--color-surface-2: #dfdfdf; /* light gray */
--color-surface-3: #808080; /* dark gray */
--color-line: #808080; /* borders */
--color-fg: #000000; /* black text */
--color-muted: #404040;
--color-faint: #808080;
--color-accent: #000080; /* navy — links / own messages */
--color-accent-strong: #1084d0; /* active title gradient */
--color-accent-ghost: #d6e4f0; /* selected row tint */
--color-online: #008000; /* IRC green */
--color-away: #808000;
--color-danger: #cc0000;
--animate-msg-in: msg-in 0.24s cubic-bezier(0.2, 0.8, 0.2, 1) both;
--animate-fade-in: fade-in 0.25s ease-out both;
--animate-rise: rise 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) both;
--animate-pulse-soft: pulse-soft 1.5s ease-in-out infinite;
--animate-bounce-dot: bounce-dot 1s ease-in-out infinite;
--animate-msg-in: msg-in 0.18s steps(3, end) both;
--animate-fade-in: fade-in 0.2s steps(4, end) both;
--animate-rise: rise 0.22s steps(4, end) both;
--animate-pulse-soft: pulse-soft 1.2s steps(2, end) infinite;
--animate-bounce-dot: bounce-dot 1s steps(2, end) infinite;
--animate-blink: blink 1s steps(1, end) infinite;
--animate-marquee: marquee 20s linear infinite;
}
@keyframes msg-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.985);
transform: translateY(4px);
}
to {
opacity: 1;
@@ -50,7 +59,7 @@
@keyframes rise {
from {
opacity: 0;
transform: translateY(14px);
transform: translateY(8px);
}
to {
opacity: 1;
@@ -60,7 +69,7 @@
@keyframes pulse-soft {
50% {
opacity: 0.4;
opacity: 0.35;
}
}
@@ -69,7 +78,7 @@
60%,
100% {
transform: translateY(0);
opacity: 0.45;
opacity: 0.4;
}
30% {
transform: translateY(-3px);
@@ -77,6 +86,26 @@
}
}
@keyframes blink {
0%,
49% {
opacity: 1;
}
50%,
100% {
opacity: 0;
}
}
@keyframes marquee {
from {
transform: translateX(100%);
}
to {
transform: translateX(-100%);
}
}
@layer base {
html,
body,
@@ -86,60 +115,301 @@
body {
margin: 0;
color-scheme: dark;
color-scheme: light;
background-color: var(--color-ink);
/* tiled desktop wallpaper: subtle checkerboard over teal */
background-image:
radial-gradient(1100px 640px at 12% -12%, color-mix(in srgb, var(--color-accent) 16%, transparent), transparent 62%),
radial-gradient(900px 560px at 100% 4%, color-mix(in srgb, var(--color-online) 8%, transparent), transparent 58%),
radial-gradient(800px 800px at 50% 120%, color-mix(in srgb, var(--color-accent-strong) 10%, transparent), transparent 60%);
linear-gradient(45deg, rgba(0, 0, 0, 0.07) 25%, transparent 25%, transparent 75%, rgba(0, 0, 0, 0.07) 75%),
linear-gradient(45deg, rgba(0, 0, 0, 0.07) 25%, transparent 25%, transparent 75%, rgba(0, 0, 0, 0.07) 75%);
background-size: 8px 8px;
background-position:
0 0,
4px 4px;
background-attachment: fixed;
color: var(--color-fg);
font-family: var(--font-sans);
font-feature-settings: "ss01" on, "cv11" on;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-size: 13px;
line-height: 1.45;
-webkit-font-smoothing: none;
-moz-osx-font-smoothing: unset;
text-rendering: optimizeSpeed;
}
/* No rounded corners anywhere — straight edges only. */
*,
*::before,
*::after {
border-radius: 0 !important;
}
::selection {
background: color-mix(in srgb, var(--color-accent) 38%, transparent);
background: #000080;
color: #ffffff;
}
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
outline: 1px dotted #000000;
outline-offset: -3px;
}
button {
font: inherit;
cursor: pointer;
}
button:disabled {
cursor: default;
}
input,
textarea,
select {
font: inherit;
caret-color: #000000;
}
a {
color: #0000ee;
text-decoration: underline;
}
a:visited {
color: #551a8b;
}
/* ---- Retro scrollbars (WebKit + Firefox) ---- */
* {
scrollbar-width: auto;
scrollbar-color: #c0c0c0 #dfdfdf;
}
::-webkit-scrollbar {
width: 16px;
height: 16px;
}
::-webkit-scrollbar-track {
background: #dfdfdf;
background-image: linear-gradient(45deg, #ffffff 25%, transparent 25%, transparent 75%, #ffffff 75%);
background-size: 2px 2px;
}
::-webkit-scrollbar-thumb {
background: #c0c0c0;
border: 2px solid;
border-color: #ffffff #0a0a0a #0a0a0a #ffffff;
box-shadow:
inset -1px -1px 0 #808080,
inset 1px 1px 0 #dfdfdf;
}
::-webkit-scrollbar-thumb:active {
border-color: #0a0a0a #ffffff #ffffff #0a0a0a;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #dfdfdf;
}
::-webkit-scrollbar-corner {
background: #dfdfdf;
}
::-webkit-scrollbar-button:single-button {
display: block;
width: 16px;
height: 16px;
background-color: #c0c0c0;
border: 2px solid;
border-color: #ffffff #0a0a0a #0a0a0a #ffffff;
box-shadow:
inset -1px -1px 0 #808080,
inset 1px 1px 0 #dfdfdf;
background-repeat: no-repeat;
background-position: center;
}
::-webkit-scrollbar-button:single-button:vertical:decrement {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Cpath d='M8 4l4 6H4z' fill='%23000'/%3E%3C/svg%3E");
}
::-webkit-scrollbar-button:single-button:vertical:increment {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Cpath d='M8 12l-4-6h8z' fill='%23000'/%3E%3C/svg%3E");
}
::-webkit-scrollbar-button:single-button:horizontal:decrement {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Cpath d='M4 8l6-4v8z' fill='%23000'/%3E%3C/svg%3E");
}
::-webkit-scrollbar-button:single-button:horizontal:increment {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Cpath d='M12 8l-6 4V4z' fill='%23000'/%3E%3C/svg%3E");
}
}
@layer utilities {
/* ---- Win98 bevels ---- */
.bevel {
border: 2px solid;
border-color: #dfdfdf #0a0a0a #0a0a0a #dfdfdf;
box-shadow:
inset 1px 1px 0 #ffffff,
inset -1px -1px 0 #808080;
}
.bevel-in {
border: 2px solid;
border-color: #0a0a0a #dfdfdf #dfdfdf #0a0a0a;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #ffffff;
}
.bevel-thin {
border: 1px solid;
border-color: #ffffff #808080 #808080 #ffffff;
}
.bevel-thin-in {
border: 1px solid;
border-color: #808080 #ffffff #ffffff #808080;
}
/* Raised button that presses in on :active */
.retro-btn {
background: #c0c0c0;
border: 2px solid;
border-color: #dfdfdf #0a0a0a #0a0a0a #dfdfdf;
box-shadow:
inset 1px 1px 0 #ffffff,
inset -1px -1px 0 #808080;
color: #000000;
}
.retro-btn:active:not(:disabled) {
border-color: #0a0a0a #dfdfdf #dfdfdf #0a0a0a;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #ffffff;
transform: translate(1px, 1px);
}
.retro-btn:disabled {
color: #808080;
text-shadow: 1px 1px 0 #ffffff;
}
.retro-btn-primary {
background: #000080;
color: #ffffff;
border: 2px solid;
border-color: #4a4aff #000040 #000040 #4a4aff;
box-shadow:
inset 1px 1px 0 #6a6aff,
inset -1px -1px 0 #000040;
}
.retro-btn-primary:active:not(:disabled) {
border-color: #000040 #4a4aff #4a4aff #000040;
box-shadow:
inset 1px 1px 0 #000040,
inset -1px -1px 0 #6a6aff;
transform: translate(1px, 1px);
}
.retro-btn-primary:disabled {
background: #808080;
color: #c0c0c0;
}
/* Sunken white input / textarea */
.retro-input {
background: #ffffff;
border: 2px solid;
border-color: #0a0a0a #dfdfdf #dfdfdf #0a0a0a;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #ffffff;
color: #000000;
outline: none;
}
.retro-input::placeholder {
color: #808080;
}
.retro-input:focus {
outline: 1px dotted #000000;
outline-offset: -4px;
}
/* Title bar gradient */
.win-title {
background: linear-gradient(90deg, #000080 0%, #1084d0 100%);
color: #ffffff;
}
.win-title-inactive {
background: linear-gradient(90deg, #808080 0%, #b5b5b5 100%);
color: #dfdfdf;
}
/* Small title-bar control button */
.win-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 16px;
background: #c0c0c0;
border: 1px solid;
border-color: #ffffff #0a0a0a #0a0a0a #ffffff;
box-shadow:
inset -1px -1px 0 #808080,
inset 1px 1px 0 #dfdfdf;
color: #000000;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1;
}
.win-btn:active {
border-color: #0a0a0a #ffffff #ffffff #0a0a0a;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #dfdfdf;
}
.pixelated {
image-rendering: pixelated;
}
.blink {
animation: var(--animate-blink);
}
.retro-link {
color: #0000ee;
text-decoration: underline;
}
.retro-link:visited {
color: #551a8b;
}
/* Marquee-style ticker */
.marquee {
overflow: hidden;
white-space: nowrap;
}
.marquee > span {
display: inline-block;
padding-left: 100%;
animation: var(--animate-marquee);
}
.scrollbar-slim {
scrollbar-width: thin;
scrollbar-color: var(--color-surface-3) transparent;
}
.scrollbar-slim::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scrollbar-slim::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-slim::-webkit-scrollbar-thumb {
background: var(--color-surface-3);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.scrollbar-slim::-webkit-scrollbar-thumb:hover {
background: #33405a;
background-clip: padding-box;
scrollbar-width: auto;
scrollbar-color: #c0c0c0 #dfdfdf;
}
.no-scrollbar {
+67 -66
View File
@@ -19,9 +19,9 @@ function PulseMark({ className }: { className?: string }) {
<path
d="M4 12.5h3l2-5 3.2 9 2.3-6 1.5 2H20"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.4"
strokeLinecap="square"
strokeLinejoin="miter"
/>
</svg>
)
@@ -80,53 +80,39 @@ export default function AuthScreen() {
}
return (
<div className="grid min-h-full grid-cols-1 lg:grid-cols-[1.05fr_1fr]">
<aside className="relative hidden overflow-hidden border-r border-line/70 bg-panel/50 lg:flex">
<div className="absolute inset-0 opacity-90" aria-hidden="true">
<div className="absolute -top-24 -left-16 h-96 w-96 rounded-full bg-accent/25 blur-3xl" />
<div className="absolute right-0 bottom-0 h-80 w-80 rounded-full bg-online/10 blur-3xl" />
</div>
<div className="relative z-10 flex flex-col justify-between p-12">
<div className="flex items-center gap-3">
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-gradient-to-br from-accent to-accent-strong text-white shadow-xl shadow-accent/25">
<PulseMark className="h-6 w-6" />
<div className="flex min-h-full flex-col items-center justify-center gap-4 px-4 py-8">
<div className="w-full max-w-md">
<div className="bevel bg-panel">
<div className="win-title flex items-center gap-1.5 px-1.5 py-1">
<span className="flex h-4 w-4 items-center justify-center bg-white text-accent">
<PulseMark className="h-3 w-3" />
</span>
<span className="flex-1 truncate text-xs font-bold tracking-wide">
Пульс — {mode === 'login' ? 'Вход в систему' : 'Регистрация'}
</span>
<span className="win-btn" aria-hidden="true">
_
</span>
<span className="win-btn" aria-hidden="true">
□
</span>
<span className="win-btn" aria-hidden="true">
×
</span>
<span className="font-display text-2xl font-bold tracking-tight">Пульс</span>
</div>
<div className="max-w-md space-y-4">
<h1 className="font-display text-4xl leading-tight font-bold text-balance">
Тёплый мессенджер для своих
</h1>
<p className="text-base leading-relaxed text-muted">
Личные диалоги, живая доставка и статусы прочтения. Всё работает на вашем домашнем сервере —
без лишнего шума и слежки.
</p>
</div>
<ul className="flex flex-wrap gap-2 text-xs text-muted">
<li className="rounded-full border border-line bg-surface/60 px-3 py-1.5">Мгновенная доставка</li>
<li className="rounded-full border border-line bg-surface/60 px-3 py-1.5">Печатает и онлайн</li>
<li className="rounded-full border border-line bg-surface/60 px-3 py-1.5">Галочки прочтения</li>
</ul>
</div>
</aside>
<main className="flex items-center justify-center px-5 py-10 sm:px-8">
<div className="w-full max-w-sm animate-rise">
<div className="mb-8 flex items-center gap-3 lg:hidden">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-accent to-accent-strong text-white shadow-lg shadow-accent/25">
<div className="p-3">
<div className="mb-3 flex items-center gap-2 border-b border-surface-3 pb-2">
<span className="flex h-8 w-8 items-center justify-center bg-accent text-white">
<PulseMark className="h-5 w-5" />
</span>
<span className="font-display text-xl font-bold tracking-tight">Пульс</span>
<div className="min-w-0">
<p className="text-sm font-bold text-fg">Пульс</p>
<p className="text-[11px] text-muted">Тёплый мессенджер для своих</p>
</div>
</div>
<h2 className="font-display text-2xl font-bold text-fg">
{mode === 'login' ? 'С возвращением' : 'Создайте аккаунт'}
</h2>
<p className="mt-1 text-sm text-muted">
{mode === 'login' ? 'Войдите, чтобы продолжить общение' : 'Это займёт меньше минуты'}
</p>
<div className="mt-6 grid grid-cols-2 gap-1 rounded-2xl border border-line bg-surface/60 p-1" role="tablist">
<div className="mb-3 grid grid-cols-2 gap-1" role="tablist">
{(['login', 'register'] as const).map((item) => (
<button
key={item}
@@ -134,10 +120,8 @@ export default function AuthScreen() {
role="tab"
aria-selected={mode === item}
onClick={() => switchMode(item)}
className={`rounded-xl px-3 py-2 text-sm font-semibold transition ${
mode === item
? 'bg-gradient-to-br from-accent to-accent-strong text-white shadow-lg shadow-accent/20'
: 'text-muted hover:text-fg'
className={`px-3 py-1.5 text-xs font-bold ${
mode === item ? 'bevel-in bg-surface-2' : 'retro-btn'
}`}
>
{item === 'login' ? 'Вход' : 'Регистрация'}
@@ -145,9 +129,9 @@ export default function AuthScreen() {
))}
</div>
<form onSubmit={handleSubmit} className="mt-6 space-y-4" noValidate>
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
<div>
<label htmlFor="username" className="mb-1.5 block text-sm font-medium text-fg">
<label htmlFor="username" className="mb-1 block text-xs font-bold text-fg">
Имя пользователя
</label>
<input
@@ -159,18 +143,18 @@ export default function AuthScreen() {
aria-invalid={Boolean(errors.username)}
aria-describedby="username-hint"
placeholder="your_name"
className={`w-full rounded-xl border bg-surface/70 px-3.5 py-2.5 text-sm text-fg placeholder:text-faint focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20 ${
errors.username ? 'border-danger/70' : 'border-line focus:border-accent/60'
className={`retro-input w-full px-2 py-1.5 text-sm ${
errors.username ? 'bg-[#ffecec]' : ''
}`}
/>
<p id="username-hint" className={`mt-1.5 text-xs ${errors.username ? 'text-danger' : 'text-faint'}`}>
<p id="username-hint" className={`mt-1 text-[11px] ${errors.username ? 'text-danger' : 'text-muted'}`}>
{errors.username ?? '3–32 символа: латиница, цифры и «_»'}
</p>
</div>
{mode === 'register' && (
<div>
<label htmlFor="displayName" className="mb-1.5 block text-sm font-medium text-fg">
<label htmlFor="displayName" className="mb-1 block text-xs font-bold text-fg">
Отображаемое имя
</label>
<input
@@ -182,13 +166,13 @@ export default function AuthScreen() {
aria-invalid={Boolean(errors.displayName)}
aria-describedby="displayName-hint"
placeholder="Анна Смирнова"
className={`w-full rounded-xl border bg-surface/70 px-3.5 py-2.5 text-sm text-fg placeholder:text-faint focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20 ${
errors.displayName ? 'border-danger/70' : 'border-line focus:border-accent/60'
className={`retro-input w-full px-2 py-1.5 text-sm ${
errors.displayName ? 'bg-[#ffecec]' : ''
}`}
/>
<p
id="displayName-hint"
className={`mt-1.5 text-xs ${errors.displayName ? 'text-danger' : 'text-faint'}`}
className={`mt-1 text-[11px] ${errors.displayName ? 'text-danger' : 'text-muted'}`}
>
{errors.displayName ?? 'Так вас увидят собеседники'}
</p>
@@ -196,10 +180,10 @@ export default function AuthScreen() {
)}
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-fg">
<label htmlFor="password" className="mb-1 block text-xs font-bold text-fg">
Пароль
</label>
<div className="relative">
<div className="flex items-stretch gap-1">
<input
id="password"
name="password"
@@ -210,19 +194,19 @@ export default function AuthScreen() {
aria-invalid={Boolean(errors.password)}
aria-describedby="password-hint"
placeholder="••••••••"
className={`w-full rounded-xl border bg-surface/70 px-3.5 py-2.5 pr-20 text-sm text-fg placeholder:text-faint focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20 ${
errors.password ? 'border-danger/70' : 'border-line focus:border-accent/60'
className={`retro-input min-w-0 flex-1 px-2 py-1.5 text-sm ${
errors.password ? 'bg-[#ffecec]' : ''
}`}
/>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute top-1/2 right-2 -translate-y-1/2 rounded-lg px-2.5 py-1 text-xs font-medium text-muted transition hover:bg-surface-2 hover:text-fg"
className="retro-btn shrink-0 px-2 text-[11px] font-bold"
>
{showPassword ? 'Скрыть' : 'Показать'}
</button>
</div>
<p id="password-hint" className={`mt-1.5 text-xs ${errors.password ? 'text-danger' : 'text-faint'}`}>
<p id="password-hint" className={`mt-1 text-[11px] ${errors.password ? 'text-danger' : 'text-muted'}`}>
{errors.password ?? 'Минимум 8 символов'}
</p>
</div>
@@ -230,7 +214,7 @@ export default function AuthScreen() {
{apiError && (
<div
role="alert"
className="flex items-start gap-2 rounded-xl border border-danger/40 bg-danger/10 px-3.5 py-2.5 text-sm text-danger"
className="bevel-in flex items-start gap-2 bg-[#ffecec] px-2 py-1.5 text-xs text-danger"
>
<IconAlert className="mt-0.5 h-4 w-4 shrink-0" />
<span>{apiError}</span>
@@ -240,14 +224,31 @@ export default function AuthScreen() {
<button
type="submit"
disabled={submitting}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-br from-accent to-accent-strong px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-accent/25 transition enabled:hover:brightness-110 enabled:active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60"
className="retro-btn flex w-full items-center justify-center gap-2 px-4 py-2 text-sm font-bold"
>
{submitting && <span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />}
{submitting && (
<span className="h-3.5 w-3.5 animate-spin border-2 border-surface-3 border-t-accent" />
)}
{mode === 'login' ? 'Войти' : 'Зарегистрироваться'}
</button>
</form>
</div>
</main>
</div>
<div className="bevel mt-3 bg-panel px-3 py-2">
<div className="marquee text-[11px] text-accent">
<span>
★ Добро пожаловать на Пульс ★ Ваш домашний сервер ★ Без слежки и лишнего шума ★ Заходите чаще ★
</span>
</div>
<div className="mt-1 flex items-center justify-between border-t border-surface-3 pt-1.5">
<span className="text-[10px] text-muted">Вы посетитель №</span>
<span className="bevel-in bg-black px-2 py-0.5 font-mono text-[11px] tracking-widest text-[#00ff00]">
000123
</span>
</div>
</div>
</div>
</div>
)
}
+27 -2
View File
@@ -340,8 +340,32 @@ export default function ChatApp({ user }: ChatAppProps) {
const peerIsTyping = selectedId !== null && typing[selectedId] === true
return (
<div className="h-full p-0 md:p-5">
<div className="mx-auto flex h-full max-w-[1440px] overflow-hidden border-line bg-panel/70 backdrop-blur-xl md:rounded-[28px] md:border md:shadow-2xl md:shadow-black/40">
<div className="flex h-full flex-col p-0 md:p-4">
<div className="bevel mx-auto flex h-full w-full max-w-[1440px] flex-col bg-panel md:shadow-[4px_4px_0_rgba(0,0,0,0.35)]">
<div className="win-title flex shrink-0 items-center gap-1.5 px-1.5 py-1">
<span className="flex h-4 w-4 items-center justify-center bg-white text-accent">
<svg viewBox="0 0 24 24" className="h-3 w-3" fill="none" aria-hidden="true">
<path
d="M4 12.5h3l2-5 3.2 9 2.3-6 1.5 2H20"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="square"
strokeLinejoin="miter"
/>
</svg>
</span>
<span className="flex-1 truncate text-xs font-bold tracking-wide">Пульс — Мессенджер</span>
<span className="win-btn" aria-hidden="true">
_
</span>
<span className="win-btn" aria-hidden="true">
□
</span>
<span className="win-btn" aria-hidden="true">
×
</span>
</div>
<div className="flex min-h-0 flex-1">
<Sidebar
className={selectedId !== null ? 'hidden md:flex' : 'flex'}
conversations={conversations}
@@ -385,6 +409,7 @@ export default function ChatApp({ user }: ChatAppProps) {
onMembersLoaded={handleMembersLoaded}
/>
</div>
</div>
{dialogOpen && (
<NewChatDialog
+2 -2
View File
@@ -16,8 +16,8 @@ export default defineConfig({
manifest: {
name: 'Messenger',
short_name: 'Messenger',
theme_color: '#0f1115',
background_color: '#0f1115',
theme_color: '#008080',
background_color: '#c0c0c0',
display: 'standalone',
start_url: '/',
icons: [