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