Compare commits
72 Commits
v0.1.0
...
renovate/h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56d5d534fb | ||
| ec011f7442 | |||
| 5228640a21 | |||
|
49df78f553
|
|||
|
|
5549171165 | ||
|
|
39f2986150 | ||
|
|
e0f85b450c | ||
|
|
19a9e0506d | ||
|
3cdca944ef
|
|||
|
895fba9ded
|
|||
|
a4503563e3
|
|||
|
82da01ca45
|
|||
|
7e235cb9d1
|
|||
|
a626a7cb33
|
|||
|
2c6bee84b4
|
|||
|
965c2b6daf
|
|||
|
20b83eb052
|
|||
|
9bd06c1f70
|
|||
|
0765f47083
|
|||
|
0b7c15f2f0
|
|||
|
7180776daa
|
|||
|
4ed6fcd752
|
|||
|
4e364bec74
|
|||
|
8e41a88599
|
|||
|
eece10388a
|
|||
|
4e308f2e61
|
|||
|
3deba1ad94
|
|||
|
d0c96c7ca5
|
|||
|
5d049d38b7
|
|||
|
bb3ba5ecd4
|
|||
|
25182b6831
|
|||
|
a37f6fa7bf
|
|||
|
|
cafe5b8fd0 | ||
|
|
3534ba9b89 | ||
|
|
205eb3e99d | ||
|
|
6bdc0ad29c | ||
|
|
3668aa8e78 | ||
|
|
ec0b6b3262 | ||
|
|
6a35e95814 | ||
|
|
38398d79be | ||
|
|
f92922ae8e | ||
|
|
4210e0a0c1 | ||
|
ce13ec2d6f
|
|||
|
5bc7630673
|
|||
|
b402f078e7
|
|||
|
b2306798ac
|
|||
|
50399bcae2
|
|||
|
3f62b36de5
|
|||
|
c5176f9ed4
|
|||
|
30c7a50240
|
|||
|
f98fc7c618
|
|||
|
402f4e676e
|
|||
|
78ec006842
|
|||
|
7a8674f0db
|
|||
|
44b11cf3e8
|
|||
|
6a6c39ce1b
|
|||
|
30b5f48349
|
|||
|
3258ee94cf
|
|||
|
db764ede58
|
|||
|
d6bd993abd
|
|||
|
53d1fdbd21
|
|||
|
b5d0102c27
|
|||
|
55d599dd2f
|
|||
|
390c70cff1
|
|||
|
ec37403369
|
|||
|
ab67cae648
|
|||
|
0ef2477403
|
|||
|
dd6c743915
|
|||
|
46fb66ca05
|
|||
|
6289ffc01d
|
|||
|
8334b4487d
|
|||
|
d07f3f7ee0
|
@@ -11,20 +11,39 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: https://git.quad4.io/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install pnpm
|
||||
uses: https://git.quad4.io/actions/setup-pnpm@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: https://git.quad4.io/actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Frontend checks
|
||||
run: bash scripts/check.sh
|
||||
- name: Build frontend
|
||||
run: bash scripts/build.sh
|
||||
- name: Upload frontend assets
|
||||
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
|
||||
uses: https://git.quad4.io/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
|
||||
with:
|
||||
name: frontend-build
|
||||
path: build/
|
||||
@@ -34,14 +53,14 @@ jobs:
|
||||
needs: build-frontend
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Download frontend assets
|
||||
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
|
||||
uses: https://git.quad4.io/actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
|
||||
with:
|
||||
name: frontend-build
|
||||
path: build/
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
uses: https://git.quad4.io/actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||
with:
|
||||
go-version: '1.25.4'
|
||||
- name: Build backend
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: git.quad4.io
|
||||
IMAGE_NAME: quad4-software/linking-tool
|
||||
IMAGE_NAME: quad4-software/webnews
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -22,18 +22,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
uses: https://git.quad4.io/actions/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
with:
|
||||
platforms: amd64,arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: https://git.quad4.io/actions/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
uses: https://git.quad4.io/actions/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
uses: https://git.quad4.io/actions/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
uses: https://git.quad4.io/actions/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -62,3 +62,7 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
name: Publish NPM Package
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --registry=https://registry.npmjs.org/
|
||||
|
||||
- name: Package
|
||||
run: make package
|
||||
|
||||
- name: Configure npm for publishing
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://git.quad4.io/api/packages/quad4-software/npm/'
|
||||
|
||||
- name: Publish
|
||||
run: npm publish
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -14,7 +14,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: https://git.quad4.io/actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: OSV scan
|
||||
run: bash scripts/osv_scan.sh
|
||||
|
||||
@@ -14,7 +14,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: https://git.quad4.io/actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: OSV scan
|
||||
run: bash scripts/osv_scan.sh
|
||||
|
||||
@@ -11,19 +11,36 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: https://git.quad4.io/actions/setup-pnpm@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: https://git.quad4.io/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
uses: https://git.quad4.io/actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
|
||||
with:
|
||||
go-version: '1.25.4'
|
||||
|
||||
- name: Setup Java
|
||||
uses: https://git.quad4.io/actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
cache: gradle
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: https://git.quad4.io/actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||
with:
|
||||
log-accepted-android-sdk-licenses: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: bash scripts/publish_setup.sh
|
||||
|
||||
@@ -39,4 +56,3 @@ jobs:
|
||||
REPO_NAME: ${{ gitea.event.repository.name }}
|
||||
SERVER_URL: ${{ gitea.server_url }}
|
||||
run: bash scripts/publish.sh
|
||||
|
||||
|
||||
50
Dockerfile
50
Dockerfile
@@ -1,31 +1,63 @@
|
||||
# Stage 1: Build the frontend
|
||||
FROM cgr.dev/chainguard/node:latest-dev AS node-builder
|
||||
USER root
|
||||
RUN npm install -g pnpm
|
||||
USER node
|
||||
WORKDIR /app
|
||||
COPY --chown=node:node package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY --chown=node:node package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY --chown=node:node . .
|
||||
COPY --chown=node:node svelte.config.docker.js svelte.config.js
|
||||
RUN npm run build
|
||||
RUN pnpm run build
|
||||
|
||||
# Stage 2: Build the Go binary with embedded assets
|
||||
FROM cgr.dev/chainguard/go:latest-dev AS go-builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
COPY . .
|
||||
COPY --from=node-builder /app/build ./build
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o web-news main.go
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o web-news main.go
|
||||
|
||||
# Create data directory for accounts.json and hashes
|
||||
RUN mkdir -p /app/data && chown 65532:65532 /app/data
|
||||
|
||||
# Stage 3: Minimal runtime image
|
||||
FROM cgr.dev/chainguard/wolfi-base:latest
|
||||
FROM cgr.dev/chainguard/static:latest
|
||||
WORKDIR /app
|
||||
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
ARG VERSION="0.2.0"
|
||||
|
||||
LABEL org.opencontainers.image.created=$BUILD_DATE \
|
||||
org.opencontainers.image.title="Web News" \
|
||||
org.opencontainers.image.description="A modern, high-performance RSS news reader." \
|
||||
org.opencontainers.image.url="https://quad4.io" \
|
||||
org.opencontainers.image.documentation="https://github.com/Quad4-Software/webnews/blob/main/README.md" \
|
||||
org.opencontainers.image.source="https://github.com/Quad4-Software/webnews" \
|
||||
org.opencontainers.image.version=$VERSION \
|
||||
org.opencontainers.image.revision=$VCS_REF \
|
||||
org.opencontainers.image.vendor="Quad4" \
|
||||
org.opencontainers.image.licenses="MIT" \
|
||||
org.opencontainers.image.authors="Quad4" \
|
||||
org.opencontainers.image.base.name="cgr.dev/chainguard/static:latest"
|
||||
|
||||
COPY --from=go-builder /app/web-news .
|
||||
RUN apk add --no-cache ca-certificates
|
||||
COPY --from=go-builder --chown=65532:65532 /app/data ./data
|
||||
COPY LICENSE README.md ./
|
||||
|
||||
EXPOSE 8080
|
||||
ENV PORT=8080
|
||||
ENV NODE_ENV=production
|
||||
ENV AUTH_FILE=/app/data/accounts.json
|
||||
ENV HASHES_FILE=/app/data/client_hashes.json
|
||||
ENV RATE_LIMIT=100
|
||||
ENV RATE_BURST=200
|
||||
ENV CACHE_FILE=/app/data/cache.db
|
||||
ENV PUBLIC_INSTANCE=false
|
||||
|
||||
USER 65532
|
||||
|
||||
CMD ["./web-news"]
|
||||
CMD ["./web-news", "-auth-file", "/app/data/accounts.json", "-hashes-file", "/app/data/client_hashes.json", "-cache-file", "/app/data/cache.db"]
|
||||
|
||||
18
Makefile
18
Makefile
@@ -10,18 +10,18 @@ help:
|
||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
android-build: build
|
||||
npx cap sync android
|
||||
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk && cd android && ./gradlew assembleDebug
|
||||
pnpm cap sync android
|
||||
cd android && ./gradlew assembleDebug
|
||||
mkdir -p $(BUILD_DIR)/android
|
||||
cp android/app/build/outputs/apk/debug/app-debug.apk $(BUILD_DIR)/android/web-news-debug.apk
|
||||
|
||||
dev:
|
||||
npm install
|
||||
(command -v air > /dev/null && air || go run main.go & npm run dev)
|
||||
pnpm install
|
||||
(command -v air > /dev/null && air || go run main.go & pnpm run dev)
|
||||
|
||||
frontend-build:
|
||||
npm install
|
||||
npm run build
|
||||
pnpm install
|
||||
pnpm run build
|
||||
|
||||
build: frontend-build
|
||||
mkdir -p $(BUILD_DIR)
|
||||
@@ -63,7 +63,11 @@ build-freebsd-amd64:
|
||||
GOOS=freebsd GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-freebsd-amd64 main.go
|
||||
|
||||
docker-build:
|
||||
docker build -t $(BINARY_NAME) .
|
||||
docker build \
|
||||
--build-arg BUILD_DATE=$(shell date -u +'%Y-%m-%dT%H:%M:%SZ') \
|
||||
--build-arg VCS_REF=$(shell git rev-parse --short HEAD) \
|
||||
--build-arg VERSION=$(shell node -p "require('./package.json').version") \
|
||||
-t $(BINARY_NAME) .
|
||||
|
||||
docker-run:
|
||||
docker run -p 8080:8080 $(BINARY_NAME)
|
||||
|
||||
21
README.md
21
README.md
@@ -25,14 +25,27 @@ Web News follows a "zero-knowledge" philosophy:
|
||||
3. **Local Cache**: Full-text content is cached locally in IndexedDB for offline reading and instant access.
|
||||
4. **Hardened Backend**: Built-in bot blocking, rate limiting, and secure token generation.
|
||||
|
||||
## To-DO
|
||||
|
||||
- [ ] Reading time
|
||||
- [ ] UI/UX Cleanup
|
||||
- [ ] Use Go Mobile, remove Java RSS plugin.
|
||||
- [ ] Export article(s)
|
||||
- [ ] Favicon fetcher and caching
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.21+
|
||||
- Node.js 18+
|
||||
- pnpm 9+
|
||||
- [Wails CLI](https://wails.io/docs/gettingstarted/installation) (for desktop builds)
|
||||
|
||||
### Build & Run (Web Server)
|
||||
|
||||
Requires Go 1.21+, Node.js 18+, pnpm 9+.
|
||||
|
||||
1. **Build the binary**:
|
||||
```bash
|
||||
make build
|
||||
@@ -43,6 +56,9 @@ Web News follows a "zero-knowledge" philosophy:
|
||||
```
|
||||
|
||||
### Build & Run (Desktop App)
|
||||
|
||||
Requires Go 1.25.4+, Wails 2.11.0+, Node.js 18+, pnpm 9+, WebKit2GTK 4.1+ (for Linux).
|
||||
|
||||
1. **Launch Dev Mode**:
|
||||
```bash
|
||||
make desktop-dev
|
||||
@@ -60,12 +76,14 @@ Web News follows a "zero-knowledge" philosophy:
|
||||
## Configuration
|
||||
|
||||
### Server Flags
|
||||
|
||||
- `--auth-mode`: `none` (default), `token`, or `multi`.
|
||||
- `--allow-registration`: Allow generating new account numbers (default: true).
|
||||
- `--auth-file`: Path to the account storage (default: `accounts.json`).
|
||||
- `--port`: Port to listen on (default: `8080`).
|
||||
|
||||
### Keyboard Shortcuts (Default)
|
||||
|
||||
- `j` / `k`: Next / Previous article
|
||||
- `r`: Mark as read
|
||||
- `s`: Toggle save
|
||||
@@ -75,7 +93,7 @@ Web News follows a "zero-knowledge" philosophy:
|
||||
## Development
|
||||
|
||||
- **Dev Server**: `make dev` (Starts Go backend + Vite frontend)
|
||||
- **Format & Lint**: `npm run format && npm run lint`
|
||||
- **Format & Lint**: `pnpm run format && pnpm run lint`
|
||||
- **Clean Artifacts**: `make clean`
|
||||
|
||||
## License
|
||||
@@ -83,4 +101,5 @@ Web News follows a "zero-knowledge" philosophy:
|
||||
MIT - See [LICENSE](LICENSE) for details.
|
||||
|
||||
---
|
||||
|
||||
Created by [Quad4](https://quad4.io)
|
||||
|
||||
@@ -7,7 +7,7 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||
classpath 'com.android.tools.build:gradle:8.13.2'
|
||||
classpath 'com.google.gms:google-services:4.4.4'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.quad4.webnews',
|
||||
appName: 'Web News',
|
||||
webDir: 'build'
|
||||
appId: 'com.quad4.webnews',
|
||||
appName: 'Web News',
|
||||
webDir: 'build',
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -151,12 +151,12 @@ func (a *App) SaveArticles(articles string) error {
|
||||
return a.db.SaveArticles(articles)
|
||||
}
|
||||
|
||||
func (a *App) GetArticles(feedId string, offset, limit int) (string, error) {
|
||||
a.logDebug("GetArticles feedId=%s offset=%d limit=%d", feedId, offset, limit)
|
||||
func (a *App) GetArticles(feedId string, offset, limit int, categoryId string) (string, error) {
|
||||
a.logDebug("GetArticles feedId=%s categoryId=%s offset=%d limit=%d", feedId, categoryId, offset, limit)
|
||||
if a.db == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
return a.db.GetArticles(feedId, offset, limit)
|
||||
return a.db.GetArticles(feedId, offset, limit, categoryId)
|
||||
}
|
||||
|
||||
func (a *App) SearchArticles(query string, limit int) (string, error) {
|
||||
|
||||
@@ -3,7 +3,7 @@ module git.quad4.io/Quad4-Software/webnews/desktop
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
git.quad4.io/Quad4-Software/webnews v0.0.0
|
||||
git.quad4.io/Quad4-Software/webnews v0.1.0
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
)
|
||||
|
||||
@@ -43,6 +43,7 @@ require (
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
{
|
||||
"name": "Web News",
|
||||
"assetdir": "frontend_dist",
|
||||
"frontend:dir": "..",
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build",
|
||||
"frontend:dev:watcher": "npm run dev",
|
||||
"frontend:dev:serverUrl": "http://localhost:5173",
|
||||
"outputfilename": "web-news",
|
||||
"author": {
|
||||
"name": "Quad4",
|
||||
"email": "dev@quad4.io"
|
||||
}
|
||||
"name": "Web News",
|
||||
"assetdir": "frontend_dist",
|
||||
"frontend:dir": "..",
|
||||
"frontend:install": "pnpm install",
|
||||
"frontend:build": "pnpm run build",
|
||||
"frontend:dev:watcher": "pnpm run dev",
|
||||
"frontend:dev:serverUrl": "http://localhost:5173",
|
||||
"outputfilename": "web-news",
|
||||
"author": {
|
||||
"name": "Quad4",
|
||||
"email": "dev@quad4.io"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
31
docker-compose.coolify.yml
Normal file
31
docker-compose.coolify.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
services:
|
||||
web-news:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: ${DOCKER_IMAGE:-web-news:latest}
|
||||
environment:
|
||||
- PORT=${PORT:-8080}
|
||||
- NODE_ENV=production
|
||||
- AUTH_MODE=${AUTH_MODE:-multi}
|
||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true}
|
||||
# Coolify automatically populates SERVICE_URL_WEB_NEWS with the domain
|
||||
- ALLOWED_ORIGINS=${SERVICE_URL_WEB_NEWS:-*}
|
||||
- AUTH_FILE=/app/data/accounts.json
|
||||
- HASHES_FILE=/app/data/client_hashes.json
|
||||
- RATE_LIMIT=${RATE_LIMIT:-100}
|
||||
- RATE_BURST=${RATE_BURST:-200}
|
||||
- CACHE_FILE=/app/data/cache.db
|
||||
- PUBLIC_INSTANCE=${PUBLIC_INSTANCE:-true}
|
||||
volumes:
|
||||
- web-news-data:/app/data
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.middlewares.web-news-ratelimit.ratelimit.average=100'
|
||||
- 'traefik.http.middlewares.web-news-ratelimit.ratelimit.burst=50'
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
web-news-data:
|
||||
23
docker-compose.prod.yml
Normal file
23
docker-compose.prod.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
# Add your own reverse proxy
|
||||
|
||||
services:
|
||||
web-news:
|
||||
build: .
|
||||
container_name: web-news-prod
|
||||
volumes:
|
||||
- data:/app/data
|
||||
environment:
|
||||
- PORT=8080
|
||||
- NODE_ENV=production
|
||||
- AUTH_MODE=multi
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
||||
- AUTH_FILE=/app/data/accounts.json
|
||||
- HASHES_FILE=/app/data/client_hashes.json
|
||||
- CACHE_FILE=/app/data/cache.db
|
||||
- PUBLIC_INSTANCE=${PUBLIC_INSTANCE:-false}
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
data:
|
||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
web-news:
|
||||
build: .
|
||||
container_name: web-news
|
||||
ports:
|
||||
- '8080:8080'
|
||||
volumes:
|
||||
- data:/app/data
|
||||
environment:
|
||||
- PORT=8080
|
||||
- NODE_ENV=production
|
||||
- AUTH_MODE=none
|
||||
- AUTH_TOKEN=${AUTH_TOKEN:-}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
||||
- AUTH_FILE=/app/data/accounts.json
|
||||
- HASHES_FILE=/app/data/client_hashes.json
|
||||
- CACHE_FILE=/app/data/cache.db
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
data:
|
||||
@@ -52,6 +52,7 @@ export default [
|
||||
FileReader: 'readonly',
|
||||
performance: 'readonly',
|
||||
AbortController: 'readonly',
|
||||
AbortSignal: 'readonly',
|
||||
DOMParser: 'readonly',
|
||||
Element: 'readonly',
|
||||
Node: 'readonly',
|
||||
|
||||
1
go.mod
1
go.mod
@@ -23,6 +23,7 @@ require (
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
@@ -16,7 +17,9 @@ import (
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Go-Libs/RSS"
|
||||
"git.quad4.io/Quad4-Software/webnews/internal/storage"
|
||||
readability "github.com/go-shiori/go-readability"
|
||||
"golang.org/x/sync/singleflight"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
@@ -45,6 +48,71 @@ type Article struct {
|
||||
ImageURL string `json:"imageUrl"`
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
data any
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type Cache struct {
|
||||
entries sync.Map
|
||||
TTL time.Duration
|
||||
Enabled bool
|
||||
Storage *storage.SQLiteDB
|
||||
}
|
||||
|
||||
func (c *Cache) Get(key string) (any, bool) {
|
||||
if !c.Enabled {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if c.Storage != nil {
|
||||
data, err := c.Storage.GetCache(key)
|
||||
if err != nil || data == nil {
|
||||
return nil, false
|
||||
}
|
||||
var val any
|
||||
if err := json.Unmarshal(data, &val); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return val, true
|
||||
}
|
||||
|
||||
val, ok := c.entries.Load(key)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
entry := val.(cacheEntry)
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
c.entries.Delete(key)
|
||||
return nil, false
|
||||
}
|
||||
return entry.data, true
|
||||
}
|
||||
|
||||
func (c *Cache) Set(key string, data any) {
|
||||
if !c.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
if c.Storage != nil {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = c.Storage.SetCache(key, b, c.TTL)
|
||||
return
|
||||
}
|
||||
|
||||
c.entries.Store(key, cacheEntry{
|
||||
data: data,
|
||||
expiresAt: time.Now().Add(c.TTL),
|
||||
})
|
||||
}
|
||||
|
||||
var FeedCache = &Cache{TTL: 10 * time.Minute, Enabled: false}
|
||||
var FullTextCache = &Cache{TTL: 1 * time.Hour, Enabled: false}
|
||||
var RequestGroup = &singleflight.Group{}
|
||||
|
||||
type RateLimiter struct {
|
||||
clients map[string]*rate.Limiter
|
||||
mu *sync.RWMutex
|
||||
@@ -94,8 +162,14 @@ func (rl *RateLimiter) SaveHashes() {
|
||||
}
|
||||
rl.mu.RUnlock()
|
||||
|
||||
data, _ := json.MarshalIndent(hashes, "", " ")
|
||||
os.WriteFile(rl.File, data, 0600)
|
||||
data, err := json.MarshalIndent(hashes, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("Error marshaling rate limit hashes: %v", err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(rl.File, data, 0600); err != nil {
|
||||
log.Printf("Error writing rate limit hashes to %s: %v", rl.File, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) GetLimiter(id string) *rate.Limiter {
|
||||
@@ -116,7 +190,16 @@ func (rl *RateLimiter) GetLimiter(id string) *rate.Limiter {
|
||||
return limiter
|
||||
}
|
||||
|
||||
var Limiter = NewRateLimiter(rate.Every(time.Second), 5, "")
|
||||
func (rl *RateLimiter) SetLimit(r rate.Limit, b int) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
rl.r = r
|
||||
rl.b = b
|
||||
// Reset existing limiters to apply new rate
|
||||
rl.clients = make(map[string]*rate.Limiter)
|
||||
}
|
||||
|
||||
var Limiter = NewRateLimiter(rate.Limit(50), 100, "")
|
||||
|
||||
var ForbiddenPatterns = []string{
|
||||
".git", ".env", ".aws", ".config", ".ssh",
|
||||
@@ -124,14 +207,34 @@ var ForbiddenPatterns = []string{
|
||||
"etc/passwd", "cgi-bin",
|
||||
}
|
||||
|
||||
func GetRealIP(r *http.Request) string {
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
if comma := strings.IndexByte(xff, ','); comma != -1 {
|
||||
return strings.TrimSpace(xff[:comma])
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
}
|
||||
|
||||
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||
return strings.TrimSpace(xri)
|
||||
}
|
||||
|
||||
return ip
|
||||
}
|
||||
|
||||
func BotBlockerMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.ToLower(r.URL.Path)
|
||||
query := strings.ToLower(r.URL.RawQuery)
|
||||
|
||||
for _, pattern := range ForbiddenPatterns {
|
||||
if strings.Contains(path, pattern) || strings.Contains(query, pattern) {
|
||||
log.Printf("Blocked suspicious request: %s from %s", r.URL.String(), r.RemoteAddr)
|
||||
if strings.Contains(path, pattern) {
|
||||
ip := GetRealIP(r)
|
||||
log.Printf("Blocked suspicious request: %s from %s", r.URL.String(), ip)
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -270,18 +373,7 @@ func AuthMiddleware(am *AuthManager, next http.HandlerFunc) http.HandlerFunc {
|
||||
|
||||
func LimitMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
if comma := strings.IndexByte(xff, ','); comma != -1 {
|
||||
ip = xff[:comma]
|
||||
} else {
|
||||
ip = xff
|
||||
}
|
||||
}
|
||||
ip := GetRealIP(r)
|
||||
|
||||
ua := r.Header.Get("User-Agent")
|
||||
hash := sha256.New()
|
||||
@@ -310,94 +402,111 @@ func HandleFeedProxy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequest("GET", feedURL, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create request: "+err.Error(), http.StatusInternalServerError)
|
||||
if data, ok := FeedCache.Get(feedURL); ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||
log.Printf("Error encoding cached feed proxy response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Add browser-like headers to avoid being blocked by Cloudflare/Bot protection
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
|
||||
req.Header.Set("Accept", "application/rss+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.7")
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
req.Header.Set("Pragma", "no-cache")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to fetch feed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
http.Error(w, "Feed returned status "+resp.Status, http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read feed body", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
parsedFeed, err := rss.Parse(data)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to parse feed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
articles := make([]Article, 0, len(parsedFeed.Items))
|
||||
for _, item := range parsedFeed.Items {
|
||||
id := item.GUID
|
||||
if id == "" {
|
||||
id = item.Link
|
||||
val, err, _ := RequestGroup.Do(feedURL, func() (any, error) {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequest("GET", feedURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
pubDate := time.Now().UnixMilli()
|
||||
if item.Published != nil {
|
||||
pubDate = item.Published.UnixMilli()
|
||||
// Add browser-like headers to avoid being blocked by Cloudflare/Bot protection
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
|
||||
req.Header.Set("Accept", "application/rss+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.7")
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
req.Header.Set("Pragma", "no-cache")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch feed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("feed returned status %s", resp.Status)
|
||||
}
|
||||
|
||||
author := ""
|
||||
if item.Author != nil {
|
||||
author = item.Author.Name
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read feed body: %w", err)
|
||||
}
|
||||
|
||||
imageURL := ""
|
||||
for _, enc := range item.Enclosures {
|
||||
if enc.Type == "image/jpeg" || enc.Type == "image/png" || enc.Type == "image/gif" {
|
||||
imageURL = enc.URL
|
||||
break
|
||||
parsedFeed, err := rss.Parse(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse feed: %w", err)
|
||||
}
|
||||
|
||||
articles := make([]Article, 0, len(parsedFeed.Items))
|
||||
for _, item := range parsedFeed.Items {
|
||||
id := item.GUID
|
||||
if id == "" {
|
||||
id = item.Link
|
||||
}
|
||||
|
||||
pubDate := time.Now().UnixMilli()
|
||||
if item.Published != nil {
|
||||
pubDate = item.Published.UnixMilli()
|
||||
}
|
||||
|
||||
author := ""
|
||||
if item.Author != nil {
|
||||
author = item.Author.Name
|
||||
}
|
||||
|
||||
imageURL := ""
|
||||
for _, enc := range item.Enclosures {
|
||||
if enc.Type == "image/jpeg" || enc.Type == "image/png" || enc.Type == "image/gif" {
|
||||
imageURL = enc.URL
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
articles = append(articles, Article{
|
||||
ID: id,
|
||||
FeedID: feedURL,
|
||||
Title: item.Title,
|
||||
Link: item.Link,
|
||||
Description: item.Description,
|
||||
Author: author,
|
||||
PubDate: pubDate,
|
||||
Read: false,
|
||||
Saved: false,
|
||||
ImageURL: imageURL,
|
||||
})
|
||||
}
|
||||
|
||||
articles = append(articles, Article{
|
||||
ID: id,
|
||||
FeedID: feedURL,
|
||||
Title: item.Title,
|
||||
Link: item.Link,
|
||||
Description: item.Description,
|
||||
Author: author,
|
||||
PubDate: pubDate,
|
||||
Read: false,
|
||||
Saved: false,
|
||||
ImageURL: imageURL,
|
||||
})
|
||||
}
|
||||
response := ProxyResponse{
|
||||
Feed: FeedInfo{
|
||||
Title: parsedFeed.Title,
|
||||
SiteURL: parsedFeed.Link,
|
||||
Description: parsedFeed.Description,
|
||||
LastFetched: time.Now().UnixMilli(),
|
||||
},
|
||||
Articles: articles,
|
||||
}
|
||||
|
||||
response := ProxyResponse{
|
||||
Feed: FeedInfo{
|
||||
Title: parsedFeed.Title,
|
||||
SiteURL: parsedFeed.Link,
|
||||
Description: parsedFeed.Description,
|
||||
LastFetched: time.Now().UnixMilli(),
|
||||
},
|
||||
Articles: articles,
|
||||
FeedCache.Set(feedURL, response)
|
||||
return response, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "status") {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
} else {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(val); err != nil {
|
||||
log.Printf("Error encoding feed proxy response: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -467,46 +576,61 @@ func HandleFullText(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
parsedURL, _ := url.Parse(targetURL)
|
||||
article, err := readability.FromURL(targetURL, 15*time.Second)
|
||||
if err != nil {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create request: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to fetch content: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
article, err = readability.FromReader(resp.Body, parsedURL)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to extract content: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
if data, ok := FullTextCache.Get(targetURL); ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||
log.Printf("Error encoding cached fulltext response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
response := FullTextResponse{
|
||||
Title: article.Title,
|
||||
Content: article.Content,
|
||||
TextContent: article.TextContent,
|
||||
Excerpt: article.Excerpt,
|
||||
Byline: article.Byline,
|
||||
SiteName: article.SiteName,
|
||||
Image: article.Image,
|
||||
Favicon: article.Favicon,
|
||||
URL: targetURL,
|
||||
val, err, _ := RequestGroup.Do("ft-"+targetURL, func() (any, error) {
|
||||
parsedURL, _ := url.Parse(targetURL)
|
||||
article, err := readability.FromURL(targetURL, 15*time.Second)
|
||||
if err != nil {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch content: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
article, err = readability.FromReader(resp.Body, parsedURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract content: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
response := FullTextResponse{
|
||||
Title: article.Title,
|
||||
Content: article.Content,
|
||||
TextContent: article.TextContent,
|
||||
Excerpt: article.Excerpt,
|
||||
Byline: article.Byline,
|
||||
SiteName: article.SiteName,
|
||||
Image: article.Image,
|
||||
Favicon: article.Favicon,
|
||||
URL: targetURL,
|
||||
}
|
||||
|
||||
FullTextCache.Set(targetURL, response)
|
||||
return response, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(val); err != nil {
|
||||
log.Printf("Error encoding fulltext response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,12 @@ func (s *SQLiteDB) init() error {
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS caches (
|
||||
key TEXT PRIMARY KEY,
|
||||
value BLOB,
|
||||
expiresAt INTEGER
|
||||
);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_caches_expiresAt ON caches(expiresAt);`,
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
@@ -316,15 +322,22 @@ func (s *SQLiteDB) SaveArticles(articlesJSON string) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetArticles(feedId string, offset, limit int) (string, error) {
|
||||
func (s *SQLiteDB) GetArticles(feedId string, offset, limit int, categoryId string) (string, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if feedId != "" {
|
||||
rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?", feedId, limit, offset)
|
||||
} else if categoryId != "" {
|
||||
rows, err = s.db.Query(`
|
||||
SELECT a.id, a.feedId, a.title, a.link, a.description, a.content, a.author, a.pubDate, a.read, a.saved, a.imageUrl, a.readAt
|
||||
FROM articles a
|
||||
JOIN feeds f ON a.feedId = f.id
|
||||
WHERE f.categoryId = ?
|
||||
ORDER BY a.pubDate DESC LIMIT ? OFFSET ?`, categoryId, limit, offset)
|
||||
} else {
|
||||
rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?", limit, offset)
|
||||
}
|
||||
|
||||
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
@@ -371,7 +384,7 @@ func (s *SQLiteDB) SearchArticles(query string, limit int) (string, error) {
|
||||
FROM articles
|
||||
WHERE title LIKE ? OR description LIKE ? OR content LIKE ?
|
||||
ORDER BY pubDate DESC LIMIT ?`, q, q, q, limit)
|
||||
|
||||
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
@@ -482,7 +495,7 @@ func (s *SQLiteDB) ClearAll() error {
|
||||
func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
|
||||
cutoff := time.Now().AddDate(0, 0, -days).UnixMilli()
|
||||
rows, err := s.db.Query(`
|
||||
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch')) as date, COUNT(*) as count
|
||||
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch', 'localtime')) as date, COUNT(*) as count
|
||||
FROM articles
|
||||
WHERE read = 1 AND readAt > ?
|
||||
GROUP BY date
|
||||
@@ -499,8 +512,11 @@ func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
|
||||
if err := rows.Scan(&date, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
// Convert date string back to timestamp for frontend
|
||||
t, _ := time.Parse("2006-01-02", date)
|
||||
// Convert local date string back to local midnight timestamp for frontend
|
||||
t, err := time.ParseInLocation("2006-01-02", date, time.Local)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
history = append(history, map[string]any{
|
||||
"date": t.UnixMilli(),
|
||||
"count": count,
|
||||
@@ -510,3 +526,32 @@ func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
|
||||
b, _ := json.Marshal(history)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) SetCache(key string, value []byte, ttl time.Duration) error {
|
||||
expiresAt := time.Now().Add(ttl).UnixMilli()
|
||||
_, err := s.db.Exec("INSERT OR REPLACE INTO caches (key, value, expiresAt) VALUES (?, ?, ?)", key, value, expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetCache(key string) ([]byte, error) {
|
||||
var value []byte
|
||||
var expiresAt int64
|
||||
err := s.db.QueryRow("SELECT value, expiresAt FROM caches WHERE key = ?", key).Scan(&value, &expiresAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if time.Now().UnixMilli() > expiresAt {
|
||||
_, _ = s.db.Exec("DELETE FROM caches WHERE key = ?", key)
|
||||
return nil, nil
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) PurgeExpiredCaches() error {
|
||||
now := time.Now().UnixMilli()
|
||||
_, err := s.db.Exec("DELETE FROM caches WHERE expiresAt < ?", now)
|
||||
return err
|
||||
}
|
||||
|
||||
99
main.go
99
main.go
@@ -4,6 +4,7 @@ import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
@@ -13,6 +14,8 @@ import (
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Quad4-Software/webnews/internal/api"
|
||||
"git.quad4.io/Quad4-Software/webnews/internal/storage"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
//go:embed build/*
|
||||
@@ -72,15 +75,101 @@ func main() {
|
||||
allowedOriginsStr := flag.String("allowed-origins", os.Getenv("ALLOWED_ORIGINS"), "Comma-separated list of allowed CORS origins")
|
||||
|
||||
// Auth flags
|
||||
authMode := flag.String("auth-mode", "none", "Authentication mode: none, token, multi")
|
||||
defaultAuthMode := os.Getenv("AUTH_MODE")
|
||||
if defaultAuthMode == "" {
|
||||
defaultAuthMode = "none"
|
||||
}
|
||||
authMode := flag.String("auth-mode", defaultAuthMode, "Authentication mode: none, token, multi")
|
||||
|
||||
authToken := flag.String("auth-token", os.Getenv("AUTH_TOKEN"), "Master token for 'token' auth mode")
|
||||
authFile := flag.String("auth-file", "accounts.json", "File to store accounts for 'multi' auth mode")
|
||||
allowReg := flag.Bool("allow-registration", true, "Allow new account generation in 'multi' mode")
|
||||
hashesFile := flag.String("hashes-file", "client_hashes.json", "File to store IP+UA hashes for rate limiting")
|
||||
disableProtection := flag.Bool("disable-protection", false, "Disable rate limiting and bot protection")
|
||||
|
||||
defaultAuthFile := os.Getenv("AUTH_FILE")
|
||||
if defaultAuthFile == "" {
|
||||
defaultAuthFile = "accounts.json"
|
||||
}
|
||||
authFile := flag.String("auth-file", defaultAuthFile, "File to store accounts for 'multi' auth mode")
|
||||
|
||||
defaultAllowReg := true
|
||||
if os.Getenv("ALLOW_REGISTRATION") == "false" {
|
||||
defaultAllowReg = false
|
||||
}
|
||||
allowReg := flag.Bool("allow-registration", defaultAllowReg, "Allow new account generation in 'multi' mode")
|
||||
|
||||
defaultHashesFile := os.Getenv("HASHES_FILE")
|
||||
if defaultHashesFile == "" {
|
||||
defaultHashesFile = "client_hashes.json"
|
||||
}
|
||||
hashesFile := flag.String("hashes-file", defaultHashesFile, "File to store IP+UA hashes for rate limiting")
|
||||
|
||||
rateLimit := flag.Float64("rate-limit", 50.0, "Rate limit in requests per second (env: RATE_LIMIT)")
|
||||
rateBurst := flag.Int("rate-burst", 100, "Rate limit burst size (env: RATE_BURST)")
|
||||
|
||||
disableProtection := flag.Bool("disable-protection", os.Getenv("DISABLE_PROTECTION") == "true", "Disable rate limiting and bot protection")
|
||||
|
||||
publicInstance := flag.Bool("public-instance", os.Getenv("PUBLIC_INSTANCE") == "true", "Enable optimizations for public instances (caching, etc.)")
|
||||
cacheEnabled := flag.Bool("cache-enabled", os.Getenv("CACHE_ENABLED") == "true", "Explicitly enable/disable caching")
|
||||
cacheTTL := flag.Duration("cache-ttl", 10*time.Minute, "Cache TTL (env: CACHE_TTL)")
|
||||
cacheFile := flag.String("cache-file", os.Getenv("CACHE_FILE"), "SQLite file for caching (reduces memory load)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// Handle cache config
|
||||
if envTTL := os.Getenv("CACHE_TTL"); envTTL != "" {
|
||||
if d, err := time.ParseDuration(envTTL); err == nil {
|
||||
*cacheTTL = d
|
||||
}
|
||||
}
|
||||
|
||||
api.FeedCache.TTL = *cacheTTL
|
||||
api.FullTextCache.TTL = *cacheTTL * 6 // Full text stays longer
|
||||
|
||||
if *cacheFile != "" {
|
||||
db, err := storage.NewSQLiteDB(*cacheFile)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize cache database: %v", err)
|
||||
}
|
||||
api.FeedCache.Storage = db
|
||||
api.FullTextCache.Storage = db
|
||||
log.Printf("Using SQLite for caching: %s\n", *cacheFile)
|
||||
|
||||
// Background cleanup of expired items
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(1 * time.Hour)
|
||||
if err := db.PurgeExpiredCaches(); err != nil {
|
||||
log.Printf("Error purging expired caches: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if *publicInstance {
|
||||
api.FeedCache.Enabled = true
|
||||
api.FullTextCache.Enabled = true
|
||||
log.Printf("Public instance optimizations enabled (caching enabled, TTL: %v)\n", *cacheTTL)
|
||||
}
|
||||
if os.Getenv("CACHE_ENABLED") != "" {
|
||||
api.FeedCache.Enabled = *cacheEnabled
|
||||
api.FullTextCache.Enabled = *cacheEnabled
|
||||
log.Printf("Caching explicitly %v (TTL: %v)\n", map[bool]string{true: "enabled", false: "disabled"}[*cacheEnabled], *cacheTTL)
|
||||
}
|
||||
|
||||
// Override rate limits from environment if set
|
||||
if envRate := os.Getenv("RATE_LIMIT"); envRate != "" {
|
||||
var r float64
|
||||
if _, err := fmt.Sscanf(envRate, "%f", &r); err == nil {
|
||||
*rateLimit = r
|
||||
}
|
||||
}
|
||||
if envBurst := os.Getenv("RATE_BURST"); envBurst != "" {
|
||||
var b int
|
||||
if _, err := fmt.Sscanf(envBurst, "%d", &b); err == nil {
|
||||
*rateBurst = b
|
||||
}
|
||||
}
|
||||
|
||||
api.Limiter.SetLimit(rate.Limit(*rateLimit), *rateBurst)
|
||||
|
||||
if *hashesFile != "" {
|
||||
api.Limiter.File = *hashesFile
|
||||
api.Limiter.LoadHashes()
|
||||
|
||||
5462
package-lock.json
generated
5462
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web-news",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
"bin": {
|
||||
@@ -26,14 +26,14 @@
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint .",
|
||||
"package": "npm run build"
|
||||
"package": "pnpm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor-community/sqlite": "^7.0.2",
|
||||
"@capacitor/cli": "^8.0.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
"@sveltejs/kit": "^2.49.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@typescript-eslint/eslint-plugin": "^8.50.1",
|
||||
@@ -42,11 +42,11 @@
|
||||
"eslint-plugin-svelte": "^3.13.1",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"svelte": "^5.45.6",
|
||||
"svelte-check": "^4.3.4",
|
||||
"svelte": "^5.46.1",
|
||||
"svelte-check": "^4.3.5",
|
||||
"svelte-eslint-parser": "^1.4.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
"vite": "^7.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.0.0",
|
||||
@@ -55,5 +55,8 @@
|
||||
"lucide-svelte": "^0.562.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19"
|
||||
},
|
||||
"overrides": {
|
||||
"cookie": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
3419
pnpm-lock.yaml
generated
Normal file
3419
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
renovate.json
Normal file
3
renovate.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
set -euo pipefail
|
||||
|
||||
echo "Building app..."
|
||||
VITE_APP_VERSION=$(node -p "require('./package.json').version") npm run build
|
||||
VITE_APP_VERSION=$(node -p "require('./package.json').version") pnpm run build
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
set -euo pipefail
|
||||
|
||||
echo "Running Svelte sync..."
|
||||
npx svelte-kit sync
|
||||
pnpm svelte-kit sync
|
||||
|
||||
echo "Running svelte-check (fail on errors)..."
|
||||
npx svelte-check --tsconfig ./tsconfig.json
|
||||
pnpm svelte-check --tsconfig ./tsconfig.json
|
||||
|
||||
|
||||
@@ -21,4 +21,3 @@ swContent = swContent.replace(
|
||||
|
||||
writeFileSync(swPath, swContent);
|
||||
console.log(`Injected version ${version} into service worker`);
|
||||
|
||||
|
||||
@@ -23,20 +23,16 @@ VULNS=$(jq -r '
|
||||
.results[]? |
|
||||
.source as $src |
|
||||
.vulns[]? |
|
||||
select(
|
||||
(.database_specific.severity // "" | ascii_upcase | test("HIGH|CRITICAL")) or
|
||||
(.severity[]?.score // "" | tostring | split("/")[0] | tonumber? // 0 | . >= 7.0)
|
||||
) |
|
||||
"\(.id) (source: \($src))"
|
||||
' "$OSV_JSON")
|
||||
|
||||
if [ -n "$VULNS" ]; then
|
||||
echo "OSV scan found HIGH/CRITICAL vulnerabilities:"
|
||||
echo "OSV scan found vulnerabilities:"
|
||||
echo "$VULNS" | while IFS= read -r line; do
|
||||
echo " - $line"
|
||||
done
|
||||
exit 1
|
||||
else
|
||||
echo "OSV scan: no HIGH/CRITICAL vulnerabilities found."
|
||||
echo "OSV scan: no vulnerabilities found."
|
||||
fi
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ elif [ -f "desktop/build/bin/web-news" ]; then
|
||||
cp desktop/build/bin/web-news dist/web-news-desktop-darwin
|
||||
fi
|
||||
|
||||
echo "Building Android APK..."
|
||||
make android-build
|
||||
cp bin/android/web-news-debug.apk dist/web-news-android-debug.apk
|
||||
|
||||
echo "Generating SHA256 hashes..."
|
||||
cd dist
|
||||
sha256sum * > SHA256SUMS
|
||||
|
||||
@@ -6,7 +6,8 @@ sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev gcc-mingw-w64 zip
|
||||
|
||||
echo "Installing project dependencies..."
|
||||
npm ci
|
||||
npm install -g pnpm
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
echo "Installing Wails CLI..."
|
||||
go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
.card {
|
||||
@apply bg-bg-primary border border-border-color rounded-lg overflow-hidden transition-all hover:shadow-md;
|
||||
}
|
||||
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-accent-blue text-white px-4 py-2 rounded-md font-medium hover:bg-accent-blue-dark transition-colors;
|
||||
}
|
||||
|
||||
28
src/app.html
28
src/app.html
@@ -3,13 +3,39 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Web News - Personal RSS Reader</title>
|
||||
<meta name="description" content="A fast, clean, and private RSS reader for all your news." />
|
||||
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="shortcut icon" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://webnews.quad4.io" />
|
||||
<meta property="og:title" content="Web News" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A fast, clean, and private RSS reader for all your news."
|
||||
/>
|
||||
<meta property="og:image" content="/favicon.svg" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://webnews.quad4.io" />
|
||||
<meta property="twitter:title" content="Web News" />
|
||||
<meta
|
||||
property="twitter:description"
|
||||
content="A fast, clean, and private RSS reader for all your news."
|
||||
/>
|
||||
<meta property="twitter:image" content="/favicon.svg" />
|
||||
|
||||
<meta name="theme-color" content="#1a73e8" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Web News" />
|
||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { db } from '$lib/db';
|
||||
import { fetchFeed } from '$lib/rss';
|
||||
import { parseOPML } from '$lib/opml';
|
||||
import { newsStore } from '$lib/store.svelte';
|
||||
import { X, Loader2 } from 'lucide-svelte';
|
||||
import { X, Loader2, Upload } from 'lucide-svelte';
|
||||
import { toast } from '$lib/toast.svelte';
|
||||
|
||||
let { onOpenChange } = $props();
|
||||
let feedUrl = $state('');
|
||||
@@ -26,7 +28,7 @@
|
||||
lastFetched: Date.now(),
|
||||
fetchInterval: 30,
|
||||
enabled: true,
|
||||
consecutiveErrors: 0
|
||||
consecutiveErrors: 0,
|
||||
});
|
||||
await db.saveArticles(articles);
|
||||
await newsStore.refresh();
|
||||
@@ -37,29 +39,70 @@
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const text = await file.text();
|
||||
const { feeds, categories } = parseOPML(text);
|
||||
|
||||
if (categories.length > 0) {
|
||||
await db.saveCategories(categories as any);
|
||||
}
|
||||
if (feeds.length > 0) {
|
||||
await db.saveFeeds(feeds as any);
|
||||
}
|
||||
|
||||
toast.success(`Imported ${feeds.length} feeds`);
|
||||
await newsStore.init();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
console.error('Import failed:', err);
|
||||
error = 'Failed to import OPML';
|
||||
} finally {
|
||||
loading = false;
|
||||
target.value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
<button
|
||||
class="absolute inset-0 bg-black/60 backdrop-blur-sm w-full h-full border-none cursor-default"
|
||||
<button
|
||||
class="absolute inset-0 bg-black/60 backdrop-blur-sm w-full h-full border-none cursor-default"
|
||||
onclick={() => onOpenChange(false)}
|
||||
aria-label="Close modal"
|
||||
></button>
|
||||
|
||||
<div class="bg-bg-primary border border-border-color rounded-2xl shadow-2xl w-full max-w-md relative overflow-hidden z-10">
|
||||
|
||||
<div
|
||||
class="bg-bg-primary border border-border-color rounded-2xl shadow-2xl w-full max-w-md relative overflow-hidden z-10"
|
||||
>
|
||||
<div class="p-6 border-b border-border-color flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold">Add RSS Feed</h2>
|
||||
<button class="text-text-secondary hover:text-text-primary" onclick={() => onOpenChange(false)}>
|
||||
<button
|
||||
class="text-text-secondary hover:text-text-primary"
|
||||
onclick={() => onOpenChange(false)}
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="p-6 space-y-4" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||||
<form
|
||||
class="p-6 space-y-4"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<label for="url" class="text-sm font-medium text-text-secondary">Feed URL</label>
|
||||
<input
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
type="url"
|
||||
bind:value={feedUrl}
|
||||
placeholder="https://example.com/rss.xml"
|
||||
class="w-full bg-bg-secondary border border-border-color rounded-xl px-4 py-2.5 outline-none focus:ring-2 focus:ring-accent-blue/20 transition-all"
|
||||
@@ -69,7 +112,7 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="category" class="text-sm font-medium text-text-secondary">Category</label>
|
||||
<select
|
||||
<select
|
||||
id="category"
|
||||
bind:value={categoryId}
|
||||
class="w-full bg-bg-secondary border border-border-color rounded-xl px-4 py-2.5 outline-none focus:ring-2 focus:ring-accent-blue/20 transition-all text-sm"
|
||||
@@ -84,8 +127,8 @@
|
||||
<p class="text-red-500 text-sm">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full btn-primary flex items-center justify-center gap-2 py-3"
|
||||
disabled={loading}
|
||||
>
|
||||
@@ -96,7 +139,30 @@
|
||||
Add Feed
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<div class="relative py-2">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-border-color"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs uppercase">
|
||||
<span class="bg-bg-primary px-2 text-text-secondary">Or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="w-full flex items-center justify-center gap-2 py-3 bg-bg-secondary border border-border-color rounded-xl text-sm font-semibold text-text-primary hover:bg-bg-primary transition-all cursor-pointer {loading
|
||||
? 'opacity-50 pointer-events-none'
|
||||
: ''}"
|
||||
>
|
||||
{#if loading}
|
||||
<Loader2 size={18} class="animate-spin" />
|
||||
Importing...
|
||||
{:else}
|
||||
<Upload size={18} />
|
||||
Import OPML File
|
||||
{/if}
|
||||
<input type="file" accept=".opml,.xml" class="hidden" onchange={handleImport} />
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Bookmark, Share2, MoreVertical, Check, FileText, Loader2 } from 'lucide-svelte';
|
||||
|
||||
let { article }: { article: Article } = $props();
|
||||
const feed = $derived(newsStore.feeds.find((f) => f.id === article.feedId));
|
||||
let copied = $state(false);
|
||||
let loadingFullText = $state(false);
|
||||
|
||||
@@ -12,27 +13,29 @@
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function getSource(feedId: string) {
|
||||
const feed = newsStore.feeds.find(f => f.id === feedId);
|
||||
return feed?.title || new URL(feedId).hostname;
|
||||
function getSourceTitle() {
|
||||
return (
|
||||
feed?.title ||
|
||||
(article.feedId.startsWith('http') ? new URL(article.feedId).hostname : article.feedId)
|
||||
);
|
||||
}
|
||||
|
||||
async function shareArticle(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
const encodedUrl = btoa(article.link);
|
||||
const shareUrl = `${window.location.origin}/share?url=${encodedUrl}`;
|
||||
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
copied = true;
|
||||
toast.success('Share link copied to clipboard');
|
||||
setTimeout(() => copied = false, 2000);
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy share link:', err);
|
||||
toast.error('Failed to copy share link');
|
||||
@@ -71,26 +74,56 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="card group relative flex flex-col sm:flex-row gap-4 transition-all hover:shadow-md {article.read ? 'opacity-60' : ''} {newsStore.readingArticle?.url === article.link ? 'ring-2 ring-accent-blue shadow-lg bg-accent-blue/5' : ''}">
|
||||
<article
|
||||
class="card group relative flex flex-col sm:flex-row gap-4 transition-all hover:shadow-md {article.read
|
||||
? 'opacity-60'
|
||||
: ''} {newsStore.readingArticle?.url === article.link
|
||||
? 'ring-2 ring-accent-blue shadow-lg bg-accent-blue/5'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Background Flavor Layer -->
|
||||
{#if article.imageUrl || feed?.icon}
|
||||
<div class="absolute inset-0 z-0 overflow-hidden rounded-2xl pointer-events-none">
|
||||
{#if article.imageUrl && article.imageUrl !== feed?.icon}
|
||||
<div
|
||||
class="absolute right-0 top-0 bottom-0 w-full sm:w-3/4 overflow-hidden"
|
||||
style="mask-image: linear-gradient(to left, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%); -webkit-mask-image: linear-gradient(to left, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%);"
|
||||
>
|
||||
<img
|
||||
src={article.imageUrl}
|
||||
alt=""
|
||||
class="w-full h-full object-cover opacity-[0.22] dark:opacity-[0.10] group-hover:opacity-[0.32] dark:group-hover:opacity-[0.18] transition-all duration-700 group-hover:scale-110 origin-right"
|
||||
/>
|
||||
</div>
|
||||
{:else if feed?.icon}
|
||||
<div
|
||||
class="absolute inset-0 opacity-0 group-hover:opacity-[0.08] dark:group-hover:opacity-[0.05] transition-opacity duration-500"
|
||||
>
|
||||
<img src={feed.icon} alt="" class="w-full h-full object-cover blur-3xl scale-150" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if newsStore.isSelectMode}
|
||||
<div class="flex items-center pl-4 z-20">
|
||||
<div class="relative w-5 h-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer appearance-none w-5 h-5 rounded border-2 border-border-color checked:bg-accent-blue checked:border-accent-blue transition-all cursor-pointer"
|
||||
checked={newsStore.selectedArticleIds.has(article.id)}
|
||||
onchange={handleToggleSelect}
|
||||
/>
|
||||
<Check
|
||||
size={14}
|
||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white opacity-0 peer-checked:opacity-100 pointer-events-none transition-opacity"
|
||||
<Check
|
||||
size={14}
|
||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white opacity-0 peer-checked:opacity-100 pointer-events-none transition-opacity"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="absolute inset-0 w-full h-full text-left cursor-pointer z-0"
|
||||
<button
|
||||
class="absolute inset-0 w-full h-full text-left cursor-pointer z-0"
|
||||
onclick={() => {
|
||||
if (newsStore.isSelectMode) {
|
||||
const isSelected = newsStore.selectedArticleIds.has(article.id);
|
||||
@@ -106,21 +139,28 @@
|
||||
|
||||
<div class="flex-1 min-w-0 p-4 relative z-10 pointer-events-none">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-semibold text-accent-blue hover:underline pointer-events-auto">{getSource(article.feedId)}</span>
|
||||
{#if feed?.icon}
|
||||
<img src={feed.icon} alt="" class="w-4 h-4 rounded-sm flex-shrink-0" />
|
||||
{/if}
|
||||
<span class="text-xs font-semibold text-accent-blue hover:underline pointer-events-auto"
|
||||
>{getSourceTitle()}</span
|
||||
>
|
||||
<span class="text-text-secondary text-xs">•</span>
|
||||
<span class="text-text-secondary text-xs">{formatDate(article.pubDate)}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<h3 class="text-lg font-bold leading-snug mb-2 group-hover:text-accent-blue transition-colors">
|
||||
{article.title}
|
||||
</h3>
|
||||
|
||||
|
||||
<p class="text-text-secondary text-sm line-clamp-2 mb-4">
|
||||
{article.description}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-4 text-text-secondary opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto">
|
||||
<button
|
||||
|
||||
<div
|
||||
class="flex items-center gap-4 text-text-secondary opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-3 py-1 rounded-full hover:bg-bg-secondary transition-colors text-xs font-semibold hover:text-accent-blue"
|
||||
onclick={fetchFullText}
|
||||
disabled={loadingFullText}
|
||||
@@ -132,15 +172,17 @@
|
||||
{/if}
|
||||
Read
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-full hover:bg-bg-secondary transition-colors {article.saved ? 'text-accent-blue' : 'hover:text-text-primary'}"
|
||||
<button
|
||||
class="p-1.5 rounded-full hover:bg-bg-secondary transition-colors {article.saved
|
||||
? 'text-accent-blue'
|
||||
: 'hover:text-text-primary'}"
|
||||
title={article.saved ? 'Remove from saved' : 'Save for later'}
|
||||
onclick={toggleSave}
|
||||
>
|
||||
<Bookmark size={18} fill={article.saved ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
<button
|
||||
class="hover:text-text-primary p-1.5 rounded-full hover:bg-bg-secondary transition-colors flex items-center gap-1"
|
||||
<button
|
||||
class="hover:text-text-primary p-1.5 rounded-full hover:bg-bg-secondary transition-colors flex items-center gap-1"
|
||||
title="Copy share link"
|
||||
onclick={shareArticle}
|
||||
>
|
||||
@@ -150,19 +192,16 @@
|
||||
<Share2 size={18} />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="hover:text-text-primary p-1.5 rounded-full hover:bg-bg-secondary transition-colors"
|
||||
<button
|
||||
class="hover:text-text-primary p-1.5 rounded-full hover:bg-bg-secondary transition-colors"
|
||||
title="Open in new tab"
|
||||
onclick={(e) => { e.stopPropagation(); window.open(article.link, '_blank'); }}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(article.link, '_blank');
|
||||
}}
|
||||
>
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if article.imageUrl}
|
||||
<div class="w-full sm:w-32 h-48 sm:h-32 flex-shrink-0 sm:m-4 rounded-xl overflow-hidden bg-bg-secondary border border-border-color relative z-10 pointer-events-none">
|
||||
<img src={article.imageUrl} alt="" class="w-full h-full object-cover transition-transform group-hover:scale-105" />
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
@@ -5,17 +5,35 @@
|
||||
let { onAddFeed } = $props();
|
||||
</script>
|
||||
|
||||
<header class="sticky top-0 z-50 bg-bg-primary/80 backdrop-blur-md border-b border-border-color px-4 py-2 flex justify-between items-center h-[calc(64px+env(safe-area-inset-top,0px))] pt-safe">
|
||||
<header
|
||||
class="sticky top-0 z-50 bg-bg-primary/80 backdrop-blur-md border-b border-border-color px-4 py-2 flex justify-between items-center h-[calc(64px+env(safe-area-inset-top,0px))] pt-safe"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="md:hidden p-2 hover:bg-bg-secondary rounded-full text-text-secondary"
|
||||
<button
|
||||
class="md:hidden p-2 hover:bg-bg-secondary rounded-full text-text-secondary"
|
||||
aria-label="Menu"
|
||||
onclick={() => newsStore.showSidebar = !newsStore.showSidebar}
|
||||
onclick={() => (newsStore.showSidebar = !newsStore.showSidebar)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line
|
||||
x1="3"
|
||||
y1="18"
|
||||
x2="21"
|
||||
y2="18"
|
||||
></line></svg
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent-blue/20 rounded-lg px-1"
|
||||
<button
|
||||
class="flex items-center gap-1.5 cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent-blue/20 rounded-lg px-1"
|
||||
onclick={() => {
|
||||
newsStore.selectFeed(null);
|
||||
newsStore.currentView = 'all';
|
||||
@@ -24,7 +42,22 @@
|
||||
aria-label="Web News Home"
|
||||
>
|
||||
<div class="w-8 h-8 bg-accent-blue rounded-lg flex items-center justify-center text-white">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle cx="5" cy="19" r="1"></circle></svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"></path><circle
|
||||
cx="5"
|
||||
cy="19"
|
||||
r="1"
|
||||
></circle></svg
|
||||
>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold tracking-tight hidden sm:block">Web News</h1>
|
||||
</button>
|
||||
@@ -33,9 +66,9 @@
|
||||
<div class="flex-1 max-w-2xl mx-4 hidden sm:block">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for topics, locations & sources"
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for topics, locations & sources"
|
||||
class="w-full bg-bg-secondary border-none rounded-xl py-2.5 pl-10 pr-4 focus:ring-2 focus:ring-accent-blue/20 outline-none transition-all"
|
||||
bind:value={newsStore.searchQuery}
|
||||
oninput={() => newsStore.loadArticles()}
|
||||
@@ -45,32 +78,42 @@
|
||||
|
||||
<div class="flex items-center gap-1 sm:gap-2">
|
||||
{#if newsStore.ping !== null && !newsStore.isWails && !newsStore.isCapacitor}
|
||||
<div class="hidden md:flex items-center gap-1.5 px-3 py-1.5 bg-bg-secondary rounded-full border border-border-color">
|
||||
<div class="w-1.5 h-1.5 rounded-full {newsStore.ping < 200 ? 'bg-green-500' : newsStore.ping < 500 ? 'bg-yellow-500' : 'bg-red-500'}"></div>
|
||||
<div
|
||||
class="hidden md:flex items-center gap-1.5 px-3 py-1.5 bg-bg-secondary rounded-full border border-border-color"
|
||||
>
|
||||
<div
|
||||
class="w-1.5 h-1.5 rounded-full {newsStore.ping < 200
|
||||
? 'bg-green-500'
|
||||
: newsStore.ping < 500
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'}"
|
||||
></div>
|
||||
<span class="text-[10px] font-medium text-text-secondary">{newsStore.ping}ms</span>
|
||||
</div>
|
||||
{:else if !newsStore.isOnline}
|
||||
<div class="hidden md:flex items-center gap-1.5 px-3 py-1.5 bg-red-500/10 rounded-full border border-red-500/20">
|
||||
<div
|
||||
class="hidden md:flex items-center gap-1.5 px-3 py-1.5 bg-red-500/10 rounded-full border border-red-500/20"
|
||||
>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse"></div>
|
||||
<span class="text-[10px] font-medium text-red-500">Offline</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
<button
|
||||
class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors"
|
||||
onclick={() => newsStore.refresh()}
|
||||
title="Refresh feeds"
|
||||
>
|
||||
<RefreshCw size={20} class={newsStore.loading ? 'animate-spin text-accent-blue' : ''} />
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors"
|
||||
onclick={onAddFeed}
|
||||
title="Add RSS Feed"
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
class="p-2 hover:bg-bg-secondary rounded-full text-text-secondary transition-colors"
|
||||
onclick={() => newsStore.toggleTheme()}
|
||||
title="Toggle theme"
|
||||
@@ -83,4 +126,3 @@
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -2,17 +2,37 @@
|
||||
import { newsStore } from '$lib/store.svelte';
|
||||
import { db } from '$lib/db';
|
||||
import { exportToOPML, parseOPML } from '$lib/opml';
|
||||
import { Home, Star, Bookmark, Hash, Settings as SettingsIcon, ChevronRight, ChevronDown, AlertCircle, Edit2, GripVertical, Plus, Trash2, Save, X, Download, Upload, GitBranch } from 'lucide-svelte';
|
||||
import {
|
||||
Home,
|
||||
Star,
|
||||
Bookmark,
|
||||
Hash,
|
||||
Settings as SettingsIcon,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
AlertCircle,
|
||||
Edit2,
|
||||
GripVertical,
|
||||
Plus,
|
||||
Trash2,
|
||||
Save,
|
||||
X,
|
||||
Download,
|
||||
Upload,
|
||||
GitBranch,
|
||||
RefreshCw,
|
||||
} from 'lucide-svelte';
|
||||
import { toast } from '$lib/toast.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { APP_VERSION } from '$lib/version';
|
||||
|
||||
let { onOpenSettings } = $props();
|
||||
|
||||
|
||||
let expandedCategories = $state<Record<string, boolean>>({});
|
||||
|
||||
$effect(() => {
|
||||
// Expand new categories by default
|
||||
newsStore.categories.forEach(cat => {
|
||||
newsStore.categories.forEach((cat) => {
|
||||
if (expandedCategories[cat.id] === undefined) {
|
||||
expandedCategories[cat.id] = true;
|
||||
}
|
||||
@@ -38,7 +58,7 @@
|
||||
|
||||
function getFeedsForCategory(categoryId: string) {
|
||||
return newsStore.feeds
|
||||
.filter(f => f.categoryId === categoryId)
|
||||
.filter((f) => f.categoryId === categoryId)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
@@ -66,34 +86,34 @@
|
||||
|
||||
if (draggedCategoryId && targetType === 'category') {
|
||||
const cats = [...newsStore.categories].sort((a, b) => a.order - b.order);
|
||||
const fromIndex = cats.findIndex(c => c.id === draggedCategoryId);
|
||||
const toIndex = cats.findIndex(c => c.id === targetId);
|
||||
|
||||
const fromIndex = cats.findIndex((c) => c.id === draggedCategoryId);
|
||||
const toIndex = cats.findIndex((c) => c.id === targetId);
|
||||
|
||||
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
||||
const [moved] = cats.splice(fromIndex, 1);
|
||||
cats.splice(toIndex, 0, moved);
|
||||
await newsStore.reorderCategories(cats.map(c => c.id));
|
||||
await newsStore.reorderCategories(cats.map((c) => c.id));
|
||||
}
|
||||
} else if (draggedFeedId && targetType === 'feed') {
|
||||
const sourceFeed = newsStore.feeds.find(f => f.id === draggedFeedId);
|
||||
const targetFeed = newsStore.feeds.find(f => f.id === targetId);
|
||||
|
||||
const sourceFeed = newsStore.feeds.find((f) => f.id === draggedFeedId);
|
||||
const targetFeed = newsStore.feeds.find((f) => f.id === targetId);
|
||||
|
||||
if (sourceFeed && targetFeed && sourceFeed.categoryId === targetFeed.categoryId) {
|
||||
const catFeeds = newsStore.feeds
|
||||
.filter(f => f.categoryId === sourceFeed.categoryId)
|
||||
.filter((f) => f.categoryId === sourceFeed.categoryId)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
const fromIndex = catFeeds.findIndex(f => f.id === draggedFeedId);
|
||||
const toIndex = catFeeds.findIndex(f => f.id === targetId);
|
||||
|
||||
|
||||
const fromIndex = catFeeds.findIndex((f) => f.id === draggedFeedId);
|
||||
const toIndex = catFeeds.findIndex((f) => f.id === targetId);
|
||||
|
||||
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
||||
const [moved] = catFeeds.splice(fromIndex, 1);
|
||||
catFeeds.splice(toIndex, 0, moved);
|
||||
await newsStore.reorderFeeds(catFeeds.map(f => f.id));
|
||||
await newsStore.reorderFeeds(catFeeds.map((f) => f.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
draggedCategoryId = null;
|
||||
draggedFeedId = null;
|
||||
}
|
||||
@@ -111,7 +131,7 @@
|
||||
|
||||
async function saveCategory() {
|
||||
if (!editingCategoryId) return;
|
||||
const cat = newsStore.categories.find(c => c.id === editingCategoryId);
|
||||
const cat = newsStore.categories.find((c) => c.id === editingCategoryId);
|
||||
if (cat) {
|
||||
await newsStore.updateCategory({ ...cat, name: editingCategoryName });
|
||||
}
|
||||
@@ -126,13 +146,16 @@
|
||||
|
||||
async function saveFeed() {
|
||||
if (!editingFeedId) return;
|
||||
const feed = newsStore.feeds.find(f => f.id === editingFeedId);
|
||||
const feed = newsStore.feeds.find((f) => f.id === editingFeedId);
|
||||
if (feed) {
|
||||
await newsStore.updateFeed({
|
||||
...feed,
|
||||
title: editingFeedTitle,
|
||||
id: editingFeedUrl
|
||||
}, editingFeedId);
|
||||
await newsStore.updateFeed(
|
||||
{
|
||||
...feed,
|
||||
title: editingFeedTitle,
|
||||
id: editingFeedUrl,
|
||||
},
|
||||
editingFeedId
|
||||
);
|
||||
}
|
||||
editingFeedId = null;
|
||||
}
|
||||
@@ -145,14 +168,14 @@
|
||||
try {
|
||||
const text = await file.text();
|
||||
const { feeds, categories } = parseOPML(text);
|
||||
|
||||
|
||||
if (categories.length > 0) {
|
||||
await db.saveCategories(categories as any);
|
||||
}
|
||||
if (feeds.length > 0) {
|
||||
await db.saveFeeds(feeds as any);
|
||||
}
|
||||
|
||||
|
||||
toast.success(`Imported ${feeds.length} feeds`);
|
||||
await newsStore.init();
|
||||
} catch (err) {
|
||||
@@ -183,234 +206,344 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="w-64 flex-shrink-0 bg-bg-primary border-r border-border-color z-40 transition-transform duration-300 {newsStore.showSidebar ? 'translate-x-0' : '-translate-x-full md:translate-x-0'} fixed md:static top-0 left-0 h-full"
|
||||
<aside
|
||||
class="w-64 flex-shrink-0 bg-bg-primary border-r border-border-color z-40 transition-transform duration-300 {newsStore.showSidebar
|
||||
? 'translate-x-0'
|
||||
: '-translate-x-full md:translate-x-0'} fixed md:static top-0 left-0 h-full"
|
||||
>
|
||||
<div class="flex flex-col gap-6 py-6 overflow-y-auto h-full pt-[calc(64px+env(safe-area-inset-top,0px))] md:pt-0 scroll-container">
|
||||
<nav class="flex flex-col gap-1 px-2">
|
||||
<button
|
||||
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView === 'all' && newsStore.selectedFeedId === null ? 'bg-accent-blue/10 text-accent-blue font-semibold' : 'text-text-primary hover:bg-bg-secondary'}"
|
||||
onclick={() => { newsStore.selectView('all'); newsStore.readingArticle = null; newsStore.showSidebar = false; }}
|
||||
>
|
||||
<Home size={20} />
|
||||
<span>Top stories</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView === 'following' ? 'bg-accent-blue/10 text-accent-blue font-semibold' : 'text-text-primary hover:bg-bg-secondary'}"
|
||||
onclick={() => { newsStore.selectView('following'); newsStore.readingArticle = null; newsStore.showSidebar = false; }}
|
||||
>
|
||||
<Star size={20} />
|
||||
<span>Following</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView === 'saved' ? 'bg-accent-blue/10 text-accent-blue font-semibold' : 'text-text-primary hover:bg-bg-secondary'}"
|
||||
onclick={() => { newsStore.selectView('saved'); newsStore.readingArticle = null; newsStore.showSidebar = false; }}
|
||||
>
|
||||
<Bookmark size={20} />
|
||||
<span>Saved stories</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="px-6 py-2">
|
||||
<div class="h-px bg-border-color w-full"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 px-2 overflow-y-auto">
|
||||
<div class="flex items-center justify-between px-4 mb-4">
|
||||
<h3 class="text-xs font-semibold text-text-secondary uppercase tracking-wider">Subscriptions</h3>
|
||||
<button
|
||||
class="text-text-secondary hover:text-accent-blue transition-colors p-1 {isManageMode ? 'text-accent-blue' : ''}"
|
||||
onclick={() => isManageMode = !isManageMode}
|
||||
title="Manage feeds"
|
||||
<div
|
||||
class="flex flex-col gap-2 pt-6 pb-2 overflow-y-auto h-full pt-[calc(64px+env(safe-area-inset-top,0px))] md:pt-0 scroll-container"
|
||||
>
|
||||
<nav class="flex flex-col gap-1 px-2">
|
||||
<button
|
||||
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView ===
|
||||
'all' && newsStore.selectedFeedId === null
|
||||
? 'bg-accent-blue/10 text-accent-blue font-semibold'
|
||||
: 'text-text-primary hover:bg-bg-secondary'}"
|
||||
onclick={() => {
|
||||
newsStore.selectView('all');
|
||||
newsStore.readingArticle = null;
|
||||
newsStore.showSidebar = false;
|
||||
}}
|
||||
>
|
||||
{#if isManageMode}
|
||||
<X size={14} />
|
||||
{:else}
|
||||
<Edit2 size={14} />
|
||||
{/if}
|
||||
<Home size={20} />
|
||||
<span>Top stories</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView ===
|
||||
'following'
|
||||
? 'bg-accent-blue/10 text-accent-blue font-semibold'
|
||||
: 'text-text-primary hover:bg-bg-secondary'}"
|
||||
onclick={() => {
|
||||
newsStore.selectView('following');
|
||||
newsStore.readingArticle = null;
|
||||
newsStore.showSidebar = false;
|
||||
}}
|
||||
>
|
||||
<Star size={20} />
|
||||
<span>Following</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-3 px-4 py-2.5 rounded-xl transition-colors {newsStore.currentView ===
|
||||
'saved'
|
||||
? 'bg-accent-blue/10 text-accent-blue font-semibold'
|
||||
: 'text-text-primary hover:bg-bg-secondary'}"
|
||||
onclick={() => {
|
||||
newsStore.selectView('saved');
|
||||
newsStore.readingArticle = null;
|
||||
newsStore.showSidebar = false;
|
||||
}}
|
||||
>
|
||||
<Bookmark size={20} />
|
||||
<span>Saved stories</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="px-6 py-2">
|
||||
<div class="h-px bg-border-color w-full"></div>
|
||||
</div>
|
||||
|
||||
{#if isManageMode}
|
||||
<div class="px-4 mb-4 space-y-3">
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newCategoryName}
|
||||
placeholder="Add category..."
|
||||
class="flex-1 bg-bg-secondary border border-border-color rounded-lg px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-accent-blue/30"
|
||||
onkeydown={(e) => e.key === 'Enter' && addCategory()}
|
||||
/>
|
||||
<button class="p-1 bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors" onclick={addCategory}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<label class="flex-1 flex items-center justify-center gap-2 py-1.5 bg-bg-secondary border border-border-color rounded-lg text-[10px] font-medium text-text-secondary hover:bg-bg-primary transition-colors cursor-pointer">
|
||||
<Upload size={12} />
|
||||
Import
|
||||
<input type="file" accept=".opml,.xml" class="hidden" onchange={handleImport} />
|
||||
</label>
|
||||
<button
|
||||
class="flex-1 flex items-center justify-center gap-2 py-1.5 bg-bg-secondary border border-border-color rounded-lg text-[10px] font-medium text-text-secondary hover:bg-bg-primary transition-colors"
|
||||
onclick={handleExport}
|
||||
>
|
||||
<Download size={12} />
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 px-2 overflow-y-auto">
|
||||
<div class="flex items-center justify-between px-4 mb-4">
|
||||
<h3 class="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Subscriptions
|
||||
</h3>
|
||||
<button
|
||||
class="text-text-secondary hover:text-accent-blue transition-colors p-1 {isManageMode
|
||||
? 'text-accent-blue'
|
||||
: ''}"
|
||||
onclick={() => (isManageMode = !isManageMode)}
|
||||
title="Manage feeds"
|
||||
>
|
||||
{#if isManageMode}
|
||||
<X size={14} />
|
||||
{:else}
|
||||
<Edit2 size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-1" role="list">
|
||||
{#each [...newsStore.categories].sort((a, b) => a.order - b.order) as cat (cat.id)}
|
||||
{@const catFeeds = getFeedsForCategory(cat.id)}
|
||||
{#if catFeeds.length > 0 || isManageMode}
|
||||
<div
|
||||
class="space-y-1 rounded-xl transition-all {dragOverId === cat.id ? 'ring-2 ring-accent-blue/50 bg-accent-blue/5' : ''}"
|
||||
draggable={isManageMode}
|
||||
role="listitem"
|
||||
ondragstart={(e) => handleDragStart(e, 'category', cat.id)}
|
||||
ondragover={(e) => handleDragOver(e, cat.id)}
|
||||
ondrop={(e) => handleDrop(e, 'category', cat.id)}
|
||||
>
|
||||
<div class="flex items-center group">
|
||||
{#if isManageMode}
|
||||
<div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2">
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editingCategoryId === cat.id}
|
||||
<div class="flex-1 flex items-center gap-1 px-2 py-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingCategoryName}
|
||||
class="flex-1 bg-bg-primary border border-border-color rounded px-2 py-0.5 text-sm outline-none"
|
||||
onkeydown={(e) => e.key === 'Enter' && saveCategory()}
|
||||
/>
|
||||
<button class="text-green-500" onclick={saveCategory}><Save size={14} /></button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="flex-1 flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium text-text-secondary hover:bg-bg-secondary transition-colors text-left min-w-0"
|
||||
onclick={() => toggleCategory(cat.id)}
|
||||
title={cat.name}
|
||||
>
|
||||
{#if expandedCategories[cat.id]}
|
||||
<ChevronDown size={16} />
|
||||
{:else}
|
||||
<ChevronRight size={16} />
|
||||
{/if}
|
||||
<span class="truncate">{cat.name}</span>
|
||||
<span class="ml-auto text-[10px] bg-bg-secondary px-1.5 py-0.5 rounded-full">{catFeeds.length}</span>
|
||||
</button>
|
||||
|
||||
{#if isManageMode}
|
||||
<div class="px-4 mb-4 space-y-3">
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newCategoryName}
|
||||
placeholder="Add category..."
|
||||
class="flex-1 bg-bg-secondary border border-border-color rounded-lg px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-accent-blue/30"
|
||||
onkeydown={(e) => e.key === 'Enter' && addCategory()}
|
||||
/>
|
||||
<button
|
||||
class="p-1 bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors"
|
||||
onclick={addCategory}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<label
|
||||
class="flex-1 flex items-center justify-center gap-2 py-1.5 bg-bg-secondary border border-border-color rounded-lg text-[10px] font-medium text-text-secondary hover:bg-bg-primary transition-colors cursor-pointer"
|
||||
>
|
||||
<Upload size={12} />
|
||||
Import
|
||||
<input type="file" accept=".opml,.xml" class="hidden" onchange={handleImport} />
|
||||
</label>
|
||||
<button
|
||||
class="flex-1 flex items-center justify-center gap-2 py-1.5 bg-bg-secondary border border-border-color rounded-lg text-[10px] font-medium text-text-secondary hover:bg-bg-primary transition-colors"
|
||||
onclick={handleExport}
|
||||
>
|
||||
<Download size={12} />
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-1" role="list">
|
||||
{#each [...newsStore.categories].sort((a, b) => a.order - b.order) as cat (cat.id)}
|
||||
{@const catFeeds = getFeedsForCategory(cat.id)}
|
||||
{#if catFeeds.length > 0 || isManageMode}
|
||||
<div
|
||||
class="space-y-1 rounded-xl transition-all {dragOverId === cat.id
|
||||
? 'ring-2 ring-accent-blue/50 bg-accent-blue/5'
|
||||
: ''}"
|
||||
draggable={isManageMode}
|
||||
role="listitem"
|
||||
ondragstart={(e) => handleDragStart(e, 'category', cat.id)}
|
||||
ondragover={(e) => handleDragOver(e, cat.id)}
|
||||
ondrop={(e) => handleDrop(e, 'category', cat.id)}
|
||||
>
|
||||
<div class="flex items-center group">
|
||||
{#if isManageMode}
|
||||
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pr-2">
|
||||
<button class="p-1 text-text-secondary hover:text-accent-blue" onclick={() => startEditCategory(cat)}><Edit2 size={12} /></button>
|
||||
<button class="p-1 text-text-secondary hover:text-red-500" onclick={() => newsStore.deleteCategory(cat.id)}><Trash2 size={12} /></button>
|
||||
<div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2">
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if expandedCategories[cat.id]}
|
||||
<div class="pl-4 space-y-0.5" transition:slide={{ duration: 200 }} role="list">
|
||||
{#each catFeeds as feed (feed.id)}
|
||||
<div
|
||||
class="flex items-center group rounded-xl transition-all {dragOverId === feed.id ? 'ring-2 ring-accent-blue/50 bg-accent-blue/5' : ''}"
|
||||
draggable={isManageMode}
|
||||
role="listitem"
|
||||
ondragstart={(e) => handleDragStart(e, 'feed', feed.id)}
|
||||
ondragover={(e) => handleDragOver(e, feed.id)}
|
||||
ondrop={(e) => handleDrop(e, 'feed', feed.id)}
|
||||
{#if editingCategoryId === cat.id}
|
||||
<div class="flex-1 flex items-center gap-1 px-2 py-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingCategoryName}
|
||||
class="flex-1 bg-bg-primary border border-border-color rounded px-2 py-0.5 text-sm outline-none"
|
||||
onkeydown={(e) => e.key === 'Enter' && saveCategory()}
|
||||
/>
|
||||
<button class="text-green-500" onclick={saveCategory}><Save size={14} /></button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="flex-1 flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors text-left min-w-0 {newsStore.selectedCategoryId ===
|
||||
cat.id
|
||||
? 'bg-accent-blue/10 text-accent-blue font-semibold'
|
||||
: 'text-text-secondary hover:bg-bg-secondary'}"
|
||||
onclick={() => {
|
||||
newsStore.selectCategory(cat.id);
|
||||
toggleCategory(cat.id);
|
||||
newsStore.readingArticle = null;
|
||||
if (!isManageMode) newsStore.showSidebar = false;
|
||||
}}
|
||||
title={cat.name}
|
||||
>
|
||||
{#if isManageMode}
|
||||
<div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2">
|
||||
<GripVertical size={12} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editingFeedId === feed.id}
|
||||
<div class="flex-1 flex flex-col gap-1 p-2 bg-bg-secondary/50 rounded-xl">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingFeedTitle}
|
||||
placeholder="Feed title"
|
||||
class="w-full bg-bg-primary border border-border-color rounded px-2 py-1 text-xs outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingFeedUrl}
|
||||
placeholder="Feed URL"
|
||||
class="w-full bg-bg-primary border border-border-color rounded px-2 py-1 text-[10px] outline-none"
|
||||
/>
|
||||
<div class="flex justify-end gap-1 mt-1">
|
||||
<button class="p-1 text-red-500 hover:bg-red-500/10 rounded" onclick={() => editingFeedId = null}><X size={14} /></button>
|
||||
<button class="p-1 text-green-500 hover:bg-green-500/10 rounded" onclick={saveFeed}><Save size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{#if expandedCategories[cat.id]}
|
||||
<ChevronDown size={16} />
|
||||
{:else}
|
||||
<button
|
||||
class="flex-1 flex items-center gap-3 px-4 py-2 rounded-xl text-sm transition-colors {newsStore.selectedFeedId === feed.id ? 'bg-accent-blue/10 text-accent-blue font-semibold' : 'text-text-secondary hover:bg-bg-secondary'} text-left min-w-0"
|
||||
onclick={() => { newsStore.selectFeed(feed.id); newsStore.readingArticle = null; newsStore.showSidebar = false; }}
|
||||
title={feed.title}
|
||||
>
|
||||
{#if feed.error}
|
||||
<AlertCircle size={16} class="text-red-500 flex-shrink-0" />
|
||||
{:else if feed.icon}
|
||||
<img src={feed.icon} alt="" class="w-4 h-4 rounded-sm flex-shrink-0" />
|
||||
{:else}
|
||||
<Hash size={16} class="flex-shrink-0" />
|
||||
{/if}
|
||||
<span class="truncate {feed.error ? 'text-red-500' : ''}">{feed.title}</span>
|
||||
</button>
|
||||
<ChevronRight size={16} />
|
||||
{/if}
|
||||
<span class="truncate">{cat.name}</span>
|
||||
<span class="ml-auto text-[10px] bg-bg-secondary px-1.5 py-0.5 rounded-full"
|
||||
>{catFeeds.length}</span
|
||||
>
|
||||
</button>
|
||||
|
||||
{#if isManageMode}
|
||||
<div
|
||||
class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pr-2"
|
||||
>
|
||||
<button
|
||||
class="p-1 text-text-secondary hover:text-accent-blue"
|
||||
onclick={() => startEditCategory(cat)}><Edit2 size={12} /></button
|
||||
>
|
||||
<button
|
||||
class="p-1 text-text-secondary hover:text-red-500"
|
||||
onclick={() => newsStore.deleteCategory(cat.id)}
|
||||
><Trash2 size={12} /></button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if expandedCategories[cat.id]}
|
||||
<div class="pl-4 space-y-0.5" transition:slide={{ duration: 200 }} role="list">
|
||||
{#each catFeeds as feed (feed.id)}
|
||||
<div
|
||||
class="flex items-center group rounded-xl transition-all {dragOverId ===
|
||||
feed.id
|
||||
? 'ring-2 ring-accent-blue/50 bg-accent-blue/5'
|
||||
: ''}"
|
||||
draggable={isManageMode}
|
||||
role="listitem"
|
||||
ondragstart={(e) => handleDragStart(e, 'feed', feed.id)}
|
||||
ondragover={(e) => handleDragOver(e, feed.id)}
|
||||
ondrop={(e) => handleDrop(e, 'feed', feed.id)}
|
||||
>
|
||||
{#if isManageMode}
|
||||
<div class="opacity-0 group-hover:opacity-100 transition-opacity pr-2 flex items-center gap-0.5">
|
||||
<button class="p-1 text-text-secondary hover:text-accent-blue" onclick={() => startEditFeed(feed)} title="Edit feed"><Edit2 size={12} /></button>
|
||||
<button class="p-1 text-text-secondary hover:text-red-500" onclick={() => newsStore.deleteFeed(feed.id)} title="Delete feed"><Trash2 size={12} /></button>
|
||||
<div class="text-text-secondary/30 cursor-grab active:cursor-grabbing pl-2">
|
||||
<GripVertical size={12} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if editingFeedId === feed.id}
|
||||
<div class="flex-1 flex flex-col gap-1 p-2 bg-bg-secondary/50 rounded-xl">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingFeedTitle}
|
||||
placeholder="Feed title"
|
||||
class="w-full bg-bg-primary border border-border-color rounded px-2 py-1 text-xs outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingFeedUrl}
|
||||
placeholder="Feed URL"
|
||||
class="w-full bg-bg-primary border border-border-color rounded px-2 py-1 text-[10px] outline-none"
|
||||
/>
|
||||
<div class="flex justify-end gap-1 mt-1">
|
||||
<button
|
||||
class="p-1 text-red-500 hover:bg-red-500/10 rounded"
|
||||
onclick={() => (editingFeedId = null)}><X size={14} /></button
|
||||
>
|
||||
<button
|
||||
class="p-1 text-green-500 hover:bg-green-500/10 rounded"
|
||||
onclick={saveFeed}><Save size={14} /></button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="flex-1 flex items-center gap-3 px-4 py-2 rounded-xl text-sm transition-colors {newsStore.selectedFeedId ===
|
||||
feed.id
|
||||
? 'bg-accent-blue/10 text-accent-blue font-semibold'
|
||||
: 'text-text-secondary hover:bg-bg-secondary'} text-left min-w-0"
|
||||
onclick={() => {
|
||||
newsStore.selectFeed(feed.id);
|
||||
newsStore.readingArticle = null;
|
||||
newsStore.showSidebar = false;
|
||||
}}
|
||||
title={feed.title}
|
||||
>
|
||||
{#if feed.error}
|
||||
<AlertCircle size={16} class="text-red-500 flex-shrink-0" />
|
||||
{:else if feed.icon}
|
||||
<img src={feed.icon} alt="" class="w-4 h-4 rounded-sm flex-shrink-0" />
|
||||
{:else}
|
||||
<Hash size={16} class="flex-shrink-0" />
|
||||
{/if}
|
||||
<span class="truncate {feed.error ? 'text-red-500' : ''}"
|
||||
>{feed.title}</span
|
||||
>
|
||||
</button>
|
||||
|
||||
{#if feed.error && !isManageMode}
|
||||
<div
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity pr-2 flex items-center gap-0.5"
|
||||
>
|
||||
<button
|
||||
class="p-1 text-text-secondary hover:text-accent-blue"
|
||||
onclick={() => newsStore.refreshFeed(feed.id)}
|
||||
title="Retry fetching feed"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-text-secondary hover:text-red-500"
|
||||
onclick={() => newsStore.deleteFeed(feed.id)}
|
||||
title="Remove feed"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isManageMode}
|
||||
<div
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity pr-2 flex items-center gap-0.5"
|
||||
>
|
||||
<button
|
||||
class="p-1 text-text-secondary hover:text-accent-blue"
|
||||
onclick={() => startEditFeed(feed)}
|
||||
title="Edit feed"><Edit2 size={12} /></button
|
||||
>
|
||||
<button
|
||||
class="p-1 text-text-secondary hover:text-red-500"
|
||||
onclick={() => newsStore.deleteFeed(feed.id)}
|
||||
title="Delete feed"><Trash2 size={12} /></button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if newsStore.feeds.length === 0}
|
||||
<p class="px-4 text-xs text-text-secondary italic">No feeds added yet</p>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if newsStore.feeds.length === 0}
|
||||
<p class="px-4 text-xs text-text-secondary italic">No feeds added yet</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center pb-24 md:pb-6 space-y-4">
|
||||
<div class="flex flex-col items-center space-y-1">
|
||||
<a
|
||||
href="https://git.quad4.io/Quad4-Software/webnews"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-1.5 text-[11px] text-text-secondary hover:text-accent-blue transition-colors font-medium"
|
||||
<div class="flex flex-col items-center pb-20 md:pb-4 space-y-2">
|
||||
<button
|
||||
class="flex items-center justify-center gap-3 px-4 py-2 rounded-xl text-text-secondary hover:bg-bg-secondary transition-colors w-full max-w-[200px]"
|
||||
onclick={onOpenSettings}
|
||||
>
|
||||
<GitBranch size={13} />
|
||||
<span>v0.1.0</span>
|
||||
</a>
|
||||
<p class="text-[11px] text-text-secondary font-medium">
|
||||
Created by <a href="https://quad4.io" target="_blank" rel="noopener noreferrer" class="hover:text-accent-blue transition-colors">Quad4</a>
|
||||
</p>
|
||||
</div>
|
||||
<SettingsIcon size={18} />
|
||||
<span class="font-medium text-sm">Settings</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex items-center justify-center gap-3 px-4 py-2 rounded-xl text-text-secondary hover:bg-bg-secondary transition-colors w-full max-w-[200px]"
|
||||
onclick={onOpenSettings}
|
||||
>
|
||||
<SettingsIcon size={18} />
|
||||
<span class="font-medium text-sm">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<a
|
||||
href="https://git.quad4.io/Quad4-Software/webnews"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-1.5 text-[11px] text-text-secondary hover:text-accent-blue transition-colors font-medium"
|
||||
>
|
||||
<GitBranch size={13} />
|
||||
<span>v{APP_VERSION}</span>
|
||||
</a>
|
||||
<p class="text-[11px] text-text-secondary font-medium">
|
||||
Created by <a
|
||||
href="https://quad4.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-accent-blue transition-colors">Quad4</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -7,27 +7,31 @@
|
||||
info: Info,
|
||||
success: CheckCircle,
|
||||
error: AlertCircle,
|
||||
warning: AlertTriangle
|
||||
warning: AlertTriangle,
|
||||
};
|
||||
|
||||
const colors = {
|
||||
info: 'text-blue-500 bg-bg-secondary/95 border-blue-500/30',
|
||||
success: 'text-green-500 bg-bg-secondary/95 border-green-500/30',
|
||||
error: 'text-red-500 bg-bg-secondary/95 border-red-500/30',
|
||||
warning: 'text-yellow-500 bg-bg-secondary/95 border-yellow-500/30'
|
||||
warning: 'text-yellow-500 bg-bg-secondary/95 border-yellow-500/30',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-[200] flex flex-col gap-2 w-full max-w-sm px-4">
|
||||
<div
|
||||
class="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-[200] flex flex-col gap-2 w-full max-w-sm px-4"
|
||||
>
|
||||
{#each toast.toasts as t (t.id)}
|
||||
<div
|
||||
class="flex items-center gap-3 p-4 rounded-2xl border backdrop-blur-xl shadow-2xl {colors[t.type]}"
|
||||
<div
|
||||
class="flex items-center gap-3 p-4 rounded-2xl border backdrop-blur-xl shadow-2xl {colors[
|
||||
t.type
|
||||
]}"
|
||||
in:fly={{ y: 20, duration: 300 }}
|
||||
out:fade={{ duration: 200 }}
|
||||
>
|
||||
<svelte:component this={icons[t.type]} size={20} class="flex-shrink-0" />
|
||||
<p class="text-sm font-medium flex-1 leading-snug">{t.message}</p>
|
||||
<button
|
||||
<button
|
||||
class="text-text-secondary hover:text-text-primary transition-colors p-1"
|
||||
onclick={() => toast.remove(t.id)}
|
||||
>
|
||||
@@ -36,4 +40,3 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="20" rx="4" fill="none"/>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#1a73e8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 11a9 9 0 0 1 9 9"/>
|
||||
<path d="M4 4a16 16 0 0 1 16 16"/>
|
||||
<circle cx="5" cy="19" r="1"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 288 B |
593
src/lib/db.ts
593
src/lib/db.ts
@@ -1,5 +1,9 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { SQLiteConnection, type SQLiteDBConnection, CapacitorSQLite } from '@capacitor-community/sqlite';
|
||||
import {
|
||||
SQLiteConnection,
|
||||
type SQLiteDBConnection,
|
||||
CapacitorSQLite,
|
||||
} from '@capacitor-community/sqlite';
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
@@ -83,10 +87,15 @@ export interface IDB {
|
||||
saveCategory(category: Category): Promise<void>;
|
||||
saveCategories(categories: Category[]): Promise<void>;
|
||||
deleteCategory(id: string): Promise<void>;
|
||||
getArticles(feedId?: string, offset?: number, limit?: number): Promise<Article[]>;
|
||||
getArticles(
|
||||
feedId?: string,
|
||||
offset?: number,
|
||||
limit?: number,
|
||||
categoryId?: string
|
||||
): Promise<Article[]>;
|
||||
saveArticles(articles: Article[]): Promise<void>;
|
||||
searchArticles(query: string, limit?: number): Promise<Article[]>;
|
||||
getReadingHistory(days?: number): Promise<{ date: number, count: number }[]>;
|
||||
getReadingHistory(days?: number): Promise<{ date: number; count: number }[]>;
|
||||
markAsRead(id: string): Promise<void>;
|
||||
bulkMarkRead(ids: string[]): Promise<void>;
|
||||
bulkDelete(ids: string[]): Promise<void>;
|
||||
@@ -122,14 +131,23 @@ class IndexedDBImpl implements IDB {
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
const transaction = (event.target as IDBOpenDBRequest).transaction!;
|
||||
if (!db.objectStoreNames.contains('feeds')) db.createObjectStore('feeds', { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains('categories')) db.createObjectStore('categories', { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains('settings')) db.createObjectStore('settings', { keyPath: 'id' });
|
||||
let articleStore: IDBObjectStore = !db.objectStoreNames.contains('articles') ? db.createObjectStore('articles', { keyPath: 'id' }) : transaction.objectStore('articles');
|
||||
if (!articleStore.indexNames.contains('feedId')) articleStore.createIndex('feedId', 'feedId', { unique: false });
|
||||
if (!articleStore.indexNames.contains('pubDate')) articleStore.createIndex('pubDate', 'pubDate', { unique: false });
|
||||
if (!articleStore.indexNames.contains('saved')) articleStore.createIndex('saved', 'saved', { unique: false });
|
||||
if (!articleStore.indexNames.contains('readAt')) articleStore.createIndex('readAt', 'readAt', { unique: false });
|
||||
if (!db.objectStoreNames.contains('feeds'))
|
||||
db.createObjectStore('feeds', { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains('categories'))
|
||||
db.createObjectStore('categories', { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains('settings'))
|
||||
db.createObjectStore('settings', { keyPath: 'id' });
|
||||
let articleStore: IDBObjectStore = !db.objectStoreNames.contains('articles')
|
||||
? db.createObjectStore('articles', { keyPath: 'id' })
|
||||
: transaction.objectStore('articles');
|
||||
if (!articleStore.indexNames.contains('feedId'))
|
||||
articleStore.createIndex('feedId', 'feedId', { unique: false });
|
||||
if (!articleStore.indexNames.contains('pubDate'))
|
||||
articleStore.createIndex('pubDate', 'pubDate', { unique: false });
|
||||
if (!articleStore.indexNames.contains('saved'))
|
||||
articleStore.createIndex('saved', 'saved', { unique: false });
|
||||
if (!articleStore.indexNames.contains('readAt'))
|
||||
articleStore.createIndex('readAt', 'readAt', { unique: false });
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -159,7 +177,7 @@ class IndexedDBImpl implements IDB {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('feeds', 'readwrite');
|
||||
const store = transaction.objectStore('feeds');
|
||||
feeds.forEach(f => store.put(f));
|
||||
feeds.forEach((f) => store.put(f));
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
@@ -174,7 +192,10 @@ class IndexedDBImpl implements IDB {
|
||||
const request = articleStore.index('feedId').openKeyCursor(IDBKeyRange.only(id));
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursor>).result;
|
||||
if (cursor) { articleStore.delete(cursor.primaryKey); cursor.continue(); }
|
||||
if (cursor) {
|
||||
articleStore.delete(cursor.primaryKey);
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
@@ -206,7 +227,7 @@ class IndexedDBImpl implements IDB {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('categories', 'readwrite');
|
||||
const store = transaction.objectStore('categories');
|
||||
categories.forEach(c => store.put(c));
|
||||
categories.forEach((c) => store.put(c));
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
@@ -221,21 +242,54 @@ class IndexedDBImpl implements IDB {
|
||||
const request = feedStore.getAll();
|
||||
request.onsuccess = () => {
|
||||
const feeds = request.result as Feed[];
|
||||
feeds.forEach(f => { if (f.categoryId === id) { f.categoryId = 'uncategorized'; feedStore.put(f); } });
|
||||
feeds.forEach((f) => {
|
||||
if (f.categoryId === id) {
|
||||
f.categoryId = 'uncategorized';
|
||||
feedStore.put(f);
|
||||
}
|
||||
});
|
||||
};
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> {
|
||||
async getArticles(
|
||||
feedId?: string,
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
categoryId?: string
|
||||
): Promise<Article[]> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('articles', 'readonly');
|
||||
const transaction = db.transaction(['articles', 'feeds'], 'readonly');
|
||||
const store = transaction.objectStore('articles');
|
||||
let request: IDBRequest<any[]>;
|
||||
if (feedId) request = store.index('feedId').getAll(IDBKeyRange.only(feedId));
|
||||
else request = store.index('pubDate').getAll();
|
||||
|
||||
if (feedId) {
|
||||
request = store.index('feedId').getAll(IDBKeyRange.only(feedId));
|
||||
} else if (categoryId) {
|
||||
// For IndexedDB, we need to get all feeds in category first
|
||||
const feedStore = transaction.objectStore('feeds');
|
||||
const feedsRequest = feedStore.getAll();
|
||||
feedsRequest.onsuccess = () => {
|
||||
const feeds = feedsRequest.result as Feed[];
|
||||
const catFeedIds = new Set(
|
||||
feeds.filter((f) => f.categoryId === categoryId).map((f) => f.id)
|
||||
);
|
||||
const allArticlesRequest = store.getAll();
|
||||
allArticlesRequest.onsuccess = () => {
|
||||
const articles = allArticlesRequest.result as Article[];
|
||||
const filtered = articles.filter((a) => catFeedIds.has(a.feedId));
|
||||
filtered.sort((a, b) => b.pubDate - a.pubDate);
|
||||
resolve(filtered.slice(offset, offset + limit));
|
||||
};
|
||||
};
|
||||
return;
|
||||
} else {
|
||||
request = store.index('pubDate').getAll();
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
const articles = request.result as Article[];
|
||||
articles.sort((a, b) => b.pubDate - a.pubDate);
|
||||
@@ -250,7 +304,7 @@ class IndexedDBImpl implements IDB {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('articles', 'readwrite');
|
||||
const store = transaction.objectStore('articles');
|
||||
articles.forEach(article => store.put(article));
|
||||
articles.forEach((article) => store.put(article));
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
@@ -268,7 +322,11 @@ class IndexedDBImpl implements IDB {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
|
||||
if (cursor && results.length < limit) {
|
||||
const article = cursor.value as Article;
|
||||
if (article.title.toLowerCase().includes(lowQuery) || article.description.toLowerCase().includes(lowQuery) || (article.content && article.content.toLowerCase().includes(lowQuery))) {
|
||||
if (
|
||||
article.title.toLowerCase().includes(lowQuery) ||
|
||||
article.description.toLowerCase().includes(lowQuery) ||
|
||||
(article.content && article.content.toLowerCase().includes(lowQuery))
|
||||
) {
|
||||
results.push(article);
|
||||
}
|
||||
cursor.continue();
|
||||
@@ -278,12 +336,12 @@ class IndexedDBImpl implements IDB {
|
||||
});
|
||||
}
|
||||
|
||||
async getReadingHistory(days = 30): Promise<{ date: number, count: number }[]> {
|
||||
async getReadingHistory(days = 30): Promise<{ date: number; count: number }[]> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('articles', 'readonly');
|
||||
const index = transaction.objectStore('articles').index('readAt');
|
||||
const startTime = Date.now() - (days * 24 * 60 * 60 * 1000);
|
||||
const startTime = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const request = index.openCursor(IDBKeyRange.lowerBound(startTime));
|
||||
const history: Record<string, number> = {};
|
||||
request.onsuccess = (event) => {
|
||||
@@ -291,12 +349,18 @@ class IndexedDBImpl implements IDB {
|
||||
if (cursor) {
|
||||
const article = cursor.value as Article;
|
||||
if (article.readAt) {
|
||||
const date = new Date(article.readAt).toISOString().split('T')[0];
|
||||
const d = new Date(article.readAt);
|
||||
const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
history[date] = (history[date] || 0) + 1;
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(Object.entries(history).map(([date, count]) => ({ date: new Date(date).getTime(), count })));
|
||||
resolve(
|
||||
Object.entries(history).map(([date, count]) => ({
|
||||
date: new Date(date).getTime(),
|
||||
count,
|
||||
}))
|
||||
);
|
||||
}
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
@@ -327,7 +391,11 @@ class IndexedDBImpl implements IDB {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => {
|
||||
const article = request.result as Article;
|
||||
if (article && !article.read) { article.read = true; article.readAt = now; store.put(article); }
|
||||
if (article && !article.read) {
|
||||
article.read = true;
|
||||
article.readAt = now;
|
||||
store.put(article);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -347,7 +415,10 @@ class IndexedDBImpl implements IDB {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => {
|
||||
const article = request.result as Article;
|
||||
if (article) { article.saved = !article.saved; store.put({ ...article, saved: article.saved ? 1 : 0 }); }
|
||||
if (article) {
|
||||
article.saved = !article.saved;
|
||||
store.put({ ...article, saved: article.saved ? 1 : 0 });
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -357,14 +428,18 @@ class IndexedDBImpl implements IDB {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('articles', 'readwrite');
|
||||
const store = transaction.objectStore('articles');
|
||||
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
|
||||
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const request = store.openCursor();
|
||||
let count = 0;
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
|
||||
if (cursor) {
|
||||
const article = cursor.value as Article;
|
||||
if (article.content && !article.saved && article.pubDate < cutoff) { delete article.content; cursor.update(article); count++; }
|
||||
if (article.content && !article.saved && article.pubDate < cutoff) {
|
||||
delete article.content;
|
||||
cursor.update(article);
|
||||
count++;
|
||||
}
|
||||
cursor.continue();
|
||||
} else resolve(count);
|
||||
};
|
||||
@@ -379,7 +454,10 @@ class IndexedDBImpl implements IDB {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => {
|
||||
const article = request.result as Article;
|
||||
if (article) { article.content = content; store.put(article); }
|
||||
if (article) {
|
||||
article.content = content;
|
||||
store.put(article);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -391,8 +469,11 @@ class IndexedDBImpl implements IDB {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => {
|
||||
const article = request.result as Article;
|
||||
if (article) { article.saved = !article.saved; store.put({ ...article, saved: article.saved ? 1 : 0 }); resolve(article.saved); }
|
||||
else resolve(false);
|
||||
if (article) {
|
||||
article.saved = !article.saved;
|
||||
store.put({ ...article, saved: article.saved ? 1 : 0 });
|
||||
resolve(article.saved);
|
||||
} else resolve(false);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
@@ -402,7 +483,10 @@ class IndexedDBImpl implements IDB {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('articles', 'readonly');
|
||||
const request = transaction.objectStore('articles').index('saved').getAll(IDBKeyRange.only(1));
|
||||
const request = transaction
|
||||
.objectStore('articles')
|
||||
.index('saved')
|
||||
.getAll(IDBKeyRange.only(1));
|
||||
request.onsuccess = () => {
|
||||
const articles = request.result as Article[];
|
||||
articles.sort((a, b) => b.pubDate - a.pubDate);
|
||||
@@ -419,9 +503,21 @@ class IndexedDBImpl implements IDB {
|
||||
const request = transaction.objectStore('settings').get('main');
|
||||
request.onsuccess = () => {
|
||||
const defaults: Settings = {
|
||||
theme: 'system', globalFetchInterval: 30, autoFetch: true, apiBaseUrl: '/api', smartFeed: false, readingMode: 'inline', paneWidth: 40, fontFamily: 'sans', fontSize: 18, lineHeight: 1.6, contentPurgeDays: 30, authToken: null, muteFilters: [],
|
||||
theme: 'system',
|
||||
globalFetchInterval: 30,
|
||||
autoFetch: true,
|
||||
apiBaseUrl: '/api',
|
||||
smartFeed: false,
|
||||
readingMode: 'inline',
|
||||
paneWidth: 40,
|
||||
fontFamily: 'sans',
|
||||
fontSize: 18,
|
||||
lineHeight: 1.6,
|
||||
contentPurgeDays: 30,
|
||||
authToken: null,
|
||||
muteFilters: [],
|
||||
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
|
||||
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 }
|
||||
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 },
|
||||
};
|
||||
resolve({ ...defaults, ...(request.result || {}) });
|
||||
};
|
||||
@@ -441,8 +537,14 @@ class IndexedDBImpl implements IDB {
|
||||
async clearAll(): Promise<void> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(['feeds', 'categories', 'articles', 'settings'], 'readwrite');
|
||||
transaction.objectStore('feeds').clear(); transaction.objectStore('categories').clear(); transaction.objectStore('articles').clear(); transaction.objectStore('settings').clear();
|
||||
const transaction = db.transaction(
|
||||
['feeds', 'categories', 'articles', 'settings'],
|
||||
'readwrite'
|
||||
);
|
||||
transaction.objectStore('feeds').clear();
|
||||
transaction.objectStore('categories').clear();
|
||||
transaction.objectStore('articles').clear();
|
||||
transaction.objectStore('settings').clear();
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
@@ -456,16 +558,16 @@ class CapacitorSQLiteDBImpl implements IDB {
|
||||
async open(): Promise<SQLiteDBConnection> {
|
||||
if (this.db) return this.db;
|
||||
if (!this.sqlite) this.sqlite = new SQLiteConnection(CapacitorSQLite);
|
||||
|
||||
|
||||
const ret = await this.sqlite.checkConnectionsConsistency();
|
||||
const isConn = (await this.sqlite.isConnection(DB_NAME, false)).result;
|
||||
|
||||
|
||||
if (ret.result && isConn) {
|
||||
this.db = await this.sqlite.retrieveConnection(DB_NAME, false);
|
||||
} else {
|
||||
this.db = await this.sqlite.createConnection(DB_NAME, false, "no-encryption", 1, false);
|
||||
this.db = await this.sqlite.createConnection(DB_NAME, false, 'no-encryption', 1, false);
|
||||
}
|
||||
|
||||
|
||||
await this.db.open();
|
||||
|
||||
const queries = [
|
||||
@@ -474,7 +576,7 @@ class CapacitorSQLiteDBImpl implements IDB {
|
||||
`CREATE TABLE IF NOT EXISTS articles (id TEXT PRIMARY KEY, feedId TEXT, title TEXT, link TEXT, description TEXT, content TEXT, author TEXT, pubDate INTEGER, read INTEGER, saved INTEGER, imageUrl TEXT, readAt INTEGER);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_articles_pubDate ON articles(pubDate);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_articles_readAt ON articles(readAt);`,
|
||||
`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);`
|
||||
`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);`,
|
||||
];
|
||||
|
||||
for (const q of queries) {
|
||||
@@ -487,16 +589,20 @@ class CapacitorSQLiteDBImpl implements IDB {
|
||||
async getFeeds(): Promise<Feed[]> {
|
||||
const db = await this.open();
|
||||
const res = await db.query('SELECT * FROM feeds ORDER BY "order" ASC');
|
||||
return (res.values || []).map(f => ({ ...f, enabled: f.enabled === 1 }));
|
||||
return (res.values || []).map((f) => ({ ...f, enabled: f.enabled === 1 }));
|
||||
}
|
||||
|
||||
async saveFeed(feed: Feed): Promise<void> { await this.saveFeeds([feed]); }
|
||||
async saveFeed(feed: Feed): Promise<void> {
|
||||
await this.saveFeeds([feed]);
|
||||
}
|
||||
|
||||
async saveFeeds(feeds: Feed[]): Promise<void> {
|
||||
const db = await this.open();
|
||||
for (const f of feeds) {
|
||||
await db.run('INSERT OR REPLACE INTO feeds (id, title, categoryId, "order", enabled, fetchInterval) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[f.id, f.title, f.categoryId, f.order, f.enabled ? 1 : 0, f.fetchInterval]);
|
||||
await db.run(
|
||||
'INSERT OR REPLACE INTO feeds (id, title, categoryId, "order", enabled, fetchInterval) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[f.id, f.title, f.categoryId, f.order, f.enabled ? 1 : 0, f.fetchInterval]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,12 +618,18 @@ class CapacitorSQLiteDBImpl implements IDB {
|
||||
return res.values || [];
|
||||
}
|
||||
|
||||
async saveCategory(category: Category): Promise<void> { await this.saveCategories([category]); }
|
||||
async saveCategory(category: Category): Promise<void> {
|
||||
await this.saveCategories([category]);
|
||||
}
|
||||
|
||||
async saveCategories(categories: Category[]): Promise<void> {
|
||||
const db = await this.open();
|
||||
for (const c of categories) {
|
||||
await db.run('INSERT OR REPLACE INTO categories (id, name, "order") VALUES (?, ?, ?)', [c.id, c.name, c.order]);
|
||||
await db.run('INSERT OR REPLACE INTO categories (id, name, "order") VALUES (?, ?, ?)', [
|
||||
c.id,
|
||||
c.name,
|
||||
c.order,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,52 +639,99 @@ class CapacitorSQLiteDBImpl implements IDB {
|
||||
await db.run('DELETE FROM categories WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> {
|
||||
async getArticles(
|
||||
feedId?: string,
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
categoryId?: string
|
||||
): Promise<Article[]> {
|
||||
const db = await this.open();
|
||||
let res;
|
||||
if (feedId) {
|
||||
res = await db.query('SELECT * FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?', [feedId, limit, offset]);
|
||||
res = await db.query(
|
||||
'SELECT * FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?',
|
||||
[feedId, limit, offset]
|
||||
);
|
||||
} else if (categoryId) {
|
||||
res = await db.query(
|
||||
`SELECT a.* FROM articles a
|
||||
JOIN feeds f ON a.feedId = f.id
|
||||
WHERE f.categoryId = ?
|
||||
ORDER BY a.pubDate DESC LIMIT ? OFFSET ?`,
|
||||
[categoryId, limit, offset]
|
||||
);
|
||||
} else {
|
||||
res = await db.query('SELECT * FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?', [limit, offset]);
|
||||
res = await db.query('SELECT * FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?', [
|
||||
limit,
|
||||
offset,
|
||||
]);
|
||||
}
|
||||
return (res.values || []).map(a => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
|
||||
return (res.values || []).map((a) => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
|
||||
}
|
||||
|
||||
async saveArticles(articles: Article[]): Promise<void> {
|
||||
const db = await this.open();
|
||||
for (const a of articles) {
|
||||
await db.run(`INSERT OR REPLACE INTO articles
|
||||
await db.run(
|
||||
`INSERT OR REPLACE INTO articles
|
||||
(id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[a.id, a.feedId, a.title, a.link, a.description, a.content, a.author, a.pubDate, a.read ? 1 : 0, a.saved ? 1 : 0, a.imageUrl, a.readAt]);
|
||||
[
|
||||
a.id,
|
||||
a.feedId,
|
||||
a.title,
|
||||
a.link,
|
||||
a.description,
|
||||
a.content,
|
||||
a.author,
|
||||
a.pubDate,
|
||||
a.read ? 1 : 0,
|
||||
a.saved ? 1 : 0,
|
||||
a.imageUrl,
|
||||
a.readAt,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async searchArticles(query: string, limit = 50): Promise<Article[]> {
|
||||
const db = await this.open();
|
||||
const q = `%${query}%`;
|
||||
const res = await db.query(`SELECT * FROM articles WHERE title LIKE ? OR description LIKE ? OR content LIKE ? ORDER BY pubDate DESC LIMIT ?`, [q, q, q, limit]);
|
||||
return (res.values || []).map(a => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
|
||||
const res = await db.query(
|
||||
`SELECT * FROM articles WHERE title LIKE ? OR description LIKE ? OR content LIKE ? ORDER BY pubDate DESC LIMIT ?`,
|
||||
[q, q, q, limit]
|
||||
);
|
||||
return (res.values || []).map((a) => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
|
||||
}
|
||||
|
||||
async getReadingHistory(days = 30): Promise<{ date: number, count: number }[]> {
|
||||
async getReadingHistory(days = 30): Promise<{ date: number; count: number }[]> {
|
||||
const db = await this.open();
|
||||
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
|
||||
const res = await db.query(`
|
||||
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch')) as date, COUNT(*) as count
|
||||
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const res = await db.query(
|
||||
`
|
||||
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch', 'localtime')) as date, COUNT(*) as count
|
||||
FROM articles
|
||||
WHERE read = 1 AND readAt > ?
|
||||
GROUP BY date
|
||||
ORDER BY date DESC`, [cutoff]);
|
||||
return (res.values || []).map(row => ({
|
||||
date: new Date(row.date).getTime(),
|
||||
count: row.count
|
||||
}));
|
||||
ORDER BY date DESC`,
|
||||
[cutoff]
|
||||
);
|
||||
return (res.values || []).map((row) => {
|
||||
const [y, m, d] = row.date.split('-').map(Number);
|
||||
const date = new Date(y, m - 1, d).getTime();
|
||||
return {
|
||||
date,
|
||||
count: row.count,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async markAsRead(id: string): Promise<void> {
|
||||
const db = await this.open();
|
||||
await db.run('UPDATE articles SET read = 1, readAt = ? WHERE id = ? AND read = 0', [Date.now(), id]);
|
||||
await db.run('UPDATE articles SET read = 1, readAt = ? WHERE id = ? AND read = 0', [
|
||||
Date.now(),
|
||||
id,
|
||||
]);
|
||||
}
|
||||
|
||||
async bulkMarkRead(ids: string[]): Promise<void> {
|
||||
@@ -591,14 +750,19 @@ class CapacitorSQLiteDBImpl implements IDB {
|
||||
async bulkToggleSave(ids: string[]): Promise<void> {
|
||||
const db = await this.open();
|
||||
for (const id of ids) {
|
||||
await db.run('UPDATE articles SET saved = CASE WHEN saved = 1 THEN 0 ELSE 1 END WHERE id = ?', [id]);
|
||||
await db.run(
|
||||
'UPDATE articles SET saved = CASE WHEN saved = 1 THEN 0 ELSE 1 END WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async purgeOldContent(days: number): Promise<number> {
|
||||
const db = await this.open();
|
||||
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
|
||||
const res = await db.run('UPDATE articles SET content = NULL WHERE saved = 0 AND pubDate < ?', [cutoff]);
|
||||
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const res = await db.run('UPDATE articles SET content = NULL WHERE saved = 0 AND pubDate < ?', [
|
||||
cutoff,
|
||||
]);
|
||||
return res.changes?.changes || 0;
|
||||
}
|
||||
|
||||
@@ -609,7 +773,9 @@ class CapacitorSQLiteDBImpl implements IDB {
|
||||
|
||||
async toggleSave(id: string): Promise<boolean> {
|
||||
const db = await this.open();
|
||||
await db.run('UPDATE articles SET saved = CASE WHEN saved = 1 THEN 0 ELSE 1 END WHERE id = ?', [id]);
|
||||
await db.run('UPDATE articles SET saved = CASE WHEN saved = 1 THEN 0 ELSE 1 END WHERE id = ?', [
|
||||
id,
|
||||
]);
|
||||
const res = await db.query('SELECT saved FROM articles WHERE id = ?', [id]);
|
||||
return res.values?.[0]?.saved === 1;
|
||||
}
|
||||
@@ -617,16 +783,28 @@ class CapacitorSQLiteDBImpl implements IDB {
|
||||
async getSavedArticles(): Promise<Article[]> {
|
||||
const db = await this.open();
|
||||
const res = await db.query('SELECT * FROM articles WHERE saved = 1 ORDER BY pubDate DESC');
|
||||
return (res.values || []).map(a => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
|
||||
return (res.values || []).map((a) => ({ ...a, read: a.read === 1, saved: a.saved === 1 }));
|
||||
}
|
||||
|
||||
async getSettings(): Promise<Settings> {
|
||||
const db = await this.open();
|
||||
const res = await db.query("SELECT value FROM settings WHERE key = 'main'");
|
||||
const defaults: Settings = {
|
||||
theme: 'system', globalFetchInterval: 30, autoFetch: true, apiBaseUrl: '/api', smartFeed: false, readingMode: 'inline', paneWidth: 40, fontFamily: 'sans', fontSize: 18, lineHeight: 1.6, contentPurgeDays: 30, authToken: null, muteFilters: [],
|
||||
theme: 'system',
|
||||
globalFetchInterval: 30,
|
||||
autoFetch: true,
|
||||
apiBaseUrl: '/api',
|
||||
smartFeed: false,
|
||||
readingMode: 'inline',
|
||||
paneWidth: 40,
|
||||
fontFamily: 'sans',
|
||||
fontSize: 18,
|
||||
lineHeight: 1.6,
|
||||
contentPurgeDays: 30,
|
||||
authToken: null,
|
||||
muteFilters: [],
|
||||
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
|
||||
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 }
|
||||
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 },
|
||||
};
|
||||
if (res.values && res.values.length > 0) {
|
||||
return { ...defaults, ...JSON.parse(res.values[0].value) };
|
||||
@@ -636,7 +814,9 @@ class CapacitorSQLiteDBImpl implements IDB {
|
||||
|
||||
async saveSettings(settings: Settings): Promise<void> {
|
||||
const db = await this.open();
|
||||
await db.run("INSERT OR REPLACE INTO settings (key, value) VALUES ('main', ?)", [JSON.stringify(settings)]);
|
||||
await db.run("INSERT OR REPLACE INTO settings (key, value) VALUES ('main', ?)", [
|
||||
JSON.stringify(settings),
|
||||
]);
|
||||
}
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
@@ -656,7 +836,7 @@ class CapacitorSQLiteDBImpl implements IDB {
|
||||
path: 'Native SQLite',
|
||||
articles: artRes.values?.[0]?.count || 0,
|
||||
feeds: feedRes.values?.[0]?.count || 0,
|
||||
walEnabled: true
|
||||
walEnabled: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -665,78 +845,150 @@ class WailsDBImpl implements IDB {
|
||||
private async call<T>(method: string, ...args: any[]): Promise<T> {
|
||||
const app = (window as any).go?.main?.App;
|
||||
if (!app || !app[method]) throw new Error(`Wails method ${method} not found`);
|
||||
|
||||
|
||||
// Add a 5 second timeout to all Wails calls to prevent infinite hangs
|
||||
return Promise.race([
|
||||
app[method](...args),
|
||||
new Promise((_, reject) =>
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Wails call ${method} timed out after 5s`)), 5000)
|
||||
)
|
||||
),
|
||||
]) as Promise<T>;
|
||||
}
|
||||
|
||||
async getFeeds(): Promise<Feed[]> { return JSON.parse(await this.call('GetFeeds')); }
|
||||
async saveFeed(feed: Feed): Promise<void> { await this.saveFeeds([feed]); }
|
||||
async saveFeeds(feeds: Feed[]): Promise<void> { await this.call('SaveFeeds', JSON.stringify(feeds)); }
|
||||
async deleteFeed(id: string): Promise<void> { await this.call('DeleteFeed', id); }
|
||||
async getCategories(): Promise<Category[]> { return JSON.parse(await this.call('GetCategories')); }
|
||||
async saveCategory(category: Category): Promise<void> { await this.saveCategories([category]); }
|
||||
async saveCategories(categories: Category[]): Promise<void> { await this.call('SaveCategories', JSON.stringify(categories)); }
|
||||
async getFeeds(): Promise<Feed[]> {
|
||||
return JSON.parse(await this.call('GetFeeds'));
|
||||
}
|
||||
async saveFeed(feed: Feed): Promise<void> {
|
||||
await this.saveFeeds([feed]);
|
||||
}
|
||||
async saveFeeds(feeds: Feed[]): Promise<void> {
|
||||
await this.call('SaveFeeds', JSON.stringify(feeds));
|
||||
}
|
||||
async deleteFeed(id: string): Promise<void> {
|
||||
await this.call('DeleteFeed', id);
|
||||
}
|
||||
async getCategories(): Promise<Category[]> {
|
||||
return JSON.parse(await this.call('GetCategories'));
|
||||
}
|
||||
async saveCategory(category: Category): Promise<void> {
|
||||
await this.saveCategories([category]);
|
||||
}
|
||||
async saveCategories(categories: Category[]): Promise<void> {
|
||||
await this.call('SaveCategories', JSON.stringify(categories));
|
||||
}
|
||||
async deleteCategory(id: string): Promise<void> {
|
||||
const feeds = await this.getFeeds();
|
||||
for (const f of feeds) { if (f.categoryId === id) { f.categoryId = 'uncategorized'; await this.saveFeed(f); } }
|
||||
for (const f of feeds) {
|
||||
if (f.categoryId === id) {
|
||||
f.categoryId = 'uncategorized';
|
||||
await this.saveFeed(f);
|
||||
}
|
||||
}
|
||||
// Wait for feed updates then delete cat
|
||||
await this.call('SaveCategories', JSON.stringify((await this.getCategories()).filter(c => c.id !== id)));
|
||||
await this.call(
|
||||
'SaveCategories',
|
||||
JSON.stringify((await this.getCategories()).filter((c) => c.id !== id))
|
||||
);
|
||||
}
|
||||
async getArticles(feedId?: string, offset = 0, limit = 20): Promise<Article[]> { return JSON.parse(await this.call('GetArticles', feedId || '', offset, limit)); }
|
||||
async saveArticles(articles: Article[]): Promise<void> { await this.call('SaveArticles', JSON.stringify(articles)); }
|
||||
async searchArticles(query: string, limit = 50): Promise<Article[]> { return JSON.parse(await this.call('SearchArticles', query, limit)); }
|
||||
async getReadingHistory(days = 30): Promise<{ date: number, count: number }[]> {
|
||||
async getArticles(
|
||||
feedId?: string,
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
categoryId?: string
|
||||
): Promise<Article[]> {
|
||||
return JSON.parse(
|
||||
await this.call('GetArticles', feedId || '', offset, limit, categoryId || '')
|
||||
);
|
||||
}
|
||||
async saveArticles(articles: Article[]): Promise<void> {
|
||||
await this.call('SaveArticles', JSON.stringify(articles));
|
||||
}
|
||||
async searchArticles(query: string, limit = 50): Promise<Article[]> {
|
||||
return JSON.parse(await this.call('SearchArticles', query, limit));
|
||||
}
|
||||
async getReadingHistory(days = 30): Promise<{ date: number; count: number }[]> {
|
||||
return JSON.parse(await this.call('GetReadingHistory', days));
|
||||
}
|
||||
async markAsRead(id: string): Promise<void> {
|
||||
await this.call('MarkAsRead', id);
|
||||
}
|
||||
async bulkMarkRead(ids: string[]): Promise<void> { for (const id of ids) await this.markAsRead(id); }
|
||||
async bulkDelete(_ids: string[]): Promise<void> { /* Not directly in SQLite bridge yet, could add */ }
|
||||
async bulkToggleSave(ids: string[]): Promise<void> { for (const id of ids) await this.toggleSave(id); }
|
||||
async purgeOldContent(days: number): Promise<number> { return Number(await this.call('PurgeOldContent', days)); }
|
||||
async bulkMarkRead(ids: string[]): Promise<void> {
|
||||
for (const id of ids) await this.markAsRead(id);
|
||||
}
|
||||
async bulkDelete(_ids: string[]): Promise<void> {
|
||||
/* Not directly in SQLite bridge yet, could add */
|
||||
}
|
||||
async bulkToggleSave(ids: string[]): Promise<void> {
|
||||
for (const id of ids) await this.toggleSave(id);
|
||||
}
|
||||
async purgeOldContent(days: number): Promise<number> {
|
||||
return Number(await this.call('PurgeOldContent', days));
|
||||
}
|
||||
async updateArticleContent(id: string, content: string): Promise<void> {
|
||||
const articles = await this.getArticles('', 0, 1000);
|
||||
const a = articles.find(art => art.id === id);
|
||||
if (a) { a.content = content; await this.call('UpdateArticle', JSON.stringify(a)); }
|
||||
const a = articles.find((art) => art.id === id);
|
||||
if (a) {
|
||||
a.content = content;
|
||||
await this.call('UpdateArticle', JSON.stringify(a));
|
||||
}
|
||||
}
|
||||
async toggleSave(id: string): Promise<boolean> {
|
||||
const articles = await this.getArticles('', 0, 1000);
|
||||
const a = articles.find(art => art.id === id);
|
||||
if (a) { a.saved = !a.saved; await this.call('UpdateArticle', JSON.stringify(a)); return a.saved; }
|
||||
const a = articles.find((art) => art.id === id);
|
||||
if (a) {
|
||||
a.saved = !a.saved;
|
||||
await this.call('UpdateArticle', JSON.stringify(a));
|
||||
return a.saved;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async getSavedArticles(): Promise<Article[]> {
|
||||
const articles = await this.getArticles('', 0, 5000);
|
||||
return articles.filter(a => a.saved).sort((a, b) => b.pubDate - a.pubDate);
|
||||
return articles.filter((a) => a.saved).sort((a, b) => b.pubDate - a.pubDate);
|
||||
}
|
||||
async getSettings(): Promise<Settings> {
|
||||
const defaults: Settings = {
|
||||
theme: 'system', globalFetchInterval: 30, autoFetch: true, apiBaseUrl: '/api', smartFeed: false, readingMode: 'inline', paneWidth: 40, fontFamily: 'sans', fontSize: 18, lineHeight: 1.6, contentPurgeDays: 30, authToken: null, muteFilters: [],
|
||||
theme: 'system',
|
||||
globalFetchInterval: 30,
|
||||
autoFetch: true,
|
||||
apiBaseUrl: '/api',
|
||||
smartFeed: false,
|
||||
readingMode: 'inline',
|
||||
paneWidth: 40,
|
||||
fontFamily: 'sans',
|
||||
fontSize: 18,
|
||||
lineHeight: 1.6,
|
||||
contentPurgeDays: 30,
|
||||
authToken: null,
|
||||
muteFilters: [],
|
||||
shortcuts: { next: 'j', prev: 'k', save: 's', read: 'r', open: 'o', toggleSelect: 'x' },
|
||||
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 }
|
||||
relevanceProfile: { categoryScores: {}, feedScores: {}, totalInteractions: 0 },
|
||||
};
|
||||
const saved = await this.call<string>('GetSettings');
|
||||
if (!saved) return defaults; // Handle empty string case
|
||||
try {
|
||||
return { ...defaults, ...JSON.parse(saved) };
|
||||
} catch (e) {
|
||||
console.error("Failed to parse settings", e);
|
||||
console.error('Failed to parse settings', e);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
async saveSettings(settings: Settings): Promise<void> { await this.call('SaveSettings', JSON.stringify(settings)); }
|
||||
async clearAll(): Promise<void> { await this.call('ClearAll'); }
|
||||
|
||||
async getStats(): Promise<DBStats> { return await this.call('GetDBStats'); }
|
||||
async vacuum(): Promise<void> { await this.call('VacuumDB'); }
|
||||
async integrityCheck(): Promise<string> { return await this.call('CheckDBIntegrity'); }
|
||||
async saveSettings(settings: Settings): Promise<void> {
|
||||
await this.call('SaveSettings', JSON.stringify(settings));
|
||||
}
|
||||
async clearAll(): Promise<void> {
|
||||
await this.call('ClearAll');
|
||||
}
|
||||
|
||||
async getStats(): Promise<DBStats> {
|
||||
return await this.call('GetDBStats');
|
||||
}
|
||||
async vacuum(): Promise<void> {
|
||||
await this.call('VacuumDB');
|
||||
}
|
||||
async integrityCheck(): Promise<string> {
|
||||
return await this.call('CheckDBIntegrity');
|
||||
}
|
||||
}
|
||||
|
||||
// LazyDBWrapper allows deciding which implementation to use at runtime (lazily),
|
||||
@@ -746,9 +998,10 @@ class LazyDBWrapper implements IDB {
|
||||
|
||||
private getImpl(): IDB {
|
||||
if (this.impl) return this.impl;
|
||||
|
||||
const isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
|
||||
const isCapacitor = typeof window !== 'undefined' && (Capacitor.isNativePlatform());
|
||||
|
||||
const isWails =
|
||||
typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
|
||||
const isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform();
|
||||
|
||||
if (isWails) {
|
||||
this.impl = new WailsDBImpl();
|
||||
@@ -758,40 +1011,96 @@ class LazyDBWrapper implements IDB {
|
||||
this.impl = new IndexedDBImpl();
|
||||
}
|
||||
|
||||
console.log(`DB Initialized using: ${isWails ? 'Wails (SQLite)' : isCapacitor ? 'Capacitor (SQLite)' : 'Browser (IndexedDB)'}`);
|
||||
console.log(
|
||||
`DB Initialized using: ${isWails ? 'Wails (SQLite)' : isCapacitor ? 'Capacitor (SQLite)' : 'Browser (IndexedDB)'}`
|
||||
);
|
||||
return this.impl;
|
||||
}
|
||||
|
||||
getFeeds() { return this.getImpl().getFeeds(); }
|
||||
saveFeed(feed: Feed) { return this.getImpl().saveFeed(feed); }
|
||||
saveFeeds(feeds: Feed[]) { return this.getImpl().saveFeeds(feeds); }
|
||||
deleteFeed(id: string) { return this.getImpl().deleteFeed(id); }
|
||||
getCategories() { return this.getImpl().getCategories(); }
|
||||
saveCategory(category: Category) { return this.getImpl().saveCategory(category); }
|
||||
saveCategories(categories: Category[]) { return this.getImpl().saveCategories(categories); }
|
||||
deleteCategory(id: string) { return this.getImpl().deleteCategory(id); }
|
||||
getArticles(feedId?: string, offset?: number, limit?: number) { return this.getImpl().getArticles(feedId, offset, limit); }
|
||||
saveArticles(articles: Article[]) { return this.getImpl().saveArticles(articles); }
|
||||
searchArticles(query: string, limit?: number) { return this.getImpl().searchArticles(query, limit); }
|
||||
getReadingHistory(days?: number) { return this.getImpl().getReadingHistory(days); }
|
||||
markAsRead(id: string) { return this.getImpl().markAsRead(id); }
|
||||
bulkMarkRead(ids: string[]) { return this.getImpl().bulkMarkRead(ids); }
|
||||
bulkDelete(ids: string[]) { return this.getImpl().bulkDelete(ids); }
|
||||
bulkToggleSave(ids: string[]) { return this.getImpl().bulkToggleSave(ids); }
|
||||
purgeOldContent(days: number) { return this.getImpl().purgeOldContent(days); }
|
||||
updateArticleContent(id: string, content: string) { return this.getImpl().updateArticleContent(id, content); }
|
||||
toggleSave(id: string) { return this.getImpl().toggleSave(id); }
|
||||
getSavedArticles() { return this.getImpl().getSavedArticles(); }
|
||||
getSettings() { return this.getImpl().getSettings(); }
|
||||
saveSettings(settings: Settings) { return this.getImpl().saveSettings(settings); }
|
||||
clearAll() { return this.getImpl().clearAll(); }
|
||||
|
||||
async getStats() {
|
||||
const impl = this.getImpl();
|
||||
return impl.getStats ? impl.getStats() : { size: 0, path: 'IndexedDB', articles: 0, feeds: 0, walEnabled: false };
|
||||
getFeeds() {
|
||||
return this.getImpl().getFeeds();
|
||||
}
|
||||
saveFeed(feed: Feed) {
|
||||
return this.getImpl().saveFeed(feed);
|
||||
}
|
||||
saveFeeds(feeds: Feed[]) {
|
||||
return this.getImpl().saveFeeds(feeds);
|
||||
}
|
||||
deleteFeed(id: string) {
|
||||
return this.getImpl().deleteFeed(id);
|
||||
}
|
||||
getCategories() {
|
||||
return this.getImpl().getCategories();
|
||||
}
|
||||
saveCategory(category: Category) {
|
||||
return this.getImpl().saveCategory(category);
|
||||
}
|
||||
saveCategories(categories: Category[]) {
|
||||
return this.getImpl().saveCategories(categories);
|
||||
}
|
||||
deleteCategory(id: string) {
|
||||
return this.getImpl().deleteCategory(id);
|
||||
}
|
||||
getArticles(feedId?: string, offset?: number, limit?: number, categoryId?: string) {
|
||||
return this.getImpl().getArticles(feedId, offset, limit, categoryId);
|
||||
}
|
||||
saveArticles(articles: Article[]) {
|
||||
return this.getImpl().saveArticles(articles);
|
||||
}
|
||||
searchArticles(query: string, limit?: number) {
|
||||
return this.getImpl().searchArticles(query, limit);
|
||||
}
|
||||
getReadingHistory(days?: number) {
|
||||
return this.getImpl().getReadingHistory(days);
|
||||
}
|
||||
markAsRead(id: string) {
|
||||
return this.getImpl().markAsRead(id);
|
||||
}
|
||||
bulkMarkRead(ids: string[]) {
|
||||
return this.getImpl().bulkMarkRead(ids);
|
||||
}
|
||||
bulkDelete(ids: string[]) {
|
||||
return this.getImpl().bulkDelete(ids);
|
||||
}
|
||||
bulkToggleSave(ids: string[]) {
|
||||
return this.getImpl().bulkToggleSave(ids);
|
||||
}
|
||||
purgeOldContent(days: number) {
|
||||
return this.getImpl().purgeOldContent(days);
|
||||
}
|
||||
updateArticleContent(id: string, content: string) {
|
||||
return this.getImpl().updateArticleContent(id, content);
|
||||
}
|
||||
toggleSave(id: string) {
|
||||
return this.getImpl().toggleSave(id);
|
||||
}
|
||||
getSavedArticles() {
|
||||
return this.getImpl().getSavedArticles();
|
||||
}
|
||||
getSettings() {
|
||||
return this.getImpl().getSettings();
|
||||
}
|
||||
saveSettings(settings: Settings) {
|
||||
return this.getImpl().saveSettings(settings);
|
||||
}
|
||||
clearAll() {
|
||||
return this.getImpl().clearAll();
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
const impl = this.getImpl();
|
||||
return impl.getStats
|
||||
? impl.getStats()
|
||||
: { size: 0, path: 'IndexedDB', articles: 0, feeds: 0, walEnabled: false };
|
||||
}
|
||||
async vacuum() {
|
||||
const impl = this.getImpl();
|
||||
if (impl.vacuum) await impl.vacuum();
|
||||
}
|
||||
async integrityCheck() {
|
||||
const impl = this.getImpl();
|
||||
return impl.integrityCheck ? await impl.integrityCheck() : 'N/A';
|
||||
}
|
||||
async vacuum() { const impl = this.getImpl(); if (impl.vacuum) await impl.vacuum(); }
|
||||
async integrityCheck() { const impl = this.getImpl(); return impl.integrityCheck ? await impl.integrityCheck() : 'N/A'; }
|
||||
}
|
||||
|
||||
export const db: IDB = new LazyDBWrapper();
|
||||
|
||||
@@ -12,32 +12,38 @@ export function exportToOPML(feeds: Feed[], categories: Category[]): string {
|
||||
|
||||
// Group feeds by category
|
||||
const categorizedFeeds: Record<string, Feed[]> = {};
|
||||
feeds.forEach(f => {
|
||||
feeds.forEach((f) => {
|
||||
if (!categorizedFeeds[f.categoryId]) categorizedFeeds[f.categoryId] = [];
|
||||
categorizedFeeds[f.categoryId].push(f);
|
||||
});
|
||||
|
||||
// Add categories and their feeds
|
||||
[...categories].sort((a, b) => a.order - b.order).forEach(cat => {
|
||||
const catFeeds = categorizedFeeds[cat.id] || [];
|
||||
if (catFeeds.length === 0) return;
|
||||
[...categories]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.forEach((cat) => {
|
||||
const catFeeds = categorizedFeeds[cat.id] || [];
|
||||
if (catFeeds.length === 0) return;
|
||||
|
||||
xml += `
|
||||
<outline text="${escapeHTML(cat.name)}" title="${escapeHTML(cat.name)}">`;
|
||||
[...catFeeds].sort((a, b) => a.order - b.order).forEach(f => {
|
||||
xml += `
|
||||
<outline text="${escapeHTML(cat.name)}" title="${escapeHTML(cat.name)}">`;
|
||||
[...catFeeds]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.forEach((f) => {
|
||||
xml += `
|
||||
<outline type="rss" text="${escapeHTML(f.title)}" title="${escapeHTML(f.title)}" xmlUrl="${escapeHTML(f.id)}" htmlUrl="${escapeHTML(f.siteUrl)}"/>`;
|
||||
});
|
||||
xml += `
|
||||
});
|
||||
xml += `
|
||||
</outline>`;
|
||||
});
|
||||
});
|
||||
|
||||
// Add uncategorized feeds
|
||||
const uncategorized = categorizedFeeds['uncategorized'] || [];
|
||||
[...uncategorized].sort((a, b) => a.order - b.order).forEach(f => {
|
||||
xml += `
|
||||
[...uncategorized]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.forEach((f) => {
|
||||
xml += `
|
||||
<outline type="rss" text="${escapeHTML(f.title)}" title="${escapeHTML(f.title)}" xmlUrl="${escapeHTML(f.id)}" htmlUrl="${escapeHTML(f.siteUrl)}"/>`;
|
||||
});
|
||||
});
|
||||
|
||||
xml += `
|
||||
</body>
|
||||
@@ -46,11 +52,14 @@ export function exportToOPML(feeds: Feed[], categories: Category[]): string {
|
||||
return xml;
|
||||
}
|
||||
|
||||
export function parseOPML(xml: string): { feeds: Partial<Feed>[], categories: Partial<Category>[] } {
|
||||
export function parseOPML(xml: string): {
|
||||
feeds: Partial<Feed>[];
|
||||
categories: Partial<Category>[];
|
||||
} {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, 'text/xml');
|
||||
const outlines = doc.querySelectorAll('body > outline');
|
||||
|
||||
|
||||
const feeds: Partial<Feed>[] = [];
|
||||
const categories: Partial<Category>[] = [];
|
||||
|
||||
@@ -67,7 +76,7 @@ export function parseOPML(xml: string): { feeds: Partial<Feed>[], categories: Pa
|
||||
categories.push({
|
||||
id: categoryId,
|
||||
name: text,
|
||||
order: categories.length
|
||||
order: categories.length,
|
||||
});
|
||||
|
||||
const childFeeds = outline.querySelectorAll('outline[type="rss"]');
|
||||
@@ -90,17 +99,20 @@ function parseFeedOutline(el: Element, categoryId: string, order: number): Parti
|
||||
enabled: true,
|
||||
consecutiveErrors: 0,
|
||||
fetchInterval: 30,
|
||||
lastFetched: 0
|
||||
lastFetched: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHTML(str: string): string {
|
||||
return str.replace(/[&<>"']/g, m => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[m] || m);
|
||||
return str.replace(
|
||||
/[&<>"']/g,
|
||||
(m) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[m] || m
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,25 +4,34 @@ import { registerPlugin } from '@capacitor/core';
|
||||
|
||||
const RSS = registerPlugin<any>('RSS');
|
||||
|
||||
export async function fetchFeed(feedUrl: string): Promise<{ feed: Partial<Feed>, articles: Article[] }> {
|
||||
export async function fetchFeed(
|
||||
feedUrl: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ feed: Partial<Feed>; articles: Article[] }> {
|
||||
// Try native RSS fetch first if on mobile to bypass CORS/Bot protection
|
||||
if (newsStore.isCapacitor) {
|
||||
try {
|
||||
// Capacitor plugin might not support signal directly, but we can check it
|
||||
if (signal?.aborted) throw new Error('Aborted');
|
||||
|
||||
const data = await RSS.fetchFeed({ url: feedUrl });
|
||||
|
||||
|
||||
if (signal?.aborted) throw new Error('Aborted');
|
||||
|
||||
const articles: Article[] = data.articles.map((item: any) => ({
|
||||
...item,
|
||||
description: stripHtml(item.description || '').substring(0, 200)
|
||||
description: stripHtml(item.description || '').substring(0, 200),
|
||||
}));
|
||||
|
||||
return {
|
||||
feed: {
|
||||
...data.feed,
|
||||
lastFetched: Date.now()
|
||||
lastFetched: Date.now(),
|
||||
},
|
||||
articles
|
||||
articles,
|
||||
};
|
||||
} catch (e: any) {
|
||||
if (e.message === 'Aborted') throw e;
|
||||
console.warn('Native RSS fetch failed, falling back to API proxy:', e);
|
||||
// Show actual error in toast if it's not a "not implemented" error
|
||||
if (e.message && !e.message.includes('not implemented')) {
|
||||
@@ -36,7 +45,10 @@ export async function fetchFeed(feedUrl: string): Promise<{ feed: Partial<Feed>,
|
||||
if (newsStore.settings.authToken) {
|
||||
headers['X-Account-Number'] = newsStore.settings.authToken;
|
||||
}
|
||||
const response = await fetch(`${apiBase}/feed?url=${encodeURIComponent(feedUrl)}`, { headers });
|
||||
const response = await fetch(`${apiBase}/feed?url=${encodeURIComponent(feedUrl)}`, {
|
||||
headers,
|
||||
signal,
|
||||
});
|
||||
if (response.status === 401) {
|
||||
newsStore.logout();
|
||||
throw new Error('Unauthorized');
|
||||
@@ -45,7 +57,7 @@ export async function fetchFeed(feedUrl: string): Promise<{ feed: Partial<Feed>,
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || `Failed to fetch feed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
const text = await response.text();
|
||||
|
||||
@@ -65,18 +77,18 @@ export async function fetchFeed(feedUrl: string): Promise<{ feed: Partial<Feed>,
|
||||
} catch {
|
||||
throw new Error('Failed to parse server response as JSON');
|
||||
}
|
||||
|
||||
|
||||
const articles: Article[] = data.articles.map((item: any) => ({
|
||||
...item,
|
||||
description: stripHtml(item.description).substring(0, 200)
|
||||
description: stripHtml(item.description).substring(0, 200),
|
||||
}));
|
||||
|
||||
return {
|
||||
feed: {
|
||||
...data.feed,
|
||||
lastFetched: Date.now()
|
||||
lastFetched: Date.now(),
|
||||
},
|
||||
articles
|
||||
articles,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,33 +103,62 @@ function stripHtml(html: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function refreshAllFeeds() {
|
||||
export async function refreshAllFeeds(signal?: AbortSignal) {
|
||||
const feeds = await db.getFeeds();
|
||||
for (const feed of feeds) {
|
||||
if (signal?.aborted) throw new Error('Aborted');
|
||||
if (!feed.enabled) continue;
|
||||
|
||||
|
||||
const now = Date.now();
|
||||
const shouldFetch = now - feed.lastFetched > feed.fetchInterval * 60000 || feed.error;
|
||||
|
||||
|
||||
if (shouldFetch) {
|
||||
try {
|
||||
const { feed: updatedFeed, articles } = await fetchFeed(feed.id);
|
||||
await db.saveFeed({
|
||||
...feed,
|
||||
...updatedFeed,
|
||||
error: undefined,
|
||||
consecutiveErrors: 0
|
||||
const { feed: updatedFeed, articles } = await fetchFeed(feed.id, signal);
|
||||
await db.saveFeed({
|
||||
...feed,
|
||||
...updatedFeed,
|
||||
error: undefined,
|
||||
consecutiveErrors: 0,
|
||||
});
|
||||
await db.saveArticles(articles);
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError' || e.message === 'Aborted') throw e;
|
||||
console.error(`Failed to refresh feed ${feed.id}:`, e);
|
||||
const consecutiveErrors = (feed.consecutiveErrors || 0) + 1;
|
||||
await db.saveFeed({
|
||||
...feed,
|
||||
await db.saveFeed({
|
||||
...feed,
|
||||
error: e.message || 'Unknown error',
|
||||
consecutiveErrors
|
||||
consecutiveErrors,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshFeed(feedId: string, signal?: AbortSignal) {
|
||||
const feeds = await db.getFeeds();
|
||||
const feed = feeds.find((f) => f.id === feedId);
|
||||
if (!feed) return;
|
||||
|
||||
try {
|
||||
const { feed: updatedFeed, articles } = await fetchFeed(feed.id, signal);
|
||||
await db.saveFeed({
|
||||
...feed,
|
||||
...updatedFeed,
|
||||
error: undefined,
|
||||
consecutiveErrors: 0,
|
||||
});
|
||||
await db.saveArticles(articles);
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError' || e.message === 'Aborted') throw e;
|
||||
console.error(`Failed to refresh feed ${feed.id}:`, e);
|
||||
const consecutiveErrors = (feed.consecutiveErrors || 0) + 1;
|
||||
await db.saveFeed({
|
||||
...feed,
|
||||
error: e.message || 'Unknown error',
|
||||
consecutiveErrors,
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db, type Article, type Feed, type Settings, type Category } from './db';
|
||||
import { refreshAllFeeds } from './rss';
|
||||
import { refreshAllFeeds, refreshFeed } from './rss';
|
||||
import { toast } from './toast.svelte';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
@@ -27,18 +27,19 @@ class NewsStore {
|
||||
save: 's',
|
||||
read: 'r',
|
||||
open: 'o',
|
||||
toggleSelect: 'x'
|
||||
toggleSelect: 'x',
|
||||
},
|
||||
relevanceProfile: {
|
||||
categoryScores: {},
|
||||
feedScores: {},
|
||||
totalInteractions: 0
|
||||
}
|
||||
totalInteractions: 0,
|
||||
},
|
||||
});
|
||||
loading = $state(false);
|
||||
isInitialLoading = $state(false);
|
||||
showSidebar = $state(false);
|
||||
selectedFeedId = $state<string | null>(null);
|
||||
selectedCategoryId = $state<string | null>(null);
|
||||
currentView = $state<'all' | 'saved' | 'following' | 'settings'>('all');
|
||||
readingArticle = $state<any | null>(null);
|
||||
searchQuery = $state('');
|
||||
@@ -46,13 +47,16 @@ class NewsStore {
|
||||
isSelectMode = $state(false);
|
||||
selectedArticleIds = $state(new Set<string>());
|
||||
private limit = 20;
|
||||
|
||||
private refreshController: AbortController | null = null;
|
||||
|
||||
// Connection status
|
||||
isOnline = $state(true);
|
||||
ping = $state<number | null>(null);
|
||||
lastStatusCheck = $state<number>(Date.now());
|
||||
authInfo = $state<{required: boolean, mode: string, canReg: boolean} | null>(null);
|
||||
lastArticlesUpdate = $state<number>(Date.now());
|
||||
authInfo = $state<{ required: boolean; mode: string; canReg: boolean } | null>(null);
|
||||
isAuthenticated = $state(false);
|
||||
newlyRegisteredToken = $state<string | null>(null);
|
||||
|
||||
isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
|
||||
isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform();
|
||||
@@ -69,20 +73,21 @@ class NewsStore {
|
||||
this.loading = true;
|
||||
this.isInitialLoading = true;
|
||||
const startTime = Date.now();
|
||||
log("Init started");
|
||||
|
||||
log('Init started');
|
||||
|
||||
try {
|
||||
// Platform detection
|
||||
const isWails = typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
|
||||
const isWails =
|
||||
typeof window !== 'undefined' && ((window as any).runtime || (window as any).go);
|
||||
const isCapacitor = typeof window !== 'undefined' && Capacitor.isNativePlatform();
|
||||
let detectedWailsUrl: string | null = null;
|
||||
|
||||
if (isWails) {
|
||||
log("Wails environment detected");
|
||||
log('Wails environment detected');
|
||||
// Wait a bit for bindings if they are not immediately available
|
||||
let retries = 0;
|
||||
while (retries < 15 && !(window as any).go?.main?.App?.GetAPIPort) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
retries++;
|
||||
}
|
||||
|
||||
@@ -95,16 +100,16 @@ class NewsStore {
|
||||
log(`Wails GetAPIPort failed: ${e}`);
|
||||
}
|
||||
} else {
|
||||
log("Wails bindings not found after retries, continuing with defaults");
|
||||
log('Wails bindings not found after retries, continuing with defaults');
|
||||
}
|
||||
} else if (isCapacitor) {
|
||||
log("Capacitor Native environment detected");
|
||||
log('Capacitor Native environment detected');
|
||||
}
|
||||
|
||||
log("Fetching settings...");
|
||||
log('Fetching settings...');
|
||||
this.settings = await db.getSettings();
|
||||
log("Settings loaded");
|
||||
|
||||
log('Settings loaded');
|
||||
|
||||
// Override API URL if running in Wails with detected port
|
||||
// This prevents the DB setting (which might be stale or default) from breaking the connection
|
||||
if (detectedWailsUrl) {
|
||||
@@ -112,11 +117,11 @@ class NewsStore {
|
||||
}
|
||||
|
||||
this.applyTheme();
|
||||
|
||||
log("Checking status...");
|
||||
|
||||
log('Checking status...');
|
||||
await this.checkStatus();
|
||||
log(`Status checked. Authenticated: ${this.isAuthenticated}`);
|
||||
|
||||
|
||||
if (this.authInfo?.required) {
|
||||
if (this.settings.authToken) {
|
||||
const isValid = await this.verifyAuth(this.settings.authToken);
|
||||
@@ -129,11 +134,11 @@ class NewsStore {
|
||||
}
|
||||
|
||||
if (this.isAuthenticated) {
|
||||
log("Loading feeds and categories...");
|
||||
log('Loading feeds and categories...');
|
||||
this.feeds = (await db.getFeeds()) || [];
|
||||
this.categories = (await db.getCategories()) || [];
|
||||
log(`Loaded ${this.feeds.length} feeds`);
|
||||
|
||||
|
||||
// Ensure at least one category exists
|
||||
if (this.categories.length === 0) {
|
||||
const uncategorized = { id: 'uncategorized', name: 'Uncategorized', order: 0 };
|
||||
@@ -141,9 +146,9 @@ class NewsStore {
|
||||
this.categories = [uncategorized];
|
||||
}
|
||||
|
||||
log("Loading articles...");
|
||||
log('Loading articles...');
|
||||
await this.loadArticles();
|
||||
log("Articles loaded");
|
||||
log('Articles loaded');
|
||||
}
|
||||
} catch (e) {
|
||||
log(`Store initialization failed: ${e}`);
|
||||
@@ -151,18 +156,18 @@ class NewsStore {
|
||||
// Forced minimum loading time to prevent flickering (1.2 seconds)
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed < 1200) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1200 - elapsed));
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200 - elapsed));
|
||||
}
|
||||
this.loading = false;
|
||||
this.isInitialLoading = false;
|
||||
this.isInitializing = false;
|
||||
log("Init complete");
|
||||
log('Init complete');
|
||||
}
|
||||
|
||||
if (this.settings.autoFetch && this.isAuthenticated) {
|
||||
this.startAutoFetch();
|
||||
}
|
||||
|
||||
|
||||
this.startStatusChecking();
|
||||
}
|
||||
|
||||
@@ -170,7 +175,7 @@ class NewsStore {
|
||||
const apiBase = this.settings.apiBaseUrl || '/api';
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/auth/verify`, {
|
||||
headers: { 'X-Account-Number': token }
|
||||
headers: { 'X-Account-Number': token },
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
@@ -184,7 +189,7 @@ class NewsStore {
|
||||
const response = await fetch(`${apiBase}/auth/register`, { method: 'POST' });
|
||||
if (!response.ok) throw new Error('Registration failed');
|
||||
const data = await response.json();
|
||||
await this.login(data.accountNumber);
|
||||
this.newlyRegisteredToken = data.accountNumber;
|
||||
return data.accountNumber;
|
||||
} catch {
|
||||
toast.error('Could not generate account');
|
||||
@@ -217,7 +222,7 @@ class NewsStore {
|
||||
|
||||
async checkStatus() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
|
||||
const wasOnline = this.isOnline;
|
||||
if (!navigator.onLine) {
|
||||
this.isOnline = false;
|
||||
@@ -232,10 +237,10 @@ class NewsStore {
|
||||
// Add a short timeout for the ping
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const response = await fetch(`${apiBase}/ping`, {
|
||||
|
||||
const response = await fetch(`${apiBase}/ping`, {
|
||||
cache: 'no-store',
|
||||
signal: controller.signal
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -268,18 +273,27 @@ class NewsStore {
|
||||
startStatusChecking() {
|
||||
this.checkStatus();
|
||||
if (this.statusInterval) clearInterval(this.statusInterval);
|
||||
|
||||
|
||||
// Only run periodic status checks (ping) on web server
|
||||
// Mobile and Desktop should avoid unnecessary background CPU/Battery usage
|
||||
if (!this.isWails && !this.isCapacitor) {
|
||||
this.statusInterval = setInterval(() => this.checkStatus(), 30000);
|
||||
}
|
||||
|
||||
window.addEventListener('online', () => this.checkStatus());
|
||||
window.addEventListener('offline', () => {
|
||||
this.isOnline = false;
|
||||
this.ping = null;
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', () => this.checkStatus());
|
||||
window.addEventListener('offline', () => {
|
||||
this.isOnline = false;
|
||||
this.ping = null;
|
||||
});
|
||||
|
||||
// Auto-refresh when tab becomes visible
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && this.isAuthenticated) {
|
||||
this.refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async loadArticles() {
|
||||
@@ -291,72 +305,79 @@ class NewsStore {
|
||||
articles = await db.searchArticles(this.searchQuery, 100);
|
||||
this.hasMore = false; // Search results are usually limited
|
||||
} else {
|
||||
if (this.currentView === 'saved') {
|
||||
articles = await db.getSavedArticles();
|
||||
} else if (this.currentView === 'following') {
|
||||
articles = await db.getArticles(undefined, 0, this.limit);
|
||||
} else {
|
||||
articles = await db.getArticles(this.selectedFeedId || undefined, 0, this.limit * 2);
|
||||
if (this.currentView === 'saved') {
|
||||
articles = await db.getSavedArticles();
|
||||
} else if (this.currentView === 'following') {
|
||||
articles = await db.getArticles(undefined, 0, this.limit);
|
||||
} else {
|
||||
articles = await db.getArticles(
|
||||
this.selectedFeedId || undefined,
|
||||
0,
|
||||
this.limit * 2,
|
||||
this.selectedCategoryId || undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Mute Filters
|
||||
if (this.settings.muteFilters && this.settings.muteFilters.length > 0) {
|
||||
const filters = this.settings.muteFilters.map(f => f.toLowerCase());
|
||||
articles = articles.filter(a => {
|
||||
const filters = this.settings.muteFilters.map((f) => f.toLowerCase());
|
||||
articles = articles.filter((a) => {
|
||||
const title = a.title.toLowerCase();
|
||||
return !filters.some(f => title.includes(f));
|
||||
return !filters.some((f) => title.includes(f));
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.searchQuery) {
|
||||
if (this.settings.smartFeed && this.currentView !== 'saved' && !this.selectedFeedId) {
|
||||
articles = this.rankArticles(articles);
|
||||
} else {
|
||||
articles.sort((a, b) => b.pubDate - a.pubDate);
|
||||
}
|
||||
this.articles = articles.slice(0, this.limit);
|
||||
if (articles.length < this.limit) {
|
||||
this.hasMore = false;
|
||||
if (this.settings.smartFeed && this.currentView !== 'saved' && !this.selectedFeedId) {
|
||||
articles = this.rankArticles(articles);
|
||||
} else {
|
||||
articles.sort((a, b) => b.pubDate - a.pubDate);
|
||||
}
|
||||
this.articles = articles.slice(0, this.limit);
|
||||
if (articles.length < this.limit) {
|
||||
this.hasMore = false;
|
||||
}
|
||||
} else {
|
||||
this.articles = articles;
|
||||
}
|
||||
this.lastArticlesUpdate = Date.now();
|
||||
}
|
||||
|
||||
private rankArticles(articles: Article[]): Article[] {
|
||||
const now = Date.now();
|
||||
const profile = this.settings.relevanceProfile;
|
||||
const totalInteractions = profile.totalInteractions || 0;
|
||||
|
||||
return articles.map(article => {
|
||||
const feed = this.feeds.find(f => f.id === article.feedId);
|
||||
const feedScore = profile.feedScores[article.feedId] || 0;
|
||||
const catScore = feed ? (profile.categoryScores[feed.categoryId] || 0) : 0;
|
||||
|
||||
// Affinity score (0 to 1)
|
||||
const affinity = totalInteractions > 0
|
||||
? (feedScore + catScore) / (totalInteractions * 2)
|
||||
: 0;
|
||||
|
||||
// Recency score (decays over 48 hours)
|
||||
const ageHours = (now - article.pubDate) / (1000 * 60 * 60);
|
||||
const recency = Math.max(0, 1 - (ageHours / 48));
|
||||
|
||||
// Weighted final score: 60% behavior, 40% recency
|
||||
const score = (affinity * 0.6) + (recency * 0.4);
|
||||
|
||||
return { ...article, relevanceScore: score };
|
||||
}).sort((a: any, b: any) => b.relevanceScore - a.relevanceScore);
|
||||
|
||||
return articles
|
||||
.map((article) => {
|
||||
const feed = this.feeds.find((f) => f.id === article.feedId);
|
||||
const feedScore = profile.feedScores[article.feedId] || 0;
|
||||
const catScore = feed ? profile.categoryScores[feed.categoryId] || 0 : 0;
|
||||
|
||||
// Affinity score (0 to 1)
|
||||
const affinity =
|
||||
totalInteractions > 0 ? (feedScore + catScore) / (totalInteractions * 2) : 0;
|
||||
|
||||
// Recency score (decays over 48 hours)
|
||||
const ageHours = (now - article.pubDate) / (1000 * 60 * 60);
|
||||
const recency = Math.max(0, 1 - ageHours / 48);
|
||||
|
||||
// Weighted final score: 60% behavior, 40% recency
|
||||
const score = affinity * 0.6 + recency * 0.4;
|
||||
|
||||
return { ...article, relevanceScore: score };
|
||||
})
|
||||
.sort((a: any, b: any) => b.relevanceScore - a.relevanceScore);
|
||||
}
|
||||
|
||||
async trackInteraction(articleId: string, type: 'click' | 'save') {
|
||||
if (!this.settings.smartFeed) return;
|
||||
|
||||
const article = this.articles.find(a => a.id === articleId);
|
||||
const article = this.articles.find((a) => a.id === articleId);
|
||||
if (!article) return;
|
||||
|
||||
const feed = this.feeds.find(f => f.id === article.feedId);
|
||||
const feed = this.feeds.find((f) => f.id === article.feedId);
|
||||
const weight = type === 'save' ? 3 : 1;
|
||||
|
||||
// Use snapshot to avoid reactive loops while updating profile
|
||||
@@ -364,7 +385,8 @@ class NewsStore {
|
||||
profile.totalInteractions += weight;
|
||||
profile.feedScores[article.feedId] = (profile.feedScores[article.feedId] || 0) + weight;
|
||||
if (feed && feed.categoryId) {
|
||||
profile.categoryScores[feed.categoryId] = (profile.categoryScores[feed.categoryId] || 0) + weight;
|
||||
profile.categoryScores[feed.categoryId] =
|
||||
(profile.categoryScores[feed.categoryId] || 0) + weight;
|
||||
}
|
||||
|
||||
this.settings.relevanceProfile = profile;
|
||||
@@ -375,7 +397,7 @@ class NewsStore {
|
||||
this.settings.relevanceProfile = {
|
||||
categoryScores: {},
|
||||
feedScores: {},
|
||||
totalInteractions: 0
|
||||
totalInteractions: 0,
|
||||
};
|
||||
await db.saveSettings($state.snapshot(this.settings));
|
||||
await this.loadArticles();
|
||||
@@ -383,7 +405,11 @@ class NewsStore {
|
||||
}
|
||||
|
||||
async resetAllData() {
|
||||
if (typeof window !== 'undefined' && !confirm('Are you sure you want to reset everything? All feeds and settings will be deleted.')) return;
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
!confirm('Are you sure you want to reset everything? All feeds and settings will be deleted.')
|
||||
)
|
||||
return;
|
||||
await db.clearAll();
|
||||
window.location.reload();
|
||||
}
|
||||
@@ -391,18 +417,39 @@ class NewsStore {
|
||||
async loadDemoData() {
|
||||
const demoCategories: Category[] = [
|
||||
{ id: 'tech', name: 'Technology', order: 0 },
|
||||
{ id: 'news', name: 'General News', order: 1 }
|
||||
{ id: 'news', name: 'General News', order: 1 },
|
||||
];
|
||||
|
||||
|
||||
const demoFeeds: Partial<Feed>[] = [
|
||||
{ id: 'https://news.ycombinator.com/rss', title: 'Hacker News', categoryId: 'tech', order: 0, enabled: true, fetchInterval: 30 },
|
||||
{ id: 'https://theverge.com/rss/index.xml', title: 'The Verge', categoryId: 'tech', order: 1, enabled: true, fetchInterval: 30 },
|
||||
{ id: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml', title: 'NYT Top Stories', categoryId: 'news', order: 0, enabled: true, fetchInterval: 30 }
|
||||
{
|
||||
id: 'https://news.ycombinator.com/rss',
|
||||
title: 'Hacker News',
|
||||
categoryId: 'tech',
|
||||
order: 0,
|
||||
enabled: true,
|
||||
fetchInterval: 30,
|
||||
},
|
||||
{
|
||||
id: 'https://theverge.com/rss/index.xml',
|
||||
title: 'The Verge',
|
||||
categoryId: 'tech',
|
||||
order: 1,
|
||||
enabled: true,
|
||||
fetchInterval: 30,
|
||||
},
|
||||
{
|
||||
id: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml',
|
||||
title: 'NYT Top Stories',
|
||||
categoryId: 'news',
|
||||
order: 0,
|
||||
enabled: true,
|
||||
fetchInterval: 30,
|
||||
},
|
||||
];
|
||||
|
||||
await db.saveCategories(demoCategories);
|
||||
await db.saveFeeds(demoFeeds as Feed[]);
|
||||
|
||||
|
||||
toast.success('Demo data loaded');
|
||||
await this.init();
|
||||
}
|
||||
@@ -410,14 +457,19 @@ class NewsStore {
|
||||
async loadMore() {
|
||||
if (this.loading || !this.hasMore) return;
|
||||
this.loading = true;
|
||||
|
||||
|
||||
let more: Article[] = [];
|
||||
const offset = this.articles.length;
|
||||
|
||||
|
||||
if (this.currentView === 'saved') {
|
||||
this.hasMore = false;
|
||||
this.hasMore = false;
|
||||
} else {
|
||||
more = await db.getArticles(this.selectedFeedId || undefined, offset, this.limit);
|
||||
more = await db.getArticles(
|
||||
this.selectedFeedId || undefined,
|
||||
offset,
|
||||
this.limit,
|
||||
this.selectedCategoryId || undefined
|
||||
);
|
||||
}
|
||||
|
||||
if (this.settings.smartFeed && this.currentView !== 'saved' && !this.selectedFeedId) {
|
||||
@@ -430,9 +482,8 @@ class NewsStore {
|
||||
|
||||
if (this.searchQuery) {
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
more = more.filter(a =>
|
||||
a.title.toLowerCase().includes(query) ||
|
||||
a.description.toLowerCase().includes(query)
|
||||
more = more.filter(
|
||||
(a) => a.title.toLowerCase().includes(query) || a.description.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -443,22 +494,31 @@ class NewsStore {
|
||||
async selectView(view: 'all' | 'saved' | 'following') {
|
||||
this.currentView = view;
|
||||
this.selectedFeedId = null;
|
||||
this.selectedCategoryId = null;
|
||||
await this.loadArticles();
|
||||
}
|
||||
|
||||
async selectFeed(feedId: string | null) {
|
||||
this.selectedFeedId = feedId;
|
||||
this.selectedCategoryId = null;
|
||||
this.currentView = 'all';
|
||||
await this.loadArticles();
|
||||
}
|
||||
|
||||
async selectCategory(categoryId: string | null) {
|
||||
this.selectedCategoryId = categoryId;
|
||||
this.selectedFeedId = null;
|
||||
this.currentView = 'all';
|
||||
await this.loadArticles();
|
||||
}
|
||||
|
||||
async toggleSave(articleId: string) {
|
||||
const isSaved = await db.toggleSave(articleId);
|
||||
const article = this.articles.find(a => a.id === articleId);
|
||||
const article = this.articles.find((a) => a.id === articleId);
|
||||
if (article) article.saved = isSaved;
|
||||
|
||||
|
||||
if (this.currentView === 'saved' && !isSaved) {
|
||||
this.articles = this.articles.filter(a => a.id !== articleId);
|
||||
this.articles = this.articles.filter((a) => a.id !== articleId);
|
||||
}
|
||||
return isSaved;
|
||||
}
|
||||
@@ -467,7 +527,7 @@ class NewsStore {
|
||||
async bulkMarkRead() {
|
||||
const ids = Array.from(this.selectedArticleIds);
|
||||
await db.bulkMarkRead(ids);
|
||||
this.articles.forEach(a => {
|
||||
this.articles.forEach((a) => {
|
||||
if (this.selectedArticleIds.has(a.id)) a.read = true;
|
||||
});
|
||||
this.selectedArticleIds.clear();
|
||||
@@ -478,7 +538,7 @@ class NewsStore {
|
||||
async bulkToggleSave() {
|
||||
const ids = Array.from(this.selectedArticleIds);
|
||||
await db.bulkToggleSave(ids);
|
||||
this.articles.forEach(a => {
|
||||
this.articles.forEach((a) => {
|
||||
if (this.selectedArticleIds.has(a.id)) a.saved = !a.saved;
|
||||
});
|
||||
this.selectedArticleIds.clear();
|
||||
@@ -490,7 +550,7 @@ class NewsStore {
|
||||
if (!confirm('Are you sure you want to delete these articles?')) return;
|
||||
const ids = Array.from(this.selectedArticleIds);
|
||||
await db.bulkDelete(ids);
|
||||
this.articles = this.articles.filter(a => !this.selectedArticleIds.has(a.id));
|
||||
this.articles = this.articles.filter((a) => !this.selectedArticleIds.has(a.id));
|
||||
this.selectedArticleIds.clear();
|
||||
this.isSelectMode = false;
|
||||
toast.success(`Deleted ${ids.length} articles`);
|
||||
@@ -503,7 +563,7 @@ class NewsStore {
|
||||
this.readingArticle = this.articles[0];
|
||||
return;
|
||||
}
|
||||
const idx = this.articles.findIndex(a => a.id === this.readingArticle.id);
|
||||
const idx = this.articles.findIndex((a) => a.id === this.readingArticle.id);
|
||||
if (idx < this.articles.length - 1) {
|
||||
this.readingArticle = this.articles[idx + 1];
|
||||
}
|
||||
@@ -511,24 +571,34 @@ class NewsStore {
|
||||
|
||||
prevArticle() {
|
||||
if (!this.readingArticle) return;
|
||||
const idx = this.articles.findIndex(a => a.id === this.readingArticle.id);
|
||||
const idx = this.articles.findIndex((a) => a.id === this.readingArticle.id);
|
||||
if (idx > 0) {
|
||||
this.readingArticle = this.articles[idx - 1];
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (this.loading && this.refreshController) {
|
||||
this.refreshController.abort();
|
||||
this.refreshController = null;
|
||||
this.loading = false;
|
||||
toast.info('Refresh cancelled');
|
||||
return;
|
||||
}
|
||||
if (this.loading || !this.isAuthenticated) return;
|
||||
|
||||
this.loading = true;
|
||||
this.refreshController = new AbortController();
|
||||
|
||||
try {
|
||||
await refreshAllFeeds();
|
||||
await refreshAllFeeds(this.refreshController.signal);
|
||||
await this.loadArticles();
|
||||
this.feeds = (await db.getFeeds()) || [];
|
||||
this.categories = (await db.getCategories()) || [];
|
||||
|
||||
// Fix orphans: if a feed has a categoryId that doesn't exist, move it to the first category
|
||||
if (this.categories.length > 0) {
|
||||
const catIds = new Set(this.categories.map(c => c.id));
|
||||
const catIds = new Set(this.categories.map((c) => c.id));
|
||||
for (const f of this.feeds) {
|
||||
if (!catIds.has(f.categoryId)) {
|
||||
f.categoryId = this.categories[0].id;
|
||||
@@ -538,7 +608,11 @@ class NewsStore {
|
||||
}
|
||||
|
||||
toast.success('Feeds refreshed');
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError' || e.message === 'Aborted') {
|
||||
console.log('Refresh aborted');
|
||||
return;
|
||||
}
|
||||
console.error('Refresh failed:', e);
|
||||
if (e instanceof Error && e.message.includes('401')) {
|
||||
this.logout();
|
||||
@@ -547,13 +621,44 @@ class NewsStore {
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.refreshController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshFeed(feedId: string) {
|
||||
if (this.loading && this.refreshController) {
|
||||
this.refreshController.abort();
|
||||
this.refreshController = null;
|
||||
this.loading = false;
|
||||
toast.info('Refresh cancelled');
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.refreshController = new AbortController();
|
||||
|
||||
try {
|
||||
await refreshFeed(feedId, this.refreshController.signal);
|
||||
this.feeds = await db.getFeeds();
|
||||
await this.loadArticles();
|
||||
toast.success('Feed refreshed');
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError' || e.message === 'Aborted') {
|
||||
console.log('Refresh aborted');
|
||||
return;
|
||||
}
|
||||
console.error('Feed refresh failed:', e);
|
||||
toast.error('Failed to refresh feed');
|
||||
this.feeds = await db.getFeeds();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.refreshController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchFullText(url: string, articleId?: string) {
|
||||
// 1. Check local cache first
|
||||
if (articleId) {
|
||||
const cached = this.articles.find(a => a.id === articleId);
|
||||
const cached = this.articles.find((a) => a.id === articleId);
|
||||
if (cached?.content) {
|
||||
return {
|
||||
title: cached.title,
|
||||
@@ -570,7 +675,9 @@ class NewsStore {
|
||||
if (this.settings.authToken) {
|
||||
headers['X-Account-Number'] = this.settings.authToken;
|
||||
}
|
||||
const response = await fetch(`${apiBase}/fulltext?url=${encodeURIComponent(url)}`, { headers });
|
||||
const response = await fetch(`${apiBase}/fulltext?url=${encodeURIComponent(url)}`, {
|
||||
headers,
|
||||
});
|
||||
if (response.status === 401) {
|
||||
this.logout();
|
||||
throw new Error('401');
|
||||
@@ -582,13 +689,14 @@ class NewsStore {
|
||||
if (articleId) {
|
||||
// Mark as read when opening
|
||||
await db.markAsRead(articleId);
|
||||
const art = this.articles.find(a => a.id === articleId);
|
||||
const art = this.articles.find((a) => a.id === articleId);
|
||||
if (art) {
|
||||
art.read = true;
|
||||
art.readAt = Date.now();
|
||||
if (data.content) art.content = data.content;
|
||||
data.feedId = art.feedId;
|
||||
}
|
||||
|
||||
|
||||
if (data.content) {
|
||||
await db.updateArticleContent(articleId, data.content);
|
||||
}
|
||||
@@ -611,20 +719,41 @@ class NewsStore {
|
||||
|
||||
async deleteFeed(id: string) {
|
||||
await db.deleteFeed(id);
|
||||
this.feeds = this.feeds.filter(f => f.id !== id);
|
||||
this.feeds = this.feeds.filter((f) => f.id !== id);
|
||||
if (this.selectedFeedId === id) this.selectFeed(null);
|
||||
toast.success('Feed removed');
|
||||
}
|
||||
|
||||
async purgeProblematicFeeds(threshold = 5) {
|
||||
const problematic = this.feeds.filter((f) => f.consecutiveErrors >= threshold);
|
||||
if (problematic.length === 0) {
|
||||
toast.info('No problematic feeds found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to remove ${problematic.length} feeds that have failed ${threshold}+ times?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
for (const feed of problematic) {
|
||||
await db.deleteFeed(feed.id);
|
||||
}
|
||||
this.feeds = this.feeds.filter((f) => f.consecutiveErrors < threshold);
|
||||
toast.success(`Removed ${problematic.length} problematic feeds`);
|
||||
}
|
||||
|
||||
async updateFeed(feed: Feed, oldId?: string) {
|
||||
const plainFeed = $state.snapshot(feed);
|
||||
if (oldId && oldId !== feed.id) {
|
||||
await db.deleteFeed(oldId);
|
||||
}
|
||||
await db.saveFeed(plainFeed);
|
||||
|
||||
|
||||
const searchId = oldId || feed.id;
|
||||
const index = this.feeds.findIndex(f => f.id === searchId);
|
||||
const index = this.feeds.findIndex((f) => f.id === searchId);
|
||||
if (index !== -1) {
|
||||
this.feeds[index] = plainFeed;
|
||||
} else {
|
||||
@@ -636,7 +765,7 @@ class NewsStore {
|
||||
async reorderFeeds(feedIds: string[]) {
|
||||
const updatedFeeds = $state.snapshot(this.feeds);
|
||||
feedIds.forEach((id, index) => {
|
||||
const feed = updatedFeeds.find(f => f.id === id);
|
||||
const feed = updatedFeeds.find((f) => f.id === id);
|
||||
if (feed) feed.order = index;
|
||||
});
|
||||
await db.saveFeeds(updatedFeeds);
|
||||
@@ -655,7 +784,7 @@ class NewsStore {
|
||||
async updateCategory(category: Category) {
|
||||
const plainCategory = $state.snapshot(category);
|
||||
await db.saveCategory(plainCategory);
|
||||
const index = this.categories.findIndex(c => c.id === category.id);
|
||||
const index = this.categories.findIndex((c) => c.id === category.id);
|
||||
if (index !== -1) this.categories[index] = plainCategory;
|
||||
toast.success('Category updated');
|
||||
}
|
||||
@@ -668,18 +797,18 @@ class NewsStore {
|
||||
|
||||
if (!confirm(`Are you sure? Feeds in this category will be moved.`)) return;
|
||||
|
||||
const fallbackCat = this.categories.find(c => c.id !== id);
|
||||
const fallbackCat = this.categories.find((c) => c.id !== id);
|
||||
if (!fallbackCat) return;
|
||||
|
||||
// Move feeds to fallback category first
|
||||
const feedsToMove = this.feeds.filter(f => f.categoryId === id);
|
||||
const feedsToMove = this.feeds.filter((f) => f.categoryId === id);
|
||||
for (const f of feedsToMove) {
|
||||
const updatedFeed = { ...$state.snapshot(f), categoryId: fallbackCat.id };
|
||||
await db.saveFeed(updatedFeed);
|
||||
}
|
||||
|
||||
await db.deleteCategory(id);
|
||||
this.categories = this.categories.filter(c => c.id !== id);
|
||||
this.categories = this.categories.filter((c) => c.id !== id);
|
||||
this.feeds = await db.getFeeds();
|
||||
toast.success('Category removed');
|
||||
}
|
||||
@@ -687,7 +816,7 @@ class NewsStore {
|
||||
async reorderCategories(catIds: string[]) {
|
||||
const updatedCats = $state.snapshot(this.categories);
|
||||
catIds.forEach((id, index) => {
|
||||
const cat = updatedCats.find(c => c.id === id);
|
||||
const cat = updatedCats.find((c) => c.id === id);
|
||||
if (cat) cat.order = index;
|
||||
});
|
||||
await db.saveCategories(updatedCats);
|
||||
@@ -697,7 +826,9 @@ class NewsStore {
|
||||
applyTheme() {
|
||||
if (typeof document === 'undefined') return;
|
||||
const theme = this.settings.theme;
|
||||
const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const isDark =
|
||||
theme === 'dark' ||
|
||||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
}
|
||||
|
||||
@@ -712,7 +843,9 @@ class NewsStore {
|
||||
startAutoFetch() {
|
||||
if (this.fetchInterval) clearInterval(this.fetchInterval);
|
||||
this.fetchInterval = setInterval(() => {
|
||||
this.refresh();
|
||||
if (this.isAuthenticated) {
|
||||
this.refresh();
|
||||
}
|
||||
}, this.settings.globalFetchInterval * 60000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,14 +23,21 @@ class ToastStore {
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
this.toasts = this.toasts.filter(t => t.id !== id);
|
||||
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
success(message: string) { this.add(message, 'success'); }
|
||||
error(message: string) { this.add(message, 'error', 5000); }
|
||||
info(message: string) { this.add(message, 'info'); }
|
||||
warning(message: string) { this.add(message, 'warning'); }
|
||||
success(message: string) {
|
||||
this.add(message, 'success');
|
||||
}
|
||||
error(message: string) {
|
||||
this.add(message, 'error', 5000);
|
||||
}
|
||||
info(message: string) {
|
||||
this.add(message, 'info');
|
||||
}
|
||||
warning(message: string) {
|
||||
this.add(message, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
export const toast = new ToastStore();
|
||||
|
||||
|
||||
@@ -77,7 +77,9 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-text-primary">Update Available</p>
|
||||
<p class="text-xs text-text-secondary mt-1">A new version is available. Reload to update.</p>
|
||||
<p class="text-xs text-text-secondary mt-1">
|
||||
A new version is available. Reload to update.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
on:click={reloadApp}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,13 @@
|
||||
import { newsStore } from '$lib/store.svelte';
|
||||
import Navbar from '../../components/Navbar.svelte';
|
||||
|
||||
let article = $state<{ title: string, link: string, description: string, imageUrl?: string, content?: string } | null>(null);
|
||||
let article = $state<{
|
||||
title: string;
|
||||
link: string;
|
||||
description: string;
|
||||
imageUrl?: string;
|
||||
content?: string;
|
||||
} | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
@@ -15,18 +21,18 @@
|
||||
const url = atob(encodedUrl);
|
||||
const apiBase = newsStore.settings.apiBaseUrl || '/api';
|
||||
const fullTextUrl = `${apiBase}/fulltext?url=${encodeURIComponent(url)}`;
|
||||
|
||||
|
||||
const response = await fetch(fullTextUrl);
|
||||
if (!response.ok) throw new Error('Failed to fetch article content');
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
article = {
|
||||
title: data.title || url,
|
||||
link: url,
|
||||
description: data.excerpt || '',
|
||||
|
||||
article = {
|
||||
title: data.title || url,
|
||||
link: url,
|
||||
description: data.excerpt || '',
|
||||
imageUrl: data.image || '',
|
||||
content: data.content || ''
|
||||
content: data.content || '',
|
||||
};
|
||||
} catch (e: any) {
|
||||
error = 'Failed to load article: ' + e.message;
|
||||
@@ -57,11 +63,41 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{article ? article.title : 'Web News - Shared Article'}</title>
|
||||
<title>{article ? article.title : 'Shared Article on Webnews'}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content={article ? article.description : 'A shared article on Webnews RSS reader'}
|
||||
/>
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:title" content={article ? article.title : 'Shared Article on Webnews'} />
|
||||
<meta
|
||||
property="og:description"
|
||||
content={article ? article.description : 'A shared article on Webnews RSS reader'}
|
||||
/>
|
||||
{#if article?.imageUrl}
|
||||
<meta property="og:image" content={article.imageUrl} />
|
||||
{:else}
|
||||
<meta property="og:image" content="/favicon.svg" />
|
||||
{/if}
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:title" content={article ? article.title : 'Shared Article on Webnews'} />
|
||||
<meta
|
||||
property="twitter:description"
|
||||
content={article ? article.description : 'A shared article on Webnews RSS reader'}
|
||||
/>
|
||||
{#if article?.imageUrl}
|
||||
<meta property="twitter:image" content={article.imageUrl} />
|
||||
{:else}
|
||||
<meta property="twitter:image" content="/favicon.svg" />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-bg-primary text-text-primary flex flex-col">
|
||||
<Navbar onAddFeed={() => window.location.href = '/'} />
|
||||
<Navbar onAddFeed={() => (window.location.href = '/')} />
|
||||
|
||||
<main class="flex-1 p-4 lg:p-8 overflow-y-auto">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
@@ -80,7 +116,7 @@
|
||||
</div>
|
||||
{:else if article}
|
||||
<div class="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<a
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-2 text-text-secondary hover:text-accent-blue transition-colors mb-4"
|
||||
>
|
||||
@@ -90,9 +126,13 @@
|
||||
|
||||
<div class="card p-6 sm:p-8 space-y-6">
|
||||
{#if article.imageUrl}
|
||||
<img src={article.imageUrl} alt="" class="w-full h-64 md:h-96 object-cover rounded-2xl border border-border-color shadow-lg" />
|
||||
<img
|
||||
src={article.imageUrl}
|
||||
alt=""
|
||||
class="w-full h-64 md:h-96 object-cover rounded-2xl border border-border-color shadow-lg"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold leading-tight tracking-tight">
|
||||
{article.title}
|
||||
@@ -109,9 +149,9 @@
|
||||
</div>
|
||||
|
||||
<div class="pt-8 border-t border-border-color flex flex-wrap gap-4">
|
||||
<a
|
||||
href={article.link}
|
||||
target="_blank"
|
||||
<a
|
||||
href={article.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-primary flex items-center gap-3 px-8 py-4 text-lg font-bold rounded-2xl"
|
||||
>
|
||||
@@ -133,7 +173,9 @@
|
||||
:global(.prose img) {
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
:global(.prose) {
|
||||
--tw-prose-body: var(--text-secondary);
|
||||
@@ -143,4 +185,3 @@
|
||||
--tw-prose-quotes: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="20" rx="4" fill="none"/>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#1a73e8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 11a9 9 0 0 1 9 9"/>
|
||||
<path d="M4 4a16 16 0 0 1 16 16"/>
|
||||
<circle cx="5" cy="19" r="1"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 288 B |
@@ -1,4 +1,4 @@
|
||||
const CACHE_VERSION = '0.1.0';
|
||||
const CACHE_VERSION = '0.2.3';
|
||||
const CACHE_NAME = `web-news-${CACHE_VERSION}`;
|
||||
const urlsToCache = ['/', '/favicon.svg', '/manifest.json'];
|
||||
|
||||
@@ -45,7 +45,7 @@ self.addEventListener('fetch', (event) => {
|
||||
}
|
||||
|
||||
// Network-First Strategy for everything else
|
||||
// This ensures you always see the latest version if online,
|
||||
// This ensures you always see the latest version if online,
|
||||
// and only see the cached version if truly offline.
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
|
||||
@@ -9,9 +9,9 @@ const config = {
|
||||
assets: 'build',
|
||||
fallback: 'index.html',
|
||||
precompress: false,
|
||||
strict: true
|
||||
})
|
||||
}
|
||||
strict: true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -11,9 +11,9 @@ const config = {
|
||||
assets: 'build',
|
||||
fallback: 'index.html',
|
||||
precompress: false,
|
||||
strict: true
|
||||
})
|
||||
}
|
||||
strict: true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -20,7 +20,5 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
typography,
|
||||
],
|
||||
plugins: [typography],
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080'
|
||||
}
|
||||
}
|
||||
'/api': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user