chahinebrini 2c1eecd1f7 feat(native): geräte-spezifische PNG-Icons (iphone/android/macbook/computer)
deviceImage()-Helper mappt Plattform→assets/devices/*.png; ersetzt Ionicons
in Geräte-Rows, MagicSheet und Detail-Sheet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:40:25 +02:00

505 lines
15 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Image,
Linking,
Pressable,
Share,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import * as Clipboard from 'expo-clipboard';
import { useTranslation } from 'react-i18next';
import type { ColorScheme } from '../../lib/theme';
import { apiFetch } from '../../lib/api';
import { useUserPlan } from '../../hooks/useUserPlan';
import { FormSheet } from '../FormSheet';
import { deviceImage } from './deviceIcon';
type PairResponse = {
code: string;
expiresAt: string;
expiresInSeconds: number;
};
type MagicDevice = {
deviceId: string;
hostname: string;
model: string | null;
osVersion: string | null;
magicEnrolledAt: string;
/** "magic" = Desktop-Binding (Mac/Win), "locked" = Mobile, "protected" = Legacy-DNS */
source?: 'magic' | 'locked' | 'protected';
};
type MagicInfo = {
latestVersion: string;
downloadUrl: string;
dmgUrl: string;
minMacosVersion: string;
windowsInstallerUrl?: string;
minWindowsVersion?: string;
};
type DesktopPlatform = 'mac' | 'windows';
/**
* MagicSheet — Rebreak-Magic-Pairing-Flow als geteiltes FormSheet
* (Bottom-Sheet mit Titel-Header). Wird aus settings.tsx getriggert.
*/
export function MagicSheet({
visible,
onClose,
colors,
initialPlatform = 'mac',
}: {
visible: boolean;
onClose: () => void;
colors: ColorScheme;
/** Vorauswahl wenn aus dem "Gerät hinzufügen"-Menü (Mac/Windows) geöffnet */
initialPlatform?: DesktopPlatform;
}) {
const [info, setInfo] = useState<MagicInfo | null>(null);
const [platform, setPlatform] = useState<DesktopPlatform>(initialPlatform);
// Bei jedem Öffnen auf die gewünschte Plattform zurücksetzen
useEffect(() => {
if (visible) setPlatform(initialPlatform);
}, [visible, initialPlatform]);
const [pair, setPair] = useState<PairResponse | null>(null);
const [pairLoading, setPairLoading] = useState(false);
const [pairError, setPairError] = useState<string | null>(null);
const [now, setNow] = useState(Date.now());
const [devices, setDevices] = useState<MagicDevice[] | null>(null);
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
const { t } = useTranslation();
const { plan } = useUserPlan();
// Desktop-Slots (Mac/Windows) — Mirror von backend plan-features.maxProtectedDevices
const desktopLimit = plan === 'legend' ? 2 : 1;
useEffect(() => {
(async () => {
try {
const i = await apiFetch<MagicInfo>('/api/magic/info');
setInfo(i);
} catch {
setInfo({
latestVersion: '0.1.0',
downloadUrl: 'https://staging.rebreak.org/download/rebreakmagic',
dmgUrl: 'https://staging.rebreak.org/downloads/RebreakMagic-latest.dmg',
minMacosVersion: '13.0',
windowsInstallerUrl: 'https://staging.rebreak.org/downloads/RebreakMagic-Setup.exe',
minWindowsVersion: '10 (21H2)',
});
}
loadDevices();
})();
return () => {
if (tickRef.current) clearInterval(tickRef.current);
};
}, []);
useEffect(() => {
if (pair) {
if (tickRef.current) clearInterval(tickRef.current);
tickRef.current = setInterval(() => setNow(Date.now()), 1000);
return () => {
if (tickRef.current) clearInterval(tickRef.current);
};
}
}, [pair]);
async function loadDevices() {
try {
const d = await apiFetch<MagicDevice[]>('/api/magic/devices');
setDevices(d);
} catch {
setDevices([]);
}
}
async function handleGenerateCode() {
setPairLoading(true);
setPairError(null);
try {
const res = await apiFetch<PairResponse>('/api/magic/pair/create', {
method: 'POST',
body: {},
});
setPair(res);
setNow(Date.now());
} catch (e: any) {
setPairError(e?.message ?? t('magic.generate_error'));
} finally {
setPairLoading(false);
}
}
async function handleCopyCode() {
if (!pair) return;
await Clipboard.setStringAsync(pair.code);
}
const remaining = useMemo(() => {
if (!pair) return 0;
const exp = new Date(pair.expiresAt).getTime();
return Math.max(0, Math.floor((exp - now) / 1000));
}, [pair, now]);
const codeExpired = pair !== null && remaining <= 0;
// Desktop-Geräte (Mac/Windows) — "locked" sind Mobile-Devices aus dem
// merged /api/magic/devices-Endpoint und zählen NICHT auf Desktop-Slots.
const desktopDevices = useMemo(
() => (devices ?? []).filter((d) => d.source !== 'locked'),
[devices],
);
const limitReached = desktopDevices.length >= desktopLimit;
const isMac = platform === 'mac';
const appName = t(isMac ? 'magic.app_mac' : 'magic.app_windows');
const downloadHref = isMac
? info?.downloadUrl
: (info?.windowsInstallerUrl ?? info?.downloadUrl);
return (
<FormSheet
visible={visible}
onClose={onClose}
title="Rebreak Magic"
growWithKeyboard={false}
>
<View style={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 24 }}>
{/* Sub-Header (Tagline) */}
<Text style={{ fontSize: 13, color: colors.textMuted, marginBottom: 16, marginLeft: 4 }}>
{t(isMac ? 'magic.tagline_mac' : 'magic.tagline_windows')}
</Text>
{/* Plattform-Wahl */}
<SectionTitle text={t('magic.platform_question')} colors={colors} />
<View style={{ flexDirection: 'row', gap: 10, marginBottom: 16 }}>
<PlatformOption
icon="laptop-outline"
label="Mac"
selected={isMac}
onPress={() => setPlatform('mac')}
colors={colors}
/>
<PlatformOption
icon="desktop-outline"
label="Windows"
selected={!isMac}
onPress={() => setPlatform('windows')}
colors={colors}
/>
</View>
{/* Step 1 — Download */}
<SectionTitle text={t('magic.step1_title', { app: appName })} colors={colors} />
<View style={cardStyle(colors)}>
<Text style={{ fontSize: 14, color: colors.text, marginBottom: 12 }}>
{isMac
? t('magic.step1_body_mac', { version: info?.minMacosVersion ?? '13.0' })
: t('magic.step1_body_windows', { version: info?.minWindowsVersion ?? '10 (21H2)' })}
</Text>
<PrimaryButton
icon="cloud-download-outline"
label={t('magic.open_download')}
onPress={() => downloadHref && Linking.openURL(downloadHref)}
/>
<TouchableOpacity
onPress={() => downloadHref && Share.share({ message: downloadHref })}
style={{ marginTop: 10, alignSelf: 'flex-start' }}
>
<Text style={{ fontSize: 13, color: '#007AFF' }}>
{t(isMac ? 'magic.send_link_mac' : 'magic.send_link_windows')}
</Text>
</TouchableOpacity>
</View>
{/* Step 2 — Pairing-Code */}
<SectionTitle text={t('magic.step2_title')} colors={colors} />
<View style={cardStyle(colors)}>
{limitReached && (!pair || codeExpired) ? (
<Text style={{ fontSize: 14, color: colors.textMuted }}>
{t('magic.limit_reached', {
count: desktopDevices.length,
max: desktopLimit,
})}{' '}
{t(plan === 'legend' ? 'magic.limit_hint_legend' : 'magic.limit_hint_pro')}
</Text>
) : !pair || codeExpired ? (
<>
<Text style={{ fontSize: 14, color: colors.text, marginBottom: 14 }}>
{t('magic.code_explainer', { app: appName })}
</Text>
<PrimaryButton
icon="key-outline"
label={
pairLoading
? t('magic.generating')
: codeExpired
? t('magic.generate_new')
: t('magic.generate')
}
onPress={handleGenerateCode}
loading={pairLoading}
/>
{pairError && (
<Text style={{ marginTop: 10, color: colors.error, fontSize: 13 }}>{pairError}</Text>
)}
</>
) : (
<>
<Text
style={{
fontSize: 13,
color: colors.textMuted,
textAlign: 'center',
marginBottom: 12,
}}
>
{t('magic.enter_in_app', { app: appName })}
</Text>
<Pressable
onPress={handleCopyCode}
style={{
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
paddingVertical: 18,
borderRadius: 14,
backgroundColor: colors.groupedBg,
marginBottom: 12,
}}
>
{pair.code.split('').map((d, i) => (
<View
key={i}
style={{
width: 38,
height: 52,
borderRadius: 8,
backgroundColor: colors.card,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 28, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{d}
</Text>
</View>
))}
</Pressable>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Ionicons name="time-outline" size={14} color={colors.textMuted} />
<Text style={{ fontSize: 13, color: colors.textMuted }}>
{t('magic.expires_in', { time: formatRemaining(remaining) })}
</Text>
</View>
<TouchableOpacity onPress={handleCopyCode}>
<Text style={{ fontSize: 13, color: '#007AFF', fontWeight: '600' }}>
{t('magic.copy')}
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={() => {
setPair(null);
setPairError(null);
}}
style={{ marginTop: 14, alignSelf: 'center' }}
>
<Text style={{ fontSize: 13, color: colors.textMuted }}>
{t('magic.discard_code')}
</Text>
</TouchableOpacity>
</>
)}
</View>
{/* Verbundene Computer + Slot-Anzeige */}
<SectionTitle
text={`${t('magic.connected_title')} \u00b7 ${desktopDevices.length}/${desktopLimit}`}
colors={colors}
/>
<View style={cardStyle(colors)}>
{devices === null ? (
<ActivityIndicator />
) : desktopDevices.length === 0 ? (
<Text style={{ fontSize: 14, color: colors.textMuted }}>
{t('magic.connected_empty')}
</Text>
) : (
desktopDevices.map((d, i) => (
<View
key={d.deviceId}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
borderTopWidth: i === 0 ? 0 : 1,
borderTopColor: colors.border,
}}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 9,
backgroundColor: colors.groupedBg,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
}}
>
<Image
source={deviceImage(undefined, d.model)}
style={{ width: 24, height: 24 }}
resizeMode="contain"
/>
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 15, color: colors.text, fontWeight: '600' }}>
{d.hostname}
</Text>
{d.model && (
<Text style={{ fontSize: 12, color: colors.textMuted, marginTop: 1 }}>
{d.model}
</Text>
)}
</View>
</View>
))
)}
</View>
</View>
</FormSheet>
);
}
// ─── Helpers ───────────────────────────────────────────────────────────────
function cardStyle(colors: ColorScheme) {
return {
backgroundColor: colors.card,
borderRadius: 14,
padding: 16,
marginBottom: 16,
} as const;
}
function SectionTitle({ text, colors }: { text: string; colors: ColorScheme }) {
return (
<Text
style={{
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 0.4,
color: colors.textMuted,
marginBottom: 8,
marginLeft: 4,
fontFamily: 'Nunito_700Bold',
}}
>
{text}
</Text>
);
}
function PrimaryButton({
icon,
label,
onPress,
loading,
}: {
icon: React.ComponentProps<typeof Ionicons>['name'];
label: string;
onPress: () => void;
loading?: boolean;
}) {
return (
<TouchableOpacity
onPress={onPress}
disabled={loading}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: '#007AFF',
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
opacity: loading ? 0.6 : 1,
}}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Ionicons name={icon} size={18} color="#fff" />
)}
<Text style={{ color: '#fff', fontSize: 15, fontFamily: 'Nunito_700Bold' }}>{label}</Text>
</TouchableOpacity>
);
}
function PlatformOption({
icon,
label,
selected,
onPress,
colors,
}: {
icon: React.ComponentProps<typeof Ionicons>['name'];
label: string;
selected: boolean;
onPress: () => void;
colors: ColorScheme;
}) {
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingVertical: 14,
borderRadius: 12,
backgroundColor: colors.card,
borderWidth: 2,
borderColor: selected ? '#007AFF' : colors.border,
}}
>
<Ionicons name={icon} size={18} color={selected ? '#007AFF' : colors.textMuted} />
<Text
style={{
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: selected ? '#007AFF' : colors.text,
}}
>
{label}
</Text>
</TouchableOpacity>
);
}
function formatRemaining(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}