has/src/components/TvProfileSelector.tsx

498 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import logoUrl from "../assets/logo.png";
import type { UserDto, AuthResponse } from "../types";
import { playTvBeep, playTvSelectBeep, playTvBackBeep, playTvErrorBeep } from "../utils/tvAudio";
import "./TvProfileSelector.css";
interface TvProfileSelectorProps {
onSelectProfile: (user: UserDto) => void;
onSwitchToDesktop?: () => void;
}
const STORAGE_KEY = "has_tv_saved_profiles";
export const TvProfileSelector: React.FC<TvProfileSelectorProps> = ({
onSelectProfile,
onSwitchToDesktop,
}) => {
const [profiles, setProfiles] = useState<UserDto[]>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
}
// Fallback: check if standard remembered user exists
const rem = localStorage.getItem("has_remembered_user");
if (rem) {
const parsedUser = JSON.parse(rem);
if (parsedUser?.email) {
localStorage.setItem(STORAGE_KEY, JSON.stringify([parsedUser]));
return [parsedUser];
}
}
} catch {}
return [];
});
// Focus state on arc: either a profile index or the central "+" button
const [focusState, setFocusState] = useState<{ type: "profile"; index: number } | { type: "plus" }>(
() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved && JSON.parse(saved).length > 0) {
return { type: "profile", index: 0 };
}
} catch {}
return { type: "plus" };
}
);
// 3-button TV Authorization Modal State
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const [tvEmail, setTvEmail] = useState("");
const [tvPassword, setTvPassword] = useState("");
const [tvLoginFocus, setTvLoginFocus] = useState<0 | 1 | 2>(0); // 0: Email, 1: Password, 2: Submit
const [tvLoading, setTvLoading] = useState(false);
const [tvError, setTvError] = useState<string | null>(null);
const emailInputRef = useRef<HTMLInputElement | null>(null);
const passwordInputRef = useRef<HTMLInputElement | null>(null);
const submitButtonRef = useRef<HTMLButtonElement | null>(null);
// Synchronize focus when modal opens
useEffect(() => {
if (isLoginModalOpen) {
setTvLoginFocus(0);
setTvError(null);
setTimeout(() => emailInputRef.current?.focus(), 60);
}
}, [isLoginModalOpen]);
// Handle focus changes in login modal
useEffect(() => {
if (!isLoginModalOpen) return;
if (tvLoginFocus === 0) {
emailInputRef.current?.focus();
} else if (tvLoginFocus === 1) {
passwordInputRef.current?.focus();
} else if (tvLoginFocus === 2) {
submitButtonRef.current?.focus();
}
}, [tvLoginFocus, isLoginModalOpen]);
// Compute profile coordinates on the arc (matching the user's sketch)
const getCoordinates = (index: number, total: number) => {
const cx = 440;
const cy = 360;
const rx = 330;
const ry = 210;
if (total === 1) {
return { x: cx, y: cy - ry };
}
// Distribute angles along the semicircle arc
const startAngle = (155 * Math.PI) / 180;
const endAngle = (25 * Math.PI) / 180;
const step = (startAngle - endAngle) / (total - 1);
const angle = startAngle - index * step;
const x = cx + rx * Math.cos(angle);
const y = cy - ry * Math.sin(angle);
return { x, y };
};
// Login handler for 3-button TV login
const handleTvLogin = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
setTvError(null);
const cleanEmail = tvEmail.trim();
if (!cleanEmail || !tvPassword) {
setTvError("Заполните почту и пароль");
playTvBeep(320, 0.08);
return;
}
setTvLoading(true);
try {
const res = await invoke<AuthResponse>("login", {
email: cleanEmail,
password: tvPassword,
});
if (res.success && res.user) {
playTvBeep(780, 0.06);
// Save to TV profiles list
const updated = [
res.user,
...profiles.filter((p) => p.email.toLowerCase() !== res.user!.email.toLowerCase()),
];
setProfiles(updated);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
localStorage.setItem("has_remembered_user", JSON.stringify(res.user));
localStorage.setItem("has_remembered_email", cleanEmail);
setIsLoginModalOpen(false);
onSelectProfile(res.user);
} else {
setTvError(res.message || "Неверный логин или пароль");
playTvErrorBeep();
}
} catch (err: any) {
setTvError(`Ошибка: ${err?.message || err}`);
playTvErrorBeep();
} finally {
setTvLoading(false);
}
};
// Select profile directly from arc
const handleSelect = (user: UserDto) => {
playTvSelectBeep();
localStorage.setItem("has_remembered_user", JSON.stringify(user));
onSelectProfile(user);
};
// Remove profile from TV list
const handleDeleteProfile = (e: React.MouseEvent, email: string) => {
e.stopPropagation();
const filtered = profiles.filter((p) => p.email.toLowerCase() !== email.toLowerCase());
setProfiles(filtered);
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
setFocusState({ type: "plus" });
playTvBackBeep();
};
// Global D-pad Remote Control Key Listener
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// ----------------------------------------------------------------------
// CASE 1: 3-Button TV Login Modal is OPEN (Strict Remote Focus Trap)
// "больше пультом дистанционного управления никуда нельзя будет нажать только на эти три кнопки"
// ----------------------------------------------------------------------
if (isLoginModalOpen) {
// Back / Escape key returns to Profile Arc
if (e.key === "Escape" || e.keyCode === 27 || e.keyCode === 4) {
e.preventDefault();
setIsLoginModalOpen(false);
setFocusState({ type: "plus" });
playTvBackBeep();
return;
}
// Down Arrow: Email (0) -> Password (1) -> Button (2)
if (e.key === "ArrowDown" || e.keyCode === 20) {
e.preventDefault();
setTvLoginFocus((prev) => ((prev + 1) % 3) as 0 | 1 | 2);
playTvBeep(520, 0.025);
return;
}
// Up Arrow: Button (2) -> Password (1) -> Email (0)
if (e.key === "ArrowUp" || e.keyCode === 19) {
e.preventDefault();
setTvLoginFocus((prev) => ((prev - 1 + 3) % 3) as 0 | 1 | 2);
playTvBeep(520, 0.025);
return;
}
// Tab / Shift+Tab cycling strictly between these 3 controls
if (e.key === "Tab") {
e.preventDefault();
if (e.shiftKey) {
setTvLoginFocus((prev) => ((prev - 1 + 3) % 3) as 0 | 1 | 2);
} else {
setTvLoginFocus((prev) => ((prev + 1) % 3) as 0 | 1 | 2);
}
playTvBeep(520, 0.025);
return;
}
// Enter / OK (KeyCode 13, 23, 66)
if (e.key === "Enter" || e.keyCode === 13 || e.keyCode === 23 || e.keyCode === 66) {
if (tvLoginFocus === 0) {
e.preventDefault();
setTvLoginFocus(1);
playTvBeep(580, 0.03);
} else if (tvLoginFocus === 1 || tvLoginFocus === 2) {
e.preventDefault();
handleTvLogin();
}
return;
}
// Left / Right keys stay inside the input for text editing
return;
}
// ----------------------------------------------------------------------
// CASE 2: Arc Profile Selection Screen
// ----------------------------------------------------------------------
const total = profiles.length;
// Left Arrow
if (e.key === "ArrowLeft" || e.keyCode === 21) {
e.preventDefault();
if (focusState.type === "profile") {
if (focusState.index > 0) {
setFocusState({ type: "profile", index: focusState.index - 1 });
playTvBeep(540, 0.025);
}
} else {
// From plus button: jump to left-middle profile if exists
if (total > 0) {
const targetIdx = Math.max(0, Math.floor((total - 1) / 2));
setFocusState({ type: "profile", index: targetIdx });
playTvBeep(540, 0.025);
}
}
return;
}
// Right Arrow
if (e.key === "ArrowRight" || e.keyCode === 22) {
e.preventDefault();
if (focusState.type === "profile") {
if (focusState.index < total - 1) {
setFocusState({ type: "profile", index: focusState.index + 1 });
playTvBeep(540, 0.025);
}
} else {
// From plus button: jump to right-middle profile if exists
if (total > 0) {
const targetIdx = Math.min(total - 1, Math.ceil((total - 1) / 2));
setFocusState({ type: "profile", index: targetIdx });
playTvBeep(540, 0.025);
}
}
return;
}
// Down Arrow -> moves focus to the Plus button in center
if (e.key === "ArrowDown" || e.keyCode === 20) {
e.preventDefault();
if (focusState.type === "profile") {
setFocusState({ type: "plus" });
playTvBeep(500, 0.025);
}
return;
}
// Up Arrow -> moves focus from Plus button up to nearest profile
if (e.key === "ArrowUp" || e.keyCode === 19) {
e.preventDefault();
if (focusState.type === "plus" && total > 0) {
const targetIdx = Math.round((total - 1) / 2);
setFocusState({ type: "profile", index: targetIdx });
playTvBeep(560, 0.025);
}
return;
}
// OK / Enter -> Select Profile or Open Plus Login
if (e.key === "Enter" || e.key === " " || e.keyCode === 13 || e.keyCode === 23 || e.keyCode === 66) {
e.preventDefault();
if (focusState.type === "profile" && profiles[focusState.index]) {
handleSelect(profiles[focusState.index]);
} else if (focusState.type === "plus") {
setIsLoginModalOpen(true);
playTvBeep(640, 0.03);
}
return;
}
// Delete / Backspace key removes focused profile
if (e.key === "Delete" || e.keyCode === 46) {
if (focusState.type === "profile" && profiles[focusState.index]) {
e.preventDefault();
const p = profiles[focusState.index];
const filtered = profiles.filter((item) => item.email.toLowerCase() !== p.email.toLowerCase());
setProfiles(filtered);
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
setFocusState({ type: "plus" });
playTvBeep(360, 0.04);
}
}
},
[isLoginModalOpen, tvLoginFocus, focusState, profiles, tvEmail, tvPassword]
);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
return (
<div className="has-tv-profile-screen">
<div className="has-tv-profile-glow" />
{/* Header */}
<header className="has-tv-profile-header">
<img src={logoUrl} alt="HAS Logo" className="has-tv-profile-logo" />
<h1 className="has-tv-profile-title">Кто сейчас смотрит?</h1>
<p className="has-tv-profile-subtitle">Выберите профиль для автоматического входа</p>
</header>
{/* Arc Stage with Semicircle & Profile Circles */}
<div className="has-tv-arc-stage">
{/* SVG Semicircle Arc (matching user's drawing) */}
<svg className="has-tv-arc-svg" viewBox="0 0 880 400" preserveAspectRatio="none">
<path d="M 110 360 A 330 210 0 0 1 770 360" className="has-tv-arc-path" />
</svg>
{/* Profile Avatars along the Arc */}
{profiles.map((user, idx) => {
const coords = getCoordinates(idx, profiles.length);
const isFocused = focusState.type === "profile" && focusState.index === idx;
return (
<button
key={`${user.email}-${idx}`}
type="button"
className={`has-tv-profile-node ${isFocused ? "focused" : ""}`}
style={{ left: `${coords.x}px`, top: `${coords.y}px` }}
onClick={() => handleSelect(user)}
onMouseEnter={() => setFocusState({ type: "profile", index: idx })}
title={`Войти как ${user.username || user.email}`}
>
<div className="has-tv-profile-avatar-circle">
{user.avatar ? (
<img src={user.avatar} alt={user.username || user.email} />
) : (
<svg
viewBox="0 0 24 24"
className="has-tv-profile-avatar-icon"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
)}
{/* Delete badge visible on hover/focus */}
{isFocused && (
<span
className="has-tv-profile-delete-btn"
onClick={(e) => handleDeleteProfile(e, user.email)}
title="Удалить профиль с ТВ"
>
×
</span>
)}
</div>
<span className="has-tv-profile-name">
{user.username || user.email.split("@")[0]}
</span>
{user.role === "admin" && <span className="has-tv-profile-badge">ADMIN</span>}
</button>
);
})}
{/* Plus Button: Positioned in Center under the Arc */}
<button
type="button"
className={`has-tv-plus-node ${focusState.type === "plus" ? "focused" : ""}`}
style={{ left: "440px", top: "340px" }}
onClick={() => {
setIsLoginModalOpen(true);
playTvBeep(640, 0.03);
}}
onMouseEnter={() => setFocusState({ type: "plus" })}
title="Добавить новый профиль"
>
<div className="has-tv-plus-circle">+</div>
<span className="has-tv-plus-label">Добавить</span>
</button>
</div>
{/* Footer Navigation Hints */}
<footer className="has-tv-profile-footer">
<span className="has-tv-profile-hint-pill">
◄ ► Влево / Вправо • ▼ Кнопка + • OK Вход в аккаунт
</span>
{onSwitchToDesktop && (
<button
type="button"
className="has-tv-profile-switch-desktop"
onClick={onSwitchToDesktop}
>
💻 Обычный режим ПК
</button>
)}
</footer>
{/* ==================================================================
STRICT 3-BUTTON TV LOGIN MODAL
"два поля почта и пароль с кнопкой входа больше пультом дистанционного
управления никуда нельзя будет нажать только на эти три кнопки"
================================================================== */}
{isLoginModalOpen && (
<div
className="has-tv-login-overlay"
onClick={() => {
setIsLoginModalOpen(false);
setFocusState({ type: "plus" });
}}
>
<div className="has-tv-login-card" onClick={(e) => e.stopPropagation()}>
<div className="has-tv-login-card-header">
<h2 className="has-tv-login-card-title">Добавить профиль</h2>
<p className="has-tv-login-card-desc">
Введите данные для авторизации нового аккаунта на ТВ
</p>
</div>
<form className="has-tv-login-form" onSubmit={handleTvLogin}>
{/* Field 1: Email */}
<input
ref={emailInputRef}
type="email"
value={tvEmail}
onChange={(e) => setTvEmail(e.target.value)}
placeholder="Почта"
className={`has-tv-login-input ${tvLoginFocus === 0 ? "focused" : ""}`}
tabIndex={0}
autoFocus
/>
{/* Field 2: Password */}
<input
ref={passwordInputRef}
type="password"
value={tvPassword}
onChange={(e) => setTvPassword(e.target.value)}
placeholder="Пароль"
className={`has-tv-login-input ${tvLoginFocus === 1 ? "focused" : ""}`}
tabIndex={0}
/>
{tvError && <div className="has-tv-login-error-banner">{tvError}</div>}
{/* Button 3: Submit Button */}
<button
ref={submitButtonRef}
type="submit"
disabled={tvLoading}
className={`has-tv-login-submit-btn ${tvLoginFocus === 2 ? "focused" : ""}`}
tabIndex={0}
>
{tvLoading ? "Подключение..." : "Войти"}
</button>
</form>
<div className="has-tv-login-card-hint">
▲ ▼ Пульт: перемещение • OK: выбор • Назад (Back / Esc): отмена
</div>
</div>
</div>
)}
</div>
);
};