72 Commits

Author SHA1 Message Date
Renovate Bot
cf92eb66e7 Update https://git.quad4.io/actions/setup-node action to v6
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 9m35s
2025-12-31 00:04:14 +00:00
ec011f7442 Merge pull request 'Update dependency cookie to v1' (#6) from renovate/cookie-1.x into master
All checks were successful
CI / build-frontend (push) Successful in 1m44s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m38s
CI / build-backend (push) Successful in 9m37s
Reviewed-on: #6
2025-12-29 20:02:14 +00:00
5228640a21 Merge pull request 'Update dependency gradle to v9' (#7) from renovate/gradle-9.x into master
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 1m2s
CI / build-frontend (push) Successful in 1m3s
CI / build-backend (push) Successful in 43s
Reviewed-on: #7
2025-12-29 20:02:05 +00:00
49df78f553 Update CI and workflow files to use custom action URLs instead of default GitHub actions. Remove obsolete renovate.yml workflow file.
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 38s
CI / build-frontend (push) Successful in 1m33s
CI / build-backend (push) Successful in 59s
2025-12-28 20:58:54 -06:00
ivan
5549171165 Merge pull request 'Update ghcr.io/renovatebot/renovate Docker tag to v42.66.11' (#12) from renovate/ghcr.io-renovatebot-renovate-42.x into master
Some checks failed
renovate / renovate (push) Failing after 27s
CI / build-backend (push) Has been skipped
CI / build-frontend (push) Failing after 29s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m39s
Reviewed-on: #12
2025-12-29 00:21:33 +00:00
Renovate Bot
39f2986150 Update ghcr.io/renovatebot/renovate Docker tag to v42.66.11
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 9m36s
2025-12-29 00:04:22 +00:00
ivan
e0f85b450c Merge pull request 'Update ghcr.io/renovatebot/renovate Docker tag to v42' (#9) from renovate/ghcr.io-renovatebot-renovate-42.x into master
Some checks failed
CI / build-frontend (push) Failing after 9s
CI / build-backend (push) Has been skipped
renovate / renovate (push) Failing after 25s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m35s
Reviewed-on: #9
2025-12-28 18:53:37 +00:00
Renovate Bot
19a9e0506d Update ghcr.io/renovatebot/renovate Docker tag to v42
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 9m35s
2025-12-28 14:53:19 +00:00
3cdca944ef 0.2.3
Some checks failed
renovate / renovate (push) Failing after 4s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 54s
CI / build-frontend (push) Successful in 1m6s
CI / build-backend (push) Successful in 36s
Build and Publish Docker Image / build (push) Successful in 11m1s
Publish / publish (push) Successful in 33m35s
2025-12-27 21:27:15 -06:00
895fba9ded Update fetching and filtering capabilities by adding category support in GetArticles methods across multiple files. Update NewsStore to manage selected category state and integrate category filtering in article loading. Improve feed fetching with abort signal handling in fetchFeed function. Update UI components to reflect category selection and enhance user experience with new feed health management features.
Some checks failed
renovate / renovate (push) Failing after 13s
CI / build-frontend (push) Successful in 50s
CI / build-backend (push) Successful in 49s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m39s
2025-12-27 21:26:28 -06:00
a4503563e3 Update version to 0.2.2 in package.json
Some checks failed
renovate / renovate (push) Failing after 15s
CI / build-frontend (push) Successful in 51s
CI / build-backend (push) Successful in 35s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m37s
2025-12-27 21:01:03 -06:00
82da01ca45 Add OPML import functionality in AddFeedModal component, including file handling and toast notifications. Update ArticleCard to display feed icons and enhance article source display. Modify NewsStore to track last articles update and improve article refresh logic. Update UI components for better user experience and consistency. 2025-12-27 21:00:36 -06:00
7e235cb9d1 Format configuration files for consistency and readability. Update docker-compose.coolify.yml to use single quotes for labels. Adjust pnpm-lock.yaml to format resolution entries uniformly. Clean up README.md by removing an empty checklist item. Standardize quotes in renovate.json and .gitea/workflows/renovate.yml. Enhance HTML structure in app.html for better readability. 2025-12-27 20:32:18 -06:00
a626a7cb33 Add newlyRegisteredToken state to NewsStore and implement account number display and copy functionality in the registration flow. Update UI to show success message upon registration and allow users to copy their account number to clipboard. 2025-12-27 20:32:06 -06:00
2c6bee84b4 Add caching support in main.go and related files, including SQLite integration for cache storage. Update Docker configurations to include cache settings and enable public instance optimizations.
Some checks failed
renovate / renovate (push) Failing after 15s
CI / build-frontend (push) Successful in 51s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m31s
CI / build-backend (push) Successful in 9m36s
2025-12-27 20:25:38 -06:00
965c2b6daf Update version to 0.2.1
Some checks failed
renovate / renovate (push) Failing after 14s
CI / build-frontend (push) Successful in 49s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m32s
CI / build-backend (push) Successful in 9m27s
2025-12-27 20:13:28 -06:00
20b83eb052 Update sidebar version display to use dynamic APP_VERSION import for improved maintainability 2025-12-27 20:13:17 -06:00
9bd06c1f70 Add rate limiting environment variables to docker-compose.coolify.yml for improved service configuration
Some checks failed
renovate / renovate (push) Failing after 20s
CI / build-frontend (push) Successful in 47s
CI / build-backend (push) Successful in 25s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m37s
2025-12-27 20:09:28 -06:00
0765f47083 Add rate limiting environment variables to Dockerfile for enhanced configuration 2025-12-27 20:09:23 -06:00
0b7c15f2f0 Fix rate limit environment variable handling in main.go to improve parsing logic and remove redundant code 2025-12-27 20:09:16 -06:00
7180776daa Add rate limiting configuration options in main.go and implement SetLimit method in RateLimiter to dynamically adjust rate limits based on environment variables 2025-12-27 20:08:20 -06:00
4ed6fcd752 Add GetRealIP function to improve IP retrieval in middleware; update BotBlockerMiddleware to use new function for logging blocked requests
Some checks failed
renovate / renovate (push) Failing after 21s
CI / build-frontend (push) Successful in 51s
CI / build-backend (push) Successful in 26s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m39s
2025-12-27 20:01:05 -06:00
4e364bec74 Fix authentication flags in main.go to support environment variable defaults for auth mode, auth file, registration allowance, hashes file, and protection settings 2025-12-27 20:01:00 -06:00
8e41a88599 Update docker-compose.coolify.yml to enhance environment variable defaults and add Traefik rate limiting labels 2025-12-27 20:00:49 -06:00
eece10388a Update service worker cache version to 0.2.0
Some checks failed
renovate / renovate (push) Failing after 15s
CI / build-frontend (push) Successful in 49s
CI / build-backend (push) Successful in 24s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m39s
Build and Publish Docker Image / build (push) Successful in 10m8s
Publish / publish (push) Successful in 33m56s
2025-12-27 19:51:14 -06:00
4e308f2e61 Add SEO and social sharing features by adding Open Graph and Twitter meta tags across app.html and share page. Update sidebar layout and version to 0.2.0. 2025-12-27 19:51:10 -06:00
3deba1ad94 Update error handling in SaveHashes method of RateLimiter to log marshaling and file writing errors 2025-12-27 19:50:59 -06:00
d0c96c7ca5 Update pnpm-lock.yaml to reflect dependency version changes and improve formatting 2025-12-27 19:50:49 -06:00
5d049d38b7 Update package.json to version 0.2.0 and upgrade dependencies 2025-12-27 19:50:44 -06:00
bb3ba5ecd4 Update Dockerfile to version 0.2.0 2025-12-27 19:50:37 -06:00
25182b6831 Fix ALLOWED_ORIGINS environment variable in docker-compose.coolify.yml to remove default fallback 2025-12-27 19:50:28 -06:00
a37f6fa7bf Update README.md with new feature checkboxes and build requirements 2025-12-27 19:50:17 -06:00
Renovate Bot
cafe5b8fd0 Update dependency gradle to v9
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 42s
2025-12-28 01:10:21 +00:00
Renovate Bot
3534ba9b89 Update dependency cookie to v1
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 42s
2025-12-28 01:10:07 +00:00
ivan
205eb3e99d Merge pull request 'Update module git.quad4.io/Quad4-Software/webnews to v0.1.0' (#4) from renovate/git.quad4.io-quad4-software-webnews-0.x into master
Some checks failed
renovate / renovate (push) Failing after 4s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 43s
CI / build-frontend (push) Successful in 1m2s
CI / build-backend (push) Successful in 27s
Reviewed-on: #4
2025-12-28 00:42:12 +00:00
ivan
6bdc0ad29c Merge pull request 'Update dependency com.android.tools.build:gradle to v8.13.2' (#2) from renovate/com.android.tools.build-gradle-8.x into master
Some checks failed
renovate / renovate (push) Failing after 45s
CI / build-frontend (push) Successful in 1m14s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m37s
CI / build-backend (push) Successful in 9m29s
Reviewed-on: #2
2025-12-28 00:42:04 +00:00
ivan
3668aa8e78 Merge pull request 'Update ghcr.io/renovatebot/renovate Docker tag to v37.440.7' (#3) from renovate/ghcr.io-renovatebot-renovate-37.x into master
Some checks failed
renovate / renovate (push) Failing after 4s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 59s
CI / build-frontend (push) Successful in 1m2s
CI / build-backend (push) Successful in 26s
Reviewed-on: #3
2025-12-28 00:41:52 +00:00
Renovate Bot
ec0b6b3262 Update module git.quad4.io/Quad4-Software/webnews to v0.1.0
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 1m0s
2025-12-28 00:02:08 +00:00
Renovate Bot
6a35e95814 Update ghcr.io/renovatebot/renovate Docker tag to v37.440.7
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 9m56s
2025-12-27 22:31:19 +00:00
Renovate Bot
38398d79be Update dependency com.android.tools.build:gradle to v8.13.2
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 9m59s
2025-12-27 22:31:17 +00:00
ivan
f92922ae8e Merge pull request 'Configure Renovate' (#1) from renovate/configure into master
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 48s
CI / build-frontend (push) Successful in 57s
CI / build-backend (push) Successful in 28s
renovate / renovate (push) Failing after 7s
Reviewed-on: #1
2025-12-27 20:55:00 +00:00
Renovate Bot
4210e0a0c1 Add renovate.json
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 39s
2025-12-27 20:49:16 +00:00
ce13ec2d6f Add Renovate workflow
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 49s
CI / build-frontend (push) Successful in 1m16s
CI / build-backend (push) Successful in 38s
renovate / renovate (push) Failing after 10s
2025-12-27 14:29:17 -06:00
5bc7630673 Update Go setup action version in OSV workflows for consistency
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 56s
CI / build-frontend (push) Successful in 1m6s
CI / build-backend (push) Successful in 32s
2025-12-27 12:42:53 -06:00
b402f078e7 Add Go setup step in OSV workflows for improved environment configuration
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Failing after 46s
CI / build-frontend (push) Successful in 1m33s
CI / build-backend (push) Successful in 26s
2025-12-27 12:38:12 -06:00
b2306798ac Refactor OSV scan script to simplify vulnerability reporting
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 20s
CI / build-frontend (push) Successful in 1m14s
CI / build-backend (push) Successful in 34s
2025-12-27 12:35:57 -06:00
50399bcae2 Update CI and publish workflows to use specific pnpm action version and add Android SDK setup
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 19s
CI / build-frontend (push) Successful in 51s
CI / build-backend (push) Successful in 28s
- Changed pnpm action setup to a specific commit version for consistency in CI and publish workflows.
- Added Android SDK setup step in the publish workflow to streamline Android development environment.
2025-12-27 12:32:36 -06:00
3f62b36de5 Refactor code for improved readability and consistency
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 11s
CI / build-frontend (push) Successful in 1m29s
CI / build-backend (push) Successful in 26s
- Cleaned up formatting in app.css and db.ts by removing unnecessary blank lines and ensuring consistent indentation.
- Enhanced readability in various files by adding missing semicolons and adjusting line breaks for better clarity.
- Updated function signatures and object definitions for improved type consistency in TypeScript files.
2025-12-27 12:26:57 -06:00
c5176f9ed4 Refactor Svelte components for improved formatting and consistency
- Cleaned up formatting in AddFeedModal.svelte, ArticleCard.svelte, Navbar.svelte, Sidebar.svelte, and Toasts.svelte by adding missing commas and adjusting indentation.
- Enhanced readability by ensuring consistent use of line breaks and spacing across components.
- Updated event handlers and bindings for better clarity and maintainability.
2025-12-27 12:26:48 -06:00
30c7a50240 Update build and check scripts to use pnpm for consistency
- Changed build command in build.sh from npm to pnpm.
- Updated dependency installation in publish_setup.sh to use pnpm.
- Modified check.sh to replace npx commands with pnpm for running Svelte sync and checks.
2025-12-27 12:26:39 -06:00
f98fc7c618 Remove unnecessary blank line in inject-sw-version.js for cleaner code 2025-12-27 12:26:31 -06:00
402f4e676e Update wails.json to use pnpm for frontend dependency management
- Changed frontend installation and build commands from npm to pnpm for consistency with recent updates across the project.
2025-12-27 12:26:21 -06:00
78ec006842 Update CI and publish workflows to integrate pnpm for dependency management
- Added pnpm installation step in both CI and publish workflows.
- Updated dependency installation command to use pnpm instead of npm for consistency and improved performance.
- Configured pnpm cache setup in CI workflow to optimize build times.
2025-12-27 12:26:16 -06:00
7a8674f0db Update vite.config.ts to add trailing comma for consistency in server proxy configuration 2025-12-27 12:26:09 -06:00
44b11cf3e8 Refactor tailwind.config.js to streamline plugins array formatting 2025-12-27 12:26:03 -06:00
6a6c39ce1b Update Svelte configuration files to ensure consistent formatting
- Added a trailing comma in the configuration objects of both svelte.config.js and svelte.config.docker.js for improved readability and consistency.
2025-12-27 12:25:58 -06:00
30b5f48349 Update README.md to include To-Do list and prerequisites for pnpm
- Added a To-Do section outlining future enhancements such as reading time and UI/UX cleanup.
- Updated prerequisites to include pnpm 9+ for consistency in dependency management.
2025-12-27 12:25:50 -06:00
3258ee94cf Update Dockerfile to set user context and improve dependency installation
- Changed user to root for installing pnpm globally, then switched back to node for application work.
- Updated WORKDIR to ensure proper directory context for the application build process.
2025-12-27 12:25:38 -06:00
db764ede58 Remove package-lock.json and update package.json to use pnpm for packaging and add cookie override 2025-12-27 12:25:32 -06:00
d6bd993abd Update Makefile to use pnpm for dependency management
- Replaced npm commands with pnpm for installing dependencies and running development scripts, enhancing consistency and performance.
- Adjusted the android-build and frontend-build targets to utilize pnpm for synchronization and building processes.
2025-12-27 12:25:23 -06:00
53d1fdbd21 Refactor Dockerfile to use pnpm for dependency management
- Replaced npm with pnpm for installing dependencies, improving installation speed and consistency.
- Updated the Dockerfile to copy pnpm-lock.yaml and use the frozen lockfile option during installation.
- Adjusted build command to utilize pnpm for building the frontend.
2025-12-27 12:24:42 -06:00
b5d0102c27 Add pnpm lockfile for dependency management
- Created pnpm-lock.yaml to manage project dependencies and ensure consistent installations.
- Included various packages such as Capacitor, TailwindCSS, and Svelte with their respective versions for improved project stability.
2025-12-27 12:24:26 -06:00
55d599dd2f format capacitor.config.ts to include trailing comma for consistency 2025-12-27 12:24:16 -06:00
390c70cff1 format compose files 2025-12-27 12:24:02 -06:00
ec37403369 Add Android build process and CI configuration
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 16s
CI / build-frontend (push) Successful in 41s
CI / build-backend (push) Successful in 24s
- Updated Makefile to remove explicit JAVA_HOME export, simplifying the android-build command.
- Added Java setup step in the Gitea workflow to ensure the correct Java version is used during builds.
- Modified publish_build.sh to include the Android APK build step and copy the generated APK to the distribution directory.
2025-12-27 12:11:20 -06:00
ab67cae648 Add build arguments to Docker workflow for enhanced image metadata
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 16s
CI / build-frontend (push) Successful in 41s
CI / build-backend (push) Successful in 25s
- Included BUILD_DATE, VCS_REF, and VERSION as build arguments in the Docker workflow to improve image documentation and traceability.
2025-12-27 12:01:41 -06:00
0ef2477403 Update Docker build process in Makefile
- Added build arguments for BUILD_DATE, VCS_REF, and VERSION to the docker build command for improved image metadata.
2025-12-27 12:01:34 -06:00
dd6c743915 Add Docker Compose files for web-news service configuration
- Introduced docker-compose.coolify.yml for Coolify deployment.
- Added docker-compose.prod.yml for production environment setup.
- Created docker-compose.yml for local development with port mapping.
- Configured environment variables and volume management for data persistence.
2025-12-27 12:01:18 -06:00
46fb66ca05 Update Dockerfile for improved build efficiency and runtime configuration
- Implemented caching for npm and Go module downloads to speed up builds.
- Updated base image for the final runtime stage to a more minimal static image.
- Added metadata labels for better image documentation and versioning.
- Created a data directory for configuration files and adjusted ownership.
- Modified the command to include paths for authentication and hashes files.
2025-12-27 12:01:03 -06:00
6289ffc01d Update Docker workflow to change image name from linking-tool to webnews
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 16s
CI / build-frontend (push) Successful in 44s
CI / build-backend (push) Successful in 27s
2025-12-27 02:31:54 -06:00
8334b4487d Replace favicons
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 15s
CI / build-frontend (push) Successful in 1m0s
CI / build-backend (push) Successful in 25s
2025-12-27 02:28:01 -06:00
d07f3f7ee0 Remove npm publish workflow 2025-12-27 02:27:50 -06:00
55 changed files with 6707 additions and 7081 deletions

View File

@@ -11,20 +11,39 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: https://git.quad4.io/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
node-version: 22
cache: npm
- name: Install pnpm
uses: https://git.quad4.io/actions/setup-pnpm@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
with:
version: 10
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: https://git.quad4.io/actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: npm ci
run: pnpm install --frozen-lockfile
- name: Frontend checks
run: bash scripts/check.sh
- name: Build frontend
run: bash scripts/build.sh
- name: Upload frontend assets
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
uses: https://git.quad4.io/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
name: frontend-build
path: build/
@@ -34,14 +53,14 @@ jobs:
needs: build-frontend
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Download frontend assets
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
uses: https://git.quad4.io/actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
with:
name: frontend-build
path: build/
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.4'
- name: Build backend

View File

@@ -8,7 +8,7 @@ on:
env:
REGISTRY: git.quad4.io
IMAGE_NAME: quad4-software/linking-tool
IMAGE_NAME: quad4-software/webnews
jobs:
build:
@@ -22,18 +22,18 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
uses: https://git.quad4.io/actions/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
with:
platforms: amd64,arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: https://git.quad4.io/actions/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to the Container registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
uses: https://git.quad4.io/actions/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
@@ -41,7 +41,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
uses: https://git.quad4.io/actions/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
@@ -54,7 +54,7 @@ jobs:
- name: Build and push Docker image
id: build
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
uses: https://git.quad4.io/actions/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
context: .
file: ./Dockerfile
@@ -62,3 +62,7 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta.outputs.version }}

View File

@@ -1,37 +0,0 @@
name: Publish NPM Package
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci --registry=https://registry.npmjs.org/
- name: Package
run: make package
- name: Configure npm for publishing
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
registry-url: 'https://git.quad4.io/api/packages/quad4-software/npm/'
- name: Publish
run: npm publish
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -14,7 +14,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: 'go.mod'
- name: OSV scan
run: bash scripts/osv_scan.sh

View File

@@ -14,7 +14,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: 'go.mod'
- name: OSV scan
run: bash scripts/osv_scan.sh

View File

@@ -11,19 +11,36 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install pnpm
uses: https://git.quad4.io/actions/setup-pnpm@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: https://git.quad4.io/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
node-version: 22
cache: npm
cache: pnpm
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.4'
- name: Setup Java
uses: https://git.quad4.io/actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
with:
distribution: 'temurin'
java-version: '21'
cache: gradle
- name: Setup Android SDK
uses: https://git.quad4.io/actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with:
log-accepted-android-sdk-licenses: false
- name: Install dependencies
run: bash scripts/publish_setup.sh
@@ -39,4 +56,3 @@ jobs:
REPO_NAME: ${{ gitea.event.repository.name }}
SERVER_URL: ${{ gitea.server_url }}
run: bash scripts/publish.sh

View File

@@ -1,31 +1,63 @@
# Stage 1: Build the frontend
FROM cgr.dev/chainguard/node:latest-dev AS node-builder
USER root
RUN npm install -g pnpm
USER node
WORKDIR /app
COPY --chown=node:node package.json package-lock.json ./
RUN npm ci
COPY --chown=node:node package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY --chown=node:node . .
COPY --chown=node:node svelte.config.docker.js svelte.config.js
RUN npm run build
RUN pnpm run build
# Stage 2: Build the Go binary with embedded assets
FROM cgr.dev/chainguard/go:latest-dev AS go-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
COPY --from=node-builder /app/build ./build
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o web-news main.go
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -ldflags="-s -w" -o web-news main.go
# Create data directory for accounts.json and hashes
RUN mkdir -p /app/data && chown 65532:65532 /app/data
# Stage 3: Minimal runtime image
FROM cgr.dev/chainguard/wolfi-base:latest
FROM cgr.dev/chainguard/static:latest
WORKDIR /app
ARG BUILD_DATE
ARG VCS_REF
ARG VERSION="0.2.0"
LABEL org.opencontainers.image.created=$BUILD_DATE \
org.opencontainers.image.title="Web News" \
org.opencontainers.image.description="A modern, high-performance RSS news reader." \
org.opencontainers.image.url="https://quad4.io" \
org.opencontainers.image.documentation="https://github.com/Quad4-Software/webnews/blob/main/README.md" \
org.opencontainers.image.source="https://github.com/Quad4-Software/webnews" \
org.opencontainers.image.version=$VERSION \
org.opencontainers.image.revision=$VCS_REF \
org.opencontainers.image.vendor="Quad4" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.authors="Quad4" \
org.opencontainers.image.base.name="cgr.dev/chainguard/static:latest"
COPY --from=go-builder /app/web-news .
RUN apk add --no-cache ca-certificates
COPY --from=go-builder --chown=65532:65532 /app/data ./data
COPY LICENSE README.md ./
EXPOSE 8080
ENV PORT=8080
ENV NODE_ENV=production
ENV AUTH_FILE=/app/data/accounts.json
ENV HASHES_FILE=/app/data/client_hashes.json
ENV RATE_LIMIT=100
ENV RATE_BURST=200
ENV CACHE_FILE=/app/data/cache.db
ENV PUBLIC_INSTANCE=false
USER 65532
CMD ["./web-news"]
CMD ["./web-news", "-auth-file", "/app/data/accounts.json", "-hashes-file", "/app/data/client_hashes.json", "-cache-file", "/app/data/cache.db"]

View File

@@ -10,18 +10,18 @@ help:
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
android-build: build
npx cap sync android
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk && cd android && ./gradlew assembleDebug
pnpm cap sync android
cd android && ./gradlew assembleDebug
mkdir -p $(BUILD_DIR)/android
cp android/app/build/outputs/apk/debug/app-debug.apk $(BUILD_DIR)/android/web-news-debug.apk
dev:
npm install
(command -v air > /dev/null && air || go run main.go & npm run dev)
pnpm install
(command -v air > /dev/null && air || go run main.go & pnpm run dev)
frontend-build:
npm install
npm run build
pnpm install
pnpm run build
build: frontend-build
mkdir -p $(BUILD_DIR)
@@ -63,7 +63,11 @@ build-freebsd-amd64:
GOOS=freebsd GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-freebsd-amd64 main.go
docker-build:
docker build -t $(BINARY_NAME) .
docker build \
--build-arg BUILD_DATE=$(shell date -u +'%Y-%m-%dT%H:%M:%SZ') \
--build-arg VCS_REF=$(shell git rev-parse --short HEAD) \
--build-arg VERSION=$(shell node -p "require('./package.json').version") \
-t $(BINARY_NAME) .
docker-run:
docker run -p 8080:8080 $(BINARY_NAME)

View File

@@ -25,14 +25,27 @@ Web News follows a "zero-knowledge" philosophy:
3. **Local Cache**: Full-text content is cached locally in IndexedDB for offline reading and instant access.
4. **Hardened Backend**: Built-in bot blocking, rate limiting, and secure token generation.
## To-DO
- [ ] Reading time
- [ ] UI/UX Cleanup
- [ ] Use Go Mobile, remove Java RSS plugin.
- [ ] Export article(s)
- [ ] Favicon fetcher and caching
## Getting Started
### Prerequisites
- Go 1.21+
- Node.js 18+
- pnpm 9+
- [Wails CLI](https://wails.io/docs/gettingstarted/installation) (for desktop builds)
### Build & Run (Web Server)
Requires Go 1.21+, Node.js 18+, pnpm 9+.
1. **Build the binary**:
```bash
make build
@@ -43,6 +56,9 @@ Web News follows a "zero-knowledge" philosophy:
```
### Build & Run (Desktop App)
Requires Go 1.25.4+, Wails 2.11.0+, Node.js 18+, pnpm 9+, WebKit2GTK 4.1+ (for Linux).
1. **Launch Dev Mode**:
```bash
make desktop-dev
@@ -60,12 +76,14 @@ Web News follows a "zero-knowledge" philosophy:
## Configuration
### Server Flags
- `--auth-mode`: `none` (default), `token`, or `multi`.
- `--allow-registration`: Allow generating new account numbers (default: true).
- `--auth-file`: Path to the account storage (default: `accounts.json`).
- `--port`: Port to listen on (default: `8080`).
### Keyboard Shortcuts (Default)
- `j` / `k`: Next / Previous article
- `r`: Mark as read
- `s`: Toggle save
@@ -75,7 +93,7 @@ Web News follows a "zero-knowledge" philosophy:
## Development
- **Dev Server**: `make dev` (Starts Go backend + Vite frontend)
- **Format & Lint**: `npm run format && npm run lint`
- **Format & Lint**: `pnpm run format && pnpm run lint`
- **Clean Artifacts**: `make clean`
## License
@@ -83,4 +101,5 @@ Web News follows a "zero-knowledge" philosophy:
MIT - See [LICENSE](LICENSE) for details.
---
Created by [Quad4](https://quad4.io)

View File

@@ -7,7 +7,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.android.tools.build:gradle:8.13.2'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -1,9 +1,9 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.quad4.webnews',
appName: 'Web News',
webDir: 'build'
appId: 'com.quad4.webnews',
appName: 'Web News',
webDir: 'build',
};
export default config;

View File

@@ -151,12 +151,12 @@ func (a *App) SaveArticles(articles string) error {
return a.db.SaveArticles(articles)
}
func (a *App) GetArticles(feedId string, offset, limit int) (string, error) {
a.logDebug("GetArticles feedId=%s offset=%d limit=%d", feedId, offset, limit)
func (a *App) GetArticles(feedId string, offset, limit int, categoryId string) (string, error) {
a.logDebug("GetArticles feedId=%s categoryId=%s offset=%d limit=%d", feedId, categoryId, offset, limit)
if a.db == nil {
return "[]", nil
}
return a.db.GetArticles(feedId, offset, limit)
return a.db.GetArticles(feedId, offset, limit, categoryId)
}
func (a *App) SearchArticles(query string, limit int) (string, error) {

View File

@@ -3,7 +3,7 @@ module git.quad4.io/Quad4-Software/webnews/desktop
go 1.25.4
require (
git.quad4.io/Quad4-Software/webnews v0.0.0
git.quad4.io/Quad4-Software/webnews v0.1.0
github.com/wailsapp/wails/v2 v2.11.0
)
@@ -43,6 +43,7 @@ require (
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect

View File

@@ -1,15 +1,14 @@
{
"name": "Web News",
"assetdir": "frontend_dist",
"frontend:dir": "..",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "http://localhost:5173",
"outputfilename": "web-news",
"author": {
"name": "Quad4",
"email": "dev@quad4.io"
}
"name": "Web News",
"assetdir": "frontend_dist",
"frontend:dir": "..",
"frontend:install": "pnpm install",
"frontend:build": "pnpm run build",
"frontend:dev:watcher": "pnpm run dev",
"frontend:dev:serverUrl": "http://localhost:5173",
"outputfilename": "web-news",
"author": {
"name": "Quad4",
"email": "dev@quad4.io"
}
}

View File

@@ -0,0 +1,31 @@
services:
web-news:
build:
context: .
dockerfile: Dockerfile
image: ${DOCKER_IMAGE:-web-news:latest}
environment:
- PORT=${PORT:-8080}
- NODE_ENV=production
- AUTH_MODE=${AUTH_MODE:-multi}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true}
# Coolify automatically populates SERVICE_URL_WEB_NEWS with the domain
- ALLOWED_ORIGINS=${SERVICE_URL_WEB_NEWS:-*}
- AUTH_FILE=/app/data/accounts.json
- HASHES_FILE=/app/data/client_hashes.json
- RATE_LIMIT=${RATE_LIMIT:-100}
- RATE_BURST=${RATE_BURST:-200}
- CACHE_FILE=/app/data/cache.db
- PUBLIC_INSTANCE=${PUBLIC_INSTANCE:-true}
volumes:
- web-news-data:/app/data
labels:
- 'traefik.enable=true'
- 'traefik.http.middlewares.web-news-ratelimit.ratelimit.average=100'
- 'traefik.http.middlewares.web-news-ratelimit.ratelimit.burst=50'
security_opt:
- no-new-privileges:true
restart: unless-stopped
volumes:
web-news-data:

23
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,23 @@
# Add your own reverse proxy
services:
web-news:
build: .
container_name: web-news-prod
volumes:
- data:/app/data
environment:
- PORT=8080
- NODE_ENV=production
- AUTH_MODE=multi
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
- AUTH_FILE=/app/data/accounts.json
- HASHES_FILE=/app/data/client_hashes.json
- CACHE_FILE=/app/data/cache.db
- PUBLIC_INSTANCE=${PUBLIC_INSTANCE:-false}
security_opt:
- no-new-privileges:true
restart: unless-stopped
volumes:
data:

23
docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
services:
web-news:
build: .
container_name: web-news
ports:
- '8080:8080'
volumes:
- data:/app/data
environment:
- PORT=8080
- NODE_ENV=production
- AUTH_MODE=none
- AUTH_TOKEN=${AUTH_TOKEN:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
- AUTH_FILE=/app/data/accounts.json
- HASHES_FILE=/app/data/client_hashes.json
- CACHE_FILE=/app/data/cache.db
security_opt:
- no-new-privileges:true
restart: unless-stopped
volumes:
data:

View File

@@ -52,6 +52,7 @@ export default [
FileReader: 'readonly',
performance: 'readonly',
AbortController: 'readonly',
AbortSignal: 'readonly',
DOMParser: 'readonly',
Element: 'readonly',
Node: 'readonly',

1
go.mod
View File

@@ -23,6 +23,7 @@ require (
github.com/stretchr/testify v1.10.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
modernc.org/libc v1.66.10 // indirect

View File

@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net"
@@ -16,7 +17,9 @@ import (
"time"
"git.quad4.io/Go-Libs/RSS"
"git.quad4.io/Quad4-Software/webnews/internal/storage"
readability "github.com/go-shiori/go-readability"
"golang.org/x/sync/singleflight"
"golang.org/x/time/rate"
)
@@ -45,6 +48,71 @@ type Article struct {
ImageURL string `json:"imageUrl"`
}
type cacheEntry struct {
data any
expiresAt time.Time
}
type Cache struct {
entries sync.Map
TTL time.Duration
Enabled bool
Storage *storage.SQLiteDB
}
func (c *Cache) Get(key string) (any, bool) {
if !c.Enabled {
return nil, false
}
if c.Storage != nil {
data, err := c.Storage.GetCache(key)
if err != nil || data == nil {
return nil, false
}
var val any
if err := json.Unmarshal(data, &val); err != nil {
return nil, false
}
return val, true
}
val, ok := c.entries.Load(key)
if !ok {
return nil, false
}
entry := val.(cacheEntry)
if time.Now().After(entry.expiresAt) {
c.entries.Delete(key)
return nil, false
}
return entry.data, true
}
func (c *Cache) Set(key string, data any) {
if !c.Enabled {
return
}
if c.Storage != nil {
b, err := json.Marshal(data)
if err != nil {
return
}
_ = c.Storage.SetCache(key, b, c.TTL)
return
}
c.entries.Store(key, cacheEntry{
data: data,
expiresAt: time.Now().Add(c.TTL),
})
}
var FeedCache = &Cache{TTL: 10 * time.Minute, Enabled: false}
var FullTextCache = &Cache{TTL: 1 * time.Hour, Enabled: false}
var RequestGroup = &singleflight.Group{}
type RateLimiter struct {
clients map[string]*rate.Limiter
mu *sync.RWMutex
@@ -94,8 +162,14 @@ func (rl *RateLimiter) SaveHashes() {
}
rl.mu.RUnlock()
data, _ := json.MarshalIndent(hashes, "", " ")
os.WriteFile(rl.File, data, 0600)
data, err := json.MarshalIndent(hashes, "", " ")
if err != nil {
log.Printf("Error marshaling rate limit hashes: %v", err)
return
}
if err := os.WriteFile(rl.File, data, 0600); err != nil {
log.Printf("Error writing rate limit hashes to %s: %v", rl.File, err)
}
}
func (rl *RateLimiter) GetLimiter(id string) *rate.Limiter {
@@ -116,7 +190,16 @@ func (rl *RateLimiter) GetLimiter(id string) *rate.Limiter {
return limiter
}
var Limiter = NewRateLimiter(rate.Every(time.Second), 5, "")
func (rl *RateLimiter) SetLimit(r rate.Limit, b int) {
rl.mu.Lock()
defer rl.mu.Unlock()
rl.r = r
rl.b = b
// Reset existing limiters to apply new rate
rl.clients = make(map[string]*rate.Limiter)
}
var Limiter = NewRateLimiter(rate.Limit(50), 100, "")
var ForbiddenPatterns = []string{
".git", ".env", ".aws", ".config", ".ssh",
@@ -124,14 +207,34 @@ var ForbiddenPatterns = []string{
"etc/passwd", "cgi-bin",
}
func GetRealIP(r *http.Request) string {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if comma := strings.IndexByte(xff, ','); comma != -1 {
return strings.TrimSpace(xff[:comma])
}
return strings.TrimSpace(xff)
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
return ip
}
func BotBlockerMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
path := strings.ToLower(r.URL.Path)
query := strings.ToLower(r.URL.RawQuery)
for _, pattern := range ForbiddenPatterns {
if strings.Contains(path, pattern) || strings.Contains(query, pattern) {
log.Printf("Blocked suspicious request: %s from %s", r.URL.String(), r.RemoteAddr)
if strings.Contains(path, pattern) {
ip := GetRealIP(r)
log.Printf("Blocked suspicious request: %s from %s", r.URL.String(), ip)
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@@ -270,18 +373,7 @@ func AuthMiddleware(am *AuthManager, next http.HandlerFunc) http.HandlerFunc {
func LimitMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if comma := strings.IndexByte(xff, ','); comma != -1 {
ip = xff[:comma]
} else {
ip = xff
}
}
ip := GetRealIP(r)
ua := r.Header.Get("User-Agent")
hash := sha256.New()
@@ -310,94 +402,111 @@ func HandleFeedProxy(w http.ResponseWriter, r *http.Request) {
return
}
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequest("GET", feedURL, nil)
if err != nil {
http.Error(w, "Failed to create request: "+err.Error(), http.StatusInternalServerError)
if data, ok := FeedCache.Get(feedURL); ok {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Printf("Error encoding cached feed proxy response: %v", err)
}
return
}
// Add browser-like headers to avoid being blocked by Cloudflare/Bot protection
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "application/rss+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.7")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
resp, err := client.Do(req)
if err != nil {
http.Error(w, "Failed to fetch feed: "+err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
http.Error(w, "Feed returned status "+resp.Status, http.StatusBadGateway)
return
}
data, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, "Failed to read feed body", http.StatusInternalServerError)
return
}
parsedFeed, err := rss.Parse(data)
if err != nil {
http.Error(w, "Failed to parse feed: "+err.Error(), http.StatusInternalServerError)
return
}
articles := make([]Article, 0, len(parsedFeed.Items))
for _, item := range parsedFeed.Items {
id := item.GUID
if id == "" {
id = item.Link
val, err, _ := RequestGroup.Do(feedURL, func() (any, error) {
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequest("GET", feedURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
pubDate := time.Now().UnixMilli()
if item.Published != nil {
pubDate = item.Published.UnixMilli()
// Add browser-like headers to avoid being blocked by Cloudflare/Bot protection
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "application/rss+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.7")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch feed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("feed returned status %s", resp.Status)
}
author := ""
if item.Author != nil {
author = item.Author.Name
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read feed body: %w", err)
}
imageURL := ""
for _, enc := range item.Enclosures {
if enc.Type == "image/jpeg" || enc.Type == "image/png" || enc.Type == "image/gif" {
imageURL = enc.URL
break
parsedFeed, err := rss.Parse(data)
if err != nil {
return nil, fmt.Errorf("failed to parse feed: %w", err)
}
articles := make([]Article, 0, len(parsedFeed.Items))
for _, item := range parsedFeed.Items {
id := item.GUID
if id == "" {
id = item.Link
}
pubDate := time.Now().UnixMilli()
if item.Published != nil {
pubDate = item.Published.UnixMilli()
}
author := ""
if item.Author != nil {
author = item.Author.Name
}
imageURL := ""
for _, enc := range item.Enclosures {
if enc.Type == "image/jpeg" || enc.Type == "image/png" || enc.Type == "image/gif" {
imageURL = enc.URL
break
}
}
articles = append(articles, Article{
ID: id,
FeedID: feedURL,
Title: item.Title,
Link: item.Link,
Description: item.Description,
Author: author,
PubDate: pubDate,
Read: false,
Saved: false,
ImageURL: imageURL,
})
}
articles = append(articles, Article{
ID: id,
FeedID: feedURL,
Title: item.Title,
Link: item.Link,
Description: item.Description,
Author: author,
PubDate: pubDate,
Read: false,
Saved: false,
ImageURL: imageURL,
})
}
response := ProxyResponse{
Feed: FeedInfo{
Title: parsedFeed.Title,
SiteURL: parsedFeed.Link,
Description: parsedFeed.Description,
LastFetched: time.Now().UnixMilli(),
},
Articles: articles,
}
response := ProxyResponse{
Feed: FeedInfo{
Title: parsedFeed.Title,
SiteURL: parsedFeed.Link,
Description: parsedFeed.Description,
LastFetched: time.Now().UnixMilli(),
},
Articles: articles,
FeedCache.Set(feedURL, response)
return response, nil
})
if err != nil {
if strings.Contains(err.Error(), "status") {
http.Error(w, err.Error(), http.StatusBadGateway)
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
if err := json.NewEncoder(w).Encode(val); err != nil {
log.Printf("Error encoding feed proxy response: %v", err)
}
}
@@ -467,46 +576,61 @@ func HandleFullText(w http.ResponseWriter, r *http.Request) {
return
}
parsedURL, _ := url.Parse(targetURL)
article, err := readability.FromURL(targetURL, 15*time.Second)
if err != nil {
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
http.Error(w, "Failed to create request: "+err.Error(), http.StatusInternalServerError)
return
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
resp, err := client.Do(req)
if err != nil {
http.Error(w, "Failed to fetch content: "+err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
article, err = readability.FromReader(resp.Body, parsedURL)
if err != nil {
http.Error(w, "Failed to extract content: "+err.Error(), http.StatusInternalServerError)
return
if data, ok := FullTextCache.Get(targetURL); ok {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Printf("Error encoding cached fulltext response: %v", err)
}
return
}
response := FullTextResponse{
Title: article.Title,
Content: article.Content,
TextContent: article.TextContent,
Excerpt: article.Excerpt,
Byline: article.Byline,
SiteName: article.SiteName,
Image: article.Image,
Favicon: article.Favicon,
URL: targetURL,
val, err, _ := RequestGroup.Do("ft-"+targetURL, func() (any, error) {
parsedURL, _ := url.Parse(targetURL)
article, err := readability.FromURL(targetURL, 15*time.Second)
if err != nil {
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch content: %w", err)
}
defer resp.Body.Close()
article, err = readability.FromReader(resp.Body, parsedURL)
if err != nil {
return nil, fmt.Errorf("failed to extract content: %w", err)
}
}
response := FullTextResponse{
Title: article.Title,
Content: article.Content,
TextContent: article.TextContent,
Excerpt: article.Excerpt,
Byline: article.Byline,
SiteName: article.SiteName,
Image: article.Image,
Favicon: article.Favicon,
URL: targetURL,
}
FullTextCache.Set(targetURL, response)
return response, nil
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
if err := json.NewEncoder(w).Encode(val); err != nil {
log.Printf("Error encoding fulltext response: %v", err)
}
}

View File

@@ -87,6 +87,12 @@ func (s *SQLiteDB) init() error {
key TEXT PRIMARY KEY,
value TEXT
);`,
`CREATE TABLE IF NOT EXISTS caches (
key TEXT PRIMARY KEY,
value BLOB,
expiresAt INTEGER
);`,
`CREATE INDEX IF NOT EXISTS idx_caches_expiresAt ON caches(expiresAt);`,
}
for _, q := range queries {
@@ -316,15 +322,22 @@ func (s *SQLiteDB) SaveArticles(articlesJSON string) error {
return tx.Commit()
}
func (s *SQLiteDB) GetArticles(feedId string, offset, limit int) (string, error) {
func (s *SQLiteDB) GetArticles(feedId string, offset, limit int, categoryId string) (string, error) {
var rows *sql.Rows
var err error
if feedId != "" {
rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?", feedId, limit, offset)
} else if categoryId != "" {
rows, err = s.db.Query(`
SELECT a.id, a.feedId, a.title, a.link, a.description, a.content, a.author, a.pubDate, a.read, a.saved, a.imageUrl, a.readAt
FROM articles a
JOIN feeds f ON a.feedId = f.id
WHERE f.categoryId = ?
ORDER BY a.pubDate DESC LIMIT ? OFFSET ?`, categoryId, limit, offset)
} else {
rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?", limit, offset)
}
if err != nil {
return "[]", err
}
@@ -371,7 +384,7 @@ func (s *SQLiteDB) SearchArticles(query string, limit int) (string, error) {
FROM articles
WHERE title LIKE ? OR description LIKE ? OR content LIKE ?
ORDER BY pubDate DESC LIMIT ?`, q, q, q, limit)
if err != nil {
return "[]", err
}
@@ -482,7 +495,7 @@ func (s *SQLiteDB) ClearAll() error {
func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
cutoff := time.Now().AddDate(0, 0, -days).UnixMilli()
rows, err := s.db.Query(`
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch')) as date, COUNT(*) as count
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch', 'localtime')) as date, COUNT(*) as count
FROM articles
WHERE read = 1 AND readAt > ?
GROUP BY date
@@ -499,8 +512,11 @@ func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
if err := rows.Scan(&date, &count); err != nil {
continue
}
// Convert date string back to timestamp for frontend
t, _ := time.Parse("2006-01-02", date)
// Convert local date string back to local midnight timestamp for frontend
t, err := time.ParseInLocation("2006-01-02", date, time.Local)
if err != nil {
continue
}
history = append(history, map[string]any{
"date": t.UnixMilli(),
"count": count,
@@ -510,3 +526,32 @@ func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
b, _ := json.Marshal(history)
return string(b), nil
}
func (s *SQLiteDB) SetCache(key string, value []byte, ttl time.Duration) error {
expiresAt := time.Now().Add(ttl).UnixMilli()
_, err := s.db.Exec("INSERT OR REPLACE INTO caches (key, value, expiresAt) VALUES (?, ?, ?)", key, value, expiresAt)
return err
}
func (s *SQLiteDB) GetCache(key string) ([]byte, error) {
var value []byte
var expiresAt int64
err := s.db.QueryRow("SELECT value, expiresAt FROM caches WHERE key = ?", key).Scan(&value, &expiresAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if time.Now().UnixMilli() > expiresAt {
_, _ = s.db.Exec("DELETE FROM caches WHERE key = ?", key)
return nil, nil
}
return value, nil
}
func (s *SQLiteDB) PurgeExpiredCaches() error {
now := time.Now().UnixMilli()
_, err := s.db.Exec("DELETE FROM caches WHERE expiresAt < ?", now)
return err
}

99
main.go
View File

@@ -4,6 +4,7 @@ import (
"embed"
"encoding/json"
"flag"
"fmt"
"io/fs"
"log"
"net"
@@ -13,6 +14,8 @@ import (
"time"
"git.quad4.io/Quad4-Software/webnews/internal/api"
"git.quad4.io/Quad4-Software/webnews/internal/storage"
"golang.org/x/time/rate"
)
//go:embed build/*
@@ -72,15 +75,101 @@ func main() {
allowedOriginsStr := flag.String("allowed-origins", os.Getenv("ALLOWED_ORIGINS"), "Comma-separated list of allowed CORS origins")
// Auth flags
authMode := flag.String("auth-mode", "none", "Authentication mode: none, token, multi")
defaultAuthMode := os.Getenv("AUTH_MODE")
if defaultAuthMode == "" {
defaultAuthMode = "none"
}
authMode := flag.String("auth-mode", defaultAuthMode, "Authentication mode: none, token, multi")
authToken := flag.String("auth-token", os.Getenv("AUTH_TOKEN"), "Master token for 'token' auth mode")
authFile := flag.String("auth-file", "accounts.json", "File to store accounts for 'multi' auth mode")
allowReg := flag.Bool("allow-registration", true, "Allow new account generation in 'multi' mode")
hashesFile := flag.String("hashes-file", "client_hashes.json", "File to store IP+UA hashes for rate limiting")
disableProtection := flag.Bool("disable-protection", false, "Disable rate limiting and bot protection")
defaultAuthFile := os.Getenv("AUTH_FILE")
if defaultAuthFile == "" {
defaultAuthFile = "accounts.json"
}
authFile := flag.String("auth-file", defaultAuthFile, "File to store accounts for 'multi' auth mode")
defaultAllowReg := true
if os.Getenv("ALLOW_REGISTRATION") == "false" {
defaultAllowReg = false
}
allowReg := flag.Bool("allow-registration", defaultAllowReg, "Allow new account generation in 'multi' mode")
defaultHashesFile := os.Getenv("HASHES_FILE")
if defaultHashesFile == "" {
defaultHashesFile = "client_hashes.json"
}
hashesFile := flag.String("hashes-file", defaultHashesFile, "File to store IP+UA hashes for rate limiting")
rateLimit := flag.Float64("rate-limit", 50.0, "Rate limit in requests per second (env: RATE_LIMIT)")
rateBurst := flag.Int("rate-burst", 100, "Rate limit burst size (env: RATE_BURST)")
disableProtection := flag.Bool("disable-protection", os.Getenv("DISABLE_PROTECTION") == "true", "Disable rate limiting and bot protection")
publicInstance := flag.Bool("public-instance", os.Getenv("PUBLIC_INSTANCE") == "true", "Enable optimizations for public instances (caching, etc.)")
cacheEnabled := flag.Bool("cache-enabled", os.Getenv("CACHE_ENABLED") == "true", "Explicitly enable/disable caching")
cacheTTL := flag.Duration("cache-ttl", 10*time.Minute, "Cache TTL (env: CACHE_TTL)")
cacheFile := flag.String("cache-file", os.Getenv("CACHE_FILE"), "SQLite file for caching (reduces memory load)")
flag.Parse()
// Handle cache config
if envTTL := os.Getenv("CACHE_TTL"); envTTL != "" {
if d, err := time.ParseDuration(envTTL); err == nil {
*cacheTTL = d
}
}
api.FeedCache.TTL = *cacheTTL
api.FullTextCache.TTL = *cacheTTL * 6 // Full text stays longer
if *cacheFile != "" {
db, err := storage.NewSQLiteDB(*cacheFile)
if err != nil {
log.Fatalf("Failed to initialize cache database: %v", err)
}
api.FeedCache.Storage = db
api.FullTextCache.Storage = db
log.Printf("Using SQLite for caching: %s\n", *cacheFile)
// Background cleanup of expired items
go func() {
for {
time.Sleep(1 * time.Hour)
if err := db.PurgeExpiredCaches(); err != nil {
log.Printf("Error purging expired caches: %v", err)
}
}
}()
}
if *publicInstance {
api.FeedCache.Enabled = true
api.FullTextCache.Enabled = true
log.Printf("Public instance optimizations enabled (caching enabled, TTL: %v)\n", *cacheTTL)
}
if os.Getenv("CACHE_ENABLED") != "" {
api.FeedCache.Enabled = *cacheEnabled
api.FullTextCache.Enabled = *cacheEnabled
log.Printf("Caching explicitly %v (TTL: %v)\n", map[bool]string{true: "enabled", false: "disabled"}[*cacheEnabled], *cacheTTL)
}
// Override rate limits from environment if set
if envRate := os.Getenv("RATE_LIMIT"); envRate != "" {
var r float64
if _, err := fmt.Sscanf(envRate, "%f", &r); err == nil {
*rateLimit = r
}
}
if envBurst := os.Getenv("RATE_BURST"); envBurst != "" {
var b int
if _, err := fmt.Sscanf(envBurst, "%d", &b); err == nil {
*rateBurst = b
}
}
api.Limiter.SetLimit(rate.Limit(*rateLimit), *rateBurst)
if *hashesFile != "" {
api.Limiter.File = *hashesFile
api.Limiter.LoadHashes()

5462
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "web-news",
"version": "0.1.0",
"version": "0.2.3",
"type": "module",
"main": "./build/index.js",
"bin": {
@@ -26,14 +26,14 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "eslint .",
"package": "npm run build"
"package": "pnpm run build"
},
"devDependencies": {
"@capacitor-community/sqlite": "^7.0.2",
"@capacitor/cli": "^8.0.0",
"@eslint/js": "^9.39.2",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/kit": "^2.49.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/typography": "^0.5.19",
"@typescript-eslint/eslint-plugin": "^8.50.1",
@@ -42,11 +42,11 @@
"eslint-plugin-svelte": "^3.13.1",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"svelte": "^5.46.1",
"svelte-check": "^4.3.5",
"svelte-eslint-parser": "^1.4.1",
"typescript": "^5.9.3",
"vite": "^7.2.6"
"vite": "^7.3.0"
},
"dependencies": {
"@capacitor/android": "^8.0.0",
@@ -55,5 +55,8 @@
"lucide-svelte": "^0.562.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19"
},
"overrides": {
"cookie": "^1.0.0"
}
}

3419
pnpm-lock.yaml generated Normal file
View File

File diff suppressed because it is too large Load Diff

3
renovate.json Normal file
View File

@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

View File

@@ -2,5 +2,5 @@
set -euo pipefail
echo "Building app..."
VITE_APP_VERSION=$(node -p "require('./package.json').version") npm run build
VITE_APP_VERSION=$(node -p "require('./package.json').version") pnpm run build

View File

@@ -2,8 +2,8 @@
set -euo pipefail
echo "Running Svelte sync..."
npx svelte-kit sync
pnpm svelte-kit sync
echo "Running svelte-check (fail on errors)..."
npx svelte-check --tsconfig ./tsconfig.json
pnpm svelte-check --tsconfig ./tsconfig.json

View File

@@ -21,4 +21,3 @@ swContent = swContent.replace(
writeFileSync(swPath, swContent);
console.log(`Injected version ${version} into service worker`);

View File

@@ -23,20 +23,16 @@ VULNS=$(jq -r '
.results[]? |
.source as $src |
.vulns[]? |
select(
(.database_specific.severity // "" | ascii_upcase | test("HIGH|CRITICAL")) or
(.severity[]?.score // "" | tostring | split("/")[0] | tonumber? // 0 | . >= 7.0)
) |
"\(.id) (source: \($src))"
' "$OSV_JSON")
if [ -n "$VULNS" ]; then
echo "OSV scan found HIGH/CRITICAL vulnerabilities:"
echo "OSV scan found vulnerabilities:"
echo "$VULNS" | while IFS= read -r line; do
echo " - $line"
done
exit 1
else
echo "OSV scan: no HIGH/CRITICAL vulnerabilities found."
echo "OSV scan: no vulnerabilities found."
fi

View File

@@ -26,6 +26,10 @@ elif [ -f "desktop/build/bin/web-news" ]; then
cp desktop/build/bin/web-news dist/web-news-desktop-darwin
fi
echo "Building Android APK..."
make android-build
cp bin/android/web-news-debug.apk dist/web-news-android-debug.apk
echo "Generating SHA256 hashes..."
cd dist
sha256sum * > SHA256SUMS

View File

@@ -6,7 +6,8 @@ sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev gcc-mingw-w64 zip
echo "Installing project dependencies..."
npm ci
npm install -g pnpm
pnpm install --frozen-lockfile
echo "Installing Wails CLI..."
go install github.com/wailsapp/wails/v2/cmd/wails@latest

View File

@@ -53,7 +53,7 @@
.card {
@apply bg-bg-primary border border-border-color rounded-lg overflow-hidden transition-all hover:shadow-md;
}
.btn-primary {
@apply bg-accent-blue text-white px-4 py-2 rounded-md font-medium hover:bg-accent-blue-dark transition-colors;
}

View File

@@ -3,13 +3,39 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Web News - Personal RSS Reader</title>
<meta name="description" content="A fast, clean, and private RSS reader for all your news." />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="shortcut icon" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://webnews.quad4.io" />
<meta property="og:title" content="Web News" />
<meta
property="og:description"
content="A fast, clean, and private RSS reader for all your news."
/>
<meta property="og:image" content="/favicon.svg" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://webnews.quad4.io" />
<meta property="twitter:title" content="Web News" />
<meta
property="twitter:description"
content="A fast, clean, and private RSS reader for all your news."
/>
<meta property="twitter:image" content="/favicon.svg" />
<meta name="theme-color" content="#1a73e8" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Web News" />
<link rel="apple-touch-icon" href="/favicon.svg" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import { db } from '$lib/db';
import { fetchFeed } from '$lib/rss';
import { parseOPML } from '$lib/opml';
import { newsStore } from '$lib/store.svelte';
import { X, Loader2 } from 'lucide-svelte';
import { X, Loader2, Upload } from 'lucide-svelte';
import { toast } from '$lib/toast.svelte';
let { onOpenChange } = $props();
let feedUrl = $state('');
@@ -26,7 +28,7 @@
lastFetched: Date.now(),
fetchInterval: 30,
enabled: true,
consecutiveErrors: 0
consecutiveErrors: 0,
});
await db.saveArticles(articles);
await newsStore.refresh();
@@ -37,29 +39,70 @@
loading = false;
}
}
async function handleImport(e: Event) {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
loading = true;
error = '';
try {
const text = await file.text();
const { feeds, categories } = parseOPML(text);
if (categories.length > 0) {
await db.saveCategories(categories as any);
}
if (feeds.length > 0) {
await db.saveFeeds(feeds as any);
}
toast.success(`Imported ${feeds.length} feeds`);
await newsStore.init();
onOpenChange(false);
} catch (err) {
console.error('Import failed:', err);
error = 'Failed to import OPML';
} finally {
loading = false;
target.value = '';
}
}
</script>
<div class="fixed inset-0 z-[100] flex items-center justify-center p-4">
<button
class="absolute inset-0 bg-black/60 backdrop-blur-sm w-full h-full border-none cursor-default"
<button
class="absolute inset-0 bg-black/60 backdrop-blur-sm w-full h-full border-none cursor-default"
onclick={() => onOpenChange(false)}
aria-label="Close modal"
></button>
<div class="bg-bg-primary border border-border-color rounded-2xl shadow-2xl w-full max-w-md relative overflow-hidden z-10">
<div
class="bg-bg-primary border border-border-color rounded-2xl shadow-2xl w-full max-w-md relative overflow-hidden z-10"
>
<div class="p-6 border-b border-border-color flex justify-between items-center">
<h2 class="text-xl font-bold">Add RSS Feed</h2>
<button class="text-text-secondary hover:text-text-primary" onclick={() => onOpenChange(false)}>
<button
class="text-text-secondary hover:text-text-primary"
onclick={() => onOpenChange(false)}
>
<X size={24} />
</button>
</div>
<form class="p-6 space-y-4" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<form
class="p-6 space-y-4"
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div class="space-y-2">
<label for="url" class="text-sm font-medium text-text-secondary">Feed URL</label>
<input
<input
id="url"
type="url"
type="url"
bind:value={feedUrl}
placeholder="https://example.com/rss.xml"
class="w-full bg-bg-secondary border border-border-color rounded-xl px-4 py-2.5 outline-none focus:ring-2 focus:ring-accent-blue/20 transition-all"
@@ -69,7 +112,7 @@
<div class="space-y-2">
<label for="category" class="text-sm font-medium text-text-secondary">Category</label>
<select
<select
id="category"
bind:value={categoryId}
class="w-full bg-bg-secondary border border-border-color rounded-xl px-4 py-2.5 outline-none focus:ring-2 focus:ring-accent-blue/20 transition-all text-sm"
@@ -84,8 +127,8 @@
<p class="text-red-500 text-sm">{error}</p>
{/if}
<button
type="submit"
<button
type="submit"
class="w-full btn-primary flex items-center justify-center gap-2 py-3"
disabled={loading}
>
@@ -96,7 +139,30 @@
Add Feed
{/if}
</button>
<div class="relative py-2">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-border-color"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-bg-primary px-2 text-text-secondary">Or</span>
</div>
</div>
<label
class="w-full flex items-center justify-center gap-2 py-3 bg-bg-secondary border border-border-color rounded-xl text-sm font-semibold text-text-primary hover:bg-bg-primary transition-all cursor-pointer {loading
? 'opacity-50 pointer-events-none'
: ''}"
>
{#if loading}
<Loader2 size={18} class="animate-spin" />
Importing...
{:else}
<Upload size={18} />
Import OPML File
{/if}
<input type="file" accept=".opml,.xml" class="hidden" onchange={handleImport} />
</label>
</form>
</div>
</div>

View File

@@ -5,6 +5,7 @@
import { Bookmark, Share2, MoreVertical, Check, FileText, Loader2 } from 'lucide-svelte';
let { article }: { article: Article } = $props();
const feed = $derived(newsStore.feeds.find((f) => f.id === article.feedId));
let copied = $state(false);
let loadingFullText = $state(false);
@@ -12,27 +13,29 @@
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function getSource(feedId: string) {
const feed = newsStore.feeds.find(f => f.id === feedId);
return feed?.title || new URL(feedId).hostname;
function getSourceTitle() {
return (
feed?.title ||
(article.feedId.startsWith('http') ? new URL(article.feedId).hostname : article.feedId)
);
}
async function shareArticle(e: MouseEvent) {
e.stopPropagation();
const encodedUrl = btoa(article.link);
const shareUrl = `${window.location.origin}/share?url=${encodedUrl}`;
try {
await navigator.clipboard.writeText(shareUrl);
copied = true;
toast.success('Share link copied to clipboard');
setTimeout(() => copied = false, 2000);
setTimeout(() => (copied = false), 2000);
} catch (err) {
console.error('Failed to copy share link:', err);
toast.error('Failed to copy share link');
@@ -71,26 +74,56 @@
}
</script>
<article class="card group relative flex flex-col sm:flex-row gap-4 transition-all hover:shadow-md {article.read ? 'opacity-60' : ''} {newsStore.readingArticle?.url === article.link ? 'ring-2 ring-accent-blue shadow-lg bg-accent-blue/5' : ''}">
<article
class="card group relative flex flex-col sm:flex-row gap-4 transition-all hover:shadow-md {article.read
? 'opacity-60'
: ''} {newsStore.readingArticle?.url === article.link
? 'ring-2 ring-accent-blue shadow-lg bg-accent-blue/5'
: ''}"
>
<!-- Background Flavor Layer -->
{#if article.imageUrl || feed?.icon}
<div class="absolute inset-0 z-0 overflow-hidden rounded-2xl pointer-events-none">
{#if article.imageUrl && article.imageUrl !== feed?.icon}
<div
class="absolute right-0 top-0 bottom-0 w-full sm:w-3/4 overflow-hidden"
style="mask-image: linear-gradient(to left, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%); -webkit-mask-image: linear-gradient(to left, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%);"
>
<img
src={article.imageUrl}
alt=""
class="w-full h-full object-cover opacity-[0.22] dark:opacity-[0.10] group-hover:opacity-[0.32] dark:group-hover:opacity-[0.18] transition-all duration-700 group-hover:scale-110 origin-right"
/>
</div>
{:else if feed?.icon}
<div
class="absolute inset-0 opacity-0 group-hover:opacity-[0.08] dark:group-hover:opacity-[0.05] transition-opacity duration-500"
>
<img src={feed.icon} alt="" class="w-full h-full object-cover blur-3xl scale-150" />
</div>
{/if}
</div>
{/if}
{#if newsStore.isSelectMode}
<div class="flex items-center pl-4 z-20">
<div class="relative w-5 h-5">
<input
type="checkbox"
<input
type="checkbox"
class="peer appearance-none w-5 h-5 rounded border-2 border-border-color checked:bg-accent-blue checked:border-accent-blue transition-all cursor-pointer"
checked={newsStore.selectedArticleIds.has(article.id)}
onchange={handleToggleSelect}
/>
<Check
size={14}
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white opacity-0 peer-checked:opacity-100 pointer-events-none transition-opacity"
<Check
size={14}
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white opacity-0 peer-checked:opacity-100 pointer-events-none transition-opacity"
strokeWidth={3}
/>
</div>
</div>
{/if}
<button
class="absolute inset-0 w-full h-full text-left cursor-pointer z-0"
<button
class="absolute inset-0 w-full h-full text-left cursor-pointer z-0"
onclick={() => {
if (newsStore.isSelectMode) {
const isSelected = newsStore.selectedArticleIds.has(article.id);
@@ -106,21 +139,28 @@
<div class="flex-1 min-w-0 p-4 relative z-10 pointer-events-none">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-accent-blue hover:underline pointer-events-auto">{getSource(article.feedId)}</span>
{#if feed?.icon}
<img src={feed.icon} alt="" class="w-4 h-4 rounded-sm flex-shrink-0" />
{/if}
<span class="text-xs font-semibold text-accent-blue hover:underline pointer-events-auto"
>{getSourceTitle()}</span
>
<span class="text-text-secondary text-xs">•</span>
<span class="text-text-secondary text-xs">{formatDate(article.pubDate)}</span>
</div>
<h3 class="text-lg font-bold leading-snug mb-2 group-hover:text-accent-blue transition-colors">
{article.title}
</h3>
<p class="text-text-secondary text-sm line-clamp-2 mb-4">
{article.description}
</p>
<div class="flex items-center gap-4 text-text-secondary opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto">
<button
<div
class="flex items-center gap-4 text-text-secondary opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto"
>
<button
class="flex items-center gap-1.5 px-3 py-1 rounded-full hover:bg-bg-secondary transition-colors text-xs font-semibold hover:text-accent-blue"
onclick={fetchFullText}
disabled={loadingFullText}
@@ -132,15 +172,17 @@
{/if}
Read
</button>
<button
class="p-1.5 rounded-full hover:bg-bg-secondary transition-colors {article.saved ? 'text-accent-blue' : 'hover:text-text-primary'}"
<button
class="p-1.5 rounded-full hover:bg-bg-secondary transition-colors {article.saved
? 'text-accent-blue'
: 'hover:text-text-primary'}"
title={article.saved ? 'Remove from saved' : 'Save for later'}
onclick={toggleSave}
>
<Bookmark size={18} fill={article.saved ? 'currentColor' : 'none'} />
</button>
<button
class="hover:text-text-primary p-1.5 rounded-full hover:bg-bg-secondary transition-colors flex items-center gap-1"
<button
class="hover:text-text-primary p-1.5 rounded-full hover:bg-bg-secondary transition-colors flex items-center gap-1"
title="Copy share link"
onclick={shareArticle}
>
@@ -150,19 +192,16 @@
<Share2 size={18} />
{/if}
</button>
<button
class="hover:text-text-primary p-1.5 rounded-full hover:bg-bg-secondary transition-colors"
<button
class="hover:text-text-primary p-1.5 rounded-full hover:bg-bg-secondary transition-colors"
title="Open in new tab"
onclick={(e) => { e.stopPropagation(); window.open(article.link, '_blank'); }}
onclick={(e) => {
e.stopPropagation();
window.open(article.link, '_blank');
}}
>
<MoreVertical size={18} />
</button>
</div>
</div>
{#if article.imageUrl}
<div class="w-full sm:w-32 h-48 sm:h-32 flex-shrink-0 sm:m-4 rounded-xl overflow-hidden bg-bg-secondary border border-border-color relative z-10 pointer-events-none">
<img src={article.imageUrl} alt="" class="w-full h-full object-cover transition-transform group-hover:scale-105" />
</div>
{/if}
</article>

View File

@@ -5,17 +5,35 @@
let { onAddFeed } = $props();
</script>
<header class="sticky top-0 z-50 bg-bg-primary/80 backdrop-blur-md border-b border-border-color px-4 py-2 flex justify-between items-center h-[calc(64px+env(safe-area-inset-top,0px))] pt-safe">
<header
class="sticky top-0 z-50 bg-bg-primary/80 backdrop-blur-md border-b border-border-color px-4 py-2 flex justify-between items-center h-[calc(64px+env(safe-area-inset-top,0px))] pt-safe"
>
<div class="flex items-center gap-2">
<button
class="md:hidden p-2 hover:bg-bg-secondary rounded-full text-text-secondary"
<button
class="md:hidden p-2 hover:bg-bg-secondary rounded-full text-text-secondary"
aria-label="Menu"
onclick={() => newsStore.showSidebar = !newsStore.showSidebar}
onclick={() => (newsStore.showSidebar = !newsStore.showSidebar)}
>
<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"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
<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"
><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line
x1="3"
y1="18"
x2="21"
y2="18"
></line></svg
>
</button>
<button
class="flex items-center gap-1.5 cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent-blue/20 rounded-lg px-1"
<button
class="flex items-center gap-1.5 cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent-blue/20 rounded-lg px-1"
onclick={() => {
newsStore.selectFeed(null);
newsStore.currentView = 'all';
@@ -24,7 +42,22 @@
aria-label="Web News Home"
>
<div class="w-8 h-8 bg-accent-blue rounded-lg flex items-center justify-center text-white">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle cx="5" cy="19" r="1"></circle></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle
cx="5"
cy="19"
r="1"
></circle></svg
>
</div>
<h1 class="text-xl font-bold tracking-tight hidden sm:block">Web News</h1>
</button>
@@ -33,9 +66,9 @@
<div class="flex-1 max-w-2xl mx-4 hidden sm:block">
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
<input
type="text"
placeholder="Search for topics, locations & sources"
<input
type="text"
placeholder="Search for topics, locations & sources"
class="w-full bg-bg-secondary border-none rounded-xl py-2.5 pl-10 pr-4 focus:ring-2 focus:ring-accent-blue/20 outline-none transition-all"
bind:value={newsStore.searchQuery}
oninput={() => newsStore.loadArticles()}
@@ -45,32 +78,42 @@
<div class="flex items-center gap-1 sm:gap-2">
{#if newsStore.ping !== null && !newsStore.isWails && !newsStore.isCapacitor}
<div class="hidden md:flex items-center gap-1.5 px-3 py-1.5 bg-bg-secondary rounded-full border border-border-color">
<div class="w-1.5 h-1.5 rounded-full {newsStore.ping < 200 ? 'bg-green-500' : newsStore.ping < 500 ? 'bg-yellow-500' : 'bg-red-500'}"></div>
<div
class="hidden md:flex items-center gap-1.5 px-3 py-1.5 bg-bg-secondary rounded-full border border-border-color"
>
<div
class="w-1.5 h-1.5 rounded-full {newsStore.ping < 200
? 'bg-green-500'
: newsStore.ping < 500
? 'bg-yellow-500'
: 'bg-red-500'}"
></div>
<span class="text-[10px] font-medium text-text-secondary">{newsStore.ping}ms</span>
</div>
{:else if !newsStore.isOnline}
<div class="hidden md:flex items-center gap-1.5 px-3 py-1.5 bg-red-500/10 rounded-full border border-red-500/20">
<div
class="hidden md:flex items-center gap-1.5 px-3 py-1.5 bg-red-500/10 rounded-full border border-red-500/20"
>
<div class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse"></div>
<span class="text-[10px] font-medium text-red-500">Offline</span>
</div>
{/if}
<button
<button
class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors"
onclick={() => newsStore.refresh()}
title="Refresh feeds"
>
<RefreshCw size={20} class={newsStore.loading ? 'animate-spin text-accent-blue' : ''} />
</button>
<button
<button
class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors"
onclick={onAddFeed}
title="Add RSS Feed"
>
<Plus size={24} />
</button>
<button
<button
class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors"
onclick={() => newsStore.toggleTheme()}
title="Toggle theme"
@@ -83,4 +126,3 @@
</button>
</div>
</header>

View File

@@ -2,17 +2,37 @@
import { newsStore } from '$lib/store.svelte';
import { db } from '$lib/db';
import { exportToOPML, parseOPML } from '$lib/opml';
import { Home, Star, Bookmark, Hash, Settings as SettingsIcon, ChevronRight, ChevronDown, AlertCircle, Edit2, GripVertical, Plus, Trash2, Save, X, Download, Upload, GitBranch } from 'lucide-svelte';
import {
Home,
Star,
Bookmark,
Hash,
Settings as SettingsIcon,
ChevronRight,
ChevronDown,
AlertCircle,
Edit2,
GripVertical,
Plus,
Trash2,
Save,
X,
Download,
Upload,
GitBranch,
RefreshCw,
} from 'lucide-svelte';
import { toast } from '$lib/toast.svelte';
import { slide } from 'svelte/transition';
import { APP_VERSION } from '$lib/version';
let { onOpenSettings } = $props();
let expandedCategories = $state<Record<string, boolean>>({});
$effect(() => {
// Expand new categories by default
newsStore.categories.forEach(cat => {
newsStore.categories.forEach((cat) => {
if (expandedCategories[cat.id] === undefined) {
expandedCategories[cat.id] = true;
}
@@ -38,7 +58,7 @@
function getFeedsForCategory(categoryId: string) {
return newsStore.feeds
.filter(f => f.categoryId === categoryId)
.filter((f) => f.categoryId === categoryId)
.sort((a, b) => a.order - b.order);
}
@@ -66,34 +86,34 @@
if (draggedCategoryId && targetType === 'category') {
const cats = [...newsStore.categories].sort((a, b) => a.order - b.order);
const fromIndex = cats.findIndex(c => c.id === draggedCategoryId);
const toIndex = cats.findIndex(c => c.id === targetId);
const fromIndex = cats.findIndex((c) => c.id === draggedCategoryId);
const toIndex = cats.findIndex((c) => c.id === targetId);
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
const [moved] = cats.splice(fromIndex, 1);
cats.splice(toIndex, 0, moved);
await newsStore.reorderCategories(cats.map(c => c.id));
await newsStore.reorderCategories(cats.map((c) => c.id));
}
} else if (draggedFeedId && targetType === 'feed') {
const sourceFeed = newsStore.feeds.find(f => f.id === draggedFeedId);
const targetFeed = newsStore.feeds.find(f => f.id === targetId);
const sourceFeed = newsStore.feeds.find((f) => f.id === draggedFeedId);
const targetFeed = newsStore.feeds.find((f) => f.id === targetId);
if (sourceFeed && targetFeed && sourceFeed.categoryId === targetFeed.categoryId) {
const catFeeds = newsStore.feeds
.filter(f => f.categoryId === sourceFeed.categoryId)
.filter((f) => f.categoryId === sourceFeed.categoryId)
.sort((a, b) => a.order - b.order);
const fromIndex = catFeeds.findIndex(f => f.id === draggedFeedId);
const toIndex = catFeeds.findIndex(f => f.id === targetId);
const fromIndex = catFeeds.findIndex((f) => f.id === draggedFeedId);
const toIndex = catFeeds.findIndex((f) => f.id === targetId);
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
const [moved] = catFeeds.splice(fromIndex, 1);
catFeeds.splice(toIndex, 0, moved);
await newsStore.reorderFeeds(catFeeds.map(f => f.id));
await newsStore.reorderFeeds(catFeeds.map((f) => f.id));
}
}
}
draggedCategoryId = null;
draggedFeedId = null;
}
@@ -111,7 +131,7 @@
async function saveCategory() {
if (!editingCategoryId) return;
const cat = newsStore.categories.find(c => c.id === editingCategoryId);
const cat = newsStore.categories.find((c) => c.id === editingCategoryId);
if (cat) {
await newsStore.updateCategory({ ...cat, name: editingCategoryName });
}
@@ -126,13 +146,16 @@
async function saveFeed() {
if (!editingFeedId) return;
const feed = newsStore.feeds.find(f => f.id === editingFeedId);
const feed = newsStore.feeds.find((f) => f.id === editingFeedId);
if (feed) {
await newsStore.updateFeed({
...feed,
title: editingFeedTitle,
id: editingFeedUrl
}, editingFeedId);
await newsStore.updateFeed(
{
...feed,
title: editingFeedTitle,
id: editingFeedUrl,
},
editingFeedId
);
}
editingFeedId = null;
}
@@ -145,14 +168,14 @@
try {
const text = await file.text();
const { feeds, categories } = parseOPML(text);
if (categories.length > 0) {
await db.saveCategories(categories as any);
}
if (feeds.length > 0) {
await db.saveFeeds(feeds as any);
}
toast.success(`Imported ${feeds.length} feeds`);
await newsStore.init();
} catch (err) {
@@ -183,234 +206,344 @@
}
</script>
<aside
class="w-64 flex-shrink-0 bg-bg-primary border-r border-border-color z-40 transition-transform duration-300 {newsStore.showSidebar ? 'translate-x-0' : '-translate-x-full md:translate-x-0'} fixed md:static top-0 left-0 h-full"
<aside
class="w-64 flex-shrink-0 bg-bg-primary border-r border-border-color z-40 transition-transform duration-300 {newsStore.showSidebar
? 'translate-x-0'
: '-translate-x-full md:translate-x-0'} fixed md:static top-0 left-0 h-full"
>
<div class="flex flex-col gap-6 py-6 overflow-y-auto h-full pt-[calc(64px+env(safe-area-inset-top,0px))] md:pt-0 scroll-container">
<nav class="flex flex-col gap-1 px-2">
<button
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView === 'all' && newsStore.selectedFeedId === null ? 'bg-accent-blue/10 text-accent-blue font-semibold' : 'text-text-primary hover:bg-bg-secondary'}"
onclick={() => { newsStore.selectView('all'); newsStore.readingArticle = null; newsStore.showSidebar = false; }}
>
<Home size={20} />
<span>Top stories</span>
</button>
<button
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView === 'following' ? 'bg-accent-blue/10 text-accent-blue font-semibold' : 'text-text-primary hover:bg-bg-secondary'}"
onclick={() => { newsStore.selectView('following'); newsStore.readingArticle = null; newsStore.showSidebar = false; }}
>
<Star size={20} />
<span>Following</span>
</button>
<button
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView === 'saved' ? 'bg-accent-blue/10 text-accent-blue font-semibold' : 'text-text-primary hover:bg-bg-secondary'}"
onclick={() => { newsStore.selectView('saved'); newsStore.readingArticle = null; newsStore.showSidebar = false; }}
>
<Bookmark size={20} />
<span>Saved stories</span>
</button>
</nav>
<div class="px-6 py-2">
<div class="h-px bg-border-color w-full"></div>
</div>
<div class="flex-1 px-2 overflow-y-auto">
<div class="flex items-center justify-between px-4 mb-4">
<h3 class="text-xs font-semibold text-text-secondary uppercase tracking-wider">Subscriptions</h3>
<button
class="text-text-secondary hover:text-accent-blue transition-colors p-1 {isManageMode ? 'text-accent-blue' : ''}"
onclick={() => isManageMode = !isManageMode}
title="Manage feeds"
<div
class="flex flex-col gap-2 pt-6 pb-2 overflow-y-auto h-full pt-[calc(64px+env(safe-area-inset-top,0px))] md:pt-0 scroll-container"
>
<nav class="flex flex-col gap-1 px-2">
<button
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView ===
'all' && newsStore.selectedFeedId === null
? 'bg-accent-blue/10 text-accent-blue font-semibold'
: 'text-text-primary hover:bg-bg-secondary'}"
onclick={() => {
newsStore.selectView('all');
newsStore.readingArticle = null;
newsStore.showSidebar = false;
}}
>
{#if isManageMode}
<X size={14} />
{:else}
<Edit2 size={14} />
{/if}
<Home size={20} />
<span>Top stories</span>
</button>
<button
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView ===
'following'
? 'bg-accent-blue/10 text-accent-blue font-semibold'
: 'text-text-primary hover:bg-bg-secondary'}"
onclick={() => {
newsStore.selectView('following');
newsStore.readingArticle = null;
newsStore.showSidebar = false;
}}
>
<Star size={20} />
<span>Following</span>
</button>
<button
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView ===
'saved'
? 'bg-accent-blue/10 text-accent-blue font-semibold'
: 'text-text-primary hover:bg-bg-secondary'}"
onclick={() => {
newsStore.selectView('saved');
newsStore.readingArticle = null;
newsStore.showSidebar = false;
}}
>
<Bookmark size={20} />
<span>Saved stories</span>
</button>
</nav>
<div class="px-6 py-2">
<div class="h-px bg-border-color w-full"></div>
</div>
{#if isManageMode}
<div class="px-4 mb-4 space-y-3">
<div class="flex gap-1">
<input
type="text"
bind:value={newCategoryName}
placeholder="Add category..."
class="flex-1 bg-bg-secondary border border-border-color rounded-lg px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-accent-blue/30"
onkeydown={(e) => e.key === 'Enter' && addCategory()}
/>
<button class="p-1 bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors" onclick={addCategory}>
<Plus size={14} />
</button>
</div>
<div class="flex gap-2">
<label class="flex-1 flex items-center justify-center gap-2 py-1.5 bg-bg-secondary border border-border-color rounded-lg text-[10px] font-medium text-text-secondary hover:bg-bg-primary transition-colors cursor-pointer">
<Upload size={12} />
Import
<input type="file" accept=".opml,.xml" class="hidden" onchange={handleImport} />
</label>
<button
class="flex-1 flex items-center justify-center gap-2 py-1.5 bg-bg-secondary border border-border-color rounded-lg text-[10px] font-medium text-text-secondary hover:bg-bg-primary transition-colors"
onclick={handleExport}
>
<Download size={12} />
Export
</button>
</div>
<div class="flex-1 px-2 overflow-y-auto">
<div class="flex items-center justify-between px-4 mb-4">
<h3 class="text-xs font-semibold text-text-secondary uppercase tracking-wider">
Subscriptions
</h3>
<button
class="text-text-secondary hover:text-accent-blue transition-colors p-1 {isManageMode
? 'text-accent-blue'
: ''}"
onclick={() => (isManageMode = !isManageMode)}
title="Manage feeds"
>
{#if isManageMode}
<X size={14} />
{:else}
<Edit2 size={14} />
{/if}
</button>
</div>
{/if}
<div class="space-y-1" role="list">
{#each [...newsStore.categories].sort((a, b) => a.order - b.order) as cat (cat.id)}
{@const catFeeds = getFeedsForCategory(cat.id)}
{#if catFeeds.length > 0 || isManageMode}
<div
class="space-y-1 rounded-xl transition-all {dragOverId === cat.id ? 'ring-2 ring-accent-blue/50 bg-accent-blue/5' : ''}"
draggable={isManageMode}
role="listitem"
ondragstart={(e) => handleDragStart(e, 'category', cat.id)}
ondragover={(e) => handleDragOver(e, cat.id)}
ondrop={(e) => handleDrop(e, 'category', cat.id)}
>
<div class="flex items-center group">
{#if isManageMode}
<div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2">
<GripVertical size={14} />
</div>
{/if}
{#if editingCategoryId === cat.id}
<div class="flex-1 flex items-center gap-1 px-2 py-1">
<input
type="text"
bind:value={editingCategoryName}
class="flex-1 bg-bg-primary border border-border-color rounded px-2 py-0.5 text-sm outline-none"
onkeydown={(e) => e.key === 'Enter' && saveCategory()}
/>
<button class="text-green-500" onclick={saveCategory}><Save size={14} /></button>
</div>
{:else}
<button
class="flex-1 flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium text-text-secondary hover:bg-bg-secondary transition-colors text-left min-w-0"
onclick={() => toggleCategory(cat.id)}
title={cat.name}
>
{#if expandedCategories[cat.id]}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
<span class="truncate">{cat.name}</span>
<span class="ml-auto text-[10px] bg-bg-secondary px-1.5 py-0.5 rounded-full">{catFeeds.length}</span>
</button>
{#if isManageMode}
<div class="px-4 mb-4 space-y-3">
<div class="flex gap-1">
<input
type="text"
bind:value={newCategoryName}
placeholder="Add category..."
class="flex-1 bg-bg-secondary border border-border-color rounded-lg px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-accent-blue/30"
onkeydown={(e) => e.key === 'Enter' && addCategory()}
/>
<button
class="p-1 bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors"
onclick={addCategory}
>
<Plus size={14} />
</button>
</div>
<div class="flex gap-2">
<label
class="flex-1 flex items-center justify-center gap-2 py-1.5 bg-bg-secondary border border-border-color rounded-lg text-[10px] font-medium text-text-secondary hover:bg-bg-primary transition-colors cursor-pointer"
>
<Upload size={12} />
Import
<input type="file" accept=".opml,.xml" class="hidden" onchange={handleImport} />
</label>
<button
class="flex-1 flex items-center justify-center gap-2 py-1.5 bg-bg-secondary border border-border-color rounded-lg text-[10px] font-medium text-text-secondary hover:bg-bg-primary transition-colors"
onclick={handleExport}
>
<Download size={12} />
Export
</button>
</div>
</div>
{/if}
<div class="space-y-1" role="list">
{#each [...newsStore.categories].sort((a, b) => a.order - b.order) as cat (cat.id)}
{@const catFeeds = getFeedsForCategory(cat.id)}
{#if catFeeds.length > 0 || isManageMode}
<div
class="space-y-1 rounded-xl transition-all {dragOverId === cat.id
? 'ring-2 ring-accent-blue/50 bg-accent-blue/5'
: ''}"
draggable={isManageMode}
role="listitem"
ondragstart={(e) => handleDragStart(e, 'category', cat.id)}
ondragover={(e) => handleDragOver(e, cat.id)}
ondrop={(e) => handleDrop(e, 'category', cat.id)}
>
<div class="flex items-center group">
{#if isManageMode}
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pr-2">
<button class="p-1 text-text-secondary hover:text-accent-blue" onclick={() => startEditCategory(cat)}><Edit2 size={12} /></button>
<button class="p-1 text-text-secondary hover:text-red-500" onclick={() => newsStore.deleteCategory(cat.id)}><Trash2 size={12} /></button>
<div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2">
<GripVertical size={14} />
</div>
{/if}
{/if}
</div>
{#if expandedCategories[cat.id]}
<div class="pl-4 space-y-0.5" transition:slide={{ duration: 200 }} role="list">
{#each catFeeds as feed (feed.id)}
<div
class="flex items-center group rounded-xl transition-all {dragOverId === feed.id ? 'ring-2 ring-accent-blue/50 bg-accent-blue/5' : ''}"
draggable={isManageMode}
role="listitem"
ondragstart={(e) => handleDragStart(e, 'feed', feed.id)}
ondragover={(e) => handleDragOver(e, feed.id)}
ondrop={(e) => handleDrop(e, 'feed', feed.id)}
{#if editingCategoryId === cat.id}
<div class="flex-1 flex items-center gap-1 px-2 py-1">
<input
type="text"
bind:value={editingCategoryName}
class="flex-1 bg-bg-primary border border-border-color rounded px-2 py-0.5 text-sm outline-none"
onkeydown={(e) => e.key === 'Enter' && saveCategory()}
/>
<button class="text-green-500" onclick={saveCategory}><Save size={14} /></button
>
</div>
{:else}
<button
class="flex-1 flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors text-left min-w-0 {newsStore.selectedCategoryId ===
cat.id
? 'bg-accent-blue/10 text-accent-blue font-semibold'
: 'text-text-secondary hover:bg-bg-secondary'}"
onclick={() => {
newsStore.selectCategory(cat.id);
toggleCategory(cat.id);
newsStore.readingArticle = null;
if (!isManageMode) newsStore.showSidebar = false;
}}
title={cat.name}
>
{#if isManageMode}
<div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2">
<GripVertical size={12} />
</div>
{/if}
{#if editingFeedId === feed.id}
<div class="flex-1 flex flex-col gap-1 p-2 bg-bg-secondary/50 rounded-xl">
<input
type="text"
bind:value={editingFeedTitle}
placeholder="Feed title"
class="w-full bg-bg-primary border border-border-color rounded px-2 py-1 text-xs outline-none"
/>
<input
type="text"
bind:value={editingFeedUrl}
placeholder="Feed URL"
class="w-full bg-bg-primary border border-border-color rounded px-2 py-1 text-[10px] outline-none"
/>
<div class="flex justify-end gap-1 mt-1">
<button class="p-1 text-red-500 hover:bg-red-500/10 rounded" onclick={() => editingFeedId = null}><X size={14} /></button>
<button class="p-1 text-green-500 hover:bg-green-500/10 rounded" onclick={saveFeed}><Save size={14} /></button>
</div>
</div>
{#if expandedCategories[cat.id]}
<ChevronDown size={16} />
{:else}
<button
class="flex-1 flex items-center gap-3 px-4 py-2 rounded-xl text-sm transition-colors {newsStore.selectedFeedId === feed.id ? 'bg-accent-blue/10 text-accent-blue font-semibold' : 'text-text-secondary hover:bg-bg-secondary'} text-left min-w-0"
onclick={() => { newsStore.selectFeed(feed.id); newsStore.readingArticle = null; newsStore.showSidebar = false; }}
title={feed.title}
>
{#if feed.error}
<AlertCircle size={16} class="text-red-500 flex-shrink-0" />
{:else if feed.icon}
<img src={feed.icon} alt="" class="w-4 h-4 rounded-sm flex-shrink-0" />
{:else}
<Hash size={16} class="flex-shrink-0" />
{/if}
<span class="truncate {feed.error ? 'text-red-500' : ''}">{feed.title}</span>
</button>
<ChevronRight size={16} />
{/if}
<span class="truncate">{cat.name}</span>
<span class="ml-auto text-[10px] bg-bg-secondary px-1.5 py-0.5 rounded-full"
>{catFeeds.length}</span
>
</button>
{#if isManageMode}
<div
class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pr-2"
>
<button
class="p-1 text-text-secondary hover:text-accent-blue"
onclick={() => startEditCategory(cat)}><Edit2 size={12} /></button
>
<button
class="p-1 text-text-secondary hover:text-red-500"
onclick={() => newsStore.deleteCategory(cat.id)}
><Trash2 size={12} /></button
>
</div>
{/if}
{/if}
</div>
{#if expandedCategories[cat.id]}
<div class="pl-4 space-y-0.5" transition:slide={{ duration: 200 }} role="list">
{#each catFeeds as feed (feed.id)}
<div
class="flex items-center group rounded-xl transition-all {dragOverId ===
feed.id
? 'ring-2 ring-accent-blue/50 bg-accent-blue/5'
: ''}"
draggable={isManageMode}
role="listitem"
ondragstart={(e) => handleDragStart(e, 'feed', feed.id)}
ondragover={(e) => handleDragOver(e, feed.id)}
ondrop={(e) => handleDrop(e, 'feed', feed.id)}
>
{#if isManageMode}
<div class="opacity-0 group-hover:opacity-100 transition-opacity pr-2 flex items-center gap-0.5">
<button class="p-1 text-text-secondary hover:text-accent-blue" onclick={() => startEditFeed(feed)} title="Edit feed"><Edit2 size={12} /></button>
<button class="p-1 text-text-secondary hover:text-red-500" onclick={() => newsStore.deleteFeed(feed.id)} title="Delete feed"><Trash2 size={12} /></button>
<div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2">
<GripVertical size={12} />
</div>
{/if}
{/if}
</div>
{/each}
</div>
{/if}
</div>
{#if editingFeedId === feed.id}
<div class="flex-1 flex flex-col gap-1 p-2 bg-bg-secondary/50 rounded-xl">
<input
type="text"
bind:value={editingFeedTitle}
placeholder="Feed title"
class="w-full bg-bg-primary border border-border-color rounded px-2 py-1 text-xs outline-none"
/>
<input
type="text"
bind:value={editingFeedUrl}
placeholder="Feed URL"
class="w-full bg-bg-primary border border-border-color rounded px-2 py-1 text-[10px] outline-none"
/>
<div class="flex justify-end gap-1 mt-1">
<button
class="p-1 text-red-500 hover:bg-red-500/10 rounded"
onclick={() => (editingFeedId = null)}><X size={14} /></button
>
<button
class="p-1 text-green-500 hover:bg-green-500/10 rounded"
onclick={saveFeed}><Save size={14} /></button
>
</div>
</div>
{:else}
<button
class="flex-1 flex items-center gap-3 px-4 py-2 rounded-xl text-sm transition-colors {newsStore.selectedFeedId ===
feed.id
? 'bg-accent-blue/10 text-accent-blue font-semibold'
: 'text-text-secondary hover:bg-bg-secondary'} text-left min-w-0"
onclick={() => {
newsStore.selectFeed(feed.id);
newsStore.readingArticle = null;
newsStore.showSidebar = false;
}}
title={feed.title}
>
{#if feed.error}
<AlertCircle size={16} class="text-red-500 flex-shrink-0" />
{:else if feed.icon}
<img src={feed.icon} alt="" class="w-4 h-4 rounded-sm flex-shrink-0" />
{:else}
<Hash size={16} class="flex-shrink-0" />
{/if}
<span class="truncate {feed.error ? 'text-red-500' : ''}"
>{feed.title}</span
>
</button>
{#if feed.error && !isManageMode}
<div
class="opacity-0 group-hover:opacity-100 transition-opacity pr-2 flex items-center gap-0.5"
>
<button
class="p-1 text-text-secondary hover:text-accent-blue"
onclick={() => newsStore.refreshFeed(feed.id)}
title="Retry fetching feed"
>
<RefreshCw size={12} />
</button>
<button
class="p-1 text-text-secondary hover:text-red-500"
onclick={() => newsStore.deleteFeed(feed.id)}
title="Remove feed"
>
<Trash2 size={12} />
</button>
</div>
{/if}
{#if isManageMode}
<div
class="opacity-0 group-hover:opacity-100 transition-opacity pr-2 flex items-center gap-0.5"
>
<button
class="p-1 text-text-secondary hover:text-accent-blue"
onclick={() => startEditFeed(feed)}
title="Edit feed"><Edit2 size={12} /></button
>
<button
class="p-1 text-text-secondary hover:text-red-500"
onclick={() => newsStore.deleteFeed(feed.id)}
title="Delete feed"><Trash2 size={12} /></button
>
</div>
{/if}
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/each}
{#if newsStore.feeds.length === 0}
<p class="px-4 text-xs text-text-secondary italic">No feeds added yet</p>
{/if}
{/each}
{#if newsStore.feeds.length === 0}
<p class="px-4 text-xs text-text-secondary italic">No feeds added yet</p>
{/if}
</div>
</div>
</div>
<div class="flex flex-col items-center pb-24 md:pb-6 space-y-4">
<div class="flex flex-col items-center space-y-1">
<a
href="https://git.quad4.io/Quad4-Software/webnews"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 text-[11px] text-text-secondary hover:text-accent-blue transition-colors font-medium"
<div class="flex flex-col items-center pb-20 md:pb-4 space-y-2">
<button
class="flex items-center justify-center gap-3 px-4 py-2 rounded-xl text-text-secondary hover:bg-bg-secondary transition-colors w-full max-w-[200px]"
onclick={onOpenSettings}
>
<GitBranch size={13} />
<span>v0.1.0</span>
</a>
<p class="text-[11px] text-text-secondary font-medium">
Created by <a href="https://quad4.io" target="_blank" rel="noopener noreferrer" class="hover:text-accent-blue transition-colors">Quad4</a>
</p>
</div>
<SettingsIcon size={18} />
<span class="font-medium text-sm">Settings</span>
</button>
<button
class="flex items-center justify-center gap-3 px-4 py-2 rounded-xl text-text-secondary hover:bg-bg-secondary transition-colors w-full max-w-[200px]"
onclick={onOpenSettings}
>
<SettingsIcon size={18} />
<span class="font-medium text-sm">Settings</span>
</button>
</div>
<div class="flex flex-col items-center">
<a
href="https://git.quad4.io/Quad4-Software/webnews"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 text-[11px] text-text-secondary hover:text-accent-blue transition-colors font-medium"
>
<GitBranch size={13} />
<span>v{APP_VERSION}</span>
</a>
<p class="text-[11px] text-text-secondary font-medium">
Created by <a
href="https://quad4.io"
target="_blank"
rel="noopener noreferrer"
class="hover:text-accent-blue transition-colors">Quad4</a
>
</p>
</div>
</div>
</div>
</aside>

View File

@@ -7,27 +7,31 @@
info: Info,
success: CheckCircle,
error: AlertCircle,
warning: AlertTriangle
warning: AlertTriangle,
};
const colors = {
info: 'text-blue-500 bg-bg-secondary/95 border-blue-500/30',
success: 'text-green-500 bg-bg-secondary/95 border-green-500/30',
error: 'text-red-500 bg-bg-secondary/95 border-red-500/30',
warning: 'text-yellow-500 bg-bg-secondary/95 border-yellow-500/30'
warning: 'text-yellow-500 bg-bg-secondary/95 border-yellow-500/30',
};
</script>
<div class="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-[200] flex flex-col gap-2 w-full max-w-sm px-4">
<div
class="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-[200] flex flex-col gap-2 w-full max-w-sm px-4"
>
{#each toast.toasts as t (t.id)}
<div
class="flex items-center gap-3 p-4 rounded-2xl border backdrop-blur-xl shadow-2xl {colors[t.type]}"
<div
class="flex items-center gap-3 p-4 rounded-2xl border backdrop-blur-xl shadow-2xl {colors[
t.type
]}"
in:fly={{ y: 20, duration: 300 }}
out:fade={{ duration: 200 }}
>
<svelte:component this={icons[t.type]} size={20} class="flex-shrink-0" />
<p class="text-sm font-medium flex-1 leading-snug">{t.message}</p>
<button
<button
class="text-text-secondary hover:text-text-primary transition-colors p-1"
onclick={() => toast.remove(t.id)}
>
@@ -36,4 +40,3 @@
</div>
{/each}
</div>

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="20" rx="4" fill="none"/>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#1a73e8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 11a9 9 0 0 1 9 9"/>
<path d="M4 4a16 16 0 0 1 16 16"/>
<circle cx="5" cy="19" r="1"/>
</svg>

Before

Width:  |  Height:  |  Size: 374 B

After

Width:  |  Height:  |  Size: 288 B

View File

@@ -1,5 +1,9 @@
import { Capacitor } from '@capacitor/core';
import { SQLiteConnection, type SQLiteDBConnection, CapacitorSQLite } from '@capacitor-community/sqlite';
import {
SQLiteConnection,
type SQLiteDBConnection,
CapacitorSQLite,
} from '@capacitor-community/sqlite';
export interface Category {
id: string;
@@ -83,10 +87,15 @@ export interface IDB {
saveCategory(category: Category): Promise<void>;
saveCategories(categories: Category[]): Promise<void>;
deleteCategory(id: string): Promise<void>;
getArticles(feedId?: string, offset?: number, limit?: number): Promise<Article[]>;
getArticles(
feedId?: string,
offset?: number,
limit?: number,
categoryId?: string
): Promise<Article[]>;
saveArticles(articles: Article[]): Promise<void>;
searchArticles(query: string, limit?: number): Promise<Article[]>;
getReadingHistory(days?: number): Promise<{ date: number, count: number }[]>;
getReadingHistory(days?: number): Promise<{ date: number; count: number }[]>;
markAsRead(id: string): Promise<void>;
bulkMarkRead(ids: string[]): Promise<void>;
bulkDelete(ids: string[]): Promise<void>;
@@ -122,14 +131,23 @@ class IndexedDBImpl implements IDB {
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
const transaction = (event.target as IDBOpenDBRequest).transaction!;
if (!db.objectStoreNames.contains('feeds')) db.createObjectStore('feeds', { keyPath: 'id' });
if (!db.objectStoreNames.contains('categories')) db.createObjectStore('categories', { keyPath: 'id' });
if (!db.objectStoreNames.contains('settings')) db.createObjectStore('settings', { keyPath: 'id' });
let articleStore: IDBObjectStore = !db.objectStoreNames.contains('articles') ? db.createObjectStore('articles', { keyPath: 'id' }) : transaction.objectStore('articles');
if (!articleStore.indexNames.contains('feedId')) articleStore.createIndex('feedId', 'feedId', { unique: false });
if (!articleStore.indexNames.contains('pubDate')) articleStore.createIndex('pubDate', 'pubDate', { unique: false });
if (!articleStore.indexNames.contains('saved')) articleStore.createIndex('saved', 'saved', { unique: false });
if (!articleStore.indexNames.contains('readAt')) articleStore.createIndex('readAt', 'readAt', { unique: false });
if (!db.objectStoreNames.contains('feeds'))
db.createObjectStore('feeds', { keyPath: 'id' });
if (!db.objectStoreNames.contains('categories'))
db.createObjectStore('categories', { keyPath: 'id' });
if (!db.objectStoreNames.contains('settings'))
db.createObjectStore('settings', { keyPath: 'id' });
let articleStore: IDBObjectStore = !db.objectStoreNames.contains('articles')
? db.createObjectStore('articles', { keyPath: 'id' })
: transaction.objectStore('articles');
if (!articleStore.indexNames.contains('feedId'))
articleStore.createIndex('feedId', 'feedId', { unique: false });
if (!articleStore.indexNames.contains('pubDate'))
articleStore.createIndex('pubDate', 'pubDate', { unique: false });
if (!articleStore.indexNames.contains('saved'))
articleStore.createIndex('saved', 'saved', { unique: false });
if (!articleStore.indexNames.contains('readAt'))
articleStore.createIndex('readAt', 'readAt', { unique: false });
};
});
}
@@ -159,7 +177,7 @@ class IndexedDBImpl implements IDB {
return new Promise((resolve, reject) => {
const transaction = db.transaction('feeds', 'readwrite');
const store = transaction.objectStore('feeds');
feeds.forEach(f => store.put(f));
feeds.forEach((f) => store.put(f));
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
@@ -174,7 +192,10 @@ class IndexedDBImpl implements IDB {
const request = articleStore.index('feedId').openKeyCursor(IDBKeyRange.only(id));
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursor>).result;
if (cursor) { articleStore.delete(cursor.primaryKey); cursor.continue(); }
if (cursor) {
articleStore.delete(cursor.primaryKey);
cursor.continue();
}
};
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
@@ -206,7 +227,7 @@ class IndexedDBImpl implements IDB {
return new Promise((resolve, reject) => {
const transaction = db.transaction('categories', 'readwrite');
const store = transaction.objectStore('categories');
categories.forEach(c => store.put(c));
categories.forEach((c) => store.put(c));
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
@@ -221,21 +242,54 @@ class IndexedDBImpl implements IDB {
const request = feedStore.getAll();
request.onsuccess = () => {
const feeds = request.result as Feed[];
feeds.forEach(f => { if (f.categoryId === id) { f.categoryId = 'uncategorized'; feedStore.put(f); } });
feeds.forEach((f) => {
if (f.categoryId === id) {
f.categoryId = 'uncategorized';
feedStore.put(f);
}
});
};
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> {
async getArticles(
feedId?: string,
offset = 0,
limit = 20,
categoryId?: string
): Promise<Article[]> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readonly');
const transaction = db.transaction(['articles', 'feeds'], 'readonly');
const store = transaction.objectStore('articles');
let request: IDBRequest<any[]>;
if (feedId) request = store.index('feedId').getAll(IDBKeyRange.only(feedId));
else request = store.index('pubDate').getAll();
if (feedId) {
request = store.index('feedId').getAll(IDBKeyRange.only(feedId));
} else if (categoryId) {
// For IndexedDB, we need to get all feeds in category first
const feedStore = transaction.objectStore('feeds');
const feedsRequest = feedStore.getAll();
feedsRequest.onsuccess = () => {
const feeds = feedsRequest.result as Feed[];
const catFeedIds = new Set(
feeds.filter((f) => f.categoryId === categoryId).map((f) => f.id)
);
const allArticlesRequest = store.getAll();
allArticlesRequest.onsuccess = () => {
const articles = allArticlesRequest.result as Article[];
const filtered = articles.filter((a) => catFeedIds.has(a.feedId));
filtered.sort((a, b) => b.pubDate - a.pubDate);
resolve(filtered.slice(offset, offset + limit));
};
};
return;
} else {
request = store.index('pubDate').getAll();
}
request.onsuccess = () => {
const articles = request.result as Article[];
articles.sort((a, b) => b.pubDate - a.pubDate);
@@ -250,7 +304,7 @@ class IndexedDBImpl implements IDB {
return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readwrite');
const store = transaction.objectStore('articles');
articles.forEach(article => store.put(article));
articles.forEach((article) => store.put(article));
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
@@ -268,7 +322,11 @@ class IndexedDBImpl implements IDB {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (cursor && results.length < limit) {
const article = cursor.value as Article;
if (article.title.toLowerCase().includes(lowQuery) || article.description.toLowerCase().includes(lowQuery) || (article.content && article.content.toLowerCase().includes(lowQuery))) {
if (
article.title.toLowerCase().includes(lowQuery) ||
article.description.toLowerCase().includes(lowQuery) ||
(article.content && article.content.toLowerCase().includes(lowQuery))
) {
results.push(article);
}
cursor.continue();
@@ -278,12 +336,12 @@ class IndexedDBImpl implements IDB {
});
}
async getReadingHistory(days = 30): Promise<{ date: number, count: number }[]> {
async getReadingHistory(days = 30): Promise<{ date: number; count: number }[]> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readonly');
const index = transaction.objectStore('articles').index('readAt');
const startTime = Date.now() - (days * 24 * 60 * 60 * 1000);
const startTime = Date.now() - days * 24 * 60 * 60 * 1000;
const request = index.openCursor(IDBKeyRange.lowerBound(startTime));
const history: Record<string, number> = {};
request.onsuccess = (event) => {
@@ -291,12 +349,18 @@ class IndexedDBImpl implements IDB {
if (cursor) {
const article = cursor.value as Article;
if (article.readAt) {
const date = new Date(article.readAt).toISOString().split('T')[0];
const d = new Date(article.readAt);
const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
history[date] = (history[date] || 0) + 1;
}
cursor.continue();
} else {
resolve(Object.entries(history).map(([date, count]) => ({ date: new Date(date).getTime(), count })));
resolve(
Object.entries(history).map(([date, count]) => ({
date: new Date(date).getTime(),
count,
}))
);
}
};
request.onerror = () => reject(request.error);
@@ -327,7 +391,11 @@ class IndexedDBImpl implements IDB {
const request = store.get(id);
request.onsuccess = () => {
const article = request.result as Article;
if (article && !article.read) { article.read = true; article.readAt = now; store.put(article); }
if (article && !article.read) {
article.read = true;
article.readAt = now;
store.put(article);
}
};
}
}
@@ -347,7 +415,10 @@ class IndexedDBImpl implements IDB {
const request = store.get(id);
request.onsuccess = () => {
const article = request.result as Article;
if (article) { article.saved = !article.saved; store.put({ ...article, saved: article.saved ? 1 : 0 }); }
if (article) {
article.saved = !article.saved;
store.put({ ...article, saved: article.saved ? 1 : 0 });
}
};
}
}
@@ -357,14 +428,18 @@ class IndexedDBImpl implements IDB {
return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readwrite');
const store = transaction.objectStore('articles');
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const request = store.openCursor();
let count = 0;
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (cursor) {
const article = cursor.value as Article;
if (article.content && !article.saved && article.pubDate < cutoff) { delete article.content; cursor.update(article); count++; }
if (article.content && !article.saved && article.pubDate < cutoff) {
delete article.content;
cursor.update(article);
count++;
}
cursor.continue();
} else resolve(count);
};
@@ -379,7 +454,10 @@ class IndexedDBImpl implements IDB {
const request = store.get(id);
request.onsuccess = () => {
const article = request.result as Article;
if (article) { article.content = content; store.put(article); }
if (article) {
article.content = content;
store.put(article);
}
};
}
@@ -391,8 +469,11 @@ class IndexedDBImpl implements IDB {
const request = store.get(id);
request.onsuccess = () => {
const article = request.result as Article;
if (article) { article.saved = !article.saved; store.put({ ...article, saved: article.saved ? 1 : 0 }); resolve(article.saved); }
else resolve(false);
if (article) {
article.saved = !article.saved;
store.put({ ...article, saved: article.saved ? 1 : 0 });
resolve(article.saved);
} else resolve(false);
};
request.onerror = () => reject(request.error);
});
@@ -402,7 +483,10 @@ class IndexedDBImpl implements IDB {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('articles', 'readonly');
const request = transaction.objectStore('articles').index('saved').getAll(IDBKeyRange.only(1));
const request = transaction
.objectStore('articles')
.index('saved')
.getAll(IDBKeyRange.only(1));
request.onsuccess = () => {
const articles = request.result as Article[];
articles.sort((a, b) => b.pubDate - a.pubDate);
@@ -419,9 +503,21 @@ class IndexedDBImpl implements IDB {
const request = transaction.objectStore('settings').get('main');
request.onsuccess = () => {
const defaults: Settings = {
theme: 'system', globalFetchInterval: 30, autoFetch: true, apiBaseUrl: '/api', smartFeed: false, readingMode: 'inline', paneWidth: 40, fontFamily: 'sans', fontSize: 18, lineHeight: 1.6, contentPurgeDays: 30, authToken: null, muteFilters: [],
theme: 'system',
globalFetchInterval: 30,
autoFetch: true,
apiBaseUrl: '/api',
smartFeed: false,
readingMode: 'inline',
paneWidth: 40,
fontFamily: 'sans',
fontSize: 18,
lineHeight: 1.6,
contentPurgeDays: 30,
authToken: null,
muteFilters: [],
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 }
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 },
};
resolve({ ...defaults, ...(request.result || {}) });
};
@@ -441,8 +537,14 @@ class IndexedDBImpl implements IDB {
async clearAll(): Promise<void> {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['feeds', 'categories', 'articles', 'settings'], 'readwrite');
transaction.objectStore('feeds').clear(); transaction.objectStore('categories').clear(); transaction.objectStore('articles').clear(); transaction.objectStore('settings').clear();
const transaction = db.transaction(
['feeds', 'categories', 'articles', 'settings'],
'readwrite'
);
transaction.objectStore('feeds').clear();
transaction.objectStore('categories').clear();
transaction.objectStore('articles').clear();
transaction.objectStore('settings').clear();
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
@@ -456,16 +558,16 @@ class CapacitorSQLiteDBImpl implements IDB {
async open(): Promise<SQLiteDBConnection> {
if (this.db) return this.db;
if (!this.sqlite) this.sqlite = new SQLiteConnection(CapacitorSQLite);
const ret = await this.sqlite.checkConnectionsConsistency();
const isConn = (await this.sqlite.isConnection(DB_NAME, false)).result;
if (ret.result && isConn) {
this.db = await this.sqlite.retrieveConnection(DB_NAME, false);
} else {
this.db = await this.sqlite.createConnection(DB_NAME, false, "no-encryption", 1, false);
this.db = await this.sqlite.createConnection(DB_NAME, false, 'no-encryption', 1, false);
}
await this.db.open();
const queries = [
@@ -474,7 +576,7 @@ class CapacitorSQLiteDBImpl implements IDB {
`CREATE TABLE IF NOT EXISTS articles (id TEXT PRIMARY KEY, feedId TEXT, title TEXT, link TEXT, description TEXT, content TEXT, author TEXT, pubDate INTEGER, read INTEGER, saved INTEGER, imageUrl TEXT, readAt INTEGER);`,
`CREATE INDEX IF NOT EXISTS idx_articles_pubDate ON articles(pubDate);`,
`CREATE INDEX IF NOT EXISTS idx_articles_readAt ON articles(readAt);`,
`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);`
`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);`,
];
for (const q of queries) {
@@ -487,16 +589,20 @@ class CapacitorSQLiteDBImpl implements IDB {
async getFeeds(): Promise<Feed[]> {
const db = await this.open();
const res = await db.query('SELECT * FROM feeds ORDER BY "order" ASC');
return (res.values || []).map(f => ({ ...f, enabled: f.enabled === 1 }));
return (res.values || []).map((f) => ({ ...f, enabled: f.enabled === 1 }));
}
async saveFeed(feed: Feed): Promise<void> { await this.saveFeeds([feed]); }
async saveFeed(feed: Feed): Promise<void> {
await this.saveFeeds([feed]);
}
async saveFeeds(feeds: Feed[]): Promise<void> {
const db = await this.open();
for (const f of feeds) {
await db.run('INSERT OR REPLACE INTO feeds (id, title, categoryId, "order", enabled, fetchInterval) VALUES (?, ?, ?, ?, ?, ?)',
[f.id, f.title, f.categoryId, f.order, f.enabled ? 1 : 0, f.fetchInterval]);
await db.run(
'INSERT OR REPLACE INTO feeds (id, title, categoryId, "order", enabled, fetchInterval) VALUES (?, ?, ?, ?, ?, ?)',
[f.id, f.title, f.categoryId, f.order, f.enabled ? 1 : 0, f.fetchInterval]
);
}
}
@@ -512,12 +618,18 @@ class CapacitorSQLiteDBImpl implements IDB {
return res.values || [];
}
async saveCategory(category: Category): Promise<void> { await this.saveCategories([category]); }
async saveCategory(category: Category): Promise<void> {
await this.saveCategories([category]);
}
async saveCategories(categories: Category[]): Promise<void> {
const db = await this.open();
for (const c of categories) {
await db.run('INSERT OR REPLACE INTO categories (id, name, "order") VALUES (?, ?, ?)', [c.id, c.name, c.order]);
await db.run('INSERT OR REPLACE INTO categories (id, name, "order") VALUES (?, ?, ?)', [
c.id,
c.name,
c.order,
]);
}
}
@@ -527,52 +639,99 @@ class CapacitorSQLiteDBImpl implements IDB {
await db.run('DELETE FROM categories WHERE id = ?', [id]);
}
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> {
async getArticles(
feedId?: string,
offset = 0,
limit = 20,
categoryId?: string
): Promise<Article[]> {
const db = await this.open();
let res;
if (feedId) {
res = await db.query('SELECT * FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?', [feedId, limit, offset]);
res = await db.query(
'SELECT * FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?',
[feedId, limit, offset]
);
} else if (categoryId) {
res = await db.query(
`SELECT a.* FROM articles a
JOIN feeds f ON a.feedId = f.id
WHERE f.categoryId = ?
ORDER BY a.pubDate DESC LIMIT ? OFFSET ?`,
[categoryId, limit, offset]
);
} else {
res = await db.query('SELECT * FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?', [limit, offset]);
res = await db.query('SELECT * FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?', [
limit,
offset,
]);
}
return (res.values || []).map(a => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
return (res.values || []).map((a) => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
}
async saveArticles(articles: Article[]): Promise<void> {
const db = await this.open();
for (const a of articles) {
await db.run(`INSERT OR REPLACE INTO articles
await db.run(
`INSERT OR REPLACE INTO articles
(id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[a.id, a.feedId, a.title, a.link, a.description, a.content, a.author, a.pubDate, a.read ? 1 : 0, a.saved ? 1 : 0, a.imageUrl, a.readAt]);
[
a.id,
a.feedId,
a.title,
a.link,
a.description,
a.content,
a.author,
a.pubDate,
a.read ? 1 : 0,
a.saved ? 1 : 0,
a.imageUrl,
a.readAt,
]
);
}
}
async searchArticles(query: string, limit = 50): Promise<Article[]> {
const db = await this.open();
const q = `%${query}%`;
const res = await db.query(`SELECT * FROM articles WHERE title LIKE ? OR description LIKE ? OR content LIKE ? ORDER BY pubDate DESC LIMIT ?`, [q, q, q, limit]);
return (res.values || []).map(a => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
const res = await db.query(
`SELECT * FROM articles WHERE title LIKE ? OR description LIKE ? OR content LIKE ? ORDER BY pubDate DESC LIMIT ?`,
[q, q, q, limit]
);
return (res.values || []).map((a) => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
}
async getReadingHistory(days = 30): Promise<{ date: number, count: number }[]> {
async getReadingHistory(days = 30): Promise<{ date: number; count: number }[]> {
const db = await this.open();
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
const res = await db.query(`
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch')) as date, COUNT(*) as count
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const res = await db.query(
`
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch', 'localtime')) as date, COUNT(*) as count
FROM articles
WHERE read = 1 AND readAt > ?
GROUP BY date
ORDER BY date DESC`, [cutoff]);
return (res.values || []).map(row => ({
date: new Date(row.date).getTime(),
count: row.count
}));
ORDER BY date DESC`,
[cutoff]
);
return (res.values || []).map((row) => {
const [y, m, d] = row.date.split('-').map(Number);
const date = new Date(y, m - 1, d).getTime();
return {
date,
count: row.count,
};
});
}
async markAsRead(id: string): Promise<void> {
const db = await this.open();
await db.run('UPDATE articles SET read = 1, readAt = ? WHERE id = ? AND read = 0', [Date.now(), id]);
await db.run('UPDATE articles SET read = 1, readAt = ? WHERE id = ? AND read = 0', [
Date.now(),
id,
]);
}
async bulkMarkRead(ids: string[]): Promise<void> {
@@ -591,14 +750,19 @@ class CapacitorSQLiteDBImpl implements IDB {
async bulkToggleSave(ids: string[]): Promise<void> {
const db = await this.open();
for (const id of ids) {
await db.run('UPDATE articles SET saved = CASE WHEN saved = 1 THEN 0 ELSE 1 END WHERE id = ?', [id]);
await db.run(
'UPDATE articles SET saved = CASE WHEN saved = 1 THEN 0 ELSE 1 END WHERE id = ?',
[id]
);
}
}
async purgeOldContent(days: number): Promise<number> {
const db = await this.open();
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
const res = await db.run('UPDATE articles SET content = NULL WHERE saved = 0 AND pubDate < ?', [cutoff]);
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const res = await db.run('UPDATE articles SET content = NULL WHERE saved = 0 AND pubDate < ?', [
cutoff,
]);
return res.changes?.changes || 0;
}
@@ -609,7 +773,9 @@ class CapacitorSQLiteDBImpl implements IDB {
async toggleSave(id: string): Promise<boolean> {
const db = await this.open();
await db.run('UPDATE articles SET saved = CASE WHEN saved = 1 THEN 0 ELSE 1 END WHERE id = ?', [id]);
await db.run('UPDATE articles SET saved = CASE WHEN saved = 1 THEN 0 ELSE 1 END WHERE id = ?', [
id,
]);
const res = await db.query('SELECT saved FROM articles WHERE id = ?', [id]);
return res.values?.[0]?.saved === 1;
}
@@ -617,16 +783,28 @@ class CapacitorSQLiteDBImpl implements IDB {
async getSavedArticles(): Promise<Article[]> {
const db = await this.open();
const res = await db.query('SELECT * FROM articles WHERE saved = 1 ORDER BY pubDate DESC');
return (res.values || []).map(a => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
return (res.values || []).map((a) => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
}
async getSettings(): Promise<Settings> {
const db = await this.open();
const res = await db.query("SELECT value FROM settings WHERE key = 'main'");
const defaults: Settings = {
theme: 'system', globalFetchInterval: 30, autoFetch: true, apiBaseUrl: '/api', smartFeed: false, readingMode: 'inline', paneWidth: 40, fontFamily: 'sans', fontSize: 18, lineHeight: 1.6, contentPurgeDays: 30, authToken: null, muteFilters: [],
theme: 'system',
globalFetchInterval: 30,
autoFetch: true,
apiBaseUrl: '/api',
smartFeed: false,
readingMode: 'inline',
paneWidth: 40,
fontFamily: 'sans',
fontSize: 18,
lineHeight: 1.6,
contentPurgeDays: 30,
authToken: null,
muteFilters: [],
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 }
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 },
};
if (res.values && res.values.length > 0) {
return { ...defaults, ...JSON.parse(res.values[0].value) };
@@ -636,7 +814,9 @@ class CapacitorSQLiteDBImpl implements IDB {
async saveSettings(settings: Settings): Promise<void> {
const db = await this.open();
await db.run("INSERT OR REPLACE INTO settings (key, value) VALUES ('main', ?)", [JSON.stringify(settings)]);
await db.run("INSERT OR REPLACE INTO settings (key, value) VALUES ('main', ?)", [
JSON.stringify(settings),
]);
}
async clearAll(): Promise<void> {
@@ -656,7 +836,7 @@ class CapacitorSQLiteDBImpl implements IDB {
path: 'Native SQLite',
articles: artRes.values?.[0]?.count || 0,
feeds: feedRes.values?.[0]?.count || 0,
walEnabled: true
walEnabled: true,
};
}
}
@@ -665,78 +845,150 @@ class WailsDBImpl implements IDB {
private async call<T>(method: string, ...args: any[]): Promise<T> {
const app = (window as any).go?.main?.App;
if (!app || !app[method]) throw new Error(`Wails method ${method} not found`);
// Add a 5 second timeout to all Wails calls to prevent infinite hangs
return Promise.race([
app[method](...args),
new Promise((_, reject) =>
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Wails call ${method} timed out after 5s`)), 5000)
)
),
]) as Promise<T>;
}
async getFeeds(): Promise<Feed[]> { return JSON.parse(await this.call('GetFeeds')); }
async saveFeed(feed: Feed): Promise<void> { await this.saveFeeds([feed]); }
async saveFeeds(feeds: Feed[]): Promise<void> { await this.call('SaveFeeds', JSON.stringify(feeds)); }
async deleteFeed(id: string): Promise<void> { await this.call('DeleteFeed', id); }
async getCategories(): Promise<Category[]> { return JSON.parse(await this.call('GetCategories')); }
async saveCategory(category: Category): Promise<void> { await this.saveCategories([category]); }
async saveCategories(categories: Category[]): Promise<void> { await this.call('SaveCategories', JSON.stringify(categories)); }
async getFeeds(): Promise<Feed[]> {
return JSON.parse(await this.call('GetFeeds'));
}
async saveFeed(feed: Feed): Promise<void> {
await this.saveFeeds([feed]);
}
async saveFeeds(feeds: Feed[]): Promise<void> {
await this.call('SaveFeeds', JSON.stringify(feeds));
}
async deleteFeed(id: string): Promise<void> {
await this.call('DeleteFeed', id);
}
async getCategories(): Promise<Category[]> {
return JSON.parse(await this.call('GetCategories'));
}
async saveCategory(category: Category): Promise<void> {
await this.saveCategories([category]);
}
async saveCategories(categories: Category[]): Promise<void> {
await this.call('SaveCategories', JSON.stringify(categories));
}
async deleteCategory(id: string): Promise<void> {
const feeds = await this.getFeeds();
for (const f of feeds) { if (f.categoryId === id) { f.categoryId = 'uncategorized'; await this.saveFeed(f); } }
for (const f of feeds) {
if (f.categoryId === id) {
f.categoryId = 'uncategorized';
await this.saveFeed(f);
}
}
// Wait for feed updates then delete cat
await this.call('SaveCategories', JSON.stringify((await this.getCategories()).filter(c => c.id !== id)));
await this.call(
'SaveCategories',
JSON.stringify((await this.getCategories()).filter((c) => c.id !== id))
);
}
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> { return JSON.parse(await this.call('GetArticles', feedId || '', offset, limit)); }
async saveArticles(articles: Article[]): Promise<void> { await this.call('SaveArticles', JSON.stringify(articles)); }
async searchArticles(query: string, limit = 50): Promise<Article[]> { return JSON.parse(await this.call('SearchArticles', query, limit)); }
async getReadingHistory(days = 30): Promise<{ date: number, count: number }[]> {
async getArticles(
feedId?: string,
offset = 0,
limit = 20,
categoryId?: string
): Promise<Article[]> {
return JSON.parse(
await this.call('GetArticles', feedId || '', offset, limit, categoryId || '')
);
}
async saveArticles(articles: Article[]): Promise<void> {
await this.call('SaveArticles', JSON.stringify(articles));
}
async searchArticles(query: string, limit = 50): Promise<Article[]> {
return JSON.parse(await this.call('SearchArticles', query, limit));
}
async getReadingHistory(days = 30): Promise<{ date: number; count: number }[]> {
return JSON.parse(await this.call('GetReadingHistory', days));
}
async markAsRead(id: string): Promise<void> {
await this.call('MarkAsRead', id);
}
async bulkMarkRead(ids: string[]): Promise<void> { for (const id of ids) await this.markAsRead(id); }
async bulkDelete(_ids: string[]): Promise<void> { /* Not directly in SQLite bridge yet, could add */ }
async bulkToggleSave(ids: string[]): Promise<void> { for (const id of ids) await this.toggleSave(id); }
async purgeOldContent(days: number): Promise<number> { return Number(await this.call('PurgeOldContent', days)); }
async bulkMarkRead(ids: string[]): Promise<void> {
for (const id of ids) await this.markAsRead(id);
}
async bulkDelete(_ids: string[]): Promise<void> {
/* Not directly in SQLite bridge yet, could add */
}
async bulkToggleSave(ids: string[]): Promise<void> {
for (const id of ids) await this.toggleSave(id);
}
async purgeOldContent(days: number): Promise<number> {
return Number(await this.call('PurgeOldContent', days));
}
async updateArticleContent(id: string, content: string): Promise<void> {
const articles = await this.getArticles('', 0, 1000);
const a = articles.find(art => art.id === id);
if (a) { a.content = content; await this.call('UpdateArticle', JSON.stringify(a)); }
const a = articles.find((art) => art.id === id);
if (a) {
a.content = content;
await this.call('UpdateArticle', JSON.stringify(a));
}
}
async toggleSave(id: string): Promise<boolean> {
const articles = await this.getArticles('', 0, 1000);
const a = articles.find(art => art.id === id);
if (a) { a.saved = !a.saved; await this.call('UpdateArticle', JSON.stringify(a)); return a.saved; }
const a = articles.find((art) => art.id === id);
if (a) {
a.saved = !a.saved;
await this.call('UpdateArticle', JSON.stringify(a));
return a.saved;
}
return false;
}
async getSavedArticles(): Promise<Article[]> {
const articles = await this.getArticles('', 0, 5000);
return articles.filter(a => a.saved).sort((a, b) => b.pubDate - a.pubDate);
return articles.filter((a) => a.saved).sort((a, b) => b.pubDate - a.pubDate);
}
async getSettings(): Promise<Settings> {
const defaults: Settings = {
theme: 'system', globalFetchInterval: 30, autoFetch: true, apiBaseUrl: '/api', smartFeed: false, readingMode: 'inline', paneWidth: 40, fontFamily: 'sans', fontSize: 18, lineHeight: 1.6, contentPurgeDays: 30, authToken: null, muteFilters: [],
theme: 'system',
globalFetchInterval: 30,
autoFetch: true,
apiBaseUrl: '/api',
smartFeed: false,
readingMode: 'inline',
paneWidth: 40,
fontFamily: 'sans',
fontSize: 18,
lineHeight: 1.6,
contentPurgeDays: 30,
authToken: null,
muteFilters: [],
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 }
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 },
};
const saved = await this.call<string>('GetSettings');
if (!saved) return defaults; // Handle empty string case
try {
return { ...defaults, ...JSON.parse(saved) };
} catch (e) {
console.error("Failed to parse settings", e);
console.error('Failed to parse settings', e);
return defaults;
}
}
async saveSettings(settings: Settings): Promise<void> { await this.call('SaveSettings', JSON.stringify(settings)); }
async clearAll(): Promise<void> { await this.call('ClearAll'); }
async getStats(): Promise<DBStats> { return await this.call('GetDBStats'); }
async vacuum(): Promise<void> { await this.call('VacuumDB'); }
async integrityCheck(): Promise<string> { return await this.call('CheckDBIntegrity'); }
async saveSettings(settings: Settings): Promise<void> {
await this.call('SaveSettings', JSON.stringify(settings));
}
async clearAll(): Promise<void> {
await this.call('ClearAll');
}
async getStats(): Promise<DBStats> {
return await this.call('GetDBStats');
}
async vacuum(): Promise<void> {
await this.call('VacuumDB');
}
async integrityCheck(): Promise<string> {
return await this.call('CheckDBIntegrity');
}
}
// LazyDBWrapper allows deciding which implementation to use at runtime (lazily),
@@ -746,9 +998,10 @@ class LazyDBWrapper implements IDB {
private getImpl(): IDB {
if (this.impl) return this.impl;
const isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
const isCapacitor = typeof window !== 'undefined' && (Capacitor.isNativePlatform());
const isWails =
typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
const isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform();
if (isWails) {
this.impl = new WailsDBImpl();
@@ -758,40 +1011,96 @@ class LazyDBWrapper implements IDB {
this.impl = new IndexedDBImpl();
}
console.log(`DB Initialized using: ${isWails ? 'Wails (SQLite)' : isCapacitor ? 'Capacitor (SQLite)' : 'Browser (IndexedDB)'}`);
console.log(
`DB Initialized using: ${isWails ? 'Wails (SQLite)' : isCapacitor ? 'Capacitor (SQLite)' : 'Browser (IndexedDB)'}`
);
return this.impl;
}
getFeeds() { return this.getImpl().getFeeds(); }
saveFeed(feed: Feed) { return this.getImpl().saveFeed(feed); }
saveFeeds(feeds: Feed[]) { return this.getImpl().saveFeeds(feeds); }
deleteFeed(id: string) { return this.getImpl().deleteFeed(id); }
getCategories() { return this.getImpl().getCategories(); }
saveCategory(category: Category) { return this.getImpl().saveCategory(category); }
saveCategories(categories: Category[]) { return this.getImpl().saveCategories(categories); }
deleteCategory(id: string) { return this.getImpl().deleteCategory(id); }
getArticles(feedId?: string, offset?: number, limit?: number) { return this.getImpl().getArticles(feedId, offset, limit); }
saveArticles(articles: Article[]) { return this.getImpl().saveArticles(articles); }
searchArticles(query: string, limit?: number) { return this.getImpl().searchArticles(query, limit); }
getReadingHistory(days?: number) { return this.getImpl().getReadingHistory(days); }
markAsRead(id: string) { return this.getImpl().markAsRead(id); }
bulkMarkRead(ids: string[]) { return this.getImpl().bulkMarkRead(ids); }
bulkDelete(ids: string[]) { return this.getImpl().bulkDelete(ids); }
bulkToggleSave(ids: string[]) { return this.getImpl().bulkToggleSave(ids); }
purgeOldContent(days: number) { return this.getImpl().purgeOldContent(days); }
updateArticleContent(id: string, content: string) { return this.getImpl().updateArticleContent(id, content); }
toggleSave(id: string) { return this.getImpl().toggleSave(id); }
getSavedArticles() { return this.getImpl().getSavedArticles(); }
getSettings() { return this.getImpl().getSettings(); }
saveSettings(settings: Settings) { return this.getImpl().saveSettings(settings); }
clearAll() { return this.getImpl().clearAll(); }
async getStats() {
const impl = this.getImpl();
return impl.getStats ? impl.getStats() : { size: 0, path: 'IndexedDB', articles: 0, feeds: 0, walEnabled: false };
getFeeds() {
return this.getImpl().getFeeds();
}
saveFeed(feed: Feed) {
return this.getImpl().saveFeed(feed);
}
saveFeeds(feeds: Feed[]) {
return this.getImpl().saveFeeds(feeds);
}
deleteFeed(id: string) {
return this.getImpl().deleteFeed(id);
}
getCategories() {
return this.getImpl().getCategories();
}
saveCategory(category: Category) {
return this.getImpl().saveCategory(category);
}
saveCategories(categories: Category[]) {
return this.getImpl().saveCategories(categories);
}
deleteCategory(id: string) {
return this.getImpl().deleteCategory(id);
}
getArticles(feedId?: string, offset?: number, limit?: number, categoryId?: string) {
return this.getImpl().getArticles(feedId, offset, limit, categoryId);
}
saveArticles(articles: Article[]) {
return this.getImpl().saveArticles(articles);
}
searchArticles(query: string, limit?: number) {
return this.getImpl().searchArticles(query, limit);
}
getReadingHistory(days?: number) {
return this.getImpl().getReadingHistory(days);
}
markAsRead(id: string) {
return this.getImpl().markAsRead(id);
}
bulkMarkRead(ids: string[]) {
return this.getImpl().bulkMarkRead(ids);
}
bulkDelete(ids: string[]) {
return this.getImpl().bulkDelete(ids);
}
bulkToggleSave(ids: string[]) {
return this.getImpl().bulkToggleSave(ids);
}
purgeOldContent(days: number) {
return this.getImpl().purgeOldContent(days);
}
updateArticleContent(id: string, content: string) {
return this.getImpl().updateArticleContent(id, content);
}
toggleSave(id: string) {
return this.getImpl().toggleSave(id);
}
getSavedArticles() {
return this.getImpl().getSavedArticles();
}
getSettings() {
return this.getImpl().getSettings();
}
saveSettings(settings: Settings) {
return this.getImpl().saveSettings(settings);
}
clearAll() {
return this.getImpl().clearAll();
}
async getStats() {
const impl = this.getImpl();
return impl.getStats
? impl.getStats()
: { size: 0, path: 'IndexedDB', articles: 0, feeds: 0, walEnabled: false };
}
async vacuum() {
const impl = this.getImpl();
if (impl.vacuum) await impl.vacuum();
}
async integrityCheck() {
const impl = this.getImpl();
return impl.integrityCheck ? await impl.integrityCheck() : 'N/A';
}
async vacuum() { const impl = this.getImpl(); if (impl.vacuum) await impl.vacuum(); }
async integrityCheck() { const impl = this.getImpl(); return impl.integrityCheck ? await impl.integrityCheck() : 'N/A'; }
}
export const db: IDB = new LazyDBWrapper();

View File

@@ -12,32 +12,38 @@ export function exportToOPML(feeds: Feed[], categories: Category[]): string {
// Group feeds by category
const categorizedFeeds: Record<string, Feed[]> = {};
feeds.forEach(f => {
feeds.forEach((f) => {
if (!categorizedFeeds[f.categoryId]) categorizedFeeds[f.categoryId] = [];
categorizedFeeds[f.categoryId].push(f);
});
// Add categories and their feeds
[...categories].sort((a, b) => a.order - b.order).forEach(cat => {
const catFeeds = categorizedFeeds[cat.id] || [];
if (catFeeds.length === 0) return;
[...categories]
.sort((a, b) => a.order - b.order)
.forEach((cat) => {
const catFeeds = categorizedFeeds[cat.id] || [];
if (catFeeds.length === 0) return;
xml += `
<outline text="${escapeHTML(cat.name)}" title="${escapeHTML(cat.name)}">`;
[...catFeeds].sort((a, b) => a.order - b.order).forEach(f => {
xml += `
<outline text="${escapeHTML(cat.name)}" title="${escapeHTML(cat.name)}">`;
[...catFeeds]
.sort((a, b) => a.order - b.order)
.forEach((f) => {
xml += `
<outline type="rss" text="${escapeHTML(f.title)}" title="${escapeHTML(f.title)}" xmlUrl="${escapeHTML(f.id)}" htmlUrl="${escapeHTML(f.siteUrl)}"/>`;
});
xml += `
});
xml += `
</outline>`;
});
});
// Add uncategorized feeds
const uncategorized = categorizedFeeds['uncategorized'] || [];
[...uncategorized].sort((a, b) => a.order - b.order).forEach(f => {
xml += `
[...uncategorized]
.sort((a, b) => a.order - b.order)
.forEach((f) => {
xml += `
<outline type="rss" text="${escapeHTML(f.title)}" title="${escapeHTML(f.title)}" xmlUrl="${escapeHTML(f.id)}" htmlUrl="${escapeHTML(f.siteUrl)}"/>`;
});
});
xml += `
</body>
@@ -46,11 +52,14 @@ export function exportToOPML(feeds: Feed[], categories: Category[]): string {
return xml;
}
export function parseOPML(xml: string): { feeds: Partial<Feed>[], categories: Partial<Category>[] } {
export function parseOPML(xml: string): {
feeds: Partial<Feed>[];
categories: Partial<Category>[];
} {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'text/xml');
const outlines = doc.querySelectorAll('body > outline');
const feeds: Partial<Feed>[] = [];
const categories: Partial<Category>[] = [];
@@ -67,7 +76,7 @@ export function parseOPML(xml: string): { feeds: Partial<Feed>[], categories: Pa
categories.push({
id: categoryId,
name: text,
order: categories.length
order: categories.length,
});
const childFeeds = outline.querySelectorAll('outline[type="rss"]');
@@ -90,17 +99,20 @@ function parseFeedOutline(el: Element, categoryId: string, order: number): Parti
enabled: true,
consecutiveErrors: 0,
fetchInterval: 30,
lastFetched: 0
lastFetched: 0,
};
}
function escapeHTML(str: string): string {
return str.replace(/[&<>"']/g, m => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[m] || m);
return str.replace(
/[&<>"']/g,
(m) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[m] || m
);
}

View File

@@ -4,25 +4,34 @@ import { registerPlugin } from '@capacitor/core';
const RSS = registerPlugin<any>('RSS');
export async function fetchFeed(feedUrl: string): Promise<{ feed: Partial<Feed>, articles: Article[] }> {
export async function fetchFeed(
feedUrl: string,
signal?: AbortSignal
): Promise<{ feed: Partial<Feed>; articles: Article[] }> {
// Try native RSS fetch first if on mobile to bypass CORS/Bot protection
if (newsStore.isCapacitor) {
try {
// Capacitor plugin might not support signal directly, but we can check it
if (signal?.aborted) throw new Error('Aborted');
const data = await RSS.fetchFeed({ url: feedUrl });
if (signal?.aborted) throw new Error('Aborted');
const articles: Article[] = data.articles.map((item: any) => ({
...item,
description: stripHtml(item.description || '').substring(0, 200)
description: stripHtml(item.description || '').substring(0, 200),
}));
return {
feed: {
...data.feed,
lastFetched: Date.now()
lastFetched: Date.now(),
},
articles
articles,
};
} catch (e: any) {
if (e.message === 'Aborted') throw e;
console.warn('Native RSS fetch failed, falling back to API proxy:', e);
// Show actual error in toast if it's not a "not implemented" error
if (e.message && !e.message.includes('not implemented')) {
@@ -36,7 +45,10 @@ export async function fetchFeed(feedUrl: string): Promise<{ feed: Partial<Feed>,
if (newsStore.settings.authToken) {
headers['X-Account-Number'] = newsStore.settings.authToken;
}
const response = await fetch(`${apiBase}/feed?url=${encodeURIComponent(feedUrl)}`, { headers });
const response = await fetch(`${apiBase}/feed?url=${encodeURIComponent(feedUrl)}`, {
headers,
signal,
});
if (response.status === 401) {
newsStore.logout();
throw new Error('Unauthorized');
@@ -45,7 +57,7 @@ export async function fetchFeed(feedUrl: string): Promise<{ feed: Partial<Feed>,
const errorText = await response.text();
throw new Error(errorText || `Failed to fetch feed: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
const text = await response.text();
@@ -65,18 +77,18 @@ export async function fetchFeed(feedUrl: string): Promise<{ feed: Partial<Feed>,
} catch {
throw new Error('Failed to parse server response as JSON');
}
const articles: Article[] = data.articles.map((item: any) => ({
...item,
description: stripHtml(item.description).substring(0, 200)
description: stripHtml(item.description).substring(0, 200),
}));
return {
feed: {
...data.feed,
lastFetched: Date.now()
lastFetched: Date.now(),
},
articles
articles,
};
}
@@ -91,33 +103,62 @@ function stripHtml(html: string): string {
.trim();
}
export async function refreshAllFeeds() {
export async function refreshAllFeeds(signal?: AbortSignal) {
const feeds = await db.getFeeds();
for (const feed of feeds) {
if (signal?.aborted) throw new Error('Aborted');
if (!feed.enabled) continue;
const now = Date.now();
const shouldFetch = now - feed.lastFetched > feed.fetchInterval * 60000 || feed.error;
if (shouldFetch) {
try {
const { feed: updatedFeed, articles } = await fetchFeed(feed.id);
await db.saveFeed({
...feed,
...updatedFeed,
error: undefined,
consecutiveErrors: 0
const { feed: updatedFeed, articles } = await fetchFeed(feed.id, signal);
await db.saveFeed({
...feed,
...updatedFeed,
error: undefined,
consecutiveErrors: 0,
});
await db.saveArticles(articles);
} catch (e: any) {
if (e.name === 'AbortError' || e.message === 'Aborted') throw e;
console.error(`Failed to refresh feed ${feed.id}:`, e);
const consecutiveErrors = (feed.consecutiveErrors || 0) + 1;
await db.saveFeed({
...feed,
await db.saveFeed({
...feed,
error: e.message || 'Unknown error',
consecutiveErrors
consecutiveErrors,
});
}
}
}
}
export async function refreshFeed(feedId: string, signal?: AbortSignal) {
const feeds = await db.getFeeds();
const feed = feeds.find((f) => f.id === feedId);
if (!feed) return;
try {
const { feed: updatedFeed, articles } = await fetchFeed(feed.id, signal);
await db.saveFeed({
...feed,
...updatedFeed,
error: undefined,
consecutiveErrors: 0,
});
await db.saveArticles(articles);
} catch (e: any) {
if (e.name === 'AbortError' || e.message === 'Aborted') throw e;
console.error(`Failed to refresh feed ${feed.id}:`, e);
const consecutiveErrors = (feed.consecutiveErrors || 0) + 1;
await db.saveFeed({
...feed,
error: e.message || 'Unknown error',
consecutiveErrors,
});
throw e;
}
}

View File

@@ -1,5 +1,5 @@
import { db, type Article, type Feed, type Settings, type Category } from './db';
import { refreshAllFeeds } from './rss';
import { refreshAllFeeds, refreshFeed } from './rss';
import { toast } from './toast.svelte';
import { Capacitor } from '@capacitor/core';
@@ -27,18 +27,19 @@ class NewsStore {
save: 's',
read: 'r',
open: 'o',
toggleSelect: 'x'
toggleSelect: 'x',
},
relevanceProfile: {
categoryScores: {},
feedScores: {},
totalInteractions: 0
}
totalInteractions: 0,
},
});
loading = $state(false);
isInitialLoading = $state(false);
showSidebar = $state(false);
selectedFeedId = $state<string | null>(null);
selectedCategoryId = $state<string | null>(null);
currentView = $state<'all' | 'saved' | 'following' | 'settings'>('all');
readingArticle = $state<any | null>(null);
searchQuery = $state('');
@@ -46,13 +47,16 @@ class NewsStore {
isSelectMode = $state(false);
selectedArticleIds = $state(new Set<string>());
private limit = 20;
private refreshController: AbortController | null = null;
// Connection status
isOnline = $state(true);
ping = $state<number | null>(null);
lastStatusCheck = $state<number>(Date.now());
authInfo = $state<{required: boolean, mode: string, canReg: boolean} | null>(null);
lastArticlesUpdate = $state<number>(Date.now());
authInfo = $state<{ required: boolean; mode: string; canReg: boolean } | null>(null);
isAuthenticated = $state(false);
newlyRegisteredToken = $state<string | null>(null);
isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform();
@@ -69,20 +73,21 @@ class NewsStore {
this.loading = true;
this.isInitialLoading = true;
const startTime = Date.now();
log("Init started");
log('Init started');
try {
// Platform detection
const isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
const isWails =
typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
const isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform();
let detectedWailsUrl: string | null = null;
if (isWails) {
log("Wails environment detected");
log('Wails environment detected');
// Wait a bit for bindings if they are not immediately available
let retries = 0;
while (retries < 15 && !(window as any).go?.main?.App?.GetAPIPort) {
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
retries++;
}
@@ -95,16 +100,16 @@ class NewsStore {
log(`Wails GetAPIPort failed: ${e}`);
}
} else {
log("Wails bindings not found after retries, continuing with defaults");
log('Wails bindings not found after retries, continuing with defaults');
}
} else if (isCapacitor) {
log("Capacitor Native environment detected");
log('Capacitor Native environment detected');
}
log("Fetching settings...");
log('Fetching settings...');
this.settings = await db.getSettings();
log("Settings loaded");
log('Settings loaded');
// Override API URL if running in Wails with detected port
// This prevents the DB setting (which might be stale or default) from breaking the connection
if (detectedWailsUrl) {
@@ -112,11 +117,11 @@ class NewsStore {
}
this.applyTheme();
log("Checking status...");
log('Checking status...');
await this.checkStatus();
log(`Status checked. Authenticated: ${this.isAuthenticated}`);
if (this.authInfo?.required) {
if (this.settings.authToken) {
const isValid = await this.verifyAuth(this.settings.authToken);
@@ -129,11 +134,11 @@ class NewsStore {
}
if (this.isAuthenticated) {
log("Loading feeds and categories...");
log('Loading feeds and categories...');
this.feeds = (await db.getFeeds()) || [];
this.categories = (await db.getCategories()) || [];
log(`Loaded ${this.feeds.length} feeds`);
// Ensure at least one category exists
if (this.categories.length === 0) {
const uncategorized = { id: 'uncategorized', name: 'Uncategorized', order: 0 };
@@ -141,9 +146,9 @@ class NewsStore {
this.categories = [uncategorized];
}
log("Loading articles...");
log('Loading articles...');
await this.loadArticles();
log("Articles loaded");
log('Articles loaded');
}
} catch (e) {
log(`Store initialization failed: ${e}`);
@@ -151,18 +156,18 @@ class NewsStore {
// Forced minimum loading time to prevent flickering (1.2 seconds)
const elapsed = Date.now() - startTime;
if (elapsed < 1200) {
await new Promise(resolve => setTimeout(resolve, 1200 - elapsed));
await new Promise((resolve) => setTimeout(resolve, 1200 - elapsed));
}
this.loading = false;
this.isInitialLoading = false;
this.isInitializing = false;
log("Init complete");
log('Init complete');
}
if (this.settings.autoFetch && this.isAuthenticated) {
this.startAutoFetch();
}
this.startStatusChecking();
}
@@ -170,7 +175,7 @@ class NewsStore {
const apiBase = this.settings.apiBaseUrl || '/api';
try {
const response = await fetch(`${apiBase}/auth/verify`, {
headers: { 'X-Account-Number': token }
headers: { 'X-Account-Number': token },
});
return response.ok;
} catch {
@@ -184,7 +189,7 @@ class NewsStore {
const response = await fetch(`${apiBase}/auth/register`, { method: 'POST' });
if (!response.ok) throw new Error('Registration failed');
const data = await response.json();
await this.login(data.accountNumber);
this.newlyRegisteredToken = data.accountNumber;
return data.accountNumber;
} catch {
toast.error('Could not generate account');
@@ -217,7 +222,7 @@ class NewsStore {
async checkStatus() {
if (typeof window === 'undefined') return;
const wasOnline = this.isOnline;
if (!navigator.onLine) {
this.isOnline = false;
@@ -232,10 +237,10 @@ class NewsStore {
// Add a short timeout for the ping
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`${apiBase}/ping`, {
const response = await fetch(`${apiBase}/ping`, {
cache: 'no-store',
signal: controller.signal
signal: controller.signal,
});
clearTimeout(timeout);
@@ -268,18 +273,27 @@ class NewsStore {
startStatusChecking() {
this.checkStatus();
if (this.statusInterval) clearInterval(this.statusInterval);
// Only run periodic status checks (ping) on web server
// Mobile and Desktop should avoid unnecessary background CPU/Battery usage
if (!this.isWails && !this.isCapacitor) {
this.statusInterval = setInterval(() => this.checkStatus(), 30000);
}
window.addEventListener('online', () => this.checkStatus());
window.addEventListener('offline', () => {
this.isOnline = false;
this.ping = null;
});
if (typeof window !== 'undefined') {
window.addEventListener('online', () => this.checkStatus());
window.addEventListener('offline', () => {
this.isOnline = false;
this.ping = null;
});
// Auto-refresh when tab becomes visible
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && this.isAuthenticated) {
this.refresh();
}
});
}
}
async loadArticles() {
@@ -291,72 +305,79 @@ class NewsStore {
articles = await db.searchArticles(this.searchQuery, 100);
this.hasMore = false; // Search results are usually limited
} else {
if (this.currentView === 'saved') {
articles = await db.getSavedArticles();
} else if (this.currentView === 'following') {
articles = await db.getArticles(undefined, 0, this.limit);
} else {
articles = await db.getArticles(this.selectedFeedId || undefined, 0, this.limit * 2);
if (this.currentView === 'saved') {
articles = await db.getSavedArticles();
} else if (this.currentView === 'following') {
articles = await db.getArticles(undefined, 0, this.limit);
} else {
articles = await db.getArticles(
this.selectedFeedId || undefined,
0,
this.limit * 2,
this.selectedCategoryId || undefined
);
}
}
// Apply Mute Filters
if (this.settings.muteFilters && this.settings.muteFilters.length > 0) {
const filters = this.settings.muteFilters.map(f => f.toLowerCase());
articles = articles.filter(a => {
const filters = this.settings.muteFilters.map((f) => f.toLowerCase());
articles = articles.filter((a) => {
const title = a.title.toLowerCase();
return !filters.some(f => title.includes(f));
return !filters.some((f) => title.includes(f));
});
}
if (!this.searchQuery) {
if (this.settings.smartFeed && this.currentView !== 'saved' && !this.selectedFeedId) {
articles = this.rankArticles(articles);
} else {
articles.sort((a, b) => b.pubDate - a.pubDate);
}
this.articles = articles.slice(0, this.limit);
if (articles.length < this.limit) {
this.hasMore = false;
if (this.settings.smartFeed && this.currentView !== 'saved' && !this.selectedFeedId) {
articles = this.rankArticles(articles);
} else {
articles.sort((a, b) => b.pubDate - a.pubDate);
}
this.articles = articles.slice(0, this.limit);
if (articles.length < this.limit) {
this.hasMore = false;
}
} else {
this.articles = articles;
}
this.lastArticlesUpdate = Date.now();
}
private rankArticles(articles: Article[]): Article[] {
const now = Date.now();
const profile = this.settings.relevanceProfile;
const totalInteractions = profile.totalInteractions || 0;
return articles.map(article => {
const feed = this.feeds.find(f => f.id === article.feedId);
const feedScore = profile.feedScores[article.feedId] || 0;
const catScore = feed ? (profile.categoryScores[feed.categoryId] || 0) : 0;
// Affinity score (0 to 1)
const affinity = totalInteractions > 0
? (feedScore + catScore) / (totalInteractions * 2)
: 0;
// Recency score (decays over 48 hours)
const ageHours = (now - article.pubDate) / (1000 * 60 * 60);
const recency = Math.max(0, 1 - (ageHours / 48));
// Weighted final score: 60% behavior, 40% recency
const score = (affinity * 0.6) + (recency * 0.4);
return { ...article, relevanceScore: score };
}).sort((a: any, b: any) => b.relevanceScore - a.relevanceScore);
return articles
.map((article) => {
const feed = this.feeds.find((f) => f.id === article.feedId);
const feedScore = profile.feedScores[article.feedId] || 0;
const catScore = feed ? profile.categoryScores[feed.categoryId] || 0 : 0;
// Affinity score (0 to 1)
const affinity =
totalInteractions > 0 ? (feedScore + catScore) / (totalInteractions * 2) : 0;
// Recency score (decays over 48 hours)
const ageHours = (now - article.pubDate) / (1000 * 60 * 60);
const recency = Math.max(0, 1 - ageHours / 48);
// Weighted final score: 60% behavior, 40% recency
const score = affinity * 0.6 + recency * 0.4;
return { ...article, relevanceScore: score };
})
.sort((a: any, b: any) => b.relevanceScore - a.relevanceScore);
}
async trackInteraction(articleId: string, type: 'click' | 'save') {
if (!this.settings.smartFeed) return;
const article = this.articles.find(a => a.id === articleId);
const article = this.articles.find((a) => a.id === articleId);
if (!article) return;
const feed = this.feeds.find(f => f.id === article.feedId);
const feed = this.feeds.find((f) => f.id === article.feedId);
const weight = type === 'save' ? 3 : 1;
// Use snapshot to avoid reactive loops while updating profile
@@ -364,7 +385,8 @@ class NewsStore {
profile.totalInteractions += weight;
profile.feedScores[article.feedId] = (profile.feedScores[article.feedId] || 0) + weight;
if (feed && feed.categoryId) {
profile.categoryScores[feed.categoryId] = (profile.categoryScores[feed.categoryId] || 0) + weight;
profile.categoryScores[feed.categoryId] =
(profile.categoryScores[feed.categoryId] || 0) + weight;
}
this.settings.relevanceProfile = profile;
@@ -375,7 +397,7 @@ class NewsStore {
this.settings.relevanceProfile = {
categoryScores: {},
feedScores: {},
totalInteractions: 0
totalInteractions: 0,
};
await db.saveSettings($state.snapshot(this.settings));
await this.loadArticles();
@@ -383,7 +405,11 @@ class NewsStore {
}
async resetAllData() {
if (typeof window !== 'undefined' && !confirm('Are you sure you want to reset everything? All feeds and settings will be deleted.')) return;
if (
typeof window !== 'undefined' &&
!confirm('Are you sure you want to reset everything? All feeds and settings will be deleted.')
)
return;
await db.clearAll();
window.location.reload();
}
@@ -391,18 +417,39 @@ class NewsStore {
async loadDemoData() {
const demoCategories: Category[] = [
{ id: 'tech', name: 'Technology', order: 0 },
{ id: 'news', name: 'General News', order: 1 }
{ id: 'news', name: 'General News', order: 1 },
];
const demoFeeds: Partial<Feed>[] = [
{ id: 'https://news.ycombinator.com/rss', title: 'Hacker News', categoryId: 'tech', order: 0, enabled: true, fetchInterval: 30 },
{ id: 'https://theverge.com/rss/index.xml', title: 'The Verge', categoryId: 'tech', order: 1, enabled: true, fetchInterval: 30 },
{ id: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml', title: 'NYT Top Stories', categoryId: 'news', order: 0, enabled: true, fetchInterval: 30 }
{
id: 'https://news.ycombinator.com/rss',
title: 'Hacker News',
categoryId: 'tech',
order: 0,
enabled: true,
fetchInterval: 30,
},
{
id: 'https://theverge.com/rss/index.xml',
title: 'The Verge',
categoryId: 'tech',
order: 1,
enabled: true,
fetchInterval: 30,
},
{
id: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml',
title: 'NYT Top Stories',
categoryId: 'news',
order: 0,
enabled: true,
fetchInterval: 30,
},
];
await db.saveCategories(demoCategories);
await db.saveFeeds(demoFeeds as Feed[]);
toast.success('Demo data loaded');
await this.init();
}
@@ -410,14 +457,19 @@ class NewsStore {
async loadMore() {
if (this.loading || !this.hasMore) return;
this.loading = true;
let more: Article[] = [];
const offset = this.articles.length;
if (this.currentView === 'saved') {
this.hasMore = false;
this.hasMore = false;
} else {
more = await db.getArticles(this.selectedFeedId || undefined, offset, this.limit);
more = await db.getArticles(
this.selectedFeedId || undefined,
offset,
this.limit,
this.selectedCategoryId || undefined
);
}
if (this.settings.smartFeed && this.currentView !== 'saved' && !this.selectedFeedId) {
@@ -430,9 +482,8 @@ class NewsStore {
if (this.searchQuery) {
const query = this.searchQuery.toLowerCase();
more = more.filter(a =>
a.title.toLowerCase().includes(query) ||
a.description.toLowerCase().includes(query)
more = more.filter(
(a) => a.title.toLowerCase().includes(query) || a.description.toLowerCase().includes(query)
);
}
@@ -443,22 +494,31 @@ class NewsStore {
async selectView(view: 'all' | 'saved' | 'following') {
this.currentView = view;
this.selectedFeedId = null;
this.selectedCategoryId = null;
await this.loadArticles();
}
async selectFeed(feedId: string | null) {
this.selectedFeedId = feedId;
this.selectedCategoryId = null;
this.currentView = 'all';
await this.loadArticles();
}
async selectCategory(categoryId: string | null) {
this.selectedCategoryId = categoryId;
this.selectedFeedId = null;
this.currentView = 'all';
await this.loadArticles();
}
async toggleSave(articleId: string) {
const isSaved = await db.toggleSave(articleId);
const article = this.articles.find(a => a.id === articleId);
const article = this.articles.find((a) => a.id === articleId);
if (article) article.saved = isSaved;
if (this.currentView === 'saved' && !isSaved) {
this.articles = this.articles.filter(a => a.id !== articleId);
this.articles = this.articles.filter((a) => a.id !== articleId);
}
return isSaved;
}
@@ -467,7 +527,7 @@ class NewsStore {
async bulkMarkRead() {
const ids = Array.from(this.selectedArticleIds);
await db.bulkMarkRead(ids);
this.articles.forEach(a => {
this.articles.forEach((a) => {
if (this.selectedArticleIds.has(a.id)) a.read = true;
});
this.selectedArticleIds.clear();
@@ -478,7 +538,7 @@ class NewsStore {
async bulkToggleSave() {
const ids = Array.from(this.selectedArticleIds);
await db.bulkToggleSave(ids);
this.articles.forEach(a => {
this.articles.forEach((a) => {
if (this.selectedArticleIds.has(a.id)) a.saved = !a.saved;
});
this.selectedArticleIds.clear();
@@ -490,7 +550,7 @@ class NewsStore {
if (!confirm('Are you sure you want to delete these articles?')) return;
const ids = Array.from(this.selectedArticleIds);
await db.bulkDelete(ids);
this.articles = this.articles.filter(a => !this.selectedArticleIds.has(a.id));
this.articles = this.articles.filter((a) => !this.selectedArticleIds.has(a.id));
this.selectedArticleIds.clear();
this.isSelectMode = false;
toast.success(`Deleted ${ids.length} articles`);
@@ -503,7 +563,7 @@ class NewsStore {
this.readingArticle = this.articles[0];
return;
}
const idx = this.articles.findIndex(a => a.id === this.readingArticle.id);
const idx = this.articles.findIndex((a) => a.id === this.readingArticle.id);
if (idx < this.articles.length - 1) {
this.readingArticle = this.articles[idx + 1];
}
@@ -511,24 +571,34 @@ class NewsStore {
prevArticle() {
if (!this.readingArticle) return;
const idx = this.articles.findIndex(a => a.id === this.readingArticle.id);
const idx = this.articles.findIndex((a) => a.id === this.readingArticle.id);
if (idx > 0) {
this.readingArticle = this.articles[idx - 1];
}
}
async refresh() {
if (this.loading && this.refreshController) {
this.refreshController.abort();
this.refreshController = null;
this.loading = false;
toast.info('Refresh cancelled');
return;
}
if (this.loading || !this.isAuthenticated) return;
this.loading = true;
this.refreshController = new AbortController();
try {
await refreshAllFeeds();
await refreshAllFeeds(this.refreshController.signal);
await this.loadArticles();
this.feeds = (await db.getFeeds()) || [];
this.categories = (await db.getCategories()) || [];
// Fix orphans: if a feed has a categoryId that doesn't exist, move it to the first category
if (this.categories.length > 0) {
const catIds = new Set(this.categories.map(c => c.id));
const catIds = new Set(this.categories.map((c) => c.id));
for (const f of this.feeds) {
if (!catIds.has(f.categoryId)) {
f.categoryId = this.categories[0].id;
@@ -538,7 +608,11 @@ class NewsStore {
}
toast.success('Feeds refreshed');
} catch (e) {
} catch (e: any) {
if (e.name === 'AbortError' || e.message === 'Aborted') {
console.log('Refresh aborted');
return;
}
console.error('Refresh failed:', e);
if (e instanceof Error && e.message.includes('401')) {
this.logout();
@@ -547,13 +621,44 @@ class NewsStore {
}
} finally {
this.loading = false;
this.refreshController = null;
}
}
async refreshFeed(feedId: string) {
if (this.loading && this.refreshController) {
this.refreshController.abort();
this.refreshController = null;
this.loading = false;
toast.info('Refresh cancelled');
return;
}
this.loading = true;
this.refreshController = new AbortController();
try {
await refreshFeed(feedId, this.refreshController.signal);
this.feeds = await db.getFeeds();
await this.loadArticles();
toast.success('Feed refreshed');
} catch (e: any) {
if (e.name === 'AbortError' || e.message === 'Aborted') {
console.log('Refresh aborted');
return;
}
console.error('Feed refresh failed:', e);
toast.error('Failed to refresh feed');
this.feeds = await db.getFeeds();
} finally {
this.loading = false;
this.refreshController = null;
}
}
async fetchFullText(url: string, articleId?: string) {
// 1. Check local cache first
if (articleId) {
const cached = this.articles.find(a => a.id === articleId);
const cached = this.articles.find((a) => a.id === articleId);
if (cached?.content) {
return {
title: cached.title,
@@ -570,7 +675,9 @@ class NewsStore {
if (this.settings.authToken) {
headers['X-Account-Number'] = this.settings.authToken;
}
const response = await fetch(`${apiBase}/fulltext?url=${encodeURIComponent(url)}`, { headers });
const response = await fetch(`${apiBase}/fulltext?url=${encodeURIComponent(url)}`, {
headers,
});
if (response.status === 401) {
this.logout();
throw new Error('401');
@@ -582,13 +689,14 @@ class NewsStore {
if (articleId) {
// Mark as read when opening
await db.markAsRead(articleId);
const art = this.articles.find(a => a.id === articleId);
const art = this.articles.find((a) => a.id === articleId);
if (art) {
art.read = true;
art.readAt = Date.now();
if (data.content) art.content = data.content;
data.feedId = art.feedId;
}
if (data.content) {
await db.updateArticleContent(articleId, data.content);
}
@@ -611,20 +719,41 @@ class NewsStore {
async deleteFeed(id: string) {
await db.deleteFeed(id);
this.feeds = this.feeds.filter(f => f.id !== id);
this.feeds = this.feeds.filter((f) => f.id !== id);
if (this.selectedFeedId === id) this.selectFeed(null);
toast.success('Feed removed');
}
async purgeProblematicFeeds(threshold = 5) {
const problematic = this.feeds.filter((f) => f.consecutiveErrors >= threshold);
if (problematic.length === 0) {
toast.info('No problematic feeds found');
return;
}
if (
!confirm(
`Are you sure you want to remove ${problematic.length} feeds that have failed ${threshold}+ times?`
)
)
return;
for (const feed of problematic) {
await db.deleteFeed(feed.id);
}
this.feeds = this.feeds.filter((f) => f.consecutiveErrors < threshold);
toast.success(`Removed ${problematic.length} problematic feeds`);
}
async updateFeed(feed: Feed, oldId?: string) {
const plainFeed = $state.snapshot(feed);
if (oldId && oldId !== feed.id) {
await db.deleteFeed(oldId);
}
await db.saveFeed(plainFeed);
const searchId = oldId || feed.id;
const index = this.feeds.findIndex(f => f.id === searchId);
const index = this.feeds.findIndex((f) => f.id === searchId);
if (index !== -1) {
this.feeds[index] = plainFeed;
} else {
@@ -636,7 +765,7 @@ class NewsStore {
async reorderFeeds(feedIds: string[]) {
const updatedFeeds = $state.snapshot(this.feeds);
feedIds.forEach((id, index) => {
const feed = updatedFeeds.find(f => f.id === id);
const feed = updatedFeeds.find((f) => f.id === id);
if (feed) feed.order = index;
});
await db.saveFeeds(updatedFeeds);
@@ -655,7 +784,7 @@ class NewsStore {
async updateCategory(category: Category) {
const plainCategory = $state.snapshot(category);
await db.saveCategory(plainCategory);
const index = this.categories.findIndex(c => c.id === category.id);
const index = this.categories.findIndex((c) => c.id === category.id);
if (index !== -1) this.categories[index] = plainCategory;
toast.success('Category updated');
}
@@ -668,18 +797,18 @@ class NewsStore {
if (!confirm(`Are you sure? Feeds in this category will be moved.`)) return;
const fallbackCat = this.categories.find(c => c.id !== id);
const fallbackCat = this.categories.find((c) => c.id !== id);
if (!fallbackCat) return;
// Move feeds to fallback category first
const feedsToMove = this.feeds.filter(f => f.categoryId === id);
const feedsToMove = this.feeds.filter((f) => f.categoryId === id);
for (const f of feedsToMove) {
const updatedFeed = { ...$state.snapshot(f), categoryId: fallbackCat.id };
await db.saveFeed(updatedFeed);
}
await db.deleteCategory(id);
this.categories = this.categories.filter(c => c.id !== id);
this.categories = this.categories.filter((c) => c.id !== id);
this.feeds = await db.getFeeds();
toast.success('Category removed');
}
@@ -687,7 +816,7 @@ class NewsStore {
async reorderCategories(catIds: string[]) {
const updatedCats = $state.snapshot(this.categories);
catIds.forEach((id, index) => {
const cat = updatedCats.find(c => c.id === id);
const cat = updatedCats.find((c) => c.id === id);
if (cat) cat.order = index;
});
await db.saveCategories(updatedCats);
@@ -697,7 +826,9 @@ class NewsStore {
applyTheme() {
if (typeof document === 'undefined') return;
const theme = this.settings.theme;
const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const isDark =
theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', isDark);
}
@@ -712,7 +843,9 @@ class NewsStore {
startAutoFetch() {
if (this.fetchInterval) clearInterval(this.fetchInterval);
this.fetchInterval = setInterval(() => {
this.refresh();
if (this.isAuthenticated) {
this.refresh();
}
}, this.settings.globalFetchInterval * 60000);
}
}

View File

@@ -23,14 +23,21 @@ class ToastStore {
}
remove(id: string) {
this.toasts = this.toasts.filter(t => t.id !== id);
this.toasts = this.toasts.filter((t) => t.id !== id);
}
success(message: string) { this.add(message, 'success'); }
error(message: string) { this.add(message, 'error', 5000); }
info(message: string) { this.add(message, 'info'); }
warning(message: string) { this.add(message, 'warning'); }
success(message: string) {
this.add(message, 'success');
}
error(message: string) {
this.add(message, 'error', 5000);
}
info(message: string) {
this.add(message, 'info');
}
warning(message: string) {
this.add(message, 'warning');
}
}
export const toast = new ToastStore();

View File

@@ -77,7 +77,9 @@
<div class="flex items-center gap-3">
<div class="flex-1">
<p class="text-sm font-medium text-text-primary">Update Available</p>
<p class="text-xs text-text-secondary mt-1">A new version is available. Reload to update.</p>
<p class="text-xs text-text-secondary mt-1">
A new version is available. Reload to update.
</p>
</div>
<button
on:click={reloadApp}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,13 @@
import { newsStore } from '$lib/store.svelte';
import Navbar from '../../components/Navbar.svelte';
let article = $state<{ title: string, link: string, description: string, imageUrl?: string, content?: string } | null>(null);
let article = $state<{
title: string;
link: string;
description: string;
imageUrl?: string;
content?: string;
} | null>(null);
let loading = $state(true);
let error = $state('');
@@ -15,18 +21,18 @@
const url = atob(encodedUrl);
const apiBase = newsStore.settings.apiBaseUrl || '/api';
const fullTextUrl = `${apiBase}/fulltext?url=${encodeURIComponent(url)}`;
const response = await fetch(fullTextUrl);
if (!response.ok) throw new Error('Failed to fetch article content');
const data = await response.json();
article = {
title: data.title || url,
link: url,
description: data.excerpt || '',
article = {
title: data.title || url,
link: url,
description: data.excerpt || '',
imageUrl: data.image || '',
content: data.content || ''
content: data.content || '',
};
} catch (e: any) {
error = 'Failed to load article: ' + e.message;
@@ -57,11 +63,41 @@
</script>
<svelte:head>
<title>{article ? article.title : 'Web News - Shared Article'}</title>
<title>{article ? article.title : 'Shared Article on Webnews'}</title>
<meta
name="description"
content={article ? article.description : 'A shared article on Webnews RSS reader'}
/>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="article" />
<meta property="og:title" content={article ? article.title : 'Shared Article on Webnews'} />
<meta
property="og:description"
content={article ? article.description : 'A shared article on Webnews RSS reader'}
/>
{#if article?.imageUrl}
<meta property="og:image" content={article.imageUrl} />
{:else}
<meta property="og:image" content="/favicon.svg" />
{/if}
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content={article ? article.title : 'Shared Article on Webnews'} />
<meta
property="twitter:description"
content={article ? article.description : 'A shared article on Webnews RSS reader'}
/>
{#if article?.imageUrl}
<meta property="twitter:image" content={article.imageUrl} />
{:else}
<meta property="twitter:image" content="/favicon.svg" />
{/if}
</svelte:head>
<div class="min-h-screen bg-bg-primary text-text-primary flex flex-col">
<Navbar onAddFeed={() => window.location.href = '/'} />
<Navbar onAddFeed={() => (window.location.href = '/')} />
<main class="flex-1 p-4 lg:p-8 overflow-y-auto">
<div class="max-w-4xl mx-auto">
@@ -80,7 +116,7 @@
</div>
{:else if article}
<div class="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<a
<a
href="/"
class="flex items-center gap-2 text-text-secondary hover:text-accent-blue transition-colors mb-4"
>
@@ -90,9 +126,13 @@
<div class="card p-6 sm:p-8 space-y-6">
{#if article.imageUrl}
<img src={article.imageUrl} alt="" class="w-full h-64 md:h-96 object-cover rounded-2xl border border-border-color shadow-lg" />
<img
src={article.imageUrl}
alt=""
class="w-full h-64 md:h-96 object-cover rounded-2xl border border-border-color shadow-lg"
/>
{/if}
<div class="space-y-4">
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold leading-tight tracking-tight">
{article.title}
@@ -109,9 +149,9 @@
</div>
<div class="pt-8 border-t border-border-color flex flex-wrap gap-4">
<a
href={article.link}
target="_blank"
<a
href={article.link}
target="_blank"
rel="noopener noreferrer"
class="btn-primary flex items-center gap-3 px-8 py-4 text-lg font-bold rounded-2xl"
>
@@ -133,7 +173,9 @@
:global(.prose img) {
border-radius: 1rem;
margin-bottom: 2rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
:global(.prose) {
--tw-prose-body: var(--text-secondary);
@@ -143,4 +185,3 @@
--tw-prose-quotes: var(--text-primary);
}
</style>

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="20" rx="4" fill="none"/>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#1a73e8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 11a9 9 0 0 1 9 9"/>
<path d="M4 4a16 16 0 0 1 16 16"/>
<circle cx="5" cy="19" r="1"/>
</svg>

Before

Width:  |  Height:  |  Size: 374 B

After

Width:  |  Height:  |  Size: 288 B

View File

@@ -1,4 +1,4 @@
const CACHE_VERSION = '0.1.0';
const CACHE_VERSION = '0.2.3';
const CACHE_NAME = `web-news-${CACHE_VERSION}`;
const urlsToCache = ['/', '/favicon.svg', '/manifest.json'];
@@ -45,7 +45,7 @@ self.addEventListener('fetch', (event) => {
}
// Network-First Strategy for everything else
// This ensures you always see the latest version if online,
// This ensures you always see the latest version if online,
// and only see the cached version if truly offline.
event.respondWith(
fetch(event.request)

View File

@@ -9,9 +9,9 @@ const config = {
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: true
})
}
strict: true,
}),
},
};
export default config;

View File

@@ -11,9 +11,9 @@ const config = {
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: true
})
}
strict: true,
}),
},
};
export default config;

View File

@@ -20,7 +20,5 @@ export default {
},
},
},
plugins: [
typography,
],
plugins: [typography],
};

View File

@@ -15,7 +15,7 @@ export default defineConfig({
plugins: [sveltekit()],
server: {
proxy: {
'/api': 'http://localhost:8080'
}
}
'/api': 'http://localhost:8080',
},
},
});