refactor: login page

This commit is contained in:
Fredrik Burmester
2026-01-31 10:52:21 +01:00
parent 6e85c8d54a
commit 85a74a9a6a
27 changed files with 2422 additions and 1236 deletions

View File

@@ -473,7 +473,7 @@ function Layout() {
}}
/>
<Stack.Screen
name='tv-server-action-modal'
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",

View File

@@ -13,11 +13,11 @@ import {
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { tvServerActionModalAtom } from "@/utils/atoms/tvServerActionModal";
import { tvAccountActionModalAtom } from "@/utils/atoms/tvAccountActionModal";
import { store } from "@/utils/store";
// Action card component
const TVServerActionCard: React.FC<{
const TVAccountActionCard: React.FC<{
label: string;
icon: keyof typeof Ionicons.glyphMap;
variant?: "default" | "destructive";
@@ -54,8 +54,8 @@ const TVServerActionCard: React.FC<{
<Animated.View
style={{
transform: [{ scale }],
width: 180,
height: 90,
flexDirection: "row",
height: 60,
backgroundColor: focused
? isDestructive
? "#ef4444"
@@ -66,13 +66,13 @@ const TVServerActionCard: React.FC<{
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
gap: 8,
paddingHorizontal: 24,
gap: 12,
}}
>
<Ionicons
name={icon}
size={28}
size={22}
color={
focused
? isDestructive
@@ -94,7 +94,6 @@ const TVServerActionCard: React.FC<{
? "#ef4444"
: "#fff",
fontWeight: "600",
textAlign: "center",
}}
numberOfLines={1}
>
@@ -105,10 +104,10 @@ const TVServerActionCard: React.FC<{
);
};
export default function TVServerActionModalPage() {
export default function TVAccountActionModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvServerActionModalAtom);
const modalState = useAtomValue(tvAccountActionModalAtom);
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
@@ -138,7 +137,7 @@ export default function TVServerActionModalPage() {
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
store.set(tvServerActionModalAtom, null);
store.set(tvAccountActionModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
@@ -152,10 +151,6 @@ export default function TVServerActionModalPage() {
router.back();
};
const handleClose = () => {
router.back();
};
if (!modalState) {
return null;
}
@@ -196,16 +191,27 @@ export default function TVServerActionModalPage() {
overflow: "visible",
}}
>
{/* Title */}
{/* Account username as title */}
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 4,
paddingHorizontal: 48,
}}
>
{modalState.account.username}
</Text>
{/* Server name as subtitle */}
<Text
style={{
fontSize: typography.callout,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 8,
marginBottom: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{modalState.server.name || modalState.server.address}
@@ -223,23 +229,18 @@ export default function TVServerActionModalPage() {
gap: 12,
}}
>
<TVServerActionCard
<TVAccountActionCard
label={t("common.login")}
icon='log-in-outline'
hasTVPreferredFocus
onPress={handleLogin}
/>
<TVServerActionCard
<TVAccountActionCard
label={t("common.delete")}
icon='trash-outline'
variant='destructive'
onPress={handleDelete}
/>
<TVServerActionCard
label={t("common.cancel")}
icon='close-outline'
onPress={handleClose}
/>
</ScrollView>
)}
</TVFocusGuideView>

View File

@@ -1,16 +1,108 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Animated, Easing, TVFocusGuideView, View } from "react-native";
import { Button } from "@/components/Button";
import {
Animated,
Easing,
Pressable,
ScrollView,
TVFocusGuideView,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVAccountCard } from "@/components/login/TVAccountCard";
import { TVUserCard } from "@/components/tv/TVUserCard";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { tvAccountSelectModalAtom } from "@/utils/atoms/tvAccountSelectModal";
import { store } from "@/utils/store";
// Action button for bottom sheet
const TVAccountSelectAction: React.FC<{
label: string;
icon: keyof typeof Ionicons.glyphMap;
variant?: "default" | "destructive";
onPress: () => void;
}> = ({ label, icon, variant = "default", onPress }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const typography = useScaledTVTypography();
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const isDestructive = variant === "destructive";
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
backgroundColor: focused
? isDestructive
? "#ef4444"
: "#fff"
: isDestructive
? "rgba(239, 68, 68, 0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 14,
minHeight: 72,
gap: 14,
}}
>
<Ionicons
name={icon}
size={22}
color={
focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff"
}
/>
<Text
style={{
fontSize: typography.callout,
color: focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff",
fontWeight: "600",
}}
numberOfLines={1}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export default function TVAccountSelectModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
@@ -19,12 +111,12 @@ export default function TVAccountSelectModalPage() {
const [isReady, setIsReady] = useState(false);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const contentScale = useRef(new Animated.Value(0.9)).current;
const sheetTranslateY = useRef(new Animated.Value(300)).current;
// Animate in on mount
useEffect(() => {
overlayOpacity.setValue(0);
contentScale.setValue(0.9);
sheetTranslateY.setValue(300);
Animated.parallel([
Animated.timing(overlayOpacity, {
@@ -33,8 +125,8 @@ export default function TVAccountSelectModalPage() {
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(contentScale, {
toValue: 1,
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
@@ -46,11 +138,7 @@ export default function TVAccountSelectModalPage() {
clearTimeout(timer);
store.set(tvAccountSelectModalAtom, null);
};
}, [overlayOpacity, contentScale]);
const handleClose = () => {
router.back();
};
}, [overlayOpacity, sheetTranslateY]);
if (!modalState) {
return null;
@@ -60,25 +148,23 @@ export default function TVAccountSelectModalPage() {
<Animated.View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.9)",
justifyContent: "center",
alignItems: "center",
padding: 80,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
opacity: overlayOpacity,
}}
>
<Animated.View
style={{
transform: [{ scale: contentScale }],
width: "100%",
maxWidth: 700,
transform: [{ translateY: sheetTranslateY }],
}}
>
<BlurView
intensity={40}
intensity={80}
tint='dark'
style={{
borderRadius: 24,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
@@ -89,67 +175,78 @@ export default function TVAccountSelectModalPage() {
trapFocusLeft
trapFocusRight
style={{
padding: 40,
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{/* Title */}
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 8,
marginBottom: 4,
paddingHorizontal: 48,
}}
>
{t("server.select_account")}
</Text>
{/* Server name as subtitle */}
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginBottom: 32,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
}}
>
{modalState.server.name || modalState.server.address}
</Text>
{/* All options in single horizontal row */}
{isReady && (
<>
<View style={{ gap: 12, marginBottom: 24 }}>
{modalState.server.accounts?.map((account, index) => (
<TVAccountCard
key={account.userId}
account={account}
onPress={() => {
modalState.onAccountSelect(account);
router.back();
}}
onLongPress={() => {
modalState.onDeleteAccount(account);
}}
hasTVPreferredFocus={index === 0}
/>
))}
</View>
<View style={{ gap: 12 }}>
<Button
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 20,
gap: 16,
}}
>
{modalState.server.accounts?.map((account, index) => (
<TVUserCard
key={account.userId}
username={account.username}
securityType={account.securityType}
onPress={() => {
modalState.onAddAccount();
router.back();
modalState.onAccountAction(account);
}}
color='white'
>
{t("server.add_account")}
</Button>
<Button
onPress={handleClose}
color='black'
className='bg-neutral-800'
>
{t("common.cancel")}
</Button>
</View>
</>
hasTVPreferredFocus={index === 0}
/>
))}
<TVAccountSelectAction
label={t("server.add_account")}
icon='person-add-outline'
onPress={() => {
modalState.onAddAccount();
router.back();
}}
/>
<TVAccountSelectAction
label={t("server.remove_server")}
icon='trash-outline'
variant='destructive'
onPress={() => {
modalState.onDeleteServer();
router.back();
}}
/>
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>