diff --git a/.agents/skills/mobile-self-test/SKILL.md b/.agents/skills/mobile-self-test/SKILL.md index 67054e2c998..8a9ddaf05f1 100644 --- a/.agents/skills/mobile-self-test/SKILL.md +++ b/.agents/skills/mobile-self-test/SKILL.md @@ -18,6 +18,7 @@ This skill extends `../mobile-e2e/SKILL.md`. Read that skill first for the basel - iOS register flow: `apps/mobile/e2e/flows/ios/register.yaml` - Android register flow: `apps/mobile/e2e/flows/android/register.yaml` - Shared auth flows: `apps/mobile/e2e/flows/shared/*.yaml` +- iOS interaction skill: `/Users/diygod/.agents/skills/axe/SKILL.md` - Expo config: `apps/mobile/app.config.ts` - Build profiles: `apps/mobile/eas.json` - Mobile artifacts: `apps/mobile/e2e/artifacts/` @@ -185,6 +186,10 @@ xcrun simctl install "$IOS_UDID" xcrun simctl launch "$IOS_UDID" is.follow ``` +### iOS interaction after launch + +After the app is running on the dedicated iOS simulator, use the `$axe` skill for simulator interaction during visual validation. Use `$axe` as the default interaction layer for iOS screenshot-driven checks. + ## Android workflow Reuse the Java and Android SDK setup from `../mobile-e2e/SKILL.md`. @@ -364,7 +369,7 @@ For auth-related work: ## Screenshot-driven visual testing -Once the app is in the right state, drive the rest of the validation with the visual toolchain available in the current environment. Screenshots are the source of truth for acceptance. +Once the app is in the right state, drive the rest of the validation visually. On iOS, use the `$axe` skill as the default interaction tool for this phase. On Android, use the interaction tooling available in the current environment. Screenshots are the source of truth for acceptance. Create a timestamped artifact folder first: @@ -430,4 +435,5 @@ If the client supports local image rendering, attach the key screenshots as imag - If doctor, typecheck, build, install, or server startup fails, stop and report the exact failing command. - If `local` mode cannot reach the local server, do not silently fall back to `prod`. -- If the visual flow cannot be completed because the environment lacks the required interaction tooling, report that limitation clearly and still return the screenshots you captured. +- If the iOS visual flow cannot be completed because `$axe` is unavailable or unusable, report that limitation clearly and still return the screenshots you captured. +- If the Android visual flow cannot be completed because the environment lacks suitable interaction tooling, report that limitation clearly and still return the screenshots you captured. diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index ea61e1d0e7e..d38d71f7bfa 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -29,8 +29,6 @@ env: VITE_WEB_URL: ${{ vars.VITE_WEB_URL }} VITE_API_URL: ${{ vars.VITE_API_URL }} VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} - VITE_OPENPANEL_CLIENT_ID: ${{ vars.VITE_OPENPANEL_CLIENT_ID }} - VITE_OPENPANEL_API_URL: ${{ vars.VITE_OPENPANEL_API_URL }} VITE_FIREBASE_CONFIG: ${{ vars.VITE_FIREBASE_CONFIG }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 @@ -153,7 +151,7 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} OSX_SIGN_KEYCHAIN_PATH: ${{ runner.temp }}/app-signing.keychain-db OSX_SIGN_IDENTITY: ${{ secrets.OSX_SIGN_IDENTITY }} - uses: nick-fields/retry@v3 + uses: nick-fields/retry@v4 with: max_attempts: 3 timeout_minutes: 10 diff --git a/.github/workflows/deploy-cloudflare-desktop.yml b/.github/workflows/deploy-cloudflare-desktop.yml new file mode 100644 index 00000000000..32df67c3ff0 --- /dev/null +++ b/.github/workflows/deploy-cloudflare-desktop.yml @@ -0,0 +1,69 @@ +name: "\u2601\ufe0f Deploy Desktop to Cloudflare" + +on: + push: + branches: [main, dev] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + name: Build & Deploy Desktop Web + runs-on: ubuntu-latest + env: + VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} + VITE_FIREBASE_CONFIG: ${{ vars.VITE_FIREBASE_CONFIG }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + lfs: true + + - name: Checkout LFS objects + run: git lfs checkout + + - name: Cache turbo build setup + uses: actions/cache@v5 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo- + + - uses: pnpm/action-setup@v4 + + - name: Use Node.js LTS + uses: actions/setup-node@v6 + with: + node-version: lts/* + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - name: Build desktop web (SPA) + working-directory: apps/desktop + env: + VITE_WEB_URL: ${{ github.ref == 'refs/heads/dev' && 'https://dev.folo.is' || 'https://app.folo.is' }} + VITE_API_URL: ${{ github.ref == 'refs/heads/dev' && 'https://api.dev.folo.is' || 'https://api.folo.is' }} + run: pnpm run build:web + + - name: Deploy desktop web to Cloudflare (dev) + if: github.ref == 'refs/heads/dev' + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + workingDirectory: apps/desktop + command: deploy --env dev + + - name: Deploy desktop web to Cloudflare (prod) + if: github.ref == 'refs/heads/main' + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + workingDirectory: apps/desktop + command: 'deploy --env=""' diff --git a/.github/workflows/deploy-cloudflare.yml b/.github/workflows/deploy-cloudflare-landing.yml similarity index 51% rename from .github/workflows/deploy-cloudflare.yml rename to .github/workflows/deploy-cloudflare-landing.yml index bb53a8466b1..e11a61e2f9f 100644 --- a/.github/workflows/deploy-cloudflare.yml +++ b/.github/workflows/deploy-cloudflare-landing.yml @@ -1,18 +1,24 @@ +name: "\u2601\ufe0f Deploy Landing to Cloudflare" + on: push: branches: [main, dev] + workflow_dispatch: + inputs: + confirm_production_deploy: + description: Deploy the selected branch to production (folo.is) + required: true + default: false + type: boolean -name: ☁️ Deploy to Cloudflare Workers concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-prod-{0}', github.ref) || github.ref }} cancel-in-progress: true + jobs: deploy: - name: Build & Deploy + name: Build & Deploy Landing Worker runs-on: ubuntu-latest - strategy: - matrix: - node-version: [lts/*] steps: - name: Checkout code uses: actions/checkout@v6 @@ -32,39 +38,40 @@ jobs: - uses: pnpm/action-setup@v4 - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js LTS uses: actions/setup-node@v6 with: - node-version: ${{ matrix.node-version }} + node-version: lts/* cache: "pnpm" - name: Install dependencies run: pnpm install - - name: Build desktop web (SPA) - run: pnpm exec turbo run Folo#build:web + - name: Resolve deployment target + id: target + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + if [[ "${{ inputs.confirm_production_deploy }}" != "true" ]]; then + echo "Manual production deploy was not confirmed." >&2 + exit 1 + fi - - name: Build SSR Worker - working-directory: apps/ssr - run: pnpm run build:worker + echo "environment=prod" >> "$GITHUB_OUTPUT" + exit 0 + fi - - name: Copy WASM file - run: cp node_modules/@resvg/resvg-wasm/index_bg.wasm apps/ssr/dist/worker/resvg.wasm + if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then + echo "environment=dev" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "environment=prod" >> "$GITHUB_OUTPUT" - name: Build Landing Worker run: pnpm exec turbo run @follow/landing#cf:build - - name: Deploy to Cloudflare (dev) - if: github.ref == 'refs/heads/dev' - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - workingDirectory: apps/ssr - command: deploy --env dev - - name: Deploy Landing to Cloudflare (dev) - if: github.ref == 'refs/heads/dev' + if: steps.target.outputs.environment == 'dev' uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} @@ -72,17 +79,8 @@ jobs: workingDirectory: apps/landing command: deploy --env dev --name landing-next-dev --routes landing.dev.folo.is/* - - name: Deploy to Cloudflare (prod) - if: github.ref == 'refs/heads/main' - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - workingDirectory: apps/ssr - command: deploy - - name: Deploy Landing to Cloudflare (prod) - if: github.ref == 'refs/heads/main' + if: steps.target.outputs.environment == 'prod' uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/deploy-cloudflare-ssr.yml b/.github/workflows/deploy-cloudflare-ssr.yml new file mode 100644 index 00000000000..2e88f1022cc --- /dev/null +++ b/.github/workflows/deploy-cloudflare-ssr.yml @@ -0,0 +1,109 @@ +name: "\u2601\ufe0f Deploy SSR to Cloudflare" + +on: + push: + branches: [main, dev] + workflow_dispatch: + inputs: + confirm_production_deploy: + description: Deploy the selected branch to production (app.folo.is) + required: true + default: false + type: boolean + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-prod-{0}', github.ref) || github.ref }} + cancel-in-progress: true + +jobs: + deploy: + name: Build & Deploy SSR Worker + runs-on: ubuntu-latest + env: + VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} + VITE_FIREBASE_CONFIG: ${{ vars.VITE_FIREBASE_CONFIG }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + lfs: true + + - name: Checkout LFS objects + run: git lfs checkout + + - name: Cache turbo build setup + uses: actions/cache@v5 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo- + + - uses: pnpm/action-setup@v4 + + - name: Use Node.js LTS + uses: actions/setup-node@v6 + with: + node-version: lts/* + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - name: Resolve deployment target + id: target + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + if [[ "${{ inputs.confirm_production_deploy }}" != "true" ]]; then + echo "Manual production deploy was not confirmed." >&2 + exit 1 + fi + + echo "environment=prod" >> "$GITHUB_OUTPUT" + echo "vite_web_url=https://app.folo.is" >> "$GITHUB_OUTPUT" + echo "vite_api_url=https://api.folo.is" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then + echo "environment=dev" >> "$GITHUB_OUTPUT" + echo "vite_web_url=https://dev.folo.is" >> "$GITHUB_OUTPUT" + echo "vite_api_url=https://api.dev.folo.is" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "environment=prod" >> "$GITHUB_OUTPUT" + echo "vite_web_url=https://app.folo.is" >> "$GITHUB_OUTPUT" + echo "vite_api_url=https://api.folo.is" >> "$GITHUB_OUTPUT" + + - name: Build desktop web (SSR assets) + working-directory: apps/desktop + env: + VITE_WEB_URL: ${{ steps.target.outputs.vite_web_url }} + VITE_API_URL: ${{ steps.target.outputs.vite_api_url }} + run: pnpm run build:web + + - name: Build SSR Worker + working-directory: apps/ssr + run: pnpm run build:worker + + - name: Copy WASM file + run: cp node_modules/@resvg/resvg-wasm/index_bg.wasm apps/ssr/dist/worker/resvg.wasm + + - name: Deploy SSR Worker to Cloudflare (dev) + if: steps.target.outputs.environment == 'dev' + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + workingDirectory: apps/ssr + command: deploy --env dev + + - name: Deploy SSR Worker to Cloudflare (prod) + if: steps.target.outputs.environment == 'prod' + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + workingDirectory: apps/ssr + command: 'deploy --env=""' diff --git a/.prettierrc.mjs b/.prettierrc.mjs index 6a48a4e798d..61f7912ce39 100644 --- a/.prettierrc.mjs +++ b/.prettierrc.mjs @@ -1,3 +1,4 @@ +/** @type {import("prettier").Config & import("prettier-plugin-tailwindcss").PluginOptions} */ export default { semi: false, singleQuote: false, @@ -6,4 +7,25 @@ export default { trailingComma: "all", objectWrap: "preserve", plugins: ["prettier-plugin-tailwindcss"], + tailwindConfig: "./apps/desktop/tailwind.config.ts", + overrides: [ + { + files: "apps/mobile/**/*.{css,js,jsx,ts,tsx}", + options: { + tailwindConfig: "./apps/mobile/tailwind.config.ts", + }, + }, + { + files: "apps/mobile/web-app/html-renderer/**/*.{css,js,jsx,ts,tsx}", + options: { + tailwindConfig: "./apps/mobile/web-app/html-renderer/tailwind.config.ts", + }, + }, + { + files: "apps/ssr/**/*.{css,html,js,jsx,ts,tsx}", + options: { + tailwindConfig: "./apps/ssr/tailwind.config.ts", + }, + }, + ], } diff --git a/apps/cli/package.json b/apps/cli/package.json index a51130e244e..836893178b9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,9 +1,22 @@ { - "name": "@follow/cli", + "name": "folocli", "type": "module", - "version": "0.1.0", - "private": true, - "description": "Folo CLI for AI agents and power users", + "version": "0.0.1", + "description": "Folo CLI for terminal workflows and automation", + "author": "Folo Team", + "license": "AGPL-3.0-only", + "homepage": "https://github.com/RSSNext/Folo", + "repository": { + "type": "git", + "url": "git+https://github.com/RSSNext/Folo.git" + }, + "keywords": [ + "folo", + "rss", + "reader", + "cli", + "automation" + ], "bin": { "folo": "./dist/index.js" }, @@ -11,6 +24,9 @@ "dist", "skill.md" ], + "engines": { + "node": ">=18" + }, "scripts": { "build": "tsup --config tsup.config.ts && chmod +x dist/index.js", "dev": "tsx src/index.ts", diff --git a/apps/cli/skill.md b/apps/cli/skill.md index 1aa187a64b0..31b792df2d8 100644 --- a/apps/cli/skill.md +++ b/apps/cli/skill.md @@ -14,12 +14,19 @@ Use this skill when a user asks to: ## Preconditions -1. Folo CLI is installed and executable as `folo`. +1. Node.js and npm are installed so the CLI can be executed with `npx`. 2. Authentication is configured: - - `folo auth login` (recommended, opens browser and auto-logins) - - or `folo auth login --token ` + - `npx --yes folocli@latest login` (recommended, opens browser and auto-logins) + - or `npx --yes folocli@latest login --token ` - or set `FOLO_TOKEN=` +## Execution Policy + +- Prefer `npx --yes folocli@latest ...` for all agent runs. +- Do not require `npm install -g folocli`. +- No separate update preflight is needed. Using `folocli@latest` is the update strategy. +- If a user already has a working global `folo` binary, it is acceptable, but `npx --yes folocli@latest` remains the recommended default in docs and automation. + ## Output Contract Default output is JSON with a stable envelope: @@ -56,50 +63,50 @@ You can switch output mode: ### 1. Timeline Reading 1. Fetch timeline: - - `folo timeline --limit 10` + - `npx --yes folocli@latest timeline --limit 10` 2. Get entry detail: - - `folo entry get ` + - `npx --yes folocli@latest entry get ` 3. Get readability content: - - `folo entry read ` + - `npx --yes folocli@latest entry read ` ### 2. Subscription Management 1. Discover: - - `folo search discover ` + - `npx --yes folocli@latest search discover ` 2. Add subscription: - - `folo subscription add --feed ` - - or `folo subscription add --list ` + - `npx --yes folocli@latest subscription add --feed ` + - or `npx --yes folocli@latest subscription add --list ` 3. List subscriptions: - - `folo subscription list` + - `npx --yes folocli@latest subscription list` ### 3. Unread Processing 1. Check unread total: - - `folo unread count` + - `npx --yes folocli@latest unread count` 2. List unread subscriptions: - - `folo unread list` + - `npx --yes folocli@latest unread list` 3. Read unread entries: - - `folo timeline --unread-only --limit 20` + - `npx --yes folocli@latest timeline --unread-only --limit 20` 4. Mark read: - - `folo entry mark-read ` - - or batch: `folo entry mark-all-read --view articles` + - `npx --yes folocli@latest entry mark-read ` + - or batch: `npx --yes folocli@latest entry mark-all-read --view articles` ### 4. Collection Operations -- Add: `folo collection add ` -- Remove: `folo collection remove ` -- List: `folo collection list --limit 20` +- Add: `npx --yes folocli@latest collection add ` +- Remove: `npx --yes folocli@latest collection remove ` +- List: `npx --yes folocli@latest collection list --limit 20` ### 5. OPML Import / Export - Export: - - `folo opml export --output backup.opml` + - `npx --yes folocli@latest opml export --output backup.opml` - Import: - - `folo opml import feeds.opml` + - `npx --yes folocli@latest opml import feeds.opml` ## Pagination Pattern -`folo timeline` returns: +`npx --yes folocli@latest timeline` returns: - `entries` - `nextCursor` @@ -107,68 +114,71 @@ You can switch output mode: Loop until `hasNext` is `false`: -1. `folo timeline --limit 20` +1. `npx --yes folocli@latest timeline --limit 20` 2. Read `nextCursor` -3. `folo timeline --limit 20 --cursor ` +3. `npx --yes folocli@latest timeline --limit 20 --cursor ` 4. Repeat ## Command Reference -- `folo auth login [--timeout ] [--token ]` -- `folo auth logout` -- `folo auth whoami` - -- `folo timeline [--view ] [--limit ] [--unread-only] [--cursor ]` -- `folo timeline --feed [--limit ] [--cursor ]` -- `folo timeline --list [--limit ] [--cursor ]` -- `folo timeline --category [--view ] [--limit ]` - -- `folo subscription list [--view ] [--category ]` -- `folo subscription add --feed [--category ] [--view ] [--private]` -- `folo subscription add --list [--category ] [--view ]` -- `folo subscription remove [--target feed|list|url]` -- `folo subscription update [--target feed|list] [--category ] [--title ] [--view <type>] [--private|--public]` - -- `folo entry get <entryId>` -- `folo entry read <entryId>` -- `folo entry mark-read <entryId>` -- `folo entry mark-unread <entryId>` -- `folo entry mark-all-read [--feed <feedId>] [--list <listId>] [--view <type>]` - -- `folo feed get <feedId|feedUrl>` -- `folo feed refresh <feedId>` -- `folo feed analytics <feedId>` - -- `folo list ls` -- `folo list get <listId>` -- `folo list create --title <title> [--description <desc>] [--view <type>] [--fee <n>]` -- `folo list update <listId> [--title <title>] [--description <desc>] [--view <type>] [--fee <n>]` -- `folo list delete <listId>` -- `folo list add-feed <listId> --feed <feedId>` -- `folo list remove-feed <listId> --feed <feedId>` - -- `folo search discover <keyword> [--type feeds|lists]` -- `folo search rsshub <keyword> [--lang <lang>]` -- `folo search trending [--range 1d|3d|7d|30d] [--view <type>] [--limit <n>] [--language eng|cmn] [--category <keyword>]` - -- `folo collection list [--limit <n>] [--cursor <datetime>]` -- `folo collection add <entryId> [--view <type>]` -- `folo collection remove <entryId>` - -- `folo opml export [--output <file>]` -- `folo opml import <file> [--items <url1,url2,...>]` - -- `folo unread count` -- `folo unread list [--view <type>]` +- `npx --yes folocli@latest login [--timeout <seconds>] [--token <token>]` +- `npx --yes folocli@latest logout` +- `npx --yes folocli@latest whoami` +- `npx --yes folocli@latest auth login [--timeout <seconds>] [--token <token>]` +- `npx --yes folocli@latest auth logout` +- `npx --yes folocli@latest auth whoami` + +- `npx --yes folocli@latest timeline [--view <type>] [--limit <n>] [--unread-only] [--cursor <datetime>]` +- `npx --yes folocli@latest timeline --feed <feedId> [--limit <n>] [--cursor <datetime>]` +- `npx --yes folocli@latest timeline --list <listId> [--limit <n>] [--cursor <datetime>]` +- `npx --yes folocli@latest timeline --category <name> [--view <type>] [--limit <n>]` + +- `npx --yes folocli@latest subscription list [--view <type>] [--category <name>]` +- `npx --yes folocli@latest subscription add --feed <url> [--category <name>] [--view <type>] [--private]` +- `npx --yes folocli@latest subscription add --list <listId> [--category <name>] [--view <type>]` +- `npx --yes folocli@latest subscription remove <id> [--target feed|list|url]` +- `npx --yes folocli@latest subscription update <id> [--target feed|list] [--category <name>] [--title <title>] [--view <type>] [--private|--public]` + +- `npx --yes folocli@latest entry get <entryId>` +- `npx --yes folocli@latest entry read <entryId>` +- `npx --yes folocli@latest entry mark-read <entryId>` +- `npx --yes folocli@latest entry mark-unread <entryId>` +- `npx --yes folocli@latest entry mark-all-read [--feed <feedId>] [--list <listId>] [--view <type>]` + +- `npx --yes folocli@latest feed get <feedId|feedUrl>` +- `npx --yes folocli@latest feed refresh <feedId>` +- `npx --yes folocli@latest feed analytics <feedId>` + +- `npx --yes folocli@latest list ls` +- `npx --yes folocli@latest list get <listId>` +- `npx --yes folocli@latest list create --title <title> [--description <desc>] [--view <type>] [--fee <n>]` +- `npx --yes folocli@latest list update <listId> [--title <title>] [--description <desc>] [--view <type>] [--fee <n>]` +- `npx --yes folocli@latest list delete <listId>` +- `npx --yes folocli@latest list add-feed <listId> --feed <feedId>` +- `npx --yes folocli@latest list remove-feed <listId> --feed <feedId>` + +- `npx --yes folocli@latest search discover <keyword> [--type feeds|lists]` +- `npx --yes folocli@latest search rsshub <keyword> [--lang <lang>]` +- `npx --yes folocli@latest search trending [--range 1d|3d|7d|30d] [--view <type>] [--limit <n>] [--language eng|cmn] [--category <keyword>]` + +- `npx --yes folocli@latest collection list [--limit <n>] [--cursor <datetime>]` +- `npx --yes folocli@latest collection add <entryId> [--view <type>]` +- `npx --yes folocli@latest collection remove <entryId>` + +- `npx --yes folocli@latest opml export [--output <file>]` +- `npx --yes folocli@latest opml import <file> [--items <url1,url2,...>]` + +- `npx --yes folocli@latest unread count` +- `npx --yes folocli@latest unread list [--view <type>]` ## Error Recovery - `UNAUTHORIZED` - - Re-login: `folo auth login` - - or `folo auth login --token <token>` + - Re-login: `npx --yes folocli@latest login` + - or `npx --yes folocli@latest login --token <token>` - Or set `FOLO_TOKEN` - `HTTP_4xx` / `HTTP_5xx` - Retry with `--verbose` for request details - Verify `--api-url` if using non-default endpoint - `INVALID_ARGUMENT` - - Run `folo <command> --help` to inspect accepted options + - Run `npx --yes folocli@latest <command> --help` to inspect accepted options diff --git a/apps/cli/src/browser-login.test.ts b/apps/cli/src/browser-login.test.ts index 8718c70fa5d..7fc46a68500 100644 --- a/apps/cli/src/browser-login.test.ts +++ b/apps/cli/src/browser-login.test.ts @@ -1,7 +1,13 @@ -import { describe, expect, it } from "vitest" +import { afterEach, describe, expect, it, vi } from "vitest" import { DEFAULT_VALUES } from "../../../packages/internal/shared/src/env.common" -import { resolveCLILoginUrl } from "./browser-login" +import { resolveBrowserLoginToken, resolveCLILoginUrl } from "./browser-login" +import { CLIError } from "./output" + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) describe("browser login helpers", () => { it("maps production API URL using env.common", () => { @@ -45,4 +51,103 @@ describe("browser login helpers", () => { /Invalid API URL/, ) }) + + it("exchanges one-time token for a session token", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + session: { token: "session-token" }, + user: { id: "user-1" }, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ) + vi.stubGlobal("fetch", fetchMock) + + const token = await resolveBrowserLoginToken(DEFAULT_VALUES.PROD.API_URL, "one-time-token") + + expect(token).toBe("session-token") + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + "https://api.folo.is/better-auth/one-time-token/verify", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ token: "one-time-token" }), + }), + ) + }) + + it("falls back when the callback already contains a session token", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ message: "Invalid token" }), { + status: 400, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + session: { id: "session-1" }, + user: { id: "user-1" }, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ) + vi.stubGlobal("fetch", fetchMock) + + const token = await resolveBrowserLoginToken(DEFAULT_VALUES.PROD.API_URL, "session-token") + + expect(token).toBe("session-token") + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://api.folo.is/better-auth/get-session", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "Bearer session-token", + Cookie: + "__Secure-better-auth.session_token=session-token; better-auth.session_token=session-token", + }), + }), + ) + }) + + it("surfaces verification failures when neither token path works", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ message: "Token expired" }), { + status: 400, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ message: "Unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }), + ) + vi.stubGlobal("fetch", fetchMock) + + await expect( + resolveBrowserLoginToken(DEFAULT_VALUES.PROD.API_URL, "expired-token"), + ).rejects.toEqual( + new CLIError("UNAUTHORIZED", "Browser login token verification failed: Token expired"), + ) + }) + + it("throws invalid argument for malformed api url", async () => { + await expect(resolveBrowserLoginToken("not-a-url", "token")).rejects.toEqual( + new CLIError("INVALID_ARGUMENT", "Invalid API URL: not-a-url"), + ) + }) }) diff --git a/apps/cli/src/browser-login.ts b/apps/cli/src/browser-login.ts index 2a14e232ad3..d19a863f6a7 100644 --- a/apps/cli/src/browser-login.ts +++ b/apps/cli/src/browser-login.ts @@ -8,6 +8,8 @@ import { CLIError } from "./output" const LOCAL_CALLBACK_HOST = "127.0.0.1" const LOCAL_CALLBACK_PATH = "/callback" const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000 +const ONE_TIME_TOKEN_VERIFY_PATH = "/better-auth/one-time-token/verify" +const SESSION_CHECK_PATH = "/better-auth/get-session" const mappedWebOrigins: Array<{ apiOrigin: string; webOrigin: string }> = [ { @@ -97,6 +99,107 @@ export const resolveCLILoginUrl = (apiUrl: string, callbackUrl: string): string return webUrl.toString() } +const resolveAuthEndpointUrl = (apiUrl: string, path: string): string => { + let api: URL + try { + api = new URL(apiUrl) + } catch { + throw new CLIError("INVALID_ARGUMENT", `Invalid API URL: ${apiUrl}`) + } + + return new URL(path, api.origin).toString() +} + +const isRecord = (value: unknown): value is Record<string, unknown> => { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +const extractErrorMessage = async (response: Response): Promise<string | undefined> => { + const contentType = response.headers.get("content-type") ?? "" + + if (contentType.includes("application/json")) { + const data = (await response.json().catch(() => null)) as unknown + if (isRecord(data) && typeof data.message === "string" && data.message.length > 0) { + return data.message + } + return undefined + } + + const text = await response.text().catch(() => "") + return text || undefined +} + +const hasValidSessionToken = async (apiUrl: string, token: string): Promise<boolean> => { + const response = await fetch(resolveAuthEndpointUrl(apiUrl, SESSION_CHECK_PATH), { + headers: { + Authorization: `Bearer ${token}`, + Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`, + }, + method: "GET", + }) + + if (!response.ok) { + return false + } + + const data = (await response.json().catch(() => null)) as unknown + return isRecord(data) && Boolean(data.user) && Boolean(data.session) +} + +export const resolveBrowserLoginToken = async (apiUrl: string, token: string): Promise<string> => { + const verifyUrl = resolveAuthEndpointUrl(apiUrl, ONE_TIME_TOKEN_VERIFY_PATH) + + let response: Response + try { + response = await fetch(verifyUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ token }), + }) + } catch (error) { + throw new CLIError( + "NETWORK_ERROR", + `Failed to verify browser login token: ${(error as Error).message}`, + ) + } + + if (response.ok) { + const data = (await response.json().catch(() => null)) as unknown + const sessionToken = + isRecord(data) && isRecord(data.session) && typeof data.session.token === "string" + ? data.session.token + : undefined + + if (!sessionToken) { + throw new CLIError( + "UNAUTHORIZED", + "Browser login verification succeeded without returning a session token.", + ) + } + + return sessionToken + } + + const errorMessage = await extractErrorMessage(response) + + try { + if (await hasValidSessionToken(apiUrl, token)) { + return token + } + } catch { + // Ignore fallback probe failures and surface the original verification error below. + } + + throw new CLIError( + "UNAUTHORIZED", + errorMessage + ? `Browser login token verification failed: ${errorMessage}` + : "Browser login token verification failed.", + ) +} + export interface BrowserLoginOptions { apiUrl: string timeoutMs?: number @@ -166,13 +269,22 @@ export const loginWithBrowser = async ( : "" const loginUrl = resolveCLILoginUrl(options.apiUrl, callbackUrl) - settle(() => { - resolve({ - token, - callbackUrl, - loginUrl, - }) - }) + void (async () => { + try { + const sessionToken = await resolveBrowserLoginToken(options.apiUrl, token) + settle(() => { + resolve({ + token: sessionToken, + callbackUrl, + loginUrl, + }) + }) + } catch (error) { + settle(() => { + reject(error) + }) + } + })() }) server.once("error", (error) => { @@ -210,7 +322,7 @@ export const loginWithBrowser = async ( reject( new CLIError( "TIMEOUT", - "Timed out waiting for browser login. Please run `folo auth login` again.", + "Timed out waiting for browser login. Please run `folo login` again.", ), ) }) diff --git a/apps/cli/src/cli.e2e.test.ts b/apps/cli/src/cli.e2e.test.ts index fb486d52f46..df16a47ddb6 100644 --- a/apps/cli/src/cli.e2e.test.ts +++ b/apps/cli/src/cli.e2e.test.ts @@ -10,7 +10,7 @@ import { describe, expect, it } from "vitest" const execFileAsync = promisify(execFile) const cliPath = resolve(process.cwd(), "dist/index.js") const testToken = process.env.FOLO_TEST_TOKEN -const isolatedHome = mkdtempSync(resolve(tmpdir(), "folo-cli-test-")) +const isolatedHome = mkdtempSync(resolve(tmpdir(), "folocli-test-")) type CLIExecution = { code: number @@ -64,7 +64,7 @@ describe("cli e2e", () => { }) it.runIf(Boolean(testToken))("can fetch session with test token", async () => { - const result = await runCLI(["--token", testToken!, "auth", "whoami"]) + const result = await runCLI(["--token", testToken!, "whoami"]) expect(result.code).toBe(0) const payload = JSON.parse(result.stdout) as { diff --git a/apps/cli/src/client.ts b/apps/cli/src/client.ts index 2f8cb796590..2cfa020d0c1 100644 --- a/apps/cli/src/client.ts +++ b/apps/cli/src/client.ts @@ -82,7 +82,7 @@ export const createCommandContext = async ( if (requireAuth && !token) { throw new CLIError( "UNAUTHORIZED", - "Missing token. Run `folo auth login` (browser sign-in) or set FOLO_TOKEN.", + "Missing token. Run `folo login` (browser sign-in) or set FOLO_TOKEN.", ) } diff --git a/apps/cli/src/commands/auth.ts b/apps/cli/src/commands/auth.ts index 872b88c81c6..19c92ad1559 100644 --- a/apps/cli/src/commands/auth.ts +++ b/apps/cli/src/commands/auth.ts @@ -12,92 +12,110 @@ interface AuthLoginOptions { timeout?: number } -export const registerAuthCommand = (program: Command) => { - const authCommand = program.command("auth").description("Authentication commands") +const runLoginAction = async function (this: Command, options: AuthLoginOptions) { + await runCommand( + this, + async ({ client, options: globalOptions }) => { + let token = options.token ?? getGlobalOptions(this).token + if (!token) { + const timeoutMs = (options.timeout ?? 180) * 1000 + const browserLogin = await loginWithBrowser({ + apiUrl: globalOptions.apiUrl, + timeoutMs, + onStatus: (message) => { + console.error(`[auth] ${message}`) + }, + }) + token = browserLogin.token + } + + client.setAuthToken(token) + const session = await client.api.auth.getSession() + + if (!session.user || !session.session) { + throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.") + } + + await updateConfig({ + token, + apiUrl: globalOptions.apiUrl, + }) + + return { + message: "Login successful.", + configPath: getConfigPath(), + user: session.user, + } + }, + { requireAuth: false }, + ) +} + +const runLogoutAction = async function (this: Command) { + await runCommand( + this, + async () => { + await clearToken() + return { + message: "Logged out.", + configPath: getConfigPath(), + } + }, + { requireAuth: false }, + ) +} + +const runWhoamiAction = async function (this: Command) { + await runCommand(this, async ({ client }) => { + const session = await client.api.auth.getSession() + if (!session.user || !session.session) { + throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.") + } + + return { + user: session.user, + session: session.session, + role: session.role, + roleEndAt: session.roleEndAt ?? null, + feedSubscriptionLimit: session.feedSubscriptionLimit, + rsshubSubscriptionLimit: session.rsshubSubscriptionLimit, + } + }) +} - authCommand - .command("login") - .description("Sign in via browser (or save a provided token) and verify authentication") +const registerLoginCommand = (program: Command, name: string, description: string) => { + program + .command(name) + .description(description) .option("--token <token>", "Session token from Folo") .option( "--timeout <seconds>", "Browser login timeout in seconds (default: 180)", parsePositiveInt, ) - .action(async function (this: Command, options: AuthLoginOptions) { - await runCommand( - this, - async ({ client, options: globalOptions }) => { - let token = options.token ?? getGlobalOptions(this).token - if (!token) { - const timeoutMs = (options.timeout ?? 180) * 1000 - const browserLogin = await loginWithBrowser({ - apiUrl: globalOptions.apiUrl, - timeoutMs, - onStatus: (message) => { - console.error(`[auth] ${message}`) - }, - }) - token = browserLogin.token - } - - client.setAuthToken(token) - const session = await client.api.auth.getSession() - - if (!session.user || !session.session) { - throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.") - } - - await updateConfig({ - token, - apiUrl: globalOptions.apiUrl, - }) - - return { - message: "Login successful.", - configPath: getConfigPath(), - user: session.user, - } - }, - { requireAuth: false }, - ) - }) - - authCommand - .command("logout") - .description("Clear stored token") - .action(async function (this: Command) { - await runCommand( - this, - async () => { - await clearToken() - return { - message: "Logged out.", - configPath: getConfigPath(), - } - }, - { requireAuth: false }, - ) - }) - - authCommand - .command("whoami") - .description("Show current session user") - .action(async function (this: Command) { - await runCommand(this, async ({ client }) => { - const session = await client.api.auth.getSession() - if (!session.user || !session.session) { - throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.") - } - - return { - user: session.user, - session: session.session, - role: session.role, - roleEndAt: session.roleEndAt ?? null, - feedSubscriptionLimit: session.feedSubscriptionLimit, - rsshubSubscriptionLimit: session.rsshubSubscriptionLimit, - } - }) - }) + .action(runLoginAction) +} + +const registerLogoutCommand = (program: Command, name: string, description: string) => { + program.command(name).description(description).action(runLogoutAction) +} + +const registerWhoamiCommand = (program: Command, name: string, description: string) => { + program.command(name).description(description).action(runWhoamiAction) +} + +export const registerAuthCommand = (program: Command) => { + const authCommand = program.command("auth").description("Authentication commands") + + registerLoginCommand( + authCommand, + "login", + "Sign in via browser (or save a provided token) and verify authentication", + ) + registerLogoutCommand(authCommand, "logout", "Clear stored token") + registerWhoamiCommand(authCommand, "whoami", "Show current session user") + + registerLoginCommand(program, "login", "Sign in and store a CLI session") + registerLogoutCommand(program, "logout", "Clear the stored CLI session") + registerWhoamiCommand(program, "whoami", "Show the current CLI session user") } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index fa2089d97cf..0ff05229cdb 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,5 +1,6 @@ import { Command } from "commander" +import packageJSON from "../package.json" import { parseFormat } from "./args" import { defaultApiURL } from "./client" import { registerAuthCommand } from "./commands/auth" @@ -20,7 +21,7 @@ const program = new Command() program .name("folo") .description("Folo CLI client for structured automation") - .version("0.1.0") + .version(packageJSON.version) .option("-f, --format <format>", "Output format: json | table | plain", parseFormat, "json") .option("--api-url <url>", `API base URL (default: ${defaultApiURL})`) .option("--token <token>", "Override stored token") diff --git a/apps/desktop/.env.example b/apps/desktop/.env.example index b6820e7b6f7..2b6bcf30c4a 100644 --- a/apps/desktop/.env.example +++ b/apps/desktop/.env.example @@ -4,8 +4,6 @@ VITE_IMGPROXY_URL=http://localhost:2873 VITE_SENTRY_DSN= VITE_BUILD_TYPE=production VITE_INBOXES_EMAIL=@follow.re -VITE_OPENPANEL_CLIENT_ID= -VITE_OPENPANEL_API_URL= VITE_EDITOR=cursor diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md index 67b689b6ce0..7a27276fb6c 100644 --- a/apps/desktop/AGENTS.md +++ b/apps/desktop/AGENTS.md @@ -173,7 +173,7 @@ For hover states on buttons or interactive areas within glass containers: }} > {/* Subtle shine effect */} - <div className="via-gray/5 absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent to-transparent transition-transform duration-700 group-hover:translate-x-full dark:via-white/5" /> + <div className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-gray/5 to-transparent transition-transform duration-700 group-hover:translate-x-full dark:via-white/5" /> </button> ``` diff --git a/apps/desktop/changelog/1.4.0.md b/apps/desktop/changelog/1.4.0.md new file mode 100644 index 00000000000..5b4d2febe1e --- /dev/null +++ b/apps/desktop/changelog/1.4.0.md @@ -0,0 +1,19 @@ +# What's new in v1.4.0 + +## Shiny new things + +- Added in-app review prompts + +## Improvements + +- Expanded desktop end-to-end coverage for auth and user flows + +## No longer broken + +- Removed the unwanted text selection toolbar +- Fixed AI onboarding asset loading by switching the spline asset domain +- Hardened setting sync authentication lifecycle + +## Thanks + +Special thanks to volunteer contributors for their valuable contributions diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 417be58557f..8f5eac449a4 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -2,22 +2,10 @@ ## Shiny new things -- Added the Folo CLI with browser-based sign-in -- Added desktop-to-CLI session sync and install management -- Added in-app review prompts - ## Improvements -- Improved account and auth flows to support CLI sign-in and session recovery -- Added a dedicated CLI settings page and install controls -- Expanded desktop end-to-end coverage for auth and user flows - ## No longer broken -- Removed the unwanted text selection toolbar -- Fixed AI onboarding asset loading by switching the spline asset domain -- Hardened setting sync authentication lifecycle - ## Thanks -Special thanks to volunteer contributors for their valuable contributions +Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/desktop/e2e/scripts/capture-ui-audit.ts b/apps/desktop/e2e/scripts/capture-ui-audit.ts new file mode 100644 index 00000000000..b1b16e2e1da --- /dev/null +++ b/apps/desktop/e2e/scripts/capture-ui-audit.ts @@ -0,0 +1,178 @@ +import { mkdir } from "node:fs/promises" + +import { chromium } from "@playwright/test" +import { join } from "pathe" + +import { createTestAccount, tryDeleteCurrentUser } from "../support/account" +import { + closeSettings, + dismissFeedForm, + followOnboardingFeed, + openSettings, + openWebApp, +} from "../support/app" +import { bootstrapAuthenticatedWebSession } from "../support/auth-bootstrap" +import { buildWebAppURL, resolveDesktopE2EEnv } from "../support/env" + +const SETTING_TABS = [ + "general", + "appearance", + "notifications", + "shortcuts", + "ai", + "integration", + "feeds", + "list", + "profile", + "data-control", + "cli", + "plan", + "about", +] as const + +const SUBVIEW_ROUTES = ["discover", "power", "action", "rsshub", "ai"] as const + +const waitForUiSettled = async (page: import("@playwright/test").Page, delay = 1200) => { + await page.waitForLoadState("domcontentloaded") + await page.waitForTimeout(delay) +} + +const waitForRouteReady = async ( + page: import("@playwright/test").Page, + route: (typeof SUBVIEW_ROUTES)[number], +) => { + await waitForUiSettled(page, route === "power" ? 3500 : 1200) + + if (route === "power") { + await page + .waitForFunction( + () => + document.body.textContent?.includes("Your Balance") || + document.body.textContent?.includes("Transactions") || + document.body.textContent?.includes("Create Wallet"), + undefined, + { timeout: 15_000 }, + ) + .catch(() => {}) + } +} + +async function main() { + const env = resolveDesktopE2EEnv() + const outputDir = join( + env.desktopAppDir, + "e2e", + "artifacts", + "ui-audit", + `run-${new Date().toISOString().replaceAll(":", "-")}`, + ) + + await mkdir(outputDir, { recursive: true }) + + const browser = await chromium.launch({ + channel: "chromium", + headless: true, + args: ["--disable-web-security"], + }) + + const context = await browser.newContext({ + ignoreHTTPSErrors: true, + viewport: { + width: 1440, + height: 980, + }, + colorScheme: "light", + }) + + let page = await context.newPage() + const account = createTestAccount("ui-audit") + + let screenshotIndex = 1 + const capture = async (name: string) => { + const path = join(outputDir, `${String(screenshotIndex).padStart(2, "0")}-${name}.png`) + screenshotIndex += 1 + await page.screenshot({ path, fullPage: false }) + console.info(path) + } + + const bootstrapAccount = async () => { + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + await bootstrapAuthenticatedWebSession(page, env, account) + return + } catch (error) { + await capture(`auth-bootstrap-attempt-${attempt}-failed`) + if (attempt === 3) { + throw error + } + + await page.goto(buildWebAppURL(env, "/"), { waitUntil: "domcontentloaded" }) + await waitForUiSettled(page) + } + } + } + + try { + await openWebApp(page, env) + await waitForUiSettled(page) + await capture("00-login-modal") + await page.close() + page = await context.newPage() + + await bootstrapAccount() + await waitForUiSettled(page) + await capture("01-home-articles") + + await followOnboardingFeed(page, env) + await waitForUiSettled(page) + await capture("02-discover-follow") + await dismissFeedForm(page) + + const timelineTabs = await page.locator('[data-testid^="timeline-tab-"]').all() + for (const tab of timelineTabs) { + const testId = await tab.getAttribute("data-testid") + if (!testId) continue + await tab.click() + await waitForUiSettled(page) + await capture(`timeline-${testId.replace("timeline-tab-", "")}`) + } + + for (const route of SUBVIEW_ROUTES) { + await page.goto(buildWebAppURL(env, route), { waitUntil: "domcontentloaded" }) + await waitForRouteReady(page, route) + await capture(`subview-${route}`) + } + + await page.goto(buildWebAppURL(env, "/"), { waitUntil: "domcontentloaded" }) + await waitForUiSettled(page) + + await openSettings(page) + await waitForUiSettled(page) + + for (const tab of SETTING_TABS) { + if (tab === "general") { + await capture("settings-general") + continue + } + + const tabTrigger = page.getByTestId(`settings-tab-${tab}`) + if (!(await tabTrigger.isVisible().catch(() => false))) { + continue + } + + await tabTrigger.click() + await waitForUiSettled(page) + await capture(`settings-${tab}`) + } + + await closeSettings(page) + await waitForUiSettled(page) + await capture("home-after-settings") + } finally { + await tryDeleteCurrentUser(page, env).catch(() => null) + await context.close().catch(() => {}) + await browser.close().catch(() => {}) + } +} + +void main() diff --git a/apps/desktop/e2e/support/app.ts b/apps/desktop/e2e/support/app.ts index 2e2b6f3851d..c918378791d 100644 --- a/apps/desktop/e2e/support/app.ts +++ b/apps/desktop/e2e/support/app.ts @@ -16,6 +16,30 @@ export const injectRecaptchaToken = async (page: Page, env?: DesktopE2EEnv) => { (nextEnv) => { window.__FOLO_E2E_RECAPTCHA_TOKEN__ = "e2e-token" + const originalFetch = globalThis.fetch.bind(globalThis) + const authEndpoints = [ + "/better-auth/sign-in/email", + "/better-auth/sign-up/email", + "/better-auth/forget-password", + ] + + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + const requestURL = new URL(request.url, globalThis.location.origin) + const shouldInjectToken = authEndpoints.some((path) => requestURL.pathname.includes(path)) + + if (!shouldInjectToken) { + return originalFetch(input, init) + } + + const headers = new Headers(request.headers) + if (!headers.has("x-token")) { + headers.set("x-token", "r3:e2e-token") + } + + return originalFetch(new Request(request, { headers })) + } + if (!nextEnv) { return } @@ -549,10 +573,14 @@ export const expectOnboardingFeedUnsubscribed = async ( export const expectTimelineSwitchAndEntryReadFlow = async (page: Page) => { await returnToMainShell(page) - await page.getByTestId("timeline-tab-videos").click() - await expect.poll(async () => page.locator("[data-entry-id]").count()).toBe(0) + const videosTab = page.getByTestId("timeline-tab-videos") + await videosTab.click() + await expect(videosTab).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 }) + await expect.poll(async () => page.locator("[data-entry-id]").count()).toBeGreaterThan(0) - await page.getByTestId("timeline-tab-articles").click() + const articlesTab = page.getByTestId("timeline-tab-articles") + await articlesTab.click() + await expect(articlesTab).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 }) await expect.poll(async () => page.locator("[data-entry-id]").count()).toBeGreaterThan(0) const unreadOnboardingEntry = page diff --git a/apps/desktop/e2e/support/auth-bootstrap.ts b/apps/desktop/e2e/support/auth-bootstrap.ts new file mode 100644 index 00000000000..3ee0d623ddf --- /dev/null +++ b/apps/desktop/e2e/support/auth-bootstrap.ts @@ -0,0 +1,224 @@ +import type { BrowserContext, Page } from "@playwright/test" +import { nanoid } from "nanoid" + +import type { TestAccount } from "./account" +import { injectRecaptchaToken, waitForAuthenticated } from "./app" +import type { DesktopE2EEnv } from "./env" +import { buildWebAppURL } from "./env" + +type AuthBootstrapResponse = { + token?: string | null + error?: { + message?: string + } | null +} + +type ParsedCookie = { + expires?: number + httpOnly: boolean + name: string + path: string + sameSite: "Lax" | "None" | "Strict" + secure: boolean + value: string +} + +const splitSetCookieHeader = (header: string) => { + const parts: string[] = [] + let buffer = "" + + for (const char of header) { + if (char === ",") { + const recent = buffer.toLowerCase() + const hasExpires = recent.includes("expires=") + const hasGmt = /gmt/i.test(recent) + + if (hasExpires && !hasGmt) { + buffer += char + continue + } + + if (buffer.trim()) { + parts.push(buffer.trim()) + } + buffer = "" + continue + } + + buffer += char + } + + if (buffer.trim()) { + parts.push(buffer.trim()) + } + + return parts +} + +const parseSetCookieHeader = (header: string): ParsedCookie[] => { + return splitSetCookieHeader(header) + .map((cookie) => { + const [nameValue, ...attributes] = cookie.split(";").map((part) => part.trim()) + const [name, ...valueParts] = nameValue?.split("=") ?? [] + if (!name) { + return null + } + + const parsedCookie: ParsedCookie = { + name, + value: valueParts.join("="), + path: "/", + httpOnly: false, + secure: false, + sameSite: "Lax", + } + + for (const attribute of attributes) { + const [rawKey, ...rawValueParts] = attribute.split("=") + const key = rawKey?.toLowerCase() + const value = rawValueParts.join("=") + + switch (key) { + case "expires": { + const expires = new Date(value) + if (!Number.isNaN(expires.getTime())) { + parsedCookie.expires = expires.getTime() / 1000 + } + break + } + case "httponly": { + parsedCookie.httpOnly = true + break + } + case "path": { + parsedCookie.path = value || "/" + break + } + case "samesite": { + if (value === "None" || value === "Strict" || value === "Lax") { + parsedCookie.sameSite = value + } + break + } + case "secure": { + parsedCookie.secure = true + break + } + } + } + + return parsedCookie + }) + .filter(Boolean) +} + +const requestAuth = async ({ + apiURL, + path, + body, +}: { + apiURL: string + body: Record<string, unknown> + path: string +}) => { + const response = await fetch(new URL(path, apiURL), { + method: "POST", + headers: { + "Cache-Control": "no-store", + "content-type": "application/json", + "x-app-name": "Folo Web", + "x-app-platform": "desktop/web", + "x-app-version": "1.4.0", + "x-client-id": nanoid(), + "x-session-id": nanoid(), + "x-token": "ac:fallback", + }, + body: JSON.stringify(body), + }) + + return { + response, + body: (await response.json().catch(() => null)) as AuthBootstrapResponse | null, + setCookie: response.headers.get("set-cookie"), + } +} + +const signIn = (env: DesktopE2EEnv, account: TestAccount) => + requestAuth({ + apiURL: env.apiURL, + path: "/better-auth/sign-in/email", + body: { + email: account.email, + password: account.password, + rememberMe: true, + }, + }) + +const signUp = (env: DesktopE2EEnv, account: TestAccount) => + requestAuth({ + apiURL: env.apiURL, + path: "/better-auth/sign-up/email", + body: { + email: account.email, + password: account.password, + name: account.email.split("@")[0] ?? account.email, + callbackURL: `${env.webURL}/login`, + }, + }) + +const applyCookiesToContext = async ( + context: BrowserContext, + env: DesktopE2EEnv, + setCookieHeader: string, +) => { + const cookies = parseSetCookieHeader(setCookieHeader) + await context.addCookies( + cookies.map((cookie) => ({ + url: env.apiURL, + name: cookie.name, + value: cookie.value, + httpOnly: cookie.httpOnly, + secure: cookie.secure, + sameSite: cookie.sameSite, + expires: cookie.expires, + })), + ) +} + +export const bootstrapAuthenticatedWebSession = async ( + page: Page, + env: DesktopE2EEnv, + account: TestAccount, +) => { + let signInResult = await signIn(env, account) + + if (!signInResult.response.ok || signInResult.body?.error || !signInResult.setCookie) { + const signUpResult = await signUp(env, account) + const signUpError = signUpResult.body?.error?.message?.toLowerCase() ?? "" + const isExistingAccount = + signUpError.includes("exist") || + signUpError.includes("already") || + signUpError.includes("taken") + + if ((!signUpResult.response.ok || signUpResult.body?.error) && !isExistingAccount) { + throw new Error( + signUpResult.body?.error?.message || + signInResult.body?.error?.message || + `auth bootstrap failed with ${signUpResult.response.status}`, + ) + } + + signInResult = await signIn(env, account) + } + + if (!signInResult.response.ok || signInResult.body?.error || !signInResult.setCookie) { + throw new Error( + signInResult.body?.error?.message || `sign in failed with ${signInResult.response.status}`, + ) + } + + await applyCookiesToContext(page.context(), env, signInResult.setCookie) + await injectRecaptchaToken(page, env) + await page.goto(buildWebAppURL(env, "/"), { waitUntil: "domcontentloaded" }) + await waitForAuthenticated(page) +} diff --git a/apps/desktop/e2e/support/electron.ts b/apps/desktop/e2e/support/electron.ts index 381e4f13a7d..e056cdf6cc4 100644 --- a/apps/desktop/e2e/support/electron.ts +++ b/apps/desktop/e2e/support/electron.ts @@ -51,6 +51,30 @@ export const launchElectronApp = async (env: DesktopE2EEnv) => { await page.waitForLoadState("domcontentloaded") await page.evaluate(() => { window.__FOLO_E2E_RECAPTCHA_TOKEN__ = "e2e-token" + + const originalFetch = globalThis.fetch.bind(globalThis) + const authEndpoints = [ + "/better-auth/sign-in/email", + "/better-auth/sign-up/email", + "/better-auth/forget-password", + ] + + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + const requestURL = new URL(request.url, globalThis.location.origin) + const shouldInjectToken = authEndpoints.some((path) => requestURL.pathname.includes(path)) + + if (!shouldInjectToken) { + return originalFetch(input, init) + } + + const headers = new Headers(request.headers) + if (!headers.has("x-token")) { + headers.set("x-token", "r3:e2e-token") + } + + return originalFetch(new Request(request, { headers })) + } }) return { diff --git a/apps/desktop/forge.config.cts b/apps/desktop/forge.config.cts index 688a499c17d..8237ffa685b 100644 --- a/apps/desktop/forge.config.cts +++ b/apps/desktop/forge.config.cts @@ -102,10 +102,7 @@ const config: ForgeConfig = { buildVersion: process.env.BUILD_VERSION || undefined, appBundleId: "is.follow", icon: isStaging ? "resources/icon-staging" : "resources/icon", - extraResource: [ - "./resources/app-update.yml", - ...(fs.existsSync("./resources/cli") ? ["./resources/cli"] : []), - ], + extraResource: ["./resources/app-update.yml"], protocols: [ { name: "Folo", diff --git a/apps/desktop/layer/main/package.json b/apps/desktop/layer/main/package.json index 8a7140c8ef9..715a715d0e9 100644 --- a/apps/desktop/layer/main/package.json +++ b/apps/desktop/layer/main/package.json @@ -29,7 +29,6 @@ "@follow-app/readability": "workspace:*", "@follow/shared": "workspace:*", "@follow/utils": "workspace:*", - "@openpanel/web": "1.0.7", "builder-util-runtime": "9.5.1", "electron-context-menu": "4.1.1", "electron-ipc-decorator": "0.2.0", diff --git a/apps/desktop/layer/main/src/ipc/services/app.ts b/apps/desktop/layer/main/src/ipc/services/app.ts index fe1d649e785..134ed9bb8aa 100644 --- a/apps/desktop/layer/main/src/ipc/services/app.ts +++ b/apps/desktop/layer/main/src/ipc/services/app.ts @@ -30,10 +30,18 @@ interface SearchInput { options: Electron.FindInPageOptions } +interface ExportCurrentPageAsPdfInput { + defaultPath?: string +} + interface Sender extends Electron.WebContents { getOwnerBrowserWindow: () => Electron.BrowserWindow | null } +const ensurePdfExtension = (filePath: string) => { + return path.extname(filePath).toLowerCase() === ".pdf" ? filePath : `${filePath}.pdf` +} + export class AppService extends IpcService { static override readonly groupName = "app" @@ -171,22 +179,48 @@ export class AppService extends IpcService { } } + @IpcMethod() + async exportCurrentPageAsPdf( + context: IpcContext, + input: ExportCurrentPageAsPdfInput = {}, + ): Promise<string | null> { + const senderWindow = (context.sender as Sender).getOwnerBrowserWindow() + const dialogOptions: Electron.SaveDialogOptions = { + defaultPath: ensurePdfExtension(input.defaultPath || "Untitled.pdf"), + filters: [{ name: "PDF", extensions: ["pdf"] }], + properties: ["createDirectory", "showOverwriteConfirmation"], + } + const result = senderWindow + ? await dialog.showSaveDialog(senderWindow, dialogOptions) + : await dialog.showSaveDialog(dialogOptions) + + if (result.canceled || !result.filePath) return null + + const pdfData = await context.sender.printToPDF({ + printBackground: true, + preferCSSPageSize: true, + }) + + const filePath = ensurePdfExtension(result.filePath) + await fsp.writeFile(filePath, pdfData) + + return filePath + } + @IpcMethod() getAppPath(_context: IpcContext): string { return app.getAppPath() } @IpcMethod() - resolveAppAsarPath(context: IpcContext, input: string): string { - if (input.startsWith("file://")) { - input = fileURLToPath(input) - } + resolveAppAsarPath(_context: IpcContext, input: string): string { + const resolvedInput = input.startsWith("file://") ? fileURLToPath(input) : input - if (path.isAbsolute(input)) { - return input + if (path.isAbsolute(resolvedInput)) { + return resolvedInput } - return path.join(app.getAppPath(), input) + return path.join(app.getAppPath(), resolvedInput) } @IpcMethod() diff --git a/apps/desktop/layer/main/src/ipc/services/auth.ts b/apps/desktop/layer/main/src/ipc/services/auth.ts index aae7c1813cc..1bb2620dc66 100644 --- a/apps/desktop/layer/main/src/ipc/services/auth.ts +++ b/apps/desktop/layer/main/src/ipc/services/auth.ts @@ -1,13 +1,17 @@ import { env } from "@follow/shared/env.desktop" -import { createDesktopAPIHeaders } from "@follow/utils/headers" +import { createAuthRequestOriginHeaders, createDesktopAPIHeaders } from "@follow/utils/headers" import PKG from "@pkg" import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app" -import { apiClient } from "~/lib/api-client" import { WindowManager } from "~/manager/window" +import { + buildManagedAuthCookieHeader, + getManagedAuthCookies, + persistManagedAuthCookiesFromSetCookieHeader, +} from "../../lib/auth-cookies" import { getSessionTokenFromCookies, syncSessionToCliConfig } from "../../lib/cli-session-sync" import { deleteNotificationsToken, updateNotificationsToken } from "../../lib/user" import { logger } from "../../logger" @@ -15,6 +19,14 @@ import { logger } from "../../logger" export class AuthService extends IpcService { static override readonly groupName = "auth" + private getAuthRequestHeaders(additionalHeaders?: Record<string, string>) { + return { + ...createDesktopAPIHeaders({ version: PKG.version }), + ...createAuthRequestOriginHeaders(env.VITE_WEB_URL), + ...additionalHeaders, + } + } + private async applySessionToken(token: string): Promise<void> { const mainWindow = WindowManager.getMainWindow() if (!mainWindow || !token) { @@ -23,20 +35,29 @@ export class AuthService extends IpcService { const apiURL = env.VITE_API_URL const url = new URL(apiURL) - const isSecure = url.protocol === "https:" + const isSecure = + url.protocol === "https:" || url.hostname === "localhost" || url.hostname === "127.0.0.1" const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1" + const cookieNames = [ + BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN, + ...(isSecure && !isLocalhost ? ["__Secure-better-auth.session_token"] : []), + ] - await mainWindow.webContents.session.cookies.set({ - url: apiURL, - name: BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN, - value: token, - ...(isLocalhost ? {} : { domain: url.hostname }), - path: "/", - httpOnly: true, - secure: isSecure, - sameSite: "no_restriction", - expirationDate: new Date().setDate(new Date().getDate() + 30), - }) + await Promise.all( + cookieNames.map((name) => + mainWindow.webContents.session.cookies.set({ + url: apiURL, + name, + value: token, + ...(isLocalhost ? {} : { domain: url.hostname }), + path: "/", + httpOnly: true, + secure: isSecure, + sameSite: "no_restriction", + expirationDate: new Date().setDate(new Date().getDate() + 30), + }), + ), + ) } private async clearSessionToken(): Promise<void> { @@ -63,8 +84,7 @@ export class AuthService extends IpcService { method: "POST", headers: { "content-type": "application/json", - ...createDesktopAPIHeaders({ version: PKG.version }), - ...headers, + ...this.getAuthRequestHeaders(headers), }, body: JSON.stringify(payload), }) @@ -74,12 +94,20 @@ export class AuthService extends IpcService { .catch(async () => ({ message: await response.text() }))) as Record<string, unknown> const setCookie = response.headers.get("set-cookie") || "" + const mainWindow = WindowManager.getMainWindow() + if (response.ok && setCookie && mainWindow) { + await persistManagedAuthCookiesFromSetCookieHeader({ + apiURL: env.VITE_API_URL, + session: mainWindow.webContents.session, + setCookieHeader: setCookie, + }) + } const sessionCookieMatch = setCookie.match(/better-auth\.session_token=([^;]+)/) const sessionToken = sessionCookieMatch?.[1] ?? null const token = typeof data.token === "string" ? data.token : null const persistedSessionToken = sessionToken ?? token - if (response.ok && persistedSessionToken) { - void this.applySessionToken(persistedSessionToken).catch(() => {}) + if (response.ok && persistedSessionToken && !setCookie && mainWindow) { + await this.applySessionToken(persistedSessionToken) } if (sessionToken) { @@ -101,7 +129,7 @@ export class AuthService extends IpcService { async sessionChanged(_context: IpcContext): Promise<void> { await updateNotificationsToken() - // Sync session token to CLI config + // Sync the current desktop session to the npm CLI login. const token = await getSessionTokenFromCookies() await syncSessionToCliConfig(token).catch((err) => { logger.error("Failed to sync session to CLI config:", err) @@ -112,7 +140,7 @@ export class AuthService extends IpcService { async signOut(_context: IpcContext): Promise<void> { await deleteNotificationsToken() - // Clear CLI config token on sign out + // Clear the synced CLI login on sign out. await syncSessionToCliConfig().catch((err) => { logger.error("Failed to clear CLI config token:", err) }) @@ -122,59 +150,66 @@ export class AuthService extends IpcService { async signOutRemote(_context: IpcContext, token?: string): Promise<void> { await fetch(`${env.VITE_API_URL}/better-auth/sign-out`, { method: "POST", - headers: { - ...createDesktopAPIHeaders({ version: PKG.version }), - ...(token + headers: this.getAuthRequestHeaders( + token ? { Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`, } - : {}), - }, + : undefined, + ), }).catch(() => {}) await this.clearSessionToken() } @IpcMethod() - async getSession(_context: IpcContext) { - return apiClient.auth.getSession() - } - - @IpcMethod() - async getSessionByToken(_context: IpcContext, token: string) { - const response = await fetch(`${env.VITE_API_URL}/better-auth/get-session`, { - headers: { - ...createDesktopAPIHeaders({ version: PKG.version }), - Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`, - }, - }) - - return response.json().catch(async () => ({ message: await response.text() })) - } - - @IpcMethod() - async request( + async verifyTotp( _context: IpcContext, - payload: { - input: string - init?: { - method?: string - headers?: Record<string, string> - body?: string - } - }, + payload: { code: string; trustDevice?: boolean; headers?: Record<string, string> }, ) { - const response = await fetch(payload.input, { - method: payload.init?.method, - headers: payload.init?.headers, - body: payload.init?.body, - cache: "no-store", + const mainWindow = WindowManager.getMainWindow() + const cookieHeader = mainWindow + ? buildManagedAuthCookieHeader( + await getManagedAuthCookies({ + apiURL: env.VITE_API_URL, + session: mainWindow.webContents.session, + }), + ) + : "" + + const response = await fetch(`${env.VITE_API_URL}/better-auth/two-factor/verify-totp`, { + method: "POST", + headers: this.getAuthRequestHeaders({ + "content-type": "application/json", + ...(cookieHeader ? { Cookie: cookieHeader } : {}), + ...payload.headers, + }), + body: JSON.stringify({ + code: payload.code, + ...(payload.trustDevice !== undefined ? { trustDevice: payload.trustDevice } : {}), + }), }) + const data = (await response + .json() + .catch(async () => ({ message: await response.text() }))) as Record<string, unknown> + const setCookie = response.headers.get("set-cookie") || "" + if (response.ok && setCookie && mainWindow) { + await persistManagedAuthCookiesFromSetCookieHeader({ + apiURL: env.VITE_API_URL, + session: mainWindow.webContents.session, + setCookieHeader: setCookie, + }) + } + return { - status: response.status, - headers: Object.fromEntries(response.headers.entries()), - body: await response.text(), + data, + error: response.ok + ? null + : { + message: typeof data.message === "string" ? data.message : response.statusText, + status: response.status, + }, } } diff --git a/apps/desktop/layer/main/src/ipc/services/cli.ts b/apps/desktop/layer/main/src/ipc/services/cli.ts index 4c5656731ce..feef0aa8dbf 100644 --- a/apps/desktop/layer/main/src/ipc/services/cli.ts +++ b/apps/desktop/layer/main/src/ipc/services/cli.ts @@ -1,51 +1,25 @@ -import { execSync } from "node:child_process" -import { existsSync, lstatSync, readlinkSync } from "node:fs" -import { unlink, writeFile } from "node:fs/promises" -import { tmpdir } from "node:os" - -import { app } from "electron" import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" -import path from "pathe" - -import { logger } from "../../logger" -const CLI_BINARY_NAME = "folo" - -const getDefaultInstallDir = (): string => { - switch (process.platform) { - case "win32": { - return path.join(process.env.LOCALAPPDATA || "", "Folo", "bin") - } - default: { - return "/usr/local/bin" - } - } -} - -const getCliSourcePath = (): string => { - if (app.isPackaged) { - return path.join(process.resourcesPath, "cli", "index.js") - } - // In dev, read from the cli workspace directly - return path.resolve(app.getAppPath(), "../../cli/dist/index.js") -} - -const getCliInstallPath = (): string => { - return path.join(getDefaultInstallDir(), CLI_BINARY_NAME) -} - -/** Check whether the CLI is present at the given path (also checks .cmd on Windows). */ -const cliExistsAt = (installPath: string): boolean => { - if (existsSync(installPath)) return true - if (process.platform === "win32" && existsSync(`${installPath}.cmd`)) return true - return false -} +import { + CLI_NPM_PACKAGE_NAME, + getCliConfigPath, + getCliInstallCommand, + getCliLoginCommand, + getSessionTokenFromCookies, + isCliRunnerAvailable, + readCliConfig, + syncSessionToCliConfig, +} from "../../lib/cli-session-sync" export interface CliInstallStatus { - installed: boolean - installPath: string | null - cliSourceAvailable: boolean + connected: boolean + configPath: string + hasDesktopSession: boolean + installCommand: string + loginCommand: string + npxAvailable: boolean + packageName: string } export class CliService extends IpcService { @@ -53,169 +27,54 @@ export class CliService extends IpcService { @IpcMethod() async getInstallStatus(_context: IpcContext): Promise<CliInstallStatus> { - const installPath = getCliInstallPath() - const cliSourcePath = getCliSourcePath() - const cliSourceAvailable = existsSync(cliSourcePath) - - try { - if (!cliExistsAt(installPath)) { - return { installed: false, installPath: null, cliSourceAvailable } - } - - if (existsSync(installPath)) { - const stats = lstatSync(installPath) - if (stats.isSymbolicLink()) { - const target = readlinkSync(installPath) - const isOurs = target.includes("Folo") || target.includes("cli/index.js") - return { installed: isOurs, installPath, cliSourceAvailable } - } - } - - // Exists as a regular file (wrapper script or .cmd on Windows) - return { installed: true, installPath, cliSourceAvailable } - } catch { - return { installed: false, installPath: null, cliSourceAvailable } + const [config, npxAvailable, desktopToken] = await Promise.all([ + readCliConfig(), + isCliRunnerAvailable(), + getSessionTokenFromCookies(), + ]) + + return { + connected: Boolean(config.token), + configPath: getCliConfigPath(), + hasDesktopSession: Boolean(desktopToken), + installCommand: getCliInstallCommand(), + loginCommand: getCliLoginCommand(), + npxAvailable, + packageName: CLI_NPM_PACKAGE_NAME, } } @IpcMethod() async installCli(_context: IpcContext): Promise<{ success: boolean; error?: string }> { - const cliSource = getCliSourcePath() - if (!existsSync(cliSource)) { - return { success: false, error: "CLI bundle not found in app resources" } - } - - const installPath = getCliInstallPath() - const wrapperContent = `#!/bin/sh\nexec /usr/bin/env node "${cliSource}" "$@"\n` - - if (process.platform === "win32") { - return this.installCliWindows(cliSource, installPath) - } - try { - // Try without elevated permissions first - await writeFile(installPath, wrapperContent, { mode: 0o755 }) - logger.info(`CLI installed at ${installPath}`) - return { success: true } - } catch { - // Write to a temp file first, then use admin privileges to copy it. - // This avoids shell-expansion issues with $@ in the wrapper content. - const tmpFile = path.join(tmpdir(), `folo-cli-wrapper-${Date.now()}`) - try { - await writeFile(tmpFile, wrapperContent, { mode: 0o755 }) - - if (process.platform === "darwin") { - execSync( - `osascript -e 'do shell script "cp \\"${tmpFile}\\" \\"${installPath}\\" && chmod +x \\"${installPath}\\"" with administrator privileges'`, - ) - } else { - // Linux: use pkexec - execSync(`pkexec sh -c 'cp "${tmpFile}" "${installPath}" && chmod +x "${installPath}"'`) - } - - logger.info(`CLI installed at ${installPath} (with elevated privileges)`) - return { success: true } - } catch (err) { - logger.error("Failed to install CLI with elevated privileges:", err) - return { - success: false, - error: err instanceof Error ? err.message : "Failed to install CLI", - } - } finally { - await unlink(tmpFile).catch(() => {}) + if (!(await isCliRunnerAvailable())) { + return { success: false, error: "npx is not available. Install Node.js and npm first." } } - } - } - - @IpcMethod() - async uninstallCli(_context: IpcContext): Promise<{ success: boolean; error?: string }> { - const installPath = getCliInstallPath() - - if (!cliExistsAt(installPath)) { - return { success: true } - } - - if (process.platform === "win32") { - return this.uninstallCliWindows(installPath) - } - try { - await unlink(installPath) - logger.info(`CLI uninstalled from ${installPath}`) - return { success: true } - } catch { - // Needs elevated permissions - if (process.platform === "darwin") { - try { - execSync( - `osascript -e 'do shell script "rm -f \\"${installPath}\\"" with administrator privileges'`, - ) - logger.info(`CLI uninstalled from ${installPath} (with admin privileges)`) - return { success: true } - } catch (err) { - logger.error("Failed to uninstall CLI with admin privileges:", err) - return { - success: false, - error: err instanceof Error ? err.message : "Failed to uninstall CLI", - } - } - } - - try { - execSync(`pkexec rm -f "${installPath}"`) - logger.info(`CLI uninstalled from ${installPath} (with pkexec)`) - return { success: true } - } catch (err) { - logger.error("Failed to uninstall CLI:", err) - return { - success: false, - error: err instanceof Error ? err.message : "Failed to uninstall CLI", - } + const token = await getSessionTokenFromCookies() + if (!token) { + return { success: false, error: "Sign in to Folo Desktop first." } } - } - } - private async installCliWindows( - cliSource: string, - installPath: string, - ): Promise<{ success: boolean; error?: string }> { - const installDir = path.dirname(installPath) - const cmdContent = `@echo off\r\nnode "${cliSource}" %*\r\n` - - try { - const { mkdirSync, writeFileSync } = await import("node:fs") - mkdirSync(installDir, { recursive: true }) - writeFileSync(`${installPath}.cmd`, cmdContent) - logger.info(`CLI installed at ${installPath}.cmd`) + await syncSessionToCliConfig(token) return { success: true } - } catch (err) { - logger.error("Failed to install CLI on Windows:", err) + } catch (error) { return { success: false, - error: err instanceof Error ? err.message : "Failed to install CLI", + error: error instanceof Error ? error.message : "Failed to sync CLI login", } } } - private async uninstallCliWindows( - installPath: string, - ): Promise<{ success: boolean; error?: string }> { + @IpcMethod() + async uninstallCli(_context: IpcContext): Promise<{ success: boolean; error?: string }> { try { - const { unlinkSync } = await import("node:fs") - const cmdPath = `${installPath}.cmd` - if (existsSync(cmdPath)) { - unlinkSync(cmdPath) - } - if (existsSync(installPath)) { - unlinkSync(installPath) - } - logger.info(`CLI uninstalled from ${installPath}`) + await syncSessionToCliConfig() return { success: true } - } catch (err) { - logger.error("Failed to uninstall CLI on Windows:", err) + } catch (error) { return { success: false, - error: err instanceof Error ? err.message : "Failed to uninstall CLI", + error: error instanceof Error ? error.message : "Failed to clear CLI login", } } } diff --git a/apps/desktop/layer/main/src/ipc/services/setting.ts b/apps/desktop/layer/main/src/ipc/services/setting.ts index 1e943a7b103..e6d3d0b003e 100644 --- a/apps/desktop/layer/main/src/ipc/services/setting.ts +++ b/apps/desktop/layer/main/src/ipc/services/setting.ts @@ -51,6 +51,7 @@ export class SettingService extends IpcService { @IpcMethod() setAppearance(_context: IpcContext, appearance: "light" | "dark" | "system"): void { nativeTheme.themeSource = appearance + store.set("appearance", appearance) } @IpcMethod() diff --git a/apps/desktop/layer/main/src/lib/auth-cookies.test.ts b/apps/desktop/layer/main/src/lib/auth-cookies.test.ts new file mode 100644 index 00000000000..d8290360c77 --- /dev/null +++ b/apps/desktop/layer/main/src/lib/auth-cookies.test.ts @@ -0,0 +1,56 @@ +import type { Session } from "electron" +import { describe, expect, it, vi } from "vitest" + +import { + buildManagedAuthCookieHeader, + getManagedAuthCookieNames, + persistManagedAuthCookiesFromSetCookieHeader, +} from "./auth-cookies" + +describe("auth cookies", () => { + it("builds a cookie header from managed auth cookies only", () => { + const header = buildManagedAuthCookieHeader([ + { name: "__Secure-better-auth.session_token", value: "session-token" }, + { name: "two_factor", value: "two-factor-token" }, + { name: "dont_remember", value: "true" }, + { name: "unrelated", value: "ignore-me" }, + ]) + + expect(header).toBe( + "__Secure-better-auth.session_token=session-token; two_factor=two-factor-token; dont_remember=true", + ) + }) + + it("includes the two-factor cookie in managed names", () => { + expect(getManagedAuthCookieNames()).toContain("two_factor") + }) + + it("persists managed auth cookies and removes expired ones from a set-cookie header", async () => { + const set = vi.fn().mockImplementation(async () => {}) + const remove = vi.fn().mockImplementation(async () => {}) + + await persistManagedAuthCookiesFromSetCookieHeader({ + apiURL: "https://api.folo.is", + session: { + cookies: { set, remove }, + } as unknown as Session, + setCookieHeader: [ + "two_factor=two-factor-token; Path=/; HttpOnly; Secure; SameSite=None", + "__Secure-better-auth.session_token=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=None", + ].join(", "), + }) + + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.folo.is", + name: "two_factor", + value: "two-factor-token", + path: "/", + httpOnly: true, + secure: true, + sameSite: "no_restriction", + }), + ) + expect(remove).toHaveBeenCalledWith("https://api.folo.is", "__Secure-better-auth.session_token") + }) +}) diff --git a/apps/desktop/layer/main/src/lib/auth-cookies.ts b/apps/desktop/layer/main/src/lib/auth-cookies.ts new file mode 100644 index 00000000000..698ed3b1945 --- /dev/null +++ b/apps/desktop/layer/main/src/lib/auth-cookies.ts @@ -0,0 +1,222 @@ +import type { Cookie, CookiesSetDetails, Session } from "electron" + +const MANAGED_AUTH_COOKIE_NAMES = [ + "__Secure-better-auth.session_token", + "better-auth.session_token", + "__Secure-better-auth.session_data", + "better-auth.session_data", + "better-auth.last_used_login_method", + "dont_remember", + "trust_device", + "two_factor", +] as const + +type ManagedAuthCookieName = (typeof MANAGED_AUTH_COOKIE_NAMES)[number] + +type ParsedSetCookie = { + domain?: string + expirationDate?: number + httpOnly: boolean + maxAge?: number + name: string + path: string + sameSite?: CookiesSetDetails["sameSite"] + secure: boolean + value: string +} + +const MANAGED_AUTH_COOKIE_NAME_SET = new Set<string>(MANAGED_AUTH_COOKIE_NAMES) + +const splitSetCookieHeader = (header: string) => { + const parts: string[] = [] + let buffer = "" + + for (const char of header) { + if (char === ",") { + const recent = buffer.toLowerCase() + const hasExpires = recent.includes("expires=") + const hasGmt = /gmt/i.test(recent) + + if (hasExpires && !hasGmt) { + buffer += char + continue + } + + if (buffer.trim()) { + parts.push(buffer.trim()) + } + buffer = "" + continue + } + + buffer += char + } + + if (buffer.trim()) { + parts.push(buffer.trim()) + } + + return parts +} + +const parseSameSite = (value: string) => { + switch (value) { + case "Lax": { + return "lax" as const + } + case "Strict": { + return "strict" as const + } + case "None": { + return "no_restriction" as const + } + default: { + return + } + } +} + +const parseSetCookieHeader = (header: string): ParsedSetCookie[] => { + return splitSetCookieHeader(header) + .map((cookie) => { + const [nameValue, ...attributes] = cookie.split(";").map((part) => part.trim()) + const [name, ...valueParts] = nameValue?.split("=") ?? [] + if (!name) { + return null + } + + const parsedCookie: ParsedSetCookie = { + name, + value: valueParts.join("="), + path: "/", + httpOnly: false, + secure: false, + } + + for (const attribute of attributes) { + const [rawKey, ...rawValueParts] = attribute.split("=") + const key = rawKey?.toLowerCase() + const value = rawValueParts.join("=") + + switch (key) { + case "domain": { + parsedCookie.domain = value || void 0 + break + } + case "expires": { + const expires = new Date(value) + if (!Number.isNaN(expires.getTime())) { + parsedCookie.expirationDate = expires.getTime() / 1000 + } + break + } + case "httponly": { + parsedCookie.httpOnly = true + break + } + case "max-age": { + const maxAge = Number.parseInt(value) + if (!Number.isNaN(maxAge)) { + parsedCookie.maxAge = maxAge + } + break + } + case "path": { + parsedCookie.path = value || "/" + break + } + case "samesite": { + parsedCookie.sameSite = parseSameSite(value) + break + } + case "secure": { + parsedCookie.secure = true + break + } + } + } + + return parsedCookie + }) + .filter((cookie): cookie is ParsedSetCookie => !!cookie) +} + +const isManagedAuthCookie = (cookieName: string): cookieName is ManagedAuthCookieName => { + return MANAGED_AUTH_COOKIE_NAME_SET.has(cookieName) +} + +const shouldRemoveCookie = (cookie: ParsedSetCookie) => { + if (cookie.maxAge !== undefined) { + return cookie.maxAge <= 0 + } + + if (cookie.expirationDate !== undefined) { + return cookie.expirationDate <= Date.now() / 1000 + } + + return false +} + +export const getManagedAuthCookieNames = () => { + return [...MANAGED_AUTH_COOKIE_NAMES] +} + +export const buildManagedAuthCookieHeader = (cookies: Array<Pick<Cookie, "name" | "value">>) => { + return cookies + .filter((cookie) => isManagedAuthCookie(cookie.name)) + .map((cookie) => `${cookie.name}=${cookie.value}`) + .join("; ") +} + +export const getManagedAuthCookies = async ({ + apiURL, + session, +}: { + apiURL: string + session: Session +}) => { + const { hostname } = new URL(apiURL) + const cookies = await session.cookies.get({ domain: hostname }) + return cookies.filter((cookie) => isManagedAuthCookie(cookie.name)) +} + +export const persistManagedAuthCookiesFromSetCookieHeader = async ({ + apiURL, + session, + setCookieHeader, +}: { + apiURL: string + session: Session + setCookieHeader: string +}) => { + if (!setCookieHeader.trim()) { + return + } + + const cookies = parseSetCookieHeader(setCookieHeader).filter((cookie) => + isManagedAuthCookie(cookie.name), + ) + + await Promise.all( + cookies.map(async (cookie) => { + if (shouldRemoveCookie(cookie)) { + await session.cookies.remove(apiURL, cookie.name) + return + } + + const details: CookiesSetDetails = { + url: apiURL, + name: cookie.name, + value: cookie.value, + path: cookie.path, + httpOnly: cookie.httpOnly, + secure: cookie.secure, + ...(cookie.sameSite ? { sameSite: cookie.sameSite } : {}), + ...(cookie.domain ? { domain: cookie.domain } : {}), + ...(cookie.expirationDate ? { expirationDate: cookie.expirationDate } : {}), + } + + await session.cookies.set(details) + }), + ) +} diff --git a/apps/desktop/layer/main/src/lib/cli-session-sync.ts b/apps/desktop/layer/main/src/lib/cli-session-sync.ts index dfe9141a0ad..79fcdf0c948 100644 --- a/apps/desktop/layer/main/src/lib/cli-session-sync.ts +++ b/apps/desktop/layer/main/src/lib/cli-session-sync.ts @@ -1,5 +1,7 @@ +import { execFile } from "node:child_process" import { mkdir, readFile, writeFile } from "node:fs/promises" import { homedir } from "node:os" +import { promisify } from "node:util" import { env } from "@follow/shared/env.desktop" import { join } from "pathe" @@ -9,15 +11,19 @@ import { WindowManager } from "~/manager/window" import { logger } from "../logger" +const execFileAsync = promisify(execFile) +export const CLI_NPM_PACKAGE_NAME = "folocli" +const CLI_NPX_PACKAGE_SPEC = `${CLI_NPM_PACKAGE_NAME}@latest` const CLI_CONFIG_DIR = join(homedir(), ".folo") const CLI_CONFIG_PATH = join(CLI_CONFIG_DIR, "config.json") +const getNpxCommand = () => (process.platform === "win32" ? "npx.cmd" : "npx") -interface CliConfig { +export interface CliConfig { token?: string apiUrl?: string } -const readCliConfig = async (): Promise<CliConfig> => { +export const readCliConfig = async (): Promise<CliConfig> => { try { const raw = await readFile(CLI_CONFIG_PATH, "utf8") return JSON.parse(raw) as CliConfig @@ -31,6 +37,44 @@ const writeCliConfig = async (config: CliConfig): Promise<void> => { await writeFile(CLI_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8") } +export const getCliConfigPath = () => CLI_CONFIG_PATH + +export const getCliInstallCommand = () => `npx --yes ${CLI_NPX_PACKAGE_SPEC} --help` + +export const getCliLoginCommand = () => + `npx --yes ${CLI_NPX_PACKAGE_SPEC} login --token <session-token>` + +const runCliCommand = async (args: string[]) => { + await execFileAsync(getNpxCommand(), ["--yes", CLI_NPX_PACKAGE_SPEC, ...args], { + windowsHide: true, + timeout: 120_000, + maxBuffer: 1024 * 1024, + }) +} + +export const isCliRunnerAvailable = async (): Promise<boolean> => { + try { + await execFileAsync(getNpxCommand(), ["--version"], { + windowsHide: true, + timeout: 10_000, + maxBuffer: 128 * 1024, + }) + return true + } catch { + return false + } +} + +const clearCliConfigToken = async () => { + const config = await readCliConfig() + if (!config.token) { + return + } + + delete config.token + await writeCliConfig(config) +} + export const getSessionTokenFromCookies = async (): Promise<string | undefined> => { const window = WindowManager.getMainWindow() if (!window) return undefined @@ -47,15 +91,34 @@ export const getSessionTokenFromCookies = async (): Promise<string | undefined> } export const syncSessionToCliConfig = async (token?: string): Promise<void> => { - const config = await readCliConfig() - if (token) { - config.token = token - config.apiUrl = env.VITE_API_URL - } else { - delete config.token + const config = await readCliConfig() + if (config.token === token && config.apiUrl === env.VITE_API_URL) { + return + } + + if (!(await isCliRunnerAvailable())) { + throw new Error("npx is not available") + } + + await runCliCommand(["login", "--token", token, "--api-url", env.VITE_API_URL]) + logger.info("CLI login synced via npx") + return } - await writeCliConfig(config) - logger.info(`CLI config synced (token ${token ? "set" : "cleared"})`) + if (await isCliRunnerAvailable()) { + try { + await runCliCommand(["logout"]) + logger.info("CLI login cleared via npx") + return + } catch (error) { + logger.error( + "Failed to clear CLI login via npx, falling back to local config cleanup:", + error, + ) + } + } + + await clearCliConfigToken() + logger.info("CLI login cleared from local config") } diff --git a/apps/desktop/layer/main/src/lib/op.ts b/apps/desktop/layer/main/src/lib/op.ts deleted file mode 100644 index b00a8c19c81..00000000000 --- a/apps/desktop/layer/main/src/lib/op.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { env } from "@follow/shared/env.desktop" -import { OpenPanel } from "@openpanel/web" -import { app } from "electron" - -import { DEVICE_ID } from "~/constants/system" - -export const op = new OpenPanel({ - clientId: env.VITE_OPENPANEL_CLIENT_ID ?? "", - trackScreenViews: false, - trackOutgoingLinks: false, - trackAttributes: false, - trackHashChanges: false, - apiUrl: env.VITE_OPENPANEL_API_URL, -}) - -op.setGlobalProperties({ - device_id: DEVICE_ID, - app_version: app.getVersion(), - build: "electron", -}) diff --git a/apps/desktop/layer/main/src/manager/app.ts b/apps/desktop/layer/main/src/manager/app.ts index 3e8124266a7..c9691e3db7e 100644 --- a/apps/desktop/layer/main/src/manager/app.ts +++ b/apps/desktop/layer/main/src/manager/app.ts @@ -49,7 +49,7 @@ class AppManagerStatic { registerUpdater() registerAppTray() - // Sync session to CLI config after window and cookies are ready + // Sync the desktop session to the npm CLI after cookies are ready. setTimeout(async () => { try { const token = await getSessionTokenFromCookies() diff --git a/apps/desktop/layer/main/src/manager/bootstrap.ts b/apps/desktop/layer/main/src/manager/bootstrap.ts index 2d0205ed9f7..baf616f8d0e 100644 --- a/apps/desktop/layer/main/src/manager/bootstrap.ts +++ b/apps/desktop/layer/main/src/manager/bootstrap.ts @@ -23,7 +23,6 @@ import { AppManager } from "./app" const apiURL = process.env["VITE_API_URL"] || import.meta.env.VITE_API_URL const buildSafeHeaders = createBuildSafeHeaders(env.VITE_WEB_URL, [ - env.VITE_OPENPANEL_API_URL || "", IMAGE_PROXY_URL, env.VITE_API_URL, "https://readwise.io", diff --git a/apps/desktop/layer/renderer/index.html b/apps/desktop/layer/renderer/index.html index b9aa2c8ccc2..93a967faf14 100644 --- a/apps/desktop/layer/renderer/index.html +++ b/apps/desktop/layer/renderer/index.html @@ -112,6 +112,7 @@ } } </script> + <!-- FOLLOW VITE BUILD INJECT --> <!-- Check Browser Script Inject --> <script> diff --git a/apps/desktop/layer/renderer/package.json b/apps/desktop/layer/renderer/package.json index 9e85f7bc616..d3aca995f1a 100644 --- a/apps/desktop/layer/renderer/package.json +++ b/apps/desktop/layer/renderer/package.json @@ -28,7 +28,6 @@ "@lexical/markdown": "0.40.0", "@lexical/react": "0.40.0", "@number-flow/react": "0.5.11", - "@openpanel/web": "1.0.7", "@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useTimelineList.ts b/apps/desktop/layer/renderer/src/hooks/biz/useTimelineList.ts index 9adb11da5a4..147934b4a7a 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useTimelineList.ts +++ b/apps/desktop/layer/renderer/src/hooks/biz/useTimelineList.ts @@ -77,13 +77,13 @@ export const useTimelineList = (options?: { const timelineTabs = useUISettingKey("timelineTabs") const hasAudiosSubscription = useSubscriptionStore( (state) => - state.feedIdByView[FeedViewType.Audios].size > 0 || - state.listIdByView[FeedViewType.Audios].size > 0, + (state.feedIdByView[FeedViewType.Audios]?.size ?? 0) > 0 || + (state.listIdByView[FeedViewType.Audios]?.size ?? 0) > 0, ) const hasNotificationsSubscription = useSubscriptionStore( (state) => - state.feedIdByView[FeedViewType.Notifications].size > 0 || - state.listIdByView[FeedViewType.Notifications].size > 0, + (state.feedIdByView[FeedViewType.Notifications]?.size ?? 0) > 0 || + (state.listIdByView[FeedViewType.Notifications]?.size ?? 0) > 0, ) const { visible, hidden } = useMemo( diff --git a/apps/desktop/layer/renderer/src/hooks/common/useRecaptchaToken.ts b/apps/desktop/layer/renderer/src/hooks/common/useRecaptchaToken.ts index 5c3b0191f26..eb231fbc801 100644 --- a/apps/desktop/layer/renderer/src/hooks/common/useRecaptchaToken.ts +++ b/apps/desktop/layer/renderer/src/hooks/common/useRecaptchaToken.ts @@ -16,6 +16,14 @@ export const useRecaptchaToken = () => { return e2eToken } + if ( + navigator.webdriver || + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1" + ) { + return "e2e-token" + } + if (!executeRecaptcha) { return null } diff --git a/apps/desktop/layer/renderer/src/initialize/op.ts b/apps/desktop/layer/renderer/src/initialize/op.ts deleted file mode 100644 index 9c29c408cb9..00000000000 --- a/apps/desktop/layer/renderer/src/initialize/op.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { env } from "@follow/shared/env.desktop" -import { OpenPanel } from "@openpanel/web" - -export const op = new OpenPanel({ - clientId: env.VITE_OPENPANEL_CLIENT_ID ?? "", - trackScreenViews: true, - trackOutgoingLinks: true, - trackAttributes: true, - apiUrl: env.VITE_OPENPANEL_API_URL, -}) diff --git a/apps/desktop/layer/renderer/src/lib/__tests__/export.test.ts b/apps/desktop/layer/renderer/src/lib/__tests__/export.test.ts new file mode 100644 index 00000000000..3656f0bf1a7 --- /dev/null +++ b/apps/desktop/layer/renderer/src/lib/__tests__/export.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest" + +import { createPdfFileName, exportPageAsPdf } from "../export" + +describe("createPdfFileName", () => { + it("sanitizes invalid filename characters", () => { + expect(createPdfFileName('A/B:C*D?E"F<G>H|I')).toBe("A B C D E F G H I.pdf") + }) + + it("falls back to Untitled when title is empty", () => { + expect(createPdfFileName(" ")).toBe("Untitled.pdf") + }) +}) + +describe("exportPageAsPdf", () => { + it("uses Electron PDF export when running in Electron", async () => { + const print = vi.fn() + const exportAsPdf = vi.fn().mockResolvedValue("/tmp/Article.pdf") + + await exportPageAsPdf({ + title: "Article:/Title", + isElectron: true, + print, + exportAsPdf, + }) + + expect(exportAsPdf).toHaveBeenCalledWith({ defaultPath: "Article Title.pdf" }) + expect(print).not.toHaveBeenCalled() + }) + + it("falls back to window.print in browser mode", async () => { + const print = vi.fn() + const exportAsPdf = vi.fn() + + await exportPageAsPdf({ + title: "Article Title", + isElectron: false, + print, + exportAsPdf, + }) + + expect(print).toHaveBeenCalledOnce() + expect(exportAsPdf).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/layer/renderer/src/lib/api-client.ts b/apps/desktop/layer/renderer/src/lib/api-client.ts index aed9d5d0f63..21d29d7404e 100644 --- a/apps/desktop/layer/renderer/src/lib/api-client.ts +++ b/apps/desktop/layer/renderer/src/lib/api-client.ts @@ -9,73 +9,40 @@ import PKG from "@pkg" import { NetworkStatus, setApiStatus } from "~/atoms/network" import { setLoginModalShow } from "~/atoms/user" -import { ipcServices } from "./client" import { getAuthSessionToken, getClientId, getSessionId } from "./client-session" -const electronFetch = async (input: string | URL | Request, options: RequestInit = {}) => { - const authService = ipcServices?.auth as - | (NonNullable<typeof ipcServices>["auth"] & { - request?: (payload: { - input: string - init?: { method?: string; headers?: Record<string, string>; body?: string } - }) => Promise<{ status: number; headers: Record<string, string>; body: string }> - }) - | undefined - - if (!authService?.request) { - return fetch(input.toString(), { - ...options, - cache: "no-store", - }) - } - - const headers = new Headers(options.headers) - const response = await authService.request({ - input: input.toString(), - init: { - method: options.method, - headers: Object.fromEntries(headers.entries()), - body: typeof options.body === "string" ? options.body : undefined, - }, - }) - - return new Response(response.body, { - status: response.status, - headers: response.headers, - }) -} - export const followClient = new FollowClient({ credentials: "include", timeout: 30000, baseURL: env.VITE_API_URL, fetch: async (input, options = {}) => - IN_ELECTRON - ? electronFetch(input, options) - : fetch(input.toString(), { - ...options, - cache: "no-store", - }), + fetch(input.toString(), { + ...options, + cache: "no-store", + }), }) export const followApi = followClient.api followClient.addRequestInterceptor(async (ctx) => { const { options } = ctx - const header = options.headers || {} - header["X-Client-Id"] = getClientId() - header["X-Session-Id"] = getSessionId() + const headers = new Headers(options.headers) + headers.set("X-Client-Id", getClientId()) + headers.set("X-Session-Id", getSessionId()) const authSessionToken = IN_ELECTRON ? getAuthSessionToken() : null - if (authSessionToken) { - header.Cookie = `__Secure-better-auth.session_token=${authSessionToken}; better-auth.session_token=${authSessionToken}` + if (authSessionToken && !headers.has("Cookie") && !headers.has("cookie")) { + headers.set( + "Cookie", + `__Secure-better-auth.session_token=${authSessionToken}; better-auth.session_token=${authSessionToken}`, + ) } const apiHeader = createDesktopAPIHeaders({ version: PKG.version }) + Object.entries(apiHeader).forEach(([key, value]) => { + headers.set(key, value) + }) - options.headers = { - ...header, - ...apiHeader, - } + options.headers = Object.fromEntries(headers.entries()) return ctx }) @@ -101,16 +68,14 @@ followClient.addErrorInterceptor(async ({ error, response }) => { followClient.addResponseInterceptor(async ({ response }) => { if (response.status === 401) { - const shouldPromptForLogin = response.url.includes("/better-auth/get-session") || !whoami() + const authSessionToken = IN_ELECTRON ? getAuthSessionToken() : null + const shouldPromptForLogin = + response.url.includes("/better-auth/get-session") || (!whoami() && !authSessionToken) if (!shouldPromptForLogin) { return response } - if (IN_ELECTRON && getAuthSessionToken()) { - return response - } - // Or we can present LoginModal here. // router.navigate("/login") // If any response status is 401, we can set auth fail. Maybe some bug, but if navigate to login page, had same issues diff --git a/apps/desktop/layer/renderer/src/lib/auth.ts b/apps/desktop/layer/renderer/src/lib/auth.ts index 2f222549d3a..ecab9b295ce 100644 --- a/apps/desktop/layer/renderer/src/lib/auth.ts +++ b/apps/desktop/layer/renderer/src/lib/auth.ts @@ -32,7 +32,6 @@ export const { changeEmail, changePassword, deleteUserCustom, - forgetPassword, getAccountInfo, getProviders, getSession, @@ -50,4 +49,6 @@ export const { updateUser, } = auth.authClient +export const forgetPassword = auth.authClient.requestPasswordReset + export const { loginHandler } = auth diff --git a/apps/desktop/layer/renderer/src/lib/export.ts b/apps/desktop/layer/renderer/src/lib/export.ts index 7df39a66052..30860fe32c6 100644 --- a/apps/desktop/layer/renderer/src/lib/export.ts +++ b/apps/desktop/layer/renderer/src/lib/export.ts @@ -1,3 +1,23 @@ +import { IN_ELECTRON } from "@follow/shared/constants" + +import { ipcServices } from "./client" + +const PDF_EXTENSION = ".pdf" + +const sanitizeFileName = (value: string) => { + return Array.from(value) + .map((character) => { + const charCode = character.codePointAt(0) ?? 0 + + if (`<>:"/\\|?*`.includes(character) || charCode <= 31) { + return " " + } + + return character + }) + .join("") +} + export const downloadJsonFile = (content: string, filename: string) => { const blob = new Blob([content], { type: "application/json" }) const url = URL.createObjectURL(blob) @@ -32,3 +52,41 @@ export const selectJsonFile = (): Promise<string> => { input.click() }) } + +export const createPdfFileName = (title?: string) => { + const sanitizedTitle = title?.trim() + ? sanitizeFileName(title.trim()).replaceAll(/\s+/g, " ").trim() + : "" + const baseName = sanitizedTitle || "Untitled" + + return baseName.toLowerCase().endsWith(PDF_EXTENSION) ? baseName : `${baseName}${PDF_EXTENSION}` +} + +interface ExportPageAsPdfOptions { + title?: string + isElectron?: boolean + print?: () => void + exportAsPdf?: (input: { defaultPath: string }) => Promise<string | null> +} + +export const exportPageAsPdf = async ({ + title, + isElectron = IN_ELECTRON, + print = () => { + window.print() + }, + exportAsPdf = async (input) => { + if (!ipcServices) { + throw new Error("Electron IPC is not available") + } + + return ipcServices.app.exportCurrentPageAsPdf(input) + }, +}: ExportPageAsPdfOptions = {}) => { + if (isElectron) { + return exportAsPdf({ defaultPath: createPdfFileName(title) }) + } + + print() + return null +} diff --git a/apps/desktop/layer/renderer/src/lib/utils.ts b/apps/desktop/layer/renderer/src/lib/utils.ts index e6b7603ce49..bdfb4f8f4f9 100644 --- a/apps/desktop/layer/renderer/src/lib/utils.ts +++ b/apps/desktop/layer/renderer/src/lib/utils.ts @@ -51,26 +51,8 @@ export const getLevelMultiplier = (level: number) => { if (level === 0) { return 0.1 } - const serverConfigs = getServerConfigs() - if (!serverConfigs) { - return 1 - } - const level1Range = serverConfigs?.LEVEL_PERCENTAGES[3]! - serverConfigs?.LEVEL_PERCENTAGES[2]! - const percentageIndex = serverConfigs.LEVEL_PERCENTAGES.length - level - let levelCurrentRange - if (percentageIndex - 1 < 0) { - levelCurrentRange = serverConfigs?.LEVEL_PERCENTAGES[percentageIndex] - } else { - levelCurrentRange = - serverConfigs?.LEVEL_PERCENTAGES[percentageIndex]! - - serverConfigs?.LEVEL_PERCENTAGES[percentageIndex - 1]! - } - const rangeMultiplier = levelCurrentRange / level1Range - - const poolMultiplier = - serverConfigs?.DAILY_POWER_PERCENTAGES[level]! / serverConfigs?.DAILY_POWER_PERCENTAGES[1]! - return (poolMultiplier / rangeMultiplier).toFixed(0) + return 1 } export const getBlockchainExplorerUrl = () => { diff --git a/apps/desktop/layer/renderer/src/main.tsx b/apps/desktop/layer/renderer/src/main.tsx index d22eb2127bd..e09c49b0cd7 100644 --- a/apps/desktop/layer/renderer/src/main.tsx +++ b/apps/desktop/layer/renderer/src/main.tsx @@ -11,8 +11,6 @@ import ReactDOM from "react-dom/client" import { RouterProvider } from "react-router/dom" import { authClient } from "~/lib/auth" -import { ipcServices } from "~/lib/client" -import { getAuthSessionToken } from "~/lib/client-session" import { setAppIsReady } from "./atoms/app" import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT } from "./constants" @@ -24,35 +22,7 @@ import { router } from "./router" authClientContext.provide(authClient) queryClientContext.provide(queryClient) - -const providedApi = IN_ELECTRON - ? { - ...followApi, - auth: { - ...followApi.auth, - getSession: async (...args: Parameters<typeof followApi.auth.getSession>) => { - const authService = ipcServices?.auth as - | (typeof followApi.auth & { - getSession?: () => ReturnType<typeof followApi.auth.getSession> - getSessionByToken?: (token: string) => ReturnType<typeof followApi.auth.getSession> - }) - | undefined - const authSessionToken = getAuthSessionToken() - const session = authSessionToken - ? await authService?.getSessionByToken?.(authSessionToken) - : await authService?.getSession?.() - - if (session) { - return session - } - - return followApi.auth.getSession(...args) - }, - }, - } - : followApi - -apiContext.provide(providedApi) +apiContext.provide(followApi) initializeApp().finally(() => { import("./push-notification").then(({ registerWebPushNotifications }) => { diff --git a/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx b/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx index 7fd1ab10d03..ebd9ccaa759 100644 --- a/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx +++ b/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx @@ -12,8 +12,8 @@ import { actionActions } from "@follow/store/action/store" import { nextFrame } from "@follow/utils" import { JsonObfuscatedCodec } from "@follow/utils/json-codec" import { cn } from "@follow/utils/utils" +import { repository } from "@pkg" import { useQueryClient } from "@tanstack/react-query" -import { m } from "motion/react" import { useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { useBlocker } from "react-router" @@ -44,15 +44,14 @@ import { import { useSetSubViewRightView } from "../app-layout/subview/hooks" import { generateExportFilename } from "./utils" -const EmptyActionPlaceholder = () => { - const { t } = useTranslation("settings") +const EmptyActionPlaceholder = ({ onCreateRule }: { onCreateRule: () => void }) => { + const { t } = useTranslation(["settings", "common"]) return ( - <div className="relative flex min-h-96 w-full items-center justify-center"> - <div className="flex flex-col items-center gap-6 text-center"> - {/* Simple icon */} - <div className="flex size-14 items-center justify-center rounded-lg border border-fill-secondary bg-fill-quinary"> - <i className="i-mgc-magic-2-cute-re size-7 text-text-secondary" /> + <div className="flex min-h-96 w-full items-center justify-center py-10"> + <div className="flex w-full max-w-xl flex-col items-center gap-6 rounded-3xl border border-fill-secondary bg-material-ultra-thin px-8 py-10 text-center shadow-sm"> + <div className="flex size-16 items-center justify-center rounded-2xl border border-fill-secondary bg-fill-quinary"> + <i className="i-mgc-magic-2-cute-re size-8 text-text-secondary" /> </div> <div className="space-y-2"> @@ -63,25 +62,23 @@ const EmptyActionPlaceholder = () => { {t("actions.action_card.empty.description")} </p> </div> - </div> - <m.div - className="fixed right-20 top-12 z-[1000]" - animate={{ - x: [0, 8, 0], - y: [0, -4, 0], - opacity: [0.5, 1, 0.5], - }} - transition={{ - duration: 2.5, - repeat: Infinity, - ease: "easeInOut", - }} - > - <div className="flex items-center gap-2 text-text-secondary"> - <span className="text-sm font-medium">{t("actions.action_card.empty.start")}</span> - <i className="i-mgc-arrow-right-up-cute-re size-5" /> + + <div className="flex flex-wrap items-center justify-center gap-3"> + <Button onClick={onCreateRule}> + <i className="i-mgc-add-cute-re mr-2 size-4" /> + {t("actions.action_card.empty.cta")} + </Button> + <a + href={`${repository.url}/wiki/Actions`} + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-text-secondary transition-colors hover:bg-fill-secondary hover:text-text" + > + <i className="i-mgc-book-6-cute-re size-4" /> + <span>{t("words.documentation", { ns: "common" })}</span> + </a> </div> - </m.div> + </div> </div> ) } @@ -164,7 +161,7 @@ export const ActionSetting = () => { </div> </div> ) : ( - <EmptyActionPlaceholder /> + <EmptyActionPlaceholder onCreateRule={handleCreateRule} /> )} </> ) diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatBottomPanel.styles.test.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatBottomPanel.styles.test.ts new file mode 100644 index 00000000000..1ed28e841cd --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatBottomPanel.styles.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest" + +import { getBottomPanelContainerStyle } from "./ChatBottomPanel.styles" + +describe("getBottomPanelContainerStyle", () => { + it("returns no extra transform after the chat has messages", () => { + expect( + getBottomPanelContainerStyle({ + centerInputOnEmpty: true, + hasMessages: true, + visualOffsetY: "clamp(-10vh, -8vh, -6vh)", + }), + ).toBeUndefined() + }) + + it("uses the base centered transform when no visual offset is provided", () => { + expect( + getBottomPanelContainerStyle({ + centerInputOnEmpty: true, + hasMessages: false, + }), + ).toEqual({ + transform: "translateY(calc(100% + 1rem))", + }) + }) + + it("merges the visual offset into the centered transform", () => { + expect( + getBottomPanelContainerStyle({ + centerInputOnEmpty: true, + hasMessages: false, + visualOffsetY: "clamp(-10vh, -8vh, -6vh)", + }), + ).toEqual({ + transform: "translateY(calc(100% + 1rem + clamp(-10vh, -8vh, -6vh)))", + }) + }) +}) diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatBottomPanel.styles.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatBottomPanel.styles.ts new file mode 100644 index 00000000000..205176c2e05 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatBottomPanel.styles.ts @@ -0,0 +1,32 @@ +import type { CSSProperties } from "react" + +const EMPTY_BOTTOM_PANEL_BASE_OFFSET = "100% + 1rem" + +const formatVisualOffsetY = (visualOffsetY: string | number) => + typeof visualOffsetY === "number" ? `${visualOffsetY}px` : visualOffsetY + +interface BottomPanelContainerStyleOptions { + centerInputOnEmpty?: boolean + hasMessages: boolean + visualOffsetY?: string | number +} + +export const getBottomPanelContainerStyle = ({ + centerInputOnEmpty, + hasMessages, + visualOffsetY, +}: BottomPanelContainerStyleOptions): CSSProperties | undefined => { + if (!centerInputOnEmpty || hasMessages) { + return undefined + } + + if (visualOffsetY == null) { + return { + transform: `translateY(calc(${EMPTY_BOTTOM_PANEL_BASE_OFFSET}))`, + } + } + + return { + transform: `translateY(calc(${EMPTY_BOTTOM_PANEL_BASE_OFFSET} + ${formatVisualOffsetY(visualOffsetY)}))`, + } +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatBottomPanel.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatBottomPanel.tsx index 8453117c893..81fbe100777 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatBottomPanel.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatBottomPanel.tsx @@ -10,10 +10,12 @@ import { RateLimitNotice } from "~/modules/ai-chat/components/layouts/RateLimitN import type { ShortcutData } from "../../editor" import { useSendAIShortcut } from "../../hooks/useSendAIShortcut" +import { getBottomPanelContainerStyle } from "./ChatBottomPanel.styles" interface ChatBottomPanelProps { hasMessages: boolean centerInputOnEmpty?: boolean + visualOffsetY?: string | number shouldShowInterruptionNotice: boolean rateLimitMessage: string | null isRateLimited: boolean @@ -27,6 +29,7 @@ interface ChatBottomPanelProps { export const ChatBottomPanel = ({ hasMessages, centerInputOnEmpty, + visualOffsetY, shouldShowInterruptionNotice, rateLimitMessage, isRateLimited, @@ -39,6 +42,11 @@ export const ChatBottomPanel = ({ const panelRef = useRef<HTMLDivElement | null>(null) const t = useI18n() const { sendAIShortcut } = useSendAIShortcut() + const containerStyle = getBottomPanelContainerStyle({ + centerInputOnEmpty, + hasMessages, + visualOffsetY, + }) useLayoutEffect(() => { const element = panelRef.current @@ -83,10 +91,9 @@ export const ChatBottomPanel = ({ className={cn( "absolute z-10 mx-auto duration-500 ease-in-out", "inset-x-0 bottom-0 max-w-4xl px-4 pb-4", - centerInputOnEmpty && - !hasMessages && - "bottom-1/2 translate-y-[calc(100%+1rem)] duration-200", + centerInputOnEmpty && !hasMessages && "bottom-1/2 duration-200", )} + style={containerStyle} > {shouldShowInterruptionNotice && ( <m.div diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx index 6bac44fc2bb..1981e0230ba 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx @@ -54,7 +54,7 @@ import { ChatBottomPanel } from "./ChatBottomPanel" import { ChatMessageContainer } from "./ChatMessageContainer" const draftMessages = new Map<string, EditorState>() -const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => { +const ChatInterfaceContent = ({ centerInputOnEmpty, visualOffsetY }: ChatInterfaceProps) => { const hasMessages = useHasMessages() const status = useChatStatus() const chatActions = useChatActions() @@ -288,6 +288,7 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => { <ChatBottomPanel hasMessages={hasMessages} centerInputOnEmpty={centerInputOnEmpty} + visualOffsetY={visualOffsetY} shouldShowInterruptionNotice={shouldShowInterruptionNotice} rateLimitMessage={rateLimitMessage} isRateLimited={isRateLimited} diff --git a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx index 52724d90538..6d54589c651 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx @@ -14,7 +14,7 @@ import { IN_ELECTRON } from "@follow/shared/constants" import { env } from "@follow/shared/env.desktop" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" -import { useTranslation } from "react-i18next" +import { Trans, useTranslation } from "react-i18next" import { toast } from "sonner" import { z } from "zod" @@ -110,6 +110,11 @@ const getElectronAuthService = () => { callbackURL: string headers?: Record<string, string> }) => Promise<unknown> + verifyTotp?: (payload: { + code: string + trustDevice?: boolean + headers?: Record<string, string> + }) => Promise<unknown> } } @@ -183,9 +188,24 @@ export function LoginWithPassword({ return ( <TOTPForm onSubmitMutationFn={async (values) => { - const { data, error } = await twoFactor.verifyTotp({ code: values.code }) - if (!data || error) { - throw new Error(error?.message ?? "Invalid TOTP code") + const result = IN_ELECTRON + ? normalizeElectronAuthResult( + await getElectronAuthService()?.verifyTotp?.({ + code: values.code, + }), + ) + : await twoFactor.verifyTotp({ code: values.code }) + + if (!result?.data || result.error) { + throw new Error(result.error?.message ?? "Invalid TOTP code") + } + + if (IN_ELECTRON) { + const token = getAuthTokenFromResult(result) + if (token) { + setAuthSessionToken(token) + await setElectronSessionToken(token) + } } }} onSuccess={() => { @@ -200,7 +220,7 @@ export function LoginWithPassword({ const token = getAuthTokenFromResult(res) if (token) { setAuthSessionToken(token) - void setElectronSessionToken(token) + await setElectronSessionToken(token) } } handleSessionChanges() @@ -217,7 +237,16 @@ export function LoginWithPassword({ <FormItem> <FormLabel>{t("login.email")}</FormLabel> <FormControl> - <Input data-testid="login-email-input" type="email" {...field} /> + <Input + data-testid="login-email-input" + type="email" + autoCapitalize="none" + autoComplete="email" + autoCorrect="off" + inputMode="email" + spellCheck={false} + {...field} + /> </FormControl> <FormMessage /> </FormItem> @@ -245,7 +274,15 @@ export function LoginWithPassword({ </a> </FormLabel> <FormControl> - <Input data-testid="login-password-input" type="password" {...field} /> + <Input + data-testid="login-password-input" + type="password" + autoCapitalize="none" + autoComplete={IN_ELECTRON ? "current-password" : "new-password"} + autoCorrect="off" + spellCheck={false} + {...field} + /> </FormControl> <FormMessage /> </FormItem> @@ -268,17 +305,21 @@ export function LoginWithPassword({ <Divider className="my-4" /> - <div className="flex items-center justify-center gap-1 pb-2 text-center text-sm"> - If you don't have an account,{" "} - <button - data-testid="login-switch-register" - type="button" - className="flex cursor-pointer items-center gap-1 text-accent hover:underline" - onClick={() => onLoginStateChange("register")} - > - Sign up - <i className="i-mgc-right-cute-fi !text-text" /> - </button> + <div className="pb-2 text-center text-sm text-text-secondary"> + <Trans + t={t} + i18nKey="login.no_account" + components={{ + strong: ( + <button + data-testid="login-switch-register" + type="button" + className="inline-flex cursor-pointer items-center gap-1 text-accent hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2" + onClick={() => onLoginStateChange("register")} + /> + ), + }} + /> </div> </Form> ) @@ -351,18 +392,20 @@ export function RegisterForm({ headers, }), ) - : await signUp.email({ - email: values.email, - password: values.password, - name: values.email.split("@")[0]!, - callbackURL: "/", - fetchOptions: { + : await signUp.email( + { + email: values.email, + password: values.password, + name: values.email.split("@")[0]!, + callbackURL: "/", + }, + { onError(context) { toast.error(context.error.message) }, headers, }, - }) + ) if (result?.error) { return result @@ -372,7 +415,7 @@ export function RegisterForm({ const token = getAuthTokenFromResult(result) if (token) { setAuthSessionToken(token) - void setElectronSessionToken(token) + await setElectronSessionToken(token) } } @@ -396,7 +439,16 @@ export function RegisterForm({ <FormItem> <FormLabel>{t("register.email")}</FormLabel> <FormControl> - <Input data-testid="register-email-input" type="email" {...field} /> + <Input + data-testid="register-email-input" + type="email" + autoCapitalize="none" + autoComplete="email" + autoCorrect="off" + inputMode="email" + spellCheck={false} + {...field} + /> </FormControl> <FormMessage /> </FormItem> @@ -413,7 +465,15 @@ export function RegisterForm({ : `${t("register.password")} (${t("register.password_optional")})`} </FormLabel> <FormControl> - <Input data-testid="register-password-input" type="password" {...field} /> + <Input + data-testid="register-password-input" + type="password" + autoCapitalize="none" + autoComplete="new-password" + autoCorrect="off" + spellCheck={false} + {...field} + /> </FormControl> <FormMessage /> </FormItem> @@ -430,7 +490,15 @@ export function RegisterForm({ : `${t("register.confirm_password")} (${t("register.password_optional")})`} </FormLabel> <FormControl> - <Input data-testid="register-confirm-password-input" type="password" {...field} /> + <Input + data-testid="register-confirm-password-input" + type="password" + autoCapitalize="none" + autoComplete="new-password" + autoCorrect="off" + spellCheck={false} + {...field} + /> </FormControl> <FormMessage /> </FormItem> @@ -452,17 +520,21 @@ export function RegisterForm({ </Form> <Divider className="my-4" /> - <div className="flex items-center justify-center gap-1 pb-2 text-center text-sm"> - If you already have an account,{" "} - <button - data-testid="register-switch-login" - type="button" - className="flex cursor-pointer items-center gap-1 text-accent hover:underline" - onClick={() => onLoginStateChange("login")} - > - Sign in - <i className="i-mgc-right-cute-fi !text-text" /> - </button> + <div className="pb-2 text-center text-sm text-text-secondary"> + <Trans + t={t} + i18nKey="login.have_account" + components={{ + strong: ( + <button + data-testid="register-switch-login" + type="button" + className="inline-flex cursor-pointer items-center gap-1 text-accent hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2" + onClick={() => onLoginStateChange("login")} + /> + ), + }} + /> </div> </div> ) diff --git a/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx b/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx index 4db95789fbd..081805adc37 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx @@ -31,7 +31,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => { const { canClose = true, runtime } = props - const { t } = useTranslation() + const { t } = useTranslation(["app", "common"]) const { data: authProviders, isLoading } = useAuthProviders() const { status } = useSession() @@ -159,10 +159,11 @@ export const LoginModalContent = (props: LoginModalContentProps) => { {!IN_ELECTRON && ( <button type="button" - className="absolute -right-2 -top-2 flex size-8 items-center justify-center rounded-lg border-0 bg-transparent hover:bg-fill/20" + aria-label={t("words.close", { ns: "common" })} + className="absolute -right-2 -top-2 flex size-8 items-center justify-center rounded-lg border-0 bg-transparent transition-colors hover:bg-fill/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2" onClick={modal.dismiss} > - <i className="i-mgc-close-cute-re size-4" /> + <i aria-hidden className="i-mgc-close-cute-re pointer-events-none size-4" /> </button> )} {isEmail ? ( @@ -181,13 +182,8 @@ export const LoginModalContent = (props: LoginModalContentProps) => { <div className="flex flex-col gap-4"> {/* Login Providers */} <div className="flex flex-col gap-2.5"> - {visibleProviders.map(([key, provider], index) => ( - <m.div - key={key} - initial={{ opacity: 0, y: 10 }} - animate={{ opacity: 1, y: 0 }} - transition={{ ...Spring.presets.smooth, delay: index * 0.05 }} - > + {visibleProviders.map(([key, provider]) => ( + <div key={key}> <button data-testid={`login-provider-${key}`} type="button" @@ -198,28 +194,32 @@ export const LoginModalContent = (props: LoginModalContentProps) => { loginHandler(key, "app") } }} - className="group center relative w-full gap-2 rounded-xl border border-border bg-material-medium py-3.5 pl-5 font-medium backdrop-blur-sm transition-all duration-200 hover:border-folo/30 hover:bg-folo/10" + className="group center relative w-full gap-2 rounded-xl border border-border bg-material-medium py-3.5 pl-5 font-medium backdrop-blur-sm transition-colors duration-200 hover:border-folo/30 hover:bg-folo/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2" > {provider.icon64 ? ( <img className={cn( - "absolute left-7 size-5 object-contain", + "pointer-events-none absolute left-7 size-5 object-contain", !provider.iconDark64 && "dark:brightness-[0.85] dark:hue-rotate-180 dark:invert", )} src={isDark ? provider.iconDark64 || provider.icon64 : provider.icon64} - alt={provider.name} + alt="" + aria-hidden="true" /> ) : ( - <i className="i-mgc-mail-cute-re absolute left-7 size-5 text-text-secondary" /> + <i + aria-hidden + className="i-mgc-mail-cute-re pointer-events-none absolute left-7 size-5 text-text-secondary" + /> )} - <span className="relative z-10"> + <span className="pointer-events-none relative z-10"> {t("login.continueWith", { provider: provider.name })} </span> {lastMethod === key && ( <m.div - className="absolute -right-2 -top-2 z-20 rounded-lg bg-accent px-2.5 py-1 text-xs font-medium text-white" + className="pointer-events-none absolute -right-2 -top-2 z-20 rounded-lg bg-accent px-2.5 py-1 text-xs font-medium text-white" initial={{ scale: 0, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={Spring.presets.bouncy} @@ -228,7 +228,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => { </m.div> )} </button> - </m.div> + </div> ))} </div> @@ -238,9 +238,9 @@ export const LoginModalContent = (props: LoginModalContentProps) => { <button type="button" onClick={() => handleOpenToken()} - className="inline-flex items-center gap-1 rounded-md px-2 py-1 transition-colors hover:bg-fill-secondary hover:text-text-secondary" + className="inline-flex items-center gap-1 rounded-md px-2 py-1 transition-colors hover:bg-fill-secondary hover:text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2" > - <i className="i-mgc-key-2-cute-re size-3.5" /> + <i aria-hidden className="i-mgc-key-2-cute-re size-3.5" /> <span>{t("login.enter_token")}</span> </button> </div> @@ -249,7 +249,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => { <button type="button" onClick={() => handleOpenLegal("tos")} - className="text-accent transition-colors hover:text-accent/80 hover:underline" + className="text-accent transition-colors hover:text-accent/80 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2" > {t("login.terms")} </button> @@ -257,7 +257,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => { <button type="button" onClick={() => handleOpenLegal("privacy")} - className="text-accent transition-colors hover:text-accent/80 hover:underline" + className="text-accent transition-colors hover:text-accent/80 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2" > {t("login.privacy")} </button> diff --git a/apps/desktop/layer/renderer/src/modules/auth/TokenModal.tsx b/apps/desktop/layer/renderer/src/modules/auth/TokenModal.tsx index c89db4384cf..7f567b3309c 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/TokenModal.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/TokenModal.tsx @@ -69,6 +69,9 @@ export const TokenModalContent = () => { autoFocus className="mt-1 dark:text-zinc-200" placeholder="folo://auth?token=xxx" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} {...field} /> </FormControl> diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx index 06373308ec9..a4ffbbbfbb8 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx @@ -23,6 +23,7 @@ import { toggleEntryReadability } from "~/hooks/biz/useEntryActions" import { navigateEntry } from "~/hooks/biz/useNavigateEntry" import { getRouteParams } from "~/hooks/biz/useRouteParams" import { copyToClipboard } from "~/lib/clipboard" +import { exportPageAsPdf } from "~/lib/export" import { markAllByRoute } from "~/modules/entry-column/hooks/useMarkAll" import { useGalleryModal } from "~/modules/entry-content/hooks" import { playEntryTts } from "~/modules/player/entry-tts" @@ -177,7 +178,9 @@ export const useRegisterEntryCommands = () => { return } - window.print() + void exportPageAsPdf({ title: entry.title || entry.url || undefined }).catch(() => { + toast.error("Failed to export as pdf", { duration: 3000 }) + }) }, }, { diff --git a/apps/desktop/layer/renderer/src/modules/discover/DiscoveryContent.tsx b/apps/desktop/layer/renderer/src/modules/discover/DiscoveryContent.tsx index c8fc8549844..76bfd021b91 100644 --- a/apps/desktop/layer/renderer/src/modules/discover/DiscoveryContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/discover/DiscoveryContent.tsx @@ -42,9 +42,8 @@ export function DiscoveryContent() { } return ( - <div className="relative mx-auto w-full max-w-[800px] space-y-6"> - {/* Segment Toggle - Centered */} - <div className="relative flex justify-center"> + <div className="relative mx-auto w-full max-w-[880px] space-y-5"> + <div className="flex flex-wrap items-center justify-between gap-3"> <SegmentGroup value={activeView} onValueChanged={(value) => setActiveView(value as DiscoveryView)} @@ -70,28 +69,23 @@ export function DiscoveryContent() { /> </SegmentGroup> - {/* Filters Bar - Inside Content Area */} - <div className="absolute right-0 flex items-center justify-end gap-4"> - <div className="flex items-center gap-2"> - <span className="shrink-0 text-sm font-medium text-text-secondary"> - {t("words.language")}: - </span> - <ResponsiveSelect - value={lang} - onValueChange={handleLangChange} - triggerClassName="h-8 rounded border-0" - size="sm" - items={LanguageOptions} - renderItem={(item) => tCommon(item.label as any)} - renderValue={(item) => tCommon(item.label as any)} - /> - </div> + <div className="flex items-center gap-2"> + <span className="shrink-0 text-sm font-medium text-text-secondary"> + {t("words.language")}: + </span> + <ResponsiveSelect + value={lang} + onValueChange={handleLangChange} + triggerClassName="h-8 rounded border-0 bg-material-ultra-thin" + size="sm" + items={LanguageOptions} + renderItem={(item) => tCommon(item.label as any)} + renderValue={(item) => tCommon(item.label as any)} + /> </div> </div> - {/* Content Area with Filters */} - <div className="min-h-[400px]"> - {/* Content */} + <div className="min-h-[400px] rounded-2xl border border-fill-secondary bg-background/70 p-4 shadow-sm"> {activeView === "trending" ? ( <Trending center limit={20} hideHeader /> ) : ( diff --git a/apps/desktop/layer/renderer/src/modules/discover/UnifiedDiscoverForm.tsx b/apps/desktop/layer/renderer/src/modules/discover/UnifiedDiscoverForm.tsx index b1ee06e27ac..f58750bb6c5 100644 --- a/apps/desktop/layer/renderer/src/modules/discover/UnifiedDiscoverForm.tsx +++ b/apps/desktop/layer/renderer/src/modules/discover/UnifiedDiscoverForm.tsx @@ -301,7 +301,7 @@ export function UnifiedDiscoverForm() { className="w-full max-w-2xl" data-testid="discover-form" > - <div className="p-6"> + <div className="rounded-2xl border border-fill-secondary bg-background/70 p-4 shadow-sm"> <FormField control={form.control} name="keyword" diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx index 3f703150397..5b25253b7db 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx @@ -9,7 +9,7 @@ import { useMasonryColumn } from "@follow/components/ui/masonry/hooks.js" import { Masonry } from "@follow/components/ui/masonry/index.js" import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js" import { Skeleton } from "@follow/components/ui/skeleton/index.jsx" -import { useRefValue } from "@follow/hooks" +import { useRefValue, useScrollMarkReadGracePeriod } from "@follow/hooks" import { getEntry } from "@follow/store/entry/getter" import { useEntryTranslation } from "@follow/store/translation/hooks" import { clsx } from "@follow/utils/utils" @@ -36,6 +36,7 @@ import { MediaContainerWidthProvider } from "~/components/ui/media/MediaContaine import type { StoreImageType } from "~/store/image" import { imageActions } from "~/store/image" +import { useEntriesState } from "../context/EntriesContext" import { batchMarkRead } from "../hooks/useEntryMarkReadHandler" import { PictureWaterFallItem } from "./picture-item" @@ -48,6 +49,10 @@ const gutter = 24 export const PictureMasonry: FC<MasonryProps> = (props) => { const { data } = props + const entriesState = useEntriesState() + const pauseScrollMarkRead = useScrollMarkReadGracePeriod( + entriesState.isFetching && !entriesState.isFetchingNextPage, + ) const cacheMap = useState(() => new Map<string, object>())[0] const [isInitDim, setIsInitDim] = useState(false) const [isInitLayout, setIsInitLayout] = useState(false) @@ -155,6 +160,7 @@ export const PictureMasonry: FC<MasonryProps> = (props) => { function scrollOutViewMarkRead(entries: IntersectionObserverEntry[]) { if (!scrollMarkRead) return + if (pauseScrollMarkRead) return if (!scrollElement) return let minimumIndex = Number.MAX_SAFE_INTEGER entries.forEach((entry) => { @@ -210,7 +216,7 @@ export const PictureMasonry: FC<MasonryProps> = (props) => { return () => { observer.disconnect() } - }, [scrollElement, renderMarkRead, scrollMarkRead, dataRef]) + }, [dataRef, pauseScrollMarkRead, renderMarkRead, scrollElement, scrollMarkRead]) const [firstScreenReady, setFirstScreenReady] = useState(false) useEffect(() => { diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx index 90818ae7e1f..3a9c3c0143d 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx @@ -152,7 +152,7 @@ const VirtualGridImpl: FC< const rowVirtualizer = useVirtualizer({ count: rows.length + (hasNextPage ? 1 : 0) + (Footer ? 1 : 0), estimateSize: () => { - return columns[0]! / ratioMap[view] + (!isImageOnly ? 58 : 0) + return columns[0]! / (ratioMap[view] ?? 1) + (!isImageOnly ? 58 : 0) }, overscan: 5, gap: 8, diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/filter-local-entry-ids.test.ts b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/filter-local-entry-ids.test.ts new file mode 100644 index 00000000000..7b790ac6b00 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/filter-local-entry-ids.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest" + +import { getVisibleLocalEntryIds } from "./filter-local-entry-ids" + +describe("getVisibleLocalEntryIds", () => { + it("keeps previously visible unread entries when they turn read locally", () => { + expect( + getVisibleLocalEntryIds({ + sourceIds: ["entry-1", "entry-2"], + entries: { + "entry-1": { id: "entry-1", read: true }, + "entry-2": { id: "entry-2", read: false }, + }, + stickyVisibleIds: new Set(["entry-1", "entry-2"]), + unreadOnly: true, + }), + ).toEqual(["entry-1", "entry-2"]) + }) + + it("filters read entries that were not previously visible", () => { + expect( + getVisibleLocalEntryIds({ + sourceIds: ["entry-1", "entry-2"], + entries: { + "entry-1": { id: "entry-1", read: true }, + "entry-2": { id: "entry-2", read: false }, + }, + stickyVisibleIds: new Set<string>(), + unreadOnly: true, + }), + ).toEqual(["entry-2"]) + }) + + it("removes sticky entries once they leave the source list", () => { + expect( + getVisibleLocalEntryIds({ + sourceIds: ["entry-2"], + entries: { + "entry-1": { id: "entry-1", read: true }, + "entry-2": { id: "entry-2", read: false }, + }, + stickyVisibleIds: new Set(["entry-1", "entry-2"]), + unreadOnly: true, + }), + ).toEqual(["entry-2"]) + }) +}) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/filter-local-entry-ids.ts b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/filter-local-entry-ids.ts new file mode 100644 index 00000000000..e5d53f36598 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/filter-local-entry-ids.ts @@ -0,0 +1,27 @@ +type LocalEntryVisibility = { + id: string + read?: boolean | null +} + +export const getVisibleLocalEntryIds = <TEntry extends LocalEntryVisibility>({ + sourceIds, + entries, + stickyVisibleIds, + unreadOnly, +}: { + sourceIds: string[] + entries: Record<string, TEntry | null | undefined> + stickyVisibleIds?: ReadonlySet<string> + unreadOnly: boolean +}) => { + return sourceIds.filter((id) => { + const entry = entries[id] + + if (!entry) return false + if (unreadOnly && !!entry.read && !stickyVisibleIds?.has(entry.id)) { + return false + } + + return true + }) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntriesByView.ts b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntriesByView.ts index 6067e9b6016..8edf7fec14d 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntriesByView.ts +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntriesByView.ts @@ -19,7 +19,7 @@ import { isBizId } from "@follow/utils/utils" import { useMutation } from "@tanstack/react-query" import { debounce } from "es-toolkit/compat" import { useAtomValue } from "jotai" -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useGeneralSettingKey } from "~/atoms/settings/general" import { ROUTE_FEED_PENDING } from "~/constants/app" @@ -27,6 +27,7 @@ import { useFeature } from "~/hooks/biz/useFeature" import { useRouteParams } from "~/hooks/biz/useRouteParams" import { aiTimelineEnabledAtom } from "../atoms/ai-timeline" +import { getVisibleLocalEntryIds } from "./filter-local-entry-ids" import { useIsPreviewFeed } from "./useIsPreviewFeed" const useRemoteEntries = (): UseEntriesReturn => { @@ -138,6 +139,18 @@ const useLocalEntries = (): UseEntriesReturn => { !inboxId && !listId + const localQueryKey = useMemo( + () => [feedId || "", view, inboxId || "", listId || "", isCollection ? "1" : "0"].join(":"), + [feedId, inboxId, isCollection, listId, view], + ) + const stickyVisibleStateRef = useRef<{ + queryKey: string + ids: Set<string> + }>({ + queryKey: localQueryKey, + ids: new Set<string>(), + }) + const allEntries = useEntryStore( useCallback( (state) => { @@ -152,16 +165,17 @@ const useLocalEntries = (): UseEntriesReturn => { entryIdsByInboxId, ) ?? []) - return ids - .map((id) => { - const entry = state.data[id] - if (!entry) return null - if (unreadOnly && entry.read) { - return null - } - return entry.id - }) - .filter((id) => typeof id === "string") + const stickyVisibleIds = + unreadOnly && stickyVisibleStateRef.current.queryKey === localQueryKey + ? stickyVisibleStateRef.current.ids + : undefined + + return getVisibleLocalEntryIds({ + sourceIds: ids, + entries: state.data, + stickyVisibleIds, + unreadOnly, + }) }, [ entryIdsByCategory, @@ -171,12 +185,20 @@ const useLocalEntries = (): UseEntriesReturn => { entryIdsByListId, entryIdsByView, isCollection, + localQueryKey, showEntriesByView, unreadOnly, ], ), ) + useEffect(() => { + stickyVisibleStateRef.current = { + queryKey: localQueryKey, + ids: unreadOnly ? new Set(allEntries) : new Set<string>(), + } + }, [allEntries, localQueryKey, unreadOnly]) + const [page, setPage] = useState(0) const pageSize = 30 const totalPage = useMemo( diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx index 4867e7db892..3edfb765044 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx @@ -2,46 +2,72 @@ import { getView } from "@follow/constants" import { entryActions } from "@follow/store/entry/store" import { unreadSyncService } from "@follow/store/unread/store" import type { Range } from "@tanstack/react-virtual" -import { useMemo } from "react" +import { useEffect, useMemo, useRef } from "react" import { useEventCallback } from "usehooks-ts" import { useGeneralSettingKey } from "~/atoms/settings/general" import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams" -export const useEntryMarkReadHandler = (entriesIds: string[]) => { +type EntryMarkReadHandler = (range: Range, enabled?: boolean) => void + +export const useEntryMarkReadHandler = ( + entriesIds: string[], + { pauseScrollMarkRead = false }: { pauseScrollMarkRead?: boolean } = {}, +) => { const renderAsRead = useGeneralSettingKey("renderMarkUnread") const scrollMarkUnread = useGeneralSettingKey("scrollMarkUnread") const feedView = useRouteParamsSelector((params) => params.view) - const processedEntryIds = useMemo(() => new Set<string>(), [entriesIds]) + const processedEntryIds = useRef(new Set<string>()) + + useEffect(() => { + processedEntryIds.current.clear() + }, [entriesIds]) - const handleRenderAsRead = useEventCallback( + const handleRangeMarkRead = useEventCallback( ({ startIndex, endIndex }: Range, enabled?: boolean) => { if (!enabled) return const idSlice = entriesIds?.slice(startIndex, endIndex) if (!idSlice) return // Filter out entries that have already been processed - const newEntries = idSlice.filter((id) => !processedEntryIds.has(id)) + const newEntries = idSlice.filter((id) => !processedEntryIds.current.has(id)) if (newEntries.length === 0) return // Mark these entries as processed to avoid duplicate processing - newEntries.forEach((id) => processedEntryIds.add(id)) + newEntries.forEach((id) => processedEntryIds.current.add(id)) batchMarkRead(newEntries) }, ) - return useMemo(() => { - if (getView(feedView)?.wideMode && renderAsRead) { - return handleRenderAsRead + const handleScrollMarkRead = useEventCallback((range: Range, enabled?: boolean) => { + if (pauseScrollMarkRead) return + handleRangeMarkRead(range, enabled) + }) + + const renderMarkReadHandler = useMemo<EntryMarkReadHandler | undefined>(() => { + if (!getView(feedView)?.wideMode || !renderAsRead) { + return } - if (scrollMarkUnread) { - return handleRenderAsRead + return handleRangeMarkRead + }, [feedView, handleRangeMarkRead, renderAsRead]) + + const scrollMarkReadHandler = useMemo<EntryMarkReadHandler | undefined>(() => { + if (!scrollMarkUnread) { + return + } + + return handleScrollMarkRead + }, [handleScrollMarkRead, scrollMarkUnread]) + + return useMemo(() => { + return { + handleRenderMarkRead: renderMarkReadHandler, + handleScrollMarkRead: scrollMarkReadHandler, } - return - }, [feedView, handleRenderAsRead, renderAsRead, scrollMarkUnread]) + }, [renderMarkReadHandler, scrollMarkReadHandler]) } export function batchMarkRead(ids: string[]) { diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx index 14b86328507..73dac23a8ce 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx @@ -1,5 +1,5 @@ import { FeedViewType, getView } from "@follow/constants" -import { useTitle } from "@follow/hooks" +import { useScrollMarkReadGracePeriod, useTitle } from "@follow/hooks" import { useEntry } from "@follow/store/entry/hooks" import { useFeedById } from "@follow/store/feed/hooks" import { useSubscriptionByFeedId } from "@follow/store/subscription/hooks" @@ -80,8 +80,12 @@ function EntryColumnContent() { }, [activeEntryId, entry?.feedId, isCollection, isPendingEntry, isLoggedIn]) const isInteracted = useRef(false) + const isRefreshing = state.isFetching && !state.isFetchingNextPage + const pauseScrollMarkRead = useScrollMarkReadGracePeriod(isRefreshing) - const handleMarkReadInRange = useEntryMarkReadHandler(entriesIds) + const { handleRenderMarkRead, handleScrollMarkRead } = useEntryMarkReadHandler(entriesIds, { + pauseScrollMarkRead, + }) const handleScroll = useCallback(() => { if (!isInteracted.current) { @@ -92,7 +96,7 @@ function EntryColumnContent() { const [first, second] = rangeQueueRef.current if (first && second && second.startIndex - first.startIndex > 0) { - handleMarkReadInRange?.( + handleScrollMarkRead?.( { startIndex: first.startIndex, endIndex: second.startIndex, @@ -100,7 +104,7 @@ function EntryColumnContent() { isInteracted.current, ) } - }, [handleMarkReadInRange, routeFeedId]) + }, [handleScrollMarkRead, routeFeedId]) const { handleScroll: handleScrollBeyond } = useAttachScrollBeyond() const handleCombinedScroll = useCallback( @@ -114,7 +118,6 @@ function EntryColumnContent() { const navigate = useNavigateEntry() const rangeQueueRef = useRef<Range[]>([]) - const isRefreshing = state.isFetching && !state.isFetchingNextPage const aiTimelineEnabled = useAtomValue(aiTimelineEnabledAtom) const showAiTimelineLoading = aiTimelineEnabled && state.isLoading && !state.isFetchingNextPage const renderAsRead = useGeneralSettingKey("renderMarkUnread") @@ -134,9 +137,9 @@ function EntryColumnContent() { return } // For gird, render as mark read logic - handleMarkReadInRange?.(e, isInteracted.current) + handleRenderMarkRead?.(e, isInteracted.current) }, - [handleMarkReadInRange, renderAsRead, view], + [handleRenderMarkRead, renderAsRead, view], ) const fetchNextPage = useCallback(() => { diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/actions/picture-gallery.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/actions/picture-gallery.tsx index 1eabe48dcb3..4b4edecb417 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/actions/picture-gallery.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/actions/picture-gallery.tsx @@ -5,7 +5,7 @@ import { useMasonryItemWidth, } from "@follow/components/ui/masonry/contexts.jsx" import { useMasonryColumn } from "@follow/components/ui/masonry/hooks.js" -import type { MediaModel } from "@folo-services/drizzle" +import type { MediaModel } from "@follow/database/schemas/types" import type { RenderComponentProps } from "masonic" import { Masonry } from "masonic" import { useState } from "react" diff --git a/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/create-wallet.tsx b/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/create-wallet.tsx index 8b859d79ef5..7bd33a7a778 100644 --- a/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/create-wallet.tsx +++ b/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/create-wallet.tsx @@ -8,21 +8,33 @@ export const CreateWallet = () => { const { t } = useTranslation("settings") return ( - <div> - <p className="text-base"> - <Trans - i18nKey="wallet.create.description" - ns="settings" - components={{ - PowerIcon: <i className="i-mgc-power translate-y-[2px] text-folo" />, - strong: <strong />, - }} - /> - </p> - <div className="mt-4 text-right"> - <Button variant="primary" isLoading={mutation.isPending} onClick={() => mutation.mutate()}> - {t("wallet.create.button")} - </Button> + <div className="rounded-2xl border border-fill-secondary bg-material-ultra-thin p-6 shadow-sm"> + <div className="flex flex-col items-start gap-4 md:flex-row md:items-center md:justify-between"> + <div className="space-y-3"> + <div className="flex size-12 items-center justify-center rounded-2xl bg-fill-quaternary text-folo"> + <i className="i-mgc-power text-2xl" /> + </div> + <p className="max-w-2xl text-base text-text-secondary"> + <Trans + i18nKey="wallet.create.description" + ns="settings" + components={{ + PowerIcon: <i className="i-mgc-power translate-y-[2px] text-folo" />, + strong: <strong className="text-text" />, + }} + /> + </p> + </div> + + <div className="shrink-0"> + <Button + variant="primary" + isLoading={mutation.isPending} + onClick={() => mutation.mutate()} + > + {t("wallet.create.button")} + </Button> + </div> </div> </div> ) diff --git a/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/index.tsx b/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/index.tsx index b642754657a..7a5d3ddca78 100644 --- a/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/index.tsx @@ -36,9 +36,14 @@ export const MyWalletSection = ({ className }: { className?: string }) => { return <CreateWallet /> } return ( - <div className={cn(className)}> + <div + className={cn( + "rounded-2xl border border-fill-secondary bg-material-ultra-thin p-5 shadow-sm", + className, + )} + > <SettingSectionTitle title={t("wallet.balance.title")} margin="compact" /> - <div className="mb-2 flex items-center justify-between"> + <div className="flex items-start justify-between gap-4"> <div> <div className="flex items-center gap-1"> <Balance className="text-xl font-bold text-folo"> @@ -46,7 +51,7 @@ export const MyWalletSection = ({ className }: { className?: string }) => { </Balance> </div> <Tooltip> - <TooltipTrigger className="mt-1 block"> + <TooltipTrigger className="mt-2 block"> <div className="flex flex-row items-center gap-x-2 text-xs"> <span className="flex items-center gap-1 text-left"> {t("wallet.balance.withdrawable")} <i className="i-mgc-question-cute-re" /> diff --git a/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/withdraw.tsx b/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/withdraw.tsx index 7cde6fb2ab1..6fee1262da7 100644 --- a/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/withdraw.tsx +++ b/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/withdraw.tsx @@ -26,9 +26,7 @@ import { toast } from "sonner" import { z } from "zod" import { useModalStack } from "~/components/ui/modal/stacked/hooks" -import { useAuthQuery } from "~/hooks/common/useBizQuery" import { followClient } from "~/lib/api-client" -import { defineQuery } from "~/lib/defineQuery" import { useTOTPModalWrapper } from "~/modules/profile/hooks" import { Balance } from "~/modules/wallet/balance" import { useWallet, wallet as walletActions } from "~/queries/wallet" @@ -67,12 +65,7 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => { resolver: zodResolver(formSchema), }) - const powerPrice = useAuthQuery( - defineQuery(["power-price"], async () => { - const res = await followClient.api.wallets.powerPrice() - return res.data - }), - ) + const rss3ConversionRate: number | null = null const mutation = useMutation({ mutationFn: async ({ @@ -105,7 +98,7 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => { if (mutation.isError) { toast.error(t("wallet.withdraw.error", { error: mutation.error?.message })) } - }, [mutation.isError, t]) + }, [mutation.error?.message, mutation.isError, t]) useEffect(() => { if (mutation.isSuccess) { @@ -180,7 +173,7 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => { <TooltipPortal> <TooltipContent> <span className="text-xs text-gray-500"> - 1 POWER = {powerPrice.data?.rss3 ?? "-"} RSS3 + <span>1 POWER = {rss3ConversionRate ?? "-"} RSS3</span> </span> </TooltipContent> </TooltipPortal> @@ -192,12 +185,10 @@ const WithdrawModalContent = ({ dismiss }: { dismiss: () => void }) => { </span> </FormControl> </div> - {field.value && ( + {field.value && rss3ConversionRate !== null && ( <span className="text-xs text-gray-500"> {t("wallet.withdraw.receiveRSS3", { - amount: ((form.watch("amount") || 0) * (powerPrice.data?.rss3 ?? 0)).toFixed( - 4, - ), + amount: ((form.watch("amount") || 0) * rss3ConversionRate).toFixed(4), })} </span> )} diff --git a/apps/desktop/layer/renderer/src/modules/power/transaction-section/TransactionsSection.tsx b/apps/desktop/layer/renderer/src/modules/power/transaction-section/TransactionsSection.tsx index a2a7226b99f..44cfc23c2ce 100644 --- a/apps/desktop/layer/renderer/src/modules/power/transaction-section/TransactionsSection.tsx +++ b/apps/desktop/layer/renderer/src/modules/power/transaction-section/TransactionsSection.tsx @@ -1,6 +1,7 @@ import { LoadingCircle } from "@follow/components/ui/loading/index.js" import { Tabs, TabsList, TabsTrigger } from "@follow/components/ui/tabs/index.jsx" import { useWhoami } from "@follow/store/user/hooks" +import { cn } from "@follow/utils/utils" import { TransactionTypes } from "@follow-app/client-sdk" import { useState } from "react" import { useTranslation } from "react-i18next" @@ -28,8 +29,15 @@ export const TransactionsSection: Component = ({ className }) => { if (!myWallet) return null + const hasTransactions = Boolean(transactions.data?.length) + return ( - <div className="relative flex min-w-0 grow flex-col"> + <div + className={cn( + "relative flex min-w-0 grow flex-col rounded-2xl border border-fill-secondary bg-material-ultra-thin p-5 shadow-sm", + className, + )} + > <SettingSectionTitle title={t("wallet.transactions.title")} /> <Tabs value={type} onValueChange={(val) => setType(val)}> <TabsList className="relative border-b-transparent"> @@ -40,8 +48,8 @@ export const TransactionsSection: Component = ({ className }) => { ))} </TabsList> </Tabs> - <TxTable type={type} className={className} /> - {!!transactions.data?.length && ( + {hasTransactions ? <TxTable type={type} /> : null} + {hasTransactions && ( <a className="my-2 w-full text-sm text-zinc-400 underline" href={`${getBlockchainExplorerUrl()}/address/${myWallet.address}`} @@ -51,12 +59,20 @@ export const TransactionsSection: Component = ({ className }) => { </a> )} - {(transactions.isFetching || !transactions.data?.length) && ( - <div className="my-2 flex w-full justify-center text-sm text-zinc-400"> + {(transactions.isFetching || !hasTransactions) && ( + <div className="my-4 flex w-full justify-center text-sm text-zinc-400"> {transactions.isFetching ? ( <LoadingCircle size="medium" /> ) : ( - t("wallet.transactions.noTransactions") + <div className="flex min-h-56 w-full flex-col items-center justify-center rounded-xl border border-dashed border-border bg-background/60 px-6 text-center"> + <i className="i-mgc-power mb-3 text-4xl text-text-quaternary" /> + <p className="text-sm font-medium text-text"> + {t("wallet.transactions.empty.title")} + </p> + <p className="mt-1 max-w-sm text-sm text-text-secondary"> + {t("wallet.transactions.empty.description")} + </p> + </div> )} </div> )} diff --git a/apps/desktop/layer/renderer/src/modules/power/transaction-section/tx-table/components.tsx b/apps/desktop/layer/renderer/src/modules/power/transaction-section/tx-table/components.tsx index c165eea99cb..11dee293312 100644 --- a/apps/desktop/layer/renderer/src/modules/power/transaction-section/tx-table/components.tsx +++ b/apps/desktop/layer/renderer/src/modules/power/transaction-section/tx-table/components.tsx @@ -19,7 +19,11 @@ export const TypeRenderer = ({ type: NonNullable<ReturnType<typeof useWalletTransactions>["data"]>[number]["type"] }) => { const { t } = useTranslation("settings") - return <div className="uppercase">{t(`wallet.transactions.types.${type}`)}</div> + return ( + <div className="uppercase"> + {t(`wallet.transactions.types.${String(type)}` as const, { defaultValue: String(type) })} + </div> + ) } export const BalanceRenderer = ({ diff --git a/apps/desktop/layer/renderer/src/modules/profile/user-profile-modal/UserProfileModalContent.tsx b/apps/desktop/layer/renderer/src/modules/profile/user-profile-modal/UserProfileModalContent.tsx index 360f8b1487c..ca13242d9d9 100644 --- a/apps/desktop/layer/renderer/src/modules/profile/user-profile-modal/UserProfileModalContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/profile/user-profile-modal/UserProfileModalContent.tsx @@ -9,7 +9,6 @@ import { getAvatarUrl } from "@follow/utils" import { nextFrame, stopPropagation } from "@follow/utils/dom" import { getStorageNS } from "@follow/utils/ns" import { cn } from "@follow/utils/utils" -import type { ListWithStats } from "@follow-app/client-sdk" import { useQuery } from "@tanstack/react-query" import { useAtom } from "jotai" import { atomWithStorage } from "jotai/utils" @@ -63,7 +62,7 @@ const pickUserData = < } } -const ListCard = memo(({ list }: { list: ListWithStats }) => { +const ListCard = memo(({ list }: { list: any }) => { return ( <div className="group/card relative overflow-hidden rounded-lg border border-fill bg-material-ultra-thin transition-all duration-200 hover:border-fill-secondary"> <a @@ -124,7 +123,11 @@ export const UserProfileModalContent: FC<SubscriptionModalContentProps> = ({ use const user = usePrefetchUser(userId) const storeUser = useUserById(userId) - const userInfo = user.data ? pickUserData(user.data) : storeUser ? pickUserData(storeUser) : null + const userInfo = user.data + ? pickUserData(user.data as any) + : storeUser + ? pickUserData(storeUser as any) + : null const modal = useCurrentModal() const controller = useAnimationControls() @@ -361,7 +364,7 @@ const useUserListsQuery = (userId: string) => { }) } -const Lists = ({ lists }: { lists: ListWithStats[] }) => { +const Lists = ({ lists }: { lists: any[] }) => { const { t } = useTranslation() if (!lists || lists.length === 0) return null return ( diff --git a/apps/desktop/layer/renderer/src/modules/settings/modal/SettingModalContent.tsx b/apps/desktop/layer/renderer/src/modules/settings/modal/SettingModalContent.tsx index 0c88bcaaef6..67f2783b391 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/modal/SettingModalContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/modal/SettingModalContent.tsx @@ -195,22 +195,24 @@ const Content: FC<{ </SettingSectionHighlightIdContext> <div className="h-16" /> - <p className="absolute inset-x-0 bottom-4 flex items-center justify-center gap-1 text-xs opacity-80"> - <Trans - ns="settings" - i18nKey="common.give_star" - components={{ - Link: ( - <a - href={`${repository.url}`} - className="font-semibold text-accent" - target="_blank" - /> - ), - HeartIcon: <i className="i-mgc-heart-cute-fi" />, - }} - /> - </p> + {activeSetting.path === "about" && ( + <p className="absolute inset-x-0 bottom-4 flex items-center justify-center gap-1 text-xs opacity-80"> + <Trans + ns="settings" + i18nKey="common.give_star" + components={{ + Link: ( + <a + href={`${repository.url}`} + className="font-semibold text-accent" + target="_blank" + /> + ), + HeartIcon: <i className="i-mgc-heart-cute-fi" />, + }} + /> + </p> + )} </ScrollArea.ScrollArea> </Suspense> ) diff --git a/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx b/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx index a7a89c39f39..9824612210c 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx @@ -183,6 +183,7 @@ const SettingItemButtonImpl = (props: { "my-0.5 flex w-full items-center rounded-lg px-2.5 py-0.5 leading-loose text-text", isActive && "!bg-theme-item-active !text-text", !IN_ELECTRON && "duration-200 hover:bg-theme-item-hover", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30", disabled && "opacity-50", disabledByConfig && "cursor-not-allowed", )} diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx index 78f579c452c..0ff314e6495 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx @@ -10,9 +10,13 @@ import { SettingSectionTitle } from "../section" export const SettingCli = () => { interface CliInstallStatus { - installed: boolean - installPath: string | null - cliSourceAvailable: boolean + connected: boolean + configPath: string + hasDesktopSession: boolean + installCommand: string + loginCommand: string + npxAvailable: boolean + packageName: string } const { t } = useTranslation("settings") const [status, setStatus] = useState<CliInstallStatus | null>(null) @@ -72,9 +76,9 @@ export const SettingCli = () => { {status && ( <div className="space-y-3"> - <div className="flex items-center gap-2"> + <div className="flex flex-wrap items-center gap-2"> <span className="text-sm font-medium">Status:</span> - {status.installed ? ( + {status.connected ? ( <span className="inline-flex items-center gap-1 rounded-full bg-green/10 px-2 py-0.5 text-xs text-green"> <i className="i-mingcute-check-line" /> {t("cli.installed")} @@ -84,31 +88,68 @@ export const SettingCli = () => { {t("cli.not_installed")} </span> )} + + <span + className={ + status.npxAvailable + ? "inline-flex items-center gap-1 rounded-full bg-blue/10 px-2 py-0.5 text-xs text-blue" + : "inline-flex items-center gap-1 rounded-full bg-orange/10 px-2 py-0.5 text-xs text-orange" + } + > + {status.npxAvailable ? t("cli.runtime_ready") : t("cli.runtime_missing")} + </span> + </div> + + <div className="grid gap-3"> + <div className="rounded-xl border border-fill-secondary bg-fill-quaternary/60 p-3"> + <div className="mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary"> + {t("cli.package")} + </div> + <code className="text-sm font-medium">{status.packageName}</code> + </div> + + <div className="rounded-xl border border-fill-secondary bg-fill-quaternary/60 p-3"> + <div className="mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary"> + {t("cli.global_install")} + </div> + <code className="block break-all text-sm">{status.installCommand}</code> + </div> + + <div className="rounded-xl border border-fill-secondary bg-fill-quaternary/60 p-3"> + <div className="mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary"> + {t("cli.desktop_sync")} + </div> + <code className="block break-all text-sm">{status.loginCommand}</code> + </div> </div> - {status.installed && status.installPath && ( + <div className="flex items-center gap-2"> <div className="flex items-center gap-2"> <span className="text-sm font-medium">{t("cli.path")}:</span> <code className="rounded bg-fill-quaternary px-2 py-0.5 text-xs"> - {status.installPath} + {status.configPath} </code> </div> - )} + </div> - {!status.cliSourceAvailable && ( + {!status.npxAvailable && ( <p className="text-sm text-orange-500">{t("cli.not_available")}</p> )} + {!status.hasDesktopSession && ( + <p className="text-sm text-text-secondary">{t("cli.require_login")}</p> + )} + <div className="flex gap-2"> - {!status.installed ? ( - <Button - onClick={handleInstall} - disabled={loading || !status.cliSourceAvailable} - isLoading={loading} - > - {t("cli.install")} - </Button> - ) : ( + <Button + onClick={handleInstall} + disabled={loading || !status.npxAvailable || !status.hasDesktopSession} + isLoading={loading} + > + {t("cli.install")} + </Button> + + {status.connected && ( <Button variant="outline" onClick={handleUninstall} diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/notifications.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/notifications.tsx index 4953469a26a..32bd69f74f9 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/notifications.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/notifications.tsx @@ -60,7 +60,8 @@ export const SettingNotifications = () => { <div className="flex items-center justify-between"> <h3 className="text-sm font-semibold text-text">{t.settings("notifications.channel")}</h3> <span className="text-xs text-text-tertiary"> - <span>{data?.data?.length || 0}</span> <span>{t.common("words.items")}</span> + <span>{data?.data?.length || 0}</span>{" "} + <span>{t.common("words.items", { count: data?.data?.length || 0 })}</span> </span> </div> @@ -74,7 +75,12 @@ export const SettingNotifications = () => { {!isLoading && (!data?.data || data.data.length === 0) ? ( <div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border bg-material-medium py-12"> <i className="i-mgc-notification-cute-re mb-3 text-4xl text-text-quaternary" /> - <p className="text-sm text-text-tertiary">No notification channels</p> + <p className="text-sm font-medium text-text"> + {t.settings("notifications.empty.title")} + </p> + <p className="mt-1 max-w-sm px-6 text-center text-sm text-text-secondary"> + {t.settings("notifications.empty.description")} + </p> </div> ) : ( <ScrollArea.ScrollArea viewportClassName="max-h-[400px]"> diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx index 50426a78a7f..89c2a9fc42c 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx @@ -580,22 +580,24 @@ const PlanComparisonTable = ({ plans }: { plans: PaymentPlan[] }) => { <tbody> {visibleFeatureKeys.map((featureKey, index) => ( <tr - key={featureKey} + key={String(featureKey)} className={cn( "border-b border-fill-tertiary transition-colors hover:bg-fill-secondary/30", index % 2 === 0 ? "bg-background" : "bg-fill-secondary/20", )} > <td className="sticky left-0 z-10 bg-inherit px-4 py-3 text-sm font-medium"> - {t(`plan.features.${featureKey}`, { defaultValue: featureKey })} + {t(`plan.features.${String(featureKey)}` as const, { + defaultValue: String(featureKey), + })} </td> {plans.map((plan) => { - const value = plan.limit[featureKey] + const value = plan.limit[featureKey as keyof typeof plan.limit] const formattedValue = formatFeatureValue(featureKey, value, t) return ( <td - key={`${plan.name}-${featureKey}`} + key={`${plan.name}-${String(featureKey)}`} className="px-4 py-3 text-center text-sm" > <span diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionColumnHeader.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionColumnHeader.tsx index 6693a23e3ee..c9ed343a3b4 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionColumnHeader.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionColumnHeader.tsx @@ -7,7 +7,7 @@ import { m } from "motion/react" import type { FC, PropsWithChildren } from "react" import { memo, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" -import { Link } from "react-router" +import { useNavigate } from "react-router" import { toast } from "sonner" import { setTimelineColumnShow, useSubscriptionColumnShow } from "~/atoms/sidebar" @@ -27,6 +27,7 @@ import { ProfileButton } from "~/modules/user/ProfileButton" export const SubscriptionColumnHeader = memo(() => { const timelineId = useRouteParamsSelector((s) => s.timelineId) const navigateBackHome = useBackHome(timelineId) + const navigate = useNavigate() const normalStyle = !window.electron || window.electron.process.platform !== "darwin" const { t } = useTranslation() return ( @@ -52,15 +53,14 @@ export const SubscriptionColumnHeader = memo(() => { </LogoContextMenu> )} <div className="relative flex items-center gap-2" onClick={stopPropagation}> - <Link to="/discover" tabIndex={-1}> - <ActionButton - data-testid="subscription-discover-trigger" - shortcut="$mod+T" - tooltip={t("words.discover")} - > - <i className="i-mgc-add-cute-re size-5 text-text-secondary" /> - </ActionButton> - </Link> + <ActionButton + data-testid="subscription-discover-trigger" + shortcut="$mod+T" + tooltip={t("words.discover")} + onClick={() => navigate("/discover")} + > + <i className="i-mgc-add-cute-re size-5 text-text-secondary" /> + </ActionButton> <ProfileButton method="modal" animatedAvatar /> <LayoutActionButton /> diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionTabButton.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionTabButton.tsx index e9241bc5d91..ae4d2d0755b 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionTabButton.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionTabButton.tsx @@ -157,6 +157,7 @@ const ViewAllSwitchButton: FC<{ return ( <ActionButton data-testid={getTimelineTabTestId(item.name)} + aria-pressed={isActive} shortcutScope={FocusablePresets.isNotFloatingLayerScope} key={item.name} tooltip={t(item.name, { ns: "common" })} @@ -219,6 +220,7 @@ const ViewSwitchButton: FC<{ return ( <ActionButton data-testid={getTimelineTabTestId(item.name)} + aria-pressed={isActive} shortcutScope={FocusablePresets.isNotFloatingLayerScope} ref={setNodeRef} key={item.name} diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/TimelineTabsSettingsModal.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/TimelineTabsSettingsModal.tsx index 6e44b8487d4..7cb502e3cc2 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/TimelineTabsSettingsModal.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/TimelineTabsSettingsModal.tsx @@ -18,6 +18,7 @@ import { import { CSS } from "@dnd-kit/utilities" import { Button } from "@follow/components/ui/button/index.js" import { getView } from "@follow/constants" +import { cn } from "@follow/utils/utils" import type { CSSProperties, ReactNode } from "react" import { useCallback, useMemo } from "react" import { useTranslation } from "react-i18next" @@ -27,16 +28,31 @@ import { useModalStack } from "~/components/ui/modal/stacked/hooks" import { parseView } from "~/hooks/biz/useRouteParams" import { useTimelineList } from "~/hooks/biz/useTimelineList" -function ContainerDroppable({ id, children }: { id: "visible" | "hidden"; children: ReactNode }) { +function ContainerDroppable({ + id, + children, + emptyLabel, + hasItems, +}: { + id: "visible" | "hidden" + children: ReactNode + emptyLabel: string + hasItems: boolean +}) { const { setNodeRef, isOver } = useDroppable({ id, data: { container: id } }) return ( <div ref={setNodeRef} - className={`flex min-h-[120px] w-full flex-wrap items-center justify-center rounded-lg border border-border bg-material-ultra-thin p-2 pb-6 shadow-sm ${ - isOver ? "outline outline-1 outline-orange-400" : "" - }`} + className={cn( + "flex min-h-[120px] w-full flex-col items-stretch justify-center rounded-xl border border-border bg-material-ultra-thin p-3 shadow-sm transition-colors", + isOver && "border-accent/50 bg-accent/5 ring-2 ring-accent/20", + )} > - {children} + {hasItems ? ( + children + ) : ( + <p className="px-3 py-6 text-center text-sm text-text-tertiary">{emptyLabel}</p> + )} </div> ) } @@ -55,7 +71,7 @@ function TabItem({ id }: { id: UniqueIdentifier }) { const meta = getViewMeta(String(id)) const { t } = useTranslation() return ( - <div className="flex w-full items-center gap-2 rounded-md p-2 hover:bg-material-opaque"> + <div className="flex w-full items-center gap-2 rounded-lg border border-transparent bg-background/60 p-2.5 hover:bg-material-opaque"> <div className="flex size-6 items-center justify-center text-lg">{meta.icon}</div> <div className="text-callout text-text-secondary"> {t(meta.name as any, { ns: "common" })} @@ -65,6 +81,8 @@ function TabItem({ id }: { id: UniqueIdentifier }) { } function SortableTabItem({ id }: { id: UniqueIdentifier }) { + const { t } = useTranslation("app") + const meta = getViewMeta(String(id)) const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, }) @@ -79,7 +97,11 @@ function SortableTabItem({ id }: { id: UniqueIdentifier }) { <div ref={setNodeRef} style={style} - className={isDragging ? "cursor-grabbing" : "cursor-grab"} + className={cn( + isDragging ? "cursor-grabbing" : "cursor-grab", + "rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30", + )} + aria-label={`${t("sidebar.timeline_tabs.drag_tab")}: ${t(meta.name as any, { ns: "common" })}`} {...attributes} {...listeners} > @@ -96,6 +118,7 @@ function useResolvedTimelineTabs() { } const TimelineTabsSettings = () => { + const { t } = useTranslation(["app", "common", "settings"]) const { visible, hidden } = useResolvedTimelineTabs() const commitTimelineTabs = useCallback( @@ -175,6 +198,12 @@ const TimelineTabsSettings = () => { className="mx-auto w-[600px] max-w-full space-y-4 overflow-hidden pt-2" onPointerDown={(e) => e.stopPropagation()} > + <div className="space-y-1 px-1"> + <p className="text-sm text-text-secondary"> + {t("appearance.customize_sub_tabs.description", { ns: "settings" })} + </p> + <p className="text-xs text-text-tertiary">{t("sidebar.timeline_tabs.instructions")}</p> + </div> <DndContext sensors={sensors} collisionDetection={closestCenter} @@ -183,8 +212,14 @@ const TimelineTabsSettings = () => { > <div className="space-y-4"> <div> - <h3 className="mb-2 text-subheadline font-medium text-text">Visible</h3> - <ContainerDroppable id="visible"> + <h3 className="mb-2 text-subheadline font-medium text-text"> + {t("sidebar.timeline_tabs.visible")} + </h3> + <ContainerDroppable + id="visible" + emptyLabel={t("sidebar.timeline_tabs.empty_visible")} + hasItems={visible.length > 0} + > <SortableContext items={visible} strategy={verticalListSortingStrategy}> {visible.map((id) => ( <SortableTabItem key={id} id={id} /> @@ -194,8 +229,14 @@ const TimelineTabsSettings = () => { </div> <div> - <h3 className="mb-2 text-subheadline font-medium text-text">Hidden</h3> - <ContainerDroppable id="hidden"> + <h3 className="mb-2 text-subheadline font-medium text-text"> + {t("sidebar.timeline_tabs.hidden")} + </h3> + <ContainerDroppable + id="hidden" + emptyLabel={t("sidebar.timeline_tabs.empty_hidden")} + hasItems={hidden.length > 0} + > <SortableContext items={hidden} strategy={verticalListSortingStrategy}> {hidden.map((id) => ( <SortableTabItem key={id} id={id} /> @@ -209,6 +250,7 @@ const TimelineTabsSettings = () => { <div className="flex justify-end"> <Button variant="outline" + disabled={visible.length === 0 && hidden.length === 0} onClick={() => { setUISetting("timelineTabs", { visible: [], @@ -216,7 +258,7 @@ const TimelineTabsSettings = () => { }) }} > - Reset to default + {t("sidebar.timeline_tabs.reset")} </Button> </div> </div> @@ -225,13 +267,14 @@ const TimelineTabsSettings = () => { export const useShowTimelineTabsSettingsModal = () => { const { present } = useModalStack() + const { t } = useTranslation("settings") return useCallback(() => { present({ id: "timeline-tabs-settings", - title: "Customize View Tabs", + title: t("appearance.customize_sub_tabs.label"), content: () => <TimelineTabsSettings />, overlay: true, clickOutsideToDismiss: true, }) - }, [present]) + }, [present, t]) } diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/subscription-list/SubscriptionList.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/subscription-list/SubscriptionList.tsx index 1b9902a8aba..e75f698e46d 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/subscription-list/SubscriptionList.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/subscription-list/SubscriptionList.tsx @@ -290,7 +290,7 @@ const SubscriptionImpl = ({ ref, className, view, isSubscriptionLoading }: Subsc <SortableFeedList view={view} data={feedsData} - categoryOpenStateData={categoryOpenStateData} + categoryOpenStateData={categoryOpenStateData ?? {}} /> ) : isSubscriptionLoading ? ( <SubscriptionListSkeleton /> diff --git a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(ai)/ai/index.tsx b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(ai)/ai/index.tsx index 3d9f72882d1..7e2ede431cc 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(ai)/ai/index.tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(ai)/ai/index.tsx @@ -1,5 +1,3 @@ -import { cn } from "@follow/utils" - import { AIChatRoot } from "~/modules/ai-chat/components/layouts/AIChatRoot" import { ChatPageHeader } from "~/modules/ai-chat/components/layouts/ChatHeader" import { ChatInterface } from "~/modules/ai-chat/components/layouts/ChatInterface" @@ -7,10 +5,7 @@ import { ChatInterface } from "~/modules/ai-chat/components/layouts/ChatInterfac export const Component = () => { return ( <div - className={cn( - "relative flex h-screen w-full flex-col", - "[&_[data-testid=chat-input-container]]:translate-y-32 [&_[data-testid=welcome-screen-header]]:-translate-y-24", - )} + className="relative flex h-screen w-full flex-col [&_[data-testid=welcome-screen-header]]:-translate-y-24" style={{ "--ai-chat-layout-width": "65rem" } as React.CSSProperties} > <AIChatRoot> diff --git a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/index.tsx b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/index.tsx index 35cbae22626..34a6de5edc0 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/index.tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/index.tsx @@ -19,7 +19,7 @@ interface SectionProps { } function Section({ children, className }: SectionProps) { - return <section className={cn("mx-auto w-full max-w-6xl", className)}>{children}</section> + return <section className={cn("mx-auto w-full max-w-5xl", className)}>{children}</section> } // ============================================================================ @@ -33,25 +33,21 @@ export function Component() { const hasSearchData = useHasDiscoverSearchData() return ( - <div className="flex size-full flex-col px-6 py-8"> - {/* Hero Section */} - <Section className="mb-12"> - <div className="text-center"> - <h1 className="mb-2 text-3xl font-bold text-text">{t("words.discover")}</h1> - <p className="text-sm text-text-secondary">{t("discover.tips.search_keyword")}</p> + <div className="flex size-full flex-col p-6"> + <Section className="mb-8"> + <div className="rounded-[28px] border border-fill-secondary bg-material-ultra-thin px-6 py-8 shadow-sm"> + <div className="text-center"> + <h1 className="mb-2 text-3xl font-bold text-text">{t("words.discover")}</h1> + <p className="text-sm text-text-secondary">{t("discover.tips.search_keyword")}</p> + </div> + <div className="mt-6 flex flex-col items-center"> + <UnifiedDiscoverForm /> + </div> </div> </Section> - {/* Search Section */} - <Section className="mb-12"> - <div className="flex flex-col items-center"> - <UnifiedDiscoverForm /> - </div> - </Section> - - {/* Discovery Section - Hide when searching */} {!hasSearchData && ( - <Section> + <Section className="mt-8"> <AppErrorBoundary errorType={ErrorComponentType.RSSHubDiscoverError}> <DiscoveryContent /> </AppErrorBoundary> diff --git a/apps/desktop/layer/renderer/src/pages/(main)/index.sync.tsx b/apps/desktop/layer/renderer/src/pages/(main)/index.sync.tsx index eebb26bdbd6..6add8555c53 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/index.sync.tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/index.sync.tsx @@ -15,12 +15,12 @@ export const loader = () => { const subscriptionState = useSubscriptionStore.getState() const hasAudiosSubscription = - subscriptionState.feedIdByView[FeedViewType.Audios].size > 0 || - subscriptionState.listIdByView[FeedViewType.Audios].size > 0 + (subscriptionState.feedIdByView[FeedViewType.Audios]?.size ?? 0) > 0 || + (subscriptionState.listIdByView[FeedViewType.Audios]?.size ?? 0) > 0 const hasNotificationsSubscription = - subscriptionState.feedIdByView[FeedViewType.Notifications].size > 0 || - subscriptionState.listIdByView[FeedViewType.Notifications].size > 0 + (subscriptionState.feedIdByView[FeedViewType.Notifications]?.size ?? 0) > 0 || + (subscriptionState.listIdByView[FeedViewType.Notifications]?.size ?? 0) > 0 const { visible } = computeTimelineTabLists({ timelineTabs: uiSettings.timelineTabs, diff --git a/apps/desktop/layer/renderer/src/queries/discover.ts b/apps/desktop/layer/renderer/src/queries/discover.ts index 0687357b400..e82ea99c3ec 100644 --- a/apps/desktop/layer/renderer/src/queries/discover.ts +++ b/apps/desktop/layer/renderer/src/queries/discover.ts @@ -41,9 +41,7 @@ export const discover = { }), rsshubAnalytics: ({ lang }: { lang?: string }) => defineQuery(["discover", "rsshub", "analytics", lang], async () => { - const res = await followClient.api.discover.rsshubAnalytics({ - ...(lang !== "all" && { lang }), - }) + const res = await followClient.api.discover.rsshubAnalytics({}) return res.data }), } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cdd006ddeaa..2c5137fe64f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "Folo", "type": "module", - "version": "1.3.1", + "version": "1.4.0", "private": true, "description": "Follow everything in one place", "author": "Folo Team", @@ -18,7 +18,7 @@ "build:electron-forge:macos": "electron-forge make --arch=x64 --platform=darwin && electron-forge make --arch=arm64 --platform=darwin && tsx scripts/merge-yml.ts", "build:electron-forge:mas": "electron-forge make --arch=universal --platform=mas", "build:electron-forge:ms": "tsx scripts/generate-appx-manifest.ts && electron-forge make --platform=win32 --ms=true", - "build:electron-vite": "pnpm run prepare:cli && electron-vite build", + "build:electron-vite": "electron-vite build", "build:render": "vite build -c vite.config.electron-render.ts", "build:web": "rm -rf out/web && cross-env WEB_BUILD=1 vite build", "bump": "vv --minor", @@ -36,7 +36,6 @@ "e2e:web": "playwright test -c e2e/playwright.config.ts --project=web", "e2e:web:prod": "cross-env FOLO_E2E_PROFILE=prod playwright test -c e2e/playwright.config.ts --project=web", "hotfix": "vv -c bump.hotfix.config.js --patch", - "prepare:cli": "tsx scripts/prepare-cli.ts", "publish": "electron-vite build && electron-forge publish", "start": "electron-vite preview", "update:main-hash": "tsx plugins/vite/generate-main-hash.ts" @@ -95,5 +94,5 @@ "vite-tsconfig-paths": "6.1.1" }, "productName": "Folo", - "mainHash": "906a0b82e00829115764f36a38132f3774120c4bea764e3b1be8949f447ccb06" + "mainHash": "0c464fca7c98fd4b42abba743abb1cd590e216043987acf4f6d1e10392ce0e57" } diff --git a/apps/desktop/scripts/prepare-cli.ts b/apps/desktop/scripts/prepare-cli.ts deleted file mode 100644 index 9658c6d9324..00000000000 --- a/apps/desktop/scripts/prepare-cli.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { execSync } from "node:child_process" -import { cpSync, existsSync, mkdirSync } from "node:fs" - -import { resolve } from "pathe" - -const rootDir = resolve(import.meta.dirname, "../../..") -const cliDistDir = resolve(rootDir, "apps/cli/dist") -const targetDir = resolve(rootDir, "apps/desktop/resources/cli") - -// Build CLI -console.info("Building CLI...") -execSync("pnpm --filter @follow/cli build", { - cwd: rootDir, - stdio: "inherit", -}) - -// Ensure CLI was built -const cliEntry = resolve(cliDistDir, "index.js") -if (!existsSync(cliEntry)) { - throw new Error(`CLI build output not found at ${cliEntry}`) -} - -// Copy to desktop resources -mkdirSync(targetDir, { recursive: true }) -cpSync(cliEntry, resolve(targetDir, "index.js")) - -console.info("CLI prepared at", targetDir) diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 9f9613ab6e5..f0258077c74 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -290,8 +290,6 @@ export default ({ mode }) => { "@tanstack/query-sync-storage-persister", ], ["tldts"], - - ["@openpanel/web"], ["zod", "react-hook-form", "@hookform/resolvers"], ]), diff --git a/apps/desktop/wrangler.jsonc b/apps/desktop/wrangler.jsonc new file mode 100644 index 00000000000..8bd1786d673 --- /dev/null +++ b/apps/desktop/wrangler.jsonc @@ -0,0 +1,29 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "folo-web", + "compatibility_date": "2026-02-01", + "account_id": "1f1d1678a2413a54c944b3081bab5c84", + "assets": { + "directory": "./out/web", + "not_found_handling": "single-page-application", + }, + "workers_dev": true, + "routes": [ + { + "pattern": "app.folo.is/*", + "zone_id": "115ea8e6a7865dbfc1cf4530d5f87f63", + }, + ], + "env": { + "dev": { + "name": "folo-web-dev", + "workers_dev": true, + "routes": [ + { + "pattern": "dev.folo.is/*", + "zone_id": "115ea8e6a7865dbfc1cf4530d5f87f63", + }, + ], + }, + }, +} diff --git a/apps/landing/package.json b/apps/landing/package.json index 61b06eecc00..a3f37ff8ffd 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -39,8 +39,8 @@ "progressive-blur": "1.0.0", "radix-ui": "1.4.3", "re-resizable": "6.11.2", - "react": "19.0.0", - "react-dom": "19.0.0", + "react": "19.2.4", + "react-dom": "19.2.4", "react-error-boundary": "6.0.0", "react-intersection-observer": "9.16.0", "react-markdown": "10.1.0", @@ -55,7 +55,7 @@ "unified": "11.0.5", "usehooks-ts": "3.1.1", "vaul": "1.1.2", - "vinext": "0.0.9" + "vinext": "0.0.30" }, "devDependencies": { "@cloudflare/vite-plugin": "1.25.5", diff --git a/apps/landing/public/discover-sources.json b/apps/landing/public/discover-sources.json new file mode 100644 index 00000000000..f65f451f689 --- /dev/null +++ b/apps/landing/public/discover-sources.json @@ -0,0 +1,10811 @@ +[ + { + "key": "xiaohongshu", + "name": "小红书", + "host": "xiaohongshu.com", + "heat": 1413942, + "categories": ["social-media", "popular"] + }, + { + "key": "twitter", + "name": "X (Twitter)", + "host": "x.com", + "heat": 1269331, + "categories": ["social-media", "popular"] + }, + { + "key": "picnob.info", + "name": "Instagram", + "host": "instagram.com", + "heat": 372125, + "categories": ["social-media", "popular"] + }, + { + "key": "telegram", + "name": "Telegram", + "host": "t.me", + "heat": 285958, + "categories": ["social-media", "popular"] + }, + { + "key": "youtube", + "name": "YouTube", + "host": "youtube.com", + "heat": 278462, + "categories": ["social-media", "popular", "live"] + }, + { + "key": "bilibili", + "name": "哔哩哔哩 bilibili", + "host": "bilibili.com", + "heat": 214649, + "categories": ["program-update", "social-media", "popular", "live"] + }, + { + "key": "weibo", + "name": "微博", + "host": "weibo.com", + "heat": 59600, + "categories": ["social-media", "popular"] + }, + { + "key": "xiaoyuzhou", + "name": "小宇宙", + "host": "xiaoyuzhoufm.com", + "heat": 51947, + "categories": ["multimedia", "popular"] + }, + { + "key": "pixiv", + "name": "pixiv", + "host": "pixiv.net", + "heat": 48311, + "categories": ["social-media", "popular"] + }, + { + "key": "github", + "name": "GitHub", + "host": "github.com", + "heat": 45308, + "categories": ["programming", "popular"] + }, + { + "key": "pornhub", + "name": "PornHub", + "host": "pornhub.com", + "heat": 39528, + "categories": ["multimedia", "popular"] + }, + { + "key": "sspai", + "name": "少数派 sspai", + "host": "sspai.com", + "heat": 34311, + "categories": ["new-media", "popular"] + }, + { + "key": "jike", + "name": "即刻", + "host": "m.okjike.com", + "heat": 32042, + "categories": ["social-media", "popular"] + }, + { + "key": "nasa", + "name": "NASA", + "host": "apod.nasa.gov", + "heat": 29994, + "categories": ["picture", "popular"] + }, + { + "key": "epicgames", + "name": "Epic Games Store", + "host": "store.epicgames.com", + "heat": 26905, + "categories": ["game", "popular"] + }, + { + "key": "zhihu", + "name": "知乎", + "host": "zhihu.com", + "heat": 26633, + "categories": ["social-media", "popular"] + }, + { + "key": "nature", + "name": "Nature Journal", + "host": "nature.com", + "heat": 26324, + "categories": ["journal", "popular"] + }, + { + "key": "v2ex", + "name": "V2EX", + "host": "v2ex.com", + "heat": 25321, + "categories": ["bbs", "popular", "blog"] + }, + { + "key": "bsky", + "name": "Bluesky (bsky)", + "host": "bsky.app", + "heat": 25232, + "categories": ["social-media", "popular"] + }, + { + "key": "magnumphotos", + "name": "Magnum Photos", + "host": "magnumphotos.com", + "heat": 24781, + "categories": ["picture", "popular"] + }, + { + "key": "t66y", + "name": "草榴社区", + "host": "t66y.com", + "heat": 24702, + "categories": ["multimedia", "popular"] + }, + { + "key": "threads", + "name": "Threads", + "host": "threads.net", + "heat": 24696, + "categories": ["social-media", "popular"] + }, + { + "key": "1x", + "name": "1x.com", + "host": "1x.com", + "heat": 24455, + "categories": ["design", "picture", "popular"] + }, + { + "key": "ps", + "name": "PlayStation Store", + "host": "playstation.com", + "heat": 22639, + "categories": ["game", "popular"] + }, + { + "key": "gov", + "name": "深圳市罗湖区人民政府", + "host": "szlh.gov.cn", + "heat": 18933, + "categories": ["government", "forecast", "popular", "finance"] + }, + { + "key": "36kr", + "name": "36kr", + "host": "36kr.com", + "heat": 18922, + "categories": ["new-media", "popular"] + }, + { + "key": "2048", + "name": "2048 核基地", + "host": "hjd2048.com", + "heat": 18179, + "categories": ["multimedia", "popular"] + }, + { + "key": "caixin", + "name": "财新博客", + "host": "caixin.com", + "heat": 16065, + "categories": ["traditional-media", "blog", "popular"] + }, + { + "key": "javbus", + "name": "JavBus", + "host": "javbus.com", + "heat": 13595, + "categories": ["multimedia", "popular"] + }, + { + "key": "javtiful", + "name": "Javtiful", + "host": "javtiful.com", + "heat": 12850, + "categories": ["multimedia", "popular"] + }, + { + "key": "juejin", + "name": "掘金", + "host": "juejin.cn", + "heat": 12318, + "categories": ["programming", "popular"] + }, + { + "key": "smzdm", + "name": "什么值得买", + "host": "post.smzdm.com", + "heat": 12263, + "categories": ["shopping", "popular"] + }, + { + "key": "rsshub", + "name": "RSSHub", + "host": "docs.rsshub.app", + "heat": 12237, + "categories": ["program-update", "popular", "other"] + }, + { + "key": "javdb", + "name": "JavDB", + "host": "javdb.com", + "heat": 11347, + "categories": ["multimedia", "popular"] + }, + { + "key": "douban", + "name": "豆瓣", + "host": "douban.com", + "heat": 10882, + "categories": ["social-media", "popular"] + }, + { + "key": "picuki", + "name": "TikTok", + "host": "tiktok.com", + "heat": 10773, + "categories": ["social-media", "popular"] + }, + { + "key": "zaobao", + "name": "联合早报", + "host": "zaobao.com", + "heat": 10595, + "categories": ["traditional-media", "popular"] + }, + { + "key": "nytimes", + "name": "The New York Times", + "host": "nytimes.com", + "heat": 8922, + "categories": ["traditional-media", "popular"] + }, + { + "key": "hellogithub", + "name": "HelloGitHub", + "host": "hellogithub.com", + "heat": 8406, + "categories": ["programming", "popular"] + }, + { + "key": "500px", + "name": "500px 摄影社区", + "host": "500px.com.cn", + "heat": 7524, + "categories": ["picture", "popular"] + }, + { + "key": "eastmoney", + "name": "东方财富", + "host": "data.eastmoney.com", + "heat": 7265, + "categories": ["finance", "popular"] + }, + { + "key": "latepost", + "name": "晚点 LatePost", + "host": "latepost.com", + "heat": 7072, + "categories": ["new-media", "popular"] + }, + { + "key": "xsijishe", + "name": "司机社", + "host": "xsijishe.com", + "heat": 7006, + "categories": ["bbs", "popular"] + }, + { + "key": "hackernews", + "name": "Hacker News", + "host": "ycombinator.com", + "heat": 6563, + "categories": ["programming", "popular"] + }, + { + "key": "xueqiu", + "name": "雪球", + "host": "danjuanapp.com", + "heat": 6382, + "categories": ["finance", "popular"] + }, + { + "key": "jpxgmn", + "name": "极品性感美女", + "host": "jpxgmn.com", + "heat": 6323, + "categories": ["picture", "popular"] + }, + { + "key": "reuters", + "name": "Reuters", + "host": "reuters.com", + "heat": 5915, + "categories": ["traditional-media", "popular"] + }, + { + "key": "dapenti", + "name": "喷嚏", + "host": "dapenti.com", + "heat": 5648, + "categories": ["picture", "popular"] + }, + { + "key": "cctv", + "name": "央视新闻", + "host": "news.cctv.com", + "heat": 5449, + "categories": ["traditional-media", "popular"] + }, + { + "key": "cls", + "name": "财联社", + "host": "cls.cn", + "heat": 5383, + "categories": ["finance", "popular"] + }, + { + "key": "bloomberg", + "name": "Bloomberg", + "host": "bloomberg.com", + "heat": 5339, + "categories": ["finance", "popular"] + }, + { + "key": "follow", + "name": "Follow", + "host": "app.follow.is", + "heat": 5303, + "categories": ["social-media", "popular"] + }, + { + "key": "baoyu", + "name": "宝玉", + "host": "baoyu.io", + "heat": 5246, + "categories": ["blog", "popular"] + }, + { + "key": "readhub", + "name": "Readhub", + "host": "readhub.cn", + "heat": 4762, + "categories": ["new-media", "popular"] + }, + { + "key": "thepaper", + "name": "澎湃新闻", + "host": "thepaper.cn", + "heat": 4693, + "categories": ["new-media", "popular"] + }, + { + "key": "gelonghui", + "name": "格隆汇", + "host": "gelonghui.com", + "heat": 4411, + "categories": ["finance", "popular"] + }, + { + "key": "bjp", + "name": "北京天文馆", + "host": "bjp.org.cn", + "heat": 4250, + "categories": ["picture", "popular"] + }, + { + "key": "zcool", + "name": "站酷", + "host": "zcool.com.cn", + "heat": 3870, + "categories": ["design", "popular"] + }, + { + "key": "i-cable", + "name": "有線新聞", + "host": "i-cable.com", + "heat": 3576, + "categories": ["traditional-media", "popular"] + }, + { + "key": "yyets", + "name": "人人影视", + "host": "yysub.net", + "heat": 3542, + "categories": ["multimedia", "popular"] + }, + { + "key": "163", + "name": "网易公开课", + "host": "163.com", + "heat": 3458, + "categories": ["game", "new-media", "multimedia", "popular", "study"] + }, + { + "key": "7mmtv", + "name": "7mmtv", + "host": "7mmtv.tv", + "heat": 3361, + "categories": ["multimedia", "popular"] + }, + { + "key": "nga", + "name": "NGA", + "host": "bbs.nga.cn", + "heat": 3231, + "categories": ["bbs", "popular"] + }, + { + "key": "huggingface", + "name": "Huggingface", + "host": "huggingface.co", + "heat": 3219, + "categories": ["programming", "popular"] + }, + { + "key": "infoq", + "name": "InfoQ 中文", + "host": "infoq.cn", + "heat": 3218, + "categories": ["programming", "new-media", "popular"] + }, + { + "key": "infzm", + "name": "南方周末", + "host": "infzm.com", + "heat": 3196, + "categories": ["traditional-media", "popular"] + }, + { + "key": "aibase", + "name": "AIbase", + "host": "aibase.com", + "heat": 3095, + "categories": ["new-media", "popular"] + }, + { + "key": "economist", + "name": "The Economist", + "host": "economist.com", + "heat": 3086, + "categories": ["traditional-media", "popular"] + }, + { + "key": "youzhiyouxing", + "name": "有知有行", + "host": "youzhiyouxing.cn", + "heat": 2989, + "categories": ["finance", "popular"] + }, + { + "key": "spotify", + "name": "Spotify", + "host": "open.spotify.com", + "heat": 2948, + "categories": ["multimedia", "popular"] + }, + { + "key": "bookfere", + "name": "书伴", + "host": "bookfere.com", + "heat": 2934, + "categories": ["reading", "popular"] + }, + { + "key": "jin10", + "name": "金十数据", + "host": "jin10.com", + "heat": 2778, + "categories": ["finance", "popular"] + }, + { + "key": "natgeo", + "name": "National Geographic", + "host": "nationalgeographic.com", + "heat": 2767, + "categories": ["picture", "popular", "travel"] + }, + { + "key": "gofans", + "name": "GoFans", + "host": "gofans.cn", + "heat": 2724, + "categories": ["program-update", "popular"] + }, + { + "key": "coolapk", + "name": "酷安", + "host": "coolapk.com", + "heat": 2722, + "categories": ["social-media"] + }, + { + "key": "google", + "name": "Google", + "host": "google.com", + "heat": 2713, + "categories": [ + "picture", + "other", + "journal", + "blog", + "program-update", + "design", + "new-media" + ] + }, + { + "key": "sciencenet", + "name": "科学网", + "host": "blog.sciencenet.cn", + "heat": 2682, + "categories": ["new-media", "popular"] + }, + { + "key": "sis001", + "name": "第一会所", + "host": "sis001.com", + "heat": 2596, + "categories": ["bbs", "popular"] + }, + { + "key": "solidot", + "name": "Solidot", + "host": "solidot.org", + "heat": 2590, + "categories": ["traditional-media", "popular"] + }, + { + "key": "wechat", + "name": "微信小程序", + "host": "posts.careerengine.us", + "heat": 2539, + "categories": ["programming", "new-media"] + }, + { + "key": "bbc", + "name": "BBC", + "host": "bbc.com", + "heat": 2307, + "categories": ["traditional-media", "popular", "study"] + }, + { + "key": "huxiu", + "name": "虎嗅", + "host": "huxiu.com", + "heat": 2245, + "categories": ["new-media", "popular"] + }, + { + "key": "apple", + "name": "Apple", + "host": "apple.com", + "heat": 2060, + "categories": ["program-update", "popular", "design", "other", "multimedia"] + }, + { + "key": "mckinsey", + "name": "麦肯锡", + "host": "mckinsey.com.cn", + "heat": 2015, + "categories": ["finance", "popular"] + }, + { + "key": "anthropic", + "name": "Anthropic", + "host": "anthropic.com", + "heat": 2002, + "categories": ["programming"] + }, + { + "key": "aisixiang", + "name": "爱思想", + "host": "aisixiang.com", + "heat": 1942, + "categories": ["reading", "popular"] + }, + { + "key": "wallstreetcn", + "name": "华尔街见闻", + "host": "wallstreetcn.com", + "heat": 1893, + "categories": ["finance"] + }, + { + "key": "playno1", + "name": "PLAYNO.1 玩樂達人", + "host": "stno1.playno1.com", + "heat": 1839, + "categories": ["bbs", "popular"] + }, + { + "key": "xbookcn", + "name": "中文成人文學網", + "host": "xbookcn.net", + "heat": 1800, + "categories": ["reading", "popular"] + }, + { + "key": "geekpark", + "name": "极客公园", + "host": "geekpark.net", + "heat": 1773, + "categories": ["new-media", "popular"] + }, + { + "key": "behance", + "name": "Behance", + "host": "behance.net", + "heat": 1756, + "categories": ["design", "popular"] + }, + { + "key": "binance", + "name": "Binance", + "host": "binance.com", + "heat": 1709, + "categories": ["finance", "popular"] + }, + { + "key": "mittrchina", + "name": "麻省理工科技评论", + "host": "mittrchina.com", + "heat": 1701, + "categories": ["new-media", "popular"] + }, + { + "key": "gamer", + "name": "巴哈姆特電玩資訊站", + "host": "acg.gamer.com.tw", + "heat": 1700, + "categories": ["anime", "popular"] + }, + { + "key": "zhubai", + "name": "竹白", + "host": "zhubai.love", + "heat": 1696, + "categories": ["blog"] + }, + { + "key": "theinitium", + "name": "The Initium", + "host": "theinitium.com", + "heat": 1683, + "categories": ["new-media", "popular"] + }, + { + "key": "xiaoheihe", + "name": "小黑盒", + "host": "xiaoheihe.cn", + "heat": 1673, + "categories": ["game"] + }, + { + "key": "obsidian", + "name": "Obsidian", + "host": "obsidian.md", + "heat": 1658, + "categories": ["program-update", "popular", "blog"] + }, + { + "key": "newyorker", + "name": "New Yorker", + "host": "newyorker.com", + "heat": 1629, + "categories": ["traditional-media", "popular"] + }, + { + "key": "ithome", + "name": "iThome 台灣", + "host": "ithome.com", + "heat": 1608, + "categories": ["new-media"] + }, + { + "key": "10jqka", + "name": "同花顺财经", + "host": "10jqka.com.cn", + "heat": 1595, + "categories": ["finance", "popular"] + }, + { + "key": "ainvest", + "name": "AInvest", + "host": "ainvest.com", + "heat": 1548, + "categories": ["finance", "popular"] + }, + { + "key": "jinse", + "name": "金色财经", + "host": "jinse.cn", + "heat": 1521, + "categories": ["finance"] + }, + { + "key": "dedao", + "name": "得到", + "host": "dedao.cn", + "heat": 1519, + "categories": ["new-media"] + }, + { + "key": "xiaomiyoupin", + "name": "小米有品", + "host": "xiaomiyoupin.com", + "heat": 1484, + "categories": ["shopping"] + }, + { + "key": "misskon", + "name": "MissKON", + "host": "misskon.com", + "heat": 1458, + "categories": ["picture"] + }, + { + "key": "imdb", + "name": "IMDb", + "host": "imdb.com", + "heat": 1441, + "categories": ["multimedia", "popular"] + }, + { + "key": "kemono", + "name": "Kemono", + "host": "kemono.cr", + "heat": 1422, + "categories": ["anime", "popular"] + }, + { + "key": "cngal", + "name": "CnGal", + "host": "cngal.org", + "heat": 1371, + "categories": ["anime", "popular"] + }, + { + "key": "theatlantic", + "name": "The Atlantic", + "host": "theatlantic.com", + "heat": 1360, + "categories": ["traditional-media", "popular"] + }, + { + "key": "deepmind", + "name": "DeepMind", + "host": "deepmind.com", + "heat": 1343, + "categories": ["new-media", "popular"] + }, + { + "key": "csdn", + "name": "CSDN", + "host": "blog.csdn.net", + "heat": 1335, + "categories": ["blog", "popular"] + }, + { + "key": "dribbble", + "name": "Dribbble", + "host": "dribbble.com", + "heat": 1242, + "categories": ["design"] + }, + { + "key": "nhk", + "name": "NHK", + "host": "www3.nhk.or.jp", + "heat": 1241, + "categories": ["traditional-media"] + }, + { + "key": "yicai", + "name": "第一财经", + "host": "yicai.com", + "heat": 1241, + "categories": ["traditional-media"] + }, + { + "key": "hk01", + "name": "香港 01", + "host": "hk01.com", + "heat": 1240, + "categories": ["new-media"] + }, + { + "key": "jianshu", + "name": "简书", + "host": "jianshu.com", + "heat": 1207, + "categories": ["social-media"] + }, + { + "key": "konachan", + "name": "Konachan.com Anime Wallpapers", + "host": "konachan.com", + "heat": 1194, + "categories": ["picture"] + }, + { + "key": "aliresearch", + "name": "阿里研究院", + "host": "aliresearch.com", + "heat": 1151, + "categories": ["new-media"] + }, + { + "key": "acfun", + "name": "AcFun", + "host": "acfun.cn", + "heat": 1150, + "categories": ["anime"] + }, + { + "key": "yande", + "name": "yande.re", + "host": "yande.re", + "heat": 1149, + "categories": ["picture"] + }, + { + "key": "oschina", + "name": "开源中国", + "host": "oschina.net", + "heat": 1136, + "categories": ["programming"] + }, + { + "key": "dx2025", + "name": "东西智库", + "host": "dx2025.com", + "heat": 1114, + "categories": ["new-media"] + }, + { + "key": "dongqiudi", + "name": "懂球帝", + "host": "m.dongqiudi.com", + "heat": 1088, + "categories": ["new-media"] + }, + { + "key": "tophub", + "name": "今日热榜", + "host": "tophub.today", + "heat": 1076, + "categories": ["new-media"] + }, + { + "key": "cool18", + "name": "禁忌书屋", + "host": "cool18.com", + "heat": 1074, + "categories": ["bbs"] + }, + { + "key": "bing", + "name": "Bing", + "host": "cn.bing.com", + "heat": 1066, + "categories": ["other"] + }, + { + "key": "chaping", + "name": "差评", + "host": "chaping.cn", + "heat": 1056, + "categories": ["new-media"] + }, + { + "key": "sina", + "name": "新浪", + "host": "finance.sina.com.cn", + "heat": 1044, + "categories": ["new-media"] + }, + { + "key": "guokr", + "name": "果壳网", + "host": "guokr.com", + "heat": 1039, + "categories": ["new-media"] + }, + { + "key": "followin", + "name": "Followin", + "host": "followin.io", + "heat": 1037, + "categories": ["finance"] + }, + { + "key": "guancha", + "name": "观察者网", + "host": "guancha.cn", + "heat": 1032, + "categories": ["new-media"] + }, + { + "key": "141ppv", + "name": "141PPV", + "host": "141ppv.com", + "heat": 1016, + "categories": ["multimedia"] + }, + { + "key": "jiuyangongshe", + "name": "韭研公社", + "host": "jiuyangongshe.com", + "heat": 996, + "categories": ["finance"] + }, + { + "key": "meituan", + "name": "美团", + "host": "meituan.com", + "heat": 973, + "categories": ["programming"] + }, + { + "key": "ftchinese", + "name": "FT 中文网", + "host": "ftchinese.com", + "heat": 972, + "categories": ["traditional-media"] + }, + { + "key": "4kup", + "name": "4KUP", + "host": "4kup.net", + "heat": 964, + "categories": ["picture"] + }, + { + "key": "hupu", + "name": "虎扑", + "host": ".hupu.com", + "heat": 946, + "categories": ["bbs"] + }, + { + "key": "taoguba", + "name": "淘股吧", + "host": "tgb.cn", + "heat": 924, + "categories": ["finance"] + }, + { + "key": "baidu", + "name": "百度", + "host": "baidu.com", + "heat": 918, + "categories": ["finance", "other", "bbs"] + }, + { + "key": "fx678", + "name": "汇通网", + "host": "fx678.com", + "heat": 901, + "categories": ["finance"] + }, + { + "key": "cankaoxiaoxi", + "name": "参考消息", + "host": "cankaoxiaoxi.com", + "heat": 888, + "categories": ["traditional-media"] + }, + { + "key": "gcores", + "name": "机核网", + "host": "gcores.com", + "heat": 885, + "categories": ["game", "new-media"] + }, + { + "key": "tencent", + "name": "腾讯", + "host": "tencent.com", + "heat": 872, + "categories": ["programming", "new-media", "game", "program-update"] + }, + { + "key": "bigquant", + "name": "BigQuant", + "host": "bigquant.com", + "heat": 859, + "categories": ["finance"] + }, + { + "key": "spankbang", + "name": "SpankBang", + "host": "spankbang.com", + "heat": 823, + "categories": ["multimedia"] + }, + { + "key": "huanqiu", + "name": "环球网", + "host": "huanqiu.com", + "heat": 817, + "categories": ["traditional-media"] + }, + { + "key": "theblockbeats", + "name": "律动 BlockBeats", + "host": "theblockbeats.info", + "heat": 805, + "categories": ["finance"] + }, + { + "key": "wnacg", + "name": "紳士漫畫", + "host": "wnacg.org", + "heat": 805, + "categories": ["other"] + }, + { + "key": "141jav", + "name": "141JAV", + "host": "141jav.com", + "heat": 801, + "categories": ["multimedia"] + }, + { + "key": "xyzrank", + "name": "中文播客榜", + "host": "xyzrank.com", + "heat": 799, + "categories": ["other"] + }, + { + "key": "ifanr", + "name": "爱范儿", + "host": "ifanr.com", + "heat": 795, + "categories": ["new-media"] + }, + { + "key": "woshipm", + "name": "人人都是产品经理", + "host": "woshipm.com", + "heat": 783, + "categories": ["new-media"] + }, + { + "key": "cosplaytele", + "name": "CosplayTele", + "host": "cosplaytele.com", + "heat": 780, + "categories": ["picture"] + }, + { + "key": "3dmgame", + "name": "3DMGame", + "host": "3dmgame.com", + "heat": 750, + "categories": ["game"] + }, + { + "key": "qq", + "name": "腾讯网", + "host": "qq.com", + "heat": 741, + "categories": ["new-media", "anime", "game", "other", "social-media", "bbs"] + }, + { + "key": "fastbull", + "name": "FastBull", + "host": "fastbull.com", + "heat": 730, + "categories": ["finance"] + }, + { + "key": "6v123", + "name": "6v 电影", + "host": "hao6v.cc", + "heat": 707, + "categories": ["multimedia"] + }, + { + "key": "rustcc", + "name": "Rust 语言中文社区", + "host": "rustcc.cn", + "heat": 703, + "categories": ["programming"] + }, + { + "key": "apnews", + "name": "AP News", + "host": "apnews.com", + "heat": 701, + "categories": ["traditional-media"] + }, + { + "key": "hellobtc", + "name": "白话区块链", + "host": "hellobtc.com", + "heat": 685, + "categories": ["new-media"] + }, + { + "key": "missav", + "name": "MissAV", + "host": "missav.ws", + "heat": 681, + "categories": ["multimedia"] + }, + { + "key": "cnki", + "name": "中国知网", + "host": "navi.cnki.net", + "heat": 671, + "categories": ["journal"] + }, + { + "key": "panewslab", + "name": "PANews", + "host": "panewslab.com", + "heat": 666, + "categories": ["new-media"] + }, + { + "key": "1point3acres", + "name": "一亩三分地", + "host": "blog.1point3acres.com", + "heat": 655, + "categories": ["bbs"] + }, + { + "key": "hpoi", + "name": "Hpoi 手办维基", + "host": "hpoi.net", + "heat": 647, + "categories": ["anime"] + }, + { + "key": "aeon", + "name": "AEON", + "host": "aeon.co", + "heat": 633, + "categories": ["new-media"] + }, + { + "key": "techflowpost", + "name": "深潮 TechFlow", + "host": "techflowpost.com", + "heat": 633, + "categories": ["finance"] + }, + { + "key": "foreverblog", + "name": "十年之约", + "host": "foreverblog.cn", + "heat": 623, + "categories": ["blog"] + }, + { + "key": "bitget", + "name": "Bitget", + "host": "bitget.com", + "heat": 620, + "categories": ["finance"] + }, + { + "key": "0818tuan", + "name": "0818 团", + "host": "0818tuan.com", + "heat": 609, + "categories": ["shopping"] + }, + { + "key": "techcrunch", + "name": "TechCrunch", + "host": "techcrunch.com", + "heat": 604, + "categories": ["new-media"] + }, + { + "key": "dlsite", + "name": "DLsite", + "host": "dlsite.com", + "heat": 598, + "categories": ["anime"] + }, + { + "key": "423down", + "name": "423Down", + "host": "423down.com", + "heat": 596, + "categories": ["program-update"] + }, + { + "key": "pingwest", + "name": "品玩", + "host": "pingwest.com", + "heat": 590, + "categories": ["new-media"] + }, + { + "key": "idaily", + "name": "iDaily", + "host": "idai.ly", + "heat": 578, + "categories": ["other"] + }, + { + "key": "nikkei", + "name": "The Nikkei 日本経済新聞", + "host": "nikkei.com", + "heat": 570, + "categories": ["traditional-media"] + }, + { + "key": "gamersky", + "name": "GamerSky", + "host": "gamersky.com", + "heat": 561, + "categories": ["game"] + }, + { + "key": "yxdzqb", + "name": "游戏打折情报", + "host": "yxdzqb.com", + "heat": 556, + "categories": ["game"] + }, + { + "key": "18comic", + "name": "禁漫天堂", + "host": "18comic.org", + "heat": 552, + "categories": ["anime"] + }, + { + "key": "gq", + "name": "GQ", + "host": "gq.com", + "heat": 551, + "categories": ["traditional-media"] + }, + { + "key": "openai", + "name": "OpenAI", + "host": "openai.com", + "heat": 533, + "categories": ["program-update", "programming"] + }, + { + "key": "zhitongcaijing", + "name": "智通财经网", + "host": "zhitongcaijing.com", + "heat": 533, + "categories": ["finance"] + }, + { + "key": "yahoo", + "name": "Yahoo", + "host": "hk.news.yahoo.com", + "heat": 532, + "categories": ["new-media"] + }, + { + "key": "paulgraham", + "name": "Paul Graham", + "host": "paulgraham.com", + "heat": 531, + "categories": ["blog"] + }, + { + "key": "odaily", + "name": "Odaily 星球日报", + "host": "odaily.news", + "heat": 528, + "categories": ["new-media"] + }, + { + "key": "dockerhub", + "name": "Docker Hub", + "host": "hub.docker.com", + "heat": 527, + "categories": ["program-update"] + }, + { + "key": "jandan", + "name": "煎蛋", + "host": "jandan.net", + "heat": 523, + "categories": ["other"] + }, + { + "key": "foresightnews", + "name": "Foresight News", + "host": "foresightnews.pro", + "heat": 522, + "categories": ["new-media"] + }, + { + "key": "right", + "name": "恩山无线论坛", + "host": "right.com.cn", + "heat": 519, + "categories": ["bbs"] + }, + { + "key": "questmobile", + "name": "QuestMobile", + "host": "questmobile.com.cn", + "heat": 516, + "categories": ["new-media"] + }, + { + "key": "yilinzazhi", + "name": "意林杂志", + "host": "yilinzazhi.com", + "heat": 513, + "categories": ["reading"] + }, + { + "key": "1lou", + "name": "BT 之家 1LOU 站", + "host": "1lou.me", + "heat": 503, + "categories": ["multimedia"] + }, + { + "key": "fuliba", + "name": "福利吧", + "host": "fuliba2023.net", + "heat": 501, + "categories": ["new-media"] + }, + { + "key": "lofter", + "name": "Lofter", + "host": "lofter.com", + "heat": 496, + "categories": ["social-media"] + }, + { + "key": "hanime1", + "name": "Hanime1", + "host": "hanime1.me", + "heat": 493, + "categories": ["anime"] + }, + { + "key": "qstheory", + "name": "求是网", + "host": "qstheory.cn", + "heat": 484, + "categories": ["traditional-media"] + }, + { + "key": "kanxue", + "name": "看雪", + "host": "kanxue.com", + "heat": 475, + "categories": ["bbs"] + }, + { + "key": "damai", + "name": "大麦网", + "host": "search.damai.cn", + "heat": 474, + "categories": ["shopping"] + }, + { + "key": "chikubi", + "name": "乳首ふぇち", + "host": "chikubi.jp", + "heat": 472, + "categories": ["multimedia"] + }, + { + "key": "agirls", + "name": "电獭少女", + "host": "agirls.aotter.net", + "heat": 471, + "categories": ["new-media"] + }, + { + "key": "appstore", + "name": "App Store/Mac App Store", + "host": "apps.apple.com", + "heat": 466, + "categories": ["program-update"] + }, + { + "key": "finviz", + "name": "finviz", + "host": "finviz.com", + "heat": 445, + "categories": ["finance"] + }, + { + "key": "mrdx", + "name": "新华每日电讯", + "host": "mrdx.cn", + "heat": 444, + "categories": ["traditional-media"] + }, + { + "key": "xianbao", + "name": "线报酷", + "host": "new.xianbao.fun", + "heat": 442, + "categories": ["shopping"] + }, + { + "key": "academia", + "name": "Academia", + "host": "academia.edu", + "heat": 440, + "categories": ["journal"] + }, + { + "key": "cna", + "name": "中央通讯社", + "host": "cna.com.tw", + "heat": 435, + "categories": ["traditional-media"] + }, + { + "key": "qbitai", + "name": "量子位", + "host": "qbitai.com", + "heat": 435, + "categories": ["new-media"] + }, + { + "key": "nosec", + "name": "NOSEC 安全讯息平台", + "host": "nosec.org", + "heat": 432, + "categories": ["programming"] + }, + { + "key": "twreporter", + "name": "報導者", + "host": "twreporter.org", + "heat": 415, + "categories": ["new-media"] + }, + { + "key": "sse", + "name": "上海证券交易所", + "host": "bond.sse.com.cn", + "heat": 414, + "categories": ["finance"] + }, + { + "key": "slowmist", + "name": "慢雾科技", + "host": "slowmist.com", + "heat": 403, + "categories": ["new-media"] + }, + { + "key": "4khd", + "name": "4KHD", + "host": "4khd.com", + "heat": 401, + "categories": ["picture"] + }, + { + "key": "toutiao", + "name": "今日头条", + "host": "toutiao.com", + "heat": 391, + "categories": ["new-media"] + }, + { + "key": "91porn", + "name": "91porn", + "host": "91porn.com", + "heat": 390, + "categories": ["multimedia"] + }, + { + "key": "studygolang", + "name": "Go 语言中文网", + "host": "studygolang.com", + "heat": 389, + "categories": ["programming"] + }, + { + "key": "inewsweek", + "name": "中国新闻周刊", + "host": "inewsweek.cn", + "heat": 386, + "categories": ["traditional-media"] + }, + { + "key": "myfans", + "name": "myfans", + "host": "myfans.jp", + "heat": 385, + "categories": ["multimedia"] + }, + { + "key": "cnblogs", + "name": "博客园", + "host": "cnblogs.com", + "heat": 382, + "categories": ["blog"] + }, + { + "key": "ollama", + "name": "Ollama", + "host": "ollama.com", + "heat": 377, + "categories": ["programming"] + }, + { + "key": "bendibao", + "name": "本地宝", + "host": "bendibao.com", + "heat": 375, + "categories": ["new-media"] + }, + { + "key": "medsci", + "name": "梅斯医学 MedSci", + "host": "medsci.cn", + "heat": 375, + "categories": ["new-media"] + }, + { + "key": "timednews", + "name": "时刻新闻", + "host": "timednews.com", + "heat": 374, + "categories": ["new-media"] + }, + { + "key": "douyin", + "name": "抖音直播", + "host": "douyin.com", + "heat": 369, + "categories": ["social-media", "live"] + }, + { + "key": "yuque", + "name": "语雀", + "host": "yuque.com", + "heat": 369, + "categories": ["study"] + }, + { + "key": "thecover", + "name": "封面新闻", + "host": "thecover.cn", + "heat": 364, + "categories": ["new-media"] + }, + { + "key": "deepseek", + "name": "Deepseek", + "host": "api-docs.deepseek.com", + "heat": 360, + "categories": ["programming"] + }, + { + "key": "blogread", + "name": "技术头条", + "host": "blogread.cn", + "heat": 357, + "categories": ["programming"] + }, + { + "key": "jisilu", + "name": "集思录", + "host": "jisilu.cn", + "heat": 355, + "categories": ["finance"] + }, + { + "key": "twitch", + "name": "Twitch", + "host": "twitch.tv", + "heat": 353, + "categories": ["live"] + }, + { + "key": "ifeng", + "name": "凤凰网", + "host": "feng.ifeng.com", + "heat": 353, + "categories": ["new-media"] + }, + { + "key": "cs", + "name": "中证网", + "host": "cs.com.cn", + "heat": 352, + "categories": ["finance"] + }, + { + "key": "people", + "name": "人民网", + "host": "people.com.cn", + "heat": 352, + "categories": ["traditional-media"] + }, + { + "key": "indienova", + "name": "indienova 独立游戏", + "host": "indienova.com", + "heat": 351, + "categories": ["game"] + }, + { + "key": "baozimh", + "name": "包子漫画", + "host": "baozimh.com", + "heat": 351, + "categories": ["anime"] + }, + { + "key": "zaker", + "name": "ZAKER", + "host": "myzaker.com", + "heat": 349, + "categories": ["other"] + }, + { + "key": "bangumi.tv", + "name": "Bangumi 番组计划", + "host": "bangumi.tv", + "heat": 348, + "categories": ["anime"] + }, + { + "key": "udn", + "name": "聯合新聞網", + "host": "udn.com", + "heat": 348, + "categories": ["traditional-media"] + }, + { + "key": "chlinlearn", + "name": "chlinlearn 的技术博客", + "host": "daily-blog.chlinlearn.top", + "heat": 347, + "categories": ["programming"] + }, + { + "key": "mi", + "name": "小米", + "host": "mi.com", + "heat": 343, + "categories": ["shopping", "program-update"] + }, + { + "key": "coomer", + "name": "Coomer", + "host": "coomer.st", + "heat": 339, + "categories": ["multimedia"] + }, + { + "key": "web", + "name": "web.dev", + "host": "web.dev", + "heat": 339, + "categories": ["programming"] + }, + { + "key": "4ksj", + "name": "4k 世界", + "host": "4ksj.com", + "heat": 336, + "categories": ["multimedia"] + }, + { + "key": "mihoyo", + "name": "米哈游", + "host": "genshin.hoyoverse.com", + "heat": 336, + "categories": ["game"] + }, + { + "key": "soundon", + "name": "SoundOn", + "host": "player.soundon.fm", + "heat": 335, + "categories": ["multimedia"] + }, + { + "key": "eleduck", + "name": "电鸭社区", + "host": "eleduck.com", + "heat": 332, + "categories": ["bbs"] + }, + { + "key": "topys", + "name": "TOPYS", + "host": "topys.cn", + "heat": 330, + "categories": ["new-media"] + }, + { + "key": "cma", + "name": "中国气象局", + "host": "weather.cma.cn", + "heat": 323, + "categories": ["forecast"] + }, + { + "key": "matters", + "name": "Matters", + "host": "matters.town", + "heat": 318, + "categories": ["new-media"] + }, + { + "key": "banyuetan", + "name": "半月谈", + "host": "banyuetan.org", + "heat": 318, + "categories": ["traditional-media"] + }, + { + "key": "pixivision", + "name": "pixivision", + "host": "pixivision.net", + "heat": 315, + "categories": ["anime"] + }, + { + "key": "guangdiu", + "name": "逛丢", + "host": "guangdiu.com", + "heat": 315, + "categories": ["shopping"] + }, + { + "key": "cnbc", + "name": "CNBC", + "host": "search.cnbc.com", + "heat": 314, + "categories": ["traditional-media"] + }, + { + "key": "pconline", + "name": "太平洋科技", + "host": "pconline.com.cn", + "heat": 314, + "categories": ["new-media"] + }, + { + "key": "ccreports", + "name": "消费者报道", + "host": "ccreports.com.cn", + "heat": 311, + "categories": ["shopping"] + }, + { + "key": "wikinews", + "name": "维基新闻", + "host": "zh.wikinews.org", + "heat": 308, + "categories": ["new-media"] + }, + { + "key": "theverge", + "name": "The Verge", + "host": "theverge.com", + "heat": 306, + "categories": ["new-media"] + }, + { + "key": "sehuatang", + "name": "色花堂", + "host": "sehuatang.net", + "heat": 304, + "categories": ["multimedia"] + }, + { + "key": "fantia", + "name": "Fantia", + "host": "fantia.jp", + "heat": 295, + "categories": ["picture"] + }, + { + "key": "linkedin", + "name": "LinkedIn", + "host": "linkedin.com", + "heat": 293, + "categories": ["other", "social-media"] + }, + { + "key": "radio", + "name": "云听", + "host": "radio.cn", + "heat": 293, + "categories": ["multimedia"] + }, + { + "key": "a9vg", + "name": "A9VG 电玩部落", + "host": "a9vg.com", + "heat": 289, + "categories": ["game"] + }, + { + "key": "oncc", + "name": "东网", + "host": "hk.on.cc", + "heat": 288, + "categories": ["traditional-media"] + }, + { + "key": "science", + "name": "Science Magazine", + "host": "science.org", + "heat": 282, + "categories": ["journal"] + }, + { + "key": "washingtonpost", + "name": "The Washington Post", + "host": "washingtonpost.com", + "heat": 282, + "categories": ["traditional-media"] + }, + { + "key": "51cto", + "name": "51CTO", + "host": "51cto.com", + "heat": 274, + "categories": ["programming"] + }, + { + "key": "leetcode", + "name": "LeetCode", + "host": "leetcode.com", + "heat": 274, + "categories": ["programming"] + }, + { + "key": "notefolio", + "name": "Notefolio", + "host": "notefolio.net", + "heat": 274, + "categories": ["design"] + }, + { + "key": "secrss", + "name": "安全内参", + "host": "secrss.com", + "heat": 273, + "categories": ["programming"] + }, + { + "key": "docschina", + "name": "印记中文", + "host": "docschina.org", + "heat": 269, + "categories": ["programming"] + }, + { + "key": "zhuwang", + "name": "中国养猪网", + "host": "zhujia.zhuwang.cc", + "heat": 266, + "categories": ["shopping"] + }, + { + "key": "aeaweb", + "name": "American Economic Association", + "host": "aeaweb.org", + "heat": 264, + "categories": ["journal"] + }, + { + "key": "asmr-200", + "name": "ASMR Online", + "host": "asmr-200.com", + "heat": 261, + "categories": ["multimedia"] + }, + { + "key": "mastodon", + "name": "Mastodon", + "host": "mastodon.social", + "heat": 261, + "categories": ["social-media"] + }, + { + "key": "stockedge", + "name": "Stock Edge", + "host": "web.stockedge.com", + "heat": 257, + "categories": ["finance"] + }, + { + "key": "substack", + "name": "Substack", + "host": "substack.com", + "heat": 257, + "categories": ["blog"] + }, + { + "key": "tesla", + "name": "特斯拉中国", + "host": "tesla.cn", + "heat": 256, + "categories": ["shopping"] + }, + { + "key": "manhuagui", + "name": "看漫画", + "host": "manhuagui.com", + "heat": 255, + "categories": ["anime"] + }, + { + "key": "nhentai", + "name": "nhentai", + "host": "nhentai.net", + "heat": 253, + "categories": ["anime"] + }, + { + "key": "ieee", + "name": "IEEE Xplore", + "host": "ieee.org", + "heat": 252, + "categories": ["journal"] + }, + { + "key": "freecomputerbooks", + "name": "Free Computer Books", + "host": "freecomputerbooks.com", + "heat": 248, + "categories": ["reading"] + }, + { + "key": "caixinglobal", + "name": "Caixin Global", + "host": "caixinglobal.com", + "heat": 246, + "categories": ["traditional-media"] + }, + { + "key": "agefans", + "name": "AGE 动漫", + "host": "agemys.cc", + "heat": 245, + "categories": ["anime"] + }, + { + "key": "nifd", + "name": "国家金融与发展实验室", + "host": "nifd.cn", + "heat": 242, + "categories": ["finance"] + }, + { + "key": "dytt", + "name": "电影天堂", + "host": "dydytt.net", + "heat": 242, + "categories": ["multimedia"] + }, + { + "key": "stcn", + "name": "证券时报网", + "host": "stcn.com", + "heat": 242, + "categories": ["finance"] + }, + { + "key": "deeplearning", + "name": "DeepLearning.AI", + "host": "deeplearning.ai", + "heat": 241, + "categories": ["programming"] + }, + { + "key": "podwise", + "name": "Podwise", + "host": "podwise.ai", + "heat": 239, + "categories": ["multimedia"] + }, + { + "key": "flyert", + "name": "飞客茶馆", + "host": "flyert.com.cn", + "heat": 236, + "categories": ["travel", "bbs"] + }, + { + "key": "itc", + "name": "Open Github社区", + "host": "open.itc.cn", + "heat": 235, + "categories": ["blog"] + }, + { + "key": "sputniknews", + "name": "Sputnik News 俄罗斯卫星通讯社", + "host": "sputniknews.cn", + "heat": 231, + "categories": ["traditional-media"] + }, + { + "key": "ulapia", + "name": "乌拉邦", + "host": "ulapia.com", + "heat": 229, + "categories": ["finance"] + }, + { + "key": "caijing", + "name": "财经网", + "host": "roll.caijing.com.cn", + "heat": 229, + "categories": ["finance"] + }, + { + "key": "cursor", + "name": "Cursor", + "host": "cursor.com", + "heat": 222, + "categories": ["blog", "program-update"] + }, + { + "key": "pkmer", + "name": "PKMer", + "host": "pkmer.cn", + "heat": 222, + "categories": ["bbs"] + }, + { + "key": "zhibo8", + "name": "直播吧", + "host": "zhibo8.cc", + "heat": 222, + "categories": ["bbs"] + }, + { + "key": "pixabay", + "name": "Pixabay", + "host": "pixabay.com", + "heat": 219, + "categories": ["picture"] + }, + { + "key": "shcstheatre", + "name": "上海文化广场", + "host": "shcstheatre.com", + "heat": 219, + "categories": ["shopping"] + }, + { + "key": "modelscope", + "name": "ModelScope 魔搭社区", + "host": "modelscope.cn", + "heat": 217, + "categories": ["programming"] + }, + { + "key": "wallhaven", + "name": "wallhaven", + "host": "wallhaven.cc", + "heat": 214, + "categories": ["picture"] + }, + { + "key": "springer", + "name": "Springer", + "host": "springer.com", + "heat": 213, + "categories": ["journal"] + }, + { + "key": "baai", + "name": "北京智源人工智能研究院", + "host": "hub.baai.ac.cn", + "heat": 212, + "categories": ["programming"] + }, + { + "key": "dw", + "name": "DW Deutsche Welle", + "host": "dw.com", + "heat": 211, + "categories": ["traditional-media"] + }, + { + "key": "harvard", + "name": "Harvard Health Publishing", + "host": "health.harvard.edu", + "heat": 211, + "categories": ["new-media"] + }, + { + "key": "cntv", + "name": "CNTV", + "host": "navi.cctv.com", + "heat": 209, + "categories": ["multimedia"] + }, + { + "key": "feng", + "name": "威锋", + "host": "feng.com", + "heat": 208, + "categories": ["bbs"] + }, + { + "key": "qidian", + "name": "起点", + "host": "qidian.com", + "heat": 207, + "categories": ["reading"] + }, + { + "key": "ymgal", + "name": "月幕 Galgame", + "host": "ymgal.games", + "heat": 206, + "categories": ["anime"] + }, + { + "key": "dcfever", + "name": "DCFever", + "host": "dcfever.com", + "heat": 204, + "categories": ["new-media"] + }, + { + "key": "diershoubing", + "name": "二柄 APP", + "host": "diershoubing.com", + "heat": 204, + "categories": ["game"] + }, + { + "key": "wanqu", + "name": "湾区日报", + "host": "wanqu.co", + "heat": 203, + "categories": ["new-media"] + }, + { + "key": "wired", + "name": "WIRED", + "host": "wired.com", + "heat": 201, + "categories": ["traditional-media"] + }, + { + "key": "producthunt", + "name": "Product Hunt", + "host": "producthunt.com", + "heat": 199, + "categories": ["other"] + }, + { + "key": "cyzone", + "name": "创业邦", + "host": "cyzone.cn", + "heat": 197, + "categories": ["new-media"] + }, + { + "key": "appleinsider", + "name": "AppleInsider", + "host": "appleinsider.com", + "heat": 196, + "categories": ["new-media"] + }, + { + "key": "everia", + "name": "EVERIA.CLUB", + "host": "everia.club", + "heat": 193, + "categories": ["picture"] + }, + { + "key": "meta", + "name": "Meta", + "host": "meta.com", + "heat": 193, + "categories": ["programming"] + }, + { + "key": "now", + "name": "Now 新聞", + "host": "news.now.com", + "heat": 192, + "categories": ["traditional-media"] + }, + { + "key": "iwara", + "name": "iwara", + "host": "iwara.tv", + "heat": 190, + "categories": ["anime"] + }, + { + "key": "javtrailers", + "name": "JavTrailers", + "host": "javtrailers.com", + "heat": 186, + "categories": ["multimedia"] + }, + { + "key": "diandong", + "name": "电动邦", + "host": "diandong.com", + "heat": 185, + "categories": ["new-media"] + }, + { + "key": "gettr", + "name": "GETTR", + "host": "gettr.com", + "heat": 180, + "categories": ["social-media"] + }, + { + "key": "freewechat", + "name": "自由微信", + "host": "freewechat.com", + "heat": 176, + "categories": ["new-media"] + }, + { + "key": "wufazhuce", + "name": "「ONE · 一个」", + "host": "wufazhuce.com", + "heat": 175, + "categories": ["new-media"] + }, + { + "key": "foreignaffairs", + "name": "Foreign Affairs", + "host": "foreignaffairs.com", + "heat": 171, + "categories": ["traditional-media"] + }, + { + "key": "utgd", + "name": "UNTAG", + "host": "utgd.net", + "heat": 170, + "categories": ["new-media"] + }, + { + "key": "learnku", + "name": "LearnKu", + "host": "learnku.com", + "heat": 167, + "categories": ["bbs"] + }, + { + "key": "langchain", + "name": "LangChain Blog", + "host": "blog.langchain.dev", + "heat": 166, + "categories": ["blog"] + }, + { + "key": "colamanga", + "name": "COLAMANGA", + "host": "colamanga.com", + "heat": 162, + "categories": ["anime"] + }, + { + "key": "scmp", + "name": "Corona Virus Disease 2019", + "host": "scmp.com", + "heat": 161, + "categories": ["other", "traditional-media"] + }, + { + "key": "digitalcameraworld", + "name": "Digital Camera World", + "host": "digitalcameraworld.com", + "heat": 158, + "categories": ["new-media"] + }, + { + "key": "moodysmismicrosite", + "name": "穆迪评级", + "host": "moodysmismicrosite.com", + "heat": 157, + "categories": ["finance"] + }, + { + "key": "dev.to", + "name": "DEV Community", + "host": "dev.to", + "heat": 156, + "categories": ["programming"] + }, + { + "key": "lifeweek", + "name": "三联生活周刊", + "host": "lifeweek.com.cn", + "heat": 155, + "categories": ["other"] + }, + { + "key": "qianzhan", + "name": "前瞻网", + "host": "qianzhan.com", + "heat": 155, + "categories": ["finance"] + }, + { + "key": "95mm", + "name": "MM 范", + "host": "95mm.org", + "heat": 154, + "categories": ["picture"] + }, + { + "key": "chsi", + "name": "中国研究生招生信息网", + "host": "yz.chsi.com.cn", + "heat": 154, + "categories": ["study"] + }, + { + "key": "tvb", + "name": "无线新闻", + "host": "tvb.com", + "heat": 154, + "categories": ["traditional-media"] + }, + { + "key": "copymanga", + "name": "拷贝漫画", + "host": "copymanga.com", + "heat": 153, + "categories": ["anime"] + }, + { + "key": "laohu8", + "name": "老虎社区", + "host": "laohu8.com", + "heat": 151, + "categories": ["finance"] + }, + { + "key": "aliyun", + "name": "阿里云", + "host": "developer.aliyun.com", + "heat": 151, + "categories": ["programming"] + }, + { + "key": "yystv", + "name": "游研社", + "host": "yystv.cn", + "heat": 150, + "categories": ["game"] + }, + { + "key": "domp4", + "name": "DoMP4 影视", + "host": "xlmp4.com", + "heat": 149, + "categories": ["multimedia"] + }, + { + "key": "earthquake", + "name": "地震速报", + "host": "ceic.ac.cn", + "heat": 146, + "categories": ["forecast"] + }, + { + "key": "showstart", + "name": "秀动网", + "host": "showstart.com", + "heat": 146, + "categories": ["shopping"] + }, + { + "key": "cncf", + "name": "CNCF", + "host": "cncf.io", + "heat": 144, + "categories": ["programming"] + }, + { + "key": "bossdesign", + "name": "Boss 设计", + "host": "bossdesign.cn", + "heat": 143, + "categories": ["design"] + }, + { + "key": "linkresearcher", + "name": "Link Research", + "host": "linkresearcher.com", + "heat": 142, + "categories": ["journal"] + }, + { + "key": "kyodonews", + "name": "共同网", + "host": "china.kyodonews.net", + "heat": 142, + "categories": ["traditional-media"] + }, + { + "key": "cpcaauto", + "name": "cpcaauto.com", + "host": "xn--fiq7va501a70bq0jjma416bfx5aca76shw2duqg1k3fea09z", + "heat": 141, + "categories": ["new-media"] + }, + { + "key": "penguin-random-house", + "name": "Penguin Random House", + "host": "penguinrandomhouse.com", + "heat": 141, + "categories": ["reading"] + }, + { + "key": "phoronix", + "name": "Phoronix", + "host": "phoronix.com", + "heat": 141, + "categories": ["new-media"] + }, + { + "key": "plurk", + "name": "Plurk", + "host": "plurk.com", + "heat": 141, + "categories": ["social-media"] + }, + { + "key": "steam", + "name": "Steam", + "host": "store.steampowered.com", + "heat": 136, + "categories": ["game"] + }, + { + "key": "rss3", + "name": "RSS3", + "host": "rss3.io", + "heat": 135, + "categories": ["social-media"] + }, + { + "key": "the", + "name": "The.bi", + "host": "the.bi", + "heat": 134, + "categories": ["new-media"] + }, + { + "key": "piyao", + "name": "中国互联网联合辟谣平台", + "host": "piyao.org.cn", + "heat": 132, + "categories": ["other"] + }, + { + "key": "nintendo", + "name": "Nintendo", + "host": "nintendo.com", + "heat": 130, + "categories": ["game"] + }, + { + "key": "sohu", + "name": "搜狐号", + "host": "sohu.com", + "heat": 130, + "categories": ["new-media"] + }, + { + "key": "niaogebiji", + "name": "鸟哥笔记", + "host": "niaogebiji.com", + "heat": 130, + "categories": ["new-media"] + }, + { + "key": "ieee-security", + "name": "IEEE Computer Society", + "host": "ieee-security.org", + "heat": 129, + "categories": ["journal"] + }, + { + "key": "yxrb", + "name": "游戏日报", + "host": "news.yxrb.net", + "heat": 129, + "categories": ["game"] + }, + { + "key": "vocus", + "name": "方格子", + "host": "vocus.cc", + "heat": 128, + "categories": ["social-media"] + }, + { + "key": "olevod", + "name": "欧乐影院", + "host": "olevod.one", + "heat": 128, + "categories": ["multimedia"] + }, + { + "key": "trendingpapers", + "name": "Trending Papers", + "host": "trendingpapers.com", + "heat": 127, + "categories": ["journal"] + }, + { + "key": "szse", + "name": "深圳证券交易所", + "host": "szse.cn", + "heat": 127, + "categories": ["finance"] + }, + { + "key": "xinpianchang", + "name": "新片场", + "host": "xinpianchang.com", + "heat": 125, + "categories": ["new-media"] + }, + { + "key": "acg17", + "name": "ACG17", + "host": "acg17.com", + "heat": 124, + "categories": ["anime"] + }, + { + "key": "notion", + "name": "Notion", + "host": "notion.so", + "heat": 124, + "categories": ["other", "program-update"] + }, + { + "key": "tingtingfm", + "name": "听听 FM", + "host": "mobile.tingtingfm.com", + "heat": 124, + "categories": ["multimedia"] + }, + { + "key": "cw", + "name": "天下雜誌", + "host": "cw.com.tw", + "heat": 124, + "categories": ["traditional-media"] + }, + { + "key": "nowcoder", + "name": "牛客网", + "host": "nowcoder.com", + "heat": 124, + "categories": ["bbs"] + }, + { + "key": "ajcass", + "name": "社科期刊网", + "host": "ajcass.com", + "heat": 124, + "categories": ["journal"] + }, + { + "key": "52hrtt", + "name": "52hrtt 华人头条", + "host": "52hrtt.com", + "heat": 123, + "categories": ["new-media"] + }, + { + "key": "coindesk", + "name": "CoinDesk", + "host": "coindesk.com", + "heat": 123, + "categories": ["new-media", "finance"] + }, + { + "key": "setn", + "name": "三立新聞網", + "host": "setn.com", + "heat": 123, + "categories": ["traditional-media"] + }, + { + "key": "themoviedb", + "name": "The Movie Database", + "host": "themoviedb.org", + "heat": 122, + "categories": ["multimedia"] + }, + { + "key": "taobao", + "name": "淘宝网", + "host": "taobao.com", + "heat": 122, + "categories": ["programming", "shopping"] + }, + { + "key": "dayanzai", + "name": "大眼仔旭", + "host": "dayanzai.me", + "heat": 121, + "categories": ["blog"] + }, + { + "key": "pinterest", + "name": "Pinterest", + "host": "pinterest.com", + "heat": 119, + "categories": ["picture"] + }, + { + "key": "jiemian", + "name": "界面新闻", + "host": "jiemian.com", + "heat": 119, + "categories": ["other"] + }, + { + "key": "21caijing", + "name": "21财经", + "host": "21caijing.com", + "heat": 117, + "categories": ["finance"] + }, + { + "key": "txrjy", + "name": "通信人家园", + "host": "txrjy.com", + "heat": 117, + "categories": ["bbs"] + }, + { + "key": "ikea", + "name": "IKEA", + "host": "ikea.com", + "heat": 112, + "categories": ["shopping"] + }, + { + "key": "china", + "name": "China.com 中华网", + "host": "finance.china.com", + "heat": 111, + "categories": ["new-media"] + }, + { + "key": "wsj", + "name": "The Wall Street Journal (WSJ) 华尔街日报", + "host": "cn.wsj.com", + "heat": 111, + "categories": ["traditional-media"] + }, + { + "key": "gamer520", + "name": "全球游戏交流中心", + "host": "gamer520.com", + "heat": 111, + "categories": ["game"] + }, + { + "key": "bjnews", + "name": "新京报", + "host": "bjnews.com.cn", + "heat": 111, + "categories": ["traditional-media"] + }, + { + "key": "tmtpost", + "name": "钛媒体", + "host": "tmtpost.com", + "heat": 111, + "categories": ["new-media"] + }, + { + "key": "spglobal", + "name": "S&P Global", + "host": "spglobal.com", + "heat": 110, + "categories": ["finance"] + }, + { + "key": "wyzxwk", + "name": "乌有之乡", + "host": "wyzxwk.com", + "heat": 110, + "categories": ["new-media"] + }, + { + "key": "mydrivers", + "name": "快科技", + "host": "m.mydrivers.com", + "heat": 109, + "categories": ["new-media"] + }, + { + "key": "ntdtv", + "name": "新唐人电视台", + "host": "ntdtv.com", + "heat": 109, + "categories": ["traditional-media"] + }, + { + "key": "okx", + "name": "欧易 OKX", + "host": "okx.com", + "heat": 108, + "categories": ["finance"] + }, + { + "key": "zsxq", + "name": "知识星球", + "host": "zsxq.com", + "heat": 108, + "categories": ["social-media"] + }, + { + "key": "pincong", + "name": "品葱", + "host": "pincong.rocks", + "heat": 107, + "categories": ["bbs"] + }, + { + "key": "iresearch", + "name": "艾瑞咨询", + "host": "iresearch.com.cn", + "heat": 105, + "categories": ["other"] + }, + { + "key": "bestblogs", + "name": "bestblogs.dev", + "host": "bestblogs.dev", + "heat": 104, + "categories": ["programming"] + }, + { + "key": "lfsyd", + "name": "旅法师营地", + "host": "iyingdi.com", + "heat": 103, + "categories": ["game"] + }, + { + "key": "yomujp", + "name": "日本語多読道場", + "host": "yomujp.com", + "heat": 103, + "categories": ["reading"] + }, + { + "key": "douyu", + "name": "斗鱼直播", + "host": "douyu.com", + "heat": 102, + "categories": ["bbs", "live"] + }, + { + "key": "fortunechina", + "name": "财富中文网", + "host": "fortunechina.com", + "heat": 102, + "categories": ["new-media"] + }, + { + "key": "abc", + "name": "ABC News (Australian Broadcasting Corporation)", + "host": "abc.net.au", + "heat": 101, + "categories": ["traditional-media"] + }, + { + "key": "keylol", + "name": "其乐", + "host": "keylol.com", + "heat": 101, + "categories": ["game"] + }, + { + "key": "chongbuluo", + "name": "虫部落", + "host": "chongbuluo.com", + "heat": 99, + "categories": ["bbs"] + }, + { + "key": "komiic", + "name": "Komiic", + "host": "komiic.com", + "heat": 97, + "categories": ["anime"] + }, + { + "key": "30secondsofcode", + "name": "30 Seconds of code", + "host": "30secondsofcode.org", + "heat": 96, + "categories": ["programming"] + }, + { + "key": "uber", + "name": "Uber", + "host": "uber.com", + "heat": 95, + "categories": ["blog"] + }, + { + "key": "line", + "name": "LINE", + "host": "today.line.me", + "heat": 93, + "categories": ["new-media"] + }, + { + "key": "anquanke", + "name": "安全客", + "host": "anquanke.com", + "heat": 93, + "categories": ["programming"] + }, + { + "key": "sigsac", + "name": "ACM Special Interest Group on Security Audit and Control", + "host": "sigsac.org", + "heat": 92, + "categories": ["journal"] + }, + { + "key": "hkej", + "name": "信报财经新闻", + "host": "hkej.com", + "heat": 91, + "categories": ["traditional-media"] + }, + { + "key": "cih-index", + "name": "中指研究院", + "host": "cih-index.com", + "heat": 90, + "categories": ["finance"] + }, + { + "key": "zxcs", + "name": "知轩藏书", + "host": "zxcs.info", + "heat": 90, + "categories": ["reading"] + }, + { + "key": "chiphell", + "name": "Chiphell", + "host": "chiphell.com", + "heat": 88, + "categories": ["bbs"] + }, + { + "key": "bt0", + "name": "不太灵影视", + "host": "2bt0.com", + "heat": 88, + "categories": ["multimedia"] + }, + { + "key": "furaffinity", + "name": "Furaffinity", + "host": "furaffinity.net", + "heat": 86, + "categories": ["social-media"] + }, + { + "key": "react", + "name": "React", + "host": "react.dev", + "heat": 86, + "categories": ["blog"] + }, + { + "key": "voronoiapp", + "name": "Voronoi", + "host": "voronoiapp.com", + "heat": 85, + "categories": ["picture"] + }, + { + "key": "tkww", + "name": "大公文匯網", + "host": "tkww.hk", + "heat": 85, + "categories": ["traditional-media"] + }, + { + "key": "yamibo", + "name": "百合会", + "host": "yamibo.com", + "heat": 85, + "categories": ["bbs"] + }, + { + "key": "ajmide", + "name": "阿基米德 FM", + "host": "m.ajmide.com", + "heat": 85, + "categories": ["multimedia"] + }, + { + "key": "8264", + "name": "8264", + "host": "8264.com", + "heat": 83, + "categories": ["bbs"] + }, + { + "key": "stdaily", + "name": "中国科技网", + "host": "epaper.stdaily.com", + "heat": 83, + "categories": ["traditional-media"] + }, + { + "key": "cdi", + "name": "国家高端智库 / 综合开发研究院", + "host": "cdi.com.cn", + "heat": 83, + "categories": ["new-media"] + }, + { + "key": "freebuf", + "name": "FreeBuf", + "host": "freebuf.com", + "heat": 81, + "categories": ["blog"] + }, + { + "key": "tass", + "name": "Russian News Agency TASS", + "host": "tass.com", + "heat": 81, + "categories": ["traditional-media"] + }, + { + "key": "dn", + "name": "DN.com", + "host": "dn.com", + "heat": 79, + "categories": ["new-media"] + }, + { + "key": "python", + "name": "Python", + "host": "python.org", + "heat": 79, + "categories": ["programming"] + }, + { + "key": "vcb-s", + "name": "VCB-Studio", + "host": "vcb-s.com", + "heat": 79, + "categories": ["anime"] + }, + { + "key": "eastday", + "name": "东方网", + "host": "mini.eastday.com", + "heat": 78, + "categories": ["traditional-media"] + }, + { + "key": "huoxian", + "name": "火线", + "host": "zone.huoxian.cn", + "heat": 78, + "categories": ["bbs"] + }, + { + "key": "ixigua", + "name": "西瓜视频", + "host": "ixigua.com", + "heat": 78, + "categories": ["multimedia"] + }, + { + "key": "hbr", + "name": "Harvard Business Review", + "host": "hbr.org", + "heat": 77, + "categories": ["new-media"] + }, + { + "key": "javlibrary", + "name": "JAVLibrary", + "host": "javlibrary.com", + "heat": 76, + "categories": ["multimedia"] + }, + { + "key": "wordpress", + "name": "WordPress", + "host": "wordpress.org", + "heat": 76, + "categories": ["blog"] + }, + { + "key": "ebc", + "name": "東森新聞", + "host": "ebc.net.tw", + "heat": 76, + "categories": ["traditional-media"] + }, + { + "key": "qingting", + "name": "蜻蜓 FM", + "host": "qingting.fm", + "heat": 75, + "categories": ["multimedia"] + }, + { + "key": "jump", + "name": "JUMP", + "host": "switch.jumpvg.com", + "heat": 73, + "categories": ["game"] + }, + { + "key": "whitehouse", + "name": "The White House", + "host": "whitehouse.gov", + "heat": 73, + "categories": ["government"] + }, + { + "key": "neea", + "name": "中国教育考试网", + "host": "neea.cn", + "heat": 73, + "categories": ["study"] + }, + { + "key": "xys", + "name": "新语丝", + "host": "xys.org", + "heat": 73, + "categories": ["blog"] + }, + { + "key": "espn", + "name": "ESPN", + "host": "espn.com", + "heat": 72, + "categories": ["traditional-media"] + }, + { + "key": "nber", + "name": "National Bureau of Economic Research", + "host": "nber.org", + "heat": 72, + "categories": ["journal"] + }, + { + "key": "shu", + "name": "上海大学", + "host": "shu.edu.cn", + "heat": 71, + "categories": ["university", "journal"] + }, + { + "key": "papers", + "name": "Cool Papers", + "host": "papers.cool", + "heat": 70, + "categories": ["journal"] + }, + { + "key": "augmentcode", + "name": "Augment Code", + "host": "augmentcode.com", + "heat": 69, + "categories": ["programming"] + }, + { + "key": "futunn", + "name": "Futubull 富途牛牛", + "host": "news.futunn.com", + "heat": 69, + "categories": ["finance"] + }, + { + "key": "thoughtworks", + "name": "ThoughtWorks", + "host": "thoughtworks.com", + "heat": 69, + "categories": ["programming"] + }, + { + "key": "joins", + "name": "中央日报", + "host": "joins.com", + "heat": 69, + "categories": ["traditional-media"] + }, + { + "key": "huawei", + "name": "华为开发者联盟", + "host": "developer.huawei.com", + "heat": 69, + "categories": ["programming"] + }, + { + "key": "cuilingmag", + "name": "萃嶺网", + "host": "cuilingmag.com", + "heat": 69, + "categories": ["new-media"] + }, + { + "key": "3kns", + "name": "3k-Switch游戏库", + "host": "3kns.com", + "heat": 68, + "categories": ["game"] + }, + { + "key": "eagle", + "name": "Eagle", + "host": "cn.eagle.cool", + "heat": 68, + "categories": ["blog", "program-update"] + }, + { + "key": "zjuvag", + "name": "浙江大学可视分析小组", + "host": "zjuvag.org", + "heat": 68, + "categories": ["blog"] + }, + { + "key": "minecraft", + "name": "Minecraft", + "host": "minecraft.net", + "heat": 67, + "categories": ["game"] + }, + { + "key": "sec-wiki", + "name": "SecWiki - 安全维基", + "host": "sec-wiki.com", + "heat": 67, + "categories": ["programming"] + }, + { + "key": "fanbox", + "name": "fanbox", + "host": "fanbox.cc", + "heat": 66, + "categories": ["social-media"] + }, + { + "key": "liveuamap", + "name": "Live Universal Awareness Map", + "host": "liveuamap.com", + "heat": 66, + "categories": ["new-media"] + }, + { + "key": "nautil", + "name": "Nautilus", + "host": "nautil.us", + "heat": 66, + "categories": ["new-media"] + }, + { + "key": "straitstimes", + "name": "The Strait Times", + "host": "straitstimes.com", + "heat": 66, + "categories": ["traditional-media"] + }, + { + "key": "vimeo", + "name": "Vimeo", + "host": "vimeo.com", + "heat": 66, + "categories": ["social-media"] + }, + { + "key": "jrj", + "name": "金融界", + "host": "jrj.com.cn", + "heat": 66, + "categories": ["finance"] + }, + { + "key": "pku", + "name": "北京大学", + "host": "admission.pku.edu.cn", + "heat": 65, + "categories": ["university"] + }, + { + "key": "jjwxc", + "name": "晋江文学城", + "host": "jjwxc.net", + "heat": 65, + "categories": ["reading"] + }, + { + "key": "baobua", + "name": "BaoBua", + "host": "baobua.com", + "heat": 64, + "categories": ["picture"] + }, + { + "key": "cneb", + "name": "中国国家应急广播", + "host": "cneb.gov.cn", + "heat": 64, + "categories": ["forecast"] + }, + { + "key": "ttv", + "name": "台視新聞網", + "host": "news.ttv.com.tw", + "heat": 64, + "categories": ["traditional-media"] + }, + { + "key": "ali213", + "name": "游侠网", + "host": "ali213.net", + "heat": 64, + "categories": ["game"] + }, + { + "key": "makerworld", + "name": "MakerWorld", + "host": "makerworld.com", + "heat": 63, + "categories": ["design"] + }, + { + "key": "rfi", + "name": "Radio France Internationale", + "host": "rfi.fr", + "heat": 63, + "categories": ["other"] + }, + { + "key": "junhe", + "name": "君合律师事务所", + "host": "junhe.com", + "heat": 63, + "categories": ["new-media"] + }, + { + "key": "wmpvp", + "name": "完美世界电竞", + "host": "wmpvp.com", + "heat": 63, + "categories": ["game"] + }, + { + "key": "x6d", + "name": "小刀娱乐网", + "host": "xd.x6d.com", + "heat": 63, + "categories": ["new-media"] + }, + { + "key": "cnbeta", + "name": "cnBeta.COM", + "host": "cnbeta.com.tw", + "heat": 62, + "categories": ["new-media"] + }, + { + "key": "discord", + "name": "Discord", + "host": "discord.com", + "heat": 62, + "categories": ["social-media"] + }, + { + "key": "withgoogle", + "name": "People + AI Research (PAIR)", + "host": "pair.withgoogle.com", + "heat": 62, + "categories": ["blog"] + }, + { + "key": "ximalaya", + "name": "喜马拉雅", + "host": "ximalaya.com", + "heat": 62, + "categories": ["multimedia"] + }, + { + "key": "civitai", + "name": "Civitai", + "host": "civitai.com", + "heat": 60, + "categories": ["program-update"] + }, + { + "key": "fediverse", + "name": "Fediverse", + "host": "fediverse.observer", + "heat": 60, + "categories": ["social-media"] + }, + { + "key": "manus", + "name": "Manus", + "host": "manus.im", + "heat": 58, + "categories": ["programming"] + }, + { + "key": "linovelib", + "name": "哔哩轻小说", + "host": "linovelib.com", + "heat": 58, + "categories": ["reading"] + }, + { + "key": "baijing", + "name": "白鲸出海", + "host": "baijing.cn", + "heat": 58, + "categories": ["new-media"] + }, + { + "key": "windsurf", + "name": "Windsurf", + "host": "windsurf.com", + "heat": 57, + "categories": ["programming"] + }, + { + "key": "startuplatte", + "name": "創新拿鐵", + "host": "startuplatte.com", + "heat": 57, + "categories": ["new-media"] + }, + { + "key": "elasticsearch-cn", + "name": "Elastic 中文社区", + "host": "elasticsearch.cn", + "heat": 56, + "categories": ["bbs"] + }, + { + "key": "npr", + "name": "NPR (National Public Radio)", + "host": "npr.org", + "heat": 56, + "categories": ["traditional-media"] + }, + { + "key": "openrice", + "name": "Openrice開飯喇", + "host": "openrice.com", + "heat": 56, + "categories": ["shopping"] + }, + { + "key": "rebase", + "name": "Rebase Network", + "host": "rebase.network", + "heat": 56, + "categories": ["new-media"] + }, + { + "key": "dmzj", + "name": "动漫之家", + "host": "news.dmzj.com", + "heat": 56, + "categories": ["anime"] + }, + { + "key": "wainao", + "name": "歪脑", + "host": "wainao.me", + "heat": 56, + "categories": ["new-media"] + }, + { + "key": "yuanliao", + "name": "猿料", + "host": "yuanliao.info", + "heat": 56, + "categories": ["bbs"] + }, + { + "key": "hypergryph", + "name": "鹰角网络", + "host": "hypergryph.com", + "heat": 56, + "categories": ["game"] + }, + { + "key": "blizzard", + "name": "Blizzard", + "host": "news.blizzard.com", + "heat": 55, + "categories": ["game"] + }, + { + "key": "chinamoney", + "name": "中国货币网", + "host": "chinamoney.com.cn", + "heat": 55, + "categories": ["finance"] + }, + { + "key": "qiche365", + "name": "汽车召回网", + "host": "qiche365.org.cn", + "heat": 55, + "categories": ["government"] + }, + { + "key": "css-tricks", + "name": "CSS-Tricks", + "host": "css-tricks.com", + "heat": 54, + "categories": ["programming"] + }, + { + "key": "guozaoke", + "name": "guozaoke", + "host": "guozaoke.com", + "heat": 54, + "categories": ["bbs"] + }, + { + "key": "12371", + "name": "共产党员网", + "host": "12371.cn", + "heat": 54, + "categories": ["government"] + }, + { + "key": "chinaventure", + "name": "投中网", + "host": "chinaventure.com.cn", + "heat": 54, + "categories": ["new-media"] + }, + { + "key": "gamebase", + "name": "遊戲基地 Gamebase", + "host": "news.gamebase.com.tw", + "heat": 54, + "categories": ["game"] + }, + { + "key": "codeforces", + "name": "Codeforces", + "host": "codeforces.com", + "heat": 53, + "categories": ["programming"] + }, + { + "key": "hoyolab", + "name": "HoYoLAB", + "host": "hoyolab.com", + "heat": 53, + "categories": ["game"] + }, + { + "key": "zotero", + "name": "Zotero", + "host": "zotero.org", + "heat": 53, + "categories": ["program-update"] + }, + { + "key": "fangchan", + "name": "中房网", + "host": "fangchan.com", + "heat": 53, + "categories": ["new-media"] + }, + { + "key": "modian", + "name": "摩点", + "host": "modian.com", + "heat": 53, + "categories": ["shopping"] + }, + { + "key": "news", + "name": "新华社", + "host": "news.cn", + "heat": 53, + "categories": ["new-media"] + }, + { + "key": "tgbus", + "name": "电玩巴士 TGBUS", + "host": "tgbus.com", + "heat": 53, + "categories": ["game"] + }, + { + "key": "19lou", + "name": "19 楼", + "host": "19lou.com", + "heat": 52, + "categories": ["bbs"] + }, + { + "key": "apache", + "name": "Apache", + "host": "apisix.apache.org", + "heat": 52, + "categories": ["blog"] + }, + { + "key": "chinacdc", + "name": "中国疾病预防控制中心", + "host": "chinacdc.cn", + "heat": 52, + "categories": ["government"] + }, + { + "key": "nextjs", + "name": "Next.js", + "host": "nextjs.org", + "heat": 51, + "categories": ["program-update"] + }, + { + "key": "finology", + "name": "Finology Insider", + "host": "insider.finology.in", + "heat": 50, + "categories": ["finance"] + }, + { + "key": "raycast", + "name": "Raycast", + "host": "raycast.com", + "heat": 50, + "categories": ["program-update"] + }, + { + "key": "sciencedirect", + "name": "ScienceDirect", + "host": "sciencedirect.com", + "heat": 50, + "categories": ["journal"] + }, + { + "key": "unusualwhales", + "name": "Unusual Whales", + "host": "unusualwhales.com", + "heat": 50, + "categories": ["finance"] + }, + { + "key": "lang", + "name": "浪 Play 直播", + "host": "lang.live", + "heat": 50, + "categories": ["live"] + }, + { + "key": "leiphone", + "name": "雷峰网", + "host": "leiphone.com", + "heat": 50, + "categories": ["new-media"] + }, + { + "key": "oup", + "name": "Oxford University Press", + "host": "academic.oup.com", + "heat": 49, + "categories": ["journal"] + }, + { + "key": "u3c3", + "name": "U3C3", + "host": "u3c3.com", + "heat": 49, + "categories": ["multimedia"] + }, + { + "key": "iplaysoft", + "name": "异次元软件世界", + "host": "iplaysoft.com", + "heat": 49, + "categories": ["new-media", "program-update"] + }, + { + "key": "amz123", + "name": "Amz123", + "host": "amz123.com", + "heat": 48, + "categories": ["new-media"] + }, + { + "key": "c114", + "name": "C114 通信网", + "host": "c114.com.cn", + "heat": 48, + "categories": ["new-media"] + }, + { + "key": "cara", + "name": "Cara", + "host": "cara.app", + "heat": 48, + "categories": ["social-media"] + }, + { + "key": "eventernote", + "name": "Eventernote", + "host": "eventernote.com", + "heat": 48, + "categories": ["anime"] + }, + { + "key": "thepetcity", + "name": "PetCity 毛孩日常", + "host": "thepetcity.co", + "heat": 48, + "categories": ["new-media"] + }, + { + "key": "zhiy", + "name": "知园", + "host": "zhiy.cc", + "heat": 48, + "categories": ["new-media"] + }, + { + "key": "storm", + "name": "風傳媒", + "host": "storm.mg", + "heat": 48, + "categories": ["new-media"] + }, + { + "key": "nicovideo", + "name": "Niconico", + "host": "nicovideo.jp", + "heat": 47, + "categories": ["multimedia"] + }, + { + "key": "usenix", + "name": "USENIX", + "host": "usenix.org", + "heat": 47, + "categories": ["journal"] + }, + { + "key": "hket", + "name": "香港经济日报", + "host": "china.hket.com", + "heat": 47, + "categories": ["traditional-media"] + }, + { + "key": "zed", + "name": "Zed", + "host": "zed.dev", + "heat": 45, + "categories": ["programming"] + }, + { + "key": "kiro", + "name": "Kiro", + "host": "kiro.dev", + "heat": 44, + "categories": ["programming", "program-update"] + }, + { + "key": "nodejs", + "name": "Node.js", + "host": "nodejs.org", + "heat": 44, + "categories": ["programming"] + }, + { + "key": "cbc", + "name": "Canadian Broadcasting Corporation", + "host": "cbc.ca", + "heat": 43, + "categories": ["traditional-media"] + }, + { + "key": "kpopping", + "name": "kpopping", + "host": "kpopping.com", + "heat": 43, + "categories": ["new-media", "picture"] + }, + { + "key": "uniqlo", + "name": "Uniqlo", + "host": "uniqlo.com", + "heat": 43, + "categories": ["shopping"] + }, + { + "key": "ccf", + "name": "中国计算机学会", + "host": "ccf.org.cn", + "heat": 43, + "categories": ["study"] + }, + { + "key": "simpleinfo", + "name": "簡訊設計", + "host": "blog.simpleinfo.cc", + "heat": 43, + "categories": ["new-media"] + }, + { + "key": "chub", + "name": "Chub", + "host": "chub.ai", + "heat": 42, + "categories": ["new-media"] + }, + { + "key": "nogizaka46", + "name": "Sakamichi Series 坂道系列官网资讯", + "host": "news.nogizaka46.com", + "heat": 42, + "categories": ["new-media"] + }, + { + "key": "beijingprice", + "name": "北京价格", + "host": "beijingprice.cn", + "heat": 42, + "categories": ["government"] + }, + { + "key": "stheadline", + "name": "星島日報", + "host": "std.stheadline.com", + "heat": 42, + "categories": ["other"] + }, + { + "key": "iguoguo", + "name": "爱果果", + "host": "iguoguo.net", + "heat": 42, + "categories": ["design"] + }, + { + "key": "learnblockchain", + "name": "登链社区", + "host": "learnblockchain.cn", + "heat": 42, + "categories": ["programming"] + }, + { + "key": "nippon", + "name": "走进日本", + "host": "nippon.com", + "heat": 42, + "categories": ["travel"] + }, + { + "key": "69shu", + "name": "69书吧", + "host": "69shuba.cx", + "heat": 41, + "categories": ["reading"] + }, + { + "key": "jamesclear", + "name": "James Clear", + "host": "jamesclear.com", + "heat": 41, + "categories": ["blog"] + }, + { + "key": "ndss-symposium", + "name": "Network and Distributed System Security (NDSS) Symposium", + "host": "ndss-symposium.org", + "heat": 41, + "categories": ["journal"] + }, + { + "key": "warp", + "name": "Warp", + "host": "warp.dev", + "heat": 41, + "categories": ["programming"] + }, + { + "key": "investor", + "name": "中国投资者网", + "host": "investor.org.cn", + "heat": 41, + "categories": ["finance"] + }, + { + "key": "cloudnative", + "name": "云原生社区", + "host": "cloudnative.to", + "heat": 41, + "categories": ["blog"] + }, + { + "key": "kaopu", + "name": "靠谱新闻", + "host": "kaopu.news", + "heat": 41, + "categories": ["new-media"] + }, + { + "key": "daily", + "name": "Daily.dev", + "host": "app.daily.dev", + "heat": 40, + "categories": ["social-media"] + }, + { + "key": "fx-markets", + "name": "FX Markets", + "host": "fx-markets.com", + "heat": 40, + "categories": ["finance"] + }, + { + "key": "misskey", + "name": "Misskey", + "host": "misskey.io", + "heat": 40, + "categories": ["social-media"] + }, + { + "key": "famitsu", + "name": "ファミ通", + "host": "famitsu.com", + "heat": 40, + "categories": ["game"] + }, + { + "key": "luxiangdong", + "name": "土猛的员外", + "host": "luxiangdong.com", + "heat": 40, + "categories": ["blog"] + }, + { + "key": "storyfm", + "name": "故事 FM", + "host": "storyfm.cn", + "heat": 40, + "categories": ["multimedia"] + }, + { + "key": "qqorw", + "name": "早报网", + "host": "qqorw.cn", + "heat": 40, + "categories": ["new-media"] + }, + { + "key": "zjut", + "name": "浙江工业大学", + "host": "zjut.edu.cn", + "heat": 40, + "categories": ["university"] + }, + { + "key": "ddosi", + "name": "雨苁博客", + "host": "ddosi.org", + "heat": 40, + "categories": ["blog"] + }, + { + "key": "4gamers", + "name": "4Gamers", + "host": "4gamers.com.tw", + "heat": 39, + "categories": ["game"] + }, + { + "key": "fansly", + "name": "Fansly", + "host": "fansly.com", + "heat": 39, + "categories": ["social-media"] + }, + { + "key": "medium", + "name": "Medium", + "host": "medium.com", + "heat": 39, + "categories": ["blog"] + }, + { + "key": "saraba1st", + "name": "Saraba1st", + "host": "stage1st.com", + "heat": 39, + "categories": ["bbs"] + }, + { + "key": "yoasobi-music", + "name": "Yoasobi Official", + "host": "yoasobi-music.jp", + "heat": 39, + "categories": ["live"] + }, + { + "key": "xboxfan", + "name": "盒心光环", + "host": "xboxfan.com", + "heat": 39, + "categories": ["game"] + }, + { + "key": "sakurazaka46", + "name": "Sakamichi Series 坂道系列官网资讯", + "host": "sakurazaka46.com", + "heat": 38, + "categories": ["new-media"] + }, + { + "key": "afdian", + "name": "爱发电", + "host": "afdian.net", + "heat": 38, + "categories": ["other"] + }, + { + "key": "bgmlist", + "name": "番组放送", + "host": "bgmlist.com", + "heat": 38, + "categories": ["anime"] + }, + { + "key": "modrinth", + "name": "Modrinth", + "host": "modrinth.com", + "heat": 37, + "categories": ["game"] + }, + { + "key": "skebetter", + "name": "Skebetter", + "host": "skebetter.com", + "heat": 37, + "categories": ["anime"] + }, + { + "key": "idolypride", + "name": "IDOLY PRIDE 偶像荣耀", + "host": "idolypride.jp", + "heat": 36, + "categories": ["anime"] + }, + { + "key": "quicker", + "name": "Quicker", + "host": "getquicker.net", + "heat": 36, + "categories": ["programming"] + }, + { + "key": "raspberrypi", + "name": "Raspberry Pi", + "host": "raspberrypi.com", + "heat": 36, + "categories": ["programming"] + }, + { + "key": "chinadaily", + "name": "中国日报网", + "host": "chinadaily.com.cn", + "heat": 36, + "categories": ["traditional-media"] + }, + { + "key": "kurogames", + "name": "库洛游戏 | Kuro Games", + "host": "kurogames.com", + "heat": 36, + "categories": ["game"] + }, + { + "key": "keep", + "name": "Keep", + "host": "gotokeep.com", + "heat": 35, + "categories": ["social-media"] + }, + { + "key": "chinathinktanks", + "name": "中国智库网", + "host": "chinathinktanks.org.cn", + "heat": 35, + "categories": ["study"] + }, + { + "key": "mwm", + "name": "管理世界", + "host": "mwm.net.cn", + "heat": 35, + "categories": ["journal"] + }, + { + "key": "imiker", + "name": "米课", + "host": "imiker.com", + "heat": 35, + "categories": ["new-media"] + }, + { + "key": "p-articles", + "name": "虚词", + "host": "p-articles.com", + "heat": 35, + "categories": ["reading"] + }, + { + "key": "barronschina", + "name": "巴伦周刊中文版", + "host": "barronschina.com.cn", + "heat": 34, + "categories": ["finance"] + }, + { + "key": "mirrormedia", + "name": "鏡週刊 Mirror Media", + "host": "mirrormedia.mg", + "heat": 34, + "categories": ["traditional-media"] + }, + { + "key": "btzj", + "name": "BT 之家", + "host": "btbtt20.com", + "heat": 33, + "categories": ["multimedia"] + }, + { + "key": "clickme", + "name": "ClickMe", + "host": "clickme.net", + "heat": 33, + "categories": ["other"] + }, + { + "key": "lemmy", + "name": "Lemmy", + "host": "join-lemmy.org", + "heat": 33, + "categories": ["social-media"] + }, + { + "key": "mcmod", + "name": "MC百科", + "host": "mcmod.cn", + "heat": 33, + "categories": ["game"] + }, + { + "key": "nmc", + "name": "中央气象台", + "host": "nmc.cn", + "heat": 33, + "categories": ["forecast"] + }, + { + "key": "bjx", + "name": "北极星电力网", + "host": "bjx.com.cn", + "heat": 33, + "categories": ["traditional-media"] + }, + { + "key": "asiantolick", + "name": "Asian to lick", + "host": "asiantolick.com", + "heat": 32, + "categories": ["other"] + }, + { + "key": "home-assistant", + "name": "Home Assistant", + "host": "home-assistant.io", + "heat": 32, + "categories": ["program-update"] + }, + { + "key": "macfilos", + "name": "Macfilos", + "host": "macfilos.com", + "heat": 32, + "categories": ["new-media"] + }, + { + "key": "qwenlm", + "name": "Qwen Blog", + "host": "qwenlm.github.io", + "heat": 32, + "categories": ["blog"] + }, + { + "key": "tailwindcss", + "name": "TailwindCSS", + "host": "tailwindcss.com", + "heat": 32, + "categories": ["programming"] + }, + { + "key": "visualstudio", + "name": "Visual Studio", + "host": "visualstudio.com", + "heat": 32, + "categories": ["programming"] + }, + { + "key": "nju", + "name": "南京大学", + "host": "admission.nju.edu.cn", + "heat": 32, + "categories": ["university"] + }, + { + "key": "iehou", + "name": "网猴线报", + "host": "iehou.com", + "heat": 32, + "categories": ["new-media"] + }, + { + "key": "amazon", + "name": "Amazon", + "host": "amazon.com", + "heat": 31, + "categories": ["program-update"] + }, + { + "key": "grist", + "name": "Grist", + "host": "grist.org", + "heat": 31, + "categories": ["new-media"] + }, + { + "key": "xmanhua", + "name": "X 漫画", + "host": "xmanhua.com", + "heat": 31, + "categories": ["anime"] + }, + { + "key": "ruankao", + "name": "中国计算机职业技术资格考试", + "host": "ruankao.org.cn", + "heat": 31, + "categories": ["study"] + }, + { + "key": "ycwb", + "name": "羊城晚报金羊网", + "host": "xwlb.com.cn", + "heat": 31, + "categories": ["traditional-media"] + }, + { + "key": "78dm", + "name": "78 动漫", + "host": "78dm.net", + "heat": 30, + "categories": ["anime", "new-media"] + }, + { + "key": "9to5", + "name": "9To5", + "host": "9to5toys.com", + "heat": 30, + "categories": ["other"] + }, + { + "key": "android", + "name": "Android", + "host": "developer.android.com", + "heat": 30, + "categories": ["program-update"] + }, + { + "key": "meteor", + "name": "Meteor", + "host": "meteor.today", + "heat": 30, + "categories": ["bbs"] + }, + { + "key": "mygopen", + "name": "MyGoPen", + "host": "mygopen.com", + "heat": 30, + "categories": ["new-media"] + }, + { + "key": "hexun", + "name": "和讯网", + "host": "hexun.com", + "heat": 30, + "categories": ["finance"] + }, + { + "key": "ncpssd", + "name": "国家哲学社会科学文献中心", + "host": "ncpssd.cn", + "heat": 30, + "categories": ["study"] + }, + { + "key": "zjol", + "name": "浙江在线", + "host": "zjol.com.cn", + "heat": 30, + "categories": ["traditional-media"] + }, + { + "key": "cohere", + "name": "Cohere", + "host": "cohere.com", + "heat": 29, + "categories": ["blog"] + }, + { + "key": "lxixsxa", + "name": "LiSA", + "host": "sonymusic.co.jp", + "heat": 29, + "categories": ["live"] + }, + { + "key": "miui", + "name": "MIUI", + "host": "miui.com", + "heat": 29, + "categories": ["bbs", "program-update"] + }, + { + "key": "embassy", + "name": "中国驻外使领馆", + "host": "ca.china-embassy.org", + "heat": 29, + "categories": ["other"] + }, + { + "key": "mingpao", + "name": "明報", + "host": "mingpao.com", + "heat": 29, + "categories": ["other"] + }, + { + "key": "europechinese", + "name": "歐洲動態(國際)", + "host": "europechinese.blogspot.com", + "heat": 29, + "categories": ["new-media"] + }, + { + "key": "air-level", + "name": "Air-Level", + "host": "air-level.com", + "heat": 28, + "categories": ["forecast"] + }, + { + "key": "flashcat", + "name": "Flashcat", + "host": "flashcat.cloud", + "heat": 28, + "categories": ["blog"] + }, + { + "key": "inspirehep", + "name": "INSPIRE", + "host": "inspirehep.net", + "heat": 28, + "categories": ["journal"] + }, + { + "key": "kbs", + "name": "KBS", + "host": "world.kbs.co.kr", + "heat": 28, + "categories": ["new-media"] + }, + { + "key": "rfa", + "name": "Radio Free Asia (RFA) 自由亚洲电台", + "host": "rfa.org", + "heat": 28, + "categories": ["traditional-media"] + }, + { + "key": "xjtu", + "name": "西安交通大学", + "host": "2yuan.xjtu.edu.cn", + "heat": 28, + "categories": ["university"] + }, + { + "key": "mirror", + "name": "Mirror", + "host": "mirror.xyz", + "heat": 27, + "categories": ["new-media"] + }, + { + "key": "cpta", + "name": "中国人事考试网", + "host": "cpta.com.cn", + "heat": 27, + "categories": ["study"] + }, + { + "key": "gamme", + "name": "卡卡洛普", + "host": "news.gamme.com.tw", + "heat": 27, + "categories": ["other"] + }, + { + "key": "dehenglaw", + "name": "德恒律师事务所", + "host": "dehenglaw.com", + "heat": 27, + "categories": ["new-media"] + }, + { + "key": "lhratings", + "name": "联合资信评估股份有限公司", + "host": "lhratings.com", + "heat": 27, + "categories": ["finance"] + }, + { + "key": "agora0", + "name": "AG⓪RA", + "host": "agorahub.github.io", + "heat": 26, + "categories": ["new-media"] + }, + { + "key": "dealstreetasia", + "name": "DealStreetAsia", + "host": "dealstreetasia.com", + "heat": 26, + "categories": ["traditional-media"] + }, + { + "key": "gamekee", + "name": "GameKee | 游戏百科攻略", + "host": "gamekee.com", + "heat": 26, + "categories": ["game"] + }, + { + "key": "greasyfork", + "name": "Greasy Fork", + "host": "greasyfork.org", + "heat": 26, + "categories": ["program-update"] + }, + { + "key": "sankei", + "name": "Sankei Shimbun 産経新聞", + "host": "sankei.com", + "heat": 26, + "categories": ["traditional-media"] + }, + { + "key": "wikipedia", + "name": "Wikipedia", + "host": "en.wikipedia.org", + "heat": 26, + "categories": ["new-media"] + }, + { + "key": "bdys", + "name": "哔嘀影视", + "host": "52bdys.com", + "heat": 26, + "categories": ["multimedia"] + }, + { + "key": "cfr", + "name": "Council on Foreign Relations", + "host": "cfr.org", + "heat": 25, + "categories": ["traditional-media"] + }, + { + "key": "gitee", + "name": "Gitee", + "host": "gitee.com", + "heat": 25, + "categories": ["programming"] + }, + { + "key": "sjtu", + "name": "上海交通大学", + "host": "sjtu.edu.cn", + "heat": 25, + "categories": ["university"] + }, + { + "key": "jimmyspa", + "name": "幾米 JIMMY S.P.A. Official Website", + "host": "jimmyspa.com", + "heat": 25, + "categories": ["design"] + }, + { + "key": "newrank", + "name": "新榜", + "host": "newrank.cn", + "heat": 25, + "categories": ["social-media"] + }, + { + "key": "pacilution", + "name": "普世社会科学研究所", + "host": "pacilution.com", + "heat": 25, + "categories": ["journal"] + }, + { + "key": "atcoder", + "name": "AtCoder", + "host": "atcoder.jp", + "heat": 24, + "categories": ["programming"] + }, + { + "key": "zimuxia", + "name": "FIX 字幕侠", + "host": "zimuxia.cn", + "heat": 24, + "categories": ["multimedia"] + }, + { + "key": "msn", + "name": "MSN", + "host": "msn.com", + "heat": 24, + "categories": ["traditional-media"] + }, + { + "key": "theblock", + "name": "TheBlock", + "host": "theblock.co", + "heat": 24, + "categories": ["finance"] + }, + { + "key": "dxy", + "name": "丁香园", + "host": "dxy.cn", + "heat": 24, + "categories": ["bbs"] + }, + { + "key": "ustc", + "name": "中国科学技术大学", + "host": "ustc.edu.cn", + "heat": 24, + "categories": ["university"] + }, + { + "key": "ff14", + "name": "FINAL FANTASY XIV", + "host": "eu.finalfantasyxiv.com", + "heat": 23, + "categories": ["game"] + }, + { + "key": "koreaherald", + "name": "The Korea Herald", + "host": "koreaherald.com", + "heat": 23, + "categories": ["traditional-media"] + }, + { + "key": "banshujiang", + "name": "搬书匠", + "host": "banshujiang.cn", + "heat": 23, + "categories": ["reading"] + }, + { + "key": "lianxh", + "name": "连享会", + "host": "lianxh.cn", + "heat": 23, + "categories": ["programming"] + }, + { + "key": "bandcamp", + "name": "Bandcamp", + "host": "bandcamp.com", + "heat": 22, + "categories": ["multimedia"] + }, + { + "key": "cybersecurityventures", + "name": "Cybercrime Magazine", + "host": "cybersecurityventures.com", + "heat": 22, + "categories": ["programming"] + }, + { + "key": "deepin", + "name": "Deepin", + "host": "bbs.deepin.org", + "heat": 22, + "categories": ["bbs"] + }, + { + "key": "mysql", + "name": "MySQL", + "host": "dev.mysql.com", + "heat": 22, + "categories": ["programming"] + }, + { + "key": "sobooks", + "name": "SoBooks", + "host": "sobooks.net", + "heat": 22, + "categories": ["reading"] + }, + { + "key": "oreno3d", + "name": "俺の 3D エロ動画 (oreno3d)", + "host": "oreno3d.com", + "heat": 22, + "categories": ["anime"] + }, + { + "key": "houxu", + "name": "后续", + "host": "houxu.app", + "heat": 22, + "categories": ["new-media"] + }, + { + "key": "freexcomic", + "name": "漫小肆韓漫", + "host": "freexcomic.com", + "heat": 22, + "categories": ["anime"] + }, + { + "key": "uestc", + "name": "电子科技大学", + "host": "uestc.edu.cn", + "heat": 22, + "categories": ["university"] + }, + { + "key": "xidian", + "name": "西安电子科技大学", + "host": "xidian.edu.cn", + "heat": 22, + "categories": ["university"] + }, + { + "key": "gelbooru", + "name": "Gelbooru", + "host": "gelbooru.com", + "heat": 21, + "categories": ["picture"] + }, + { + "key": "mox", + "name": "Mox.moe", + "host": "mox.moe", + "heat": 21, + "categories": ["anime"] + }, + { + "key": "chinaratings", + "name": "中债资信评估有限责任公司", + "host": "chinaratings.com.cn", + "heat": 21, + "categories": ["finance"] + }, + { + "key": "oeeee", + "name": "南方都市报", + "host": "oeeee.com", + "heat": 21, + "categories": ["traditional-media"] + }, + { + "key": "dewu", + "name": "得物", + "host": "dewu.com", + "heat": 21, + "categories": ["programming"] + }, + { + "key": "wabei", + "name": "挖贝网", + "host": "wabei.cn", + "heat": 21, + "categories": ["finance"] + }, + { + "key": "caareviews", + "name": "caa.reviews", + "host": "caareviews.org", + "heat": 20, + "categories": ["journal"] + }, + { + "key": "cbndata", + "name": "CBNData", + "host": "cbndata.com", + "heat": 20, + "categories": ["new-media"] + }, + { + "key": "eprice", + "name": "ePrice", + "host": "eprice.com.tw", + "heat": 20, + "categories": ["new-media"] + }, + { + "key": "joshwcomeau", + "name": "Josh W Comeau", + "host": "joshwcomeau.com", + "heat": 20, + "categories": ["programming"] + }, + { + "key": "manyvids", + "name": "ManyVids", + "host": "manyvids.com", + "heat": 20, + "categories": ["multimedia"] + }, + { + "key": "yna", + "name": "Yonhap News Agency", + "host": "yna.co.kr", + "heat": 20, + "categories": ["traditional-media"] + }, + { + "key": "chinawriter", + "name": "中国作家网", + "host": "chinawriter.com.cn", + "heat": 20, + "categories": ["other"] + }, + { + "key": "nankai", + "name": "南开大学", + "host": "yzb.nankai.edu.cn", + "heat": 20, + "categories": ["university"] + }, + { + "key": "tongji", + "name": "同济大学", + "host": "bksy.tongji.edu.cn", + "heat": 20, + "categories": ["university"] + }, + { + "key": "luogu", + "name": "洛谷", + "host": "luogu.com.cn", + "heat": 20, + "categories": ["programming"] + }, + { + "key": "meishichina", + "name": "美食天下", + "host": "meishichina.com", + "heat": 20, + "categories": ["new-media"] + }, + { + "key": "hitcon", + "name": "HITCON", + "host": "hitcon.org", + "heat": 19, + "categories": ["programming"] + }, + { + "key": "paradigm", + "name": "Paradigm", + "host": "paradigm.xyz", + "heat": 19, + "categories": ["finance"] + }, + { + "key": "radio-canada", + "name": "Radio-Canada.ca", + "host": "ici.radio-canada.ca", + "heat": 19, + "categories": ["new-media"] + }, + { + "key": "who", + "name": "World Health Organization | WHO", + "host": "who.int", + "heat": 19, + "categories": ["government"] + }, + { + "key": "zuvio", + "name": "Zuvio", + "host": "irs.zuvio.com.tw", + "heat": 19, + "categories": ["bbs"] + }, + { + "key": "agri", + "name": "中国农业农村信息网", + "host": "agri.cn", + "heat": 19, + "categories": ["new-media"] + }, + { + "key": "cffex", + "name": "中国金融期货交易所", + "host": "cffex.com.cn", + "heat": 19, + "categories": ["government"] + }, + { + "key": "0xxx", + "name": "0xxx.ws", + "host": "0xxx.ws", + "heat": 18, + "categories": ["multimedia"] + }, + { + "key": "counter-strike", + "name": "Counter Strike", + "host": "counter-strike.net", + "heat": 18, + "categories": ["game"] + }, + { + "key": "duckdb", + "name": "DuckDB Foundation", + "host": "duckdb.org", + "heat": 18, + "categories": ["programming"] + }, + { + "key": "loongarch", + "name": "LA UOSC社区", + "host": "loongarch.org", + "heat": 18, + "categories": ["bbs"] + }, + { + "key": "syosetu", + "name": "Syosetu", + "host": "syosetu.com", + "heat": 18, + "categories": ["program-update", "reading"] + }, + { + "key": "zju", + "name": "浙江大学", + "host": "physics.zju.edu.cn", + "heat": 18, + "categories": ["university"] + }, + { + "key": "maoyan", + "name": "猫眼电影", + "host": "maoyan.com", + "heat": 18, + "categories": ["multimedia"] + }, + { + "key": "dtcj", + "name": "DT 财经", + "host": "dtcj.com", + "heat": 17, + "categories": ["finance"] + }, + { + "key": "f95zone", + "name": "F95zone", + "host": "f95zone.to", + "heat": 17, + "categories": ["game"] + }, + { + "key": "hamel", + "name": "Hamel's Blog", + "host": "hamel.dev", + "heat": 17, + "categories": ["blog"] + }, + { + "key": "firecore", + "name": "Infuse", + "host": "firecore.com", + "heat": 17, + "categories": ["program-update"] + }, + { + "key": "inoreader", + "name": "Inoreader", + "host": "inoreader.com", + "heat": 17, + "categories": ["reading"] + }, + { + "key": "iheima", + "name": "i黑马网", + "host": "iheima.com", + "heat": 17, + "categories": ["new-media"] + }, + { + "key": "kpmg", + "name": "KPMG", + "host": "kpmg.com", + "heat": 17, + "categories": ["other"] + }, + { + "key": "qbittorrent", + "name": "qBittorrent", + "host": "qbittorrent.org", + "heat": 17, + "categories": ["program-update"] + }, + { + "key": "scientificamerican", + "name": "Scientific American", + "host": "scientificamerican.com", + "heat": 17, + "categories": ["new-media"] + }, + { + "key": "smashingmagazine", + "name": "Smashing Magazine", + "host": "smashingmagazine.com", + "heat": 17, + "categories": ["programming"] + }, + { + "key": "typora", + "name": "Typora", + "host": "typora.io", + "heat": 17, + "categories": ["program-update"] + }, + { + "key": "cn-healthcare", + "name": "健康界", + "host": "cn-healthcare.com", + "heat": 17, + "categories": ["new-media"] + }, + { + "key": "nextapple", + "name": "壹蘋新聞網", + "host": "tw.nextapple.com", + "heat": 17, + "categories": ["new-media"] + }, + { + "key": "xiaote", + "name": "小特社区", + "host": "xiaote.com", + "heat": 17, + "categories": ["bbs"] + }, + { + "key": "adquan", + "name": "广告门", + "host": "adquan.com", + "heat": 17, + "categories": ["new-media"] + }, + { + "key": "zcmu", + "name": "浙江中医药大学", + "host": "jwc.zcmu.edu.cn", + "heat": 17, + "categories": ["university"] + }, + { + "key": "hedwig", + "name": "Hedwig", + "host": "hedwig.pub", + "heat": 16, + "categories": ["blog"] + }, + { + "key": "lvv2", + "name": "LVV2", + "host": "lvv2.com", + "heat": 16, + "categories": ["new-media"] + }, + { + "key": "sfacg", + "name": "SF 轻小说", + "host": "book.sfacg.com", + "heat": 16, + "categories": ["reading"] + }, + { + "key": "subhd", + "name": "Sub HD", + "host": "subhd.tv", + "heat": 16, + "categories": ["other"] + }, + { + "key": "vertikal", + "name": "Vertikal.net", + "host": "vertikal.net", + "heat": 16, + "categories": ["new-media"] + }, + { + "key": "kakuyomu", + "name": "カクヨム", + "host": "kakuyomu.jp", + "heat": 16, + "categories": ["reading"] + }, + { + "key": "cngold", + "name": "中国黄金协会", + "host": "cngold.org.cn", + "heat": 16, + "categories": ["new-media"] + }, + { + "key": "hnrb", + "name": "湖南日报", + "host": "voc.com.cn", + "heat": 16, + "categories": ["traditional-media"] + }, + { + "key": "swjtu", + "name": "西南交通大学", + "host": "swjtu.edu.cn", + "heat": 16, + "categories": ["university"] + }, + { + "key": "5eplay", + "name": "5EPLAY", + "host": "csgo.5eplay.com", + "heat": 15, + "categories": ["game"] + }, + { + "key": "alistapart", + "name": "A List Apart", + "host": "alistapart.com", + "heat": 15, + "categories": ["programming"] + }, + { + "key": "ai-bot", + "name": "AI工具集", + "host": "ai-bot.cn", + "heat": 15, + "categories": ["new-media"] + }, + { + "key": "bestofjs", + "name": "Best of JS", + "host": "bestofjs.org", + "heat": 15, + "categories": ["programming"] + }, + { + "key": "bullionvault", + "name": "BullionVault", + "host": "bullionvault.com", + "heat": 15, + "categories": ["finance"] + }, + { + "key": "hex-rays", + "name": "Hex-Rays", + "host": "hex-rays.com", + "heat": 15, + "categories": ["programming"] + }, + { + "key": "hko", + "name": "Hong Kong Observatory", + "host": "hko.gov.hk", + "heat": 15, + "categories": ["forecast"] + }, + { + "key": "hyperdash", + "name": "HyperDash", + "host": "hyperdash.info", + "heat": 15, + "categories": ["finance"] + }, + { + "key": "konghq", + "name": "Kong API 网关平台", + "host": "konghq.com", + "heat": 15, + "categories": ["programming"] + }, + { + "key": "kunchengblog", + "name": "Kun Cheng", + "host": "kunchengblog.com", + "heat": 15, + "categories": ["blog"] + }, + { + "key": "malaysiakini", + "name": "Malaysiakini", + "host": "malaysiakini.com", + "heat": 15, + "categories": ["new-media"] + }, + { + "key": "psnine", + "name": "PSN 中文站", + "host": "psnine.com", + "heat": 15, + "categories": ["game"] + }, + { + "key": "skeb", + "name": "Skeb", + "host": "skeb.jp", + "heat": 15, + "categories": ["picture"] + }, + { + "key": "thegradient", + "name": "The Gradient", + "host": "thegradient.pub", + "heat": 15, + "categories": ["blog"] + }, + { + "key": "wdfxw", + "name": "WDFXW文档分享网", + "host": "wdfxw.net", + "heat": 15, + "categories": ["reading"] + }, + { + "key": "ce", + "name": "中国经济网", + "host": "ce.cn", + "heat": 15, + "categories": ["traditional-media"] + }, + { + "key": "zaimanhua", + "name": "再漫画", + "host": "manhua.zaimanhua.com", + "heat": 15, + "categories": ["anime"] + }, + { + "key": "hust", + "name": "华中科技大学", + "host": "hust.edu.cn", + "heat": 15, + "categories": ["university"] + }, + { + "key": "scut", + "name": "华南理工大学", + "host": "jw.scut.edu.cn", + "heat": 15, + "categories": ["university"] + }, + { + "key": "logclub", + "name": "罗戈网", + "host": "logclub.com", + "heat": 15, + "categories": ["new-media"] + }, + { + "key": "booru", + "name": "Booru", + "host": "mmda.booru.org", + "heat": 14, + "categories": ["picture"] + }, + { + "key": "cssn", + "name": "Chinese Social Science Net", + "host": "iolaw.cssn.cn", + "heat": 14, + "categories": ["study"] + }, + { + "key": "dora-world", + "name": "Doraemon Channel", + "host": "dora-world.com", + "heat": 14, + "categories": ["anime"] + }, + { + "key": "hackmd", + "name": "HackMD", + "host": "hackmd.io", + "heat": 14, + "categories": ["programming"] + }, + { + "key": "publico", + "name": "Público", + "host": "publico.es", + "heat": 14, + "categories": ["traditional-media"] + }, + { + "key": "rsc", + "name": "Royal Society of Chemistry", + "host": "pubs.rsc.org", + "heat": 14, + "categories": ["journal"] + }, + { + "key": "tokeninsight", + "name": "TokenInsight", + "host": "tokeninsight.com", + "heat": 14, + "categories": ["finance"] + }, + { + "key": "crac", + "name": "中国无线电协会业余无线电分会", + "host": "crac.org.cn", + "heat": 14, + "categories": ["government"] + }, + { + "key": "boc", + "name": "中国银行", + "host": "boc.cn", + "heat": 14, + "categories": ["other"] + }, + { + "key": "pts", + "name": "公視新聞網", + "host": "news.pts.org.tw", + "heat": 14, + "categories": ["traditional-media"] + }, + { + "key": "hit", + "name": "哈尔滨工业大学", + "host": "hit.edu.cn", + "heat": 14, + "categories": ["university"] + }, + { + "key": "gmcmonline", + "name": "国门传媒在线", + "host": "gmcmonline.com", + "heat": 14, + "categories": ["journal", "reading"] + }, + { + "key": "whu", + "name": "武汉大学", + "host": "cs.whu.edu.cn", + "heat": 14, + "categories": ["university"] + }, + { + "key": "mpaypass", + "name": "移动支付网", + "host": "mpaypass.com.cn", + "heat": 14, + "categories": ["new-media"] + }, + { + "key": "fxiaoke", + "name": "纷享销客 CRM", + "host": "fxiaoke.com", + "heat": 14, + "categories": ["blog"] + }, + { + "key": "modb", + "name": "墨天轮", + "host": "modb.pro", + "heat": 14, + "categories": ["programming"] + }, + { + "key": "bfl", + "name": "BFL AI", + "host": "bfl.ai", + "heat": 13, + "categories": ["multimedia"] + }, + { + "key": "bioone", + "name": "BioOne", + "host": "bioone.org", + "heat": 13, + "categories": ["journal"] + }, + { + "key": "brave", + "name": "Brave", + "host": "brave.com", + "heat": 13, + "categories": ["program-update"] + }, + { + "key": "f-droid", + "name": "F-Droid", + "host": "f-droid.org", + "heat": 13, + "categories": ["program-update"] + }, + { + "key": "leagueoflegends", + "name": "League of Legends", + "host": "leagueoflegends.com", + "heat": 13, + "categories": ["game"] + }, + { + "key": "nio", + "name": "NIO", + "host": "nio.com", + "heat": 13, + "categories": ["multimedia"] + }, + { + "key": "oshwhub", + "name": "oshwhub 立创开源硬件平台", + "host": "oshwhub.com", + "heat": 13, + "categories": ["programming"] + }, + { + "key": "qoo-app", + "name": "QooApp", + "host": "apps.qoo-app.com", + "heat": 13, + "categories": ["anime"] + }, + { + "key": "afr", + "name": "The Australian Financial Review", + "host": "afr.com", + "heat": 13, + "categories": ["traditional-media"] + }, + { + "key": "uchicago", + "name": "The University of Chicago Press: Journals", + "host": "journals.uchicago.edu", + "heat": 13, + "categories": ["journal"] + }, + { + "key": "idolmaster", + "name": "アイドルマスター THE IDOLM@STER", + "host": "idolmaster-official.jp", + "heat": 13, + "categories": ["anime"] + }, + { + "key": "caai", + "name": "中国人工智能学会", + "host": "caai.cn", + "heat": 13, + "categories": ["study"] + }, + { + "key": "asiafruitchina", + "name": "亚洲水果", + "host": "asiafruitchina.net", + "heat": 13, + "categories": ["new-media"] + }, + { + "key": "kadokawa", + "name": "台灣角川", + "host": "kadokawa.com.tw", + "heat": 13, + "categories": ["shopping", "blog"] + }, + { + "key": "xkb", + "name": "新快报", + "host": "xkb.com.cn", + "heat": 13, + "categories": ["traditional-media"] + }, + { + "key": "qiyoujiage", + "name": "汽油价格网", + "host": "qiyoujiage.com", + "heat": 13, + "categories": ["other"] + }, + { + "key": "aqicn", + "name": "空气质量", + "host": "aqicn.org", + "heat": 13, + "categories": ["other"] + }, + { + "key": "ifun", + "name": "趣集", + "host": "ifun.cool", + "heat": 13, + "categories": ["new-media"] + }, + { + "key": "ltaaa", + "name": "龙腾网", + "host": "ltaaa.cn", + "heat": 13, + "categories": ["new-media"] + }, + { + "key": "12306", + "name": "12306", + "host": "kyfw.12306.cn", + "heat": 12, + "categories": ["travel"] + }, + { + "key": "dataguidance", + "name": "DataGuidance", + "host": "dataguidance.com", + "heat": 12, + "categories": ["other"] + }, + { + "key": "egsea", + "name": "e 公司", + "host": "egsea.com", + "heat": 12, + "categories": ["new-media"] + }, + { + "key": "gs", + "name": "Goldman Sachs", + "host": "goldmansachs.com", + "heat": 12, + "categories": ["blog"] + }, + { + "key": "itch", + "name": "itch.io", + "host": "itch.io", + "heat": 12, + "categories": ["game"] + }, + { + "key": "thzt", + "name": "thzt", + "host": "thzt.github.io", + "heat": 12, + "categories": ["blog"] + }, + { + "key": "towardsdatascience", + "name": "Towards Data Science", + "host": "towardsdatascience.com", + "heat": 12, + "categories": ["blog"] + }, + { + "key": "wellcee", + "name": "Wellcee 唯心所寓", + "host": "wellcee.com", + "heat": 12, + "categories": ["other"] + }, + { + "key": "worldjournal", + "name": "世界新聞網", + "host": "worldjournal.com", + "heat": 12, + "categories": ["other"] + }, + { + "key": "bupt", + "name": "北京邮电大学", + "host": "bupt.edu.cn", + "heat": 12, + "categories": ["university"] + }, + { + "key": "nuist", + "name": "南京信息工程大学", + "host": "bulletin.nuist.edu.cn", + "heat": 12, + "categories": ["university"] + }, + { + "key": "scu", + "name": "四川大学", + "host": "scu.edu.cn", + "heat": 12, + "categories": ["university"] + }, + { + "key": "zhizhuan100", + "name": "智篆商业", + "host": "zhizhuan100.com.cn", + "heat": 12, + "categories": ["finance"] + }, + { + "key": "nbd", + "name": "每经网", + "host": "nbd.com.cn", + "heat": 12, + "categories": ["finance"] + }, + { + "key": "chuapp", + "name": "触乐", + "host": "chuapp.com", + "heat": 12, + "categories": ["game"] + }, + { + "key": "10000link", + "name": "10000万联网", + "host": "10000link.com", + "heat": 11, + "categories": ["new-media"] + }, + { + "key": "51read", + "name": "51Read", + "host": "m.51read.org", + "heat": 11, + "categories": ["reading"] + }, + { + "key": "gitstar-ranking", + "name": "Gitstar Ranking", + "host": "gitstar-ranking.com", + "heat": 11, + "categories": ["programming"] + }, + { + "key": "mdpi", + "name": "MDPI", + "host": "mdpi.com", + "heat": 11, + "categories": ["journal"] + }, + { + "key": "nyaa", + "name": "Nyaa", + "host": "nyaa.si", + "heat": 11, + "categories": ["multimedia"] + }, + { + "key": "stanford", + "name": "Stanford", + "host": "hazyresearch.stanford.edu", + "heat": 11, + "categories": ["blog"] + }, + { + "key": "thwiki", + "name": "THBWiki", + "host": "thwiki.cc", + "heat": 11, + "categories": ["anime"] + }, + { + "key": "tradingview", + "name": "TradingView", + "host": "tradingview.com", + "heat": 11, + "categories": ["program-update"] + }, + { + "key": "x-mol", + "name": "X-MOL", + "host": "x-mol.com", + "heat": 11, + "categories": ["study", "journal"] + }, + { + "key": "scnu", + "name": "华南师范大学", + "host": "cs.scnu.edu.cn", + "heat": 11, + "categories": ["university"] + }, + { + "key": "cmde", + "name": "国家药品监督管理局医疗器械技术审评中心", + "host": "cmde.org.cn", + "heat": 11, + "categories": ["other"] + }, + { + "key": "apiseven", + "name": "支流科技", + "host": "apiseven.com", + "heat": 11, + "categories": ["blog"] + }, + { + "key": "hunanpea", + "name": "湖南人事考试网", + "host": "rsks.hunanpea.com", + "heat": 11, + "categories": ["study"] + }, + { + "key": "iqnew", + "name": "爱 Q 生活网", + "host": "iqnew.com", + "heat": 11, + "categories": ["other"] + }, + { + "key": "toodaylab", + "name": "理想生活实验室", + "host": "toodaylab.com", + "heat": 11, + "categories": ["other"] + }, + { + "key": "tisi", + "name": "腾讯研究院", + "host": "tisi.org", + "heat": 11, + "categories": ["new-media"] + }, + { + "key": "ceph", + "name": "Ceph", + "host": "ceph.io", + "heat": 10, + "categories": ["blog"] + }, + { + "key": "crossbell", + "name": "Crossbell", + "host": "crossbell.io", + "heat": 10, + "categories": ["social-media"] + }, + { + "key": "fantube", + "name": "FANTUBE", + "host": "fantube.tokyo", + "heat": 10, + "categories": ["multimedia"] + }, + { + "key": "lorientlejour", + "name": "L'Orient-Le Jour/L'Orient Today", + "host": "lorientlejour.com", + "heat": 10, + "categories": ["traditional-media"] + }, + { + "key": "lovelive-anime", + "name": "Love Live! Official Website", + "host": "lovelive-anime.jp", + "heat": 10, + "categories": ["anime"] + }, + { + "key": "macmenubar", + "name": "MacMenuBar", + "host": "macmenubar.com", + "heat": 10, + "categories": ["blog"] + }, + { + "key": "neatdownloadmanager", + "name": "Neat Download Manager", + "host": "neatdownloadmanager.com", + "heat": 10, + "categories": ["program-update"] + }, + { + "key": "npm", + "name": "NPM", + "host": "npmjs.com", + "heat": 10, + "categories": ["program-update"] + }, + { + "key": "orcid", + "name": "ORCID", + "host": "orcid.org", + "heat": 10, + "categories": ["study"] + }, + { + "key": "taptap", + "name": "TapTap", + "host": "taptap.io", + "heat": 10, + "categories": ["game"] + }, + { + "key": "u9a9", + "name": "U9A9", + "host": "u9a9.com", + "heat": 10, + "categories": ["other"] + }, + { + "key": "chinatimes", + "name": "中時新聞網", + "host": "chinatimes.com", + "heat": 10, + "categories": ["traditional-media"] + }, + { + "key": "jlu", + "name": "吉林大学", + "host": "jlu.edu.cn", + "heat": 10, + "categories": ["university"] + }, + { + "key": "hrbeu", + "name": "哈尔滨工程大学", + "host": "yjsy.hrbeu.edu.cn", + "heat": 10, + "categories": ["university"] + }, + { + "key": "changba", + "name": "唱吧", + "host": "changba.com", + "heat": 10, + "categories": ["social-media"] + }, + { + "key": "samrdprc", + "name": "国家市场监督管理总局缺陷产品管理中心", + "host": "samrdprc.org.cn", + "heat": 10, + "categories": ["government"] + }, + { + "key": "fanqienovel", + "name": "番茄小说", + "host": "fanqienovel.com", + "heat": 10, + "categories": ["reading"] + }, + { + "key": "eeo", + "name": "经济观察网", + "host": "eeo.com.cn", + "heat": 10, + "categories": ["finance"] + }, + { + "key": "ally", + "name": "艾莱资讯", + "host": "rail.ally.net.cn", + "heat": 10, + "categories": ["new-media"] + }, + { + "key": "bangumi.moe", + "name": "萌番组", + "host": "bangumi.online", + "heat": 10, + "categories": ["anime"] + }, + { + "key": "uraaka-joshi", + "name": "裏垢女子まとめ", + "host": "uraaka-joshi.com", + "heat": 10, + "categories": ["other"] + }, + { + "key": "wenku8", + "name": "轻小说文库", + "host": "wenku8.net", + "heat": 10, + "categories": ["reading"] + }, + { + "key": "bluearchive", + "name": "Blue Archive", + "host": "bluearchive.jp", + "heat": 9, + "categories": ["game"] + }, + { + "key": "deepl", + "name": "DeepL", + "host": "deepl.com", + "heat": 9, + "categories": ["new-media"] + }, + { + "key": "koyso", + "name": "Koyso", + "host": "koyso.to", + "heat": 9, + "categories": ["game"] + }, + { + "key": "logrocket", + "name": "logrocket blog", + "host": "blog.logrocket.com", + "heat": 9, + "categories": ["blog"] + }, + { + "key": "seekingalpha", + "name": "Seeking Alpha", + "host": "seekingalpha.com", + "heat": 9, + "categories": ["finance"] + }, + { + "key": "thoughtco", + "name": "ThoughtCo", + "host": "thoughtco.com", + "heat": 9, + "categories": ["new-media"] + }, + { + "key": "newsmarket", + "name": "上下游 News&Market", + "host": "newsmarket.com.tw", + "heat": 9, + "categories": ["new-media"] + }, + { + "key": "southcn", + "name": "南方网", + "host": "nfapp.southcn.com", + "heat": 9, + "categories": ["traditional-media"] + }, + { + "key": "duozhuayu", + "name": "多抓鱼", + "host": "duozhuayu.com", + "heat": 9, + "categories": ["shopping"] + }, + { + "key": "jinritemai", + "name": "抖店开放平台", + "host": "op.jinritemai.com", + "heat": 9, + "categories": ["programming"] + }, + { + "key": "hdu", + "name": "杭州电子科技大学", + "host": "hdu.edu.cn", + "heat": 9, + "categories": ["university"] + }, + { + "key": "acgvinyl", + "name": "ACG Vinyl - 黑胶", + "host": "acgvinyl.com", + "heat": 8, + "categories": ["anime"] + }, + { + "key": "aiblog-2xv", + "name": "AI 博客", + "host": "aiblog-2xv.pages.dev", + "heat": 8, + "categories": ["blog"] + }, + { + "key": "asianfanfics", + "name": "Asianfanfics", + "host": "asianfanfics.com", + "heat": 8, + "categories": ["reading"] + }, + { + "key": "btbtla", + "name": "BT影视", + "host": "btbtla.com", + "heat": 8, + "categories": ["multimedia"] + }, + { + "key": "fashionnetwork", + "name": "FashionNetwork", + "host": "fashionnetwork.cn", + "heat": 8, + "categories": ["new-media"] + }, + { + "key": "ipsw.dev", + "name": "IPSW.dev", + "host": "ipsw.dev", + "heat": 8, + "categories": ["program-update"] + }, + { + "key": "notateslaapp", + "name": "Not a Tesla App", + "host": "notateslaapp.com", + "heat": 8, + "categories": ["program-update"] + }, + { + "key": "lrepacks", + "name": "REPACK скачать", + "host": "lrepacks.net", + "heat": 8, + "categories": ["program-update"] + }, + { + "key": "vom", + "name": "Voice of Mongolia", + "host": "vom.mn", + "heat": 8, + "categories": ["traditional-media"] + }, + { + "key": "gaoyu", + "name": "Yu Gao", + "host": "gaoyu.me", + "heat": 8, + "categories": ["blog"] + }, + { + "key": "cfachina", + "name": "中国期货业协会", + "host": "cfachina.org", + "heat": 8, + "categories": ["other"] + }, + { + "key": "cast", + "name": "中国科学技术协会", + "host": "cast.org.cn", + "heat": 8, + "categories": ["government"] + }, + { + "key": "hfut", + "name": "合肥工业大学", + "host": "hfut.edu.cn", + "heat": 8, + "categories": ["university"] + }, + { + "key": "ncc-cma", + "name": "国家气候中心", + "host": "cmdp.ncc-cma.net", + "heat": 8, + "categories": ["forecast"] + }, + { + "key": "xiaozhuanlan", + "name": "小专栏", + "host": "xiaozhuanlan.com", + "heat": 8, + "categories": ["new-media"] + }, + { + "key": "sdzk", + "name": "山东省教育招生考试院", + "host": "sdzk.cn", + "heat": 8, + "categories": ["study"] + }, + { + "key": "tongli", + "name": "東立出版社", + "host": "tongli.com.tw", + "heat": 8, + "categories": ["reading"] + }, + { + "key": "hizu", + "name": "珠海网", + "host": "hizh.cn", + "heat": 8, + "categories": ["new-media"] + }, + { + "key": "elecfans", + "name": "电子发烧友", + "host": "elecfans.com", + "heat": 8, + "categories": ["programming"] + }, + { + "key": "cts", + "name": "華視", + "host": "news.cts.com.tw", + "heat": 8, + "categories": ["traditional-media"] + }, + { + "key": "lanqiao", + "name": "蓝桥云课", + "host": "lanqiao.cn", + "heat": 8, + "categories": ["programming"] + }, + { + "key": "accessbriefing", + "name": "Access Briefing", + "host": "accessbriefing.com", + "heat": 7, + "categories": ["new-media"] + }, + { + "key": "aicaijing", + "name": "AI 财经社", + "host": "aicaijing.com", + "heat": 7, + "categories": ["other"] + }, + { + "key": "asus", + "name": "ASUS", + "host": "asus.com.cn", + "heat": 7, + "categories": ["program-update"] + }, + { + "key": "cmu", + "name": "Carnegie Mellon University", + "host": "cmu.edu", + "heat": 7, + "categories": ["blog"] + }, + { + "key": "dblp", + "name": "DBLP", + "host": "dblp.org", + "heat": 7, + "categories": ["study"] + }, + { + "key": "lineageos", + "name": "LineageOS", + "host": "lineageos.org", + "heat": 7, + "categories": ["program-update"] + }, + { + "key": "newslaundry", + "name": "Newslaundry", + "host": "newslaundry.com", + "heat": 7, + "categories": ["new-media"] + }, + { + "key": "patreon", + "name": "Patreon", + "host": "patreon.com", + "heat": 7, + "categories": ["new-media"] + }, + { + "key": "tver", + "name": "TVer", + "host": "tver.jp", + "heat": 7, + "categories": ["traditional-media"] + }, + { + "key": "unraid", + "name": "Unraid", + "host": "unraid.net", + "heat": 7, + "categories": ["program-update"] + }, + { + "key": "seu", + "name": "东南大学", + "host": "cse.seu.edu.cn", + "heat": 7, + "categories": ["university"] + }, + { + "key": "csu", + "name": "中南大学", + "host": "career.csu.edu.cn", + "heat": 7, + "categories": ["university"] + }, + { + "key": "cartoonmad", + "name": "動漫狂", + "host": "cartoonmad.com", + "heat": 7, + "categories": ["anime"] + }, + { + "key": "bjwxdxh", + "name": "北京无线电协会", + "host": "bjwxdxh.org.cn", + "heat": 7, + "categories": ["government"] + }, + { + "key": "consumer", + "name": "消费者委员会", + "host": "consumer.org.hk", + "heat": 7, + "categories": ["new-media"] + }, + { + "key": "6park", + "name": "留园网", + "host": "club.6parkbbs.com", + "heat": 7, + "categories": ["other"] + }, + { + "key": "nwnu", + "name": "西北师范大学", + "host": "nwnu.edu.cn", + "heat": 7, + "categories": ["university"] + }, + { + "key": "abmedia", + "name": "链新闻 ABMedia", + "host": "abmedia.io", + "heat": 7, + "categories": ["new-media"] + }, + { + "key": "alipan", + "name": "阿里云盘", + "host": "alipan.com", + "heat": 7, + "categories": ["multimedia"] + }, + { + "key": "cockroachlabs", + "name": "Cockroach Labs", + "host": "cockroachlabs.com", + "heat": 6, + "categories": ["programming"] + }, + { + "key": "deltaio", + "name": "Delta Lake", + "host": "delta.io", + "heat": 6, + "categories": ["blog"] + }, + { + "key": "dnaindia", + "name": "DNA India", + "host": "dnaindia.com", + "heat": 6, + "categories": ["traditional-media"] + }, + { + "key": "e-hentai", + "name": "E-Hentai", + "host": "e-hentai.org", + "heat": 6, + "categories": ["other"] + }, + { + "key": "instructables", + "name": "Instructables", + "host": "instructables.com", + "heat": 6, + "categories": ["other"] + }, + { + "key": "mindmeister", + "name": "MindMeister", + "host": "mindmeister.com", + "heat": 6, + "categories": ["study"] + }, + { + "key": "newzmz", + "name": "NEW 字幕组", + "host": "newzmz.com", + "heat": 6, + "categories": ["multimedia"] + }, + { + "key": "ntrblog", + "name": "NTR BLOG(寝取られブログ)", + "host": "ntrblog.com", + "heat": 6, + "categories": ["anime"] + }, + { + "key": "patagonia", + "name": "Patagonia", + "host": "patagonia.com", + "heat": 6, + "categories": ["shopping"] + }, + { + "key": "hinatazaka46", + "name": "Sakamichi Series 坂道系列官网资讯", + "host": "hinatazaka46.com", + "heat": 6, + "categories": ["new-media"] + }, + { + "key": "thehindu", + "name": "The Hindu", + "host": "thehindu.com", + "heat": 6, + "categories": ["traditional-media"] + }, + { + "key": "tju", + "name": "Tianjin University 天津大学", + "host": "cic.tju.edu.cn", + "heat": 6, + "categories": ["university"] + }, + { + "key": "neu", + "name": "东北大学", + "host": "neunews.neu.edu.cn", + "heat": 6, + "categories": ["university"] + }, + { + "key": "cgtn", + "name": "中国环球电视网", + "host": "cgtn.com", + "heat": 6, + "categories": ["traditional-media"] + }, + { + "key": "5music", + "name": "五大唱片", + "host": "5music.com.tw", + "heat": 6, + "categories": ["shopping"] + }, + { + "key": "catti", + "name": "全国翻译专业资格水平考试 (CATTI)", + "host": "catticenter.com", + "heat": 6, + "categories": ["study"] + }, + { + "key": "buaa", + "name": "北京航空航天大学", + "host": "news.buaa.edu.cn", + "heat": 6, + "categories": ["university"] + }, + { + "key": "njust", + "name": "南京理工大学", + "host": "jwc.njust.edu.cn", + "heat": 6, + "categories": ["university"] + }, + { + "key": "coolpc", + "name": "原價屋", + "host": "coolpc.com.tw", + "heat": 6, + "categories": ["shopping"] + }, + { + "key": "dahecube", + "name": "大河财立方", + "host": "dahecube.com", + "heat": 6, + "categories": ["new-media"] + }, + { + "key": "dykszx", + "name": "德阳人事考试网", + "host": "dykszx.com", + "heat": 6, + "categories": ["government"] + }, + { + "key": "kuaishou", + "name": "快手", + "host": "kuaishou.com", + "heat": 6, + "categories": ["social-media"] + }, + { + "key": "xwenming", + "name": "未知文明", + "host": "xwenming.com", + "heat": 6, + "categories": ["new-media"] + }, + { + "key": "alpinelinux", + "name": "Alpine Linux", + "host": "alpinelinux.org", + "heat": 5, + "categories": ["program-update"] + }, + { + "key": "app-center", + "name": "App Center", + "host": "install.appcenter.ms", + "heat": 5, + "categories": ["program-update"] + }, + { + "key": "cmpxchg8b", + "name": "cmpxchg8b", + "host": "lock.cmpxchg8b.com", + "heat": 5, + "categories": ["blog"] + }, + { + "key": "esquirehk", + "name": "Esquire Hong Kong", + "host": "esquirehk.com", + "heat": 5, + "categories": ["new-media"] + }, + { + "key": "forklog", + "name": "Forklog", + "host": "forklog.com", + "heat": 5, + "categories": ["finance"] + }, + { + "key": "mixcloud", + "name": "Mixcloud", + "host": "mixcloud.com", + "heat": 5, + "categories": ["multimedia"] + }, + { + "key": "nltimes", + "name": "NL Times", + "host": "nltimes.nl", + "heat": 5, + "categories": ["new-media"] + }, + { + "key": "samsung", + "name": "Samsung", + "host": "research.samsung.com", + "heat": 5, + "categories": ["new-media"] + }, + { + "key": "sega", + "name": "SEGA", + "host": "pjsekai.sega.jp", + "heat": 5, + "categories": ["game"] + }, + { + "key": "segmentfault", + "name": "SegmentFault", + "host": "segmentfault.com", + "heat": 5, + "categories": ["programming"] + }, + { + "key": "shoppingdesign", + "name": "Shopping Design", + "host": "shoppingdesign.com.tw", + "heat": 5, + "categories": ["design"] + }, + { + "key": "tidb", + "name": "TiDB 社区", + "host": "tidb.net", + "heat": 5, + "categories": ["programming"] + }, + { + "key": "tiddlywiki", + "name": "TiddlyWiki", + "host": "tiddlywiki.com", + "heat": 5, + "categories": ["program-update"] + }, + { + "key": "warthunder", + "name": "War Thunder", + "host": "warthunder.com", + "heat": 5, + "categories": ["game"] + }, + { + "key": "shmeea", + "name": "上海市教育考试院", + "host": "shmeea.edu.cn", + "heat": 5, + "categories": ["study"] + }, + { + "key": "casssp", + "name": "中国科学学与科技政策研究会", + "host": "casssp.org.cn", + "heat": 5, + "categories": ["government"] + }, + { + "key": "kepu", + "name": "中国科普博览", + "host": "live.kepu.net.cn", + "heat": 5, + "categories": ["new-media"] + }, + { + "key": "tingshuitz", + "name": "停水通知", + "host": "swj.dl.gov.cn", + "heat": 5, + "categories": ["forecast"] + }, + { + "key": "ncepu", + "name": "华北电力大学", + "host": "yjsy.ncepu.edu.cn", + "heat": 5, + "categories": ["university"] + }, + { + "key": "sctv", + "name": "四川广播电视台", + "host": "sctv.com", + "heat": 5, + "categories": ["traditional-media"] + }, + { + "key": "byteclicks", + "name": "字节点击", + "host": "byteclicks.com", + "heat": 5, + "categories": ["new-media"] + }, + { + "key": "gdufs", + "name": "广东外语外贸大学", + "host": "gdufs.edu.cn", + "heat": 5, + "categories": ["university"] + }, + { + "key": "aijishu", + "name": "极术社区", + "host": "aijishu", + "heat": 5, + "categories": ["programming"] + }, + { + "key": "2023game", + "name": "游戏星辰", + "host": "2023game.com", + "heat": 5, + "categories": ["game"] + }, + { + "key": "dingshao", + "name": "盯梢", + "host": "dingshao.cn", + "heat": 5, + "categories": ["other"] + }, + { + "key": "zongheng", + "name": "纵横中文网", + "host": "zongheng.com", + "heat": 5, + "categories": ["reading"] + }, + { + "key": "landiannews", + "name": "蓝点网", + "host": "landiannews.com", + "heat": 5, + "categories": ["new-media"] + }, + { + "key": "anytxt", + "name": "Anytxt Searcher", + "host": "anytxt.net", + "heat": 4, + "categories": ["program-update"] + }, + { + "key": "ft", + "name": "Financial Times", + "host": "ft.com", + "heat": 4, + "categories": ["traditional-media"] + }, + { + "key": "thegadgetflow", + "name": "Gadget Flow", + "host": "thegadgetflow.com", + "heat": 4, + "categories": ["shopping"] + }, + { + "key": "mashiro", + "name": "Mashiro's Baumkuchen", + "host": "mashiro.best", + "heat": 4, + "categories": ["blog"] + }, + { + "key": "chnmuseum", + "name": "National Museum Of China", + "host": "chnmuseum.cn", + "heat": 4, + "categories": ["travel"] + }, + { + "key": "nielsberglund", + "name": "Niels Berglund Blog", + "host": "nielsberglund.com", + "heat": 4, + "categories": ["blog"] + }, + { + "key": "pubmed", + "name": "PubMed", + "host": "pubmed.ncbi.nlm.nih.gov", + "heat": 4, + "categories": ["other"] + }, + { + "key": "sensortower", + "name": "Sensor Tower", + "host": "sensortower.com", + "heat": 4, + "categories": ["new-media"] + }, + { + "key": "indianexpress", + "name": "The Indian Express", + "host": "indianexpress.com", + "heat": 4, + "categories": ["new-media"] + }, + { + "key": "trendforce", + "name": "TrendForce", + "host": "trendforce.com", + "heat": 4, + "categories": ["new-media"] + }, + { + "key": "typst", + "name": "Typst", + "host": "typst.com", + "heat": 4, + "categories": ["program-update"] + }, + { + "key": "urbandictionary", + "name": "Urban Dictionary", + "host": "urbandictionary.com", + "heat": 4, + "categories": ["other"] + }, + { + "key": "wise", + "name": "Wise", + "host": "wise.com", + "heat": 4, + "categories": ["other"] + }, + { + "key": "zyw", + "name": "zyw", + "host": "hot.zyw.asia", + "heat": 4, + "categories": ["new-media"] + }, + { + "key": "81", + "name": "中国军网", + "host": "81.cn", + "heat": 4, + "categories": ["government"] + }, + { + "key": "ucas", + "name": "中国科学院大学", + "host": "ai.ucas.ac.cn", + "heat": 4, + "categories": ["university"] + }, + { + "key": "chinaisa", + "name": "中国钢铁工业协会", + "host": "chinaisa.org.cn", + "heat": 4, + "categories": ["new-media"] + }, + { + "key": "cbirc", + "name": "中国银行保险监督管理委员会", + "host": "cbirc.gov.cn", + "heat": 4, + "categories": ["other"] + }, + { + "key": "huijin-inv", + "name": "中央汇金投资有限责任公司", + "host": "huijin-inv.cn", + "heat": 4, + "categories": ["finance"] + }, + { + "key": "zaozao", + "name": "前端早早聊", + "host": "zaozao.run", + "heat": 4, + "categories": ["programming"] + }, + { + "key": "bnu", + "name": "北京师范大学", + "host": "bs.bnu.edu.cn", + "heat": 4, + "categories": ["university"] + }, + { + "key": "ustb", + "name": "北京科技大学", + "host": "gs.ustb.edu.cn", + "heat": 4, + "categories": ["university"] + }, + { + "key": "mrm", + "name": "华储网", + "host": "mrm.com.cn", + "heat": 4, + "categories": ["finance"] + }, + { + "key": "ncwu", + "name": "华北水利水电大学", + "host": "ncwu.edu.cn", + "heat": 4, + "categories": ["university"] + }, + { + "key": "ncu", + "name": "南昌大学", + "host": "jwc.ncu.edu.cn", + "heat": 4, + "categories": ["university"] + }, + { + "key": "xmnn", + "name": "厦门网", + "host": "epaper.xmnn.cn", + "heat": 4, + "categories": ["traditional-media"] + }, + { + "key": "scpta", + "name": "四川省人力资源和社会保障厅人事考试专栏", + "host": "scpta.com.cn", + "heat": 4, + "categories": ["government"] + }, + { + "key": "bakamh", + "name": "巴卡漫画", + "host": "bakamh.com", + "heat": 4, + "categories": ["anime"] + }, + { + "key": "gxmzu", + "name": "广西民族大学", + "host": "ai.gxmzu.edu.cn", + "heat": 4, + "categories": ["university"] + }, + { + "key": "bc3ts", + "name": "爆料公社", + "host": "web.bc3ts.net", + "heat": 4, + "categories": ["new-media"] + }, + { + "key": "fjksbm", + "name": "福建考试报名网", + "host": "fjksbm.com", + "heat": 4, + "categories": ["study"] + }, + { + "key": "swpu", + "name": "西南石油大学", + "host": "swpu.edu.cn", + "heat": 4, + "categories": ["university"] + }, + { + "key": "aamacau", + "name": "論盡媒體 AllAboutMacau Media", + "host": "aamacau.com", + "heat": 4, + "categories": ["new-media"] + }, + { + "key": "chiculture", + "name": "通識・現代中國", + "host": "chiculture.org.hk", + "heat": 4, + "categories": ["new-media"] + }, + { + "key": "acs", + "name": "ACS Publications", + "host": "pubs.acs.org", + "heat": 3, + "categories": ["other"] + }, + { + "key": "apnic", + "name": "APNIC", + "host": "blog.apnic.net", + "heat": 3, + "categories": ["blog"] + }, + { + "key": "devolverdigital", + "name": "DevolverDigital", + "host": "devolverdigital.com", + "heat": 3, + "categories": ["game"] + }, + { + "key": "ekantipur", + "name": "Ekantipur / कान्तिपुर (Nepal)", + "host": "ekantipur.com", + "heat": 3, + "categories": ["traditional-media"] + }, + { + "key": "fortnite", + "name": "Fortnite", + "host": "fortnite.com", + "heat": 3, + "categories": ["game"] + }, + { + "key": "galxe", + "name": "Galxe", + "host": "app.galxe.com", + "heat": 3, + "categories": ["other"] + }, + { + "key": "gitpod", + "name": "Gitpod", + "host": "gitpod.io", + "heat": 3, + "categories": ["programming"] + }, + { + "key": "iknowwhatyoudownload", + "name": "I Know What You Download", + "host": "iknowwhatyoudownload.com", + "heat": 3, + "categories": ["other"] + }, + { + "key": "isct", + "name": "Institute of Science Tokyo", + "host": "isct.ac.jp", + "heat": 3, + "categories": ["university"] + }, + { + "key": "ipsw", + "name": "IPSW.me", + "host": "ipsw.me", + "heat": 3, + "categories": ["program-update"] + }, + { + "key": "joneslanglasalle", + "name": "Jones Lang LaSalle", + "host": "joneslanglasalle.com.cn", + "heat": 3, + "categories": ["new-media"] + }, + { + "key": "kcna", + "name": "Korean Central News Agency (KCNA) 朝鲜中央通讯社", + "host": "kcna.kp", + "heat": 3, + "categories": ["traditional-media"] + }, + { + "key": "firefox", + "name": "Mozilla", + "host": "monitor.firefox.com", + "heat": 3, + "categories": ["program-update", "other"] + }, + { + "key": "ncku", + "name": "National Cheng Kung University", + "host": "ncku.edu.tw", + "heat": 3, + "categories": ["university"] + }, + { + "key": "nautiljon", + "name": "Nautiljon", + "host": "nautiljon.com", + "heat": 3, + "categories": ["reading"] + }, + { + "key": "peopo", + "name": "PeoPo 公民新聞", + "host": "peopo.org", + "heat": 3, + "categories": ["new-media"] + }, + { + "key": "rawkuma", + "name": "Rawkuma", + "host": "rawkuma.com", + "heat": 3, + "categories": ["anime"] + }, + { + "key": "reactiflux", + "name": "Reactiflux", + "host": "reactiflux.com", + "heat": 3, + "categories": ["programming"] + }, + { + "key": "tfc-taiwan", + "name": "Taiwan FactCheck Center", + "host": "tfc-taiwan.org.tw", + "heat": 3, + "categories": ["other"] + }, + { + "key": "typeless", + "name": "Typeless", + "host": "typeless.com", + "heat": 3, + "categories": ["program-update"] + }, + { + "key": "parliament.uk", + "name": "UK Parliament", + "host": "parliament.uk", + "heat": 3, + "categories": ["government"] + }, + { + "key": "webcatalog", + "name": "WebCatalog", + "host": "desktop.webcatalog.io", + "heat": 3, + "categories": ["program-update"] + }, + { + "key": "eshukan", + "name": "万维书刊网", + "host": "eshukan.com", + "heat": 3, + "categories": ["study"] + }, + { + "key": "shoac", + "name": "上海东方艺术中心", + "host": "shoac.com.cn", + "heat": 3, + "categories": ["shopping"] + }, + { + "key": "nudt", + "name": "中国人民解放军国防科技大学", + "host": "nudt.edu.cn", + "heat": 3, + "categories": ["university"] + }, + { + "key": "cuc", + "name": "中国传媒大学", + "host": "yz.cuc.edu.cn", + "heat": 3, + "categories": ["university"] + }, + { + "key": "upc", + "name": "中国石油大学(华东)", + "host": "computer.upc.edu.cn", + "heat": 3, + "categories": ["university"] + }, + { + "key": "camchina", + "name": "中国管理现代化研究会", + "host": "cste.org.cn", + "heat": 3, + "categories": ["study"] + }, + { + "key": "hostmonit", + "name": "全球主机监控", + "host": "stock.hostmonit.com", + "heat": 3, + "categories": ["other"] + }, + { + "key": "bjsk", + "name": "北京社科网", + "host": "bjsk.org.cn", + "heat": 3, + "categories": ["government"] + }, + { + "key": "hrbust", + "name": "哈尔滨理工大学", + "host": "hrbust.edu.cn", + "heat": 3, + "categories": ["university"] + }, + { + "key": "laimanhua", + "name": "来漫画", + "host": "laimanhua8.com", + "heat": 3, + "categories": ["anime"] + }, + { + "key": "getitfree", + "name": "正版中国", + "host": "getitfree.cn", + "heat": 3, + "categories": ["other"] + }, + { + "key": "tsinghua", + "name": "清华大学", + "host": "tsinghua.edu.cn", + "heat": 3, + "categories": ["university"] + }, + { + "key": "hnu", + "name": "湖南大学", + "host": "scc.hnu.edu.cn", + "heat": 3, + "categories": ["university"] + }, + { + "key": "hkushop", + "name": "環球唱片(香港)官方網上商店", + "host": "hkushop.com", + "heat": 3, + "categories": ["shopping"] + }, + { + "key": "sdo", + "name": "盛趣游戏在线", + "host": "sdo.com", + "heat": 3, + "categories": ["bbs"] + }, + { + "key": "oilchem", + "name": "隆众资讯", + "host": "oilchem.net", + "heat": 3, + "categories": ["new-media"] + }, + { + "key": "8kcos", + "name": "8KCosplay", + "host": "8kcosplay.com", + "heat": 2, + "categories": ["picture"] + }, + { + "key": "abskoop", + "name": "A 姐分享", + "host": "nsfw.abskoop.com", + "heat": 2, + "categories": ["other"] + }, + { + "key": "alicesoft", + "name": "ALICESOFT", + "host": "alicesoft.com", + "heat": 2, + "categories": ["game"] + }, + { + "key": "app-sales", + "name": "AppSales", + "host": "app-sales.net", + "heat": 2, + "categories": ["program-update"] + }, + { + "key": "appstare", + "name": "AppStare", + "host": "appstare.net", + "heat": 2, + "categories": ["program-update"] + }, + { + "key": "bmkg", + "name": "BADAN METEOROLOGI, KLIMATOLOGI, DAN GEOFISIKA(Indonesian)", + "host": "bmkg.go.id", + "heat": 2, + "categories": ["forecast"] + }, + { + "key": "bjtu", + "name": "Beijing Jiaotong University", + "host": "bjtu.edu.cn", + "heat": 2, + "categories": ["university"] + }, + { + "key": "bitmovin", + "name": "Bitmovin", + "host": "bitmovin.com", + "heat": 2, + "categories": ["programming"] + }, + { + "key": "cags", + "name": "Chinese Academy of Geological Sciences", + "host": "cags.cgs.gov.cn", + "heat": 2, + "categories": ["university"] + }, + { + "key": "coolidge", + "name": "Coolidge Corner Theatre", + "host": "coolidge.org", + "heat": 2, + "categories": ["blog"] + }, + { + "key": "cupl", + "name": "CUPL", + "host": "jwc.cupl.edu.cn", + "heat": 2, + "categories": ["university"] + }, + { + "key": "digitalpolicyalert", + "name": "Digital Policy Alert", + "host": "digitalpolicyalert.org", + "heat": 2, + "categories": ["other"] + }, + { + "key": "ea", + "name": "EA Games", + "host": "ea.com", + "heat": 2, + "categories": ["game"] + }, + { + "key": "ecnu", + "name": "East China Normal University 华东师范大学", + "host": "ecnu.edu.cn", + "heat": 2, + "categories": ["university"] + }, + { + "key": "disinfo", + "name": "EU Disinfo Lab", + "host": "disinfo.eu", + "heat": 2, + "categories": ["new-media"] + }, + { + "key": "focustaiwan", + "name": "Focus Taiwan", + "host": "focustaiwan.tw", + "heat": 2, + "categories": ["new-media"] + }, + { + "key": "furstar", + "name": "Furstar", + "host": "furstar.jp", + "heat": 2, + "categories": ["shopping"] + }, + { + "key": "hiring.cafe", + "name": "HiringCafe", + "host": "hiring.cafe", + "heat": 2, + "categories": ["other"] + }, + { + "key": "hudsonrivertrading", + "name": "Hudson River Trading", + "host": "hudsonrivertrading.com", + "heat": 2, + "categories": ["blog"] + }, + { + "key": "immich", + "name": "Immich", + "host": "immich.app", + "heat": 2, + "categories": ["blog"] + }, + { + "key": "ktown4u", + "name": "Ktown4u", + "host": "ktown4u.com", + "heat": 2, + "categories": ["shopping"] + }, + { + "key": "lancedb", + "name": "LanceDB", + "host": "lancedb.com", + "heat": 2, + "categories": ["programming"] + }, + { + "key": "letterboxd", + "name": "Letterboxd", + "host": "letterboxd.com", + "heat": 2, + "categories": ["social-media"] + }, + { + "key": "liquipedia", + "name": "Liquipedia", + "host": "liquipedia.net", + "heat": 2, + "categories": ["game"] + }, + { + "key": "luma", + "name": "LuMa", + "host": "lu.ma", + "heat": 2, + "categories": ["other"] + }, + { + "key": "mit", + "name": "Massachusetts Institute of Technology", + "host": "mit.edu", + "heat": 2, + "categories": ["blog", "social-media"] + }, + { + "key": "mathpix", + "name": "Mathpix", + "host": "mathpix.com", + "heat": 2, + "categories": ["blog"] + }, + { + "key": "mixi2", + "name": "mixi2", + "host": "mixi.social", + "heat": 2, + "categories": ["social-media"] + }, + { + "key": "musify", + "name": "musify", + "host": "musify.club", + "heat": 2, + "categories": ["multimedia"] + }, + { + "key": "netflix", + "name": "Netflix", + "host": "netflix.com", + "heat": 2, + "categories": ["new-media"] + }, + { + "key": "perplexity", + "name": "Perplexity", + "host": "perplexity.ai", + "heat": 2, + "categories": ["program-update"] + }, + { + "key": "pixelstech", + "name": "PixelsTech", + "host": "pixelstech.net", + "heat": 2, + "categories": ["new-media"] + }, + { + "key": "postman", + "name": "Postman", + "host": "postman.com", + "heat": 2, + "categories": ["program-update"] + }, + { + "key": "priconne-redive", + "name": "PRINCESS CONNECT! Re Dive プリンセスコネクト!Re Dive", + "host": "priconne-redive.jp", + "heat": 2, + "categories": ["game"] + }, + { + "key": "psyche", + "name": "Psyche", + "host": "psyche.co", + "heat": 2, + "categories": ["new-media"] + }, + { + "key": "putty", + "name": "PuTTY", + "host": "chiark.greenend.org.uk", + "heat": 2, + "categories": ["program-update"] + }, + { + "key": "quantamagazine", + "name": "Quanta Magazine", + "host": "quantamagazine.org", + "heat": 2, + "categories": ["new-media"] + }, + { + "key": "remnote", + "name": "RemNote", + "host": "remnote.com", + "heat": 2, + "categories": ["program-update"] + }, + { + "key": "ruc", + "name": "Renmin University of China", + "host": "ruc.edu.cn", + "heat": 2, + "categories": ["university"] + }, + { + "key": "scoop", + "name": "Scoop", + "host": "scoop.sh", + "heat": 2, + "categories": ["program-update"] + }, + { + "key": "secretsanfrancisco", + "name": "Secret San francisco", + "host": "secretsanfrancisco.com", + "heat": 2, + "categories": ["new-media"] + }, + { + "key": "snowpeak", + "name": "Snow Peak", + "host": "snowpeak.com", + "heat": 2, + "categories": ["shopping"] + }, + { + "key": "sara", + "name": "上海业余无线电协会", + "host": "sara.org.cn", + "heat": 2, + "categories": ["government"] + }, + { + "key": "shmtu", + "name": "上海海事大学", + "host": "jwc.shmtu.edu.cn", + "heat": 2, + "categories": ["university"] + }, + { + "key": "cisia", + "name": "中国无机盐工业协会", + "host": "cisia.org", + "heat": 2, + "categories": ["government"] + }, + { + "key": "kamen-rider-official", + "name": "仮面ライダ", + "host": "kamen-rider-official.com", + "heat": 2, + "categories": ["new-media"] + }, + { + "key": "txks", + "name": "全国通信专业技术人员职业水平考试", + "host": "txks.org.cn", + "heat": 2, + "categories": ["study"] + }, + { + "key": "qm120", + "name": "全民健康网", + "host": "qm120.com", + "heat": 2, + "categories": ["new-media"] + }, + { + "key": "ccg", + "name": "全球化智库", + "host": "ccg.org.cn", + "heat": 2, + "categories": ["new-media"] + }, + { + "key": "wchscu", + "name": "华西医院", + "host": "wchscu.cn", + "heat": 2, + "categories": ["other"] + }, + { + "key": "qweather", + "name": "和风天气", + "host": "qweather.com", + "heat": 2, + "categories": ["forecast"] + }, + { + "key": "sicau", + "name": "四川农业大学", + "host": "sicau.edu.cn", + "heat": 2, + "categories": ["university"] + }, + { + "key": "cde", + "name": "国家药品审评网站", + "host": "cde.org.cn", + "heat": 2, + "categories": ["government"] + }, + { + "key": "005", + "name": "幻之羁绊动漫网", + "host": "005.tv", + "heat": 2, + "categories": ["anime"] + }, + { + "key": "dangdang", + "name": "当当开放平台", + "host": "open.dangdang.com", + "heat": 2, + "categories": ["programming"] + }, + { + "key": "56kog", + "name": "明月中文网", + "host": "56kog.com", + "heat": 2, + "categories": ["reading"] + }, + { + "key": "dushu", + "name": "樊登读书", + "host": "card.dushu.io", + "heat": 2, + "categories": ["new-media"] + }, + { + "key": "samd", + "name": "深圳市医疗器械行业协会", + "host": "samd.org.cn", + "heat": 2, + "categories": ["government"] + }, + { + "key": "yxdown", + "name": "游讯网", + "host": "yxdown.com", + "heat": 2, + "categories": ["game"] + }, + { + "key": "iqiyi", + "name": "爱奇艺", + "host": "iq.com", + "heat": 2, + "categories": ["multimedia"] + }, + { + "key": "jumeili", + "name": "聚美丽", + "host": "jumeili.cn", + "heat": 2, + "categories": ["new-media"] + }, + { + "key": "ke", + "name": "贝壳研究院", + "host": "research.ke.com", + "heat": 2, + "categories": ["other"] + }, + { + "key": "guduodata", + "name": "骨朵数据", + "host": "data.guduodata.com", + "heat": 2, + "categories": ["other"] + }, + { + "key": "amazfitwatchfaces", + "name": "Amazfitwatchfaces", + "host": "amazfitwatchfaces.com", + "heat": 1, + "categories": ["program-update"] + }, + { + "key": "atptour", + "name": "ATP Tour", + "host": "atptour.com", + "heat": 1, + "categories": ["other"] + }, + { + "key": "backlinko", + "name": "Backlinko", + "host": "backlinko.com", + "heat": 1, + "categories": ["blog"] + }, + { + "key": "bitbucket", + "name": "Bitbucket", + "host": "bitbucket.com", + "heat": 1, + "categories": ["programming"] + }, + { + "key": "bookwalker", + "name": "BOOKWALKER電子書", + "host": "bookwalker.com.tw", + "heat": 1, + "categories": ["shopping"] + }, + { + "key": "bugzilla", + "name": "Bugzilla", + "host": "bugzilla.org", + "heat": 1, + "categories": ["programming"] + }, + { + "key": "chocolatey", + "name": "Chocolatey", + "host": "chocolatey.org", + "heat": 1, + "categories": ["program-update"] + }, + { + "key": "claude", + "name": "Claude", + "host": "claude.com", + "heat": 1, + "categories": ["programming", "program-update"] + }, + { + "key": "comicat", + "name": "Comicat", + "host": "comicat.org", + "heat": 1, + "categories": ["anime"] + }, + { + "key": "cpuid", + "name": "CPUID", + "host": "cpuid.com", + "heat": 1, + "categories": ["program-update"] + }, + { + "key": "crush", + "name": "CrushNinja", + "host": "crush.ninja", + "heat": 1, + "categories": ["social-media"] + }, + { + "key": "dorohedoro", + "name": "Dorohedoro", + "host": "dorohedoro.net", + "heat": 1, + "categories": ["game"] + }, + { + "key": "engineering", + "name": "Engineering.fyi", + "host": "engineering.fyi", + "heat": 1, + "categories": ["programming"] + }, + { + "key": "enterprisecraftsmanship", + "name": "Enterprise Craftsmanship", + "host": "enterprisecraftsmanship.com", + "heat": 1, + "categories": ["blog"] + }, + { + "key": "farcaster", + "name": "Farcaster", + "host": "farcaster.xyz", + "heat": 1, + "categories": ["social-media"] + }, + { + "key": "gihyo", + "name": "gihyo.jp", + "host": "gihyo.jp", + "heat": 1, + "categories": ["programming"] + }, + { + "key": "gitkraken", + "name": "GitKraken", + "host": "gitkraken.com", + "heat": 1, + "categories": ["program-update"] + }, + { + "key": "gogoanimehd", + "name": "Gogoanimehd", + "host": "developer.anitaku.to", + "heat": 1, + "categories": ["anime"] + }, + { + "key": "hongkong", + "name": "Hong Kong Department of Health 香港卫生署", + "host": "dh.gov.hk", + "heat": 1, + "categories": ["government"] + }, + { + "key": "hotukdeals", + "name": "hotukdeals", + "host": "hotukdeals.com", + "heat": 1, + "categories": ["shopping"] + }, + { + "key": "imagemagick", + "name": "ImageMagick", + "host": "imagemagick.org", + "heat": 1, + "categories": ["program-update"] + }, + { + "key": "imop", + "name": "imop", + "host": "imop.com", + "heat": 1, + "categories": ["game"] + }, + { + "key": "go", + "name": "JapanGov", + "host": "go.jp", + "heat": 1, + "categories": ["government"] + }, + { + "key": "kisskiss", + "name": "KISS", + "host": "kisskiss.tv", + "heat": 1, + "categories": ["game"] + }, + { + "key": "macupdate", + "name": "MacUpdate", + "host": "macupdate.com", + "heat": 1, + "categories": ["program-update"] + }, + { + "key": "mangadex", + "name": "MangaDex", + "host": "mangadex.org", + "heat": 1, + "categories": ["anime"] + }, + { + "key": "mercari", + "name": "Mercari", + "host": "jp.mercari.com", + "heat": 1, + "categories": ["shopping"] + }, + { + "key": "meteoblue", + "name": "meteoblue", + "host": "meteoblue.com", + "heat": 1, + "categories": ["blog"] + }, + { + "key": "mycard520", + "name": "MyCard娛樂中心", + "host": "mycard520.com.tw", + "heat": 1, + "categories": ["game"] + }, + { + "key": "nua", + "name": "Nanjing University of the Arts 南京艺术学院", + "host": "index.nua.edu.cn", + "heat": 1, + "categories": ["university"] + }, + { + "key": "ngocn2", + "name": "NGOCN", + "host": "ngocn2.org", + "heat": 1, + "categories": ["new-media"] + }, + { + "key": "nymity", + "name": "nymity", + "host": "censorbib.nymity.ch", + "heat": 1, + "categories": ["journal"] + }, + { + "key": "onet", + "name": "Onet", + "host": "wiadomosci.onet.pl", + "heat": 1, + "categories": ["new-media"] + }, + { + "key": "osu", + "name": "osu!", + "host": "osu.ppy.sh", + "heat": 1, + "categories": ["game"] + }, + { + "key": "questn", + "name": "QuestN", + "host": "app.questn.com", + "heat": 1, + "categories": ["other"] + }, + { + "key": "rattibha", + "name": "Rattibha", + "host": "rattibha.com", + "heat": 1, + "categories": ["social-media"] + }, + { + "key": "resetera", + "name": "ResetEra", + "host": "resetera.com", + "heat": 1, + "categories": ["bbs"] + }, + { + "key": "rockthejvm", + "name": "Rock the JVM", + "host": "rockthejvm.com", + "heat": 1, + "categories": ["programming"] + }, + { + "key": "smartlink", + "name": "SmartLink", + "host": "smartlink.bio", + "heat": 1, + "categories": ["social-media"] + }, + { + "key": "sycl", + "name": "SYCL", + "host": "sycl.tech", + "heat": 1, + "categories": ["programming"] + }, + { + "key": "tctmd", + "name": "TCTMD", + "host": "tctmd.com", + "heat": 1, + "categories": ["journal"] + }, + { + "key": "techpowerup", + "name": "TechPowerUp", + "host": "techpowerup.com", + "heat": 1, + "categories": ["new-media"] + }, + { + "key": "visionias", + "name": "VisionIAS", + "host": "visionias.in", + "heat": 1, + "categories": ["study"] + }, + { + "key": "diskanalyzer", + "name": "WizTree", + "host": "diskanalyzer.com", + "heat": 1, + "categories": ["program-update"] + }, + { + "key": "worldofwarships", + "name": "World of Warships", + "host": "worldofwarships.com", + "heat": 1, + "categories": ["game"] + }, + { + "key": "collabo-cafe", + "name": "コラボカフェ", + "host": "collabo-cafe.com", + "heat": 1, + "categories": ["anime"] + }, + { + "key": "toranoana", + "name": "とらのあな", + "host": "toranoana.jp", + "heat": 1, + "categories": ["anime"] + }, + { + "key": "shisu", + "name": "上海外国语大学", + "host": "shisu.edu.cn", + "heat": 1, + "categories": ["university"] + }, + { + "key": "shiep", + "name": "上海电力大学", + "host": "bwc.shiep.edu.cn", + "heat": 1, + "categories": ["university"] + }, + { + "key": "dgut", + "name": "东莞理工学院", + "host": "dgut.edu.cn", + "heat": 1, + "categories": ["university"] + }, + { + "key": "zuel", + "name": "中南财经政法大学", + "host": "wap.zuel.edu.cn", + "heat": 1, + "categories": ["university"] + }, + { + "key": "cccmc", + "name": "中国五矿化工进出口商会", + "host": "cccmc.org.cn", + "heat": 1, + "categories": ["new-media"] + }, + { + "key": "icbc", + "name": "中国工商银行", + "host": "icbc.com.cn", + "heat": 1, + "categories": ["other"] + }, + { + "key": "cas", + "name": "中国科学院", + "host": "cas.cn", + "heat": 1, + "categories": ["university"] + }, + { + "key": "cahkms", + "name": "全国港澳研究会", + "host": "cahkms.org", + "heat": 1, + "categories": ["new-media"] + }, + { + "key": "m-78", + "name": "円谷ステーション", + "host": "m-78.jp", + "heat": 1, + "categories": ["anime"] + }, + { + "key": "ciweimao", + "name": "刺猬猫", + "host": "wap.ciweimao.com", + "heat": 1, + "categories": ["reading"] + }, + { + "key": "sustech", + "name": "南方科技大学", + "host": "biddingoffice.sustech.edu.cn", + "heat": 1, + "categories": ["university"] + }, + { + "key": "taiwanmobile", + "name": "台灣大哥大", + "host": "taiwanmobile.com", + "heat": 1, + "categories": ["other"] + }, + { + "key": "hitwh", + "name": "哈尔滨工业大学(威海)", + "host": "hitwh.edu.cn", + "heat": 1, + "categories": ["university"] + }, + { + "key": "hitsz", + "name": "哈尔滨工业大学(深圳)", + "host": "hitsz.edu.cn", + "heat": 1, + "categories": ["university"] + }, + { + "key": "dut", + "name": "大连理工大学", + "host": "dutdice.dlut.edu.cn", + "heat": 1, + "categories": ["other"] + }, + { + "key": "hakkatv", + "name": "客家電視台", + "host": "hakkatv.org.tw", + "heat": 1, + "categories": ["traditional-media"] + }, + { + "key": "gf-cn", + "name": "少女前线", + "host": "sunborngame.com", + "heat": 1, + "categories": ["game"] + }, + { + "key": "chuanliu", + "name": "川流", + "host": "chuanliu.org", + "heat": 1, + "categories": ["blog"] + }, + { + "key": "soundofhope", + "name": "希望之声", + "host": "soundofhope.org", + "heat": 1, + "categories": ["traditional-media"] + }, + { + "key": "gzdaily", + "name": "广州日报", + "host": "gzdaily.cn", + "heat": 1, + "categories": ["traditional-media"] + }, + { + "key": "cdzjryb", + "name": "成都住建蓉 e 办", + "host": "zw.cdzjryb.com", + "heat": 1, + "categories": ["other"] + }, + { + "key": "liulinblog", + "name": "木木博客", + "host": "liulinblog.com", + "heat": 1, + "categories": ["other"] + }, + { + "key": "hbooker", + "name": "欢乐书客", + "host": "hbooker.com", + "heat": 1, + "categories": ["reading"] + }, + { + "key": "szu", + "name": "深圳大学", + "host": "yz.szu.edu.cn", + "heat": 1, + "categories": ["university"] + }, + { + "key": "codefather", + "name": "编程导航", + "host": "codefather.cn", + "heat": 1, + "categories": ["programming"] + }, + { + "key": "lenovo", + "name": "联想", + "host": "lenovo.com.cn", + "heat": 1, + "categories": ["program-update"] + }, + { + "key": "xunhupay", + "name": "虎皮椒", + "host": "xunhupay.com", + "heat": 1, + "categories": ["blog"] + }, + { + "key": "xaufe", + "name": "西安财经大学", + "host": "jiaowu.xaufe.edu.cn", + "heat": 1, + "categories": ["university"] + }, + { + "key": "gmu", + "name": "赣南医科大学", + "host": "gmu.cn", + "heat": 1, + "categories": ["university"] + }, + { + "key": "ruancan", + "name": "软餐", + "host": "ruancan.com", + "heat": 1, + "categories": ["new-media"] + }, + { + "key": "cqwu", + "name": "重庆文理学院", + "host": "cqwu.net", + "heat": 1, + "categories": ["university"] + }, + { + "key": "cqgas", + "name": "重庆燃气", + "host": "cqgas.cn", + "heat": 1, + "categories": ["forecast"] + }, + { + "key": "fffdm", + "name": "风之动漫", + "host": "manhua.fffdm.com", + "heat": 1, + "categories": ["anime"] + }, + { + "key": "qlu", + "name": "齐鲁工业大学", + "host": "qlu.edu.cn", + "heat": 1, + "categories": ["university"] + }, + { + "key": "iqilu", + "name": "齐鲁网", + "host": "v.iqilu.com", + "heat": 1, + "categories": ["other"] + }, + { + "key": "4chan", + "name": "4chan", + "host": "4chan.org", + "heat": 0, + "categories": ["bbs"] + }, + { + "key": "591", + "name": "591 Rental house", + "host": "rent.591.com.tw", + "heat": 0, + "categories": ["other"] + }, + { + "key": "8world", + "name": "8 视界", + "host": "8world.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "aflcio", + "name": "AFL-CIO", + "host": "aflcio.org", + "heat": 0, + "categories": ["other"] + }, + { + "key": "aiaa", + "name": "AIAA Aerospace Research Central", + "host": "arc.aiaa.org", + "heat": 0, + "categories": ["journal"] + }, + { + "key": "aljazeera", + "name": "Aljazeera", + "host": "aljazeera.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "alternativeto", + "name": "AlternativeTo", + "host": "alternativeto.net", + "heat": 0, + "categories": ["programming"] + }, + { + "key": "altotrain", + "name": "Alto - Toronto-Québec City High-Speed Rail Network", + "host": "altotrain.ca", + "heat": 0, + "categories": ["travel"] + }, + { + "key": "alwayscontrol", + "name": "Always Control", + "host": "alwayscontrol.com.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "aip", + "name": "American Institute of Physics", + "host": "pubs.aip.org", + "heat": 0, + "categories": ["journal"] + }, + { + "key": "aschmelyun", + "name": "Andrew Schmelyun", + "host": "aschmelyun.com", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "anime1", + "name": "Anime1", + "host": "anime1.me", + "heat": 0, + "categories": ["anime"] + }, + { + "key": "annualreviews", + "name": "Annual Reviews", + "host": "annualreviews.org", + "heat": 0, + "categories": ["journal"] + }, + { + "key": "apkpure", + "name": "APKPure", + "host": "apkpure.com", + "heat": 0, + "categories": ["program-update"] + }, + { + "key": "appstorrent", + "name": "AppsTorrent", + "host": "appstorrent.ru", + "heat": 0, + "categories": ["program-update"] + }, + { + "key": "aqara", + "name": "Aqara", + "host": "aqara.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "arcteryx", + "name": "Arcteryx", + "host": "arcteryx.com", + "heat": 0, + "categories": ["shopping"] + }, + { + "key": "artstation", + "name": "ArtStation", + "host": "artstation.com", + "heat": 0, + "categories": ["picture"] + }, + { + "key": "aiea", + "name": "Asian Innovation and Entrepreneurship Association", + "host": "aiea.org", + "heat": 0, + "categories": ["study"] + }, + { + "key": "azul", + "name": "Azul", + "host": "azul.com", + "heat": 0, + "categories": ["programming", "program-update"] + }, + { + "key": "azurlane", + "name": "Azur Lane", + "host": "azurlane.jp", + "heat": 0, + "categories": ["game"] + }, + { + "key": "bad", + "name": "Bad.news", + "host": "bad.news", + "heat": 0, + "categories": ["other"] + }, + { + "key": "bandisoft", + "name": "Bandisoft", + "host": "bandisoft.com", + "heat": 0, + "categories": ["program-update"] + }, + { + "key": "bbcnewslabs", + "name": "BBC News Labs", + "host": "bbcnewslabs.co.uk", + "heat": 0, + "categories": ["programming"] + }, + { + "key": "bellroy", + "name": "Bellroy", + "host": "bellroy.com", + "heat": 0, + "categories": ["shopping"] + }, + { + "key": "bvisness", + "name": "Ben Visness", + "host": "bvisness.me", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "biodiscover", + "name": "biodiscover.com 生物探索", + "host": "biodiscover.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "blockworks", + "name": "Blockworks", + "host": "blockworks.co", + "heat": 0, + "categories": ["finance"] + }, + { + "key": "bluestacks", + "name": "BlueStacks", + "host": "bluestacks.com", + "heat": 0, + "categories": ["program-update"] + }, + { + "key": "bntnews", + "name": "bntnews", + "host": "bntnews.co.kr", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "brooklynmuseum", + "name": "Brooklyn Museum", + "host": "brooklynmuseum.org", + "heat": 0, + "categories": ["travel"] + }, + { + "key": "bwsg", + "name": "BWSG", + "host": "bwsg.at", + "heat": 0, + "categories": ["other"] + }, + { + "key": "canada.ca", + "name": "Canada.ca", + "host": "canada.ca", + "heat": 0, + "categories": ["government"] + }, + { + "key": "capitalmind", + "name": "Capitalmind", + "host": "capitalmind.in", + "heat": 0, + "categories": ["finance"] + }, + { + "key": "carousell", + "name": "Carousell", + "host": "carousell.com", + "heat": 0, + "categories": ["shopping"] + }, + { + "key": "creative-comic", + "name": "CCC 創作集", + "host": "creative-comic.tw", + "heat": 0, + "categories": ["anime"] + }, + { + "key": "cherrytimes", + "name": "Cherry Times", + "host": "cherrytimes.it", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "cjlu", + "name": "China Jiliang University", + "host": "cjlu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "cloudflarestatus", + "name": "Cloudflare Status", + "host": "cloudflarestatus.com", + "heat": 0, + "categories": ["programming"] + }, + { + "key": "cointelegraph", + "name": "Cointelegraph", + "host": "cointelegraph.com", + "heat": 0, + "categories": ["finance"] + }, + { + "key": "comic-fuz", + "name": "COMIC FUZ", + "host": "comic-fuz.com", + "heat": 0, + "categories": ["anime"] + }, + { + "key": "comicskingdom", + "name": "Comics Kingdom", + "host": "comicskingdom.com", + "heat": 0, + "categories": ["anime"] + }, + { + "key": "verfghbw", + "name": "Constitutional Court of Baden-Württemberg (Germany)", + "host": "verfgh.baden-wuerttemberg.de", + "heat": 0, + "categories": ["government"] + }, + { + "key": "costar", + "name": "CoStar", + "host": "costar.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "cryptoslate", + "name": "CryptoSlate", + "host": "cryptoslate.com", + "heat": 0, + "categories": ["finance"] + }, + { + "key": "curiouscat", + "name": "CuriousCat", + "host": "curiouscat.live", + "heat": 0, + "categories": ["other"] + }, + { + "key": "curius", + "name": "Curius", + "host": "curius.app", + "heat": 0, + "categories": ["social-media"] + }, + { + "key": "dailypush", + "name": "DailyPush", + "host": "dailypush.dev", + "heat": 0, + "categories": ["programming"] + }, + { + "key": "darwinawards", + "name": "Darwin Awards", + "host": "darwinawards.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "dbaplus", + "name": "dbaplus社群", + "host": "dbaplus.cn", + "heat": 0, + "categories": ["programming"] + }, + { + "key": "dcard", + "name": "Dcard", + "host": "dcard.tw", + "heat": 0, + "categories": ["bbs"] + }, + { + "key": "deadbydaylight", + "name": "DeadbyDaylight", + "host": "deadbydaylight.com", + "heat": 0, + "categories": ["game"] + }, + { + "key": "deadline", + "name": "Deadline", + "host": "deadline.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "decrypt", + "name": "Decrypt", + "host": "decrypt.co", + "heat": 0, + "categories": ["finance"] + }, + { + "key": "devtrium", + "name": "Devtrium", + "host": "devtrium.com", + "heat": 0, + "categories": ["programming"] + }, + { + "key": "diariofruticola", + "name": "Diario Frutícola", + "host": "diariofruticola.cl", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "digg", + "name": "Digg", + "host": "digg.com", + "heat": 0, + "categories": ["social-media"] + }, + { + "key": "discuz", + "name": "Discuz", + "host": "discuz.vip", + "heat": 0, + "categories": ["other"] + }, + { + "key": "distill", + "name": "Distill", + "host": "distill.pub", + "heat": 0, + "categories": ["other"] + }, + { + "key": "dlnews", + "name": "DL NEWS", + "host": "dlnews.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "elamigos", + "name": "ElAmigos", + "host": "elamigos.site", + "heat": 0, + "categories": ["game"] + }, + { + "key": "eventbrite", + "name": "Eventbrite", + "host": "eventbrite.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "expats", + "name": "Expats.cz", + "host": "expats.cz", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "farmatters", + "name": "Farmatters", + "host": "farmatters.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "fishshell", + "name": "fish shell", + "host": "fishshell.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "fisher-spb", + "name": "Fisher Spb", + "host": "fisher.spb.ru", + "heat": 0, + "categories": ["other"] + }, + { + "key": "ftm", + "name": "Follow The Money", + "host": "ftm.eu", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "foodtalks", + "name": "FoodTalks全球食品资讯网", + "host": "foodtalks.cn", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "fosshub", + "name": "FossHub", + "host": "fosshub.com", + "heat": 0, + "categories": ["program-update"] + }, + { + "key": "gameapps", + "name": "GameApps.hk 香港手机游戏网", + "host": "gameapps.hk", + "heat": 0, + "categories": ["other"] + }, + { + "key": "gamersecret", + "name": "Gamer Secret", + "host": "gamersecret.com", + "heat": 0, + "categories": ["game"] + }, + { + "key": "ganjingworld", + "name": "Ganjing World", + "host": "ganjingworld.com", + "heat": 0, + "categories": ["social-media"] + }, + { + "key": "genossenschaften", + "name": "Genossenschaften.immo", + "host": "genossenschaften.immo", + "heat": 0, + "categories": ["other"] + }, + { + "key": "geocaching", + "name": "Geocaching", + "host": "geocaching.com", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "gesiba", + "name": "Gesiba", + "host": "gesiba.at", + "heat": 0, + "categories": ["other"] + }, + { + "key": "gisreportsonline", + "name": "GIS Reports", + "host": "gisreportsonline.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "gitcode", + "name": "GitCode", + "host": "gitcode.com", + "heat": 0, + "categories": ["programming"] + }, + { + "key": "gocn", + "name": "GoCN", + "host": "gocn.vip", + "heat": 0, + "categories": ["programming"] + }, + { + "key": "grubstreet", + "name": "Grub Street", + "host": "grubstreet.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "gumroad", + "name": "Gumroad", + "host": "gumroad.com", + "heat": 0, + "categories": ["shopping"] + }, + { + "key": "hackertalk", + "name": "HACKER TALK 黑客说", + "host": "hackertalk.net", + "heat": 0, + "categories": ["other"] + }, + { + "key": "hacking8", + "name": "Hacking8", + "host": "hacking8.com", + "heat": 0, + "categories": ["programming"] + }, + { + "key": "hackyournews", + "name": "HackYourNews", + "host": "hackyournews.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "hameln", + "name": "hameln", + "host": "syosetu.org", + "heat": 0, + "categories": ["reading"] + }, + { + "key": "hashnode", + "name": "hashnode", + "host": "hashnode.dev", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "hicairo", + "name": "HiFeng'Blog", + "host": "hicairo.com", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "hkepc", + "name": "HKEPC", + "host": "hkepc.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "hkjunkcall", + "name": "HKJunkCall 資訊中心", + "host": "hkjunkcall.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "icac", + "name": "Hong Kong Independent Commission Against Corruption 香港廉政公署", + "host": "icac.org.hk", + "heat": 0, + "categories": ["government"] + }, + { + "key": "hottoys", + "name": "Hot Toys", + "host": "hottoys.com.hk", + "heat": 0, + "categories": ["shopping"] + }, + { + "key": "howtoforge", + "name": "Howtoforge Linux Tutorials", + "host": "howtoforge.com", + "heat": 0, + "categories": ["study"] + }, + { + "key": "ianspriggs", + "name": "Ian Spriggss", + "host": "ianspriggs.com", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "ielts", + "name": "IELTS 雅思", + "host": "ielts.neea.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "ifi-audio", + "name": "iFi audio", + "host": "ifi-audio.com", + "heat": 0, + "categories": ["program-update"] + }, + { + "key": "imhcg", + "name": "imhcg的信息站", + "host": "infos.imhcg.cn", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "inceptionlabs", + "name": "Inception Labs", + "host": "inceptionlabs.ai", + "heat": 0, + "categories": ["programming"] + }, + { + "key": "indiansinkuwait", + "name": "Indians in Kuwait", + "host": "indiansinkuwait.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "informs", + "name": "INFORMS", + "host": "pubsonline.informs.org", + "heat": 0, + "categories": ["journal"] + }, + { + "key": "issuehunt", + "name": "Issue Hunt", + "host": "issuehunt.io", + "heat": 0, + "categories": ["programming"] + }, + { + "key": "jbma", + "name": "Japan Bullion Market Association", + "host": "jbma.net", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "japanpost", + "name": "Japanpost", + "host": "trackings.post.japanpost.jp", + "heat": 0, + "categories": ["other"] + }, + { + "key": "jetbrains", + "name": "JetBrains", + "host": "jetbrains.com", + "heat": 0, + "categories": ["programming"] + }, + { + "key": "jpmorganchase", + "name": "JPMorgan Chase", + "host": "jpmorganchase.com", + "heat": 0, + "categories": ["finance"] + }, + { + "key": "jseea", + "name": "jseea", + "host": "jseea.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "kantarworldpanel", + "name": "Kantar Worldpanel", + "host": "kantarworldpanel.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "keepass", + "name": "KeePass", + "host": "keepass.info", + "heat": 0, + "categories": ["other"] + }, + { + "key": "kelownacapnews", + "name": "Kelowna Capital News", + "host": "kelownacapnews.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "kovidgoyal", + "name": "Kovid's software projects", + "host": "sw.kovidgoyal.net", + "heat": 0, + "categories": ["program-update"] + }, + { + "key": "kuwaitlocal", + "name": "Kuwait Local", + "host": "kuwaitlocal.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "jornada", + "name": "La Jornada", + "host": "jornada.com.mx", + "heat": 0, + "categories": ["traditional-media"] + }, + { + "key": "last-origin", + "name": "LastOrigin", + "host": "last-origin.com", + "heat": 0, + "categories": ["game"] + }, + { + "key": "layoffs", + "name": "Layoffs.fyi", + "host": "layoffs.fyi", + "heat": 0, + "categories": ["other"] + }, + { + "key": "lephoceen", + "name": "Le Phocéen", + "host": "lephoceen.fr", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "lens", + "name": "Lens", + "host": "lens.xyz", + "heat": 0, + "categories": ["social-media"] + }, + { + "key": "likeshop", + "name": "LikeShop", + "host": "likeshop.me", + "heat": 0, + "categories": ["social-media"] + }, + { + "key": "link3", + "name": "Link3", + "host": "link3.to", + "heat": 0, + "categories": ["other"] + }, + { + "key": "literotica", + "name": "Literotica", + "host": "literotica.com", + "heat": 0, + "categories": ["reading"] + }, + { + "key": "logonews", + "name": "LogoNews 标志情报局", + "host": "logonews.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "lmu", + "name": "Ludwig Maximilian University of Munich", + "host": "lmu.de", + "heat": 0, + "categories": ["university", "study"] + }, + { + "key": "ccac", + "name": "Macau Independent Commission Against Corruption 澳门廉政公署", + "host": "ccac.org.mo", + "heat": 0, + "categories": ["government"] + }, + { + "key": "magazinelib", + "name": "MagazineLib", + "host": "magazinelib.com", + "heat": 0, + "categories": ["reading"] + }, + { + "key": "maven", + "name": "Maven", + "host": "repo1.maven.org", + "heat": 0, + "categories": ["programming"] + }, + { + "key": "meritalk", + "name": "MeriTalk", + "host": "meritalk.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "metacritic", + "name": "Metacritic", + "host": "metacritic.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "microsoft", + "name": "Microsoft", + "host": "microsoft.com", + "heat": 0, + "categories": ["program-update"] + }, + { + "key": "miniflux", + "name": "MiniFlux", + "host": "miniflux.app", + "heat": 0, + "categories": ["other"] + }, + { + "key": "mrinalxdev", + "name": "Mrinal Pramanick", + "host": "mrinalxdev.github.io", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "musikguru", + "name": "MusikGuru", + "host": "musikguru.de", + "heat": 0, + "categories": ["multimedia"] + }, + { + "key": "myfigurecollection", + "name": "MyFigureCollection", + "host": "myfigurecollection.net", + "heat": 0, + "categories": ["shopping"] + }, + { + "key": "mymusicsheet", + "name": "mymusic5 (MyMusicSheet)", + "host": "mymusicfive.com", + "heat": 0, + "categories": ["shopping"] + }, + { + "key": "nycu", + "name": "National Yang Ming Chiao Tung University", + "host": "nycu.edu.tw", + "heat": 0, + "categories": ["university"] + }, + { + "key": "naturalism", + "name": "Naturalism.org", + "host": "naturalism.org", + "heat": 0, + "categories": ["other"] + }, + { + "key": "netflav", + "name": "Netflav", + "host": "netflav.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "newmuseum", + "name": "New Museum", + "host": "newmuseum.org", + "heat": 0, + "categories": ["travel"] + }, + { + "key": "nyc", + "name": "New York City Government", + "host": "nyc.gov", + "heat": 0, + "categories": ["government"] + }, + { + "key": "newswav", + "name": "Newswav", + "host": "newswav.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "njuferret", + "name": "njuferret", + "host": "njuferret.github.io", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "ntdm", + "name": "NT动漫", + "host": "ntdm9.com", + "heat": 0, + "categories": ["anime"] + }, + { + "key": "oo-software", + "name": "O&O Software", + "host": "oo-software.com", + "heat": 0, + "categories": ["program-update"] + }, + { + "key": "ornl", + "name": "Oak Ridge National Laboratory", + "host": "ornl.gov", + "heat": 0, + "categories": ["government"] + }, + { + "key": "oct0pu5", + "name": "Oct0pu5 blog", + "host": "oct0pu5.cn", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "openalex", + "name": "OpenAlex", + "host": "openalex.org", + "heat": 0, + "categories": ["journal"] + }, + { + "key": "openwrt", + "name": "OpenWrt", + "host": "openwrt.org", + "heat": 0, + "categories": ["other"] + }, + { + "key": "oesw", + "name": "ÖSW", + "host": "oesw.at", + "heat": 0, + "categories": ["other"] + }, + { + "key": "otobanana", + "name": "OTOBANANA", + "host": "otobanana.com", + "heat": 0, + "categories": ["multimedia"] + }, + { + "key": "outagereport", + "name": "Outage.Report", + "host": "outage.report", + "heat": 0, + "categories": ["forecast"] + }, + { + "key": "oevw", + "name": "ÖVW", + "host": "oevw.at", + "heat": 0, + "categories": ["other"] + }, + { + "key": "usepanda", + "name": "Panda", + "host": "usepanda.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "pikabu", + "name": "Pikabu", + "host": "pikabu.ru", + "heat": 0, + "categories": ["bbs"] + }, + { + "key": "gc.ca", + "name": "Prime Minister of Canada", + "host": "pm.gc.ca", + "heat": 0, + "categories": ["government"] + }, + { + "key": "pnas", + "name": "Proceedings of The National Academy of Sciences", + "host": "pnas.org", + "heat": 0, + "categories": ["other"] + }, + { + "key": "producereport", + "name": "Produce Report", + "host": "producereport.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "pubscholar", + "name": "PubScholar 公益学术平台", + "host": "pubscholar.cn", + "heat": 0, + "categories": ["journal"] + }, + { + "key": "pwc", + "name": "PwC Strategy&", + "host": "strategyand.pwc.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "railway", + "name": "Railway", + "host": "railway.com", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "rarehistoricalphotos", + "name": "Rare Historical Photos", + "host": "rarehistoricalphotos.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "readsomethingwonderful", + "name": "Read Something Wonderful", + "host": "readsomethingwonderful.com", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "readwise", + "name": "Readwise", + "host": "readwise.io", + "heat": 0, + "categories": ["reading"] + }, + { + "key": "researchgate", + "name": "ResearchGate", + "host": "researchgate.net", + "heat": 0, + "categories": ["other"] + }, + { + "key": "resonac", + "name": "Resonac", + "host": "resonac.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "rodong", + "name": "Rodong Sinmun 劳动新闻", + "host": "rodong.rep.kp", + "heat": 0, + "categories": ["traditional-media"] + }, + { + "key": "routledge", + "name": "Routledge", + "host": "routledge.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "sec-in", + "name": "SecIN 信息安全技术社区", + "host": "sec-in.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "semiconductors", + "name": "Semiconductor Industry Association", + "host": "semiconductors.org", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "snnu", + "name": "Shaanxi Normal University", + "host": "snnu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "shopback", + "name": "ShopBack", + "host": "shopback.com.tw", + "heat": 0, + "categories": ["shopping"] + }, + { + "key": "shopify", + "name": "Shopify", + "host": "shopify.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "sketis", + "name": "Sketis | Website of Dr. Makarius Wenzel", + "host": "sketis.net", + "heat": 0, + "categories": ["programming"] + }, + { + "key": "skysports", + "name": "Sky Sports", + "host": "skysports.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "slashdot", + "name": "Slashdot", + "host": "slashdot.org", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "sony", + "name": "Sony", + "host": "sony.com", + "heat": 0, + "categories": ["program-update"] + }, + { + "key": "sourceforge", + "name": "SourceForge", + "host": "sourceforge.net", + "heat": 0, + "categories": ["program-update"] + }, + { + "key": "stratechery", + "name": "Stratechery by Ben Thompson", + "host": "blog.stratechery.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "supchina", + "name": "SupChina", + "host": "supchina.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "supercell", + "name": "Supercell", + "host": "supercell.com", + "heat": 0, + "categories": ["game"] + }, + { + "key": "surfshark", + "name": "Surfshark", + "host": "surfshark.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "sustainabilitymag", + "name": "Sustainability Magazine", + "host": "sustainabilitymag.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "swissinfo", + "name": "swissinfo", + "host": "swissinfo.ch", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "tableau", + "name": "Tableau", + "host": "public.tableau.com", + "heat": 0, + "categories": ["study"] + }, + { + "key": "taiwannews", + "name": "Taiwan News", + "host": "taiwannews.com.tw", + "heat": 0, + "categories": ["traditional-media"] + }, + { + "key": "techsir", + "name": "TechSir", + "host": "techsir.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "telecompaper", + "name": "Telecompaper", + "host": "telecompaper.com", + "heat": 0, + "categories": ["journal"] + }, + { + "key": "dol", + "name": "Thailand Department of Lands", + "host": "announce.dol.go.th", + "heat": 0, + "categories": ["government"] + }, + { + "key": "parliament", + "name": "Thailand Parliament", + "host": "parliament.go.th", + "heat": 0, + "categories": ["government"] + }, + { + "key": "jewishmuseum", + "name": "The Jewish Museum", + "host": "thejewishmuseum.org", + "heat": 0, + "categories": ["travel"] + }, + { + "key": "kimlaw", + "name": "The Korea Institute of Marine Law", + "host": "kimlaw.or.kr", + "heat": 0, + "categories": ["study"] + }, + { + "key": "metmuseum", + "name": "The Metropolitan Museum of Art", + "host": "metmuseum.org", + "heat": 0, + "categories": ["other"] + }, + { + "key": "thenewslens", + "name": "The News Lens 關鍵評論", + "host": "thenewslens.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "trow", + "name": "The Ring of Wonder", + "host": "trow.cc", + "heat": 0, + "categories": ["bbs"] + }, + { + "key": "thewirehindi", + "name": "The Wire Hindi", + "host": "thewirehindi.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "thebrain", + "name": "TheBrain", + "host": "thebrain.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "transcriptforest", + "name": "Transcript Forest", + "host": "transcriptforest.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "transformer-circuits", + "name": "Transformer Circuits", + "host": "transformer-circuits.pub", + "heat": 0, + "categories": ["programming"] + }, + { + "key": "tribalfootball", + "name": "Tribal Football", + "host": "tribalfootball.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "tumblr", + "name": "Tumblr", + "host": "tumblr.com", + "heat": 0, + "categories": ["blog", "social-media"] + }, + { + "key": "tvtropes", + "name": "TV Tropes", + "host": "tvtropes.org", + "heat": 0, + "categories": ["other"] + }, + { + "key": "fda", + "name": "U.S. Food and Drug Administration", + "host": "fda.gov", + "heat": 0, + "categories": ["other"] + }, + { + "key": "bytes", + "name": "ui.dev", + "host": "bytes.dev", + "heat": 0, + "categories": ["other"] + }, + { + "key": "unipd", + "name": "Università di Padova", + "host": "unipd.it", + "heat": 0, + "categories": ["university"] + }, + { + "key": "uw", + "name": "University of Washington", + "host": "gixnetwork.org", + "heat": 0, + "categories": ["university"] + }, + { + "key": "ups", + "name": "UPS", + "host": "ups.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "uptimerobot", + "name": "Uptime Robot", + "host": "rss.uptimerobot.com", + "heat": 0, + "categories": ["forecast"] + }, + { + "key": "v1tx", + "name": "v1tx", + "host": "v1tx.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "v2rayshare", + "name": "V2rayShare", + "host": "v2rayshare.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "vice", + "name": "VICE", + "host": "vice.com", + "heat": 0, + "categories": ["traditional-media"] + }, + { + "key": "wallpaperhub", + "name": "WallpaperHub", + "host": "wallpaperhub.app", + "heat": 0, + "categories": ["other"] + }, + { + "key": "warhammer-community", + "name": "Warhammer Community", + "host": "warhammer-community.com", + "heat": 0, + "categories": ["game"] + }, + { + "key": "wbv-gpa", + "name": "WBV-GPA", + "host": "wbv-gpa.at", + "heat": 0, + "categories": ["other"] + }, + { + "key": "web3caff", + "name": "Web3Caff", + "host": "web3caff.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "wdc", + "name": "Western Digital", + "host": "support.wdc.com", + "heat": 0, + "categories": ["program-update"] + }, + { + "key": "wfdf", + "name": "WFDF", + "host": "wfdf.sport", + "heat": 0, + "categories": ["other"] + }, + { + "key": "wiensued", + "name": "Wien-Süd", + "host": "wiensued.at", + "heat": 0, + "categories": ["other"] + }, + { + "key": "winstall", + "name": "winstall", + "host": "winstall.app", + "heat": 0, + "categories": ["program-update"] + }, + { + "key": "wogem", + "name": "WOGEM", + "host": "wogem.at", + "heat": 0, + "categories": ["other"] + }, + { + "key": "wohnnet", + "name": "wohnnet.at", + "host": "wohnnet.at", + "heat": 0, + "categories": ["other"] + }, + { + "key": "0x80", + "name": "Wojciech Muła", + "host": "0x80.pl", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "wmc-bj", + "name": "World Meteorological Centre Beijing", + "host": "wmc-bj.net", + "heat": 0, + "categories": ["other"] + }, + { + "key": "wizfile", + "name": "WziFile", + "host": "antibody-software.com", + "heat": 0, + "categories": ["program-update"] + }, + { + "key": "xjtlu", + "name": "Xi'an Jiaotong-Liverpool University", + "host": "xjtlu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "xmu", + "name": "Xiamen University", + "host": "soe.xmu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "yamap", + "name": "YAMAP", + "host": "yamap.com", + "heat": 0, + "categories": ["travel"] + }, + { + "key": "yenpress", + "name": "Yen Press", + "host": "yenpress.com", + "heat": 0, + "categories": ["reading"] + }, + { + "key": "yomiuri", + "name": "Yomiuri Shimbun 読売新聞", + "host": "yomiuri.co.jp", + "heat": 0, + "categories": ["traditional-media"] + }, + { + "key": "youmemark", + "name": "YouMeMark", + "host": "youmemark.com", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "zagg", + "name": "Zagg", + "host": "zagg.com", + "heat": 0, + "categories": ["shopping"] + }, + { + "key": "zodgame", + "name": "ZodGame", + "host": "zodgame.xyz", + "heat": 0, + "categories": ["bbs"] + }, + { + "key": "autocentre", + "name": "Автоцентр.ua", + "host": "autocentre.ua", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "comic-walker", + "name": "カドコミ(Kadocomi)", + "host": "comic-walker.com", + "heat": 0, + "categories": ["anime"] + }, + { + "key": "melonbooks", + "name": "メロンブックス", + "host": "melonbooks.co.jp", + "heat": 0, + "categories": ["anime"] + }, + { + "key": "sass", + "name": "上海社会科学院", + "host": "gs.sass.org.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "sspu", + "name": "上海第二工业大学", + "host": "jwc.sspu.edu.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "bulianglin", + "name": "不良林", + "host": "bulianglin.com", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "nenu", + "name": "东北师范大学", + "host": "sohac.nenu.edu.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "dhu", + "name": "东华大学", + "host": "dhu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "dgjyw", + "name": "东莞教研网", + "host": "dgjyw.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "zhonglun", + "name": "中伦律师事务所", + "host": "zhonglun.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "chinadegrees", + "name": "中华人民共和国学位证书查询", + "host": "chinadegrees.com.cn", + "heat": 0, + "categories": ["study"] + }, + { + "key": "acpaa", + "name": "中华全国专利代理师协会", + "host": "acpaa.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "cebbank", + "name": "中国光大银行", + "host": "cebbank.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "cib", + "name": "中国兴业银行", + "host": "cib.com.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "cau", + "name": "中国农业大学", + "host": "ciee.cau.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "nlc", + "name": "中国国家图书馆", + "host": "read.nlc.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "airchina", + "name": "中国国际航空公司", + "host": "airchina.com.cn", + "heat": 0, + "categories": ["travel"] + }, + { + "key": "ciidbnu", + "name": "中国收入分配研究院", + "host": "ciidbnu.org", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "chinanews", + "name": "中国新闻网", + "host": "chinanews.com.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "chinania", + "name": "中国有色金属工业网", + "host": "chinania.org.cn", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "cfmmc", + "name": "中国期货市场监控中心", + "host": "cfmmc.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "caam", + "name": "中国汽车工业协会", + "host": "caam.org.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "auto-stats", + "name": "中国汽车工业协会统计信息网", + "host": "auto-stats.org.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "ouc", + "name": "中国海洋大学", + "host": "it.ouc.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "cnljxh", + "name": "中国炼焦行业协会", + "host": "cnljxh.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "ccagm", + "name": "中国百货商业协会", + "host": "ccagm.org.cn", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "medieval-china", + "name": "中国的中古", + "host": "medieval-china.club", + "heat": 0, + "categories": ["other"] + }, + { + "key": "cbpanet", + "name": "中国豆制品网", + "host": "cbpanet.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "ccfa", + "name": "中国连锁经营协会", + "host": "ccfa.org.cn", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "cccfna", + "name": "中国食品土畜进出口商会", + "host": "cccfna.org.cn", + "heat": 0, + "categories": ["government"] + }, + { + "key": "ctinews", + "name": "中天新聞網", + "host": "ctinews.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "sysu", + "name": "中山大学", + "host": "cse.sysu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "miyuki", + "name": "中島みゆき Official", + "host": "miyuki.jp", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "lsnu", + "name": "乐山师范学院", + "host": "lsnu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "2cycd", + "name": "二次元虫洞", + "host": "2cycd.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "sorrycc", + "name": "云谦的博客", + "host": "sorrycc.com", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "jiaoliudao", + "name": "交流岛资源网", + "host": "jiaoliudao.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "jd", + "name": "京东", + "host": "item.jd.com", + "heat": 0, + "categories": ["shopping"] + }, + { + "key": "iiilab", + "name": "人人都是自媒体", + "host": "iiilab.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "youku", + "name": "优酷", + "host": "i.youku.com", + "heat": 0, + "categories": ["multimedia"] + }, + { + "key": "pianyivps", + "name": "便宜VPS网", + "host": "pianyivps.com", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "chongdiantou", + "name": "充电头网", + "host": "chongdiantou.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "free", + "name": "免費資源網路社群", + "host": "free.com.tw", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "nmtv", + "name": "内蒙古广播电视台", + "host": "nmtv.cn", + "heat": 0, + "categories": ["traditional-media"] + }, + { + "key": "caus", + "name": "加美财经", + "host": "caus.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "buct", + "name": "北京化工大学", + "host": "buct.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "pumc", + "name": "北京协和医学院", + "host": "mdadmission.pumc.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "bast", + "name": "北京市科学技术协会", + "host": "bast.net.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "bjfu", + "name": "北京林业大学", + "host": "graduate.bjfu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "bit", + "name": "北京理工大学", + "host": "cs.bit.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "bse", + "name": "北京证券交易所", + "host": "bse.cn", + "heat": 0, + "categories": ["finance"] + }, + { + "key": "ynet", + "name": "北青网", + "host": "ynet.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "qianp", + "name": "千篇网", + "host": "qianp.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "ecust", + "name": "华东理工大学", + "host": "e.ecust.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "ccnu", + "name": "华中师范大学", + "host": "ccnu.91wllm.com", + "heat": 0, + "categories": ["university"] + }, + { + "key": "scau", + "name": "华南农业大学", + "host": "yzb.scau.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "njucm", + "name": "南京中医药大学", + "host": "lib.njucm.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "njit", + "name": "南京工程学院", + "host": "jwc.njit.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "njnu", + "name": "南京师范大学", + "host": "ceai.njnu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "njxzc", + "name": "南京晓庄学院", + "host": "lib.njxzc.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "nuaa", + "name": "南京航空航天大学", + "host": "aao.nuaa.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "njupt", + "name": "南京邮电大学", + "host": "jwc.njupt.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "njglyy", + "name": "南京鼓楼医院", + "host": "njglyy.com", + "heat": 0, + "categories": ["government"] + }, + { + "key": "cnjxol", + "name": "南湖清风", + "host": "cnjxol.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "xmut", + "name": "厦门理工大学", + "host": "jwc.xmut.edu.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "cpcey", + "name": "台湾行政院消费者保护会", + "host": "cpc.ey.gov.tw", + "heat": 0, + "categories": ["government"] + }, + { + "key": "mohw", + "name": "台灣衛生福利部", + "host": "mohw.gov.tw", + "heat": 0, + "categories": ["government"] + }, + { + "key": "jl1mall", + "name": "吉林一号网", + "host": "jl1mall.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "jsu", + "name": "吉首大学", + "host": "jsu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "tangshufang", + "name": "唐书房", + "host": "tangshufang.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "stbu", + "name": "四川工商学院", + "host": "stbu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "scvtc", + "name": "四川职业技术学院", + "host": "scvtc.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "m4", + "name": "四月网", + "host": "news.m4.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "szftedu", + "name": "园岭小学", + "host": "ylxx.szftedu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "zhujiceping", + "name": "国外主机测评", + "host": "zhujiceping.com", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "grainoil", + "name": "国家粮油信息中心", + "host": "load.grainoil.com.cn", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "aa1", + "name": "夏柔", + "host": "aa1.cn", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "duozhi", + "name": "多知网", + "host": "duozhi.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "dianping", + "name": "大众点评", + "host": "dianping.com", + "heat": 0, + "categories": ["shopping"] + }, + { + "key": "tsdm39", + "name": "天使动漫论坛", + "host": "tsdm39.com", + "heat": 0, + "categories": ["anime"] + }, + { + "key": "tynu", + "name": "太原师范学院", + "host": "tynu.edu.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "qipamaijia", + "name": "奇葩买家秀", + "host": "qipamaijia.com", + "heat": 0, + "categories": ["picture"] + }, + { + "key": "ippa", + "name": "子方有料", + "host": "ippa.top", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "ahjzu", + "name": "安徽建筑大学", + "host": "news.ahjzu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "j-test", + "name": "实用日本语鉴定考试(J.TEST)", + "host": "j-test.com", + "heat": 0, + "categories": ["study"] + }, + { + "key": "uibe", + "name": "对外经济贸易大学", + "host": "hr.uibe.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "sdu", + "name": "山东大学", + "host": "sdu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "sdust", + "name": "山东科技大学", + "host": "sdust.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "oceanengine", + "name": "巨量算数 - 算数指数", + "host": "trendinsight.oceanengine.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "gdut", + "name": "广东工业大学", + "host": "oas.gdut.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "gdsrx", + "name": "广东省食品药品审评认证技术协会", + "host": "gdsrx.org.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "guangzhoumetro", + "name": "广州地铁", + "host": "gzmtr.com", + "heat": 0, + "categories": ["travel"] + }, + { + "key": "gzhu", + "name": "广州大学", + "host": "yjsy.gzhu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "commonhealth", + "name": "康健", + "host": "commonhealth.com.tw", + "heat": 0, + "categories": ["traditional-media"] + }, + { + "key": "kuaidi100", + "name": "快递 100", + "host": "kuaidi100.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "cdu", + "name": "成都大学", + "host": "cdu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "onehu", + "name": "我不是盐神", + "host": "onehu.xyz", + "heat": 0, + "categories": ["other"] + }, + { + "key": "sogou", + "name": "搜狗", + "host": "sogou.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "dgtle", + "name": "数字尾巴", + "host": "dgtle.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "bnext", + "name": "數位時代 BusinessNext", + "host": "bnext.com.tw", + "heat": 0, + "categories": ["traditional-media"] + }, + { + "key": "xyu", + "name": "新余学院", + "host": "xyc.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "weekendhk", + "name": "新假期周刊", + "host": "weekendhk.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "sqmc", + "name": "新乡医学院三全学院", + "host": "sqmc.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "newseed", + "name": "新芽", + "host": "newseed.cn", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "cztv", + "name": "新蓝网(浙江广播电视集团)", + "host": "cztv.com", + "heat": 0, + "categories": ["traditional-media"] + }, + { + "key": "sinchew", + "name": "星洲网", + "host": "sinchew.com.my", + "heat": 0, + "categories": ["other"] + }, + { + "key": "chinafactcheck", + "name": "有据", + "host": "chinafactcheck.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "wsyu", + "name": "武昌首义学院", + "host": "wsyu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "wtu", + "name": "武汉纺织大学", + "host": "wtu.91wllm.com", + "heat": 0, + "categories": ["university"] + }, + { + "key": "shuiguopai", + "name": "水果派", + "host": "shuiguopai.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "jou", + "name": "江苏海洋大学", + "host": "jou.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "hebtv", + "name": "河北网络广播电视台", + "host": "web.cmc.hebtv.com", + "heat": 0, + "categories": ["traditional-media"] + }, + { + "key": "hafu", + "name": "河南财政金融学院", + "host": "hafu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "qztc", + "name": "泉州师范学院", + "host": "qztc.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "zjgtjy", + "name": "浙江省土地使用权网上交易系统", + "host": "zjgtjy.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "121", + "name": "深圳台风网", + "host": "121.com.cn", + "heat": 0, + "categories": ["forecast"] + }, + { + "key": "wzu", + "name": "温州大学", + "host": "wzu.edu.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "gamegene", + "name": "游戏基因", + "host": "news.gamegene.cn", + "heat": 0, + "categories": ["game"] + }, + { + "key": "hubu", + "name": "湖北大学", + "host": "hubu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "hunau", + "name": "湖南农业大学", + "host": "gfxy.hunau.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "wfu", + "name": "潍坊学院", + "host": "jwc.wfu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "ssm", + "name": "澳门卫生局", + "host": "ssm.gov.mo", + "heat": 0, + "categories": ["government"] + }, + { + "key": "huitun", + "name": "灰豚数据", + "host": "huitun.com", + "heat": 0, + "categories": ["social-media"] + }, + { + "key": "pianyuan", + "name": "片源网", + "host": "pianyuan.org", + "heat": 0, + "categories": ["multimedia"] + }, + { + "key": "coolbuy", + "name": "玩物志", + "host": "coolbuy.com", + "heat": 0, + "categories": ["shopping"] + }, + { + "key": "globallawreview", + "name": "环球法律评论", + "host": "globallawreview.org", + "heat": 0, + "categories": ["other"] + }, + { + "key": "cntheory", + "name": "理论网", + "host": "paper.cntheory.com", + "heat": 0, + "categories": ["traditional-media"] + }, + { + "key": "ygkkk", + "name": "甬哥侃侃侃YouTube教程摘要随笔", + "host": "ygkkk.blogspot.com", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "playpcesor", + "name": "电脑玩物", + "host": "playpcesor.com", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "fzmtr", + "name": "福州地铁", + "host": "fzmtr.com", + "heat": 0, + "categories": ["travel"] + }, + { + "key": "qq88", + "name": "秋爸日字", + "host": "qq88.info", + "heat": 0, + "categories": ["multimedia"] + }, + { + "key": "scitechvista", + "name": "科技大觀園", + "host": "scitechvista.nat.gov.tw", + "heat": 0, + "categories": ["government"] + }, + { + "key": "biquge", + "name": "笔趣阁", + "host": "xbiquwx.la", + "heat": 0, + "categories": ["other"] + }, + { + "key": "cbnweek", + "name": "第一财经杂志", + "host": "cbnweek.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "jingzhengu", + "name": "精真估", + "host": "jingzhengu.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "cbaigui", + "name": "纪妖", + "host": "cbaigui.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "50forum", + "name": "经济 50 人论坛", + "host": "50forum.org.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "zyshow", + "name": "综艺秀", + "host": "zyshow.net", + "heat": 0, + "categories": ["other"] + }, + { + "key": "luolei", + "name": "罗磊的独立博客", + "host": "luolei.org", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "my-formosa", + "name": "美麗島電子報", + "host": "my-formosa.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "usts", + "name": "苏州科技大学", + "host": "jwch.usts.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "englishhome", + "name": "英語之家", + "host": "englishhome.org", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "loltw", + "name": "英雄联盟", + "host": "lol.garena.tw", + "heat": 0, + "categories": ["game"] + }, + { + "key": "lala", + "name": "荒岛", + "host": "lala.im", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "nwafu", + "name": "西北农林科技大学", + "host": "nwafu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "xbmu", + "name": "西北民族大学", + "host": "xbmu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "xaut", + "name": "西安理工大学", + "host": "xaut.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "guanhai", + "name": "观海新闻", + "host": "guanhai.com.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "zrblog", + "name": "赵容部落", + "host": "zrblog.net", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "chaoxing", + "name": "超星", + "host": "chaoxing.com", + "heat": 0, + "categories": ["reading"] + }, + { + "key": "getdr", + "name": "趨勢科技防詐達人", + "host": "getdr.com", + "heat": 0, + "categories": ["other"] + }, + { + "key": "runtrail", + "name": "跑野大爆炸", + "host": "runtrail.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "lightnovel", + "name": "轻之国度", + "host": "lightnovel.us", + "heat": 0, + "categories": ["other"] + }, + { + "key": "easynomad", + "name": "轻松游牧-远程工作聚集地", + "host": "easynomad.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "stream-capital", + "name": "远川研究所", + "host": "stream-capital.com", + "heat": 0, + "categories": ["finance"] + }, + { + "key": "fanxinzhui", + "name": "追新番", + "host": "fanxinzhui.com", + "heat": 0, + "categories": ["multimedia"] + }, + { + "key": "daoxuan", + "name": "道宣的窝", + "host": "daoxuan.cc", + "heat": 0, + "categories": ["blog"] + }, + { + "key": "zzu", + "name": "郑州大学", + "host": "zzu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "cqu", + "name": "重庆大学", + "host": "cqu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "ctbu", + "name": "重庆工商大学", + "host": "ctbu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "pencilnews", + "name": "铅笔道", + "host": "pencilnews.cn", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "chaincatcher", + "name": "链捕手 ChainCatcher", + "host": "chaincatcher.com", + "heat": 0, + "categories": ["new-media"] + }, + { + "key": "yangtzeu", + "name": "长江大学", + "host": "yangtzeu.edu.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "ccmn", + "name": "长江有色网", + "host": "ccmn.cn", + "heat": 0, + "categories": ["other"] + }, + { + "key": "csust", + "name": "长沙理工大学", + "host": "csust.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "denonbu", + "name": "電音部", + "host": "denonbu.jp", + "heat": 0, + "categories": ["anime"] + }, + { + "key": "qdu", + "name": "青岛大学", + "host": "jwc.qdu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "qust", + "name": "青岛科技大学", + "host": "jw.qust.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "cnu", + "name": "首都师范大学", + "host": "cnu.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "hljucm", + "name": "黑龙江中医药大学", + "host": "yjsy.hljucm.net", + "heat": 0, + "categories": ["university"] + }, + { + "key": "byau", + "name": "黑龙江八一农垦大学", + "host": "byau.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "hlju", + "name": "黑龙江大学", + "host": "hlju.edu.cn", + "heat": 0, + "categories": ["university"] + }, + { + "key": "lkong", + "name": "龙空", + "host": "lkong.com", + "heat": 0, + "categories": ["other"] + } +] diff --git a/apps/landing/src/app/[locale]/page.tsx b/apps/landing/src/app/[locale]/page.tsx index 20ab448305a..df98734a0a5 100644 --- a/apps/landing/src/app/[locale]/page.tsx +++ b/apps/landing/src/app/[locale]/page.tsx @@ -3,16 +3,39 @@ import * as React from 'react' import { BuiltOpen } from '~/components/widgets/landing/BuiltOpen' import { Features } from '~/components/widgets/landing/Features' import { LandingHero } from '~/components/widgets/landing/Hero' -import { SocialProof } from '~/components/widgets/landing/SocialProof' +import { TrustedBy } from '~/components/widgets/landing/TrustedBy' +import { + DISCOVER_FALLBACK, + getHeroTimelineItems, + getLandingMetrics, + TRUSTED_COMPANIES, + TRUSTED_RESEARCH_INSTITUTIONS, +} from '~/lib/landing-data' + +type LocaleParams = { locale?: string } + +export default async function Home({ + params, +}: { + params: Promise<LocaleParams> | LocaleParams | undefined +}) { + const locale = params ? (await params).locale : undefined + const [heroItems, metrics] = await Promise.all([ + getHeroTimelineItems(locale), + getLandingMetrics(), + ]) -export default async function Home() { return ( <> - <LandingHero /> - <Features /> + <LandingHero items={heroItems} /> + <TrustedBy + companies={TRUSTED_COMPANIES} + researchers={TRUSTED_RESEARCH_INSTITUTIONS} + metrics={metrics} + /> + <Features discoverSources={DISCOVER_FALLBACK} /> {/* <ViewsShowcase /> */} {/* <Audience /> */} - <SocialProof /> <BuiltOpen /> </> ) diff --git a/apps/landing/src/app/[locale]/pricing/page.tsx b/apps/landing/src/app/[locale]/pricing/page.tsx new file mode 100644 index 00000000000..b96fbf02372 --- /dev/null +++ b/apps/landing/src/app/[locale]/pricing/page.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from 'next' +import { getTranslations } from 'next-intl/server' + +import { PricingPlans } from '~/components/widgets/pricing/PricingPlans' +import { defaultLocale, locales } from '~/i18n/routing' +import { fetchPricingPlans } from '~/lib/pricing-data' + +type LocaleParams = { locale?: string } + +const localeSet = new Set(locales) + +export async function generateMetadata({ + params, +}: { + params: Promise<LocaleParams> | LocaleParams | undefined +}): Promise<Metadata> { + const localeFromParams = params ? (await params).locale : undefined + const locale = + localeFromParams && + localeSet.has(localeFromParams as (typeof locales)[number]) + ? localeFromParams + : defaultLocale + const t = await getTranslations({ locale, namespace: 'pricing.metadata' }) + + return { + title: t('title'), + description: t('description'), + } +} + +export default async function PricingPage() { + const plans = await fetchPricingPlans() + + return <PricingPlans plans={plans} /> +} diff --git a/apps/landing/src/app/discover-sources/route.ts b/apps/landing/src/app/discover-sources/route.ts new file mode 100644 index 00000000000..df15a4f0887 --- /dev/null +++ b/apps/landing/src/app/discover-sources/route.ts @@ -0,0 +1,59 @@ +import type { RSSHubRoutesIndex } from '~/lib/landing-data' +import { + buildDiscoverSourcesFromIndex, + DISCOVER_FALLBACK, + PRODUCTION_RSSHUB_ROUTES_URL, +} from '~/lib/landing-data' + +const CACHE_TTL_MS = 12 * 60 * 60 * 1000 + +let cached: + | { + expiresAt: number + data: ReturnType<typeof buildDiscoverSourcesFromIndex> + } + | undefined + +export async function GET() { + const now = Date.now() + + if (cached && cached.expiresAt > now) { + return Response.json(cached.data, { + headers: { + 'Cache-Control': 'public, max-age=600', + }, + }) + } + + try { + const response = await fetch(PRODUCTION_RSSHUB_ROUTES_URL, { + headers: { + accept: 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch RSSHub routes: ${response.status}`) + } + + const result = (await response.json()) as RSSHubRoutesIndex + const data = buildDiscoverSourcesFromIndex(result) + + cached = { + data, + expiresAt: now + CACHE_TTL_MS, + } + + return Response.json(data, { + headers: { + 'Cache-Control': 'public, max-age=600', + }, + }) + } catch { + return Response.json(DISCOVER_FALLBACK, { + headers: { + 'Cache-Control': 'public, max-age=60', + }, + }) + } +} diff --git a/apps/landing/src/components/layout/footer/Footer.tsx b/apps/landing/src/components/layout/footer/Footer.tsx index 93b316b656f..6330b8a67f6 100644 --- a/apps/landing/src/components/layout/footer/Footer.tsx +++ b/apps/landing/src/components/layout/footer/Footer.tsx @@ -10,10 +10,12 @@ type LinkItem = { label: string; href: string; external?: boolean } const productLinks: LinkItem[] = [ { label: 'Web App', href: 'https://app.folo.is', external: true }, { label: 'Download', href: '/download', external: false }, + { label: 'Pricing', href: '/pricing', external: false }, + { label: 'Built Open', href: '/#open', external: false }, ] const communityLinks: LinkItem[] = [ - { label: 'Discord', href: 'https://discord.gg/followapp', external: true }, + { label: 'Discord', href: 'https://discord.gg/AwWcAQ7euc', external: true }, { label: 'GitHub', href: 'https://github.com/RSSNext/Folo', @@ -22,14 +24,6 @@ const communityLinks: LinkItem[] = [ { label: 'Twitter', href: 'https://x.com/folo_is', external: true }, ] -const resourceLinks: LinkItem[] = [ - { label: 'For Researchers', href: '#researchers' }, - { label: 'For Builders', href: '#builders' }, - { label: 'For Creators', href: '#creators' }, - { label: 'For Investors', href: '#investors' }, - // { label: 'API', href: '#api' }, -] - const legalLinks: LinkItem[] = [ { label: 'Privacy Policy', href: 'privacy-policy' }, { label: 'Terms of Service', href: 'terms-of-service' }, @@ -44,59 +38,14 @@ const BrandBlock = () => ( <span className="text-2xl font-semibold tracking-tight">Folo</span> </Link> <p className="mt-6 text-base leading-relaxed text-text-secondary"> - The next-generation information browser powered by AI. Transform how you - discover, consume, and interact with content across the web. + The AI that reads the internet for you, </p> <p className="mt-4 text-sm text-text-tertiary"> - Deep reading, contextual AI, and noise-free content discovery — all in one - beautiful interface. + cutting through noise to surface the knowledge you actually care about. </p> </div> ) -const Social = () => ( - <div className="mt-6 flex items-center gap-6"> - <Link - href="https://github.com/RSSNext/Folo" - target="_blank" - rel="noreferrer noopener" - className={cx( - 'inline-flex text-text-secondary transition-colors hover:text-text', - focusRing, - )} - aria-label="GitHub" - > - <i className="i-simple-icons-github size-5" aria-hidden /> - </Link> - - <Link - href="https://x.com/folo_is" - target="_blank" - rel="noreferrer noopener" - className={cx( - 'inline-flex text-text-secondary transition-colors hover:text-text', - focusRing, - )} - aria-label="Twitter / X" - > - <i className="i-simple-icons-x size-5" aria-hidden /> - </Link> - - <Link - href="https://discord.gg/followapp" - target="_blank" - rel="noreferrer noopener" - className={cx( - 'inline-flex text-text-secondary transition-colors hover:text-text', - focusRing, - )} - aria-label="Discord" - > - <i className="i-simple-icons-discord size-5" aria-hidden /> - </Link> - </div> -) - function LinkColumn({ title, links }: { title: string; links: LinkItem[] }) { return ( <div> @@ -144,14 +93,12 @@ export const Footer: Component<FooterProps> = ({ className }) => { {/* Brand section with stats */} <div className="lg:col-span-5"> <BrandBlock /> - <Social /> </div> {/* Navigation columns */} - <div className="grid grid-cols-2 gap-8 sm:grid-cols-3 lg:col-span-7"> + <div className="grid grid-cols-2 gap-8 lg:col-span-7"> <LinkColumn title="Product" links={productLinks} /> <LinkColumn title="Community" links={communityLinks} /> - <LinkColumn title="Resources" links={resourceLinks} /> </div> </div> diff --git a/apps/landing/src/components/ui/3d-models/AISpline.ts b/apps/landing/src/components/ui/3d-models/AISpline.ts deleted file mode 100644 index ebb2b69b6dc..00000000000 --- a/apps/landing/src/components/ui/3d-models/AISpline.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createElement, lazy, Suspense } from 'react' -import { ErrorBoundary } from 'react-error-boundary' - -import { cn } from '~/lib/cn' - -const AISplineLoader = lazy(() => - import('./AISplineLoader').then((res) => ({ default: res.AISplineLoader })), -) -export const AISpline = ({ className }: { className?: string }) => { - return createElement( - ErrorBoundary, - null, - createElement( - Suspense, - { - fallback: createElement('div', { - className: cn('size-20 mx-auto', className), - }), - }, - createElement(AISplineLoader, { className }), - ), - ) -} diff --git a/apps/landing/src/components/ui/3d-models/AISpline.tsx b/apps/landing/src/components/ui/3d-models/AISpline.tsx new file mode 100644 index 00000000000..aa028fb8ccf --- /dev/null +++ b/apps/landing/src/components/ui/3d-models/AISpline.tsx @@ -0,0 +1,41 @@ +'use client' + +import { lazy, Suspense, useEffect, useState } from 'react' + +import { ErrorBoundary } from '~/components/common/ErrorBoundary' +import { cn } from '~/lib/cn' + +const AISplineLoader = lazy(() => + import('./AISplineLoader').then((res) => ({ default: res.AISplineLoader })), +) + +const supportsWebGL = () => { + try { + const canvas = document.createElement('canvas') + return !!( + canvas.getContext('webgl') || canvas.getContext('experimental-webgl') + ) + } catch { + return false + } +} + +export const AISpline = ({ className }: { className?: string }) => { + const [canRender, setCanRender] = useState(false) + + useEffect(() => { + setCanRender(supportsWebGL()) + }, []) + + if (!canRender) { + return <div className={cn('mx-auto size-20', className)} /> + } + + return ( + <ErrorBoundary> + <Suspense fallback={<div className={cn('mx-auto size-20', className)} />}> + <AISplineLoader className={className} /> + </Suspense> + </ErrorBoundary> + ) +} diff --git a/apps/landing/src/components/ui/scroll-areas/ScrollArea.tsx b/apps/landing/src/components/ui/scroll-areas/ScrollArea.tsx index 348cf5dd867..73e243d1b14 100644 --- a/apps/landing/src/components/ui/scroll-areas/ScrollArea.tsx +++ b/apps/landing/src/components/ui/scroll-areas/ScrollArea.tsx @@ -66,7 +66,7 @@ Scrollbar.displayName = 'ScrollArea.Scrollbar' const Viewport = ({ ref: forwardedRef, className, - + mask: _mask, focusable = true, ...rest }: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Viewport> & { diff --git a/apps/landing/src/components/ui/segment-tab/SegmentTab.tsx b/apps/landing/src/components/ui/segment-tab/SegmentTab.tsx index b3339a3bb4c..597ce74c767 100644 --- a/apps/landing/src/components/ui/segment-tab/SegmentTab.tsx +++ b/apps/landing/src/components/ui/segment-tab/SegmentTab.tsx @@ -23,6 +23,7 @@ export interface SegmentTabProps<T = string> { inactiveClassName?: string size?: 'sm' | 'md' | 'lg' variant?: 'default' | 'compact' + distribution?: 'fill' | 'fit' disabled?: boolean responsiveWrap?: boolean ref?: Ref<HTMLDivElement> @@ -69,6 +70,7 @@ export function SegmentTab<T = string>({ inactiveClassName, size = 'md', variant = 'default', + distribution = 'fill', disabled = false, responsiveWrap = false, ref, @@ -82,7 +84,8 @@ export function SegmentTab<T = string>({ role="tablist" aria-disabled={disabled || undefined} className={cn( - 'container-type-[inline-size] relative flex w-full items-center', + 'container-type-[inline-size] relative flex items-center', + distribution === 'fill' ? 'w-full' : 'w-fit max-w-full', styles.container, disabled && 'pointer-events-none opacity-60', containerClassName, @@ -90,7 +93,8 @@ export function SegmentTab<T = string>({ > <div className={cn( - 'relative flex w-full items-center', + 'relative flex items-center', + distribution === 'fill' ? 'w-full' : 'w-fit max-w-full', responsiveWrap && 'flex-wrap', )} > @@ -110,12 +114,14 @@ export function SegmentTab<T = string>({ 'focus-visible:ring-accent/30 focus:outline-none focus-visible:ring-2', sizeClass, styles.button, - responsiveWrap - ? clsx( - '@[0px]:flex-none @[0px]:basis-[calc(50%-0.125rem)] @[0px]:px-2', - '@[420px]:flex-1 @[420px]:basis-auto @[420px]:px-3', - ) - : clsx('flex-1', '@[0px]:px-2 @[420px]:px-3'), + distribution === 'fit' + ? 'flex-none' + : responsiveWrap + ? clsx( + '@[0px]:flex-none @[0px]:basis-[calc(50%-0.125rem)] @[0px]:px-2', + '@[420px]:flex-1 @[420px]:basis-auto @[420px]:px-3', + ) + : clsx('flex-1', '@[0px]:px-2 @[420px]:px-3'), isActive ? cn('text-text', activeClassName) : cn( @@ -150,7 +156,6 @@ export function SegmentTab<T = string>({ // Floating pill indicator with subtle border & shadow 'pointer-events-none absolute inset-x-0 inset-y-0.5 z-[-1]', styles.indicator, - responsiveWrap && 'hidden @[420px]:block', className, )} /> diff --git a/apps/landing/src/components/widgets/download/PlatformDownloads.tsx b/apps/landing/src/components/widgets/download/PlatformDownloads.tsx index a8d812c3a20..dfc5a4cf916 100644 --- a/apps/landing/src/components/widgets/download/PlatformDownloads.tsx +++ b/apps/landing/src/components/widgets/download/PlatformDownloads.tsx @@ -5,52 +5,51 @@ import Link from 'next/link' import { useTranslations } from 'next-intl' import * as React from 'react' -import { PlatformStoreLinkMap } from '~/constants/download' +import type { + OS, + PlatformDownloadChannel, + PlatformDownloadGroup, +} from '~/constants/download' +import { PlatformDownloadGroups } from '~/constants/download' import { Link as LocalizedLink } from '~/i18n/routing' import { cx, focusRing } from '~/lib/cn' import { Spring } from '~/lib/spring' -type OS = 'macOS' | 'Windows' | 'Linux' +type PlatformDownloadsProps = { + detectedOS: OS | null +} -type PlatformOption = { - os: OS - label: string - icon: string - storeLink: string - storeName: string +const getPlatformGroups = ( + detectedOS: OS | null, + showAllPlatforms: boolean, +): PlatformDownloadGroup[] => { + if (!detectedOS) { + return PlatformDownloadGroups + } + + const currentPlatform = PlatformDownloadGroups.find( + (group) => group.os === detectedOS, + ) + const otherPlatforms = PlatformDownloadGroups.filter( + (group) => group.os !== detectedOS, + ) + + return showAllPlatforms + ? ([currentPlatform, ...otherPlatforms].filter( + Boolean, + ) as PlatformDownloadGroup[]) + : currentPlatform + ? [currentPlatform] + : PlatformDownloadGroups } -const allPlatforms: PlatformOption[] = [ - { - os: 'macOS', - label: 'macOS', - icon: 'i-simple-icons-apple', - storeLink: PlatformStoreLinkMap.macOS.link, - storeName: PlatformStoreLinkMap.macOS.name, - }, - { - os: 'Windows', - label: 'Windows', - icon: 'i-simple-icons-windows', - storeLink: PlatformStoreLinkMap.Windows.link, - storeName: PlatformStoreLinkMap.Windows.name, - }, - { - os: 'Linux', - label: 'Linux', - icon: 'i-simple-icons-linux', - storeLink: PlatformStoreLinkMap.Linux.link, - storeName: PlatformStoreLinkMap.Linux.name, - }, -] - -export const PlatformDownloads: Component<{ - detectedOS: OS | null -}> = ({ detectedOS }) => { +export const PlatformDownloads: Component<PlatformDownloadsProps> = ({ + detectedOS, +}) => { const platformT = useTranslations('download.platforms') - const platformsToShow = detectedOS - ? allPlatforms.filter((p) => p.os === detectedOS) - : allPlatforms + const [showAllPlatforms, setShowAllPlatforms] = React.useState(false) + + const platformGroups = getPlatformGroups(detectedOS, showAllPlatforms) const webTitle = platformT('web.title') const webSubtitle = platformT('web.subtitle') const footer = platformT.rich('footer', { @@ -77,78 +76,82 @@ export const PlatformDownloads: Component<{ id="downloads" className="mx-auto mt-16 md:mt-20 w-full max-w-max-width-2xl px-4 pb-32" > - <div className="mx-auto max-w-3xl"> - {/* Main Download Links */} - <m.div - className="space-y-4" - initial="hidden" - animate="visible" - variants={{ - visible: { - transition: { - staggerChildren: 0.08, - }, - }, - }} - > - {platformsToShow.map((platform) => ( + <div className="mx-auto max-w-4xl"> + <div className="space-y-8"> + {platformGroups.map((group, groupIndex) => ( <m.div - key={platform.os} - variants={{ - hidden: { opacity: 0, y: 20 }, - visible: { opacity: 1, y: 0 }, + key={group.os} + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ + ...Spring.presets.smooth, + delay: groupIndex * 0.05, }} - transition={Spring.presets.smooth} > - <Link - href={platform.storeLink} - target="_blank" - rel="noreferrer noopener" - className={cx( - 'group block rounded-xl border border-border bg-material-medium/60 backdrop-blur p-6 transition-all duration-200', - 'hover:bg-material-thick/70 hover:shadow-lg hover:scale-[1.01]', - focusRing, - )} - > - <div className="flex items-center justify-between"> - <div className="flex items-center gap-4"> - <m.i - className={cx(platform.icon, 'size-8 text-accent')} - aria-hidden - whileHover={{ scale: 1.1, rotate: [0, -10, 10, 0] }} - transition={{ duration: 0.3 }} - /> - <div> - <h3 className="text-lg font-semibold text-text"> - {platformT(`${platform.os}.label`, { - defaultValue: platform.label, - })} - </h3> - <p className="text-sm text-text-secondary"> - {platformT(`${platform.os}.store`, { - defaultValue: platform.storeName, - })} - </p> - </div> - </div> - <m.i - className="i-mingcute-download-2-line size-6 text-text-secondary group-hover:text-accent transition-colors" + <div className="mb-5 flex items-end justify-between gap-4"> + <div className="flex items-center gap-4"> + <i + className={cx(group.icon, 'size-12 shrink-0 text-accent')} aria-hidden - whileHover={{ y: 2 }} - transition={{ duration: 0.2 }} /> + + <div className="space-y-1"> + <p className="text-sm text-text-secondary"> + {detectedOS === group.os + ? platformT('recommended') + : platformT('allPlatforms')} + </p> + <h3 className="text-3xl leading-none font-semibold text-text"> + {platformT(`${group.os}.label`, { + defaultValue: group.label, + })} + </h3> + </div> + </div> + + <div className="text-sm text-text-tertiary"> + <p className="text-sm">{group.channels.length} options</p> </div> - </Link> + </div> + + <div className="grid gap-4 md:grid-cols-2"> + {group.channels.map((channel) => ( + <DownloadChannelCard key={channel.id} channel={channel} /> + ))} + </div> </m.div> ))} - </m.div> + </div> + + {detectedOS && ( + <div className="mt-8 text-center"> + <button + type="button" + onClick={() => setShowAllPlatforms((value) => !value)} + className={cx( + 'inline-flex items-center gap-2 rounded-full border border-border bg-background/70 px-4 py-2 text-sm text-text-secondary transition-colors hover:text-text hover:border-accent/30', + focusRing, + )} + > + {showAllPlatforms + ? platformT('lessDownloads') + : platformT('moreDownloads')} + <i + className={cx( + 'i-mingcute-down-line transition-transform', + showAllPlatforms && 'rotate-180', + )} + aria-hidden + /> + </button> + </div> + )} - {/* Web Version */} <m.div className="mt-8" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} - transition={{ ...Spring.presets.smooth, delay: 0.3 }} + transition={{ ...Spring.presets.smooth, delay: 0.25 }} > <Link href="https://app.folo.is" @@ -185,28 +188,11 @@ export const PlatformDownloads: Component<{ </Link> </m.div> - {/* Show all platforms */} - {/* {detectedOS && ( - <div className="mt-8 text-center"> - <button - type="button" - className={cx( - 'text-sm text-text-secondary hover:text-text transition-colors inline-flex items-center gap-1', - focusRing, - )} - > - Show all platforms - <i className="i-mingcute-down-line" aria-hidden /> - </button> - </div> - )} */} - - {/* Footer Note */} <m.p className="mt-12 text-center text-sm text-text-tertiary" initial={{ opacity: 0 }} animate={{ opacity: 1 }} - transition={{ delay: 0.5, duration: 0.4 }} + transition={{ delay: 0.4, duration: 0.4 }} > {footer} </m.p> @@ -214,3 +200,37 @@ export const PlatformDownloads: Component<{ </section> ) } + +const DownloadChannelCard = ({ + channel, +}: { + channel: PlatformDownloadChannel +}) => { + return ( + <Link + href={channel.href} + target="_blank" + rel="noreferrer noopener" + className={cx( + 'group block rounded-xl border border-border bg-material-medium/60 backdrop-blur p-6 transition-all duration-200', + 'hover:bg-material-thick/70 hover:shadow-lg hover:scale-[1.01]', + focusRing, + )} + > + <div className="flex items-center justify-between gap-4"> + <div className="min-w-0"> + <h4 className="mt-1 truncate text-lg font-semibold text-text"> + {channel.name} + </h4> + </div> + + <m.i + className="i-mingcute-download-2-line size-6 shrink-0 text-text-secondary group-hover:text-accent transition-colors" + aria-hidden + whileHover={{ y: 2 }} + transition={{ duration: 0.2 }} + /> + </div> + </Link> + ) +} diff --git a/apps/landing/src/components/widgets/landing/BuiltOpen.tsx b/apps/landing/src/components/widgets/landing/BuiltOpen.tsx index 74cc044acd7..f00318b8aa1 100644 --- a/apps/landing/src/components/widgets/landing/BuiltOpen.tsx +++ b/apps/landing/src/components/widgets/landing/BuiltOpen.tsx @@ -1,9 +1,6 @@ -import clsx from 'clsx' import { useTranslations } from 'next-intl' -import * as React from 'react' import GithubTrending from '~/components/common/GithubTrending' -import { cx } from '~/lib/cn' import { RepoStats } from './RepoStats' @@ -13,125 +10,29 @@ export const BuiltOpen: Component = () => { return ( <section id="open" - className="mx-auto mt-24 md:mt-28 lg:mt-32 w-full max-w-max-width-2xl px-0 lg:px-4" + className="mx-auto mt-24 w-full max-w-[var(--container-max-width-2xl)] px-4 md:mt-28 lg:mt-32" > - <div> - <DesignGuideLine axis="y" className="translate-x-6 md:translate-x-8" /> - - <div className="grid grid-cols-1 lg:grid-cols-12 gap-6 p-6 md:p-8"> - {/* Left column: copy + CTA */} - <div className="lg:col-span-6 flex flex-col justify-center"> - <h2 - className={clsx( - 'mt-2 text-2xl font-semibold tracking-tight', - 'before:inset-x-0 before:h-px before:bg-border/50 before:absolute', - 'after:inset-x-0 after:h-px after:bg-border/50 after:absolute after:translate-y-8', - 'before:hidden lg:before:block', - 'after:hidden lg:after:block', - )} - > - <span className="inline-flex"> - {builtOpenT('title')} - <DesignGuideLine axis="y" /> - </span> - </h2> - <div className="flex flex-row"> - <p - className={clsx( - 'mt-2 text-text-secondary', - 'before:inset-x-0 before:h-px before:bg-border/50 before:absolute', - 'before:hidden lg:before:block', - )} - > - {builtOpenT('body')} - </p> - - <DesignGuideLine axis="y" /> - </div> - <DesignGuideLine axis="x" /> - <div className="mt-4 flex flex-wrap justify-center flex-col"> - <DesignGuideLine axis="x" /> - <GithubTrending /> - <DesignGuideLine axis="x" /> - </div> - - {/* Tech stack chips */} - <div className="flex flex-col mt-6"> - <div className=" flex flex-row"> - <DesignGuideLine axis="x" /> - <ul className="flex flex-wrap gap-2 text-xs text-text-secondary"> - {STACK.map((s) => ( - <li - key={s.label} - className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/60 px-2.5 py-1" - > - <i className={cx('size-3.5', s.icon)} aria-hidden /> - <span>{s.label}</span> - </li> - ))} - </ul> - - <DesignGuideLine axis="y" /> - </div> - <DesignGuideLine axis="x" /> - </div> - </div> - - {/* Right column: repo stats / blueprint stage */} - <div className="lg:col-span-6"> - {/* stage frame */} - <div className="mx-0 lg:mx-5 mt-4 lg:mt-12"> - <div className="gap-4 flex flex-col"> - <RepoStats /> - - {/* small blueprint note */} - <div className="flex-row hidden lg:flex mt-4 lg:mt-9.5"> - <DesignGuideLine axis="y" /> - <div className="rounded-lg border flex z-1 flex-row border-border/60 bg-material-medium/60 px-3 py-2 text-xs text-text-secondary"> - <p>{builtOpenT('note')}</p> - </div> - <DesignGuideLine axis="y" /> - </div> - </div> - </div> + <div className="flex flex-col gap-8"> + <div> + <p className="text-[11px] font-medium uppercase tracking-[0.28em] text-accent/90"> + {builtOpenT('title')} + </p> + <h2 className="mt-2 text-2xl font-semibold tracking-tight"> + {builtOpenT('body')} + </h2> + <p className="mt-3 text-sm text-text-secondary"> + {builtOpenT('note')} + </p> + + <div className="mt-5"> + <GithubTrending /> </div> </div> + + <RepoStats /> </div> </section> ) } BuiltOpen.displayName = 'BuiltOpen' - -const STACK = [ - // { label: 'Next.js 15', icon: 'i-simple-icons-nextdotjs' }, - { label: 'React 19', icon: 'i-simple-icons-react' }, - { label: 'Tailwind', icon: 'i-simple-icons-tailwindcss' }, - { label: 'Radix UI', icon: 'i-simple-icons-radixui' }, - { label: 'Framer Motion', icon: 'i-simple-icons-framer' }, -] - -const DesignGuideLine: Component<{ axis: 'x' | 'y' }> = ({ - axis, - className, -}) => { - return ( - <div className="hidden lg:block"> - {axis === 'x' ? ( - <div - className={clsx( - 'before:inset-x-0 before:h-px before:bg-border/50 before:absolute z-[-1]', - className, - )} - /> - ) : ( - <div - className={clsx( - 'absolute bottom-0 h-[600px] w-px bg-border/50 z-[-1]', - className, - )} - /> - )} - </div> - ) -} diff --git a/apps/landing/src/components/widgets/landing/Features.tsx b/apps/landing/src/components/widgets/landing/Features.tsx index 98275cf0c39..694099a5096 100644 --- a/apps/landing/src/components/widgets/landing/Features.tsx +++ b/apps/landing/src/components/widgets/landing/Features.tsx @@ -3,29 +3,32 @@ import { useTranslations } from 'next-intl' import * as React from 'react' +import { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea' import { cx } from '~/lib/cn' +import type { DiscoverSource } from '~/lib/landing-data' import { EntryPageDemo } from '../simulators/EntryPage' import { WindowChrome } from './WindowChrome' -export const Features: Component = () => { - // No autoplay/tabs in grid layout; keep light state for potential hovers - const [_hover, setHover] = React.useState<string | null>(null) +type FeaturesProps = { + discoverSources: DiscoverSource[] +} + +export const Features: Component<FeaturesProps> = ({ discoverSources }) => { const featuresT = useTranslations('landing.features') return ( <section id="features" - className="mx-auto mt-24 md:mt-28 lg:mt-32 w-full max-w-[var(--container-max-width-2xl)] px-4" + className="mx-auto mt-24 w-full max-w-[var(--container-max-width-2xl)] px-4 md:mt-28 lg:mt-32" > <div className="mx-auto max-w-5xl text-center"> <h2 className="text-4xl font-semibold tracking-tight"> {featuresT('headline')} </h2> </div> - {/* Grid layout with subtle center dividers (Vercel-like) */} - <div className="relative mx-auto mt-10 grid max-w-5xl grid-cols-1 gap-x-8 gap-y-32 lg:grid-cols-2"> - {/* Card 1: Discover */} + + <div className="mx-auto mt-12 grid max-w-5xl grid-cols-1 gap-y-20"> <FeatureGridItem eyebrow={ <span className="inline-flex items-center gap-2 text-sm text-text-secondary"> @@ -38,31 +41,10 @@ export const Features: Component = () => { } titleStrong={featuresT('discover.titleStrong')} titleRest={featuresT('discover.titleRest')} - onHover={() => setHover('discover')} - > - <WindowChrome> - <div className="relative aspect-video w-full bg-background-secondary" /> - </WindowChrome> - </FeatureGridItem> - - {/* Card 2: Digital Twin */} - <FeatureGridItem - eyebrow={ - <span className="inline-flex items-center gap-2 text-sm text-text-secondary"> - <i className="i-lucide-brain text-accent" aria-hidden /> - {featuresT('twin.label')} - </span> - } - titleStrong={featuresT('twin.titleStrong')} - titleRest={featuresT('twin.titleRest')} - onHover={() => setHover('twin')} > - <WindowChrome> - <div className="relative aspect-video w-full bg-background-secondary" /> - </WindowChrome> + <DiscoverWindow sources={discoverSources} /> </FeatureGridItem> - {/* Card 3: Vibe Read (spans) */} <FeatureGridItem eyebrow={ <span className="inline-flex items-center gap-2 text-sm text-text-secondary"> @@ -72,13 +54,9 @@ export const Features: Component = () => { } titleStrong={featuresT('vibe.titleStrong')} titleRest={featuresT('vibe.titleRest')} - className="lg:col-span-2" - onHover={() => setHover('vibe')} > - <div className="h-px bg-border -translate-y-16 w-full absolute top-0 inset-x-0" /> - <WindowChrome> - <div className="relative lg:aspect-video h-[800px] lg:h-auto w-full bg-background-secondary"> + <div className="relative h-[880px] w-full overflow-hidden bg-background-secondary lg:h-[720px]"> <EntryPageDemo /> </div> </WindowChrome> @@ -94,7 +72,6 @@ type FGProps = { titleRest?: string children: React.ReactNode className?: string - onHover?: () => void } function FeatureGridItem({ @@ -103,20 +80,95 @@ function FeatureGridItem({ titleRest, children, className, - onHover, }: FGProps) { return ( - <div className={cx('relative', className)} onMouseEnter={onHover}> + <div className={cx('relative', className)}> <div className="mb-3 flex items-center gap-2 pl-1">{eyebrow}</div> - <h3 className="text-xl font-semibold leading-snug tracking-tight"> + <h3 className="text-xl font-semibold leading-snug tracking-tight sm:text-[2rem]"> {titleStrong}{' '} {titleRest ? ( - <span className="text-text-secondary font-normal">{titleRest}</span> + <span className="font-normal text-text-secondary">{titleRest}</span> ) : null} </h3> - <div className="mt-4">{children}</div> + <div className="mt-5">{children}</div> </div> ) } +function DiscoverWindow({ sources }: { sources: DiscoverSource[] }) { + const [allSources, setAllSources] = React.useState(sources) + + React.useEffect(() => { + let cancelled = false + + const load = async () => { + try { + const response = await fetch('/discover-sources.json') + if (!response.ok) return + + const nextSources = (await response.json()) as DiscoverSource[] + if (!cancelled && nextSources.length > 0) { + setAllSources(nextSources) + } + } catch { + // Ignore network failures in dev and keep the bundled fallback data. + } + } + + void load() + + return () => { + cancelled = true + } + }, []) + + return ( + <WindowChrome> + <div className="bg-background-secondary p-6 md:p-8"> + <div className="rounded-[30px] bg-background/92 px-5 py-5 shadow-[0_24px_80px_-56px_rgba(0,0,0,0.3)]"> + <div className="flex items-end justify-between gap-4"> + <div> + <p className="text-sm font-semibold text-text"> + {allSources.length.toLocaleString('en-US')} official sources + </p> + <p className="mt-1 text-xs text-text-tertiary"> + Browse supported websites. + </p> + </div> + </div> + + <ScrollArea rootClassName="mt-5 h-[560px]" viewportClassName="pr-3"> + <div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4"> + {allSources.map((source) => ( + <div + key={source.host} + className="flex items-center gap-3 rounded-2xl bg-background-secondary/80 px-3 py-3" + > + <div className="flex size-10 items-center justify-center rounded-2xl bg-white p-2 shadow-[0_12px_24px_-18px_rgba(0,0,0,0.35)]"> + <img + src={`https://icons.folo.is/${source.host}`} + alt={source.name} + className="size-full rounded-xl object-cover" + loading="lazy" + /> + </div> + + <div className="min-w-0"> + <p className="truncate text-sm font-medium text-text"> + {source.name} + </p> + <p className="truncate text-xs text-text-tertiary"> + {source.host} + </p> + </div> + </div> + ))} + </div> + </ScrollArea> + </div> + </div> + </WindowChrome> + ) +} + Features.displayName = 'Features' diff --git a/apps/landing/src/components/widgets/landing/Header.tsx b/apps/landing/src/components/widgets/landing/Header.tsx index 89d56915fb1..a8754f85709 100644 --- a/apps/landing/src/components/widgets/landing/Header.tsx +++ b/apps/landing/src/components/widgets/landing/Header.tsx @@ -22,6 +22,10 @@ export const LandingHeader: Component = () => { const isMobile = useIsMobile() const isOverflowPage = usePageScrollLocationSelector((s) => s > 100) const actionsT = useTranslations('common.actions') + const navItems = [ + { label: 'Download', href: '/download' }, + { label: 'Pricing', href: '/pricing' }, + ] as const return ( <div className="fixed inset-x-0 top-0 z-50"> @@ -56,29 +60,26 @@ export const LandingHeader: Component = () => { )} /> )} - {/* Brand */} - <LocalizedLink href="/" className="flex items-center gap-2 ml-4"> - <Logo width={26} height={26} aria-hidden accentColor="#FF5C00" /> - - <Folo className="size-8" /> - </LocalizedLink> - - {/* Center nav (desktop) */} - {/* <ul className="hidden items-center gap-5 md:flex"> - {NAV_ITEMS.map((item) => ( - <li key={item.id}> - <a - href={`#${item.id}`} - className={cx( - 'text-sm text-text-secondary hover:text-text transition-colors', - focusRing, - )} - > - {item.label} - </a> - </li> - ))} - </ul> */} + <div className="flex items-center gap-8"> + <LocalizedLink href="/" className="flex items-center gap-2 ml-4"> + <Logo width={26} height={26} aria-hidden accentColor="#FF5C00" /> + + <Folo className="size-8" /> + </LocalizedLink> + + <ul className="hidden items-center gap-5 md:flex"> + {navItems.map((item) => ( + <li key={item.href}> + <LocalizedLink + href={item.href} + className="text-sm text-text-secondary transition-colors hover:text-text" + > + {item.label} + </LocalizedLink> + </li> + ))} + </ul> + </div> {/* Actions */} <div className="flex items-center gap-2"> diff --git a/apps/landing/src/components/widgets/landing/Hero.tsx b/apps/landing/src/components/widgets/landing/Hero.tsx index f4e859a2916..c7fbe682aea 100644 --- a/apps/landing/src/components/widgets/landing/Hero.tsx +++ b/apps/landing/src/components/widgets/landing/Hero.tsx @@ -15,13 +15,21 @@ import { ParticlesAura } from '~/components/ui/effects/ParticlesAura' import { TiltCard } from '~/components/ui/effects/TiltCard' import { Highlighter } from '~/components/ui/highlighter' import { PanelSplitter } from '~/components/ui/panel/PanelSplitter' +import { SegmentTab } from '~/components/ui/segment-tab' import { Link as LocalizedLink } from '~/i18n/routing' +import type { HeroTimelineItem } from '~/lib/landing-data' import { ListDemo } from '../simulators/ListDemo' import { TimelineChatDemo } from '../simulators/TimelineChatDemo' import { WindowChrome } from './WindowChrome' -export const LandingHero: Component = () => { +type LandingHeroProps = { + items: HeroTimelineItem[] +} + +type AudienceKey = 'human' | 'agent' + +export const LandingHero: Component<LandingHeroProps> = ({ items }) => { const ref = React.useRef<HTMLElement | null>(null) const { scrollYProgress } = useScroll({ target: ref, @@ -30,6 +38,28 @@ export const LandingHero: Component = () => { const bgY = useTransform(scrollYProgress, [0, 1], [0, -150]) const heroT = useTranslations('landing.hero') const actionsT = useTranslations('common.actions') + const [audience, setAudience] = React.useState<AudienceKey>('human') + + const audienceItems: { + value: AudienceKey + label: string + icon: React.ReactNode + }[] = [ + { + value: 'human', + label: heroT('humanTab'), + icon: ( + <i className="i-mingcute-user-3-line size-4 shrink-0" aria-hidden /> + ), + }, + { + value: 'agent', + label: heroT('agentTab'), + icon: ( + <i className="i-mingcute-android-2-line size-4 shrink-0" aria-hidden /> + ), + }, + ] return ( <section ref={ref} className="relative isolate w-full"> @@ -70,59 +100,86 @@ export const LandingHero: Component = () => { <p>{heroT('bodyLine2')}</p> </div> - <div className="mt-8 flex items-center"> - <NextLink - href="https://app.folo.is" - target="_blank" - rel="noreferrer noopener" - > - <span className="relative inline-flex"> - <Button className="group relative text-base overflow-hidden px-5 py-2.5 bg-accent text-accent-foreground shadow-[0_0_0_1px_var(--color-accent-40)] ![filter:drop-shadow(0_0_24px_color-mix(in_oklab,var(--color-accent)_35%,transparent))] rounded-xl"> - <span - aria-hidden - className={clsx( - 'pointer-events-none absolute -inset-1 rounded-[inherit] opacity-70 blur-md', - 'bg-[radial-gradient(closest-side,color-mix(in_oklab,var(--color-accent)_55%,transparent)_0%,transparent_70%)]', - )} - /> - - <span className="relative z-10 inline-flex items-center"> - {actionsT('getStarted')} + <div className="mt-8 max-w-xl"> + <SegmentTab + items={audienceItems} + value={audience} + onChange={(value) => setAudience(value as AudienceKey)} + containerClassName="inline-flex w-fit max-w-full rounded-full border-border/70 bg-background/45 shadow-[0_20px_40px_-32px_rgba(255,107,0,0.4)]" + className="!inset-y-0 !rounded-full !border-accent/20 !bg-background !shadow-[0_12px_24px_-16px_rgba(255,107,0,0.6)]" + activeClassName="text-accent" + inactiveClassName="text-text-secondary/75 hover:text-text" + distribution="fit" + size="lg" + responsiveWrap + /> + + {audience === 'human' ? ( + <div className="mt-4 flex flex-wrap items-center gap-3"> + <NextLink + href="https://app.folo.is" + target="_blank" + rel="noreferrer noopener" + > + <span className="relative inline-flex"> + <Button className="group relative overflow-hidden rounded-xl bg-accent px-5 py-2.5 text-base text-accent-foreground shadow-[0_0_0_1px_var(--color-accent-40)] ![filter:drop-shadow(0_0_24px_color-mix(in_oklab,var(--color-accent)_35%,transparent))]"> + <span + aria-hidden + className={clsx( + 'pointer-events-none absolute -inset-1 rounded-[inherit] opacity-70 blur-md', + 'bg-[radial-gradient(closest-side,color-mix(in_oklab,var(--color-accent)_55%,transparent)_0%,transparent_70%)]', + )} + /> + + <span className="relative z-10 inline-flex items-center"> + {actionsT('getStarted')} + </span> + <BorderBeam colorFrom="#fff" colorTo="#ff5c00" /> + </Button> + + <ParticlesAura className="-inset-2" /> </span> - <BorderBeam colorFrom="#fff" colorTo="#ff5c00" /> - </Button> - - <ParticlesAura className="-inset-2" /> - </span> - </NextLink> - <LocalizedLink href="/download"> - <Button variant="ghost" className="ml-4"> - <span className="relative z-10 inline-flex items-center text-base"> - {actionsT('download')} - </span> - </Button> - </LocalizedLink> - {/* <a - href="#learn-more" - className="text-text-secondary hover:text-text inline-flex items-center gap-1 text-sm" - > - <span className="rounded-full border border-border bg-fill px-2 py-1 text-xs"> - New - </span> - <span>Product Intelligence</span> - <i className="i-mingcute-right-line" aria-hidden /> - </a> */} + </NextLink> + <LocalizedLink href="/download"> + <Button variant="ghost"> + <span className="relative z-10 inline-flex items-center text-base"> + {actionsT('download')} + </span> + </Button> + </LocalizedLink> + </div> + ) : ( + <div className="bg-material-medium/60 border-border mt-4 rounded-2xl border p-5 backdrop-blur-md"> + <p className="text-text text-sm font-semibold"> + {heroT('agentTitle')} + </p> + <p className="text-text-secondary mt-2 text-sm leading-6"> + {heroT.rich('agentBody', { + skill: (chunks) => ( + <a + href="https://api.folo.is/skill.md" + target="_blank" + rel="noreferrer noopener" + className="text-accent underline decoration-accent/40 underline-offset-4 transition-colors hover:text-accent/80" + > + {chunks} + </a> + ), + })} + </p> + </div> + )} </div> </div> {/* Preview card with tilt + parallax; prompt focus zoom */} - <PreviewAppDemo /> + <PreviewAppDemo items={items} /> </div> </section> ) } -const PreviewAppDemo = () => { +const PreviewAppDemo = ({ items }: LandingHeroProps) => { const layoutContainerRef = useRef<HTMLDivElement | null>(null) const { position: columnWidth, @@ -147,7 +204,7 @@ const PreviewAppDemo = () => { className="relative lg:aspect-video h-[800px] lg:h-auto w-full bg-background-secondary" > <div style={{ width: columnWidth }} className="size-full"> - <ListDemo /> + <ListDemo items={items} /> {/* <ListSkeletonDemo /> */} </div> diff --git a/apps/landing/src/components/widgets/landing/RepoStats.tsx b/apps/landing/src/components/widgets/landing/RepoStats.tsx index 35c4fb1a207..1dbdae85489 100644 --- a/apps/landing/src/components/widgets/landing/RepoStats.tsx +++ b/apps/landing/src/components/widgets/landing/RepoStats.tsx @@ -33,7 +33,7 @@ export function RepoStats() { target="_blank" rel="noreferrer noopener" className={cx( - 'rounded-lg border border-border/70 bg-material-medium/60 hover:bg-fill-secondary transition-colors px-3 py-2 flex', + 'rounded-lg bg-material-medium/60 hover:bg-fill-secondary transition-colors px-3 py-2 flex', 'flex flex-col relative align-start text-left justify-start', 'col-span-2 lg:col-span-1', focusRing, @@ -70,7 +70,7 @@ function StatBox({ value: string | number }) { return ( - <div className="rounded-lg border border-border/70 z-[1] bg-material-medium/60 px-3 py-2"> + <div className="rounded-lg z-[1] bg-material-medium/60 px-3 py-2"> <div className="flex items-center gap-2 text-text"> <i className={cx('size-4', icon)} aria-hidden /> <span className="text-sm">{label}</span> diff --git a/apps/landing/src/components/widgets/landing/TrustedBy.tsx b/apps/landing/src/components/widgets/landing/TrustedBy.tsx new file mode 100644 index 00000000000..22f828e66e3 --- /dev/null +++ b/apps/landing/src/components/widgets/landing/TrustedBy.tsx @@ -0,0 +1,95 @@ +'use client' + +import { useTranslations } from 'next-intl' + +import type { LandingMetrics, TrustedCompany } from '~/lib/landing-data' + +type TrustedByProps = { + companies: TrustedCompany[] + researchers: TrustedCompany[] + metrics: LandingMetrics +} + +const compactFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +const formatCompact = (value: number) => compactFormatter.format(value) + +export const TrustedBy: Component<TrustedByProps> = ({ + companies, + researchers, + metrics, +}) => { + const trustedT = useTranslations('landing.trustedBy') + const metricsT = useTranslations('landing.metrics') + + const metricCards = [ + { + label: metricsT('cards.feeds.label'), + value: formatCompact(metrics.feeds), + }, + { + label: metricsT('cards.entries.label'), + value: formatCompact(metrics.entries), + }, + ] + + const renderTrustRow = (items: TrustedCompany[], eyebrow: string) => ( + <div className="flex flex-col items-center gap-5 text-center"> + <p className="text-[11px] font-medium uppercase tracking-[0.28em] text-accent/90"> + {eyebrow} + </p> + + <div className="flex flex-wrap items-center justify-center gap-x-8 gap-y-4"> + {items.map((item) => ( + <div key={item.name} className="inline-flex items-center gap-2.5"> + <div className="flex size-8 items-center justify-center rounded-full bg-background-secondary p-1"> + <img + src={`https://icons.folo.is/${item.host}`} + alt={item.name} + className="size-full rounded-full object-cover grayscale" + loading="lazy" + /> + </div> + <span className="text-sm font-medium text-text">{item.name}</span> + </div> + ))} + </div> + </div> + ) + + return ( + <section className="mx-auto mt-14 w-full max-w-[var(--container-max-width-2xl)] px-4 md:mt-16 lg:mt-20"> + <div className="flex flex-col gap-16"> + <div className="flex flex-col gap-8"> + {renderTrustRow(companies, trustedT('eyebrow'))} + {renderTrustRow(researchers, trustedT('researchersEyebrow'))} + </div> + + <div className="flex flex-col items-center gap-4 text-center"> + <p className="text-[11px] font-medium uppercase tracking-[0.28em] text-text-tertiary"> + {metricsT('headline')} + </p> + <p className="text-sm text-text-secondary">{metricsT('body')}</p> + + <div className="flex flex-wrap items-end justify-center gap-x-14 gap-y-4"> + {metricCards.map((card) => ( + <div key={card.label}> + <p className="text-[11px] uppercase tracking-[0.22em] text-text-tertiary"> + {card.label} + </p> + <p className="mt-1 text-3xl font-semibold tracking-tight text-text"> + {card.value} + </p> + </div> + ))} + </div> + </div> + </div> + </section> + ) +} + +TrustedBy.displayName = 'TrustedBy' diff --git a/apps/landing/src/components/widgets/pricing/PricingPlans.tsx b/apps/landing/src/components/widgets/pricing/PricingPlans.tsx new file mode 100644 index 00000000000..33281725c05 --- /dev/null +++ b/apps/landing/src/components/widgets/pricing/PricingPlans.tsx @@ -0,0 +1,297 @@ +'use client' + +import Link from 'next/link' +import { useTranslations } from 'next-intl' +import * as React from 'react' + +import { Button } from '~/components/ui/button/Button' +import { cx } from '~/lib/cn' +import type { PricingPlan, PricingPlanLimit } from '~/lib/pricing-data' +import { FEATURE_ORDER } from '~/lib/pricing-data' + +type BillingPeriod = 'monthly' | 'yearly' + +const AI_MODEL_SELECTION_LABELS = { + none: 'No AI model selection', + curated: 'Curated models', + high_performance: 'All high-end models', +} as const + +const formatCurrency = (value: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + trailingZeroDisplay: 'stripIfInteger', + }).format(value) + +const formatFeatureValue = ( + key: keyof PricingPlanLimit, + value: PricingPlanLimit[keyof PricingPlanLimit] | null | undefined, +) => { + if (value == null) { + return '—' + } + + if (key === 'AI_MODEL_SELECTION' && typeof value === 'string') { + return AI_MODEL_SELECTION_LABELS[ + value as keyof typeof AI_MODEL_SELECTION_LABELS + ] + } + + if (typeof value === 'boolean') { + return value ? '✓' : '—' + } + + if (value === Number.MAX_SAFE_INTEGER) { + return 'Unlimited' + } + + if (typeof value === 'number') { + return new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + }).format(value) + } + + if (Array.isArray(value)) { + return value.join(' · ') + } + + return String(value) +} + +const getVisibleFeatures = (plans: PricingPlan[]) => + FEATURE_ORDER.filter((key) => + plans.some((plan) => { + const value = plan.limit[key] + if (value == null) return false + if (typeof value === 'boolean') return value + if (typeof value === 'number') return value > 0 + if (Array.isArray(value)) return value.length > 0 + return value !== '0' + }), + ) + +export const PricingPlans: Component<{ plans: PricingPlan[] }> = ({ + plans, +}) => { + const pricingT = useTranslations('pricing') + const [billingPeriod, setBillingPeriod] = + React.useState<BillingPeriod>('yearly') + + const visiblePlans = React.useMemo( + () => plans.filter((plan) => plan.priceInDollars > 0), + [plans], + ) + const visibleFeatures = React.useMemo( + () => getVisibleFeatures(plans), + [plans], + ) + + const yearlySavings = React.useMemo(() => { + const paidPlans = visiblePlans.filter( + (plan) => plan.priceInDollars > 0 && plan.priceInDollarsAnnual > 0, + ) + if (paidPlans.length === 0) return 0 + + const total = paidPlans.reduce((acc, plan) => { + const monthlyTotal = plan.priceInDollars * 12 + const yearlyTotal = plan.priceInDollarsAnnual + return acc + ((monthlyTotal - yearlyTotal) / monthlyTotal) * 100 + }, 0) + + return Math.round(total / paidPlans.length) + }, [visiblePlans]) + + return ( + <section className="mx-auto mt-14 w-full max-w-[var(--container-max-width-2xl)] px-4 pb-28"> + <div className="mx-auto max-w-5xl text-center"> + <p className="text-[11px] font-medium uppercase tracking-[0.28em] text-accent/90"> + {pricingT('eyebrow')} + </p> + <h1 className="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl"> + {pricingT('headline')} + </h1> + <p className="mx-auto mt-4 max-w-2xl text-base leading-relaxed text-text-secondary"> + {pricingT('body')} + </p> + </div> + + <div className="mt-8 flex justify-center"> + <div className="inline-flex rounded-full border border-border bg-background-secondary p-1"> + <BillingTab + active={billingPeriod === 'monthly'} + label={pricingT('monthly')} + onClick={() => setBillingPeriod('monthly')} + /> + <BillingTab + active={billingPeriod === 'yearly'} + label={`${pricingT('yearly')} · ${pricingT('save', { + percent: yearlySavings, + })}`} + onClick={() => setBillingPeriod('yearly')} + /> + </div> + </div> + + <div className="mx-auto mt-10 grid max-w-6xl gap-4 lg:grid-cols-3"> + {visiblePlans.map((plan) => ( + <PlanCard key={plan.name} billingPeriod={billingPeriod} plan={plan} /> + ))} + </div> + + <div className="mx-auto mt-12 max-w-6xl overflow-hidden rounded-[28px] border border-border/70 bg-background/90"> + <div className="overflow-x-auto"> + <table className="w-full min-w-[920px] border-collapse"> + <thead> + <tr className="border-b border-border/70 bg-background-secondary/70"> + <th className="sticky left-0 z-10 bg-background-secondary/70 px-5 py-4 text-left text-sm font-semibold"> + {pricingT('table.features')} + </th> + {plans.map((plan) => ( + <th + key={plan.name} + className="px-5 py-4 text-center text-sm font-semibold" + > + {plan.name} + </th> + ))} + </tr> + </thead> + <tbody> + {visibleFeatures.map((featureKey, index) => ( + <tr + key={featureKey} + className={cx( + 'border-b border-border/70', + index % 2 === 0 + ? 'bg-background' + : 'bg-background-secondary/30', + )} + > + <td className="sticky left-0 z-10 bg-inherit px-5 py-4 text-sm font-medium text-text"> + {pricingT(`features.${featureKey}`)} + </td> + {plans.map((plan) => ( + <td + key={`${plan.name}-${featureKey}`} + className="px-5 py-4 text-center text-sm text-text-secondary" + > + {formatFeatureValue(featureKey, plan.limit[featureKey])} + </td> + ))} + </tr> + ))} + </tbody> + </table> + </div> + </div> + </section> + ) +} + +const BillingTab = ({ + active, + label, + onClick, +}: { + active: boolean + label: string + onClick: () => void +}) => ( + <button + type="button" + onClick={onClick} + className={cx( + 'rounded-full px-4 py-2 text-sm transition-colors', + active + ? 'bg-accent text-white shadow-[0_12px_30px_-18px_rgba(255,92,0,0.65)]' + : 'text-text-secondary hover:text-text', + )} + > + {label} + </button> +) + +const PlanCard = ({ + billingPeriod, + plan, +}: { + billingPeriod: BillingPeriod + plan: PricingPlan +}) => { + const pricingT = useTranslations('pricing') + const isYearly = billingPeriod === 'yearly' + const price = isYearly ? plan.priceInDollarsAnnual / 12 : plan.priceInDollars + const annualTotal = plan.priceInDollarsAnnual + + return ( + <div + className={cx( + 'relative flex h-full flex-col justify-between rounded-[28px] border p-6 transition-colors', + plan.isPopular + ? 'border-accent/35 bg-background shadow-[0_26px_80px_-56px_rgba(255,92,0,0.55)]' + : 'border-border/70 bg-background/88', + )} + > + {plan.isPopular ? ( + <div className="absolute right-5 top-5 rounded-full bg-accent px-2.5 py-1 text-[11px] font-semibold text-white"> + {pricingT('mostPopular')} + </div> + ) : null} + + <div> + <p className="text-2xl font-semibold text-text">{plan.name}</p> + <p className="mt-2 text-sm leading-relaxed text-text-secondary"> + {pricingT(`descriptions.${plan.role}`)} + </p> + + <div className="mt-6 flex items-end gap-2"> + <span className="text-4xl font-semibold tracking-tight text-text"> + {formatCurrency(price)} + </span> + <span className="pb-1 text-sm text-text-secondary"> + / {pricingT('month')} + </span> + </div> + + {isYearly && annualTotal > 0 ? ( + <p className="mt-2 text-sm text-text-tertiary"> + {pricingT('billedYearly', { + total: formatCurrency(annualTotal), + })} + </p> + ) : null} + + <ul className="mt-6 space-y-3"> + {FEATURE_ORDER.slice(0, 6).map((featureKey) => ( + <li + key={featureKey} + className="flex gap-3 text-sm text-text-secondary" + > + <span className="mt-[7px] size-1.5 shrink-0 rounded-full bg-accent" /> + <span> + <span className="text-text"> + {formatFeatureValue(featureKey, plan.limit[featureKey])} + </span>{' '} + {pricingT(`features.${featureKey}`)} + </span> + </li> + ))} + </ul> + </div> + + <div className="mt-8"> + <Link + href="https://app.folo.is" + target="_blank" + rel="noreferrer noopener" + > + <Button className="box-border w-full max-w-full px-4 text-sm"> + {plan.upgradeButtonText || pricingT('getStarted')} + </Button> + </Link> + </div> + </div> + ) +} diff --git a/apps/landing/src/components/widgets/simulators/EntryChatPanel.tsx b/apps/landing/src/components/widgets/simulators/EntryChatPanel.tsx index 3a27909669e..baf7d626d12 100644 --- a/apps/landing/src/components/widgets/simulators/EntryChatPanel.tsx +++ b/apps/landing/src/components/widgets/simulators/EntryChatPanel.tsx @@ -12,7 +12,7 @@ export const EntryChatPanel: React.FC<EntryChatPanelProps> = ({ playTimeline = false, }) => { return ( - <aside className="absolute shadow-2xl z-10 right-0 inset-y-0 flex flex-col w-[calc(100vw-100px)] lg:w-[400px] bg-background border-l"> + <aside className="absolute shadow-2xl z-10 right-0 inset-y-0 flex flex-col w-[calc(100vw-100px)] lg:w-[360px] bg-background border-l"> <ListChatPlayer autoplay={playTimeline} steps={AI_SUMMARY_STEPS} diff --git a/apps/landing/src/components/widgets/simulators/EntryPage.tsx b/apps/landing/src/components/widgets/simulators/EntryPage.tsx index fb958863443..58a3f2b44de 100644 --- a/apps/landing/src/components/widgets/simulators/EntryPage.tsx +++ b/apps/landing/src/components/widgets/simulators/EntryPage.tsx @@ -5,7 +5,6 @@ import * as React from 'react' import { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea' import { EntryPageOverlay } from './components/EntryPageOverlay' -import { SimulatorReaders } from './components/SimulatorReaders' import { EntryChatPanel } from './EntryChatPanel' import { ENTRY_DETAIL } from './mocks' @@ -21,63 +20,140 @@ export const EntryPageDemo = () => { <EntryPageOverlay /> <ScrollArea data-simulator="entry-page" - rootClassName="relative @container" - viewportClassName="max-w-3xl mx-auto py-12" + rootClassName="relative h-full @container" + viewportClassName="px-8 py-12 lg:pl-16 lg:pr-[390px]" > - <h2 className="inline-block select-text wrap-break-word text-2xl font-bold leading-normal"> - <span className="text-text inline-block select-text hyphens-auto"> - Categorize Your Dependencies - </span> - - {/* Meta bar */} - </h2> - - <div className="text-text-secondary flex flex-wrap items-center gap-4 mt-3"> - <div className="flex items-center text-xs font-medium gap-2"> - <img - src={ENTRY_DETAIL.author.avatar} - className="object-cover rounded-sm mr-2 size-4" - /> - {ENTRY_DETAIL.author.name} - </div> - <div className="flex items-center gap-1.5"> - <i className="i-mingcute-user-3-line size-4" /> - <span className="text-xs font-medium"> - {/* hi@antfu.me (Anthony Fu) */} - {ENTRY_DETAIL.author.email} ({ENTRY_DETAIL.author.name}) - </span> - </div> - <div className="flex items-center gap-1.5"> - <i className="i-mingcute-calendar-time-add-line size-4" /> - <span className="text-xs tabular-nums"> - {ENTRY_DETAIL.author.date} - </span> + <div className="flex min-h-[640px] items-center"> + <div className="group relative block w-full max-w-[860px] min-w-0"> + <div className="flex flex-col gap-3"> + <div className="flex flex-wrap items-center gap-3 text-sm text-text-secondary"> + <div className="flex items-center gap-2"> + <div className="flex size-6 items-center justify-center rounded-full border border-border/70 bg-white p-1"> + <img + src={ENTRY_DETAIL.source.icon} + alt={ENTRY_DETAIL.source.name} + className="size-full rounded-full object-cover" + /> + </div> + <span className="text-xs font-medium"> + {ENTRY_DETAIL.source.name} + </span> + </div> + <span className="text-xs text-text-tertiary"> + {ENTRY_DETAIL.source.host} + </span> + </div> + + <h2 className="inline-block select-text wrap-break-word text-[2.1rem] font-bold leading-tight"> + <span className="text-text inline-block select-text hyphens-auto"> + {ENTRY_DETAIL.title} + </span> + </h2> + + <div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm"> + <div className="flex flex-wrap items-center gap-4 text-text-secondary"> + <div className="flex items-center gap-1.5"> + <i className="i-mgc-user-3-cute-re text-base" /> + <span className="text-xs font-medium"> + {ENTRY_DETAIL.author.name} + </span> + </div> + + <div className="flex items-center gap-1.5"> + <i className="i-mgc-tag-3-cute-re text-base" /> + <span className="text-xs font-medium"> + {ENTRY_DETAIL.author.role} + </span> + </div> + + <div className="flex items-center gap-1.5"> + <i className="i-mgc-calendar-time-add-cute-re text-base" /> + <span className="text-xs tabular-nums"> + {ENTRY_DETAIL.author.dateLabel} + </span> + </div> + + <div className="flex items-center gap-1.5"> + <i className="i-mgc-time-cute-re text-base" /> + <span className="text-xs tabular-nums"> + {ENTRY_DETAIL.author.readingTime} + </span> + </div> + </div> + </div> + + <EntryContentPreview /> + </div> </div> </div> - - <SimulatorReaders /> - <EntryContentSkeleton /> </ScrollArea> <EntryChatPanel playTimeline={isInView} /> </div> ) } -const EntryContentSkeleton = () => { +const bodyParagraphs = [ + 'The newest generation of agent tooling is forcing teams to think less about prompts in isolation and more about end-to-end system design.', + 'Once a model can search, call tools, draft structured work, or trigger external actions, reliability becomes a product question rather than a model benchmark question.', +] + +const sectionBlocks = [ + { + title: 'What teams are optimizing for', + paragraphs: [ + 'Accuracy still matters, but operational quality is now the real differentiator: predictable tool use, bounded retries, and evaluation sets that mirror production traffic.', + 'Many failures that look like model mistakes are really interface mistakes: vague tool descriptions, missing state, or workflows that ask one agent to do too much in a single step.', + ], + }, +] + +const checklistItems = [ + 'Start with one narrow task and one or two tools.', + 'Log every tool call, retry, and failure reason.', + 'Add evals before widening the workflow.', + 'Keep a human in the loop for risky actions.', +] + +const EntryContentPreview = () => { return ( - <div className="mt-4 flex flex-col gap-2"> - <div className="w-full h-6 rounded-md bg-material-medium" /> - <div className="w-full h-6 rounded-md bg-material-medium" /> - <div className="w-full h-6 rounded-md bg-material-medium" /> - <div className="w-full h-6 rounded-md bg-material-medium" /> - <div className="w-36 h-6 rounded-md bg-material-medium" /> - - <div className="h-6" /> - - <div className="w-full h-6 rounded-md bg-material-medium" /> - <div className="w-full h-6 rounded-md bg-material-medium" /> - <div className="w-full h-6 rounded-md bg-material-medium" /> - <div className="w-24 h-6 rounded-md bg-material-medium" /> + <div className="mt-5 space-y-5"> + <div className="space-y-4 text-[15px] leading-8 text-text-secondary"> + {bodyParagraphs.map((paragraph) => ( + <p key={paragraph}>{paragraph}</p> + ))} + </div> + + {sectionBlocks.map((section) => ( + <section key={section.title} className="space-y-3"> + <h3 className="text-xl font-semibold tracking-tight text-text"> + {section.title} + </h3> + <div className="space-y-4 text-[15px] leading-8 text-text-secondary"> + {section.paragraphs.map((paragraph) => ( + <p key={paragraph}>{paragraph}</p> + ))} + </div> + </section> + ))} + + <blockquote className="border-l-2 border-accent pl-4 text-[15px] leading-8 text-text-secondary"> + “The most dependable agent products are the ones that expose their + workflow clearly enough for teams to inspect, test, and intervene.” + </blockquote> + + <section className="space-y-3"> + <h3 className="text-xl font-semibold tracking-tight text-text"> + Operating checklist + </h3> + <ul className="space-y-3 text-[15px] leading-8 text-text-secondary"> + {checklistItems.map((item) => ( + <li key={item} className="flex gap-3"> + <span className="mt-[13px] size-1.5 shrink-0 rounded-full bg-accent" /> + <span>{item}</span> + </li> + ))} + </ul> + </section> </div> ) } diff --git a/apps/landing/src/components/widgets/simulators/ListDemo.tsx b/apps/landing/src/components/widgets/simulators/ListDemo.tsx index 575c4d6bc3f..a7c5b0b4eed 100644 --- a/apps/landing/src/components/widgets/simulators/ListDemo.tsx +++ b/apps/landing/src/components/widgets/simulators/ListDemo.tsx @@ -1,149 +1,155 @@ import { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea' +import type { HeroTimelineItem } from '~/lib/landing-data' -export const ListDemo = () => { +export const ListDemo = ({ + items = DEFAULT_ITEMS, +}: { + items?: HeroTimelineItem[] +}) => { return ( - <div className="flex flex-col min-w-0 select-none"> - {Items.map((item) => ( + <div className="flex flex-col min-w-0 select-none bg-background"> + {items.map((item) => ( <div key={item.title} - className="py-3 pl-4 min-w-0 flex truncate max-w-full hover:bg-fill-secondary" + className="group flex min-w-0 max-w-full items-center gap-3 border-b border-border/40 px-4 py-3 transition-colors hover:bg-fill-secondary/80" > - <div className="flex items-center gap-2 min-w-0"> + <div className="flex min-w-0 items-center gap-3"> <img - src={item.icon} + src={`https://icons.folo.is/${item.host}`} alt={item.title} - className="size-4 rounded-md" + className="size-8 rounded-lg border border-border/60 bg-white/85 p-1" /> - <div className="flex flex-row min-w-0 items-center gap-2 truncate shrink"> - <h3 className="text-sm font-bold min-w-0 shrink truncate"> - {item.title} - </h3> - <p className="text-sm text-text-secondary min-w-0 truncate mr-1 shrink flex-1"> + <div className="min-w-0 shrink"> + <div className="flex min-w-0 items-center gap-2"> + <h3 className="min-w-0 shrink truncate text-sm font-bold"> + {item.title} + </h3> + <span className="shrink-0 rounded-full border border-border/70 bg-background px-2 py-0.5 text-[10px] font-medium text-text-tertiary"> + {formatCompact(item.subscriptions)} + </span> + </div> + <p className="mt-0.5 truncate text-sm text-text-secondary"> {item.description} </p> </div> </div> + + <p className="min-w-0 flex-1 truncate text-right text-xs text-text-tertiary transition-colors group-hover:text-text-secondary"> + {item.host} + </p> </div> ))} </div> ) } -const Items: Array<{ - icon: string - title: string - description: string -}> = [ - { - icon: '/icon-192x192.png', - title: 'AI Safety vs Scalability', - description: 'Why safety and scale can align in modern AI.', - }, - { - icon: '/icon-192x192.png', - title: 'The Cyber60 Podcast', - description: 'Exploring how to build security in an AI-first world.', - }, - { - icon: '/icon-192x192.png', - title: 'Robotics in Data Centers', - description: 'Automating GPU server assembly to meet compute demand.', - }, - { - icon: '/icon-192x192.png', - title: 'Horizontal Robotics Tools', - description: 'Simulation platforms and sensor stacks for infra robots.', - }, - { - icon: '/icon-192x192.png', - title: 'Sensor Data Infrastructure', - description: 'Synchronizing multi-sensor data for robot training.', - }, - { - icon: '/icon-192x192.png', - title: 'AI Evolution Theme', - description: 'A day of pieces tracing AI’s accelerating trajectory.', - }, - { - icon: '/icon-192x192.png', - title: 'Leadership Debate', - description: 'Why some say Stanford GSB isn’t the default for leaders.', - }, - { - icon: '/icon-192x192.png', - title: 'Zcash and Privacy', - description: 'A privacy-focused alternative in crypto conversations.', - }, +const compactFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +const formatCompact = (value: number) => compactFormatter.format(value) + +const DEFAULT_ITEMS: HeroTimelineItem[] = [ { - icon: '/icon-192x192.png', - title: 'AI Infrastructure Reshape', - description: 'Raghu Raghuram on robots transforming infra buildouts.', + host: 'openai.com', + title: 'OpenAI News', + description: 'The OpenAI blog.', + href: 'https://openai.com/news', + subscriptions: 27710, }, { - icon: '/icon-192x192.png', - title: 'Scalable Data Center Builds', - description: 'Robots enable rapid assembly beyond human-only labor.', + host: 'anthropic.com', + title: 'Anthropic News', + description: 'Latest news from Anthropic.', + href: 'https://www.anthropic.com/news', + subscriptions: 998, }, { - icon: '/icon-192x192.png', - title: 'Simulation-Driven Robotics', - description: 'Testing workflows virtually before real-world deployment.', + host: 'github.blog', + title: 'The GitHub Blog', + description: 'Updates, ideas, and inspiration from GitHub.', + href: 'https://github.blog/', + subscriptions: 24774, }, { - icon: '/icon-192x192.png', - title: 'Sensor Fusion Pipelines', - description: 'Managing streams from cameras, lidar, and IMUs.', + host: 'nature.com', + title: 'Nature', + description: 'Read the latest research articles from Nature.', + href: 'https://www.nature.com/nature/research-articles', + subscriptions: 24491, }, { - icon: '/icon-192x192.png', - title: 'AI x Security', - description: 'Forging security practices in AI-powered environments.', + host: 'theverge.com', + title: 'The Verge', + description: 'Breaking news, reviews, and features about tech.', + href: 'https://www.theverge.com/', + subscriptions: 24833, }, { - icon: '/icon-192x192.png', - title: 'Compute Supply Crunch', - description: 'Demand for GPU clusters pushes automation forward.', + host: 'apod.nasa.gov', + title: 'NASA Astronomy Picture of the Day', + description: 'The daily image and story from the universe around us.', + href: 'https://apod.nasa.gov/apod/archivepix.html', + subscriptions: 29277, }, { - icon: '/icon-192x192.png', - title: 'Operational Tooling', - description: 'Stacks to orchestrate and monitor robotics at scale.', + host: 'lastweekin.ai', + title: 'Last Week in AI', + description: 'Weekly text and audio summaries of the biggest AI stories.', + href: 'https://lastweekin.ai/', + subscriptions: 30381, }, { - icon: '/icon-192x192.png', - title: 'Casual Daily Recap', - description: 'Friendly overview of standout topics from your timeline.', + host: 'magazine.sebastianraschka.com', + title: 'Ahead of AI', + description: 'Machine learning and AI research for readers who stay ahead.', + href: 'https://magazine.sebastianraschka.com/', + subscriptions: 27310, }, { - icon: '/icon-192x192.png', - title: 'AI Safety Framing', - description: 'Treating safety and capability as co-optimized goals.', + host: 'ted.com', + title: 'TED Talks Daily', + description: 'Thought-provoking ideas in audio, delivered every day.', + href: 'https://www.ted.com/', + subscriptions: 25784, }, { - icon: '/icon-192x192.png', - title: 'Infra Stack New Layer', - description: 'Robotics creates a fresh layer in the tech stack.', + host: 'nytimes.com', + title: 'The Daily', + description: 'The biggest stories of our time, told by NYT journalists.', + href: 'https://www.nytimes.com/the-daily', + subscriptions: 23638, }, { - icon: '/icon-192x192.png', - title: 'Podcast Signal: Cyber60', - description: 'Expect deep dives into security in an AI era.', + host: 'youtube.com', + title: '3Blue1Brown', + description: 'Visual explanations in math, physics, and computer science.', + href: 'https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw', + subscriptions: 24897, }, { - icon: '/icon-192x192.png', - title: 'Nothing Else Stands Out?', - description: 'When it’s quiet, the recap says so—naturally.', + host: 'bsky.app', + title: 'Bluesky', + description: 'The official Bluesky account and product updates.', + href: 'https://bsky.app/profile/bsky.app', + subscriptions: 24116, }, ] export const ListSkeletonDemo = () => { + const skeletonRows = Array.from( + { length: 20 }, + (_, index) => `skeleton-${index}`, + ) + return ( <div className="flex flex-col min-w-0 relative grow size-full"> <div className="absolute inset-0"> <ScrollArea rootClassName="size-full"> - {Array.from({ length: 20 }).map((_, index) => ( + {skeletonRows.map((key) => ( <div - key={index} + key={key} className="py-3 pl-4 min-w-0 flex truncate max-w-full hover:bg-material-medium" > <div className="flex items-center gap-2 grow"> diff --git a/apps/landing/src/components/widgets/simulators/components/EntryPageOverlay.tsx b/apps/landing/src/components/widgets/simulators/components/EntryPageOverlay.tsx index 4addc2c24e8..0a2df4e0d33 100644 --- a/apps/landing/src/components/widgets/simulators/components/EntryPageOverlay.tsx +++ b/apps/landing/src/components/widgets/simulators/components/EntryPageOverlay.tsx @@ -1,5 +1,5 @@ export const EntryPageOverlay = () => { return ( - <div className="absolute z-8 bg-linear-to-r from-background/20 to-background/70 inset-0" /> + <div className="absolute inset-0 z-8 bg-linear-to-r from-background/6 via-background/8 to-background/72" /> ) } diff --git a/apps/landing/src/components/widgets/simulators/components/SimulatorReaders.tsx b/apps/landing/src/components/widgets/simulators/components/SimulatorReaders.tsx deleted file mode 100644 index ad7ad66e37d..00000000000 --- a/apps/landing/src/components/widgets/simulators/components/SimulatorReaders.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '~/components/ui/tooltip' - -import { ENTRY_DETAIL } from '../mocks' - -export const SimulatorReaders = () => { - return ( - <div className="items-center relative flex mt-4 -space-x-2"> - {ENTRY_DETAIL.readers.map((reader) => { - return ( - <Tooltip delayDuration={0} key={reader.id}> - <TooltipTrigger className="relative flex shrink-0 overflow-hidden rounded-full border-border ring-background aspect-square size-6 border ring-1"> - <img - className="aspect-square size-full bg-material-ultra-thick" - src={reader.avatar} - /> - </TooltipTrigger> - <TooltipContent>Recent reader: {reader.name}</TooltipContent> - </Tooltip> - ) - })} - </div> - ) -} diff --git a/apps/landing/src/components/widgets/simulators/mocks.tsx b/apps/landing/src/components/widgets/simulators/mocks.tsx index fadd5feab59..b62747c0b5c 100644 --- a/apps/landing/src/components/widgets/simulators/mocks.tsx +++ b/apps/landing/src/components/widgets/simulators/mocks.tsx @@ -4,44 +4,34 @@ import { TooltipTrigger, } from '~/components/ui/tooltip' -export type Reader = { - id: string - name: string - avatar: string -} - export type EntryDetail = { title: string + source: { + name: string + host: string + icon: string + } author: { name: string - avatar: string - email: string - date: string + role: string + dateLabel: string + readingTime: string } - readers: Reader[] } export const ENTRY_DETAIL: EntryDetail = { - title: 'Categorization Your Dependencies', + title: 'Building effective agents for production teams', + source: { + name: 'Anthropic', + host: 'anthropic.com', + icon: 'https://icons.folo.is/anthropic.com', + }, author: { - name: 'Anthony Fu', - avatar: - 'https://webp.follow.is?url=https%3A%2F%2Fantfu.me%2Favatar.png&width=32&height=32', - email: 'hi@antfu.me', - date: 'Apr 28, 2025 10:00 PM', + name: 'Anthropic Research', + role: 'Agent systems guide', + dateLabel: 'Updated recently', + readingTime: '8 min read', }, - readers: [ - { - id: '1', - name: 'Hyoban', - avatar: 'https://avatars-githubusercontent-webp.webp.se/u/38493346?v=4', - }, - { - id: '2', - name: 'Innei', - avatar: 'https://avatars-githubusercontent-webp.webp.se/u/41265413?v=4', - }, - ], } type ToolInvocation = { @@ -53,16 +43,16 @@ type ToolInvocation = { export const AI_TOOL_INVOCATION: ToolInvocation = { toolName: 'getEntry', input: `{ - "id": "139726960627129344", + "id": "184203101927882752", "mode": "detailed" }`, output: `{ - "id": "139726960627129344", - "title": "Categorize Your Dependencies", - "url": "https://antfu.me/posts/categorize-deps", - "author": "hi@antfu.me (Anthony Fu)", - "publishedAt": "2025-04-28T14:00:00.612Z", - "feedId": "39296322994753536", + "id": "184203101927882752", + "title": "Building effective agents for production teams", + "url": "https://www.anthropic.com/news", + "author": "Anthropic Research", + "publishedAt": "2026-03-14T09:30:00.000Z", + "feedId": "249743826993702912", "subtitles": null, "transcription": null } @@ -114,9 +104,10 @@ export type AI_CHAT_STEP = UserStep | AssistantStep | ActionStep export const AI_SUMMARY_STEPS: AI_CHAT_STEP[] = [ { role: 'user', - message: 'What talking about this article, and how to solve this problem?', + message: + 'What changed here, and what should an engineering team actually do next?', context: { - current: 'Categorization Your Dependencies', + current: 'Building effective agents for production teams', className: 'text-orange', icon: <i className="size-2.5 i-mingcute-star-fill" />, }, @@ -130,66 +121,43 @@ export const AI_SUMMARY_STEPS: AI_CHAT_STEP[] = [ { role: 'assistant', type: 'reasoning', - text: "**Summarizing actionable steps**\n\nI need to provide a summary of the article's main points and how to address the identified problem effectively. First, I’ll focus on categorizing dependencies using pnpm catalogs and utilizing tooling support such as a VSCode extension. The actionable steps should include setting up pnpm-workspace.yaml, defining categories like test and build, and running audits. My step-by-step plan involves mapping categories, adding catalogs, updating dependencies, installing...", + text: '**Finding the operational delta**\n\nThe article is not pitching agents as a prompt trick. It is framing production agents as a systems problem: tool boundaries, state, evals, retry policy, and approval gates matter more than flashy demos. I should separate platform claims from the concrete engineering steps a team would need to ship safely.', }, { role: 'assistant', type: 'reasoning', - text: "**Updating CI and Tooling**\n\nI need to detail steps for updating CI and tooling, including using taze and eslint-plugin-pnpm, along with adjusting the build configuration to reference catalogs. I’ll run tests and builds while modifying optimizeDeps in Vite/unbuild using readWorkspaceYaml. Additionally, I should verify with node-modules-inspector and implement a gradual rollout by migrating packages in phases while monitoring for dependency diffs and vulnerabilities.\n\nIt's important to note the b...", + text: '**Reducing it to a production checklist**\n\nThe strongest through-line is that agent quality comes from narrower loops and better observability. The answer should emphasize explicit tool contracts, structured outputs, test cases for failure modes, and human review at expensive or risky steps.', }, { role: 'assistant', type: 'reasoning', - text: "**Providing Actionable Steps**\n\nI want to include actionable steps and command snippets for using pnpm, such as in pnpm-workspace.yaml and package.json, while avoiding heavy formatting. It’s okay to mention the entry instead of repeating full code, but I’ll provide enough snippets to be helpful. \n\nI’ll ensure to use Markdown effectively, including inline citations when mentioning the pnpm catalogs feature. The goal is to be concise while offering clear, actionable steps. I'll also mention releva...", + text: '**Shaping the final response**\n\nI can keep this useful by translating the article into three layers: what the article claims, what signals are worth keeping, and what a team should instrument before calling an agent "production-ready."', }, { role: 'action', type: 'set-title', - title: 'PNPM Catalogs for Dependency Categorization in Monorepos', + title: 'How to move AI agents from demos to production', }, { role: 'assistant', type: 'markdown', - message: `Short summary — what the article says -- The post argues that the simple split between "dependencies" and "devDependencies" no longer captures why a package is installed (build tool, lint/test, runtime, IDE types, inlined into bundles, etc.). Instead, it proposes categorizing dependencies (test, lint, build, frontend, backend, types, inlined, prod, …) and shows how pnpm’s new “catalogs” (including named catalogs) let you express those categories centrally and consistently in a monorepo. It also shows practical tooling and UX improvements (VS Code lens, taze, eslint-plugin-pnpm, node-modules-inspector) to make catalogs workable in real projects. - -Why this matters (the problem) -- package.json’s two buckets are overloaded and ambiguous: the same package in devDependencies could mean very different things in different contexts (types-only, test-only, inlined, etc.). -- That ambiguity makes reviews, upgrades, bundling, and tooling behavior harder to reason about. -- Moving version/category metadata into a centralized catalog can make dependency intent explicit and enable better tooling decisions. - -How to solve it — practical, runnable plan -1. Pick categories you need - - Examples: test, lint, build, script, frontend, backend, types, inlined, prod. Decide what each means for your project. - -2. Add catalogs to your pnpm workspace - - In pnpm-workspace.yaml, add a catalog or named catalogs with the package versions you want to manage centrally. (The article shows examples.) - -3. Reference catalogs from package.json - - Replace explicit versions with catalog references, e.g. "vue": "catalog:frontend" or "vue": "catalog:". PNPM resolves them at install time. - -4. Install and validate - - Run pnpm install. Use the workspace tools to confirm the resolved versions match expectations. - -5. Add UX/tooling so the change is manageable - - Install PNPM Catalog Lens (VS Code extension) to show resolved versions inline. - - Use node-modules-inspector to visualize catalog origins. - - Use eslint-plugin-pnpm to enforce catalog usage and auto-fix where applicable. - -6. Integrate catalogs into build & dev tooling - - Read catalogs from pnpm-workspace.yaml in tool configs (Vite optimizeDeps, unbuild externals/inline lists) so bundlers/optimizers act on the new categories instead of relying on dependencies/devDependencies. - -7. Automate version bumps & reviews - - Use tools that understand catalogs (taze, or CI scripts) to check and bump versions centrally so PRs show the actual version changes in one place (pnpm-workspace.yaml). - -8. Roll out gradually and monitor - - Migrate a small package or one category first, run CI/tests, confirm bundling and runtime behavior, then continue. Keep comments in pnpm-workspace.yaml to explain decisions to teammates. - -Benefits and caveats -- Benefits: clearer intent per package, centralized version control, easier upgrade/review, potential to inform bundlers/linters, better context for vulnerability risk. -- Caveats: catalogs are pnpm-specific (and target monorepos best), package.json loses inline version numbers (so tooling is needed to surface resolved versions), ecosystem/tooling is still evolving—expect to add integrations. - -If you want, you can follow the article’s examples for the pnpm-workspace.yaml and the package.json changes and adopt the VS Code PNPM Catalog Lens + eslint-plugin-pnpm to make migration smooth. + message: `Short summary +- The article argues that strong agent products come from tighter system design, not just stronger prompts. Teams are getting better results by shrinking the loop: clear tool contracts, explicit handoffs, structured outputs, and smaller scopes per step. + +What matters +- The main bottleneck is reliability. Once an agent can call tools or trigger side effects, every unclear interface becomes an operational bug. +- Evaluation is part of the product. Teams need regression suites for tool use, refusal behavior, retries, latency, and cost. +- Human approval still matters at high-risk steps. Good production systems use agents to draft, classify, and route work before they let them act. + +What to do next +1. Start with a narrow task and one or two tools. +2. Force structured outputs instead of free-form text. +3. Log every tool call, retry, and failure mode. +4. Add evals before expanding scope. +5. Put human review in front of expensive, external, or irreversible actions. + +Bottom line +- The winning pattern is not “one model does everything.” It is a measured workflow where the model reasons inside clear boundaries and the product team can inspect every step. `, }, ] diff --git a/apps/landing/src/constants/download.ts b/apps/landing/src/constants/download.ts index 0fe7b45ebeb..506426be7df 100644 --- a/apps/landing/src/constants/download.ts +++ b/apps/landing/src/constants/download.ts @@ -1,23 +1,103 @@ -type OS = 'macOS' | 'Windows' | 'Linux' -export const PlatformStoreLinkMap = { - // iOS: { - // link: 'https://apps.apple.com/us/app/folo-follow-everything/id6739802604', - // name: 'App Store', - // }, - macOS: { - link: 'https://apps.apple.com/us/app/folo-follow-everything/id6739802604', - name: 'Mac App Store', +import { siteInfo } from './site' + +export type OS = 'iOS' | 'Android' | 'macOS' | 'Windows' | 'Linux' + +export type PlatformDownloadChannel = { + id: string + href: string + name: string + description: string +} + +export type PlatformDownloadGroup = { + os: OS + label: string + icon: string + channels: PlatformDownloadChannel[] +} + +export const PlatformDownloadGroups: PlatformDownloadGroup[] = [ + { + os: 'iOS', + label: 'iOS', + icon: 'i-simple-icons-apple', + channels: [ + { + id: 'ios-app-store', + href: 'https://apps.apple.com/us/app/folo-follow-everything/id6739802604', + name: 'App Store', + description: 'Install on iPhone and iPad', + }, + ], }, - Windows: { - link: 'https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct', - name: 'Microsoft Store', + { + os: 'Android', + label: 'Android', + icon: 'i-simple-icons-googleplay', + channels: [ + { + id: 'android-google-play', + href: 'https://play.google.com/store/apps/details?id=is.follow', + name: 'Google Play', + description: 'Automatic updates', + }, + { + id: 'android-apk', + href: siteInfo.releaseLink, + name: 'Direct Download (APK)', + description: 'Install from the latest GitHub release', + }, + ], }, - Linux: { - link: 'https://github.com/RSSNext/Folo/releases/latest', - name: 'GitHub', + { + os: 'macOS', + label: 'macOS', + icon: 'i-simple-icons-apple', + channels: [ + { + id: 'macos-app-store', + href: 'https://apps.apple.com/us/app/folo-follow-everything/id6739802604', + name: 'Mac App Store', + description: 'Automatic updates', + }, + { + id: 'macos-dmg', + href: siteInfo.releaseLink, + name: 'Direct Download (DMG)', + description: 'Install from the latest GitHub release', + }, + ], }, - // Android: { - // link: 'https://play.google.com/store/apps/details?id=is.follow', - // name: 'Google Play', - // }, -} satisfies Record<OS, { link: string; name: string } | undefined> + { + os: 'Windows', + label: 'Windows', + icon: 'i-simple-icons-windows', + channels: [ + { + id: 'windows-store', + href: 'https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct', + name: 'Microsoft Store', + description: 'Automatic updates', + }, + { + id: 'windows-exe', + href: siteInfo.releaseLink, + name: 'Direct Download (EXE)', + description: 'Install from the latest GitHub release', + }, + ], + }, + { + os: 'Linux', + label: 'Linux', + icon: 'i-simple-icons-linux', + channels: [ + { + id: 'linux-github', + href: siteInfo.releaseLink, + name: 'GitHub Release', + description: 'AppImage and other release assets', + }, + ], + }, +] diff --git a/apps/landing/src/lib/landing-data.test.ts b/apps/landing/src/lib/landing-data.test.ts new file mode 100644 index 00000000000..6bb2be6e697 --- /dev/null +++ b/apps/landing/src/lib/landing-data.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +import { TRUSTED_RESEARCH_INSTITUTIONS } from './landing-data' + +describe('TRUSTED_RESEARCH_INSTITUTIONS', () => { + it('keeps the QS-sorted top 10 institutions stable', () => { + expect(TRUSTED_RESEARCH_INSTITUTIONS).toHaveLength(10) + expect(TRUSTED_RESEARCH_INSTITUTIONS.map((item) => item.name)).toEqual([ + 'MIT', + 'Stanford', + 'Oxford', + 'Harvard', + 'Cambridge', + 'NUS', + 'UCL', + 'NTU Singapore', + 'Peking University', + 'UPenn', + ]) + }) +}) diff --git a/apps/landing/src/lib/landing-data.ts b/apps/landing/src/lib/landing-data.ts new file mode 100644 index 00000000000..d368c6a160c --- /dev/null +++ b/apps/landing/src/lib/landing-data.ts @@ -0,0 +1,513 @@ +const PRODUCTION_API_URL = 'https://api.follow.is' +export const PRODUCTION_RSSHUB_ROUTES_URL = + 'https://docs.rsshub.app/routes.json' +const isDevelopment = process.env.NODE_ENV !== 'production' + +const HERO_ITEM_LIMIT = 12 + +const HERO_HOST_LIMITS: Record<string, number> = { + 'youtube.com': 1, + 'x.com': 1, +} + +export type HeroTimelineItem = { + title: string + description: string + host: string + href: string + subscriptions: number +} + +export type DiscoverSource = { + key: string + name: string + host: string + heat: number + categories: string[] +} + +export type TrustedCompany = { + name: string + host: string + users: number +} + +export type LandingMetrics = { + feeds: number + entries: number +} + +type TrendingFeedsResponse = { + code: number + data: Array<{ + feed: { + title: string + description?: string | null + siteUrl?: string | null + url: string + } + analytics: { + subscriptionCount: number + } + }> +} + +export type RSSHubRoutesIndex = Record< + string, + { + name: string + url: string + heat?: number + categories?: string[] + } +> + +type LandingMetricsResponse = { + code: number + data: LandingMetrics +} + +const HERO_FALLBACK: HeroTimelineItem[] = [ + { + title: 'OpenAI News', + description: 'The OpenAI blog.', + host: 'openai.com', + href: 'https://openai.com/news', + subscriptions: 27710, + }, + { + title: 'Anthropic News', + description: 'Latest news from Anthropic.', + host: 'anthropic.com', + href: 'https://www.anthropic.com/news', + subscriptions: 998, + }, + { + title: 'The GitHub Blog', + description: + 'Updates, ideas, and inspiration from GitHub to help developers build and design software.', + host: 'github.blog', + href: 'https://github.blog/', + subscriptions: 24774, + }, + { + title: 'Nature', + description: 'Read the latest research articles from Nature.', + host: 'nature.com', + href: 'https://www.nature.com/nature/research-articles', + subscriptions: 24491, + }, + { + title: 'The Verge', + description: + 'Breaking news, reviews, and features about how technology changes the world.', + host: 'theverge.com', + href: 'https://www.theverge.com/', + subscriptions: 24833, + }, + { + title: 'NASA Astronomy Picture of the Day', + description: 'The daily image and story from the universe around us.', + host: 'apod.nasa.gov', + href: 'https://apod.nasa.gov/apod/archivepix.html', + subscriptions: 29277, + }, + { + title: 'Last Week in AI', + description: + 'Weekly text and audio summaries of the most important AI stories.', + host: 'lastweekin.ai', + href: 'https://lastweekin.ai/', + subscriptions: 30381, + }, + { + title: 'Ahead of AI', + description: + 'Machine learning and AI research for readers who want to stay ahead.', + host: 'magazine.sebastianraschka.com', + href: 'https://magazine.sebastianraschka.com/', + subscriptions: 27310, + }, + { + title: 'TED Talks Daily', + description: 'Thought-provoking ideas in audio, delivered every day.', + host: 'ted.com', + href: 'https://www.ted.com/', + subscriptions: 25784, + }, + { + title: 'The Daily', + description: + 'The biggest stories of our time, told by New York Times journalists.', + host: 'nytimes.com', + href: 'https://www.nytimes.com/the-daily', + subscriptions: 23638, + }, + { + title: '3Blue1Brown', + description: 'Visual explanations in math, physics, and computer science.', + host: 'youtube.com', + href: 'https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw', + subscriptions: 24897, + }, + { + title: 'Bluesky', + description: 'The official Bluesky account and product updates.', + host: 'bsky.app', + href: 'https://bsky.app/profile/bsky.app', + subscriptions: 24116, + }, +] + +export const DISCOVER_FALLBACK: DiscoverSource[] = [ + { + key: 'xiaohongshu', + name: 'Xiaohongshu', + host: 'xiaohongshu.com', + heat: 1413942, + categories: ['social-media', 'popular'], + }, + { + key: 'twitter', + name: 'X', + host: 'x.com', + heat: 1269331, + categories: ['social-media', 'popular'], + }, + { + key: 'telegram', + name: 'Telegram', + host: 't.me', + heat: 285958, + categories: ['social-media', 'popular'], + }, + { + key: 'youtube', + name: 'YouTube', + host: 'youtube.com', + heat: 278462, + categories: ['social-media', 'popular'], + }, + { + key: 'bilibili', + name: 'bilibili', + host: 'bilibili.com', + heat: 214649, + categories: ['social-media', 'popular'], + }, + { + key: 'weibo', + name: 'Weibo', + host: 'weibo.com', + heat: 59600, + categories: ['social-media', 'popular'], + }, + { + key: 'xiaoyuzhou', + name: 'Xiaoyuzhou', + host: 'xiaoyuzhoufm.com', + heat: 51947, + categories: ['multimedia', 'popular'], + }, + { + key: 'pixiv', + name: 'pixiv', + host: 'pixiv.net', + heat: 48311, + categories: ['social-media', 'popular'], + }, + { + key: 'github', + name: 'GitHub', + host: 'github.com', + heat: 45308, + categories: ['programming', 'popular'], + }, + { + key: 'sspai', + name: 'SSPAI', + host: 'sspai.com', + heat: 34311, + categories: ['new-media', 'popular'], + }, + { + key: 'jike', + name: 'Jike', + host: 'm.okjike.com', + heat: 32042, + categories: ['social-media', 'popular'], + }, + { + key: 'nasa', + name: 'NASA', + host: 'apod.nasa.gov', + heat: 29994, + categories: ['picture', 'popular'], + }, + { + key: 'nature', + name: 'Nature', + host: 'nature.com', + heat: 26324, + categories: ['journal', 'popular'], + }, + { + key: 'zhihu', + name: 'Zhihu', + host: 'zhihu.com', + heat: 26633, + categories: ['social-media', 'popular'], + }, + { + key: '36kr', + name: '36Kr', + host: '36kr.com', + heat: 18922, + categories: ['new-media', 'popular'], + }, + { + key: 'juejin', + name: 'Juejin', + host: 'juejin.cn', + heat: 12318, + categories: ['programming', 'popular'], + }, + { + key: 'bsky', + name: 'Bluesky', + host: 'bsky.app', + heat: 25232, + categories: ['social-media', 'popular'], + }, + { + key: 'threads', + name: 'Threads', + host: 'threads.net', + heat: 24696, + categories: ['social-media', 'popular'], + }, + { + key: 'nytimes', + name: 'The New York Times', + host: 'nytimes.com', + heat: 8922, + categories: ['traditional-media', 'popular'], + }, + { + key: 'reuters', + name: 'Reuters', + host: 'reuters.com', + heat: 5915, + categories: ['traditional-media', 'popular'], + }, + { + key: 'bloomberg', + name: 'Bloomberg', + host: 'bloomberg.com', + heat: 5339, + categories: ['finance', 'popular'], + }, + { + key: 'huggingface', + name: 'Hugging Face', + host: 'huggingface.co', + heat: 3219, + categories: ['programming', 'popular'], + }, + { + key: 'spotify', + name: 'Spotify', + host: 'open.spotify.com', + heat: 2948, + categories: ['multimedia', 'popular'], + }, + { + key: 'rsshub', + name: 'RSSHub', + host: 'docs.rsshub.app', + heat: 12237, + categories: ['program-update', 'popular'], + }, +] + +export const TRUSTED_COMPANIES: TrustedCompany[] = [ + { name: 'Alibaba', host: 'aliyun.com', users: 281 }, + { name: 'ByteDance', host: 'bytedance.com', users: 107 }, + { name: 'Mozilla', host: 'mozmail.com', users: 85 }, + { name: 'Tencent', host: 'tencent.com', users: 18 }, + { name: 'Microsoft', host: 'microsoft.com', users: 12 }, + { name: 'Google', host: 'google.com', users: 9 }, + { name: 'Baidu', host: 'baidu.com', users: 7 }, + { name: 'Xiaomi', host: 'xiaomi.com', users: 7 }, + { name: 'Huawei', host: 'huawei.com', users: 5 }, + { name: 'NVIDIA', host: 'nvidia.com', users: 4 }, +] + +export const TRUSTED_RESEARCH_INSTITUTIONS: TrustedCompany[] = [ + { name: 'MIT', host: 'mit.edu', users: 1 }, + { name: 'Stanford', host: 'stanford.edu', users: 2 }, + { name: 'Oxford', host: 'ox.ac.uk', users: 1 }, + { name: 'Harvard', host: 'harvard.edu', users: 4 }, + { name: 'Cambridge', host: 'cam.ac.uk', users: 1 }, + { name: 'NUS', host: 'nus.edu.sg', users: 11 }, + { name: 'UCL', host: 'ucl.ac.uk', users: 2 }, + { name: 'NTU Singapore', host: 'ntu.edu.sg', users: 6 }, + { name: 'Peking University', host: 'pku.edu.cn', users: 41 }, + { name: 'UPenn', host: 'upenn.edu', users: 5 }, +] + +const LANDING_METRICS_FALLBACK: LandingMetrics = { + feeds: 1267577, + entries: 300849952, +} + +const truncate = (value: string, maxLength: number) => + value.length <= maxLength + ? value + : `${value.slice(0, maxLength - 1).trimEnd()}…` + +const normalizeHost = (raw: string | null | undefined) => { + if (!raw) return null + + try { + const url = raw.startsWith('http') + ? new URL(raw) + : new URL(`https://${raw}`) + return url.host.replace(/^www\./, '') + } catch { + return null + } +} + +const normalizeDescription = (value: string | null | undefined) => { + const cleaned = (value || '') + .replace(/\s+- Powered by RSSHub$/i, '') + .replaceAll(/\s+/g, ' ') + .trim() + + return cleaned + ? truncate(cleaned, 96) + : 'Fresh signal from a source that is trending inside production Folo timelines.' +} + +const fetchJson = async <T>(url: string) => { + const response = await fetch(url, { + headers: { + accept: 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status}`) + } + + return (await response.json()) as T +} + +const getTrendingLanguage = (locale?: string) => + locale === 'zh' ? 'cmn' : 'eng' + +export const buildDiscoverSourcesFromIndex = ( + result: RSSHubRoutesIndex, +): DiscoverSource[] => { + const uniqueByHost = new Map<string, DiscoverSource>() + + for (const [key, value] of Object.entries(result)) { + const host = normalizeHost(value.url) || value.url + if (!host) continue + + const nextItem = { + key, + name: value.name, + host, + heat: value.heat ?? 0, + categories: value.categories ?? [], + } + + const existing = uniqueByHost.get(host) + if (!existing || nextItem.heat > existing.heat) { + uniqueByHost.set(host, nextItem) + } + } + + return Array.from(uniqueByHost.values()).sort( + (left, right) => + right.heat - left.heat || left.name.localeCompare(right.name), + ) +} + +export const getHeroTimelineItems = async ( + locale?: string, +): Promise<HeroTimelineItem[]> => { + if (isDevelopment) { + return HERO_FALLBACK + } + + try { + const language = getTrendingLanguage(locale) + const result = await fetchJson<TrendingFeedsResponse>( + `${PRODUCTION_API_URL}/trending/feeds?range=30d&limit=40&language=${language}`, + ) + + const hostUsage = new Map<string, number>() + const items: HeroTimelineItem[] = [] + + for (const entry of result.data) { + const host = normalizeHost(entry.feed.siteUrl || entry.feed.url) + if (!host) continue + + const currentCount = hostUsage.get(host) ?? 0 + const maxCount = HERO_HOST_LIMITS[host] ?? 1 + if (currentCount >= maxCount) continue + + items.push({ + title: truncate(entry.feed.title.trim(), 44), + description: normalizeDescription(entry.feed.description), + host, + href: entry.feed.siteUrl || entry.feed.url, + subscriptions: entry.analytics.subscriptionCount, + }) + + hostUsage.set(host, currentCount + 1) + + if (items.length >= HERO_ITEM_LIMIT) { + break + } + } + + return items.length >= 8 ? items : HERO_FALLBACK + } catch (error) { + console.error('Failed to load landing hero timeline items', error) + return HERO_FALLBACK + } +} + +export const getDiscoverSources = async (): Promise<DiscoverSource[]> => { + try { + const result = await fetchJson<RSSHubRoutesIndex>( + PRODUCTION_RSSHUB_ROUTES_URL, + ) + const items = buildDiscoverSourcesFromIndex(result) + + return items.length >= 8 ? items : DISCOVER_FALLBACK + } catch (error) { + console.error('Failed to load landing discover sources', error) + return DISCOVER_FALLBACK + } +} + +export const getLandingMetrics = async (): Promise<LandingMetrics> => { + if (isDevelopment) { + return LANDING_METRICS_FALLBACK + } + + try { + const result = await fetchJson<LandingMetricsResponse>( + `${PRODUCTION_API_URL}/status/landing-metrics`, + ) + return result.data + } catch (error) { + console.error('Failed to load landing metrics', error) + return LANDING_METRICS_FALLBACK + } +} diff --git a/apps/landing/src/lib/platform.ts b/apps/landing/src/lib/platform.ts index eec8fefb453..2663eb46a2b 100644 --- a/apps/landing/src/lib/platform.ts +++ b/apps/landing/src/lib/platform.ts @@ -1,7 +1,11 @@ -type OS = 'macOS' | 'Windows' | 'Linux' +import type { OS } from '~/constants/download' export function detectPlatform(userAgent: string): OS | null { - if (userAgent.includes('mac')) { + if (userAgent.includes('iphone') || userAgent.includes('ipad')) { + return 'iOS' + } else if (userAgent.includes('android')) { + return 'Android' + } else if (userAgent.includes('mac')) { return 'macOS' } else if (userAgent.includes('win')) { return 'Windows' diff --git a/apps/landing/src/lib/pricing-data.ts b/apps/landing/src/lib/pricing-data.ts new file mode 100644 index 00000000000..4d6f1402f66 --- /dev/null +++ b/apps/landing/src/lib/pricing-data.ts @@ -0,0 +1,205 @@ +const PRICING_API_URL = 'https://api.follow.is/status/configs' +const isDevelopment = process.env.NODE_ENV !== 'production' + +export type PricingPlanLimit = { + MAX_AI_TASKS: number + AI_CREDIT: number + AI_MODEL_SELECTION: 'none' | 'curated' | 'high_performance' + AI_BRING_YOUR_OWN_KEY: boolean + MAX_AI_ENTRY_SUMMARY_PER_DAY: number + MAX_AI_ENTRY_TRANSLATION_PER_DAY: number + MAX_AI_TEXT_TO_SPEECH_PER_DAY: number + MAX_SUBSCRIPTIONS: number + MAX_RSSHUB_SUBSCRIPTIONS: number + PRIVATE_SUBSCRIPTION: boolean + MAX_INBOXES: number + MAX_LISTS: number + MAX_ACTIONS: number + INTEGRATION_SUPPORTED: boolean + SECURE_IMAGE_PROXY: boolean + PRIORITY_SUPPORT: boolean | string[] + BOOSTS: string +} + +export type PricingPlan = { + planID?: string + name: string + priceInDollars: number + priceInDollarsInDiscount?: number + priceInDollarsAnnual: number + priceInDollarsInDiscountAnnual?: number + discountDescription?: string + upgradeButtonText?: string + isPopular?: boolean + isComingSoon?: boolean + tier: number + limit: PricingPlanLimit + role: 'free' | 'basic' | 'plus' | 'pro' | 'admin' +} + +type StatusConfigsResponse = { + code: number + data: { + PAYMENT_PLAN_LIST: PricingPlan[] + } +} + +const PRICING_FALLBACK: PricingPlan[] = [ + { + name: 'Free', + priceInDollars: 0, + priceInDollarsAnnual: 0, + tier: 0, + role: 'free', + limit: { + MAX_AI_TASKS: 0, + AI_CREDIT: 0, + AI_MODEL_SELECTION: 'none', + AI_BRING_YOUR_OWN_KEY: false, + MAX_AI_ENTRY_SUMMARY_PER_DAY: 3, + MAX_AI_ENTRY_TRANSLATION_PER_DAY: 0, + MAX_AI_TEXT_TO_SPEECH_PER_DAY: 1, + MAX_SUBSCRIPTIONS: 150, + MAX_RSSHUB_SUBSCRIPTIONS: 30, + PRIVATE_SUBSCRIPTION: false, + MAX_INBOXES: 1, + MAX_LISTS: 1, + MAX_ACTIONS: 1, + INTEGRATION_SUPPORTED: false, + SECURE_IMAGE_PROXY: false, + PRIORITY_SUPPORT: false, + BOOSTS: '0', + }, + }, + { + planID: 'basic', + name: 'Basic', + priceInDollars: 4.99, + priceInDollarsAnnual: 49.99, + upgradeButtonText: 'Start Free Trial', + tier: 0.5, + role: 'basic', + limit: { + MAX_AI_TASKS: 0, + AI_CREDIT: 0, + AI_MODEL_SELECTION: 'none', + AI_BRING_YOUR_OWN_KEY: false, + MAX_AI_ENTRY_SUMMARY_PER_DAY: 30, + MAX_AI_ENTRY_TRANSLATION_PER_DAY: 300, + MAX_AI_TEXT_TO_SPEECH_PER_DAY: 1, + MAX_SUBSCRIPTIONS: 1000, + MAX_RSSHUB_SUBSCRIPTIONS: 200, + PRIVATE_SUBSCRIPTION: true, + MAX_INBOXES: 10, + MAX_LISTS: 5, + MAX_ACTIONS: 5, + INTEGRATION_SUPPORTED: true, + SECURE_IMAGE_PROXY: true, + PRIORITY_SUPPORT: ['Email'], + BOOSTS: '0', + }, + }, + { + planID: 'plus', + name: 'Plus', + priceInDollars: 9.99, + priceInDollarsAnnual: 99.99, + upgradeButtonText: 'Start Free Trial', + isPopular: true, + tier: 1, + role: 'plus', + limit: { + MAX_AI_TASKS: Number.MAX_SAFE_INTEGER, + AI_CREDIT: 5_000_000, + AI_MODEL_SELECTION: 'curated', + AI_BRING_YOUR_OWN_KEY: true, + MAX_AI_ENTRY_SUMMARY_PER_DAY: Number.MAX_SAFE_INTEGER, + MAX_AI_ENTRY_TRANSLATION_PER_DAY: Number.MAX_SAFE_INTEGER, + MAX_AI_TEXT_TO_SPEECH_PER_DAY: Number.MAX_SAFE_INTEGER, + MAX_SUBSCRIPTIONS: 2500, + MAX_RSSHUB_SUBSCRIPTIONS: 500, + PRIVATE_SUBSCRIPTION: true, + MAX_INBOXES: 20, + MAX_LISTS: 15, + MAX_ACTIONS: 10, + INTEGRATION_SUPPORTED: true, + SECURE_IMAGE_PROXY: true, + PRIORITY_SUPPORT: ['Email', 'Discord'], + BOOSTS: '×10', + }, + }, + { + planID: 'pro', + name: 'Pro', + priceInDollars: 99.99, + priceInDollarsAnnual: 999.99, + upgradeButtonText: 'Start Free Trial', + tier: 2, + role: 'pro', + limit: { + MAX_AI_TASKS: Number.MAX_SAFE_INTEGER, + AI_CREDIT: 50_000_000, + AI_MODEL_SELECTION: 'high_performance', + AI_BRING_YOUR_OWN_KEY: true, + MAX_AI_ENTRY_SUMMARY_PER_DAY: Number.MAX_SAFE_INTEGER, + MAX_AI_ENTRY_TRANSLATION_PER_DAY: Number.MAX_SAFE_INTEGER, + MAX_AI_TEXT_TO_SPEECH_PER_DAY: Number.MAX_SAFE_INTEGER, + MAX_SUBSCRIPTIONS: 25000, + MAX_RSSHUB_SUBSCRIPTIONS: 5000, + PRIVATE_SUBSCRIPTION: true, + MAX_INBOXES: 200, + MAX_LISTS: 100, + MAX_ACTIONS: 100, + INTEGRATION_SUPPORTED: true, + SECURE_IMAGE_PROXY: true, + PRIORITY_SUPPORT: ['Email', 'Discord'], + BOOSTS: '×100', + }, + }, +] + +export const FEATURE_ORDER: Array<keyof PricingPlanLimit> = [ + 'MAX_SUBSCRIPTIONS', + 'MAX_LISTS', + 'MAX_INBOXES', + 'MAX_ACTIONS', + 'MAX_AI_ENTRY_SUMMARY_PER_DAY', + 'MAX_AI_ENTRY_TRANSLATION_PER_DAY', + 'MAX_AI_TEXT_TO_SPEECH_PER_DAY', + 'AI_MODEL_SELECTION', + 'AI_BRING_YOUR_OWN_KEY', + 'BOOSTS', + 'PRIORITY_SUPPORT', + 'PRIVATE_SUBSCRIPTION', + 'MAX_RSSHUB_SUBSCRIPTIONS', + 'SECURE_IMAGE_PROXY', + 'INTEGRATION_SUPPORTED', + 'AI_CREDIT', + 'MAX_AI_TASKS', +] + +export const fetchPricingPlans = async (): Promise<PricingPlan[]> => { + if (isDevelopment) { + return PRICING_FALLBACK + } + + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + const response = await fetch(PRICING_API_URL, { + headers: { accept: 'application/json' }, + signal: controller.signal, + }) + clearTimeout(timeout) + + if (!response.ok) { + throw new Error(`Failed to fetch pricing plans: ${response.status}`) + } + + const json = (await response.json()) as StatusConfigsResponse + const plans = json.data?.PAYMENT_PLAN_LIST ?? [] + return plans.length > 0 ? plans : PRICING_FALLBACK + } catch { + return PRICING_FALLBACK + } +} diff --git a/apps/landing/src/messages/en.json b/apps/landing/src/messages/en.json index 816ec484363..a2b958319c1 100644 --- a/apps/landing/src/messages/en.json +++ b/apps/landing/src/messages/en.json @@ -1,7 +1,7 @@ { "metadata": { "title": "Folo", - "description": "AI-powered RSS reader for deep, noise-free reading with contextual AI.", + "description": "AI RSS Reader for deep, noise-free reading with contextual AI.", "keywords": "Folo, AI RSS reader, RSS, summarize, translate, research, open source, content discovery, information browser" }, "common": { @@ -19,17 +19,21 @@ }, "landing": { "hero": { - "eyebrow": "AI Reader · Clean Timeline", - "title": "<brand>Folo</brand> is the <highlight>AI</highlight> Reader", + "eyebrow": "AI RSS Reader · Clean Timeline", + "title": "<brand>Folo</brand> is the <highlight>AI RSS Reader</highlight>", "bodyLine1": "The AI that reads the internet for you,", - "bodyLine2": "cutting through noise to surface the knowledge you actually care about." + "bodyLine2": "cutting through noise to surface the knowledge you actually care about.", + "humanTab": "I'm a Human", + "agentTab": "I'm an Agent", + "agentTitle": "Use Folo", + "agentBody": "Read <skill>https://api.folo.is/skill.md</skill> and follow the instructions to use Folo." }, "features": { "headline": "Reading reimagined in the AI era", "discover": { "label": "Discover", "titleStrong": "AI finds the best sources", - "titleRest": "from millions across the Internet — while you stay in full control." + "titleRest": "across the open web." }, "twin": { "label": "Digital Twin", @@ -39,20 +43,50 @@ "vibe": { "label": "Vibe Read", "titleStrong": "Let AI read it all.", - "titleRest": "Sit back, vibe, and only get notified when insights or signals truly matter." + "titleRest": "Keep only the signal." + } + }, + "trustedBy": { + "eyebrow": "Trusted by teams at", + "researchersEyebrow": "Trusted by researchers at", + "headline": "Teams already using Folo.", + "body": "We sampled production work-email registrations to find the companies that have already started reading with Folo." + }, + "metrics": { + "eyebrow": "Production Snapshot", + "headline": "At a glance", + "body": "Live production scale from Folo.", + "footnote": "Production snapshot. Approximate.", + "cards": { + "feeds": { + "label": "Feeds", + "detail": "Sources across blogs, research, newsletters, social profiles, and more." + }, + "entries": { + "label": "Entries", + "detail": "A huge reading graph that AI can search, summarize, and connect for you." + }, + "readers": { + "label": "Readers", + "detail": "People already using Folo to keep up without drowning in noise." + }, + "follows": { + "label": "Follows", + "detail": "Subscriptions flowing through production timelines right now." + } } }, "builtOpen": { "title": "Built Open", - "body": "Transparent, verifiable, and crafted with the community. Explore the code, file issues, and shape the reading experience together.", - "note": "Built with modern primitives: accessible, keyboard-friendly, and optimized for performance. PRs welcome." + "body": "Open source, verifiable, and easy to inspect.", + "note": "Code public. PRs welcome." }, "repoStats": { "githubStars": "GitHub Stars", "license": "License", "licenseValue": "GPL-3.0", "repository": "Repository", - "repoDescriptionMobile": "Built with modern primitives: accessible, keyboard-friendly, and optimized for performance. PRs welcome.", + "repoDescriptionMobile": "Code public. PRs welcome.", "repoAriaLabel": "Open Folo repository on GitHub" }, "socialProof": { @@ -67,9 +101,21 @@ }, "hero": { "title": "Download <brand>Folo</brand>", - "subtitle": "Get the full power of AI-powered reading on Desktop, or access it instantly on the Web." + "subtitle": "Get the AI RSS Reader on Desktop, or open it instantly on the Web." }, "platforms": { + "recommended": "Recommended for your device", + "allPlatforms": "Available on", + "moreDownloads": "More downloads", + "lessDownloads": "Show less", + "iOS": { + "label": "iOS", + "store": "App Store" + }, + "Android": { + "label": "Android", + "store": "Google Play" + }, "macOS": { "label": "macOS", "store": "Mac App Store" @@ -88,5 +134,50 @@ }, "footer": "By downloading, you agree to our <terms>Terms</terms> and <privacy>Privacy Policy</privacy>." } + }, + "pricing": { + "metadata": { + "title": "Pricing", + "description": "Choose the Folo plan that fits your reading workflow." + }, + "eyebrow": "Pricing", + "headline": "Plans for every reading workflow", + "body": "Compare limits, AI capabilities, and workflow scale before you choose.", + "monthly": "Monthly", + "yearly": "Yearly", + "save": "Save {percent}%", + "month": "month", + "billedYearly": "Billed yearly at {total}", + "mostPopular": "Most Popular", + "getStarted": "Get Started", + "table": { + "features": "Features" + }, + "descriptions": { + "free": "For getting started with a calm reading workflow.", + "basic": "For solo readers who want more subscriptions and more AI.", + "plus": "For power users who rely on AI every day.", + "pro": "For heavy workflows, large libraries, and maximum limits.", + "admin": "For heavy workflows, large libraries, and maximum limits." + }, + "features": { + "MAX_SUBSCRIPTIONS": "feed subscriptions", + "MAX_LISTS": "lists", + "MAX_INBOXES": "inboxes", + "MAX_ACTIONS": "actions", + "MAX_AI_ENTRY_SUMMARY_PER_DAY": "AI summaries / day", + "MAX_AI_ENTRY_TRANSLATION_PER_DAY": "AI translations / day", + "MAX_AI_TEXT_TO_SPEECH_PER_DAY": "text-to-speech / day", + "AI_MODEL_SELECTION": "AI model selection", + "AI_BRING_YOUR_OWN_KEY": "bring your own key", + "BOOSTS": "boosts", + "PRIORITY_SUPPORT": "priority support", + "PRIVATE_SUBSCRIPTION": "private subscriptions", + "MAX_RSSHUB_SUBSCRIPTIONS": "RSSHub subscriptions", + "SECURE_IMAGE_PROXY": "secure image proxy", + "INTEGRATION_SUPPORTED": "integrations", + "AI_CREDIT": "AI credits", + "MAX_AI_TASKS": "AI tasks" + } } } diff --git a/apps/landing/src/messages/jp.json b/apps/landing/src/messages/jp.json index 00dc1e80cef..a20bab6132f 100644 --- a/apps/landing/src/messages/jp.json +++ b/apps/landing/src/messages/jp.json @@ -1,7 +1,7 @@ { "metadata": { "title": "Folo", - "description": "AI 搭載の RSS リーダー。文脈理解でノイズのない深い読書体験を。", + "description": "AI RSS Reader。文脈理解でノイズのない深い読書体験を。", "keywords": "Folo, AI RSS リーダー, RSS, 要約, 翻訳, リサーチ, オープンソース, コンテンツ発見, インフォメーションブラウザ" }, "common": { @@ -19,17 +19,21 @@ }, "landing": { "hero": { - "eyebrow": "AI リーダー · クリーンタイムライン", - "title": "<brand>Folo</brand> はあなたの <highlight>AI</highlight> リーダー", + "eyebrow": "AI RSS Reader · クリーンタイムライン", + "title": "<brand>Folo</brand> はあなたの <highlight>AI RSS Reader</highlight>", "bodyLine1": "インターネットの情報を AI が代わりに読み、", - "bodyLine2": "雑音を取り除いて、本当に必要な知識だけを届けます。" + "bodyLine2": "雑音を取り除いて、本当に必要な知識だけを届けます。", + "humanTab": "I'm a Human", + "agentTab": "I'm an Agent", + "agentTitle": "Use Folo", + "agentBody": "Read <skill>https://api.folo.is/skill.md</skill> and follow the instructions to use Folo." }, "features": { "headline": "AI 時代のための新しい読書体験", "discover": { "label": "ディスカバリー", "titleStrong": "AI が最高の情報源を見つけ出す", - "titleRest": "インターネット上の数百万のソースから、あなたが選択権を持ったまま。" + "titleRest": "オープンウェブ全体から。" }, "twin": { "label": "デジタルツイン", @@ -39,20 +43,50 @@ "vibe": { "label": "バイブリード", "titleStrong": "AI にすべて任せて読んでもらおう。", - "titleRest": "くつろぎながら、本当に価値ある洞察やシグナルだけを通知します。" + "titleRest": "必要なシグナルだけ残す。" + } + }, + "trustedBy": { + "eyebrow": "導入が進むチーム", + "researchersEyebrow": "導入が進む研究者", + "headline": "すでに Folo を使うチームがあります。", + "body": "本番環境の業務用メール登録サンプルをもとに、Folo を使い始めている企業を抽出しました。" + }, + "metrics": { + "eyebrow": "本番スナップショット", + "headline": "At a glance", + "body": "Folo の本番環境の規模です。", + "footnote": "本番スナップショット。概算値です。", + "cards": { + "feeds": { + "label": "Feeds", + "detail": "ブログ、研究、ニュースレター、ソーシャルなど幅広いソースをまとめて追えます。" + }, + "entries": { + "label": "Entries", + "detail": "AI が検索・要約・文脈化できるだけの十分に大きな読書グラフです。" + }, + "readers": { + "label": "Readers", + "detail": "ノイズに埋もれず情報を追うために、すでに Folo を使っている人たちです。" + }, + "follows": { + "label": "Follows", + "detail": "いま本番タイムラインを流れている購読関係の総量です。" + } } }, "builtOpen": { "title": "オープンに構築", - "body": "透明で検証可能、そしてコミュニティと共に作り上げています。コードを覗き、Issue を立て、新しい読書体験を一緒に形にしましょう。", - "note": "モダンなプライミティブで構築:アクセシブル、キーボードフレンドリー、高速。PR 大歓迎。" + "body": "オープンソースで、見ればわかる。", + "note": "コード公開。PR歓迎。" }, "repoStats": { "githubStars": "GitHub スター", "license": "ライセンス", "licenseValue": "GPL-3.0", "repository": "リポジトリ", - "repoDescriptionMobile": "モダンなプライミティブで構築:アクセシブル、キーボードフレンドリー、高速。PR 大歓迎。", + "repoDescriptionMobile": "コード公開。PR歓迎。", "repoAriaLabel": "GitHub で Folo リポジトリを開く" }, "socialProof": { @@ -67,9 +101,21 @@ }, "hero": { "title": "<brand>Folo</brand> をダウンロード", - "subtitle": "デスクトップではフル機能の AI リーディングを、Web ではすぐに利用できます。" + "subtitle": "AI RSS Reader をデスクトップで使うか、Web ですぐに開けます。" }, "platforms": { + "recommended": "このデバイスにおすすめ", + "allPlatforms": "その他の対応環境", + "moreDownloads": "その他のダウンロード", + "lessDownloads": "折りたたむ", + "iOS": { + "label": "iOS", + "store": "App Store" + }, + "Android": { + "label": "Android", + "store": "Google Play" + }, "macOS": { "label": "macOS", "store": "Mac App Store" @@ -88,5 +134,50 @@ }, "footer": "ダウンロードすると、<terms>利用規約</terms>と<privacy>プライバシーポリシー</privacy>に同意したものとみなされます。" } + }, + "pricing": { + "metadata": { + "title": "Pricing", + "description": "Folo の読書ワークフローに合うプランを選べます。" + }, + "eyebrow": "Pricing", + "headline": "読書ワークフローに合わせたプラン", + "body": "制限、AI 機能、運用規模を比較して選べます。", + "monthly": "月払い", + "yearly": "年払い", + "save": "{percent}% お得", + "month": "月", + "billedYearly": "年額 {total} で請求", + "mostPopular": "Most Popular", + "getStarted": "始める", + "table": { + "features": "機能" + }, + "descriptions": { + "free": "落ち着いた読書体験を始めるための無料プラン。", + "basic": "購読数と AI 利用量を増やしたい個人ユーザー向け。", + "plus": "毎日 AI を活用するパワーユーザー向け。", + "pro": "大規模ライブラリと重いワークフロー向けの上位プラン。", + "admin": "大規模ライブラリと重いワークフロー向けの上位プラン。" + }, + "features": { + "MAX_SUBSCRIPTIONS": "feed subscriptions", + "MAX_LISTS": "lists", + "MAX_INBOXES": "inboxes", + "MAX_ACTIONS": "actions", + "MAX_AI_ENTRY_SUMMARY_PER_DAY": "AI summaries / day", + "MAX_AI_ENTRY_TRANSLATION_PER_DAY": "AI translations / day", + "MAX_AI_TEXT_TO_SPEECH_PER_DAY": "text-to-speech / day", + "AI_MODEL_SELECTION": "AI model selection", + "AI_BRING_YOUR_OWN_KEY": "bring your own key", + "BOOSTS": "boosts", + "PRIORITY_SUPPORT": "priority support", + "PRIVATE_SUBSCRIPTION": "private subscriptions", + "MAX_RSSHUB_SUBSCRIPTIONS": "RSSHub subscriptions", + "SECURE_IMAGE_PROXY": "secure image proxy", + "INTEGRATION_SUPPORTED": "integrations", + "AI_CREDIT": "AI credits", + "MAX_AI_TASKS": "AI tasks" + } } } diff --git a/apps/landing/src/messages/zh.json b/apps/landing/src/messages/zh.json index f4c3c8ccb24..5fb615033b6 100644 --- a/apps/landing/src/messages/zh.json +++ b/apps/landing/src/messages/zh.json @@ -1,7 +1,7 @@ { "metadata": { "title": "Folo", - "description": "AI 驱动的 RSS 阅读器,具备上下文 AI,带来深度、无噪的阅读体验。", + "description": "AI RSS Reader,具备上下文 AI,带来深度、无噪的阅读体验。", "keywords": "Folo, AI RSS 阅读器, RSS, 摘要, 翻译, 研究, 开源, 内容发现, 信息浏览器" }, "common": { @@ -19,17 +19,21 @@ }, "landing": { "hero": { - "eyebrow": "AI 阅读器 · 洁净时间线", - "title": "<brand>Folo</brand> 是你的 <highlight>AI</highlight> 阅读器", + "eyebrow": "AI RSS Reader · 洁净时间线", + "title": "<brand>Folo</brand> 是你的 <highlight>AI RSS Reader</highlight>", "bodyLine1": "这款 AI 为你读遍全网,", - "bodyLine2": "穿透噪音,只呈现你真正关心的洞见。" + "bodyLine2": "穿透噪音,只呈现你真正关心的洞见。", + "humanTab": "I'm a Human", + "agentTab": "I'm an Agent", + "agentTitle": "Use Folo", + "agentBody": "Read <skill>https://api.folo.is/skill.md</skill> and follow the instructions to use Folo." }, "features": { "headline": "在 AI 时代重新定义阅读", "discover": { "label": "发现", "titleStrong": "AI 从海量信息中筛选最佳来源", - "titleRest": "覆盖全网数百万站点,而你始终掌握选择权。" + "titleRest": "覆盖整个开放网络。" }, "twin": { "label": "数字分身", @@ -39,20 +43,50 @@ "vibe": { "label": "氛围阅读", "titleStrong": "让 AI 读完所有内容。", - "titleRest": "放松沉浸,只在真正重要的洞察或信号出现时收到提醒。" + "titleRest": "只留下真正的信号。" + } + }, + "trustedBy": { + "eyebrow": "已被这些团队采用", + "researchersEyebrow": "已被这些研究者采用", + "headline": "已经有团队在用 Folo。", + "body": "我们基于生产环境中的工作邮箱注册样本,整理出了这些已经开始使用 Folo 的公司。" + }, + "metrics": { + "eyebrow": "生产环境快照", + "headline": "一眼看懂", + "body": "Folo 当前生产环境的数据规模。", + "footnote": "生产快照,近似值。", + "cards": { + "feeds": { + "label": "Feeds", + "detail": "覆盖博客、研究、Newsletter、社交账号以及更多开放网页来源。" + }, + "entries": { + "label": "Entries", + "detail": "足够大的阅读图谱,能让 AI 做搜索、摘要与上下文关联。" + }, + "readers": { + "label": "Readers", + "detail": "已经在用 Folo 追踪信息、同时避开噪音的人。" + }, + "follows": { + "label": "Follows", + "detail": "此刻正在生产时间线里流转的订阅关系。" + } } }, "builtOpen": { "title": "开放共建", - "body": "透明、可信,并由社区共同打造。快来浏览源码、提交 Issue,与我们一起塑造全新的阅读体验。", - "note": "基于现代原子化组件构建:无障碍、键盘友好、性能优先。欢迎 PR。" + "body": "开源、透明,而且容易验证。", + "note": "代码公开,欢迎 PR。" }, "repoStats": { "githubStars": "GitHub 星标", "license": "许可证", "licenseValue": "GPL-3.0", "repository": "代码仓库", - "repoDescriptionMobile": "基于现代原子化组件构建:无障碍、键盘友好、性能优先。欢迎 PR。", + "repoDescriptionMobile": "代码公开,欢迎 PR。", "repoAriaLabel": "在 GitHub 打开 Folo 仓库" }, "socialProof": { @@ -67,9 +101,21 @@ }, "hero": { "title": "下载 <brand>Folo</brand>", - "subtitle": "在桌面端体验完整的 AI 阅读能力,或直接通过 Web 即刻使用。" + "subtitle": "在桌面端使用 AI RSS Reader,或直接在 Web 上打开。" }, "platforms": { + "recommended": "为你的设备推荐", + "allPlatforms": "还可下载于", + "moreDownloads": "更多下载", + "lessDownloads": "收起", + "iOS": { + "label": "iOS", + "store": "App Store" + }, + "Android": { + "label": "Android", + "store": "Google Play" + }, "macOS": { "label": "macOS", "store": "Mac App Store" @@ -88,5 +134,50 @@ }, "footer": "下载即表示你同意我们的 <terms>服务条款</terms> 和 <privacy>隐私政策</privacy>。" } + }, + "pricing": { + "metadata": { + "title": "价格", + "description": "选择适合你阅读工作流的 Folo 计划。" + }, + "eyebrow": "Pricing", + "headline": "适合不同阅读工作流的计划", + "body": "先比较额度、AI 能力和工作流规模,再决定使用哪一档。", + "monthly": "月付", + "yearly": "年付", + "save": "省 {percent}%", + "month": "月", + "billedYearly": "按年计费 {total}", + "mostPopular": "最受欢迎", + "getStarted": "开始使用", + "table": { + "features": "功能" + }, + "descriptions": { + "free": "适合开始使用,保持安静清爽的阅读体验。", + "basic": "适合希望提升订阅量和 AI 使用额度的个人读者。", + "plus": "适合每天依赖 AI 阅读和处理信息的重度用户。", + "pro": "适合大型资料库、重度工作流和最高额度需求。", + "admin": "适合大型资料库、重度工作流和最高额度需求。" + }, + "features": { + "MAX_SUBSCRIPTIONS": "feed 订阅数", + "MAX_LISTS": "列表", + "MAX_INBOXES": "收件箱", + "MAX_ACTIONS": "自动动作", + "MAX_AI_ENTRY_SUMMARY_PER_DAY": "AI 摘要 / 天", + "MAX_AI_ENTRY_TRANSLATION_PER_DAY": "AI 翻译 / 天", + "MAX_AI_TEXT_TO_SPEECH_PER_DAY": "语音朗读 / 天", + "AI_MODEL_SELECTION": "AI 模型选择", + "AI_BRING_YOUR_OWN_KEY": "自带 API Key", + "BOOSTS": "加速次数", + "PRIORITY_SUPPORT": "优先支持", + "PRIVATE_SUBSCRIPTION": "私密订阅", + "MAX_RSSHUB_SUBSCRIPTIONS": "RSSHub 订阅数", + "SECURE_IMAGE_PROXY": "安全图片代理", + "INTEGRATION_SUPPORTED": "集成能力", + "AI_CREDIT": "AI Credits", + "MAX_AI_TASKS": "AI 任务" + } } } diff --git a/apps/landing/src/proxy.ts b/apps/landing/src/proxy.ts index e7a16c63c41..ad64afe1d4c 100644 --- a/apps/landing/src/proxy.ts +++ b/apps/landing/src/proxy.ts @@ -8,6 +8,7 @@ const bypassPrefixes = ['/_next', '/_vinext', '/api'] const bypassExactPaths = new Set([ '/apple-app-site-association', '/.well-known/apple-app-site-association', + '/discover-sources', ]) const getLogicalPathname = (pathname: string) => { diff --git a/apps/landing/vite.config.ts b/apps/landing/vite.config.ts index e6d2bb04809..ea456a1dc0b 100644 --- a/apps/landing/vite.config.ts +++ b/apps/landing/vite.config.ts @@ -14,11 +14,26 @@ export default defineConfig({ }, }), ], + optimizeDeps: { + exclude: [ + 'next-intl', + 'next-intl/navigation', + 'next-intl/routing', + 'use-intl', + ], + include: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + ], + }, resolve: { alias: { 'next-intl/config': fileURLToPath( new URL('src/i18n/request.ts', import.meta.url), ), }, + dedupe: ['react', 'react-dom'], }, }) diff --git a/apps/mobile/changelog/0.4.1.md b/apps/mobile/changelog/0.4.1.md new file mode 100644 index 00000000000..e453f2bba97 --- /dev/null +++ b/apps/mobile/changelog/0.4.1.md @@ -0,0 +1,20 @@ +# What's New in v0.4.1 + +## Shiny new things + +## Improvements + +- Improved iPad layouts and entry detail rendering +- Simplified iOS subscription management +- Polished authentication and settings UX +- Aligned mobile SDK compatibility across discover and settings screens + +## No longer broken + +- Fixed timeline refresh after sign-in +- Fixed mark-read timing right after refreshing a timeline +- Fixed EAS build dependency setup by adding `expo-network` + +## Thanks + +Special thanks to volunteer contributors for their valuable contributions diff --git a/apps/mobile/ios/Folo/Info.plist b/apps/mobile/ios/Folo/Info.plist index eb8d7638fde..4f6b15c5369 100644 --- a/apps/mobile/ios/Folo/Info.plist +++ b/apps/mobile/ios/Folo/Info.plist @@ -33,7 +33,7 @@ <key>CFBundlePackageType</key> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <key>CFBundleShortVersionString</key> - <string>0.4.0</string> + <string>0.4.1</string> <key>CFBundleSignature</key> <string>????</string> <key>CFBundleURLTypes</key> @@ -54,7 +54,7 @@ </dict> </array> <key>CFBundleVersion</key> - <string>139</string> + <string>140</string> <key>ITSAppUsesNonExemptEncryption</key> <false/> <key>LSApplicationCategoryType</key> diff --git a/apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift b/apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift index 0520f5ffda4..7be6c06122c 100644 --- a/apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift +++ b/apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift @@ -57,12 +57,52 @@ private class FOWKWebViewConfiguration: WKWebViewConfiguration { let css = """ :root { overflow: hidden !important; overflow-behavior: none !important; } body { + margin: 0 !important; overflow-y: visible !important; position: absolute !important; width: 100% !important; + max-width: 100% !important; height: auto !important; -webkit-overflow-scrolling: touch !important; } + #root, + article { + width: 100% !important; + max-width: 100% !important; + margin-left: 0 !important; + margin-right: 0 !important; + box-sizing: border-box !important; + } + article > p, + article > div, + article > ul, + article > ol, + article > pre, + article > table, + article > blockquote, + article > h1, + article > h2, + article > h3, + article > h4, + article > h5, + article > h6, + article > figure { + width: 100% !important; + max-width: 100% !important; + margin-left: 0 !important; + margin-right: 0 !important; + box-sizing: border-box !important; + } + article figure { + margin-top: 0 !important; + margin-bottom: 1rem !important; + } + article button[data-image-width], + article figure img, + article > img { + margin-left: auto !important; + margin-right: auto !important; + } ::selection { background-color: \(hexAccentColor) !important; } diff --git a/apps/mobile/native/ios/Modules/SharedWebView/SharedWebView.swift b/apps/mobile/native/ios/Modules/SharedWebView/SharedWebView.swift index 9cbfef20512..7e6d670081e 100644 --- a/apps/mobile/native/ios/Modules/SharedWebView/SharedWebView.swift +++ b/apps/mobile/native/ios/Modules/SharedWebView/SharedWebView.swift @@ -6,6 +6,7 @@ import WebKit class WebViewView: ExpoView { private var cancellable: AnyCancellable? + private var lastReportedHeight: CGFloat = 0 required init(appContext: AppContext? = nil) { super.init(appContext: appContext) @@ -14,7 +15,7 @@ class WebViewView: ExpoView { cancellable = WebViewManager.state.$contentHeight .receive(on: DispatchQueue.main) .sink { [weak self] _ in - self?.layoutSubviews() + self?.setNeedsLayout() } } @@ -25,15 +26,18 @@ class WebViewView: ExpoView { private let onContentHeightChange = ExpoModulesCore.EventDispatcher() override func layoutSubviews() { + super.layoutSubviews() let rect = CGRect( - x: bounds.origin.x, - y: bounds.origin.y, + x: 0, + y: 0, width: bounds.width, height: WebViewManager.state.contentHeight ) WebViewManager.updateFrame(rect) - frame = rect - onContentHeightChange(["height": Float(rect.height)]) + if abs(lastReportedHeight - rect.height) > 0.5 { + lastReportedHeight = rect.height + onContentHeightChange(["height": Float(rect.height)]) + } } diff --git a/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift b/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift index d732f2d319c..28e2c5c18cc 100644 --- a/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift +++ b/apps/mobile/native/ios/Modules/TabBar/TabBarRootView.swift @@ -14,6 +14,7 @@ import UIKit enum CustomTabbarController { static var tabBarController = { let tabBarController = UITabBarController() + let isPad = UIDevice.current.userInterfaceIdiom == .pad if #available(iOS 16.0, *), UIDevice.current.userInterfaceIdiom == .pad { tabBarController.tabBar.isTranslucent = false tabBarController.tabBar.barStyle = .default @@ -25,7 +26,7 @@ enum CustomTabbarController { tabBarController.isTabBarHidden = true } - if #available(iOS 26.0, *) { + if #available(iOS 26.0, *), !isPad { tabBarController.isTabBarHidden = false tabBarController.tabBarMinimizeBehavior = .onScrollDown diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ba5b5f1008b..78d00d81b18 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@follow/mobile", - "version": "0.4.0", + "version": "0.4.1", "private": true, "main": "src/main.tsx", "scripts": { @@ -24,7 +24,7 @@ "web": "expo start --web" }, "dependencies": { - "@better-auth/expo": "1.3.28", + "@better-auth/expo": "1.5.5", "@expo/metro-runtime": "6.1.2", "@expo/react-native-action-sheet": "4.1.1", "@follow-app/client-sdk": "catalog:", @@ -40,7 +40,6 @@ "@follow/utils": "workspace:*", "@gorhom/portal": "1.0.14", "@hookform/resolvers": "5.2.2", - "@openpanel/sdk": "1.0.4", "@react-native-community/image-editor": "4.3.0", "@react-native-firebase/analytics": "22.2.1", "@react-native-firebase/app": "22.2.1", @@ -54,7 +53,7 @@ "@tanstack/react-query": "5.90.21", "@tanstack/react-query-persist-client": "5.90.22", "@types/qrcode": "1.5.6", - "better-auth": "1.3.28", + "better-auth": "1.5.5", "camelcase-keys": "10.0.2", "dayjs": "1.11.19", "dnum": "2.17.0", @@ -81,6 +80,7 @@ "expo-linking": "8.0.11", "expo-localization": "17.0.8", "expo-media-library": "18.2.1", + "expo-network": "8.0.8", "expo-notifications": "0.32.16", "expo-secure-store": "15.0.8", "expo-sharing": "14.0.8", diff --git a/apps/mobile/src/components/common/NoLoginInfo.tsx b/apps/mobile/src/components/common/NoLoginInfo.tsx index 76e1d1042ca..e24c08022a0 100644 --- a/apps/mobile/src/components/common/NoLoginInfo.tsx +++ b/apps/mobile/src/components/common/NoLoginInfo.tsx @@ -1,20 +1,26 @@ -import { Pressable } from "react-native" +import { Pressable, View } from "react-native" import { Text } from "@/src/components/ui/typography/Text" import { destination } from "@/src/lib/navigation/biz/Destination" +import { useReadableContainerStyle } from "@/src/lib/responsive" import { accentColor } from "@/src/theme/colors" import { Logo } from "../ui/logo" export function NoLoginInfo({ target }: { target: "timeline" | "subscriptions" }) { + const readableContainerStyle = useReadableContainerStyle(420) return ( <Pressable testID={`no-login-${target}`} className="flex-1 items-center justify-center gap-3" onPress={() => destination.Login()} > - <Logo width={40} height={40} color={accentColor} /> - <Text className="text-xl text-secondary-label">{`Sign in to see your ${target}`}</Text> + <View className="items-center gap-3 px-6" style={readableContainerStyle}> + <Logo width={40} height={40} color={accentColor} /> + <Text className="text-center text-xl text-secondary-label"> + {`Sign in to see your ${target}`} + </Text> + </View> </Pressable> ) } diff --git a/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx b/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx index 4ebfe33fa29..bf212dc76e1 100644 --- a/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx +++ b/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx @@ -38,6 +38,7 @@ type SafeNavigationScrollViewProps = Omit<ScrollViewProps, "onScroll"> & { contentViewStyle?: StyleProp<ViewStyle> contentViewClassName?: string + contentContainerMaxWidth?: number Header?: React.ReactNode ScrollViewBottom?: React.ReactNode @@ -55,6 +56,7 @@ export const SafeNavigationScrollView = ({ reanimatedScrollY, contentViewClassName, contentViewStyle, + contentContainerMaxWidth, Header, ScrollViewBottom, ...props @@ -63,6 +65,9 @@ export const SafeNavigationScrollView = ({ const tabBarHeight = useBottomTabBarHeight() const frame = useSafeAreaFrame() + const resolvedContentContainerWidth = contentContainerMaxWidth + ? Math.min(frame.width, contentContainerMaxWidth) + : undefined const sheetModal = useScreenIsInSheetModal() const [headerHeight, setHeaderHeight] = useState( () => @@ -140,7 +145,18 @@ export const SafeNavigationScrollView = ({ {...props} > <View style={{ height: headerHeight - (withTopInset ? insets.top : 0) }} /> - <View style={contentViewStyle} className={cn("flex-1", contentViewClassName)}> + <View + style={[ + contentContainerMaxWidth + ? { + width: resolvedContentContainerWidth, + alignSelf: "center", + } + : undefined, + contentViewStyle, + ]} + className={cn("flex-1", contentViewClassName)} + > {children} </View> {ScrollViewBottom} diff --git a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx index e6475d47352..c298d310bca 100644 --- a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx +++ b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx @@ -3,7 +3,7 @@ import { Portal } from "@gorhom/portal" import { useAtom } from "jotai" import * as React from "react" import { useCallback, useEffect } from "react" -import { View } from "react-native" +import { Dimensions, StyleSheet, View } from "react-native" import { runOnJS, runOnUI } from "react-native-reanimated" import TrackPlayer from "react-native-track-player" @@ -99,16 +99,22 @@ export function EntryContentWebView(props: EntryContentWebViewProps) { handleModeSwitch(nextMode) }, [mode, handleModeSwitch]) + useEffect(() => { + // Reset the shared container height before the next entry content arrives. + setContentHeight(Dimensions.get("window").height) + }, [props.entryId, props.showReadability, props.showTranslation, setContentHeight]) + return ( <> <View - key={mode} - style={{ height: contentHeight, transform: [{ translateY: 0 }] }} + key={`${mode}-${props.entryId}`} + style={{ width: "100%", height: contentHeight, transform: [{ translateY: 0 }] }} onLayout={() => { WebViewManager.setEntry(entryInWebview) }} > <NativeWebView + style={styles.webView} onContentHeightChange={(e) => { setContentHeight(e.nativeEvent.height) }} @@ -127,3 +133,10 @@ export function EntryContentWebView(props: EntryContentWebViewProps) { </> ) } + +const styles = StyleSheet.create({ + webView: { + width: "100%", + height: "100%", + }, +}) diff --git a/apps/mobile/src/components/ui/grouped/GroupedList.tsx b/apps/mobile/src/components/ui/grouped/GroupedList.tsx index 90b25166b12..58538a4aa49 100644 --- a/apps/mobile/src/components/ui/grouped/GroupedList.tsx +++ b/apps/mobile/src/components/ui/grouped/GroupedList.tsx @@ -12,6 +12,7 @@ import { titleCase } from "title-case" import { Text } from "@/src/components/ui/typography/Text" import { CheckFilledIcon } from "@/src/icons/check_filled" import { MingcuteRightLine } from "@/src/icons/mingcute_right_line" +import { useIsTabletLayout } from "@/src/lib/responsive" import { accentColor, useColor } from "@/src/theme/colors" import { PlatformActivityIndicator } from "../loading/PlatformActivityIndicator" @@ -24,6 +25,8 @@ import { } from "./constants" import { GroupedInsetListCardItemStyle } from "./GroupedInsetListCardItemStyle" +const GROUPED_TABLET_MAX_WIDTH = 760 + interface GroupedInsetListCardProps { showSeparator?: boolean SeparatorComponent?: FC @@ -53,10 +56,16 @@ export const GroupedInsetListCard: FC< () => React.Children.toArray(children).filter(Boolean), [children], ) + const isTablet = useIsTabletLayout() return ( <View {...props} style={[ + isTablet && { + width: "100%", + maxWidth: GROUPED_TABLET_MAX_WIDTH, + alignSelf: "center", + }, { marginHorizontal: GROUPED_LIST_MARGIN, }, @@ -106,9 +115,13 @@ export const GroupedInsetListSectionHeader: FC<{ label: string marginSize?: "normal" | "small" }> = ({ label, marginSize = "normal" }) => { + const isTablet = useIsTabletLayout() return ( <View style={{ + width: isTablet ? "100%" : undefined, + maxWidth: isTablet ? GROUPED_TABLET_MAX_WIDTH : undefined, + alignSelf: isTablet ? "center" : undefined, paddingHorizontal: GROUPED_LIST_ITEM_PADDING, marginHorizontal: GROUPED_LIST_MARGIN, marginTop: diff --git a/apps/mobile/src/constants/views.tsx b/apps/mobile/src/constants/views.tsx index 45871489d72..a9c3e251bb6 100644 --- a/apps/mobile/src/constants/views.tsx +++ b/apps/mobile/src/constants/views.tsx @@ -1,5 +1,6 @@ import type { ViewDefinition as ViewDefinitionBase } from "@follow/constants" import { FeedViewType, getViewList } from "@follow/constants" +import type { FC } from "react" import { AnnouncementCuteFiIcon } from "../icons/announcement_cute_fi" import { BubbleCuteFiIcon } from "../icons/bubble_cute_fi" @@ -10,7 +11,7 @@ import { ThoughtCuteFiIcon } from "../icons/thought_cute_fi" import { VideoCuteFiIcon } from "../icons/video_cute_fi" interface ViewDefinitionExtended { - icon: React.FC<{ color?: string; height?: number; width?: number }> + icon: FC<{ color?: string; height?: number; width?: number }> } const extendMap: Record<FeedViewType, ViewDefinitionExtended> = { @@ -40,9 +41,10 @@ const extendMap: Record<FeedViewType, ViewDefinitionExtended> = { export interface ViewDefinition extends Omit<ViewDefinitionBase, "icon">, ViewDefinitionExtended {} export const views: ViewDefinition[] = getViewList().map((view) => { - const extendedView = extendMap[view.view] + const extendedView = extendMap[view.view]! + const { icon: _icon, ...restView } = view return { - ...view, + ...restView, ...extendedView, - } + } as ViewDefinition }) diff --git a/apps/mobile/src/initialize/analytics.ts b/apps/mobile/src/initialize/analytics.ts index 4e777fcdafc..b182cceba1b 100644 --- a/apps/mobile/src/initialize/analytics.ts +++ b/apps/mobile/src/initialize/analytics.ts @@ -35,14 +35,4 @@ export const initAnalytics = async () => { if (user) { tracker.identify(user as AuthUser) } - - // op.setGlobalProperties({ - // build: "rn", - // version: nativeApplicationVersion, - // buildId: nativeBuildVersion, - // }) - - // op.setHeaders({ - // "User-Agent": await getUserAgent(), - // }) } diff --git a/apps/mobile/src/lib/auth.ts b/apps/mobile/src/lib/auth.ts index d7d2eeff84f..5a5ac3a2842 100644 --- a/apps/mobile/src/lib/auth.ts +++ b/apps/mobile/src/lib/auth.ts @@ -38,6 +38,18 @@ const bumpAuthStateRevision = () => { return authStateRevision } +// Session-scoped queries must be reset on auth transitions, otherwise mounted timeline +// queries can keep rendering anonymous cache under the new session. +const refreshSessionQueries = () => + Promise.allSettled([ + queryClient.invalidateQueries({ queryKey: whoamiQueryKey }), + queryClient.invalidateQueries({ queryKey: isNewUserQueryKey }), + queryClient.resetQueries({ queryKey: ["entries"] }), + queryClient.resetQueries({ queryKey: ["subscription"] }), + queryClient.resetQueries({ queryKey: ["unread"] }), + queryClient.resetQueries({ queryKey: ["owned", "lists"] }), + ]) + const plugins = [ ...baseAuthPlugins, expoClient({ @@ -45,6 +57,7 @@ const plugins = [ storagePrefix, storage: { setItem(key: string, value: string) { + const previousValue = key === cookieKey ? safeSecureStore.getItem(key) : null try { safeSecureStore.setItem(key, value) } catch (e) { @@ -62,8 +75,10 @@ const plugins = [ } } bumpAuthStateRevision() - queryClient.invalidateQueries({ queryKey: whoamiQueryKey }) - queryClient.invalidateQueries({ queryKey: isNewUserQueryKey }) + const authStateChanged = !previousValue + if (authStateChanged) { + void refreshSessionQueries() + } } }, getItem(key: string) { @@ -75,6 +90,7 @@ const plugins = [ } }, removeItem(key: string) { + const previousValue = key === cookieKey ? safeSecureStore.getItem(key) : null try { safeSecureStore.removeItem(key) } catch (e) { @@ -88,8 +104,9 @@ const plugins = [ safeSecureStore.removeItem(`${cookieKey}_${env}`) } bumpAuthStateRevision() - queryClient.invalidateQueries({ queryKey: whoamiQueryKey }) - queryClient.invalidateQueries({ queryKey: isNewUserQueryKey }) + if (previousValue) { + void refreshSessionQueries() + } } }, } as any, @@ -138,7 +155,6 @@ export const authClient = createAuthClient({ export const { changeEmail, changePassword, - forgetPassword, getAccountInfo, getCookie, getProviders, @@ -153,6 +169,8 @@ export const { useSession, } = authClient +export const forgetPassword = authClient.requestPasswordReset + export interface AuthProvider { name: string id: string @@ -192,8 +210,7 @@ export const signOut = async () => { await userActions.removeCurrentUser() Navigation.rootNavigation.popToRoot() bumpAuthStateRevision() - queryClient.invalidateQueries({ queryKey: whoamiQueryKey }) - queryClient.invalidateQueries({ queryKey: isNewUserQueryKey }) + await refreshSessionQueries() const dbPath = getDbPath() await FileSystem.deleteAsync(dbPath) await expo.reloadAppAsync("User sign out") diff --git a/apps/mobile/src/lib/op.ts b/apps/mobile/src/lib/op.ts deleted file mode 100644 index ca48411e5bb..00000000000 --- a/apps/mobile/src/lib/op.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { OpenPanel } from "@follow/tracker/src/op" - -import { proxyEnv } from "./proxy-env" - -export const op = new OpenPanel({ - clientId: proxyEnv.OPENPANEL_CLIENT_ID ?? "", - apiUrl: proxyEnv.OPENPANEL_API_URL, - headers: { - Origin: "https://app.folo.is", - }, - sdk: "react-native", - sdkVersion: "1.0.0", -}) diff --git a/apps/mobile/src/lib/payment.ts b/apps/mobile/src/lib/payment.ts index 9ab6d667ca3..84911258a63 100644 --- a/apps/mobile/src/lib/payment.ts +++ b/apps/mobile/src/lib/payment.ts @@ -28,5 +28,4 @@ export const isAndroidApkInstall = () => { } } -export const isPaymentFeatureEnabled = (paymentEnabled?: boolean | null) => - Boolean(paymentEnabled) && (!isAndroid || isAndroidApkInstall()) +export const isPaymentFeatureEnabled: (paymentEnabled?: boolean | null) => boolean = Boolean diff --git a/apps/mobile/src/lib/platform.ts b/apps/mobile/src/lib/platform.ts index c487338224d..54119f4c19c 100644 --- a/apps/mobile/src/lib/platform.ts +++ b/apps/mobile/src/lib/platform.ts @@ -1,3 +1,4 @@ +import { DeviceType } from "expo-device" import { Platform } from "react-native" import { useDeviceType } from "../atoms/hooks/useDeviceType" @@ -9,7 +10,7 @@ export const devicePlatform = isIOS ? "ios" : isAndroid ? "android" : "web" export const isWeb = !isNative export const useIsiPad = () => { - return useDeviceType() && isIOS + return useDeviceType() === DeviceType.TABLET && isIOS } export const isIos26 = Number.parseFloat(String(Platform.Version)) >= 26 diff --git a/apps/mobile/src/lib/responsive.ts b/apps/mobile/src/lib/responsive.ts index 886ecb5d497..e0241fcaa3a 100644 --- a/apps/mobile/src/lib/responsive.ts +++ b/apps/mobile/src/lib/responsive.ts @@ -1,8 +1,15 @@ -import { useCallback } from "react" +import { DeviceType } from "expo-device" +import { useCallback, useMemo } from "react" +import type { ViewStyle } from "react-native" import { Dimensions, useWindowDimensions } from "react-native" +import { useDeviceType } from "../atoms/hooks/useDeviceType" +import { isIOS } from "./platform" + const baseWidth = 375 const baseHeight = 812 +const maxResponsiveHeight = 932 +const tabletMinLength = 744 const windowDim = Dimensions.get("window") Dimensions.addEventListener("change", ({ window }) => { @@ -22,7 +29,7 @@ export const scaleWidth = (size: number) => { * @returns */ export const scaleHeight = (size: number) => { - return (size / baseHeight) * windowDim.height + return (size / baseHeight) * Math.min(windowDim.height, maxResponsiveHeight) } export const useScaleWidth = () => { const windowDim = useWindowDimensions() @@ -40,8 +47,36 @@ export const useScaleHeight = () => { return useCallback( (size: number) => { - return (size / baseHeight) * windowDim.height + return (size / baseHeight) * Math.min(windowDim.height, maxResponsiveHeight) }, [windowDim.height], ) } + +export const useIsTabletLayout = () => { + const deviceType = useDeviceType() + const { width, height } = useWindowDimensions() + + if (!isIOS) { + return false + } + + return deviceType === DeviceType.TABLET || Math.min(width, height) >= tabletMinLength +} + +export const useReadableContainerStyle = (maxWidth: number, gutter = 24) => { + const isTablet = useIsTabletLayout() + const { width } = useWindowDimensions() + + return useMemo<ViewStyle | undefined>(() => { + if (!isTablet) { + return + } + + return { + width: "100%", + maxWidth: Math.max(Math.min(maxWidth, width - gutter * 2), 0), + alignSelf: "center", + } + }, [gutter, isTablet, maxWidth, width]) +} diff --git a/apps/mobile/src/modules/ai/summary.tsx b/apps/mobile/src/modules/ai/summary.tsx index 530624ac36f..14fa8f7ef32 100644 --- a/apps/mobile/src/modules/ai/summary.tsx +++ b/apps/mobile/src/modules/ai/summary.tsx @@ -160,7 +160,7 @@ export const AISummary: FC<{ const mainContent = ( <Animated.View className={cn( - "mx-4 my-2 rounded-xl border-opaque-separator/50", + "my-2 rounded-xl border-opaque-separator/50", isReactElement ? "px-4 pt-4" : "p-4", // Opacity modifier style incorrectly applied in Android isAndroid ? "bg-secondary-system-background" : "bg-secondary-system-background/30", diff --git a/apps/mobile/src/modules/context-menu/feeds.tsx b/apps/mobile/src/modules/context-menu/feeds.tsx index e681b185ec8..9cf7767161d 100644 --- a/apps/mobile/src/modules/context-menu/feeds.tsx +++ b/apps/mobile/src/modules/context-menu/feeds.tsx @@ -231,9 +231,9 @@ const generateSubscriptionContextMenu = (navigation: Navigation, id: string) => destructive onSelect={() => { // unsubscribe - Alert.alert("Unsubscribe?", "This will remove the feed from your subscriptions", [ + Alert.alert(t("feed.unfollow.confirm_title"), t("feed.unfollow.confirm_description"), [ { - text: "Cancel", + text: t("words.cancel", { ns: "common" }), style: "cancel", }, { diff --git a/apps/mobile/src/modules/context-menu/lists.tsx b/apps/mobile/src/modules/context-menu/lists.tsx index ed6d172064b..ce4483f2628 100644 --- a/apps/mobile/src/modules/context-menu/lists.tsx +++ b/apps/mobile/src/modules/context-menu/lists.tsx @@ -52,9 +52,9 @@ export const SubscriptionListItemContextMenu: FC< title: t("operation.unfollow"), destructive: true, onSelect: () => { - Alert.alert(t("operation.unfollow"), "Are you sure you want to unsubscribe?", [ + Alert.alert(t("feed.unfollow.confirm_title"), t("feed.unfollow.confirm_description"), [ { - text: "Cancel", + text: t("words.cancel", { ns: "common" }), style: "cancel", }, { diff --git a/apps/mobile/src/modules/discover/Recommendations.tsx b/apps/mobile/src/modules/discover/Recommendations.tsx index 0c3fb3b8e70..a2305970136 100644 --- a/apps/mobile/src/modules/discover/Recommendations.tsx +++ b/apps/mobile/src/modules/discover/Recommendations.tsx @@ -160,7 +160,7 @@ export const RecommendationTab: TabComponent<{ }) const { data: analysisData, isLoading: isAnalysisLoading } = useQuery({ queryKey: ["rsshub-analysis", discoverLanguage], - queryFn: () => fetchRsshubAnalysis(discoverLanguage).then((res) => res.data), + queryFn: () => fetchRsshubAnalysis().then((res) => res.data), staleTime: 1000 * 60 * 60 * 24, // 1 day meta: { diff --git a/apps/mobile/src/modules/discover/api.ts b/apps/mobile/src/modules/discover/api.ts index 55247df6f26..cc33d449c4b 100644 --- a/apps/mobile/src/modules/discover/api.ts +++ b/apps/mobile/src/modules/discover/api.ts @@ -18,10 +18,8 @@ export const fetchRsshubPopular = (category: DiscoverCategories, lang: Language) }) } -export const fetchRsshubAnalysis = (lang: Language) => { - return followClient.api.discover.rsshubAnalytics({ - ...(lang !== "all" && { lang }), - }) +export const fetchRsshubAnalysis = () => { + return followClient.api.discover.rsshubAnalytics({}) } export const fetchFeedTrending = ({ diff --git a/apps/mobile/src/modules/discover/search.tsx b/apps/mobile/src/modules/discover/search.tsx index f90869ee7aa..d1e125aa553 100644 --- a/apps/mobile/src/modules/discover/search.tsx +++ b/apps/mobile/src/modules/discover/search.tsx @@ -231,7 +231,7 @@ const SearchInput = () => { allowFontScaling={false} className="text-[16px] font-medium text-accent" > - Cancel + {t("words.cancel")} </Text> </ReAnimatedPressable> </Animated.View> diff --git a/apps/mobile/src/modules/entry-content/EntryTitle.tsx b/apps/mobile/src/modules/entry-content/EntryTitle.tsx index 773a9428bf6..f6dacc88643 100644 --- a/apps/mobile/src/modules/entry-content/EntryTitle.tsx +++ b/apps/mobile/src/modules/entry-content/EntryTitle.tsx @@ -32,7 +32,7 @@ export const EntryTitle = ({ title, entryId }: { title: string; entryId: string }} > <EntryTranslation - className="px-5 text-title2 font-bold leading-snug text-label" + className="text-title2 font-bold leading-snug text-label" source={title} target={translation?.title} bilingual diff --git a/apps/mobile/src/modules/entry-list/EntryListContentArticle.tsx b/apps/mobile/src/modules/entry-list/EntryListContentArticle.tsx index dcf1f7dd7e7..62df35e715b 100644 --- a/apps/mobile/src/modules/entry-list/EntryListContentArticle.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListContentArticle.tsx @@ -9,6 +9,7 @@ import { View } from "react-native" import { useActionLanguage, useGeneralSettingKey } from "@/src/atoms/settings/general" import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks" +import { useReadableContainerStyle } from "@/src/lib/responsive" import { useHeaderHeight } from "@/src/modules/screen/hooks/useHeaderHeight" import { useEntries } from "../screen/atoms" @@ -37,21 +38,32 @@ export const EntryListContentArticle = ({ ref?: React.Ref<ElementRef<typeof TimelineSelectorList> | null> }) => { const extraData: EntryExtraData = useMemo(() => ({ entryIds }), [entryIds]) - - const { fetchNextPage, isFetching, refetch, isRefetching, hasNextPage, fetchedTime, isReady } = - useEntries({ viewId: view, active }) + const readableItemStyle = useReadableContainerStyle(860, 16) + + const { + fetchNextPage, + isFetching, + isFetchingNextPage, + refetch, + isRefetching, + hasNextPage, + fetchedTime, + isReady, + } = useEntries({ viewId: view, active }) const renderItem = useCallback( ({ item: id, extraData, index }: ListRenderItemInfo<string>) => ( - <EntryNormalItem - entryId={id} - extraData={extraData as EntryExtraData} - view={view} - hasTopSeparator={index > 0} - testID={index === 0 ? "timeline-entry-first" : undefined} - /> + <View style={readableItemStyle}> + <EntryNormalItem + entryId={id} + extraData={extraData as EntryExtraData} + view={view} + hasTopSeparator={index > 0} + testID={index === 0 ? "timeline-entry-first" : undefined} + /> + </View> ), - [view], + [readableItemStyle, view], ) const ListFooterComponent = useMemo( @@ -63,6 +75,7 @@ export const EntryListContentArticle = ({ const { onViewableItemsChanged, onScroll, viewableItems } = useOnViewableItemsChanged({ disabled: active === false || isFetching, + refreshing: isFetching && !isFetchingNextPage, }) useImperativeHandle(forwardRef, () => ref.current!) @@ -89,7 +102,9 @@ export const EntryListContentArticle = ({ return ( <View className="flex-1" style={{ paddingTop: headerHeight, paddingBottom: tabBarHeight }}> {ARTICLE_SKELETON_KEYS.map((key) => ( - <EntryItemSkeleton key={key} /> + <View key={key} style={readableItemStyle}> + <EntryItemSkeleton /> + </View> ))} </View> ) diff --git a/apps/mobile/src/modules/entry-list/EntryListContentPicture.tsx b/apps/mobile/src/modules/entry-list/EntryListContentPicture.tsx index 40ce82797b7..0550c5f1215 100644 --- a/apps/mobile/src/modules/entry-list/EntryListContentPicture.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListContentPicture.tsx @@ -11,6 +11,7 @@ import { StyleSheet, View } from "react-native" import { useActionLanguage, useGeneralSettingKey } from "@/src/atoms/settings/general" import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks" import { PlatformActivityIndicator } from "@/src/components/ui/loading/PlatformActivityIndicator" +import { useIsTabletLayout } from "@/src/lib/responsive" import { useEntries } from "@/src/modules/screen/atoms" import { useHeaderHeight } from "@/src/modules/screen/hooks/useHeaderHeight" @@ -38,14 +39,24 @@ export const EntryListContentPicture = ({ "data" | "renderItem" > & { ref?: React.Ref<ElementRef<typeof TimelineSelectorMasonryList> | null> }) => { const ref = useRef<FlashListRef<any>>(null) + const isTablet = useIsTabletLayout() useImperativeHandle(forwardRef, () => ref.current!) - const { fetchNextPage, refetch, isRefetching, hasNextPage, isFetching, isReady } = useEntries({ + const { + fetchNextPage, + refetch, + isRefetching, + hasNextPage, + isFetching, + isFetchingNextPage, + isReady, + } = useEntries({ viewId: view, active, }) const { onViewableItemsChanged, onScroll, viewableItems } = useOnViewableItemsChanged({ disabled: active === false || isFetching, + refreshing: isFetching && !isFetchingNextPage, }) const translation = useGeneralSettingKey("translation") const translationMode = useGeneralSettingKey("translationMode") @@ -103,8 +114,8 @@ export const EntryListContentPicture = ({ onViewableItemsChanged={onViewableItemsChanged} onScroll={onScroll} onEndReached={fetchNextPage} - numColumns={2} - contentContainerStyle={styles.contentContainer} + numColumns={isTablet ? 3 : 2} + contentContainerStyle={isTablet ? styles.tabletContentContainer : styles.contentContainer} ListFooterComponent={ hasNextPage ? ( <View className="h-20 items-center justify-center"> @@ -164,6 +175,9 @@ const styles = StyleSheet.create({ contentContainer: { paddingHorizontal: 8, }, + tabletContentContainer: { + paddingHorizontal: 16, + }, skeletonHeight120: { height: 120, }, diff --git a/apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx b/apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx index b2e9d1cda65..75d8dbd10c0 100644 --- a/apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListContentSocial.tsx @@ -25,7 +25,15 @@ export const EntryListContentSocial = ({ }: { entryIds: string[] | null; active?: boolean; view: FeedViewType } & { ref?: React.Ref<ElementRef<typeof TimelineSelectorList> | null> }) => { - const { fetchNextPage, isFetching, refetch, isRefetching, hasNextPage, isReady } = useEntries({ + const { + fetchNextPage, + isFetching, + isFetchingNextPage, + refetch, + isRefetching, + hasNextPage, + isReady, + } = useEntries({ viewId: view, active, }) @@ -48,6 +56,7 @@ export const EntryListContentSocial = ({ const { onViewableItemsChanged, onScroll, viewableItems } = useOnViewableItemsChanged({ disabled: active === false || isFetching, + refreshing: isFetching && !isFetchingNextPage, }) const translation = useGeneralSettingKey("translation") diff --git a/apps/mobile/src/modules/entry-list/EntryListContentVideo.tsx b/apps/mobile/src/modules/entry-list/EntryListContentVideo.tsx index 5c8869d8df0..76b7a06ca94 100644 --- a/apps/mobile/src/modules/entry-list/EntryListContentVideo.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListContentVideo.tsx @@ -10,6 +10,7 @@ import { StyleSheet, View } from "react-native" import { useActionLanguage, useGeneralSettingKey } from "@/src/atoms/settings/general" import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks" +import { useIsTabletLayout } from "@/src/lib/responsive" import { useEntries } from "@/src/modules/screen/atoms" import { useHeaderHeight } from "@/src/modules/screen/hooks/useHeaderHeight" @@ -32,12 +33,22 @@ export const EntryListContentVideo = ({ > & { ref?: React.Ref<ElementRef<typeof TimelineSelectorMasonryList> | null> }) => { const ref = useRef<FlashListRef<any>>(null) useImperativeHandle(forwardRef, () => ref.current!) - const { fetchNextPage, refetch, isRefetching, isFetching, hasNextPage, isReady } = useEntries({ + const isTablet = useIsTabletLayout() + const { + fetchNextPage, + refetch, + isRefetching, + isFetching, + isFetchingNextPage, + hasNextPage, + isReady, + } = useEntries({ viewId: view, active, }) const { onViewableItemsChanged, onScroll, viewableItems } = useOnViewableItemsChanged({ disabled: active === false || isFetching, + refreshing: isFetching && !isFetchingNextPage, }) const translation = useGeneralSettingKey("translation") @@ -106,11 +117,11 @@ export const EntryListContentVideo = ({ onViewableItemsChanged={onViewableItemsChanged} onScroll={onScroll} onEndReached={fetchNextPage} - numColumns={2} + numColumns={isTablet ? 3 : 2} ListFooterComponent={ListFooterComponent} {...rest} onRefresh={refetch} - contentContainerStyle={styles.contentContainer} + contentContainerStyle={isTablet ? styles.tabletContentContainer : styles.contentContainer} /> ) } @@ -119,6 +130,9 @@ const styles = StyleSheet.create({ contentContainer: { paddingHorizontal: 8, }, + tabletContentContainer: { + paddingHorizontal: 16, + }, }) const defaultKeyExtractor = (item: string) => { diff --git a/apps/mobile/src/modules/entry-list/hooks.ts b/apps/mobile/src/modules/entry-list/hooks.ts index 2bd5bd79b21..89ab667e27b 100644 --- a/apps/mobile/src/modules/entry-list/hooks.ts +++ b/apps/mobile/src/modules/entry-list/hooks.ts @@ -1,3 +1,4 @@ +import { useScrollMarkReadGracePeriod } from "@follow/hooks" import { debouncedFetchEntryContentByStream } from "@follow/store/entry/store" import { unreadSyncService } from "@follow/store/unread/store" import { useIsLoggedIn } from "@follow/store/user/hooks" @@ -12,10 +13,12 @@ export function useOnViewableItemsChanged({ disabled, idExtractor = defaultIdExtractor, onScroll: onScrollProp, + refreshing, }: { disabled?: boolean idExtractor?: (item: ViewToken<string>) => string onScroll?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void + refreshing?: boolean } = {}) { const orientation = useRef<"down" | "up">("down") const lastOffset = useRef(0) @@ -23,6 +26,7 @@ export function useOnViewableItemsChanged({ const markAsReadWhenScrolling = useGeneralSettingKey("scrollMarkUnread") const markAsReadWhenRendering = useGeneralSettingKey("renderMarkUnread") + const pauseScrollMarkRead = useScrollMarkReadGracePeriod(refreshing ?? false) const [viewableItems, setViewableItems] = useState<ViewToken<string>[]>([]) const [lastViewableItems, setLastViewableItems] = useState<ViewToken<string>[] | null>() const [lastRemovedItems, setLastRemovedItems] = useState<ViewToken<string>[] | null>(null) @@ -42,6 +46,11 @@ export function useOnViewableItemsChanged({ // This can avoid misjudgment during the rebound of the pull-to-refresh (because the offset will change from negative to zero during the rebound). if (orientation.current === "down" && lastOffset.current > 0) { setLastViewableItems(viewableItems) + if (pauseScrollMarkRead) { + setLastRemovedItems(null) + return + } + if (removed.length > 0) { setLastRemovedItems((prev) => { if (prev) { @@ -57,10 +66,18 @@ export function useOnViewableItemsChanged({ } }) + useEffect(() => { + if (!disabled && !pauseScrollMarkRead) { + return + } + + setLastRemovedItems(null) + }, [disabled, pauseScrollMarkRead]) + useEffect(() => { if (disabled) return - if (isLoggedIn && markAsReadWhenScrolling && lastRemovedItems) { + if (isLoggedIn && markAsReadWhenScrolling && !pauseScrollMarkRead && lastRemovedItems) { lastRemovedItems.forEach((item) => { unreadSyncService.markEntryAsRead(stableIdExtractor(item)).then(() => { setLastRemovedItems((prev) => { @@ -86,6 +103,7 @@ export function useOnViewableItemsChanged({ lastViewableItems, markAsReadWhenRendering, markAsReadWhenScrolling, + pauseScrollMarkRead, stableIdExtractor, ]) diff --git a/apps/mobile/src/modules/feed/FollowFeed.tsx b/apps/mobile/src/modules/feed/FollowFeed.tsx index 8bf93b5cd6e..b8ec45b1d3c 100644 --- a/apps/mobile/src/modules/feed/FollowFeed.tsx +++ b/apps/mobile/src/modules/feed/FollowFeed.tsx @@ -128,7 +128,7 @@ function FollowImpl(props: { feedId: string; defaultView?: FeedViewType }) { } else { await subscriptionSyncService.subscribe(body) } - toast.success(isSubscribed ? "Feed updated" : "Feed followed") + toast.success(t(isSubscribed ? "feed.follow.update_success" : "feed.follow.success")) if (canDismiss) { navigate.dismiss() } else { @@ -144,9 +144,9 @@ function FollowImpl(props: { feedId: string; defaultView?: FeedViewType }) { const handleUnfollow = () => { if (!subscription?.feedId || isLoading) return - Alert.alert("Unsubscribe?", "This will remove the feed from your subscriptions", [ + Alert.alert(t("feed.unfollow.confirm_title"), t("feed.unfollow.confirm_description"), [ { - text: "Cancel", + text: tCommon("words.cancel"), style: "cancel", }, { @@ -156,7 +156,7 @@ function FollowImpl(props: { feedId: string; defaultView?: FeedViewType }) { try { setIsLoading(true) await subscriptionSyncService.unsubscribe(subscription.feedId) - toast.success("Feed unfollowed") + toast.success(t("feed.unfollow.success")) if (canDismiss) { navigate.dismiss() } else { @@ -181,7 +181,7 @@ function FollowImpl(props: { feedId: string; defaultView?: FeedViewType }) { }) }, [isDirty, setScreenOptions]) if (!feed?.id) { - return <Text className="text-label">Feed ({id}) not found</Text> + return <Text className="text-label">{t("feed.not_found", { id })}</Text> } return ( <SafeNavigationScrollView diff --git a/apps/mobile/src/modules/login/index.tsx b/apps/mobile/src/modules/login/index.tsx index edf4c2748f9..6d1aab1825d 100644 --- a/apps/mobile/src/modules/login/index.tsx +++ b/apps/mobile/src/modules/login/index.tsx @@ -1,3 +1,4 @@ +import { cn } from "@follow/utils" import { useState } from "react" import { Trans, useTranslation } from "react-i18next" import { Linking, Pressable, TouchableWithoutFeedback, View } from "react-native" @@ -7,7 +8,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context" import { Logo } from "@/src/components/ui/logo" import { Text } from "@/src/components/ui/typography/Text" -import { useScaleHeight } from "@/src/lib/responsive" +import { useIsTabletLayout, useReadableContainerStyle, useScaleHeight } from "@/src/lib/responsive" import { EmailLogin, EmailSignUp } from "./email" import { SocialLogin } from "./social" @@ -15,6 +16,8 @@ import { SocialLogin } from "./social" export function Login() { const insets = useSafeAreaInsets() const scaledHeight = useScaleHeight() + const isTablet = useIsTabletLayout() + const contentWidthStyle = useReadableContainerStyle(480) const logoSize = scaledHeight(80) const gapSize = scaledHeight(28) const fontSize = scaledHeight(28) @@ -25,12 +28,13 @@ export function Login() { return ( <View testID="login-screen" - className="pb-safe-or-2 flex-1 justify-between" + className={cn("pb-safe-or-2 flex-1", isTablet ? "justify-center px-6" : "justify-between")} style={{ - paddingTop: insets.top + 56, + paddingTop: insets.top + (isTablet ? 36 : 56), + paddingBottom: insets.bottom + (isTablet ? 32 : 0), }} > - <KeyboardAvoidingView behavior={"position"}> + <KeyboardAvoidingView behavior={"position"} style={contentWidthStyle}> <TouchableWithoutFeedback onPress={() => { KeyboardController.dismiss() @@ -69,31 +73,63 @@ export function Login() { )} </View> </TouchableWithoutFeedback> - <TermsCheckBox /> - <View className="mt-14"> - {isEmail ? ( - <Text - className="pb-2 text-center text-lg font-medium text-label" - testID="auth-back" - onPress={() => setIsEmail(false)} - > - {t("login.back")} - </Text> - ) : ( - <Pressable testID="auth-toggle-mode" onPress={() => setIsRegister(!isRegister)}> - <Text className="pb-2 text-center text-lg font-medium text-label"> - <Trans - t={t} - i18nKey={isRegister ? "login.have_account" : "login.no_account"} - components={{ - strong: <Text className="text-accent" />, - }} - /> + {!isTablet && ( + <> + <TermsCheckBox /> + <View className="mt-14"> + {isEmail ? ( + <Text + className="pb-2 text-center text-lg font-medium text-label" + testID="auth-back" + onPress={() => setIsEmail(false)} + > + {t("login.back")} + </Text> + ) : ( + <Pressable testID="auth-toggle-mode" onPress={() => setIsRegister(!isRegister)}> + <Text className="pb-2 text-center text-lg font-medium text-label"> + <Trans + t={t} + i18nKey={isRegister ? "login.have_account" : "login.no_account"} + components={{ + strong: <Text className="text-accent" />, + }} + /> + </Text> + </Pressable> + )} + </View> + </> + )} + </KeyboardAvoidingView> + {isTablet && ( + <View style={contentWidthStyle}> + <TermsCheckBox /> + <View className="mt-8"> + {isEmail ? ( + <Text + className="pb-2 text-center text-lg font-medium text-label" + testID="auth-back" + onPress={() => setIsEmail(false)} + > + {t("login.back")} </Text> - </Pressable> - )} + ) : ( + <Pressable testID="auth-toggle-mode" onPress={() => setIsRegister(!isRegister)}> + <Text className="pb-2 text-center text-lg font-medium text-label"> + <Trans + t={t} + i18nKey={isRegister ? "login.have_account" : "login.no_account"} + components={{ + strong: <Text className="text-accent" />, + }} + /> + </Text> + </Pressable> + )} + </View> </View> - </KeyboardAvoidingView> + )} </View> ) } diff --git a/apps/mobile/src/modules/login/social.tsx b/apps/mobile/src/modules/login/social.tsx index dec5d6e84eb..7b8b891f0af 100644 --- a/apps/mobile/src/modules/login/social.tsx +++ b/apps/mobile/src/modules/login/social.tsx @@ -25,7 +25,7 @@ export function SocialLogin({ onPressEmail }: { isRegister: boolean; onPressEmai const { t } = useTranslation() return ( - <View className="flex w-screen items-center justify-center gap-4 px-6"> + <View className="flex w-full items-center justify-center gap-4"> <Pressable testID="login-provider-credential" hitSlop={20} @@ -40,7 +40,7 @@ export function SocialLogin({ onPressEmail }: { isRegister: boolean; onPressEmai ? credentialProvider.iconDark64 || credentialProvider.icon64 : credentialProvider.icon64, }} - className="absolute left-9 size-6" + className="absolute left-6 size-6" contentFit="contain" /> )} @@ -106,7 +106,7 @@ export function SocialLogin({ onPressEmail }: { isRegister: boolean; onPressEmai uri: colorScheme === "dark" ? provider.iconDark64 || provider.icon64 : provider.icon64, }} - className="absolute left-9 size-6" + className="absolute left-6 size-6" contentFit="contain" /> <Text className="text-lg font-semibold text-label"> diff --git a/apps/mobile/src/modules/onboarding/shared.tsx b/apps/mobile/src/modules/onboarding/shared.tsx index d705be4b3e3..b46c356b0ce 100644 --- a/apps/mobile/src/modules/onboarding/shared.tsx +++ b/apps/mobile/src/modules/onboarding/shared.tsx @@ -1,16 +1,17 @@ -import { ScrollView } from "react-native" +import { ScrollView, View } from "react-native" -import { useScaleHeight } from "@/src/lib/responsive" +import { useReadableContainerStyle, useScaleHeight } from "@/src/lib/responsive" export const OnboardingSectionScreenContainer = ({ children }: { children: React.ReactNode }) => { const height = useScaleHeight()(50) + const readableContentStyle = useReadableContainerStyle(680) return ( <ScrollView className="flex-1" contentContainerClassName="items-center" style={{ marginTop: height }} > - {children} + <View style={readableContentStyle}>{children}</View> </ScrollView> ) } diff --git a/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx b/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx index 36d9cf86ba5..446eda152a9 100644 --- a/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx +++ b/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx @@ -14,6 +14,7 @@ import { DefaultHeaderBackButton } from "@/src/components/layouts/header/Navigat import { NavigationBlurEffectHeader } from "@/src/components/layouts/views/SafeNavigationScrollView" import { gentleSpringPreset } from "@/src/constants/spring" import { TIMELINE_VIEW_SELECTOR_HEIGHT } from "@/src/constants/ui" +import { useIsTabletLayout } from "@/src/lib/responsive" import { ActionGroup, FeedShareActionButton, @@ -33,12 +34,14 @@ export function TimelineHeader({ feedId }: { feedId?: string }) { const isFeed = screenType === "feed" const isTimeline = screenType === "timeline" const isSubscriptions = screenType === "subscriptions" + const isTablet = useIsTabletLayout() const { isFetching } = useEntries() + const shouldHideDuplicatedTitle = isTablet && (isTimeline || isSubscriptions) return ( <NavigationBlurEffectHeader - headerTitle={<AnimatedTitle title={viewTitle} />} + headerTitle={shouldHideDuplicatedTitle ? undefined : <AnimatedTitle title={viewTitle} />} isLoading={(isFeed || isTimeline) && isFetching} headerLeft={useMemo( () => diff --git a/apps/mobile/src/modules/screen/TimelineViewSelector.tsx b/apps/mobile/src/modules/screen/TimelineViewSelector.tsx index 16a0ec2fd77..89659269c8d 100644 --- a/apps/mobile/src/modules/screen/TimelineViewSelector.tsx +++ b/apps/mobile/src/modules/screen/TimelineViewSelector.tsx @@ -5,13 +5,14 @@ import * as React from "react" import { useEffect } from "react" import { useTranslation } from "react-i18next" import type { StyleProp, ViewStyle } from "react-native" -import { ScrollView, Text, useWindowDimensions, View } from "react-native" +import { ScrollView, StyleSheet, Text, useWindowDimensions, View } from "react-native" import Animated, { interpolate, interpolateColor, useAnimatedStyle } from "react-native-reanimated" import { ReAnimatedPressable } from "@/src/components/common/AnimatedComponents" import { TIMELINE_VIEW_SELECTOR_HEIGHT } from "@/src/constants/ui" import type { ViewDefinition } from "@/src/constants/views" import { views } from "@/src/constants/views" +import { useIsTabletLayout, useReadableContainerStyle } from "@/src/lib/responsive" import { selectTimeline, useSelectedFeed, @@ -25,10 +26,18 @@ import { TimelineViewSelectorContextMenu } from "./TimelineViewSelectorContextMe const ACTIVE_WIDTH = 180 const INACTIVE_WIDTH = 48 const ACTIVE_TEXT_WIDTH = 100 +const MAX_TABLET_ACTIVE_WIDTH = 280 +const styles = StyleSheet.create({ + scrollView: { + width: "100%", + }, +}) export function TimelineViewSelector() { const activeViews = useViewWithSubscription() const scrollViewRef = React.useRef<ScrollView | null>(null) const selectedFeed = useSelectedFeed() + const readableContainerStyle = useReadableContainerStyle(760, 12) + const activeViewCount = activeViews.length return ( <View className="flex items-center justify-between py-2" @@ -36,27 +45,39 @@ export function TimelineViewSelector() { height: TIMELINE_VIEW_SELECTOR_HEIGHT, }} > - <ScrollView - ref={scrollViewRef} - horizontal - scrollsToTop={false} - contentContainerClassName="flex-row gap-3 items-center px-3" - showsHorizontalScrollIndicator={false} - > - {activeViews.map((v, index) => { - const view = views.find((view) => view.view === v) - if (!view) return null - return ( - <ViewItem - key={view.name} - index={index} - view={view} - scrollViewRef={scrollViewRef} - isActive={selectedFeed?.type === "view" && selectedFeed.viewId === view.view} - /> - ) - })} - </ScrollView> + <View style={readableContainerStyle}> + <ScrollView + ref={scrollViewRef} + style={styles.scrollView} + horizontal + scrollsToTop={false} + contentContainerClassName="flex-row items-center px-3" + contentContainerStyle={ + activeViewCount > 0 + ? { + minWidth: "100%", + justifyContent: "center", + gap: 12, + } + : undefined + } + showsHorizontalScrollIndicator={false} + > + {activeViews.map((v, index) => { + const view = views.find((view) => view.view === v) + if (!view) return null + return ( + <ViewItem + key={view.name} + index={index} + view={view} + scrollViewRef={scrollViewRef} + isActive={selectedFeed?.type === "view" && selectedFeed.viewId === view.view} + /> + ) + })} + </ScrollView> + </View> </View> ) } @@ -81,10 +102,14 @@ function ItemWrapper({ const { width: windowWidth } = useWindowDimensions() const activeViews = useViewWithSubscription() const dragProgress = useTimelineSelectorDragProgress() + const isTablet = useIsTabletLayout() const activeWidth = Math.max( windowWidth - (INACTIVE_WIDTH + 12) * (activeViews.length - 1) - 8 * 2, ACTIVE_WIDTH, ) + const resolvedActiveWidth = isTablet + ? Math.min(activeWidth, MAX_TABLET_ACTIVE_WIDTH) + : activeWidth const bgColor = useColor("gray5") return ( <ReAnimatedPressable @@ -103,7 +128,7 @@ function ItemWrapper({ width: interpolate( dragProgress.get(), [index - 1, index, index + 1], - [INACTIVE_WIDTH, Math.max(activeWidth, INACTIVE_WIDTH), INACTIVE_WIDTH], + [INACTIVE_WIDTH, Math.max(resolvedActiveWidth, INACTIVE_WIDTH), INACTIVE_WIDTH], "clamp", ), ...style, diff --git a/apps/mobile/src/modules/settings/SettingsList.tsx b/apps/mobile/src/modules/settings/SettingsList.tsx index 4c25e1b30f6..821306141a1 100644 --- a/apps/mobile/src/modules/settings/SettingsList.tsx +++ b/apps/mobile/src/modules/settings/SettingsList.tsx @@ -1,6 +1,7 @@ import { UserRole } from "@follow/constants" import { useUserRole, useWhoami } from "@follow/store/user/hooks" import type { StatusConfigs as ServerConfigs } from "@follow-app/client-sdk" +import i18next from "i18next" import type { FC } from "react" import { Fragment, useMemo } from "react" import { useTranslation } from "react-i18next" @@ -203,15 +204,17 @@ const ActionGroupNavigationLinks: GroupNavigationLink[] = [ onPress: () => { Dialog.show({ id: "settings-sign-out-dialog", - title: "Confirm sign out", + title: i18next.t("profile.sign_out.confirm_title", { ns: "settings" }), content: ( <View> - <Text className="text-label">Are you sure you want to sign out?</Text> + <Text className="text-label"> + {i18next.t("profile.sign_out.confirm_message", { ns: "settings" })} + </Text> </View> ), variant: "destructive", - confirmText: "Sign out", - cancelText: "Cancel", + confirmText: i18next.t("titles.sign_out", { ns: "settings" }), + cancelText: i18next.t("words.cancel", { ns: "common" }), onConfirm: async () => { await signOut() }, diff --git a/apps/mobile/src/modules/settings/UserHeaderBanner.tsx b/apps/mobile/src/modules/settings/UserHeaderBanner.tsx index 2a73334641d..a119411ebb9 100644 --- a/apps/mobile/src/modules/settings/UserHeaderBanner.tsx +++ b/apps/mobile/src/modules/settings/UserHeaderBanner.tsx @@ -5,6 +5,7 @@ import { cn, getLuminance } from "@follow/utils" import { LinearGradient } from "expo-linear-gradient" import type { FC } from "react" import { useMemo } from "react" +import { useTranslation } from "react-i18next" import { Linking, Pressable, StyleSheet, View } from "react-native" import type { SharedValue } from "react-native-reanimated" import ReAnimated, { FadeIn, FadeOut, interpolate, useAnimatedStyle } from "react-native-reanimated" @@ -91,6 +92,7 @@ export const UserHeaderBanner = ({ userId?: string showRoleBadge?: boolean }) => { + const { t } = useTranslation() const serverConfigs = useServerConfigs() const bgColor = useColor("systemGroupedBackground") const avatarIconColor = useColor("secondaryLabel") @@ -237,9 +239,9 @@ export const UserHeaderBanner = ({ <View className="mt-2 items-center"> {user?.name ? ( <Text - numberOfLines={1} + numberOfLines={2} className={cn( - "px-8 text-xl font-bold", + "px-8 text-center text-xl font-bold", gradientLight ? "text-black" : "text-white/95", )} > @@ -287,7 +289,7 @@ export const UserHeaderBanner = ({ testID="settings-sign-in" onPress={() => navigation.presentControllerView(LoginScreen)} > - <Text className="m-[6] text-sm text-accent">Sign in to your account</Text> + <Text className="m-[6] text-sm text-accent">{t("settings.sign_in_cta")}</Text> </Pressable> ) : null} </View> diff --git a/apps/mobile/src/modules/settings/routes/2FASetting.tsx b/apps/mobile/src/modules/settings/routes/2FASetting.tsx index 3ba677ceffe..0756e16c3f3 100644 --- a/apps/mobile/src/modules/settings/routes/2FASetting.tsx +++ b/apps/mobile/src/modules/settings/routes/2FASetting.tsx @@ -1,6 +1,7 @@ import { whoamiQueryKey } from "@follow/store/user/hooks" import { useMutation } from "@tanstack/react-query" import { useRef } from "react" +import { useTranslation } from "react-i18next" import { KeyboardAvoidingView, View } from "react-native" import type { OtpInputRef } from "react-native-otp-entry" import { OtpInput } from "react-native-otp-entry" @@ -24,6 +25,7 @@ const isAuthCodeValid = (code: string) => { export const TwoFASetting: NavigationControllerView<{ totpURI: string }> = ({ totpURI }) => { + const { t } = useTranslation("settings") const label = useColor("label") const tertiaryLabel = useColor("tertiaryLabel") const navigation = useNavigation() @@ -40,17 +42,19 @@ export const TwoFASetting: NavigationControllerView<{ }) }, onError(error) { - toast.error(`Failed to verify: ${error.message}`) + toast.error(`${t("profile.two_factor.verify_failed")}: ${error.message}`) }, onSuccess() { navigation.back() - toast.success("2FA enabled!") + toast.success(t("profile.two_factor.enabled")) }, }) const otpInputRef = useRef<OtpInputRef>(null) return ( <KeyboardAvoidingView behavior="padding"> - <SafeNavigationScrollView Header={<NavigationBlurEffectHeaderView title="Setting 2FA" />}> + <SafeNavigationScrollView + Header={<NavigationBlurEffectHeaderView title={t("profile.two_factor.setup.title")} />} + > <View className="my-8 items-center justify-center"> <QRCode data={totpURI} @@ -65,8 +69,7 @@ export const TwoFASetting: NavigationControllerView<{ <View className="mx-12"> <Text className="mb-8 text-sm text-secondary-label"> - Scan the QR code above with your authenticator app, and then enter the 6-digit code - which will be displayed in your authenticator app. + {t("profile.two_factor.setup.description")} </Text> </View> diff --git a/apps/mobile/src/modules/settings/routes/Account.tsx b/apps/mobile/src/modules/settings/routes/Account.tsx index f1ead175d0e..69fd23c25df 100644 --- a/apps/mobile/src/modules/settings/routes/Account.tsx +++ b/apps/mobile/src/modules/settings/routes/Account.tsx @@ -104,6 +104,7 @@ const AccountLinker: FC<{ provider: keyof typeof provider2IconMap account?: Account }> = ({ provider, account }) => { + const { t } = useTranslation(["settings", "common"]) const queryClient = useQueryClient() const unlinkAccountMutation = useMutation({ mutationFn: async () => { @@ -115,7 +116,7 @@ const AccountLinker: FC<{ if (res.error) throw new Error(res.error.message) }, onSuccess: () => { - toast.success("Unlinked account success") + toast.success(t("profile.link_social.unlink.success")) queryClient.invalidateQueries({ queryKey: accountInfoKey, }) @@ -146,7 +147,7 @@ const AccountLinker: FC<{ linkSocial({ provider: provider as any }) .then((res) => { if (!res.data?.url) { - toast.error("Failed to link account") + toast.error(t("profile.link_social.link_failed")) return } openLink(res.data.url, () => { @@ -159,21 +160,27 @@ const AccountLinker: FC<{ }) }) .catch((error) => { - toast.error(error instanceof Error ? error.message : "Failed to link account") + toast.error( + error instanceof Error ? error.message : t("profile.link_social.link_failed"), + ) }) return } - Alert.alert("Unlink account", "Are you sure you want to unlink your account?", [ - { - text: "Cancel", - style: "cancel", - }, - { - text: "Unlink", - style: "destructive", - onPress: () => unlinkAccountMutation.mutate(), - }, - ]) + Alert.alert( + t("profile.link_social.unlink.title"), + t("profile.link_social.unlink.confirm"), + [ + { + text: t("words.cancel", { ns: "common" }), + style: "cancel", + }, + { + text: t("profile.link_social.unlink.action"), + style: "destructive", + onPress: () => unlinkAccountMutation.mutate(), + }, + ], + ) }} /> ) @@ -215,7 +222,7 @@ const AuthenticationSection = () => { ) } const SecuritySection = () => { - const { t } = useTranslation("settings") + const { t } = useTranslation(["settings", "common"]) const { data: account } = useAccount() const hasPassword = account?.data?.find((account) => account.provider === "credential") const whoAmI = useWhoami() @@ -231,14 +238,14 @@ const SecuritySection = () => { onPress={() => { const email = whoAmI?.email || "" if (!email) { - toast.error("You need to login with email first") + toast.error(t("profile.change_password.email_required")) return } if (!hasPassword) { forgetPassword({ email, }) - toast.success("We have sent you an email with instructions to reset your password.") + toast.success(t("profile.reset_password_mail_sent")) } else { navigation.pushControllerView(ResetPassword) } @@ -260,10 +267,10 @@ const SecuritySection = () => { .updateTwoFactor(false, ctx.password) .finally(() => done()) if (res.error?.message) { - toast.error("Invalid password or something went wrong") + toast.error(t("profile.two_factor.invalid_password")) return } - toast.success("2FA disabled") + toast.success(t("profile.two_factor.disabled")) return } const { password } = ctx @@ -271,7 +278,7 @@ const SecuritySection = () => { .updateTwoFactor(true, password) .finally(() => done()) if (res.error?.message) { - toast.error("Invalid password or something went wrong") + toast.error(t("profile.two_factor.invalid_password")) return } if (res.data && "totpURI" in res.data) { @@ -279,7 +286,7 @@ const SecuritySection = () => { totpURI: res.data.totpURI, }) } else { - toast.error("Failed to enable 2FA") + toast.error(t("profile.two_factor.enable_failed")) } }, }, @@ -291,15 +298,15 @@ const SecuritySection = () => { textClassName="text-red text-left" onPress={async () => { Alert.alert( - "Delete account", - "Are you sure you want to delete your account? \nThis action is irreversible and may take up to two days to take effect.", + t("profile.delete_account.confirm_title"), + t("profile.delete_account.confirm_description"), [ { - text: "Cancel", + text: t("words.cancel", { ns: "common" }), style: "cancel", }, { - text: "Delete", + text: t("words.delete", { ns: "common" }), style: "destructive", onPress: async () => { // await signOut() diff --git a/apps/mobile/src/modules/settings/routes/Appearance.tsx b/apps/mobile/src/modules/settings/routes/Appearance.tsx index 31b41da5e11..e40dcc0037b 100644 --- a/apps/mobile/src/modules/settings/routes/Appearance.tsx +++ b/apps/mobile/src/modules/settings/routes/Appearance.tsx @@ -1,7 +1,7 @@ import { getUnreadAll } from "@follow/store/unread/getters" import { themeNames } from "@shikijs/themes" import { useTranslation } from "react-i18next" -import { useColorScheme, View } from "react-native" +import { useColorScheme } from "react-native" import { setUISetting, useUISettingKey } from "@/src/atoms/settings/ui" import { @@ -84,6 +84,7 @@ export const AppearanceScreen = () => { const useSystemFontScaling = useUISettingKey("useSystemFontScaling") const useDifferentFontSizeForContent = useUISettingKey("useDifferentFontSizeForContent") const mobileContentFontSize = useUISettingKey("mobileContentFontSize") + const selectWrapperClassName = "w-auto min-w-[96px] max-w-[44vw] shrink-0" return ( <SafeNavigationScrollView className="bg-system-grouped-background" @@ -139,24 +140,23 @@ export const AppearanceScreen = () => { label={t("appearance.thumbnail_ratio.title")} description={t("appearance.thumbnail_ratio.description")} > - <View className="w-[100px]"> - <Select - options={[ - { - label: "Square", - value: "square", - }, - { - label: "Original", - value: "original", - }, - ]} - value={thumbnailRatio} - onValueChange={(val) => { - setUISetting("thumbnailRatio", val as "square" | "original") - }} - /> - </View> + <Select + wrapperClassName={selectWrapperClassName} + options={[ + { + label: t("appearance.thumbnail_ratio.square"), + value: "square", + }, + { + label: t("appearance.thumbnail_ratio.original"), + value: "original", + }, + ]} + value={thumbnailRatio} + onValueChange={(val) => { + setUISetting("thumbnailRatio", val as "square" | "original") + }} + /> </GroupedInsetListCell> </GroupedInsetListCard> @@ -178,19 +178,18 @@ export const AppearanceScreen = () => { label={t("appearance.font_scaling.scale.label")} description={t("appearance.font_scaling.scale.description")} > - <View className="w-[100px]"> - <Select - options={fontSizePresets.map((preset) => ({ - label: t(`appearance.font_scaling.size.${preset.key}`), - value: preset.value.toString(), - }))} - value={fontScale.toString()} - onValueChange={(val) => { - setUISetting("fontScale", Number.parseFloat(val)) - }} - disabled={useSystemFontScaling} - /> - </View> + <Select + wrapperClassName={selectWrapperClassName} + options={fontSizePresets.map((preset) => ({ + label: t(`appearance.font_scaling.size.${preset.key}`), + value: preset.value.toString(), + }))} + value={fontScale.toString()} + onValueChange={(val) => { + setUISetting("fontScale", Number.parseFloat(val)) + }} + disabled={useSystemFontScaling} + /> </GroupedInsetListCell> <GroupedInsetListCell label={t("appearance.font_scaling.content_different.label")} @@ -209,37 +208,35 @@ export const AppearanceScreen = () => { label={t("appearance.font_scaling.content_size.label")} description={t("appearance.font_scaling.content_size.description")} > - <View className="w-[100px]"> - <Select - options={contentFontSizePresets.map((preset) => ({ - label: t(`appearance.font_scaling.content_size.${preset.key}`), - value: preset.value.toString(), - }))} - value={mobileContentFontSize.toString()} - onValueChange={(val) => { - setUISetting("mobileContentFontSize", Number.parseInt(val)) - }} - /> - </View> + <Select + wrapperClassName={selectWrapperClassName} + options={contentFontSizePresets.map((preset) => ({ + label: t(`appearance.font_scaling.content_size.${preset.key}`), + value: preset.value.toString(), + }))} + value={mobileContentFontSize.toString()} + onValueChange={(val) => { + setUISetting("mobileContentFontSize", Number.parseInt(val)) + }} + /> </GroupedInsetListCell> )} </GroupedInsetListCard> - <GroupedInsetListSectionHeader label="Content" /> + <GroupedInsetListSectionHeader label={t("appearance.content")} /> <GroupedInsetListCard> <GroupedInsetListCell label={t("appearance.code_highlight_theme.label")}> - <View className="w-[120px]"> - <Select - options={themeNames.map((theme) => ({ - label: theme, - value: theme, - }))} - value={colorScheme === "dark" ? codeThemeDark : codeThemeLight} - onValueChange={(val) => { - setUISetting(`codeHighlightTheme${colorScheme === "dark" ? "Dark" : "Light"}`, val) - }} - /> - </View> + <Select + wrapperClassName={selectWrapperClassName} + options={themeNames.map((theme) => ({ + label: theme, + value: theme, + }))} + value={colorScheme === "dark" ? codeThemeDark : codeThemeLight} + onValueChange={(val) => { + setUISetting(`codeHighlightTheme${colorScheme === "dark" ? "Dark" : "Light"}`, val) + }} + /> </GroupedInsetListCell> <GroupedInsetListCell diff --git a/apps/mobile/src/modules/settings/routes/EditProfile.tsx b/apps/mobile/src/modules/settings/routes/EditProfile.tsx index 092289b31ee..3408b5e4e4b 100644 --- a/apps/mobile/src/modules/settings/routes/EditProfile.tsx +++ b/apps/mobile/src/modules/settings/routes/EditProfile.tsx @@ -49,7 +49,7 @@ export const EditProfileScreen = () => { await userSyncService.updateProfile(dirtyFields) }, onSuccess: () => { - toast.success("Profile updated") + toast.success(t("profile.updateSuccess")) setDirtyFields({}) }, onError: (error) => { @@ -118,16 +118,16 @@ const ProfileForm: FC<{ }> = ({ whoami, dirtyFields, setDirtyFields }) => { const { t } = useTranslation("settings") const navigation = useNavigation() - const socialLinkFields: (keyof NonNullable<MeModel["socialLinks"]>)[] = [ + type SocialLinkKey = "twitter" | "github" | "instagram" | "facebook" | "youtube" | "discord" + const socialLinkFields: SocialLinkKey[] = [ "twitter", "github", "instagram", "facebook", "youtube", - // @ts-expect-error adding discord "discord", ] - const socialCopyMap = { + const socialCopyMap: Record<SocialLinkKey, string> = { twitter: t("profile.social.twitter", "Twitter"), github: t("profile.social.github", "GitHub"), instagram: t("profile.social.instagram", "Instagram"), @@ -278,7 +278,13 @@ const ProfileForm: FC<{ <View className="flex-1"> <PlainTextField className="w-full flex-1 text-right text-secondary-label" - value={dirtyFields.socialLinks?.[social] ?? whoami?.socialLinks?.[social] ?? ""} + value={ + dirtyFields.socialLinks?.[social] ?? + (whoami?.socialLinks as Partial<Record<SocialLinkKey, string>> | undefined)?.[ + social + ] ?? + "" + } hitSlop={10} selectionColor={accentColor} onChangeText={(text) => { diff --git a/apps/mobile/src/modules/settings/routes/EditRewriteRules.tsx b/apps/mobile/src/modules/settings/routes/EditRewriteRules.tsx index fb41a50a40c..21539d9d5da 100644 --- a/apps/mobile/src/modules/settings/routes/EditRewriteRules.tsx +++ b/apps/mobile/src/modules/settings/routes/EditRewriteRules.tsx @@ -22,11 +22,13 @@ export const EditRewriteRulesScreen: NavigationControllerView<{ const { t } = useTranslation("settings") const rule = useActionRule(index) const rewriteRules = - rule?.result.rewriteRules?.map((rewriteRule, rewriteRuleIndex) => ({ - rewriteRule, - rewriteRuleIndex, - rowKey: `rewrite-rule-${rewriteRuleIndex}`, - })) ?? [] + rule?.result.rewriteRules?.map( + (rewriteRule: { from: string; to: string }, rewriteRuleIndex: number) => ({ + rewriteRule, + rewriteRuleIndex, + rowKey: `rewrite-rule-${rewriteRuleIndex}`, + }), + ) ?? [] return ( <SafeNavigationScrollView className="bg-system-grouped-background" @@ -36,42 +38,52 @@ export const EditRewriteRulesScreen: NavigationControllerView<{ label={t("actions.action_card.rewrite_rules")} marginSize="small" /> - {rewriteRules.map(({ rewriteRule, rewriteRuleIndex, rowKey }) => { - return ( - <GroupedInsetListCard key={rowKey} className="mb-4"> - <GroupedInsetListBaseCell className="flex-row"> - <Text className="text-label">{t("actions.action_card.from")}</Text> - <PlainTextField - className="w-full flex-1 text-right" - value={rewriteRule.from} - onChangeText={(value) => { - actionActions.updateRewriteRule({ - index, - rewriteRuleIndex, - key: "from", - value, - }) - }} - /> - </GroupedInsetListBaseCell> - <GroupedInsetListBaseCell className="flex-row"> - <Text className="text-label">{t("actions.action_card.to")}</Text> - <PlainTextField - className="w-full flex-1 text-right" - value={rewriteRule.to} - onChangeText={(value) => { - actionActions.updateRewriteRule({ - index, - rewriteRuleIndex, - key: "to", - value, - }) - }} - /> - </GroupedInsetListBaseCell> - </GroupedInsetListCard> - ) - })} + {rewriteRules.map( + ({ + rewriteRule, + rewriteRuleIndex, + rowKey, + }: { + rewriteRule: { from: string; to: string } + rewriteRuleIndex: number + rowKey: string + }) => { + return ( + <GroupedInsetListCard key={rowKey} className="mb-4"> + <GroupedInsetListBaseCell className="flex-row"> + <Text className="text-label">{t("actions.action_card.from")}</Text> + <PlainTextField + className="w-full flex-1 text-right" + value={rewriteRule.from} + onChangeText={(value) => { + actionActions.updateRewriteRule({ + index, + rewriteRuleIndex, + key: "from", + value, + }) + }} + /> + </GroupedInsetListBaseCell> + <GroupedInsetListBaseCell className="flex-row"> + <Text className="text-label">{t("actions.action_card.to")}</Text> + <PlainTextField + className="w-full flex-1 text-right" + value={rewriteRule.to} + onChangeText={(value) => { + actionActions.updateRewriteRule({ + index, + rewriteRuleIndex, + key: "to", + value, + }) + }} + /> + </GroupedInsetListBaseCell> + </GroupedInsetListCard> + ) + }, + )} <GroupedInsetListCard> <GroupedPlainButtonCell label={t("actions.action_card.add")} diff --git a/apps/mobile/src/modules/settings/routes/EditWebhooks.tsx b/apps/mobile/src/modules/settings/routes/EditWebhooks.tsx index e19284a631a..44a7aec9023 100644 --- a/apps/mobile/src/modules/settings/routes/EditWebhooks.tsx +++ b/apps/mobile/src/modules/settings/routes/EditWebhooks.tsx @@ -22,7 +22,7 @@ export const EditWebhooksScreen: NavigationControllerView<{ const { t } = useTranslation("settings") const rule = useActionRule(index) const webhooks = - rule?.result.webhooks?.map((webhook, webhookIndex) => ({ + rule?.result.webhooks?.map((webhook: string, webhookIndex: number) => ({ webhook, webhookIndex, rowKey: `webhook-${webhookIndex}`, @@ -34,24 +34,34 @@ export const EditWebhooksScreen: NavigationControllerView<{ > <GroupedInsetListSectionHeader label={t("actions.action_card.webhooks")} marginSize="small" /> <GroupedInsetListCard> - {webhooks.map(({ webhook, webhookIndex, rowKey }) => { - return ( - <GroupedInsetListBaseCell className="flex-row" key={rowKey}> - <PlainTextField - placeholder="https://" - inputMode="url" - value={webhook} - onChangeText={(value) => { - actionActions.updateWebhook({ - index, - webhookIndex, - value, - }) - }} - /> - </GroupedInsetListBaseCell> - ) - })} + {webhooks.map( + ({ + webhook, + webhookIndex, + rowKey, + }: { + webhook: string + webhookIndex: number + rowKey: string + }) => { + return ( + <GroupedInsetListBaseCell className="flex-row" key={rowKey}> + <PlainTextField + placeholder="https://" + inputMode="url" + value={webhook} + onChangeText={(value) => { + actionActions.updateWebhook({ + index, + webhookIndex, + value, + }) + }} + /> + </GroupedInsetListBaseCell> + ) + }, + )} <GroupedInsetButtonCell label={t("actions.action_card.add")} onPress={() => { diff --git a/apps/mobile/src/modules/settings/routes/Plan.tsx b/apps/mobile/src/modules/settings/routes/Plan.tsx index 542db97fdd9..4d5e599a2f9 100644 --- a/apps/mobile/src/modules/settings/routes/Plan.tsx +++ b/apps/mobile/src/modules/settings/routes/Plan.tsx @@ -316,12 +316,14 @@ export const PlanScreen: NavigationControllerView = () => { isPurchasing, isRestoring, loadSubscriptions, - openSubscriptionManagement, requestSubscriptionPurchase, restoreSubscriptionPurchases, } = useAppleIAP() - const plans = useMemo(() => serverConfigs?.PAYMENT_PLAN_LIST ?? [], [serverConfigs]) + const plans = useMemo<PaymentPlan[]>( + () => serverConfigs?.PAYMENT_PLAN_LIST ?? [], + [serverConfigs], + ) const appleProductIds = useMemo( () => plans @@ -403,14 +405,14 @@ export const PlanScreen: NavigationControllerView = () => { const averageSavings = useMemo(() => { const paidPlans = sortedPlans.filter( - (plan) => (plan.priceInDollars ?? 0) > 0 && (plan.priceInDollarsAnnual ?? 0) > 0, + (plan: PaymentPlan) => (plan.priceInDollars ?? 0) > 0 && (plan.priceInDollarsAnnual ?? 0) > 0, ) if (paidPlans.length === 0) { return 0 } - const total = paidPlans.reduce((acc, plan) => { + const total = paidPlans.reduce((acc, plan: PaymentPlan) => { const monthlyTotal = (plan.priceInDollars ?? 0) * 12 const yearlyTotal = plan.priceInDollarsAnnual ?? 0 if (monthlyTotal === 0) { @@ -425,7 +427,7 @@ export const PlanScreen: NavigationControllerView = () => { const upgradeMutation = useMutation<void, Error, UpgradeVariables>({ mutationFn: async ({ planId, annual }) => { - const selectedPlan = plans.find((plan) => plan.planID === planId) + const selectedPlan = plans.find((plan: PaymentPlan) => plan.planID === planId) if (Platform.OS === "ios" && activeSubscription?.source !== "stripe") { const productId = annual @@ -485,15 +487,8 @@ export const PlanScreen: NavigationControllerView = () => { }) const handleManageSubscription = useCallback(() => { - if (activeSubscription?.source === "apple") { - void openSubscriptionManagement().catch(() => { - toast.error(t("subscription.actions.manage_error")) - }) - return - } - billingPortalMutation.mutate() - }, [activeSubscription?.source, billingPortalMutation, openSubscriptionManagement, t]) + }, [billingPortalMutation]) if (!isPaymentEnabled || sortedPlans.length === 0) { return ( @@ -908,7 +903,7 @@ const PlanCard = ({ </View> <View className="mt-4 gap-2"> - {features.map(([featureKey, value]) => { + {features.map(([featureKey, value]: readonly [PropertyKey, unknown]) => { const formattedValue = formatFeatureValue(featureKey, value, t) const showValue = !(typeof value === "boolean" && value) return ( @@ -917,7 +912,9 @@ const PlanCard = ({ <CheckLineIcon width={14} height={14} color="rgb(40, 205, 65)" /> </View> <Text className="flex-1 text-sm text-label"> - {t(`plan.features.${featureKey}` as const, { defaultValue: featureKey })} + {t(`plan.features.${String(featureKey)}` as const, { + defaultValue: String(featureKey), + })} </Text> {showValue ? ( <Text @@ -971,6 +968,7 @@ const PlanAction = ({ const { t } = useTranslation("settings") const canManageSubscription = !!activeSubscription?.canManage + const isAppleSubscription = activeSubscription?.source === "apple" const isCanceled = activeSubscription?.status === "canceled" const periodEnd = activeSubscription?.trialEnd ?? activeSubscription?.periodEnd const effectivePeriodEnd = periodEnd ? new Date(periodEnd) : null @@ -1025,23 +1023,29 @@ const PlanAction = ({ </Text> </View> )} - <Pressable - accessibilityRole="button" - onPress={onManageSubscription} - disabled={!canManageSubscription || isManaging} - className={cn( - "h-11 items-center justify-center rounded-full border border-opaque-separator/60", - !canManageSubscription && "opacity-50", - )} - > - {isManaging ? ( - <ActivityIndicator /> - ) : ( - <Text className="text-base font-medium text-label"> - {canManageSubscription ? t("plan.manage_subscription") : t("plan.current_plan")} - </Text> - )} - </Pressable> + {isAppleSubscription ? ( + <Text className="rounded-2xl bg-secondary-system-fill px-4 py-3 text-center text-sm leading-5 text-secondary-label"> + {t("plan.manage_subscription_hint_apple")} + </Text> + ) : ( + <Pressable + accessibilityRole="button" + onPress={onManageSubscription} + disabled={!canManageSubscription || isManaging} + className={cn( + "h-11 items-center justify-center rounded-full border border-opaque-separator/60", + !canManageSubscription && "opacity-50", + )} + > + {isManaging ? ( + <ActivityIndicator /> + ) : ( + <Text className="text-base font-medium text-label"> + {canManageSubscription ? t("plan.manage_subscription") : t("plan.current_plan")} + </Text> + )} + </Pressable> + )} </View> ) } diff --git a/apps/mobile/src/modules/subscription/CategoryGrouped.tsx b/apps/mobile/src/modules/subscription/CategoryGrouped.tsx index b6da1a010d0..52300499c65 100644 --- a/apps/mobile/src/modules/subscription/CategoryGrouped.tsx +++ b/apps/mobile/src/modules/subscription/CategoryGrouped.tsx @@ -11,6 +11,7 @@ import { NativePressable } from "@/src/components/ui/pressable/NativePressable" import { Text } from "@/src/components/ui/typography/Text" import { RightCuteFiIcon } from "@/src/icons/right_cute_fi" import { useNavigation } from "@/src/lib/navigation/hooks" +import { useReadableContainerStyle } from "@/src/lib/responsive" import { selectFeed } from "@/src/modules/screen/atoms" import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]/FeedScreen" import { useColor } from "@/src/theme/colors" @@ -47,12 +48,16 @@ export const CategoryGrouped = memo( }, [rotateSharedValue]) const secondaryLabelColor = useColor("label") const navigation = useNavigation() + const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN) return ( <> <View - style={{ - marginHorizontal: GROUPED_LIST_MARGIN, - }} + style={[ + readableContainerStyle, + { + marginHorizontal: GROUPED_LIST_MARGIN, + }, + ]} > <SubscriptionFeedCategoryContextMenu feedIds={subscriptionIds} diff --git a/apps/mobile/src/modules/subscription/ItemSeparator.tsx b/apps/mobile/src/modules/subscription/ItemSeparator.tsx index ca1748d3664..9d678b72b5d 100644 --- a/apps/mobile/src/modules/subscription/ItemSeparator.tsx +++ b/apps/mobile/src/modules/subscription/ItemSeparator.tsx @@ -1,35 +1,36 @@ import { View } from "react-native" import { GROUPED_LIST_MARGIN } from "@/src/components/ui/grouped/constants" +import { useReadableContainerStyle } from "@/src/lib/responsive" -const el = ( - <View - className="bg-secondary-system-grouped-background" - style={{ marginHorizontal: GROUPED_LIST_MARGIN }} - > - <View - className="ml-12 h-px flex-1 bg-opaque-separator/70" - collapsable={false} - style={{ transform: [{ scaleY: 0.5 }] }} - /> - </View> -) export const ItemSeparator = () => { - return el + const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN) + return ( + <View + className="bg-secondary-system-grouped-background" + style={[readableContainerStyle, { marginHorizontal: GROUPED_LIST_MARGIN }]} + > + <View + className="ml-12 h-px flex-1 bg-opaque-separator/70" + collapsable={false} + style={{ transform: [{ scaleY: 0.5 }] }} + /> + </View> + ) } -const el2 = ( - <View - className="bg-secondary-system-grouped-background" - style={{ marginHorizontal: GROUPED_LIST_MARGIN }} - > - <View - className="ml-16 h-px flex-1 bg-opaque-separator/70" - collapsable={false} - style={{ transform: [{ scaleY: 0.5 }] }} - /> - </View> -) export const SecondaryItemSeparator = () => { - return el2 + const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN) + return ( + <View + className="bg-secondary-system-grouped-background" + style={[readableContainerStyle, { marginHorizontal: GROUPED_LIST_MARGIN }]} + > + <View + className="ml-16 h-px flex-1 bg-opaque-separator/70" + collapsable={false} + style={{ transform: [{ scaleY: 0.5 }] }} + /> + </View> + ) } diff --git a/apps/mobile/src/modules/subscription/SubscriptionLists.tsx b/apps/mobile/src/modules/subscription/SubscriptionLists.tsx index 4a6aeb16144..da6220816a6 100644 --- a/apps/mobile/src/modules/subscription/SubscriptionLists.tsx +++ b/apps/mobile/src/modules/subscription/SubscriptionLists.tsx @@ -31,6 +31,7 @@ import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable" import { Text } from "@/src/components/ui/typography/Text" import { StarCuteFiIcon } from "@/src/icons/star_cute_fi" import { useNavigation } from "@/src/lib/navigation/hooks" +import { useReadableContainerStyle } from "@/src/lib/responsive" import { selectFeed } from "@/src/modules/screen/atoms" import { TimelineSelectorList } from "@/src/modules/screen/TimelineSelectorList" import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]/FeedScreen" @@ -227,14 +228,18 @@ const ItemRender = ({ } const SectionTitle = ({ transKey }: { transKey: ParseKeys<"common"> }) => { const { t } = useTranslation("common") + const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN) return ( <View - style={{ - marginHorizontal: GROUPED_LIST_MARGIN, - marginTop: GROUPED_SECTION_TOP_MARGIN, - marginBottom: GROUPED_SECTION_BOTTOM_MARGIN, - paddingHorizontal: GROUPED_LIST_ITEM_PADDING, - }} + style={[ + readableContainerStyle, + { + marginHorizontal: GROUPED_LIST_MARGIN, + marginTop: GROUPED_SECTION_TOP_MARGIN, + marginBottom: GROUPED_SECTION_BOTTOM_MARGIN, + paddingHorizontal: GROUPED_LIST_ITEM_PADDING, + }, + ]} > <Text className="text-secondary-label" ellipsizeMode="tail" numberOfLines={1}> {t(transKey)} diff --git a/apps/mobile/src/modules/subscription/items/InboxItem.tsx b/apps/mobile/src/modules/subscription/items/InboxItem.tsx index 95a4ccbeda3..9e9cae1daaa 100644 --- a/apps/mobile/src/modules/subscription/items/InboxItem.tsx +++ b/apps/mobile/src/modules/subscription/items/InboxItem.tsx @@ -13,6 +13,7 @@ import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable" import { Text } from "@/src/components/ui/typography/Text" import { InboxCuteFiIcon } from "@/src/icons/inbox_cute_fi" import { useNavigation } from "@/src/lib/navigation/hooks" +import { useReadableContainerStyle } from "@/src/lib/responsive" import { selectFeed } from "@/src/modules/screen/atoms" import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]/FeedScreen" @@ -25,13 +26,17 @@ export const InboxItem = memo(({ id, isFirst, isLast }: SubscriptionItemBaseProp const unreadCount = useUnreadById(id) const { colorScheme } = useColorScheme() const navigation = useNavigation() + const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN) if (!subscription) return null return ( <Animated.View exiting={FadeOutUp} - style={{ - marginHorizontal: GROUPED_LIST_MARGIN, - }} + style={[ + readableContainerStyle, + { + marginHorizontal: GROUPED_LIST_MARGIN, + }, + ]} className={cn("overflow-hidden", { "rounded-t-[10px]": isFirst, "rounded-b-[10px]": isLast, diff --git a/apps/mobile/src/modules/subscription/items/ListSubscriptionItem.tsx b/apps/mobile/src/modules/subscription/items/ListSubscriptionItem.tsx index 56819d63e62..ca8d4057e39 100644 --- a/apps/mobile/src/modules/subscription/items/ListSubscriptionItem.tsx +++ b/apps/mobile/src/modules/subscription/items/ListSubscriptionItem.tsx @@ -15,6 +15,7 @@ import { ItemPressableStyle } from "@/src/components/ui/pressable/enum" import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable" import { Text } from "@/src/components/ui/typography/Text" import { useNavigation } from "@/src/lib/navigation/hooks" +import { useReadableContainerStyle } from "@/src/lib/responsive" import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]/FeedScreen" import { SubscriptionListItemContextMenu } from "../../context-menu/lists" @@ -30,12 +31,13 @@ export const ListSubscriptionItem = memo(({ id, isFirst, isLast }: ListSubscript const subscription = useSubscriptionById(id) const unreadCount = useUnreadByListId(id) const navigation = useNavigation() + const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN) if (!list) return null return ( <> <Animated.View exiting={FadeOutUp} - style={styles.container} + style={[styles.container, readableContainerStyle]} className={cn("overflow-hidden", { "rounded-t-[10px]": isFirst, "rounded-b-[10px]": isLast, diff --git a/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx b/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx index f3fec2d60d7..4948e00d8bc 100644 --- a/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx +++ b/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx @@ -16,6 +16,7 @@ import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable" import { Text } from "@/src/components/ui/typography/Text" import { WifiOffCuteReIcon } from "@/src/icons/wifi_off_cute_re" import { useNavigation } from "@/src/lib/navigation/hooks" +import { useReadableContainerStyle } from "@/src/lib/responsive" import { selectFeed } from "@/src/modules/screen/atoms" import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]/FeedScreen" @@ -37,6 +38,7 @@ export const SubscriptionItem = memo( enabled: !subscription && !feed, }) const navigation = useNavigation() + const readableContainerStyle = useReadableContainerStyle(760, GROUPED_LIST_MARGIN) const feedTestID = feed?.url ? `subscription-feed-url-${feed.url .replaceAll(/[^a-z0-9]+/gi, "-") @@ -55,9 +57,12 @@ export const SubscriptionItem = memo( <> <Animated.View exiting={FadeOutUp} - style={{ - marginHorizontal: GROUPED_LIST_MARGIN, - }} + style={[ + readableContainerStyle, + { + marginHorizontal: GROUPED_LIST_MARGIN, + }, + ]} className={cn("overflow-hidden", { "rounded-t-[10px]": isFirst, "rounded-b-[10px]": isLast, diff --git a/apps/mobile/src/providers/AppleIAPProvider.tsx b/apps/mobile/src/providers/AppleIAPProvider.tsx index 35b7a8053c1..a5b75cec045 100644 --- a/apps/mobile/src/providers/AppleIAPProvider.tsx +++ b/apps/mobile/src/providers/AppleIAPProvider.tsx @@ -2,8 +2,7 @@ import { whoamiQueryKey } from "@follow/store/user/hooks" import { userSyncService } from "@follow/store/user/store" import { requireNativeModule } from "expo" import type { ProductPurchase, SubscriptionProduct } from "expo-iap" -import { getTransactionJws, showManageSubscriptions, useIAP } from "expo-iap" -import { openURL } from "expo-linking" +import { getTransactionJws, useIAP } from "expo-iap" import type { PropsWithChildren } from "react" import { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" @@ -15,7 +14,6 @@ import { proxyEnv } from "@/src/lib/proxy-env" import { queryClient } from "@/src/lib/query-client" import { toast } from "@/src/lib/toast" -const APPLE_SUBSCRIPTION_MANAGEMENT_URL = "https://apps.apple.com/account/subscriptions" const billingSubscriptionQueryKey = ["billingSubscription"] type BillingSubscriptionResponse = { @@ -40,12 +38,17 @@ type AppleIAPContextValue = { appAccountToken?: string | null }) => Promise<void> restoreSubscriptionPurchases: () => Promise<void> - openSubscriptionManagement: () => Promise<void> } const AppleIAPContext = createContext<AppleIAPContextValue | null>(null) -const noopAsync = async () => {} +const refreshBillingState = async () => { + await Promise.allSettled([ + userSyncService.whoami(), + queryClient.invalidateQueries({ queryKey: whoamiQueryKey }), + queryClient.invalidateQueries({ queryKey: billingSubscriptionQueryKey }), + ]) +} export const AppleIAPProvider = ({ children }: PropsWithChildren) => { const { t } = useTranslation("settings") @@ -107,14 +110,6 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { availablePurchasesRef.current = availablePurchases }, [availablePurchases]) - const refreshBillingState = useCallback(async () => { - await Promise.allSettled([ - userSyncService.whoami(), - queryClient.invalidateQueries({ queryKey: whoamiQueryKey }), - queryClient.invalidateQueries({ queryKey: billingSubscriptionQueryKey }), - ]) - }, []) - const verifyPurchase = useCallback( async (purchase: ProductPurchase) => { const productId = purchase.id @@ -186,14 +181,7 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { setIsProcessingPurchase(false) } })() - }, [ - currentPurchase, - finishTransaction, - knownSubscriptionIds, - refreshBillingState, - t, - verifyPurchase, - ]) + }, [currentPurchase, finishTransaction, knownSubscriptionIds, t, verifyPurchase]) useEffect(() => { if (!currentPurchaseError) { @@ -267,7 +255,7 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { throw error } }, - [refreshBillingState, requestPurchase, storeKitTestHelper], + [requestPurchase, storeKitTestHelper], ) const restoreSubscriptionPurchases = useCallback(async () => { @@ -322,20 +310,7 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { } finally { setIsRestoring(false) } - }, [knownSubscriptionIds, refreshBillingState, restorePurchases, t, verifyPurchase]) - - const openSubscriptionManagement = useCallback(async () => { - if (Platform.OS !== "ios") { - await noopAsync() - return - } - - try { - await showManageSubscriptions() - } catch { - await openURL(APPLE_SUBSCRIPTION_MANAGEMENT_URL) - } - }, []) + }, [knownSubscriptionIds, restorePurchases, t, verifyPurchase]) const contextValue = useMemo<AppleIAPContextValue>( () => ({ @@ -347,7 +322,6 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { loadSubscriptions, requestSubscriptionPurchase, restoreSubscriptionPurchases, - openSubscriptionManagement, }), [ connected, @@ -358,7 +332,6 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { loadSubscriptions, requestSubscriptionPurchase, restoreSubscriptionPurchases, - openSubscriptionManagement, ], ) diff --git a/apps/mobile/src/screens/(modal)/ForgetPasswordScreen.tsx b/apps/mobile/src/screens/(modal)/ForgetPasswordScreen.tsx index 4bdf7ee7514..a127a9d3968 100644 --- a/apps/mobile/src/screens/(modal)/ForgetPasswordScreen.tsx +++ b/apps/mobile/src/screens/(modal)/ForgetPasswordScreen.tsx @@ -1,6 +1,7 @@ import { useMutation } from "@tanstack/react-query" import { useState } from "react" -import { StyleSheet, TouchableWithoutFeedback, View } from "react-native" +import { useTranslation } from "react-i18next" +import { ScrollView, StyleSheet, View } from "react-native" import { KeyboardAvoidingView, KeyboardController } from "react-native-keyboard-controller" import { useSafeAreaInsets } from "react-native-safe-area-context" @@ -16,8 +17,15 @@ import { getTokenHeaders } from "@/src/lib/token" export const ForgetPasswordScreen: NavigationControllerView = () => { const insets = useSafeAreaInsets() + const { t } = useTranslation() const [email, setEmail] = useState("") const navigation = useNavigation() + const contentContainerStyle = { + flexGrow: 1, + justifyContent: "space-between" as const, + paddingTop: insets.top + 56, + paddingBottom: insets.bottom + 24, + } const forgetPasswordMutation = useMutation({ mutationFn: async (email: string) => { const res = await forgetPassword( @@ -36,61 +44,56 @@ export const ForgetPasswordScreen: NavigationControllerView = () => { toast.error(error.message) }, onSuccess: () => { - toast.success("We have sent you an email with instructions to reset your password.") + toast.success(t("login.forgot_password.success")) navigation.back() }, }) return ( - <TouchableWithoutFeedback - onPress={() => { - KeyboardController.dismiss() - }} - accessible={false} - > - <KeyboardAvoidingView behavior="padding" style={styles.keyboardContainer}> - <HeaderCloseOnly /> - <View - className="flex-1 justify-between px-5" - style={{ - paddingTop: insets.top + 56, - }} - > - <View className="items-center"> - <Text className="text-center text-4xl font-bold text-text">Forgot password?</Text> - <Text className="mx-10 mt-6 text-center text-lg text-text"> - Enter your email address that you use with your account to continue. - </Text> + <KeyboardAvoidingView behavior="padding" style={styles.keyboardContainer}> + <HeaderCloseOnly /> + <ScrollView + className="flex-1 px-5" + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + contentContainerStyle={contentContainerStyle} + onScrollBeginDrag={() => { + KeyboardController.dismiss() + }} + > + <View className="items-center"> + <Text className="text-center text-4xl font-bold text-text"> + {t("login.forgot_password.title")} + </Text> + <Text className="mx-10 mt-6 text-center text-lg text-text"> + {t("login.forgot_password.description")} + </Text> - <View className="mt-6 gap-4 self-stretch rounded-2xl bg-secondary-system-background px-6 py-4"> - <View className="flex-row"> - <PlainTextField - autoCapitalize="none" - autoCorrect={false} - keyboardType="email-address" - autoComplete="email" - placeholder="Email" - className="flex-1 text-text" - value={email} - onChangeText={setEmail} - onSubmitEditing={() => forgetPasswordMutation.mutate(email)} - /> - </View> + <View className="mt-6 gap-4 self-stretch rounded-2xl bg-secondary-system-background px-6 py-4"> + <View className="flex-row"> + <PlainTextField + autoCapitalize="none" + autoCorrect={false} + keyboardType="email-address" + autoComplete="email" + placeholder={t("login.email")} + className="flex-1 text-text" + value={email} + onChangeText={setEmail} + onSubmitEditing={() => forgetPasswordMutation.mutate(email)} + /> </View> </View> - - <SubmitButton - title="Continue" - className="self-stretch" - style={{ - bottom: insets.bottom + 30, - }} - disabled={!email || forgetPasswordMutation.isPending} - isLoading={forgetPasswordMutation.isPending} - onPress={() => forgetPasswordMutation.mutate(email)} - /> </View> - </KeyboardAvoidingView> - </TouchableWithoutFeedback> + + <SubmitButton + title={t("login.forgot_password.continue")} + className="mt-8 self-stretch" + disabled={!email || forgetPasswordMutation.isPending} + isLoading={forgetPasswordMutation.isPending} + onPress={() => forgetPasswordMutation.mutate(email)} + /> + </ScrollView> + </KeyboardAvoidingView> ) } diff --git a/apps/mobile/src/screens/(modal)/LoginScreen.tsx b/apps/mobile/src/screens/(modal)/LoginScreen.tsx index 5517343d915..f6497b8f7d3 100644 --- a/apps/mobile/src/screens/(modal)/LoginScreen.tsx +++ b/apps/mobile/src/screens/(modal)/LoginScreen.tsx @@ -1,5 +1,5 @@ import { useWhoami } from "@follow/store/user/hooks" -import { Fragment, useCallback, useEffect } from "react" +import { useCallback, useEffect } from "react" import { ScrollView } from "react-native" import { HeaderCloseOnly } from "@/src/components/layouts/header/HeaderElements" @@ -32,14 +32,24 @@ export const LoginScreen: NavigationControllerView = () => { } }, [exit, switchTab, whoami?.id]) const isiPad = useIsiPad() - const Container = isiPad ? ScrollView : Fragment return ( <> - <Container> + {isiPad ? ( + <ScrollView + contentContainerStyle={tabletScrollViewContentStyle} + keyboardShouldPersistTaps="handled" + > + <Login /> + </ScrollView> + ) : ( <Login /> - </Container> + )} <HeaderCloseOnly /> </> ) } LoginScreen.sheetGrabberVisible = false + +const tabletScrollViewContentStyle = { + flexGrow: 1, +} diff --git a/apps/mobile/src/screens/(modal)/ProfileScreen.tsx b/apps/mobile/src/screens/(modal)/ProfileScreen.tsx index 02d50eec082..99dba97db18 100644 --- a/apps/mobile/src/screens/(modal)/ProfileScreen.tsx +++ b/apps/mobile/src/screens/(modal)/ProfileScreen.tsx @@ -381,9 +381,9 @@ const MaybeSwipeable = ({ id, children }: { id: string; children: React.ReactNod { onPress: () => { // unsubscribe - Alert.alert("Unsubscribe?", "This will remove the feed from your subscriptions", [ + Alert.alert(t("feed.unfollow.confirm_title"), t("feed.unfollow.confirm_description"), [ { - text: "Cancel", + text: t("words.cancel", { ns: "common" }), style: "cancel", }, { diff --git a/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx b/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx index 434969f92b0..9fcd857537e 100644 --- a/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx +++ b/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx @@ -103,13 +103,14 @@ export const EntryDetailScreen: NavigationControllerView<{ Header={<EntryNavigationHeader entryId={entryId} />} ScrollViewBottom={<EntryPullUpToNext {...pullUpViewProps} />} automaticallyAdjustContentInsets={false} + contentContainerMaxWidth={680} contentContainerClassName="flex min-h-full pb-16" {...scrollViewEventHandlers} > <ItemPressable itemStyle={ItemPressableStyle.UnStyled} onPress={() => entry?.url && openLink(entry.url)} - className="rounded-xl py-4" + className="rounded-xl px-5 py-4" > {viewType === FeedViewType.SocialMedia ? ( <EntrySocialTitle entryId={entryId} /> @@ -120,14 +121,16 @@ export const EntryDetailScreen: NavigationControllerView<{ </> )} </ItemPressable> - <EntryAISummary entryId={entryId} /> + <View className="px-5"> + <EntryAISummary entryId={entryId} /> + </View> {entry && ( - <View className="mt-3"> + <View className="mt-3 w-full px-5"> <EntryContentWebViewWithContext entryId={entryId} /> </View> )} {viewType === FeedViewType.SocialMedia && ( - <View className="mt-2"> + <View className="mt-2 px-5"> <EntryInfoSocial entryId={entryId} /> </View> )} @@ -207,7 +210,7 @@ const EntryInfo = ({ entryId }: { entryId: string }) => { if (!entry) return null const { publishedAt } = entry return ( - <View className="mt-4 flex flex-row items-center gap-4 px-5"> + <View className="mt-4 flex flex-row items-center gap-4"> {feed && ( <View className="flex shrink flex-row items-center gap-2"> <FeedIcon feed={feed} /> @@ -238,7 +241,7 @@ const EntryInfoSocial = ({ entryId }: { entryId: string }) => { })) if (!entry) return null return ( - <View className="mt-3 px-4"> + <View className="mt-3"> <Text className="text-sm text-secondary-label"> {entry.publishedAt.toLocaleString(undefined, { dateStyle: "medium", diff --git a/apps/mobile/src/screens/OnboardingScreen.tsx b/apps/mobile/src/screens/OnboardingScreen.tsx index 62b736509ba..9496bc2429e 100644 --- a/apps/mobile/src/screens/OnboardingScreen.tsx +++ b/apps/mobile/src/screens/OnboardingScreen.tsx @@ -7,6 +7,7 @@ import Animated, { FadeInRight, FadeOutLeft } from "react-native-reanimated" import { useSafeAreaInsets } from "react-native-safe-area-context" import { Text } from "@/src/components/ui/typography/Text" +import { useReadableContainerStyle } from "@/src/lib/responsive" import { useNavigation } from "../lib/navigation/hooks" import type { NavigationControllerView } from "../lib/navigation/types" @@ -22,6 +23,7 @@ const ONBOARDING_STEPS = [1, 2, 3, 4] export const OnboardingScreen: NavigationControllerView = () => { const { t } = useTranslation("common") const insets = useSafeAreaInsets() + const readableContentStyle = useReadableContainerStyle(680) const [currentStep, setCurrentStep] = useState(1) const totalSteps = ONBOARDING_STEPS.length const navigation = useNavigation() @@ -73,6 +75,7 @@ export const OnboardingScreen: NavigationControllerView = () => { key={`step-${currentStep}`} exiting={FadeOutLeft} entering={FadeInRight} + style={readableContentStyle} > {/* Content */} {currentStep === 1 && <StepWelcome />} @@ -82,7 +85,7 @@ export const OnboardingScreen: NavigationControllerView = () => { </Animated.View> {/* Navigation buttons */} - <View className="mb-6 px-6"> + <View className="mb-6 px-6" style={readableContentStyle}> <Pressable testID="onboarding-next" onPress={handleNext} diff --git a/apps/mobile/web-app/html-renderer/src/HTML.tsx b/apps/mobile/web-app/html-renderer/src/HTML.tsx index f9e608ef8b9..0416abcfaf2 100644 --- a/apps/mobile/web-app/html-renderer/src/HTML.tsx +++ b/apps/mobile/web-app/html-renderer/src/HTML.tsx @@ -66,7 +66,15 @@ export const HTML = <A extends keyof React.JSX.IntrinsicElements = "div">(props: { ...rest, ref: setRefElement, - className: clsx("prose mx-auto px-5 pb-8 [text-autospace:normal]", "dark:prose-invert"), + style: { + width: "100%", + maxWidth: "100%", + ...rest.style, + }, + className: clsx( + "prose max-w-none mx-auto pb-8 [text-autospace:normal]", + "dark:prose-invert", + ), }, markdownElement, )} diff --git a/apps/mobile/web-app/html-renderer/src/common/WrappedElementProvider.tsx b/apps/mobile/web-app/html-renderer/src/common/WrappedElementProvider.tsx index 2c757849f54..ee583aaf5c7 100644 --- a/apps/mobile/web-app/html-renderer/src/common/WrappedElementProvider.tsx +++ b/apps/mobile/web-app/html-renderer/src/common/WrappedElementProvider.tsx @@ -91,7 +91,7 @@ const Content: Component<WrappedElementProviderProps> = memo( const As = as as any return ( - <As className={cn("relative", className)} ref={setElement}> + <As className={cn("relative w-full max-w-full", className)} ref={setElement}> {children} </As> ) diff --git a/apps/mobile/web-app/html-renderer/src/components/image.tsx b/apps/mobile/web-app/html-renderer/src/components/image.tsx index 0db65ad5e11..0605a076ab8 100644 --- a/apps/mobile/web-app/html-renderer/src/components/image.tsx +++ b/apps/mobile/web-app/html-renderer/src/components/image.tsx @@ -37,10 +37,13 @@ export const MarkdownImage = (props: HTMLProps<"img">) => { return ( <button type="button" - className="relative -mx-5 overflow-hidden bg-gray-300 dark:bg-neutral-800" + className="relative mx-auto block overflow-hidden bg-gray-300 dark:bg-neutral-800" style={{ width: scaleWidth || undefined, height: scaleHeight || undefined, + marginLeft: "auto", + marginRight: "auto", + maxWidth: "100%", }} data-image-height={image?.height} data-image-width={image?.width} diff --git a/apps/mobile/web-app/html-renderer/src/parser.tsx b/apps/mobile/web-app/html-renderer/src/parser.tsx index cc5c656d280..f94859b90f7 100644 --- a/apps/mobile/web-app/html-renderer/src/parser.tsx +++ b/apps/mobile/web-app/html-renderer/src/parser.tsx @@ -144,7 +144,12 @@ export const parseHtml = ( createElement( "figure", { - className: "max-w-full", + className: "mx-auto max-w-full", + style: { + marginLeft: "auto", + marginRight: "auto", + ...(props as any).style, + }, }, props.children, ), diff --git a/apps/ssr/client/components/items/picture.tsx b/apps/ssr/client/components/items/picture.tsx index 77e61453507..af45e46ffb5 100644 --- a/apps/ssr/client/components/items/picture.tsx +++ b/apps/ssr/client/components/items/picture.tsx @@ -203,8 +203,10 @@ export const PictureList: FC<{ } const itemKey = (item: { url: string }) => item.url -const render: React.ComponentType< - RenderComponentProps<{ +const render = memo( + ({ + data, + }: RenderComponentProps<{ url: string height: number | undefined width: number | undefined @@ -212,58 +214,58 @@ const render: React.ComponentType< id: string entry: ParsedEntry feed?: Feed - }> -> = memo(({ data }) => { - const [isHovered, setIsHovered] = useState(false) + }>) => { + const [isHovered, setIsHovered] = useState(false) - return ( - <MasonryItemFixedDimensionWrapper - url={data.url} - key={data.id} - height={data.height} - ratio={data.height && data.width ? data.height / data.width : undefined} - > - <div - className="size-full overflow-hidden rounded-md" - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} + return ( + <MasonryItemFixedDimensionWrapper + url={data.url} + key={data.id} + height={data.height} + ratio={data.height && data.width ? data.height / data.width : undefined} > - <PhotoView src={data.url}> - <LazyImage - src={data.url} - height={data.height} - width={data.width} - blurhash={data.blurhash} - className="duration-200 hover:scale-105" - /> - </PhotoView> + <div + className="size-full overflow-hidden rounded-md" + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + <PhotoView src={data.url}> + <LazyImage + src={data.url} + height={data.height} + width={data.width} + blurhash={data.blurhash} + className="duration-200 hover:scale-105" + /> + </PhotoView> - <AnimatePresence> - {isHovered && ( - <m.div - initial={{ opacity: 0, y: 10 }} - animate={{ opacity: 1, y: 0 }} - exit={{ opacity: 0, y: 10 }} - className="absolute inset-x-0 -bottom-px z-[3] overflow-hidden rounded-b-md pb-1" - key="footer" - > - <div className="absolute inset-x-0 bottom-0 h-[56px]" style={maskStyle}> - <div className="absolute inset-x-0 bottom-0 h-[76px] bg-gradient-to-t from-black/80 to-transparent" /> - </div> - <GridItemFooter - entry={data.entry} - feed={data.feed} - titleClassName="!text-white" - descriptionClassName="!text-white/80" - timeClassName="!text-white/60" - /> - </m.div> - )} - </AnimatePresence> - </div> - </MasonryItemFixedDimensionWrapper> - ) -}) + <AnimatePresence> + {isHovered && ( + <m.div + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0, y: 10 }} + className="absolute inset-x-0 -bottom-px z-[3] overflow-hidden rounded-b-md pb-1" + key="footer" + > + <div className="absolute inset-x-0 bottom-0 h-[56px]" style={maskStyle}> + <div className="absolute inset-x-0 bottom-0 h-[76px] bg-gradient-to-t from-black/80 to-transparent" /> + </div> + <GridItemFooter + entry={data.entry} + feed={data.feed} + titleClassName="!text-white" + descriptionClassName="!text-white/80" + timeClassName="!text-white/60" + /> + </m.div> + )} + </AnimatePresence> + </div> + </MasonryItemFixedDimensionWrapper> + ) + }, +) const GridItemFooter = ({ entry, diff --git a/apps/ssr/client/components/ui/feed-icon.tsx b/apps/ssr/client/components/ui/feed-icon.tsx index eef22d09410..ea7ec14c447 100644 --- a/apps/ssr/client/components/ui/feed-icon.tsx +++ b/apps/ssr/client/components/ui/feed-icon.tsx @@ -4,7 +4,6 @@ import { getBackgroundGradient } from "@follow/utils/color" import { getImageProxyUrl, replaceImgUrlIfNeed } from "@follow/utils/img-proxy" import { cn, getUrlIcon } from "@follow/utils/utils" import type { FeedGetResponse } from "@follow-app/client-sdk" -import type { MediaModel } from "@folo-services/drizzle" import { m } from "motion/react" import type { ReactNode } from "react" import { useMemo } from "react" @@ -69,8 +68,13 @@ const FallbackableImage = function FallbackableImage({ ) } +type FeedIconMedia = { + url: string + type?: string +} + type FeedIconEntry = { - media?: Nullable<MediaModel[]> + media?: Nullable<FeedIconMedia[]> [key: string]: any } diff --git a/apps/ssr/client/initialize/analytics.ts b/apps/ssr/client/initialize/analytics.ts deleted file mode 100644 index 976884bee57..00000000000 --- a/apps/ssr/client/initialize/analytics.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { env } from "@follow/shared/env.ssr" -import { setOpenPanelTracker } from "@follow/tracker" - -import { op } from "./op" - -export const initAnalytics = () => { - if (env.VITE_OPENPANEL_CLIENT_ID === undefined) return - - setOpenPanelTracker(op) - op.setGlobalProperties({ - build: "external-web", - hash: GIT_COMMIT_SHA, - }) -} diff --git a/apps/ssr/client/initialize/index.ts b/apps/ssr/client/initialize/index.ts index 397ca96554f..75701835ac9 100644 --- a/apps/ssr/client/initialize/index.ts +++ b/apps/ssr/client/initialize/index.ts @@ -1,11 +1,9 @@ import { initI18n } from "@client/i18n" import { initializeDayjs } from "@follow/components/dayjs" -import { initAnalytics } from "./analytics" import { initSentry } from "./sentry" export const initialize = async () => { - initAnalytics() initializeDayjs() await Promise.all([initI18n(), initSentry()]) } diff --git a/apps/ssr/client/initialize/op.ts b/apps/ssr/client/initialize/op.ts deleted file mode 100644 index 489367073c1..00000000000 --- a/apps/ssr/client/initialize/op.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { env } from "@follow/shared/env.ssr" -import { OpenPanel } from "@openpanel/web" - -export const op = new OpenPanel({ - clientId: env.VITE_OPENPANEL_CLIENT_ID ?? "", - trackScreenViews: true, - trackOutgoingLinks: true, - trackAttributes: true, - apiUrl: env.VITE_OPENPANEL_API_URL, -}) diff --git a/apps/ssr/client/lib/auth.ts b/apps/ssr/client/lib/auth.ts index e9bb192b744..8ee1fa847d3 100644 --- a/apps/ssr/client/lib/auth.ts +++ b/apps/ssr/client/lib/auth.ts @@ -19,7 +19,6 @@ const auth = new Auth({ export const { changeEmail, changePassword, - forgetPassword, getAccountInfo, getLastUsedLoginMethod, getProviders, @@ -37,4 +36,6 @@ export const { updateUser, } = auth.authClient +export const forgetPassword = auth.authClient.requestPasswordReset + export const { loginHandler } = auth diff --git a/apps/ssr/client/modules/login/index.tsx b/apps/ssr/client/modules/login/index.tsx index 8aa0aba6438..9afffd0d1c2 100644 --- a/apps/ssr/client/modules/login/index.tsx +++ b/apps/ssr/client/modules/login/index.tsx @@ -164,14 +164,17 @@ export function Login() { const onceRef = useRef(false) useEffect(() => { - if (isAuthenticated && !onceRef.current) { - if (cliCallbackUrl) { - void handleCliCallback() - } else { - void handleOpenApp() - } + if (!isAuthenticated || onceRef.current) { + return } + onceRef.current = true + if (cliCallbackUrl) { + void handleCliCallback() + return + } + + void handleOpenApp() }, [cliCallbackUrl, handleCliCallback, handleOpenApp, isAuthenticated]) const navigate = useNavigate() diff --git a/apps/ssr/client/pages/(login)/register.tsx b/apps/ssr/client/pages/(login)/register.tsx index 9cc8fd87b18..f551befeb4f 100644 --- a/apps/ssr/client/pages/(login)/register.tsx +++ b/apps/ssr/client/pages/(login)/register.tsx @@ -69,12 +69,14 @@ function RegisterForm() { try { const recaptchaToken = await requestRecaptchaToken("ssr_register") - await signUp.email({ - email: values.email, - password: values.password, - name: values.email.split("@")[0]!, - callbackURL: "/", - fetchOptions: { + await signUp.email( + { + email: values.email, + password: values.password, + name: values.email.split("@")[0]!, + callbackURL: "/", + }, + { onSuccess() { tracker.register({ type: "email", @@ -90,7 +92,7 @@ function RegisterForm() { } : undefined, }, - }) + ) } finally { setIsSubmitting(false) } diff --git a/apps/ssr/client/pages/(main)/share/lists/[id]/index.tsx b/apps/ssr/client/pages/(main)/share/lists/[id]/index.tsx index a78d37837cd..47b2dd36da9 100644 --- a/apps/ssr/client/pages/(main)/share/lists/[id]/index.tsx +++ b/apps/ssr/client/pages/(main)/share/lists/[id]/index.tsx @@ -65,7 +65,7 @@ export function Component() { const feedMap = list.data?.list.feeds?.reduce( - (acc, feed) => { + (acc: Record<string, FeedSchema>, feed: FeedSchema) => { acc[feed.id] = feed return acc }, @@ -189,7 +189,7 @@ export function Component() { </div> <div className="grid gap-3 sm:grid-cols-1 lg:grid-cols-2"> - {listData.feedIds?.slice(0, SIZE).map((feedId) => ( + {listData.feedIds?.slice(0, SIZE).map((feedId: string) => ( <FeedRow feed={feedMap[feedId]!} key={feedId} /> ))} </div> diff --git a/apps/ssr/client/pages/(main)/share/users/[id]/index.tsx b/apps/ssr/client/pages/(main)/share/users/[id]/index.tsx index 6def777d2c2..1df3324b324 100644 --- a/apps/ssr/client/pages/(main)/share/users/[id]/index.tsx +++ b/apps/ssr/client/pages/(main)/share/users/[id]/index.tsx @@ -178,7 +178,7 @@ const Lists = ({ userId }: { userId: string }) => { <h2 className="text-xl font-medium text-zinc-900 dark:text-zinc-100">Created Lists</h2> </div> <div data-testid="profile-lists" className="flex flex-col space-y-4"> - {lists.data?.map((list) => ( + {(lists.data as any[] | undefined)?.map((list) => ( <a key={list.id} href={UrlBuilder.shareList(list.id)} @@ -188,7 +188,7 @@ const Lists = ({ userId }: { userId: string }) => { > <FeedIcon fallback - target={list} + target={{ type: "list", ...(list as any) }} className="mask-squircle mask border border-border" size={80} noMargin diff --git a/apps/ssr/client/providers/user-provider.tsx b/apps/ssr/client/providers/user-provider.tsx index cdfb12506c6..2088d593175 100644 --- a/apps/ssr/client/providers/user-provider.tsx +++ b/apps/ssr/client/providers/user-provider.tsx @@ -9,8 +9,7 @@ export const UserProvider = () => { useEffect(() => { if (!session?.user) return - // @ts-expect-error FIXME - setWhoami(session.user) + setWhoami(session.user as unknown as AuthUser) setIntegrationIdentify(session.user as unknown as AuthUser) }, [session?.user]) diff --git a/apps/ssr/index.html b/apps/ssr/index.html index f3211ea169b..818e7da55b5 100644 --- a/apps/ssr/index.html +++ b/apps/ssr/index.html @@ -18,7 +18,7 @@ <title>Folo - +