From 4c5be8cf8d942743398581b46ac2c76d0ff273d8 Mon Sep 17 00:00:00 2001 From: Yuval Adam <_@yuv.al> Date: Wed, 24 Sep 2025 09:12:44 +0200 Subject: Initial migration --- src/components/Datetime.tsx | 28 ++++++ src/components/Geshem.tsx | 65 ++++++++++++++ src/components/Map.tsx | 108 ++++++++++++++++++++++ src/components/Slider.tsx | 43 +++++++++ src/config.ts | 19 ++++ src/functions/scheduled.ts | 178 ++++++++++++++++++++++++++++++++++++ src/pages/404.astro | 67 ++++++++++++++ src/pages/api/update.ts | 200 +++++++++++++++++++++++++++++++++++++++++ src/pages/history/[date].astro | 23 +++++ src/pages/index.astro | 11 ++- src/pages/privacy.astro | 38 ++++++++ src/styles/geshem.css | 45 ++++++++++ 12 files changed, 822 insertions(+), 3 deletions(-) create mode 100644 src/components/Datetime.tsx create mode 100644 src/components/Geshem.tsx create mode 100644 src/components/Map.tsx create mode 100644 src/components/Slider.tsx create mode 100644 src/config.ts create mode 100644 src/functions/scheduled.ts create mode 100644 src/pages/404.astro create mode 100644 src/pages/api/update.ts create mode 100644 src/pages/history/[date].astro create mode 100644 src/pages/privacy.astro create mode 100644 src/styles/geshem.css (limited to 'src') diff --git a/src/components/Datetime.tsx b/src/components/Datetime.tsx new file mode 100644 index 0000000..2010f8b --- /dev/null +++ b/src/components/Datetime.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { DateTime as LuxonDateTime } from "luxon"; + +interface DateTimeProps { + images: string[]; + slider: number; +} + +export function DateTime({ images, slider }: DateTimeProps) { + let datetime: LuxonDateTime | null = null; + + if (images.length > 0 && images[slider]) { + const ds = images[slider].substring(5, 18); + datetime = LuxonDateTime.fromFormat(ds, "yyyyMMdd/HHmm", { + zone: "utc", + }).setZone("Asia/Jerusalem"); + } + + const date = datetime ? datetime.toFormat("dd/MM/y") : ""; + const time = datetime ? datetime.toFormat("HH:mm") : ""; + + return ( +
+
{date}
+
{time}
+
+ ); +} \ No newline at end of file diff --git a/src/components/Geshem.tsx b/src/components/Geshem.tsx new file mode 100644 index 0000000..acd2bc6 --- /dev/null +++ b/src/components/Geshem.tsx @@ -0,0 +1,65 @@ +import React, { useState, useEffect } from "react"; + +import { Map } from "./Map"; +import { Slider } from "./Slider"; +import { DateTime } from "./Datetime"; + +import { IMAGES_BASE_URL, PLAYBACK_HOURS, PLAYBACK_SLOTS } from "../config"; + +interface GeshemProps { + date?: string; +} + +export function Geshem({ date }: GeshemProps) { + const [images, setImages] = useState([]); + const [playback] = useState( + date || + (typeof window !== 'undefined' ? new URL(window.location.toString()).searchParams.get("history") : null) || + undefined + ); + const [slider, setSlider] = useState(playback ? PLAYBACK_SLOTS : 9); + + useEffect(() => { + const fetchImages = async () => + fetch(`${IMAGES_BASE_URL}/imgs.json`) + .then(res => res.json()) + .then(imgs => imgs["280"]) + .then(setImages); + + const buildPlayback = async () => { + const date = playback; + const hours = Array.from(Array(PLAYBACK_HOURS).keys()).map( + h => `${String(h).padStart(2, "0")}` + ); + const minutes = Array.from(Array(6).keys()).map( + m => `${String(m * 10).padStart(2, "0")}` + ); + const paths = hours.reduce( + (acc, h) => + acc.concat(minutes.map(m => `imgs/${date}/${h}${m}/280.png`)), + [] + ); + setImages(paths); + }; + + let timer: number; + if (playback) { + buildPlayback(); + } else { + fetchImages(); + timer = window.setInterval(fetchImages, 60 * 1000); + } + + return () => { + if (timer !== undefined) clearInterval(timer); + }; + }, [playback]); + + return ( + <> + + + + + ); +} \ No newline at end of file diff --git a/src/components/Map.tsx b/src/components/Map.tsx new file mode 100644 index 0000000..0e39308 --- /dev/null +++ b/src/components/Map.tsx @@ -0,0 +1,108 @@ +import React, { useState, useEffect, useRef } from "react"; +import mapboxgl from 'mapbox-gl'; + +import { + MAPBOX_ACCESS_TOKEN, + IMAGE_COORDINATES, + IMAGES_BASE_URL +} from "../config"; + +import "mapbox-gl/dist/mapbox-gl.css"; + +interface MapProps { + slider: number; + images: string[]; +} + +export function Map({ slider, images }: MapProps) { + const mapContainer = useRef(null); + const map = useRef(null); + + const [lng] = useState(35); + const [lat] = useState(31.9); + const [zoom] = useState(6.3); + const [loaded, setLoaded] = useState(false); + + const prevImages = useRef(images).current; + + useEffect(() => { + if (map.current || !mapContainer.current) return; + + map.current = new mapboxgl.Map({ + accessToken: MAPBOX_ACCESS_TOKEN, + container: mapContainer.current, + style: "mapbox://styles/mapbox/dark-v9", + center: [lng, lat], + zoom: zoom, + minZoom: 5, + maxZoom: 10, + hash: false, + }); + + map.current.on("style.load", () => { + setLoaded(true); + }); + }, [lng, lat, zoom]); + + useEffect(() => { + if (!loaded || !map.current) 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}`); + } + } + }); + + // add new layers + images.forEach((img, i) => { + 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({ + id: `layer-${img}`, + source: `source-${img}`, + type: "raster", + paint: { + "raster-opacity": 0, + "raster-opacity-transition": { + duration: 0 + } + } + }); + } + }); + }, [loaded, prevImages, images]); + + useEffect(() => { + if (!loaded || !images.length || !map.current) return; + + if (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); + } + }; + }, [loaded, slider, images]); + + return ( +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/Slider.tsx b/src/components/Slider.tsx new file mode 100644 index 0000000..2ee3fa1 --- /dev/null +++ b/src/components/Slider.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import RcSlider from 'rc-slider'; +import { PLAYBACK_SLOTS } from "../config"; +import "rc-slider/assets/index.css"; + +interface GeshemSliderProps { + playback?: string; + slider: number; + setSlider: React.Dispatch>; +} + +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 ( +
+ setSlider(val as number)} + /> +
+ ); +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..6d838be --- /dev/null +++ b/src/config.ts @@ -0,0 +1,19 @@ +const hostname = typeof window !== 'undefined' && window.location && window.location.hostname; + +const PRODUCTION = hostname ? hostname.includes("geshem") : false; + +export const IMAGES_BASE_URL = PRODUCTION ? "https://imgs.geshem.space" : ""; + +export const MAPBOX_ACCESS_TOKEN = + "pk.eyJ1IjoieXV2YWRtIiwiYSI6ImNpcnMxbzBuaTAwZWdoa25oczlzZmkwbHcifQ.UHtLngbKm9O8945pJm23Nw"; + +export const IMAGE_COORDINATES: [number, number][] = [ + [31.7503896894, 34.4878044232], + [37.8574239563, 34.5078463729], + [37.7157066403, 29.4538271687], + [31.9347389087, 29.4373462909] +]; + +export const PLAYBACK_HOURS = 2; + +export const PLAYBACK_SLOTS = (PLAYBACK_HOURS * 6) - 1; \ No newline at end of file diff --git a/src/functions/scheduled.ts b/src/functions/scheduled.ts new file mode 100644 index 0000000..5aa3fa0 --- /dev/null +++ b/src/functions/scheduled.ts @@ -0,0 +1,178 @@ +// 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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/pages/404.astro b/src/pages/404.astro new file mode 100644 index 0000000..4db6121 --- /dev/null +++ b/src/pages/404.astro @@ -0,0 +1,67 @@ +--- +--- + + + + + + + 404 - Page Not Found | geshem.space + + + +
+

404

+

Page Not Found

+

The page you're looking for doesn't exist.

+

← Back to Rain Radar

+
+ + \ No newline at end of file diff --git a/src/pages/api/update.ts b/src/pages/api/update.ts new file mode 100644 index 0000000..88dfa3c --- /dev/null +++ b/src/pages/api/update.ts @@ -0,0 +1,200 @@ +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 new file mode 100644 index 0000000..0bd1e40 --- /dev/null +++ b/src/pages/history/[date].astro @@ -0,0 +1,23 @@ +--- +import { Geshem } from '../../components/Geshem'; +import '../../styles/global.css'; +import '../../styles/geshem.css'; + +export const prerender = false; + +const { date } = Astro.params; +--- + + + + + + + + Geshem - Rain Radar History + + + + + + \ No newline at end of file diff --git a/src/pages/index.astro b/src/pages/index.astro index 2d14107..fb90246 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,16 +1,21 @@ --- +import { Geshem } from '../components/Geshem'; +import '../styles/global.css'; +import '../styles/geshem.css'; +const { date } = Astro.params; --- - + - Astro + Geshem - Rain Radar + -

Astro

+ diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro new file mode 100644 index 0000000..12ac001 --- /dev/null +++ b/src/pages/privacy.astro @@ -0,0 +1,38 @@ +--- + +--- + + + + + + + + + + + + Privacy Policy | geshem.space + + + +

We do not collect any data.

+ + diff --git a/src/styles/geshem.css b/src/styles/geshem.css new file mode 100644 index 0000000..2dba79f --- /dev/null +++ b/src/styles/geshem.css @@ -0,0 +1,45 @@ +#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; + } +} + +body { + margin: 0; + padding: 0; + position: fixed; + overflow: hidden; + width: 100vw; + height: 100vh; + user-select: none; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", + "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} \ No newline at end of file -- cgit v1.3.1