feat: redesign + avatars + theme + call presence fixes (v0.6.0)
Visual:
- Light/dark theme via ThemeContext + CSS vars
- New design tokens (surface/fg/accent/line/etc.) across all pages
- Reusable Avatar component (img + letter fallback) wired into UserBar,
ConversationHeader, ChatsPage, FriendsPage, GroupInfoPanel, IncomingCallPanel
Calls:
- Split call UI: IncomingCallPanel, InCallPanel, CallControls,
CallParticipantTile, ScreenShareViewer
- Active speaker hook (useActiveSpeakers)
- Fix: ActiveCallBanner stayed hidden after hangup while peers in room.
- useCallPresence: bind presence callbacks only when we own subscribe
(Supabase forbids .on() after .subscribe() on shared dedup'd channels)
- useCallPresence: never removeChannel — channel is shared with CallContext
so tearing it down on ConversationHeader unmount killed live tracking
- ActiveCallBanner: lastCallConversationId fallback so banner shows
instantly after hangup, auto-dismiss when room confirmed empty
- Drop unused useAnyActiveCall
Bump tauri version 0.5.0 -> 0.6.0
This commit is contained in:
@@ -16,6 +16,7 @@ import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import {
|
||||
AlertIcon,
|
||||
CopyIcon,
|
||||
@@ -64,9 +65,10 @@ export function AdminPage() {
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
|
||||
<div className="h-full bg-surface-3 text-fg">
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
|
||||
<header>
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-white">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
{t('app:admin.title')}
|
||||
</h1>
|
||||
</header>
|
||||
@@ -74,16 +76,16 @@ export function AdminPage() {
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
|
||||
className="flex items-start gap-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-sm text-rose-700 dark:text-rose-100"
|
||||
>
|
||||
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
||||
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark:text-rose-400" />
|
||||
<p className="min-w-0 flex-1 break-words">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||
<SpinnerIcon className="h-4 w-4 text-brand-400" />
|
||||
<div className="flex items-center gap-2 text-sm text-fg-muted">
|
||||
<SpinnerIcon className="h-4 w-4 text-accent" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -92,15 +94,16 @@ export function AdminPage() {
|
||||
<UsersSection users={users} onRefresh={refresh} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-white/10 bg-ink-900/60 p-5 backdrop-blur-xl">
|
||||
<section className="rounded-2xl border border-line bg-surface-2 p-5">
|
||||
<header className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-neutral-400">{title}</h2>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">{title}</h2>
|
||||
{action}
|
||||
</header>
|
||||
{children}
|
||||
@@ -199,7 +202,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
type="button"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={busy}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-400 disabled:opacity-60"
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:opacity-60"
|
||||
>
|
||||
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : <PlusIcon className="h-3.5 w-3.5" />}
|
||||
<span>{t('app:admin.invites_create')}</span>
|
||||
@@ -207,11 +210,11 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
}
|
||||
>
|
||||
{invites.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500">{t('app:admin.invites_empty')}</p>
|
||||
<p className="text-sm text-fg-muted">{t('app:admin.invites_empty')}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-[10px] uppercase tracking-wide text-neutral-500">
|
||||
<thead className="text-[10px] uppercase tracking-[0.1em] text-fg-muted">
|
||||
<tr>
|
||||
<th className="py-2 pr-3">{t('app:admin.invite_col_code')}</th>
|
||||
<th className="py-2 pr-3">{t('app:admin.invite_col_uses')}</th>
|
||||
@@ -220,7 +223,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
<th className="py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
<tbody className="divide-y divide-line">
|
||||
{invites.map((inv) => {
|
||||
const expired = inv.expiresAt ? new Date(inv.expiresAt) < new Date() : false;
|
||||
const status = inv.disabled
|
||||
@@ -229,10 +232,10 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
? t('app:admin.invite_status_expired')
|
||||
: t('app:admin.invite_status_active');
|
||||
const statusColor = inv.disabled
|
||||
? 'text-rose-300'
|
||||
? 'text-rose-600 dark:text-rose-300'
|
||||
: expired
|
||||
? 'text-amber-300'
|
||||
: 'text-emerald-300';
|
||||
? 'text-amber-600 dark:text-amber-300'
|
||||
: 'text-emerald-600 dark:text-emerald-300';
|
||||
const usesText = inv.usesLimit
|
||||
? inv.usesCount + '/' + inv.usesLimit
|
||||
: inv.usesCount + '/∞';
|
||||
@@ -241,9 +244,9 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
: t('app:admin.invite_expires_never');
|
||||
return (
|
||||
<tr key={inv.code} className="py-2">
|
||||
<td className="py-2 pr-3 font-mono text-xs">{inv.code}</td>
|
||||
<td className="py-2 pr-3 text-xs text-neutral-400">{usesText}</td>
|
||||
<td className="py-2 pr-3 text-xs text-neutral-400">{expiresText}</td>
|
||||
<td className="py-2 pr-3 font-mono text-xs text-fg">{inv.code}</td>
|
||||
<td className="py-2 pr-3 text-xs text-fg-muted">{usesText}</td>
|
||||
<td className="py-2 pr-3 text-xs text-fg-muted">{expiresText}</td>
|
||||
<td className={'py-2 pr-3 text-xs font-medium ' + statusColor}>{status}</td>
|
||||
<td className="py-2 text-right">
|
||||
<div className="inline-flex gap-1">
|
||||
@@ -260,7 +263,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleToggle(inv.code, inv.disabled)}
|
||||
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[11px] font-medium text-neutral-300 transition hover:bg-white/10"
|
||||
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-[11px] font-medium text-fg transition hover:brightness-95"
|
||||
>
|
||||
{inv.disabled ? t('app:admin.invites_enable') : t('app:admin.invites_disable')}
|
||||
</button>
|
||||
@@ -301,20 +304,19 @@ function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefres
|
||||
return (
|
||||
<Section title={t('app:admin.users_title')}>
|
||||
{users.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500">{t('app:admin.users_empty')}</p>
|
||||
<p className="text-sm text-fg-muted">{t('app:admin.users_empty')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-white/5">
|
||||
<ul className="divide-y divide-line">
|
||||
{users.map((u) => {
|
||||
const letter =
|
||||
(u.displayName ?? u.username ?? '?').trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<li key={u.userId} className="flex items-center gap-3 py-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
|
||||
{letter}
|
||||
</div>
|
||||
<Avatar
|
||||
displayName={u.displayName ?? u.username}
|
||||
className="h-9 w-9 text-sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-white">{u.displayName}</p>
|
||||
<p className="truncate text-xs text-neutral-500">@{u.username}</p>
|
||||
<p className="truncate text-sm font-semibold text-fg">{u.displayName}</p>
|
||||
<p className="truncate text-xs text-fg-muted">@{u.username}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<FlagChip
|
||||
@@ -361,8 +363,8 @@ function Toggle({
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start justify-between gap-4">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm text-neutral-200">{label}</span>
|
||||
{hint && <span className="mt-1 block text-xs text-neutral-500">{hint}</span>}
|
||||
<span className="block text-sm text-fg">{label}</span>
|
||||
{hint && <span className="mt-1 block text-xs text-fg-muted">{hint}</span>}
|
||||
</span>
|
||||
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
|
||||
<input
|
||||
@@ -372,8 +374,8 @@ function Toggle({
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span className="inline-block h-6 w-11 rounded-full bg-neutral-700 transition peer-checked:bg-brand-500/70 peer-disabled:opacity-50" />
|
||||
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
||||
<span className="inline-block h-6 w-11 rounded-full bg-surface transition peer-checked:bg-accent peer-disabled:opacity-50" />
|
||||
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition peer-checked:translate-x-5" />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
@@ -399,8 +401,8 @@ function IconButton({
|
||||
className={
|
||||
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border transition focus:outline-none focus-visible:ring-2 ' +
|
||||
(tone === 'danger'
|
||||
? 'border-rose-500/30 bg-rose-500/10 text-rose-200 hover:bg-rose-500/20 focus-visible:ring-rose-400/40'
|
||||
: 'border-white/10 bg-white/5 text-neutral-300 hover:bg-white/10 focus-visible:ring-brand-400/40')
|
||||
? 'border-rose-500/40 bg-rose-500/10 text-rose-600 hover:bg-rose-500/20 focus-visible:ring-rose-400/40 dark:text-rose-300'
|
||||
: 'border-line bg-surface-3 text-fg hover:brightness-95 focus-visible:ring-accent/40')
|
||||
}
|
||||
>
|
||||
{children}
|
||||
@@ -421,15 +423,15 @@ function FlagChip({
|
||||
}) {
|
||||
const activeClass =
|
||||
tone === 'danger'
|
||||
? 'border-rose-400/40 bg-rose-500/20 text-rose-100'
|
||||
: 'border-brand-400/40 bg-brand-500/20 text-brand-100';
|
||||
? 'border-rose-500/40 bg-rose-500/20 text-rose-700 dark:text-rose-100'
|
||||
: 'border-accent/40 bg-accent/20 text-accent';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(!active)}
|
||||
className={
|
||||
'cursor-pointer rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||||
(active ? activeClass : 'border-white/10 bg-white/5 text-neutral-400 hover:bg-white/10')
|
||||
'cursor-pointer rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active ? activeClass : 'border-line bg-surface-3 text-fg-muted hover:brightness-95')
|
||||
}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -5,7 +5,14 @@ import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, Outlet, useParams } from 'react-router-dom';
|
||||
|
||||
import { CreateGroupDialog } from '../components/CreateGroupDialog';
|
||||
import { ChatBubbleIcon, PlusIcon, SpinnerIcon, UsersIcon } from '../components/icons';
|
||||
import {
|
||||
AddUserIcon,
|
||||
ChatBubbleIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
UsersIcon,
|
||||
} from '../components/icons';
|
||||
import { UserBar } from '../components/UserBar';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
@@ -13,6 +20,7 @@ export function ChatsPage() {
|
||||
const { conversations, loading, error, refresh, unread } = useConversationsContext();
|
||||
const { id: activeId } = useParams<{ id: string }>();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...conversations].sort((a, b) => {
|
||||
@@ -22,14 +30,26 @@ export function ChatsPage() {
|
||||
});
|
||||
}, [conversations]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return sorted;
|
||||
return sorted.filter((c) => {
|
||||
const title = (c.type === 'dm' ? c.peer?.displayName : c.name) ?? '';
|
||||
const handle = c.type === 'dm' ? c.peer?.username ?? '' : '';
|
||||
return title.toLowerCase().includes(q) || handle.toLowerCase().includes(q);
|
||||
});
|
||||
}, [sorted, query]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<ConversationList
|
||||
items={sorted}
|
||||
items={filtered}
|
||||
loading={loading}
|
||||
error={error}
|
||||
activeId={activeId}
|
||||
unread={unread}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
onAccept={async (id) => {
|
||||
try {
|
||||
await acceptDm(supabase, id);
|
||||
@@ -41,7 +61,7 @@ export function ChatsPage() {
|
||||
}}
|
||||
onNewGroup={() => setCreateOpen(true)}
|
||||
/>
|
||||
<div className="flex-1 border-l border-white/5">
|
||||
<div className="flex-1 border-l border-line bg-surface-3">
|
||||
<Outlet />
|
||||
</div>
|
||||
<CreateGroupDialog open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||
@@ -49,32 +69,38 @@ export function ChatsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
interface ConversationListProps {
|
||||
items: ConversationSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
activeId: string | undefined;
|
||||
unread: Record<string, number>;
|
||||
query: string;
|
||||
onQueryChange: (q: string) => void;
|
||||
onAccept: (id: string) => void;
|
||||
onNewGroup: () => void;
|
||||
}
|
||||
|
||||
function ConversationList({
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
activeId,
|
||||
unread,
|
||||
query,
|
||||
onQueryChange,
|
||||
onAccept,
|
||||
onNewGroup,
|
||||
}: {
|
||||
items: ConversationSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
activeId: string | undefined;
|
||||
unread: Record<string, number>;
|
||||
onAccept: (id: string) => void;
|
||||
onNewGroup: () => void;
|
||||
}) {
|
||||
}: ConversationListProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Conversations"
|
||||
className="flex h-full w-[320px] shrink-0 flex-col bg-ink-900/40"
|
||||
className="flex h-full w-[320px] shrink-0 flex-col bg-surface-2"
|
||||
>
|
||||
<header className="flex items-center justify-between px-4 pb-3 pt-5">
|
||||
<h2 className="font-display text-base font-semibold tracking-tight text-white">
|
||||
<h2 className="font-display text-base font-semibold tracking-tight text-fg">
|
||||
{t('app:nav.chats')}
|
||||
</h2>
|
||||
<button
|
||||
@@ -82,29 +108,45 @@ function ConversationList({
|
||||
onClick={onNewGroup}
|
||||
aria-label={t('app:chats.new_group')}
|
||||
title={t('app:chats.new_group')}
|
||||
className="flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 text-xs font-medium text-neutral-300 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<UsersIcon className="h-3.5 w-3.5" />
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
<AddUserIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="px-3 pb-2">
|
||||
<label className="relative block">
|
||||
<span className="sr-only">{t('app:chats.search', { defaultValue: 'Suche' })}</span>
|
||||
<SearchIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-muted"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
placeholder={t('app:chats.search_placeholder', { defaultValue: 'Suche…' })}
|
||||
className="w-full rounded-lg border border-line bg-surface-3 py-2 pl-9 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-4 py-2 text-xs text-neutral-500">
|
||||
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
|
||||
<div className="flex items-center gap-2 px-4 py-2 text-xs text-fg-muted">
|
||||
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="px-4 py-2 text-xs text-rose-300">{error}</p>
|
||||
<p className="px-4 py-2 text-xs text-rose-500 dark:text-rose-300">{error}</p>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-brand-300">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-3 text-accent">
|
||||
<ChatBubbleIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-sm text-neutral-400">{t('app:chats.empty_title')}</p>
|
||||
<p className="text-xs text-neutral-500">{t('app:chats.empty_subtitle')}</p>
|
||||
<p className="text-sm text-fg-muted">{t('app:chats.empty_title')}</p>
|
||||
<p className="text-xs text-fg-muted/80">{t('app:chats.empty_subtitle')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex-1 overflow-y-auto px-2 pb-4">
|
||||
<ul className="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{items.map((c) => (
|
||||
<li key={c.id}>
|
||||
<ConversationRow
|
||||
@@ -117,6 +159,10 @@ function ConversationList({
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="shrink-0 border-t border-line bg-surface-2 p-2">
|
||||
<UserBar />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -136,16 +182,22 @@ function ConversationRow({
|
||||
const title =
|
||||
item.type === 'dm' ? (item.peer?.displayName ?? '?') : (item.name ?? '?');
|
||||
const handle = item.type === 'dm' ? '@' + (item.peer?.username ?? '?') : '';
|
||||
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
||||
const avatarUrl =
|
||||
item.type === 'dm' ? (item.peer?.avatarUrl ?? null) : (item.avatarUrl ?? null);
|
||||
const preview =
|
||||
item.type === 'dm' ? handle : t('app:chats.group_preview', {
|
||||
count: item.members.length,
|
||||
defaultValue: `${item.members.length} Mitglieder`,
|
||||
});
|
||||
|
||||
if (!item.acceptedByMe) {
|
||||
return (
|
||||
<div className="my-1 rounded-xl border border-amber-500/20 bg-amber-500/5 p-3">
|
||||
<div className="my-1 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar letter={letter} />
|
||||
<ConvAvatar url={avatarUrl} title={title} isGroup={item.type === 'group'} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-white">{title}</p>
|
||||
<p className="truncate text-xs text-amber-200/80">
|
||||
<p className="truncate text-sm font-semibold text-fg">{title}</p>
|
||||
<p className="truncate text-xs text-amber-600 dark:text-amber-200/80">
|
||||
{t('app:friends.incoming_request')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -153,7 +205,7 @@ function ConversationRow({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAccept(item.id)}
|
||||
className="mt-3 w-full cursor-pointer rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-semibold text-amber-100 transition hover:bg-amber-500/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/40"
|
||||
className="mt-3 w-full cursor-pointer rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-semibold text-amber-700 transition hover:bg-amber-500/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/40 dark:text-amber-100"
|
||||
>
|
||||
{t('app:friends.action_accept')}
|
||||
</button>
|
||||
@@ -165,26 +217,28 @@ function ConversationRow({
|
||||
<NavLink
|
||||
to={'/chats/' + item.id}
|
||||
className={
|
||||
'my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||||
(active ? 'bg-brand-500/15 ring-1 ring-brand-400/30' : 'hover:bg-white/5')
|
||||
'my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active
|
||||
? 'bg-accent/15 text-fg'
|
||||
: 'text-fg hover:bg-surface-3/70')
|
||||
}
|
||||
>
|
||||
<Avatar letter={letter} />
|
||||
<ConvAvatar url={avatarUrl} title={title} isGroup={item.type === 'group'} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={
|
||||
'truncate text-sm ' +
|
||||
(unreadCount > 0 ? 'font-bold text-white' : 'font-semibold text-white')
|
||||
(unreadCount > 0 ? 'font-bold' : 'font-semibold')
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p className="truncate text-xs text-neutral-500">{handle}</p>
|
||||
<p className="truncate text-xs text-fg-muted">{preview}</p>
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
aria-label={'Unread: ' + unreadCount}
|
||||
className="inline-flex min-w-[20px] items-center justify-center rounded-full bg-rose-500 px-1.5 text-[10px] font-bold leading-tight text-white"
|
||||
className="inline-flex min-w-[20px] items-center justify-center rounded-full bg-accent px-1.5 text-[10px] font-bold leading-tight text-accent-fg"
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
@@ -193,9 +247,35 @@ function ConversationRow({
|
||||
);
|
||||
}
|
||||
|
||||
function Avatar({ letter }: { letter: string }) {
|
||||
function ConvAvatar({
|
||||
url,
|
||||
title,
|
||||
isGroup,
|
||||
}: {
|
||||
url: string | null;
|
||||
title: string;
|
||||
isGroup: boolean;
|
||||
}) {
|
||||
if (url) {
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="h-9 w-9 shrink-0 rounded-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isGroup) {
|
||||
return (
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-sm font-semibold text-accent">
|
||||
{letter}
|
||||
</div>
|
||||
);
|
||||
@@ -206,13 +286,13 @@ export function ChatsEmptyState() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-10">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-brand-300">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-line bg-surface-2 text-accent">
|
||||
<ChatBubbleIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<h2 className="font-display text-2xl font-semibold tracking-tight text-white">
|
||||
<h2 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
{t('app:chats.select_prompt')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-neutral-400">{t('app:chats.select_subtitle')}</p>
|
||||
<p className="mt-2 text-sm text-fg-muted">{t('app:chats.select_subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,9 +7,11 @@ import { ConversationHeader } from '../components/ConversationHeader';
|
||||
import { GroupInfoPanel } from '../components/GroupInfoPanel';
|
||||
import { AlertIcon, ArrowRightIcon, PlusIcon, SpinnerIcon, XIcon } from '../components/icons';
|
||||
import { InCallPanel } from '../components/InCallPanel';
|
||||
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||
import { MessageBubble } from '../components/MessageBubble';
|
||||
import { TypingIndicator } from '../components/TypingIndicator';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { useConversationMessages } from '../lib/useConversationMessages';
|
||||
import { useMessageReactions } from '../lib/useMessageReactions';
|
||||
@@ -157,14 +159,27 @@ export function ConversationPage() {
|
||||
}
|
||||
|
||||
const isGroup = conversation?.type === 'group';
|
||||
const { state: callState } = useCall();
|
||||
// Hide the chat header while this conversation hosts an active call — the
|
||||
// call topbar inside the dock already shows the channel name + duration,
|
||||
// and fullscreen cinema needs the whole slot.
|
||||
const callHereActive =
|
||||
(callState.kind === 'connected' ||
|
||||
callState.kind === 'connecting' ||
|
||||
callState.kind === 'outgoing') &&
|
||||
callState.conversationId === id;
|
||||
const incomingHere =
|
||||
callState.kind === 'incoming' && callState.conversationId === id;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col">
|
||||
<ConversationHeader
|
||||
conversation={conversation}
|
||||
peerPresence={peerPresence}
|
||||
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
|
||||
/>
|
||||
{!callHereActive && (
|
||||
<ConversationHeader
|
||||
conversation={conversation}
|
||||
peerPresence={peerPresence}
|
||||
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGroup && conversation && (
|
||||
<GroupInfoPanel
|
||||
@@ -174,28 +189,36 @@ export function ConversationPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
|
||||
{conversation && <InCallPanel conversation={conversation} />}
|
||||
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto bg-surface-3 px-6 py-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-500">
|
||||
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
|
||||
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<Banner>{error}</Banner>
|
||||
) : messages.length === 0 ? (
|
||||
<p className="text-center text-sm text-neutral-500">…</p>
|
||||
<p className="text-center text-sm text-fg-muted">…</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{messages.map((m, idx) => {
|
||||
const prev = messages[idx - 1];
|
||||
const next = messages[idx + 1];
|
||||
const grouped = idx > 0 && prev?.senderId === m.senderId;
|
||||
const isLastOfRun = !next || next.senderId !== m.senderId;
|
||||
const senderProfile =
|
||||
conversation?.members.find((mm) => mm.userId === m.senderId)?.profile ?? null;
|
||||
return (
|
||||
<li key={m.id}>
|
||||
<MessageBubble
|
||||
message={m}
|
||||
mine={m.senderId === myId}
|
||||
groupedWithPrev={grouped}
|
||||
isLastOfRun={isLastOfRun}
|
||||
senderDisplayName={senderProfile?.displayName}
|
||||
senderAvatarUrl={senderProfile?.avatarUrl}
|
||||
conversationId={id ?? ''}
|
||||
reactions={reactionsByMessage.get(m.id) ?? []}
|
||||
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
|
||||
@@ -213,7 +236,7 @@ export function ConversationPage() {
|
||||
members={conversation?.members ?? []}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSend} className="border-t border-white/5 p-4">
|
||||
<form onSubmit={handleSend} className="border-t border-line bg-surface-3 p-4">
|
||||
{sendError && (
|
||||
<div className="mb-2">
|
||||
<Banner>{sendError}</Banner>
|
||||
@@ -248,7 +271,7 @@ export function ConversationPage() {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label="Bild anhängen"
|
||||
title="Bild anhängen"
|
||||
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg border border-white/10 bg-white/5 text-neutral-300 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
||||
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -265,14 +288,14 @@ export function ConversationPage() {
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder="…"
|
||||
className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-white/10 bg-ink-900/60 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
||||
placeholder="Nachricht schreiben…"
|
||||
className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending || (text.trim().length === 0 && attachments.length === 0)}
|
||||
aria-busy={sending}
|
||||
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 text-white transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{sending ? <SpinnerIcon className="h-4 w-4" /> : <ArrowRightIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
@@ -286,9 +309,9 @@ function Banner({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
|
||||
className="flex items-start gap-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-sm text-rose-700 dark:text-rose-100"
|
||||
>
|
||||
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
||||
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark:text-rose-400" />
|
||||
<p className="min-w-0 flex-1 break-words">{children}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -302,7 +325,7 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
||||
return () => URL.revokeObjectURL(u);
|
||||
}, [file]);
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-lg border border-white/10 bg-ink-900/60">
|
||||
<div className="relative overflow-hidden rounded-lg border border-line bg-surface-2">
|
||||
{url ? (
|
||||
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
||||
) : (
|
||||
@@ -312,7 +335,7 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label="Entfernen"
|
||||
className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-ink-950/80 text-neutral-200 transition hover:bg-rose-500/70 hover:text-white"
|
||||
className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white transition hover:bg-rose-500/80"
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import {
|
||||
AlertIcon,
|
||||
ChatBubbleIcon,
|
||||
@@ -28,6 +30,7 @@ type Tab = 'friends' | 'pending' | 'requests';
|
||||
export function FriendsPage() {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
const { friendships, loading, error, refresh } = useFriendshipsContext();
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<Tab>('friends');
|
||||
const [query, setQuery] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
@@ -104,16 +107,17 @@ export function FriendsPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-3xl flex-col gap-5 px-6 py-8">
|
||||
<div className="h-full bg-surface-3 text-fg">
|
||||
<div className="mx-auto flex h-full max-w-3xl flex-col gap-5 px-6 py-8">
|
||||
<header className="flex items-center justify-between gap-4">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-white">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
{t('app:friends.title')}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-500" />
|
||||
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
@@ -122,7 +126,7 @@ export function FriendsPage() {
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
className="w-full rounded-lg border border-white/10 bg-ink-900/60 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
||||
className="w-full rounded-lg border border-line bg-surface-2 py-2.5 pl-10 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
{query.length > 0 && (
|
||||
@@ -138,7 +142,7 @@ export function FriendsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-b border-white/5">
|
||||
<div className="flex gap-2 border-b border-line">
|
||||
<TabButton active={tab === 'friends'} onClick={() => setTab('friends')}>
|
||||
{t('app:friends.tab_friends')} · {accepted.length}
|
||||
</TabButton>
|
||||
@@ -165,7 +169,7 @@ export function FriendsPage() {
|
||||
onMessage={() =>
|
||||
performAction('msg-' + f.peer.userId, async () => {
|
||||
const id = await createDm(supabase, f.peer.userId);
|
||||
navigateToChat(id);
|
||||
navigate('/chats/' + id);
|
||||
})
|
||||
}
|
||||
onUnfriend={() =>
|
||||
@@ -206,16 +210,11 @@ export function FriendsPage() {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function navigateToChat(id: string): void {
|
||||
// Push history + dispatch popstate so React Router re-evaluates the route.
|
||||
window.history.pushState(null, '', '/chats/' + id);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
function translateError(err: unknown, t: ReturnType<typeof useTranslation>['t']): string {
|
||||
const code = extractErrorCode(err);
|
||||
if (code) return t('errors:' + code, { defaultValue: t('errors:generic') });
|
||||
@@ -241,8 +240,8 @@ function TabButton({
|
||||
className={
|
||||
'-mb-px cursor-pointer border-b-2 px-3 pb-2.5 text-sm font-medium transition focus:outline-none ' +
|
||||
(active
|
||||
? 'border-brand-400 text-white'
|
||||
: 'border-transparent text-neutral-400 hover:text-neutral-200')
|
||||
? 'border-accent text-fg'
|
||||
: 'border-transparent text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{children}
|
||||
@@ -262,10 +261,11 @@ function FriendList({
|
||||
const { t } = useTranslation(['app']);
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-neutral-500">
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-neutral-400">
|
||||
<div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-fg-muted">
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-2 text-fg-muted">
|
||||
<UsersIcon className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<p>{t(emptyKey)}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -283,26 +283,21 @@ function FriendList({
|
||||
|
||||
function FriendRow({ profile, children }: { profile: ProfileBrief; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-white/5 bg-ink-900/50 px-4 py-3 backdrop-blur-sm">
|
||||
<Avatar profile={profile} />
|
||||
<div className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3">
|
||||
<Avatar
|
||||
url={profile.avatarUrl}
|
||||
displayName={profile.displayName ?? profile.username}
|
||||
className="h-9 w-9 text-sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-white">{profile.displayName}</p>
|
||||
<p className="truncate text-xs text-neutral-500">@{profile.username}</p>
|
||||
<p className="truncate text-sm font-semibold text-fg">{profile.displayName}</p>
|
||||
<p className="truncate text-xs text-fg-muted">@{profile.username}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Avatar({ profile }: { profile: ProfileBrief }) {
|
||||
const letter = (profile.displayName ?? profile.username ?? '?').trim().charAt(0).toUpperCase();
|
||||
return (
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
|
||||
{letter || '?'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FriendActions({
|
||||
busy,
|
||||
onMessage,
|
||||
@@ -371,7 +366,7 @@ function PrimaryButton({
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onClick}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : icon}
|
||||
<span>{children}</span>
|
||||
@@ -393,7 +388,7 @@ function SecondaryButton({
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onClick}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg transition hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
||||
<span>{children}</span>
|
||||
@@ -415,7 +410,7 @@ function DangerButton({
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onClick}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
||||
<span>{children}</span>
|
||||
@@ -425,8 +420,8 @@ function DangerButton({
|
||||
|
||||
function LoadingRow() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm text-neutral-500">
|
||||
<SpinnerIcon className="h-4 w-4 text-brand-400" />
|
||||
<div className="flex items-center gap-3 text-sm text-fg-muted">
|
||||
<SpinnerIcon className="h-4 w-4 text-accent" />
|
||||
<span>…</span>
|
||||
</div>
|
||||
);
|
||||
@@ -440,14 +435,14 @@ function Banner({ kind, children }: { kind: 'error' | 'info'; children: React.Re
|
||||
className={
|
||||
'flex items-start gap-3 rounded-lg border p-3 text-sm ' +
|
||||
(isError
|
||||
? 'border-rose-500/20 bg-rose-500/10 text-rose-100'
|
||||
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-100')
|
||||
? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-100'
|
||||
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-100')
|
||||
}
|
||||
>
|
||||
{isError ? (
|
||||
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
||||
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark:text-rose-400" />
|
||||
) : (
|
||||
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-400" />
|
||||
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
<p className="min-w-0 flex-1 break-words">{children}</p>
|
||||
</div>
|
||||
@@ -474,24 +469,24 @@ function SearchResults({
|
||||
const { t } = useTranslation(['app']);
|
||||
|
||||
if (query.length < 2) {
|
||||
return <p className="text-xs text-neutral-500">{t('app:friends.search_min_chars')}</p>;
|
||||
return <p className="text-xs text-fg-muted">{t('app:friends.search_min_chars')}</p>;
|
||||
}
|
||||
if (searching) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-500">
|
||||
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
|
||||
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||||
<span>…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) return <Banner kind="error">{error}</Banner>;
|
||||
if (results.length === 0) {
|
||||
return <p className="text-xs text-neutral-500">{t('app:friends.search_no_results')}</p>;
|
||||
return <p className="text-xs text-fg-muted">{t('app:friends.search_no_results')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-[0.1em] text-fg-muted">
|
||||
{t('app:friends.search_results_title')}
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
@@ -530,7 +525,7 @@ function SearchActionButton({
|
||||
|
||||
if (existing?.status === 'accepted') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-500/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-200">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-700 dark:text-emerald-200">
|
||||
<CheckCircleIcon className="h-3.5 w-3.5" />
|
||||
{t('app:friends.already_friends')}
|
||||
</span>
|
||||
@@ -539,7 +534,7 @@ function SearchActionButton({
|
||||
|
||||
if (existing?.status === 'pending' && existing.direction === 'outgoing') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-xs text-fg-muted">
|
||||
{t('app:friends.request_sent')}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import { LockIcon } from '../components/icons';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { loadDevicePrivateKey, saveDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
@@ -75,9 +76,10 @@ export function SettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||
<div className="h-full bg-surface-3 text-fg">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||
<header className="mb-2">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-white">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
{t('app:settings.title')}
|
||||
</h1>
|
||||
</header>
|
||||
@@ -96,7 +98,7 @@ export function SettingsPage() {
|
||||
{/* Appearance */}
|
||||
<Section title={t('app:settings.section_appearance')}>
|
||||
<SettingRow label={t('app:settings.language')}>
|
||||
<div className="inline-flex rounded-lg border border-white/10 bg-ink-800 p-1">
|
||||
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
|
||||
{SUPPORTED_LOCALES.map((locale) => {
|
||||
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
|
||||
return (
|
||||
@@ -106,10 +108,10 @@ export function SettingsPage() {
|
||||
disabled={busy}
|
||||
onClick={() => void handleLocaleChange(locale)}
|
||||
className={
|
||||
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||||
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active
|
||||
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
|
||||
: 'text-neutral-400 hover:text-neutral-200')
|
||||
? 'bg-accent text-accent-fg'
|
||||
: 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{LOCALE_LABELS[locale]}
|
||||
@@ -141,10 +143,10 @@ export function SettingsPage() {
|
||||
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
|
||||
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
|
||||
<AudioQualityControls />
|
||||
<div className="mt-3 border-t border-white/5 pt-3">
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<PttControls />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-white/5 pt-3">
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<CallE2EEControls />
|
||||
</div>
|
||||
</Section>
|
||||
@@ -157,8 +159,8 @@ export function SettingsPage() {
|
||||
{/* Devices */}
|
||||
<Section title={t('app:settings.section_devices')}>
|
||||
{device && (
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-emerald-200">
|
||||
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-emerald-700 dark:text-emerald-200">
|
||||
<LockIcon className="h-4 w-4" />
|
||||
{t('app:settings.this_device')}
|
||||
</div>
|
||||
@@ -177,11 +179,12 @@ export function SettingsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut()}
|
||||
className="cursor-pointer rounded-lg border border-rose-500/30 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50"
|
||||
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300"
|
||||
>
|
||||
{t('app:settings.sign_out')}
|
||||
</button>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -228,10 +231,10 @@ function PttControls() {
|
||||
type="button"
|
||||
onClick={() => setCapturing((v) => !v)}
|
||||
className={
|
||||
'inline-flex min-w-[7rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||||
'inline-flex min-w-[7rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(capturing
|
||||
? 'border-brand-400 bg-brand-500/20 text-white animate-pulse'
|
||||
: 'border-white/10 bg-ink-800 text-neutral-200 hover:bg-ink-700')
|
||||
? 'border-accent bg-accent/20 text-fg animate-pulse'
|
||||
: 'border-line bg-surface-3 text-fg hover:brightness-95')
|
||||
}
|
||||
>
|
||||
{capturing
|
||||
@@ -288,7 +291,7 @@ function AudioQualityControls() {
|
||||
return (
|
||||
<>
|
||||
<SettingRow label={t('app:settings.audio_quality', { defaultValue: 'Audio-Qualität' })}>
|
||||
<div className="inline-flex rounded-lg border border-white/10 bg-ink-800 p-1">
|
||||
<div className="inline-flex rounded-lg border border-line bg-surface-2 p-1">
|
||||
{AUDIO_QUALITY_ORDER.map((q) => {
|
||||
const active = cfg.quality === q;
|
||||
return (
|
||||
@@ -297,10 +300,10 @@ function AudioQualityControls() {
|
||||
type="button"
|
||||
onClick={() => updateAudioSettings({ quality: q })}
|
||||
className={
|
||||
'cursor-pointer rounded-md px-3 py-1 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||||
'cursor-pointer rounded-md px-3 py-1 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active
|
||||
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
|
||||
: 'text-neutral-400 hover:text-neutral-200')
|
||||
? 'bg-accent text-accent-fg'
|
||||
: 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{labels[q]}
|
||||
@@ -309,7 +312,7 @@ function AudioQualityControls() {
|
||||
})}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<div className="rounded-lg border border-white/5 bg-ink-900/40 p-3 text-[11px] text-neutral-400">
|
||||
<div className="rounded-lg border border-line bg-surface-3 p-3 text-[11px] text-fg-muted">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Stat label="Bitrate" value={params.bitrateKbps + ' kbps'} />
|
||||
<Stat label="Channels" value={params.stereo ? 'Stereo' : 'Mono'} />
|
||||
@@ -317,7 +320,7 @@ function AudioQualityControls() {
|
||||
<Stat label="DSP" value={params.echoCancellation ? 'On' : 'Off'} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500">
|
||||
<p className="text-xs text-fg-muted">
|
||||
{cfg.quality === 'hifi'
|
||||
? t('app:settings.audio_hifi_hint', {
|
||||
defaultValue:
|
||||
@@ -346,10 +349,6 @@ function AvatarControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
|
||||
const userId = profile?.userId;
|
||||
const url = profile?.avatarUrl ?? null;
|
||||
const letter = (profile?.displayName ?? profile?.username ?? '?')
|
||||
.trim()
|
||||
.charAt(0)
|
||||
.toUpperCase() || '?';
|
||||
|
||||
async function handleFile(file: File) {
|
||||
if (!userId) return;
|
||||
@@ -389,27 +388,23 @@ function AvatarControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative h-16 w-16 overflow-hidden rounded-full bg-brand-500/30 ring-1 ring-brand-400/30">
|
||||
{url ? (
|
||||
<img src={url} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-2xl font-semibold text-white">
|
||||
{letter}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Avatar
|
||||
url={url}
|
||||
displayName={profile?.displayName ?? profile?.username}
|
||||
className="h-16 w-16 text-2xl"
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="text-sm font-medium text-neutral-200">
|
||||
<div className="text-sm font-medium text-fg">
|
||||
{t('app:settings.avatar', { defaultValue: 'Avatar' })}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
<div className="text-xs text-fg-muted">
|
||||
{t('app:settings.avatar_hint', {
|
||||
defaultValue: 'Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.',
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-rose-300">{error}</div>
|
||||
<div className="text-xs text-rose-500 dark:text-rose-300">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -427,7 +422,7 @@ function AvatarControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={busy || uploading}
|
||||
className="cursor-pointer rounded-lg bg-brand-500/80 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{uploading
|
||||
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
|
||||
@@ -440,7 +435,7 @@ function AvatarControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
type="button"
|
||||
onClick={() => void handleRemove()}
|
||||
disabled={busy || uploading}
|
||||
className="cursor-pointer rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-200 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||
>
|
||||
{t('app:settings.avatar_remove', { defaultValue: 'Entfernen' })}
|
||||
</button>
|
||||
@@ -514,11 +509,11 @@ function DeviceKeyBackupControls() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-xl border border-white/10 bg-ink-900/40 p-4">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
<div className="mt-4 rounded-xl border border-line bg-surface-3 p-4">
|
||||
<div className="text-sm font-semibold text-fg">
|
||||
{t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-neutral-400">
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{t('app:settings.device_key_backup_hint', {
|
||||
defaultValue:
|
||||
'Sichere deinen privaten Schlüssel passwortgeschützt, damit du auf neuen Geräten alte Nachrichten weiter lesen kannst.',
|
||||
@@ -526,7 +521,7 @@ function DeviceKeyBackupControls() {
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
||||
{t('app:settings.backup_export', { defaultValue: 'Export' })}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -535,13 +530,13 @@ function DeviceKeyBackupControls() {
|
||||
value={exportPass}
|
||||
onChange={(e) => setExportPass(e.target.value)}
|
||||
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase (min 8)' })}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none"
|
||||
className="flex-1 rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || exportPass.length < 8 || !canRun}
|
||||
onClick={() => void handleExport()}
|
||||
className="cursor-pointer rounded-lg bg-brand-500/80 px-4 text-sm font-semibold text-white transition hover:bg-brand-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="cursor-pointer rounded-lg bg-accent px-4 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:settings.backup_create', { defaultValue: 'Erstellen' })}
|
||||
</button>
|
||||
@@ -552,13 +547,13 @@ function DeviceKeyBackupControls() {
|
||||
value={backupOut}
|
||||
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-emerald-500/30 bg-ink-950/60 p-2 font-mono text-[10px] text-emerald-200"
|
||||
className="w-full rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-2 font-mono text-[10px] text-emerald-700 dark:text-emerald-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 border-t border-white/5 pt-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
<div className="mt-4 space-y-2 border-t border-line pt-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
||||
{t('app:settings.backup_import', { defaultValue: 'Import' })}
|
||||
</div>
|
||||
<textarea
|
||||
@@ -568,7 +563,7 @@ function DeviceKeyBackupControls() {
|
||||
placeholder={t('app:settings.backup_blob_placeholder', {
|
||||
defaultValue: 'chatapp-backup-v1.…',
|
||||
})}
|
||||
className="w-full rounded-lg border border-white/10 bg-ink-800 p-2 font-mono text-[11px] text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none"
|
||||
className="w-full rounded-lg border border-line bg-surface-3 p-2 font-mono text-[11px] text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -576,13 +571,13 @@ function DeviceKeyBackupControls() {
|
||||
value={importPass}
|
||||
onChange={(e) => setImportPass(e.target.value)}
|
||||
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase' })}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none"
|
||||
className="flex-1 rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !importBlob || !importPass || !canRun}
|
||||
onClick={() => void handleImport()}
|
||||
className="cursor-pointer rounded-lg bg-emerald-500/80 px-4 text-sm font-semibold text-white transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="cursor-pointer rounded-lg bg-emerald-600 px-4 text-sm font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:settings.backup_restore', { defaultValue: 'Wiederherstellen' })}
|
||||
</button>
|
||||
@@ -593,7 +588,9 @@ function DeviceKeyBackupControls() {
|
||||
<p
|
||||
className={
|
||||
'mt-3 text-xs ' +
|
||||
(msg.kind === 'ok' ? 'text-emerald-300' : 'text-rose-300')
|
||||
(msg.kind === 'ok'
|
||||
? 'text-emerald-600 dark:text-emerald-300'
|
||||
: 'text-rose-600 dark:text-rose-300')
|
||||
}
|
||||
>
|
||||
{msg.text}
|
||||
@@ -619,7 +616,7 @@ function ScreenShareControls() {
|
||||
onChange={(e) =>
|
||||
updateScreenShareSettings({ preset: e.target.value as ScreenSharePreset })
|
||||
}
|
||||
className="cursor-pointer rounded-lg border border-white/10 bg-ink-800 px-3 py-1.5 text-xs text-neutral-200 focus:border-brand-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
||||
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs text-fg focus:border-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{PRESET_ORDER.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
@@ -628,7 +625,7 @@ function ScreenShareControls() {
|
||||
))}
|
||||
</select>
|
||||
</SettingRow>
|
||||
<div className="rounded-lg border border-white/5 bg-ink-900/40 p-3 text-[11px] text-neutral-400">
|
||||
<div className="rounded-lg border border-line bg-surface-3 p-3 text-[11px] text-fg-muted">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Stat label="Bitrate (max)" value={formatBitrate(params.bitrateKbps)} />
|
||||
<Stat
|
||||
@@ -638,7 +635,7 @@ function ScreenShareControls() {
|
||||
<Stat label="Framerate" value={params.framerate + ' fps'} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500">
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('app:settings.screen_share_hint', {
|
||||
defaultValue:
|
||||
'WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.',
|
||||
@@ -651,8 +648,8 @@ function ScreenShareControls() {
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-neutral-500">{label}</div>
|
||||
<div className="mt-0.5 font-mono text-neutral-200">{value}</div>
|
||||
<div className="text-[10px] uppercase tracking-[0.1em] text-fg-muted">{label}</div>
|
||||
<div className="mt-0.5 font-mono text-fg">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -666,8 +663,8 @@ function formatBitrate(kbps: number): string {
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-white/10 bg-ink-900/60 p-5 backdrop-blur-xl">
|
||||
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
<section className="rounded-2xl border border-line bg-surface-2 p-5">
|
||||
<h2 className="mb-4 text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="space-y-3">{children}</div>
|
||||
@@ -678,10 +675,10 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-sm text-neutral-500">{label}</dt>
|
||||
<dt className="text-sm text-fg-muted">{label}</dt>
|
||||
<dd
|
||||
className={
|
||||
'max-w-[60%] truncate text-right text-sm text-neutral-200 ' +
|
||||
'max-w-[60%] truncate text-right text-sm text-fg ' +
|
||||
(mono ? 'font-mono text-xs' : '')
|
||||
}
|
||||
title={value}
|
||||
@@ -695,7 +692,7 @@ function Row({ label, value, mono }: { label: string; value: string; mono?: bool
|
||||
function SettingRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-neutral-200">{label}</span>
|
||||
<span className="text-sm text-fg">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -717,8 +714,8 @@ function Toggle({
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start justify-between gap-4">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm text-neutral-200">{label}</span>
|
||||
{hint && <span className="mt-1 block text-xs text-neutral-500">{hint}</span>}
|
||||
<span className="block text-sm text-fg">{label}</span>
|
||||
{hint && <span className="mt-1 block text-xs text-fg-muted">{hint}</span>}
|
||||
</span>
|
||||
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
|
||||
<input
|
||||
@@ -728,8 +725,8 @@ function Toggle({
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span className="inline-block h-6 w-11 rounded-full bg-neutral-700 transition peer-checked:bg-brand-500/70 peer-disabled:opacity-50" />
|
||||
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
||||
<span className="inline-block h-6 w-11 rounded-full bg-surface transition peer-checked:bg-accent peer-disabled:opacity-50" />
|
||||
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition peer-checked:translate-x-5" />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user