Compare commits
26 Commits
v0.2.0
...
renovate/h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2147a0b15f | ||
| ec011f7442 | |||
| 5228640a21 | |||
|
49df78f553
|
|||
|
|
5549171165 | ||
|
|
39f2986150 | ||
|
|
e0f85b450c | ||
|
|
19a9e0506d | ||
|
3cdca944ef
|
|||
|
895fba9ded
|
|||
|
a4503563e3
|
|||
|
82da01ca45
|
|||
|
7e235cb9d1
|
|||
|
a626a7cb33
|
|||
|
2c6bee84b4
|
|||
|
965c2b6daf
|
|||
|
20b83eb052
|
|||
|
9bd06c1f70
|
|||
|
0765f47083
|
|||
|
0b7c15f2f0
|
|||
|
7180776daa
|
|||
|
4ed6fcd752
|
|||
|
4e364bec74
|
|||
|
8e41a88599
|
|||
|
|
cafe5b8fd0 | ||
|
|
3534ba9b89 |
@@ -11,14 +11,14 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
uses: https://git.quad4.io/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
uses: https://git.quad4.io/actions/setup-pnpm@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
run_install: false
|
run_install: false
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
- name: Setup pnpm cache
|
||||||
uses: actions/cache@v4
|
uses: https://git.quad4.io/actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ env.STORE_PATH }}
|
path: ${{ env.STORE_PATH }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: bash scripts/build.sh
|
run: bash scripts/build.sh
|
||||||
- name: Upload frontend assets
|
- name: Upload frontend assets
|
||||||
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
|
uses: https://git.quad4.io/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
|
||||||
with:
|
with:
|
||||||
name: frontend-build
|
name: frontend-build
|
||||||
path: build/
|
path: build/
|
||||||
@@ -53,14 +53,14 @@ jobs:
|
|||||||
needs: build-frontend
|
needs: build-frontend
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
- name: Download frontend assets
|
- name: Download frontend assets
|
||||||
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
|
uses: https://git.quad4.io/actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
|
||||||
with:
|
with:
|
||||||
name: frontend-build
|
name: frontend-build
|
||||||
path: build/
|
path: build/
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.25.4'
|
go-version: '1.25.4'
|
||||||
- name: Build backend
|
- name: Build backend
|
||||||
|
|||||||
@@ -22,18 +22,18 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
uses: https://git.quad4.io/actions/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||||
with:
|
with:
|
||||||
platforms: amd64,arm64
|
platforms: amd64,arm64
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- 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
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
uses: https://git.quad4.io/actions/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
uses: https://git.quad4.io/actions/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
uses: https://git.quad4.io/actions/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: 'go.mod'
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: 'go.mod'
|
||||||
|
|
||||||
|
|||||||
@@ -11,33 +11,33 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
uses: https://git.quad4.io/actions/setup-pnpm@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
uses: https://git.quad4.io/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.25.4'
|
go-version: '1.25.4'
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
|
uses: https://git.quad4.io/actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
cache: gradle
|
cache: gradle
|
||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
uses: https://git.quad4.io/actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||||
with:
|
with:
|
||||||
log-accepted-android-sdk-licenses: false
|
log-accepted-android-sdk-licenses: false
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
name: renovate
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "@daily"
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- master
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
renovate:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container: ghcr.io/renovatebot/renovate:37.440.7
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
||||||
- name: Fetch remote configuration
|
|
||||||
run: curl -sL https://git.quad4.io/Quad4-Extra/renovate-config/raw/branch/master/config.js -o config.js
|
|
||||||
- run: renovate
|
|
||||||
env:
|
|
||||||
RENOVATE_CONFIG_FILE: "config.js"
|
|
||||||
LOG_LEVEL: "debug"
|
|
||||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
|
||||||
@@ -53,7 +53,11 @@ ENV PORT=8080
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV AUTH_FILE=/app/data/accounts.json
|
ENV AUTH_FILE=/app/data/accounts.json
|
||||||
ENV HASHES_FILE=/app/data/client_hashes.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
|
USER 65532
|
||||||
|
|
||||||
CMD ["./web-news", "-auth-file", "/app/data/accounts.json", "-hashes-file", "/app/data/client_hashes.json"]
|
CMD ["./web-news", "-auth-file", "/app/data/accounts.json", "-hashes-file", "/app/data/client_hashes.json", "-cache-file", "/app/data/cache.db"]
|
||||||
|
|||||||
@@ -29,12 +29,9 @@ Web News follows a "zero-knowledge" philosophy:
|
|||||||
|
|
||||||
- [ ] Reading time
|
- [ ] Reading time
|
||||||
- [ ] UI/UX Cleanup
|
- [ ] UI/UX Cleanup
|
||||||
- [ ] Add feed fetching timeout and button to remove if failed 3 times
|
|
||||||
- [ ] Use Go Mobile, remove Java RSS plugin.
|
- [ ] Use Go Mobile, remove Java RSS plugin.
|
||||||
- [ ] Dont show loading screen if not initial load (eg. reloading tab)
|
|
||||||
- [ ] Fix feeds double image (Feed image and article image at top)
|
|
||||||
- [ ] Export article(s)
|
- [ ] Export article(s)
|
||||||
- [ ] Export/Import OPML on add feed modal
|
- [ ] Favicon fetcher and caching
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -151,12 +151,12 @@ func (a *App) SaveArticles(articles string) error {
|
|||||||
return a.db.SaveArticles(articles)
|
return a.db.SaveArticles(articles)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetArticles(feedId string, offset, limit int) (string, error) {
|
func (a *App) GetArticles(feedId string, offset, limit int, categoryId string) (string, error) {
|
||||||
a.logDebug("GetArticles feedId=%s offset=%d limit=%d", feedId, offset, limit)
|
a.logDebug("GetArticles feedId=%s categoryId=%s offset=%d limit=%d", feedId, categoryId, offset, limit)
|
||||||
if a.db == nil {
|
if a.db == nil {
|
||||||
return "[]", 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) {
|
func (a *App) SearchArticles(query string, limit int) (string, error) {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ require (
|
|||||||
golang.org/x/crypto v0.46.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
golang.org/x/net v0.48.0 // 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/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
|
|||||||
@@ -5,15 +5,24 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: ${DOCKER_IMAGE:-web-news:latest}
|
image: ${DOCKER_IMAGE:-web-news:latest}
|
||||||
environment:
|
environment:
|
||||||
- PORT=${PORT:?8080}
|
- PORT=${PORT:-8080}
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- AUTH_MODE=multi
|
- AUTH_MODE=${AUTH_MODE:-multi}
|
||||||
|
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true}
|
||||||
# Coolify automatically populates SERVICE_URL_WEB_NEWS with the domain
|
# Coolify automatically populates SERVICE_URL_WEB_NEWS with the domain
|
||||||
- ALLOWED_ORIGINS=${SERVICE_URL_WEB_NEWS:-*}
|
- ALLOWED_ORIGINS=${SERVICE_URL_WEB_NEWS:-*}
|
||||||
- AUTH_FILE=/app/data/accounts.json
|
- AUTH_FILE=/app/data/accounts.json
|
||||||
- HASHES_FILE=/app/data/client_hashes.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:
|
volumes:
|
||||||
- web-news-data:/app/data
|
- 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:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ services:
|
|||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
||||||
- AUTH_FILE=/app/data/accounts.json
|
- AUTH_FILE=/app/data/accounts.json
|
||||||
- HASHES_FILE=/app/data/client_hashes.json
|
- HASHES_FILE=/app/data/client_hashes.json
|
||||||
|
- CACHE_FILE=/app/data/cache.db
|
||||||
|
- PUBLIC_INSTANCE=${PUBLIC_INSTANCE:-false}
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ services:
|
|||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
||||||
- AUTH_FILE=/app/data/accounts.json
|
- AUTH_FILE=/app/data/accounts.json
|
||||||
- HASHES_FILE=/app/data/client_hashes.json
|
- HASHES_FILE=/app/data/client_hashes.json
|
||||||
|
- CACHE_FILE=/app/data/cache.db
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export default [
|
|||||||
FileReader: 'readonly',
|
FileReader: 'readonly',
|
||||||
performance: 'readonly',
|
performance: 'readonly',
|
||||||
AbortController: 'readonly',
|
AbortController: 'readonly',
|
||||||
|
AbortSignal: 'readonly',
|
||||||
DOMParser: 'readonly',
|
DOMParser: 'readonly',
|
||||||
Element: 'readonly',
|
Element: 'readonly',
|
||||||
Node: 'readonly',
|
Node: 'readonly',
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -23,6 +23,7 @@ require (
|
|||||||
github.com/stretchr/testify v1.10.0 // indirect
|
github.com/stretchr/testify v1.10.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
golang.org/x/net v0.48.0 // 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/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
modernc.org/libc v1.66.10 // indirect
|
modernc.org/libc v1.66.10 // indirect
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -16,7 +17,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.quad4.io/Go-Libs/RSS"
|
"git.quad4.io/Go-Libs/RSS"
|
||||||
|
"git.quad4.io/Quad4-Software/webnews/internal/storage"
|
||||||
readability "github.com/go-shiori/go-readability"
|
readability "github.com/go-shiori/go-readability"
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,6 +48,71 @@ type Article struct {
|
|||||||
ImageURL string `json:"imageUrl"`
|
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 {
|
type RateLimiter struct {
|
||||||
clients map[string]*rate.Limiter
|
clients map[string]*rate.Limiter
|
||||||
mu *sync.RWMutex
|
mu *sync.RWMutex
|
||||||
@@ -122,7 +190,16 @@ func (rl *RateLimiter) GetLimiter(id string) *rate.Limiter {
|
|||||||
return 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{
|
var ForbiddenPatterns = []string{
|
||||||
".git", ".env", ".aws", ".config", ".ssh",
|
".git", ".env", ".aws", ".config", ".ssh",
|
||||||
@@ -130,14 +207,34 @@ var ForbiddenPatterns = []string{
|
|||||||
"etc/passwd", "cgi-bin",
|
"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 {
|
func BotBlockerMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
path := strings.ToLower(r.URL.Path)
|
path := strings.ToLower(r.URL.Path)
|
||||||
query := strings.ToLower(r.URL.RawQuery)
|
|
||||||
|
|
||||||
for _, pattern := range ForbiddenPatterns {
|
for _, pattern := range ForbiddenPatterns {
|
||||||
if strings.Contains(path, pattern) || strings.Contains(query, pattern) {
|
if strings.Contains(path, pattern) {
|
||||||
log.Printf("Blocked suspicious request: %s from %s", r.URL.String(), r.RemoteAddr)
|
ip := GetRealIP(r)
|
||||||
|
log.Printf("Blocked suspicious request: %s from %s", r.URL.String(), ip)
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -276,18 +373,7 @@ func AuthMiddleware(am *AuthManager, next http.HandlerFunc) http.HandlerFunc {
|
|||||||
|
|
||||||
func LimitMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
func LimitMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
ip := GetRealIP(r)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ua := r.Header.Get("User-Agent")
|
ua := r.Header.Get("User-Agent")
|
||||||
hash := sha256.New()
|
hash := sha256.New()
|
||||||
@@ -316,94 +402,111 @@ func HandleFeedProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{Timeout: 15 * time.Second}
|
if data, ok := FeedCache.Get(feedURL); ok {
|
||||||
req, err := http.NewRequest("GET", feedURL, nil)
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if err != nil {
|
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||||
http.Error(w, "Failed to create request: "+err.Error(), http.StatusInternalServerError)
|
log.Printf("Error encoding cached feed proxy response: %v", err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add browser-like headers to avoid being blocked by Cloudflare/Bot protection
|
val, err, _ := RequestGroup.Do(feedURL, func() (any, error) {
|
||||||
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")
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
req.Header.Set("Accept", "application/rss+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.7")
|
req, err := http.NewRequest("GET", feedURL, nil)
|
||||||
req.Header.Set("Cache-Control", "no-cache")
|
if err != nil {
|
||||||
req.Header.Set("Pragma", "no-cache")
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pubDate := time.Now().UnixMilli()
|
// Add browser-like headers to avoid being blocked by Cloudflare/Bot protection
|
||||||
if item.Published != nil {
|
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")
|
||||||
pubDate = item.Published.UnixMilli()
|
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 := ""
|
data, err := io.ReadAll(resp.Body)
|
||||||
if item.Author != nil {
|
if err != nil {
|
||||||
author = item.Author.Name
|
return nil, fmt.Errorf("failed to read feed body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
imageURL := ""
|
parsedFeed, err := rss.Parse(data)
|
||||||
for _, enc := range item.Enclosures {
|
if err != nil {
|
||||||
if enc.Type == "image/jpeg" || enc.Type == "image/png" || enc.Type == "image/gif" {
|
return nil, fmt.Errorf("failed to parse feed: %w", err)
|
||||||
imageURL = enc.URL
|
}
|
||||||
break
|
|
||||||
|
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{
|
response := ProxyResponse{
|
||||||
ID: id,
|
Feed: FeedInfo{
|
||||||
FeedID: feedURL,
|
Title: parsedFeed.Title,
|
||||||
Title: item.Title,
|
SiteURL: parsedFeed.Link,
|
||||||
Link: item.Link,
|
Description: parsedFeed.Description,
|
||||||
Description: item.Description,
|
LastFetched: time.Now().UnixMilli(),
|
||||||
Author: author,
|
},
|
||||||
PubDate: pubDate,
|
Articles: articles,
|
||||||
Read: false,
|
}
|
||||||
Saved: false,
|
|
||||||
ImageURL: imageURL,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
response := ProxyResponse{
|
FeedCache.Set(feedURL, response)
|
||||||
Feed: FeedInfo{
|
return response, nil
|
||||||
Title: parsedFeed.Title,
|
})
|
||||||
SiteURL: parsedFeed.Link,
|
|
||||||
Description: parsedFeed.Description,
|
if err != nil {
|
||||||
LastFetched: time.Now().UnixMilli(),
|
if strings.Contains(err.Error(), "status") {
|
||||||
},
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
Articles: articles,
|
} else {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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)
|
log.Printf("Error encoding feed proxy response: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -473,46 +576,61 @@ func HandleFullText(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedURL, _ := url.Parse(targetURL)
|
if data, ok := FullTextCache.Get(targetURL); ok {
|
||||||
article, err := readability.FromURL(targetURL, 15*time.Second)
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if err != nil {
|
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||||
client := &http.Client{Timeout: 15 * time.Second}
|
log.Printf("Error encoding cached fulltext response: %v", err)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response := FullTextResponse{
|
val, err, _ := RequestGroup.Do("ft-"+targetURL, func() (any, error) {
|
||||||
Title: article.Title,
|
parsedURL, _ := url.Parse(targetURL)
|
||||||
Content: article.Content,
|
article, err := readability.FromURL(targetURL, 15*time.Second)
|
||||||
TextContent: article.TextContent,
|
if err != nil {
|
||||||
Excerpt: article.Excerpt,
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
Byline: article.Byline,
|
req, err := http.NewRequest("GET", targetURL, nil)
|
||||||
SiteName: article.SiteName,
|
if err != nil {
|
||||||
Image: article.Image,
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
Favicon: article.Favicon,
|
}
|
||||||
URL: targetURL,
|
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")
|
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)
|
log.Printf("Error encoding fulltext response: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,12 @@ func (s *SQLiteDB) init() error {
|
|||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT
|
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 {
|
for _, q := range queries {
|
||||||
@@ -316,15 +322,22 @@ func (s *SQLiteDB) SaveArticles(articlesJSON string) error {
|
|||||||
return tx.Commit()
|
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 rows *sql.Rows
|
||||||
var err error
|
var err error
|
||||||
if feedId != "" {
|
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)
|
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 {
|
} 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)
|
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 {
|
if err != nil {
|
||||||
return "[]", err
|
return "[]", err
|
||||||
}
|
}
|
||||||
@@ -371,7 +384,7 @@ func (s *SQLiteDB) SearchArticles(query string, limit int) (string, error) {
|
|||||||
FROM articles
|
FROM articles
|
||||||
WHERE title LIKE ? OR description LIKE ? OR content LIKE ?
|
WHERE title LIKE ? OR description LIKE ? OR content LIKE ?
|
||||||
ORDER BY pubDate DESC LIMIT ?`, q, q, q, limit)
|
ORDER BY pubDate DESC LIMIT ?`, q, q, q, limit)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "[]", err
|
return "[]", err
|
||||||
}
|
}
|
||||||
@@ -482,7 +495,7 @@ func (s *SQLiteDB) ClearAll() error {
|
|||||||
func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
|
func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
|
||||||
cutoff := time.Now().AddDate(0, 0, -days).UnixMilli()
|
cutoff := time.Now().AddDate(0, 0, -days).UnixMilli()
|
||||||
rows, err := s.db.Query(`
|
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
|
FROM articles
|
||||||
WHERE read = 1 AND readAt > ?
|
WHERE read = 1 AND readAt > ?
|
||||||
GROUP BY date
|
GROUP BY date
|
||||||
@@ -499,8 +512,11 @@ func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
|
|||||||
if err := rows.Scan(&date, &count); err != nil {
|
if err := rows.Scan(&date, &count); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Convert date string back to timestamp for frontend
|
// Convert local date string back to local midnight timestamp for frontend
|
||||||
t, _ := time.Parse("2006-01-02", date)
|
t, err := time.ParseInLocation("2006-01-02", date, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
history = append(history, map[string]any{
|
history = append(history, map[string]any{
|
||||||
"date": t.UnixMilli(),
|
"date": t.UnixMilli(),
|
||||||
"count": count,
|
"count": count,
|
||||||
@@ -510,3 +526,32 @@ func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
|
|||||||
b, _ := json.Marshal(history)
|
b, _ := json.Marshal(history)
|
||||||
return string(b), nil
|
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
99
main.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -13,6 +14,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.quad4.io/Quad4-Software/webnews/internal/api"
|
"git.quad4.io/Quad4-Software/webnews/internal/api"
|
||||||
|
"git.quad4.io/Quad4-Software/webnews/internal/storage"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed build/*
|
//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")
|
allowedOriginsStr := flag.String("allowed-origins", os.Getenv("ALLOWED_ORIGINS"), "Comma-separated list of allowed CORS origins")
|
||||||
|
|
||||||
// Auth flags
|
// 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")
|
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")
|
defaultAuthFile := os.Getenv("AUTH_FILE")
|
||||||
hashesFile := flag.String("hashes-file", "client_hashes.json", "File to store IP+UA hashes for rate limiting")
|
if defaultAuthFile == "" {
|
||||||
disableProtection := flag.Bool("disable-protection", false, "Disable rate limiting and bot protection")
|
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()
|
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 != "" {
|
if *hashesFile != "" {
|
||||||
api.Limiter.File = *hashesFile
|
api.Limiter.File = *hashesFile
|
||||||
api.Limiter.LoadHashes()
|
api.Limiter.LoadHashes()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "web-news",
|
"name": "web-news",
|
||||||
"version": "0.2.0",
|
"version": "0.2.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./build/index.js",
|
"main": "./build/index.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -57,6 +57,6 @@
|
|||||||
"tailwindcss": "^3.4.19"
|
"tailwindcss": "^3.4.19"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"cookie": "^0.7.0"
|
"cookie": "^1.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/app.html
14
src/app.html
@@ -5,24 +5,30 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>Web News - Personal RSS Reader</title>
|
<title>Web News - Personal RSS Reader</title>
|
||||||
<meta name="description" content="A fast, clean, and private RSS reader for all your news." />
|
<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="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="shortcut icon" href="/favicon.svg" />
|
<link rel="shortcut icon" href="/favicon.svg" />
|
||||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://webnews.quad4.io" />
|
<meta property="og:url" content="https://webnews.quad4.io" />
|
||||||
<meta property="og:title" content="Web News" />
|
<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:description"
|
||||||
|
content="A fast, clean, and private RSS reader for all your news."
|
||||||
|
/>
|
||||||
<meta property="og:image" content="/favicon.svg" />
|
<meta property="og:image" content="/favicon.svg" />
|
||||||
|
|
||||||
<!-- Twitter -->
|
<!-- Twitter -->
|
||||||
<meta property="twitter:card" content="summary_large_image" />
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
<meta property="twitter:url" content="https://webnews.quad4.io" />
|
<meta property="twitter:url" content="https://webnews.quad4.io" />
|
||||||
<meta property="twitter:title" content="Web News" />
|
<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:description"
|
||||||
|
content="A fast, clean, and private RSS reader for all your news."
|
||||||
|
/>
|
||||||
<meta property="twitter:image" content="/favicon.svg" />
|
<meta property="twitter:image" content="/favicon.svg" />
|
||||||
|
|
||||||
<meta name="theme-color" content="#1a73e8" />
|
<meta name="theme-color" content="#1a73e8" />
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { db } from '$lib/db';
|
import { db } from '$lib/db';
|
||||||
import { fetchFeed } from '$lib/rss';
|
import { fetchFeed } from '$lib/rss';
|
||||||
|
import { parseOPML } from '$lib/opml';
|
||||||
import { newsStore } from '$lib/store.svelte';
|
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 { onOpenChange } = $props();
|
||||||
let feedUrl = $state('');
|
let feedUrl = $state('');
|
||||||
@@ -37,6 +39,36 @@
|
|||||||
loading = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
<div class="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||||
@@ -107,6 +139,30 @@
|
|||||||
Add Feed
|
Add Feed
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { Bookmark, Share2, MoreVertical, Check, FileText, Loader2 } from 'lucide-svelte';
|
import { Bookmark, Share2, MoreVertical, Check, FileText, Loader2 } from 'lucide-svelte';
|
||||||
|
|
||||||
let { article }: { article: Article } = $props();
|
let { article }: { article: Article } = $props();
|
||||||
|
const feed = $derived(newsStore.feeds.find((f) => f.id === article.feedId));
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
let loadingFullText = $state(false);
|
let loadingFullText = $state(false);
|
||||||
|
|
||||||
@@ -18,9 +19,11 @@
|
|||||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSource(feedId: string) {
|
function getSourceTitle() {
|
||||||
const feed = newsStore.feeds.find((f) => f.id === feedId);
|
return (
|
||||||
return feed?.title || new URL(feedId).hostname;
|
feed?.title ||
|
||||||
|
(article.feedId.startsWith('http') ? new URL(article.feedId).hostname : article.feedId)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shareArticle(e: MouseEvent) {
|
async function shareArticle(e: MouseEvent) {
|
||||||
@@ -78,6 +81,30 @@
|
|||||||
? 'ring-2 ring-accent-blue shadow-lg bg-accent-blue/5'
|
? '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}
|
{#if newsStore.isSelectMode}
|
||||||
<div class="flex items-center pl-4 z-20">
|
<div class="flex items-center pl-4 z-20">
|
||||||
<div class="relative w-5 h-5">
|
<div class="relative w-5 h-5">
|
||||||
@@ -112,8 +139,11 @@
|
|||||||
|
|
||||||
<div class="flex-1 min-w-0 p-4 relative z-10 pointer-events-none">
|
<div class="flex-1 min-w-0 p-4 relative z-10 pointer-events-none">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
{#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"
|
<span class="text-xs font-semibold text-accent-blue hover:underline pointer-events-auto"
|
||||||
>{getSource(article.feedId)}</span
|
>{getSourceTitle()}</span
|
||||||
>
|
>
|
||||||
<span class="text-text-secondary text-xs">•</span>
|
<span class="text-text-secondary text-xs">•</span>
|
||||||
<span class="text-text-secondary text-xs">{formatDate(article.pubDate)}</span>
|
<span class="text-text-secondary text-xs">{formatDate(article.pubDate)}</span>
|
||||||
@@ -174,16 +204,4 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</article>
|
||||||
|
|||||||
@@ -20,9 +20,11 @@
|
|||||||
Download,
|
Download,
|
||||||
Upload,
|
Upload,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
RefreshCw,
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
|
import { APP_VERSION } from '$lib/version';
|
||||||
|
|
||||||
let { onOpenSettings } = $props();
|
let { onOpenSettings } = $props();
|
||||||
|
|
||||||
@@ -351,8 +353,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<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"
|
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 ===
|
||||||
onclick={() => toggleCategory(cat.id)}
|
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}
|
title={cat.name}
|
||||||
>
|
>
|
||||||
{#if expandedCategories[cat.id]}
|
{#if expandedCategories[cat.id]}
|
||||||
@@ -454,6 +464,27 @@
|
|||||||
>
|
>
|
||||||
</button>
|
</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}
|
{#if isManageMode}
|
||||||
<div
|
<div
|
||||||
class="opacity-0 group-hover:opacity-100 transition-opacity pr-2 flex items-center gap-0.5"
|
class="opacity-0 group-hover:opacity-100 transition-opacity pr-2 flex items-center gap-0.5"
|
||||||
@@ -502,7 +533,7 @@
|
|||||||
class="flex items-center gap-1.5 text-[11px] text-text-secondary hover:text-accent-blue transition-colors font-medium"
|
class="flex items-center gap-1.5 text-[11px] text-text-secondary hover:text-accent-blue transition-colors font-medium"
|
||||||
>
|
>
|
||||||
<GitBranch size={13} />
|
<GitBranch size={13} />
|
||||||
<span>v0.2.0</span>
|
<span>v{APP_VERSION}</span>
|
||||||
</a>
|
</a>
|
||||||
<p class="text-[11px] text-text-secondary font-medium">
|
<p class="text-[11px] text-text-secondary font-medium">
|
||||||
Created by <a
|
Created by <a
|
||||||
|
|||||||
@@ -87,7 +87,12 @@ export interface IDB {
|
|||||||
saveCategory(category: Category): Promise<void>;
|
saveCategory(category: Category): Promise<void>;
|
||||||
saveCategories(categories: Category[]): Promise<void>;
|
saveCategories(categories: Category[]): Promise<void>;
|
||||||
deleteCategory(id: string): 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>;
|
saveArticles(articles: Article[]): Promise<void>;
|
||||||
searchArticles(query: string, limit?: number): Promise<Article[]>;
|
searchArticles(query: string, limit?: number): Promise<Article[]>;
|
||||||
getReadingHistory(days?: number): Promise<{ date: number; count: number }[]>;
|
getReadingHistory(days?: number): Promise<{ date: number; count: number }[]>;
|
||||||
@@ -249,14 +254,42 @@ class IndexedDBImpl implements IDB {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
const db = await this.open();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = db.transaction('articles', 'readonly');
|
const transaction = db.transaction(['articles', 'feeds'], 'readonly');
|
||||||
const store = transaction.objectStore('articles');
|
const store = transaction.objectStore('articles');
|
||||||
let request: IDBRequest<any[]>;
|
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 = () => {
|
request.onsuccess = () => {
|
||||||
const articles = request.result as Article[];
|
const articles = request.result as Article[];
|
||||||
articles.sort((a, b) => b.pubDate - a.pubDate);
|
articles.sort((a, b) => b.pubDate - a.pubDate);
|
||||||
@@ -316,7 +349,8 @@ class IndexedDBImpl implements IDB {
|
|||||||
if (cursor) {
|
if (cursor) {
|
||||||
const article = cursor.value as Article;
|
const article = cursor.value as Article;
|
||||||
if (article.readAt) {
|
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;
|
history[date] = (history[date] || 0) + 1;
|
||||||
}
|
}
|
||||||
cursor.continue();
|
cursor.continue();
|
||||||
@@ -605,7 +639,12 @@ class CapacitorSQLiteDBImpl implements IDB {
|
|||||||
await db.run('DELETE FROM categories WHERE id = ?', [id]);
|
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();
|
const db = await this.open();
|
||||||
let res;
|
let res;
|
||||||
if (feedId) {
|
if (feedId) {
|
||||||
@@ -613,6 +652,14 @@ class CapacitorSQLiteDBImpl implements IDB {
|
|||||||
'SELECT * FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?',
|
'SELECT * FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?',
|
||||||
[feedId, 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 {
|
} else {
|
||||||
res = await db.query('SELECT * FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?', [
|
res = await db.query('SELECT * FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?', [
|
||||||
limit,
|
limit,
|
||||||
@@ -662,17 +709,21 @@ class CapacitorSQLiteDBImpl implements IDB {
|
|||||||
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
const res = await db.query(
|
const res = await 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
|
FROM articles
|
||||||
WHERE read = 1 AND readAt > ?
|
WHERE read = 1 AND readAt > ?
|
||||||
GROUP BY date
|
GROUP BY date
|
||||||
ORDER BY date DESC`,
|
ORDER BY date DESC`,
|
||||||
[cutoff]
|
[cutoff]
|
||||||
);
|
);
|
||||||
return (res.values || []).map((row) => ({
|
return (res.values || []).map((row) => {
|
||||||
date: new Date(row.date).getTime(),
|
const [y, m, d] = row.date.split('-').map(Number);
|
||||||
count: row.count,
|
const date = new Date(y, m - 1, d).getTime();
|
||||||
}));
|
return {
|
||||||
|
date,
|
||||||
|
count: row.count,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAsRead(id: string): Promise<void> {
|
async markAsRead(id: string): Promise<void> {
|
||||||
@@ -839,8 +890,15 @@ class WailsDBImpl implements IDB {
|
|||||||
JSON.stringify((await this.getCategories()).filter((c) => c.id !== id))
|
JSON.stringify((await this.getCategories()).filter((c) => c.id !== id))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> {
|
async getArticles(
|
||||||
return JSON.parse(await this.call('GetArticles', feedId || '', offset, limit));
|
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> {
|
async saveArticles(articles: Article[]): Promise<void> {
|
||||||
await this.call('SaveArticles', JSON.stringify(articles));
|
await this.call('SaveArticles', JSON.stringify(articles));
|
||||||
@@ -983,8 +1041,8 @@ class LazyDBWrapper implements IDB {
|
|||||||
deleteCategory(id: string) {
|
deleteCategory(id: string) {
|
||||||
return this.getImpl().deleteCategory(id);
|
return this.getImpl().deleteCategory(id);
|
||||||
}
|
}
|
||||||
getArticles(feedId?: string, offset?: number, limit?: number) {
|
getArticles(feedId?: string, offset?: number, limit?: number, categoryId?: string) {
|
||||||
return this.getImpl().getArticles(feedId, offset, limit);
|
return this.getImpl().getArticles(feedId, offset, limit, categoryId);
|
||||||
}
|
}
|
||||||
saveArticles(articles: Article[]) {
|
saveArticles(articles: Article[]) {
|
||||||
return this.getImpl().saveArticles(articles);
|
return this.getImpl().saveArticles(articles);
|
||||||
|
|||||||
@@ -5,13 +5,19 @@ import { registerPlugin } from '@capacitor/core';
|
|||||||
const RSS = registerPlugin<any>('RSS');
|
const RSS = registerPlugin<any>('RSS');
|
||||||
|
|
||||||
export async function fetchFeed(
|
export async function fetchFeed(
|
||||||
feedUrl: string
|
feedUrl: string,
|
||||||
|
signal?: AbortSignal
|
||||||
): Promise<{ feed: Partial<Feed>; articles: Article[] }> {
|
): Promise<{ feed: Partial<Feed>; articles: Article[] }> {
|
||||||
// Try native RSS fetch first if on mobile to bypass CORS/Bot protection
|
// Try native RSS fetch first if on mobile to bypass CORS/Bot protection
|
||||||
if (newsStore.isCapacitor) {
|
if (newsStore.isCapacitor) {
|
||||||
try {
|
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 });
|
const data = await RSS.fetchFeed({ url: feedUrl });
|
||||||
|
|
||||||
|
if (signal?.aborted) throw new Error('Aborted');
|
||||||
|
|
||||||
const articles: Article[] = data.articles.map((item: any) => ({
|
const articles: Article[] = data.articles.map((item: any) => ({
|
||||||
...item,
|
...item,
|
||||||
description: stripHtml(item.description || '').substring(0, 200),
|
description: stripHtml(item.description || '').substring(0, 200),
|
||||||
@@ -25,6 +31,7 @@ export async function fetchFeed(
|
|||||||
articles,
|
articles,
|
||||||
};
|
};
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (e.message === 'Aborted') throw e;
|
||||||
console.warn('Native RSS fetch failed, falling back to API proxy:', 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
|
// Show actual error in toast if it's not a "not implemented" error
|
||||||
if (e.message && !e.message.includes('not implemented')) {
|
if (e.message && !e.message.includes('not implemented')) {
|
||||||
@@ -38,7 +45,10 @@ export async function fetchFeed(
|
|||||||
if (newsStore.settings.authToken) {
|
if (newsStore.settings.authToken) {
|
||||||
headers['X-Account-Number'] = 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) {
|
if (response.status === 401) {
|
||||||
newsStore.logout();
|
newsStore.logout();
|
||||||
throw new Error('Unauthorized');
|
throw new Error('Unauthorized');
|
||||||
@@ -93,9 +103,10 @@ function stripHtml(html: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshAllFeeds() {
|
export async function refreshAllFeeds(signal?: AbortSignal) {
|
||||||
const feeds = await db.getFeeds();
|
const feeds = await db.getFeeds();
|
||||||
for (const feed of feeds) {
|
for (const feed of feeds) {
|
||||||
|
if (signal?.aborted) throw new Error('Aborted');
|
||||||
if (!feed.enabled) continue;
|
if (!feed.enabled) continue;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -103,7 +114,7 @@ export async function refreshAllFeeds() {
|
|||||||
|
|
||||||
if (shouldFetch) {
|
if (shouldFetch) {
|
||||||
try {
|
try {
|
||||||
const { feed: updatedFeed, articles } = await fetchFeed(feed.id);
|
const { feed: updatedFeed, articles } = await fetchFeed(feed.id, signal);
|
||||||
await db.saveFeed({
|
await db.saveFeed({
|
||||||
...feed,
|
...feed,
|
||||||
...updatedFeed,
|
...updatedFeed,
|
||||||
@@ -112,6 +123,7 @@ export async function refreshAllFeeds() {
|
|||||||
});
|
});
|
||||||
await db.saveArticles(articles);
|
await db.saveArticles(articles);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (e.name === 'AbortError' || e.message === 'Aborted') throw e;
|
||||||
console.error(`Failed to refresh feed ${feed.id}:`, e);
|
console.error(`Failed to refresh feed ${feed.id}:`, e);
|
||||||
const consecutiveErrors = (feed.consecutiveErrors || 0) + 1;
|
const consecutiveErrors = (feed.consecutiveErrors || 0) + 1;
|
||||||
await db.saveFeed({
|
await db.saveFeed({
|
||||||
@@ -123,3 +135,30 @@ export async function refreshAllFeeds() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db, type Article, type Feed, type Settings, type Category } from './db';
|
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 { toast } from './toast.svelte';
|
||||||
import { Capacitor } from '@capacitor/core';
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ class NewsStore {
|
|||||||
isInitialLoading = $state(false);
|
isInitialLoading = $state(false);
|
||||||
showSidebar = $state(false);
|
showSidebar = $state(false);
|
||||||
selectedFeedId = $state<string | null>(null);
|
selectedFeedId = $state<string | null>(null);
|
||||||
|
selectedCategoryId = $state<string | null>(null);
|
||||||
currentView = $state<'all' | 'saved' | 'following' | 'settings'>('all');
|
currentView = $state<'all' | 'saved' | 'following' | 'settings'>('all');
|
||||||
readingArticle = $state<any | null>(null);
|
readingArticle = $state<any | null>(null);
|
||||||
searchQuery = $state('');
|
searchQuery = $state('');
|
||||||
@@ -46,13 +47,16 @@ class NewsStore {
|
|||||||
isSelectMode = $state(false);
|
isSelectMode = $state(false);
|
||||||
selectedArticleIds = $state(new Set<string>());
|
selectedArticleIds = $state(new Set<string>());
|
||||||
private limit = 20;
|
private limit = 20;
|
||||||
|
private refreshController: AbortController | null = null;
|
||||||
|
|
||||||
// Connection status
|
// Connection status
|
||||||
isOnline = $state(true);
|
isOnline = $state(true);
|
||||||
ping = $state<number | null>(null);
|
ping = $state<number | null>(null);
|
||||||
lastStatusCheck = $state<number>(Date.now());
|
lastStatusCheck = $state<number>(Date.now());
|
||||||
|
lastArticlesUpdate = $state<number>(Date.now());
|
||||||
authInfo = $state<{ required: boolean; mode: string; canReg: boolean } | null>(null);
|
authInfo = $state<{ required: boolean; mode: string; canReg: boolean } | null>(null);
|
||||||
isAuthenticated = $state(false);
|
isAuthenticated = $state(false);
|
||||||
|
newlyRegisteredToken = $state<string | null>(null);
|
||||||
|
|
||||||
isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
|
isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
|
||||||
isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform();
|
isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform();
|
||||||
@@ -185,7 +189,7 @@ class NewsStore {
|
|||||||
const response = await fetch(`${apiBase}/auth/register`, { method: 'POST' });
|
const response = await fetch(`${apiBase}/auth/register`, { method: 'POST' });
|
||||||
if (!response.ok) throw new Error('Registration failed');
|
if (!response.ok) throw new Error('Registration failed');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
await this.login(data.accountNumber);
|
this.newlyRegisteredToken = data.accountNumber;
|
||||||
return data.accountNumber;
|
return data.accountNumber;
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Could not generate account');
|
toast.error('Could not generate account');
|
||||||
@@ -276,11 +280,20 @@ class NewsStore {
|
|||||||
this.statusInterval = setInterval(() => this.checkStatus(), 30000);
|
this.statusInterval = setInterval(() => this.checkStatus(), 30000);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('online', () => this.checkStatus());
|
if (typeof window !== 'undefined') {
|
||||||
window.addEventListener('offline', () => {
|
window.addEventListener('online', () => this.checkStatus());
|
||||||
this.isOnline = false;
|
window.addEventListener('offline', () => {
|
||||||
this.ping = null;
|
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() {
|
async loadArticles() {
|
||||||
@@ -297,7 +310,12 @@ class NewsStore {
|
|||||||
} else if (this.currentView === 'following') {
|
} else if (this.currentView === 'following') {
|
||||||
articles = await db.getArticles(undefined, 0, this.limit);
|
articles = await db.getArticles(undefined, 0, this.limit);
|
||||||
} else {
|
} else {
|
||||||
articles = await db.getArticles(this.selectedFeedId || undefined, 0, this.limit * 2);
|
articles = await db.getArticles(
|
||||||
|
this.selectedFeedId || undefined,
|
||||||
|
0,
|
||||||
|
this.limit * 2,
|
||||||
|
this.selectedCategoryId || undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +341,7 @@ class NewsStore {
|
|||||||
} else {
|
} else {
|
||||||
this.articles = articles;
|
this.articles = articles;
|
||||||
}
|
}
|
||||||
|
this.lastArticlesUpdate = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
private rankArticles(articles: Article[]): Article[] {
|
private rankArticles(articles: Article[]): Article[] {
|
||||||
@@ -445,7 +464,12 @@ class NewsStore {
|
|||||||
if (this.currentView === 'saved') {
|
if (this.currentView === 'saved') {
|
||||||
this.hasMore = false;
|
this.hasMore = false;
|
||||||
} else {
|
} 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) {
|
if (this.settings.smartFeed && this.currentView !== 'saved' && !this.selectedFeedId) {
|
||||||
@@ -470,11 +494,20 @@ class NewsStore {
|
|||||||
async selectView(view: 'all' | 'saved' | 'following') {
|
async selectView(view: 'all' | 'saved' | 'following') {
|
||||||
this.currentView = view;
|
this.currentView = view;
|
||||||
this.selectedFeedId = null;
|
this.selectedFeedId = null;
|
||||||
|
this.selectedCategoryId = null;
|
||||||
await this.loadArticles();
|
await this.loadArticles();
|
||||||
}
|
}
|
||||||
|
|
||||||
async selectFeed(feedId: string | null) {
|
async selectFeed(feedId: string | null) {
|
||||||
this.selectedFeedId = feedId;
|
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';
|
this.currentView = 'all';
|
||||||
await this.loadArticles();
|
await this.loadArticles();
|
||||||
}
|
}
|
||||||
@@ -545,10 +578,20 @@ class NewsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refresh() {
|
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;
|
if (this.loading || !this.isAuthenticated) return;
|
||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
this.refreshController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await refreshAllFeeds();
|
await refreshAllFeeds(this.refreshController.signal);
|
||||||
await this.loadArticles();
|
await this.loadArticles();
|
||||||
this.feeds = (await db.getFeeds()) || [];
|
this.feeds = (await db.getFeeds()) || [];
|
||||||
this.categories = (await db.getCategories()) || [];
|
this.categories = (await db.getCategories()) || [];
|
||||||
@@ -565,7 +608,11 @@ class NewsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast.success('Feeds refreshed');
|
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);
|
console.error('Refresh failed:', e);
|
||||||
if (e instanceof Error && e.message.includes('401')) {
|
if (e instanceof Error && e.message.includes('401')) {
|
||||||
this.logout();
|
this.logout();
|
||||||
@@ -574,6 +621,37 @@ class NewsStore {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,6 +694,7 @@ class NewsStore {
|
|||||||
art.read = true;
|
art.read = true;
|
||||||
art.readAt = Date.now();
|
art.readAt = Date.now();
|
||||||
if (data.content) art.content = data.content;
|
if (data.content) art.content = data.content;
|
||||||
|
data.feedId = art.feedId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.content) {
|
if (data.content) {
|
||||||
@@ -645,6 +724,27 @@ class NewsStore {
|
|||||||
toast.success('Feed removed');
|
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) {
|
async updateFeed(feed: Feed, oldId?: string) {
|
||||||
const plainFeed = $state.snapshot(feed);
|
const plainFeed = $state.snapshot(feed);
|
||||||
if (oldId && oldId !== feed.id) {
|
if (oldId && oldId !== feed.id) {
|
||||||
@@ -743,7 +843,9 @@ class NewsStore {
|
|||||||
startAutoFetch() {
|
startAutoFetch() {
|
||||||
if (this.fetchInterval) clearInterval(this.fetchInterval);
|
if (this.fetchInterval) clearInterval(this.fetchInterval);
|
||||||
this.fetchInterval = setInterval(() => {
|
this.fetchInterval = setInterval(() => {
|
||||||
this.refresh();
|
if (this.isAuthenticated) {
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
}, this.settings.globalFetchInterval * 60000);
|
}, this.settings.globalFetchInterval * 60000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
X,
|
X,
|
||||||
Hash,
|
Hash,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { db } from '$lib/db';
|
import { db } from '$lib/db';
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
@@ -37,6 +39,8 @@
|
|||||||
let fontSize = $state(newsStore.settings.fontSize);
|
let fontSize = $state(newsStore.settings.fontSize);
|
||||||
let lineHeight = $state(newsStore.settings.lineHeight);
|
let lineHeight = $state(newsStore.settings.lineHeight);
|
||||||
let contentPurgeDays = $state(newsStore.settings.contentPurgeDays);
|
let contentPurgeDays = $state(newsStore.settings.contentPurgeDays);
|
||||||
|
let muteFilters = $state<string[]>([]);
|
||||||
|
let errorThreshold = $state(5);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
theme = newsStore.settings.theme;
|
theme = newsStore.settings.theme;
|
||||||
@@ -49,6 +53,7 @@
|
|||||||
fontSize = newsStore.settings.fontSize;
|
fontSize = newsStore.settings.fontSize;
|
||||||
lineHeight = newsStore.settings.lineHeight;
|
lineHeight = newsStore.settings.lineHeight;
|
||||||
contentPurgeDays = newsStore.settings.contentPurgeDays;
|
contentPurgeDays = newsStore.settings.contentPurgeDays;
|
||||||
|
muteFilters = [...newsStore.settings.muteFilters];
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleSaveSettings() {
|
async function handleSaveSettings() {
|
||||||
@@ -64,7 +69,8 @@
|
|||||||
fontSize,
|
fontSize,
|
||||||
lineHeight,
|
lineHeight,
|
||||||
contentPurgeDays,
|
contentPurgeDays,
|
||||||
// muteFilters and shortcuts are already updated in newsStore.settings via UI binding
|
muteFilters: [...muteFilters],
|
||||||
|
// shortcuts are already updated in newsStore.settings via UI binding
|
||||||
};
|
};
|
||||||
|
|
||||||
newsStore.settings = newSettings;
|
newsStore.settings = newSettings;
|
||||||
@@ -115,6 +121,18 @@
|
|||||||
let isResizing = $state(false);
|
let isResizing = $state(false);
|
||||||
let paneWidth = $state(newsStore.settings.paneWidth || 40);
|
let paneWidth = $state(newsStore.settings.paneWidth || 40);
|
||||||
let showDismissHatch = $state(false);
|
let showDismissHatch = $state(false);
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
|
async function copyToClipboard(text: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 2000);
|
||||||
|
toast.success('Account number copied to clipboard');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startResizing(e: MouseEvent) {
|
function startResizing(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -280,62 +298,118 @@
|
|||||||
<div
|
<div
|
||||||
class="card p-8 w-full max-w-md space-y-8 animate-in fade-in slide-in-from-bottom-8 duration-500"
|
class="card p-8 w-full max-w-md space-y-8 animate-in fade-in slide-in-from-bottom-8 duration-500"
|
||||||
>
|
>
|
||||||
<div class="text-center space-y-2">
|
{#if newsStore.newlyRegisteredToken}
|
||||||
<div
|
<div class="text-center space-y-4">
|
||||||
class="w-16 h-16 bg-accent-blue rounded-2xl flex items-center justify-center text-white mx-auto shadow-xl shadow-accent-blue/20"
|
<div
|
||||||
>
|
class="w-16 h-16 bg-green-500 rounded-2xl flex items-center justify-center text-white mx-auto shadow-xl shadow-green-500/20"
|
||||||
<Zap size={32} fill="currentColor" />
|
>
|
||||||
</div>
|
<Check size={32} />
|
||||||
<h1 class="text-3xl font-bold tracking-tight pt-4">Welcome to Web News</h1>
|
</div>
|
||||||
<p class="text-text-secondary">Please enter your account number to continue</p>
|
<h1 class="text-3xl font-bold tracking-tight pt-4">Account Created!</h1>
|
||||||
</div>
|
<p class="text-text-secondary text-sm">
|
||||||
|
Save your account number now. You will need it to log back in. <br />
|
||||||
|
<strong class="text-red-500">We cannot recover this for you.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="relative group">
|
||||||
<div class="space-y-2">
|
<button
|
||||||
<label for="token" class="text-sm font-medium">Account Number</label>
|
type="button"
|
||||||
<div class="relative">
|
class="w-full bg-accent-blue/5 border-2 border-accent-blue/20 rounded-xl p-4 font-mono text-lg text-accent-blue break-all text-center select-all cursor-pointer group-hover:border-accent-blue/40 transition-all"
|
||||||
<Hash
|
onclick={() => copyToClipboard(newsStore.newlyRegisteredToken!)}
|
||||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary"
|
>
|
||||||
size={18}
|
{newsStore.newlyRegisteredToken}
|
||||||
/>
|
</button>
|
||||||
<input
|
<button
|
||||||
id="token"
|
type="button"
|
||||||
type="text"
|
class="absolute -top-3 -right-3 bg-accent-blue text-white p-2 rounded-lg shadow-lg hover:scale-110 transition-transform"
|
||||||
placeholder="1234-5678-abcd-efgh"
|
onclick={() => copyToClipboard(newsStore.newlyRegisteredToken!)}
|
||||||
class="w-full bg-bg-secondary border border-border-color rounded-xl py-3 pl-10 pr-4 focus:ring-2 focus:ring-accent-blue/20 outline-none transition-all font-mono"
|
>
|
||||||
bind:value={loginToken}
|
{#if copied}
|
||||||
onkeydown={(e) => e.key === 'Enter' && newsStore.login(loginToken)}
|
<Check size={16} />
|
||||||
/>
|
{:else}
|
||||||
|
<Copy size={16} />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4 space-y-3">
|
||||||
|
<button
|
||||||
|
class="w-full btn-primary py-4 rounded-xl text-lg font-bold shadow-xl shadow-accent-blue/20 flex items-center justify-center gap-2"
|
||||||
|
onclick={() => {
|
||||||
|
const token = newsStore.newlyRegisteredToken;
|
||||||
|
newsStore.newlyRegisteredToken = null;
|
||||||
|
newsStore.login(token!);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
I've saved it, let's go!
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-full text-xs text-text-secondary hover:underline"
|
||||||
|
onclick={() => (newsStore.newlyRegisteredToken = null)}
|
||||||
|
>
|
||||||
|
Go back
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<div
|
||||||
|
class="w-16 h-16 bg-accent-blue rounded-2xl flex items-center justify-center text-white mx-auto shadow-xl shadow-accent-blue/20"
|
||||||
|
>
|
||||||
|
<Zap size={32} fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight pt-4">Welcome to Web News</h1>
|
||||||
|
<p class="text-text-secondary">Please enter your account number to continue</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<div class="space-y-4">
|
||||||
class="w-full btn-primary py-4 rounded-xl text-lg font-bold shadow-xl shadow-accent-blue/20 flex items-center justify-center gap-2"
|
<div class="space-y-2">
|
||||||
onclick={() => newsStore.login(loginToken)}
|
<label for="token" class="text-sm font-medium">Account Number</label>
|
||||||
disabled={!loginToken}
|
<div class="relative">
|
||||||
>
|
<Hash
|
||||||
Access Dashboard
|
class="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary"
|
||||||
<ChevronRight size={20} />
|
size={18}
|
||||||
</button>
|
/>
|
||||||
|
<input
|
||||||
{#if newsStore.authInfo?.canReg}
|
id="token"
|
||||||
<div class="relative py-4">
|
type="text"
|
||||||
<div class="absolute inset-0 flex items-center">
|
placeholder="1234-5678-abcd-efgh"
|
||||||
<div class="w-full border-t border-border-color"></div>
|
class="w-full bg-bg-secondary border border-border-color rounded-xl py-3 pl-10 pr-4 focus:ring-2 focus:ring-accent-blue/20 outline-none transition-all font-mono"
|
||||||
</div>
|
bind:value={loginToken}
|
||||||
<div class="relative flex justify-center text-xs uppercase">
|
onkeydown={(e) => e.key === 'Enter' && newsStore.login(loginToken)}
|
||||||
<span class="bg-bg-primary px-2 text-text-secondary">Or</span>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="w-full py-4 border border-border-color rounded-xl font-semibold hover:bg-bg-secondary transition-all flex items-center justify-center gap-2"
|
class="w-full btn-primary py-4 rounded-xl text-lg font-bold shadow-xl shadow-accent-blue/20 flex items-center justify-center gap-2"
|
||||||
onclick={() => newsStore.registerAuth()}
|
onclick={() => newsStore.login(loginToken)}
|
||||||
|
disabled={!loginToken}
|
||||||
>
|
>
|
||||||
Generate New Account Number
|
Access Dashboard
|
||||||
|
<ChevronRight size={20} />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
|
||||||
</div>
|
{#if newsStore.authInfo?.canReg}
|
||||||
|
<div class="relative py-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full py-4 border border-border-color rounded-xl font-semibold hover:bg-bg-secondary transition-all flex items-center justify-center gap-2"
|
||||||
|
onclick={() => newsStore.registerAuth()}
|
||||||
|
>
|
||||||
|
Generate New Account Number
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<p class="text-[10px] text-text-secondary text-center leading-relaxed">
|
<p class="text-[10px] text-text-secondary text-center leading-relaxed">
|
||||||
Web News is privacy-focused. We don't use emails or passwords. <br />
|
Web News is privacy-focused. We don't use emails or passwords. <br />
|
||||||
@@ -639,6 +713,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Feed Health -->
|
||||||
|
<section id="feed-health" class="space-y-4 w-full min-w-0">
|
||||||
|
<h3
|
||||||
|
class="text-sm font-semibold text-text-secondary uppercase tracking-wider flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Zap size={16} />
|
||||||
|
Feed Health
|
||||||
|
</h3>
|
||||||
|
<div class="card p-4 sm:p-6 space-y-6">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-text-secondary"
|
||||||
|
>Purge feeds with consecutive errors ≥</span
|
||||||
|
>
|
||||||
|
<span class="font-medium text-accent-blue">{errorThreshold} failures</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
step="1"
|
||||||
|
bind:value={errorThreshold}
|
||||||
|
class="w-full h-1.5 bg-bg-secondary rounded-lg appearance-none cursor-pointer accent-accent-blue"
|
||||||
|
aria-label="Error threshold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="w-full py-2.5 bg-red-500/10 hover:bg-red-500/20 text-red-500 rounded-xl text-xs font-semibold transition-colors flex items-center justify-center gap-2"
|
||||||
|
onclick={() => newsStore.purgeProblematicFeeds(errorThreshold)}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
Purge All Problematic Feeds
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Mute Filters -->
|
<!-- Mute Filters -->
|
||||||
<section id="mute-filters" class="space-y-4 w-full min-w-0">
|
<section id="mute-filters" class="space-y-4 w-full min-w-0">
|
||||||
<h3
|
<h3
|
||||||
@@ -661,10 +772,7 @@
|
|||||||
class="flex-1 bg-bg-secondary border border-border-color rounded-xl px-4 py-2 outline-none focus:ring-2 focus:ring-accent-blue/20"
|
class="flex-1 bg-bg-secondary border border-border-color rounded-xl px-4 py-2 outline-none focus:ring-2 focus:ring-accent-blue/20"
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter' && newFilter) {
|
if (e.key === 'Enter' && newFilter) {
|
||||||
newsStore.settings.muteFilters = [
|
muteFilters = [...muteFilters, newFilter];
|
||||||
...newsStore.settings.muteFilters,
|
|
||||||
newFilter,
|
|
||||||
];
|
|
||||||
newFilter = '';
|
newFilter = '';
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -673,10 +781,7 @@
|
|||||||
class="btn-primary px-4 py-2 rounded-xl whitespace-nowrap"
|
class="btn-primary px-4 py-2 rounded-xl whitespace-nowrap"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (newFilter) {
|
if (newFilter) {
|
||||||
newsStore.settings.muteFilters = [
|
muteFilters = [...muteFilters, newFilter];
|
||||||
...newsStore.settings.muteFilters,
|
|
||||||
newFilter,
|
|
||||||
];
|
|
||||||
newFilter = '';
|
newFilter = '';
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -687,7 +792,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 pt-2">
|
<div class="flex flex-wrap gap-2 pt-2">
|
||||||
{#each newsStore.settings.muteFilters as filter}
|
{#each muteFilters as filter}
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-bg-secondary border border-border-color text-xs font-medium"
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-bg-secondary border border-border-color text-xs font-medium"
|
||||||
>
|
>
|
||||||
@@ -695,8 +800,7 @@
|
|||||||
<button
|
<button
|
||||||
class="text-text-secondary hover:text-red-500 transition-colors"
|
class="text-text-secondary hover:text-red-500 transition-colors"
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
(newsStore.settings.muteFilters =
|
(muteFilters = muteFilters.filter((f) => f !== filter))}
|
||||||
newsStore.settings.muteFilters.filter((f) => f !== filter))}
|
|
||||||
>
|
>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
@@ -765,12 +869,14 @@
|
|||||||
{#each Array(30)
|
{#each Array(30)
|
||||||
.fill(0)
|
.fill(0)
|
||||||
.map((_, i) => {
|
.map((_, i) => {
|
||||||
const date = new Date();
|
const d = new Date();
|
||||||
date.setDate(date.getDate() - (29 - i));
|
d.setDate(d.getDate() - (29 - i));
|
||||||
const dStr = date.toISOString().split('T')[0];
|
const dStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
const entry = readingHistory.find((h) => new Date(h.date)
|
const entry = readingHistory.find((h) => {
|
||||||
.toISOString()
|
const hd = new Date(h.date);
|
||||||
.split('T')[0] === dStr);
|
const hStr = `${hd.getFullYear()}-${String(hd.getMonth() + 1).padStart(2, '0')}-${String(hd.getDate()).padStart(2, '0')}`;
|
||||||
|
return hStr === dStr;
|
||||||
|
});
|
||||||
return { date: dStr, count: entry?.count || 0 };
|
return { date: dStr, count: entry?.count || 0 };
|
||||||
}) as day}
|
}) as day}
|
||||||
<div
|
<div
|
||||||
@@ -994,7 +1100,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if newsStore.readingArticle.image}
|
{#if newsStore.readingArticle.image && newsStore.readingArticle.image !== newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)?.icon}
|
||||||
<img
|
<img
|
||||||
src={newsStore.readingArticle.image}
|
src={newsStore.readingArticle.image}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -1007,6 +1113,14 @@
|
|||||||
{newsStore.readingArticle.title}
|
{newsStore.readingArticle.title}
|
||||||
</h1>
|
</h1>
|
||||||
<div class="flex items-center gap-2 text-sm text-text-secondary">
|
<div class="flex items-center gap-2 text-sm text-text-secondary">
|
||||||
|
{#if newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)?.icon}
|
||||||
|
<img
|
||||||
|
src={newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)
|
||||||
|
?.icon}
|
||||||
|
alt=""
|
||||||
|
class="w-4 h-4 rounded-sm"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
<span class="font-medium text-accent-blue"
|
<span class="font-medium text-accent-blue"
|
||||||
>{newsStore.readingArticle.siteName || ''}</span
|
>{newsStore.readingArticle.siteName || ''}</span
|
||||||
>
|
>
|
||||||
@@ -1039,6 +1153,11 @@
|
|||||||
>{feed?.id}</span
|
>{feed?.id}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if newsStore.selectedCategoryId}
|
||||||
|
{@const cat = newsStore.categories.find(
|
||||||
|
(c) => c.id === newsStore.selectedCategoryId
|
||||||
|
)}
|
||||||
|
<span>{cat?.name}</span>
|
||||||
{:else if newsStore.currentView === 'saved'}
|
{:else if newsStore.currentView === 'saved'}
|
||||||
Saved stories
|
Saved stories
|
||||||
{:else if newsStore.currentView === 'following'}
|
{:else if newsStore.currentView === 'following'}
|
||||||
@@ -1048,7 +1167,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</h2>
|
</h2>
|
||||||
<span class="text-xs text-text-secondary">
|
<span class="text-xs text-text-secondary">
|
||||||
Updated {new Date(newsStore.lastStatusCheck).toLocaleTimeString([], {
|
Updated {new Date(newsStore.lastArticlesUpdate).toLocaleTimeString([], {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
})}
|
})}
|
||||||
@@ -1199,7 +1318,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if newsStore.readingArticle.image}
|
{#if newsStore.readingArticle.image && newsStore.readingArticle.image !== newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)?.icon}
|
||||||
<img
|
<img
|
||||||
src={newsStore.readingArticle.image}
|
src={newsStore.readingArticle.image}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -1211,9 +1330,21 @@
|
|||||||
<h1 class="text-3xl font-bold leading-tight tracking-tight">
|
<h1 class="text-3xl font-bold leading-tight tracking-tight">
|
||||||
{newsStore.readingArticle.title}
|
{newsStore.readingArticle.title}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-xs text-text-secondary">
|
<div class="flex items-center gap-2 text-xs text-text-secondary">
|
||||||
{newsStore.readingArticle.byline || newsStore.readingArticle.siteName || ''}
|
{#if newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)?.icon}
|
||||||
</p>
|
<img
|
||||||
|
src={newsStore.feeds.find((f) => f.id === newsStore.readingArticle.feedId)
|
||||||
|
?.icon}
|
||||||
|
alt=""
|
||||||
|
class="w-4 h-4 rounded-sm"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<span
|
||||||
|
>{newsStore.readingArticle.byline ||
|
||||||
|
newsStore.readingArticle.siteName ||
|
||||||
|
''}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="prose prose-invert max-w-none text-text-secondary leading-relaxed pb-20">
|
<div class="prose prose-invert max-w-none text-text-secondary leading-relaxed pb-20">
|
||||||
|
|||||||
@@ -64,12 +64,18 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{article ? article.title : 'Shared Article on Webnews'}</title>
|
<title>{article ? article.title : 'Shared Article on Webnews'}</title>
|
||||||
<meta name="description" content={article ? article.description : 'A shared article on Webnews RSS reader'} />
|
<meta
|
||||||
|
name="description"
|
||||||
|
content={article ? article.description : 'A shared article on Webnews RSS reader'}
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:type" content="article" />
|
<meta property="og:type" content="article" />
|
||||||
<meta property="og:title" content={article ? article.title : 'Shared Article on Webnews'} />
|
<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'} />
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content={article ? article.description : 'A shared article on Webnews RSS reader'}
|
||||||
|
/>
|
||||||
{#if article?.imageUrl}
|
{#if article?.imageUrl}
|
||||||
<meta property="og:image" content={article.imageUrl} />
|
<meta property="og:image" content={article.imageUrl} />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -79,7 +85,10 @@
|
|||||||
<!-- Twitter -->
|
<!-- Twitter -->
|
||||||
<meta property="twitter:card" content="summary_large_image" />
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
<meta property="twitter:title" content={article ? article.title : 'Shared Article on Webnews'} />
|
<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'} />
|
<meta
|
||||||
|
property="twitter:description"
|
||||||
|
content={article ? article.description : 'A shared article on Webnews RSS reader'}
|
||||||
|
/>
|
||||||
{#if article?.imageUrl}
|
{#if article?.imageUrl}
|
||||||
<meta property="twitter:image" content={article.imageUrl} />
|
<meta property="twitter:image" content={article.imageUrl} />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_VERSION = '0.2.0';
|
const CACHE_VERSION = '0.2.3';
|
||||||
const CACHE_NAME = `web-news-${CACHE_VERSION}`;
|
const CACHE_NAME = `web-news-${CACHE_VERSION}`;
|
||||||
const urlsToCache = ['/', '/favicon.svg', '/manifest.json'];
|
const urlsToCache = ['/', '/favicon.svg', '/manifest.json'];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user