changeset 219:8c9bb0b0759e hg-web

[hg-web] Merge current Zenbu baseline
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 08:34:54 -0700
parents 921ca3086879 (current diff) 4c725fde6999 (diff)
children ce7f4400c2de
files
diffstat 76 files changed, 5183 insertions(+), 99 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.env.development	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,2 @@
+AWS_MRJUNEJUNE_ACCESS_KEY=FILL
+AWS_MRJUNEJUNE_SECRET_ACCESS_KEY=FILL
--- a/.hgignore	Sun Aug 02 08:34:28 2026 -0700
+++ b/.hgignore	Sun Aug 02 08:34:54 2026 -0700
@@ -40,3 +40,10 @@
 
 # OS related
 .DS_Store
+
+# Environment var.
+.env
+# Server config
+.config
+# DB
+mrjunejune.db
--- a/BUILD	Sun Aug 02 08:34:28 2026 -0700
+++ b/BUILD	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,7 @@
+exports_files([".env"])
+
+filegroup(
+  name = "env_file",
+  srcs = [".env"],
+  visibility = ["//visibility:public"],
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/assets/BUILD	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,5 @@
+filegroup(
+  name = "icons",
+  srcs = glob(["icons/*.png"]),
+  visibility = ["//visibility:public"],
+)
Binary file assets/cabinet.png has changed
Binary file assets/icons/binder.png has changed
Binary file assets/icons/bold.png has changed
Binary file assets/icons/book.png has changed
Binary file assets/icons/cabinet.png has changed
Binary file assets/icons/clipy.png has changed
Binary file assets/icons/h1.png has changed
Binary file assets/icons/h2.png has changed
Binary file assets/icons/h3.png has changed
Binary file assets/icons/link.png has changed
Binary file assets/icons/open-book.png has changed
--- a/benchmark/bun-http-framework-benchmark/results/bun/bun-web-standard.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/bun/bun-web-standard.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec    228408.02,228408.02
+Reqs/sec    228466.99,228466.99
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec    153303.58,153303.58
+Reqs/sec    152460.20,152460.20
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec    151110.26,151110.26
+Reqs/sec    156344.05,156344.05
--- a/benchmark/bun-http-framework-benchmark/results/bun/bun.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/bun/bun.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec    254858.08,254858.08
+Reqs/sec    258730.63,258730.63
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec    205117.00,205117.00
+Reqs/sec    208220.35,208220.35
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec    156658.25,156658.25
+Reqs/sec    158675.80,158675.80
--- a/benchmark/bun-http-framework-benchmark/results/bun/elysia.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/bun/elysia.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec    400960.78,400960.78
+Reqs/sec    394707.12,394707.12
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec    200374.15,200374.15
+Reqs/sec    196336.94,196336.94
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec    164129.36,164129.36
+Reqs/sec    161899.39,161899.39
--- a/benchmark/bun-http-framework-benchmark/results/bun/express.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/bun/express.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec     94358.98,94358.98
+Reqs/sec     89438.11,89438.11
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec     83982.98,83982.98
+Reqs/sec     80241.22,80241.22
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec     84024.83,84024.83
+Reqs/sec     83023.74,83023.74
--- a/benchmark/bun-http-framework-benchmark/results/bun/h3.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/bun/h3.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec     98530.52,98530.52
+Reqs/sec     96603.44,96603.44
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec     81602.75,81602.75
+Reqs/sec     79062.78,79062.78
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec      9829.93,9829.93
+Reqs/sec      9958.75,9958.75
--- a/benchmark/bun-http-framework-benchmark/results/bun/hono.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/bun/hono.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec    202727.16,202727.16
+Reqs/sec    197638.70,197638.70
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec    147760.89,147760.89
+Reqs/sec    146857.99,146857.99
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec    130184.98,130184.98
+Reqs/sec    129630.67,129630.67
--- a/benchmark/bun-http-framework-benchmark/results/bun/nbit.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/bun/nbit.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec    166352.26,166352.26
+Reqs/sec    161889.19,161889.19
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec    130437.58,130437.58
+Reqs/sec    128469.38,128469.38
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec    109477.61,109477.61
+Reqs/sec    107344.24,107344.24
--- a/benchmark/bun-http-framework-benchmark/results/bun/nhttp.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/bun/nhttp.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec    260256.20,260256.20
+Reqs/sec    251208.25,251208.25
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec    155733.48,155733.48
+Reqs/sec    146262.76,146262.76
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec    164285.02,164285.02
+Reqs/sec    150412.21,150412.21
--- a/benchmark/bun-http-framework-benchmark/results/bun/oak.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/bun/oak.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec     66974.27,66974.27
+Reqs/sec     69435.13,69435.13
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec     55255.71,55255.71
+Reqs/sec     55855.49,55855.49
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec     49373.25,49373.25
+Reqs/sec     48430.51,48430.51
--- a/benchmark/bun-http-framework-benchmark/results/bun/wobe.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/bun/wobe.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec    177452.13,177452.13
+Reqs/sec    164622.23,164622.23
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec    159185.52,159185.52
+Reqs/sec    140543.28,140543.28
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec    133618.73,133618.73
+Reqs/sec    125350.31,125350.31
--- a/benchmark/bun-http-framework-benchmark/results/c/seobeo.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/c/seobeo.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec     35694.89,35694.89
+Reqs/sec    762500.23,762500.23
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec     35792.17,35792.17
+Reqs/sec    664161.76,664161.76
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec     35844.77,35844.77
+Reqs/sec    680603.95,680603.95
--- a/benchmark/bun-http-framework-benchmark/results/node/express.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/node/express.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec     16803.98,16803.98
+Reqs/sec     16270.25,16270.25
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec     15956.22,15956.22
+Reqs/sec     15420.94,15420.94
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec     17518.46,17518.46
+Reqs/sec     17296.20,17296.20
--- a/benchmark/bun-http-framework-benchmark/results/node/fastify.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/node/fastify.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec    114069.22,114069.22
+Reqs/sec    108144.21,108144.21
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec    106418.14,106418.14
+Reqs/sec    102523.21,102523.21
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec     32150.27,32150.27
+Reqs/sec     31702.05,31702.05
--- a/benchmark/bun-http-framework-benchmark/results/node/h3.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/node/h3.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec    102794.32,102794.32
+Reqs/sec     98213.55,98213.55
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec     86779.18,86779.18
+Reqs/sec     83364.88,83364.88
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec     79135.52,79135.52
+Reqs/sec     77675.20,77675.20
--- a/benchmark/bun-http-framework-benchmark/results/node/hono.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/node/hono.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec    102406.51,102406.51
+Reqs/sec    101150.50,101150.50
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec     91815.44,91815.44
+Reqs/sec     89469.69,89469.69
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec     23631.62,23631.62
+Reqs/sec     22884.58,22884.58
--- a/benchmark/bun-http-framework-benchmark/results/node/hyper-express.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/node/hyper-express.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec    270288.86,270288.86
+Reqs/sec    261221.09,261221.09
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec    207667.76,207667.76
+Reqs/sec    202783.27,202783.27
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec    145951.54,145951.54
+Reqs/sec    139488.27,139488.27
--- a/benchmark/bun-http-framework-benchmark/results/node/koa.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/node/koa.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec     57452.05,57452.05
+Reqs/sec     52681.21,52681.21
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec     48624.37,48624.37
+Reqs/sec     46472.31,46472.31
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec     35546.79,35546.79
+Reqs/sec     34781.23,34781.23
--- a/benchmark/bun-http-framework-benchmark/results/node/ultimate-express.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/node/ultimate-express.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec    398161.89,398161.89
+Reqs/sec    390978.83,390978.83
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec    377012.33,377012.33
+Reqs/sec    371893.76,371893.76
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec    134859.38,134859.38
+Reqs/sec    131597.36,131597.36
--- a/benchmark/bun-http-framework-benchmark/results/node/uws.txt	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/node/uws.txt	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,6 @@
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/
-Reqs/sec    405159.40,405159.40
+Reqs/sec    395545.79,395545.79
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun
-Reqs/sec    376621.69,376621.69
+Reqs/sec    372478.96,372478.96
 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json
-Reqs/sec    216381.92,216381.92
+Reqs/sec    211797.72,211797.72
--- a/benchmark/bun-http-framework-benchmark/results/results.md	Sun Aug 02 08:34:28 2026 -0700
+++ b/benchmark/bun-http-framework-benchmark/results/results.md	Sun Aug 02 08:34:54 2026 -0700
@@ -1,22 +1,22 @@
 
 |  Framework       | Runtime | Average | Ping       | Query      | Body       |
 | ---------------- | ------- | ------- | ---------- | ---------- | ---------- |
-| uws | node | 332,721.003 | 405,159.4 | 376,621.69 | 216,381.92 |
-| ultimate-express | node | 303,344.533 | 398,161.89 | 377,012.33 | 134,859.38 |
-| elysia | bun | 255,154.763 | 400,960.78 | 200,374.15 | 164,129.36 |
-| hyper-express | node | 207,969.387 | 270,288.86 | 207,667.76 | 145,951.54 |
-| bun | bun | 205,544.443 | 254,858.08 | 205,117 | 156,658.25 |
-| nhttp | bun | 193,424.9 | 260,256.2 | 155,733.48 | 164,285.02 |
-| bun-web-standard | bun | 177,607.287 | 228,408.02 | 153,303.58 | 151,110.26 |
-| hono | bun | 160,224.343 | 202,727.16 | 147,760.89 | 130,184.98 |
-| wobe | bun | 156,752.127 | 177,452.13 | 159,185.52 | 133,618.73 |
-| nbit | bun | 135,422.483 | 166,352.26 | 130,437.58 | 109,477.61 |
-| h3 | node | 89,569.673 | 102,794.32 | 86,779.18 | 79,135.52 |
-| express | bun | 87,455.597 | 94,358.98 | 83,982.98 | 84,024.83 |
-| fastify | node | 84,212.543 | 114,069.22 | 106,418.14 | 32,150.27 |
-| hono | node | 72,617.857 | 102,406.51 | 91,815.44 | 23,631.62 |
-| h3 | bun | 63,321.067 | 98,530.52 | 81,602.75 | 9,829.93 |
-| oak | bun | 57,201.077 | 66,974.27 | 55,255.71 | 49,373.25 |
-| koa | node | 47,207.737 | 57,452.05 | 48,624.37 | 35,546.79 |
-| seobeo | c | 35,777.277 | 35,694.89 | 35,792.17 | 35,844.77 |
-| express | node | 16,759.553 | 16,803.98 | 15,956.22 | 17,518.46 |
\ No newline at end of file
+| seobeo | c | 702,421.98 | 762,500.23 | 664,161.76 | 680,603.95 |
+| uws | node | 326,607.49 | 395,545.79 | 372,478.96 | 211,797.72 |
+| ultimate-express | node | 298,156.65 | 390,978.83 | 371,893.76 | 131,597.36 |
+| elysia | bun | 250,981.15 | 394,707.12 | 196,336.94 | 161,899.39 |
+| bun | bun | 208,542.26 | 258,730.63 | 208,220.35 | 158,675.8 |
+| hyper-express | node | 201,164.21 | 261,221.09 | 202,783.27 | 139,488.27 |
+| nhttp | bun | 182,627.74 | 251,208.25 | 146,262.76 | 150,412.21 |
+| bun-web-standard | bun | 179,090.413 | 228,466.99 | 152,460.2 | 156,344.05 |
+| hono | bun | 158,042.453 | 197,638.7 | 146,857.99 | 129,630.67 |
+| wobe | bun | 143,505.273 | 164,622.23 | 140,543.28 | 125,350.31 |
+| nbit | bun | 132,567.603 | 161,889.19 | 128,469.38 | 107,344.24 |
+| h3 | node | 86,417.877 | 98,213.55 | 83,364.88 | 77,675.2 |
+| express | bun | 84,234.357 | 89,438.11 | 80,241.22 | 83,023.74 |
+| fastify | node | 80,789.823 | 108,144.21 | 102,523.21 | 31,702.05 |
+| hono | node | 71,168.257 | 101,150.5 | 89,469.69 | 22,884.58 |
+| h3 | bun | 61,874.99 | 96,603.44 | 79,062.78 | 9,958.75 |
+| oak | bun | 57,907.043 | 69,435.13 | 55,855.49 | 48,430.51 |
+| koa | node | 44,644.917 | 52,681.21 | 46,472.31 | 34,781.23 |
+| express | node | 16,329.13 | 16,270.25 | 15,420.94 | 17,296.2 |
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/.config.development	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,12 @@
+# MrJuneJune Server Configuration
+
+# Auth token for S3 upload API
+# Client must send this in Authorization header: Bearer <token>
+UPLOAD_AUTH_TOKEN=THIS
+
+# S3 Configuration
+S3_REGION=THIS
+S3_BUCKET=THIS
+
+# Presigned URL expiration (seconds)
+S3_URL_EXPIRES=SECONDS
--- a/mrjunejune/BUILD	Sun Aug 02 08:34:28 2026 -0700
+++ b/mrjunejune/BUILD	Sun Aug 02 08:34:54 2026 -0700
@@ -28,6 +28,22 @@
   dest = "src/public/highlight",
 )
 
+move_files_into_dir(
+  name = "rich_editor_js",
+  srcs = [
+    "//rich_editor:rich_editor",
+  ],
+  dest = "src/public/js",
+)
+
+move_files_into_dir(
+  name = "icons",
+  srcs = [
+    "//assets:icons",
+  ],
+  dest = "src/public/icons",
+)
+
 filegroup(
   name = "public_files",
   srcs = glob(["src/public/*"]),
@@ -42,7 +58,7 @@
     
 filegroup(
   name = "src_files",
-  srcs = glob(["src/**"]) + [":react_pages", ":shared_js_non_public", "shared_js_file"],
+  srcs = glob(["src/**"]) + [":react_pages", ":shared_js_non_public", ":shared_js_file", ":rich_editor_js", ":icons"],
   visibility = ["//mrjunejune/test:__pkg__"],
 )
 
@@ -51,10 +67,18 @@
   name = "mrjunejune_server",
   srcs = ["main.c"],
   deps = [
-    "//seobeo:seobeo_tcp_server_ws",
+    "//seobeo:seobeo",
     "//markdown_converter:markdown_to_html_c",
+    "//s3:s3",
+    "//deita:deita",
   ],
-  data = [":src_files"],
+  copts = ["-D_GNU_SOURCE"],
+  linkopts = ["-lpthread"],
+  data = [
+    ":src_files",
+    ":config_file",
+    "//:env_file",
+  ],
   visibility = ["//mrjunejune/test:__pkg__"],
 )
 
@@ -63,10 +87,30 @@
   name = "mrjunejune_server_debug",
   srcs = ["main.c"],
   deps = [
-    "//seobeo:seobeo_tcp_server_ws_debug", 
-    "//markdown_converter:markdown_to_html_c"
+    "//seobeo:seobeo_debug",
+    "//markdown_converter:markdown_to_html_c",
+    "//s3:s3",
+    "//deita:deita",
+  ],
+  copts = ["-D_GNU_SOURCE"],
+  linkopts = ["-lpthread"],
+  data = [
+    ":src_files",
+    ":config_file",
+    "//:env_file",
   ],
-  data = [":src_files"],
+)
+
+filegroup(
+  name = "config_file",
+  srcs = [".config"],
+  visibility = ["//visibility:public"],
+)
+
+filegroup(
+  name = "data_dir",
+  srcs = glob(["data/*"]),
+  visibility = ["//visibility:public"],
 )
 
 # Run this to create html files
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/PWA_SETUP.md	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,126 @@
+# PWA Setup Guide
+
+Your site is now configured as a Progressive Web App! 🎉
+
+## What's Been Added
+
+1. **manifest.json** - App configuration
+2. **sw.js** - Service worker for caching and offline support
+3. **pwa-register.js** - Service worker registration
+4. **offline.html** - Offline fallback page
+5. **Updated base_head.html** - Links to manifest and PWA script
+
+## Missing: App Icons
+
+You need to create PNG icons from your SVG. Run these commands:
+
+### Using ImageMagick (recommended):
+
+```bash
+cd src/public
+
+# 192x192 icon
+convert epi_all_colors.svg -resize 192x192 -background none icon-192.png
+
+# 512x512 icon
+convert epi_all_colors.svg -resize 512x512 -background none icon-512.png
+
+# Maskable icon (512x512 with safe zone)
+convert epi_all_colors.svg -resize 409x409 -background none -gravity center -extent 512x512 icon-maskable-512.png
+```
+
+### Or using Inkscape:
+
+```bash
+inkscape epi_all_colors.svg --export-type=png --export-filename=icon-192.png -w 192 -h 192
+inkscape epi_all_colors.svg --export-type=png --export-filename=icon-512.png -w 512 -h 512
+inkscape epi_all_colors.svg --export-type=png --export-filename=icon-maskable-512.png -w 512 -h 512
+```
+
+### Or use an online tool:
+- https://realfavicongenerator.net/
+- https://www.pwabuilder.com/
+
+## Optional: Screenshots (for better app install experience)
+
+Create screenshots of your site:
+
+```bash
+# Mobile screenshot (540x720)
+screenshot-mobile.png
+
+# Desktop screenshot (1280x720)
+screenshot-desktop.png
+```
+
+You can use browser DevTools to capture these, or remove the `screenshots` section from manifest.json if you don't want them.
+
+## Testing Your PWA
+
+1. **Serve over HTTPS** - PWAs require HTTPS (localhost works for testing)
+2. **Open Chrome DevTools** → Application tab → Manifest
+3. **Check Service Worker** → Application tab → Service Workers
+4. **Lighthouse Audit** → Run PWA audit to see score
+
+## Install Prompt
+
+When users visit your site, they'll see an "Install App" button in the bottom-right corner for 10 seconds. They can:
+- Click it to install immediately
+- Use browser menu: "Install MrJuneJune" or "Add to Home Screen"
+
+## Features
+
+✅ **Offline Support** - Caches pages, CSS, JS, fonts, images
+✅ **App Shortcuts** - Quick access to Blog and Notes
+✅ **Install Prompt** - Automatic install button
+✅ **Auto-updates** - Service worker updates on reload
+✅ **Fast Loading** - Cached resources load instantly
+
+## Customization
+
+Edit `manifest.json` to change:
+- `theme_color` - App theme color
+- `background_color` - Splash screen color
+- `display` - `standalone`, `fullscreen`, `minimal-ui`, or `browser`
+- `shortcuts` - App shortcut menu items
+
+Edit `sw.js` to change:
+- `CACHE_VERSION` - Increment to force cache refresh
+- `STATIC_CACHE` - Files to cache immediately
+- Caching strategy (currently: cache-first with network fallback)
+
+## Testing on Mobile
+
+### Android:
+1. Open Chrome
+2. Visit your site
+3. Tap menu → "Install app" or "Add to Home Screen"
+
+### iOS (Safari):
+1. Open Safari
+2. Tap Share button
+3. Tap "Add to Home Screen"
+
+Note: iOS has limited PWA support (no install prompt, limited background features)
+
+## Debugging
+
+Check console for:
+- `[PWA]` - Registration events
+- `[SW]` - Service worker caching events
+
+Clear cache:
+```js
+navigator.serviceWorker.controller.postMessage({ type: 'CLEAR_CACHE' });
+```
+
+## Next Steps
+
+1. Generate the PNG icons (see commands above)
+2. Test on HTTPS
+3. Run Lighthouse audit
+4. Deploy and test on mobile device
+5. Consider adding:
+   - Push notifications
+   - Background sync
+   - Share target API
--- a/mrjunejune/deploy.sh	Sun Aug 02 08:34:28 2026 -0700
+++ b/mrjunejune/deploy.sh	Sun Aug 02 08:34:54 2026 -0700
@@ -5,6 +5,9 @@
 sudo mkdir -p /opt/mrjunejune_server
 sudo cp -a bazel-bin/mrjunejune/mrjunejune_server_bundle /opt/mrjunejune_server_bundle_new
 sudo chown -R mrjunejune_server:zenbu_team /opt/mrjunejune_server_bundle_new
+# sqlite3 db
+sudo mkdir -p /opt/data/mrjunejune/
+sudo chown -R mrjunejune_server:zenbu_team /opt/data/mrjunejune/
 
 # Swap
 sudo rm -rf /opt/mrjunejune_server_bundle_active
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/generate-icons.sh	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,42 @@
+#!/bin/bash
+
+# Generate PWA icons from SVG
+# Requires ImageMagick: apt-get install imagemagick
+
+cd "$(dirname "$0")/src/public"
+
+if ! command -v convert &> /dev/null; then
+    echo "❌ ImageMagick not found. Please install it:"
+    echo "   Ubuntu/Debian: sudo apt-get install imagemagick"
+    echo "   macOS: brew install imagemagick"
+    echo ""
+    echo "Or use an online tool: https://realfavicongenerator.net/"
+    exit 1
+fi
+
+echo "🎨 Generating PWA icons from epi_all_colors.svg..."
+
+# 192x192 icon
+echo "  → Creating icon-192.png..."
+convert epi_all_colors.svg -resize 192x192 -background none icon-192.png
+
+# 512x512 icon
+echo "  → Creating icon-512.png..."
+convert epi_all_colors.svg -resize 512x512 -background none icon-512.png
+
+# Maskable icon (512x512 with safe zone - 80% of size)
+echo "  → Creating icon-maskable-512.png..."
+convert epi_all_colors.svg -resize 409x409 -background none -gravity center -extent 512x512 icon-maskable-512.png
+
+echo ""
+echo "✅ Icons generated successfully!"
+echo ""
+echo "Generated files:"
+echo "  - icon-192.png"
+echo "  - icon-512.png"
+echo "  - icon-maskable-512.png"
+echo ""
+echo "Next steps:"
+echo "  1. Verify icons look good: ls -lh src/public/icon-*.png"
+echo "  2. Test PWA: Open site in Chrome → DevTools → Application → Manifest"
+echo "  3. Run Lighthouse audit to check PWA score"
--- a/mrjunejune/main.c	Sun Aug 02 08:34:28 2026 -0700
+++ b/mrjunejune/main.c	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,11 @@
 #include "seobeo/seobeo.h"
 #include "markdown_converter/markdown_to_html.h"
+#include "s3/s3_uploader.h"
+#include "deita/deita.h"
 #include <time.h>
+#include <sys/stat.h>
+#include <stdarg.h>
+#include <pthread.h>
 
 // UUID + /tmp/ + format (max 4)
 #define TMP_FILE_LENGTH 47
@@ -9,6 +14,171 @@
 volatile sig_atomic_t stop_server = 0;
 static _Atomic uint32_t counter = 0;
 
+// Media Processing Context for background threads
+typedef struct {
+  int64    media_id;
+  char     s3_key_original[512];
+  char     s3_key_processed[512];
+  char     content_type[128];
+  char     access_token[256];
+  char     db_path[256];
+  S3_Config s3_config;
+} Media_Processing_Context;
+
+// Server configuration (loaded from .config)
+static char g_upload_auth_token[256] = {0};
+static char g_s3_region[64] = "us-west-2";
+static char g_s3_bucket[128] = "mrjunejune";
+static char g_s3_cloudfront_url[256] = {0};
+static char g_db_path[256] = "mrjunejune/data/mrjunejune.db";
+static int  g_s3_url_expires = 3600;
+static S3_Config g_s3_config = {0};
+static Deita_Connection *g_db_connection = NULL;
+
+static void load_config(const char *config_path)
+{
+  FILE *f = fopen(config_path, "r");
+  if (!f)
+  {
+    printf("[CONFIG] Warning: Could not open %s, using defaults\n", config_path);
+    return;
+  }
+
+  char line[512];
+  while (fgets(line, sizeof(line), f))
+  {
+    // Skip comments and empty lines
+    if (line[0] == '#' || line[0] == '\n' || line[0] == '\r') continue;
+
+    char *eq = strchr(line, '=');
+    if (!eq) continue;
+
+    *eq = '\0';
+    char *key = line;
+    char *value = eq + 1;
+
+    // Trim newline from value
+    size_t vlen = strlen(value);
+    while (vlen > 0 && (value[vlen-1] == '\n' || value[vlen-1] == '\r'))
+      value[--vlen] = '\0';
+
+    if (strcmp(key, "UPLOAD_AUTH_TOKEN") == 0)
+    {
+      strncpy(g_upload_auth_token, value, sizeof(g_upload_auth_token) - 1);
+    }
+    else if (strcmp(key, "S3_REGION") == 0)
+    {
+      strncpy(g_s3_region, value, sizeof(g_s3_region) - 1);
+    }
+    else if (strcmp(key, "S3_BUCKET") == 0)
+    {
+      strncpy(g_s3_bucket, value, sizeof(g_s3_bucket) - 1);
+    }
+    else if (strcmp(key, "S3_URL_EXPIRES") == 0)
+    {
+      g_s3_url_expires = atoi(value);
+    }
+    else if (strcmp(key, "S3_CLOUDFRONT_URL") == 0)
+    {
+      strncpy(g_s3_cloudfront_url, value, sizeof(g_s3_cloudfront_url) - 1);
+    }
+    else if (strcmp(key, "DB_PATH") == 0)
+    {
+      strncpy(g_db_path, value, sizeof(g_db_path) - 1);
+    }
+  }
+  fclose(f);
+
+  printf("[CONFIG] Loaded: token=%s..., region=%s, bucket=%s, expires=%d, cloudfront=%s, db=%s\n",
+         g_upload_auth_token[0] ? "***" : "(empty)",
+         g_s3_region, g_s3_bucket, g_s3_url_expires,
+         g_s3_cloudfront_url[0] ? g_s3_cloudfront_url : "(none)",
+         g_db_path);
+}
+
+static void init_database(void)
+{
+  // Create data directory if needed
+  char *last_slash = strrchr(g_db_path, '/');
+  if (last_slash)
+  {
+    char dir_path[256];
+    size_t dir_len = last_slash - g_db_path;
+    strncpy(dir_path, g_db_path, dir_len);
+    dir_path[dir_len] = '\0';
+    mkdir(dir_path, 0755);
+  }
+
+  g_db_connection = Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, g_db_path);
+  if (!g_db_connection || !Deita_Connection_Is_Open(g_db_connection))
+  {
+    printf("[DB] ERROR: Failed to open database at %s\n", g_db_path);
+    return;
+  }
+
+  // Create editor_content table
+  const char *create_table =
+    "CREATE TABLE IF NOT EXISTS editor_content ("
+    "  id INTEGER PRIMARY KEY AUTOINCREMENT,"
+    "  access_token TEXT NOT NULL,"
+    "  doc_id TEXT NOT NULL,"
+    "  content TEXT,"
+    "  created_at INTEGER DEFAULT (strftime('%s', 'now')),"
+    "  updated_at INTEGER DEFAULT (strftime('%s', 'now')),"
+    "  UNIQUE(access_token, doc_id)"
+    ")";
+
+  int32 result = Deita_Query_Execute_Update(g_db_connection, create_table);
+  if (result < 0)
+  {
+    printf("[DB] ERROR: Failed to create editor_content table\n");
+  }
+
+  // Create media_uploads table
+  const char *create_media_uploads =
+    "CREATE TABLE IF NOT EXISTS media_uploads ("
+    "  id INTEGER PRIMARY KEY AUTOINCREMENT,"
+    "  access_token TEXT NOT NULL,"
+    "  original_filename TEXT NOT NULL,"
+    "  content_type TEXT NOT NULL,"
+    "  s3_key_original TEXT NOT NULL,"
+    "  s3_key_processed TEXT,"
+    "  file_size INTEGER,"
+    "  status TEXT NOT NULL DEFAULT 'pending',"
+    "  error_message TEXT,"
+    "  created_at INTEGER DEFAULT (strftime('%s', 'now')),"
+    "  updated_at INTEGER DEFAULT (strftime('%s', 'now'))"
+    ")";
+
+  result = Deita_Query_Execute_Update(g_db_connection, create_media_uploads);
+  if (result < 0)
+  {
+    printf("[DB] ERROR: Failed to create media_uploads table\n");
+  }
+
+  // Create indices for media_uploads
+  const char *create_status_idx =
+    "CREATE INDEX IF NOT EXISTS idx_media_uploads_status ON media_uploads(status)";
+  result = Deita_Query_Execute_Update(g_db_connection, create_status_idx);
+  if (result < 0)
+  {
+    printf("[DB] ERROR: Failed to create status index\n");
+  }
+
+  const char *create_token_status_idx =
+    "CREATE INDEX IF NOT EXISTS idx_media_uploads_token_status "
+    "ON media_uploads(access_token, status)";
+  result = Deita_Query_Execute_Update(g_db_connection, create_token_status_idx);
+  if (result < 0)
+  {
+    printf("[DB] ERROR: Failed to create token_status index\n");
+  }
+  else
+  {
+    printf("[DB] Initialized: %s\n", g_db_path);
+  }
+}
+
 void handle_sigint(int sig)
 {
   printf("Failed\n");
@@ -34,6 +204,8 @@
     char *end_tag = strstr(start_tag, "}}");
     if (!end_tag) break;
 
+    Seobeo_Log(SEOBEO_INFO, "[Curr] Life\n");
+
     size_t leading_len = start_tag - cursor;
     memcpy(final_body + current_offset, cursor, leading_len);
     current_offset += leading_len;
@@ -45,7 +217,8 @@
 
     size_t sub_file_size = 0;
     char *sub_content = Seobeo_Web_LoadFile(include_name, &sub_file_size);
-    Seobeo_Log(SEOBEO_DEBUG, "[Curr] Sub content: %s\n", sub_content);
+    Seobeo_Log(SEOBEO_DEBUG, "[TEMPLATE] Loading include: '%s' -> %s (size=%zu)\n",
+              include_name, sub_content ? "OK" : "FAILED", sub_file_size);
     if (sub_content)
     {
       memcpy(final_body + current_offset, sub_content, sub_file_size);
@@ -58,15 +231,15 @@
   strcpy(final_body + current_offset, cursor);
 }
 
-
 void Seobeo_Render_Html_FilePath(
     char *final_body,
     char *path,
     Dowa_Arena *arena
 ) {
-  Seobeo_Log(SEOBEO_DEBUG, "[Curr] %s\n", path);
+  Seobeo_Log(SEOBEO_DEBUG, "[TEMPLATE] Loading main template: '%s'\n", path);
   size_t html_size = 0;
   char *template = Seobeo_Web_LoadFile(path, &html_size);
+  Seobeo_Log(SEOBEO_DEBUG, "[TEMPLATE] Main template loaded: %s (size=%zu)\n", template ? "OK" : "FAILED", html_size);
   if (!template) return;
   Seobeo_Render_Html(final_body, template, arena);
 }
@@ -123,7 +296,7 @@
 
   if (!req)
   {
-    printf("ERROR: Request is NULL\n");
+    Seobeo_Log(SEOBEO_ERROR, "Request is NULL\n");
     char *error_msg = "Internal error: no request data";
     Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
     Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
@@ -136,7 +309,7 @@
 
   for (size_t i = 0; i < req_length; i++)
   {
-    printf("  Key[%zu]: '%s'\n", i, req[i].key);
+    Seobeo_Log(SEOBEO_INFO, "  Key[%zu]: '%s'\n", i, req[i].key);
   }
 
   void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body");
@@ -496,15 +669,1143 @@
   return resp;
 }
 
+Seobeo_Request_Entry *GetNotesLogin(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+{
+  Seobeo_Request_Entry *resp = NULL;
+  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
+  Seobeo_Render_Html_FilePath(final_body, "/notes/login.html", arena);
+  Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
+  return resp;
+}
+
+Seobeo_Request_Entry *GetNotes(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+{
+  Seobeo_Request_Entry *resp = NULL;
+  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
+  Seobeo_Render_Html_FilePath(final_body, "/notes/index.html", arena);
+  Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
+  return resp;
+}
+
+Seobeo_Request_Entry *GetNoteById(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+{
+  Seobeo_Request_Entry *resp = NULL;
+  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
+  // Same template - JavaScript handles the note_id from URL
+  Seobeo_Render_Html_FilePath(final_body, "/notes/index.html", arena);
+  Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
+  return resp;
+}
+
 CREATE_REDIRECT_HANDLER(HomePage, "/")
 CREATE_REDIRECT_HANDLER(Resume, "/resume")
 CREATE_REDIRECT_HANDLER(Tools, "/tools")
 CREATE_REDIRECT_HANDLER(MarkDownToHtml, "/tools/markdown_to_html")
 CREATE_REDIRECT_HANDLER(FileConverter, "/tools/file_converter")
 CREATE_REDIRECT_HANDLER(Talk, "/talk")
+CREATE_REDIRECT_HANDLER(Editor, "/editor")
+
+// S3 Upload URL API
+// POST /api/s3/upload-url
+// Headers: Authorization: Bearer <token>, Content-Type: application/json
+// Body: {"filename": "photo.png", "content_type": "image/png"}
+// Returns: {"upload_url": "https://...", "key": "uploads/..."}
+Seobeo_Request_Entry *GetS3UploadUrl(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+{
+  Seobeo_Request_Entry *resp = NULL;
+
+  // Check auth token
+  void *auth_kv = Dowa_HashMap_Get_Ptr(req, "Authorization");
+  if (!auth_kv)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "401", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing Authorization header\"}", arena);
+    return resp;
+  }
+
+  const char *auth_header = ((Seobeo_Request_Entry*)auth_kv)->value;
+
+  // Expect "Bearer <token>"
+  if (strncmp(auth_header, "Bearer ", 7) != 0)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "401", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid Authorization format, use Bearer token\"}", arena);
+    return resp;
+  }
+
+  const char *token = auth_header + 7;
+  if (strlen(g_upload_auth_token) == 0 || strcmp(token, g_upload_auth_token) != 0)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "403", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid token\"}", arena);
+    return resp;
+  }
+
+  // Parse request body for filename and content_type
+  void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body");
+  if (!body_kv)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing request body\"}", arena);
+    return resp;
+  }
+
+  const char *body = ((Seobeo_Request_Entry*)body_kv)->value;
+
+  // Simple JSON parsing for filename and content_type
+  char filename[256] = {0};
+  char content_type[128] = "application/octet-stream";
+
+  // Find "filename":"value"
+  const char *fn_key = strstr(body, "\"filename\"");
+  if (fn_key)
+  {
+    const char *fn_start = strchr(fn_key + 10, '"');
+    if (fn_start)
+    {
+      fn_start++;
+      const char *fn_end = strchr(fn_start, '"');
+      if (fn_end && (size_t)(fn_end - fn_start) < sizeof(filename))
+      {
+        memcpy(filename, fn_start, fn_end - fn_start);
+        filename[fn_end - fn_start] = '\0';
+      }
+    }
+  }
+
+  // Find "content_type":"value"
+  const char *ct_key = strstr(body, "\"content_type\"");
+  if (ct_key)
+  {
+    const char *ct_start = strchr(ct_key + 14, '"');
+    if (ct_start)
+    {
+      ct_start++;
+      const char *ct_end = strchr(ct_start, '"');
+      if (ct_end && (size_t)(ct_end - ct_start) < sizeof(content_type))
+      {
+        memcpy(content_type, ct_start, ct_end - ct_start);
+        content_type[ct_end - ct_start] = '\0';
+      }
+    }
+  }
+
+  if (strlen(filename) == 0)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing filename in request body\"}", arena);
+    return resp;
+  }
+
+  // Generate unique S3 key with timestamp
+  char s3_key[512];
+  char *uuid = Dowa_Arena_Allocate(arena, UUID_LEN);
+  uint32 seed = (uint32)time(NULL) ^ (uint32)pthread_self() ^ counter++;
+  Dowa_String_UUID(seed, uuid);
+  snprintf(s3_key, sizeof(s3_key), "uploads/%s/%s", uuid, filename);
+
+  // Generate presigned URL
+  S3_Presigned_URL presigned = S3_Presign_Put(&g_s3_config, s3_key, content_type, g_s3_url_expires);
+
+  if (!presigned.success)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    char *error_body = Dowa_Arena_Allocate(arena, 256);
+    snprintf(error_body, 256, "{\"error\":\"Failed to generate upload URL: %s\"}",
+             presigned.error_message ? presigned.error_message : "unknown");
+    Dowa_HashMap_Push_Arena(resp, "body", error_body, arena);
+    S3_Presigned_URL_Destroy(&presigned);
+    return resp;
+  }
+
+  // Build public URL using CloudFront
+  char public_url[512];
+  if (g_s3_cloudfront_url[0])
+  {
+    snprintf(public_url, sizeof(public_url), "%s/%s", g_s3_cloudfront_url, s3_key);
+  }
+  else
+  {
+    snprintf(public_url, sizeof(public_url), "https://%s.s3.%s.amazonaws.com/%s",
+             g_s3_bucket, g_s3_region, s3_key);
+  }
+
+  // Build response
+  char *response_body = Dowa_Arena_Allocate(arena, 4096 + strlen(presigned.url));
+  snprintf(response_body, 4096 + strlen(presigned.url),
+           "{\"upload_url\":\"%s\",\"public_url\":\"%s\",\"key\":\"%s\",\"expires\":%d}",
+           presigned.url, public_url, s3_key, g_s3_url_expires);
+
+  S3_Presigned_URL_Destroy(&presigned);
+
+  Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
+  Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+  Dowa_HashMap_Push_Arena(resp, "body", response_body, arena);
+
+  printf("[S3] Generated upload URL for: %s\n", s3_key);
+
+  return resp;
+}
+
+// Editor Content Save API
+// POST /api/editor/save
+// Headers: Authorization: Bearer <token>
+// Body: {"doc_id": "my-doc", "content": "<html content>"}
+Seobeo_Request_Entry *EditorSave(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+{
+  Seobeo_Request_Entry *resp = NULL;
+
+  // Check auth token
+  void *auth_kv = Dowa_HashMap_Get_Ptr(req, "Authorization");
+  if (!auth_kv)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "401", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing Authorization header\"}", arena);
+    return resp;
+  }
+
+  const char *auth_header = ((Seobeo_Request_Entry*)auth_kv)->value;
+  if (strncmp(auth_header, "Bearer ", 7) != 0)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "401", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid Authorization format\"}", arena);
+    return resp;
+  }
+
+  const char *token = auth_header + 7;
+  if (strlen(g_upload_auth_token) == 0 || strcmp(token, g_upload_auth_token) != 0)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "403", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid token\"}", arena);
+    return resp;
+  }
+
+  if (!g_db_connection)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Database not available\"}", arena);
+    return resp;
+  }
+
+  // Parse request body
+  void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body");
+  if (!body_kv)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing request body\"}", arena);
+    return resp;
+  }
+
+  const char *body = ((Seobeo_Request_Entry*)body_kv)->value;
+
+  // Parse doc_id and content from JSON
+  char doc_id[256] = "default";
+  char *content = NULL;
+  size_t content_len = 0;
+
+  // Find "doc_id":"value"
+  const char *doc_key = strstr(body, "\"doc_id\"");
+  if (doc_key)
+  {
+    const char *doc_start = strchr(doc_key + 8, '"');
+    if (doc_start)
+    {
+      doc_start++;
+      const char *doc_end = strchr(doc_start, '"');
+      if (doc_end && (size_t)(doc_end - doc_start) < sizeof(doc_id))
+      {
+        memcpy(doc_id, doc_start, doc_end - doc_start);
+        doc_id[doc_end - doc_start] = '\0';
+      }
+    }
+  }
+
+  // Find "content":"value" - content can be large and contain escaped characters
+  const char *content_key = strstr(body, "\"content\"");
+  if (content_key)
+  {
+    const char *content_start = strchr(content_key + 9, '"');
+    if (content_start)
+    {
+      content_start++;
+      // Find closing quote (accounting for escaped quotes)
+      const char *p = content_start;
+      while (*p)
+      {
+        if (*p == '\\' && *(p+1))
+        {
+          p += 2;
+          continue;
+        }
+        if (*p == '"') break;
+        p++;
+      }
+      content_len = p - content_start;
+      content = Dowa_Arena_Allocate(arena, content_len + 1);
+      memcpy(content, content_start, content_len);
+      content[content_len] = '\0';
+    }
+  }
+
+  if (!content)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing content\"}", arena);
+    return resp;
+  }
+
+  // Upsert content
+  const char *upsert_query =
+    "INSERT INTO editor_content (access_token, doc_id, content, updated_at) "
+    "VALUES (?, ?, ?, strftime('%s', 'now')) "
+    "ON CONFLICT(access_token, doc_id) DO UPDATE SET "
+    "content = excluded.content, updated_at = strftime('%s', 'now')";
+
+  const char *params[] = { token, doc_id, content };
+  int32 result = Deita_Query_Execute_Update_Prepared(g_db_connection, upsert_query, 3, params);
+
+  if (result < 0)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Failed to save\"}", arena);
+    return resp;
+  }
+
+  printf("[EDITOR] Saved doc_id=%s, content_len=%zu\n", doc_id, content_len);
+
+  Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
+  Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+  Dowa_HashMap_Push_Arena(resp, "body", "{\"success\":true}", arena);
+  return resp;
+}
+
+// Editor Content Load API
+// GET /api/editor/load/:doc_id
+// Headers: Authorization: Bearer <token>
+Seobeo_Request_Entry *EditorLoad(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+{
+  Seobeo_Request_Entry *resp = NULL;
+
+  // Check auth token
+  void *auth_kv = Dowa_HashMap_Get_Ptr(req, "Authorization");
+  if (!auth_kv)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "401", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing Authorization header\"}", arena);
+    return resp;
+  }
+
+  const char *auth_header = ((Seobeo_Request_Entry*)auth_kv)->value;
+  if (strncmp(auth_header, "Bearer ", 7) != 0)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "401", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid Authorization format\"}", arena);
+    return resp;
+  }
+
+  const char *token = auth_header + 7;
+  if (strlen(g_upload_auth_token) == 0 || strcmp(token, g_upload_auth_token) != 0)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "403", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid token\"}", arena);
+    return resp;
+  }
+
+  if (!g_db_connection)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Database not available\"}", arena);
+    return resp;
+  }
+
+  // Get doc_id from URL parameter
+  void *doc_id_kv = Dowa_HashMap_Get_Ptr(req, ":doc_id");
+  const char *doc_id = "default";
+  if (doc_id_kv)
+  {
+    doc_id = ((Seobeo_Request_Entry*)doc_id_kv)->value;
+  }
+
+  // Query content
+  const char *select_query =
+    "SELECT content, updated_at FROM editor_content WHERE access_token = ? AND doc_id = ?";
+  const char *params[] = { token, doc_id };
+
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(g_db_connection, select_query, 2, params, arena);
+
+  if (p_result && Deita_Result_Set_Next(p_result))
+  {
+    const char *content = Deita_Result_Set_Get_Text(p_result, 0);
+    int64 updated_at = Deita_Result_Set_Get_Integer(p_result, 1);
+
+    // Build JSON response - escape content
+    size_t content_len = content ? strlen(content) : 0;
+    char *response_body = Dowa_Arena_Allocate(arena, content_len + 256);
+    snprintf(response_body, content_len + 256,
+             "{\"doc_id\":\"%s\",\"content\":\"%s\",\"updated_at\":%lld}",
+             doc_id, content ? content : "", (long long)updated_at);
+
+    Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", response_body, arena);
+
+    printf("[EDITOR] Loaded doc_id=%s\n", doc_id);
+  }
+  else
+  {
+    // No content found, return empty
+    char *response_body = Dowa_Arena_Allocate(arena, 128);
+    snprintf(response_body, 128, "{\"doc_id\":\"%s\",\"content\":\"\",\"updated_at\":0}", doc_id);
+
+    Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", response_body, arena);
+  }
+
+  if (p_result) Deita_Result_Set_Free(p_result);
+  return resp;
+}
+
+// Media Upload API - Create media record
+// POST /api/media/create
+// Headers: Authorization: Bearer <token>, Content-Type: application/json
+// Body: {"filename": "photo.jpg", "content_type": "image/jpeg"}
+// Returns: {"media_id": 123, "upload_url": "https://...", "expires": 3600}
+Seobeo_Request_Entry *MediaCreate(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+{
+  Seobeo_Request_Entry *resp = NULL;
+
+  // Check auth token
+  void *auth_kv = Dowa_HashMap_Get_Ptr(req, "Authorization");
+  if (!auth_kv)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "401", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing Authorization header\"}", arena);
+    return resp;
+  }
+
+  const char *auth_header = ((Seobeo_Request_Entry*)auth_kv)->value;
+  if (strncmp(auth_header, "Bearer ", 7) != 0)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "401", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid Authorization format\"}", arena);
+    return resp;
+  }
+
+  const char *token = auth_header + 7;
+  if (strlen(g_upload_auth_token) == 0 || strcmp(token, g_upload_auth_token) != 0)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "403", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid token\"}", arena);
+    return resp;
+  }
+
+  if (!g_db_connection)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Database not available\"}", arena);
+    return resp;
+  }
+
+  // Parse request body
+  void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body");
+  if (!body_kv)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing request body\"}", arena);
+    return resp;
+  }
+
+  const char *body = ((Seobeo_Request_Entry*)body_kv)->value;
+
+  // Parse filename and content_type
+  char filename[256] = {0};
+  char content_type[128] = "application/octet-stream";
+
+  // Find "filename":"value"
+  const char *fn_key = strstr(body, "\"filename\"");
+  if (fn_key)
+  {
+    const char *fn_start = strchr(fn_key + 10, '"');
+    if (fn_start)
+    {
+      fn_start++;
+      const char *fn_end = strchr(fn_start, '"');
+      if (fn_end && (size_t)(fn_end - fn_start) < sizeof(filename))
+      {
+        memcpy(filename, fn_start, fn_end - fn_start);
+        filename[fn_end - fn_start] = '\0';
+      }
+    }
+  }
+
+  // Find "content_type":"value"
+  const char *ct_key = strstr(body, "\"content_type\"");
+  if (ct_key)
+  {
+    const char *ct_start = strchr(ct_key + 14, '"');
+    if (ct_start)
+    {
+      ct_start++;
+      const char *ct_end = strchr(ct_start, '"');
+      if (ct_end && (size_t)(ct_end - ct_start) < sizeof(content_type))
+      {
+        memcpy(content_type, ct_start, ct_end - ct_start);
+        content_type[ct_end - ct_start] = '\0';
+      }
+    }
+  }
+
+  if (strlen(filename) == 0)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing filename\"}", arena);
+    return resp;
+  }
+
+  // Generate UUID for this upload
+  char *uuid = Dowa_Arena_Allocate(arena, UUID_LEN);
+  uint32 seed = (uint32)time(NULL) ^ (uint32)pthread_self() ^ counter++;
+  Dowa_String_UUID(seed, uuid);
+
+  // Generate S3 keys
+  char s3_key_original[512];
+  char s3_key_processed[512];
+  snprintf(s3_key_original, sizeof(s3_key_original), "uploads/%s/%s", uuid, filename);
+
+  // Only use .webp for images
+  int is_image = (strncmp(content_type, "image/", 6) == 0);
+  if (is_image)
+  {
+    snprintf(s3_key_processed, sizeof(s3_key_processed), "uploads/%s/processed.webp", uuid);
+  }
+  else
+  {
+    s3_key_processed[0] = '\0'; // No processed version for non-images
+  }
+
+  // Insert into database
+  const char *insert_query =
+    "INSERT INTO media_uploads (access_token, original_filename, content_type, s3_key_original, s3_key_processed, status) "
+    "VALUES (?, ?, ?, ?, ?, 'pending')";
+
+  const char *params[] = { token, filename, content_type, s3_key_original, s3_key_processed };
+  int32 result = Deita_Query_Execute_Update_Prepared(g_db_connection, insert_query, 5, params);
+
+  if (result < 0)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Failed to create media record\"}", arena);
+    return resp;
+  }
+
+  // Get the inserted media_id using last_insert_rowid()
+  const char *last_id_query = "SELECT last_insert_rowid()";
+  Deita_Result_Set *id_result = Deita_Query_Execute(g_db_connection, last_id_query, arena);
+  int64 media_id = 0;
+  if (id_result && Deita_Result_Set_Next(id_result))
+  {
+    media_id = Deita_Result_Set_Get_Integer(id_result, 0);
+  }
+  if (id_result) Deita_Result_Set_Free(id_result);
+
+  // Generate presigned PUT URL
+  S3_Presigned_URL presigned = S3_Presign_Put(&g_s3_config, s3_key_original, content_type, g_s3_url_expires);
+
+  if (!presigned.success)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    char *error_body = Dowa_Arena_Allocate(arena, 256);
+    snprintf(error_body, 256, "{\"error\":\"Failed to generate upload URL: %s\"}",
+             presigned.error_message ? presigned.error_message : "unknown");
+    Dowa_HashMap_Push_Arena(resp, "body", error_body, arena);
+    S3_Presigned_URL_Destroy(&presigned);
+    return resp;
+  }
+
+  // Build public URL using CloudFront or S3
+  char public_url[512];
+  if (g_s3_cloudfront_url[0])
+  {
+    snprintf(public_url, sizeof(public_url), "%s/%s", g_s3_cloudfront_url, s3_key_original);
+  }
+  else
+  {
+    snprintf(public_url, sizeof(public_url), "https://%s.s3.%s.amazonaws.com/%s",
+             g_s3_bucket, g_s3_region, s3_key_original);
+  }
+
+  // Build response
+  char *response_body = Dowa_Arena_Allocate(arena, 4096 + strlen(presigned.url) + strlen(public_url));
+  snprintf(response_body, 4096 + strlen(presigned.url) + strlen(public_url),
+           "{\"media_id\":%lld,\"upload_url\":\"%s\",\"public_url\":\"%s\",\"expires\":%d}",
+           (long long)media_id, presigned.url, public_url, g_s3_url_expires);
+
+  S3_Presigned_URL_Destroy(&presigned);
+
+  Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
+  Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+  Dowa_HashMap_Push_Arena(resp, "body", response_body, arena);
+
+  printf("[MEDIA] Created media_id=%lld, file=%s\n", (long long)media_id, filename);
+
+  return resp;
+}
+
+// Background thread function for media processing
+void *Media_Process_Background(void *arg)
+{
+  Media_Processing_Context *ctx = (Media_Processing_Context *)arg;
+
+  Seobeo_Log(SEOBEO_INFO, "[MEDIA] Background thread started for media_id=%lld\n", (long long)ctx->media_id);
+  Seobeo_Log(SEOBEO_INFO, "[MEDIA] S3 key original: %s\n", ctx->s3_key_original);
+  Seobeo_Log(SEOBEO_INFO, "[MEDIA] S3 key processed: %s\n", ctx->s3_key_processed);
+  Seobeo_Log(SEOBEO_INFO, "[MEDIA] DB path: %s\n", ctx->db_path);
+
+  // Open thread-local DB connection
+  Deita_Connection *db_conn = Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, ctx->db_path);
+  if (!db_conn || !Deita_Connection_Is_Open(db_conn))
+  {
+    Seobeo_Log(SEOBEO_ERROR, "[MEDIA] Thread ERROR: Failed to open database for media_id=%lld\n", (long long)ctx->media_id);
+    free(ctx);
+    return NULL;
+  }
+
+  // Update status to 'processing'
+  const char *update_processing =
+    "UPDATE media_uploads SET status='processing', updated_at=strftime('%s','now') WHERE id=?";
+  char media_id_str[32];
+  snprintf(media_id_str, sizeof(media_id_str), "%lld", (long long)ctx->media_id);
+  const char *params[] = { media_id_str };
+  int32 update_result = Deita_Query_Execute_Update_Prepared(db_conn, update_processing, 1, params);
+  Seobeo_Log(SEOBEO_INFO, "[MEDIA] Updated status to 'processing' for media_id=%lld (result=%d)\n", (long long)ctx->media_id, update_result);
+
+  // Generate presigned GET URL for download (10 min expiry)
+  Seobeo_Log(SEOBEO_INFO, "[MEDIA] Generating presigned GET URL for media_id=%lld\n", (long long)ctx->media_id);
+  S3_Presigned_URL download_url = S3_Presign_Get(&ctx->s3_config, ctx->s3_key_original, 600);
+  if (!download_url.success)
+  {
+    const char *error_msg = download_url.error_message ? download_url.error_message : "Failed to generate download URL";
+    Seobeo_Log(SEOBEO_ERROR, "[MEDIA] ERROR: Failed to generate download URL for media_id=%lld: %s\n",
+               (long long)ctx->media_id, error_msg);
+    const char *update_error =
+      "UPDATE media_uploads SET status='error', error_message=?, updated_at=strftime('%s','now') WHERE id=?";
+    const char *error_params[] = { error_msg, media_id_str };
+    Deita_Query_Execute_Update_Prepared(db_conn, update_error, 2, error_params);
+    S3_Presigned_URL_Destroy(&download_url);
+    Deita_Connection_Close(db_conn);
+    free(ctx);
+    return NULL;
+  }
+  Seobeo_Log(SEOBEO_INFO, "[MEDIA] Generated presigned URL: %.100s...\n", download_url.url);
+
+  // Generate temp file paths
+  char tmp_input[256];
+  char tmp_output[256];
+  char *uuid_input = malloc(UUID_LEN);
+  char *uuid_output = malloc(UUID_LEN);
+  uint32 seed1 = (uint32)time(NULL) ^ (uint32)pthread_self() ^ counter++;
+  uint32 seed2 = (uint32)time(NULL) ^ (uint32)pthread_self() ^ counter++;
+  Dowa_String_UUID(seed1, uuid_input);
+  Dowa_String_UUID(seed2, uuid_output);
+  snprintf(tmp_input, sizeof(tmp_input), "/tmp/%s", uuid_input);
+  snprintf(tmp_output, sizeof(tmp_output), "/tmp/%s.webp", uuid_output);
+  free(uuid_input);
+  free(uuid_output);
+
+  // Download from S3
+  Seobeo_Log(SEOBEO_INFO, "[MEDIA] Downloading from S3 to %s for media_id=%lld\n", tmp_input, (long long)ctx->media_id);
+  Seobeo_Client_Request *download_req = Seobeo_Client_Request_Create(download_url.url);
+  Seobeo_Client_Request_Set_Download_Path(download_req, tmp_input);
+  Seobeo_Client_Response *download_resp = Seobeo_Client_Request_Execute(download_req);
+
+  S3_Presigned_URL_Destroy(&download_url);
+
+  if (!download_resp || download_resp->status_code != 200)
+  {
+    int status = download_resp ? download_resp->status_code : 0;
+    Seobeo_Log(SEOBEO_ERROR, "[MEDIA] ERROR: Failed to download from S3 for media_id=%lld (status=%d)\n",
+               (long long)ctx->media_id, status);
+    const char *update_error =
+      "UPDATE media_uploads SET status='error', error_message=?, updated_at=strftime('%s','now') WHERE id=?";
+    const char *error_params[] = { "Failed to download from S3", media_id_str };
+    Deita_Query_Execute_Update_Prepared(db_conn, update_error, 2, error_params);
+    if (download_req) Seobeo_Client_Request_Destroy(download_req);
+    if (download_resp) Seobeo_Client_Response_Destroy(download_resp);
+    unlink(tmp_input);
+    Deita_Connection_Close(db_conn);
+    free(ctx);
+    return NULL;
+  }
+
+  Seobeo_Log(SEOBEO_INFO, "[MEDIA] Successfully downloaded file to %s\n", tmp_input);
+  Seobeo_Client_Request_Destroy(download_req);
+  Seobeo_Client_Response_Destroy(download_resp);
+
+  // Convert to webp using FFmpeg
+  char cmd[1024];
+  char log_file[256];
+  snprintf(log_file, sizeof(log_file), "/tmp/ffmpeg_%lld.log", (long long)ctx->media_id);
+  snprintf(cmd, sizeof(cmd), "ffmpeg -y -i %s -quality 80 %s 2>%s",
+           tmp_input, tmp_output, log_file);
+
+  Seobeo_Log(SEOBEO_INFO, "[MEDIA] Running FFmpeg: %s\n", cmd);
+  int ffmpeg_result = system(cmd);
+  Seobeo_Log(SEOBEO_INFO, "[MEDIA] FFmpeg result: %d for media_id=%lld\n", ffmpeg_result, (long long)ctx->media_id);
+
+  if (ffmpeg_result != 0)
+  {
+    Seobeo_Log(SEOBEO_ERROR, "[MEDIA] ERROR: FFmpeg conversion failed for media_id=%lld (exit code %d). Check log: %s\n",
+               (long long)ctx->media_id, ffmpeg_result, log_file);
+    const char *update_error =
+      "UPDATE media_uploads SET status='error', error_message=?, updated_at=strftime('%s','now') WHERE id=?";
+    const char *error_params[] = { "Image conversion failed", media_id_str };
+    Deita_Query_Execute_Update_Prepared(db_conn, update_error, 2, error_params);
+    unlink(tmp_input);
+    unlink(tmp_output);
+    Deita_Connection_Close(db_conn);
+    free(ctx);
+    return NULL;
+  }
+  Seobeo_Log(SEOBEO_INFO, "[MEDIA] Successfully converted to webp: %s\n", tmp_output);
+
+  // Upload processed file to S3
+  Seobeo_Log(SEOBEO_INFO, "[MEDIA] Uploading processed file to S3: %s -> %s\n", tmp_output, ctx->s3_key_processed);
+  S3_Result upload_result = S3_Upload_File_With_Content_Type(
+    &ctx->s3_config, tmp_output, ctx->s3_key_processed, "image/webp");
+
+  if (!upload_result.success)
+  {
+    const char *error_msg = upload_result.error_message ? upload_result.error_message : "Failed to upload processed file";
+    Seobeo_Log(SEOBEO_ERROR, "[MEDIA] ERROR: Failed to upload processed file for media_id=%lld: %s (HTTP status: %d)\n",
+               (long long)ctx->media_id, error_msg, upload_result.status_code);
+    const char *update_error =
+      "UPDATE media_uploads SET status='error', error_message=?, updated_at=strftime('%s','now') WHERE id=?";
+    const char *error_params[] = { error_msg, media_id_str };
+    Deita_Query_Execute_Update_Prepared(db_conn, update_error, 2, error_params);
+    unlink(tmp_input);
+    unlink(tmp_output);
+    Deita_Connection_Close(db_conn);
+    free(ctx);
+    return NULL;
+  }
+
+  Seobeo_Log(SEOBEO_INFO, "[MEDIA] Successfully uploaded processed file to S3\n");
+
+  // Update status to 'finished'
+  const char *update_finished =
+    "UPDATE media_uploads SET status='finished', updated_at=strftime('%s','now') WHERE id=?";
+  Deita_Query_Execute_Update_Prepared(db_conn, update_finished, 1, params);
+
+  Seobeo_Log(SEOBEO_INFO, "[MEDIA] Successfully processed media_id=%lld - COMPLETE\n", (long long)ctx->media_id);
+
+  // Cleanup
+  unlink(tmp_input);
+  unlink(tmp_output);
+  Deita_Connection_Close(db_conn);
+  free(ctx);
+
+  return NULL;
+}
+
+// Media Upload API - Mark uploaded
+// POST /api/media/:id/uploaded
+// Headers: Authorization: Bearer <token>
+Seobeo_Request_Entry *MediaUploaded(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+{
+  Seobeo_Request_Entry *resp = NULL;
+
+  // Check auth token
+  void *auth_kv = Dowa_HashMap_Get_Ptr(req, "Authorization");
+  if (!auth_kv)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "401", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing Authorization header\"}", arena);
+    return resp;
+  }
+
+  const char *auth_header = ((Seobeo_Request_Entry*)auth_kv)->value;
+  if (strncmp(auth_header, "Bearer ", 7) != 0)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "401", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid Authorization format\"}", arena);
+    return resp;
+  }
+
+  const char *token = auth_header + 7;
+  if (strlen(g_upload_auth_token) == 0 || strcmp(token, g_upload_auth_token) != 0)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "403", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid token\"}", arena);
+    return resp;
+  }
+
+  if (!g_db_connection)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Database not available\"}", arena);
+    return resp;
+  }
+
+  // Extract media_id from URL params
+  void *id_kv = Dowa_HashMap_Get_Ptr(req, ":id");
+  if (!id_kv)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing media ID\"}", arena);
+    return resp;
+  }
+
+  const char *media_id_str = ((Seobeo_Request_Entry*)id_kv)->value;
+  int64 media_id = atoll(media_id_str);
+
+  // Verify access_token matches and get content_type
+  const char *select_query =
+    "SELECT content_type, s3_key_original, s3_key_processed FROM media_uploads WHERE id = ? AND access_token = ?";
+  const char *select_params[] = { media_id_str, token };
+
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(g_db_connection, select_query, 2, select_params, arena);
+
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    if (p_result) Deita_Result_Set_Free(p_result);
+    Dowa_HashMap_Push_Arena(resp, "status", "404", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Media not found or access denied\"}", arena);
+    return resp;
+  }
+
+  const char *content_type = Deita_Result_Set_Get_Text(p_result, 0);
+  const char *s3_key_original = Deita_Result_Set_Get_Text(p_result, 1);
+  const char *s3_key_processed = Deita_Result_Set_Get_Text(p_result, 2);
+
+  // Copy values before freeing result set
+  char content_type_copy[128];
+  char s3_key_original_copy[512];
+  char s3_key_processed_copy[512];
+  strncpy(content_type_copy, content_type, sizeof(content_type_copy) - 1);
+  strncpy(s3_key_original_copy, s3_key_original, sizeof(s3_key_original_copy) - 1);
+  strncpy(s3_key_processed_copy, s3_key_processed, sizeof(s3_key_processed_copy) - 1);
+  content_type_copy[sizeof(content_type_copy) - 1] = '\0';
+  s3_key_original_copy[sizeof(s3_key_original_copy) - 1] = '\0';
+  s3_key_processed_copy[sizeof(s3_key_processed_copy) - 1] = '\0';
+
+  Deita_Result_Set_Free(p_result);
+
+  // Update status to 'uploaded'
+  const char *update_query =
+    "UPDATE media_uploads SET status='uploaded', updated_at=strftime('%s','now') WHERE id=?";
+  const char *update_params[] = { media_id_str };
+  int32 result = Deita_Query_Execute_Update_Prepared(g_db_connection, update_query, 1, update_params);
+
+  if (result < 0)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Failed to update status\"}", arena);
+    return resp;
+  }
+
+  Seobeo_Log(SEOBEO_INFO, "[MEDIA] Content type for media_id=%lld: '%s'\n", (long long)media_id, content_type_copy);
+
+  // If content_type starts with "image/", spawn background processing thread
+  if (strncmp(content_type_copy, "image/", 6) == 0)
+  {
+    Seobeo_Log(SEOBEO_INFO, "[MEDIA] Detected image type, preparing to spawn background thread for media_id=%lld\n", (long long)media_id);
+
+    // Create context for background thread (heap allocated)
+    Media_Processing_Context *ctx = malloc(sizeof(Media_Processing_Context));
+    ctx->media_id = media_id;
+    strncpy(ctx->s3_key_original, s3_key_original_copy, sizeof(ctx->s3_key_original) - 1);
+    strncpy(ctx->s3_key_processed, s3_key_processed_copy, sizeof(ctx->s3_key_processed) - 1);
+    strncpy(ctx->content_type, content_type_copy, sizeof(ctx->content_type) - 1);
+    strncpy(ctx->access_token, token, sizeof(ctx->access_token) - 1);
+    strncpy(ctx->db_path, g_db_path, sizeof(ctx->db_path) - 1);
+    ctx->s3_key_original[sizeof(ctx->s3_key_original) - 1] = '\0';
+    ctx->s3_key_processed[sizeof(ctx->s3_key_processed) - 1] = '\0';
+    ctx->content_type[sizeof(ctx->content_type) - 1] = '\0';
+    ctx->access_token[sizeof(ctx->access_token) - 1] = '\0';
+    ctx->db_path[sizeof(ctx->db_path) - 1] = '\0';
+    ctx->s3_config = g_s3_config;
+
+    Seobeo_Log(SEOBEO_INFO, "[MEDIA] Creating pthread for media_id=%lld\n", (long long)media_id);
+
+    // Spawn detached thread
+    pthread_t thread_id;
+    int thread_result = pthread_create(&thread_id, NULL, Media_Process_Background, ctx);
+
+    if (thread_result != 0)
+    {
+      Seobeo_Log(SEOBEO_ERROR, "[MEDIA] ERROR: pthread_create failed with result=%d for media_id=%lld\n", thread_result, (long long)media_id);
+      free(ctx);
+    }
+    else
+    {
+      // Detach thread so it cleans up automatically when done
+      pthread_detach(thread_id);
+      Seobeo_Log(SEOBEO_INFO, "[MEDIA] Successfully spawned and detached thread for media_id=%lld\n", (long long)media_id);
+    }
+  }
+  else
+  {
+    Seobeo_Log(SEOBEO_INFO, "[MEDIA] Non-image file, skipping background processing for media_id=%lld\n", (long long)media_id);
+  }
+
+  Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
+  Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+  Dowa_HashMap_Push_Arena(resp, "body", "{\"success\":true,\"status\":\"uploaded\"}", arena);
+
+  Seobeo_Log(SEOBEO_INFO, "[MEDIA] Marked uploaded media_id=%lld\n", (long long)media_id);
+
+  return resp;
+}
+
+// Media Upload API - Get status
+// GET /api/media/:id/status
+// Headers: Authorization: Bearer <token>
+// Returns: {"id": 123, "status": "finished", "processed_url": "https://...", "error_message": null}
+Seobeo_Request_Entry *MediaStatus(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+{
+  Seobeo_Request_Entry *resp = NULL;
+
+  // Check auth token
+  void *auth_kv = Dowa_HashMap_Get_Ptr(req, "Authorization");
+  if (!auth_kv)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "401", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing Authorization header\"}", arena);
+    return resp;
+  }
+
+  const char *auth_header = ((Seobeo_Request_Entry*)auth_kv)->value;
+  if (strncmp(auth_header, "Bearer ", 7) != 0)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "401", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid Authorization format\"}", arena);
+    return resp;
+  }
+
+  const char *token = auth_header + 7;
+  if (strlen(g_upload_auth_token) == 0 || strcmp(token, g_upload_auth_token) != 0)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "403", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid token\"}", arena);
+    return resp;
+  }
+
+  if (!g_db_connection)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "500", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Database not available\"}", arena);
+    return resp;
+  }
+
+  // Extract media_id from URL params
+  void *id_kv = Dowa_HashMap_Get_Ptr(req, ":id");
+  if (!id_kv)
+  {
+    Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing media ID\"}", arena);
+    return resp;
+  }
+
+  const char *media_id_str = ((Seobeo_Request_Entry*)id_kv)->value;
+
+  // Query media status
+  const char *select_query =
+    "SELECT id, status, s3_key_original, s3_key_processed, error_message FROM media_uploads WHERE id = ? AND access_token = ?";
+  const char *select_params[] = { media_id_str, token };
+
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(g_db_connection, select_query, 2, select_params, arena);
+
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    if (p_result) Deita_Result_Set_Free(p_result);
+    Dowa_HashMap_Push_Arena(resp, "status", "404", arena);
+    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Media not found\"}", arena);
+    return resp;
+  }
+
+  int64 id = Deita_Result_Set_Get_Integer(p_result, 0);
+  const char *status = Deita_Result_Set_Get_Text(p_result, 1);
+  const char *s3_key_original = Deita_Result_Set_Get_Text(p_result, 2);
+  const char *s3_key_processed = Deita_Result_Set_Get_Text(p_result, 3);
+  const char *error_message = Deita_Result_Set_Get_Text(p_result, 4);
+
+  // Build CloudFront URL for processed file if status is 'finished'
+  char processed_url[1024] = {0};
+  if (strcmp(status, "finished") == 0 && s3_key_processed && strlen(s3_key_processed) > 0)
+  {
+    if (g_s3_cloudfront_url[0])
+    {
+      snprintf(processed_url, sizeof(processed_url), "%s/%s", g_s3_cloudfront_url, s3_key_processed);
+    }
+    else
+    {
+      snprintf(processed_url, sizeof(processed_url), "https://%s.s3.%s.amazonaws.com/%s",
+               g_s3_bucket, g_s3_region, s3_key_processed);
+    }
+  }
+
+  // Build CloudFront URL for original file (for non-images or before processing completes)
+  char original_url[1024] = {0};
+  if (s3_key_original && strlen(s3_key_original) > 0)
+  {
+    if (g_s3_cloudfront_url[0])
+    {
+      snprintf(original_url, sizeof(original_url), "%s/%s", g_s3_cloudfront_url, s3_key_original);
+    }
+    else
+    {
+      snprintf(original_url, sizeof(original_url), "https://%s.s3.%s.amazonaws.com/%s",
+               g_s3_bucket, g_s3_region, s3_key_original);
+    }
+  }
+
+  // Build JSON response with both processed_url and original_url
+  char *response_body = Dowa_Arena_Allocate(arena, 3072);
+
+  // Build the base response
+  int offset = snprintf(response_body, 3072,
+                        "{\"id\":%lld,\"status\":\"%s\",",
+                        (long long)id, status);
+
+  // Add processed_url
+  if (strlen(processed_url) > 0)
+  {
+    offset += snprintf(response_body + offset, 3072 - offset,
+                       "\"processed_url\":\"%s\",", processed_url);
+  }
+  else
+  {
+    offset += snprintf(response_body + offset, 3072 - offset,
+                       "\"processed_url\":null,");
+  }
+
+  // Add original_url
+  if (strlen(original_url) > 0)
+  {
+    offset += snprintf(response_body + offset, 3072 - offset,
+                       "\"original_url\":\"%s\",", original_url);
+  }
+  else
+  {
+    offset += snprintf(response_body + offset, 3072 - offset,
+                       "\"original_url\":null,");
+  }
+
+  // Add error_message
+  if (error_message && strlen(error_message) > 0)
+  {
+    snprintf(response_body + offset, 3072 - offset,
+             "\"error_message\":\"%s\"}", error_message);
+  }
+  else
+  {
+    snprintf(response_body + offset, 3072 - offset,
+             "\"error_message\":null}");
+  }
+
+  Deita_Result_Set_Free(p_result);
+
+  Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
+  Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
+  Dowa_HashMap_Push_Arena(resp, "body", response_body, arena);
+
+  return resp;
+}
 
 int main(void)
 {
+  // Load server config
+  load_config("mrjunejune/.config");
+
+  // Load S3 credentials from .env
+  FILE *env_file = fopen(".env", "r");
+  static char s3_access_key[128] = {0};
+  static char s3_secret_key[128] = {0};
+
+  if (env_file)
+  {
+    char line[512];
+    while (fgets(line, sizeof(line), env_file))
+    {
+      if (strncmp(line, "AWS_MRJUNEJUNE_ACCESS_KEY=", 26) == 0)
+      {
+        char *val = line + 26;
+        size_t len = strlen(val);
+        while (len > 0 && (val[len-1] == '\n' || val[len-1] == '\r')) val[--len] = '\0';
+        strncpy(s3_access_key, val, sizeof(s3_access_key) - 1);
+      }
+      else if (strncmp(line, "AWS_MRJUNEJUNE_SECRET_ACCESS_KEY=", 33) == 0)
+      {
+        char *val = line + 33;
+        size_t len = strlen(val);
+        while (len > 0 && (val[len-1] == '\n' || val[len-1] == '\r')) val[--len] = '\0';
+        strncpy(s3_secret_key, val, sizeof(s3_secret_key) - 1);
+      }
+    }
+    fclose(env_file);
+  }
+
+  // Initialize S3 config
+  g_s3_config.access_key_id = s3_access_key;
+  g_s3_config.secret_access_key = s3_secret_key;
+  g_s3_config.region = g_s3_region;
+  g_s3_config.bucket = g_s3_bucket;
+  g_s3_config.endpoint = NULL;
+  g_s3_config.use_path_style = FALSE;
+
+  printf("[S3] Configured: region=%s, bucket=%s, key=%s...\n",
+         g_s3_region, g_s3_bucket, s3_access_key[0] ? "***" : "(missing)");
+
+  // Show current working directory
+  char cwd[1024];
+  if (getcwd(cwd, sizeof(cwd)) != NULL)
+  {
+    printf("[STARTUP] Current working directory: %s\n", cwd);
+    printf("[STARTUP] Database path (relative): %s\n", g_db_path);
+  }
+
+  // Initialize database
+  init_database();
+
   Seobeo_Router_Init();
 
   Seobeo_Router_Register("GET", "/", GetHomePage);
@@ -527,6 +1828,18 @@
   Seobeo_Router_Register("POST", "/api/convert/video-to-mp4", ConvertVideoToMP4);
   Seobeo_Router_Register("GET", "/api/download/:filename", DownloadConvertedFile);
 
+  // -- S3 Upload --/
+  Seobeo_Router_Register("POST", "/api/s3/upload-url", GetS3UploadUrl);
+
+  // -- Media Upload --/
+  Seobeo_Router_Register("POST", "/api/media/create", MediaCreate);
+  Seobeo_Router_Register("POST", "/api/media/:id/uploaded", MediaUploaded);
+  Seobeo_Router_Register("GET", "/api/media/:id/status", MediaStatus);
+
+  // -- Editor --/
+  Seobeo_Router_Register("POST", "/api/editor/save", EditorSave);
+  Seobeo_Router_Register("GET", "/api/editor/load/:doc_id", EditorLoad);
+
   // -- Blog --/
   Seobeo_Router_Register("GET", "/blog", RenderBlogList);
   Seobeo_Router_Register("GET", "/blog/:blog_id", RenderBlog);
@@ -535,8 +1848,17 @@
   Seobeo_Router_Register("GET", "/talk", GetTalk);
   Seobeo_Router_Register("GET", "/talk/index.html", GetRedirectTalk);
 
+  // -- Notes --/
+  Seobeo_Router_Register("GET", "/notes", GetNotes);
+  Seobeo_Router_Register("GET", "/notes/", GetNotes);
+  Seobeo_Router_Register("GET", "/notes/index.html", GetNotes);
+  Seobeo_Router_Register("GET", "/notes/login", GetNotesLogin);
+  Seobeo_Router_Register("GET", "/notes/login/", GetNotesLogin);
+  Seobeo_Router_Register("GET", "/notes/:note_id", GetNoteById);
+
   Seobeo_WebSocket_Server_Init();
   Seobeo_WebSocket_Server_Register("/chat", Chat_Handler, NULL);
 
-  Seobeo_Web_Server_Start("mrjunejune/src", "6969", SEOBEO_MODE_EDGE, 3);
+  Seobeo_Log(SEOBEO_INFO, "WTF is going on\n");
+  Seobeo_Web_Server_Start("mrjunejune/src", "6969", SEOBEO_MODE_EDGE, 1);
 }
--- a/mrjunejune/src/base.css	Sun Aug 02 08:34:28 2026 -0700
+++ b/mrjunejune/src/base.css	Sun Aug 02 08:34:54 2026 -0700
@@ -359,3 +359,40 @@
 }
 
 
+/* pwa icon */
+#pwa-install-btn {
+  position: fixed;
+  bottom: 20px;
+  right: 20px;
+  padding: 12px 24px 12px 36px;
+  background: #000000;
+  color: #eeffee;
+  border: none;
+  border-radius: 8px;
+  cursor: pointer;
+  font-size: 14px;
+  font-weight: bold;
+  box-shadow: 0 4px 12px rgba(0,0,0,0.2);
+  z-index: 1000;
+  font-family: inherit;
+}
+
+#pwa-install-btn.with-icon::before {
+  content: "";
+  position: absolute;
+  left: 14px;
+  top: 50%;
+  transform: translateY(-50%);
+  width: 18px;
+  height: 18px;
+
+  /* Masked icon */
+  -webkit-mask: url('/public/paw.svg') no-repeat center;
+  mask: url('/public/paw.svg') no-repeat center;
+  mask-size: contain;
+  mask-repeat: no-repeat;
+
+  /* Icon color = button text color */
+  background-color: currentColor;
+}
+
--- a/mrjunejune/src/index.html	Sun Aug 02 08:34:28 2026 -0700
+++ b/mrjunejune/src/index.html	Sun Aug 02 08:34:54 2026 -0700
@@ -29,6 +29,41 @@
           margin-bottom: 0.75em;
         }
       }
+
+      #background {
+        position: fixed;
+        top: 0;
+        left: 0;
+        width: 100%;
+        height: 100%;
+        pointer-events: none;
+        z-index: 0;
+      }
+
+      #game {
+        position: fixed;
+        top: 0;
+        left: 0;
+        width: 100%;
+        height: 100%;
+        pointer-events: none;
+        z-index: 1;
+        cursor: default;
+      }
+
+      #game.active {
+        pointer-events: auto;
+        z-index: 100;
+      }
+
+      #gameUI {
+        position: fixed;
+        top: 10px;
+        right: 10px;
+        pointer-events: auto;
+        z-index: 101;
+        cursor: pointer;
+      }
     </style>
   </head>
   <body>
@@ -53,10 +88,14 @@
          <li><a href="/resume">Resume</a> - My professional experiences </li>
          <li><a href="/tools">Tools</a> - Things I made for myself </li>
        </ul> 
+       <canvas id="background"></canvas>
+       <canvas id="game"></canvas>
+       <canvas id="gameUI"></canvas>
      </main>
      {{/parts/footer.html}}
   </body>
   <script>
+    // Epi image
     let arr = Array.from({ length: 18 }, (_, i) => i+1);
     function setRandomImages() {
       const randomIndex = Math.floor(Math.random() * arr.length);
@@ -69,4 +108,5 @@
     }
     setRandomImages();
   </script>
+  <script src="/public/dog-game.js"></script>
 </html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/notes/index.html	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,162 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  {{/parts/base_head.html}}
+  <title>Notes</title>
+  <style>
+    .notes-page {
+      min-width: 70%;
+      margin: 0 auto;
+      padding: 20px;
+    }
+    .notes-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      margin-bottom: 20px;
+      flex-wrap: wrap;
+      gap: 12px;
+    }
+    .notes-header h1 {
+      margin: 0;
+      display: flex;
+      align-items: center;
+      gap: 12px;
+    }
+    .note-id-display {
+      font-size: 14px;
+      color: #666;
+      background: #f0f0f0;
+      padding: 4px 12px;
+      border-radius: 4px;
+      font-weight: normal;
+    }
+    .notes-actions {
+      display: flex;
+      gap: 8px;
+      align-items: center;
+    }
+    .notes-actions button, .notes-actions a {
+      padding: 8px 16px;
+      border: 1px solid #ccc;
+      border-radius: 4px;
+      background: #fff;
+      cursor: pointer;
+      text-decoration: none;
+      color: #333;
+      font-size: 14px;
+    }
+    .notes-actions button:hover, .notes-actions a:hover {
+      background: #f5f5f5;
+    }
+    .notes-actions .btn-primary {
+      background: #0078ff;
+      color: white;
+      border-color: #0078ff;
+    }
+    .notes-actions .btn-primary:hover {
+      background: #0066dd;
+    }
+    .notes-actions .btn-danger {
+      color: #d32f2f;
+      border-color: #d32f2f;
+    }
+    .new-note-dialog {
+      position: fixed;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      background: rgba(0,0,0,0.5);
+      display: none;
+      align-items: center;
+      justify-content: center;
+      z-index: 100;
+    }
+    .new-note-dialog.show {
+      display: flex;
+    }
+    .new-note-form {
+      background: white;
+      padding: 24px;
+      border-radius: 8px;
+      width: 400px;
+      max-width: 90%;
+    }
+    .new-note-form h2 {
+      margin: 0 0 16px 0;
+    }
+    .new-note-form input {
+      width: 100%;
+      padding: 10px;
+      border: 1px solid #ccc;
+      border-radius: 4px;
+      font-size: 16px;
+      box-sizing: border-box;
+      margin-bottom: 16px;
+    }
+    .new-note-form .form-actions {
+      display: flex;
+      gap: 8px;
+      justify-content: flex-end;
+    }
+    .new-note-form button {
+      padding: 8px 16px;
+      border-radius: 4px;
+      cursor: pointer;
+      font-size: 14px;
+    }
+    .new-note-form .btn-cancel {
+      background: #f5f5f5;
+      border: 1px solid #ccc;
+    }
+    .new-note-form .btn-create {
+      background: #0078ff;
+      color: white;
+      border: none;
+    }
+    .note-hint {
+      font-size: 12px;
+      color: #666;
+      margin-top: -12px;
+      margin-bottom: 16px;
+    }
+  </style>
+</head>
+<body>
+  {{/parts/header.html}}
+
+  <main class="notes-page">
+    <div class="notes-header">
+      <h1>
+        Notes
+        <span class="note-id-display" id="note-id-display">index</span>
+      </h1>
+      <div class="notes-actions">
+        <button onclick="goHome()"> Home </button>
+        <button onclick="logout()" class="btn-danger">Logout</button>
+      </div>
+    </div>
+
+    <div id="editor-container"></div>
+  </main>
+
+  <!-- New Note Dialog -->
+  <div class="new-note-dialog" id="new-note-dialog">
+    <div class="new-note-form">
+      <h2>Create New Note</h2>
+      <input type="text" id="new-note-id" placeholder="note-id (e.g., my-ideas)">
+      <p class="note-hint">Use lowercase letters, numbers, and hyphens only</p>
+      <div class="form-actions">
+        <button class="btn-cancel" onclick="hideNewNoteDialog()">Cancel</button>
+        <button class="btn-create" onclick="createNewNote()">Create & Open</button>
+      </div>
+    </div>
+  </div>
+
+  {{/parts/footer.html}}
+
+  <script src="/public/js/rich_editor.js"></script>
+  <script src="/public/editor.js"></script>
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/notes/login.html	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,113 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  {{/parts/base_head.html}}
+  <title>Login | Notes</title>
+  <style>
+    .login-page {
+      max-width: 400px;
+      margin: 100px auto;
+      padding: 20px;
+    }
+    .login-box {
+      background: #fff;
+      border: 1px solid #ddd;
+      border-radius: 8px;
+      padding: 32px;
+      box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+    }
+    .login-box h1 {
+      margin: 0 0 24px 0;
+      font-size: 24px;
+      text-align: center;
+    }
+    .login-box label {
+      display: block;
+      margin-bottom: 8px;
+      font-weight: 500;
+    }
+    .login-box input {
+      width: 100%;
+      padding: 12px;
+      border: 1px solid #ccc;
+      border-radius: 4px;
+      font-size: 16px;
+      box-sizing: border-box;
+    }
+    .login-box input:focus {
+      outline: none;
+      border-color: #0078ff;
+    }
+    .login-box button {
+      width: 100%;
+      padding: 12px;
+      margin-top: 16px;
+      background: #0078ff;
+      color: white;
+      border: none;
+      border-radius: 4px;
+      font-size: 16px;
+      cursor: pointer;
+    }
+    .login-box button:hover {
+      background: #0066dd;
+    }
+    .error-msg {
+      color: #d32f2f;
+      margin-top: 12px;
+      text-align: center;
+      display: none;
+    }
+  </style>
+</head>
+<body>
+  {{/parts/header.html}}
+
+  <main class="login-page">
+    <div class="login-box">
+      <h1>Notes Login</h1>
+      <form id="login-form">
+        <label for="token">Access Token</label>
+        <input type="password" id="token" placeholder="Enter your access token" required>
+        <button type="submit">Login</button>
+        <p class="error-msg" id="error-msg">Invalid token</p>
+      </form>
+    </div>
+  </main>
+
+  {{/parts/footer.html}}
+
+  <script>
+    // Check if already logged in
+    const savedToken = localStorage.getItem('notes-auth-token');
+    if (savedToken) {
+      window.location.href = '/notes/';
+    }
+
+    document.getElementById('login-form').addEventListener('submit', async function(e) {
+      e.preventDefault();
+
+      const token = document.getElementById('token').value.trim();
+      if (!token) return;
+
+      // Verify token by trying to load a document
+      try {
+        const response = await fetch('/api/editor/load/index', {
+          headers: { 'Authorization': 'Bearer ' + token }
+        });
+
+        if (response.ok) {
+          localStorage.setItem('notes-auth-token', token);
+          // Redirect to originally requested page or /notes/
+          const returnUrl = new URLSearchParams(window.location.search).get('return') || '/notes/';
+          window.location.href = returnUrl;
+        } else {
+          document.getElementById('error-msg').style.display = 'block';
+        }
+      } catch (err) {
+        document.getElementById('error-msg').style.display = 'block';
+      }
+    });
+  </script>
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/offline.html	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,61 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Offline - MrJuneJune</title>
+    <style>
+      body {
+        font-family: system-ui, -apple-system, sans-serif;
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+        min-height: 100vh;
+        margin: 0;
+        padding: 20px;
+        background: linear-gradient(135deg, #544e43 0%, #3a3630 100%);
+        color: white;
+        text-align: center;
+      }
+
+      h1 {
+        font-size: 3em;
+        margin: 0 0 0.5em 0;
+      }
+
+      p {
+        font-size: 1.2em;
+        margin: 0 0 2em 0;
+        opacity: 0.9;
+      }
+
+      button {
+        padding: 12px 32px;
+        font-size: 1em;
+        background: white;
+        color: #544e43;
+        border: none;
+        border-radius: 8px;
+        cursor: pointer;
+        font-weight: bold;
+        transition: transform 0.2s;
+      }
+
+      button:hover {
+        transform: scale(1.05);
+      }
+
+      .icon {
+        font-size: 5em;
+        margin-bottom: 0.3em;
+      }
+    </style>
+  </head>
+  <body>
+    <div class="icon">📡</div>
+    <h1>You're Offline</h1>
+    <p>It looks like you've lost your internet connection.<br>Don't worry, some cached pages might still work!</p>
+    <button onclick="window.location.reload()">Try Again</button>
+  </body>
+</html>
--- a/mrjunejune/src/parts/base_head.html	Sun Aug 02 08:34:28 2026 -0700
+++ b/mrjunejune/src/parts/base_head.html	Sun Aug 02 08:34:54 2026 -0700
@@ -1,6 +1,9 @@
 <meta charset="UTF-8">
 <meta name="viewport" content="width=device-width, initial-scale=1.0">
+<meta name="theme-color" content="#544e43">
 <link rel="icon" type="image/svg+xml" href="/public/epi_all_colors.svg">
+<link rel="manifest" href="/public/manifest.json">
+<link rel="apple-touch-icon" href="/public/epi_all_colors.svg">
 
 <link rel="preload" href="/public/fonts/Roboto-Regular.ttf" as="font"  crossorigin>
 <link rel="preload" href="/public/fonts/Roboto-Thin.ttf"as="font" crossorigin>
@@ -16,3 +19,5 @@
 <link rel="preload" href="/base.css" as="style" />
 <link rel="stylesheet" href="/base.css" />
 
+<script src="/public/pwa-register.js" defer></script>
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/public/dog-game.js	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,295 @@
+// Dog frames
+const imagesSrc = [
+  "/public/sprite_shiba0.png",
+  "/public/sprite_shiba1.png",
+  "/public/sprite_shiba2.png",
+  "/public/sprite_shiba3.png",
+];
+const images =imagesSrc.map((src, index) => {
+  const img = new Image();
+  img.src = src;
+  img.onload = () => {
+    if (index == imagesSrc.length-1)
+      startAnimation();
+  };
+  return img
+});
+
+// Load treat image
+const treatImage = new Image();
+treatImage.src = "/public/dog-treat.png";
+
+// Load star images for background
+let starImages = [
+  { src: "/public/start-large.png", img: new Image() },
+  { src: "/public/start-small-1.png", img: new Image() },
+  { src: "/public/start-small-2.png", img: new Image() },
+  { src: "/public/start-small-3.png", img: new Image() }
+];
+starImages.forEach(star => star.img.src = star.src);
+
+const background = document.getElementById('background');
+const bgCtx = background.getContext("2d");
+const ctx = game.getContext("2d");
+const uiCtx = gameUI.getContext("2d");
+
+
+// Dog initial position and movement
+const initialDogVx = 2;
+let x = Math.random() * (game.width);
+let y = Math.random() * (game.height);
+let vx = initialDogVx; 
+let vy = 0; 
+const gravity = 0.5;
+const jumpForce = -12;
+let isFlipped = false;
+
+// Random jump interval
+// TODO: let's do it by frame?
+let nextJump = Math.random() * 2000 + 1000;
+
+// Treat system
+const originalTreatSize = 30;
+let treatSize = originalTreatSize;
+const treatMargin = 20;
+const reachDistance = 50;
+let treatRainActive = false;
+let treats = []; 
+let lastTreatSpawn = 0;
+const treatFallSpeed = 0.25;
+const treatGravity = 0.13;
+
+// Background stars
+let stars = [];
+function createStars() {
+  stars = [];
+  const starCount = window.innerWidth * 0.05;
+
+  for (let i = 0; i < starCount; i++) {
+    const starType = starImages[Math.floor(Math.random() * starImages.length)];
+    const scale = 0.5 + Math.random() * 0.8; // Random scale
+
+    stars.push({
+      x: Math.random() * background.width,
+      y: Math.random() * background.height,
+      vx: (Math.random() - 0.5) * 0.5,
+      vy: (Math.random() - 0.5) * 0.5,
+      rotation: Math.random() * Math.PI * 2,
+      rotationSpeed: (Math.random() - 0.5) * 0.02,
+      image: starType.img,
+      size: starType.img.width,
+      scale: scale,
+      opacity: 0.3 + Math.random() * 0.4 // Random opacity
+    });
+  }
+}
+
+function animateBackground() {
+  bgCtx.clearRect(0, 0, background.width, background.height);
+
+  stars.forEach(star => {
+    // Update position
+    star.x += star.vx;
+    star.y += star.vy;
+    star.rotation += star.rotationSpeed;
+
+    // Wrap around edges
+    if (star.x < -100) star.x = background.width + 100;
+    if (star.x > background.width + 100) star.x = -100;
+    if (star.y < -100) star.y = background.height + 100;
+    if (star.y > background.height + 100) star.y = -100;
+
+    // Draw star
+    bgCtx.save();
+    bgCtx.globalAlpha = star.opacity;
+    bgCtx.translate(star.x, star.y);
+    bgCtx.rotate(star.rotation);
+
+    const size = star.size * star.scale;
+    bgCtx.drawImage(star.image, -size / 2, -size / 2, size, size);
+
+    bgCtx.restore();
+  });
+
+  requestAnimationFrame(animateBackground);
+}
+
+function getMousePosition(event) {
+  const rect = game.getBoundingClientRect();
+  const x = event.clientX - rect.left;
+  const y = event.clientY - rect.top;
+  return { x: x, y: y };
+}
+
+// UI canvas click - toggle treat rain
+gameUI.addEventListener('click', (e) => {
+  treatRainActive = !treatRainActive;
+  if (treatRainActive)
+    game.classList.add('active');
+  else
+    game.classList.remove('active');
+});
+
+game.addEventListener('click', (e) => {
+  if (treatRainActive) {
+    const mousePos = getMousePosition(e);
+    treats.push({ x: mousePos.x, y: mousePos.y, vy: treatFallSpeed });
+  }
+});
+
+function startAnimation() {
+  requestAnimationFrame(animate);
+}
+
+
+let index = 0;
+let last = 0;
+const frameDuration = 120; // ms per frame
+function animate(ts) {
+  if (ts - last > frameDuration) {
+    index = (index + 1) % images.length;
+    last = ts;
+  }
+
+  // Get image dimensions
+  const imgWidth = images[0].width;
+  const imgHeight = images[0].height;
+
+  // Update falling treats
+  treats.forEach(treat => {
+    treat.vy += treatGravity;
+    treat.y += treat.vy;
+  });
+  treats = treats.filter(treat => treat.y < game.height + treatSize);
+
+  // Find closest treat for dog to chase
+  let targetTreat = null;
+  let closestDist = Infinity;
+  treats.forEach(treat => {
+    const dx = treat.x + treatSize / 2 - (x + imgWidth / 2);
+    const dy = treat.y + treatSize / 2 - (y + imgHeight / 2);
+    const dist = Math.sqrt(dx * dx + dy * dy);
+
+    if (dist < closestDist) {
+      closestDist = dist;
+      targetTreat = treat;
+    }
+  });
+
+  // Dog behavior
+  if (targetTreat) {
+    const dx = targetTreat.x + treatSize / 2 - (x + imgWidth / 2);
+    const dy = targetTreat.y + treatSize / 2 - (y + imgHeight / 2);
+    const distance = Math.sqrt(dx * dx + dy * dy);
+
+    // Dog eating
+    if (distance < reachDistance)
+    {
+      treats = treats.filter(t => t !== targetTreat);
+      vx = initialDogVx;
+    }
+    // Looking for it
+    else
+    {
+      const speed = initialDogVx * 1.5;
+      vx = dx > 0 ? speed : -speed;
+
+      // Update flip direction
+      if ((dx > 0 && isFlipped) || (dx < 0 && !isFlipped))
+      {
+        isFlipped = !isFlipped;
+      }
+
+      // Jump if treat is above and dog is on ground
+      const onGround = y + imgHeight >= game.height - 5;
+      if (dy < -50 && onGround && Math.abs(vy) < 1)
+      {
+        vy = jumpForce;
+      }
+    }
+  }
+
+  // --- Entity updates --- // 
+  vy += gravity;
+  x += vx;
+  y += vy;
+
+  // Bounce off walls
+  if (x <= 0 || x + imgWidth >= game.width)
+  {
+    if (!targetTreat)
+    {
+      vx = -vx;
+      isFlipped = !isFlipped;
+    }
+    x = x <= 0 ? 0 : game.width - imgWidth;
+  }
+
+  // Bounce off floor
+  if (y + imgHeight >= game.height)
+  {
+    y = game.height - imgHeight;
+    vy = -vy * 0.7; 
+    if (Math.abs(vy) < 2) vy = 0;
+  }
+
+  // Random jumps (only when not chasing treat)
+  if (!targetTreat && Date.now() > nextJump && Math.abs(vy) < 1)
+  {
+    vy = jumpForce;
+    nextJump = Date.now() + 1000 + Math.random() * 3000;
+  }
+
+  // --- Draw entity --- // 
+  ctx.clearRect(0, 0, game.width, game.height);
+  ctx.save();
+
+  if (isFlipped) {
+    ctx.translate(x + imgWidth, y);
+    ctx.scale(-1, 1);
+    ctx.drawImage(images[index], 0, 0);
+  } else {
+    ctx.drawImage(images[index], x, y);
+  }
+
+  ctx.restore();
+  treats.forEach(treat => {
+    ctx.drawImage(treatImage, treat.x, treat.y, treatSize, treatSize);
+  });
+
+  drawUI();
+  // repeat
+  requestAnimationFrame(animate);
+}
+
+// Draw UI elements (treat toggle button)
+function drawUI() {
+  uiCtx.drawImage(treatImage, 0, 0, treatSize, treatSize);
+}
+
+// --- Window functions --- //
+function resizeCanvas() {
+  background.width = window.innerWidth;
+  background.height = window.innerHeight;
+  game.width = window.innerWidth;
+  game.height = window.innerHeight;
+
+  // resize treats
+  treatSize = window.innerWidth < 700 ? originalTreatSize * 0.6 : originalTreatSize;
+
+  // Size UI canvas to fit treat button + text
+  gameUI.width = treatSize + 10;
+  gameUI.height = treatSize + 25;
+
+  createStars(); // Recreate stars on resize
+  drawUI(); // Redraw UI after resize
+}
+resizeCanvas();
+window.addEventListener('resize', resizeCanvas);
+
+window.onload = () => {
+  resizeCanvas();     // ensure canvas has real size
+  createStars();      // stars need correct canvas size
+  animateBackground(); // safe to start background animation
+};
+
Binary file mrjunejune/src/public/dog-treat.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/public/editor.js	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,218 @@
+let editor = null;
+let currentNoteId = 'index';
+
+function getAuthToken() {
+  return localStorage.getItem('notes-auth-token');
+}
+
+function requireAuth() {
+  if (!getAuthToken()) {
+    const returnUrl = encodeURIComponent(window.location.pathname);
+    window.location.href = '/notes/login?return=' + returnUrl;
+    return false;
+  }
+  return true;
+}
+
+function goHome() {
+  window.location.href = '/notes';
+}
+
+function logout() {
+  localStorage.removeItem('notes-auth-token');
+  window.location.href = '/notes/login';
+}
+
+function getNoteIdFromPath() {
+  const path = window.location.pathname;
+  const match = path.match(/^\/notes\/(.+)$/);
+  if (match && match[1] && match[1] !== 'login') {
+    return decodeURIComponent(match[1]);
+  }
+  return 'index';
+}
+
+function showNewNoteDialog() {
+  document.getElementById('new-note-dialog').classList.add('show');
+  document.getElementById('new-note-id').focus();
+}
+
+function hideNewNoteDialog() {
+  document.getElementById('new-note-dialog').classList.remove('show');
+  document.getElementById('new-note-id').value = '';
+}
+
+function createNewNote() {
+  let noteId = document.getElementById('new-note-id').value.trim();
+  if (!noteId) return;
+
+  // Sanitize note ID
+  noteId = noteId.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
+
+  hideNewNoteDialog();
+  window.location.href = '/notes/' + encodeURIComponent(noteId);
+}
+
+// Handle Enter key in new note dialog
+document.getElementById('new-note-id').addEventListener('keydown', function(e) {
+  if (e.key === 'Enter') {
+    e.preventDefault();
+    createNewNote();
+  }
+  if (e.key === 'Escape') {
+    hideNewNoteDialog();
+  }
+});
+
+// Close dialog on backdrop click
+document.getElementById('new-note-dialog').addEventListener('click', function(e) {
+  if (e.target === this) {
+    hideNewNoteDialog();
+  }
+});
+
+async function uploadFile(file) {
+  const token = getAuthToken();
+  if (!token) {
+    throw new Error('Not authenticated');
+  }
+
+  // 1. Create media record
+  const createResponse = await fetch('/api/media/create', {
+    method: 'POST',
+    headers: {
+      'Authorization': 'Bearer ' + token,
+      'Content-Type': 'application/json'
+    },
+    body: JSON.stringify({
+      filename: file.name,
+      content_type: file.type
+    })
+  });
+
+  if (!createResponse.ok) {
+    const error = await createResponse.json().catch(() => ({}));
+    throw new Error(error.error || 'Failed to create media record');
+  }
+
+  const data = await createResponse.json();
+
+  // 2. Upload file directly to S3
+  const uploadResponse = await fetch(data.upload_url, {
+    method: 'PUT',
+    headers: {
+      'Content-Type': file.type
+    },
+    body: file
+  });
+
+  if (!uploadResponse.ok) {
+    throw new Error('Failed to upload file to S3');
+  }
+
+  // 3. Mark as uploaded (triggers processing for images)
+  await fetch(`/api/media/${data.media_id}/uploaded`, {
+    method: 'POST',
+    headers: {
+      'Authorization': 'Bearer ' + token
+    }
+  });
+
+  // 4. Poll for images, return immediately for non-images
+  if (file.type.startsWith('image/')) {
+    return await pollForProcessedImage(data.media_id, token);
+  } else {
+    // For non-images, use the public URL from the server
+    return { url: data.public_url };
+  }
+}
+
+async function pollForProcessedImage(mediaId, token) {
+  const maxAttempts = 60; // 2 minutes max (60 * 2 seconds)
+
+  for (let i = 0; i < maxAttempts; i++) {
+    await new Promise(resolve => setTimeout(resolve, 2000)); // 2 second interval
+
+    const statusResponse = await fetch(`/api/media/${mediaId}/status`, {
+      headers: {
+        'Authorization': 'Bearer ' + token
+      }
+    });
+
+    if (!statusResponse.ok) {
+      console.warn('Status check failed, retrying...');
+      continue;
+    }
+
+    const statusData = await statusResponse.json();
+
+    if (statusData.status === 'finished') {
+      return { url: statusData.processed_url };
+    } else if (statusData.status === 'error') {
+      throw new Error(statusData.error_message || 'Processing failed');
+    }
+    // Status is 'uploaded' or 'processing', continue polling
+  }
+
+  throw new Error('Processing timeout after 2 minutes');
+}
+
+async function saveContent(content) {
+  const token = getAuthToken();
+  if (!token) return;
+
+  const response = await fetch('/api/editor/save', {
+    method: 'POST',
+    headers: {
+      'Authorization': 'Bearer ' + token,
+      'Content-Type': 'application/json'
+    },
+    body: JSON.stringify({
+      doc_id: currentNoteId,
+      content: content
+    })
+  });
+
+  if (!response.ok) {
+    throw new Error('Failed to save');
+  }
+}
+
+async function loadNote(noteId) {
+  const token = getAuthToken();
+  if (!token) return;
+
+  try {
+    const response = await fetch('/api/editor/load/' + encodeURIComponent(noteId), {
+      headers: { 'Authorization': 'Bearer ' + token }
+    });
+
+    if (response.ok) {
+      const data = await response.json();
+      editor.setContent(data.content || '');
+    }
+  } catch (error) {
+    console.error('Failed to load note:', error);
+  }
+}
+
+// Initialize
+document.addEventListener('DOMContentLoaded', function() {
+  if (!requireAuth()) return;
+
+  currentNoteId = getNoteIdFromPath();
+  document.getElementById('note-id-display').textContent = currentNoteId;
+
+  // Update page title
+  document.title = currentNoteId + ' | Notes';
+
+  editor = RichEditor.init('editor-container', {
+    uploadCallback: uploadFile,
+    saveCallback: saveContent,
+    debounceMs: 1500,
+    placeholder: 'Start writing... (paste images, drag files, or use /upload)\n\nTip: Click "+ New Note" to create linked notes.'
+  });
+
+  loadNote(currentNoteId);
+});
+
Binary file mrjunejune/src/public/icon-192.png has changed
Binary file mrjunejune/src/public/icon-512.png has changed
Binary file mrjunejune/src/public/icon-maskable-512.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/public/manifest.json	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,67 @@
+{
+  "name": "MrJuneJune",
+  "short_name": "MrJuneJune",
+  "description": "Personal website and blog of Juntae (June)",
+  "start_url": "/",
+  "display": "standalone",
+  "background_color": "#ffffff",
+  "theme_color": "#544e43",
+  "orientation": "portrait-primary",
+  "icons": [
+    {
+      "src": "/public/epi_all_colors.svg",
+      "sizes": "any",
+      "type": "image/svg+xml",
+      "purpose": "any maskable"
+    },
+    {
+      "src": "/public/icon-192.png",
+      "sizes": "192x192",
+      "type": "image/png",
+      "purpose": "any"
+    },
+    {
+      "src": "/public/icon-512.png",
+      "sizes": "512x512",
+      "type": "image/png",
+      "purpose": "any"
+    },
+    {
+      "src": "/public/icon-maskable-512.png",
+      "sizes": "512x512",
+      "type": "image/png",
+      "purpose": "maskable"
+    }
+  ],
+  "screenshots": [
+    {
+      "src": "/public/screenshot-mobile.png",
+      "sizes": "540x720",
+      "type": "image/png",
+      "form_factor": "narrow"
+    },
+    {
+      "src": "/public/screenshot-desktop.png",
+      "sizes": "1280x720",
+      "type": "image/png",
+      "form_factor": "wide"
+    }
+  ],
+  "categories": ["lifestyle", "productivity"],
+  "shortcuts": [
+    {
+      "name": "Blog",
+      "short_name": "Blog",
+      "description": "Read latest blog posts",
+      "url": "/blog",
+      "icons": [{ "src": "/public/epi_all_colors.svg", "sizes": "192x192" }]
+    },
+    {
+      "name": "Notes",
+      "short_name": "Notes",
+      "description": "Access your notes",
+      "url": "/notes",
+      "icons": [{ "src": "/public/epi_all_colors.svg", "sizes": "192x192" }]
+    }
+  ]
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/public/pwa-register.js	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,88 @@
+// PWA Service Worker Registration
+if ('serviceWorker' in navigator) {
+  window.addEventListener('load', () => {
+    navigator.serviceWorker
+      .register('/public/sw.js')
+      .then((registration) => {
+        // console.log('[PWA] Service Worker registered:', registration.scope);
+
+        // Check for updates periodically
+        setInterval(() => {
+          registration.update();
+        }, 60000); // Check every minute
+
+        // Handle updates
+        registration.addEventListener('updatefound', () => {
+          const newWorker = registration.installing;
+          newWorker.addEventListener('statechange', () => {
+            if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
+              // New content available, show update notification
+              if (confirm('New version available! Reload to update?')) {
+                newWorker.postMessage({ type: 'SKIP_WAITING' });
+                window.location.reload();
+              }
+            }
+          });
+        });
+      })
+      .catch((error) => {
+        console.error('[PWA] Service Worker registration failed:', error);
+      });
+
+    // Handle service worker updates
+    let refreshing = false;
+    navigator.serviceWorker.addEventListener('controllerchange', () => {
+      if (!refreshing) {
+        refreshing = true;
+        window.location.reload();
+      }
+    });
+  });
+}
+
+// Add install prompt handler
+let deferredPrompt;
+
+window.addEventListener('beforeinstallprompt', (e) => {
+  e.preventDefault();
+  deferredPrompt = e;
+
+  showInstallPromotion();
+});
+
+function showInstallPromotion() {
+  // Create an install button if it doesn't exist
+  if (document.getElementById('pwa-install-btn')) return;
+
+  const installBtn = document.createElement('button');
+  installBtn.classList.add('with-icon');
+  installBtn.id = 'pwa-install-btn';
+  installBtn.textContent = 'Install as App';
+
+  installBtn.addEventListener('click', async () => {
+    if (!deferredPrompt) return;
+
+    deferredPrompt.prompt();
+    const { outcome } = await deferredPrompt.userChoice;
+    console.log(`[PWA] User response: ${outcome}`);
+
+    deferredPrompt = null;
+    installBtn.remove();
+  });
+
+  document.body.appendChild(installBtn);
+
+  setTimeout(() => {
+    installBtn.style.opacity = '0';
+    installBtn.style.transition = 'opacity 0.3s';
+    setTimeout(() => installBtn.remove(), 300);
+  }, 5000);
+}
+
+window.addEventListener('appinstalled', () => {
+  console.log('[PWA] App installed successfully!');
+  deferredPrompt = null;
+
+  const installBtn = document.getElementById('pwa-install-btn');
+  if (installBtn) installBtn.remove();
+});
Binary file mrjunejune/src/public/shiba_open_sprites.zip has changed
Binary file mrjunejune/src/public/sprite_shiba0.png has changed
Binary file mrjunejune/src/public/sprite_shiba1.png has changed
Binary file mrjunejune/src/public/sprite_shiba2.png has changed
Binary file mrjunejune/src/public/sprite_shiba3.png has changed
Binary file mrjunejune/src/public/start-large.png has changed
Binary file mrjunejune/src/public/start-small-1.png has changed
Binary file mrjunejune/src/public/start-small-2.png has changed
Binary file mrjunejune/src/public/start-small-3.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/public/sw.js	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,138 @@
+// Service Worker for MrJuneJune PWA
+const CACHE_VERSION = 'v1';
+const CACHE_NAME = `mrjunejune-${CACHE_VERSION}`;
+
+// Files to cache immediately on install
+const STATIC_CACHE = [
+  '/',
+  '/base.css',
+  '/public/epi_all_colors.svg',
+  '/public/fonts/Roboto-Regular.ttf',
+  '/public/fonts/Roboto-Thin.ttf',
+  '/public/fonts/more-sugar.regular.otf',
+  '/public/fonts/more-sugar.thin.otf',
+];
+
+// Install event - cache static assets
+self.addEventListener('install', (event) => {
+  console.log('[SW] Installing service worker...');
+
+  event.waitUntil(
+    caches.open(CACHE_NAME).then((cache) => {
+      console.log('[SW] Caching static assets');
+      return cache.addAll(STATIC_CACHE);
+    }).then(() => {
+      console.log('[SW] Skip waiting');
+      return self.skipWaiting();
+    })
+  );
+});
+
+// Activate event - clean up old caches
+self.addEventListener('activate', (event) => {
+  console.log('[SW] Activating service worker...');
+
+  event.waitUntil(
+    caches.keys().then((cacheNames) => {
+      return Promise.all(
+        cacheNames.map((cacheName) => {
+          if (cacheName !== CACHE_NAME) {
+            console.log('[SW] Deleting old cache:', cacheName);
+            return caches.delete(cacheName);
+          }
+        })
+      );
+    }).then(() => {
+      console.log('[SW] Claiming clients');
+      return self.clients.claim();
+    })
+  );
+});
+
+// Fetch event - serve from cache, fallback to network
+self.addEventListener('fetch', (event) => {
+  const { request } = event;
+  const url = new URL(request.url);
+
+  // Skip non-GET requests
+  if (request.method !== 'GET') {
+    return;
+  }
+
+  // Skip chrome-extension and other non-http(s) requests
+  if (!url.protocol.startsWith('http')) {
+    return;
+  }
+
+  // Skip API calls and media uploads (always go to network)
+  if (url.pathname.startsWith('/api/')) {
+    return;
+  }
+
+  event.respondWith(
+    caches.match(request).then((cachedResponse) => {
+      if (cachedResponse) {
+        console.log('[SW] Serving from cache:', url.pathname);
+        return cachedResponse;
+      }
+
+      // Not in cache, fetch from network
+      return fetch(request).then((networkResponse) => {
+        // Only cache successful responses
+        if (!networkResponse || networkResponse.status !== 200 || networkResponse.type === 'error') {
+          return networkResponse;
+        }
+
+        // Cache specific file types
+        const shouldCache =
+          url.pathname.endsWith('.css') ||
+          url.pathname.endsWith('.js') ||
+          url.pathname.endsWith('.svg') ||
+          url.pathname.endsWith('.png') ||
+          url.pathname.endsWith('.jpg') ||
+          url.pathname.endsWith('.webp') ||
+          url.pathname.endsWith('.woff') ||
+          url.pathname.endsWith('.woff2') ||
+          url.pathname.endsWith('.ttf') ||
+          url.pathname.endsWith('.otf') ||
+          url.pathname.startsWith('/blog/') ||
+          url.pathname.startsWith('/notes/') ||
+          url.pathname === '/';
+
+        if (shouldCache) {
+          const responseToCache = networkResponse.clone();
+          caches.open(CACHE_NAME).then((cache) => {
+            console.log('[SW] Caching new resource:', url.pathname);
+            cache.put(request, responseToCache);
+          });
+        }
+
+        return networkResponse;
+      }).catch((error) => {
+        console.log('[SW] Fetch failed:', error);
+
+        // Return offline page for HTML requests
+        if (request.headers.get('Accept').includes('text/html')) {
+          return caches.match('/offline.html');
+        }
+      });
+    })
+  );
+});
+
+// Handle messages from the client
+self.addEventListener('message', (event) => {
+  if (event.data && event.data.type === 'SKIP_WAITING') {
+    self.skipWaiting();
+  }
+
+  if (event.data && event.data.type === 'CLEAR_CACHE') {
+    event.waitUntil(
+      caches.keys().then((cacheNames) => {
+        return Promise.all(
+          cacheNames.map((cacheName) => caches.delete(cacheName))
+        );
+      })
+    );
+  }
+});
--- a/mrjunejune/src/resume/index.html	Sun Aug 02 08:34:28 2026 -0700
+++ b/mrjunejune/src/resume/index.html	Sun Aug 02 08:34:54 2026 -0700
@@ -27,7 +27,7 @@
             <a href="mailto:[email protected]">[email protected]</a>
           </div>
           <div>
-            <a href="https://www.linkedin.com/in/junepark"><svg width="12" height="12" fill="currentColor" class="bi bi-linkedin" viewBox="0 0 16 16">
+            <a href="https://www.linkedin.com/in/june-park-61721215b"><svg width="12" height="12" fill="currentColor" class="bi bi-linkedin" viewBox="0 0 16 16">
                <path d="M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854zm4.943 12.248V6.169H2.542v7.225zm-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248S2.4 3.226 2.4 3.934c0 .694.521 1.248 1.327 1.248zm4.908 8.212V9.359c0-.216.016-.432.08-.586.173-.431.568-.878 1.232-.878.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016l.016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225z"/>
              </svg> June Park
             </a>
--- a/mrjunejune/src/tools/index.html	Sun Aug 02 08:34:28 2026 -0700
+++ b/mrjunejune/src/tools/index.html	Sun Aug 02 08:34:54 2026 -0700
@@ -11,9 +11,9 @@
        <ul class="nav-list">
          <li><a href="/tools/markdown_to_html">MarkDown to HTML</a></li>
          <li><a href="/tools/file_converter">Images to Webp / Video to Mp4</a></li>
+         <li><a href="/notes">Personal Notes</a></li>
        </ul>
        <h3> TODOs </h3>
-       <p> Probably should add this... </p>
        <ul class="nav-list">
          <li>- Simple online LaTex editor.</li>
          <li>- Online HLS player.</li>
--- a/mrjunejune/src/tools/markdown_to_html/index.html	Sun Aug 02 08:34:28 2026 -0700
+++ b/mrjunejune/src/tools/markdown_to_html/index.html	Sun Aug 02 08:34:54 2026 -0700
@@ -54,7 +54,7 @@
 
 ### Images
 
-![Alt text](https://via.placeholder.com/150)
+![Alt text](https://mrjunejune.com/public/epi_favicon.svg)
 
 **Try editing this text!**</textarea>
         </div>
@@ -68,10 +68,16 @@
         </div>
     </div>
     {{/parts/footer.html}}
-    <script src="/markdown_to_html_bin.js"></script>
-    <script>
-        // Wait for WASM module to be ready
-        Module.onRuntimeInitialized = () => {
+    <script type="module">
+        import createModule from '/markdown_to_html_bin.js';
+
+        // Get DOM elements
+        const input = document.getElementById('input');
+        const output = document.getElementById('output');
+        const copy = document.getElementById('copy');
+
+        // Modern Emscripten exports a factory function that returns a Promise
+        createModule().then(Module => {
           // Get raw pointer so we can free it after use
           const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']);
           const markdownFree = Module.cwrap('markdown_free', null, ['number']);
@@ -108,7 +114,7 @@
               console.error('Failed to copy: ', err);
             });
           });
-        };
+        });
     </script>
 </body>
 </html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rich_editor/BUILD	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,5 @@
+filegroup(
+  name = "rich_editor",
+  srcs = ["rich_editor.js"],
+  visibility = ["//visibility:public"],
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rich_editor/rich_editor.js	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,1062 @@
+/**
+ * Rich Editor
+ * -----------
+ * A vanilla JavaScript rich text editor with:
+ * - Text formatting (h1-h6, paragraphs, lists)
+ * - Image/file upload via paste, drop, click, or /upload command
+ * - Debounced auto-save
+ * - Generic callback functions for uploads and saves
+ *
+ * Usage:
+ *   const editor = RichEditor.init('editor-container', {
+ *     uploadCallback: async (file) => { return { url: '...', key: '...' }; },
+ *     saveCallback: async (content) => { console.log('Saved:', content); },
+ *     debounceMs: 1000,
+ *     placeholder: 'Start writing...'
+ *   });
+ */
+
+const RichEditor = (function() {
+  'use strict';
+
+  const DEFAULT_OPTIONS = {
+    uploadCallback: null,
+    saveCallback: null,
+    debounceMs: 1000,
+    placeholder: 'Start writing... (Use /upload to insert files)',
+    cloudFrontUrl: ''
+  };
+
+  function debounce(func, wait) {
+    let timeout;
+    return function executedFunction(...args) {
+      const later = () => {
+        clearTimeout(timeout);
+        func(...args);
+      };
+      clearTimeout(timeout);
+      timeout = setTimeout(later, wait);
+    };
+  }
+
+  function createToolbar(editor) {
+    const toolbar = document.createElement('div');
+    toolbar.className = 'rich-editor-toolbar';
+
+    const buttons = [
+      { cmd: 'h1', label: '', title: 'Heading 1', icons: "/public/icons/h1.png" },
+      { cmd: 'h2', label: '', title: 'Heading 2', icons: "/public/icons/h2.png" },
+      { cmd: 'h3', label: '', title: 'Heading 3', icons: "/public/icons/h3.png" },
+      { cmd: 'p', label: '¶', title: 'Paragraph' },
+      { cmd: 'ul', label: '•', title: 'Bullet List' },
+      { cmd: 'ol', label: '1.', title: 'Numbered List' },
+      { cmd: 'bold', label: '', title: 'Bold', icons: "/public/icons/bold.png" },
+      { cmd: 'separator' },
+      { cmd: 'link', label: '', title: 'Insert Link', icons: "/public/icons/link.png"  },
+      { cmd: 'notelink', label: '', title: 'Link to Note', icons: "/public/icons/binder.png"  },
+      { cmd: 'upload', label: '', title: 'Upload File', icons: "/public/icons/clipy.png"  },
+      { cmd: 'separator' },
+      { cmd: 'readonly', label: '', title: 'readonly', icons: "/public/icons/book.png" },
+    ];
+
+    buttons.forEach(btn => {
+      if (btn.cmd === 'separator') {
+        const sep = document.createElement('span');
+        sep.className = 'rich-editor-separator';
+        toolbar.appendChild(sep);
+        return;
+      }
+
+      const button = document.createElement('button');
+      button.type = 'button';
+      button.className = 'rich-editor-btn';
+      if (btn.icons)
+        button.style.backgroundImage = `url("${btn.icons}")`;
+      button.textContent = btn.label;
+      button.title = btn.title;
+      button.dataset.cmd = btn.cmd;
+
+      button.addEventListener('click', (e) => {
+        e.preventDefault();
+        editor.execCommand(btn.cmd);
+      });
+
+      toolbar.appendChild(button);
+    });
+
+    return toolbar;
+  }
+
+  function ensureViewportMeta() {
+    // Check if viewport meta tag exists
+    let viewport = document.querySelector('meta[name="viewport"]');
+    if (!viewport) {
+      viewport = document.createElement('meta');
+      viewport.name = 'viewport';
+      viewport.content = 'width=device-width, initial-scale=1.0, maximum-scale=5.0';
+      document.head.appendChild(viewport);
+    }
+  }
+
+  function createStyles() {
+    if (document.getElementById('rich-editor-styles')) return;
+
+    ensureViewportMeta();
+
+    const style = document.createElement('style');
+    style.id = 'rich-editor-styles';
+    style.textContent = `
+      .rich-editor-container {
+        border: 1px solid #ccc;
+        border-radius: 4px;
+        overflow: hidden;
+      }
+
+      .rich-editor-toolbar {
+        display: flex;
+        flex-wrap: wrap;
+        gap: 4px;
+        padding: 8px;
+        background: #f5f5f5;
+        border-bottom: 1px solid #ccc;
+      }
+
+      .rich-editor-btn {
+        padding: 6px 12px;
+        border: 1px solid #ccc;
+        border-radius: 4px;
+        background: #fff;
+        cursor: pointer;
+        font-size: 14px;
+        min-width: 32px;
+        background-repeat: no-repeat;
+        background-position: center;
+        background-size: 24px 24px;
+        border: none;
+      }
+
+      .rich-editor-btn:hover {
+        background: #e9e9e9;
+        background-repeat: no-repeat;
+        background-position: center;
+        background-size: 24px 24px;
+      }
+
+      .rich-editor-btn:active {
+        background: #ddd;
+        background-repeat: no-repeat;
+        background-position: center;
+        background-size: 24px 24px;
+      }
+
+      .rich-editor-separator {
+        width: 1px;
+        background: #ccc;
+        margin: 0 4px;
+      }
+
+      .rich-editor-content {
+        min-height: 300px;
+        padding: 16px;
+        outline: none;
+        overflow-y: auto;
+      }
+
+      .rich-editor-content:empty:before {
+        content: attr(data-placeholder);
+        color: #999;
+        pointer-events: none;
+      }
+
+      .rich-editor-content img {
+        max-width: 100%;
+        height: auto;
+        border-radius: 4px;
+        margin: 8px 0;
+        cursor: pointer;
+      }
+
+      .rich-editor-content img.selected {
+        outline: 2px solid #0078ff;
+        outline-offset: 2px;
+      }
+
+      .rich-editor-resize-wrapper {
+        position: relative;
+        display: inline-block;
+        margin: 8px 0;
+      }
+
+      .rich-editor-resize-handle {
+        position: absolute;
+        width: 12px;
+        height: 12px;
+        background: #0078ff;
+        border: 2px solid white;
+        border-radius: 50%;
+        cursor: nwse-resize;
+        z-index: 10;
+      }
+
+      .rich-editor-resize-handle.nw {
+        top: -6px;
+        left: -6px;
+        cursor: nwse-resize;
+      }
+
+      .rich-editor-resize-handle.ne {
+        top: -6px;
+        right: -6px;
+        cursor: nesw-resize;
+      }
+
+      .rich-editor-resize-handle.sw {
+        bottom: -6px;
+        left: -6px;
+        cursor: nesw-resize;
+      }
+
+      .rich-editor-resize-handle.se {
+        bottom: -6px;
+        right: -6px;
+        cursor: nwse-resize;
+      }
+
+      .rich-editor-size-input {
+        position: absolute;
+        top: -35px;
+        left: 50%;
+        transform: translateX(-50%);
+        background: white;
+        border: 1px solid #ccc;
+        padding: 4px 8px;
+        border-radius: 4px;
+        font-size: 12px;
+        display: flex;
+        gap: 8px;
+        align-items: center;
+        box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+        z-index: 11;
+      }
+
+      .rich-editor-size-input input {
+        width: 60px;
+        padding: 2px 4px;
+        border: 1px solid #ccc;
+        border-radius: 2px;
+        font-size: 12px;
+      }
+
+      .rich-editor-size-input label {
+        font-size: 11px;
+        color: #666;
+      }
+
+      .rich-editor-content a {
+        color: #0078ff;
+        text-decoration: none;
+      }
+
+      .rich-editor-content a:hover {
+        text-decoration: underline;
+      }
+
+      .rich-editor-content a.note-link {
+        background: #e8f4ff;
+        padding: 2px 6px;
+        border-radius: 3px;
+      }
+
+      .rich-editor-content a.note-link:hover {
+        background: #d0e8ff;
+      }
+
+      .rich-editor-content .upload-placeholder {
+        display: inline-block;
+        padding: 8px 16px;
+        background: #f0f0f0;
+        border-radius: 4px;
+        color: #666;
+        font-style: italic;
+      }
+
+      .rich-editor-content .upload-placeholder.loading {
+        padding: 8px 16px;
+        background: #fffacd;
+        border-radius: 4px;
+        animation: pulse 1.5s ease-in-out infinite;
+      }
+
+      @keyframes pulse {
+        0%, 100% { opacity: 1; }
+        50% { opacity: 0.6; }
+      }
+
+      .rich-editor-upload-overlay {
+        position: absolute;
+        top: 0;
+        left: 0;
+        right: 0;
+        bottom: 0;
+        background: rgba(0, 120, 255, 0.1);
+        border: 2px dashed #0078ff;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        font-size: 18px;
+        color: #0078ff;
+        pointer-events: none;
+        z-index: 10;
+        padding: 20px;
+        text-align: center;
+      }
+
+      .rich-editor-status {
+        padding: 4px 8px;
+        font-size: 12px;
+        color: #666;
+        background: #f9f9f9;
+        border-top: 1px solid #eee;
+      }
+
+      .rich-editor-file-input {
+        display: none;
+      }
+
+      ol,ul {
+        padding: 16px;
+      }
+
+      /* Mobile optimizations */
+      @media (max-width: 720px) {
+        .rich-editor-toolbar {
+          padding: 6px;
+          gap: 3px;
+          overflow-x: auto;
+          -webkit-overflow-scrolling: touch;
+        }
+
+        .rich-editor-btn {
+          min-width: 44px;
+          min-height: 44px;
+          padding: 8px;
+          flex-shrink: 0;
+        }
+
+        .rich-editor-content {
+          min-height: 200px;
+          padding: 12px;
+          font-size: 16px; /* Prevents iOS zoom */
+          line-height: 1.6;
+        }
+
+        .rich-editor-content h1 {
+          font-size: 1.75em;
+        }
+
+        .rich-editor-content h2 {
+          font-size: 1.5em;
+        }
+
+        .rich-editor-content h3 {
+          font-size: 1.25em;
+        }
+
+        .rich-editor-status {
+          font-size: 11px;
+          padding: 6px 8px;
+        }
+
+        /* Mobile-friendly image resizing */
+        .rich-editor-resize-handle {
+          width: 20px;
+          height: 20px;
+          border-width: 3px;
+        }
+
+        .rich-editor-resize-handle.nw {
+          top: -10px;
+          left: -10px;
+        }
+
+        .rich-editor-resize-handle.ne {
+          top: -10px;
+          right: -10px;
+        }
+
+        .rich-editor-resize-handle.sw {
+          bottom: -10px;
+          left: -10px;
+        }
+
+        .rich-editor-resize-handle.se {
+          bottom: -10px;
+          right: -10px;
+        }
+
+        .rich-editor-size-input {
+          top: -40px;
+          padding: 6px 10px;
+        }
+
+        .rich-editor-size-input input {
+          width: 70px;
+          padding: 4px 6px;
+          font-size: 16px; /* Prevents iOS zoom */
+        }
+
+        /* Better touch targets for images */
+        .rich-editor-content img {
+          margin: 12px 0;
+        }
+
+        /* Hide drop overlay text on small screens */
+        .rich-editor-upload-overlay {
+          font-size: 16px;
+        }
+
+        /* Make link/note dialogs mobile-friendly */
+        .rich-editor-content a {
+          padding: 2px 0;
+        }
+      }
+
+      /* Extra small devices */
+      @media (max-width: 480px) {
+        .rich-editor-toolbar {
+          padding: 4px;
+          gap: 2px;
+        }
+
+        .rich-editor-btn {
+          min-width: 40px;
+          min-height: 40px;
+          padding: 6px;
+        }
+
+        .rich-editor-content {
+          padding: 10px;
+          font-size: 16px;
+        }
+
+        .rich-editor-size-input {
+          font-size: 11px;
+        }
+
+        .rich-editor-size-input input {
+          width: 60px;
+          font-size: 14px;
+        }
+      }
+    `;
+    document.head.appendChild(style);
+  }
+
+  class Editor {
+    constructor(elementId, options) {
+      this.options = { ...DEFAULT_OPTIONS, ...options };
+      this.container = document.getElementById(elementId);
+      this.state = {
+        readOnly: false,
+        selectedImage: null,
+        resizing: false
+      };
+
+      if (!this.container) {
+        throw new Error(`Element with id "${elementId}" not found`);
+      }
+
+      this.init();
+    }
+
+    init() {
+      createStyles();
+
+      // Create wrapper
+      this.wrapper = document.createElement('div');
+      this.wrapper.className = 'rich-editor-container';
+      this.wrapper.style.position = 'relative';
+
+      // Create toolbar
+      this.toolbar = createToolbar(this);
+      this.wrapper.appendChild(this.toolbar);
+
+      // Create content area
+      this.content = document.createElement('div');
+      this.content.className = 'rich-editor-content';
+      this.content.contentEditable = true;
+      this.content.dataset.placeholder = this.options.placeholder;
+      this.wrapper.appendChild(this.content);
+
+      // Create status bar
+      this.status = document.createElement('div');
+      this.status.className = 'rich-editor-status';
+      this.status.textContent = 'Ready';
+      this.wrapper.appendChild(this.status);
+
+      // Create hidden file input
+      this.fileInput = document.createElement('input');
+      this.fileInput.type = 'file';
+      this.fileInput.className = 'rich-editor-file-input';
+      this.fileInput.multiple = true;
+      this.fileInput.accept = 'image/*,application/pdf,.doc,.docx,.txt';
+      this.wrapper.appendChild(this.fileInput);
+
+      // Add to container
+      this.container.appendChild(this.wrapper);
+
+      // Setup event listeners
+      this.setupEvents();
+
+      // Setup debounced save
+      if (this.options.saveCallback) {
+        this.debouncedSave = debounce(() => {
+          this.save();
+        }, this.options.debounceMs);
+      }
+    }
+
+    setupEvents() {
+      // Input events for auto-save
+      this.content.addEventListener('input', () => {
+        if (this.debouncedSave) {
+          this.setStatus('Editing...');
+          this.debouncedSave();
+        }
+      });
+
+      // Handle paste
+      this.content.addEventListener('paste', (e) => {
+        const items = e.clipboardData?.items;
+        if (!items) return;
+
+        for (const item of items) {
+          if (item.type.startsWith('image/') || item.kind === 'file') {
+            e.preventDefault();
+            const file = item.getAsFile();
+            if (file) this.uploadFile(file);
+            return;
+          }
+        }
+      });
+
+      // Handle drop
+      this.content.addEventListener('dragover', (e) => {
+        e.preventDefault();
+        this.showDropOverlay();
+      });
+
+      this.content.addEventListener('dragleave', (e) => {
+        e.preventDefault();
+        this.hideDropOverlay();
+      });
+
+      this.content.addEventListener('drop', (e) => {
+        e.preventDefault();
+        this.hideDropOverlay();
+
+        const files = e.dataTransfer?.files;
+        if (files && files.length > 0) {
+          for (const file of files) {
+            this.uploadFile(file);
+          }
+        }
+      });
+
+      // Handle /upload command
+      this.content.addEventListener('keydown', (e) => {
+        if (e.key === 'Enter') {
+          const selection = window.getSelection();
+          const node = selection.anchorNode;
+          if (node && node.textContent) {
+            const text = node.textContent;
+            if (text.trim() === '/upload') {
+              e.preventDefault();
+              node.textContent = '';
+              this.triggerFileUpload();
+            }
+          }
+        }
+      });
+
+      // File input change
+      this.fileInput.addEventListener('change', (e) => {
+        const files = e.target.files;
+        if (files && files.length > 0) {
+          for (const file of files) {
+            this.uploadFile(file);
+          }
+        }
+        this.fileInput.value = '';
+      });
+
+      // Handle keyboard shortcuts
+      this.content.addEventListener('keydown', (e) => {
+        if (e.ctrlKey || e.metaKey) {
+          switch (e.key.toLowerCase()) {
+            case 'b':
+              e.preventDefault();
+              this.execCommand('bold');
+              break;
+            case 'i':
+              e.preventDefault();
+              this.execCommand('italic');
+              break;
+            case 's':
+              e.preventDefault();
+              this.save();
+              break;
+          }
+        }
+      });
+
+      // Handle image clicks for resizing
+      this.content.addEventListener('click', (e) => {
+        if (e.target.tagName === 'IMG') {
+          e.preventDefault();
+          this.selectImage(e.target);
+        } else if (!e.target.closest('.rich-editor-resize-wrapper')) {
+          this.deselectImage();
+        }
+      });
+
+      // Deselect image when clicking outside
+      document.addEventListener('click', (e) => {
+        if (!this.content.contains(e.target)) {
+          this.deselectImage();
+        }
+      });
+    }
+
+    showDropOverlay() {
+      if (this.dropOverlay) return;
+
+      this.dropOverlay = document.createElement('div');
+      this.dropOverlay.className = 'rich-editor-upload-overlay';
+      this.dropOverlay.textContent = 'Drop files here to upload';
+      this.wrapper.appendChild(this.dropOverlay);
+    }
+
+    hideDropOverlay() {
+      if (this.dropOverlay) {
+        this.dropOverlay.remove();
+        this.dropOverlay = null;
+      }
+    }
+
+    triggerFileUpload() {
+      this.fileInput.click();
+    }
+
+    readOnly() {
+      this.state.readOnly = !this.state.readOnly;
+
+      if (this.state.readOnly)
+      {
+        this.content.contentEditable = false;
+        this.setStatus('Read-only mode');
+        const buttons = this.toolbar.querySelectorAll('.rich-editor-btn');
+        buttons.forEach(btn => {
+          if (btn.dataset.cmd !== 'readonly') {
+            btn.disabled = true;
+          }
+        });
+      }
+      else
+      {
+        // Enable editing
+        this.content.contentEditable = true;
+        this.content.style.backgroundColor = '';
+        this.setStatus('Ready');
+
+        // Enable toolbar buttons
+        const buttons = this.toolbar.querySelectorAll('.rich-editor-btn');
+        buttons.forEach(btn => {
+          btn.disabled = false;
+        });
+      }
+    }
+
+    async uploadFile(file) {
+      if (!this.options.uploadCallback) {
+        console.warn('No upload callback configured');
+        return;
+      }
+
+      // Insert loading placeholder
+      const placeholder = document.createElement('div');
+      placeholder.className = 'upload-placeholder loading';
+      placeholder.textContent = file.type.startsWith('image/')
+        ? `Processing ${file.name}... ⏳`
+        : `Uploading ${file.name}...`;
+
+      this.insertAtCursor(placeholder);
+
+      this.setStatus(`Uploading ${file.name}...`);
+
+      try {
+        const result = await this.options.uploadCallback(file);
+
+        if (result && result.url) {
+          // Replace placeholder with actual content
+          if (file.type.startsWith('image/')) {
+            const img = document.createElement('img');
+            img.src = result.url;
+            img.alt = file.name;
+
+            // Store original dimensions when image loads
+            img.onload = () => {
+              img.dataset.originalWidth = img.naturalWidth;
+              img.dataset.originalHeight = img.naturalHeight;
+            };
+
+            placeholder.replaceWith(img);
+          } else {
+            const link = document.createElement('a');
+            link.href = result.url;
+            link.textContent = file.name;
+            link.target = '_blank';
+            placeholder.replaceWith(link);
+          }
+
+          this.setStatus(`Uploaded ${file.name}`);
+          if (this.debouncedSave) this.debouncedSave();
+        } else {
+          placeholder.textContent = `Failed to upload ${file.name}`;
+          placeholder.className = 'upload-placeholder';
+          this.setStatus('Upload failed');
+        }
+      } catch (error) {
+        console.error('Upload error:', error);
+        placeholder.textContent = `Error: ${error.message}`;
+        placeholder.className = 'upload-placeholder';
+        this.setStatus('Upload error');
+      }
+    }
+
+    insertAtCursor(element) {
+      const selection = window.getSelection();
+      if (selection.rangeCount > 0) {
+        const range = selection.getRangeAt(0);
+        range.deleteContents();
+        range.insertNode(element);
+        range.setStartAfter(element);
+        range.collapse(true);
+        selection.removeAllRanges();
+        selection.addRange(range);
+      } else {
+        this.content.appendChild(element);
+      }
+      this.content.focus();
+    }
+
+    execCommand(cmd) {
+      this.content.focus();
+
+      switch (cmd) {
+        case 'h1':
+        case 'h2':
+        case 'h3':
+        case 'h4':
+        case 'h5':
+        case 'h6':
+          document.execCommand('formatBlock', false, cmd);
+          break;
+        case 'p':
+          document.execCommand('formatBlock', false, 'p');
+          break;
+        case 'ul':
+          document.execCommand('insertUnorderedList', false, null);
+          break;
+        case 'ol':
+          document.execCommand('insertOrderedList', false, null);
+          break;
+        case 'bold':
+          document.execCommand('bold', false, null);
+          break;
+        case 'italic':
+          document.execCommand('italic', false, null);
+          break;
+        case 'link':
+          this.insertLink();
+          break;
+        case 'notelink':
+          this.insertNoteLink();
+          break;
+        case 'upload':
+          this.triggerFileUpload();
+          break;
+        case 'readonly':
+          this.readOnly();
+          break;
+      }
+
+      if (this.debouncedSave) this.debouncedSave();
+    }
+
+    insertLink() {
+      const url = prompt('Enter URL:');
+      if (!url) return;
+
+      const selection = window.getSelection();
+      const selectedText = selection.toString() || url;
+
+      const link = document.createElement('a');
+      link.href = url;
+      link.textContent = selectedText;
+      link.target = '_blank';
+
+      if (selection.rangeCount > 0) {
+        const range = selection.getRangeAt(0);
+        range.deleteContents();
+        range.insertNode(link);
+        range.setStartAfter(link);
+        range.collapse(true);
+        selection.removeAllRanges();
+        selection.addRange(range);
+      }
+    }
+
+    insertNoteLink() {
+      const noteId = prompt('Enter note ID (e.g., my-ideas):');
+      if (!noteId) return;
+
+      const sanitizedId = noteId.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
+      const selection = window.getSelection();
+      const selectedText = selection.toString() || sanitizedId;
+
+      const link = document.createElement('a');
+      link.href = '/notes/' + encodeURIComponent(sanitizedId);
+      link.textContent = selectedText;
+      link.className = 'note-link';
+
+      if (selection.rangeCount > 0) {
+        const range = selection.getRangeAt(0);
+        range.deleteContents();
+        range.insertNode(link);
+        range.setStartAfter(link);
+        range.collapse(true);
+        selection.removeAllRanges();
+        selection.addRange(range);
+      }
+    }
+
+    setStatus(text) {
+      this.status.textContent = text;
+    }
+
+    async save() {
+      if (!this.options.saveCallback) return;
+
+      this.setStatus('Saving...');
+
+      try {
+        await this.options.saveCallback(this.getContent());
+        this.setStatus('Saved');
+      } catch (error) {
+        console.error('Save error:', error);
+        this.setStatus('Save failed');
+      }
+    }
+
+    getContent() {
+      return this.content.innerHTML;
+    }
+
+    setContent(html) {
+      this.content.innerHTML = html;
+    }
+
+    getText() {
+      return this.content.textContent;
+    }
+
+    clear() {
+      this.content.innerHTML = '';
+    }
+
+    focus() {
+      this.content.focus();
+    }
+
+    selectImage(img) {
+      if (this.state.readOnly) return;
+
+      // Deselect previous image
+      this.deselectImage();
+
+      this.state.selectedImage = img;
+      img.classList.add('selected');
+
+      // Wrap image in resize container if not already wrapped
+      if (!img.parentElement.classList.contains('rich-editor-resize-wrapper')) {
+        const wrapper = document.createElement('div');
+        wrapper.className = 'rich-editor-resize-wrapper';
+        wrapper.contentEditable = false;
+
+        // Store original dimensions if not set
+        if (!img.dataset.originalWidth) {
+          img.dataset.originalWidth = img.naturalWidth;
+          img.dataset.originalHeight = img.naturalHeight;
+        }
+
+        img.parentNode.insertBefore(wrapper, img);
+        wrapper.appendChild(img);
+      }
+
+      const wrapper = img.parentElement;
+
+      // Add resize handles
+      const corners = ['nw', 'ne', 'sw', 'se'];
+      corners.forEach(corner => {
+        const handle = document.createElement('div');
+        handle.className = `rich-editor-resize-handle ${corner}`;
+        handle.dataset.corner = corner;
+
+        // Mouse events
+        handle.addEventListener('mousedown', (e) => {
+          e.preventDefault();
+          e.stopPropagation();
+          this.startResize(e, img, corner);
+        });
+
+        // Touch events for mobile
+        handle.addEventListener('touchstart', (e) => {
+          e.preventDefault();
+          e.stopPropagation();
+          const touch = e.touches[0];
+          this.startResize(touch, img, corner, true);
+        });
+
+        wrapper.appendChild(handle);
+      });
+
+      // Add size input
+      const sizeInput = document.createElement('div');
+      sizeInput.className = 'rich-editor-size-input';
+      sizeInput.innerHTML = `
+        <label>Width:</label>
+        <input type="number" class="width-input" value="${img.width}" min="50" max="2000">
+        <label>px</label>
+      `;
+
+      const widthInput = sizeInput.querySelector('.width-input');
+      widthInput.addEventListener('input', (e) => {
+        const newWidth = parseInt(e.target.value);
+        if (newWidth && newWidth > 0) {
+          this.resizeImage(img, newWidth);
+        }
+      });
+
+      wrapper.appendChild(sizeInput);
+    }
+
+    deselectImage() {
+      if (!this.state.selectedImage) return;
+
+      const img = this.state.selectedImage;
+      img.classList.remove('selected');
+
+      const wrapper = img.parentElement;
+      if (wrapper && wrapper.classList.contains('rich-editor-resize-wrapper')) {
+        // Remove resize handles and size input
+        wrapper.querySelectorAll('.rich-editor-resize-handle, .rich-editor-size-input').forEach(el => el.remove());
+
+        // Unwrap image
+        wrapper.parentNode.insertBefore(img, wrapper);
+        wrapper.remove();
+      }
+
+      this.state.selectedImage = null;
+    }
+
+    startResize(e, img, corner, isTouch = false) {
+      this.state.resizing = true;
+
+      const startX = e.clientX;
+      const startY = e.clientY;
+      const startWidth = img.width;
+      const startHeight = img.height;
+      const aspectRatio = startWidth / startHeight;
+
+      const onMove = (e) => {
+        if (!this.state.resizing) return;
+
+        const clientX = isTouch ? e.touches[0].clientX : e.clientX;
+        const clientY = isTouch ? e.touches[0].clientY : e.clientY;
+
+        let deltaX = clientX - startX;
+        let deltaY = clientY - startY;
+
+        // Adjust delta based on corner
+        if (corner === 'nw' || corner === 'sw') {
+          deltaX = -deltaX;
+        }
+        if (corner === 'nw' || corner === 'ne') {
+          deltaY = -deltaY;
+        }
+
+        // Use the larger delta to maintain aspect ratio
+        const delta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY;
+        const newWidth = Math.max(50, startWidth + delta);
+
+        this.resizeImage(img, newWidth, aspectRatio);
+      };
+
+      const onEnd = () => {
+        this.state.resizing = false;
+
+        if (isTouch) {
+          document.removeEventListener('touchmove', onMove);
+          document.removeEventListener('touchend', onEnd);
+        } else {
+          document.removeEventListener('mousemove', onMove);
+          document.removeEventListener('mouseup', onEnd);
+        }
+
+        // Update width input
+        const wrapper = img.parentElement;
+        const widthInput = wrapper.querySelector('.width-input');
+        if (widthInput) {
+          widthInput.value = img.width;
+        }
+
+        if (this.debouncedSave) this.debouncedSave();
+      };
+
+      if (isTouch) {
+        document.addEventListener('touchmove', onMove, { passive: false });
+        document.addEventListener('touchend', onEnd);
+      } else {
+        document.addEventListener('mousemove', onMove);
+        document.addEventListener('mouseup', onEnd);
+      }
+    }
+
+    resizeImage(img, newWidth, aspectRatio = null) {
+      if (!aspectRatio) {
+        const originalWidth = parseInt(img.dataset.originalWidth) || img.naturalWidth;
+        const originalHeight = parseInt(img.dataset.originalHeight) || img.naturalHeight;
+        aspectRatio = originalWidth / originalHeight;
+      }
+
+      const newHeight = newWidth / aspectRatio;
+
+      img.width = newWidth;
+      img.height = newHeight;
+      img.style.width = newWidth + 'px';
+      img.style.height = newHeight + 'px';
+    }
+  }
+
+  return {
+    init: function(elementId, options) {
+      return new Editor(elementId, options);
+    }
+  };
+})();
+
+// Export for module systems
+if (typeof module !== 'undefined' && module.exports) {
+  module.exports = RichEditor;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/s3/BUILD	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,56 @@
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+
+# File group
+filegroup(
+  name = "s3_hdrs",
+  srcs = ["s3_uploader.h"],
+  visibility = ["//visibility:public"],
+)
+
+# Main S3 uploader library (platform-aware)
+alias(
+  name = "s3",
+  actual = select({
+    "//config:macos":  ":s3_macos",
+    "//config:linux":  ":s3_linux",
+    "//conditions:default": ":s3_linux",
+  }),
+  visibility = ["//visibility:public"],
+)
+
+cc_library(
+  name = "s3_macos",
+  srcs = ["s3_uploader.c"],
+  hdrs = [":s3_hdrs"],
+  deps = [
+    "//dowa:dowa",
+    "//seobeo:seobeo",
+    "@openssl//:ssl",
+  ],
+  target_compatible_with = [
+    "@platforms//os:osx",
+  ],
+  visibility = ["//visibility:public"],
+)
+
+cc_library(
+  name = "s3_linux",
+  srcs = ["s3_uploader.c"],
+  hdrs = [":s3_hdrs"],
+  deps = [
+    "//dowa:dowa",
+    "//seobeo:seobeo",
+    "@openssl//:ssl",
+  ],
+  target_compatible_with = [
+    "@platforms//os:linux",
+  ],
+  visibility = ["//visibility:public"],
+)
+
+# Alias for convenience
+alias(
+  name = "s3_uploader",
+  actual = ":s3",
+  visibility = ["//visibility:public"],
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/s3/s3_uploader.c	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,787 @@
+#include "s3_uploader.h"
+#include "seobeo/seobeo.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <ctype.h>
+
+#include <openssl/hmac.h>
+#include <openssl/sha.h>
+
+#define S3_ARENA_SIZE       (10 * ONE_MEGA_BYTE)
+#define S3_RESULT_ARENA_SIZE (64 * 1024)
+#define S3_SERVICE_NAME     "s3"
+#define S3_AWS4_REQUEST     "aws4_request"
+#define S3_ALGORITHM        "AWS4-HMAC-SHA256"
+
+// --- Internal Structures --- //
+
+typedef struct {
+  char date[9];       // YYYYMMDD
+  char datetime[17];  // YYYYMMDDTHHMMSSZ
+} S3_Timestamp;
+
+// --- Forward Declarations --- //
+
+static void     s3__get_timestamp(S3_Timestamp *p_ts);
+static void     s3__sha256_hex(const uint8 *data, size_t len, char *out);
+static void     s3__hmac_sha256(const uint8 *key, size_t key_len,
+                                const uint8 *data, size_t data_len,
+                                uint8 *out);
+static void     s3__hex_encode(const uint8 *data, size_t len, char *out);
+static char    *s3__uri_encode(const char *str, Dowa_Arena *p_arena);
+static char    *s3__build_canonical_request(const char *method,
+                                            const char *uri,
+                                            const char *query,
+                                            const char *headers,
+                                            const char *signed_headers,
+                                            const char *payload_hash,
+                                            Dowa_Arena *p_arena);
+static char    *s3__build_string_to_sign(const char *datetime,
+                                         const char *date,
+                                         const char *region,
+                                         const char *canonical_request,
+                                         Dowa_Arena *p_arena);
+static void     s3__calculate_signing_key(const char *secret_key,
+                                          const char *date,
+                                          const char *region,
+                                          uint8 *out);
+static char    *s3__build_authorization_header(const char *access_key,
+                                               const char *date,
+                                               const char *region,
+                                               const char *signed_headers,
+                                               const uint8 *signing_key,
+                                               const char *string_to_sign,
+                                               Dowa_Arena *p_arena);
+static uint8   *s3__load_file(const char *path, size_t *p_size);
+
+// --- Public API Implementation --- //
+
+S3_Result S3_Upload_File(const S3_Config *p_config,
+                         const char *local_path,
+                         const char *s3_key)
+{
+  const char *content_type = S3_Guess_Content_Type(local_path);
+  return S3_Upload_File_With_Content_Type(p_config, local_path, s3_key, content_type);
+}
+
+S3_Result S3_Upload_File_With_Content_Type(const S3_Config *p_config,
+                                            const char *local_path,
+                                            const char *s3_key,
+                                            const char *content_type)
+{
+  S3_Result result = {0};
+  result.p_arena = Dowa_Arena_Create(S3_RESULT_ARENA_SIZE);
+
+  size_t file_size = 0;
+  uint8 *file_data = s3__load_file(local_path, &file_size);
+  if (!file_data)
+  {
+    result.success = FALSE;
+    result.status_code = 0;
+    result.error_message = Dowa_String_Copy_Arena("Failed to read file", result.p_arena);
+    return result;
+  }
+
+  result = S3_Upload_Data(p_config, file_data, file_size, s3_key, content_type);
+  free(file_data);
+
+  return result;
+}
+
+S3_Result S3_Upload_Data(const S3_Config *p_config,
+                         const uint8 *data,
+                         size_t data_length,
+                         const char *s3_key,
+                         const char *content_type)
+{
+  S3_Result result = {0};
+  result.p_arena = Dowa_Arena_Create(S3_RESULT_ARENA_SIZE);
+
+  if (!p_config || !data || !s3_key)
+  {
+    result.success = FALSE;
+    result.error_message = Dowa_String_Copy_Arena("Invalid parameters", result.p_arena);
+    return result;
+  }
+
+  Dowa_Arena *p_arena = Dowa_Arena_Create(S3_ARENA_SIZE);
+
+  // Get timestamp
+  S3_Timestamp ts;
+  s3__get_timestamp(&ts);
+
+  // Calculate payload hash
+  char payload_hash[65];
+  s3__sha256_hex(data, data_length, payload_hash);
+
+  // Build host
+  char *host;
+  if (p_config->endpoint)
+  {
+    host = Dowa_String_Copy_Arena((char *)p_config->endpoint, p_arena);
+  }
+  else if (p_config->use_path_style)
+  {
+    size_t host_len = strlen("s3.") + strlen(p_config->region) + strlen(".amazonaws.com") + 1;
+    host = Dowa_Arena_Allocate(p_arena, host_len);
+    snprintf(host, host_len, "s3.%s.amazonaws.com", p_config->region);
+  }
+  else
+  {
+    size_t host_len = strlen(p_config->bucket) + strlen(".s3.") + strlen(p_config->region) + strlen(".amazonaws.com") + 1;
+    host = Dowa_Arena_Allocate(p_arena, host_len);
+    snprintf(host, host_len, "%s.s3.%s.amazonaws.com", p_config->bucket, p_config->region);
+  }
+
+  // Build URI path
+  char *uri_path;
+  char *encoded_key = s3__uri_encode(s3_key, p_arena);
+  if (p_config->use_path_style)
+  {
+    size_t uri_len = strlen("/") + strlen(p_config->bucket) + strlen("/") + strlen(encoded_key) + 1;
+    uri_path = Dowa_Arena_Allocate(p_arena, uri_len);
+    snprintf(uri_path, uri_len, "/%s/%s", p_config->bucket, encoded_key);
+  }
+  else
+  {
+    size_t uri_len = strlen("/") + strlen(encoded_key) + 1;
+    uri_path = Dowa_Arena_Allocate(p_arena, uri_len);
+    snprintf(uri_path, uri_len, "/%s", encoded_key);
+  }
+
+  // Build canonical headers (must be sorted alphabetically)
+  // Note: Content-Length is NOT signed for S3 PUT requests (it's sent but not in signature)
+  size_t headers_len = 512 + strlen(host) + strlen(content_type);
+  char *canonical_headers = Dowa_Arena_Allocate(p_arena, headers_len);
+  snprintf(canonical_headers, headers_len,
+           "content-type:%s\n"
+           "host:%s\n"
+           "x-amz-content-sha256:%s\n"
+           "x-amz-date:%s\n",
+           content_type,
+           host,
+           payload_hash,
+           ts.datetime);
+
+  const char *signed_headers = "content-type;host;x-amz-content-sha256;x-amz-date";
+
+  // Build canonical request
+  char *canonical_request = s3__build_canonical_request("PUT",
+                                                         uri_path,
+                                                         "",  // No query string
+                                                         canonical_headers,
+                                                         signed_headers,
+                                                         payload_hash,
+                                                         p_arena);
+
+  // Build string to sign
+  char *string_to_sign = s3__build_string_to_sign(ts.datetime,
+                                                   ts.date,
+                                                   p_config->region,
+                                                   canonical_request,
+                                                   p_arena);
+
+  // Calculate signing key
+  uint8 signing_key[32];
+  s3__calculate_signing_key(p_config->secret_access_key,
+                            ts.date,
+                            p_config->region,
+                            signing_key);
+
+  // Build authorization header
+  char *auth_header = s3__build_authorization_header(p_config->access_key_id,
+                                                      ts.date,
+                                                      p_config->region,
+                                                      signed_headers,
+                                                      signing_key,
+                                                      string_to_sign,
+                                                      p_arena);
+
+  // Build URL
+  size_t url_len = strlen("https://") + strlen(host) + strlen(uri_path) + 1;
+  char *url = Dowa_Arena_Allocate(p_arena, url_len);
+  snprintf(url, url_len, "https://%s%s", host, uri_path);
+
+  // Execute request using seobeo
+  Seobeo_Client_Request *p_req = Seobeo_Client_Request_Create(url);
+  Seobeo_Client_Request_Set_Method(p_req, "PUT");
+  Seobeo_Client_Request_Set_Body(p_req, (const char *)data, data_length);
+  Seobeo_Client_Request_Add_Header_Map(p_req, "Connection", "keep-alive");
+  Seobeo_Client_Request_Add_Header_Map(p_req, "Content-Type", content_type);
+  Seobeo_Client_Request_Add_Header_Map(p_req, "x-amz-date", ts.datetime);
+  Seobeo_Client_Request_Add_Header_Map(p_req, "x-amz-content-sha256", payload_hash);
+  Seobeo_Client_Request_Add_Header_Map(p_req, "Authorization", auth_header);
+
+  Seobeo_Log(SEOBEO_DEBUG, "[S3] Uploading %zu bytes to %s\n", data_length, url);
+  Seobeo_Log(SEOBEO_DEBUG, "[S3] Content-Type: %s\n", content_type);
+  Seobeo_Log(SEOBEO_DEBUG, "[S3] x-amz-date: %s\n", ts.datetime);
+  Seobeo_Log(SEOBEO_DEBUG, "[S3] x-amz-content-sha256: %s\n", payload_hash);
+  Seobeo_Log(SEOBEO_DEBUG, "[S3] Authorization: %.80s...\n", auth_header);
+
+  Seobeo_Client_Response *p_resp = Seobeo_Client_Request_Execute(p_req);
+
+  if (p_resp)
+  {
+    result.status_code = p_resp->status_code;
+    if (p_resp->status_code >= 200 && p_resp->status_code < 300)
+    {
+      result.success = TRUE;
+
+      // Extract ETag from response headers
+      if (p_resp->headers)
+      {
+        char *etag = Dowa_HashMap_Get(p_resp->headers, "etag");
+        if (!etag) etag = Dowa_HashMap_Get(p_resp->headers, "ETag");
+        if (etag)
+        {
+          result.etag = Dowa_String_Copy_Arena(etag, result.p_arena);
+        }
+      }
+    }
+    else
+    {
+      result.success = FALSE;
+      if (p_resp->body && p_resp->body_length > 0)
+      {
+        result.error_message = Dowa_String_Copy_Arena(p_resp->body, result.p_arena);
+      }
+      else
+      {
+        result.error_message = Dowa_String_Copy_Arena("Upload failed", result.p_arena);
+      }
+    }
+    Seobeo_Client_Response_Destroy(p_resp);
+  }
+  else
+  {
+    result.success = FALSE;
+    result.error_message = Dowa_String_Copy_Arena("Failed to execute request", result.p_arena);
+  }
+
+  Seobeo_Client_Request_Destroy(p_req);
+  Dowa_Arena_Free(p_arena);
+
+  return result;
+}
+
+void S3_Result_Destroy(S3_Result *p_result)
+{
+  if (p_result && p_result->p_arena)
+  {
+    Dowa_Arena_Free(p_result->p_arena);
+    p_result->p_arena = NULL;
+    p_result->error_message = NULL;
+    p_result->etag = NULL;
+  }
+}
+
+// --- Presigned URL Implementation --- //
+
+static char *s3__uri_encode_strict(const char *str, Dowa_Arena *p_arena)
+{
+  // Stricter encoding for query string values (no forward slash allowed)
+  if (!str) return NULL;
+
+  size_t len = strlen(str);
+  size_t max_len = len * 3 + 1;
+  char *out = Dowa_Arena_Allocate(p_arena, max_len);
+  char *p = out;
+
+  for (size_t i = 0; i < len; i++)
+  {
+    char c = str[i];
+    if ((c >= 'A' && c <= 'Z') ||
+        (c >= 'a' && c <= 'z') ||
+        (c >= '0' && c <= '9') ||
+        c == '-' || c == '_' || c == '.' || c == '~')
+    {
+      *p++ = c;
+    }
+    else
+    {
+      sprintf(p, "%%%02X", (unsigned char)c);
+      p += 3;
+    }
+  }
+  *p = '\0';
+  return out;
+}
+
+static S3_Presigned_URL s3__presign_url(const S3_Config *p_config,
+                                         const char *s3_key,
+                                         const char *method,
+                                         const char *content_type,
+                                         int32 expires_seconds)
+{
+  S3_Presigned_URL result = {0};
+  result.p_arena = Dowa_Arena_Create(S3_RESULT_ARENA_SIZE);
+
+  if (!p_config || !s3_key)
+  {
+    result.success = FALSE;
+    result.error_message = Dowa_String_Copy_Arena("Invalid parameters", result.p_arena);
+    return result;
+  }
+
+  Dowa_Arena *p_arena = Dowa_Arena_Create(S3_ARENA_SIZE);
+
+  // Get timestamp
+  S3_Timestamp ts;
+  s3__get_timestamp(&ts);
+
+  // Build host
+  char *host;
+  if (p_config->endpoint)
+  {
+    host = Dowa_String_Copy_Arena((char *)p_config->endpoint, p_arena);
+  }
+  else if (p_config->use_path_style)
+  {
+    size_t host_len = strlen("s3.") + strlen(p_config->region) + strlen(".amazonaws.com") + 1;
+    host = Dowa_Arena_Allocate(p_arena, host_len);
+    snprintf(host, host_len, "s3.%s.amazonaws.com", p_config->region);
+  }
+  else
+  {
+    size_t host_len = strlen(p_config->bucket) + strlen(".s3.") + strlen(p_config->region) + strlen(".amazonaws.com") + 1;
+    host = Dowa_Arena_Allocate(p_arena, host_len);
+    snprintf(host, host_len, "%s.s3.%s.amazonaws.com", p_config->bucket, p_config->region);
+  }
+
+  // Build URI path
+  char *uri_path;
+  char *encoded_key = s3__uri_encode(s3_key, p_arena);
+  if (p_config->use_path_style)
+  {
+    size_t uri_len = strlen("/") + strlen(p_config->bucket) + strlen("/") + strlen(encoded_key) + 1;
+    uri_path = Dowa_Arena_Allocate(p_arena, uri_len);
+    snprintf(uri_path, uri_len, "/%s/%s", p_config->bucket, encoded_key);
+  }
+  else
+  {
+    size_t uri_len = strlen("/") + strlen(encoded_key) + 1;
+    uri_path = Dowa_Arena_Allocate(p_arena, uri_len);
+    snprintf(uri_path, uri_len, "/%s", encoded_key);
+  }
+
+  // Build credential scope
+  size_t scope_len = strlen(p_config->access_key_id) + strlen(ts.date) + strlen(p_config->region) +
+                     strlen(S3_SERVICE_NAME) + strlen(S3_AWS4_REQUEST) + 10;
+  char *credential = Dowa_Arena_Allocate(p_arena, scope_len);
+  snprintf(credential, scope_len, "%s/%s/%s/%s/%s",
+           p_config->access_key_id, ts.date, p_config->region, S3_SERVICE_NAME, S3_AWS4_REQUEST);
+
+  char *encoded_credential = s3__uri_encode_strict(credential, p_arena);
+
+  // Build signed headers (host is required, content-type for PUT)
+  const char *signed_headers;
+  if (content_type && strcmp(method, "PUT") == 0)
+  {
+    signed_headers = "content-type;host";
+  }
+  else
+  {
+    signed_headers = "host";
+  }
+
+  // Build canonical query string (must be sorted alphabetically)
+  char expires_str[16];
+  snprintf(expires_str, sizeof(expires_str), "%d", expires_seconds);
+
+  // URL-encode signed headers (semicolon becomes %3B)
+  char *encoded_signed_headers = s3__uri_encode_strict(signed_headers, p_arena);
+
+  size_t query_len = 1024 + strlen(encoded_credential);
+  char *canonical_query = Dowa_Arena_Allocate(p_arena, query_len);
+
+  snprintf(canonical_query, query_len,
+           "X-Amz-Algorithm=%s"
+           "&X-Amz-Credential=%s"
+           "&X-Amz-Date=%s"
+           "&X-Amz-Expires=%s"
+           "&X-Amz-SignedHeaders=%s",
+           S3_ALGORITHM,
+           encoded_credential,
+           ts.datetime,
+           expires_str,
+           encoded_signed_headers);
+
+  // Build canonical headers
+  size_t headers_len = 256 + strlen(host) + (content_type ? strlen(content_type) : 0);
+  char *canonical_headers = Dowa_Arena_Allocate(p_arena, headers_len);
+
+  if (content_type && strcmp(method, "PUT") == 0)
+  {
+    snprintf(canonical_headers, headers_len,
+             "content-type:%s\nhost:%s\n",
+             content_type, host);
+  }
+  else
+  {
+    snprintf(canonical_headers, headers_len, "host:%s\n", host);
+  }
+
+  // For presigned URLs, payload is UNSIGNED-PAYLOAD
+  const char *payload_hash = "UNSIGNED-PAYLOAD";
+
+  // Build canonical request
+  char *canonical_request = s3__build_canonical_request(method,
+                                                         uri_path,
+                                                         canonical_query,
+                                                         canonical_headers,
+                                                         signed_headers,
+                                                         payload_hash,
+                                                         p_arena);
+
+  // Build string to sign
+  char *string_to_sign = s3__build_string_to_sign(ts.datetime,
+                                                   ts.date,
+                                                   p_config->region,
+                                                   canonical_request,
+                                                   p_arena);
+
+  // Calculate signing key
+  uint8 signing_key[32];
+  s3__calculate_signing_key(p_config->secret_access_key,
+                            ts.date,
+                            p_config->region,
+                            signing_key);
+
+  // Calculate signature
+  uint8 signature[32];
+  s3__hmac_sha256(signing_key, 32,
+                  (const uint8 *)string_to_sign, strlen(string_to_sign),
+                  signature);
+
+  char signature_hex[65];
+  s3__hex_encode(signature, 32, signature_hex);
+
+  // Build final presigned URL
+  size_t url_len = strlen("https://") + strlen(host) + strlen(uri_path) + 1 +
+                   strlen(canonical_query) + strlen("&X-Amz-Signature=") + 64 + 1;
+  char *url = Dowa_Arena_Allocate(result.p_arena, url_len);
+  snprintf(url, url_len, "https://%s%s?%s&X-Amz-Signature=%s",
+           host, uri_path, canonical_query, signature_hex);
+
+  result.success = TRUE;
+  result.url = url;
+
+  Dowa_Arena_Free(p_arena);
+  return result;
+}
+
+S3_Presigned_URL S3_Presign_Put(const S3_Config *p_config,
+                                 const char *s3_key,
+                                 const char *content_type,
+                                 int32 expires_seconds)
+{
+  return s3__presign_url(p_config, s3_key, "PUT", content_type, expires_seconds);
+}
+
+S3_Presigned_URL S3_Presign_Get(const S3_Config *p_config,
+                                 const char *s3_key,
+                                 int32 expires_seconds)
+{
+  return s3__presign_url(p_config, s3_key, "GET", NULL, expires_seconds);
+}
+
+void S3_Presigned_URL_Destroy(S3_Presigned_URL *p_url)
+{
+  if (p_url && p_url->p_arena)
+  {
+    Dowa_Arena_Free(p_url->p_arena);
+    p_url->p_arena = NULL;
+    p_url->url = NULL;
+    p_url->error_message = NULL;
+  }
+}
+
+const char *S3_Guess_Content_Type(const char *filename)
+{
+  if (!filename) return "application/octet-stream";
+
+  const char *dot = strrchr(filename, '.');
+  if (!dot) return "application/octet-stream";
+
+  dot++;  // Skip the dot
+
+  // Common content types
+  if (strcasecmp(dot, "html") == 0 || strcasecmp(dot, "htm") == 0)
+    return "text/html";
+  if (strcasecmp(dot, "css") == 0)
+    return "text/css";
+  if (strcasecmp(dot, "js") == 0)
+    return "application/javascript";
+  if (strcasecmp(dot, "json") == 0)
+    return "application/json";
+  if (strcasecmp(dot, "xml") == 0)
+    return "application/xml";
+  if (strcasecmp(dot, "txt") == 0)
+    return "text/plain";
+  if (strcasecmp(dot, "csv") == 0)
+    return "text/csv";
+
+  // Images
+  if (strcasecmp(dot, "png") == 0)
+    return "image/png";
+  if (strcasecmp(dot, "jpg") == 0 || strcasecmp(dot, "jpeg") == 0)
+    return "image/jpeg";
+  if (strcasecmp(dot, "gif") == 0)
+    return "image/gif";
+  if (strcasecmp(dot, "svg") == 0)
+    return "image/svg+xml";
+  if (strcasecmp(dot, "webp") == 0)
+    return "image/webp";
+  if (strcasecmp(dot, "ico") == 0)
+    return "image/x-icon";
+
+  // Audio/Video
+  if (strcasecmp(dot, "mp3") == 0)
+    return "audio/mpeg";
+  if (strcasecmp(dot, "mp4") == 0)
+    return "video/mp4";
+  if (strcasecmp(dot, "webm") == 0)
+    return "video/webm";
+  if (strcasecmp(dot, "ogg") == 0)
+    return "audio/ogg";
+  if (strcasecmp(dot, "wav") == 0)
+    return "audio/wav";
+
+  // Documents
+  if (strcasecmp(dot, "pdf") == 0)
+    return "application/pdf";
+  if (strcasecmp(dot, "zip") == 0)
+    return "application/zip";
+  if (strcasecmp(dot, "gz") == 0 || strcasecmp(dot, "gzip") == 0)
+    return "application/gzip";
+  if (strcasecmp(dot, "tar") == 0)
+    return "application/x-tar";
+
+  // Fonts
+  if (strcasecmp(dot, "woff") == 0)
+    return "font/woff";
+  if (strcasecmp(dot, "woff2") == 0)
+    return "font/woff2";
+  if (strcasecmp(dot, "ttf") == 0)
+    return "font/ttf";
+  if (strcasecmp(dot, "otf") == 0)
+    return "font/otf";
+
+  return "application/octet-stream";
+}
+
+// --- Internal Implementation --- //
+
+static void s3__get_timestamp(S3_Timestamp *p_ts)
+{
+  time_t now = time(NULL);
+  struct tm *utc = gmtime(&now);
+
+  strftime(p_ts->date, sizeof(p_ts->date), "%Y%m%d", utc);
+  strftime(p_ts->datetime, sizeof(p_ts->datetime), "%Y%m%dT%H%M%SZ", utc);
+}
+
+static void s3__hex_encode(const uint8 *data, size_t len, char *out)
+{
+  static const char hex[] = "0123456789abcdef";
+  for (size_t i = 0; i < len; i++)
+  {
+    out[i * 2]     = hex[(data[i] >> 4) & 0x0F];
+    out[i * 2 + 1] = hex[data[i] & 0x0F];
+  }
+  out[len * 2] = '\0';
+}
+
+static void s3__sha256_hex(const uint8 *data, size_t len, char *out)
+{
+  uint8 hash[SHA256_DIGEST_LENGTH];
+  SHA256(data, len, hash);
+  s3__hex_encode(hash, SHA256_DIGEST_LENGTH, out);
+}
+
+static void s3__hmac_sha256(const uint8 *key, size_t key_len,
+                            const uint8 *data, size_t data_len,
+                            uint8 *out)
+{
+  unsigned int out_len = 0;
+  HMAC(EVP_sha256(), key, (int)key_len, data, data_len, out, &out_len);
+}
+
+static char *s3__uri_encode(const char *str, Dowa_Arena *p_arena)
+{
+  if (!str) return NULL;
+
+  size_t len = strlen(str);
+  // Worst case: every char becomes %XX (3 chars)
+  size_t max_len = len * 3 + 1;
+  char *out = Dowa_Arena_Allocate(p_arena, max_len);
+  char *p = out;
+
+  for (size_t i = 0; i < len; i++)
+  {
+    char c = str[i];
+    // Unreserved characters per RFC 3986
+    if ((c >= 'A' && c <= 'Z') ||
+        (c >= 'a' && c <= 'z') ||
+        (c >= '0' && c <= '9') ||
+        c == '-' || c == '_' || c == '.' || c == '~' || c == '/')
+    {
+      *p++ = c;
+    }
+    else
+    {
+      sprintf(p, "%%%02X", (unsigned char)c);
+      p += 3;
+    }
+  }
+  *p = '\0';
+  return out;
+}
+
+static char *s3__build_canonical_request(const char *method,
+                                          const char *uri,
+                                          const char *query,
+                                          const char *headers,
+                                          const char *signed_headers,
+                                          const char *payload_hash,
+                                          Dowa_Arena *p_arena)
+{
+  size_t len = strlen(method) + strlen(uri) + strlen(query) +
+               strlen(headers) + strlen(signed_headers) + strlen(payload_hash) + 10;
+  char *out = Dowa_Arena_Allocate(p_arena, len);
+  snprintf(out, len, "%s\n%s\n%s\n%s\n%s\n%s",
+           method, uri, query, headers, signed_headers, payload_hash);
+  return out;
+}
+
+static char *s3__build_string_to_sign(const char *datetime,
+                                       const char *date,
+                                       const char *region,
+                                       const char *canonical_request,
+                                       Dowa_Arena *p_arena)
+{
+  // Hash the canonical request
+  char canonical_hash[65];
+  s3__sha256_hex((const uint8 *)canonical_request, strlen(canonical_request), canonical_hash);
+
+  // Build credential scope
+  size_t scope_len = strlen(date) + strlen(region) + strlen(S3_SERVICE_NAME) + strlen(S3_AWS4_REQUEST) + 4;
+  char *scope = Dowa_Arena_Allocate(p_arena, scope_len);
+  snprintf(scope, scope_len, "%s/%s/%s/%s", date, region, S3_SERVICE_NAME, S3_AWS4_REQUEST);
+
+  // Build string to sign
+  size_t len = strlen(S3_ALGORITHM) + strlen(datetime) + strlen(scope) + strlen(canonical_hash) + 10;
+  char *out = Dowa_Arena_Allocate(p_arena, len);
+  snprintf(out, len, "%s\n%s\n%s\n%s", S3_ALGORITHM, datetime, scope, canonical_hash);
+
+  return out;
+}
+
+static void s3__calculate_signing_key(const char *secret_key,
+                                       const char *date,
+                                       const char *region,
+                                       uint8 *out)
+{
+  // AWS4 + SecretAccessKey
+  size_t key_len = 4 + strlen(secret_key) + 1;
+  char *k_secret = malloc(key_len);
+  snprintf(k_secret, key_len, "AWS4%s", secret_key);
+
+  uint8 k_date[32];
+  uint8 k_region[32];
+  uint8 k_service[32];
+
+  s3__hmac_sha256((const uint8 *)k_secret, strlen(k_secret),
+                  (const uint8 *)date, strlen(date), k_date);
+
+  s3__hmac_sha256(k_date, 32,
+                  (const uint8 *)region, strlen(region), k_region);
+
+  s3__hmac_sha256(k_region, 32,
+                  (const uint8 *)S3_SERVICE_NAME, strlen(S3_SERVICE_NAME), k_service);
+
+  s3__hmac_sha256(k_service, 32,
+                  (const uint8 *)S3_AWS4_REQUEST, strlen(S3_AWS4_REQUEST), out);
+
+  free(k_secret);
+}
+
+static char *s3__build_authorization_header(const char *access_key,
+                                             const char *date,
+                                             const char *region,
+                                             const char *signed_headers,
+                                             const uint8 *signing_key,
+                                             const char *string_to_sign,
+                                             Dowa_Arena *p_arena)
+{
+  // Calculate signature
+  uint8 signature[32];
+  s3__hmac_sha256(signing_key, 32,
+                  (const uint8 *)string_to_sign, strlen(string_to_sign),
+                  signature);
+
+  char signature_hex[65];
+  s3__hex_encode(signature, 32, signature_hex);
+
+  // Build credential
+  size_t cred_len = strlen(access_key) + strlen(date) + strlen(region) +
+                    strlen(S3_SERVICE_NAME) + strlen(S3_AWS4_REQUEST) + 10;
+  char *credential = Dowa_Arena_Allocate(p_arena, cred_len);
+  snprintf(credential, cred_len, "%s/%s/%s/%s/%s",
+           access_key, date, region, S3_SERVICE_NAME, S3_AWS4_REQUEST);
+
+  // Build full authorization header
+  size_t auth_len = strlen(S3_ALGORITHM) + strlen(credential) +
+                    strlen(signed_headers) + strlen(signature_hex) + 64;
+  char *auth = Dowa_Arena_Allocate(p_arena, auth_len);
+  snprintf(auth, auth_len,
+           "%s Credential=%s, SignedHeaders=%s, Signature=%s",
+           S3_ALGORITHM, credential, signed_headers, signature_hex);
+
+  return auth;
+}
+
+static uint8 *s3__load_file(const char *path, size_t *p_size)
+{
+  FILE *f = fopen(path, "rb");
+  if (!f)
+  {
+    *p_size = 0;
+    return NULL;
+  }
+
+  fseek(f, 0, SEEK_END);
+  long size = ftell(f);
+  fseek(f, 0, SEEK_SET);
+
+  if (size <= 0)
+  {
+    fclose(f);
+    *p_size = 0;
+    return NULL;
+  }
+
+  uint8 *data = malloc((size_t)size);
+  if (!data)
+  {
+    fclose(f);
+    *p_size = 0;
+    return NULL;
+  }
+
+  size_t read = fread(data, 1, (size_t)size, f);
+  fclose(f);
+
+  if (read != (size_t)size)
+  {
+    free(data);
+    *p_size = 0;
+    return NULL;
+  }
+
+  *p_size = (size_t)size;
+  return data;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/s3/s3_uploader.h	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,150 @@
+#ifndef S3_UPLOADER_H
+#define S3_UPLOADER_H
+
+/**
+ * S3 Uploader
+ * -----------
+ *
+ * Generic S3 bucket uploader using AWS Signature Version 4.
+ * Built on top of seobeo HTTP client.
+ *
+ * ## Examples
+ *
+ * ### 1. Upload a file from disk
+ *
+ * ```c
+ * S3_Config config = {
+ *   .access_key_id     = "AKIAIOSFODNN7EXAMPLE",
+ *   .secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ *   .region            = "us-east-1",
+ *   .bucket            = "my-bucket"
+ * };
+ *
+ * S3_Result result = S3_Upload_File(&config, "/path/to/local/file.txt", "uploads/file.txt");
+ * if (result.success)
+ * {
+ *   printf("Uploaded successfully!\n");
+ * }
+ * else
+ * {
+ *   printf("Error: %s\n", result.error_message);
+ * }
+ * S3_Result_Destroy(&result);
+ * ```
+ *
+ * ### 2. Upload raw data from memory
+ *
+ * ```c
+ * const char *data = "Hello, S3!";
+ * S3_Result result = S3_Upload_Data(&config, (const uint8 *)data, strlen(data),
+ *                                   "hello.txt", "text/plain");
+ * S3_Result_Destroy(&result);
+ * ```
+ *
+ * ### 3. Upload with custom content type
+ *
+ * ```c
+ * S3_Result result = S3_Upload_File_With_Content_Type(&config,
+ *                                                      "/path/to/image.png",
+ *                                                      "images/photo.png",
+ *                                                      "image/png");
+ * S3_Result_Destroy(&result);
+ * ```
+ */
+
+#include <stddef.h>
+
+#include "dowa/dowa.h"
+
+// S3 configuration
+typedef struct {
+  const char *access_key_id;      // AWS access key ID
+  const char *secret_access_key;  // AWS secret access key
+  const char *region;             // AWS region (e.g., "us-east-1")
+  const char *bucket;             // S3 bucket name
+  const char *endpoint;           // Optional custom endpoint (NULL for default AWS)
+  boolean     use_path_style;     // Use path-style URLs (bucket in path, not subdomain)
+} S3_Config;
+
+// Upload result
+typedef struct {
+  boolean     success;        // TRUE if upload succeeded
+  int32       status_code;    // HTTP status code
+  char       *error_message;  // Error message (NULL on success)
+  char       *etag;           // ETag of uploaded object (on success)
+  Dowa_Arena *p_arena;        // Internal arena (do not modify)
+} S3_Result;
+
+// --- Core API --- //
+
+/* Upload file from disk to S3. Content-Type is auto-detected. */
+extern S3_Result S3_Upload_File(const S3_Config *p_config,
+                                const char *local_path,
+                                const char *s3_key);
+
+/* Upload file with explicit content type. */
+extern S3_Result S3_Upload_File_With_Content_Type(const S3_Config *p_config,
+                                                   const char *local_path,
+                                                   const char *s3_key,
+                                                   const char *content_type);
+
+/* Upload raw data from memory to S3. */
+extern S3_Result S3_Upload_Data(const S3_Config *p_config,
+                                const uint8 *data,
+                                size_t data_length,
+                                const char *s3_key,
+                                const char *content_type);
+
+/* Free resources associated with result. */
+extern void S3_Result_Destroy(S3_Result *p_result);
+
+// --- Presigned URLs --- //
+
+// Presigned URL result
+typedef struct {
+  boolean     success;        // TRUE if generation succeeded
+  char       *url;            // The presigned URL (on success)
+  char       *error_message;  // Error message (on failure)
+  Dowa_Arena *p_arena;        // Internal arena (do not modify)
+} S3_Presigned_URL;
+
+/* Generate a presigned URL for uploading (PUT) a file.
+ *
+ * The client can use this URL to upload directly to S3 without credentials.
+ *
+ * Example:
+ *   S3_Presigned_URL url = S3_Presign_Put(&config, "uploads/file.txt", "image/png", 3600);
+ *   if (url.success) {
+ *     printf("Upload URL: %s\n", url.url);
+ *     // Client does: curl -X PUT -H "Content-Type: image/png" --data-binary @file.png "$URL"
+ *   }
+ *   S3_Presigned_URL_Destroy(&url);
+ */
+extern S3_Presigned_URL S3_Presign_Put(const S3_Config *p_config,
+                                        const char *s3_key,
+                                        const char *content_type,
+                                        int32 expires_seconds);
+
+/* Generate a presigned URL for downloading (GET) a file.
+ *
+ * Example:
+ *   S3_Presigned_URL url = S3_Presign_Get(&config, "uploads/file.txt", 3600);
+ *   if (url.success) {
+ *     printf("Download URL: %s\n", url.url);
+ *     // Client does: curl "$URL" -o file.txt
+ *   }
+ *   S3_Presigned_URL_Destroy(&url);
+ */
+extern S3_Presigned_URL S3_Presign_Get(const S3_Config *p_config,
+                                        const char *s3_key,
+                                        int32 expires_seconds);
+
+/* Free resources associated with presigned URL result. */
+extern void S3_Presigned_URL_Destroy(S3_Presigned_URL *p_url);
+
+// --- Utility Functions --- //
+
+/* Guess content type from file extension. Returns "application/octet-stream" if unknown. */
+extern const char *S3_Guess_Content_Type(const char *filename);
+
+#endif
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/s3/tests/BUILD	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,23 @@
+load("@rules_cc//cc:cc_binary.bzl", "cc_binary")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+cc_test(
+  name = "s3_uploader_test",
+  srcs = ["s3_uploader_test.c"],
+  deps = [
+    "//s3:s3",
+  ],
+  data = [
+    "//:env_file",
+  ],
+  visibility = ["//visibility:public"],
+)
+
+cc_binary(
+  name = "s3_uploader_bin",
+  srcs = ["s3_uploader_test.c"],
+  deps = [
+    "//s3:s3",
+  ],
+  visibility = ["//visibility:public"],
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/s3/tests/s3_uploader_test.c	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,192 @@
+#include "s3/s3_uploader.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+// Simple .env parser
+static char *parse_env_value(const char *env_content, const char *key)
+{
+  const char *pos = strstr(env_content, key);
+  if (!pos) return NULL;
+
+  pos += strlen(key);
+  if (*pos != '=') return NULL;
+  pos++;
+
+  const char *end = pos;
+  while (*end && *end != '\n' && *end != '\r') end++;
+
+  size_t len = end - pos;
+  char *value = malloc(len + 1);
+  memcpy(value, pos, len);
+  value[len] = '\0';
+  return value;
+}
+
+static char *load_file(const char *path)
+{
+  FILE *f = fopen(path, "r");
+  if (!f) return NULL;
+
+  fseek(f, 0, SEEK_END);
+  long size = ftell(f);
+  fseek(f, 0, SEEK_SET);
+
+  char *content = malloc(size + 1);
+  fread(content, 1, size, f);
+  content[size] = '\0';
+  fclose(f);
+  return content;
+}
+
+int main(int argc, char **argv)
+{
+  // Try to load .env file - check multiple locations for bazel runfiles
+  char *env_content = load_file(".env");
+  if (!env_content)
+  {
+    env_content = load_file("zenbu/.env");  // bazel runfiles path
+  }
+  if (!env_content)
+  {
+    env_content = load_file("../.env");
+  }
+  if (!env_content)
+  {
+    env_content = load_file("../../.env");
+  }
+
+  char *access_key = NULL;
+  char *secret_key = NULL;
+
+  if (env_content)
+  {
+    access_key = parse_env_value(env_content, "AWS_MRJUNEJUNE_ACCESS_KEY");
+    secret_key = parse_env_value(env_content, "AWS_MRJUNEJUNE_SECRET_ACCESS_KEY");
+    free(env_content);
+  }
+
+  // Fall back to environment variables
+  if (!access_key) access_key = getenv("AWS_ACCESS_KEY_ID") ? strdup(getenv("AWS_ACCESS_KEY_ID")) : NULL;
+  if (!secret_key) secret_key = getenv("AWS_SECRET_ACCESS_KEY") ? strdup(getenv("AWS_SECRET_ACCESS_KEY")) : NULL;
+
+  if (!access_key || !secret_key)
+  {
+    printf("Missing credentials. Set in .env file or environment:\n");
+    printf("  AWS_MRJUNEJUNE_ACCESS_KEY / AWS_ACCESS_KEY_ID\n");
+    printf("  AWS_MRJUNEJUNE_SECRET_ACCESS_KEY / AWS_SECRET_ACCESS_KEY\n");
+    return 1;
+  }
+
+  S3_Config config = {
+    .access_key_id     = access_key,
+    .secret_access_key = secret_key,
+    .region            = "us-west-2",
+    .bucket            = "mrjunejune",
+    .endpoint          = NULL,
+    .use_path_style    = FALSE
+  };
+
+  printf("S3 Uploader Test\n");
+  printf("================\n");
+  printf("Region: %s\n", config.region);
+  printf("Bucket: %s\n", config.bucket);
+  printf("\n");
+
+  // Test 1: Upload string data
+  printf("Test 1: Uploading string data...\n");
+  const char *test_data = "Hello from S3 uploader test!";
+  S3_Result result = S3_Upload_Data(&config,
+                                     (const uint8 *)test_data,
+                                     strlen(test_data),
+                                     "test/hello.txt",
+                                     "text/plain");
+  if (result.success)
+  {
+    printf("  SUCCESS! ETag: %s\n", result.etag ? result.etag : "(none)");
+  }
+  else
+  {
+    printf("  FAILED! Status: %d, Error: %s\n",
+           result.status_code,
+           result.error_message ? result.error_message : "(unknown)");
+  }
+  S3_Result_Destroy(&result);
+
+  // Test 2: Content type detection
+  printf("\nTest 2: Content type detection...\n");
+  printf("  .html -> %s\n", S3_Guess_Content_Type("page.html"));
+  printf("  .png  -> %s\n", S3_Guess_Content_Type("image.png"));
+  printf("  .json -> %s\n", S3_Guess_Content_Type("data.json"));
+  printf("  .xyz  -> %s\n", S3_Guess_Content_Type("unknown.xyz"));
+
+  // Test 3: Generate presigned PUT URL
+  printf("\nTest 3: Generating presigned PUT URL...\n");
+  S3_Presigned_URL put_url = S3_Presign_Put(&config,
+                                             "uploads/client-upload.png",
+                                             "image/png",
+                                             3600);  // 1 hour
+  if (put_url.success)
+  {
+    printf("  SUCCESS!\n");
+    printf("  URL: %s\n", put_url.url);
+    printf("\n  Client can upload with:\n");
+    printf("    curl -X PUT -H \"Content-Type: image/png\" --data-binary @file.png \"$URL\"\n");
+  }
+  else
+  {
+    printf("  FAILED! Error: %s\n", put_url.error_message ? put_url.error_message : "(unknown)");
+  }
+  S3_Presigned_URL_Destroy(&put_url);
+
+  // Test 4: Generate presigned GET URL
+  printf("\nTest 4: Generating presigned GET URL...\n");
+  S3_Presigned_URL get_url = S3_Presign_Get(&config,
+                                             "uploads/client-upload.png",
+                                             3600);  // 1 hour
+  if (get_url.success)
+  {
+    printf("  SUCCESS!\n");
+    printf("  URL: %s\n", get_url.url);
+    printf("\n  Client can download with:\n");
+    printf("    curl \"$URL\" -o file.png\n");
+  }
+  else
+  {
+    printf("  FAILED! Error: %s\n", get_url.error_message ? get_url.error_message : "(unknown)");
+  }
+  S3_Presigned_URL_Destroy(&get_url);
+
+  // Test 5: Upload file (if provided as argument)
+  if (argc >= 3)
+  {
+    printf("\nTest 5: Uploading file...\n");
+    printf("  Local: %s\n", argv[1]);
+    printf("  S3 Key: %s\n", argv[2]);
+
+    result = S3_Upload_File(&config, argv[1], argv[2]);
+    if (result.success)
+    {
+      printf("  SUCCESS! ETag: %s\n", result.etag ? result.etag : "(none)");
+    }
+    else
+    {
+      printf("  FAILED! Status: %d, Error: %s\n",
+             result.status_code,
+             result.error_message ? result.error_message : "(unknown)");
+    }
+    S3_Result_Destroy(&result);
+  }
+  else
+  {
+    printf("\nTest 5: Skipped (no file path provided)\n");
+    printf("  Usage: %s <local_file> <s3_key>\n", argv[0]);
+  }
+
+  printf("\nDone!\n");
+
+  free(access_key);
+  free(secret_key);
+  return 0;
+}
--- a/seobeo/s_network.c	Sun Aug 02 08:34:28 2026 -0700
+++ b/seobeo/s_network.c	Sun Aug 02 08:34:54 2026 -0700
@@ -268,9 +268,18 @@
     uint32 offset = 0;
     while (offset < data_size)
     {
-      ssize_t n = write(p_handle->socket,
-                data + offset,
-                data_size - offset);
+      ssize_t n;
+      if (p_handle->ssl)
+      {
+        n = Seobeo_SSL_Write(p_handle, data + offset, data_size - offset);
+      }
+      else
+      {
+        n = write(p_handle->socket,
+                  data + offset,
+                  data_size - offset);
+      }
+
       if (n==0)
       {
         // DEBUG