Add files via upload

This commit is contained in:
Savsis 2026-05-08 13:06:38 +05:00 • committed by GitHub
parent f36d85bdb0
commit 5be2cbda15
6 changed files with 237 additions and 372 deletions

View file

@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "NodeShiftMusic", "name": "NodeShiftMusic",
"slug": "nodeshiftmusic", "slug": "nodeshiftmusic",
"version": "1.0.0", "version": "1.0.7",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
@ -15,7 +15,7 @@
"ios": { "ios": {
"supportsTablet": false, "supportsTablet": false,
"bundleIdentifier": "com.nodeshiftmusic", "bundleIdentifier": "com.nodeshiftmusic",
"buildNumber": "1", "buildNumber": "7",
"infoPlist": { "infoPlist": {
"UIBackgroundModes": ["audio"], "UIBackgroundModes": ["audio"],
"NSMicrophoneUsageDescription": "Не используется", "NSMicrophoneUsageDescription": "Не используется",
@ -29,21 +29,10 @@
}, },
"scheme": "burmalda", "scheme": "burmalda",
"plugins": [ "plugins": [
"expo-router", "expo-router"
[
"expo-updates",
{
"username": "nodeshift"
}
]
], ],
"updates": { "updates": {
"enabled": true, "enabled": false
"checkAutomatically": "ON_LOAD",
"url": "https://nodeshift.space/api/ios/updates"
},
"runtimeVersion": {
"policy": "appVersion"
}, },
"extra": { "extra": {
"apiBase": "https://music.nodeshift.space", "apiBase": "https://music.nodeshift.space",

View file

@ -84,7 +84,7 @@ export default function ProfileScreen() {
</Animated.View> </Animated.View>
<Animated.Text entering={FadeInDown.delay(300)} style={styles.version}> <Animated.Text entering={FadeInDown.delay(300)} style={styles.version}>
Burmalda Music · v1.0.0 · by NodeShift Burmalda Music · v1.0.7 BETA · by NodeShift
</Animated.Text> </Animated.Text>
</ScrollView> </ScrollView>
); );

View file

@ -2,7 +2,6 @@ import { useEffect } from 'react';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import * as Updates from 'expo-updates';
import { useBootAuth } from '../hooks/useAuth'; import { useBootAuth } from '../hooks/useAuth';
import { Colors } from '../constants/theme'; import { Colors } from '../constants/theme';
@ -16,7 +15,6 @@ export default function RootLayout() {
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: Colors.bg } }}> <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: Colors.bg } }}>
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="auth" options={{ presentation: 'fullScreenModal' }} /> <Stack.Screen name="auth" options={{ presentation: 'fullScreenModal' }} />
<Stack.Screen name="auth/callback" options={{ presentation: 'fullScreenModal' }} />
</Stack> </Stack>
</GestureHandlerRootView> </GestureHandlerRootView>
); );
@ -25,16 +23,17 @@ export default function RootLayout() {
function useOTAUpdates() { function useOTAUpdates() {
useEffect(() => { useEffect(() => {
if (__DEV__) return; if (__DEV__) return;
// Lazy import so expo-updates crash doesn't kill the app
(async () => { (async () => {
try { try {
const Updates = await import('expo-updates');
const check = await Updates.checkForUpdateAsync(); const check = await Updates.checkForUpdateAsync();
if (check.isAvailable) { if (check.isAvailable) {
await Updates.fetchUpdateAsync(); await Updates.fetchUpdateAsync();
await Updates.reloadAsync(); await Updates.reloadAsync();
} }
// eslint-disable-next-line no-empty
} catch { } catch {
// silent fail // Silently ignore — OTA is optional
} }
})(); })();
}, []); }, []);

View file

@ -5,15 +5,15 @@ import {
} from 'react-native'; } from 'react-native';
import Animated, { import Animated, {
useSharedValue, useAnimatedStyle, withSpring, withTiming, useSharedValue, useAnimatedStyle, withSpring, withTiming,
withRepeat, withSequence, FadeIn, FadeInDown, FadeInUp, withRepeat, withSequence, withDelay, Easing,
interpolate, Extrapolate, withDelay, Easing, FadeIn, FadeInDown, FadeInUp,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import * as WebBrowser from 'expo-web-browser'; import * as WebBrowser from 'expo-web-browser';
import * as Linking from 'expo-linking'; import * as Linking from 'expo-linking';
import { router } from 'expo-router'; import { router } from 'expo-router';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import Svg, { Path, Circle, Defs, RadialGradient, Stop } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
import { Colors, Font } from '../constants/theme'; import { Colors, Font } from '../constants/theme';
import { buildOAuthUrl, exchangeCode, saveTokens } from '../services/api'; import { buildOAuthUrl, exchangeCode, saveTokens } from '../services/api';
import { useAuth } from '../store'; import { useAuth } from '../store';
@ -23,8 +23,7 @@ WebBrowser.maybeCompleteAuthSession();
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
// Fake album-like gradient cards for the carousel const CARD_COLORS: [string, string][] = [
const CARDS = [
['#1a1a5e', '#3a0ca3'], ['#1a1a5e', '#3a0ca3'],
['#0d1b2a', '#1b4332'], ['#0d1b2a', '#1b4332'],
['#2d0a3a', '#7b2d8b'], ['#2d0a3a', '#7b2d8b'],
@ -34,96 +33,87 @@ const CARDS = [
['#0a1a00', '#1a4a00'], ['#0a1a00', '#1a4a00'],
['#2a0010', '#6a0030'], ['#2a0010', '#6a0030'],
['#001a2a', '#004a5a'], ['#001a2a', '#004a5a'],
['#1e0030', '#5e0080'],
['#002200', '#004400'],
['#300000', '#700000'],
]; ];
function CardRow({ reverse, speed }: { reverse: boolean; speed: number }) {
const ref = useRef<ScrollView>(null);
const pos = useRef(0);
const doubled = [...CARD_COLORS, ...CARD_COLORS, ...CARD_COLORS];
const max = CARD_COLORS.length * 86;
useEffect(() => {
const id = setInterval(() => {
if (reverse) {
pos.current = pos.current <= 0 ? max : pos.current - 0.7;
} else {
pos.current = pos.current >= max ? 0 : pos.current + 0.7;
}
ref.current?.scrollTo({ x: pos.current, animated: false });
}, speed);
return () => clearInterval(id);
}, []);
return (
<ScrollView
ref={ref}
horizontal
scrollEnabled={false}
showsHorizontalScrollIndicator={false}
style={{ flexGrow: 0 }}
>
{doubled.map((c, i) => (
<LinearGradient key={i} colors={c} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={card} />
))}
</ScrollView>
);
}
const card: object = {
width: 76, height: 76, borderRadius: 14, marginHorizontal: 5, opacity: 0.65,
};
export default function AuthScreen() { export default function AuthScreen() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const { setUser } = useAuth(); const { setUser } = useAuth();
// Button animation
const btnScale = useSharedValue(1); const btnScale = useSharedValue(1);
const btnGlow = useSharedValue(0); const orb1y = useSharedValue(0);
const orb2y = useSharedValue(0);
// Orb animations const orb1x = useSharedValue(0);
const orb1X = useSharedValue(0); const orb2x = useSharedValue(0);
const orb1Y = useSharedValue(0); const glowOpacity = useSharedValue(0.3);
const orb2X = useSharedValue(0);
const orb2Y = useSharedValue(0);
const orb3X = useSharedValue(0);
// Title word stagger
const titleOpacity = useSharedValue(0);
const titleY = useSharedValue(30);
useEffect(() => { useEffect(() => {
// Orb 1 - slow drift orb1y.value = withRepeat(withSequence(
orb1X.value = withRepeat( withTiming(-50, { duration: 8000, easing: Easing.inOut(Easing.sin) }),
withSequence( withTiming(30, { duration: 8000, easing: Easing.inOut(Easing.sin) }),
), -1, true);
orb1x.value = withRepeat(withSequence(
withTiming(40, { duration: 9000, easing: Easing.inOut(Easing.sin) }), withTiming(40, { duration: 9000, easing: Easing.inOut(Easing.sin) }),
withTiming(-30, { duration: 9000, easing: Easing.inOut(Easing.sin) }), withTiming(-30, { duration: 9000, easing: Easing.inOut(Easing.sin) }),
), -1, true, ), -1, true);
); orb2y.value = withRepeat(withSequence(
orb1Y.value = withRepeat( withTiming(60, { duration: 10000, easing: Easing.inOut(Easing.sin) }),
withSequence( withTiming(-40, { duration: 10000, easing: Easing.inOut(Easing.sin) }),
withTiming(-50, { duration: 7000, easing: Easing.inOut(Easing.sin) }), ), -1, true);
withTiming(30, { duration: 7000, easing: Easing.inOut(Easing.sin) }), orb2x.value = withRepeat(withSequence(
), -1, true, withTiming(-50, { duration: 11000, easing: Easing.inOut(Easing.sin) }),
);
// Orb 2
orb2X.value = withRepeat(
withSequence(
withTiming(-60, { duration: 11000, easing: Easing.inOut(Easing.sin) }),
withTiming(40, { duration: 11000, easing: Easing.inOut(Easing.sin) }), withTiming(40, { duration: 11000, easing: Easing.inOut(Easing.sin) }),
), -1, true, ), -1, true);
); glowOpacity.value = withDelay(800, withRepeat(withSequence(
orb2Y.value = withRepeat( withTiming(0.8, { duration: 2200, easing: Easing.inOut(Easing.ease) }),
withSequence( withTiming(0.3, { duration: 2200, easing: Easing.inOut(Easing.ease) }),
withTiming(60, { duration: 8000, easing: Easing.inOut(Easing.sin) }), ), -1, false));
withTiming(-40, { duration: 8000, easing: Easing.inOut(Easing.sin) }),
), -1, true,
);
// Orb 3
orb3X.value = withRepeat(
withSequence(
withTiming(50, { duration: 13000, easing: Easing.inOut(Easing.sin) }),
withTiming(-35, { duration: 13000, easing: Easing.inOut(Easing.sin) }),
), -1, true,
);
// Title entrance
titleOpacity.value = withDelay(300, withTiming(1, { duration: 900 }));
titleY.value = withDelay(300, withSpring(0, { damping: 18, stiffness: 80 }));
// Button glow pulse
btnGlow.value = withDelay(1200, withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.ease) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.ease) }),
), -1, false,
));
}, []); }, []);
const orb1Style = useAnimatedStyle(() => ({ const orb1Style = useAnimatedStyle(() => ({ transform: [{ translateX: orb1x.value }, { translateY: orb1y.value }] }));
transform: [{ translateX: orb1X.value }, { translateY: orb1Y.value }], const orb2Style = useAnimatedStyle(() => ({ transform: [{ translateX: orb2x.value }, { translateY: orb2y.value }] }));
})); const btnStyle = useAnimatedStyle(() => ({ transform: [{ scale: btnScale.value }] }));
const orb2Style = useAnimatedStyle(() => ({ const glowStyle = useAnimatedStyle(() => ({ opacity: glowOpacity.value }));
transform: [{ translateX: orb2X.value }, { translateY: orb2Y.value }],
}));
const orb3Style = useAnimatedStyle(() => ({
transform: [{ translateX: orb3X.value }],
}));
const titleStyle = useAnimatedStyle(() => ({
opacity: titleOpacity.value,
transform: [{ translateY: titleY.value }],
}));
const btnStyle = useAnimatedStyle(() => ({
transform: [{ scale: btnScale.value }],
}));
const glowStyle = useAnimatedStyle(() => ({
opacity: interpolate(btnGlow.value, [0, 1], [0.35, 0.75], Extrapolate.CLAMP),
transform: [{ scale: interpolate(btnGlow.value, [0, 1], [1, 1.08], Extrapolate.CLAMP) }],
}));
const handleLogin = useCallback(async () => { const handleLogin = useCallback(async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
@ -133,17 +123,10 @@ export default function AuthScreen() {
const state = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); const state = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
const url = buildOAuthUrl(state); const url = buildOAuthUrl(state);
const result = await WebBrowser.openAuthSessionAsync(url, 'burmalda://auth/callback'); const result = await WebBrowser.openAuthSessionAsync(url, 'burmalda://auth/callback');
if (result.type !== 'success') { setError('Авторизация отменена'); return; }
if (result.type !== 'success') {
setError('Авторизация отменена');
return;
}
const parsed = Linking.parse(result.url); const parsed = Linking.parse(result.url);
const code = parsed.queryParams?.code as string | undefined; const code = parsed.queryParams?.code as string | undefined;
if (!code) { if (!code) { setError((parsed.queryParams?.error as string) || 'Ошибка авторизации'); return; }
setError((parsed.queryParams?.error as string) || 'Ошибка авторизации');
return;
}
const data = await exchangeCode(code); const data = await exchangeCode(code);
await saveTokens(data.access_token, data.refresh_token); await saveTokens(data.access_token, data.refresh_token);
await saveUser(data.user); await saveUser(data.user);
@ -158,232 +141,159 @@ export default function AuthScreen() {
return ( return (
<View style={s.root}> <View style={s.root}>
{/* Deep dark base */} {/* Dark background */}
<View style={[StyleSheet.absoluteFill, { backgroundColor: '#07071a' }]} />
<LinearGradient <LinearGradient
colors={['#070711', '#0d0d1f', '#070711']} colors={['#0d0d25', '#07071a', '#07071a']}
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
/> />
{/* Animated color orbs */} {/* Orbs */}
<Animated.View style={[s.orb, s.orb1, orb1Style]} pointerEvents="none"> <Animated.View style={[s.orb1, orb1Style]}>
<LinearGradient colors={['#3a0ca3', '#4361ee']} style={s.orbInner} /> <LinearGradient colors={['#4361ee', '#7b2ff7']} style={s.orbGrad} />
</Animated.View> </Animated.View>
<Animated.View style={[s.orb, s.orb2, orb2Style]} pointerEvents="none"> <Animated.View style={[s.orb2, orb2Style]}>
<LinearGradient colors={['#7b2ff7', '#e040fb']} style={s.orbInner} /> <LinearGradient colors={['#e040fb', '#7b2ff7']} style={s.orbGrad} />
</Animated.View>
<Animated.View style={[s.orb, s.orb3, orb3Style]} pointerEvents="none">
<LinearGradient colors={['#00b4d8', '#0077b6']} style={s.orbInner} />
</Animated.View> </Animated.View>
{/* Album cards carousel — auto scroll */} {/* Scrolling cards — top half */}
<View style={s.carouselWrap} pointerEvents="none"> <View style={s.carousel}>
<AutoScrollRow reverse={false} speed={35} /> <CardRow reverse={false} speed={16} />
<AutoScrollRow reverse={true} speed={28} /> <CardRow reverse={true} speed={20} />
<AutoScrollRow reverse={false} speed={40} /> <CardRow reverse={false} speed={13} />
</View> </View>
{/* Gradient fade over carousel */} {/* Gradient covers carousel bottom half */}
<LinearGradient <LinearGradient
colors={['#070711', 'transparent', 'transparent', '#070711']} colors={['transparent', '#07071a']}
locations={[0, 0.15, 0.75, 1]} style={s.fadeBottom}
style={StyleSheet.absoluteFill} />
pointerEvents="none" <LinearGradient
colors={['#07071a', 'transparent']}
style={s.fadeTop}
/> />
{/* Bottom gradient */} {/* Main content */}
<LinearGradient
colors={['transparent', '#070711']}
style={[StyleSheet.absoluteFill, { top: height * 0.45 }]}
pointerEvents="none"
/>
{/* Content */}
<View style={s.content}> <View style={s.content}>
{/* Logo */} {/* Logo icon */}
<Animated.View style={[s.logoWrap, titleStyle]}> <Animated.View entering={FadeIn.delay(200).duration(600)} style={s.logoRow}>
<Animated.View style={[s.logoIconWrap, glowStyle]}> <View style={s.logoShadow}>
<Animated.View style={glowStyle}>
<LinearGradient <LinearGradient
colors={['#4361ee', '#7b2ff7', '#e040fb']} colors={['#4361ee', '#7b2ff7', '#e040fb']}
start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }}
style={s.logoIcon} style={s.logoBox}
> >
{/* Abstract waveform / sound lines — no music note */} <Svg width={32} height={32} viewBox="0 0 32 32" fill="none">
<Svg width={34} height={34} viewBox="0 0 34 34" fill="none"> <Path
<Path d="M4 17 Q8 8, 12 17 Q16 26, 20 17 Q24 8, 30 17" d="M3 16 Q7 7, 11 16 Q15 25, 19 16 Q23 7, 29 16"
stroke="#fff" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> stroke="#fff" strokeWidth={2.5} strokeLinecap="round" fill="none"
<Circle cx={17} cy={17} r={2.5} fill="rgba(255,255,255,0.6)" /> />
<Circle cx={16} cy={16} r={2.5} fill="rgba(255,255,255,0.55)" />
</Svg> </Svg>
</LinearGradient> </LinearGradient>
</Animated.View> </Animated.View>
</View>
</Animated.View> </Animated.View>
<Animated.Text style={[s.brand, titleStyle]}>Burmalda</Animated.Text> {/* Brand name */}
<Animated.Text entering={FadeInDown.delay(300).springify()} style={s.brand}>
Burmalda
</Animated.Text>
<Animated.View entering={FadeInDown.delay(500).springify()}> {/* Tagline */}
<Text style={s.tagline}>Твоя музыка. Твой ритм.</Text> <Animated.Text entering={FadeInDown.delay(450).duration(600)} style={s.tagline}>
«Если я говорю сукин сын и сын шлюхи — это не одно и то же»
</Animated.Text>
{/* Feature list */}
<Animated.View entering={FadeInUp.delay(600).springify()} style={s.features}>
<FeatureItem color="#4361ee" text="Умные рекомендации DeepSeek" />
<FeatureItem color="#7b2ff7" text="Моя волна — персональный микс" />
<FeatureItem color="#00b4d8" text="Оффлайн — треки без интернета" />
</Animated.View> </Animated.View>
<Animated.View entering={FadeInUp.delay(700).springify()} style={s.features}> {/* Button */}
<FeatureRow icon="wave" text="Умные рекомендации" delay={800} /> <Animated.View entering={FadeInUp.delay(750).springify()}>
<FeatureRow icon="cloud" text="Оффлайн доступ" delay={950} />
<FeatureRow icon="heart" text="Ваша волна" delay={1100} />
</Animated.View>
{/* Login button */}
<Animated.View entering={FadeInUp.delay(900).springify()} style={s.btnArea}>
<Animated.View style={btnStyle}> <Animated.View style={btnStyle}>
<Pressable <Pressable
onPressIn={() => { btnScale.value = withSpring(0.96, { damping: 12 }); }} onPressIn={() => { btnScale.value = withSpring(0.96, { damping: 12 }); }}
onPressOut={() => { btnScale.value = withSpring(1, { damping: 12 }); }} onPressOut={() => { btnScale.value = withSpring(1, { damping: 12 }); }}
onPress={handleLogin} onPress={handleLogin}
disabled={loading} disabled={loading}
style={s.btnOuter}
> >
<LinearGradient <LinearGradient
colors={['#4361ee', '#7b2ff7', '#e040fb']} colors={['#4361ee', '#7b2ff7', '#e040fb']}
start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }}
style={s.btn} style={s.btn}
> >
{loading ? ( {loading
<ActivityIndicator color="#fff" size="small" /> ? <ActivityIndicator color="#fff" size="small" />
) : ( : (
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none"> <Svg width={18} height={18} viewBox="0 0 24 24" fill="none">
<Path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3" <Path
stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3"
stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"
/>
</Svg> </Svg>
)} )
}
<Text style={s.btnText}> <Text style={s.btnText}>
{loading ? 'Входим…' : 'Войти через NodeShift'} {loading ? 'Входим…' : 'Войти через NodeShift'}
</Text> </Text>
</LinearGradient> </LinearGradient>
</Pressable> </Pressable>
</Animated.View> </Animated.View>
</Animated.View>
{!!error && ( {!!error && (
<Animated.Text entering={FadeIn} style={s.error}>{error}</Animated.Text> <Animated.Text entering={FadeIn} style={s.error}>{error}</Animated.Text>
)} )}
<Animated.Text entering={FadeIn.delay(1200)} style={s.terms}> <Animated.Text entering={FadeIn.delay(900)} style={s.terms}>
Продолжая, вы соглашаетесь с условиями NodeShift Продолжая, вы соглашаетесь с условиями NodeShift
</Animated.Text> </Animated.Text>
</Animated.View>
</View> </View>
</View> </View>
); );
} }
// ── Auto-scrolling card row ──────────────────────────────────────────────────── function FeatureItem({ color, text }: { color: string; text: string }) {
function AutoScrollRow({ reverse, speed }: { reverse: boolean; speed: number }) {
const scrollRef = useRef<ScrollView>(null);
const offset = useRef(0);
const maxScroll = useRef(CARDS.length * 90);
useEffect(() => {
const interval = setInterval(() => {
offset.current = reverse
? offset.current <= 0 ? maxScroll.current : offset.current - 0.8
: offset.current >= maxScroll.current ? 0 : offset.current + 0.8;
scrollRef.current?.scrollTo({ x: offset.current, animated: false });
}, speed / 10);
return () => clearInterval(interval);
}, []);
const doubled = [...CARDS, ...CARDS, ...CARDS];
return ( return (
<ScrollView <View style={s.featureRow}>
ref={scrollRef} <View style={[s.dot, { backgroundColor: color }]} />
horizontal
scrollEnabled={false}
showsHorizontalScrollIndicator={false}
style={s.cardRow}
>
{doubled.map((colors, i) => (
<GradientCard key={i} colors={colors as [string, string]} />
))}
</ScrollView>
);
}
function GradientCard({ colors }: { colors: [string, string] }) {
return (
<LinearGradient colors={colors} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={s.card} />
);
}
// ── Feature row item ──────────────────────────────────────────────────────────
function FeatureRow({ icon, text, delay }: { icon: string; text: string; delay: number }) {
const icons: Record<string, JSX.Element> = {
wave: (
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none">
<Path d="M2 12 Q5 4, 8 12 Q11 20, 14 12 Q17 4, 22 12"
stroke={Colors.accent} strokeWidth={2} strokeLinecap="round" fill="none" />
</Svg>
),
cloud: (
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none">
<Path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"
stroke={Colors.accent} strokeWidth={2} strokeLinecap="round" />
</Svg>
),
heart: (
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none">
<Path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 0 0 0-7.78z"
stroke={Colors.accent} strokeWidth={2} strokeLinejoin="round" />
</Svg>
),
};
return (
<Animated.View entering={FadeInDown.delay(delay).springify()} style={s.featureRow}>
<View style={s.featureIcon}>{icons[icon]}</View>
<Text style={s.featureText}>{text}</Text> <Text style={s.featureText}>{text}</Text>
</Animated.View> </View>
); );
} }
// ── Styles ────────────────────────────────────────────────────────────────────
const CARD_W = 76;
const CARD_H = 76;
const s = StyleSheet.create({ const s = StyleSheet.create({
root: { flex: 1, backgroundColor: '#070711' }, root: { flex: 1 },
// Orbs orb1: { position: 'absolute', width: 320, height: 320, top: -60, left: -100, borderRadius: 160, overflow: 'hidden', opacity: 0.2 },
orb: { position: 'absolute', borderRadius: 999 }, orb2: { position: 'absolute', width: 260, height: 260, top: height * 0.2, right: -80, borderRadius: 130, overflow: 'hidden', opacity: 0.15 },
orbInner: { flex: 1, borderRadius: 999 }, orbGrad: { flex: 1 },
orb1: { width: 340, height: 340, top: -80, left: -100, opacity: 0.22 },
orb2: { width: 280, height: 280, top: height * 0.25, right: -80, opacity: 0.18 },
orb3: { width: 200, height: 200, bottom: height * 0.3, left: -60, opacity: 0.14 },
// Carousel carousel: { position: 'absolute', top: 0, left: 0, right: 0, height: height * 0.52, gap: 10, paddingTop: 56, overflow: 'hidden' },
carouselWrap: { position: 'absolute', top: 0, left: 0, right: 0, height: height * 0.55, gap: 10, paddingTop: 60, overflow: 'hidden' }, fadeBottom: { position: 'absolute', top: height * 0.35, left: 0, right: 0, height: height * 0.2 },
cardRow: { flexGrow: 0, paddingHorizontal: 8 }, fadeTop: { position: 'absolute', top: 0, left: 0, right: 0, height: 80 },
card: { width: CARD_W, height: CARD_H, borderRadius: 14, marginHorizontal: 5, opacity: 0.7 },
// Content content: { flex: 1, justifyContent: 'flex-end', paddingBottom: 50, paddingHorizontal: 28 },
content: { flex: 1, justifyContent: 'flex-end', paddingBottom: 48, paddingHorizontal: 28 },
logoWrap: { alignItems: 'flex-start', marginBottom: 16 },
logoIconWrap:{ alignSelf: 'flex-start' },
logoIcon: { width: 64, height: 64, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
brand: { fontSize: 48, fontWeight: '800', color: '#fff', letterSpacing: -2, marginBottom: 8, lineHeight: 52 }, logoRow: { marginBottom: 18 },
tagline: { fontSize: 18, color: 'rgba(255,255,255,0.55)', fontWeight: '500', marginBottom: 32, letterSpacing: -0.3 }, logoShadow: { shadowColor: '#7b2ff7', shadowOffset: { width: 0, height: 12 }, shadowOpacity: 0.6, shadowRadius: 20 },
logoBox: { width: 62, height: 62, borderRadius: 17, alignItems: 'center', justifyContent: 'center' },
// Features brand: { fontSize: 46, fontWeight: '800', color: '#fff', letterSpacing: -2, marginBottom: 10, lineHeight: 50 },
features: { gap: 12, marginBottom: 36 }, tagline: { fontSize: 13, color: 'rgba(255,255,255,0.38)', fontWeight: '400', fontStyle: 'italic', marginBottom: 30, lineHeight: 19, letterSpacing: 0.1 },
features: { gap: 11, marginBottom: 32 },
featureRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, featureRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
featureIcon: { width: 32, height: 32, borderRadius: 10, backgroundColor: 'rgba(67,97,238,0.15)', alignItems: 'center', justifyContent: 'center' }, dot: { width: 8, height: 8, borderRadius: 4 },
featureText: { color: 'rgba(255,255,255,0.7)', fontSize: 15, fontWeight: '500' }, featureText:{ color: 'rgba(255,255,255,0.65)', fontSize: 14, fontWeight: '500' },
// Button btn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, paddingVertical: 17, paddingHorizontal: 24, borderRadius: 17 },
btnArea: { gap: 16 },
btnOuter: { borderRadius: 18, overflow: 'hidden', shadowColor: '#7b2ff7', shadowOffset: { width: 0, height: 12 }, shadowOpacity: 0.5, shadowRadius: 24 },
btn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, paddingVertical: 18, paddingHorizontal: 24 },
btnText: { color: '#fff', fontSize: 16, fontWeight: '700', letterSpacing: -0.3 }, btnText: { color: '#fff', fontSize: 16, fontWeight: '700', letterSpacing: -0.3 },
error: { color: '#ff6b6b', fontSize: 13, textAlign: 'center' }, error: { color: '#ff6b6b', fontSize: 13, textAlign: 'center', marginTop: 12 },
terms: { color: 'rgba(255,255,255,0.28)', fontSize: 12, textAlign: 'center', lineHeight: 18 }, terms: { color: 'rgba(255,255,255,0.25)', fontSize: 11, textAlign: 'center', marginTop: 16, lineHeight: 17 },
}); });

View file

@ -1,57 +1,24 @@
/** import { useEffect } from 'react';
* app/auth/callback.tsx import { View, ActivityIndicator } from 'react-native';
* Handles OAuth deep link: burmalda://auth/callback?code=...
* expo-web-browser closes the session, then this page completes the flow.
*/
import { useEffect, useState } from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { useLocalSearchParams, router } from 'expo-router';
import * as WebBrowser from 'expo-web-browser'; import * as WebBrowser from 'expo-web-browser';
import { Colors, Font } from '../../constants/theme'; import { router } from 'expo-router';
import { exchangeCode, saveTokens } from '../../services/api';
import { useAuth } from '../../store';
import { saveUser } from '../../hooks/useAuth';
// Complete the auth session so expo-web-browser can close // Complete the browser auth session so WebBrowser.openAuthSessionAsync returns
WebBrowser.maybeCompleteAuthSession(); WebBrowser.maybeCompleteAuthSession();
export default function AuthCallback() { export default function AuthCallback() {
const params = useLocalSearchParams<{ code?: string; error?: string }>();
const { setUser } = useAuth();
const [status, setStatus] = useState('Завершаем вход…');
useEffect(() => { useEffect(() => {
async function finish() { // The actual code exchange is handled in auth.tsx via openAuthSessionAsync
const code = params.code; // This page only shows briefly if opened directly; redirect to auth
if (!code) { const t = setTimeout(() => {
const msg = params.error || 'Код авторизации отсутствует'; router.replace('/auth');
setStatus(`Ошибка: ${msg}`); }, 500);
setTimeout(() => router.replace('/auth'), 2000); return () => clearTimeout(t);
return;
}
try {
const data = await exchangeCode(code);
await saveTokens(data.access_token, data.refresh_token);
await saveUser(data.user);
setUser(data.user);
router.replace('/(tabs)');
} catch (e: any) {
setStatus(`Ошибка: ${e.message || 'Не удалось войти'}`);
setTimeout(() => router.replace('/auth'), 2500);
}
}
finish();
}, []); }, []);
return ( return (
<View style={s.root}> <View style={{ flex: 1, backgroundColor: '#07071a', alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator size="large" color={Colors.accent} /> <ActivityIndicator color="#4361ee" size="large" />
<Text style={s.text}>{status}</Text>
</View> </View>
); );
} }
const s = StyleSheet.create({
root: { flex: 1, backgroundColor: Colors.bg, alignItems: 'center', justifyContent: 'center', gap: 20 },
text: { color: Colors.text, fontSize: Font.md, textAlign: 'center', opacity: 0.7 },
});

View file

@ -1,6 +1,6 @@
{ {
"name": "burmalda-music", "name": "burmalda-music",
"version": "1.0.0", "version": "1.0.7-beta",
"main": "./index.js", "main": "./index.js",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",