simo 5 dagar sedan
förälder
incheckning
6c58f9426d

+ 5 - 0
astro.config.mjs

@@ -1,7 +1,12 @@
 // @ts-check
 import { defineConfig } from "astro/config";
+import { nodePolyfills } from "vite-plugin-node-polyfills";
 
 // https://astro.build/config
 export default defineConfig({
   output: "server",
+  vite: {
+    // @ts-ignore
+    plugins: [nodePolyfills()],
+  },
 });

+ 10 - 1
package.json

@@ -10,6 +10,15 @@
   },
   "dependencies": {
     "astro": "^5.15.5",
-    "rss-parser": "^3.13.0"
+    "events": "^3.3.0",
+    "nanostores": "^1.1.0",
+    "rss-parser": "^3.13.0",
+    "sanitize-html": "^2.17.0",
+    "url": "^0.11.4",
+    "vite-plugin-node-polyfills": "^0.24.0"
+  },
+  "devDependencies": {
+    "@types/node": "^24.10.1",
+    "@types/sanitize-html": "^2.16.0"
   }
 }

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 570 - 6
pnpm-lock.yaml


+ 142 - 0
src/components/ArticleItem.astro

@@ -0,0 +1,142 @@
+---
+
+---
+
+<script>
+    import { isOpen, content } from "@utils/popupStore";
+
+    import sanitizeHtml from "sanitize-html";
+
+    class ArticleItem extends HTMLElement {
+        connectedCallback() {
+            const title = this.getAttribute("data-title");
+            const link = this.getAttribute("data-link") as string;
+            const creator = this.getAttribute("data-creator");
+            const pubDate = this.getAttribute("data-pubdate");
+            const contentSnippet = this.getAttribute("data-snippet");
+
+            const publisherName = new URL(link).hostname
+                .replace(/.+\/\/|www.|\..+/g, "")
+                .split(".")[0];
+
+            const html =
+                this.getAttribute("data-html") ||
+                "Failed to fetch article content";
+
+            this.addEventListener("click", () => {
+                if (isOpen.get()) return;
+
+                content.setKey(
+                    "title",
+                    `${publisherName} ${creator ? "- " + creator : ""}`,
+                );
+
+                content.setKey("html", html);
+
+                setTimeout(() => {
+                    isOpen.set(true);
+                }, 1);
+            });
+
+            this.innerHTML = sanitizeHtml(
+                `
+                <article class="article">
+                    <h2 class="article-title">
+                        ${link ? `<a rel="noopener noreferrer">${title}</a>` : title}
+                        </h2>
+
+                    <div class="article-publisher">${publisherName}</div>
+
+                    ${creator ? `<div class="article-author">By ${creator}</div>` : ""}
+                    ${pubDate ? `<div class="article-date">${new Date(pubDate).toLocaleDateString()}</div>` : ""}
+                    ${contentSnippet ? `<div class="article-snippet">${contentSnippet}</div>` : ""}
+                </article>
+            `,
+                {
+                    allowedClasses: {
+                        "*": ["*"],
+                    },
+                },
+            );
+        }
+    }
+
+    customElements.define("article-item", ArticleItem);
+</script>
+
+<style is:global>
+    article-item {
+        display: block;
+    }
+
+    article-item .article {
+        border: 2px solid #ccc;
+        border-radius: 5px;
+        padding: 20px;
+        margin-bottom: 20px;
+        transition: border-color 0.3s ease;
+        overflow: hidden;
+        word-wrap: break-word;
+        background: transparent;
+    }
+
+    article-item .article:hover {
+        border-color: #999;
+    }
+
+    article-item .article-title {
+        margin: 0 0 10px 0;
+        font-size: 24px;
+        color: white;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        word-wrap: break-word;
+    }
+
+    article-item .article-title a {
+        color: white;
+        text-decoration: none;
+        transition: opacity 0.3s ease;
+    }
+
+    article-item .article-title a:hover {
+        opacity: 0.8;
+    }
+
+    article-item .article-publisher {
+        font-size: 14px;
+        color: #999;
+        margin-bottom: 5px;
+        font-weight: 600;
+        text-transform: capitalize;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+    }
+
+    article-item .article-author {
+        font-size: 14px;
+        color: #ccc;
+        margin-bottom: 5px;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+    }
+
+    article-item .article-date {
+        font-size: 14px;
+        color: #999;
+        margin-bottom: 15px;
+    }
+
+    article-item .article-snippet {
+        font-size: 16px;
+        line-height: 1.6;
+        color: white;
+        overflow: hidden;
+        display: -webkit-box;
+        -webkit-line-clamp: 3;
+        -webkit-box-orient: vertical;
+        word-wrap: break-word;
+    }
+</style>

+ 379 - 0
src/components/ArticleList.astro

@@ -0,0 +1,379 @@
+---
+import ArticleItem from "./ArticleItem.astro";
+---
+
+<ArticleItem />
+
+<div id="article-container" class="article-container">
+    <div class="loading">Loading articles...</div>
+</div>
+
+<script>
+    import ClientCachedParser from "@utils/clientCachedParser";
+
+    interface RSSItem {
+        title?: string;
+        link?: string;
+        pubDate?: string;
+        creator?: string;
+        content?: string;
+        contentSnippet?: string;
+        guid?: string;
+        isoDate?: string;
+    }
+
+    interface RSSFeed {
+        items: RSSItem[];
+        title?: string;
+        link?: string;
+        description?: string;
+    }
+
+    const container = document.getElementById("article-container");
+
+    // Get current category from URL
+    const currentPath =
+        window.location.pathname === "/"
+            ? "/"
+            : window.location.pathname.replace(/^\/|\/$/g, "");
+
+    function getCookie(name: string): string | null {
+        const value = `; ${document.cookie}`;
+        const parts = value.split(`; ${name}=`);
+        if (parts.length === 2) {
+            const cookieValue = parts.pop()?.split(";").shift();
+            return cookieValue ? decodeURIComponent(cookieValue) : null;
+        }
+        return null;
+    }
+
+    function loadFeedsForCategory(): string[] {
+        const stored = getCookie("rssFeeds");
+        let articles: any = {};
+
+        if (stored) {
+            try {
+                articles = JSON.parse(stored);
+            } catch (e) {
+                console.error("Failed to parse stored RSS feeds", e);
+                return [];
+            }
+        } else {
+            articles = {
+                All: {
+                    path: "/",
+                    feeds: [],
+                },
+                Tech: {
+                    feeds: ["https://techcrunch.com/feed/"],
+                    path: "tech",
+                },
+                "USA News": {
+                    feeds: [
+                        "https://feeds.nbcnews.com/nbcnews/public/news",
+                        "https://abcnews.go.com/abcnews/topstories",
+                        "https://www.cbsnews.com/latest/rss/main",
+                    ],
+                    path: "usa-news",
+                },
+                "World News": {
+                    feeds: [
+                        "https://feeds.nbcnews.com/nbcnews/public/news",
+                        "https://www.cnbc.com/id/100727362/device/rss/rss.html",
+                    ],
+                    path: "world-news",
+                },
+            };
+
+            Object.keys(articles).forEach((category) => {
+                if (category === "All") return;
+                articles.All.feeds = articles.All.feeds.concat(
+                    articles[category].feeds,
+                );
+            });
+        }
+
+        const category = Object.keys(articles).find(
+            (cat) => articles[cat].path === currentPath,
+        );
+
+        if (category && articles[category]) {
+            return articles[category].feeds || [];
+        }
+
+        return [];
+    }
+
+    const feedList: string[] = loadFeedsForCategory();
+
+    if (!feedList || feedList.length === 0) {
+        console.error("No feeds found for current category");
+        if (container) {
+            container.innerHTML =
+                '<div class="error">No feeds configured for this category</div>';
+        }
+    } else {
+        const parser = new ClientCachedParser();
+
+        let allArticles: RSSItem[] = [];
+        let currentPage = 0;
+        const articlesPerPage = 10;
+        let isLoading = false;
+        let hasMoreArticles = true;
+        let sentinel: HTMLElement | null = null;
+        let observer: IntersectionObserver | null = null;
+
+        async function createArticleElement(
+            item: RSSItem,
+        ): Promise<HTMLElement> {
+            const articleItem = document.createElement("article-item");
+
+            if (item.link) {
+                try {
+                    const response = await fetch(item.link);
+                    const html = await response.text();
+                    articleItem.setAttribute("data-html", html);
+                } catch (error) {
+                    console.error(
+                        `Failed to fetch article HTML directly: ${item.link}`,
+                        error,
+                    );
+
+                    try {
+                        const proxyUrl = `/api/cors?url=${encodeURIComponent(item.link)}`;
+                        const proxyResponse = await fetch(proxyUrl);
+                        if (proxyResponse.ok) {
+                            const html = await proxyResponse.text();
+                            articleItem.setAttribute("data-html", html);
+                            console.log(
+                                `Successfully fetched via CORS proxy: ${item.link}`,
+                            );
+                        } else {
+                            console.error(
+                                `CORS proxy also failed for: ${item.link}`,
+                            );
+                        }
+                    } catch (proxyError) {
+                        console.error(
+                            `CORS proxy error for: ${item.link}`,
+                            proxyError,
+                        );
+                    }
+                }
+            }
+
+            if (item.title) {
+                articleItem.setAttribute("data-title", item.title);
+            }
+            if (item.link) {
+                articleItem.setAttribute("data-link", item.link);
+            }
+
+            if (item.creator) {
+                articleItem.setAttribute("data-creator", item.creator);
+            }
+            if (item.pubDate) {
+                articleItem.setAttribute("data-pubdate", item.pubDate);
+            }
+            if (item.contentSnippet) {
+                articleItem.setAttribute("data-snippet", item.contentSnippet);
+            }
+
+            return articleItem;
+        }
+
+        async function loadAllArticles() {
+            if (!container) return;
+
+            container.innerHTML =
+                '<div class="loading">Loading articles...</div>';
+
+            try {
+                const feedPromises = feedList.map(async (feedUrl) => {
+                    try {
+                        const feed: RSSFeed = await parser.parseURL(feedUrl);
+                        if (feed.items && Array.isArray(feed.items)) {
+                            return feed.items;
+                        }
+                        return [];
+                    } catch (error) {
+                        console.error(`Failed to load feed: ${feedUrl}`, error);
+                        return [];
+                    }
+                });
+
+                const feedResults = await Promise.all(feedPromises);
+                feedResults.forEach((items) => {
+                    allArticles.push(...items);
+                });
+
+                allArticles.sort((a, b) => {
+                    const dateA = a.isoDate || a.pubDate;
+                    const dateB = b.isoDate || b.pubDate;
+                    if (!dateA && !dateB) return 0;
+                    if (!dateA) return 1;
+                    if (!dateB) return -1;
+                    return (
+                        new Date(dateB).getTime() - new Date(dateA).getTime()
+                    );
+                });
+
+                if (allArticles.length === 0) {
+                    container.innerHTML =
+                        '<div class="no-articles">No articles found</div>';
+                    hasMoreArticles = false;
+                } else {
+                    container.innerHTML = "";
+                    console.log(
+                        `Loaded ${allArticles.length} articles from ${feedList.length} feeds`,
+                    );
+                    await renderNextPage();
+                }
+            } catch (error) {
+                console.error("Error loading articles:", error);
+                container.innerHTML =
+                    '<div class="error">Failed to load articles. Please try again later.</div>';
+            }
+        }
+
+        async function renderNextPage() {
+            if (!container || isLoading || !hasMoreArticles) return;
+
+            isLoading = true;
+
+            const startIndex = currentPage * articlesPerPage;
+            const endIndex = startIndex + articlesPerPage;
+            const articlesToRender = allArticles.slice(startIndex, endIndex);
+
+            if (articlesToRender.length === 0) {
+                hasMoreArticles = false;
+                isLoading = false;
+                return;
+            }
+
+            removeSentinel();
+
+            for (const item of articlesToRender) {
+                const articleElement = await createArticleElement(item);
+                container.appendChild(articleElement);
+            }
+
+            currentPage++;
+
+            if (endIndex >= allArticles.length) {
+                hasMoreArticles = false;
+            } else {
+                addSentinel();
+            }
+
+            isLoading = false;
+            console.log(
+                `Rendered page ${currentPage}, showing ${endIndex} of ${allArticles.length} articles`,
+            );
+        }
+
+        function addSentinel() {
+            if (!container || sentinel) return;
+
+            sentinel = document.createElement("div");
+            sentinel.className = "scroll-sentinel";
+            sentinel.textContent = "Loading more...";
+            container.appendChild(sentinel);
+
+            if (observer && sentinel) {
+                observer.observe(sentinel);
+            }
+        }
+
+        function removeSentinel() {
+            if (!container || !sentinel) return;
+
+            // Unobserve before removing
+            if (observer && sentinel) {
+                observer.unobserve(sentinel);
+            }
+
+            sentinel.remove();
+            sentinel = null;
+        }
+
+        // Intersection Observer for infinite scroll
+        function setupInfiniteScroll() {
+            observer = new IntersectionObserver(
+                (entries) => {
+                    entries.forEach((entry) => {
+                        if (
+                            entry.isIntersecting &&
+                            hasMoreArticles &&
+                            !isLoading
+                        ) {
+                            console.log(
+                                "Sentinel reached, loading more articles...",
+                            );
+                            renderNextPage();
+                        }
+                    });
+                },
+                {
+                    root: null,
+                    rootMargin: "100px",
+                    threshold: 0.1,
+                },
+            );
+
+            // Initial sentinel will be added after first render
+        }
+
+        async function refreshArticles() {
+            allArticles = [];
+            currentPage = 0;
+            hasMoreArticles = true;
+            isLoading = false;
+            removeSentinel();
+            await loadAllArticles();
+        }
+
+        setupInfiniteScroll();
+        loadAllArticles();
+
+        (window as any).refreshArticles = refreshArticles;
+    }
+</script>
+
+<style>
+    .article-container {
+        margin: 20px auto;
+        padding: 20px;
+        max-width: 1200px;
+    }
+
+    .loading {
+        text-align: center;
+        padding: 40px;
+        font-size: 18px;
+        color: white;
+    }
+
+    .error {
+        text-align: center;
+        padding: 40px;
+        font-size: 18px;
+        color: #d32f2f;
+    }
+
+    .no-articles {
+        text-align: center;
+        padding: 40px;
+        font-size: 18px;
+        color: white;
+    }
+
+    .scroll-sentinel {
+        text-align: center;
+        padding: 20px;
+        font-size: 16px;
+        color: #888;
+        font-style: italic;
+        min-height: 20px;
+    }
+</style>

+ 630 - 0
src/components/SettingsDialog.astro

@@ -0,0 +1,630 @@
+---
+
+---
+
+<button id="settings-button" class="settings-button" aria-label="Settings">
+    <svg
+        xmlns="http://www.w3.org/2000/svg"
+        width="24"
+        height="24"
+        viewBox="0 0 24 24"
+        fill="none"
+        stroke="currentColor"
+        stroke-width="2"
+        stroke-linecap="round"
+        stroke-linejoin="round"
+    >
+        <circle cx="12" cy="12" r="3"></circle>
+        <path d="M12 1v6m0 6v6m0-6h6m-6 0H6"></path>
+        <path
+            d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
+        ></path>
+    </svg>
+</button>
+
+<div id="settings-dialog" class="settings-dialog">
+    <div class="settings-content">
+        <div class="settings-header">
+            <h2>Feed Settings</h2>
+            <button id="close-settings" class="close-button" aria-label="Close"
+                >✕</button
+            >
+        </div>
+
+        <div class="settings-body">
+            <div id="categories-container"></div>
+
+            <button id="add-category" class="add-button">+ Add Category</button>
+
+            <div class="settings-actions">
+                <button id="export-settings" class="action-button"
+                    >Export Settings</button
+                >
+                <button id="import-settings" class="action-button"
+                    >Import Settings</button
+                >
+                <button id="reset-settings" class="action-button reset"
+                    >Reset to Defaults</button
+                >
+                <input
+                    type="file"
+                    id="import-file-input"
+                    accept=".json"
+                    style="display: none;"
+                />
+            </div>
+        </div>
+
+        <div class="settings-footer">
+            <button id="save-settings" class="save-button">Save Changes</button>
+            <button id="cancel-settings" class="cancel-button">Cancel</button>
+        </div>
+    </div>
+</div>
+
+<script>
+    const settingsButton = document.getElementById("settings-button");
+    const settingsDialog = document.getElementById("settings-dialog");
+    const closeSettings = document.getElementById("close-settings");
+    const cancelSettings = document.getElementById("cancel-settings");
+    const saveSettings = document.getElementById("save-settings");
+    const addCategoryButton = document.getElementById("add-category");
+    const categoriesContainer = document.getElementById("categories-container");
+    const exportButton = document.getElementById("export-settings");
+    const importButton = document.getElementById("import-settings");
+    const resetButton = document.getElementById("reset-settings");
+    const importFileInput = document.getElementById(
+        "import-file-input",
+    ) as HTMLInputElement;
+
+    let currentSettings: any = {};
+
+    const defaultSettings = {
+        Tech: {
+            feeds: ["https://techcrunch.com/feed/"],
+            path: "tech",
+        },
+        "USA News": {
+            feeds: [
+                "https://feeds.nbcnews.com/nbcnews/public/news",
+                "https://abcnews.go.com/abcnews/topstories",
+                "https://www.cbsnews.com/latest/rss/main",
+            ],
+            path: "usa-news",
+        },
+        "World News": {
+            feeds: [
+                "https://feeds.nbcnews.com/nbcnews/public/news",
+                "https://www.cnbc.com/id/100727362/device/rss/rss.html",
+            ],
+            path: "world-news",
+        },
+    };
+
+    function getCookie(name: string): string | null {
+        const value = `; ${document.cookie}`;
+        const parts = value.split(`; ${name}=`);
+        if (parts.length === 2) {
+            const cookieValue = parts.pop()?.split(";").shift();
+            return cookieValue ? decodeURIComponent(cookieValue) : null;
+        }
+        return null;
+    }
+
+    function setCookie(name: string, value: string, days: number = 365) {
+        const date = new Date();
+        date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
+        const expires = `expires=${date.toUTCString()}`;
+        document.cookie = `${name}=${encodeURIComponent(value)};${expires};path=/`;
+    }
+
+    function loadSettings() {
+        const stored = getCookie("rssFeeds");
+        if (stored) {
+            try {
+                currentSettings = JSON.parse(stored);
+            } catch (e) {
+                console.error("Failed to parse stored settings", e);
+                currentSettings = JSON.parse(JSON.stringify(defaultSettings));
+            }
+        } else {
+            currentSettings = JSON.parse(JSON.stringify(defaultSettings));
+        }
+        renderCategories();
+    }
+
+    function renderCategories() {
+        if (!categoriesContainer) return;
+
+        categoriesContainer.innerHTML = "";
+
+        Object.keys(currentSettings).forEach((categoryName) => {
+            if (categoryName === "All") return;
+
+            const category = currentSettings[categoryName];
+            const categoryDiv = document.createElement("div");
+            categoryDiv.className = "category-item";
+            categoryDiv.innerHTML = `
+                <div class="category-header">
+                    <input type="text" class="category-name" value="${categoryName}" data-original="${categoryName}" placeholder="Category Name">
+                    <input type="text" class="category-path" value="${category.path}" placeholder="URL path (e.g., tech)">
+                    <button class="delete-category" data-category="${categoryName}">Delete</button>
+                </div>
+                <div class="feeds-list">
+                    ${category.feeds
+                        .map(
+                            (feed: string, idx: number) => `
+                        <div class="feed-item">
+                            <input type="url" class="feed-url" value="${feed}" placeholder="RSS Feed URL">
+                            <button class="delete-feed" data-category="${categoryName}" data-index="${idx}">−</button>
+                        </div>
+                    `,
+                        )
+                        .join("")}
+                </div>
+                <button class="add-feed" data-category="${categoryName}">+ Add Feed</button>
+            `;
+            categoriesContainer.appendChild(categoryDiv);
+        });
+
+        // Attach event listeners
+        attachEventListeners();
+    }
+
+    function attachEventListeners() {
+        // Delete category buttons
+        document.querySelectorAll(".delete-category").forEach((btn) => {
+            btn.addEventListener("click", (e) => {
+                const categoryName = (e.target as HTMLButtonElement).dataset
+                    .category!;
+                delete currentSettings[categoryName];
+                renderCategories();
+            });
+        });
+
+        // Delete feed buttons
+        document.querySelectorAll(".delete-feed").forEach((btn) => {
+            btn.addEventListener("click", (e) => {
+                const target = e.target as HTMLButtonElement;
+                const categoryName = target.dataset.category!;
+                const index = parseInt(target.dataset.index!);
+                currentSettings[categoryName].feeds.splice(index, 1);
+                renderCategories();
+            });
+        });
+
+        // Add feed buttons
+        document.querySelectorAll(".add-feed").forEach((btn) => {
+            btn.addEventListener("click", (e) => {
+                const categoryName = (e.target as HTMLButtonElement).dataset
+                    .category!;
+                currentSettings[categoryName].feeds.push("");
+                renderCategories();
+            });
+        });
+    }
+
+    // Open settings dialog
+    settingsButton?.addEventListener("click", () => {
+        loadSettings();
+        settingsDialog!.style.display = "flex";
+    });
+
+    // Close settings dialog
+    function closeDialog() {
+        settingsDialog!.style.display = "none";
+    }
+
+    closeSettings?.addEventListener("click", closeDialog);
+    cancelSettings?.addEventListener("click", closeDialog);
+
+    // Close when clicking outside
+    settingsDialog?.addEventListener("click", (e) => {
+        if (e.target === settingsDialog) {
+            closeDialog();
+        }
+    });
+
+    // Add new category
+    addCategoryButton?.addEventListener("click", () => {
+        const categoryName = `New Category ${Object.keys(currentSettings).length}`;
+        currentSettings[categoryName] = {
+            path: `category-${Date.now()}`,
+            feeds: [],
+        };
+        renderCategories();
+    });
+
+    // Save settings
+    saveSettings?.addEventListener("click", () => {
+        // Collect updated data from inputs
+        const updatedSettings: any = {};
+
+        document.querySelectorAll(".category-item").forEach((categoryDiv) => {
+            const nameInput = categoryDiv.querySelector(
+                ".category-name",
+            ) as HTMLInputElement;
+            const pathInput = categoryDiv.querySelector(
+                ".category-path",
+            ) as HTMLInputElement;
+            const categoryName = nameInput.value.trim();
+            const categoryPath = pathInput.value.trim();
+
+            if (!categoryName || !categoryPath) return;
+
+            const feeds: string[] = [];
+            categoryDiv.querySelectorAll(".feed-url").forEach((feedInput) => {
+                const url = (feedInput as HTMLInputElement).value.trim();
+                if (url) feeds.push(url);
+            });
+
+            updatedSettings[categoryName] = {
+                path: categoryPath,
+                feeds: feeds,
+            };
+        });
+
+        // Build the "All" category by combining all other category feeds
+        updatedSettings.All = {
+            path: "/",
+            feeds: [],
+        };
+
+        Object.keys(updatedSettings).forEach((category) => {
+            if (category === "All") return;
+            updatedSettings.All.feeds = updatedSettings.All.feeds.concat(
+                updatedSettings[category].feeds,
+            );
+        });
+
+        // Save to cookie
+        setCookie("rssFeeds", JSON.stringify(updatedSettings));
+
+        closeDialog();
+
+        // Reload the page to apply new settings
+        window.location.reload();
+    });
+
+    // Export settings
+    exportButton?.addEventListener("click", () => {
+        const dataStr = JSON.stringify(currentSettings, null, 2);
+        const dataBlob = new Blob([dataStr], { type: "application/json" });
+        const url = URL.createObjectURL(dataBlob);
+        const link = document.createElement("a");
+        link.href = url;
+        link.download = "rss-feeds.json";
+        document.body.appendChild(link);
+        link.click();
+        document.body.removeChild(link);
+        URL.revokeObjectURL(url);
+    });
+
+    // Import settings
+    importButton?.addEventListener("click", () => {
+        importFileInput?.click();
+    });
+
+    importFileInput?.addEventListener("change", (e) => {
+        const file = (e.target as HTMLInputElement).files?.[0];
+        if (!file) return;
+
+        const reader = new FileReader();
+        reader.onload = (event) => {
+            try {
+                const imported = JSON.parse(event.target?.result as string);
+                currentSettings = imported;
+                setCookie("rssFeeds", JSON.stringify(imported));
+                renderCategories();
+                alert("Settings imported successfully!");
+            } catch (error) {
+                alert(
+                    "Failed to import settings. Please check the file format.",
+                );
+                console.error("Import error:", error);
+            }
+        };
+        reader.readAsText(file);
+
+        // Reset input so the same file can be imported again
+        importFileInput.value = "";
+    });
+
+    // Reset to defaults
+    resetButton?.addEventListener("click", () => {
+        if (
+            confirm(
+                "Are you sure you want to reset to default settings? This will discard all custom feeds.",
+            )
+        ) {
+            currentSettings = JSON.parse(JSON.stringify(defaultSettings));
+            setCookie("rssFeeds", JSON.stringify(currentSettings));
+            renderCategories();
+        }
+    });
+</script>
+
+<style>
+    .settings-button {
+        position: fixed;
+        top: 20px;
+        left: 20px;
+        background: transparent;
+        border: 2px solid #ccc;
+        border-radius: 5px;
+        color: white;
+        width: 48px;
+        height: 48px;
+        cursor: pointer;
+        transition: all 0.3s ease;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        z-index: 1000;
+    }
+
+    .settings-button:hover {
+        border-color: #999;
+        background: #1a1a1a;
+    }
+
+    .settings-dialog {
+        display: none;
+        position: fixed;
+        top: 0;
+        left: 0;
+        width: 100%;
+        height: 100%;
+        background: rgba(0, 0, 0, 0.8);
+        z-index: 10000;
+        align-items: center;
+        justify-content: center;
+        padding: 20px;
+    }
+
+    .settings-content {
+        background: black;
+        border: 2px solid #ccc;
+        border-radius: 5px;
+        width: 100%;
+        max-width: 800px;
+        max-height: 90vh;
+        display: flex;
+        flex-direction: column;
+        overflow: hidden;
+    }
+
+    .settings-header {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        padding: 20px;
+        border-bottom: 2px solid #333;
+    }
+
+    .settings-header h2 {
+        margin: 0;
+        color: white;
+        font-size: 24px;
+    }
+
+    .close-button {
+        background: transparent;
+        border: none;
+        color: white;
+        font-size: 28px;
+        cursor: pointer;
+        padding: 0;
+        width: 32px;
+        height: 32px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        transition: opacity 0.3s ease;
+    }
+
+    .close-button:hover {
+        opacity: 0.7;
+    }
+
+    .settings-body {
+        flex: 1;
+        overflow-y: auto;
+        padding: 20px;
+    }
+
+    #categories-container {
+        display: flex;
+        flex-direction: column;
+        gap: 20px;
+        margin-bottom: 20px;
+    }
+
+    .category-item {
+        border: 2px solid #444;
+        border-radius: 5px;
+        padding: 15px;
+        background: #0a0a0a;
+    }
+
+    .category-header {
+        display: flex;
+        gap: 10px;
+        margin-bottom: 15px;
+        flex-wrap: wrap;
+    }
+
+    .category-name,
+    .category-path {
+        flex: 1;
+        min-width: 150px;
+        background: #1a1a1a;
+        border: 1px solid #555;
+        border-radius: 3px;
+        padding: 8px 12px;
+        color: white;
+        font-family: Monospace;
+        font-size: 14px;
+    }
+
+    .category-name:focus,
+    .category-path:focus,
+    .feed-url:focus {
+        outline: none;
+        border-color: #999;
+    }
+
+    .delete-category {
+        background: #cc0000;
+        border: none;
+        border-radius: 3px;
+        color: white;
+        padding: 8px 16px;
+        cursor: pointer;
+        font-family: Monospace;
+        transition: background 0.3s ease;
+    }
+
+    .delete-category:hover {
+        background: #aa0000;
+    }
+
+    .feeds-list {
+        display: flex;
+        flex-direction: column;
+        gap: 8px;
+        margin-bottom: 10px;
+    }
+
+    .feed-item {
+        display: flex;
+        gap: 10px;
+        align-items: center;
+    }
+
+    .feed-url {
+        flex: 1;
+        background: #1a1a1a;
+        border: 1px solid #555;
+        border-radius: 3px;
+        padding: 8px 12px;
+        color: white;
+        font-family: Monospace;
+        font-size: 14px;
+    }
+
+    .delete-feed {
+        background: #cc0000;
+        border: none;
+        border-radius: 3px;
+        color: white;
+        width: 32px;
+        height: 32px;
+        cursor: pointer;
+        font-size: 20px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        transition: background 0.3s ease;
+    }
+
+    .delete-feed:hover {
+        background: #aa0000;
+    }
+
+    .add-feed,
+    .add-button {
+        background: transparent;
+        border: 2px solid #555;
+        border-radius: 3px;
+        color: #999;
+        padding: 8px 16px;
+        cursor: pointer;
+        font-family: Monospace;
+        transition: all 0.3s ease;
+        width: 100%;
+    }
+
+    .add-feed:hover,
+    .add-button:hover {
+        border-color: #999;
+        color: white;
+    }
+
+    .settings-actions {
+        display: flex;
+        gap: 10px;
+        margin-top: 20px;
+        padding-top: 20px;
+        border-top: 2px solid #333;
+        flex-wrap: wrap;
+    }
+
+    .action-button {
+        background: transparent;
+        border: 2px solid #555;
+        border-radius: 3px;
+        color: #999;
+        padding: 8px 16px;
+        cursor: pointer;
+        font-family: Monospace;
+        font-size: 14px;
+        transition: all 0.3s ease;
+        flex: 1;
+        min-width: 150px;
+    }
+
+    .action-button:hover {
+        border-color: #999;
+        color: white;
+    }
+
+    .action-button.reset {
+        border-color: #cc6600;
+        color: #cc6600;
+    }
+
+    .action-button.reset:hover {
+        border-color: #ff8800;
+        color: #ff8800;
+    }
+
+    .settings-footer {
+        display: flex;
+        gap: 10px;
+        padding: 20px;
+        border-top: 2px solid #333;
+        justify-content: flex-end;
+    }
+
+    .save-button,
+    .cancel-button {
+        padding: 10px 24px;
+        border-radius: 3px;
+        font-family: Monospace;
+        font-size: 14px;
+        cursor: pointer;
+        transition: all 0.3s ease;
+    }
+
+    .save-button {
+        background: white;
+        border: 2px solid white;
+        color: black;
+    }
+
+    .save-button:hover {
+        background: #ccc;
+        border-color: #ccc;
+    }
+
+    .cancel-button {
+        background: transparent;
+        border: 2px solid #ccc;
+        color: white;
+    }
+
+    .cancel-button:hover {
+        border-color: #999;
+    }
+</style>

+ 0 - 19
src/components/article.astro

@@ -1,19 +0,0 @@
----
-import type { Item } from "rss-parser";
-
-interface Props {
-    article: Item; // Accept an array of Items
-}
-
-const { article } = Astro.props;
----
-
-<div class="article">
-    <h1>{article}</h1>
-</div>
-
-<style>
-    .article {
-        color: white;
-    }
-</style>

+ 22 - 16
src/components/nav.astro

@@ -3,25 +3,31 @@ const { cat, articles } = Astro.props;
 ---
 
 <div class="container">
-    <div class="navbar">
+    <div class="navbar" id="navbar">
         {
-            Object.keys(articles).map((c) => (
-                <a
-                    class={
-                        cat == articles[c as keyof typeof articles].path
-                            ? "selected"
-                            : ""
-                    }
-                    href={articles[c as keyof typeof articles].path}
-                >
-                    {c}
-                </a>
-            ))
+            Object.keys(articles)
+                .sort((a, b) => {
+                    if (a === "All") return -1;
+                    if (b === "All") return 1;
+                    return 0;
+                })
+                .map((c) => (
+                    <a
+                        class={
+                            cat == articles[c as keyof typeof articles].path
+                                ? "selected"
+                                : ""
+                        }
+                        href={articles[c as keyof typeof articles].path}
+                    >
+                        {c}
+                    </a>
+                ))
         }
     </div>
 </div>
 
-<style>
+<style is:global>
     .container {
         width: 100%;
         display: flex;
@@ -43,13 +49,13 @@ const { cat, articles } = Astro.props;
         align-items: center;
     }
 
-    a {
+    .navbar a {
         text-decoration: none;
         font-size: 22px;
         color: white;
     }
 
-    .selected {
+    .navbar a.selected {
         text-decoration: underline;
     }
 </style>

+ 209 - 0
src/components/popUpBox.astro

@@ -0,0 +1,209 @@
+---
+
+---
+
+<div id="popup" class="popup-article">
+    <div class="popup-article-content">
+        <h3 id="title" class="popup-article-title"></h3>
+        <div id="content"></div>
+        <p class="popup-article-excerpt"></p>
+    </div>
+
+    <script>
+        import { isOpen, content, type popupContent } from "@utils/popupStore";
+
+        import sanitizeHtml from "sanitize-html";
+
+        content.subscribe((content: popupContent) => {
+            document.getElementById("title")!.textContent = content.title;
+
+            document.getElementById("content")!.innerHTML = sanitizeHtml(
+                content.html,
+                {
+                    allowedClasses: {
+                        "*": ["*"],
+                    },
+
+                    transformTags: {
+                        header: function (tagName, attribs) {
+                            return {
+                                tagName: "details",
+                                attribs: {
+                                    name: "Header",
+                                    class: "dropdown-header",
+                                },
+                            };
+                        },
+                        nav: function (tagName, attribs) {
+                            return {
+                                tagName: "details",
+                                attribs: {
+                                    name: "Header",
+                                    class: "dropdown-header",
+                                },
+                            };
+                        },
+
+                        footer: function (tagName, attribs) {
+                            return {
+                                tagName: "details",
+                                attribs: {
+                                    class: "dropdown-footer",
+                                },
+                            };
+                        },
+                    },
+
+                    allowedTags: sanitizeHtml.defaults.allowedTags.concat([
+                        "details",
+                        "summary",
+                    ]),
+                },
+            );
+        });
+
+        isOpen.subscribe((open: boolean) => {
+            if (open) {
+                document.getElementById("popup")!.style.display = "flex";
+                document.getElementById("popup")?.scroll(0, 0);
+            } else {
+                const popup = document.getElementById("popup")!;
+                popup.style.animation = "collapseToLine 0.5s ease-out";
+                setTimeout(() => {
+                    popup.style.display = "none";
+                    popup.style.animation = "";
+                }, 450);
+            }
+        });
+    </script>
+</div>
+
+<style>
+    #popup * {
+        font-size: 16px;
+    }
+
+    @keyframes expandFromLine {
+        from {
+            transform: translateX(-50%) scaleY(0);
+            opacity: 0;
+        }
+        to {
+            transform: translateX(-50%) scaleY(1);
+            opacity: 1;
+        }
+    }
+
+    @keyframes collapseToLine {
+        from {
+            transform: translateX(-50%) scaleY(1);
+            opacity: 1;
+        }
+        to {
+            transform: translateX(-50%) scaleY(0);
+            opacity: 0;
+        }
+    }
+
+    @keyframes shimmer {
+        0% {
+            background-position: -1000px 0;
+        }
+        100% {
+            background-position: 1000px 0;
+        }
+    }
+
+    .popup-article {
+        display: none;
+        position: fixed;
+
+        gap: 1rem;
+        padding: 20px;
+        left: 50%;
+        transform: translateX(-50%) scaleY(1);
+        background: black;
+        border: 2px solid #ccc;
+        border-radius: 5px;
+        transition: border-color 0.3s ease;
+        animation: expandFromLine 0.5s ease-out;
+        transform-origin: center;
+        width: 70%;
+
+        max-height: 90%;
+
+        margin: 1rem auto;
+        overflow: scroll;
+        word-wrap: break-word;
+        z-index: 100000;
+    }
+
+    .popup-article:hover {
+        border-color: #999;
+    }
+
+    .popup-article-content {
+        flex: 1;
+        display: flex;
+        flex-direction: column;
+        gap: 0.5rem;
+    }
+
+    .popup-article-title {
+        margin: 0 0 10px 0;
+        font-size: 28px !important;
+        font-weight: 600;
+        color: white;
+
+        text-overflow: ellipsis;
+        word-wrap: break-word;
+    }
+
+    .popup-article-excerpt {
+        margin: 0;
+        color: white;
+        font-size: 16px;
+        line-height: 1.6;
+        overflow: hidden;
+        display: -webkit-box;
+        -webkit-line-clamp: 3;
+        -webkit-box-orient: vertical;
+        word-wrap: break-word;
+    }
+
+    .dropdown-header,
+    .dropdown-footer {
+        margin: 1rem 0;
+        border: 1px solid #444;
+        border-radius: 4px;
+        background: #1a1a1a;
+    }
+
+    .dropdown-summary {
+        padding: 0.5rem 1rem;
+        cursor: pointer;
+        user-select: none;
+        font-weight: 600;
+        color: #888;
+        list-style: none;
+    }
+
+    .dropdown-summary:hover {
+        color: #aaa;
+        background: #222;
+    }
+
+    .dropdown-summary::marker {
+        content: "";
+    }
+
+    .dropdown-summary::before {
+        content: "▶ ";
+        display: inline-block;
+        transition: transform 0.2s;
+    }
+
+    details[open] .dropdown-summary::before {
+        transform: rotate(90deg);
+    }
+</style>

+ 0 - 53
src/components/sidebar.astro

@@ -1,53 +0,0 @@
----
-const { catagory, articles, parser } = Astro.props;
----
-
-<div class="container">
-    <div class="sidebar">
-        {
-            articles[catagory as keyof typeof articles]!.feeds.map(
-                async (feed: string) => (
-                    <a>{(await parser.parseURL(feed)).title}</a>
-                ),
-            )
-        }
-    </div>
-</div>
-
-<style>
-    .sidebar {
-        display: flex;
-        flex-direction: column;
-        align-items: center;
-        justify-content: center;
-
-        gap: 30px;
-
-        margin-top: 15px;
-        margin-bottom: 15px;
-    }
-
-    .container {
-        position: absolute;
-
-        top: 25%;
-
-        left: 2%;
-
-        height: fit-content;
-        width: 15%;
-
-        border: 2px solid #ccc;
-        border-radius: 5px;
-    }
-
-    a {
-        font-size: 20px;
-
-        max-width: 80%;
-
-        white-space: nowrap;
-        overflow: hidden;
-        text-overflow: ellipsis;
-    }
-</style>

+ 11 - 29
src/feeds.js

@@ -1,32 +1,14 @@
-let articles = {
-  All: {
-    path: "/",
-    feeds: [],
-  },
-  Tech: {
-    feeds: ["https://techcrunch.com/feed/"],
-    path: "tech",
-  },
-  "USA News": {
-    feeds: [
-      "https://feeds.nbcnews.com/nbcnews/public/news",
-      "https://abcnews.go.com/abcnews/topstories",
-      "https://www.cbsnews.com/latest/rss/main",
-    ],
-    path: "usa-news",
-  },
-  "World News": {
-    feeds: [
-      "https://feeds.nbcnews.com/nbcnews/public/news",
-      "https://www.cnbc.com/id/100727362/device/rss/rss.html",
-    ],
-    path: "world-news",
-  },
-};
+let articles = defaultArticles;
 
-Object.keys(articles).forEach((catagory) => {
-  if (catagory == "All") return;
-  articles.All.feeds = articles.All.feeds.concat(articles[catagory].feeds);
-});
+const stored = Astro.getCookie("rssFeeds");
+if (stored) {
+  try {
+    const customSettings = JSON.parse(stored);
+    articles = customSettings;
+  } catch (e) {
+    console.error("Failed to parse stored RSS feeds, using defaults", e);
+    articles = defaultArticles;
+  }
+}
 
 export default articles;

+ 1 - 1
src/layouts/layout.astro

@@ -3,7 +3,7 @@
     <head>
         <meta charset="UTF-8" />
         <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-        <title>Simo Reader</title>
+        <title>Mikro</title>
     </head>
     <body>
         <slot />

+ 61 - 25
src/pages/[...cat].astro

@@ -1,40 +1,76 @@
 ---
-import articles from "feeds.js";
-
 import Nav from "@components/nav.astro";
-import Sidebar from "@components/sidebar.astro";
 import Layout from "@layouts/layout.astro";
-import Article from "@components/article.astro";
+import ArticleList from "@components/ArticleList.astro";
+import PopUpBox from "@components/popUpBox.astro";
+import SettingsDialog from "@components/SettingsDialog.astro";
 
-import Parser from "rss-parser";
+const defaultArticles = {
+    Tech: {
+        feeds: ["https://techcrunch.com/feed/"],
+        path: "tech",
+    },
+    "USA News": {
+        feeds: [
+            "https://feeds.nbcnews.com/nbcnews/public/news",
+            "https://abcnews.go.com/abcnews/topstories",
+            "https://www.cbsnews.com/latest/rss/main",
+        ],
+        path: "usa-news",
+    },
+    "World News": {
+        feeds: [
+            "https://feeds.nbcnews.com/nbcnews/public/news",
+            "https://www.cnbc.com/id/100727362/device/rss/rss.html",
+        ],
+        path: "world-news",
+    },
+};
 
-let parser = new Parser();
+let articles: any;
 
-let { cat } = Astro.params;
+const stored = Astro.cookies.get("rssFeeds")?.json();
+if (stored) {
+    try {
+        const customSettings = stored;
+        articles = customSettings;
+    } catch (e) {
+        console.error("Failed to parse stored RSS feeds, using defaults", e);
+        articles = defaultArticles;
+    }
+}
 
-cat = !cat ? "/" : cat;
+articles.All = {
+    path: "/",
+    feeds: [],
+};
 
-const catagory: string | undefined = Object.keys(articles).find(
-    (catagory) => articles[catagory as keyof typeof articles].path == cat,
-);
+Object.keys(articles).forEach((catagory) => {
+    if (catagory == "All") return;
+    articles.All.feeds = articles.All.feeds.concat(articles[catagory].feeds);
+});
 
-const feedList = articles[catagory as keyof typeof articles].feeds;
+let { cat } = Astro.params;
 
-console.log(feedList);
+cat = !cat ? "/" : cat;
 ---
 
 <Layout>
+    <SettingsDialog />
+
+    <PopUpBox title="test" content="content" />
+
     <Nav {cat} {articles} />
-    <Sidebar {catagory} {articles} {parser} />
-
-    {
-        feedList.map(async (feed) =>
-            (await parser.parseURL(feed)).items.forEach((item) => {
-                {
-                    console.log(item.title);
-                }
-                <Article article={item} />;
-            }),
-        )
-    }
+
+    <ArticleList />
+
+    <script>
+        import { isOpen } from "@utils/popupStore";
+        document.addEventListener("click", (event) => {
+            isOpen &&
+            (event.target as HTMLHtmlElement).offsetParent?.id !== "popup"
+                ? isOpen.set(false)
+                : "";
+        });
+    </script>
 </Layout>

+ 126 - 0
src/pages/api/cors.ts

@@ -0,0 +1,126 @@
+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 targetUrl = url.searchParams.get("url");
+
+  if (!targetUrl) {
+    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(targetUrl);
+  } catch (error) {
+    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(targetUrl);
+    const now = Date.now();
+
+    if (cached && now - cached.timestamp < CACHE_TTL) {
+      console.log(`[CORS Proxy Cache] Hit for: ${targetUrl}`);
+      return new Response(cached.data, {
+        status: 200,
+        headers: {
+          ...cached.headers,
+          "X-Cache": "HIT",
+          "Access-Control-Allow-Origin": "*",
+        },
+      });
+    }
+
+    console.log(`[CORS Proxy Cache] Miss, fetching: ${targetUrl}`);
+
+    const response = await fetch(targetUrl, {
+      headers: {
+        "User-Agent": "Mozilla/5.0 (compatible; MikroProxy/1.0)",
+      },
+    });
+
+    if (!response.ok) {
+      return new Response(
+        JSON.stringify({
+          error: `Failed to fetch resource: ${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") || "text/plain";
+
+    // Store relevant headers
+    const headersToCache: Record<string, string> = {
+      "Content-Type": contentType,
+    };
+
+    cache.set(targetUrl, {
+      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) {
+    console.error(`[CORS Proxy] Error fetching ${targetUrl}:`, error);
+
+    return new Response(
+      JSON.stringify({
+        error: "Failed to fetch resource",
+        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",
+    },
+  });
+};

+ 73 - 0
src/pages/api/feed.ts

@@ -0,0 +1,73 @@
+import type { APIRoute } from "astro";
+import Parser from "rss-parser";
+
+const parser = new Parser();
+
+const cache = new Map<string, { data: any; timestamp: number }>();
+const CACHE_TTL = 5 * 60 * 1000;
+
+export const GET: APIRoute = async ({ request }) => {
+  const url = new URL(request.url);
+  const feedUrl = url.searchParams.get("url");
+
+  if (!feedUrl) {
+    return new Response(JSON.stringify({ error: "Missing url parameter" }), {
+      status: 400,
+      headers: {
+        "Content-Type": "application/json",
+      },
+    });
+  }
+
+  try {
+    const cached = cache.get(feedUrl);
+    const now = Date.now();
+
+    if (cached && now - cached.timestamp < CACHE_TTL) {
+      console.log(`[API Cache] Hit for: ${feedUrl}`);
+      return new Response(JSON.stringify(cached.data), {
+        status: 200,
+        headers: {
+          "Content-Type": "application/json",
+          "X-Cache": "HIT",
+          "Access-Control-Allow-Origin": "*",
+        },
+      });
+    }
+
+    console.log(`[API Cache] Miss, fetching: ${feedUrl}`);
+
+    const feed = await parser.parseURL(feedUrl);
+
+    cache.set(feedUrl, {
+      data: feed,
+      timestamp: now,
+    });
+
+    return new Response(JSON.stringify(feed), {
+      status: 200,
+      headers: {
+        "Content-Type": "application/json",
+        "X-Cache": "MISS",
+        "Access-Control-Allow-Origin": "*",
+        "Cache-Control": "public, max-age=300",
+      },
+    });
+  } catch (error) {
+    console.error(`[API] Error fetching feed ${feedUrl}:`, error);
+
+    return new Response(
+      JSON.stringify({
+        error: "Failed to fetch or parse RSS feed",
+        details: error instanceof Error ? error.message : "Unknown error",
+      }),
+      {
+        status: 500,
+        headers: {
+          "Content-Type": "application/json",
+          "Access-Control-Allow-Origin": "*",
+        },
+      },
+    );
+  }
+};

+ 89 - 0
src/utils/clientCachedParser.ts

@@ -0,0 +1,89 @@
+import Parser from "rss-parser";
+
+const STORAGE_KEY = "rss_feed_cache";
+
+function loadCache(): Map<string, any> {
+  try {
+    const stored = localStorage.getItem(STORAGE_KEY);
+    if (stored) {
+      const obj = JSON.parse(stored);
+      return new Map(Object.entries(obj));
+    }
+  } catch (error) {
+    console.error("[Cache] Error loading from localStorage:", error);
+  }
+  return new Map();
+}
+
+function saveCache(cache: Map<string, any>): void {
+  try {
+    let obj = Object.fromEntries(cache);
+
+    localStorage.setItem(STORAGE_KEY, JSON.stringify(obj));
+  } catch (error) {
+    console.error("[Cache] Error saving to localStorage:", error);
+  }
+}
+
+const cache = loadCache();
+
+const parser = new Parser();
+
+class ClientCachedParser {
+  async parseURL(url: string) {
+    const now = Date.now();
+
+    const CACHE_TTL = 1 * 60 * 1000;
+
+    if (cache.has(url) && now - cache.get(url).createdAt < CACHE_TTL) {
+      console.log(`[Cache] Hit for: ${url}`);
+
+      return cache.get(url);
+    }
+
+    console.log(`[Cache] Miss, fetching: ${url}`);
+
+    try {
+      let result = await parser.parseURL(url);
+
+      result.createdAt = Date.now();
+
+      cache.set(url, result);
+      saveCache(cache);
+      return result;
+    } catch (error) {
+      console.log(`[Cache] Direct fetch failed, using CORS proxy for: ${url}`);
+      console.error(error);
+
+      const response = await fetch(`/api/feed?url=${encodeURIComponent(url)}`);
+
+      if (!response.ok) {
+        throw new Error(
+          `Failed to fetch feed via proxy: ${response.statusText}`,
+        );
+      }
+
+      const result = await response.json();
+
+      result.createdAt = Date.now();
+
+      cache.set(url, result);
+      saveCache(cache);
+      return result;
+    }
+  }
+
+  clearCache() {
+    cache.clear();
+    localStorage.removeItem(STORAGE_KEY);
+    console.log("[Cache] Cleared all cached feeds");
+  }
+
+  clearCacheFor(url: string) {
+    cache.delete(url);
+    saveCache(cache);
+    console.log(`[Cache] Cleared cache for: ${url}`);
+  }
+}
+
+export default ClientCachedParser;

+ 10 - 0
src/utils/popupStore.ts

@@ -0,0 +1,10 @@
+import { atom, map } from "nanostores";
+
+export const isOpen = atom(false);
+
+export type popupContent = {
+  title: string;
+  html: string;
+};
+
+export const content = map<popupContent>({} as popupContent);

+ 0 - 0
test


+ 5 - 3
tsconfig.json

@@ -2,11 +2,13 @@
   "extends": "astro/tsconfigs/strict",
   "include": [".astro/types.d.ts", "**/*"],
   "exclude": ["dist"],
+
   "compilerOptions": {
     "baseUrl": "./src",
     "paths": {
       "@layouts/*": ["layouts/*"],
-      "@components/*": ["components/*"]
-    }
-  }
+      "@components/*": ["components/*"],
+      "@utils/*": ["utils/*"],
+    },
+  },
 }

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