Browse Source

google login & stuff

simo 1 month ago
parent
commit
fbe04faca3

+ 2 - 0
.gitignore

@@ -22,3 +22,5 @@ pnpm-debug.log*
 
 # jetbrains setting folder
 .idea/
+
+data

+ 24 - 30
README.md

@@ -1,43 +1,37 @@
-# Astro Starter Kit: Minimal
+# Mikro
 
-```sh
-pnpm create astro@latest -- --template minimal
-```
+Mikro is a minimal, opinionated RSS reader
 
-> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
+## Demo
 
-## 🚀 Project Structure
+![Demo screenshot](./public/demo.png)
 
-Inside of your Astro project, you'll see the following folders and files:
+## Getting started
 
-```text
-/
-├── public/
-├── src/
-│   └── pages/
-│       └── index.astro
-└── package.json
-```
+Prerequisites:
+- Node.js
+- pnpm
 
-Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
+Install dependencies:
 
-There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
+- `pnpm install`
 
-Any static assets, like images, can be placed in the `public/` directory.
+Run the dev server:
 
-## 🧞 Commands
+- `pnpm dev`
 
-All commands are run from the root of the project, from a terminal:
+Build for production:
 
-| Command                   | Action                                           |
-| :------------------------ | :----------------------------------------------- |
-| `pnpm install`             | Installs dependencies                            |
-| `pnpm dev`             | Starts local dev server at `localhost:4321`      |
-| `pnpm build`           | Build your production site to `./dist/`          |
-| `pnpm preview`         | Preview your build locally, before deploying     |
-| `pnpm astro ...`       | Run CLI commands like `astro add`, `astro check` |
-| `pnpm astro -- --help` | Get help using the Astro CLI                     |
+- `pnpm build`
 
-## 👀 Want to learn more?
+Preview the production build:
 
-Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
+- `pnpm preview`
+
+## Configuration
+
+### Database
+
+By default the server uses a local SQLite database:
+
+- `file:./data/mikro.sqlite`

+ 13 - 0
astro.config.mjs

@@ -2,11 +2,24 @@
 import { defineConfig } from "astro/config";
 import { nodePolyfills } from "vite-plugin-node-polyfills";
 
+import node from "@astrojs/node";
+
 // https://astro.build/config
 export default defineConfig({
   output: "server",
+
   vite: {
     // @ts-ignore
     plugins: [nodePolyfills()],
   },
+
+  server: {
+    headers: {
+      "Referrer-Policy": "no-referrer-when-downgrade",
+    },
+  },
+
+  adapter: node({
+    mode: "standalone",
+  }),
 });

+ 3 - 1
package.json

@@ -9,6 +9,8 @@
     "astro": "astro"
   },
   "dependencies": {
+    "@astrojs/node": "^9.5.3",
+    "@libsql/client": "^0.15.0",
     "astro": "^5.15.5",
     "events": "^3.3.0",
     "nanostores": "^1.1.0",
@@ -21,4 +23,4 @@
     "@types/node": "^24.10.1",
     "@types/sanitize-html": "^2.16.0"
   }
-}
+}

+ 371 - 0
pnpm-lock.yaml

@@ -8,6 +8,12 @@ importers:
 
   .:
     dependencies:
+      '@astrojs/node':
+        specifier: ^9.5.3
+        version: 9.5.3(astro@5.15.5(@types/node@24.10.1)(rollup@4.53.2)(typescript@5.9.3))
+      '@libsql/client':
+        specifier: ^0.15.0
+        version: 0.15.15
       astro:
         specifier: ^5.15.5
         version: 5.15.5(@types/node@24.10.1)(rollup@4.53.2)(typescript@5.9.3)
@@ -45,9 +51,17 @@ packages:
   '@astrojs/internal-helpers@0.7.4':
     resolution: {integrity: sha512-lDA9MqE8WGi7T/t2BMi+EAXhs4Vcvr94Gqx3q15cFEz8oFZMO4/SFBqYr/UcmNlvW+35alowkVj+w9VhLvs5Cw==}
 
+  '@astrojs/internal-helpers@0.7.5':
+    resolution: {integrity: sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA==}
+
   '@astrojs/markdown-remark@6.3.8':
     resolution: {integrity: sha512-uFNyFWadnULWK2cOw4n0hLKeu+xaVWeuECdP10cQ3K2fkybtTlhb7J7TcScdjmS8Yps7oje9S/ehYMfZrhrgCg==}
 
+  '@astrojs/node@9.5.3':
+    resolution: {integrity: sha512-72jrSn0XtrD7COJVO6TxJmyU1yXdYK7MDdN/+fhqhf4YOhxuIPHclkXrJs8FbLCMx5ur56d/1ijX4XBeneqyXQ==}
+    peerDependencies:
+      astro: ^5.14.3
+
   '@astrojs/prism@3.3.0':
     resolution: {integrity: sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==}
     engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0}
@@ -376,6 +390,70 @@ packages:
   '@jridgewell/sourcemap-codec@1.5.5':
     resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
 
+  '@libsql/client@0.15.15':
+    resolution: {integrity: sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==}
+
+  '@libsql/core@0.15.15':
+    resolution: {integrity: sha512-C88Z6UKl+OyuKKPwz224riz02ih/zHYI3Ho/LAcVOgjsunIRZoBw7fjRfaH9oPMmSNeQfhGklSG2il1URoOIsA==}
+
+  '@libsql/darwin-arm64@0.5.22':
+    resolution: {integrity: sha512-4B8ZlX3nIDPndfct7GNe0nI3Yw6ibocEicWdC4fvQbSs/jdq/RC2oCsoJxJ4NzXkvktX70C1J4FcmmoBy069UA==}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@libsql/darwin-x64@0.5.22':
+    resolution: {integrity: sha512-ny2HYWt6lFSIdNFzUFIJ04uiW6finXfMNJ7wypkAD8Pqdm6nAByO+Fdqu8t7sD0sqJGeUCiOg480icjyQ2/8VA==}
+    cpu: [x64]
+    os: [darwin]
+
+  '@libsql/hrana-client@0.7.0':
+    resolution: {integrity: sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==}
+
+  '@libsql/isomorphic-fetch@0.3.1':
+    resolution: {integrity: sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==}
+    engines: {node: '>=18.0.0'}
+
+  '@libsql/isomorphic-ws@0.1.5':
+    resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==}
+
+  '@libsql/linux-arm-gnueabihf@0.5.22':
+    resolution: {integrity: sha512-3Uo3SoDPJe/zBnyZKosziRGtszXaEtv57raWrZIahtQDsjxBVjuzYQinCm9LRCJCUT5t2r5Z5nLDPJi2CwZVoA==}
+    cpu: [arm]
+    os: [linux]
+
+  '@libsql/linux-arm-musleabihf@0.5.22':
+    resolution: {integrity: sha512-LCsXh07jvSojTNJptT9CowOzwITznD+YFGGW+1XxUr7fS+7/ydUrpDfsMX7UqTqjm7xG17eq86VkWJgHJfvpNg==}
+    cpu: [arm]
+    os: [linux]
+
+  '@libsql/linux-arm64-gnu@0.5.22':
+    resolution: {integrity: sha512-KSdnOMy88c9mpOFKUEzPskSaF3VLflfSUCBwas/pn1/sV3pEhtMF6H8VUCd2rsedwoukeeCSEONqX7LLnQwRMA==}
+    cpu: [arm64]
+    os: [linux]
+
+  '@libsql/linux-arm64-musl@0.5.22':
+    resolution: {integrity: sha512-mCHSMAsDTLK5YH//lcV3eFEgiR23Ym0U9oEvgZA0667gqRZg/2px+7LshDvErEKv2XZ8ixzw3p1IrBzLQHGSsw==}
+    cpu: [arm64]
+    os: [linux]
+
+  '@libsql/linux-x64-gnu@0.5.22':
+    resolution: {integrity: sha512-kNBHaIkSg78Y4BqAdgjcR2mBilZXs4HYkAmi58J+4GRwDQZh5fIUWbnQvB9f95DkWUIGVeenqLRFY2pcTmlsew==}
+    cpu: [x64]
+    os: [linux]
+
+  '@libsql/linux-x64-musl@0.5.22':
+    resolution: {integrity: sha512-UZ4Xdxm4pu3pQXjvfJiyCzZop/9j/eA2JjmhMaAhe3EVLH2g11Fy4fwyUp9sT1QJYR1kpc2JLuybPM0kuXv/Tg==}
+    cpu: [x64]
+    os: [linux]
+
+  '@libsql/win32-x64-msvc@0.5.22':
+    resolution: {integrity: sha512-Fj0j8RnBpo43tVZUVoNK6BV/9AtDUM5S7DF3LB4qTYg1LMSZqi3yeCneUTLJD6XomQJlZzbI4mst89yspVSAnA==}
+    cpu: [x64]
+    os: [win32]
+
+  '@neon-rs/load@0.0.4':
+    resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==}
+
   '@oslojs/encoding@1.1.0':
     resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
 
@@ -561,6 +639,9 @@ packages:
   '@types/unist@3.0.3':
     resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
 
+  '@types/ws@8.18.1':
+    resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
+
   '@ungap/structured-clone@1.3.0':
     resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
 
@@ -780,6 +861,10 @@ packages:
     engines: {node: '>=4'}
     hasBin: true
 
+  data-uri-to-buffer@4.0.1:
+    resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
+    engines: {node: '>= 12'}
+
   debug@4.4.3:
     resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
     engines: {node: '>=6.0'}
@@ -807,6 +892,10 @@ packages:
   defu@6.1.4:
     resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
 
+  depd@2.0.0:
+    resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+    engines: {node: '>= 0.8'}
+
   dequal@2.0.3:
     resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
     engines: {node: '>=6'}
@@ -817,6 +906,10 @@ packages:
   destr@2.0.5:
     resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
 
+  detect-libc@2.0.2:
+    resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==}
+    engines: {node: '>=8'}
+
   detect-libc@2.1.2:
     resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
     engines: {node: '>=8'}
@@ -869,6 +962,9 @@ packages:
     resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
     engines: {node: '>= 0.4'}
 
+  ee-first@1.1.1:
+    resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+
   elliptic@6.6.1:
     resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==}
 
@@ -878,6 +974,10 @@ packages:
   emoji-regex@8.0.0:
     resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
 
+  encodeurl@2.0.0:
+    resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
+    engines: {node: '>= 0.8'}
+
   entities@2.2.0:
     resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
 
@@ -909,6 +1009,9 @@ packages:
     engines: {node: '>=18'}
     hasBin: true
 
+  escape-html@1.0.3:
+    resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
   escape-string-regexp@4.0.0:
     resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
     engines: {node: '>=10'}
@@ -923,6 +1026,10 @@ packages:
   estree-walker@3.0.3:
     resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
 
+  etag@1.8.1:
+    resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
+    engines: {node: '>= 0.6'}
+
   eventemitter3@5.0.1:
     resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
 
@@ -948,6 +1055,10 @@ packages:
       picomatch:
         optional: true
 
+  fetch-blob@3.2.0:
+    resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
+    engines: {node: ^12.20 || >= 14.13}
+
   find-up@5.0.0:
     resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
     engines: {node: '>=10'}
@@ -966,6 +1077,14 @@ packages:
     resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
     engines: {node: '>= 0.4'}
 
+  formdata-polyfill@4.0.10:
+    resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
+    engines: {node: '>=12.20.0'}
+
+  fresh@2.0.0:
+    resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
+    engines: {node: '>= 0.8'}
+
   fsevents@2.3.3:
     resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
     engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1071,6 +1190,10 @@ packages:
   http-cache-semantics@4.2.0:
     resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
 
+  http-errors@2.0.1:
+    resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
+    engines: {node: '>= 0.8'}
+
   https-browserify@1.0.0:
     resolution: {integrity: sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==}
 
@@ -1150,6 +1273,9 @@ packages:
     resolution: {integrity: sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ==}
     engines: {node: '>=10'}
 
+  js-base64@3.7.8:
+    resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
+
   js-yaml@4.1.0:
     resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
     hasBin: true
@@ -1158,6 +1284,10 @@ packages:
     resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
     engines: {node: '>=6'}
 
+  libsql@0.5.22:
+    resolution: {integrity: sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA==}
+    os: [darwin, linux, win32]
+
   locate-path@6.0.0:
     resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
     engines: {node: '>=10'}
@@ -1314,6 +1444,14 @@ packages:
     resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==}
     hasBin: true
 
+  mime-db@1.54.0:
+    resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
+    engines: {node: '>= 0.6'}
+
+  mime-types@3.0.2:
+    resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
+    engines: {node: '>=18'}
+
   minimalistic-assert@1.0.1:
     resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
 
@@ -1343,9 +1481,18 @@ packages:
   nlcst-to-string@4.0.0:
     resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==}
 
+  node-domexception@1.0.0:
+    resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
+    engines: {node: '>=10.5.0'}
+    deprecated: Use your platform's native DOMException instead
+
   node-fetch-native@1.6.7:
     resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
 
+  node-fetch@3.3.2:
+    resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
+    engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
   node-mock-http@1.0.3:
     resolution: {integrity: sha512-jN8dK25fsfnMrVsEhluUTPkBFY+6ybu7jSB1n+ri/vOGjJxU8J9CZhpSGkHXSkFjtUhbmoncG/YG9ta5Ludqog==}
 
@@ -1379,6 +1526,10 @@ packages:
   ohash@2.0.11:
     resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
 
+  on-finished@2.4.1:
+    resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
+    engines: {node: '>= 0.8'}
+
   oniguruma-parser@0.12.1:
     resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
 
@@ -1478,6 +1629,9 @@ packages:
     resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
     engines: {node: '>= 0.6.0'}
 
+  promise-limit@2.7.0:
+    resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==}
+
   prompts@2.4.2:
     resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
     engines: {node: '>= 6'}
@@ -1511,6 +1665,10 @@ packages:
   randomfill@1.0.4:
     resolution: {integrity: sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==}
 
+  range-parser@1.2.1:
+    resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
+    engines: {node: '>= 0.6'}
+
   readable-stream@2.3.8:
     resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
 
@@ -1612,6 +1770,13 @@ packages:
     engines: {node: '>=10'}
     hasBin: true
 
+  send@1.2.1:
+    resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
+    engines: {node: '>= 18'}
+
+  server-destroy@1.0.1:
+    resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==}
+
   set-function-length@1.2.2:
     resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
     engines: {node: '>= 0.4'}
@@ -1619,6 +1784,9 @@ packages:
   setimmediate@1.0.5:
     resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
 
+  setprototypeof@1.2.0:
+    resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+
   sha.js@2.4.12:
     resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==}
     engines: {node: '>= 0.10'}
@@ -1661,6 +1829,10 @@ packages:
   space-separated-tokens@2.0.2:
     resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
 
+  statuses@2.0.2:
+    resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+    engines: {node: '>= 0.8'}
+
   stream-browserify@3.0.0:
     resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==}
 
@@ -1715,6 +1887,10 @@ packages:
     resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
     engines: {node: '>= 0.4'}
 
+  toidentifier@1.0.1:
+    resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+    engines: {node: '>=0.6'}
+
   trim-lines@3.0.1:
     resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
 
@@ -1941,6 +2117,10 @@ packages:
   web-namespaces@2.0.1:
     resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
 
+  web-streams-polyfill@3.3.3:
+    resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
+    engines: {node: '>= 8'}
+
   which-pm-runs@1.1.0:
     resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==}
     engines: {node: '>=4'}
@@ -1957,6 +2137,18 @@ packages:
     resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
     engines: {node: '>=18'}
 
+  ws@8.19.0:
+    resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
+    engines: {node: '>=10.0.0'}
+    peerDependencies:
+      bufferutil: ^4.0.1
+      utf-8-validate: '>=5.0.2'
+    peerDependenciesMeta:
+      bufferutil:
+        optional: true
+      utf-8-validate:
+        optional: true
+
   xml2js@0.5.0:
     resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==}
     engines: {node: '>=4.0.0'}
@@ -2015,6 +2207,8 @@ snapshots:
 
   '@astrojs/internal-helpers@0.7.4': {}
 
+  '@astrojs/internal-helpers@0.7.5': {}
+
   '@astrojs/markdown-remark@6.3.8':
     dependencies:
       '@astrojs/internal-helpers': 0.7.4
@@ -2041,6 +2235,15 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  '@astrojs/node@9.5.3(astro@5.15.5(@types/node@24.10.1)(rollup@4.53.2)(typescript@5.9.3))':
+    dependencies:
+      '@astrojs/internal-helpers': 0.7.5
+      astro: 5.15.5(@types/node@24.10.1)(rollup@4.53.2)(typescript@5.9.3)
+      send: 1.2.1
+      server-destroy: 1.0.1
+    transitivePeerDependencies:
+      - supports-color
+
   '@astrojs/prism@3.3.0':
     dependencies:
       prismjs: 1.30.0
@@ -2256,6 +2459,70 @@ snapshots:
 
   '@jridgewell/sourcemap-codec@1.5.5': {}
 
+  '@libsql/client@0.15.15':
+    dependencies:
+      '@libsql/core': 0.15.15
+      '@libsql/hrana-client': 0.7.0
+      js-base64: 3.7.8
+      libsql: 0.5.22
+      promise-limit: 2.7.0
+    transitivePeerDependencies:
+      - bufferutil
+      - utf-8-validate
+
+  '@libsql/core@0.15.15':
+    dependencies:
+      js-base64: 3.7.8
+
+  '@libsql/darwin-arm64@0.5.22':
+    optional: true
+
+  '@libsql/darwin-x64@0.5.22':
+    optional: true
+
+  '@libsql/hrana-client@0.7.0':
+    dependencies:
+      '@libsql/isomorphic-fetch': 0.3.1
+      '@libsql/isomorphic-ws': 0.1.5
+      js-base64: 3.7.8
+      node-fetch: 3.3.2
+    transitivePeerDependencies:
+      - bufferutil
+      - utf-8-validate
+
+  '@libsql/isomorphic-fetch@0.3.1': {}
+
+  '@libsql/isomorphic-ws@0.1.5':
+    dependencies:
+      '@types/ws': 8.18.1
+      ws: 8.19.0
+    transitivePeerDependencies:
+      - bufferutil
+      - utf-8-validate
+
+  '@libsql/linux-arm-gnueabihf@0.5.22':
+    optional: true
+
+  '@libsql/linux-arm-musleabihf@0.5.22':
+    optional: true
+
+  '@libsql/linux-arm64-gnu@0.5.22':
+    optional: true
+
+  '@libsql/linux-arm64-musl@0.5.22':
+    optional: true
+
+  '@libsql/linux-x64-gnu@0.5.22':
+    optional: true
+
+  '@libsql/linux-x64-musl@0.5.22':
+    optional: true
+
+  '@libsql/win32-x64-msvc@0.5.22':
+    optional: true
+
+  '@neon-rs/load@0.0.4': {}
+
   '@oslojs/encoding@1.1.0': {}
 
   '@rollup/plugin-inject@5.0.5(rollup@4.53.2)':
@@ -2411,6 +2678,10 @@ snapshots:
 
   '@types/unist@3.0.3': {}
 
+  '@types/ws@8.18.1':
+    dependencies:
+      '@types/node': 24.10.1
+
   '@ungap/structured-clone@1.3.0': {}
 
   acorn@8.15.0: {}
@@ -2752,6 +3023,8 @@ snapshots:
 
   cssesc@3.0.0: {}
 
+  data-uri-to-buffer@4.0.1: {}
+
   debug@4.4.3:
     dependencies:
       ms: 2.1.3
@@ -2776,6 +3049,8 @@ snapshots:
 
   defu@6.1.4: {}
 
+  depd@2.0.0: {}
+
   dequal@2.0.3: {}
 
   des.js@1.1.0:
@@ -2785,6 +3060,8 @@ snapshots:
 
   destr@2.0.5: {}
 
+  detect-libc@2.0.2: {}
+
   detect-libc@2.1.2:
     optional: true
 
@@ -2838,6 +3115,8 @@ snapshots:
       es-errors: 1.3.0
       gopd: 1.2.0
 
+  ee-first@1.1.1: {}
+
   elliptic@6.6.1:
     dependencies:
       bn.js: 4.12.2
@@ -2852,6 +3131,8 @@ snapshots:
 
   emoji-regex@8.0.0: {}
 
+  encodeurl@2.0.0: {}
+
   entities@2.2.0: {}
 
   entities@4.5.0: {}
@@ -2897,6 +3178,8 @@ snapshots:
       '@esbuild/win32-ia32': 0.25.12
       '@esbuild/win32-x64': 0.25.12
 
+  escape-html@1.0.3: {}
+
   escape-string-regexp@4.0.0: {}
 
   escape-string-regexp@5.0.0: {}
@@ -2907,6 +3190,8 @@ snapshots:
     dependencies:
       '@types/estree': 1.0.8
 
+  etag@1.8.1: {}
+
   eventemitter3@5.0.1: {}
 
   events@3.3.0: {}
@@ -2924,6 +3209,11 @@ snapshots:
     optionalDependencies:
       picomatch: 4.0.3
 
+  fetch-blob@3.2.0:
+    dependencies:
+      node-domexception: 1.0.0
+      web-streams-polyfill: 3.3.3
+
   find-up@5.0.0:
     dependencies:
       locate-path: 6.0.0
@@ -2952,6 +3242,12 @@ snapshots:
     dependencies:
       is-callable: 1.2.7
 
+  formdata-polyfill@4.0.10:
+    dependencies:
+      fetch-blob: 3.2.0
+
+  fresh@2.0.0: {}
+
   fsevents@2.3.3:
     optional: true
 
@@ -3132,6 +3428,14 @@ snapshots:
 
   http-cache-semantics@4.2.0: {}
 
+  http-errors@2.0.1:
+    dependencies:
+      depd: 2.0.0
+      inherits: 2.0.4
+      setprototypeof: 1.2.0
+      statuses: 2.0.2
+      toidentifier: 1.0.1
+
   https-browserify@1.0.0: {}
 
   ieee754@1.2.1: {}
@@ -3199,12 +3503,29 @@ snapshots:
 
   isomorphic-timers-promises@1.0.1: {}
 
+  js-base64@3.7.8: {}
+
   js-yaml@4.1.0:
     dependencies:
       argparse: 2.0.1
 
   kleur@3.0.3: {}
 
+  libsql@0.5.22:
+    dependencies:
+      '@neon-rs/load': 0.0.4
+      detect-libc: 2.0.2
+    optionalDependencies:
+      '@libsql/darwin-arm64': 0.5.22
+      '@libsql/darwin-x64': 0.5.22
+      '@libsql/linux-arm-gnueabihf': 0.5.22
+      '@libsql/linux-arm-musleabihf': 0.5.22
+      '@libsql/linux-arm64-gnu': 0.5.22
+      '@libsql/linux-arm64-musl': 0.5.22
+      '@libsql/linux-x64-gnu': 0.5.22
+      '@libsql/linux-x64-musl': 0.5.22
+      '@libsql/win32-x64-msvc': 0.5.22
+
   locate-path@6.0.0:
     dependencies:
       p-locate: 5.0.0
@@ -3551,6 +3872,12 @@ snapshots:
       bn.js: 4.12.2
       brorand: 1.1.0
 
+  mime-db@1.54.0: {}
+
+  mime-types@3.0.2:
+    dependencies:
+      mime-db: 1.54.0
+
   minimalistic-assert@1.0.1: {}
 
   minimalistic-crypto-utils@1.0.1: {}
@@ -3569,8 +3896,16 @@ snapshots:
     dependencies:
       '@types/nlcst': 2.0.3
 
+  node-domexception@1.0.0: {}
+
   node-fetch-native@1.6.7: {}
 
+  node-fetch@3.3.2:
+    dependencies:
+      data-uri-to-buffer: 4.0.1
+      fetch-blob: 3.2.0
+      formdata-polyfill: 4.0.10
+
   node-mock-http@1.0.3: {}
 
   node-stdlib-browser@1.3.1:
@@ -3631,6 +3966,10 @@ snapshots:
 
   ohash@2.0.11: {}
 
+  on-finished@2.4.1:
+    dependencies:
+      ee-first: 1.1.1
+
   oniguruma-parser@0.12.1: {}
 
   oniguruma-to-es@4.3.3:
@@ -3728,6 +4067,8 @@ snapshots:
 
   process@0.11.10: {}
 
+  promise-limit@2.7.0: {}
+
   prompts@2.4.2:
     dependencies:
       kleur: 3.0.3
@@ -3765,6 +4106,8 @@ snapshots:
       randombytes: 2.1.0
       safe-buffer: 5.2.1
 
+  range-parser@1.2.1: {}
+
   readable-stream@2.3.8:
     dependencies:
       core-util-is: 1.0.3
@@ -3953,6 +4296,24 @@ snapshots:
 
   semver@7.7.3: {}
 
+  send@1.2.1:
+    dependencies:
+      debug: 4.4.3
+      encodeurl: 2.0.0
+      escape-html: 1.0.3
+      etag: 1.8.1
+      fresh: 2.0.0
+      http-errors: 2.0.1
+      mime-types: 3.0.2
+      ms: 2.1.3
+      on-finished: 2.4.1
+      range-parser: 1.2.1
+      statuses: 2.0.2
+    transitivePeerDependencies:
+      - supports-color
+
+  server-destroy@1.0.1: {}
+
   set-function-length@1.2.2:
     dependencies:
       define-data-property: 1.1.4
@@ -3964,6 +4325,8 @@ snapshots:
 
   setimmediate@1.0.5: {}
 
+  setprototypeof@1.2.0: {}
+
   sha.js@2.4.12:
     dependencies:
       inherits: 2.0.4
@@ -4049,6 +4412,8 @@ snapshots:
 
   space-separated-tokens@2.0.2: {}
 
+  statuses@2.0.2: {}
+
   stream-browserify@3.0.0:
     dependencies:
       inherits: 2.0.4
@@ -4115,6 +4480,8 @@ snapshots:
       safe-buffer: 5.2.1
       typed-array-buffer: 1.0.3
 
+  toidentifier@1.0.1: {}
+
   trim-lines@3.0.1: {}
 
   trough@2.2.0: {}
@@ -4282,6 +4649,8 @@ snapshots:
 
   web-namespaces@2.0.1: {}
 
+  web-streams-polyfill@3.3.3: {}
+
   which-pm-runs@1.1.0: {}
 
   which-typed-array@1.1.19:
@@ -4304,6 +4673,8 @@ snapshots:
       string-width: 7.2.0
       strip-ansi: 7.1.2
 
+  ws@8.19.0: {}
+
   xml2js@0.5.0:
     dependencies:
       sax: 1.4.3

BIN
public/demo.png


BIN
public/favicon.ico


+ 14 - 2
src/components/ArticleItem.astro

@@ -41,9 +41,11 @@
             this.innerHTML = sanitizeHtml(
                 `
                 <article class="article">
-                    <h2 class="article-title">
-                        ${link ? `<a rel="noopener noreferrer">${title}</a>` : title}
+                    <div class="article-header">
+                        <h2 class="article-title">
+                            ${link ? `<a rel="noopener noreferrer">${title}</a>` : title}
                         </h2>
+                    </div>
 
                     <div class="article-publisher">${publisherName}</div>
 
@@ -56,6 +58,9 @@
                     allowedClasses: {
                         "*": ["*"],
                     },
+                    allowedTags: sanitizeHtml.defaults.allowedTags.concat([
+                        "button",
+                    ]),
                 },
             );
         }
@@ -93,6 +98,13 @@
         word-wrap: break-word;
     }
 
+    article-item .article-header {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        gap: 12px;
+    }
+
     article-item .article-title a {
         color: white;
         text-decoration: none;

+ 0 - 5
src/components/ArticleList.astro

@@ -31,7 +31,6 @@ import ArticleItem from "./ArticleItem.astro";
 
     const container = document.getElementById("article-container");
 
-    // Get current category from URL
     const currentPath =
         window.location.pathname === "/"
             ? "/"
@@ -288,7 +287,6 @@ import ArticleItem from "./ArticleItem.astro";
         function removeSentinel() {
             if (!container || !sentinel) return;
 
-            // Unobserve before removing
             if (observer && sentinel) {
                 observer.unobserve(sentinel);
             }
@@ -297,7 +295,6 @@ import ArticleItem from "./ArticleItem.astro";
             sentinel = null;
         }
 
-        // Intersection Observer for infinite scroll
         function setupInfiniteScroll() {
             observer = new IntersectionObserver(
                 (entries) => {
@@ -320,8 +317,6 @@ import ArticleItem from "./ArticleItem.astro";
                     threshold: 0.1,
                 },
             );
-
-            // Initial sentinel will be added after first render
         }
 
         async function refreshArticles() {

File diff suppressed because it is too large
+ 788 - 45
src/components/SettingsDialog.astro


+ 28 - 0
src/components/popUpBox.astro

@@ -66,7 +66,29 @@
             if (open) {
                 document.getElementById("popup")!.style.display = "flex";
                 document.getElementById("popup")?.scroll(0, 0);
+
+                fetch("/api/user/events", {
+                    method: "POST",
+                    headers: { "Content-Type": "application/json" },
+                    body: JSON.stringify({
+                        event: "popup_open",
+                        occurredAt: new Date().toISOString(),
+                    }),
+                }).catch((error) => {
+                    console.error("Failed to record popup open event", error);
+                });
             } else {
+                fetch("/api/user/events", {
+                    method: "POST",
+                    headers: { "Content-Type": "application/json" },
+                    body: JSON.stringify({
+                        event: "popup_close",
+                        occurredAt: new Date().toISOString(),
+                    }),
+                }).catch((error) => {
+                    console.error("Failed to record popup close event", error);
+                });
+
                 const popup = document.getElementById("popup")!;
                 popup.style.animation = "collapseToLine 0.5s ease-out";
                 setTimeout(() => {
@@ -134,10 +156,16 @@
 
         margin: 1rem auto;
         overflow: scroll;
+        scrollbar-width: none;
+        -ms-overflow-style: none;
         word-wrap: break-word;
         z-index: 100000;
     }
 
+    .popup-article::-webkit-scrollbar {
+        display: none;
+    }
+
     .popup-article:hover {
         border-color: #999;
     }

+ 2 - 0
src/pages/[...cat].astro

@@ -38,6 +38,8 @@ if (stored) {
         console.error("Failed to parse stored RSS feeds, using defaults", e);
         articles = defaultArticles;
     }
+} else {
+    articles = defaultArticles;
 }
 
 articles.All = {

+ 3 - 5
src/pages/api/cors.ts

@@ -20,7 +20,6 @@ export const GET: APIRoute = async ({ request }) => {
     });
   }
 
-  // Validate URL
   try {
     new URL(targetUrl);
   } catch (error) {
@@ -38,7 +37,7 @@ export const GET: APIRoute = async ({ request }) => {
     const now = Date.now();
 
     if (cached && now - cached.timestamp < CACHE_TTL) {
-      console.log(`[CORS Proxy Cache] Hit for: ${targetUrl}`);
+      // console.log(`[CORS Proxy Cache] Hit for: ${targetUrl}`);
       return new Response(cached.data, {
         status: 200,
         headers: {
@@ -49,7 +48,7 @@ export const GET: APIRoute = async ({ request }) => {
       });
     }
 
-    console.log(`[CORS Proxy Cache] Miss, fetching: ${targetUrl}`);
+    // console.log(`[CORS Proxy Cache] Miss, fetching: ${targetUrl}`);
 
     const response = await fetch(targetUrl, {
       headers: {
@@ -75,7 +74,6 @@ export const GET: APIRoute = async ({ request }) => {
     const data = await response.text();
     const contentType = response.headers.get("content-type") || "text/plain";
 
-    // Store relevant headers
     const headersToCache: Record<string, string> = {
       "Content-Type": contentType,
     };
@@ -96,7 +94,7 @@ export const GET: APIRoute = async ({ request }) => {
       },
     });
   } catch (error) {
-    console.error(`[CORS Proxy] Error fetching ${targetUrl}:`, error);
+    // console.error(`[CORS Proxy] Error fetching ${targetUrl}:`, error);
 
     return new Response(
       JSON.stringify({

+ 2 - 2
src/pages/api/feed.ts

@@ -24,7 +24,7 @@ export const GET: APIRoute = async ({ request }) => {
     const now = Date.now();
 
     if (cached && now - cached.timestamp < CACHE_TTL) {
-      console.log(`[API Cache] Hit for: ${feedUrl}`);
+      // console.log(`[API Cache] Hit for: ${feedUrl}`);
       return new Response(JSON.stringify(cached.data), {
         status: 200,
         headers: {
@@ -35,7 +35,7 @@ export const GET: APIRoute = async ({ request }) => {
       });
     }
 
-    console.log(`[API Cache] Miss, fetching: ${feedUrl}`);
+    // console.log(`[API Cache] Miss, fetching: ${feedUrl}`);
 
     const feed = await parser.parseURL(feedUrl);
 

+ 124 - 0
src/pages/api/feedsearch.ts

@@ -0,0 +1,124 @@
+import type { APIRoute } from "astro";
+
+const cache = new Map<
+  string,
+  { data: string; timestamp: number; headers: Record<string, string> }
+>();
+const CACHE_TTL = 5 * 60 * 1000;
+
+export const GET: APIRoute = async ({ request }) => {
+  const url = new URL(request.url);
+  const target = url.searchParams.get("url");
+
+  if (!target) {
+    return new Response(JSON.stringify({ error: "Missing url parameter" }), {
+      status: 400,
+      headers: {
+        "Content-Type": "application/json",
+        "Access-Control-Allow-Origin": "*",
+      },
+    });
+  }
+
+  // Validate URL
+  try {
+    new URL(target);
+  } catch {
+    return new Response(JSON.stringify({ error: "Invalid URL provided" }), {
+      status: 400,
+      headers: {
+        "Content-Type": "application/json",
+        "Access-Control-Allow-Origin": "*",
+      },
+    });
+  }
+
+  try {
+    const cached = cache.get(target);
+    const now = Date.now();
+
+    if (cached && now - cached.timestamp < CACHE_TTL) {
+      return new Response(cached.data, {
+        status: 200,
+        headers: {
+          ...cached.headers,
+          "X-Cache": "HIT",
+          "Access-Control-Allow-Origin": "*",
+        },
+      });
+    }
+
+    const upstream = `https://feedsearch.dev/api/v1/search?url=${encodeURIComponent(
+      target,
+    )}`;
+
+    const response = await fetch(upstream, {
+      headers: {
+        "User-Agent": "Mozilla/5.0 (compatible; MikroFeedSearch/1.0)",
+      },
+    });
+
+    if (!response.ok) {
+      return new Response(
+        JSON.stringify({
+          error: `Failed to fetch feedsearch: ${response.status} ${response.statusText}`,
+        }),
+        {
+          status: response.status,
+          headers: {
+            "Content-Type": "application/json",
+            "Access-Control-Allow-Origin": "*",
+          },
+        },
+      );
+    }
+
+    const data = await response.text();
+    const contentType = response.headers.get("content-type") ?? "application/json";
+
+    const headersToCache: Record<string, string> = {
+      "Content-Type": contentType,
+    };
+
+    cache.set(target, {
+      data,
+      timestamp: now,
+      headers: headersToCache,
+    });
+
+    return new Response(data, {
+      status: 200,
+      headers: {
+        ...headersToCache,
+        "X-Cache": "MISS",
+        "Access-Control-Allow-Origin": "*",
+        "Cache-Control": "public, max-age=300",
+      },
+    });
+  } catch (error) {
+    return new Response(
+      JSON.stringify({
+        error: "Failed to fetch feedsearch",
+        details: error instanceof Error ? error.message : "Unknown error",
+      }),
+      {
+        status: 500,
+        headers: {
+          "Content-Type": "application/json",
+          "Access-Control-Allow-Origin": "*",
+        },
+      },
+    );
+  }
+};
+
+export const OPTIONS: APIRoute = async () => {
+  return new Response(null, {
+    status: 204,
+    headers: {
+      "Access-Control-Allow-Origin": "*",
+      "Access-Control-Allow-Methods": "GET, OPTIONS",
+      "Access-Control-Allow-Headers": "Content-Type",
+    },
+  });
+};

+ 180 - 0
src/pages/api/user.ts

@@ -0,0 +1,180 @@
+import type { APIRoute } from "astro";
+import { createClient } from "@libsql/client";
+
+type UserRecord = {
+  feeds?: unknown;
+  updatedAt: string;
+};
+
+const client = createClient({
+  url: process.env.LIBSQL_URL ?? "file:./data/mikro.sqlite",
+  authToken: process.env.LIBSQL_AUTH_TOKEN,
+});
+
+let initialized = false;
+
+const init = async () => {
+  if (initialized) return;
+  await client.execute(`
+    CREATE TABLE IF NOT EXISTS users (
+      jwt TEXT PRIMARY KEY,
+      feeds TEXT,
+      updated_at TEXT NOT NULL
+    );
+  `);
+  await client.execute(`
+    CREATE TABLE IF NOT EXISTS user_events (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      jwt TEXT NOT NULL,
+      event_type TEXT NOT NULL,
+      occurred_at TEXT NOT NULL
+    );
+  `);
+  try {
+    await client.execute(`ALTER TABLE users ADD COLUMN feeds TEXT;`);
+  } catch {}
+  initialized = true;
+};
+
+const jsonResponse = (data: unknown, status = 200) =>
+  new Response(JSON.stringify(data), {
+    status,
+    headers: {
+      "Content-Type": "application/json",
+      "Access-Control-Allow-Origin": "*",
+    },
+  });
+
+const getUser = async (jwt: string): Promise<UserRecord> => {
+  const result = await client.execute({
+    sql: "SELECT feeds, updated_at FROM users WHERE jwt = ?",
+    args: [jwt],
+  });
+
+  const row = result.rows?.[0] as
+    | {
+        feeds?: string;
+        updated_at?: string;
+      }
+    | undefined;
+
+  if (!row || !row.updated_at) {
+    return {
+      feeds: undefined,
+      updatedAt: new Date().toISOString(),
+    };
+  }
+
+  let feeds: unknown = undefined;
+  if (row.feeds) {
+    try {
+      feeds = JSON.parse(row.feeds);
+    } catch {
+      feeds = undefined;
+    }
+  }
+
+  return { feeds, updatedAt: row.updated_at };
+};
+
+const upsertUser = async (jwt: string, feeds: unknown): Promise<UserRecord> => {
+  const now = new Date().toISOString();
+
+  await client.execute({
+    sql: `
+      INSERT INTO users (jwt, feeds, updated_at)
+      VALUES (?, ?, ?)
+      ON CONFLICT(jwt) DO UPDATE SET
+        feeds = excluded.feeds,
+        updated_at = excluded.updated_at
+    `,
+    args: [jwt, JSON.stringify(feeds ?? null), now],
+  });
+
+  return { feeds, updatedAt: now };
+};
+
+const insertEvent = async (
+  jwt: string,
+  eventType: string,
+  occurredAt: string,
+) => {
+  await client.execute({
+    sql: `
+      INSERT INTO user_events (jwt, event_type, occurred_at)
+      VALUES (?, ?, ?)
+    `,
+    args: [jwt, eventType, occurredAt],
+  });
+};
+
+export const GET: APIRoute = async ({ request }) => {
+  await init();
+
+  const cookieHeader = request.headers.get("cookie") ?? "";
+  const jwt = cookieHeader
+    .split("; ")
+    .find((row) => row.startsWith("userJwt="))
+    ?.split("=")[1];
+  const decodedJwt = jwt ? decodeURIComponent(jwt) : null;
+
+  if (!decodedJwt) {
+    return jsonResponse({ error: "Missing jwt cookie" }, 400);
+  }
+
+  const record = await getUser(decodedJwt);
+  return jsonResponse(record, 200);
+};
+
+export const POST: APIRoute = async ({ request }) => {
+  await init();
+
+  try {
+    const body = await request.json();
+    const cookieHeader = request.headers.get("cookie") ?? "";
+    const jwt = cookieHeader
+      .split("; ")
+      .find((row) => row.startsWith("userJwt="))
+      ?.split("=")[1];
+    const decodedJwt = jwt ? decodeURIComponent(jwt) : undefined;
+
+    const feeds = body?.feeds as unknown;
+    const eventType = body?.event as string | undefined;
+    const occurredAt = body?.occurredAt as string | undefined;
+
+    if (!decodedJwt) {
+      return jsonResponse({ error: "Missing jwt cookie" }, 400);
+    }
+
+    if (eventType) {
+      if (!occurredAt) {
+        return jsonResponse({ error: "Missing occurredAt" }, 400);
+      }
+
+      await insertEvent(decodedJwt, eventType, occurredAt);
+      return jsonResponse({ ok: true }, 200);
+    }
+
+    const record = await upsertUser(decodedJwt, feeds);
+    return jsonResponse({ ok: true, ...record }, 200);
+  } catch (error) {
+    return jsonResponse(
+      {
+        error: "Invalid JSON body",
+        details: error instanceof Error ? error.message : "Unknown error",
+      },
+      400,
+    );
+  }
+};
+
+export const OPTIONS: APIRoute = async () => {
+  return new Response(null, {
+    status: 204,
+    headers: {
+      "Access-Control-Allow-Origin": "*",
+      "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
+      "Access-Control-Allow-Headers": "Content-Type",
+    },
+  });
+};

+ 89 - 0
src/pages/api/user/events.ts

@@ -0,0 +1,89 @@
+import type { APIRoute } from "astro";
+import { createClient } from "@libsql/client";
+
+const client = createClient({
+  url: process.env.LIBSQL_URL ?? "file:./data/mikro.sqlite",
+  authToken: process.env.LIBSQL_AUTH_TOKEN,
+});
+
+let initialized = false;
+
+const init = async () => {
+  if (initialized) return;
+  await client.execute(`
+    CREATE TABLE IF NOT EXISTS user_events (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      jwt TEXT NOT NULL,
+      event_type TEXT NOT NULL,
+      occurred_at TEXT NOT NULL
+    );
+  `);
+  initialized = true;
+};
+
+const jsonResponse = (data: unknown, status = 200) =>
+  new Response(JSON.stringify(data), {
+    status,
+    headers: {
+      "Content-Type": "application/json",
+      "Access-Control-Allow-Origin": "*",
+    },
+  });
+
+export const POST: APIRoute = async ({ request }) => {
+  await init();
+
+  try {
+    const body = await request.json();
+    const cookieHeader = request.headers.get("cookie") ?? "";
+    const jwt = cookieHeader
+      .split("; ")
+      .find((row) => row.startsWith("userJwt="))
+      ?.split("=")[1];
+    const decodedJwt = jwt ? decodeURIComponent(jwt) : undefined;
+
+    if (!decodedJwt) {
+      return jsonResponse({ error: "Missing jwt cookie" }, 400);
+    }
+
+    const eventType = body?.event as string | undefined;
+    const occurredAt = body?.occurredAt as string | undefined;
+
+    if (!eventType) {
+      return jsonResponse({ error: "Missing event" }, 400);
+    }
+
+    if (!occurredAt) {
+      return jsonResponse({ error: "Missing occurredAt" }, 400);
+    }
+
+    await client.execute({
+      sql: `
+        INSERT INTO user_events (jwt, event_type, occurred_at)
+        VALUES (?, ?, ?)
+      `,
+      args: [decodedJwt, eventType, occurredAt],
+    });
+
+    return jsonResponse({ ok: true }, 200);
+  } catch (error) {
+    return jsonResponse(
+      {
+        error: "Invalid JSON body",
+        details: error instanceof Error ? error.message : "Unknown error",
+      },
+      400,
+    );
+  }
+};
+
+export const OPTIONS: APIRoute = async () => {
+  return new Response(null, {
+    status: 204,
+    headers: {
+      "Access-Control-Allow-Origin": "*",
+      "Access-Control-Allow-Methods": "POST, OPTIONS",
+      "Access-Control-Allow-Headers": "Content-Type",
+    },
+  });
+};

+ 169 - 0
src/pages/api/user/stats.ts

@@ -0,0 +1,169 @@
+import type { APIRoute } from "astro";
+import { createClient } from "@libsql/client";
+
+const client = createClient({
+  url: process.env.LIBSQL_URL ?? "file:./data/mikro.sqlite",
+  authToken: process.env.LIBSQL_AUTH_TOKEN,
+});
+
+let initialized = false;
+
+const init = async () => {
+  if (initialized) return;
+  await client.execute(`
+    CREATE TABLE IF NOT EXISTS user_events (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      jwt TEXT NOT NULL,
+      event_type TEXT NOT NULL,
+      occurred_at TEXT NOT NULL
+    );
+  `);
+  initialized = true;
+};
+
+const jsonResponse = (data: unknown, status = 200) =>
+  new Response(JSON.stringify(data), {
+    status,
+    headers: {
+      "Content-Type": "application/json",
+      "Access-Control-Allow-Origin": "*",
+    },
+  });
+
+const getJwtFromCookie = (cookieHeader: string | null) => {
+  const cookie = cookieHeader ?? "";
+  const jwt = cookie
+    .split("; ")
+    .find((row) => row.startsWith("userJwt="))
+    ?.split("=")[1];
+  return jwt ? decodeURIComponent(jwt) : null;
+};
+
+const getRangeStart = (range: string) => {
+  const now = new Date();
+  if (range === "today") {
+    return new Date(
+      Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
+    );
+  }
+  if (range === "week") {
+    const day = now.getUTCDay();
+    const diff = (day + 6) % 7;
+    const start = new Date(
+      Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
+    );
+    start.setUTCDate(start.getUTCDate() - diff);
+    return start;
+  }
+  if (range === "month") {
+    return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
+  }
+  return null;
+};
+
+type UserEvent = {
+  event_type: string;
+  occurred_at: string;
+};
+
+const computeStats = (
+  events: UserEvent[],
+  rangeStart: Date | null,
+  rangeEnd: Date,
+) => {
+  const inRange = events
+    .filter((event) => {
+      const ts = new Date(event.occurred_at);
+      if (Number.isNaN(ts.getTime())) return false;
+      if (rangeStart && ts < rangeStart) return false;
+      if (ts > rangeEnd) return false;
+      return true;
+    })
+    .sort(
+      (a, b) =>
+        new Date(a.occurred_at).getTime() - new Date(b.occurred_at).getTime(),
+    );
+
+  let totalReads = 0;
+  let timeSpentMs = 0;
+  const openStack: number[] = [];
+
+  for (const event of inRange) {
+    const ts = new Date(event.occurred_at).getTime();
+    if (event.event_type === "popup_open") {
+      totalReads += 1;
+      openStack.push(ts);
+      continue;
+    }
+    if (event.event_type === "popup_close") {
+      const openTs = openStack.pop();
+      if (openTs !== undefined) {
+        const endTs = Math.min(ts, rangeEnd.getTime());
+        const startTs = Math.max(
+          openTs,
+          rangeStart ? rangeStart.getTime() : openTs,
+        );
+        if (endTs > startTs) {
+          timeSpentMs += endTs - startTs;
+        }
+      }
+    }
+  }
+
+  const avgTimeMs = totalReads > 0 ? Math.round(timeSpentMs / totalReads) : 0;
+
+  return {
+    totalReads,
+    timeSpentMs,
+    avgTimeMs,
+  };
+};
+
+export const GET: APIRoute = async ({ request }) => {
+  await init();
+
+  const jwt = getJwtFromCookie(request.headers.get("cookie"));
+  if (!jwt) {
+    return jsonResponse({ error: "Missing jwt cookie" }, 400);
+  }
+
+  const url = new URL(request.url);
+  const range = (url.searchParams.get("range") || "today").toLowerCase();
+  const validRanges = new Set(["today", "week", "month", "lifetime"]);
+  const safeRange = validRanges.has(range) ? range : "today";
+
+  const result = await client.execute({
+    sql: `
+      SELECT event_type, occurred_at
+      FROM user_events
+      WHERE jwt = ?
+      ORDER BY occurred_at ASC
+    `,
+    args: [jwt],
+  });
+
+  const events = (result.rows || []) as unknown as UserEvent[];
+  const rangeStart = safeRange === "lifetime" ? null : getRangeStart(safeRange);
+  const rangeEnd = new Date();
+
+  const stats = computeStats(events, rangeStart, rangeEnd);
+
+  return jsonResponse(
+    {
+      range: safeRange,
+      ...stats,
+    },
+    200,
+  );
+};
+
+export const OPTIONS: APIRoute = async () => {
+  return new Response(null, {
+    status: 204,
+    headers: {
+      "Access-Control-Allow-Origin": "*",
+      "Access-Control-Allow-Methods": "GET, OPTIONS",
+      "Access-Control-Allow-Headers": "Content-Type",
+    },
+  });
+};

Some files were not shown because too many files changed in this diff