diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Datetime.tsx (renamed from src/components/Datetime.tsx) | 16 | ||||
| -rw-r--r-- | src/Geshem.css | 30 | ||||
| -rw-r--r-- | src/Geshem.test.tsx | 9 | ||||
| -rw-r--r-- | src/Geshem.tsx (renamed from src/components/Geshem.tsx) | 31 | ||||
| -rw-r--r-- | src/Map.tsx (renamed from src/components/Map.tsx) | 54 | ||||
| -rw-r--r-- | src/Slider.tsx | 45 | ||||
| -rw-r--r-- | src/components/Slider.tsx | 86 | ||||
| -rw-r--r-- | src/config.tsx (renamed from src/config.ts) | 6 | ||||
| -rw-r--r-- | src/functions/scheduled.ts | 178 | ||||
| -rw-r--r-- | src/index.css (renamed from src/styles/global.css) | 4 | ||||
| -rw-r--r-- | src/index.tsx | 13 | ||||
| -rw-r--r-- | src/layouts/Layout.astro | 28 | ||||
| -rw-r--r-- | src/pages/404.astro | 67 | ||||
| -rw-r--r-- | src/pages/api/update.ts | 200 | ||||
| -rw-r--r-- | src/pages/history/[date].astro | 13 | ||||
| -rw-r--r-- | src/pages/index.astro | 11 | ||||
| -rw-r--r-- | src/pages/privacy.astro | 40 | ||||
| -rw-r--r-- | src/react-app-env.d.ts | 1 | ||||
| -rw-r--r-- | src/setupTests.ts | 5 |
19 files changed, 162 insertions, 675 deletions
diff --git a/src/components/Datetime.tsx b/src/Datetime.tsx index 8b66a4f..0a8fbcf 100644 --- a/src/components/Datetime.tsx +++ b/src/Datetime.tsx @@ -2,14 +2,14 @@ import React from "react"; import { DateTime as LuxonDateTime } from "luxon"; interface DateTimeProps { - images: string[]; - slider: number; + images: string[], + slider: number } export function DateTime({ images, slider }: DateTimeProps) { - let datetime: LuxonDateTime | null = null; + let datetime = null; - if (images.length > 0 && images[slider]) { + if (images.length > 0) { const ds = images[slider].substring(5, 18); datetime = LuxonDateTime.fromFormat(ds, "yyyyMMdd/HHmm", { zone: "utc", @@ -20,9 +20,9 @@ export function DateTime({ images, slider }: DateTimeProps) { const time = datetime ? datetime.toFormat("HH:mm") : ""; return ( - <div id="datetime" className="absolute top-5 left-5 text-white font-mono"> - <div id="date" className="text-xl">{date}</div> - <div id="time" className="text-4xl">{time}</div> + <div id="datetime"> + <div id="date">{date}</div> + <div id="time">{time}</div> </div> ); -}
\ No newline at end of file +} diff --git a/src/Geshem.css b/src/Geshem.css new file mode 100644 index 0000000..2e0f58e --- /dev/null +++ b/src/Geshem.css @@ -0,0 +1,30 @@ +#datetime { + position: absolute; + top: 20px; + left: 20px; + color: white; + font-family: monospace; +} + +#date { + font-size: 1.4em; +} + +#time { + font-size: 2.8em; +} + +#slider { + position: fixed; + bottom: 8vh; + width: 30vw; + margin-left: 35vw; +} + +@media only screen and (max-device-width: 812px) { + #slider { + width: 80vw; + margin-left: 8vw; + z-index: 10; + } +} diff --git a/src/Geshem.test.tsx b/src/Geshem.test.tsx new file mode 100644 index 0000000..8e4652d --- /dev/null +++ b/src/Geshem.test.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Geshem } from './Geshem'; + +it('renders without crashing', () => { + const div = document.createElement('div'); + ReactDOM.render(<Geshem />, div); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/src/components/Geshem.tsx b/src/Geshem.tsx index 982d841..6e88f19 100644 --- a/src/components/Geshem.tsx +++ b/src/Geshem.tsx @@ -1,20 +1,36 @@ import React, { useState, useEffect } from "react"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; import { Map } from "./Map"; import { Slider } from "./Slider"; import { DateTime } from "./Datetime"; -import { IMAGES_BASE_URL, PLAYBACK_HOURS, PLAYBACK_SLOTS } from "../config"; +import { IMAGES_BASE_URL, PLAYBACK_HOURS, PLAYBACK_SLOTS } from "./config"; + +import "./Geshem.css"; +import "rc-slider/assets/index.css"; + +export function App() { + return ( + <BrowserRouter> + <Routes> + <Route path="/" element={<Geshem />} /> + <Route path="/history/:date" element={<Geshem />} /> + </Routes> + </BrowserRouter> + ); +} interface GeshemProps { - date?: string; + date?: string } + export function Geshem({ date }: GeshemProps) { const [images, setImages] = useState<string[]>([]); const [playback] = useState( date || - (typeof window !== 'undefined' ? new URL(window.location.toString()).searchParams.get("history") : null) || + new URL(window.location.toString()).searchParams.get("history") || undefined ); const [slider, setSlider] = useState(playback ? PLAYBACK_SLOTS : 9); @@ -23,7 +39,7 @@ export function Geshem({ date }: GeshemProps) { const fetchImages = async () => fetch(`${IMAGES_BASE_URL}/imgs.json`) .then(res => res.json()) - .then(imgs => (imgs as any)["280"]) + .then(imgs => imgs["280"]) .then(setImages); const buildPlayback = async () => { @@ -43,9 +59,8 @@ export function Geshem({ date }: GeshemProps) { }; let timer: number; - if (playback) { - buildPlayback(); - } else { + if (playback) buildPlayback(); + else { fetchImages(); timer = window.setInterval(fetchImages, 60 * 1000); } @@ -62,4 +77,4 @@ export function Geshem({ date }: GeshemProps) { <Slider slider={slider} playback={playback} setSlider={setSlider} /> </> ); -}
\ No newline at end of file +} diff --git a/src/components/Map.tsx b/src/Map.tsx index 070dc5c..39f28fa 100644 --- a/src/components/Map.tsx +++ b/src/Map.tsx @@ -1,22 +1,23 @@ import React, { useState, useEffect, useRef } from "react"; -import mapboxgl from 'mapbox-gl'; +// @ts-ignore +import mapboxgl from '!mapbox-gl'; // eslint-disable-line import/no-webpack-loader-syntax import { MAPBOX_ACCESS_TOKEN, IMAGE_COORDINATES, IMAGES_BASE_URL -} from "../config"; +} from "./config"; import "mapbox-gl/dist/mapbox-gl.css"; interface MapProps { - slider: number; - images: string[]; + slider: number, + images: string[], } export function Map({ slider, images }: MapProps) { - const mapContainer = useRef<HTMLDivElement>(null); - const map = useRef<mapboxgl.Map | null>(null); + const mapContainer = useRef(null); + const map = useRef<mapboxgl.Map>(null); const [lng] = useState(35); const [lat] = useState(31.9); @@ -26,7 +27,7 @@ export function Map({ slider, images }: MapProps) { const prevImages = useRef(images).current; useEffect(() => { - if (map.current || !mapContainer.current) return; + if (map.current) return; map.current = new mapboxgl.Map({ accessToken: MAPBOX_ACCESS_TOKEN, @@ -42,32 +43,28 @@ export function Map({ slider, images }: MapProps) { map.current.on("style.load", () => { setLoaded(true); }); - }, [lng, lat, zoom]); + }); useEffect(() => { - if (!loaded || !map.current) return; + if (!loaded) return; // remove old layers prevImages.forEach((img) => { if (!images.includes(img)) { - if (map.current?.getLayer(`layer-${img}`)) { - map.current.removeLayer(`layer-${img}`); - } - if (map.current?.getSource(`source-${img}`)) { - map.current.removeSource(`source-${img}`); - } + map.current.removeLayer(`layer-${img}`); + map.current.removeSource(`source-${img}`); } }); // add new layers images.forEach((img, i) => { - if (!prevImages.includes(img) && !map.current?.getSource(`source-${img}`)) { - map.current?.addSource(`source-${img}`, { + if (!prevImages.includes(img) && !map.current.getSource(`source-${img}`)) { + map.current.addSource(`source-${img}`, { type: "image", url: `${IMAGES_BASE_URL}/${img}`, coordinates: IMAGE_COORDINATES }); - map.current?.addLayer({ + map.current.addLayer({ id: `layer-${img}`, source: `source-${img}`, type: "raster", @@ -83,23 +80,20 @@ export function Map({ slider, images }: MapProps) { }, [loaded, prevImages, images]); useEffect(() => { - if (!loaded || !images.length || !map.current) return; - - if (images[slider]) { - map.current.setPaintProperty(`layer-${images[slider]}`, "raster-opacity", 0.85); - } - + if (!loaded || !images.length) return; + images[slider] && map.current.setPaintProperty(`layer-${images[slider]}`, "raster-opacity", 0.85); return () => { // callback will hide the previous layer with the previous slider value - if (images[slider] && map.current?.getLayer(`layer-${images[slider]}`)) { - map.current.setPaintProperty(`layer-${images[slider]}`, "raster-opacity", 0); - } - }; + map.current.setPaintProperty(`layer-${images[slider]}`, "raster-opacity", 0); + } }, [loaded, slider, images]); return ( <div> - <div ref={mapContainer} className="map-container h-screen w-screen" /> + <div ref={mapContainer} className="map-container" style={{ + height: "100vh", + width: "100vw" + }} /> </div> ); -}
\ No newline at end of file +} diff --git a/src/Slider.tsx b/src/Slider.tsx new file mode 100644 index 0000000..50223fc --- /dev/null +++ b/src/Slider.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import RcSlider, { SliderProps, SliderRef } from 'rc-slider/lib/Slider'; +import { PLAYBACK_SLOTS } from "./config"; + +// use workaround from: https://github.com/react-component/slider/issues/835#issuecomment-1201805736 +const CustomSlider = RcSlider as React.ForwardRefExoticComponent<SliderProps<number> & React.RefAttributes<SliderRef>>; + +interface GeshemSliderProps { + playback?: string, + slider: number, + setSlider: React.Dispatch<React.SetStateAction<number>> +} + +export function Slider({ playback, slider, setSlider }: GeshemSliderProps) { + const handleStyle = { + height: 40, + width: 40, + border: 0, + marginTop: -10, + boxShadow: ".5px .5px 2px 1px rgba(0,0,0,.32)" + }; + + const railStyle = { + height: 20, + backgroundColor: "#3498db" + }; + + const trackStyle = { + display: "none" + }; + + return ( + <div id="slider"> + <CustomSlider + min={0} + max={playback ? PLAYBACK_SLOTS : 9} + defaultValue={slider} + handleStyle={handleStyle} + railStyle={railStyle} + trackStyle={trackStyle} + onChange={val => setSlider(val)} + /> + </div> + ); +} diff --git a/src/components/Slider.tsx b/src/components/Slider.tsx deleted file mode 100644 index 22a8f8f..0000000 --- a/src/components/Slider.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React, { useCallback, useRef, useState } from "react"; -import { PLAYBACK_SLOTS } from "../config"; - -interface SliderProps { - playback?: string; - slider: number; - setSlider: React.Dispatch<React.SetStateAction<number>>; -} - -export function Slider({ playback, slider, setSlider }: SliderProps) { - const sliderRef = useRef<HTMLDivElement>(null); - const [isDragging, setIsDragging] = useState(false); - - const max = playback ? PLAYBACK_SLOTS : 9; - - const handleStart = useCallback((e: React.MouseEvent | React.TouchEvent) => { - setIsDragging(true); - updateSliderValue(e); - }, []); - - const updateSliderValue = useCallback((e: MouseEvent | React.MouseEvent | TouchEvent | React.TouchEvent) => { - if (!sliderRef.current) return; - - const rect = sliderRef.current.getBoundingClientRect(); - let clientX: number; - - if ('touches' in e) { - clientX = e.touches?.[0]?.clientX || (e as TouchEvent).changedTouches?.[0]?.clientX || 0; - } else { - clientX = e.clientX; - } - - const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); - const value = Math.round(percentage * max); - setSlider(value); - }, [max, setSlider]); - - const handleMove = useCallback((e: MouseEvent | TouchEvent) => { - if (isDragging) { - e.preventDefault(); - updateSliderValue(e); - } - }, [isDragging, updateSliderValue]); - - const handleEnd = useCallback(() => { - setIsDragging(false); - }, []); - - React.useEffect(() => { - if (isDragging) { - document.addEventListener('mousemove', handleMove); - document.addEventListener('mouseup', handleEnd); - document.addEventListener('touchmove', handleMove, { passive: false }); - document.addEventListener('touchend', handleEnd); - - return () => { - document.removeEventListener('mousemove', handleMove); - document.removeEventListener('mouseup', handleEnd); - document.removeEventListener('touchmove', handleMove); - document.removeEventListener('touchend', handleEnd); - }; - } - }, [isDragging, handleMove, handleEnd]); - - const percentage = (slider / max) * 100; - - return ( - <div id="slider" className="py-2.5 fixed bottom-[8vh] w-[30vw] ml-[35vw] max-[812px]:w-[80vw] max-[812px]:ml-[8vw] max-[812px]:z-10"> - <div - ref={sliderRef} - className="relative h-5 bg-blue-500 rounded-full cursor-pointer touch-none" - onMouseDown={handleStart} - onTouchStart={handleStart} - > - <div - className={`absolute -top-2.5 w-10 h-10 bg-white rounded-full shadow-md z-[1] ${ - isDragging ? 'cursor-grabbing' : 'cursor-grab' - }`} - style={{ - left: `calc(${percentage}% - 20px)` - }} - /> - </div> - </div> - ); -}
\ No newline at end of file diff --git a/src/config.ts b/src/config.tsx index 615e6d4..4ca4578 100644 --- a/src/config.ts +++ b/src/config.tsx @@ -1,13 +1,13 @@ -const hostname = typeof window !== 'undefined' && window.location && window.location.hostname; +const hostname = window && window.location && window.location.hostname; -const PRODUCTION = true; +const PRODUCTION = hostname.includes("geshem"); export const IMAGES_BASE_URL = PRODUCTION ? "https://imgs.geshem.space" : ""; export const MAPBOX_ACCESS_TOKEN = "pk.eyJ1IjoieXV2YWRtIiwiYSI6ImNpcnMxbzBuaTAwZWdoa25oczlzZmkwbHcifQ.UHtLngbKm9O8945pJm23Nw"; -export const IMAGE_COORDINATES: [number, number][] = [ +export const IMAGE_COORDINATES = [ [31.7503896894, 34.4878044232], [37.8574239563, 34.5078463729], [37.7157066403, 29.4538271687], diff --git a/src/functions/scheduled.ts b/src/functions/scheduled.ts deleted file mode 100644 index 5aa3fa0..0000000 --- a/src/functions/scheduled.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Cloudflare Workers scheduled handler for cron jobs -import type { ScheduledController, ScheduledEvent } from '@cloudflare/workers-types'; - -interface Env { - BASE_URL: string; - AUTH_USER: string; - AUTH_PASS: string; - IMGS_BUCKET: R2Bucket; -} - -interface ImageData { - [key: string]: string[]; -} - -class GeshemUpdate { - private bucket: R2Bucket; - private env: Env; - - constructor(bucket: R2Bucket, env: Env) { - this.bucket = bucket; - this.env = env; - } - - private async authenticate(request: Request): Promise<Request> { - const headers = new Headers(request.headers); - const credentials = btoa(`${this.env.AUTH_USER}:${this.env.AUTH_PASS}`); - headers.set('Authorization', `Basic ${credentials}`); - - return new Request(request.url, { - method: request.method, - headers, - body: request.body - }); - } - - private async getLatestImages(): Promise<string[]> { - const authenticatedRequest = await this.authenticate(new Request(this.env.BASE_URL)); - const response = await fetch(authenticatedRequest); - const html = await response.text(); - - const regex = /radar280comp_\d+\.png/g; - const matches = html.match(regex) || []; - const uniqueImages = [...new Set(matches)]; - - return uniqueImages.sort().slice(-10); - } - - private async getLatestBucketKeys(): Promise<string[]> { - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - const yesterdayStr = yesterday.toISOString().slice(0, 10).replace(/-/g, ''); - - try { - const objects = await this.bucket.list({ - prefix: 'imgs/', - startAfter: `imgs/${yesterdayStr}` - }); - - return objects.objects.map(obj => obj.key); - } catch (error) { - console.error('Error listing bucket objects:', error); - return []; - } - } - - private async fetchMissingImages(): Promise<boolean> { - const images = await this.getLatestImages(); - const s3Images = await this.getLatestBucketKeys(); - let updated = false; - - for (const img of images) { - const key = this.keyFromFilename(img); - if (!s3Images.includes(key)) { - await this.fetchImage(img, key); - updated = true; - } - } - - return updated; - } - - private async fetchImage(imgName: string, key: string): Promise<void> { - console.log(`Downloading ${imgName} from web server`); - - const imageUrl = `${this.env.BASE_URL}/${imgName}`; - const authenticatedRequest = await this.authenticate(new Request(imageUrl)); - const response = await fetch(authenticatedRequest); - - if (!response.ok) { - throw new Error(`Failed to fetch image: ${response.statusText}`); - } - - const imageData = await response.arrayBuffer(); - - console.log(`Uploading to ${key}`); - await this.bucket.put(key, imageData, { - httpMetadata: { - contentType: 'image/png', - cacheControl: 'public, max-age=31536000' - } - }); - } - - private keyFromFilename(filename: string): string { - const [name, dateStr] = filename.split('.')[0].split('_'); - const res = 280; - - // Parse date: YYYYMMDDHHMM - const year = dateStr.slice(0, 4); - const month = dateStr.slice(4, 6); - const day = dateStr.slice(6, 8); - const hour = dateStr.slice(8, 10); - const minute = dateStr.slice(10, 12); - - const d = `${year}${month}${day}`; - const t = `${hour}${minute}`; - - return `imgs/${d}/${t}/${res}.png`; - } - - private async generateJson(): Promise<void> { - const latestKeys = await this.getLatestBucketKeys(); - const keys = latestKeys - .filter(key => key.endsWith('280.png')) - .sort() - .slice(-10); - - const index: ImageData = { '280': keys }; - - await this.bucket.put('imgs.json', JSON.stringify(index), { - httpMetadata: { - contentType: 'application/json', - cacheControl: 'public, max-age=60' - } - }); - } - - async run(): Promise<string> { - try { - const updated = await this.fetchMissingImages(); - if (updated) { - await this.generateJson(); - } - return `Updated: ${updated}`; - } catch (error) { - console.error('Error in GeshemUpdate.run():', error); - throw error; - } - } -} - -export default { - async scheduled( - controller: ScheduledController, - env: Env, - ctx: ExecutionContext - ): Promise<void> { - console.log('Cron job triggered at', new Date().toISOString()); - - try { - if (!env?.BASE_URL || !env?.AUTH_USER || !env?.AUTH_PASS) { - throw new Error('Missing environment variables'); - } - - if (!env.IMGS_BUCKET) { - throw new Error('R2 bucket not configured'); - } - - const updater = new GeshemUpdate(env.IMGS_BUCKET, env); - const result = await updater.run(); - - console.log(`Cron job completed: ${result}`); - } catch (error) { - console.error('Error in scheduled function:', error); - throw error; - } - } -};
\ No newline at end of file diff --git a/src/styles/global.css b/src/index.css index c81f9a9..822eb36 100644 --- a/src/styles/global.css +++ b/src/index.css @@ -1,5 +1,3 @@ -@import "tailwindcss"; - body { margin: 0; padding: 0; @@ -13,4 +11,4 @@ body { sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; -}
\ No newline at end of file +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..353cebb --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import { App } from './Geshem'; + +console.log() + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +root.render( + <React.StrictMode> + <App /> + </React.StrictMode > +);
\ No newline at end of file diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro deleted file mode 100644 index e983a6d..0000000 --- a/src/layouts/Layout.astro +++ /dev/null @@ -1,28 +0,0 @@ ---- -export interface Props { - title?: string; - description?: string; -} - -const { title = "geshem.space", description = "geshem.space - Israel Rain Radar" } = Astro.props; ---- - -<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> - <meta name="apple-mobile-web-app-title" content="Geshem"> - <meta name="apple-mobile-web-app-capable" content="yes"> - <meta name="theme-color" content="#000000"> - <link rel="shortcut icon" href="/favicon.ico"> - <link rel="apple-touch-icon" href="/apple-touch-icon.png"> - <link rel="manifest" href="/manifest.json"> - <title>{title}</title> - <meta name="description" content={description}> - <script src="https://cdn.usefathom.com/script.js" data-site="MBXPLVRM" defer></script> - </head> - <body> - <slot /> - </body> -</html>
\ No newline at end of file diff --git a/src/pages/404.astro b/src/pages/404.astro deleted file mode 100644 index 4db6121..0000000 --- a/src/pages/404.astro +++ /dev/null @@ -1,67 +0,0 @@ ---- ---- - -<html lang="en"> - <head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <link rel="shortcut icon" href="/favicon.ico"> - <title>404 - Page Not Found | geshem.space</title> - <style> - body { - margin: 0; - padding: 0; - background-color: #1a1a1a; - color: white; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", - "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; - display: flex; - justify-content: center; - align-items: center; - height: 100vh; - text-align: center; - } - - .container { - max-width: 500px; - } - - h1 { - font-size: 6rem; - margin: 0; - font-weight: bold; - opacity: 0.8; - } - - h2 { - font-size: 1.5rem; - margin: 1rem 0; - font-weight: normal; - } - - p { - margin: 1rem 0; - opacity: 0.7; - line-height: 1.5; - } - - a { - color: #3498db; - text-decoration: none; - font-weight: 500; - } - - a:hover { - text-decoration: underline; - } - </style> - </head> - <body> - <div class="container"> - <h1>404</h1> - <h2>Page Not Found</h2> - <p>The page you're looking for doesn't exist.</p> - <p><a href="/">← Back to Rain Radar</a></p> - </div> - </body> -</html>
\ No newline at end of file diff --git a/src/pages/api/update.ts b/src/pages/api/update.ts deleted file mode 100644 index 88dfa3c..0000000 --- a/src/pages/api/update.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { APIRoute } from 'astro'; - -export const prerender = false; - -interface ImageData { - [key: string]: string[]; -} - -interface CloudflareEnv { - BASE_URL: string; - AUTH_USER: string; - AUTH_PASS: string; - BUCKET_NAME: string; - SCHEDULED?: boolean; -} - -class GeshemUpdate { - private bucket: R2Bucket; - private env: CloudflareEnv; - - constructor(bucket: R2Bucket, env: CloudflareEnv) { - this.bucket = bucket; - this.env = env; - } - - private async authenticate(request: Request): Promise<Request> { - const headers = new Headers(request.headers); - const credentials = btoa(`${this.env.AUTH_USER}:${this.env.AUTH_PASS}`); - headers.set('Authorization', `Basic ${credentials}`); - - return new Request(request.url, { - method: request.method, - headers, - body: request.body - }); - } - - private async getLatestImages(): Promise<string[]> { - const authenticatedRequest = await this.authenticate(new Request(this.env.BASE_URL)); - const response = await fetch(authenticatedRequest); - const html = await response.text(); - - const regex = /radar280comp_\d+\.png/g; - const matches = html.match(regex) || []; - const uniqueImages = [...new Set(matches)]; - - return uniqueImages.sort().slice(-10); - } - - private async getLatestBucketKeys(): Promise<string[]> { - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - const yesterdayStr = yesterday.toISOString().slice(0, 10).replace(/-/g, ''); - - try { - const objects = await this.bucket.list({ - prefix: 'imgs/', - startAfter: `imgs/${yesterdayStr}` - }); - - return objects.objects.map(obj => obj.key); - } catch (error) { - console.error('Error listing bucket objects:', error); - return []; - } - } - - private async fetchMissingImages(): Promise<boolean> { - const images = await this.getLatestImages(); - const s3Images = await this.getLatestBucketKeys(); - let updated = false; - - for (const img of images) { - const key = this.keyFromFilename(img); - if (!s3Images.includes(key)) { - await this.fetchImage(img, key); - updated = true; - } - } - - return updated; - } - - private async fetchImage(imgName: string, key: string): Promise<void> { - console.log(`Downloading ${imgName} from web server`); - - const imageUrl = `${this.env.BASE_URL}/${imgName}`; - const authenticatedRequest = await this.authenticate(new Request(imageUrl)); - const response = await fetch(authenticatedRequest); - - if (!response.ok) { - throw new Error(`Failed to fetch image: ${response.statusText}`); - } - - const imageData = await response.arrayBuffer(); - - console.log(`Uploading to ${key}`); - await this.bucket.put(key, imageData, { - httpMetadata: { - contentType: 'image/png', - cacheControl: 'public, max-age=31536000' - } - }); - } - - private keyFromFilename(filename: string): string { - const [name, dateStr] = filename.split('.')[0].split('_'); - const res = 280; - - // Parse date: YYYYMMDDHHMM - const year = dateStr.slice(0, 4); - const month = dateStr.slice(4, 6); - const day = dateStr.slice(6, 8); - const hour = dateStr.slice(8, 10); - const minute = dateStr.slice(10, 12); - - const d = `${year}${month}${day}`; - const t = `${hour}${minute}`; - - return `imgs/${d}/${t}/${res}.png`; - } - - private async generateJson(): Promise<void> { - const latestKeys = await this.getLatestBucketKeys(); - const keys = latestKeys - .filter(key => key.endsWith('280.png')) - .sort() - .slice(-10); - - const index: ImageData = { '280': keys }; - - await this.bucket.put('imgs.json', JSON.stringify(index), { - httpMetadata: { - contentType: 'application/json', - cacheControl: 'public, max-age=60' - } - }); - } - - async run(): Promise<string> { - try { - const updated = await this.fetchMissingImages(); - if (updated) { - await this.generateJson(); - } - return `Updated: ${updated}`; - } catch (error) { - console.error('Error in GeshemUpdate.run():', error); - throw error; - } - } -} - -export const GET: APIRoute = async ({ platform, request }) => { - // This endpoint should only be accessible via Cloudflare Cron - const cron = request.headers.get('CF-Cron'); - if (!cron) { - return new Response('Unauthorized', { status: 401 }); - } - - try { - const env = platform?.env as unknown as CloudflareEnv; - - if (!env?.BASE_URL || !env?.AUTH_USER || !env?.AUTH_PASS) { - return new Response('Missing environment variables', { status: 500 }); - } - - // Get R2 bucket (this would be configured in your Cloudflare Workers environment) - const bucket = env.BUCKET_NAME ? platform?.env[env.BUCKET_NAME] as R2Bucket : null; - - if (!bucket) { - return new Response('R2 bucket not configured', { status: 500 }); - } - - const updater = new GeshemUpdate(bucket, env); - const result = await updater.run(); - - return new Response(JSON.stringify({ - message: `SUCCESS: ${result}`, - timestamp: new Date().toISOString() - }), { - status: 200, - headers: { - 'Content-Type': 'application/json' - } - }); - - } catch (error) { - console.error('Error in update handler:', error); - return new Response(JSON.stringify({ - message: 'ERROR', - error: error instanceof Error ? error.message : 'Unknown error' - }), { - status: 500, - headers: { - 'Content-Type': 'application/json' - } - }); - } -};
\ No newline at end of file diff --git a/src/pages/history/[date].astro b/src/pages/history/[date].astro deleted file mode 100644 index 0a78f9f..0000000 --- a/src/pages/history/[date].astro +++ /dev/null @@ -1,13 +0,0 @@ ---- -import Layout from '../../layouts/Layout.astro'; -import { Geshem } from '../../components/Geshem'; -import '../../styles/global.css'; - -export const prerender = false; - -const { date } = Astro.params; ---- - -<Layout title="Geshem - Rain Radar History" description="Israel Rain Radar History"> - <Geshem client:load date={date} /> -</Layout>
\ No newline at end of file diff --git a/src/pages/index.astro b/src/pages/index.astro deleted file mode 100644 index 5b08f18..0000000 --- a/src/pages/index.astro +++ /dev/null @@ -1,11 +0,0 @@ ---- -import Layout from "../layouts/Layout.astro"; -import { Geshem } from "../components/Geshem"; -import "../styles/global.css"; - -const { date } = Astro.params; ---- - -<Layout> - <Geshem client:load date={date} /> -</Layout> diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro deleted file mode 100644 index d01a26b..0000000 --- a/src/pages/privacy.astro +++ /dev/null @@ -1,40 +0,0 @@ ---- - ---- - -<html lang="en"> - <head> - <meta charset="utf-8" /> - <meta - name="viewport" - content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" - /> - <meta name="apple-mobile-web-app-title" content="Geshem" /> - <meta name="apple-mobile-web-app-capable" content="yes" /> - <meta name="theme-color" content="#000000" /> - <link rel="shortcut icon" href="/favicon.ico" /> - <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> - <link rel="manifest" href="/manifest.json" /> - <title>Privacy Policy | geshem.space</title> - <script src="https://cdn.usefathom.com/script.js" data-site="MBXPLVRM" defer - ></script> - <style> - body { - overflow: hidden; - position: absolute; - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: center; - font-family: sans-serif; - background-color: #1a1a1a; - color: white; - margin: 0; - } - </style> - </head> - <body> - <h1>We do not collect any data.</h1> - </body> -</html> diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts new file mode 100644 index 0000000..6431bc5 --- /dev/null +++ b/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// <reference types="react-scripts" /> diff --git a/src/setupTests.ts b/src/setupTests.ts new file mode 100644 index 0000000..8f2609b --- /dev/null +++ b/src/setupTests.ts @@ -0,0 +1,5 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom'; |
