-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathApp.tsx
More file actions
173 lines (151 loc) · 6.15 KB
/
App.tsx
File metadata and controls
173 lines (151 loc) · 6.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
import React, { useState, useEffect, useCallback, useRef } from 'react';
import PullSwitch from './components/PullSwitch';
import AlbumCard from './components/AlbumCard';
import { fetchAlbums, getRandomSlogan } from './services/geminiService';
import { Album, LightState } from './types';
// Local background image
const BG_URL = '/background.webp';
const App: React.FC = () => {
const [lightState, setLightState] = useState<LightState>(LightState.OFF);
const [albums, setAlbums] = useState<Album[]>([]);
const [loading, setLoading] = useState(false);
const [hasFetched, setHasFetched] = useState(false);
const [currentPlayingIndex, setCurrentPlayingIndex] = useState<number | null>(null);
const [slogan, setSlogan] = useState<string>(() => getRandomSlogan());
const audioRef = useRef<HTMLAudioElement | null>(null);
// Initialize data but don't show until light is on
useEffect(() => {
const loadData = async () => {
setLoading(true);
const data = await fetchAlbums();
setAlbums(data);
setLoading(false);
setHasFetched(true);
};
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const toggleLight = useCallback(() => {
setLightState(prev => {
const newState = prev === LightState.ON ? LightState.OFF : LightState.ON;
// Update slogan only when turning ON
if (newState === LightState.ON) {
setSlogan(getRandomSlogan());
}
return newState;
});
}, []);
const handleAlbumClick = useCallback((index: number, musicUrl?: string) => {
if (!musicUrl) return;
// If clicking the same album that's playing, pause it
if (currentPlayingIndex === index) {
audioRef.current?.pause();
setCurrentPlayingIndex(null);
return;
}
// Play new album
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = musicUrl;
audioRef.current.play().catch(err => {
console.error('Failed to play audio:', err);
});
setCurrentPlayingIndex(index);
}
}, [currentPlayingIndex]);
// Handle audio end
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const handleEnded = () => {
setCurrentPlayingIndex(null);
};
audio.addEventListener('ended', handleEnded);
return () => {
audio.removeEventListener('ended', handleEnded);
};
}, []);
const isOn = lightState === LightState.ON;
return (
<div className="relative min-h-screen w-full overflow-hidden bg-black select-none">
{/* Background Layer - Visible when ON */}
<div
className="absolute inset-0 z-0 transition-opacity duration-[2000ms] ease-in-out"
style={{
opacity: isOn ? 1 : 0,
backgroundImage: `url(${BG_URL})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{/* Overlay to darken background slightly for text readability */}
<div className="absolute inset-0 bg-black/40 backdrop-blur-[2px]"></div>
</div>
{/* "Pitch Black" Layer - Active when OFF */}
{/* We use a high z-index overlay that fades out */}
<div
className="absolute inset-0 z-10 bg-black pointer-events-none transition-opacity duration-700 ease-out"
style={{ opacity: isOn ? 0 : 1 }}
>
{/* Spotlight Effect around the switch area so user can see it in the dark */}
<div className="absolute top-0 right-8 w-32 h-64 bg-radial-gradient from-white/10 to-transparent opacity-50 blur-xl rounded-full transform -translate-y-1/2 translate-x-1/4"></div>
</div>
{/* Content Layer */}
<div className={`relative z-20 min-h-screen flex flex-col items-center justify-start pt-24 px-4 pb-20 transition-all duration-1000 ${isOn ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
{/* Header */}
<header className={`mb-12 text-center ${
isOn
? 'animate-[bounce_3s_ease-in-out_1_normal_forwards] delay-[2s]'
: 'animate-[bounce_3s_ease-in-out_1_reverse_forwards]'
}`}>
<h1 className="text-4xl md:text-6xl text-white font-chinese text-shadow-pixel mb-4 drop-shadow-[4px_4px_0_#ef4444]">
华晨宇
</h1>
<p className="text-red-400 font-pixel text-sm font-chinese md:text-base bg-black/60 p-2 inline-block rounded border-2 border-red-500">
{slogan}
</p>
</header>
{/* Album Grid */}
<main className="w-full max-w-6xl">
{loading && !hasFetched ? (
<div className="text-white font-pixel text-center animate-pulse">Loading Martian Data...</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 md:gap-12">
{albums.map((album, index) => (
<div key={index} style={{ animationDelay: `${index * 150}ms` }} className="animate-[slideUp_0.5s_ease-out_both]">
<AlbumCard
album={album}
isPlaying={currentPlayingIndex === index}
onClick={() => handleAlbumClick(index, album.musicUrl)}
/>
</div>
))}
</div>
)}
</main>
<footer className="mt-20 text-gray-500 text-[10px] font-pixel text-center bg-black/80 p-4 rounded border border-gray-800">
© 2024 MARS SPACE STATION. PIXEL ART TRIBUTE.
</footer>
</div>
{/* The Switch - Always clickable, stays on top of everything */}
<PullSwitch onToggle={toggleLight} isOn={isOn} />
{/* Hidden audio element for music playback */}
<audio ref={audioRef} preload="none" />
{/* Global Styles for custom keyframes not in standard Tailwind */}
<style>{`
@keyframes slideUp {
from { transform: translateY(50px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.text-shadow-pixel {
text-shadow: 4px 4px 0px #000000;
}
`}</style>
</div>
);
};
export default App;