feat: webcore/old-web visual redesign
This commit is contained in:
+1
-7
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user