feat(about): add local time/weather and remove unused components

This commit is contained in:
ssbg04 committed 2026-09-08 09:26:56 +08:00
1 parent 286ec847fc
commit ae76389178
7 files changed
+218 -732

No files matched your search

+3
View File
@@ -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
-165
View File
@@ -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>
);
}
-8
View File
@@ -1,8 +0,0 @@
.light-rays-container {
width: 100%;
height: 100%;
position: relative;
pointer-events: none;
z-index: 3;
overflow: hidden;
}
-450
View File
@@ -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;
-48
View File
@@ -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>
+215
View File
@@ -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}&current=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>
</>
);
}
-61
View File
@@ -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>
);
}