diff --git a/src/components/About.tsx b/src/components/About.tsx index f91c557..b084ea3 100644 --- a/src/components/About.tsx +++ b/src/components/About.tsx @@ -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({ {settings.location} + +
Contact ([ - { 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(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 ( -
- {/* Accent gradients */} -
- - {/* Header */} -
-
- - AI - - -
-
-

- Cris's Portfolio Assistant -

-

- Powered by Gemini 2.5 Flash -

-
-
- - {/* Messages list */} -
- - {messages.map((msg, index) => { - const isUser = msg.role === 'user'; - return ( - -
- {msg.text} -
-
- ); - })} - - {/* Loading typing indicator */} - {loading && ( - -
- - - -
-
- )} -
- -
-
- - {/* Form Input */} -
- 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" - /> - -
-
- ); -} diff --git a/src/components/LightRays/LightRays.css b/src/components/LightRays/LightRays.css deleted file mode 100644 index debb557..0000000 --- a/src/components/LightRays/LightRays.css +++ /dev/null @@ -1,8 +0,0 @@ -.light-rays-container { - width: 100%; - height: 100%; - position: relative; - pointer-events: none; - z-index: 3; - overflow: hidden; -} diff --git a/src/components/LightRays/LightRays.tsx b/src/components/LightRays/LightRays.tsx deleted file mode 100644 index 699f145..0000000 --- a/src/components/LightRays/LightRays.tsx +++ /dev/null @@ -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 = ({ - 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(null); - const uniformsRef = useRef(null); - const rendererRef = useRef(null); - const mouseRef = useRef({ x: 0.5, y: 0.5 }); - const smoothMouseRef = useRef({ x: 0.5, y: 0.5 }); - const animationIdRef = useRef(null); - const meshRef = useRef(null); - const cleanupFunctionRef = useRef<(() => void) | null>(null); - const [isVisible, setIsVisible] = useState(false); - const observerRef = useRef(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
; -}; - -export default LightRays; diff --git a/src/components/LoadingScreen.astro b/src/components/LoadingScreen.astro deleted file mode 100644 index 9fe14b4..0000000 --- a/src/components/LoadingScreen.astro +++ /dev/null @@ -1,48 +0,0 @@ ---- -// A creative, futuristic loading screen that runs before the page finishes loading ---- - -
-
- -
- - -
- Loading -
-
-
- - - - diff --git a/src/components/LocalTimeWeather.tsx b/src/components/LocalTimeWeather.tsx new file mode 100644 index 0000000..725798d --- /dev/null +++ b/src/components/LocalTimeWeather.tsx @@ -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 ( + + + + ); + } + + // Clear / Sunny + if (code === 0) { + if (isDay) { + return ( + + + + + ); + } + return ( + + + + ); + } + + // Mainly Clear / Partly Cloudy + if (code === 1 || code === 2) { + return ( + + + + ); + } + + // Overcast + if (code === 3) { + return ( + + + + ); + } + + // Rain / Drizzle / Showers + if ((code >= 51 && code <= 67) || (code >= 80 && code <= 82)) { + return ( + + + + ); + } + + // Thunderstorm + if (code >= 95) { + return ( + + + + ); + } + + // Fog / Mist + return ( + + + + ); +} + +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({ + 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) */} +
+ Local Time +
+ + {dateTime.time} + PHT +
+
+ + {/* Date */} +
+ Date + + {dateTime.date} + +
+ + {/* Weather */} +
+ Weather +
+ {getWeatherIcon(weather.code, weather.isDay)} + + {weather.temp !== null ? `${weather.temp}°C` : weather.loading ? '...' : '--'} + + + ({getWeatherDescription(weather.code)}) + +
+
+ + ); +} diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx deleted file mode 100644 index 57e2ead..0000000 --- a/src/components/ThemeToggle.tsx +++ /dev/null @@ -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 ( - - ); -}