Bläddra i källkod

Refine site metadata and desktop behavior

simo 2 dagar sedan
förälder
incheckning
238005c8ba

+ 11 - 9
.kb-context.md

@@ -1,9 +1,11 @@
-<!-- KB managed: ~/knowledge-base/projects/simo2/context.md -->
-<!-- Always edit THIS file for project context. Do NOT edit the repo's CLAUDE.md. -->
-<!-- KB:REFS
-  - site-structure-and-window-system.md (134 lines)
-    kb_read: projects/simo2/site-structure-and-window-system.md
-  Use kb_search or kb_read to access these docs.
--->
-
-# simo2
+# Legacy simo2 KB context
+
+This file is no longer canonical.
+
+Use:
+
+```md
+@~/knowledge-base/projects/simo2/context.md
+```
+
+The repo's `CLAUDE.md` already points there. Do not add new durable project notes here.

+ 3 - 4
astro.config.mjs

@@ -1,12 +1,11 @@
 import { defineConfig } from "astro/config";
 import mdx from "@astrojs/mdx";
 
-import sitemap from "@astrojs/sitemap";
-
 // https://astro.build/config
 export default defineConfig({
 	site: "https://simo.ng",
-	// Sitemap is currently crashing in this repo's build (reduce of undefined).
-	// Keep it disabled until the underlying integration/config is fixed.
+	// @astrojs/sitemap currently crashes against this Astro 6 build output
+	// (reduce of undefined in astro:build:done), so sitemap.xml is generated
+	// by src/pages/sitemap.xml.ts instead.
 	integrations: [mdx()],
 });

+ 9 - 9
data/spotify-last-known.json

@@ -1,11 +1,11 @@
 {
-  "title": "Under Pressure - Remastered 2011",
-  "artist": "Queen, David Bowie",
-  "album": "Hot Space (Deluxe Remastered Version)",
-  "albumArtUrl": "https://i.scdn.co/image/ab67616d0000b273a1e05e1048e2cf2737adf742",
-  "trackUrl": "https://open.spotify.com/track/11IzgLRXV7Cgek3tEgGgjw",
-  "durationMs": 248440,
-  "stoppedAtMs": 1776291222996,
-  "stoppedProgressMs": 19546,
-  "lastUpdatedAtMs": 1776291216786
+  "title": "Last Train At 25 O'clock",
+  "artist": "Lamp",
+  "album": "For Lovers",
+  "albumArtUrl": "https://i.scdn.co/image/ab67616d0000b27358ebd661a51fcf2968db3ea1",
+  "trackUrl": "https://open.spotify.com/track/3VPBPBZKxQu3bqeuzz8gRm",
+  "durationMs": 263840,
+  "stoppedAtMs": 1777575992861,
+  "stoppedProgressMs": null,
+  "lastUpdatedAtMs": 1777534606824
 }

BIN
public/favicon.ico


+ 10 - 8
public/favicon.svg

@@ -1,9 +1,11 @@
-<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
-    <path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
-    <style>
-        path { fill: #000; }
-        @media (prefers-color-scheme: dark) {
-            path { fill: #FFF; }
-        }
-    </style>
+<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="simo.ng favicon">
+  <rect width="64" height="64" fill="#008080"/>
+  <rect x="8" y="10" width="48" height="42" fill="#c0c0c0"/>
+  <path fill="#ffffff" d="M8 10h48v3H11v39H8z"/>
+  <path fill="#808080" d="M11 49h45v3H8v-3z"/>
+  <path fill="#000000" d="M53 13h3v39h-3zM8 52h48v3H8z"/>
+  <rect x="12" y="14" width="40" height="8" fill="#000080"/>
+  <path fill="#ffffff" d="M16 16h18v2H16z"/>
+  <path fill="#000000" d="M45 16h4v4h-4zM46 17h2v2h-2z"/>
+  <path fill="#000080" d="M20 34c0-5 4-8 10-8 4 0 8 1 11 3l-3 5c-3-2-6-3-9-3-2 0-3 1-3 2 0 4 16 2 16 11 0 5-4 8-11 8-5 0-10-2-13-5l4-5c3 3 7 4 10 4 2 0 4-1 4-2 0-4-16-2-16-10z"/>
 </svg>

+ 8 - 1
public/oembed.json

@@ -1,5 +1,12 @@
 {
+	"version": "1.0",
+	"provider_name": "simo.ng",
+	"provider_url": "https://simo.ng",
 	"author_name": "simo.ng",
 	"author_url": "https://simo.ng",
-	"type": "photo"
+	"type": "photo",
+	"url": "https://simo.ng/smaller.gif",
+	"width": 96,
+	"height": 96,
+	"title": "Simo"
 }

+ 3 - 3
src/components/aboutme.astro

@@ -16,11 +16,11 @@ title="About Me" >
         readonly
         id="text201"
         style="font-size: 22px; resize: none; padding: 10px; font-family: inherit; background-color: #fff; border: 1px solid #ccc; box-sizing: border-box;"
-        >I'm Simon, a software developer studying computer science @ Widener university
+        >I'm Simon, a software developer studying computer science @ Widener University
 
-I am flexable with language choice. Development is a universal language, and I frequntly expand my stack knowledge through experience in different projects.
+I am flexible with language choice. Development is a universal language, and I frequently expand my stack knowledge through experience in different projects.
 
-When it's up to me, I usually chose to write in TypeScript; sometimes GoLang if the project calls for it's usecase.
+When it's up to me, I usually choose to write in TypeScript; sometimes Golang if the project calls for its use case.
         </textarea>
     </div>
   </div>

+ 17 - 4
src/components/app.astro

@@ -2,13 +2,20 @@
 let app = Astro.props.item;
 
 let itemName = app.name.replace(" ", "");
-
-
+const actionLabel = app.redirectUrl ? `Open ${app.name} link` : `Open ${app.name} window`;
 ---
 
-<a id={itemName} data-id={app.name} href={app.link}>
+<a
+	id={itemName}
+	data-id={app.name}
+	data-window-id={app.windowId}
+	data-redirect-url={app.redirectUrl}
+	href={app.redirectUrl ?? "#"}
+	role="button"
+	aria-label={actionLabel}
+>
 	<div class="h-100 m-4">
-		<img src={app.logo} alt={app.name} class="w-auto h-16 mx-auto my-2" />
+		<img src={app.logo} alt="" aria-hidden="true" class="w-auto h-16 mx-auto my-2" />
 		<h3 class="font-sans text-white text-center">{app.name}</h3>
 	</div>
 </a>
@@ -23,6 +30,12 @@ let itemName = app.name.replace(" ", "");
 	a {
 		cursor: pointer;
 		user-select: none;
+		display: block;
+	}
+
+	a:focus-visible {
+		outline: 1px dotted #fff;
+		outline-offset: -2px;
 	}
 
 	.link {

+ 2 - 1
src/components/githubgraph.astro

@@ -11,7 +11,8 @@
 	</div>
 	<div class="window-body" style="padding: 10px;">
 		<iframe
-			src="https://github-readme-stats.vercel.app/api/top-langs/?username=S1monlol&theme=github_dark"
+			data-src="https://github-readme-stats.vercel.app/api/top-langs/?username=S1monlol&theme=github_dark"
+			loading="lazy"
 			style="width: 100%; height: 300px; border: none; image-rendering: pixelated; image-rendering: -moz-crisp-edges; image-rendering: crisp-edges;"
 			frameborder="0"
 		></iframe>

+ 71 - 24
src/components/nowplaying.astro

@@ -231,11 +231,20 @@ const layout = {
     elTime.textContent = `${fmtTime(progressMs)} / ${fmtTime(durationMs)}`;
   }
 
+  function numberFrom(...values) {
+    for (const value of values) {
+      if (typeof value === "number" && Number.isFinite(value)) return value;
+    }
+    return null;
+  }
+
   function startTicking() {
     if (tickTimer) return;
     tickTimer = setInterval(() => {
       if (!lastIsPlaying) return;
       if (typeof lastProgressMs !== "number" || typeof lastDurationMs !== "number") return;
+      // lastSyncAt is the server timestamp (best-effort) for when progressMs was sampled.
+      // If we used Date.now() here, client clock skew could make the bar jump.
       const elapsed = Date.now() - lastSyncAt;
       const est = lastProgressMs + elapsed;
       setProgress(est, lastDurationMs);
@@ -257,16 +266,31 @@ const layout = {
     elArt.style.backgroundImage = `url(${url})`;
   }
 
+  function normalizeResponse(data) {
+    if (!data || typeof data !== "object") return data;
+
+    // The external service returns the last-known shape directly. While music is
+    // playing, that shape has no stoppedAtMs and may expose progressMs directly.
+    if ("title" in data || "lastUpdatedAtMs" in data) {
+      const isPlaying =
+        typeof data.isPlaying === "boolean"
+          ? data.isPlaying
+          : Boolean(data.title) && data.stoppedAtMs == null;
+
+      return isPlaying
+        ? { ...data, isPlaying }
+        : { isPlaying: false, lastKnown: data };
+    }
+
+    return data;
+  }
+
   async function refresh() {
     try {
       const res = await fetch(apiUrl, { cache: "no-store" });
       const data = await res.json();
 
-      // External service returns the last-known state directly.
-      const normalized =
-        data && typeof data === "object" && ("title" in data || "lastUpdatedAtMs" in data)
-          ? { isPlaying: false, lastKnown: data }
-          : data;
+      const normalized = normalizeResponse(data);
 
       if (!normalized || normalized.isPlaying === false || !normalized.title) {
         // If we just transitioned from playing -> not playing, capture stop time.
@@ -296,46 +320,69 @@ const layout = {
         return;
       }
 
-      elTitle.textContent = data.title;
-      elArtist.textContent = data.artist || "";
-      elAlbum.textContent = data.album || "";
-      setArt(data.albumArtUrl);
+      elTitle.textContent = normalized.title;
+      elArtist.textContent = normalized.artist || "";
+      elAlbum.textContent = normalized.album || "";
+      setArt(normalized.albumArtUrl);
 
       // Update the in-memory "last known" track (server persists it).
       lastKnown = {
-        title: data.title ?? null,
-        artist: data.artist ?? null,
-        album: data.album ?? null,
-        albumArtUrl: data.albumArtUrl ?? null,
-        trackUrl: data.trackUrl ?? null,
-        durationMs: typeof data.durationMs === "number" ? data.durationMs : null,
+        title: normalized.title ?? null,
+        artist: normalized.artist ?? null,
+        album: normalized.album ?? null,
+        albumArtUrl: normalized.albumArtUrl ?? null,
+        trackUrl: normalized.trackUrl ?? null,
+        durationMs: typeof normalized.durationMs === "number" ? normalized.durationMs : null,
         stoppedAtMs: null,
         stoppedProgressMs: null,
         lastUpdatedAtMs: Date.now(),
       };
 
-      const trackKey = `${data.title}::${data.artist}::${data.album}`;
-      const durationMs = typeof data.durationMs === "number" ? data.durationMs : null;
-      const progressMs = typeof data.progressMs === "number" ? data.progressMs : null;
-      const isPlaying = Boolean(data.isPlaying);
-
-      if (lastTrackKey && trackKey !== lastTrackKey) {
+      const trackKey = `${normalized.title}::${normalized.artist}::${normalized.album}`;
+      const durationMs = numberFrom(normalized.durationMs, normalized.duration_ms);
+      const sampledProgressMs = numberFrom(
+        normalized.progressMs,
+        normalized.progress_ms,
+        normalized.stoppedProgressMs,
+        normalized.stopped_progress_ms,
+      );
+      const startedAtMs = numberFrom(normalized.startedAtMs, normalized.started_at_ms);
+      const isPlaying = Boolean(normalized.isPlaying);
+      const sameTrack = lastTrackKey === trackKey;
+
+      if (lastTrackKey && !sameTrack) {
         lastProgressMs = null;
         lastSyncAt = 0;
       }
 
+      const syncAt =
+        numberFrom(normalized.progressUpdatedAtMs, normalized.lastUpdatedAtMs, normalized.last_updated_at_ms) ??
+        Date.now();
+      let progressMs =
+        isPlaying && startedAtMs != null
+          ? Date.now() - startedAtMs
+          : sampledProgressMs ??
+            (isPlaying && sameTrack && typeof lastProgressMs === "number"
+              ? lastProgressMs + Math.max(0, Date.now() - lastSyncAt)
+              : null);
+      // Some older last-known responses only expose that a track is playing, not
+      // exact progress. In that case, animate from page load instead of blanking.
+      if (progressMs == null && isPlaying) progressMs = 0;
+
       lastTrackKey = trackKey;
       lastDurationMs = durationMs;
       lastProgressMs = progressMs;
       lastIsPlaying = isPlaying;
-      lastSyncAt = Date.now();
+      // Prefer the explicit track start time when the last-known service sends
+      // it; otherwise use the timestamp for when progress was sampled.
+      lastSyncAt = startedAtMs != null || sampledProgressMs == null ? Date.now() : syncAt;
 
       setProgress(progressMs, durationMs);
 
-      if (isPlaying) startTicking();
+      if (isPlaying && progressMs != null) startTicking();
       else stopTicking();
 
-      lastTrackUrl = data.trackUrl || null;
+      lastTrackUrl = normalized.trackUrl || null;
       if (btnOpen instanceof HTMLButtonElement) btnOpen.disabled = !lastTrackUrl;
       if (elHint) elHint.textContent = "";
     } catch (e) {

+ 13 - 3
src/components/projects.astro

@@ -98,6 +98,11 @@ let projects = Astro.props.projects;
 			window.open(url, '_blank', 'noopener,noreferrer');
 		}
 
+		function isWindowVisible() {
+			const windowEl = container.closest('.window');
+			return !windowEl || window.getComputedStyle(windowEl).display !== 'none';
+		}
+
 		function setSelected(projectId) {
 			const p = projects[projectId];
 			if (!p) return;
@@ -112,24 +117,29 @@ let projects = Astro.props.projects;
 			const openUrl = p.previewUrl;
 			if (openBtn) {
 				openBtn.disabled = !openUrl;
-				openBtn.onclick = () => openRedirect(openUrl);
+				openBtn.dataset.url = openUrl || '';
 			}
 			if (repoBtn) {
 				repoBtn.disabled = !p.repoUrl;
-				repoBtn.onclick = () => openRedirect(p.repoUrl);
+				repoBtn.dataset.url = p.repoUrl || '';
 			}
 
 			if (frameWrap && frame) {
 				if (p.previewUrl) {
 					frameWrap.hidden = false;
-					frame.src = p.previewUrl;
+					frame.dataset.src = p.previewUrl;
+					if (isWindowVisible()) frame.src = p.previewUrl;
+					else frame.removeAttribute('src');
 				} else {
 					frameWrap.hidden = true;
 					frame.removeAttribute('src');
+					delete frame.dataset.src;
 				}
 			}
 		}
 
+		openBtn?.addEventListener('click', () => openRedirect(openBtn.dataset.url));
+		repoBtn?.addEventListener('click', () => openRedirect(repoBtn.dataset.url));
 
 		// Prevent the homepage window-spawning click handler from running.
 		container.querySelectorAll('.project').forEach((pbtn) => {

+ 1 - 1
src/components/projects/glance.astro

@@ -16,7 +16,7 @@ mobile: { height: '300px' },
         A customizable dashboard optimized for E-Ink Displays
     </h2>
 
-    <iframe style="height: 100%; width: 100%; display: none;" class="desktop-only" src="https://glance.simo.ng/app"></iframe>
+    <iframe style="height: 100%; width: 100%; display: none;" class="desktop-only" data-src="https://glance.simo.ng/app" loading="lazy"></iframe>
     <style>
       @media (min-width: 768px) {
         .desktop-only {

+ 1 - 1
src/components/projects/old/imdb.astro

@@ -13,7 +13,7 @@ import Item from '../item.astro';
       One of my first projects, this is an alternative frontend for IMDB that uses TMDB to fetch information about movies.
     </h2>
 
-    <iframe src="https://imdb.simo.ng/?search=invincible" width="800" height="400" frameborder="0" allowfullscreen="">
+    <iframe data-src="https://imdb.simo.ng/?search=invincible" loading="lazy" width="800" height="400" frameborder="0" allowfullscreen="">
     </iframe>
   </div>
 </Item>

+ 2 - 2
src/components/projects/summize.astro

@@ -3,7 +3,7 @@ import Item from './item.astro';
 import summizeExample from "../../assets/summizeExample.png";
 ---
 
-<Item projectName="summize" title="Summize - A Youtube Video Summarizer" layout={{
+<Item projectName="summize" title="Summize - A YouTube Video Summarizer" layout={{
   mobile: { top: '40%' },
   desktop: { width: '70%', height: '45%' }
 }}>
@@ -11,7 +11,7 @@ import summizeExample from "../../assets/summizeExample.png";
     id="text207"
     style="font-size: 22px; resize: none; padding: 10px; height:90%; overflow-y: auto; padding-top: 0px;"
   >
-    <a href="https://github.com/S1monlol/summize"><h1 style="width: fit-content;"><i><u><b>Summize - A Youtube Video Summarizer</b></u></i></h1></a>
+    <a href="https://github.com/S1monlol/summize"><h1 style="width: fit-content;"><i><u><b>Summize - A YouTube Video Summarizer</b></u></i></h1></a>
     <img src={summizeExample.src}>
     <h2>
       Summize is a browser extension that summarizes YouTube videos using OpenAI's GPT-3.5 language model.

+ 1 - 1
src/components/ram.astro

@@ -12,7 +12,7 @@ const layout = {
 		<iframe
 			id="ram-frame"
 			title="RAM"
-			src="/ram/embed"
+			data-src="/ram/embed"
 			style="width: 100%; height: 100%; border: 0; outline: 0; background: white;"
 		></iframe>
 	</div>

+ 1 - 1
src/content/blogs/video-Ge3rG2h2nRY.md

@@ -1,7 +1,7 @@
 ---
 title: "Your projects don't have to matter"
 pubDate: 2025-11-04
-views: 17725
+views: 17765
 description: "YouTube video"
 ---
 

+ 1 - 1
src/content/blogs/video-i_qSU-GiBeo.md

@@ -1,7 +1,7 @@
 ---
 title: "You Need To Stop Rationalizing Irrational Things"
 pubDate: 2026-03-14
-views: 287
+views: 288
 description: "YouTube video"
 ---
 

+ 41 - 5
src/layouts/Layout.astro

@@ -4,6 +4,11 @@ interface Props {
 }
 
 const { title } = Astro.props;
+const siteTitle = "Simo";
+const description = "Simon's personal website";
+const canonicalUrl = "https://simo.ng/";
+const socialImage = "https://simo.ng/smaller.gif";
+
 import { ViewTransitions } from "astro:transitions";
 import "../styles/98.css"
 ---
@@ -13,13 +18,32 @@ import "../styles/98.css"
 	<head>
 		<meta charset="UTF-8" />
 		<meta name="viewport" content="width=device-width, initial-scale=1" />
-		<meta content="https://simo.ng/smaller.gif" name="og:image">
-		<meta content="#ff0504" name="theme-color">
+		<meta name="description" content={description} />
+		<meta name="theme-color" content="#008080" />
+		<link rel="canonical" href={canonicalUrl} />
+
+		<meta property="og:type" content="website" />
+		<meta property="og:url" content={canonicalUrl} />
+		<meta property="og:title" content={siteTitle} />
+		<meta property="og:description" content={description} />
+		<meta property="og:image" content={socialImage} />
+		<meta property="og:image:secure_url" content={socialImage} />
+		<meta property="og:image:type" content="image/gif" />
+		<meta property="og:image:width" content="96" />
+		<meta property="og:image:height" content="96" />
+		<meta property="og:image:alt" content="Simon's character avatar" />
+		<meta name="twitter:card" content="summary" />
+		<meta name="twitter:title" content={siteTitle} />
+		<meta name="twitter:description" content={description} />
+		<meta name="twitter:image" content={socialImage} />
+		<meta name="twitter:image:alt" content="Simon's character avatar" />
+
 		<link type="application/json+oembed" href="https://simo.ng/oembed.json" />
-		<link rel="icon" type="image/svg+xml" href="/favicon.gif" />
+		<link rel="icon" href="/smaller.gif" type="image/gif" sizes="96x96" />
+		<link rel="shortcut icon" href="/smaller.gif" type="image/gif" />
 		<!-- <link rel="stylesheet" href="https://unpkg.com/98.css@0.1.16" /> -->
 		<meta name="generator" content={Astro.generator} />
-		<title>{title}</title>
+		<title>{siteTitle}</title>
 	</head>
 	<body>
 		<slot />
@@ -55,10 +79,22 @@ import "../styles/98.css"
 		resize: none;
 
 		overscroll-behavior-y: contain;
-		touch-action: none;
+		touch-action: auto;
 	}
 
 	.title-bar {
 		user-select: none;
+		touch-action: none;
+	}
+
+	@media (prefers-reduced-motion: reduce) {
+		*,
+		*::before,
+		*::after {
+			animation-duration: 0.01ms !important;
+			animation-iteration-count: 1 !important;
+			scroll-behavior: auto !important;
+			transition-duration: 0.01ms !important;
+		}
 	}
 </style>

+ 0 - 79
src/lib/spotifyLastKnown.server.ts

@@ -1,79 +0,0 @@
-import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
-import { dirname, resolve } from "node:path";
-
-export type SpotifyLastKnown = {
-	// Track identity
-	title: string | null;
-	artist: string | null;
-	album: string | null;
-	albumArtUrl: string | null;
-	trackUrl: string | null;
-
-	// Playback metadata
-	durationMs: number | null;
-	stoppedAtMs: number | null;
-	stoppedProgressMs: number | null;
-
-	// When the server last successfully synced this track from Spotify.
-	lastUpdatedAtMs: number | null;
-};
-
-const DEFAULT_STATE: SpotifyLastKnown = {
-	title: null,
-	artist: null,
-	album: null,
-	albumArtUrl: null,
-	trackUrl: null,
-	durationMs: null,
-	stoppedAtMs: null,
-	stoppedProgressMs: null,
-	lastUpdatedAtMs: null,
-};
-
-function coerceState(v: unknown): SpotifyLastKnown {
-	if (!v || typeof v !== "object") return { ...DEFAULT_STATE };
-	const o = v as Record<string, unknown>;
-	return {
-		title: typeof o.title === "string" ? o.title : null,
-		artist: typeof o.artist === "string" ? o.artist : null,
-		album: typeof o.album === "string" ? o.album : null,
-		albumArtUrl: typeof o.albumArtUrl === "string" ? o.albumArtUrl : null,
-		trackUrl: typeof o.trackUrl === "string" ? o.trackUrl : null,
-		durationMs: typeof o.durationMs === "number" ? o.durationMs : null,
-		stoppedAtMs: typeof o.stoppedAtMs === "number" ? o.stoppedAtMs : null,
-		stoppedProgressMs:
-			typeof o.stoppedProgressMs === "number" ? o.stoppedProgressMs : null,
-		lastUpdatedAtMs:
-			typeof o.lastUpdatedAtMs === "number" ? o.lastUpdatedAtMs : null,
-	};
-}
-
-function stateFilePath(): string {
-	// Workspace-local file for a single Node server.
-	// If you want to move it, set SPOTIFY_LAST_KNOWN_PATH.
-	const p = process.env.SPOTIFY_LAST_KNOWN_PATH;
-	return resolve(
-		process.cwd(),
-		p && p.trim() ? p : "data/spotify-last-known.json",
-	);
-}
-
-export async function readSpotifyLastKnown(): Promise<SpotifyLastKnown> {
-	const path = stateFilePath();
-	try {
-		const text = await readFile(path, "utf8");
-		return coerceState(JSON.parse(text));
-	} catch {
-		return { ...DEFAULT_STATE };
-	}
-}
-
-export async function writeSpotifyLastKnown(
-	next: SpotifyLastKnown,
-): Promise<void> {
-	const path = stateFilePath();
-	await mkdir(dirname(path), { recursive: true });
-	const tmp = `${path}.tmp`;
-	await writeFile(tmp, JSON.stringify(next, null, 2) + "\n", "utf8");
-	await rename(tmp, path);
-}

+ 0 - 111
src/pages/api/spotify/callback.json.ts

@@ -1,111 +0,0 @@
-export const prerender = true;
-
-const TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token";
-
-function requiredEnv(name: string): string {
-	const v = import.meta.env[name];
-	if (!v) throw new Error(`Missing env var: ${name}`);
-	return v;
-}
-
-export async function GET({ request }: { request: Request }) {
-	try {
-		const reqUrl = new URL(request.url);
-		const code = reqUrl.searchParams.get("code");
-
-		if (!code) {
-			return new Response(
-				[
-					"Missing ?code",
-					"",
-					"If your browser shows ?code=..., but this endpoint doesn't receive it,",
-					"something in front of Astro is stripping the query string.",
-					"",
-					`request.url: ${request.url}`,
-				].join("\n"),
-				{
-					status: 400,
-					headers: { "Content-Type": "text/plain; charset=utf-8" },
-				},
-			);
-		}
-
-		const client_id = requiredEnv("SPOTIFY_CLIENT_ID");
-		const client_secret = requiredEnv("SPOTIFY_CLIENT_SECRET");
-		const redirect_uri =
-			import.meta.env.SPOTIFY_REDIRECT_URI ??
-			`${reqUrl.origin}/api/spotify/callback.json`;
-
-		const basic = Buffer.from(`${client_id}:${client_secret}`).toString(
-			"base64",
-		);
-
-		const res = await fetch(TOKEN_ENDPOINT, {
-			method: "POST",
-			headers: {
-				Authorization: `Basic ${basic}`,
-				"Content-Type": "application/x-www-form-urlencoded",
-			},
-			body: new URLSearchParams({
-				grant_type: "authorization_code",
-				code,
-				redirect_uri,
-			}),
-		});
-
-		const text = await res.text();
-		if (!res.ok) {
-			return new Response(
-				`Spotify token exchange failed (${res.status}).\n\n${text}`,
-				{
-					status: 500,
-					headers: { "Content-Type": "text/plain; charset=utf-8" },
-				},
-			);
-		}
-
-		const data = JSON.parse(text) as {
-			refresh_token?: string;
-			access_token?: string;
-			expires_in?: number;
-			scope?: string;
-			token_type?: string;
-		};
-
-		const refresh = data.refresh_token;
-		if (!refresh) {
-			return new Response(
-				[
-					"No refresh_token returned.",
-					"",
-					"This usually means you already authorized this app before.",
-					"Go to https://www.spotify.com/account/apps/ and remove access, then try again.",
-					"",
-					"Raw response:",
-					text,
-				].join("\n"),
-				{
-					status: 500,
-					headers: { "Content-Type": "text/plain; charset=utf-8" },
-				},
-			);
-		}
-
-		return new Response(
-			[
-				"Refresh token generated successfully.",
-				"",
-				`SPOTIFY_REFRESH_TOKEN=${refresh}`,
-				"",
-				"Put that into your .env (server-side).",
-			].join("\n"),
-			{ status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" } },
-		);
-	} catch (err) {
-		const message = err instanceof Error ? err.message : String(err);
-		return new Response(message, {
-			status: 500,
-			headers: { "Content-Type": "text/plain; charset=utf-8" },
-		});
-	}
-}

+ 0 - 103
src/pages/api/spotify/callback.ts

@@ -1,103 +0,0 @@
-// These endpoints are only used in dev / when deployed with an SSR adapter.
-// Marking them prerenderable keeps `astro build` working for static deploys.
-export const prerender = true;
-
-const TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token";
-
-function requiredEnv(name: string): string {
-	const v = import.meta.env[name];
-	if (!v) throw new Error(`Missing env var: ${name}`);
-	return v;
-}
-
-export async function GET({ request, url }: { request: Request; url: URL }) {
-	try {
-		const reqUrl = new URL(request.url);
-		const code =
-			reqUrl.searchParams.get("code") ?? url.searchParams.get("code");
-		if (!code) {
-			return new Response(
-				[
-					"Missing ?code",
-					"",
-					`request.url: ${request.url}`,
-					`astro.url: ${url.toString()}`,
-				].join("\n"),
-				{
-					status: 400,
-					headers: { "Content-Type": "text/plain; charset=utf-8" },
-				},
-			);
-		}
-
-		const client_id = requiredEnv("SPOTIFY_CLIENT_ID");
-		const client_secret = requiredEnv("SPOTIFY_CLIENT_SECRET");
-		const redirect_uri =
-			import.meta.env.SPOTIFY_REDIRECT_URI ??
-			`${reqUrl.origin}/api/spotify/callback`;
-
-		const basic = Buffer.from(`${client_id}:${client_secret}`).toString(
-			"base64",
-		);
-
-		const res = await fetch(TOKEN_ENDPOINT, {
-			method: "POST",
-			headers: {
-				Authorization: `Basic ${basic}`,
-				"Content-Type": "application/x-www-form-urlencoded",
-			},
-			body: new URLSearchParams({
-				grant_type: "authorization_code",
-				code,
-				redirect_uri,
-			}),
-		});
-
-		const text = await res.text();
-		if (!res.ok) {
-			return new Response(
-				`Spotify token exchange failed (${res.status}).\n\n${text}`,
-				{
-					status: 500,
-					headers: { "Content-Type": "text/plain; charset=utf-8" },
-				},
-			);
-		}
-
-		const data = JSON.parse(text) as {
-			access_token?: string;
-			refresh_token?: string;
-			scope?: string;
-			expires_in?: number;
-			token_type?: string;
-		};
-
-		const refresh = data.refresh_token;
-		if (!refresh) {
-			return new Response(
-				`No refresh_token returned.\n\nThis usually means you already authorized this app before.\nGo to https://www.spotify.com/account/apps/ and remove access, then try again.\n\nRaw response:\n${text}`,
-				{
-					status: 500,
-					headers: { "Content-Type": "text/plain; charset=utf-8" },
-				},
-			);
-		}
-
-		return new Response(
-			[
-				"Refresh token generated successfully.",
-				"",
-				`SPOTIFY_REFRESH_TOKEN=${refresh}`,
-				"",
-				"Put that into your .env (server-side).",
-			].join("\n"),
-			{ status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" } },
-		);
-	} catch (err) {
-		const message = err instanceof Error ? err.message : String(err);
-		return new Response(message, {
-			status: 500,
-			headers: { "Content-Type": "text/plain; charset=utf-8" },
-		});
-	}
-}

+ 0 - 26
src/pages/api/spotify/login.ts

@@ -1,26 +0,0 @@
-export const prerender = true;
-
-export async function GET({ url }: { url: URL }) {
-	const client_id = import.meta.env.SPOTIFY_CLIENT_ID;
-	if (!client_id) {
-		return new Response("Missing SPOTIFY_CLIENT_ID", { status: 500 });
-	}
-
-	// Allow overriding in case you use a different host/port locally.
-	const redirect_uri =
-		import.meta.env.SPOTIFY_REDIRECT_URI ??
-		`${url.origin}/api/spotify/callback.json`;
-
-	const scopes = [
-		"user-read-currently-playing",
-		"user-read-playback-state",
-	].join(" ");
-
-	const authUrl = new URL("https://accounts.spotify.com/authorize");
-	authUrl.searchParams.set("client_id", client_id);
-	authUrl.searchParams.set("response_type", "code");
-	authUrl.searchParams.set("redirect_uri", redirect_uri);
-	authUrl.searchParams.set("scope", scopes);
-
-	return Response.redirect(authUrl.toString(), 302);
-}

+ 0 - 224
src/pages/api/spotify/now-playing.json.ts

@@ -1,224 +0,0 @@
-export const prerender = true;
-
-import {
-	readSpotifyLastKnown,
-	writeSpotifyLastKnown,
-	type SpotifyLastKnown,
-} from "../../../lib/spotifyLastKnown.server";
-
-const NOW_PLAYING_ENDPOINT =
-	"https://api.spotify.com/v1/me/player/currently-playing";
-const TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token";
-
-type SpotifyNowPlaying = {
-	is_playing?: boolean;
-	progress_ms?: number;
-	item?: {
-		name?: string;
-		duration_ms?: number;
-		artists?: { name?: string }[];
-		album?: {
-			name?: string;
-			images?: { url: string; height?: number; width?: number }[];
-		};
-		external_urls?: { spotify?: string };
-	};
-};
-
-function requiredEnv(name: string): string {
-	const v = import.meta.env[name];
-	if (!v) throw new Error(`Missing env var: ${name}`);
-	return v;
-}
-
-let cachedAccessToken: { token: string; expiresAtMs: number } | null = null;
-
-async function getAccessToken(): Promise<string> {
-	const now = Date.now();
-	if (
-		cachedAccessToken &&
-		cachedAccessAccessTokenStillValid(cachedAccessToken, now)
-	) {
-		return cachedAccessToken.token;
-	}
-
-	const client_id = requiredEnv("SPOTIFY_CLIENT_ID");
-	const client_secret = requiredEnv("SPOTIFY_CLIENT_SECRET");
-	const refresh_token = requiredEnv("SPOTIFY_REFRESH_TOKEN");
-
-	const basic = Buffer.from(`${client_id}:${client_secret}`).toString("base64");
-
-	const res = await fetch(TOKEN_ENDPOINT, {
-		method: "POST",
-		headers: {
-			Authorization: `Basic ${basic}`,
-			"Content-Type": "application/x-www-form-urlencoded",
-		},
-		body: new URLSearchParams({
-			grant_type: "refresh_token",
-			refresh_token,
-		}),
-	});
-
-	if (!res.ok) {
-		const text = await res.text().catch(() => "");
-		throw new Error(`Spotify token error: ${res.status} ${text}`);
-	}
-
-	const data = (await res.json()) as {
-		access_token: string;
-		expires_in: number;
-	};
-	// expires_in is seconds; subtract a small skew so we refresh early.
-	cachedAccessToken = {
-		token: data.access_token,
-		expiresAtMs: Date.now() + data.expires_in * 1000 - 30_000,
-	};
-	return cachedAccessToken.token;
-}
-
-function cachedAccessAccessTokenStillValid(
-	cached: { token: string; expiresAtMs: number },
-	nowMs: number,
-): boolean {
-	return Boolean(cached.token) && cached.expiresAtMs > nowMs;
-}
-
-export async function GET() {
-	try {
-		const prev = await readSpotifyLastKnown();
-		const nowMs = Date.now();
-
-		const accessToken = await getAccessToken();
-
-		const res = await fetch(NOW_PLAYING_ENDPOINT, {
-			headers: {
-				Authorization: `Bearer ${accessToken}`,
-			},
-		});
-
-		// 204 No Content means nothing is currently playing
-		if (res.status === 204) {
-			// Capture the stop time once (first time we observe "not playing").
-			// Do NOT overwrite lastUpdatedAtMs here: that should represent the last
-			// time we successfully synced while it was playing.
-			if (prev.title && !prev.stoppedAtMs) {
-				await writeSpotifyLastKnown({
-					...prev,
-					stoppedAtMs: nowMs,
-				});
-			}
-			const lastKnown = await readSpotifyLastKnown();
-			return new Response(JSON.stringify({ isPlaying: false, lastKnown }), {
-				status: 200,
-				headers: {
-					"Content-Type": "application/json; charset=utf-8",
-					"Cache-Control": "no-store",
-				},
-			});
-		}
-
-		if (res.status === 401) {
-			// Token might be expired; clear cache and let client retry on next poll.
-			cachedAccessToken = null;
-			return new Response(
-				JSON.stringify({
-					isPlaying: false,
-					error: "unauthorized",
-					lastKnown: prev,
-				}),
-				{
-					status: 200,
-					headers: {
-						"Content-Type": "application/json; charset=utf-8",
-						"Cache-Control": "no-store",
-					},
-				},
-			);
-		}
-
-		if (!res.ok) {
-			const text = await res.text().catch(() => "");
-			return new Response(
-				JSON.stringify({
-					isPlaying: false,
-					error: `spotify_${res.status}`,
-					detail: text,
-					lastKnown: prev,
-				}),
-				{
-					status: 200,
-					headers: {
-						"Content-Type": "application/json; charset=utf-8",
-						"Cache-Control": "no-store",
-					},
-				},
-			);
-		}
-
-		const data = (await res.json()) as SpotifyNowPlaying;
-		const item = data.item;
-
-		const payload = {
-			isPlaying: Boolean(data.is_playing),
-			title: item?.name ?? null,
-			artist:
-				item?.artists
-					?.map((a) => a?.name)
-					.filter(Boolean)
-					.join(", ") ?? null,
-			album: item?.album?.name ?? null,
-			albumArtUrl: item?.album?.images?.[0]?.url ?? null,
-			progressMs: data.progress_ms ?? null,
-			durationMs: item?.duration_ms ?? null,
-			trackUrl: item?.external_urls?.spotify ?? null,
-		};
-
-		// Persist last-known track server-side.
-		const nextLastKnown: SpotifyLastKnown = {
-			title: payload.title,
-			artist: payload.artist,
-			album: payload.album,
-			albumArtUrl: payload.albumArtUrl,
-			trackUrl: payload.trackUrl,
-			durationMs:
-				typeof payload.durationMs === "number" ? payload.durationMs : null,
-			stoppedAtMs: payload.isPlaying ? null : (prev.stoppedAtMs ?? nowMs),
-			stoppedProgressMs:
-				payload.isPlaying === false
-					? typeof payload.progressMs === "number"
-						? payload.progressMs
-						: prev.stoppedProgressMs
-					: null,
-			// Only update the "last updated" timestamp while playing.
-			// When playback stops, we want the UI to show when it *stopped being updated*.
-			lastUpdatedAtMs: payload.isPlaying ? nowMs : prev.lastUpdatedAtMs,
-		};
-		await writeSpotifyLastKnown(nextLastKnown);
-
-		return new Response(JSON.stringify(payload), {
-			status: 200,
-			headers: {
-				"Content-Type": "application/json; charset=utf-8",
-				"Cache-Control": "no-store",
-			},
-		});
-	} catch (err) {
-		const message = err instanceof Error ? err.message : String(err);
-		return new Response(
-			JSON.stringify({
-				isPlaying: false,
-				error: "server_error",
-				detail: message,
-				lastKnown: await readSpotifyLastKnown().catch(() => null),
-			}),
-			{
-				status: 200,
-				headers: {
-					"Content-Type": "application/json; charset=utf-8",
-					"Cache-Control": "no-store",
-				},
-			},
-		);
-	}
-}

+ 170 - 209
src/pages/index.astro

@@ -32,186 +32,49 @@ import Glance from "../components/projects/glance.astro";
 
 import ramIcon from "../assets/apps/blog.png";
 
-function dragElement(elmnt) {
-	var pos1 = 0,
-		pos2 = 0,
-		pos3 = 0,
-		pos4 = 0;
-
-	if (document.getElementById(elmnt.id + "header")) {
-		document.getElementById(elmnt.id + "header").onmousedown = dragMouseDown;
-		document.getElementById(elmnt.id + "header").ontouchstart = dragMouseDown;
-	} else {
-		elmnt.onmousedown = dragMouseDown;
-		elmnt.ontouchstart = dragMouseDown;
-	}
-
-	function dragMouseDown(e) {
-		// e.preventDefault();
-		raiseUpSpec(elmnt.id);
-		if (e.type == "touchstart") {
-			pos3 = e.touches[0].clientX;
-			pos4 = e.touches[0].clientY;
-		} else {
-			pos3 = e.clientX;
-			pos4 = e.clientY;
-		}
-		document.onmouseup = closeDragElement;
-		document.ontouchend = closeDragElement;
-		document.onmousemove = elementDrag;
-		document.ontouchmove = elementDrag;
-	}
-
-	function elementDrag(e) {
-		// e.preventDefault();
-		if (e.type == "touchmove") {
-			pos1 = pos3 - e.touches[0].clientX;
-			pos2 = pos4 - e.touches[0].clientY;
-			pos3 = e.touches[0].clientX;
-			pos4 = e.touches[0].clientY;
-		} else {
-			pos1 = pos3 - e.clientX;
-			pos2 = pos4 - e.clientY;
-			pos3 = e.clientX;
-			pos4 = e.clientY;
-		}
-
-		elmnt.style.top = elmnt.offsetTop - pos2 + "px";
-		elmnt.style.left = elmnt.offsetLeft - pos1 + "px";
-	}
-
-	function closeDragElement() {
-		document.onmouseup = null;
-		document.ontouchend = null;
-		document.onmousemove = null;
-		document.ontouchmove = null;
-	}
-}
-
-/** Raise up window with given ID */
-function raiseUpSpec(id: string) {
-	console.log("raising ", id);
-	let els = document.getElementsByClassName("window");
-
-	let elArray = Array.from(els as HTMLCollectionOf<HTMLElement>);
-
-	// find the highest z-index
-	let highestIndex = 0;
-
-	for (let element in elArray) {
-		let zindex = parseInt(elArray[element].style.zIndex);
-		if (zindex > highestIndex) {
-			highestIndex = zindex;
-		}
-	}
-
-	// set the highest z-index plus one
-	document.getElementById(id).style.zIndex = (highestIndex + 1).toString();
-}
-
-function updateRedirectMessage() {
-	const redirectElement = document.getElementById("Redirect");
-
-	const redirectUrl = redirectElement.dataset.redirecturl; // Access the data attribute
-
-	const messageElement = redirectElement.querySelector("h2");
-	messageElement.innerHTML = `You are about to be redirected to <a class="redirUrl" style="  cursor: pointer;
-    color: blue;
-    text-decoration: underline; " href="${redirectUrl}"">${redirectUrl.replace("https://", "")}</a>`;
-
-	const buttonElement = redirectElement.querySelector("#okButton");
-	// open redirect url
-	buttonElement.setAttribute(
-		"onclick",
-		`window.location.href = "${redirectUrl}"`
-	);
-}
-
-let apps: app[] = [
+let apps = [
 	{
 		name: "About Me",
 		logo: me.src,
-		onclick: () => {
-			raiseUpSpec("About Me");
-			document.getElementById("About Me").style.display = "block";
-		},
+		windowId: "About Me",
 		row: 1,
 	},
-	// {
-	// 	name: "Blog",
-	// 	logo: blog.src,
-	// 	onclick: () => {
-	// 		raiseUpSpec("Redirect");
-	// 		document.getElementById("Redirect").style.display = "block";
-	// 		document.getElementById("Redirect").dataset.redirecturl =
-	// 			"https://blog.simo.ng/";
-	// 		updateRedirectMessage();
-	// 	},
-	// 	row: 1,
-	// },
 	{
 		name: "Projects",
 		logo: projects.src,
-		onclick: () => {
-			raiseUpSpec("Projects ");
-			document.getElementById("Projects ").style.display = "block";
-		},
+		windowId: "Projects ",
 		row: 1,
 	},
-	// {
-	// 	name: "Past Work",
-	// 	logo: pastwork.src,
-	// 	row: 1,
-	// },
 	{
 		name: "Past Work",
 		logo: pastwork.src,
-		onclick: () => {
-			raiseUpSpec("Past Work");
-			document.getElementById("Past Work").style.display = "block";
-		},
+		windowId: "Past Work",
 		row: 1,
 	},
 	{
 		name: "Git",
 		logo: github.src,
-		onclick: () => {
-			// ignore
-			raiseUpSpec("Redirect");
-			document.getElementById("Redirect").style.display = "block";
-			document.getElementById("Redirect").dataset.redirecturl =
-				"https://git.simo.ng/simo";
-			updateRedirectMessage();
-		},
+		redirectUrl: "https://git.simo.ng/simo",
 		row: 2,
 	},
 	{
 		name: "RAM",
 		logo: ramIcon.src,
-		onclick: () => {
-			raiseUpSpec("ram");
-			document.getElementById("ram").style.display = "block";
-		},
+		windowId: "ram",
 		row: 2,
 	},
 	{
 		name: "Contact",
 		logo: contact.src,
+		windowId: "contact",
 		row: 2,
-		onclick: () => {
-			raiseUpSpec("contact");
-			document.getElementById("contact").style.display = "block";
-		},
 	},
 	{
 		name: "PGP Key",
 		logo: pgp.src,
+		windowId: "pgp",
+		fullscreen: true,
 		row: 1,
-		onclick: () => {
-			raiseUpSpec("pgp");
-			document.getElementById("pgp").classList.add("fullscreen");
-			document.getElementById("pgp").style.display = "block";
-		},
 	},
 ];
 
@@ -233,20 +96,6 @@ let projectsArr: project[] = [
 const row1Items = apps.filter((item) => item.row === 1);
 const row2Items = apps.filter((item) => item.row === 2);
 
-let clientFunctions: Function[] | String[] = [
-	raiseUpSpec,
-	updateRedirectMessage,
-	dragElement,
-	updateRedirectMessage,
-];
-
-clientFunctions = clientFunctions.map((item) => String(item));
-
-apps = apps.map((item) => {
-	item.onclick = String(item.onclick);
-
-	return item;
-});
 ---
 
 <Layout title="Simo">
@@ -273,7 +122,7 @@ apps = apps.map((item) => {
 		<iframe
 			id="SimoSearch"
 			title="SimoSearch"
-			src="https://search.simo.ng/#embed"
+			data-src="https://search.simo.ng/#embed"
 			loading="lazy"
 			referrerpolicy="no-referrer"
 			allowfullscreen
@@ -301,7 +150,7 @@ apps = apps.map((item) => {
 	<Taskbar />
 </Layout>
 
-<script define:vars={{ apps, projectsArr, clientFunctions }}>
+<script define:vars={{ apps, projectsArr }}>
 	let dino = `
 	                	██████████████
                       ████░░████████████
@@ -327,8 +176,126 @@ apps = apps.map((item) => {
 
 	console.log(dino);
 
-	for (func in clientFunctions) {
-		eval(clientFunctions[func]);
+	const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+	function getWindow(id) {
+		return document.getElementById(id);
+	}
+
+	function raiseUpSpec(id) {
+		const target = getWindow(id);
+		if (!target) return;
+		const windows = Array.from(document.getElementsByClassName('window'));
+		const highestIndex = windows.reduce((highest, element) => {
+			const zindex = parseInt(element.style.zIndex || '0', 10);
+			return Number.isFinite(zindex) && zindex > highest ? zindex : highest;
+		}, 0);
+		target.style.zIndex = String(highestIndex + 1);
+	}
+
+	function loadDeferredIframes(root = document) {
+		const load = (iframe) => {
+			if (!(iframe instanceof HTMLIFrameElement)) return;
+			const src = iframe.dataset.src;
+			if (src && !iframe.src) iframe.src = src;
+		};
+
+		load(root);
+		if ('querySelectorAll' in root) {
+			root.querySelectorAll('iframe[data-src]:not([src])').forEach(load);
+		}
+	}
+
+	function showWindow(id, options = {}) {
+		const windowEl = getWindow(id);
+		if (!windowEl) return;
+		if (options.fullscreen) windowEl.classList.add('fullscreen');
+		windowEl.style.display = 'block';
+		loadDeferredIframes(windowEl);
+		raiseUpSpec(id);
+		const focusTarget = windowEl.querySelector('button, a, input, textarea, select, [tabindex]:not([tabindex="-1"])');
+		if (focusTarget instanceof HTMLElement) focusTarget.focus({ preventScroll: true });
+	}
+
+	function openRedirect(redirectUrl) {
+		const redirectElement = getWindow('Redirect') || getWindow('redirect');
+		if (!redirectElement || !redirectUrl) return;
+		redirectElement.dataset.redirecturl = redirectUrl;
+		updateRedirectMessage();
+		showWindow(redirectElement.id);
+	}
+
+	function updateRedirectMessage() {
+		const redirectElement = getWindow('Redirect') || getWindow('redirect');
+		if (!redirectElement) return;
+		const redirectUrl = redirectElement.dataset.redirecturl;
+		if (!redirectUrl) return;
+
+		const messageElement = redirectElement.querySelector('h2');
+		if (messageElement) {
+			messageElement.textContent = 'You are about to be redirected to ';
+			const link = document.createElement('a');
+			link.className = 'redirUrl';
+			link.href = redirectUrl;
+			link.textContent = redirectUrl.replace(/^https?:\/\//, '');
+			link.style.cssText = 'cursor: pointer; color: blue; text-decoration: underline;';
+			messageElement.appendChild(link);
+		}
+
+		const buttonElement = redirectElement.querySelector('#okButton');
+		if (buttonElement && !buttonElement.dataset.redirectBound) {
+			buttonElement.dataset.redirectBound = 'true';
+			buttonElement.addEventListener('click', () => {
+				const currentUrl = redirectElement.dataset.redirecturl;
+				if (currentUrl) window.location.href = currentUrl;
+			});
+		}
+	}
+
+	function dragElement(elmnt) {
+		let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
+		const header = document.getElementById(elmnt.id + 'header') || elmnt;
+		header.addEventListener('mousedown', dragMouseDown);
+		header.addEventListener('touchstart', dragMouseDown, { passive: true });
+
+		function dragMouseDown(e) {
+			raiseUpSpec(elmnt.id);
+			if (e.type === 'touchstart') {
+				pos3 = e.touches[0].clientX;
+				pos4 = e.touches[0].clientY;
+			} else {
+				pos3 = e.clientX;
+				pos4 = e.clientY;
+			}
+			document.addEventListener('mouseup', closeDragElement);
+			document.addEventListener('touchend', closeDragElement);
+			document.addEventListener('mousemove', elementDrag);
+			document.addEventListener('touchmove', elementDrag, { passive: false });
+		}
+
+		function elementDrag(e) {
+			if (e.cancelable) e.preventDefault();
+			if (e.type === 'touchmove') {
+				pos1 = pos3 - e.touches[0].clientX;
+				pos2 = pos4 - e.touches[0].clientY;
+				pos3 = e.touches[0].clientX;
+				pos4 = e.touches[0].clientY;
+			} else {
+				pos1 = pos3 - e.clientX;
+				pos2 = pos4 - e.clientY;
+				pos3 = e.clientX;
+				pos4 = e.clientY;
+			}
+			elmnt.style.top = elmnt.offsetTop - pos2 + 'px';
+			elmnt.style.left = elmnt.offsetLeft - pos1 + 'px';
+		}
+
+		function closeDragElement() {
+			document.removeEventListener('mouseup', closeDragElement);
+			document.removeEventListener('touchend', closeDragElement);
+			document.removeEventListener('mousemove', elementDrag);
+			document.removeEventListener('touchmove', elementDrag);
+		}
 	}
 
 	Array.from(document.getElementsByClassName("project")).forEach((project) => {
@@ -343,28 +310,22 @@ apps = apps.map((item) => {
 		let id = found.id;
 
 		if (id == "pgpcord") {
-			project.onclick = () => {
+			project.addEventListener("click", () => {
 				setTimeout(() => {
-					raiseUpSpec("redirect");
-					document.getElementById("redirect").style.display = "block";
-					document.getElementById("redirect").dataset.redirecturl =
-						"https://blog.simo.ng/articles/WIP/pgpcord";
-
-					updateRedirectMessage();
+					openRedirect("https://blog.simo.ng/articles/WIP/pgpcord");
 				}, 1);
-			};
+			});
 
 			return;
 		}
-		project.onclick = () => {
+		project.addEventListener("click", () => {
 			setTimeout(() => {
 				console.log(id);
 				const el = document.getElementsByClassName(id)[0];
 				if (!el) return;
-				el.style.display = "block";
-				raiseUpSpec(el.id);
+				showWindow(el.id);
 			}, 1);
-		};
+		});
 	});
 
 	Array.from(document.getElementsByClassName("window")).forEach((window) => {
@@ -379,23 +340,12 @@ apps = apps.map((item) => {
 			raiseUpSpec(window.id);
 		});
 
-		Array.from(document.querySelectorAll(".projectwindow a")).forEach(
-			(link) => {
-				link.addEventListener("click", (e) => {
-					document.getElementById("redirect").style.display = "block";
-					document.getElementById("redirect").dataset.redirecturl = link.href;
-					updateRedirectMessage();
-
-					e.preventDefault();
-
-					setTimeout(() => {
-						raiseUpSpec("redirect");
-					}, 1);
-
-					return false;
-				});
-			}
-		);
+		Array.from(document.querySelectorAll(".projectwindow a")).forEach((link) => {
+			link.addEventListener("click", (e) => {
+				e.preventDefault();
+				openRedirect(link.href);
+			});
+		});
 
 		const titleButtons = Array.from(
 			window.querySelectorAll('.title-bar-controls button')
@@ -436,17 +386,22 @@ apps = apps.map((item) => {
 	});
 
 	apps.forEach((app) => {
-		let itemName = app.name.replace(" ", "");
-
-		// console.log(itemName)
-
-		if (app.onclick == "undefined") {
-			return;
-		}
+		const itemName = app.name.replace(' ', '');
+		const launcher = document.getElementById(itemName);
+		if (!launcher) return;
+
+		const activate = (event) => {
+			event.preventDefault();
+			if (app.redirectUrl) {
+				openRedirect(app.redirectUrl);
+				return;
+			}
+			if (app.windowId) showWindow(app.windowId, { fullscreen: Boolean(app.fullscreen) });
+		};
 
-		document.getElementById(itemName).addEventListener("click", () => {
-			const appFunc = eval(app.onclick);
-			appFunc();
+		launcher.addEventListener('click', activate);
+		launcher.addEventListener('keydown', (event) => {
+			if (event.key === 'Enter' || event.key === ' ') activate(event);
 		});
 	});
 
@@ -462,16 +417,18 @@ apps = apps.map((item) => {
 		const translateX = buttonRect.left - windowRect.left;
 		const translateY = buttonRect.top - windowRect.top;
 
-		window.style.transition = "all 0.3s ease-out";
-		window.style.transform = `translate(${translateX}px, ${translateY}px) scale(0.1)`;
-		window.style.opacity = "0";
+		if (!prefersReducedMotion) {
+			window.style.transition = "all 0.3s ease-out";
+			window.style.transform = `translate(${translateX}px, ${translateY}px) scale(0.1)`;
+			window.style.opacity = "0";
+		}
 
 		setTimeout(() => {
 			window.style.display = "none";
 			window.style.transition = "";
 			window.style.transform = "";
 			window.style.opacity = "";
-		}, 300);
+		}, prefersReducedMotion ? 0 : 300);
 	}
 
 	// Expose a tiny API for legacy components that wire their own controls.
@@ -488,14 +445,17 @@ apps = apps.map((item) => {
 		const translateX = buttonRect.left - windowRect.left;
 		const translateY = buttonRect.top - windowRect.top;
 
-		window.style.transform = `translate(${translateX}px, ${translateY}px) scale(0.1)`;
-		window.style.opacity = "0";
+		if (!prefersReducedMotion) {
+			window.style.transform = `translate(${translateX}px, ${translateY}px) scale(0.1)`;
+			window.style.opacity = "0";
+		}
 		window.style.display = "block";
 
 		removeFromTaskbar(windowId);
 		raiseUpSpec(windowId);
 
 		requestAnimationFrame(() => {
+			if (prefersReducedMotion) return;
 			window.style.transition = "all 0.3s ease-out";
 			window.style.transform = "translate(0, 0) scale(1)";
 			window.style.opacity = "1";
@@ -587,6 +547,7 @@ apps = apps.map((item) => {
 		startButton.addEventListener('click', () => {
 			const w = document.getElementById('SimoSearch');
 			if (!w) return;
+			loadDeferredIframes(w);
 			w.style.display = 'block';
 			w.style.zIndex = '9999';
 		});

+ 1 - 35
src/pages/ram/embed.astro

@@ -46,44 +46,10 @@ const posts = allPosts
 
 	<footer class="ram-embed__status" aria-label="Status">
 		<span>{posts.length} object{posts.length === 1 ? "" : "s"}</span>
-		<span class="ram-embed__status-hint">Tip: click an icon to open</span>
+		<span class="ram-embed__status-hint">Tip: single-click an icon to open</span>
 	</footer>
 </div>
 
-<script>
-	// Lightweight icon selection (single-click highlights, double-click navigates).
-	const root = document.querySelector(".ram-embed");
-	if (root) {
-		const icons = Array.from(root.querySelectorAll(".ram-embed__icon"));
-		let selected = null;
-
-		function setSelected(el) {
-			if (selected) selected.removeAttribute("data-selected");
-			selected = el;
-			if (selected) selected.setAttribute("data-selected", "true");
-		}
-
-		for (const icon of icons) {
-			icon.addEventListener("click", (e: MouseEvent) => {
-				// Allow normal navigation on modified clicks.
-				if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
-				e.preventDefault();
-				setSelected(icon);
-			});
-
-			icon.addEventListener("dblclick", () => {
-				window.location.href = icon.getAttribute("href") || "/ram/embed";
-			});
-		}
-
-		root.addEventListener("click", (e) => {
-			if (!(e.target instanceof Element)) return;
-			if (e.target.closest(".ram-embed__icon")) return;
-			setSelected(null);
-		});
-	}
-</script>
-
 <style>
 	:global(html, body) {
 		height: 100%;

+ 1 - 1
src/pages/ram/embed/[slug].astro

@@ -92,7 +92,7 @@ const { Content } = await render(entry);
 	}
 
 	.ram-embed-post__article {
-		/* Let embedded content (eg YouTube iframes) use full available width. */
+		/* Let embedded content (e.g. YouTube iframes) use full available width. */
 		padding: 0;
 		max-width: none;
 		margin: 0;

+ 14 - 0
src/pages/robots.txt.ts

@@ -0,0 +1,14 @@
+export const prerender = true;
+
+export function GET() {
+	return new Response([
+		"User-agent: *",
+		"Allow: /",
+		"Sitemap: https://simo.ng/sitemap.xml",
+		"",
+	].join("\n"), {
+		headers: {
+			"Content-Type": "text/plain; charset=utf-8",
+		},
+	});
+}

+ 32 - 0
src/pages/sitemap.xml.ts

@@ -0,0 +1,32 @@
+import { getCollection } from "astro:content";
+
+export const prerender = true;
+
+const site = "https://simo.ng";
+
+function urlEntry(path: string, date?: Date) {
+	const loc = new URL(path, site).toString();
+	const lastmod = date ? `\n\t\t<lastmod>${date.toISOString()}</lastmod>` : "";
+	return `\n\t<url>\n\t\t<loc>${loc}</loc>${lastmod}\n\t</url>`;
+}
+
+export async function GET() {
+	const posts = await getCollection("blogs");
+	const visiblePosts = posts.filter((post) => (import.meta.env.PROD ? !post.data.draft : true));
+
+	const urls = [
+		urlEntry("/"),
+		urlEntry("/ram/"),
+		urlEntry("/ram/embed/"),
+		...visiblePosts.flatMap((post) => [
+			urlEntry(`/ram/${post.id}/`, post.data.pubDate),
+			urlEntry(`/ram/embed/${post.id}/`, post.data.pubDate),
+		]),
+	];
+
+	return new Response(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.join("")}\n</urlset>\n`, {
+		headers: {
+			"Content-Type": "application/xml; charset=utf-8",
+		},
+	});
+}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
src/styles/98.css


+ 0 - 10
temp

@@ -1,10 +0,0 @@
-CODE="AQAlhlxxKq6CziG_CWA8TXjjOL9J8v-D8ijr-9o7jPSKWqjL169mJgntvGApUsmaGGTAilY1FeyeSFBzit_IIorMicFH6FDLL2bQlh-PSpnR5KF7fa-6fxvVV0IJrO5EuvlnLmjFzQeDY_5wN4zd2eHFvM1U1r0cj4d-0K8B4Rl5IJWTaUjlvKsLf_ykONtiupYhQW8buXgkKefUaKiH-l9z-Y5u_HFhZqfiqnsP-L-unA6Zl4rFOTYe7tCdSG2rmnQdMH4"
-REDIRECT_URI="http://127.0.0.1:4321/api/spotify/callback"
-CLIENT_SECRET="3ebc996495774e27a8fe279e89e1c2cd"
-CLIENT_ID="86b57478283e4fa08dec17a50a307a8c"
-curl -s -X POST "https://accounts.spotify.com/api/token" \
-  -H "Authorization: Basic $(printf "%s:%s" "$CLIENT_ID" "$CLIENT_SECRET" | base64 -w 0)" \
-  -H "Content-Type: application/x-www-form-urlencoded" \
-  --data-urlencode "grant_type=authorization_code" \
-  --data-urlencode "code=$CODE" \
-  --data-urlencode "redirect_uri=$REDIRECT_URI"

+ 0 - 13
temp.js

@@ -1,13 +0,0 @@
-const client_id = "86b57478283e4fa08dec17a50a307a8c";
-const redirect_uri = "http://127.0.0.1:4321/api/spotify/callback.json";
-const scopes = "user-read-currently-playing user-read-playback-state";
-
-const authUrl = new URL("https://accounts.spotify.com/authorize");
-authUrl.searchParams.append("client_id", client_id);
-authUrl.searchParams.append("response_type", "code");
-authUrl.searchParams.append("redirect_uri", redirect_uri);
-authUrl.searchParams.append("scope", scopes);
-
-// Redirect user to this URL
-console.log("Visit this URL to authorize:", authUrl.toString());
-

Vissa filer visades inte eftersom för många filer har ändrats