feat(about): add local time/weather and remove unused components
This commit is contained in:
1 parent
286ec847fc
commit
ae76389178
7 files changed
+218
-732
No files matched your search
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import type { Education, Experience, SiteSettings } from '../lib/data';
|
||||
import ScrollReveal from './ScrollReveal';
|
||||
import GithubContributions from './GithubContributions';
|
||||
import LocalTimeWeather from './LocalTimeWeather';
|
||||
|
||||
interface AboutProps {
|
||||
settings: SiteSettings;
|
||||
@@ -130,6 +131,8 @@ export default function About({
|
||||
<span className="font-medium text-foreground-custom">{settings.location}</span>
|
||||
</div>
|
||||
|
||||
<LocalTimeWeather latitude={14.130792771445167} longitude={121.43629230199042} />
|
||||
|
||||
<div className="flex justify-between items-center py-1 border-b border-border-custom/50">
|
||||
<span className="font-mono text-muted-foreground-custom">Contact</span>
|
||||
<a
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'model';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function ChatWidget() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{ role: 'model', text: "Hello! I am Cris Charles's AI assistant. Ask me anything about Cris's projects, experience, skills, or background!" }
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, loading]);
|
||||
|
||||
const handleSend = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || loading) return;
|
||||
|
||||
const userText = input.trim();
|
||||
setInput('');
|
||||
setMessages((prev) => [...prev, { role: 'user', text: userText }]);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Map previous history for context
|
||||
const chatHistory = messages.slice(1).map((m) => ({
|
||||
role: m.role,
|
||||
text: m.text
|
||||
}));
|
||||
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: userText,
|
||||
history: chatHistory
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessages((prev) => [...prev, { role: 'model', text: data.text }]);
|
||||
} else {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'model', text: "Sorry, I encountered an error. Please try again." }
|
||||
]);
|
||||
}
|
||||
} catch (err) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'model', text: "Network error. Please check your connection and try again." }
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl glass-card rounded-[32px] border border-border/20 shadow-2xl overflow-hidden flex flex-col h-[600px] relative">
|
||||
{/* Accent gradients */}
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-primary-custom to-secondary-custom" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4.5 border-b border-border/10 flex items-center gap-3 bg-muted-custom/10">
|
||||
<div className="relative flex">
|
||||
<span className="w-10 h-10 rounded-full bg-gradient-to-tr from-primary-custom to-secondary-custom flex items-center justify-center text-white font-bold text-sm shadow-md">
|
||||
AI
|
||||
</span>
|
||||
<span className="absolute bottom-0 right-0 w-3 h-3 rounded-full bg-emerald-500 border-2 border-background-custom" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-foreground-custom font-heading">
|
||||
Cris's Portfolio Assistant
|
||||
</h3>
|
||||
<p className="text-[10px] text-muted-foreground-custom font-semibold">
|
||||
Powered by Gemini 2.5 Flash
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages list */}
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.map((msg, index) => {
|
||||
const isUser = msg.role === 'user';
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
className={`flex w-full ${isUser ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] px-4 py-3 rounded-2xl text-sm leading-relaxed ${
|
||||
isUser
|
||||
? 'bg-primary-custom text-primary-foreground rounded-br-none shadow-md'
|
||||
: 'glass-card border border-border/15 text-foreground-custom/95 rounded-bl-none'
|
||||
}`}
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Loading typing indicator */}
|
||||
{loading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex w-full justify-start"
|
||||
>
|
||||
<div className="glass-card border border-border/15 px-5 py-4 rounded-2xl rounded-bl-none flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-primary-custom animate-bounce" style={{ animationDelay: '0s' }} />
|
||||
<span className="w-2 h-2 rounded-full bg-primary-custom animate-bounce" style={{ animationDelay: '0.2s' }} />
|
||||
<span className="w-2 h-2 rounded-full bg-primary-custom animate-bounce" style={{ animationDelay: '0.4s' }} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Form Input */}
|
||||
<form
|
||||
onSubmit={handleSend}
|
||||
className="p-4 border-t border-border/10 flex items-center gap-2 bg-muted-custom/10"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Ask about projects, work history, skills..."
|
||||
disabled={loading}
|
||||
className="flex-1 px-4.5 py-3.5 rounded-2xl bg-background-custom border border-border/15 focus:border-primary-custom/40 outline-none text-sm text-foreground-custom placeholder-muted-foreground-custom/50 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !input.trim()}
|
||||
className="p-3.5 rounded-2xl bg-primary-custom hover:bg-primary-custom-hover text-primary-foreground shadow-md hover:scale-[1.02] active:scale-[0.98] transition-transform duration-200 cursor-pointer disabled:opacity-50 disabled:scale-100 disabled:pointer-events-none"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
.light-rays-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { Renderer, Program, Triangle, Mesh } from 'ogl';
|
||||
import './LightRays.css';
|
||||
|
||||
export type RaysOrigin =
|
||||
| 'top-center'
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'right'
|
||||
| 'left'
|
||||
| 'bottom-center'
|
||||
| 'bottom-right'
|
||||
| 'bottom-left';
|
||||
|
||||
interface LightRaysProps {
|
||||
raysOrigin?: RaysOrigin;
|
||||
raysColor?: string;
|
||||
raysSpeed?: number;
|
||||
lightSpread?: number;
|
||||
rayLength?: number;
|
||||
pulsating?: boolean;
|
||||
fadeDistance?: number;
|
||||
saturation?: number;
|
||||
followMouse?: boolean;
|
||||
mouseInfluence?: number;
|
||||
noiseAmount?: number;
|
||||
distortion?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_COLOR = '#ffffff';
|
||||
|
||||
const hexToRgb = (hex: string): [number, number, number] => {
|
||||
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return m ? [parseInt(m[1], 16) / 255, parseInt(m[2], 16) / 255, parseInt(m[3], 16) / 255] : [1, 1, 1];
|
||||
};
|
||||
|
||||
const getAnchorAndDir = (
|
||||
origin: RaysOrigin,
|
||||
w: number,
|
||||
h: number
|
||||
): { anchor: [number, number]; dir: [number, number] } => {
|
||||
const outside = 0.2;
|
||||
switch (origin) {
|
||||
case 'top-left':
|
||||
return { anchor: [0, -outside * h], dir: [0, 1] };
|
||||
case 'top-right':
|
||||
return { anchor: [w, -outside * h], dir: [0, 1] };
|
||||
case 'left':
|
||||
return { anchor: [-outside * w, 0.5 * h], dir: [1, 0] };
|
||||
case 'right':
|
||||
return { anchor: [(1 + outside) * w, 0.5 * h], dir: [-1, 0] };
|
||||
case 'bottom-left':
|
||||
return { anchor: [0, (1 + outside) * h], dir: [0, -1] };
|
||||
case 'bottom-center':
|
||||
return { anchor: [0.5 * w, (1 + outside) * h], dir: [0, -1] };
|
||||
case 'bottom-right':
|
||||
return { anchor: [w, (1 + outside) * h], dir: [0, -1] };
|
||||
default: // "top-center"
|
||||
return { anchor: [0.5 * w, -outside * h], dir: [0, 1] };
|
||||
}
|
||||
};
|
||||
|
||||
type Vec2 = [number, number];
|
||||
type Vec3 = [number, number, number];
|
||||
|
||||
interface Uniforms {
|
||||
iTime: { value: number };
|
||||
iResolution: { value: Vec2 };
|
||||
rayPos: { value: Vec2 };
|
||||
rayDir: { value: Vec2 };
|
||||
raysColor: { value: Vec3 };
|
||||
raysSpeed: { value: number };
|
||||
lightSpread: { value: number };
|
||||
rayLength: { value: number };
|
||||
pulsating: { value: number };
|
||||
fadeDistance: { value: number };
|
||||
saturation: { value: number };
|
||||
mousePos: { value: Vec2 };
|
||||
mouseInfluence: { value: number };
|
||||
noiseAmount: { value: number };
|
||||
distortion: { value: number };
|
||||
}
|
||||
|
||||
const LightRays: React.FC<LightRaysProps> = ({
|
||||
raysOrigin = 'top-center',
|
||||
raysColor = DEFAULT_COLOR,
|
||||
raysSpeed = 1,
|
||||
lightSpread = 1,
|
||||
rayLength = 2,
|
||||
pulsating = false,
|
||||
fadeDistance = 1.0,
|
||||
saturation = 1.0,
|
||||
followMouse = true,
|
||||
mouseInfluence = 0.1,
|
||||
noiseAmount = 0.0,
|
||||
distortion = 0.0,
|
||||
className = ''
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const uniformsRef = useRef<Uniforms | null>(null);
|
||||
const rendererRef = useRef<Renderer | null>(null);
|
||||
const mouseRef = useRef({ x: 0.5, y: 0.5 });
|
||||
const smoothMouseRef = useRef({ x: 0.5, y: 0.5 });
|
||||
const animationIdRef = useRef<number | null>(null);
|
||||
const meshRef = useRef<Mesh | null>(null);
|
||||
const cleanupFunctionRef = useRef<(() => void) | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
observerRef.current = new IntersectionObserver(
|
||||
entries => {
|
||||
const entry = entries[0];
|
||||
setIsVisible(entry.isIntersecting);
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observerRef.current.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !containerRef.current) return;
|
||||
|
||||
if (cleanupFunctionRef.current) {
|
||||
cleanupFunctionRef.current();
|
||||
cleanupFunctionRef.current = null;
|
||||
}
|
||||
|
||||
const initializeWebGL = async () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const renderer = new Renderer({
|
||||
dpr: Math.min(window.devicePixelRatio, 2),
|
||||
alpha: true
|
||||
});
|
||||
rendererRef.current = renderer;
|
||||
|
||||
const gl = renderer.gl;
|
||||
gl.canvas.style.width = '100%';
|
||||
gl.canvas.style.height = '100%';
|
||||
|
||||
while (containerRef.current.firstChild) {
|
||||
containerRef.current.removeChild(containerRef.current.firstChild);
|
||||
}
|
||||
containerRef.current.appendChild(gl.canvas);
|
||||
|
||||
const vert = `
|
||||
attribute vec2 position;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = position * 0.5 + 0.5;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
const frag = `precision highp float;
|
||||
|
||||
uniform float iTime;
|
||||
uniform vec2 iResolution;
|
||||
|
||||
uniform vec2 rayPos;
|
||||
uniform vec2 rayDir;
|
||||
uniform vec3 raysColor;
|
||||
uniform float raysSpeed;
|
||||
uniform float lightSpread;
|
||||
uniform float rayLength;
|
||||
uniform float pulsating;
|
||||
uniform float fadeDistance;
|
||||
uniform float saturation;
|
||||
uniform vec2 mousePos;
|
||||
uniform float mouseInfluence;
|
||||
uniform float noiseAmount;
|
||||
uniform float distortion;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
float noise(vec2 st) {
|
||||
return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123);
|
||||
}
|
||||
|
||||
float rayStrength(vec2 raySource, vec2 rayRefDirection, vec2 coord,
|
||||
float seedA, float seedB, float speed) {
|
||||
vec2 sourceToCoord = coord - raySource;
|
||||
vec2 dirNorm = normalize(sourceToCoord);
|
||||
float cosAngle = dot(dirNorm, rayRefDirection);
|
||||
|
||||
float distortedAngle = cosAngle + distortion * sin(iTime * 2.0 + length(sourceToCoord) * 0.01) * 0.2;
|
||||
|
||||
float spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / max(lightSpread, 0.001));
|
||||
|
||||
float distance = length(sourceToCoord);
|
||||
float maxDistance = iResolution.x * rayLength;
|
||||
float lengthFalloff = clamp((maxDistance - distance) / maxDistance, 0.0, 1.0);
|
||||
|
||||
float fadeFalloff = clamp((iResolution.x * fadeDistance - distance) / (iResolution.x * fadeDistance), 0.5, 1.0);
|
||||
float pulse = pulsating > 0.5 ? (0.8 + 0.2 * sin(iTime * speed * 3.0)) : 1.0;
|
||||
|
||||
float baseStrength = clamp(
|
||||
(0.45 + 0.15 * sin(distortedAngle * seedA + iTime * speed)) +
|
||||
(0.3 + 0.2 * cos(-distortedAngle * seedB + iTime * speed)),
|
||||
0.0, 1.0
|
||||
);
|
||||
|
||||
return baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse;
|
||||
}
|
||||
|
||||
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||
vec2 coord = vec2(fragCoord.x, iResolution.y - fragCoord.y);
|
||||
|
||||
vec2 finalRayDir = rayDir;
|
||||
if (mouseInfluence > 0.0) {
|
||||
vec2 mouseScreenPos = mousePos * iResolution.xy;
|
||||
vec2 mouseDirection = normalize(mouseScreenPos - rayPos);
|
||||
finalRayDir = normalize(mix(rayDir, mouseDirection, mouseInfluence));
|
||||
}
|
||||
|
||||
vec4 rays1 = vec4(1.0) *
|
||||
rayStrength(rayPos, finalRayDir, coord, 36.2214, 21.11349,
|
||||
1.5 * raysSpeed);
|
||||
vec4 rays2 = vec4(1.0) *
|
||||
rayStrength(rayPos, finalRayDir, coord, 22.3991, 18.0234,
|
||||
1.1 * raysSpeed);
|
||||
|
||||
fragColor = rays1 * 0.5 + rays2 * 0.4;
|
||||
|
||||
if (noiseAmount > 0.0) {
|
||||
float n = noise(coord * 0.01 + iTime * 0.1);
|
||||
fragColor.rgb *= (1.0 - noiseAmount + noiseAmount * n);
|
||||
}
|
||||
|
||||
float brightness = 1.0 - (coord.y / iResolution.y);
|
||||
fragColor.x *= 0.1 + brightness * 0.8;
|
||||
fragColor.y *= 0.3 + brightness * 0.6;
|
||||
fragColor.z *= 0.5 + brightness * 0.5;
|
||||
|
||||
if (saturation != 1.0) {
|
||||
float gray = dot(fragColor.rgb, vec3(0.299, 0.587, 0.114));
|
||||
fragColor.rgb = mix(vec3(gray), fragColor.rgb, saturation);
|
||||
}
|
||||
|
||||
fragColor.rgb *= raysColor;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 color;
|
||||
mainImage(color, gl_FragCoord.xy);
|
||||
gl_FragColor = color;
|
||||
}`;
|
||||
|
||||
const uniforms: Uniforms = {
|
||||
iTime: { value: 0 },
|
||||
iResolution: { value: [1, 1] },
|
||||
|
||||
rayPos: { value: [0, 0] },
|
||||
rayDir: { value: [0, 1] },
|
||||
|
||||
raysColor: { value: hexToRgb(raysColor) },
|
||||
raysSpeed: { value: raysSpeed },
|
||||
lightSpread: { value: lightSpread },
|
||||
rayLength: { value: rayLength },
|
||||
pulsating: { value: pulsating ? 1.0 : 0.0 },
|
||||
fadeDistance: { value: fadeDistance },
|
||||
saturation: { value: saturation },
|
||||
mousePos: { value: [0.5, 0.5] },
|
||||
mouseInfluence: { value: mouseInfluence },
|
||||
noiseAmount: { value: noiseAmount },
|
||||
distortion: { value: distortion }
|
||||
};
|
||||
uniformsRef.current = uniforms;
|
||||
|
||||
const geometry = new Triangle(gl);
|
||||
const program = new Program(gl, {
|
||||
vertex: vert,
|
||||
fragment: frag,
|
||||
uniforms
|
||||
});
|
||||
const mesh = new Mesh(gl, { geometry, program });
|
||||
meshRef.current = mesh;
|
||||
|
||||
const updatePlacement = () => {
|
||||
if (!containerRef.current || !renderer) return;
|
||||
|
||||
renderer.dpr = Math.min(window.devicePixelRatio, 2);
|
||||
|
||||
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current;
|
||||
renderer.setSize(wCSS, hCSS);
|
||||
|
||||
const dpr = renderer.dpr;
|
||||
const w = wCSS * dpr;
|
||||
const h = hCSS * dpr;
|
||||
|
||||
uniforms.iResolution.value = [w, h];
|
||||
|
||||
const { anchor, dir } = getAnchorAndDir(raysOrigin, w, h);
|
||||
uniforms.rayPos.value = anchor;
|
||||
uniforms.rayDir.value = dir;
|
||||
};
|
||||
|
||||
const loop = (t: number) => {
|
||||
if (!rendererRef.current || !uniformsRef.current || !meshRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
uniforms.iTime.value = t * 0.001;
|
||||
|
||||
if (followMouse && mouseInfluence > 0.0) {
|
||||
const smoothing = 0.92;
|
||||
|
||||
smoothMouseRef.current.x = smoothMouseRef.current.x * smoothing + mouseRef.current.x * (1 - smoothing);
|
||||
smoothMouseRef.current.y = smoothMouseRef.current.y * smoothing + mouseRef.current.y * (1 - smoothing);
|
||||
|
||||
uniforms.mousePos.value = [smoothMouseRef.current.x, smoothMouseRef.current.y];
|
||||
}
|
||||
|
||||
try {
|
||||
renderer.render({ scene: mesh });
|
||||
animationIdRef.current = requestAnimationFrame(loop);
|
||||
} catch (error) {
|
||||
console.warn('WebGL rendering error:', error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', updatePlacement);
|
||||
updatePlacement();
|
||||
animationIdRef.current = requestAnimationFrame(loop);
|
||||
|
||||
cleanupFunctionRef.current = () => {
|
||||
if (animationIdRef.current) {
|
||||
cancelAnimationFrame(animationIdRef.current);
|
||||
animationIdRef.current = null;
|
||||
}
|
||||
|
||||
window.removeEventListener('resize', updatePlacement);
|
||||
|
||||
if (renderer) {
|
||||
try {
|
||||
const canvas = renderer.gl.canvas;
|
||||
const loseContextExt = renderer.gl.getExtension('WEBGL_lose_context');
|
||||
if (loseContextExt) {
|
||||
loseContextExt.loseContext();
|
||||
}
|
||||
|
||||
if (canvas && canvas.parentNode) {
|
||||
canvas.parentNode.removeChild(canvas);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error during WebGL cleanup:', error);
|
||||
}
|
||||
}
|
||||
|
||||
rendererRef.current = null;
|
||||
uniformsRef.current = null;
|
||||
meshRef.current = null;
|
||||
};
|
||||
};
|
||||
|
||||
initializeWebGL();
|
||||
|
||||
return () => {
|
||||
if (cleanupFunctionRef.current) {
|
||||
cleanupFunctionRef.current();
|
||||
cleanupFunctionRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
isVisible,
|
||||
raysOrigin,
|
||||
raysColor,
|
||||
raysSpeed,
|
||||
lightSpread,
|
||||
rayLength,
|
||||
pulsating,
|
||||
fadeDistance,
|
||||
saturation,
|
||||
followMouse,
|
||||
mouseInfluence,
|
||||
noiseAmount,
|
||||
distortion
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uniformsRef.current || !containerRef.current || !rendererRef.current) return;
|
||||
|
||||
const u = uniformsRef.current;
|
||||
const renderer = rendererRef.current;
|
||||
|
||||
u.raysColor.value = hexToRgb(raysColor);
|
||||
u.raysSpeed.value = raysSpeed;
|
||||
u.lightSpread.value = lightSpread;
|
||||
u.rayLength.value = rayLength;
|
||||
u.pulsating.value = pulsating ? 1.0 : 0.0;
|
||||
u.fadeDistance.value = fadeDistance;
|
||||
u.saturation.value = saturation;
|
||||
u.mouseInfluence.value = mouseInfluence;
|
||||
u.noiseAmount.value = noiseAmount;
|
||||
u.distortion.value = distortion;
|
||||
|
||||
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current;
|
||||
const dpr = renderer.dpr;
|
||||
const { anchor, dir } = getAnchorAndDir(raysOrigin, wCSS * dpr, hCSS * dpr);
|
||||
u.rayPos.value = anchor;
|
||||
u.rayDir.value = dir;
|
||||
}, [
|
||||
raysColor,
|
||||
raysSpeed,
|
||||
lightSpread,
|
||||
raysOrigin,
|
||||
rayLength,
|
||||
pulsating,
|
||||
fadeDistance,
|
||||
saturation,
|
||||
mouseInfluence,
|
||||
noiseAmount,
|
||||
distortion
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!containerRef.current || !rendererRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = (e.clientY - rect.top) / rect.height;
|
||||
mouseRef.current = { x, y };
|
||||
};
|
||||
|
||||
if (followMouse) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
return () => window.removeEventListener('mousemove', handleMouseMove);
|
||||
}
|
||||
}, [followMouse]);
|
||||
|
||||
return <div ref={containerRef} className={`light-rays-container ${className}`.trim()} />;
|
||||
};
|
||||
|
||||
export default LightRays;
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
// A creative, futuristic loading screen that runs before the page finishes loading
|
||||
---
|
||||
|
||||
<div id="global-loader" class="fixed inset-0 z-[99999] flex flex-col items-center justify-center bg-background-custom transition-all duration-1000 ease-[cubic-bezier(0.22,1,0.36,1)]">
|
||||
<div class="flex flex-col items-center justify-center gap-6">
|
||||
<!-- Minimalist Spinner -->
|
||||
<div class="w-10 h-10 rounded-full border-[2px] border-foreground-custom/10 border-t-foreground-custom animate-spin"></div>
|
||||
|
||||
<!-- Minimalist Text -->
|
||||
<div class="text-[10px] font-medium tracking-[0.3em] text-foreground-custom/60 uppercase">
|
||||
Loading
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes progress {
|
||||
0% { width: 0%; left: 0; }
|
||||
50% { width: 100%; left: 0; }
|
||||
100% { width: 0%; left: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script is:inline>
|
||||
// Block scrolling on body immediately
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
|
||||
// Wait for the window to fully load (all resources, images, background components)
|
||||
window.addEventListener('load', () => {
|
||||
const loader = document.getElementById('global-loader');
|
||||
if (loader) {
|
||||
// Small delay to ensure everything is settled (like 3D backgrounds or heavy React components)
|
||||
setTimeout(() => {
|
||||
loader.style.opacity = '0';
|
||||
loader.style.transform = 'scale(1.1)';
|
||||
loader.style.filter = 'blur(10px)';
|
||||
|
||||
// Restore scrolling
|
||||
document.documentElement.style.overflow = '';
|
||||
|
||||
setTimeout(() => {
|
||||
loader.remove();
|
||||
}, 1000); // Wait for transition to finish
|
||||
}, 800); // 800ms extra buffer for smooth transition
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,215 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
interface WeatherState {
|
||||
temp: number | null;
|
||||
code: number | null;
|
||||
isDay: boolean;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
const LATITUDE = 14.130792771445167;
|
||||
const LONGITUDE = 121.43629230199042;
|
||||
|
||||
function getManilaDateTime() {
|
||||
const now = new Date();
|
||||
const time = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Manila',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true,
|
||||
}).format(now);
|
||||
|
||||
const date = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Manila',
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(now);
|
||||
|
||||
return { time, date };
|
||||
}
|
||||
|
||||
function getWeatherIcon(code: number | null, isDay: boolean) {
|
||||
if (code === null) {
|
||||
return (
|
||||
<svg className="w-3.5 h-3.5 text-muted-foreground-custom animate-pulse" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Clear / Sunny
|
||||
if (code === 0) {
|
||||
if (isDay) {
|
||||
return (
|
||||
<svg className="w-3.5 h-3.5 text-amber-500" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path strokeLinecap="round" d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className="w-3.5 h-3.5 text-primary-custom" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Mainly Clear / Partly Cloudy
|
||||
if (code === 1 || code === 2) {
|
||||
return (
|
||||
<svg className="w-3.5 h-3.5 text-primary-custom" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Overcast
|
||||
if (code === 3) {
|
||||
return (
|
||||
<svg className="w-3.5 h-3.5 text-muted-foreground-custom" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15a4.5 4.5 0 0 0 4.5 4.5H18a3.75 3.75 0 0 0 1.332-7.257 3 3 0 0 0-3.758-3.848 5.25 5.25 0 0 0-10.233 2.33A4.502 4.502 0 0 0 2.25 15Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Rain / Drizzle / Showers
|
||||
if ((code >= 51 && code <= 67) || (code >= 80 && code <= 82)) {
|
||||
return (
|
||||
<svg className="w-3.5 h-3.5 text-primary-custom" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15a4.5 4.5 0 0 0 4.5 4.5H18a3.75 3.75 0 0 0 1.332-7.257 3 3 0 0 0-3.758-3.848 5.25 5.25 0 0 0-10.233 2.33A4.502 4.502 0 0 0 2.25 15Zm6 5.25v1.5m4.5-1.5v1.5m4.5-1.5v1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Thunderstorm
|
||||
if (code >= 95) {
|
||||
return (
|
||||
<svg className="w-3.5 h-3.5 text-amber-500" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Fog / Mist
|
||||
return (
|
||||
<svg className="w-3.5 h-3.5 text-muted-foreground-custom" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9h16.5m-16.5 3.75h16.5m-16.5 3.75h16.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function getWeatherDescription(code: number | null): string {
|
||||
if (code === null) return 'Loading...';
|
||||
if (code === 0) return 'Clear';
|
||||
if (code === 1) return 'Mainly Clear';
|
||||
if (code === 2) return 'Partly Cloudy';
|
||||
if (code === 3) return 'Overcast';
|
||||
if (code === 45 || code === 48) return 'Foggy';
|
||||
if (code >= 51 && code <= 55) return 'Drizzle';
|
||||
if (code >= 61 && code <= 65) return 'Rainy';
|
||||
if (code >= 80 && code <= 82) return 'Showers';
|
||||
if (code >= 95) return 'Thunderstorm';
|
||||
return 'Cloudy';
|
||||
}
|
||||
|
||||
export default function LocalTimeWeather({
|
||||
latitude = LATITUDE,
|
||||
longitude = LONGITUDE
|
||||
}: Props) {
|
||||
const [dateTime, setDateTime] = useState(getManilaDateTime);
|
||||
const [weather, setWeather] = useState<WeatherState>({
|
||||
temp: null,
|
||||
code: null,
|
||||
isDay: true,
|
||||
loading: true,
|
||||
error: false,
|
||||
});
|
||||
|
||||
// Clock tick every 1 second (PHT)
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setDateTime(getManilaDateTime());
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// Fetch Open-Meteo current weather for coordinates
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function fetchWeather() {
|
||||
try {
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,weather_code,is_day&timezone=Asia%2FManila`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('Weather fetch failed');
|
||||
const data = await res.json();
|
||||
|
||||
if (isMounted && data?.current) {
|
||||
setWeather({
|
||||
temp: Math.round(data.current.temperature_2m),
|
||||
code: data.current.weather_code,
|
||||
isDay: Boolean(data.current.is_day),
|
||||
loading: false,
|
||||
error: false,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
setWeather((prev) => ({ ...prev, loading: false, error: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchWeather();
|
||||
// Refresh weather every 15 minutes
|
||||
const weatherInterval = setInterval(fetchWeather, 15 * 60 * 1000);
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearInterval(weatherInterval);
|
||||
};
|
||||
}, [latitude, longitude]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Local Time (PHT) */}
|
||||
<div className="flex justify-between items-center py-1 border-b border-border-custom/50">
|
||||
<span className="font-mono text-muted-foreground-custom">Local Time</span>
|
||||
<div className="flex items-center gap-1.5 font-mono text-foreground-custom">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span className="font-semibold text-xs">{dateTime.time}</span>
|
||||
<span className="text-[10px] text-muted-foreground-custom font-medium">PHT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="flex justify-between items-center py-1 border-b border-border-custom/50">
|
||||
<span className="font-mono text-muted-foreground-custom">Date</span>
|
||||
<span className="font-mono text-foreground-custom text-xs font-medium">
|
||||
{dateTime.date}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Weather */}
|
||||
<div className="flex justify-between items-center py-1 border-b border-border-custom/50">
|
||||
<span className="font-mono text-muted-foreground-custom">Weather</span>
|
||||
<div className="flex items-center gap-1.5 font-mono text-foreground-custom">
|
||||
{getWeatherIcon(weather.code, weather.isDay)}
|
||||
<span className="font-semibold text-xs">
|
||||
{weather.temp !== null ? `${weather.temp}°C` : weather.loading ? '...' : '--'}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground-custom">
|
||||
({getWeatherDescription(weather.code)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
||||
|
||||
useEffect(() => {
|
||||
// Determine initial theme
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
setTheme(isDark ? 'dark' : 'light');
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const nextTheme = theme === 'light' ? 'dark' : 'light';
|
||||
setTheme(nextTheme);
|
||||
|
||||
if (nextTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2.5 rounded-full glass-card hover:bg-primary-custom/10 text-foreground-custom relative group overflow-hidden transition-all duration-300"
|
||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
>
|
||||
<div className="relative w-5 h-5 flex items-center justify-center">
|
||||
{/* Sun Icon */}
|
||||
<svg
|
||||
className={`w-5 h-5 absolute transform transition-transform duration-500 ease-out ${
|
||||
theme === 'dark' ? 'rotate-90 scale-0' : 'rotate-0 scale-100'
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
|
||||
{/* Moon Icon */}
|
||||
<svg
|
||||
className={`w-5 h-5 absolute transform transition-transform duration-500 ease-out ${
|
||||
theme === 'dark' ? 'rotate-0 scale-100' : '-rotate-90 scale-0'
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user