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