From 2a050b383bcafefb8fc378531d6f15f352fdb5a5 Mon Sep 17 00:00:00 2001
From: Yuval Adam <_@yuv.al>
Date: Wed, 24 Sep 2025 10:52:38 +0200
Subject: Override all files with v8 implementation
---
src/components/Datetime.tsx | 28 ++++++
src/components/Geshem.tsx | 65 ++++++++++++++
src/components/Map.tsx | 105 ++++++++++++++++++++++
src/components/Slider.tsx | 86 ++++++++++++++++++
src/config.ts | 19 ++++
src/functions/scheduled.ts | 178 ++++++++++++++++++++++++++++++++++++
src/layouts/Layout.astro | 28 ++++++
src/pages/404.astro | 67 ++++++++++++++
src/pages/api/update.ts | 200 +++++++++++++++++++++++++++++++++++++++++
src/pages/history/[date].astro | 13 +++
src/pages/index.astro | 11 +++
src/pages/privacy.astro | 40 +++++++++
src/styles/global.css | 16 ++++
13 files changed, 856 insertions(+)
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/layouts/Layout.astro
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/index.astro
create mode 100644 src/pages/privacy.astro
create mode 100644 src/styles/global.css
(limited to 'src')
diff --git a/src/components/Datetime.tsx b/src/components/Datetime.tsx
new file mode 100644
index 0000000..8b66a4f
--- /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 (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Geshem.tsx b/src/components/Geshem.tsx
new file mode 100644
index 0000000..982d841
--- /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 as any)["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..070dc5c
--- /dev/null
+++ b/src/components/Map.tsx
@@ -0,0 +1,105 @@
+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..22a8f8f
--- /dev/null
+++ b/src/components/Slider.tsx
@@ -0,0 +1,86 @@
+import React, { useCallback, useRef, useState } from "react";
+import { PLAYBACK_SLOTS } from "../config";
+
+interface SliderProps {
+ playback?: string;
+ slider: number;
+ setSlider: React.Dispatch>;
+}
+
+export function Slider({ playback, slider, setSlider }: SliderProps) {
+ const sliderRef = useRef(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 (
+
+ );
+}
\ No newline at end of file
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..615e6d4
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,19 @@
+const hostname = typeof window !== 'undefined' && window.location && window.location.hostname;
+
+const PRODUCTION = true;
+
+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/layouts/Layout.astro b/src/layouts/Layout.astro
new file mode 100644
index 0000000..e983a6d
--- /dev/null
+++ b/src/layouts/Layout.astro
@@ -0,0 +1,28 @@
+---
+export interface Props {
+ title?: string;
+ description?: string;
+}
+
+const { title = "geshem.space", description = "geshem.space - Israel Rain Radar" } = Astro.props;
+---
+
+
+
+
+
+
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+
\ 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
+
+
+
+
+
+
\ 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..0a78f9f
--- /dev/null
+++ b/src/pages/history/[date].astro
@@ -0,0 +1,13 @@
+---
+import Layout from '../../layouts/Layout.astro';
+import { Geshem } from '../../components/Geshem';
+import '../../styles/global.css';
+
+export const prerender = false;
+
+const { date } = Astro.params;
+---
+
+
+
+
\ No newline at end of file
diff --git a/src/pages/index.astro b/src/pages/index.astro
new file mode 100644
index 0000000..5b08f18
--- /dev/null
+++ b/src/pages/index.astro
@@ -0,0 +1,11 @@
+---
+import Layout from "../layouts/Layout.astro";
+import { Geshem } from "../components/Geshem";
+import "../styles/global.css";
+
+const { date } = Astro.params;
+---
+
+
+
+
diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro
new file mode 100644
index 0000000..d01a26b
--- /dev/null
+++ b/src/pages/privacy.astro
@@ -0,0 +1,40 @@
+---
+
+---
+
+
+
+
+
+
+
+
+
+
+
+ Privacy Policy | geshem.space
+
+
+
+
+ We do not collect any data.
+
+
diff --git a/src/styles/global.css b/src/styles/global.css
new file mode 100644
index 0000000..c81f9a9
--- /dev/null
+++ b/src/styles/global.css
@@ -0,0 +1,16 @@
+@import "tailwindcss";
+
+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