merge: combine fleet docs with degenfeed-web project history
Some checks are pending
DegenFeed CI/CD / test (push) Waiting to run
DegenFeed CI/CD / deploy-worker (push) Blocked by required conditions
DegenFeed CI/CD / deploy-web (push) Blocked by required conditions

Resolve AGENTS.md conflict (keep fleet canonical version).
Merge .gitignore combining home-dir patterns with Node/TS project patterns.
This commit is contained in:
Crypto Rug Munch 2026-07-09 17:05:21 +07:00
commit d652bf4fab
301 changed files with 53362 additions and 5 deletions

29
.editorconfig Normal file
View file

@ -0,0 +1,29 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
max_line_length = 100
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
[*.{py,pyi}]
indent_size = 4
max_line_length = 100
[*.{yml,yaml}]
indent_size = 2
[*.{json,jsonc}]
indent_size = 2
[{*.bat,*.cmd,*.ps1}]
end_of_line = crlf

68
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,68 @@
name: DegenFeed CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm test
deploy-worker:
needs: [test]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
- name: Deploy Worker
uses: cloudflare/wrangler-action@v3
with:
apiToken: \${{ secrets.CF_API_TOKEN }}
workingDirectory: workers/api
command: deploy
deploy-web:
needs: [test]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
- run: pnpm --filter degenfeed-web build
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: \${{ secrets.CF_API_TOKEN }}
accountId: \${{ secrets.CF_ACCOUNT_ID }}
projectName: degenfeed
directory: apps/web/out
gitHubToken: \${{ secrets.GITHUB_TOKEN }}

79
.gitattributes vendored Normal file
View file

@ -0,0 +1,79 @@
# .gitattributes — Rug Munch Media fleet standard
# See https://git.rugmunch.io/RugMunchMedia/standards for source of truth.
# ── Line endings ───────────────────────────────────────────────
* text=auto eol=lf
# Shell scripts must use LF even on Windows checkouts
*.sh text eol=lf
*.bash text eol=lf
# Windows-native scripts keep CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
# ── Binary defaults ────────────────────────────────────────────
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.pdf binary
*.zip binary
*.gz binary
*.tgz binary
*.tar binary
*.bz2 binary
*.xz binary
*.7z binary
*.rar binary
*.mp4 binary
*.mp3 binary
*.wav binary
*.mov binary
*.avi binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
*.otf binary
*.so binary
*.dll binary
*.dylib binary
*.pyc binary
*.pyo binary
*.pyd binary
*.o binary
*.a binary
*.lib binary
# ── Linguist overrides ─────────────────────────────────────────
# Mark vendor / vendored / generated so they don't pollute stats
vendor/ linguist-vendored
node_modules/ linguist-vendored
__pycache__/ linguist-vendored
*.min.js linguist-vendored
*.min.css linguist-vendored
dist/ linguist-vendored
build/ linguist-vendored
coverage/ linguist-vendored
pnpm-lock.yaml linguist-generated
package-lock.json linguist-generated
yarn.lock linguist-generated
# Markdown documentation
*.md linguist-documentation
docs/ linguist-documentation
# Makefile
Makefile linguist-language=Makefile
*.mk linguist-language=Makefile
# ── Diff & merge ───────────────────────────────────────────────
*.json diff=json
*.lock -diff
package-lock.json -diff
pnpm-lock.yaml -diff
yarn.lock -diff

76
.github/renovate.json vendored Normal file
View file

@ -0,0 +1,76 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"enabledManagers": ["npm"],
"packageRules": [
{
"matchUpdateTypes": ["patch"],
"automerge": true,
"automergeType": "branch",
"platformAutomerge": true,
"description": "Auto-merge patch updates (bug fixes) after CI passes"
},
{
"matchPackageNames": [
"@solana/web3.js",
"@solana/wallet-adapter-base",
"@solana/wallet-adapter-react",
"@solana/wallet-adapter-wallets",
"@web3modal/wagmi",
"viem",
"wagmi"
],
"matchUpdateTypes": ["minor"],
"automerge": false,
"description": "Web3 deps need manual review on minor bumps"
},
{
"matchPackageNames": ["next", "react", "react-dom"],
"matchUpdateTypes": ["minor"],
"automerge": false,
"description": "Framework deps — always manual review"
},
{
"matchPackageNames": ["@biomejs/biome", "typescript", "vitest"],
"groupName": "dev-tooling",
"automerge": true,
"automergeType": "branch",
"platformAutomerge": true,
"description": "Dev tooling can auto-merge"
},
{
"matchDepTypes": ["devDependencies"],
"matchUpdateTypes": ["patch"],
"automerge": true,
"automergeType": "branch"
},
{
"matchPackageNames": [
"@degenfeed/api-worker",
"@degenfeed/auth",
"@degenfeed/bluesky-sdk",
"@degenfeed/farcaster-sdk",
"@degenfeed/feed-core",
"@degenfeed/feed-providers",
"@degenfeed/identity",
"@degenfeed/lens-sdk",
"@degenfeed/mastodon-sdk",
"@degenfeed/nostr-sdk",
"@degenfeed/ranking",
"@degenfeed/rss-sdk",
"@degenfeed/storage",
"@degenfeed/threads-sdk",
"@degenfeed/types",
"@degenfeed/ui",
"@degenfeed/utils"
],
"enabled": false,
"description": "Internal workspace packages — never bump externally"
}
],
"labels": ["dependencies"],
"prHourlyLimit": 5,
"schedule": ["before 6am on Monday"],
"timezone": "UTC",
"dependencyDashboard": true
}

90
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,90 @@
name: CI
on:
push:
branches: [main, feat/**, fix/**, chore/**, hotfix/**]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
lint:
runs-on: ubuntu-latest
needs: install
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
typecheck:
runs-on: ubuntu-latest
needs: install
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
test:
runs-on: ubuntu-latest
needs: install
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test
build:
runs-on: ubuntu-latest
needs: [lint, typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build web
run: pnpm --filter degenfeed-web build
- name: Build worker (dry-run)
run: pnpm --filter @degenfeed/api-worker build
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install gitleaks
run: |
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz | tar -xz -C /usr/local/bin gitleaks
- name: Scan for secrets
run: gitleaks detect --source . --no-banner --exit-code 1

115
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,115 @@
name: Deploy
# Deploy the worker to a Cloudflare preview environment on every PR,
# and to production on every push to main. The worker is the single
# source of truth for the API surface (degenfeed.xyz/api/* and
# app.degenfeed.xyz/api/*). The Next.js frontend is built separately
# (see the Pages deploy job below).
#
# Required GitHub Actions secrets (Settings → Secrets → Actions):
# CLOUDFLARE_API_TOKEN — Cloudflare API token with Workers Scripts:Edit
# and Workers KV Storage:Edit scopes
# CLOUDFLARE_ACCOUNT_ID — Cloudflare account id (32 hex chars)
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
jobs:
preview:
# PR build → deploy to a Cloudflare Workers preview environment so the
# PR has a stable URL to test against. PRs don't get a custom domain;
# the preview URL is `https://degenfeed-api-preview.<workers subdomain>`.
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
environment:
name: preview
url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm --filter @degenfeed/api-worker typecheck
- name: Test
run: pnpm --filter @degenfeed/api-worker test
- name: Build (dry-run)
run: pnpm --filter @degenfeed/api-worker build
- name: Deploy worker (preview)
id: deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: workers/api
command: deploy --env preview --var ALLOWED_ORIGINS:https://preview.degenfeed.xyz,https://app.preview.degenfeed.xyz
# Note: AUTH_SECRET and GTS_CLIENT_SECRET must already exist as
# encrypted secrets on the preview environment (set once via
# `wrangler secret put`). This job never sees them in plaintext.
production:
# main → deploy to production with the routes configured in wrangler.toml.
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment:
name: production
url: https://degenfeed.xyz
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: pnpm/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm --filter @degenfeed/api-worker typecheck
- name: Test
run: pnpm --filter @degenfeed/api-worker test
- name: Build (dry-run)
run: pnpm --filter @degenfeed/api-worker build
- name: Deploy worker (production)
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: workers/api
command: deploy
# Secrets (AUTH_SECRET, GTS_CLIENT_SECRET) must already be set
# via `wrangler secret put` before the first deploy. They live
# only on the Cloudflare side; this job never sees plaintext.
notify-failure:
if: failure()
runs-on: ubuntu-latest
needs: [preview, production]
steps:
- name: Ping on deploy failure
run: |
echo "::error::Deploy failed for ${{ github.ref }} — see run logs"
# Add Slack/Discord/PagerDuty webhook here when ready.
exit 1

53
.gitignore vendored
View file

@ -15,23 +15,66 @@
.hermes/cache/
.hermes/logs/
# IDE
.vscode/
.idea/
# Secrets (zero tolerance)
.env
.env.*
.dev.vars
!.env.example
!.env.template
*.pem
*.key
*.p12
*.pfx
*.session
credentials.json
service-account.json
.secrets/
# Node
node_modules/
.npm/
.pnpm-store/
yarn-error.log
yarn-debug.log
# Build artifacts
dist/
build/
.next/
.turbo/
out/
*.tsbuildinfo
# Coverage
coverage/
*.lcov
# Python
__pycache__/
*.pyc
# Node
node_modules/
# IDE
.vscode/
.idea/
# Shell history
.bash_history
.zsh_history
# Logs
*.log
logs/
pnpm-debug.log*
# Cache / temp
.cache/
tmp/
.vercel/
*.tmp.vercel/
# Temp
*.swp
*.swo
*~
.DS_Store
.aider*

9
.gitleaks.toml Normal file
View file

@ -0,0 +1,9 @@
title = "DegenFeed Web Gitleaks Config"
[allowlist]
paths = [
# Build artifacts generated by Vercel/Next.js; these contain auto-generated
# preview/encryption tokens that are not real secrets. The directory is
# already gitignored; this prevents historical commits from failing scans.
'''\.vercel\/''',
]

3
.mise.toml Normal file
View file

@ -0,0 +1,3 @@
[tools]
node = "22"
pnpm = "11.7.0"

37
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,37 @@
# Pre-commit — Rug Munch Media TypeScript fleet standard
# See https://git.rugmunch.io/RugMunchMedia/standards for source of truth.
# Install: pre-commit install
# Run all: pre-commit run --all-files
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-toml
- id: check-added-large-files
args: ["--maxkb=500"]
- id: check-merge-conflict
- id: mixed-line-ending
args: ["--fix=lf"]
- id: no-commit-to-branch
args: ["--branch", "main"]
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.0
hooks:
- id: gitleaks
args: ["detect", "--source", ".", "--verbose"]
- repo: local
hooks:
- id: biome-check
name: Biome lint + format check
entry: bash -c 'npx @biomejs/biome check . --no-errors-on-unmatched || true'
language: system
types_or: [javascript, jsx, ts, tsx, json]
pass_filenames: false
stages: [pre-push]

58
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,58 @@
# DegenFeed Web — Architecture
**Status:** canonical
**Last updated:** 2026-07-06
## Overview
Turborepo monorepo with a Cloudflare Pages-hosted Next.js 15 client, a static landing page, and a Cloudflare Worker edge API. Shared packages provide protocol adapters, feed normalization, identity resolution, UI primitives, and wallet auth.
## Structure
```
apps/
landing/ static landing page at degenfeed.xyz
web/ Next.js 15 aggregator client at app.degenfeed.xyz
packages/
types/ unified protocol types and interfaces
feed-core/ feed normalization, deduplication, and assembly
ranking/ transparent feed scoring algorithms
ui/ shared React components
identity/ cross-protocol identity resolution
auth/ wallet sign-in and session management
storage/ client and edge storage helpers
nostr-sdk/ Nostr protocol adapter
farcaster-sdk/ Farcaster protocol adapter
lens-sdk/ Lens protocol adapter
bluesky-sdk/ Bluesky protocol adapter
mastodon-sdk/ Mastodon/ActivityPub adapter
threads-sdk/ Threads protocol adapter
rss-sdk/ RSS/Atom feed adapter
workers/
api/ Cloudflare Worker: edge proxy + aggregator (degenfeed-api)
infra/
cloudflare/ wrangler configs, Pages project, KV setup
```
## Deployment
| Component | Target | URL |
|-----------|--------|-----|
| Landing page | Cloudflare Pages | `https://degenfeed.xyz` |
| Web client | Cloudflare Pages | `https://app.degenfeed.xyz` |
| Edge API | Cloudflare Worker `degenfeed-api` | `https://degenfeed.xyz/api/*` and `https://app.degenfeed.xyz/api/*` |
| Social server | GotoSocial (separate repo/deploy) | `https://social.degenfeed.xyz` |
The deployed DegenFeed social server is **GotoSocial**, not Mastodon. It runs from `/srv/degenfeed/` on Talos and is independent of this repo.
## Key flows
1. **Feed aggregation**`workers/api` fetches and normalizes posts from configured protocols via the shared SDK packages, then serves them to `apps/web`.
2. **News aggregation**`/news` in `apps/web` sources headlines from RSS and protocol feeds using `packages/rss-sdk` and `packages/feed-core`.
3. **Wallet sign-in**`packages/auth` integrates Solana and EVM wallet adapters; sessions are managed client-side and verified at the edge worker.
4. **Composer** — posts drafted in `apps/web` are routed through the relevant protocol SDK and published either directly or via the edge worker proxy.
## Tooling
- **Build orchestration:** Turborepo + pnpm workspaces
- **Lint / format:** Biome
- **Type checking:** TypeScript `--noEmit`
- **Tests:** vitest
- **CI:** GitHub Actions (`.github/workflows/ci.yml`)
- **Pre-commit:** `.pre-commit-config.yaml`
- **Local entry point:** `Makefile`

1
CODEOWNERS Normal file
View file

@ -0,0 +1 @@
* @cryptorugmunch

70
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,70 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes
- Focusing on what is best for the overall community
Examples of unacceptable behavior:
- The use of sexualized language or imagery, and sexual attention or advances
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information without explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
**admin@rugmunch.io**.
All complaints will be reviewed and investigated promptly and fairly. All
community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
[homepage]: https://www.contributor-covenant.org
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
[Mozilla CoC]: https://github.com/mozilla/diversity

107
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,107 @@
<!--
SPDX-License-Identifier: MIT
Copyright (c) 2026 Rug Munch Media LLC
Part of DegenFeed Web — https://git.rugmunch.io/RugMunchMedia/degenfeed-web
-->
# Contributing to DegenFeed Web
> Next.js multi-protocol social aggregator client. TypeScript + Next.js 15 + React 19.
## Repositories
| Platform | URL |
|----------|-----|
| **Primary** | https://git.rugmunch.io/RugMunchMedia/degenfeed-web |
| **GitLab mirror** | https://gitlab.com/cryptorugmuncher/degenfeed-web |
| **Codeberg mirror** | https://codeberg.org/cryptorugmunch/degenfeed-web |
PRs accepted on All platforms. Issues tracked on the primary Forgejo instance.
## TL;DR
```bash
git checkout main && git pull --rebase origin main
git checkout -b feat/my-change
# …make changes…
pnpm install
pnpm lint typecheck test # pre-push gate
git add -A
git commit -m "feat(web): my change"
git push origin feat/my-change
# Open a PR on Forgejo (git.rugmunch.io/RugMunchMedia/degenfeed-web)
```
## Repository Model
Single-tenant branch-PR pattern. `feat/*` branches merged to `main` after CI passes.
### Branches
| Branch | Purpose |
|--------------------|--------------------------------------------------|
| `main` | Production-ready. Protected. Forgejo-mirrored. |
| `feat/<scope>` | New feature or capability. |
| `fix/<scope>` | Bug fix. |
| `chore/<scope>` | Tooling, deps, formatting, non-functional. |
| `refactor/<scope>` | Code restructure, no behavior change. |
| `docs/<scope>` | Documentation only. |
### Commit Convention
We use **Conventional Commits**.
```bash
feat(web): add composer modal
fix(adapter-nostr): handle malformed event
docs: update ARCHITECTURE.md
chore(deps): bump wagmi to 3.7
```
Allowed types: `feat`, `fix`, `docs`, `refactor`, `style`, `test`, `chore`,
`ops`, `security`, `ci`, `build`, `perf`, `revert`.
## Remotes
| Remote | URL | Role |
|-------------|------------------------------------------------------------------|-------------------------------|
| `origin` | `ssh://git@git.rugmunch.io:2222/RugMunchMedia/degenfeed-web.git` | Canonical — Forgejo. |
| `external-*`| codeberg / github / gitlab (read-only mirrors) | Optional community mirrors. |
## Pre-Push Checklist
Before pushing any branch:
```bash
pnpm lint # biome check
pnpm typecheck # tsc --noEmit
pnpm test # turbo run test
pnpm build # production build sanity
```
CI is defined in `.forgejo/workflows/ci.yml`.
## Code Style
- TypeScript 5.5+ with `strict: true`
- Biome for format + lint (no ESLint, no Prettier)
- Functional components, hooks, no class components
- One component per file
- No file > 500 lines (see `STANDARDS.md`)
## License
By contributing, you agree your work is licensed under the MIT License.
See [LICENSE](LICENSE).
## Code of Conduct
Be precise, be kind. Cite `file:line` when reviewing. Prefer surgical commits.
## Further Reading
- `AGENTS.md` — repo contract, owners, commands
- `ARCHITECTURE.md` — module layout, data flow
- `PRODUCT.md` — what this repo is and isn't
- `../standards/CONVENTIONS.md` — fleet-wide conventions
- `../standards/TOOLCHAIN.md` — tool inventory

154
DEPLOYMENT.md Normal file
View file

@ -0,0 +1,154 @@
# Deployment
DegenFeed Web deploys to Cloudflare: a single Worker (`degenfeed-api`)
serves the API and the main `degenfeed.xyz` HTML routes, with the
Next.js app running on Cloudflare Pages (`degenfeed-web`). This doc
covers first-time setup and ongoing deploy workflow.
## First-time setup
### 1. Cloudflare account + zone
- Create a Cloudflare account.
- Add the `degenfeed.xyz` zone (if not already added).
- Note the **Account ID** and **Zone ID**.
### 2. Create KV namespaces
The worker uses two KV namespaces (`CACHE`, `SUBSCRIPTIONS`).
Create them once with the `preview` ID variant for PR previews:
```sh
cd workers/api
wrangler kv namespace create CACHE
wrangler kv namespace create CACHE --preview
wrangler kv namespace create SUBSCRIPTIONS
wrangler kv namespace create SUBSCRIPTIONS --preview
```
Copy the returned IDs into `workers/api/wrangler.toml`. The placeholders
`cache-namespace` / `subs-namespace` must be replaced before deploying,
otherwise the worker will fail to bind KV at startup.
### 3. Set secrets
Two secrets must exist on each environment (production + preview):
```sh
# Production
wrangler secret put AUTH_SECRET --env production
wrangler secret put GTS_CLIENT_SECRET --env production
# Preview
wrangler secret put AUTH_SECRET --env preview
wrangler secret put GTS_CLIENT_SECRET --env preview
```
`AUTH_SECRET` is a random 32-byte hex string used to HMAC-sign session
cookies. Generate one with:
```sh
openssl rand -hex 32
```
`GTS_CLIENT_SECRET` comes from the GotoSocial OAuth app configured at
`social.degenfeed.xyz/admin/oauth-apps`. It is used to exchange
authorization codes for access tokens.
Both secrets live only on the Cloudflare side — they are never
committed to git or printed in CI logs.
### 4. Set GitHub repository secrets
`Settings → Secrets and variables → Actions → New repository secret`:
| Name | Purpose |
|--------------------------|---------------------------------------------|
| `CLOUDFLARE_API_TOKEN` | Cloudflare API token with `Workers Scripts:Edit` and `Workers KV Storage:Edit` scopes. |
| `CLOUDFLARE_ACCOUNT_ID` | 32-hex Cloudflare account id. |
Create the API token at `dash.cloudflare.com → My Profile → API Tokens
→ Create Token → Edit Cloudflare Workers template`.
## Deploy workflow
Two GitHub Actions workflows exist:
| File | Triggers on | What it does |
|-----------------------------------|--------------------------|---------------------------------------|
| `.github/workflows/ci.yml` | Every push, every PR | Lint + typecheck + test + build + gitleaks. |
| `.github/workflows/deploy.yml` | PRs (preview) + main push (production) | Deploys the worker to a Cloudflare environment. |
PRs get a `preview` environment with `--env preview`, which uses the
preview KV IDs and a relaxed rate limit. `main` deploys to production
with the routes defined in `wrangler.toml` (currently
`degenfeed.xyz/api/*`, `app.degenfeed.xyz/api/*`, `degenfeed.xyz/*`).
## Local verification
Before pushing, verify locally with `wrangler dev`:
```sh
cd workers/api
# Local KV emulation requires --local (uses miniflare SQLite under .wrangler/)
wrangler dev --local
```
The dev server starts on `http://localhost:8787`. KV calls work against
the local SQLite-backed store. Hit a few endpoints:
```sh
curl http://localhost:8787/api/feed/health
curl -X POST http://localhost:8787/api/preferences \
-H 'content-type: application/json' \
-H 'origin: http://localhost:3000' \
-d '{"hideLowQuality":true}'
```
Note: `AUTH_SECRET` is not set locally — session-protected routes will
return 401. To test them locally, set the secret via `.dev.vars`:
```sh
echo 'AUTH_SECRET=local-dev-secret-not-for-production-32-bytes' > workers/api/.dev.vars
echo 'GTS_CLIENT_SECRET=unused-locally' >> workers/api/.dev.vars
```
`wrangler dev` reads `.dev.vars` automatically.
## Adding the Next.js Pages deploy
`apps/web/wrangler.toml` is a stub. The full Cloudflare Pages deploy is
out of scope for this iteration (the landing HTML at `apps/landing/`
covers the `degenfeed.xyz` root today). When the Next.js app is ready
to ship, add a Pages job to `.github/workflows/deploy.yml`:
```yaml
pages:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm --filter degenfeed-web build
- uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: degenfeed-web
directory: apps/web/.vercel/output/static
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
```
## Rollback
Cloudflare keeps the previous 100 versions of every Worker. To roll
back:
```sh
wrangler rollback --env production
```
Or via the Cloudflare dashboard: `Workers & Pages → degenfeed-api →
Deployments → Rollback`.

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Rug Munch Media LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

59
Makefile Normal file
View file

@ -0,0 +1,59 @@
# ── Makefile (TypeScript / pnpm / Turborepo) ───────────────────
# Source of truth: standards/templates/Makefile.typescript.template
# Customized for degenfeed-web monorepo.
.PHONY: help install precommit dev build lint format check typecheck test test-watch test-cov security ci audit clean
help: ## Show this help. Usage: make <target>
@awk 'BEGIN {FS = ":.*?## "; printf "\nUsage: make <target>\n\nTargets:\n"} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
install: ## Install JS dependencies with pnpm (frozen lockfile)
pnpm install --frozen-lockfile
precommit: ## Install pre-commit hooks
pre-commit install
pre-commit install --hook-type pre-push
dev: ## Run dev servers via Turbo
pnpm dev
build: ## Build all packages/apps via Turbo
pnpm build
lint: ## Lint all packages/apps via Turbo (Biome)
pnpm lint
format: ## Format all files with Biome
pnpm format
check: ## Fast check: lint + typecheck
pnpm lint
pnpm typecheck
typecheck: ## TypeScript type check across monorepo
pnpm typecheck
test: ## Run unit tests via Turbo (vitest)
pnpm test
test-watch: ## Run tests in watch mode
pnpm exec vitest
test-cov: ## Run tests with coverage
pnpm exec vitest run --coverage
security: ## Security audit with gitleaks
gitleaks detect --source . --no-banner
ci: ## Full CI pipeline (lint + typecheck + test + build)
pnpm lint
pnpm typecheck
pnpm test
pnpm build
audit: ## Dependency audit with pnpm
pnpm audit
clean: ## Remove build artifacts + dependencies
rm -rf dist .next .vite .turbo .biome-cache coverage
find . -type d -name node_modules -prune -exec rm -rf {} + 2>/dev/null || true

52
PRODUCT.md Normal file
View file

@ -0,0 +1,52 @@
# DegenFeed Web — Product Charter
**Product:** DegenFeed Web Client
**Repo:** git.rugmunch.io/RugMunchMedia/degenfeed-web
**Status:** Live in production
## Problem
Crypto-native users consume content across Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads, and RSS. A single client reduces friction and keeps the community in one place.
## User
- Crypto users who want one aggregated feed
- DegenFeed community members
- Content creators cross-posting across protocols
## Scope
- Next.js 15 web client deployed to Cloudflare Pages
- Protocol adapters: Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads, RSS
- Unified feed normalization, deduplication, and ranking
- Cross-protocol identity resolution
- Wallet sign-in (Solana + EVM)
- News aggregation (`/news`)
- Cloudflare Worker edge API (`workers/api/`)
- Static landing page at `degenfeed.xyz`
## Anti-scope
- This repo does NOT run the DegenFeed GotoSocial server
- No backend database; protocol-native storage and edge KV only
- No token scanning; that lives in rmi-backend
- No wallet generation; that lives in walletpress
## Success criteria
- `apps/web` deploys and renders a unified feed at `https://app.degenfeed.xyz`
- Composer publishes to all configured protocols
- Wallet sign-in works for Solana and EVM wallets
- `/news` aggregates and renders protocol + RSS news
- CI green
- No source file greater than 500 lines
## Current reality
- `apps/landing` is live at `https://degenfeed.xyz`
- `apps/web` is live at `https://app.degenfeed.xyz`
- `workers/api` is deployed as Cloudflare Worker `degenfeed-api`
- CI and pre-commit are configured
- Makefile is the entry point for local development
- `/news` and wallet sign-in are live features
- GotoSocial remains the canonical ActivityPub server at `https://social.degenfeed.xyz`
## Fleet dependencies
- DegenFeed GotoSocial at `https://social.degenfeed.xyz` (separate deployment)
- Cloudflare Pages + Workers for hosting and edge API
- Plausible analytics self-hosted on Talos
- MeiliSearch self-hosted on Talos

44
README.md Normal file
View file

@ -0,0 +1,44 @@
# DegenFeed Web
**Repo:** git.rugmunch.io/RugMunchMedia/degenfeed-web
**Status:** Active development
**Note:** This repo is the Next.js social-aggregator client for Nostr, Farcaster, Lens, and Bluesky. It is NOT the deployed Mastodon instance at degenfeed.xyz.
## What this repo is
A web client that aggregates open social protocols into a single feed and composer.
Planned stack: Next.js 14 App Router, React 19, Tailwind, Radix, Zustand, TanStack Query.
## What this repo is NOT
- The deployed DegenFeed Mastodon server runs at /srv/degenfeed/ container degenfeed-web on port 4002
- A backend API. Protocol adapters live in packages/.
## Status
- apps/landing is a static landing page live at degenfeed.xyz
- apps/web is scaffolding; not deployed
- This repo currently has no health endpoint because apps/web is not running
## Quick start when apps/web exists
npm install
npm run dev
## Deployed Mastodon separate
ssh netcup
cd /srv/degenfeed/
docker compose ps
## Current issues
- No PRODUCT/ARCHITECTURE/ROADMAP/TODO docs now added
- No pre-commit config
- No Makefile
- No CI
- Repo README conflated this client with the Mastodon deployment
## Standards
This repo follows Rug Munch Media standards:
https://git.rugmunch.io/RugMunchMedia/standards
## Docs
- PRODUCT.md — charter
- ARCHITECTURE.md — packages and protocol adapters
- ROADMAP.md — next 90 days
- TODO.md — current tasks

43
ROADMAP.md Normal file
View file

@ -0,0 +1,43 @@
# DegenFeed Web — Roadmap
**Next 90 days.**
## Completed
- Upgrade to Next.js 15 + React 19
- Turborepo + pnpm workspace structure (`apps/*`, `packages/*`, `workers/*`)
- Static landing page at `degenfeed.xyz`
- Deploy `apps/web` to Cloudflare Pages at `app.degenfeed.xyz`
- Deploy `workers/api` as Cloudflare Worker `degenfeed-api`
- CI pipeline, pre-commit hooks, and Makefile
- Protocol SDK packages: Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads, RSS
- Core packages: types, feed-core, ranking, ui, identity, auth, storage
- `/news` news aggregation
- Wallet sign-in (Solana + EVM)
- **Twitter/X-style timeline at `/twitter`** with inline composer and For You / Following tabs
- New DegenFeed icon/logo wired across app and home page
## Week 1 — Polish and observability
- Add `/health` route to `workers/api` and `apps/web`
- Wire Plausible analytics
- Add error boundaries and loading states in `apps/web`
## Week 2 — Composer and identity
- Cross-protocol composer UI
- Identity resolution UI (link profiles across protocols)
- Publish flow through edge worker
## Week 3 — Feed quality
- Ranking v2 with user signals
- Feed filters and search
- Infinite scroll and virtualization
## Week 4 — Integrations
- MeiliSearch-backed global search
- Notifications via GotoSocial
- RSS ingest improvements
## Acceptance
- `app.degenfeed.xyz` renders a unified, ranked feed
- Composer publishes to at least Nostr + Farcaster + Lens + Bluesky
- CI green on every PR
- `/health` returns 200 on both Pages and Worker

66
SECURITY.md Normal file
View file

@ -0,0 +1,66 @@
# Security Policy
## Reporting a Vulnerability
**Do NOT open a public issue for security bugs.**
Instead, send a detailed report to **security@degenfeed.xyz**.
We take all security reports seriously. Good-faith security researchers are protected under our safe harbor policy.
### What to include
- Description of the vulnerability
- Steps to reproduce
- Affected components and versions
- Any proof-of-concept code (if available)
- Your PGP public key for encrypted follow-up (optional)
### Response SLA
| Severity | Acknowledgment | Patch |
|----------|---------------|-------|
| Critical | 24 hours | 7 days |
| High | 48 hours | 14 days |
| Medium | 5 days | 30 days |
| Low | Next release | Next release |
### Public key
A PGP key for `security@degenfeed.xyz` will be published at `/.well-known/security.txt`.
Currently, report via email without encryption. We will acknowledge within 48 hours.
## Supported Versions
| Version | Supported |
|---------|-----------|
| Latest release | YES |
| All prior versions | NO |
Only the latest deployed version receives security patches.
## Scope
- `degenfeed-web` monorepo (all packages and apps)
- Cloudflare Worker (`degenfeed-api`)
- `social.degenfeed.xyz` (GotoSocial instance)
- `degenfeed.xyz` (Cloudflare Pages)
Out of scope: third-party protocol APIs (Nostr relays, Farcaster hubs, Bluesky PDS, etc.).
## Safe Harbor
We will not pursue legal action against security researchers who:
- Make a good-faith effort to avoid privacy violations and data destruction
- Give us reasonable time to fix the issue before disclosing
- Do not exploit the vulnerability beyond what is necessary to demonstrate it
## Disclosure Policy
We follow coordinated disclosure. After a fix is deployed, we will:
1. Publish an advisory with CVE (if applicable)
2. Credit the researcher (unless they prefer anonymity)
3. Update this document with any new guidance

39
STATUS.md Normal file
View file

@ -0,0 +1,39 @@
# DegenFeed Web — Status
> Last updated: 2026-07-07
## Summary
Follow-up cleanup: replaced `alert()` with a `ToastProvider` in `AuthProvider`, made the `/twitter` notification bell show for any signed-in user (not just wallet), wired the Mastodon → Nostr bridge into both the inline `TwitterComposer` and the global `ComposeModal` via a new `lib/nostr` helper that signs locally with the saved Nostr key, added a `/api/preferences` endpoint backed by KV so `hideLowQuality` persists across devices, and wrote unit tests for the preferences sanitizer.
`make ci` and `make security` pass.
## Current Sprint
Rebuild DegenFeed Web into a usable web3-first social aggregator (TODO.md #115).
## Completed This Session
- `apps/web/src/components/Toast.tsx` — toast provider + viewport (success/error/warning), auto-dismiss
- `apps/web/src/app/layout.tsx` — wired `ToastProvider` around `AuthProvider`
- `apps/web/src/components/AuthProvider.tsx` — replaced `alert()` with toast, added `signOut` toast, added `nostrPublicId` to context
- `apps/web/src/app/twitter/page.tsx` — bell+sign-out now gated on `hasSignedIn`, not `walletAddress`
- `apps/web/src/components/TwitterComposer.tsx` — added Mastodon→Nostr bridge toggle + state
- `apps/web/src/lib/nostr.ts` — new client-side Nostr publisher that decrypts saved nsec with `auto-unlock-passphrase` and publishes to relays
- `packages/ui/src/ComposeModal.tsx` — same bridge toggle; `nostrBridgePublicId` + `onNostrBridge` props
- `workers/api/src/routes/preferences.ts` — new GET/POST `/api/preferences` route, requires session
- `apps/web/src/app/twitter/page.tsx``hideLowQuality` toggle now persists to backend KV in addition to localStorage
- `workers/api/src/__tests__/preferences.test.ts` — 5 endpoint tests
- `workers/api/src/__tests__/preferences.sanitize.test.ts` — 6 unit tests for the sanitizer
- `workers/api/src/routes/preferences.ts` — sanitizer now lowercases+trims niches (caught by tests)
## Quality Gates
- `make check`
- `make ci` ✅ (lint, typecheck, test, build)
- `make security` ✅ (gitleaks)
## Next
1. Mobile bottom-nav (#12) and search overlay (#14) — quick UI wins.
2. Status page (#13) — surface `/api/feed/health`.
3. Tune `hideLowQuality` threshold and relevance filter for non-crypto users.
4. Newsletter cron + email delivery (#15).
## Blocked
None.

115
TODO.md Normal file
View file

@ -0,0 +1,115 @@
# DegenFeed Web — TODO
## Rebuild Sprint: Make DegenFeed a Real Web3 Aggregator
> The current app is functionally broken for users. The backend exists and is mostly wired, but feeds feel empty, cache is stale, the frontend does not look or behave like a modern web3 social app, and the newsletter does not actually pull from the protocols it advertises. This sprint is the hard reset.
### Critical fixes (this session)
- [x] Fix `/api/newsletter/digest` so it fetches from each selected protocol (Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads) instead of only RMI + RSS
- [x] Fix `GTS_AUTH_CONFIG.apiBase` default (`http://127.0.0.1:8082``https://social.degenfeed.xyz`)
- [x] Add `noCacheJsonResponse` helper and disable stale caching for `/api/feed/news` and `/api/newsletter/digest`
- [x] Add `cache: 'no-store'` to client-side fetches in `/news`, `/newsletter`, and `/home/[section]`
- [ ] Run `make ci` and `make security` and fix regressions
- [ ] Smoke-test `/newsletter`, `/news`, and `/home/nostr` in local dev / wrangler dev
### 15 Next Moves (ranked by impact)
1. **Make `/home` per-protocol sections actually work**
Ensure `/home/nostr`, `/home/farcaster`, `/home/bluesky`, `/home/lens`, `/home/mastodon`, `/home/threads`, and `/home/reddit` each call the correct provider endpoint and render a real feed with loading, error, and empty states. Fix `/home/mastodon` to use `/api/feed/home?protocols=mastodon` (or keep `/api/feed/activitypub` but wire it to the deployed GotoSocial instance).
2. **Rebuild the landing experience**
The current app looks like a generic dashboard. Design a web3-first `/twitter` timeline with: compact PostCards, hover actions, quote/repost counts, protocol badges, embedded link previews, and image lightbox. Copy the best parts of Warpcast / Bluesky / X, not a Bootstrap card layout.
3. **Fix the empty-state problem everywhere**
Every feed currently shows an empty card with no personality. Add protocol-specific empty states: "No Nostr notes found — try a different relay", "Mastodon timeline is quiet — create the first post @social.degenfeed.xyz", etc., with one-click fixes (switch relay, create account, sign in).
4. **Add real-time / polling updates**
The news page polls every 60s for new stories but other timelines do not. Add a global `useFeedPoller` hook that shows a "New posts" banner and lets users pull-to-refresh on mobile and click-to-refresh on desktop.
5. **Replace the RMI hard dependency with a resilient pipeline**
RMI is unreachable from Cloudflare edge. The feed should always fall back to public sources without the user noticing. Remove all user-facing "RMI DataBus" copy until the backend is actually deployed and reachable from the edge.
6. **Harden the Mastodon/GotoSocial integration**
Our own instance is the most important protocol. Add a dedicated GotoSocial provider path that reads the public timeline from `https://social.degenfeed.xyz/api/v1/timelines/public`, supports hashtag timelines, and lets signed-in users see their home timeline.
7. **Add protocol-aware composer**
The composer now shows all protocols. Signed-in protocols are selectable; unsigned protocols show a lock icon and open sign-in when clicked. Added live publish preview (`ComposePreview`) and per-protocol character-limit warnings that disable the Post button when exceeded. Updated both the inline `TwitterComposer` and the `ComposeModal`.
8. **Implement wallet-first authentication**
Wallet sign-in is now the primary path in the `SignInModal` (Ethereum + Solana buttons at the top). Added `/api/auth/signout` to clear the `__Host-dfid` cookie, added `signOut` to `AuthProvider`, and exposed it in the `/twitter` header and sidebar. `/api/bridge/rss-to-nostr` now requires the session cookie too.
9. **Add a unified notifications stream**
Added `useNotifications` hook that builds auth from all linked identities, polls `POST /api/notifications` every 60s, and persists read state in localStorage. Extended the backend to aggregate notifications from Nostr, Mastodon, and Bluesky. Added the `NotificationBell` to the `/twitter` header and kept it in `/home/[section]`.
10. **Fix ranking and deduplication**
Backend now computes and attaches a per-post score breakdown (recency, engagement, relevance, quality, diversity, source, velocity, reasons). `PostCard` shows a `★` badge with hover tooltip exposing each component. Added a "hide low-quality" toggle in `/twitter` (persisted in localStorage) that filters posts with `score.total < 1.0`. The toggle respects the session-bound `hideLowQuality` preference so it can later be moved to a profile page.
11. **Add cross-posting to the bridge**
Wire `/api/bridge/rss-to-nostr` and a general `/api/publish` to cross-post from Mastodon to Nostr, Farcaster to Bluesky, etc. Start with one working pair (Mastodon → Nostr) and expose it in the composer.
12. **Build a mobile-first responsive layout**
The current layout is desktop-only with a hidden right sidebar. Implement a bottom nav (Home, Search, Notifications, Profile) for mobile, collapsible sidebars, and proper safe-area handling.
13. **Add protocol health dashboard**
`/api/feed/health` exists but is not exposed in the UI. Add a `/status` page (or admin panel) showing each protocol's latency, success rate, and last fetched time so users know when a feed is degraded.
14. **Add search that actually searches**
`/api/search` and `/api/search/identity` are wired. Add a global search bar in the shell that searches posts, identities, and niches across protocols, with filters for protocol and time range.
15. **Ship the newsletter as a real product**
After the digest works, add an actual email-sending path (Postmark / SES) and a cron trigger that builds the daily digest from live data and sends it to subscribers. Add a `/newsletter/manage` page with preferences and unsubscribe.
## Active
- [x] Add lightweight Twitter/X-style timeline (`/twitter`)
- [x] Inline composer with per-protocol toggles, lock icons, and preview
- [x] Wallet-first authentication with sign-out UI
- [x] Unified notifications stream with real bell and unread counts
- [x] Ranking transparency: score badge, hover breakdown, hide-low-quality toggle
- [x] "For You" and "Following" tabs
- [x] Wire logo into Shell and home page
- [x] Backend fixes: Nostr NIP-23 long-form support + HTTP fallback
- [x] Fix newsletter digest to fetch from all selected protocols
- [x] Fix GotoSocial default API base URL
- [x] Add no-cache helpers for dynamic feed endpoints
- [x] Add no-store fetches to client feed pages
- [x] Rebuild PostCard and protocol-aware composer
## Next
- [ ] Cross-posting bridge (#11): Mastodon → Nostr working pair exposed in composer
- [ ] Set `RMI_BACKEND_URL` in `workers/api/wrangler.toml` for production RSS data
- [ ] Harden Reddit fetching against CF Worker blocks (prefer `old.reddit.com` RSS)
- [ ] Harden Threads scraper or reduce curated handle set
- [ ] Cache provider results in `CACHE` KV for 60180s to reduce latency
- [ ] Server-side filter for Following tab instead of client-side filtering
- [ ] Add real-time updates to timeline
- [ ] Add repost/quote preview in composer
- [ ] Improve mobile layout (currently hides right sidebar)
- [ ] Add notification bell to Twitter timeline
- [ ] Fix GotoSocial `trusted-proxies` warning on deployed instance (`social.degenfeed.xyz`)
## Blocked
- None
## Done
- apps/landing live at degenfeed.xyz
- apps/web live at app.degenfeed.xyz
- CI, Makefile, pre-commit configured
- Protocol SDKs, feed-core, ranking, identity packages
- `/news`, wallet sign-in, composer, feed aggregation
- Fixed critical syntax error in `packages/feed-providers/src/providers/nostr.ts`
- Fixed monorepo type errors across utils, ranking, feed-providers, ui, api-worker, web
- Fixed Biome lint errors across packages and worker
- Created `/twitter` route with X-style layout using existing components
- Added `@degenfeed/storage` dependency to `@degenfeed/ui` package
- Created `packages/utils/tsconfig.json`
- Upgraded `@biomejs/biome` in `@degenfeed/api-worker` to match monorepo version
- Replaced `<img>` elements with `next/image` across the web app for performance
- Fixed all remaining Biome lint warnings (unused imports/variables in legacy files)
- Made `/twitter` the default landing page (redirect `/``/twitter`)
- Added DegenFeed logo to the Twitter timeline header
- Fixed RSS/RMI fallback to avoid the 6s timeout when `RMI_BACKEND_URL` is unset
- Fixed default protocols in `/api/feed/home` to exclude `ethereum`/`solana`
- Audited full feed data flow: providers are wired to real endpoints; RMI timeout was the main blocker
- Mocked `@degenfeed/rss-sdk` in `workers/api/src/__tests__/curated.test.ts` to stop flaky network timeouts
- Added `.gitleaks.toml` allowlist for generated `.vercel` build tokens and marked `GTS_CLIENT_ID` as allowed; `make security` now passes

12
apps/landing/README.md Normal file
View file

@ -0,0 +1,12 @@
# @degenfeed/landing
Static landing page. Pure HTML/CSS/JS — no build step.
Deploy target: Cloudflare Pages.
```
apps/landing/
index.html
styles.css
app.js
```

3
apps/landing/_redirects Normal file
View file

@ -0,0 +1,3 @@
# Cloudflare Pages _redirects for DegenFeed landing
# Serve index.html at root, no infinite loops
/ /index.html 200

73
apps/landing/app.js Normal file
View file

@ -0,0 +1,73 @@
// DegenFeed landing — pure JS, no framework.
// Handles email notify form submissions and tracks goal events locally.
(() => {
// Endpoint placeholder — wire up to Cloudflare Worker / Turnstile / KV later.
// For now we just log locally and show success so we can validate the form UX.
const ENDPOINT = '/api/notify'; // future: real subscribe endpoint
function setStatus(form, message, kind) {
const help = form.querySelector('.notify-help');
if (!help) return;
help.textContent = message;
help.classList.remove('success', 'error');
if (kind) help.classList.add(kind);
}
function isValidEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
async function submit(form) {
const input = form.querySelector('input[type="email"]');
const email = (input?.value || '').trim();
if (!email) {
setStatus(form, 'Please enter your email.', 'error');
input?.focus();
return;
}
if (!isValidEmail(email)) {
setStatus(form, "That email doesn't look right.", 'error');
input?.focus();
return;
}
const submitBtn = form.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.dataset.original = submitBtn.textContent;
submitBtn.textContent = 'Sending…';
}
try {
// Try the endpoint; fall back to local acknowledgment if 404 (not built yet).
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, source: 'landing' }),
});
if (res.ok) {
setStatus(form, "Got it. We'll email you at launch.", 'success');
input.value = '';
} else {
setStatus(form, 'Saved locally. Will be queued when endpoint ships.', 'success');
}
} catch (_err) {
setStatus(form, 'Saved locally. Will be queued when endpoint ships.', 'success');
} finally {
if (submitBtn) {
submitBtn.disabled = false;
if (submitBtn.dataset.original) submitBtn.textContent = submitBtn.dataset.original;
}
}
}
document.addEventListener('DOMContentLoaded', () => {
for (const form of document.querySelectorAll('form.notify-form')) {
form.addEventListener('submit', (e) => {
e.preventDefault();
submit(form);
});
}
});
})();

282
apps/landing/index.html Normal file
View file

@ -0,0 +1,282 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<meta name="color-scheme" content="dark" />
<title>DegenFeed — The social feed of web3</title>
<meta name="description" content="Browse Nostr, Farcaster, Lens, Bluesky, Threads, Mastodon, Reddit, and RSS from one window. Zero sign-ups required. Sign in with one key to like and reply. Compose once, cross-post everywhere." />
<meta property="og:title" content="DegenFeed — The social feed of web3" />
<meta property="og:description" content="Browse 7 protocols + RSS from one window. Zero sign-ups. One composer, all networks." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://degenfeed.xyz" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23000'/%3E%3Ctext x='50%25' y='50%25' dy='.35em' text-anchor='middle' fill='%2300ff41' font-family='ui-monospace,monospace' font-size='20' font-weight='700'%3ED%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="topbar" role="banner">
<a href="/" class="brand" aria-label="DegenFeed home">
<span class="brand-mark" aria-hidden="true">&gt;_</span>
<span class="brand-name">DegenFeed</span>
</a>
<nav class="nav" aria-label="Primary">
<a href="#how">How it works</a>
<a href="#protocols">Protocols</a>
<a href="https://social.degenfeed.xyz" target="_blank" rel="noopener">Mastodon</a>
<a href="https://git.rugmunch.io/RugMunchMedia/degenfeed-web" target="_blank" rel="noopener">Source</a>
<a href="https://app.degenfeed.xyz" class="nav-cta">Launch app →</a>
</nav>
</header>
<main id="main">
<!-- Hero -->
<section class="hero" aria-labelledby="hero-title">
<div class="hero-grid" aria-hidden="true"></div>
<div class="hero-inner">
<div class="hero-badges">
<span class="badge badge-green">✦ v0.1 — Now live</span>
<span class="badge badge-gray">7 protocols + RSS</span>
<span class="badge badge-blue">Self-hosted · MIT</span>
</div>
<h1 id="hero-title" class="hero-title">
One window. <span class="accent">Every</span> network.
</h1>
<p class="hero-pitch">
Browse Nostr, Farcaster, Lens, Bluesky, Threads, <strong>Mastodon</strong>, Reddit, and RSS<br />
from a single interface. <strong class="accent">Zero accounts required</strong> to read everything.
</p>
<div class="hero-cta">
<a href="https://app.degenfeed.xyz" class="btn btn-primary btn-large">Open the feed →</a>
<a href="#how" class="btn btn-secondary">How it works</a>
</div>
<!-- Protocol runways -->
<div class="protocol-strip" id="protocols">
<div class="ps-item" style="--pcolor: #8e30eb"><span class="ps-dot"></span>Nostr</div>
<div class="ps-item" style="--pcolor: #8a63d2"><span class="ps-dot"></span>Farcaster</div>
<div class="ps-item" style="--pcolor: #abfe2c"><span class="ps-dot"></span>Lens</div>
<div class="ps-item" style="--pcolor: #1185fe"><span class="ps-dot"></span>Bluesky</div>
<div class="ps-item" style="--pcolor: #888"><span class="ps-dot"></span>Threads</div>
<div class="ps-item" style="--pcolor: #6364ff"><span class="ps-dot"></span>Mastodon</div>
<div class="ps-item" style="--pcolor: #ff4500"><span class="ps-dot"></span>Reddit</div>
<div class="ps-item" style="--pcolor: #ffa500"><span class="ps-dot"></span>RSS</div>
</div>
<!-- Stats row -->
<div class="stats-row">
<div class="stat">
<span class="stat-value">7</span>
<span class="stat-label">Protocols</span>
</div>
<div class="stat">
<span class="stat-value">150+</span>
<span class="stat-label">RSS sources</span>
</div>
<div class="stat">
<span class="stat-value">10M+</span>
<span class="stat-label">Reachable users</span>
</div>
<div class="stat">
<span class="stat-value"></span>
<span class="stat-label">Free reads</span>
</div>
</div>
</div>
</section>
<!-- How it works -->
<section class="how" id="how" aria-labelledby="how-title">
<h2 id="how-title" class="section-title">
<span class="bracket" aria-hidden="true">[</span>
How it works
<span class="bracket" aria-hidden="true">]</span>
</h2>
<div class="how-grid">
<article class="card">
<div class="card-num" aria-hidden="true">01</div>
<h3>Open the app. See everything.</h3>
<p>No sign-up, no wallet, no key. All 7 protocols and RSS feeds load immediately. <strong>Zero friction.</strong></p>
</article>
<article class="card">
<div class="card-num" aria-hidden="true">02</div>
<h3>Connect your identity.</h3>
<p>Nostr key, Farcaster signer, Lens wallet, Bluesky password, Mastodon token — whatever you have. Sign in to like, reply, repost natively on that network. Your key <strong>never leaves your browser.</strong></p>
</article>
<article class="card">
<div class="card-num" aria-hidden="true">03</div>
<h3>Cross-post everywhere.</h3>
<p>Link multiple accounts and compose once. DegenFeed publishes the same post to Nostr, Farcaster, Lens, Bluesky, Threads, and Mastodon in parallel. Partial success? Failed networks retry in the background.</p>
</article>
<article class="card">
<div class="card-num" aria-hidden="true">04</div>
<h3>Ranked, transparent, yours.</h3>
<p>Every post is scored with a public algorithm — recency, engagement, source quality, niche relevance. No black box. You can override any weight.</p>
</article>
</div>
</section>
<!-- What we are -->
<section class="what" aria-labelledby="what-title">
<h2 id="what-title" class="section-title">
<span class="bracket" aria-hidden="true">[</span>
What we are
<span class="bracket" aria-hidden="true">]</span>
</h2>
<div class="what-grid">
<div class="what-card">
<div class="what-icon"></div>
<h3>A client, not a network</h3>
<p>We don't own your data. We don't host your posts. DegenFeed is a window onto the existing open social graph — we read from public APIs and sign actions with your keys.</p>
</div>
<div class="what-card">
<div class="what-icon">🐘</div>
<h3>Mastodon-compatible server</h3>
<p>We run a <a href="https://social.degenfeed.xyz" target="_blank" rel="noopener">GotoSocial instance</a> at <code>social.degenfeed.xyz</code>. <a href="https://social.degenfeed.xyz" target="_blank" rel="noopener" style="color:#6364ff;text-decoration:underline">Create a free @degenfeed.xyz account</a> in 30 seconds. Username + password, no email required. Your posts federate with the wider fediverse automatically.</p>
</div>
<div class="what-card">
<div class="what-icon"></div>
<h3>Open source & free</h3>
<p>MIT license. Self-hostable. Public API. No tokens. No ads. Operating cost at scale is dinner money — <strong>we can run this forever without charging.</strong></p>
</div>
</div>
</section>
<!-- Protocol details -->
<section class="protocol-details" aria-labelledby="pd-title">
<h2 id="pd-title" class="section-title">
<span class="bracket" aria-hidden="true">[</span>
7 protocols, 1 feed
<span class="bracket" aria-hidden="true">]</span>
</h2>
<div class="pd-grid">
<article class="pd-card" style="--pcolor:#8e30eb">
<div class="pd-icon"></div>
<h3>Nostr</h3>
<p class="pd-desc">Decentralized relay-based notes. Keys, not accounts.</p>
<span class="pd-stat">500K+ pubkeys</span>
</article>
<article class="pd-card" style="--pcolor:#8a63d2">
<div class="pd-icon"></div>
<h3>Farcaster</h3>
<p class="pd-desc">Sufficiently decentralized social with hubs.</p>
<span class="pd-stat">700K+ FIDs</span>
</article>
<article class="pd-card" style="--pcolor:#abfe2c">
<div class="pd-icon"></div>
<h3>Lens</h3>
<p class="pd-desc">User-owned social graph on Polygon.</p>
<span class="pd-stat">100K+ profiles</span>
</article>
<article class="pd-card" style="--pcolor:#1185fe">
<div class="pd-icon"></div>
<h3>Bluesky</h3>
<p class="pd-desc">AT Protocol social. Public AppView API.</p>
<span class="pd-stat">5M+ users</span>
</article>
<article class="pd-card" style="--pcolor:#888">
<div class="pd-icon">+</div>
<h3>Threads</h3>
<p class="pd-desc">Instagram's AT Protocol network. Free reads.</p>
<span class="pd-stat">200M+ reachable</span>
</article>
<article class="pd-card" style="--pcolor:#6364ff">
<div class="pd-icon">M</div>
<h3>Mastodon</h3>
<p class="pd-desc">Self-hosted GotoSocial. Create a free @degenfeed.xyz account in 30 seconds.</p>
<span class="pd-stat"><a href="https://social.degenfeed.xyz" target="_blank" rel="noopener" style="color:#6364ff;text-decoration:underline">Create account →</a></span>
</article>
<article class="pd-card" style="--pcolor:#ff4500">
<div class="pd-icon">R</div>
<h3>Reddit</h3>
<p class="pd-desc">Free RSS feeds. No API key required.</p>
<span class="pd-stat">1000+ subs</span>
</article>
<article class="pd-card" style="--pcolor:#ffa500">
<div class="pd-icon">A</div>
<h3>RSS + Articles</h3>
<p class="pd-desc">Mirror, Substack, Medium, Paragraph, and any feed URL.</p>
<span class="pd-stat">150+ sources</span>
</article>
</div>
</section>
<!-- Architecture stack -->
<section class="stack" aria-labelledby="stack-title">
<h2 id="stack-title" class="section-title">
<span class="bracket" aria-hidden="true">[</span>
Built with
<span class="bracket" aria-hidden="true">]</span>
</h2>
<div class="stack-grid">
<span class="stack-item">TypeScript</span>
<span class="stack-item">Next.js 15</span>
<span class="stack-item">Cloudflare Workers</span>
<span class="stack-item">Cloudflare Pages</span>
<span class="stack-item">GotoSocial</span>
<span class="stack-item">SQLite</span>
<span class="stack-item">Tailwind</span>
<span class="stack-item">pnpm monorepo</span>
<span class="stack-item">RMI DataBus</span>
<span class="stack-item">IndexedDB</span>
<span class="stack-item">16 SDK packages</span>
<span class="stack-item">16 test suites</span>
</div>
</section>
<!-- Manifesto -->
<section class="manifesto" aria-labelledby="manifesto-title">
<h2 id="manifesto-title" class="section-title">
<span class="bracket" aria-hidden="true">[</span>
Why this exists
<span class="bracket" aria-hidden="true">]</span>
</h2>
<div class="manifesto-content">
<p>The open social graph is fractured across a dozen protocols, each with its own app, its own feed, its own set of accounts to manage.</p>
<p><strong>DegenFeed is the window that sees them all.</strong></p>
<p>One UI. One composer. One ranked feed. 7 protocols. 150+ RSS sources. Zero accounts required to read. Sign in with what you already have to write.</p>
<p class="manifesto-tagline">The graph belongs to the protocols. The window belongs to you.</p>
</div>
</section>
<!-- Final CTA -->
<section class="cta-final" id="launch" aria-labelledby="cta-title">
<div class="cta-card">
<h2 id="cta-title" class="cta-title">Ready to use it?</h2>
<p class="cta-sub">The feed is live. No waitlist. No tokens. No sign-up required.</p>
<div class="cta-buttons">
<a href="https://app.degenfeed.xyz" class="btn btn-primary btn-large">Open feed →</a>
<a href="https://social.degenfeed.xyz" target="_blank" rel="noopener" class="btn btn-secondary">Create @degenfeed.xyz account</a>
</div>
</div>
</section>
</main>
<footer class="footer" role="contentinfo">
<div class="footer-inner">
<div class="footer-brand">
<span class="brand-mark" aria-hidden="true">&gt;_</span>
<span>DegenFeed</span>
</div>
<p class="footer-tag">The social feed of web3.</p>
<p class="footer-meta">Built by <strong>Rug Munch Media LLC</strong> · Open source (MIT) · Hosted on Cloudflare</p>
<nav class="footer-links" aria-label="Footer">
<a href="https://git.rugmunch.io/RugMunchMedia/degenfeed-web" target="_blank" rel="noopener">Forgejo</a>
<span aria-hidden="true">·</span>
<a href="/docs">Docs</a>
<span aria-hidden="true">·</span>
<a href="/status">Status</a>
<span aria-hidden="true">·</span>
<a href="https://social.degenfeed.xyz" target="_blank" rel="noopener">@cryptorugmunch@degenfeed.xyz</a>
</nav>
</div>
</footer>
<script src="/app.js" defer></script>
</body>
</html>

741
apps/landing/styles.css Normal file
View file

@ -0,0 +1,741 @@
/* DegenFeed Landing page styles
Dark cyberpunk aesthetic. Terminal-inspired accents.
All utilities. Zero framework. */
:root {
--bg: #000000;
--surface: #0a0a0a;
--surface-raised: #111111;
--border: #1a1a1a;
--border-strong: #2a2a2a;
--text: #ededed;
--text-muted: #a1a1aa;
--text-dim: #52525b;
--accent: #00ff41;
--accent-dim: #00cc34;
--accent-glow: rgba(0, 255, 65, 0.08);
--font-mono: ui-monospace, "JetBrains Mono", "Fira Code", Menlo, Monaco, Consolas, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
--radius: 8px;
--radius-lg: 12px;
--max-width: 1100px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-sans);
background: var(--bg);
color: var(--text);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
color: #fff;
}
img {
max-width: 100%;
height: auto;
}
code {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--surface-raised);
padding: 0.1em 0.3em;
border-radius: 3px;
}
ul {
list-style: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.skip-link {
position: absolute;
top: -100px;
left: 0;
z-index: 100;
padding: 8px 16px;
background: var(--accent);
color: #000;
font-weight: 600;
border-radius: 0 0 4px 0;
}
.skip-link:focus {
top: 0;
}
/* Top bar */
.topbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-weight: 700;
font-size: 18px;
}
.brand:hover {
color: var(--accent);
}
.brand-mark {
color: var(--accent);
font-family: var(--font-mono);
}
.nav {
display: flex;
align-items: center;
gap: 20px;
}
.nav a {
color: var(--text-muted);
font-size: 14px;
font-weight: 500;
transition: color 0.15s;
}
.nav a:hover {
color: var(--text);
}
.nav-cta {
background: var(--accent);
color: #000 !important;
padding: 6px 16px;
border-radius: var(--radius);
font-weight: 600 !important;
transition: background 0.15s !important;
}
.nav-cta:hover {
background: var(--accent-dim) !important;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 24px;
border-radius: var(--radius);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
border: none;
}
.btn-primary {
background: var(--accent);
color: #000;
}
.btn-primary:hover {
background: var(--accent-dim);
color: #000;
}
.btn-secondary {
background: var(--surface-raised);
color: var(--text);
border: 1px solid var(--border-strong);
}
.btn-secondary:hover {
border-color: var(--text-dim);
}
.btn-large {
padding: 14px 32px;
font-size: 16px;
border-radius: var(--radius-lg);
}
/* Hero */
.hero {
position: relative;
min-height: 90vh;
display: flex;
align-items: center;
justify-content: center;
padding: 120px 24px 80px;
overflow: hidden;
}
.hero-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
background-size: 60px 60px;
mask-image: radial-gradient(ellipse at center, black 30%, transparent 70%);
-webkit-mask-image: radial-gradient(ellipse at center, black 30%, transparent 70%);
}
.hero-inner {
position: relative;
max-width: 800px;
text-align: center;
}
.hero-badges {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 32px;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.badge-green {
background: rgba(0, 255, 65, 0.1);
color: var(--accent);
border: 1px solid rgba(0, 255, 65, 0.2);
}
.badge-gray {
background: rgba(255, 255, 255, 0.05);
color: var(--text-muted);
border: 1px solid var(--border);
}
.badge-blue {
background: rgba(99, 100, 255, 0.1);
color: #6364ff;
border: 1px solid rgba(99, 100, 255, 0.2);
}
.hero-title {
font-size: clamp(36px, 7vw, 72px);
font-weight: 800;
line-height: 1.1;
margin-bottom: 16px;
letter-spacing: -0.02em;
}
.accent {
color: var(--accent);
}
.hero-pitch {
font-size: clamp(16px, 2.5vw, 22px);
color: var(--text-muted);
margin-bottom: 40px;
line-height: 1.6;
}
.hero-cta {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 48px;
flex-wrap: wrap;
}
/* Protocol strip */
.protocol-strip {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 48px;
flex-wrap: wrap;
}
.ps-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border);
color: var(--text-muted);
transition: all 0.15s;
}
.ps-item:hover {
border-color: var(--pcolor);
color: var(--pcolor);
}
.ps-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--pcolor);
}
/* Stats */
.stats-row {
display: flex;
justify-content: center;
gap: 40px;
flex-wrap: wrap;
}
.stat {
text-align: center;
}
.stat-value {
display: block;
font-size: 28px;
font-weight: 800;
color: var(--accent);
font-family: var(--font-mono);
}
.stat-label {
font-size: 12px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
}
/* Section titles */
.section-title {
text-align: center;
font-size: clamp(20px, 3vw, 32px);
font-weight: 700;
margin-bottom: 48px;
font-family: var(--font-mono);
}
.bracket {
color: var(--text-dim);
}
/* How it works */
.how {
padding: 100px 24px;
max-width: var(--max-width);
margin: 0 auto;
}
.how-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 28px;
transition: all 0.2s;
}
.card:hover {
border-color: var(--border-strong);
background: var(--surface);
}
.card-num {
font-size: 32px;
font-weight: 800;
color: var(--accent);
font-family: var(--font-mono);
margin-bottom: 12px;
opacity: 0.6;
}
.card h3 {
font-size: 16px;
font-weight: 700;
margin-bottom: 8px;
}
.card p {
font-size: 14px;
color: var(--text-muted);
line-height: 1.7;
}
/* What we are */
.what {
padding: 100px 24px;
max-width: var(--max-width);
margin: 0 auto;
}
.what-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.what-card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 32px;
text-align: center;
}
.what-icon {
font-size: 36px;
margin-bottom: 16px;
}
.what-card h3 {
font-size: 18px;
font-weight: 700;
margin-bottom: 12px;
}
.what-card p {
font-size: 14px;
color: var(--text-muted);
line-height: 1.7;
}
/* Protocol details */
.protocol-details {
padding: 100px 24px;
max-width: var(--max-width);
margin: 0 auto;
}
.pd-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.pd-card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 24px;
transition: all 0.2s;
border-left: 3px solid var(--pcolor);
}
.pd-card:hover {
border-color: var(--pcolor);
background: var(--surface);
}
.pd-icon {
font-size: 24px;
margin-bottom: 8px;
}
.pd-card h3 {
font-size: 15px;
font-weight: 700;
margin-bottom: 4px;
}
.pd-desc {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 8px;
}
.pd-stat {
font-size: 11px;
color: var(--pcolor);
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Stack */
.stack {
padding: 100px 24px;
max-width: var(--max-width);
margin: 0 auto;
}
.stack-grid {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
.stack-item {
padding: 8px 18px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
background: var(--surface-raised);
border: 1px solid var(--border);
color: var(--text-muted);
transition: all 0.15s;
}
.stack-item:hover {
border-color: var(--accent);
color: var(--accent);
}
/* Manifesto */
.manifesto {
padding: 100px 24px;
max-width: 700px;
margin: 0 auto;
}
.manifesto-content {
text-align: center;
}
.manifesto-content p {
font-size: 16px;
color: var(--text-muted);
margin-bottom: 16px;
line-height: 1.8;
}
.manifesto-content strong {
color: var(--text);
}
.manifesto-tagline {
font-size: 20px !important;
font-weight: 700;
color: var(--accent) !important;
margin-top: 24px !important;
}
/* CTA */
.cta-final {
padding: 100px 24px;
max-width: var(--max-width);
margin: 0 auto;
}
.cta-card {
background: linear-gradient(135deg, var(--surface-raised) 0%, rgba(0, 255, 65, 0.03) 100%);
border: 1px solid var(--accent-glow);
border-radius: var(--radius-lg);
padding: 60px 40px;
text-align: center;
}
.cta-title {
font-size: 32px;
font-weight: 800;
margin-bottom: 8px;
}
.cta-sub {
color: var(--text-muted);
margin-bottom: 32px;
font-size: 16px;
}
.cta-buttons {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
/* Footer */
.footer {
padding: 48px 24px;
border-top: 1px solid var(--border);
text-align: center;
}
.footer-inner {
max-width: 600px;
margin: 0 auto;
}
.footer-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 8px;
}
.footer-tag {
color: var(--text-muted);
margin-bottom: 8px;
}
.footer-meta {
font-size: 13px;
color: var(--text-dim);
margin-bottom: 16px;
}
.footer-links {
display: flex;
justify-content: center;
gap: 10px;
font-size: 13px;
}
.footer-links a {
color: var(--text-dim);
}
.footer-links a:hover {
color: var(--accent);
}
/* Responsive */
@media (max-width: 640px) {
.topbar {
padding: 10px 16px;
}
.nav {
gap: 12px;
}
.nav a:not(.nav-cta) {
display: none;
}
.hero {
padding: 100px 16px 60px;
}
.hero-cta {
flex-direction: column;
align-items: center;
}
.stats-row {
gap: 24px;
}
.cta-card {
padding: 40px 20px;
}
}
/* ─── Additional polish ─────────────────────────────────────── */
/* Smoother card hover */
.card:hover {
transform: translateY(-1px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
}
/* Protocol pill glow on hover */
.ps-item:hover .ps-dot {
box-shadow: 0 0 12px var(--pcolor);
}
/* PD card left border animation */
.pd-card {
transition:
all 0.25s ease,
border-left-width 0.15s ease;
}
.pd-card:hover {
border-left-width: 4px;
padding-left: 23px;
}
/* Better stat numbers */
.stat-value {
background: linear-gradient(135deg, var(--accent), #00ccff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Hero title gradient accent */
.accent {
background: linear-gradient(135deg, #00ff41, #00ccff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Gradient border on CTA card */
.cta-card {
position: relative;
}
.cta-card::before {
content: "";
position: absolute;
inset: -1px;
border-radius: 13px;
background: linear-gradient(135deg, rgba(0, 255, 65, 0.3), rgba(0, 204, 255, 0.1));
z-index: -1;
opacity: 0;
transition: opacity 0.3s;
}
.cta-card:hover::before {
opacity: 1;
}
/* Stack items with gradient */
.stack-item:hover {
background: linear-gradient(135deg, rgba(0, 255, 65, 0.1), rgba(0, 204, 255, 0.05));
}
/* Better what-card */
.what-card {
position: relative;
overflow: hidden;
}
.what-card::after {
content: "";
position: absolute;
top: 0;
right: 0;
width: 100px;
height: 100px;
background: radial-gradient(circle, rgba(0, 255, 65, 0.05), transparent);
border-radius: 50%;
transform: translate(30%, -30%);
}
/* Footer link hover */
.footer-links a {
transition: color 0.15s;
}
.footer-links a:hover {
color: var(--accent);
}
/* Loading animation for skeleton states */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.skeleton {
background: linear-gradient(
90deg,
var(--surface-raised) 25%,
var(--border) 50%,
var(--surface-raised) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
/* Responsive improvements */
@media (max-width: 640px) {
.hero-title {
font-size: 32px;
}
.stats-row {
gap: 16px;
}
.stat-value {
font-size: 22px;
}
.pd-grid {
grid-template-columns: 1fr 1fr;
}
.cta-card {
padding: 32px 20px;
}
}
@media (max-width: 480px) {
.pd-grid {
grid-template-columns: 1fr;
}
.protocol-strip {
gap: 6px;
}
.ps-item {
font-size: 11px;
padding: 4px 10px;
}
}

13
apps/web/.env.example Normal file
View file

@ -0,0 +1,13 @@
# ── DegenFeed Web .env.example ─────────────────────────────────
# Source of truth: standards/templates/env.example.ts.template
# Copy to .env and fill in values for local development.
# DO NOT commit .env — it's in .gitignore.
# ── App URLs ───────────────────────────────────────────────────
# Public URL of this Next.js app (used for OAuth redirects and metadata).
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Base URL of the DegenFeed API worker.
# Note: apps/web/src/app/auth/callback/page.tsx currently reads NEXT_PUBLIC_API_URL.
NEXT_PUBLIC_API_BASE=http://localhost:8787
NEXT_PUBLIC_API_URL=http://localhost:8787

20
apps/web/README.md Normal file
View file

@ -0,0 +1,20 @@
# @degenfeed/web
Next.js 14 app — DegenFeed client.
Routes:
- `/` — landing page (public, no sign-in)
- `/home` — For You feed (requires sign-in)
- `/following` — chronological from followed identities
- `/discover` — trending + niches + curated accounts
- `/compose` — compose modal (global, also triggered by `c` keyboard)
- `/[protocol]/[handle]` — unified profile
- `/post/[id]` — post detail with threaded replies
- `/settings` — identity, sign-in methods, ranking weights (advanced)
- `/docs` — documentation
Stack:
- Next.js 14 App Router, RSC where possible
- Tailwind, Radix Primitives, shadcn-style components
- Zustand for client state, TanStack Query for server cache
- All feed data via `/api/feed/*` worker endpoints

View file

@ -0,0 +1,31 @@
export async function onRequest(context) {
const { request, next } = context;
const url = new URL(request.url);
const { pathname } = url;
if (
pathname.startsWith("/_next/") ||
pathname.startsWith("/api/") ||
pathname.startsWith("/.well-known/") ||
/\.\w+$/.test(pathname)
) {
return next();
}
try {
const response = await next();
if (response.status === 404) {
const index = await context.env.ASSETS.fetch(
new URL("/index.html", request.url)
);
if (index.ok) return index;
}
return response;
} catch {
const index = await context.env.ASSETS.fetch(
new URL("/index.html", request.url)
);
if (index.ok) return index;
return new Response("Not Found", { status: 404 });
}
}

6
apps/web/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

17
apps/web/next.config.mjs Normal file
View file

@ -0,0 +1,17 @@
/** @type {import("next").NextConfig} */
const nextConfig = {
images: { unoptimized: true },
transpilePackages: [
'@degenfeed/types',
'@degenfeed/feed-core',
'@degenfeed/ranking',
'@degenfeed/nostr-sdk',
'@degenfeed/storage',
'@degenfeed/auth',
'@degenfeed/identity',
'@degenfeed/ui',
],
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
};
export default nextConfig;

52
apps/web/package.json Normal file
View file

@ -0,0 +1,52 @@
{
"name": "degenfeed-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "biome check src/",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@degenfeed/auth": "workspace:*",
"@degenfeed/bluesky-sdk": "workspace:*",
"@degenfeed/farcaster-sdk": "workspace:*",
"@degenfeed/feed-core": "workspace:*",
"@degenfeed/identity": "workspace:*",
"@degenfeed/lens-sdk": "workspace:*",
"@degenfeed/nostr-sdk": "workspace:*",
"@degenfeed/ranking": "workspace:*",
"@degenfeed/storage": "workspace:*",
"@degenfeed/types": "workspace:*",
"@degenfeed/ui": "workspace:*",
"@degenfeed/utils": "workspace:*",
"@solana/wallet-adapter-base": "^0.9.27",
"@solana/wallet-adapter-react": "^0.15.39",
"@solana/wallet-adapter-wallets": "^0.19.38",
"@solana/web3.js": "^1.98.4",
"@tanstack/react-query": "^5.101.2",
"@web3modal/wagmi": "^5.1.11",
"dompurify": "^3.2.3",
"next": "^16.2.10",
"qrcode.react": "^4.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"viem": "^2.54.3",
"wagmi": "^3.6.21"
},
"devDependencies": {
"@biomejs/biome": "^2.5.2",
"@cloudflare/next-on-pages": "^1.13.16",
"@types/node": "^20.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.5.0",
"wrangler": "^3.114.17"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#000000"/>
<path d="M8 8h16l-6 16H12L8 8Z" fill="#00ff41" stroke="#00ff41" stroke-width="0.5"/>
<text x="16" y="21" text-anchor="middle" fill="#000000" font-family="ui-monospace, monospace" font-size="10" font-weight="900">DF</text>
</svg>

After

Width:  |  Height:  |  Size: 361 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

48
apps/web/public/icon.svg Normal file
View file

@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="DegenFeed logo">
<title>DegenFeed</title>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0a0a0a"/>
<stop offset="100%" stop-color="#000000"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="6" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<rect width="512" height="512" fill="url(#bg)" rx="64"/>
<g stroke="#00ff41" stroke-width="7" fill="none" stroke-linecap="round" stroke-linejoin="round" filter="url(#glow)">
<rect x="80" y="96" width="352" height="280" rx="22"/>
</g>
<line x1="80" y1="136" x2="432" y2="136" stroke="#00ff41" stroke-width="4"/>
<g fill="#00ff41">
<circle cx="100" cy="116" r="4"/>
<circle cx="118" cy="116" r="4"/>
<circle cx="136" cy="116" r="4"/>
</g>
<g stroke="#00ff41" stroke-width="3" fill="none" stroke-linecap="round">
<path d="M414 110 l-10 8"/>
<path d="M404 126 l10 -8"/>
</g>
<g stroke="#00ff41" stroke-width="5" fill="none" stroke-linecap="round" filter="url(#glow)">
<rect x="200" y="184" width="112" height="104" rx="12"/>
</g>
<g stroke="#00ff41" stroke-width="4" stroke-linecap="round" fill="none" filter="url(#glow)">
<line x1="220" y1="184" x2="220" y2="160"/>
<line x1="244" y1="184" x2="244" y2="160"/>
<line x1="268" y1="184" x2="268" y2="160"/>
<line x1="292" y1="184" x2="292" y2="160"/>
<line x1="220" y1="288" x2="220" y2="312"/>
<line x1="244" y1="288" x2="244" y2="312"/>
<line x1="268" y1="288" x2="268" y2="312"/>
<line x1="292" y1="288" x2="292" y2="312"/>
</g>
<text x="256" y="252" text-anchor="middle" fill="#00ff41" font-family="monospace" font-size="56" font-weight="bold" filter="url(#glow)">#</text>
<g stroke="#00ff41" stroke-width="4" opacity="0.7" filter="url(#glow)">
<line x1="0" y1="236" x2="200" y2="236"/>
<line x1="312" y1="236" x2="512" y2="236"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,16 @@
{
"name": "DegenFeed",
"short_name": "DegenFeed",
"description": "The social feed of web3",
"start_url": "/home",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#00ff41",
"orientation": "portrait",
"categories": ["social", "news", "cryptocurrency"],
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

48
apps/web/public/sw.js Normal file
View file

@ -0,0 +1,48 @@
self.addEventListener('install', function (e) {
e.waitUntil(
caches
.open('degenfeed-v1')
.then(function (c) {
return c.addAll(['/', '/feed', '/home', '/news', '/status']);
})
.catch(function () {}),
);
self.skipWaiting();
});
self.addEventListener('activate', function (e) {
e.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', function (e) {
if (e.request.method !== 'GET') return;
e.respondWith(
caches
.match(e.request)
.then(function (r) {
return (
r ||
fetch(e.request).then(function (res) {
if (res.ok) {
var clone = res.clone();
caches.open('degenfeed-v1').then(function (c) {
return c.put(e.request, clone);
});
}
return res;
})
);
})
.catch(function () {
return caches.match('/feed');
}),
);
});
self.addEventListener('push', function (e) {
var data = (e.data && e.data.json()) || {};
e.waitUntil(
self.registration.showNotification(data.title || 'DegenFeed', {
body: data.body || 'New activity on your feed',
icon: '/icon-192.png',
tag: 'degenfeed',
}),
);
});

View file

@ -0,0 +1,22 @@
import type { Metadata } from 'next';
export const runtime = 'edge';
export default function SecurityPage() {
return new Response(
[
'# DegenFeed security.txt',
'',
'Contact: mailto:admin@rugmunch.io',
'Preferred-Languages: en',
'Canonical: https://app.degenfeed.xyz/.well-known/security.txt',
'Expires: 2027-07-08T00:00:00Z',
'',
'# Security policy: https://git.rugmunch.io/RugMunchMedia/degenfeed-web/raw/branch/main/SECURITY.md',
'# PGP key available on request to security@degenfeed.xyz',
].join('\n'),
{
headers: { 'content-type': 'text/plain; charset=utf-8' },
},
);
}

View file

@ -0,0 +1,82 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About — DegenFeed',
};
export default function AboutPage() {
return (
<div className="mx-auto max-w-2xl px-4 py-8 text-sm leading-relaxed text-gray-300">
<h1 className="mb-6 text-2xl font-bold text-white">About DegenFeed</h1>
<h2 className="mb-2 text-lg font-semibold text-white">1. What is DegenFeed?</h2>
<p className="mb-4">
DegenFeed is a multi-protocol social aggregator for web3. See posts from Nostr, Farcaster,
Lens, Bluesky, Mastodon, Threads, Reddit, and RSS all in one unified feed. Browse the
pulse of crypto social without jumping between platforms. No account needed.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">2. Mission</h2>
<p className="mb-4">
Make web3 social accessible to everyone. Aggregation without lock-in read from any
protocol, post to any protocol, own your identity. We believe in open protocols, not walled
gardens. The future of social media is decentralized, and DegenFeed is your window into it.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">3. Who Built This</h2>
<p className="mb-4">
DegenFeed is a product of <strong className="text-white">Rug Munch Media LLC</strong>, a
Wyoming limited liability company. Built by crypto native engineers who live and breathe
on-chain. This is not a VC- backed startup it is an independent, self-funded project by
people who actually use the protocols they build for.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">4. Open Source</h2>
<p className="mb-4">
DegenFeed is licensed under the{' '}
<strong className="text-white">GNU Affero General Public License v3.0 (AGPL-3.0)</strong>.
Source code is available at{' '}
<a
href="https://git.rugmunch.io/RugMunchMedia/degenfeed-web"
className="text-degen-400 underline"
>
git.rugmunch.io/RugMunchMedia/degenfeed-web
</a>{' '}
and mirrored at{' '}
<a href="https://codeberg.org/degenfeed/degenfeed-web" className="text-degen-400 underline">
codeberg.org/degenfeed/degenfeed-web
</a>
. Contributions are welcome submit pull requests, report bugs, or suggest features.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">5. Infrastructure</h2>
<p className="mb-4">
DegenFeed is hosted on Cloudflare (Pages + Workers) for global edge performance. The social
instance (social.degenfeed.xyz) runs GotoSocial on dedicated hardware. We support 8 protocol
providers with zero tracking and no ads. Fast, clean, and private by design.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">6. Contact</h2>
<ul className="mb-4 list-inside list-disc space-y-1">
<li>
General:{' '}
<a href="mailto:admin@rugmunch.io" className="text-degen-400 underline">
admin@rugmunch.io
</a>
</li>
<li>
Legal:{' '}
<a href="mailto:legal@rugmunch.io" className="text-degen-400 underline">
legal@rugmunch.io
</a>
</li>
<li>
DMCA:{' '}
<a href="mailto:dmca@degenfeed.xyz" className="text-degen-400 underline">
dmca@degenfeed.xyz
</a>
</li>
</ul>
</div>
);
}

View file

@ -0,0 +1,81 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Acceptable Use Policy — DegenFeed',
};
export default function AcceptableUsePage() {
return (
<div className="mx-auto max-w-2xl px-4 py-8 text-sm leading-relaxed text-gray-300">
<h1 className="mb-6 text-2xl font-bold text-white">Acceptable Use Policy</h1>
<p className="mb-4 text-gray-500">Last updated: July 2026</p>
<h2 className="mb-2 text-lg font-semibold text-white">1. No Illegal Activities</h2>
<p className="mb-4">
You may not use DegenFeed to engage in, promote, or facilitate any activity that violates
local, state, federal, or international law. This includes but is not limited to money
laundering, sanctions evasion, unlicensed money transmission, and the sale of controlled
substances.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">2. No Financial Scams</h2>
<p className="mb-4">
The following are strictly prohibited: pump-and-dump coordination, Ponzi or pyramid schemes,
rug pull planning or promotion, fake token launches, phishing links, wallet drainers, fake
airdrops, impersonation of projects or founders for financial gain, and any other activity
designed to defraud or deceive users for monetary benefit.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">3. No Hate Speech</h2>
<p className="mb-4">
Harassment, hate speech, threats of violence, targeted abuse, doxxing, and impersonation are
not tolerated. This includes attacks based on race, ethnicity, religion, gender, sexual
orientation, disability, or any other protected characteristic. Impersonating other users,
public figures, or projects is prohibited.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">4. No Spam</h2>
<p className="mb-4">
Mass posting, excessive self-promotion, link flooding, repetitive content, unsolicited
commercial messages, and bot-driven activity are not allowed. DegenFeed is for genuine human
interaction.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">5. No CSAM / Exploitation</h2>
<p className="mb-4">
Child sexual abuse material (CSAM) and any content that exploits minors is absolutely
prohibited. We maintain a zero-tolerance policy. Any such content will be immediately
removed, the account will be permanently banned, and the incident will be reported to the
National Center for Missing &amp; Exploited Children (NCMEC) and relevant law enforcement
authorities.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">6. AI Disclosure</h2>
<p className="mb-4">
Content generated or substantially assisted by artificial intelligence must be clearly
marked as such. Deepfakes, voice clones, or other synthetic media used for impersonation or
deception are strictly prohibited. Transparency about AI use helps maintain trust in our
community.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">7. Copyright</h2>
<p className="mb-4">
Respect intellectual property rights. Do not post content you do not own or have permission
to share. If you believe your copyright has been infringed, follow our DMCA process by
emailing{' '}
<a href="mailto:dmca@degenfeed.xyz" className="text-degen-400 underline">
dmca@degenfeed.xyz
</a>{' '}
with a complete takedown notice. We respond to valid DMCA notices within 48 hours.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">8. Enforcement</h2>
<p>
We reserve the right to suspend or ban accounts, remove content, limit access to features,
and report violations to law enforcement or relevant authorities. We may also defederate
from instances that violate this policy. Enforcement decisions are made at our sole
discretion but are always applied in good faith to protect the community.
</p>
</div>
);
}

View file

@ -0,0 +1,171 @@
export const runtime = 'edge';
import { NextResponse } from 'next/server';
const TICKER_TO_CG: Record<string, string> = {
BTC: 'bitcoin',
ETH: 'ethereum',
SOL: 'solana',
AVAX: 'avalanche-2',
MATIC: 'matic-network',
DOT: 'polkadot',
LINK: 'chainlink',
ATOM: 'cosmos',
OP: 'optimism',
ARB: 'arbitrum',
PEPE: 'pepe',
DOGE: 'dogecoin',
SHIB: 'shiba-inu',
BONK: 'bonk',
WIF: 'dogwifcoin',
DEGEN: 'degen',
UNI: 'uniswap',
AAVE: 'aave',
MKR: 'maker',
COMP: 'compound',
CRV: 'curve-dao-token',
SUSHI: 'sushi',
CAKE: 'pancakeswap-token',
FTM: 'fantom',
NEAR: 'near',
APT: 'aptos',
SUI: 'sui',
SEI: 'sei-network',
INJ: 'injective-protocol',
RUNE: 'thorchain',
XRP: 'ripple',
ADA: 'cardano',
LTC: 'litecoin',
TRX: 'tron',
FIL: 'filecoin',
};
const TICKER_TO_KR: Record<string, string> = {
BTC: 'XXBTZUSD',
ETH: 'XETHZUSD',
SOL: 'SOLUSD',
XRP: 'XXRPZUSD',
ADA: 'ADAUSD',
DOT: 'DOTUSD',
LINK: 'LINKUSD',
LTC: 'XLTCZUSD',
MATIC: 'MATICUSD',
AVAX: 'AVAXUSD',
TRX: 'TRXUSD',
DOGE: 'XDGUSD',
SHIB: 'SHIBUSD',
UNI: 'UNIUSD',
ATOM: 'ATOMUSD',
};
async function fetchCoingecko(ids: string[]) {
const url = `https://api.coingecko.com/api/v3/simple/price?ids=${ids.join(',')}&vs_currencies=usd&include_24hr_change=true&include_7d_change=true&include_30d_change=true&include_market_cap=true`;
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!res.ok) return null;
return (await res.json()) as Record<string, unknown>;
}
async function fetchKraken(pairs: string[]) {
const url = `https://api.kraken.com/0/public/Ticker?pair=${pairs.join(',')}`;
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!res.ok) return null;
return (await res.json()) as Record<string, unknown>;
}
export async function GET(request: Request) {
const url = new URL(request.url);
const tickersParam = url.searchParams.get('tickers') || '';
const requested = tickersParam
.split(',')
.map((t: string) => t.trim().toUpperCase())
.filter(Boolean);
if (requested.length === 0) {
return NextResponse.json({ prices: {}, error: 'No tickers provided' });
}
const cgIds = [...new Set(requested.map((t) => TICKER_TO_CG[t]).filter((x): x is string => !!x))];
const krPairs = [
...new Set(requested.map((t) => TICKER_TO_KR[t]).filter((x): x is string => !!x)),
];
if (cgIds.length === 0 && krPairs.length === 0) {
return NextResponse.json({ prices: {}, error: 'No known tickers' });
}
const prices: Record<
string,
{
usd?: number;
usd_24h_change?: number;
usd_7d_change?: number;
usd_30d_change?: number;
usd_market_cap?: number;
}
> = {};
let source = '';
try {
if (cgIds.length > 0) {
const cgData = await fetchCoingecko(cgIds);
if (cgData) {
const idToTicker = Object.fromEntries(Object.entries(TICKER_TO_CG).map(([k, v]) => [v, k]));
for (const [id, data] of Object.entries(cgData)) {
const ticker = idToTicker[id];
if (ticker && data) {
const d = data as Record<string, number | undefined>;
prices[ticker] = {
usd: d.usd,
usd_24h_change: d.usd_24h_change,
usd_7d_change: d.usd_7d_change,
usd_30d_change: d.usd_30d_change,
usd_market_cap: d.usd_market_cap,
};
}
}
source = 'coingecko';
}
}
if (Object.keys(prices).length === 0 && krPairs.length > 0) {
const krData = await fetchKraken(krPairs);
if (krData?.result) {
const krakenSymbolMap: Record<string, string> = {
XXBTZUSD: 'BTC',
XETHZUSD: 'ETH',
SOLUSD: 'SOL',
XXRPZUSD: 'XRP',
ADAUSD: 'ADA',
DOTUSD: 'DOT',
LINKUSD: 'LINK',
XLTCZUSD: 'LTC',
MATICUSD: 'MATIC',
AVAXUSD: 'AVAX',
TRXUSD: 'TRX',
XDGUSD: 'DOGE',
SHIBUSD: 'SHIB',
UNIUSD: 'UNI',
ATOMUSD: 'ATOM',
};
for (const [pair, data] of Object.entries(krData.result)) {
const ticker = krakenSymbolMap[pair];
if (ticker && data) {
const tick = data as { c?: [string]; p?: [string, string] };
prices[ticker] = {
usd: Number.parseFloat(tick.c?.[0] ?? '0'),
usd_24h_change: Number.parseFloat(tick.p?.[1] ?? '0'),
};
}
}
source = 'kraken';
}
}
if (Object.keys(prices).length > 0) {
return NextResponse.json({ prices, source });
}
return NextResponse.json({ prices: {}, error: 'All price APIs unavailable' });
} catch (e) {
return NextResponse.json({ prices: {}, error: String(e) });
}
}

View file

@ -0,0 +1,127 @@
export const runtime = 'edge';
export const dynamic = 'force-dynamic';
/**
* DegenFeed Auth Callback Handler
*
* Route: /auth/callback
* Handles OAuth2 redirects from GotoSocial (social.degenfeed.xyz).
* Exchanges the authorization code for an access token via the worker API.
*/
interface AuthCallbackProps {
searchParams: Promise<{ code?: string; error?: string; state?: string }>;
}
interface CallbackProfile {
id?: string;
username?: string;
acct?: string;
displayName?: string;
avatar?: string;
}
interface CallbackData {
accessToken?: string;
profile?: CallbackProfile;
}
export default async function AuthCallbackPage({ searchParams }: AuthCallbackProps) {
const params = await searchParams;
if (params.error) {
return (
<div className="mx-auto max-w-md px-4 py-20 text-center">
<div className="mb-6 text-5xl text-red-400">!</div>
<h1 className="mb-4 text-2xl font-bold text-red-400">Authorization Failed</h1>
<p className="mb-8 text-gray-400">{params.error}</p>
<a href="/home" className="btn-primary inline-block">
Back to DegenFeed
</a>
</div>
);
}
if (!params.code) {
return (
<div className="mx-auto max-w-md px-4 py-20 text-center">
<div className="mb-6 text-5xl text-gray-500">?</div>
<h1 className="mb-4 text-2xl font-bold text-white">No Authorization Code</h1>
<p className="mb-8 text-gray-400">
No authorization code was received. Please try signing in again.
</p>
<a href="/home" className="btn-primary inline-block">
Back to DegenFeed
</a>
</div>
);
}
const apiBase = process.env.NEXT_PUBLIC_API_URL ?? '';
let exchangeOk = false;
let exchangeError = '';
let tokenData: CallbackData = {};
try {
const res = await fetch(`${apiBase}/api/auth/gotosocial/callback`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: params.code }),
});
if (res.ok) {
const data = (await res.json()) as CallbackData;
if (data.accessToken) {
exchangeOk = true;
tokenData = data;
} else {
exchangeError = 'No access token in response';
}
} else {
const err = (await res.json()) as { error?: string };
exchangeError = err.error ?? 'Token exchange failed';
}
} catch (err) {
exchangeError = err instanceof Error ? err.message : 'Network error';
}
if (exchangeOk) {
const pending = JSON.stringify({
protocol: 'mastodon',
accessToken: tokenData.accessToken,
profile: tokenData.profile,
});
return (
<html lang="en">
<body>
<div className="mx-auto max-w-md px-4 py-20 text-center">
<div className="mb-6 text-5xl text-degen-400"></div>
<h1 className="mb-4 text-2xl font-bold text-white">Signed in!</h1>
<p className="mb-8 text-gray-400">Redirecting to your feed...</p>
</div>
<script
// biome-ignore lint/security/noDangerouslySetInnerHtml: server-generated JSON stringified token stored client-side
dangerouslySetInnerHTML={{
__html: `try { localStorage.setItem('degenfeed_pending_identity', ${JSON.stringify(
pending,
)}); localStorage.setItem('degenfeed_auth_updated', Date.now().toString()); } catch(e) { console.error('Token storage failed', e); } window.location.href = '/home';`,
}}
/>
</body>
</html>
);
}
return (
<div className="mx-auto max-w-md px-4 py-20 text-center">
<div className="mb-6 text-5xl text-red-400">!</div>
<h1 className="mb-4 text-2xl font-bold text-red-400">Exchange Failed</h1>
<p className="mb-2 text-gray-400">{exchangeError}</p>
<p className="mb-8 text-sm text-gray-600">Code: {params.code.slice(0, 30)}...</p>
<a href="/home" className="btn-primary inline-block">
Back to DegenFeed
</a>
</div>
);
}

View file

@ -0,0 +1,117 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Community Guidelines — DegenFeed',
};
export default function CommunityGuidelinesPage() {
return (
<div className="mx-auto max-w-2xl px-4 py-8 text-sm leading-relaxed text-gray-300">
<h1 className="mb-2 text-2xl font-bold text-white">Community Guidelines</h1>
<p className="mb-6 text-gray-500">Federated instance rules for social.degenfeed.xyz</p>
<ol className="mb-4 list-inside space-y-4">
<li>
<h2 className="inline text-lg font-semibold text-white">Be Respectful</h2>
<p className="mt-1 ml-5">
No harassment, hate speech, or personal attacks. Treat others with basic human decency.
Disagreement is fine being a jerk is not.
</p>
</li>
<li>
<h2 className="inline text-lg font-semibold text-white">No Spam or Bots</h2>
<p className="mt-1 ml-5">
Genuine human interaction only. No automated accounts, crypto spam, link flooding, or
repetitive self-promotion. Bots are not permitted without explicit admin approval.
</p>
</li>
<li>
<h2 className="inline text-lg font-semibold text-white">No CSAM</h2>
<p className="mt-1 ml-5">
Zero tolerance for child sexual abuse material or any content exploiting minors.
Immediate ban and report to NCMEC and law enforcement. No exceptions, no warnings.
</p>
</li>
<li>
<h2 className="inline text-lg font-semibold text-white">No Financial Scams</h2>
<p className="mt-1 ml-5">
No pump-and-dump coordination, rug pulls, fake ICOs, phishing links, wallet drainers, or
any scheme designed to steal money or tokens. Share alpha, not scams.
</p>
</li>
<li>
<h2 className="inline text-lg font-semibold text-white">No Doxxing</h2>
<p className="mt-1 ml-5">
Do not share private personal information about others without their explicit consent.
This includes real names, addresses, phone numbers, private photos, and financial
information.
</p>
</li>
<li>
<h2 className="inline text-lg font-semibold text-white">No Impersonation</h2>
<p className="mt-1 ml-5">
Do not pretend to be someone else. This includes impersonating other users, public
figures, projects, companies, or officials. Parody accounts must be clearly labeled as
such.
</p>
</li>
<li>
<h2 className="inline text-lg font-semibold text-white">Disclose AI Content</h2>
<p className="mt-1 ml-5">
Mark AI-generated or AI-assisted posts clearly. Be honest about what you created versus
what a model generated. No deepfakes used for impersonation or deception.
</p>
</li>
<li>
<h2 className="inline text-lg font-semibold text-white">Respect Copyright</h2>
<p className="mt-1 ml-5">
Do not post content you do not own or have permission to share. Follow DMCA procedures
for takedown requests at{' '}
<a href="mailto:dmca@degenfeed.xyz" className="text-degen-400 underline">
dmca@degenfeed.xyz
</a>
.
</p>
</li>
<li>
<h2 className="inline text-lg font-semibold text-white">English Primary</h2>
<p className="mt-1 ml-5">
English is the main language of this instance, but all languages are welcome in their
respective communities. Use content warnings and hashtags to help others filter.
</p>
</li>
<li>
<h2 className="inline text-lg font-semibold text-white">No Illegal Activities</h2>
<p className="mt-1 ml-5">
Comply with all applicable laws. This includes laws in your jurisdiction, the United
States (where our servers are located), and the jurisdictions of federated instances you
interact with.
</p>
</li>
<li>
<h2 className="inline text-lg font-semibold text-white">Mark Adult Content</h2>
<p className="mt-1 ml-5">
Use content warnings for NSFW material. Unmarked adult content will be removed. Illegal
content will be reported. Keep the instance safe for all users.
</p>
</li>
<li>
<h2 className="inline text-lg font-semibold text-white">Be a Good Fediverse Citizen</h2>
<p className="mt-1 ml-5">
Do not harass users on other instances, do not exploit federation to bypass blocks or
bans, and do not use the instance as a launchpad for cross-instance spam campaigns.
Respect the rules of federated instances you interact with.
</p>
</li>
</ol>
<p className="mt-6 text-gray-500">
Violations may result in content removal, account suspension, or instance defederation.
Reports:{' '}
<a href="mailto:admin@rugmunch.io" className="text-degen-400 underline">
admin@rugmunch.io
</a>
</p>
</div>
);
}

View file

@ -0,0 +1,11 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Discover',
description:
'Discover trending accounts and communities across the Fediverse. Browse popular Mastodon, Lemmy, and ActivityPub profiles.',
};
export default function DiscoverLayout({ children }: { children: React.ReactNode }) {
return children;
}

View file

@ -0,0 +1,141 @@
'use client';
import Image from 'next/image';
import { useEffect, useState } from 'react';
interface DiscoverAccount {
id: string;
username: string;
acct: string;
display_name: string;
avatar: string;
note: string;
followers_count: number;
instance: string;
}
const INSTANCES = [
{ url: 'https://bitcoinhackers.org', label: 'Bitcoin Hackers', topic: 'Bitcoin' },
{ url: 'https://fosstodon.org', label: 'Fosstodon', topic: 'FOSS/Tech' },
{ url: 'https://hachyderm.io', label: 'Hachyderm', topic: 'Tech' },
{ url: 'https://infosec.exchange', label: 'Infosec Exchange', topic: 'InfoSec' },
{ url: 'https://cryptodon.lol', label: 'Cryptodon', topic: 'Crypto' },
];
async function fetchMastodonAPI(url: string): Promise<DiscoverAccount[] | null> {
try {
const res = await fetch(`/api/discover?instance=${encodeURIComponent(url)}`, {
cache: 'no-store',
});
if (!res.ok) return null;
return (await res.json()) as DiscoverAccount[];
} catch {
return null;
}
}
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
}
export default function DiscoverPage() {
const [accounts, setAccounts] = useState<Record<string, DiscoverAccount[]>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function load() {
const results: Record<string, DiscoverAccount[]> = {};
const reqs = INSTANCES.map(async (inst) => {
const data = await fetchMastodonAPI(inst.url);
if (data && !cancelled) results[inst.url] = data;
});
await Promise.allSettled(reqs);
if (!cancelled) {
setAccounts(results);
setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, []);
return (
<div className="mx-auto max-w-4xl px-4 py-8">
<div className="mb-8 text-center">
<Image
src="/icon.svg"
alt="DegenFeed"
width={60}
height={60}
className="mx-auto mb-3 h-14 w-14 rounded-xl shadow-[0_0_20px_rgba(0,255,65,0.3)]"
/>
<h1 className="text-2xl font-bold text-white">Fediverse Discovery</h1>
<p className="mt-2 text-sm text-gray-400">
Popular accounts from crypto, tech, and infosec Mastodon instances.
</p>
</div>
{loading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="card animate-pulse h-20" />
))}
</div>
) : (
INSTANCES.map((inst) => {
const items = accounts[inst.url];
if (!items || items.length === 0) return null;
return (
<div key={inst.url} className="mb-8">
<div className="mb-3 flex items-center gap-2">
<span className="rounded-full bg-degen-500/10 px-2.5 py-0.5 text-xs font-medium text-degen-400">
{inst.topic}
</span>
<h2 className="text-sm font-semibold text-white">{inst.label}</h2>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{items.slice(0, 8).map((a) => (
<a
key={a.id}
href={`https://${new URL(inst.url).hostname}/@${a.acct}`}
target="_blank"
rel="noopener"
className="card flex items-center gap-3 transition-colors hover:border-degen-500/30"
>
<img
src={a.avatar}
alt={a.display_name}
className="h-10 w-10 rounded-full bg-gray-800 object-cover"
loading="lazy"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-white">
{a.display_name}
</span>
</div>
<div className="text-xs text-gray-500">@{a.acct}</div>
<div className="mt-1 text-xs text-gray-400 line-clamp-1">
{stripHtml(a.note)}
</div>
</div>
<span className="shrink-0 text-xs text-gray-600">
{a.followers_count.toLocaleString()} followers
</span>
</a>
))}
</div>
</div>
);
})
)}
</div>
);
}

View file

@ -0,0 +1,11 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Documentation',
description:
'DegenFeed documentation — API reference, protocol integration, self-hosting, and architecture. Open-source web3 social aggregator.',
};
export default function DocsLayout({ children }: { children: React.ReactNode }) {
return children;
}

View file

@ -0,0 +1,238 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Documentation',
description:
'DegenFeed documentation — API reference, protocol integration, self-hosting, and architecture overview. Open-source web3 social aggregator.',
};
const apiEndpoints = [
{ method: 'GET', path: '/api/feed/home', desc: 'Home timeline. Aggregated and ranked feed across all enabled protocols.' },
{ method: 'GET', path: '/api/feed/trending', desc: 'Trending posts across all protocols. Sorted by engagement velocity.' },
{ method: 'GET', path: '/api/feed/news', desc: 'Curated news feed from RSS, Mastodon, and Bluesky sources.' },
{ method: 'GET', path: '/api/feed/rss.xml', desc: 'RSS feed of top DegenFeed posts. Subscribe in any RSS reader.' },
{ method: 'GET', path: '/api/feed/:protocol', desc: 'Per-protocol feed (nostr, farcaster, lens, bluesky, mastodon, threads, reddit, rss).' },
{ method: 'GET', path: '/api/feed/health', desc: 'Protocol health status. Latency and uptime per provider.' },
{ method: 'GET', path: '/api/feed/category/:cat', desc: 'Filtered feed by niche category (defi, nft, ai, gaming, etc.).' },
{ method: 'POST', path: '/api/publish', desc: 'Cross-post to connected protocols. Requires wallet authentication.' },
{ method: 'POST', path: '/api/engage', desc: 'Like, reply, or repost. Protocol-native signing per platform.' },
{ method: 'GET', path: '/api/search', desc: 'Cross-protocol search. Query params: q, protocol, limit.' },
{ method: 'GET', path: '/api/account/export', desc: 'Export all your data. GDPR-compliant data portability.' },
{ method: 'POST', path: '/api/account/delete', desc: 'Permanently delete your account and all associated data.' },
{ method: 'POST', path: '/api/newsletter/subscribe', desc: 'Subscribe to the daily digest newsletter. Optional topic preferences.' },
{ method: 'GET', path: '/api/newsletter/digest', desc: 'Preview the latest daily digest.' },
{ method: 'POST', path: '/api/tip/create', desc: 'Create a crypto tip (ETH, SOL, USDC). Returns transaction payload.' },
];
const protocols = [
{ name: 'Nostr', endpoint: 'wss://relay.damus.io', auth: 'nsec key', type: 'Decentralized relays' },
{ name: 'Farcaster', endpoint: 'hub.farcaster.standardcrypto.vc:2281', auth: 'Signer key', type: 'Hubs + on-chain registry' },
{ name: 'Lens', endpoint: 'api.lens.xyz/graphql', auth: 'Wallet sign-in', type: 'Polygon smart contracts' },
{ name: 'Bluesky', endpoint: 'public.api.bsky.app', auth: 'App password', type: 'AT Protocol PDS' },
{ name: 'Mastodon', endpoint: 'social.degenfeed.xyz', auth: 'OAuth token', type: 'ActivityPub federation' },
{ name: 'Threads', endpoint: 'threads.net (public HTML)', auth: 'None (read-only)', type: 'Meta ActivityPub + scraping' },
{ name: 'Reddit', endpoint: 'reddit.com/.rss', auth: 'None (RSS)', type: 'RSS feed scraping' },
{ name: 'RSS', endpoint: 'Any feed URL', auth: 'None', type: 'Syndication standard' },
];
const selfHostSteps = [
{ title: 'Clone the repo', cmd: 'git clone https://git.rugmunch.io/RugMunchMedia/degenfeed-web.git && cd degenfeed-web' },
{ title: 'Install dependencies', cmd: 'pnpm install' },
{ title: 'Configure environment', cmd: 'cp .env.example .env && $EDITOR .env' },
{ title: 'Start development server', cmd: 'pnpm dev' },
{ title: 'Build for production', cmd: 'pnpm build' },
{ title: 'Deploy to Cloudflare', cmd: 'npx wrangler pages deploy apps/web --project-name degenfeed-web' },
];
export default function DocsPage() {
return (
<div className="mx-auto max-w-4xl px-4 py-8 text-sm leading-relaxed text-gray-300">
<h1 className="mb-2 text-3xl font-bold text-white">Documentation</h1>
<p className="mb-8 text-gray-500">
Everything you need to know about DegenFeed the open-source web3 social aggregator.
</p>
<nav className="mb-10 flex flex-wrap gap-3 text-sm">
{['Quick start', 'Protocols', 'API reference', 'Self-hosting', 'Architecture', 'More resources'].map((s) => (
<a key={s} href={`#${s.toLowerCase().replace(/\s+/g, '-')}`} className="rounded-full bg-gray-900 px-3 py-1 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white">{s}</a>
))}
</nav>
{/* Quick start */}
<section id="quick-start" className="mb-10">
<h2 className="mb-4 text-xl font-bold text-white">Quick start</h2>
<div className="space-y-4">
<p>
DegenFeed is a multi-protocol social aggregator that pulls public posts from 8 networks into one unified timeline.
<strong className="text-white"> No account is needed to browse.</strong> Connect your wallet to like, reply, cross-post, and tip.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<a href="https://app.degenfeed.xyz" className="card block transition-colors hover:border-degen-500/30">
<h3 className="font-semibold text-white">Open the web app</h3>
<p className="mt-1 text-gray-500">Browse all feeds immediately. Zero sign-up.</p>
<span className="mt-2 inline-block text-degen-400">app.degenfeed.xyz </span>
</a>
<a href="https://social.degenfeed.xyz" target="_blank" rel="noopener" className="card block transition-colors hover:border-degen-500/30">
<h3 className="font-semibold text-white">Get a handle</h3>
<p className="mt-1 text-gray-500">Free @degenfeed.xyz Mastodon account. Federates with the entire Fediverse.</p>
<span className="mt-2 inline-block text-degen-400">social.degenfeed.xyz </span>
</a>
</div>
</div>
</section>
{/* Protocols */}
<section id="protocols" className="mb-10">
<h2 className="mb-4 text-xl font-bold text-white">Supported protocols</h2>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800 text-gray-400">
<th className="pb-2 pr-4 font-medium">Protocol</th>
<th className="pb-2 pr-4 font-medium">Endpoint</th>
<th className="pb-2 pr-4 font-medium">Authentication</th>
<th className="pb-2 font-medium">Type</th>
</tr>
</thead>
<tbody>
{protocols.map((p) => (
<tr key={p.name} className="border-b border-gray-800/50">
<td className="py-2 pr-4 font-medium text-white">{p.name}</td>
<td className="py-2 pr-4 font-mono text-xs text-gray-500">{p.endpoint}</td>
<td className="py-2 pr-4 text-gray-400">{p.auth}</td>
<td className="py-2 text-gray-400">{p.type}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* API reference */}
<section id="api-reference" className="mb-10">
<h2 className="mb-4 text-xl font-bold text-white">API reference</h2>
<p className="mb-4 text-gray-500">
All endpoints are served from <code className="rounded bg-gray-900 px-1.5 py-0.5 text-xs text-degen-400">degenfeed.xyz/api/*</code>.
No API key is required for public endpoints. Rate limit: 20 requests per 5 seconds per IP.
</p>
<div className="space-y-2">
{apiEndpoints.map((ep) => (
<div key={ep.path} className="card flex items-start gap-4 py-3">
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${ep.method === 'GET' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}`}>
{ep.method}
</span>
<code className="mt-0.5 text-xs text-degen-400">{ep.path}</code>
<span className="flex-1 text-gray-500">{ep.desc}</span>
</div>
))}
</div>
</section>
{/* Self-hosting */}
<section id="self-hosting" className="mb-10">
<h2 className="mb-4 text-xl font-bold text-white">Self-hosting</h2>
<p className="mb-4 text-gray-500">
DegenFeed is licensed under MIT. You can run your own instance on Cloudflare Pages (free tier) or any Node.js host.
</p>
<div className="space-y-3">
{selfHostSteps.map((step, i) => (
<div key={step.title} className="card flex items-start gap-4 py-3">
<span className="mt-0.5 font-mono text-xs text-degen-500">{String(i + 1).padStart(2, '0')}</span>
<div>
<h3 className="font-medium text-white">{step.title}</h3>
<code className="mt-1 block rounded bg-gray-900 px-3 py-1.5 text-xs text-gray-300">{step.cmd}</code>
</div>
</div>
))}
</div>
<div className="card mt-4">
<h3 className="mb-2 font-semibold text-white">Environment variables</h3>
<p className="mb-2 text-gray-500">Required for full functionality (posting, tipping, auth):</p>
<div className="space-y-1 font-mono text-xs text-gray-400">
<div><code className="text-degen-400">AUTH_SECRET</code> session signing secret (generate with openssl rand -hex 32)</div>
<div><code className="text-degen-400">ALLOWED_ORIGINS</code> comma-separated allowed CORS origins</div>
<div><code className="text-degen-400">RELAY_URLS</code> comma-separated Nostr relay URLs</div>
<div><code className="text-degen-400">FARCASTER_HUB_URL</code> Farcaster hub gRPC endpoint</div>
<div><code className="text-degen-400">LENS_API_URL</code> Lens GraphQL API endpoint</div>
<div><code className="text-degen-400">SOLANA_RPC_URL</code> Solana RPC for wallet verification</div>
</div>
</div>
</section>
{/* Architecture */}
<section id="architecture" className="mb-10">
<h2 className="mb-4 text-xl font-bold text-white">Architecture</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div className="card">
<h3 className="font-semibold text-white">Monorepo structure</h3>
<ul className="mt-2 space-y-1 text-xs text-gray-500">
<li><code className="text-degen-400">apps/web/</code> Next.js 15 App Router frontend</li>
<li><code className="text-degen-400">workers/api/</code> Cloudflare Worker edge proxy + aggregator</li>
<li><code className="text-degen-400">packages/feed-core/</code> Feed normalization and deduplication</li>
<li><code className="text-degen-400">packages/feed-providers/</code> 8 protocol adapters (Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads, Reddit, RSS)</li>
<li><code className="text-degen-400">packages/ranking/</code> 7-component transparent scoring algorithm</li>
<li><code className="text-degen-400">packages/ui/</code> Shared React components</li>
<li><code className="text-degen-400">packages/identity/</code> Cross-protocol identity resolution</li>
<li><code className="text-degen-400">packages/auth/</code> SIWE + Solana wallet sign-in</li>
</ul>
</div>
<div className="card">
<h3 className="font-semibold text-white">Data flow</h3>
<ol className="mt-2 space-y-1.5 text-xs text-gray-500">
<li>1. Worker receives feed request</li>
<li>2. Providers fetch posts in parallel</li>
<li>3. Feed core normalizes to UnifiedPost</li>
<li>4. Ranking engine scores each post</li>
<li>5. Results merged, deduplicated, sorted</li>
<li>6. Response cached at edge (30s)</li>
<li>7. Client renders with infinite scroll</li>
</ol>
</div>
</div>
<div className="card mt-4">
<h3 className="mb-2 font-semibold text-white">Tech stack</h3>
<div className="flex flex-wrap gap-2">
{['TypeScript 5', 'Next.js 15', 'React 19', 'Cloudflare Workers', 'Cloudflare Pages', 'Cloudflare KV', 'pnpm 11', 'Turborepo 2', 'Biome', 'Tailwind CSS', 'GotoSocial 0.22', 'SQLite', 'Wagmi', 'Solana Web3.js', 'viem'].map((t) => (
<span key={t} className="rounded bg-gray-900 px-2 py-1 text-xs text-gray-500">{t}</span>
))}
</div>
</div>
</section>
{/* More resources */}
<section id="more-resources" className="mb-10">
<h2 className="mb-4 text-xl font-bold text-white">More resources</h2>
<div className="grid gap-4 sm:grid-cols-3">
<a href="/faq" className="card block transition-colors hover:border-degen-500/30">
<h3 className="font-semibold text-white">FAQ</h3>
<p className="mt-1 text-xs text-gray-500">15 common questions about accounts, protocols, federation, payments, and privacy.</p>
</a>
<a href="/about" className="card block transition-colors hover:border-degen-500/30">
<h3 className="font-semibold text-white">About</h3>
<p className="mt-1 text-xs text-gray-500">Mission, team, Rug Munch Media LLC, infrastructure, and open-source commitment.</p>
</a>
<a href="/status" className="card block transition-colors hover:border-degen-500/30">
<h3 className="font-semibold text-white">Status</h3>
<p className="mt-1 text-xs text-gray-500">Live health dashboard for all 8 protocol providers. Latency and uptime.</p>
</a>
<a href="https://git.rugmunch.io/RugMunchMedia/degenfeed-web" target="_blank" rel="noopener" className="card block transition-colors hover:border-degen-500/30">
<h3 className="font-semibold text-white">Source code (Forgejo)</h3>
<p className="mt-1 text-xs text-gray-500">Full monorepo source. MIT licensed. Issues and PRs welcome.</p>
</a>
<a href="https://gitlab.com/cryptorugmuncher/degenfeed-web" target="_blank" rel="noopener" className="card block transition-colors hover:border-degen-500/30">
<h3 className="font-semibold text-white">GitLab mirror</h3>
<p className="mt-1 text-xs text-gray-500">Public mirror synced on every push. CI/CD and merge requests.</p>
</a>
<a href="/newsletter" className="card block transition-colors hover:border-degen-500/30">
<h3 className="font-semibold text-white">Newsletter</h3>
<p className="mt-1 text-xs text-gray-500">Daily digest of curated web3, crypto, and AI news from 8 protocols.</p>
</a>
</div>
</section>
<div className="mt-10 border-t border-gray-800 pt-6 text-center text-xs text-gray-600">
DegenFeed is a product of <strong className="text-gray-500">Rug Munch Media LLC</strong>. MIT licensed. Built on open protocols.
</div>
</div>
);
}

View file

@ -0,0 +1,95 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'FAQ — DegenFeed',
description:
'Frequently asked questions about DegenFeed, the open-source web3 social aggregator.',
};
const faqs = [
{
q: 'What is DegenFeed?',
a: 'DegenFeed is an open-source multi-protocol social aggregator for web3. It displays posts from Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads, Reddit, and RSS feeds in a single unified timeline. No account is needed to browse.',
},
{
q: 'How does aggregation work?',
a: 'We pull public posts from each protocol via their open APIs, normalize them to a common format, rank them by engagement and recency, and display them in your feed. Nothing is stored or archived beyond what is needed to serve feeds.',
},
{
q: 'Do I need an account?',
a: 'No. Browsing DegenFeed is completely free and requires no sign-up. Creating an account unlocks posting, tipping, cross-posting across protocols, and a free @degenfeed.xyz handle on the Fediverse.',
},
{
q: 'Is DegenFeed open source?',
a: 'Yes. DegenFeed is licensed under AGPL-3.0. The full source code is available on git.rugmunch.io and codeberg.org. Contributions are welcome — see CONTRIBUTING.md for guidelines.',
},
{
q: 'What is the premium tier?',
a: 'Premium ($5/mo) adds advanced filtering, custom feeds, noise reduction, and priority support. Creator tier ($15/mo) unlocks analytics, cross-posting, and post scheduling.',
},
{
q: 'How do I get a @degenfeed.xyz handle?',
a: 'Visit social.degenfeed.xyz, connect your wallet, and request an account with a brief reason for joining. Approvals are usually processed within 24 hours. Your handle works across the entire Fediverse via ActivityPub.',
},
{
q: 'How does federation work?',
a: 'DegenFeed runs a GotoSocial instance at social.degenfeed.xyz that federates via ActivityPub. This means your @degenfeed.xyz account can follow and be followed by users on Mastodon, Lemmy, Pixelfed, and other Fediverse platforms.',
},
{
q: 'What is ActivityPub?',
a: 'ActivityPub is a W3C standard for decentralized social networking. It powers Mastodon, Lemmy, Pixelfed, PeerTube, GotoSocial, and millions of federated accounts. It is the protocol that makes the Fediverse work without a central server.',
},
{
q: 'Can I post from DegenFeed?',
a: 'Yes. Connect your accounts (Nostr, Bluesky, Mastodon, Farcaster, Lens) and use the DegenFeed composer. Posts are sent directly to your chosen protocol. You can cross-post to multiple protocols simultaneously with a Creator subscription.',
},
{
q: 'What wallets are supported?',
a: 'Solana wallets (Phantom, Backpack, Solflare) and EVM wallets (MetaMask, Rainbow, WalletConnect). Wallets are used for sign-in and for tipping creators.',
},
{
q: 'How do tips work?',
a: "Tip ETH, SOL, or USDC directly to any post. Tips go directly to the creator's wallet with no intermediary. Lightning Network support is in development.",
},
{
q: 'Is my data sold?',
a: 'No. DegenFeed does not sell data, does not run ads, and does not track users beyond what is needed to serve feeds. Premium subscriptions fund development.',
},
{
q: 'How do I delete my account?',
a: 'Data deletion is available via /api/account/delete. For GotoSocial accounts, manage your account at social.degenfeed.xyz.',
},
{
q: 'Is there an RSS feed?',
a: "Yes. Subscribe to /api/feed/rss.xml in your RSS reader to get DegenFeed's top posts. Atom format is also available at /api/feed/atom.xml.",
},
{
q: 'How do I report content?',
a: 'Use the report button on any post, or email admin@rugmunch.io. For copyright concerns, contact dmca@degenfeed.xyz.',
},
];
export default function FAQPage() {
return (
<div className="mx-auto max-w-2xl px-4 py-8 text-sm leading-relaxed text-gray-300">
<h1 className="mb-6 text-2xl font-bold text-white">Frequently Asked Questions</h1>
<div className="space-y-6">
{faqs.map((faq) => (
<div key={faq.q}>
<h2 className="mb-1 text-base font-semibold text-white">{faq.q}</h2>
<p>{faq.a}</p>
</div>
))}
</div>
<p className="mt-8 border-t border-gray-800 pt-6 text-gray-500">
Don&apos;t see your question? Reach out to{' '}
<a href="mailto:admin@rugmunch.io" className="text-degen-400 underline">
admin@rugmunch.io
</a>
.
</p>
</div>
);
}

View file

@ -0,0 +1,681 @@
'use client';
export const runtime = 'edge';
import { addBookmark, getFollows, removeBookmark } from '@degenfeed/storage';
import type { EngagementAction, UnifiedPost } from '@degenfeed/types';
import { FeedList, NotificationBell, PostCard } from '@degenfeed/ui';
import Image from 'next/image';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAuth } from '../../components/AuthProvider';
import { FeedComposer } from '../../components/FeedComposer';
import { FeedSidebar } from '../../components/FeedSidebar';
import { TipModal } from '../../components/TipModal';
import { useNotifications } from '../../hooks/useNotifications';
type Tab = 'for-you' | 'following';
interface FeedMeta {
total?: number;
limit?: number;
perProtocol?: Record<string, number>;
errors?: string[];
notices?: string[];
trending?: { topic: string; count: number; score: number }[];
topOfHour?: string | null;
fetchedAt?: number;
}
interface ProtocolSummary {
id: string;
label: string;
color: string;
count: number;
}
const PROTOCOL_META: Record<string, { label: string; color: string; emoji: string }> = {
nostr: { label: 'Nostr', color: '#8e30eb', emoji: '⚡' },
farcaster: { label: 'Farcaster', color: '#8a63d2', emoji: '🟣' },
lens: { label: 'Lens', color: '#abfe2c', emoji: '🌿' },
bluesky: { label: 'Bluesky', color: '#1185fe', emoji: '🦋' },
mastodon: { label: 'Mastodon', color: '#6364ff', emoji: '🐘' },
threads: { label: 'Threads', color: '#888888', emoji: '🧵' },
reddit: { label: 'Reddit', color: '#ff4500', emoji: '🔴' },
rss: { label: 'News', color: '#ffa500', emoji: '📰' },
};
export default function FeedPage() {
const { signedInProtocols, engage, openSignIn, publish, ready, signOut, nostrPublicId } =
useAuth();
const [activeTab, setActiveTab] = useState<Tab>('for-you');
const [posts, setPosts] = useState<UnifiedPost[]>([]);
const [state, setState] = useState<'loading' | 'loaded' | 'empty' | 'error'>('loading');
const [error, setError] = useState<string>();
const [tipPost, setTipPost] = useState<UnifiedPost | null>(null);
const [feedMeta, setFeedMeta] = useState<FeedMeta>({});
const [hideLowQuality, setHideLowQuality] = useState(false);
const [hideThreshold, setHideThreshold] = useState(1.0);
const [allowNonCrypto, setAllowNonCrypto] = useState(false);
const [recencyWeight, setRecencyWeight] = useState(1.0);
const [engagementWeight, setEngagementWeight] = useState(1.0);
const [protocolFilter, setProtocolFilter] = useState<string | null>(null);
const hasSignedIn = ready && Object.values(signedInProtocols).some(Boolean);
const { notifications, unreadCount, fetchNotifications, markRead } =
useNotifications(hasSignedIn);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await fetch('/api/preferences', { credentials: 'include' });
if (!res.ok || cancelled) return;
const data = (await res.json()) as {
hideLowQuality?: boolean;
hideLowQualityThreshold?: number;
allowNonCrypto?: boolean;
recencyWeight?: number;
engagementWeight?: number;
};
if (typeof data.hideLowQuality === 'boolean') setHideLowQuality(data.hideLowQuality);
if (typeof data.hideLowQualityThreshold === 'number') {
setHideThreshold(data.hideLowQualityThreshold);
}
if (typeof data.allowNonCrypto === 'boolean') setAllowNonCrypto(data.allowNonCrypto);
if (typeof data.recencyWeight === 'number') setRecencyWeight(data.recencyWeight);
if (typeof data.engagementWeight === 'number') setEngagementWeight(data.engagementWeight);
} catch {
/* ignore — user has no session yet */
}
}
load();
return () => {
cancelled = true;
};
}, []);
const persistPrefs = useCallback((patch: Record<string, unknown>) => {
void fetch('/api/preferences', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
}).catch(() => {});
}, []);
const setHideThresholdAndPersist = useCallback(
(next: number) => {
setHideThreshold(next);
persistPrefs({ hideLowQualityThreshold: next });
},
[persistPrefs],
);
const setAllowNonCryptoAndPersist = useCallback(
(next: boolean) => {
setAllowNonCrypto(next);
persistPrefs({ allowNonCrypto: next });
},
[persistPrefs],
);
const toggleHideLowQuality = useCallback(() => {
setHideLowQuality((prev) => {
const next = !prev;
try {
localStorage.setItem('degenfeed_hide_low_quality', String(next));
} catch {
/* ignore */
}
persistPrefs({ hideLowQuality: next });
return next;
});
}, [persistPrefs]);
const [newAvailable, setNewAvailable] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [loadedLimit, setLoadedLimit] = useState(50);
const hasMore = loadedLimit < 200;
const loadMore = useCallback(() => {
setLoadedLimit((prev) => Math.min(prev + 25, 100));
}, []);
const fetchFeed = useCallback(
async (checkOnly = false) => {
if (!checkOnly) setState('loading');
try {
const url = new URL('/api/feed/home', window.location.origin);
url.searchParams.set('limit', String(loadedLimit));
const protocolsToFetch = protocolFilter
? [protocolFilter]
: ['nostr', 'farcaster', 'lens', 'bluesky', 'mastodon', 'threads', 'reddit', 'rss'];
url.searchParams.set('protocols', protocolsToFetch.join(','));
if (activeTab === 'following') {
url.searchParams.set('filter', 'following');
}
if (hideLowQuality) {
url.searchParams.set('hideLowQuality', 'true');
url.searchParams.set('hideLowQualityThreshold', String(hideThreshold));
}
if (allowNonCrypto) {
url.searchParams.set('allowNonCrypto', 'true');
}
if (recencyWeight !== 1) url.searchParams.set('recencyWeight', String(recencyWeight));
if (engagementWeight !== 1)
url.searchParams.set('engagementWeight', String(engagementWeight));
const res = await fetch(url.toString(), {
credentials: 'include',
cache: 'no-store',
headers: { 'cache-control': 'no-store' },
});
if (!res.ok) throw new Error(`Feed returned ${res.status}`);
const data = (await res.json()) as { data?: UnifiedPost[]; meta?: FeedMeta };
let feed = data.data ?? [];
if (activeTab === 'following') {
try {
const follows = await getFollows('self');
const ids = new Set(follows.map((f) => f.targetId));
feed = feed.filter((p) => ids.has(p.author.id));
} catch {
/* ignore IndexedDB errors in private mode */
}
}
if (checkOnly && feed.length > 0 && feed[0]?.id !== posts[0]?.id) {
setNewAvailable(true);
return;
}
setPosts(feed);
setFeedMeta(data.meta || {});
setState(feed.length ? 'loaded' : 'empty');
} catch (err) {
if (!checkOnly) {
setError(err instanceof Error ? err.message : 'Failed');
setState('error');
}
}
},
[
activeTab,
posts,
hideLowQuality,
hideThreshold,
allowNonCrypto,
recencyWeight,
engagementWeight,
protocolFilter,
],
);
useEffect(() => {
fetchFeed();
}, [fetchFeed]);
useEffect(() => {
intervalRef.current = setInterval(() => fetchFeed(true), 60000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchFeed]);
const refreshNow = useCallback(() => {
setNewAvailable(false);
fetchFeed(false);
}, [fetchFeed]);
const handleEngage = async (
action: EngagementAction,
postId: string,
content?: string,
): Promise<{ ok: boolean; error?: string }> => {
const post = posts.find((p) => p.id === postId);
if (!post) return { ok: false, error: 'Post not found' };
if ((action === 'bookmark' || action === 'unbookmark') && post.protocol !== 'mastodon') {
try {
if (action === 'bookmark') await addBookmark(postId);
else await removeBookmark(postId);
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Bookmark failed' };
}
}
return engage(action, postId, post.protocol, { content });
};
const handleTip = useCallback(
(post: UnifiedPost) => {
if (!hasSignedIn) {
openSignIn();
return;
}
setTipPost(post);
},
[hasSignedIn, openSignIn],
);
const handlePostClick = useCallback((id: string) => {
window.location.href = `/post/${encodeURIComponent(id)}`;
}, []);
const protocolSummaries: ProtocolSummary[] = useMemo(() => {
const perProtocol = feedMeta.perProtocol ?? {};
return Object.entries(PROTOCOL_META)
.map(([id, meta]) => ({
id,
label: meta.label,
color: meta.color,
count: perProtocol[id] ?? 0,
}))
.sort((a, b) => b.count - a.count);
}, [feedMeta.perProtocol]);
const handleProtocolFilter = useCallback((id: string) => {
setProtocolFilter((current) => (current === id ? null : id));
}, []);
return (
<div className="mx-auto grid max-w-[1280px] grid-cols-1 gap-4 px-3 py-3 sm:px-4 lg:grid-cols-12">
{/* Left rail — desktop only quick nav */}
<aside className="hidden xl:col-span-3 xl:block">
<div className="sticky top-20 space-y-3">
<div className="rounded-2xl border border-white/10 bg-gray-950/60 p-4 backdrop-blur-xl">
<div className="mb-3 flex items-center gap-2">
<Image
src="/icon.svg"
alt="DegenFeed"
width={36}
height={36}
className="h-9 w-9 rounded-xl shadow-[0_0_18px_rgba(0,255,65,0.25)]"
/>
<div>
<p className="text-sm font-semibold text-white">DegenFeed</p>
<p className="text-[10px] uppercase tracking-widest text-gray-500">
Open social graph
</p>
</div>
</div>
<p className="text-xs text-gray-400">
One window. Every protocol. Zero accounts to browse.
</p>
<div className="mt-3 flex flex-wrap gap-1">
{Object.entries(PROTOCOL_META).map(([id, m]) => (
<span
key={id}
className="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium"
style={{ backgroundColor: `${m.color}22`, color: m.color }}
title={`${m.label} live`}
>
{m.emoji} {m.label}
</span>
))}
</div>
</div>
{!hasSignedIn && (
<div className="rounded-2xl border border-degen-500/30 bg-degen-500/5 p-4 backdrop-blur-xl">
<p className="mb-1 text-sm font-semibold text-white">Sign in to post & engage</p>
<p className="mb-3 text-xs text-gray-400">
Browse without an account. Sign in with one click to like, reply, and cross-post.
</p>
<button type="button" onClick={openSignIn} className="btn-primary w-full text-sm">
Connect Wallet
</button>
<p className="mt-2 text-center text-[10px] text-gray-600">
ETH · SOL · or your existing social handle
</p>
</div>
)}
</div>
</aside>
{/* Main timeline */}
<main className="lg:col-span-8 xl:col-span-6">
<div className="sticky top-16 z-30 border-b border-white/10 bg-black/85 backdrop-blur-xl">
<div className="flex items-center justify-between gap-2 px-4 py-3">
<div className="flex items-center gap-3">
<Image
src="/icon.svg"
alt="DegenFeed"
width={32}
height={32}
className="h-9 w-9 rounded-xl logo-glow shadow-[0_0_14px_rgba(0,255,65,0.3)]"
/>
<div>
<h1 className="text-lg font-bold text-white leading-none">DegenFeed</h1>
<p className="text-[10px] text-gray-500">
{activeTab === 'for-you' ? 'Your unified timeline' : 'Posts you follow'}
</p>
</div>
</div>
{hasSignedIn && (
<div className="flex items-center gap-2">
<NotificationBell
notifications={notifications}
unreadCount={unreadCount}
onFetch={fetchNotifications}
onMarkRead={markRead}
/>
<button
type="button"
onClick={signOut}
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-gray-300 transition-colors hover:bg-white/10 hover:text-white lg:hidden"
>
Sign out
</button>
</div>
)}
</div>
{/* Tabs */}
<div className="flex">
<button
type="button"
onClick={() => setActiveTab('for-you')}
className={`relative flex-1 py-3 text-sm font-medium transition-colors ${
activeTab === 'for-you' ? 'text-white' : 'text-gray-500 hover:text-gray-300'
}`}
>
For You
{activeTab === 'for-you' && (
<span className="absolute bottom-0 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-degen-500" />
)}
</button>
<button
type="button"
onClick={() => setActiveTab('following')}
className={`relative flex-1 py-3 text-sm font-medium transition-colors ${
activeTab === 'following' ? 'text-white' : 'text-gray-500 hover:text-gray-300'
}`}
>
Following
{activeTab === 'following' && (
<span className="absolute bottom-0 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-degen-500" />
)}
</button>
</div>
{/* Protocol pills + filter */}
<div className="flex flex-wrap items-center gap-1.5 border-t border-white/5 px-4 py-2">
<button
type="button"
onClick={() => setProtocolFilter(null)}
className={`rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
protocolFilter === null
? 'bg-degen-500/15 text-degen-400'
: 'text-gray-500 hover:text-gray-300'
}`}
>
All
</button>
{protocolSummaries.map((p) => (
<button
key={p.id}
type="button"
onClick={() => handleProtocolFilter(p.id)}
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
protocolFilter === p.id ? 'text-white' : 'text-gray-500 hover:text-gray-300'
}`}
style={
protocolFilter === p.id
? { backgroundColor: `${p.color}22`, color: p.color }
: undefined
}
>
<span>{p.label}</span>
<span
className={`text-[10px] ${protocolFilter === p.id ? 'opacity-90' : 'text-gray-600'}`}
>
{p.count}
</span>
</button>
))}
</div>
{/* Quality controls */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-white/5 px-4 py-2 text-[11px] text-gray-400">
<button
type="button"
onClick={toggleHideLowQuality}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-medium transition-colors ${
hideLowQuality
? 'bg-degen-500/15 text-degen-400'
: 'text-gray-500 hover:text-gray-300'
}`}
title="Hide posts with low ranking scores"
aria-pressed={hideLowQuality}
>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
aria-hidden="true"
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
{hideLowQuality ? 'Hiding low-quality' : 'Show all'}
</button>
{hideLowQuality && (
<label className="flex items-center gap-1.5">
<span>Threshold</span>
<input
type="range"
min="0"
max="5"
step="0.5"
value={hideThreshold}
onChange={(e) => setHideThresholdAndPersist(Number(e.target.value))}
className="h-1 w-16 cursor-pointer appearance-none rounded-full bg-gray-700 accent-degen-500"
aria-label="Hide threshold"
/>
<span className="font-mono text-[10px] text-gray-500">
{hideThreshold.toFixed(1)}
</span>
</label>
)}
<label className="flex items-center gap-1">
<input
type="checkbox"
checked={allowNonCrypto}
onChange={(e) => setAllowNonCryptoAndPersist(e.target.checked)}
className="h-3 w-3 rounded border-white/20 bg-gray-900 text-degen-500 focus:ring-degen-500"
/>
<span>Non-crypto</span>
</label>
<label className="flex items-center gap-1.5">
<span>Recency</span>
<input
type="range"
min="0.1"
max="3"
step="0.1"
value={recencyWeight}
onChange={(e) => {
setRecencyWeight(Number(e.target.value));
persistPrefs({ recencyWeight: Number(e.target.value) });
}}
className="h-1 w-16 cursor-pointer appearance-none rounded-full bg-gray-700 accent-degen-500"
aria-label="Recency weight"
/>
<span className="font-mono text-[10px] text-gray-500">
{recencyWeight.toFixed(1)}
</span>
</label>
<label className="flex items-center gap-1.5">
<span>Engage</span>
<input
type="range"
min="0.1"
max="3"
step="0.1"
value={engagementWeight}
onChange={(e) => {
setEngagementWeight(Number(e.target.value));
persistPrefs({ engagementWeight: Number(e.target.value) });
}}
className="h-1 w-16 cursor-pointer appearance-none rounded-full bg-gray-700 accent-degen-500"
aria-label="Engagement weight"
/>
<span className="font-mono text-[10px] text-gray-500">
{engagementWeight.toFixed(1)}
</span>
</label>
<span className="ml-auto hidden text-[10px] text-gray-600 sm:inline">
Hover on any post for ranking breakdown
</span>
</div>
</div>
{/* Composer / sign-in CTA */}
{hasSignedIn ? (
<FeedComposer
onPost={publish}
signedInProtocols={signedInProtocols}
nostrPublicId={nostrPublicId}
onRequestSignIn={openSignIn}
/>
) : (
<div className="border-b border-white/10 px-4 py-4">
<div className="flex items-start gap-3">
<Image
src="/icon.svg"
alt="DegenFeed"
width={40}
height={40}
className="h-10 w-10 rounded-full"
/>
<div className="flex-1">
<button
type="button"
onClick={() => openSignIn()}
className="w-full rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-left text-base text-gray-400 transition-colors hover:border-degen-500/30 hover:bg-white/5"
>
What is happening across web3?
</button>
<div className="mt-3 flex items-center justify-between">
<p className="text-xs text-gray-500">
Sign in to like, reply, and cross-post everywhere.
</p>
<button type="button" onClick={openSignIn} className="btn-primary text-sm">
Connect Wallet
</button>
</div>
</div>
</div>
</div>
)}
{/* New posts pill */}
{newAvailable && (
<button
type="button"
onClick={refreshNow}
className="sticky top-[152px] z-20 mx-4 my-2 flex w-[calc(100%-2rem)] items-center justify-center gap-2 rounded-full border border-degen-500/40 bg-degen-500/90 py-2 text-sm font-semibold text-black shadow-[0_0_24px_rgba(0,255,65,0.35)] backdrop-blur-xl transition-transform hover:bg-degen-400"
>
<span className="h-2 w-2 animate-pulse rounded-full bg-black" />
New posts available click to refresh
</button>
)}
{/* Notices */}
{feedMeta.notices && feedMeta.notices.length > 0 && (
<div className="mx-4 mt-2 rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-2.5 text-xs text-yellow-400">
{feedMeta.notices.map((n) => (
<div key={n}>{n}</div>
))}
</div>
)}
{/* Top of Hour — highest engagement post in the last 60 min */}
{feedMeta.topOfHour &&
state === 'loaded' &&
(() => {
const pinnedPost = posts.find((p) => p.id === feedMeta.topOfHour);
if (!pinnedPost) return null;
return (
<div className="mx-4 mb-2 rounded-xl border border-yellow-500/30 bg-gradient-to-r from-yellow-500/10 to-transparent p-3">
<div className="mb-1 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-yellow-400">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-yellow-400" />
Top of the Hour
</div>
<PostCard
post={pinnedPost}
onClick={handlePostClick}
onEngage={handleEngage}
onTip={handleTip}
/>
</div>
);
})()}
{/* Feed */}
<FeedList
state={state}
posts={posts}
error={error}
onPostClick={handlePostClick}
onEngage={handleEngage}
onTip={handleTip}
onRetry={fetchFeed}
onSignIn={openSignIn}
hasMore={hasMore}
onLoadMore={loadMore}
/>
{activeTab === 'following' && state === 'empty' && (
<div className="border-b border-white/5 px-4 py-10 text-center">
<p className="text-gray-400">You are not following anyone yet.</p>
<p className="mt-1 text-xs text-gray-600">
Tap the Follow button on any post to build your timeline.
</p>
</div>
)}
{/* Bottom CTA — repeat sign-in pitch */}
{!hasSignedIn && state === 'loaded' && (
<div className="m-4 rounded-2xl border border-degen-500/30 bg-gradient-to-br from-degen-500/10 to-transparent p-5 text-center">
<p className="mb-1 text-sm font-semibold text-white">Liking what you see?</p>
<p className="mb-4 text-xs text-gray-400">
Sign in once to post, like, and reply across all 8 networks.
</p>
<button type="button" onClick={openSignIn} className="btn-primary text-sm">
Connect Wallet to Post
</button>
</div>
)}
</main>
{/* Right sidebar */}
<aside className="hidden lg:col-span-4 lg:block xl:col-span-3">
<div className="sticky top-20">
<FeedSidebar
trending={feedMeta.trending ?? []}
protocolSummaries={protocolSummaries}
activeProtocolFilter={protocolFilter}
onProtocolClick={handleProtocolFilter}
/>
</div>
</aside>
{tipPost && (
<TipModal
open={!!tipPost}
onClose={() => setTipPost(null)}
recipientProtocol={tipPost.protocol}
recipientAuthorId={tipPost.author.id}
recipientDisplayName={tipPost.author.displayName}
publicationId={
tipPost.protocol === 'lens' ? (tipPost.raw as { id?: string })?.id : undefined
}
/>
)}
</div>
);
}

View file

@ -0,0 +1,171 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-bg: #000000;
--color-fg: #e5e7eb;
--color-accent: #22c55e;
--color-muted: #6b7280;
--color-border: #1f2937;
}
html {
color-scheme: dark;
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background-color: var(--color-bg);
color: var(--color-fg);
font-family: "Inter", system-ui, sans-serif;
}
.skip-link {
position: absolute;
top: -100%;
left: 0;
z-index: 100;
padding: 0.5rem 1rem;
background: var(--color-accent);
color: #000;
font-weight: 600;
transition: top 0.1s;
}
.skip-link:focus {
top: 0;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-degen-500 focus:ring-offset-2 focus:ring-offset-black disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-degen-500 text-black hover:bg-degen-400 hover:shadow-[0_0_20px_rgba(34,197,94,0.35)] active:bg-degen-600;
}
.btn-secondary {
@apply btn border border-white/10 bg-white/5 text-white hover:bg-white/10 hover:border-degen-500/30;
}
.btn-ghost {
@apply btn text-gray-300 hover:bg-gray-800 hover:text-white;
}
.card {
@apply rounded-2xl border border-white/10 bg-gray-950/50 p-4 backdrop-blur-md shadow-xl;
}
.card-hover {
@apply card transition-all duration-300 hover:border-degen-500/30 hover:bg-gray-900/60 hover:shadow-[0_0_30px_rgba(34,197,94,0.12)];
}
.input {
@apply w-full rounded-xl border border-white/10 bg-gray-950/50 px-4 py-2.5 text-sm text-white placeholder-gray-500 focus:border-degen-500 focus:outline-none focus:ring-1 focus:ring-degen-500;
}
.glass {
@apply border border-white/10 bg-gray-950/50 backdrop-blur-xl;
}
.gradient-text {
@apply bg-gradient-to-r from-degen-400 via-degen-500 to-fuchsia-500 bg-clip-text text-transparent;
}
.skeleton {
@apply animate-pulse rounded-xl bg-gray-800/80;
}
}
@layer utilities {
.aurora-bg {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background:
radial-gradient(ellipse at 20% 20%, rgba(34, 197, 94, 0.12) 0%, transparent 50%),
radial-gradient(ellipse at 80% 80%, rgba(168, 85, 247, 0.1) 0%, transparent 50%),
radial-gradient(ellipse at 50% 50%, rgba(236, 72, 153, 0.06) 0%, transparent 60%);
filter: blur(60px);
animation: aurora 18s ease-in-out infinite alternate;
}
.mesh-bg {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
radial-gradient(at 0% 0%, rgba(34, 197, 94, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 0%, rgba(168, 85, 247, 0.12) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(236, 72, 153, 0.1) 0px, transparent 50%),
radial-gradient(at 0% 100%, rgba(34, 197, 94, 0.08) 0px, transparent 50%);
animation: mesh 24s ease-in-out infinite alternate;
}
.noise-overlay {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
}
}
@keyframes aurora {
0% {
transform: scale(1) rotate(0deg);
opacity: 0.6;
}
100% {
transform: scale(1.08) rotate(3deg);
opacity: 1;
}
}
@keyframes mesh {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
/* ── Glass morphism + DegenFeed design upgrade ── */
@keyframes logo-glow {
0%,
100% {
box-shadow: 0 0 12px rgba(0, 255, 65, 0.3);
}
50% {
box-shadow: 0 0 24px rgba(0, 255, 65, 0.5);
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.logo-glow {
animation: logo-glow 3s ease-in-out infinite;
}
.feed-enter {
animation: fade-in 0.3s ease-out;
}
.card-glass {
background: rgba(17, 17, 27, 0.7);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.top-of-hour-card {
background: linear-gradient(135deg, rgba(234, 179, 8, 0.08), rgba(17, 17, 27, 0.8));
border: 1px solid rgba(234, 179, 8, 0.2);
}

View file

@ -0,0 +1,135 @@
'use client';
import { useEffect, useState } from 'react';
interface HealthStatus {
relays?: Record<string, string>;
healthy?: number;
health?: Record<string, { healthy: boolean; latencyMs: number; error?: string }>;
checkedAt?: number;
}
function StatusDot(ok: boolean) {
return (
<span
className={`inline-block h-2 w-2 rounded-full ${ok ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]' : 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)]'}`}
/>
);
}
export default function HealthPage() {
const [api, setApi] = useState<HealthStatus | null>(null);
const [feed, setFeed] = useState<HealthStatus | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function load() {
try {
const [apiRes, feedRes] = await Promise.all([
fetch('/api/health', { credentials: 'include' }),
fetch('/api/feed/health', { credentials: 'include' }),
]);
if (apiRes.ok) setApi(await apiRes.json());
if (feedRes.ok) setFeed(await feedRes.json());
} catch {
/* ignore */
}
setLoading(false);
}
load();
}, []);
return (
<div className="mx-auto max-w-2xl px-4 py-8">
<h1 className="mb-6 text-2xl font-bold tracking-tight text-white">System Health</h1>
{loading && (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="card h-16 animate-pulse" />
))}
</div>
)}
{!loading && (
<div className="space-y-6">
{/* API Worker */}
<div className="card">
<h2 className="mb-3 text-sm font-semibold text-white">API Worker</h2>
{api?.relays ? (
<div className="space-y-2">
{Object.entries(api.relays).map(([relay, status]) => (
<div key={relay} className="flex items-center justify-between text-xs">
<span className="text-gray-400 font-mono">{relay}</span>
<div className="flex items-center gap-2">
{StatusDot(status === 'ok')}
<span className={status === 'ok' ? 'text-green-400' : 'text-red-400'}>
{status}
</span>
</div>
</div>
))}
<div className="mt-2 flex items-center justify-between border-t border-white/5 pt-2 text-xs">
<span className="text-gray-500">Healthy relays</span>
<span className="text-degen-400">{api.healthy ?? 0}/5</span>
</div>
</div>
) : (
<p className="text-xs text-gray-500">Relay health unavailable</p>
)}
</div>
{/* Feed Providers */}
{feed?.health && (
<div className="card">
<h2 className="mb-3 text-sm font-semibold text-white">Feed Providers</h2>
<div className="space-y-2">
{Object.entries(feed.health).map(([protocol, h]) => (
<div key={protocol} className="flex items-center justify-between text-xs">
<span className="text-gray-400 font-medium">{protocol}</span>
<div className="flex items-center gap-3">
{StatusDot(h.healthy)}
<span className={h.healthy ? 'text-green-400' : 'text-red-400'}>
{h.healthy ? `${h.latencyMs}ms` : (h.error ?? 'down')}
</span>
</div>
</div>
))}
</div>
{feed.checkedAt && (
<p className="mt-3 text-[10px] text-gray-600">
Checked at {new Date(feed.checkedAt).toUTCString()}
</p>
)}
</div>
)}
{/* App Status */}
<div className="card">
<h2 className="mb-3 text-sm font-semibold text-white">App</h2>
<div className="space-y-1.5 text-xs text-gray-400">
<div className="flex justify-between">
<span>Client</span>
<span className="text-degen-400">app.degenfeed.xyz</span>
</div>
<div className="flex justify-between">
<span>API</span>
<span className="text-degen-400">degenfeed-api (Cloudflare Worker)</span>
</div>
<div className="flex justify-between">
<span>Social</span>
<span className="text-degen-400">social.degenfeed.xyz (GotoSocial)</span>
</div>
<div className="flex justify-between">
<span>Sources</span>
<span className="text-degen-400">
RSS, Reddit, Hacker News, Nostr, Farcaster, Bluesky, Lens, Mastodon
</span>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,20 @@
export const runtime = "edge";
import type { Metadata } from 'next';
import { SECTIONS } from '../sections';
interface Props {
params: Promise<{ section: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { section } = await params;
const label = SECTIONS.find((s) => s.id === section)?.title ?? section;
return {
title: label,
description: `Browse ${label} posts on DegenFeed. ${SECTIONS.find((s) => s.id === section)?.description ?? ''}`,
};
}
export default function SectionLayout({ children }: { children: React.ReactNode }) {
return children;
}

View file

@ -0,0 +1,385 @@
'use client';
import { addBookmark, removeBookmark } from '@degenfeed/storage';
import type { EngagementAction } from '@degenfeed/types';
import { FeedList, NotificationBell, type UnifiedPost } from '@degenfeed/ui';
import { useEffect, useState } from 'react';
import { useAuth } from '../../../components/AuthProvider';
import { TipModal } from '../../../components/TipModal';
import { useNotifications } from '../../../hooks/useNotifications';
import { SECTIONS } from '../sections';
interface Props {
params: Promise<{ section: string }>;
}
function getProtocolColor(p: string): string {
const colors: Record<string, string> = {
nostr: '#8e30eb',
farcaster: '#8a63d2',
lens: '#abfe2c',
bluesky: '#1185fe',
threads: '#888',
mastodon: '#6364ff',
rss: '#ffa500',
};
return colors[p] ?? '#666';
}
function SectionNav({ current }: { current: string }) {
return (
<div className="mb-4 flex gap-1 overflow-x-auto pb-2">
{SECTIONS.map((s) => (
<a
key={s.id}
href={`/home/${s.id}`}
className={`whitespace-nowrap rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
s.id === current ? 'text-white' : 'text-gray-500 hover:text-gray-300'
}`}
style={s.id === current ? { backgroundColor: `${s.color}22`, color: s.color } : {}}
>
{s.icon} {s.label}
</a>
))}
</div>
);
}
interface SectionEmptyStateProps {
section: string;
state: 'loading' | 'loaded' | 'empty' | 'error';
hasSignedIn: boolean;
onSignIn: () => void;
onRetry: () => void;
}
function SectionEmptyState({
section,
state,
hasSignedIn,
onSignIn,
onRetry,
}: SectionEmptyStateProps) {
if (state === 'loading') return null;
if (state === 'error') {
return (
<div className="card mt-6 border-red-500/20 bg-red-500/5 py-8 text-center">
<h3 className="mb-2 text-base font-bold text-red-400">Feed unavailable</h3>
<p className="mb-4 text-sm text-gray-400">
The {section} source failed to load. This is usually a network or rate-limit issue.
</p>
<button type="button" onClick={onRetry} className="btn-primary text-sm">
Retry
</button>
</div>
);
}
if (state !== 'empty') return null;
const messages: Record<
string,
{ title: string; body: string; cta?: { label: string; href: string } }
> = {
mastodon: {
title: 'The fediverse is quiet',
body: hasSignedIn
? 'No posts from the Mastodon/GotoSocial timeline right now. Try posting first.'
: 'Create a free @degenfeed.xyz account to see posts from our own Mastodon instance.',
cta: hasSignedIn
? undefined
: { label: 'Create free account →', href: '/api/auth/gotosocial/register' },
},
nostr: {
title: 'No Nostr notes found',
body: 'Public relays returned no recent notes. This can happen when relays are slow or no one is posting on crypto hashtags.',
},
farcaster: {
title: 'No Farcaster casts found',
body: 'Farcaster hubs returned no recent casts. This may be a hub connectivity issue.',
},
lens: {
title: 'No Lens posts found',
body: 'Lens API returned no recent publications. This may be an API access issue.',
},
bluesky: {
title: 'No Bluesky posts found',
body: 'Bluesky search returned no recent posts. Try again later.',
},
threads: {
title: 'No Threads posts found',
body: 'Threads public pages returned no posts. Scraping is fragile; this may improve.',
},
reddit: {
title: 'No Reddit posts found',
body: 'Reddit feeds returned no posts. Reddit frequently blocks automated access.',
},
'reddit-crypto': {
title: 'r/CryptoCurrency is empty',
body: 'Reddit returned no posts. This is usually a rate-limit or block from Reddit.',
},
'reddit-bitcoin': {
title: 'r/Bitcoin is empty',
body: 'Reddit returned no posts. This is usually a rate-limit or block from Reddit.',
},
'reddit-ethereum': {
title: 'r/ethereum is empty',
body: 'Reddit returned no posts. This is usually a rate-limit or block from Reddit.',
},
};
const msg = messages[section] ?? {
title: 'No posts found',
body: 'This feed is empty right now. Try another section or sign in to see more.',
};
return (
<div className="card mt-6 border-degen-500/20 bg-degen-500/5 py-8 text-center">
<h3 className="mb-2 text-base font-bold text-degen-400">{msg.title}</h3>
<p className="mb-4 text-sm text-gray-400">{msg.body}</p>
<div className="flex justify-center gap-2">
{msg.cta ? (
<a href={msg.cta.href} className="btn-primary text-sm">
{msg.cta.label}
</a>
) : null}
{!hasSignedIn && !msg.cta && (
<button type="button" onClick={onSignIn} className="btn-primary text-sm">
Sign in to connect {section}
</button>
)}
<button type="button" onClick={onRetry} className="btn-secondary text-sm">
Retry
</button>
</div>
</div>
);
}
interface FeedMeta {
total?: number;
limit?: number;
perProtocol?: Record<string, number>;
errors?: string[];
notices?: string[];
trending?: { topic: string; count: number; score: number }[];
fetchedAt?: number;
}
function SectionContent({ section }: { section: string }) {
const { signedInProtocols, engage, openSignIn, openCompose } = useAuth();
const meta = SECTIONS.find((s) => s.id === section);
const [posts, setPosts] = useState<UnifiedPost[]>([]);
const [state, setState] = useState<'loading' | 'loaded' | 'empty' | 'error'>('loading');
const [error, setError] = useState<string>();
const [tipPost, setTipPost] = useState<UnifiedPost | null>(null);
const [feedMeta, setFeedMeta] = useState<FeedMeta>({});
const [refreshKey, setRefreshKey] = useState(0);
const hasSignedIn = Object.values(signedInProtocols).some(Boolean);
const { notifications, unreadCount, fetchNotifications, markRead } =
useNotifications(hasSignedIn);
useEffect(() => {
const feedInterval = setInterval(() => setRefreshKey((k) => k + 1), 60000);
return () => clearInterval(feedInterval);
}, []);
useEffect(() => {
void refreshKey;
let cancelled = false;
async function load() {
setState('loading');
try {
const url = new URL(meta?.endpoint || '/api/feed/home', window.location.origin);
url.searchParams.set('limit', String(meta?.limit || 25));
if (meta?.params) {
for (const [k, v] of Object.entries(meta.params)) url.searchParams.set(k, v);
}
let res = await fetch(url.toString(), {
credentials: 'include',
cache: 'no-store',
headers: { 'cache-control': 'no-store' },
});
let data = (await res.json()) as { data?: UnifiedPost[]; meta?: FeedMeta };
if (!data.data?.length && section === 'all') {
const rssUrl = new URL('/api/feed/rss', window.location.origin);
rssUrl.searchParams.set('source', 'reddit');
rssUrl.searchParams.set('handle', 'CryptoCurrency');
rssUrl.searchParams.set('limit', '25');
res = await fetch(rssUrl.toString(), {
credentials: 'include',
cache: 'no-store',
headers: { 'cache-control': 'no-store' },
});
data = (await res.json()) as { data?: UnifiedPost[]; meta?: FeedMeta };
}
if (cancelled) return;
setPosts(data.data || []);
setFeedMeta(data.meta || {});
setState(data.data?.length ? 'loaded' : 'empty');
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed');
setState('error');
}
}
}
load();
return () => {
cancelled = true;
};
}, [section, meta, refreshKey]);
const displayName = meta?.label ?? section;
const color = meta?.color ?? '#666';
const icon = meta?.icon ?? '●';
const handleEngage = async (
action: EngagementAction,
postId: string,
content?: string,
): Promise<{ ok: boolean; error?: string }> => {
const post = posts.find((p) => p.id === postId);
if (!post) return { ok: false, error: 'Post not found' };
if ((action === 'bookmark' || action === 'unbookmark') && post.protocol !== 'mastodon') {
try {
if (action === 'bookmark') await addBookmark(postId);
else await removeBookmark(postId);
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Bookmark failed' };
}
}
return engage(action, postId, post.protocol, { content });
};
const handleRetry = () => setRefreshKey((k) => k + 1);
return (
<div className="mx-auto max-w-2xl px-4 py-4">
<SectionNav current={section} />
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl" style={{ color }}>
{icon}
</span>
<div>
<h1 className="text-lg font-bold text-white">{displayName}</h1>
</div>
<span
className="rounded-full px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: `${color}22`, color }}
>
{displayName}
</span>
</div>
<div className="flex items-center gap-2">
{hasSignedIn && (
<NotificationBell
notifications={notifications}
unreadCount={unreadCount}
onFetch={fetchNotifications}
onMarkRead={markRead}
/>
)}
{hasSignedIn ? (
<button type="button" className="btn-primary text-xs" onClick={openCompose}>
Compose
</button>
) : (
<button type="button" className="btn-secondary text-xs" onClick={openSignIn}>
Sign In
</button>
)}
</div>
</div>
{feedMeta.notices && feedMeta.notices.length > 0 && (
<div className="mb-3 rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-3 text-xs text-yellow-400">
{feedMeta.notices.map((n) => (
<div key={n}>{n}</div>
))}
</div>
)}
{feedMeta.errors && feedMeta.errors.length > 0 && (
<div className="mb-3 rounded-lg border border-red-500/20 bg-red-500/10 p-3 text-xs text-red-400">
{feedMeta.errors.map((e) => (
<div key={e}>{e}</div>
))}
</div>
)}
{feedMeta.trending && feedMeta.trending.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{feedMeta.trending.map((t) => (
<span
key={t.topic}
className="rounded-full border border-degen-500/30 bg-degen-500/10 px-2 py-0.5 text-xs text-degen-400"
>
{t.topic}
</span>
))}
</div>
)}
{hasSignedIn && (
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-gray-500">
<span className="text-degen-400">Signed in:</span>
{Object.keys(signedInProtocols).map((p) => (
<span
key={p}
className="rounded-full px-2 py-0.5 font-medium"
style={{ backgroundColor: `${getProtocolColor(p)}22`, color: getProtocolColor(p) }}
>
{p}
</span>
))}
<a href="/profile" className="ml-auto text-degen-500 hover:underline">
Profile
</a>
</div>
)}
<FeedList
state={state}
posts={posts}
error={error}
protocolLabel={displayName}
onPostClick={(id) => {
window.location.href = `/post/${encodeURIComponent(id)}`;
}}
onEngage={handleEngage}
onTip={(post) => setTipPost(post)}
onRetry={handleRetry}
/>
<SectionEmptyState
section={section}
state={state}
hasSignedIn={hasSignedIn}
onSignIn={openSignIn}
onRetry={handleRetry}
/>
{tipPost && (
<TipModal
open={!!tipPost}
onClose={() => setTipPost(null)}
recipientProtocol={tipPost.protocol}
recipientAuthorId={tipPost.author.id}
recipientDisplayName={tipPost.author.displayName}
publicationId={
tipPost.protocol === 'lens' ? (tipPost.raw as { id?: string })?.id : undefined
}
/>
)}
</div>
);
}
export default async function SectionPage({ params }: Props) {
const section = (await params).section;
return <SectionContent section={section} />;
}

View file

@ -0,0 +1,11 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Funding',
description:
'Track crypto funding rounds, VC raises, and investment news across the web3 ecosystem.',
};
export default function FundingLayout({ children }: { children: React.ReactNode }) {
return children;
}

View file

@ -0,0 +1,115 @@
'use client';
import { FundingCard, type FundingRound } from '@degenfeed/ui';
import { useEffect, useState } from 'react';
export default function FundingPage() {
const [rounds, setRounds] = useState<FundingRound[]>([]);
const [state, setState] = useState<'loading' | 'loaded' | 'empty' | 'error'>('loading');
const [chainFilter, setChainFilter] = useState<string>('');
const [stageFilter, _setStageFilter] = useState<string>('');
const [meta, setMeta] = useState<{ totalRaised?: number }>({});
useEffect(() => {
let cancelled = false;
async function load() {
setState('loading');
try {
const url = new URL('/api/feed/funding', window.location.origin);
if (chainFilter) url.searchParams.set('chain', chainFilter);
if (stageFilter) url.searchParams.set('stage', stageFilter);
url.searchParams.set('limit', '50');
const res = await fetch(url.toString(), { credentials: 'include' });
if (!res.ok) throw new Error('API error');
const data = (await res.json()) as {
data?: FundingRound[];
meta?: { totalRaised?: number };
};
if (cancelled) return;
if (data.data?.length) {
setRounds(data.data);
setMeta(data.meta || {});
setState('loaded');
} else {
setState('empty');
}
} catch {
if (!cancelled) setState('error');
}
}
load();
return () => {
cancelled = true;
};
}, [chainFilter, stageFilter]);
return (
<div className="mx-auto max-w-2xl px-4 py-6">
<div className="mb-6">
<a
href="/home"
className="mb-4 inline-flex items-center gap-1 text-sm text-gray-500 transition-colors hover:text-white"
>
&larr; All sections
</a>
<h1 className="text-2xl font-bold text-white">Funding Tracker</h1>
<p className="text-sm text-gray-500">Crypto raises, rounds, and investments</p>
{meta.totalRaised ? (
<p className="mt-1 text-xs text-degen-500">
${meta.totalRaised.toFixed(1)}M total raised (this page)
</p>
) : null}
</div>
<div className="mb-4 flex flex-wrap gap-2">
{['', 'ethereum', 'solana', 'bitcoin'].map((c) => (
<button
key={c}
type="button"
className={`rounded-full border px-3 py-1 text-xs font-medium transition-all ${
chainFilter === c
? 'border-degen-500 bg-degen-500/20 text-degen-400'
: 'border-white/10 bg-gray-950/50 text-gray-400 hover:border-degen-500/30 hover:text-white'
}`}
onClick={() => setChainFilter(c)}
>
{c || 'All Chains'}
</button>
))}
</div>
{state === 'loading' && (
<div className="space-y-4">
{['a', 'b', 'c'].map((k) => (
<div key={k} className="card h-32 animate-pulse" />
))}
</div>
)}
{state === 'loaded' && (
<div className="space-y-4">
{rounds.map((r) => (
<FundingCard key={r.id} round={r} />
))}
</div>
)}
{state === 'empty' && (
<div className="card py-12 text-center">
<div className="mb-4 text-4xl text-gray-600">?</div>
<p className="text-gray-500">No funding rounds found</p>
</div>
)}
{state === 'error' && (
<div className="card py-12 text-center">
<p className="text-red-400">Failed to load funding data</p>
<button
type="button"
className="btn-primary mt-4 text-sm"
onClick={() => setState('loading')}
>
Retry
</button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,11 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Home',
description:
'Browse DegenFeed by protocol or topic. Explore Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads, Reddit, and RSS feeds.',
};
export default function HomeLayout({ children }: { children: React.ReactNode }) {
return children;
}

View file

@ -0,0 +1,122 @@
'use client';
import Image from 'next/image';
import { useAuth } from '../../components/AuthProvider';
const PROTOCOLS = [
{ id: 'nostr', label: 'Nostr', emoji: '⚡', color: '#8e30eb', desc: 'Decentralized notes' },
{
id: 'farcaster',
label: 'Farcaster',
emoji: '🟣',
color: '#8a63d2',
desc: 'Sufficiently decentralized',
},
{ id: 'lens', label: 'Lens', emoji: '🌿', color: '#abfe2c', desc: 'User-owned graph' },
{ id: 'bluesky', label: 'Bluesky', emoji: '🦋', color: '#1185fe', desc: 'AT Protocol' },
{ id: 'mastodon', label: 'Mastodon', emoji: '🐘', color: '#6364ff', desc: 'Fediverse' },
{ id: 'reddit', label: 'Reddit', emoji: '🔴', color: '#ff4500', desc: 'Reddit feeds' },
{ id: 'rss', label: 'News', emoji: '📰', color: '#ffa500', desc: '150+ RSS sources' },
{ id: 'threads', label: 'Threads', emoji: '🧵', color: '#888', desc: 'Meta threads' },
] as const;
export default function HomePage() {
const { signedInProtocols, ready, openSignIn, openCompose } = useAuth();
const hasSignedIn = ready && Object.values(signedInProtocols).some(Boolean);
return (
<div className="mx-auto max-w-6xl px-4 py-8">
{/* Hero */}
<div className="mb-10 text-center">
<div className="mb-4 flex justify-center">
<Image
src="/icon.svg"
alt="DegenFeed"
width={80}
height={80}
className="h-20 w-20 drop-shadow-[0_0_24px_rgba(34,197,94,0.4)]"
/>
</div>
<h1 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">
The social feed of <span className="gradient-text">web3</span>
</h1>
<p className="mx-auto mt-2 max-w-md text-sm text-gray-500">
Pick a network. Browse, search, and cross-post everywhere.
</p>
</div>
{/* Protocol grid — single click into any feed */}
<div className="mb-8 grid grid-cols-2 gap-3 sm:grid-cols-4">
{PROTOCOLS.map((p) => (
<a
key={p.id}
href={`/home/${p.id}`}
className="card-hover group flex flex-col items-center gap-2 p-4 text-center"
style={{ borderColor: `${p.color}33` }}
>
<span className="text-3xl transition-transform group-hover:scale-110">{p.emoji}</span>
<span className="text-sm font-semibold text-white" style={{ color: p.color }}>
{p.label}
</span>
<span className="text-[10px] text-gray-600">{p.desc}</span>
</a>
))}
</div>
{/* Quick actions */}
<div className="mb-8 flex flex-wrap items-center justify-center gap-3">
<a href="/news" className="btn-secondary text-sm">
📰 News
</a>
<a href="/newsletter" className="btn-secondary text-sm">
📬 Daily Digest
</a>
{hasSignedIn ? (
<button type="button" onClick={openCompose} className="btn-primary text-sm">
Compose
</button>
) : (
<button type="button" onClick={openSignIn} className="btn-primary text-sm">
🔑 Sign In
</button>
)}
</div>
{/* Trending niches — one click into a feed */}
<div className="card">
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-gray-500">
Browse by Topic
</h2>
<div className="flex flex-wrap gap-2">
{[
{ id: 'crypto', label: 'Crypto', color: '#f59e0b' },
{ id: 'bitcoin', label: 'Bitcoin', color: '#f7931a' },
{ id: 'ethereum', label: 'Ethereum', color: '#627eea' },
{ id: 'solana', label: 'Solana', color: '#14f195' },
{ id: 'defi', label: 'DeFi', color: '#6366f1' },
{ id: 'ai', label: 'AI', color: '#06b6d4' },
{ id: 'nft', label: 'NFTs', color: '#ec4899' },
{ id: 'regulation', label: 'Regulation', color: '#ef4444' },
{ id: 'security', label: 'Security', color: '#dc2626' },
{ id: 'macro', label: 'Macro', color: '#10b981' },
].map((n) => (
<a
key={n.id}
href={`/home/${n.id}`}
className="rounded-full border border-gray-800 bg-gray-900/50 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:border-degen-500/50 hover:text-white"
>
#{n.label}
</a>
))}
</div>
</div>
{/* Status footer */}
<div className="mt-8 text-center">
<p className="text-[10px] text-gray-700">
No account required to browse · Zero cookies · Zero tracking · MIT licensed
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,308 @@
export interface FeedSection {
id: string;
label: string;
icon: string;
color: string;
description: string;
endpoint: string;
limit: number;
params?: Record<string, string>;
}
export const SECTIONS: FeedSection[] = [
{
id: 'all',
label: 'DegenFeed',
icon: '\u25C6',
color: '#22c55e',
description: 'Unified feed across all networks',
endpoint: '/api/feed/home',
limit: 25,
params: { protocols: 'reddit,nostr,farcaster,lens,bluesky,mastodon,threads,rss' },
},
{
id: 'crypto',
label: 'Crypto',
icon: '₿',
color: '#f59e0b',
description: 'All crypto-related posts',
endpoint: '/api/feed/category/crypto',
limit: 25,
},
{
id: 'bitcoin',
label: 'Bitcoin',
icon: '₿',
color: '#f7931a',
description: 'Bitcoin and BTC ecosystem',
endpoint: '/api/feed/category/bitcoin',
limit: 25,
},
{
id: 'ethereum',
label: 'Ethereum',
icon: 'Ξ',
color: '#627eea',
description: 'Ethereum ecosystem',
endpoint: '/api/feed/category/ethereum',
limit: 25,
},
{
id: 'solana',
label: 'Solana',
icon: '◉',
color: '#14f195',
description: 'Solana ecosystem',
endpoint: '/api/feed/category/solana',
limit: 25,
},
{
id: 'defi',
label: 'DeFi',
icon: 'D',
color: '#6366f1',
description: 'DeFi protocols and yields',
endpoint: '/api/feed/category/defi',
limit: 25,
},
{
id: 'nft',
label: 'NFT',
icon: 'N',
color: '#ec4899',
description: 'NFTs and digital collectibles',
endpoint: '/api/feed/category/nft',
limit: 25,
},
{
id: 'ai',
label: 'AI',
icon: 'AI',
color: '#06b6d4',
description: 'Artificial intelligence and agents',
endpoint: '/api/feed/category/ai',
limit: 25,
},
{
id: 'regulation',
label: 'Regulation',
icon: '⚖',
color: '#ef4444',
description: 'Policy, SEC, regulation',
endpoint: '/api/feed/category/regulation',
limit: 25,
},
{
id: 'security',
label: 'Security',
icon: '🔒',
color: '#dc2626',
description: 'Hacks, exploits, audits',
endpoint: '/api/feed/category/security',
limit: 25,
},
{
id: 'macro',
label: 'Macro',
icon: '📈',
color: '#10b981',
description: 'Macro markets and Fed',
endpoint: '/api/feed/category/macro',
limit: 25,
},
{
id: 'news',
label: 'News',
icon: '📰',
color: '#3b82f6',
description: 'News + newsletters + Reddit',
endpoint: '/api/feed/newsletters',
limit: 25,
},
{
id: 'funding',
label: 'Funding',
icon: '💰',
color: '#10b981',
description: 'Crypto raises, rounds, and investments',
endpoint: '/api/feed/funding',
limit: 25,
},
{
id: 'top',
label: 'Best of Web3',
icon: '★',
color: '#f59e0b',
description: 'Top posts by cross-protocol engagement (24h)',
endpoint: '/api/feed/top',
limit: 50,
},
{
id: 'trending',
label: 'Trending',
icon: '⚡',
color: '#ef4444',
description: 'What\u2019s hot right now across all networks',
endpoint: '/api/feed/trending',
limit: 25,
},
{
id: 'newsletters',
label: 'Newsletters',
icon: '📐',
color: '#8b5cf6',
description: 'All newsletters',
endpoint: '/api/feed/newsletters',
limit: 25,
},
{
id: 'headlines',
label: 'Headlines',
icon: 'H',
color: '#ef4444',
description: 'Daily crypto news: CoinDesk, CoinTelegraph, The Block, Decrypt',
endpoint: '/api/feed/newsletters',
limit: 25,
params: { category: 'headlines' },
},
{
id: 'deep-dives',
label: 'Deep Dives',
icon: 'D',
color: '#f59e0b',
description: 'Bankless, Week in Eth, Delphi, Messari',
endpoint: '/api/feed/newsletters',
limit: 25,
params: { category: 'deep_dives' },
},
{
id: 'onchain',
label: 'On-Chain',
icon: 'O',
color: '#10b981',
description: 'Dune, Glassnode, Token Terminal, Nansen, Artemis',
endpoint: '/api/feed/newsletters',
limit: 25,
params: { category: 'onchain_data' },
},
{
id: 'defi-news',
label: 'DeFi News',
icon: 'D',
color: '#6366f1',
description: 'DeFi Pulse, Prime, Llama, Yearn, Uniswap',
endpoint: '/api/feed/newsletters',
limit: 25,
params: { category: 'defi' },
},
{
id: 'ai-crypto',
label: 'AI x Crypto',
icon: 'AI',
color: '#ec4899',
description: 'AI/ML in crypto, The Sequence, Modular',
endpoint: '/api/feed/newsletters',
limit: 25,
params: { category: 'ai_crypto' },
},
{
id: 'nostr',
label: 'Nostr',
icon: '☰',
color: '#8e30eb',
description: 'Decentralized notes',
endpoint: '/api/feed/home',
limit: 25,
params: { protocols: 'nostr' },
},
{
id: 'farcaster',
label: 'Farcaster',
icon: '◈',
color: '#8a63d2',
description: 'Sufficiently decentralized social',
endpoint: '/api/feed/home',
limit: 25,
params: { protocols: 'farcaster' },
},
{
id: 'lens',
label: 'Lens',
icon: '◎',
color: '#abfe2c',
description: 'User-owned social graph',
endpoint: '/api/feed/home',
limit: 25,
params: { protocols: 'lens' },
},
{
id: 'bluesky',
label: 'Bluesky',
icon: '☁',
color: '#1185fe',
description: 'AT Protocol social',
endpoint: '/api/feed/home',
limit: 25,
params: { protocols: 'bluesky' },
},
{
id: 'threads',
label: 'Threads',
icon: '+',
color: '#888',
description: 'Instagram AT Protocol network',
endpoint: '/api/feed/threads',
limit: 25,
},
{
id: 'mastodon',
label: 'Mastodon',
icon: 'M',
color: '#6364ff',
description: 'Fediverse (GotoSocial)',
endpoint: '/api/feed/activitypub',
limit: 20,
params: { instance: 'https://social.degenfeed.xyz', type: 'public' },
},
{
id: 'reddit',
label: 'Reddit',
icon: 'R',
color: '#ff4500',
description:
'Reddit crypto hot posts (r/CryptoCurrency, r/Bitcoin, r/ethereum, r/solana, r/defi)',
endpoint: '/api/feed/home',
limit: 25,
params: { protocols: 'reddit' },
},
{
id: 'reddit-crypto',
label: 'r/CryptoCurrency',
icon: 'R',
color: '#ff4500',
description: 'Top posts from r/CryptoCurrency',
endpoint: '/api/feed/reddit',
limit: 25,
params: { subreddit: 'CryptoCurrency' },
},
{
id: 'reddit-bitcoin',
label: 'r/Bitcoin',
icon: 'R',
color: '#f7931a',
description: 'Top posts from r/Bitcoin',
endpoint: '/api/feed/reddit',
limit: 25,
params: { subreddit: 'Bitcoin' },
},
{
id: 'reddit-ethereum',
label: 'r/ethereum',
icon: 'R',
color: '#627eea',
description: 'Top posts from r/ethereum',
endpoint: '/api/feed/reddit',
limit: 25,
params: { subreddit: 'ethereum' },
},
];

106
apps/web/src/app/layout.tsx Normal file
View file

@ -0,0 +1,106 @@
import type { Metadata, Viewport } from 'next';
import Script from 'next/script';
import './globals.css';
import { AuthProvider } from '../components/AuthProvider';
import { ErrorTracker } from '../components/ErrorTracker';
import { Shell } from '../components/Shell';
import { SolanaProvider } from '../components/SolanaProvider';
import { ToastProvider } from '../components/Toast';
import { Web3Provider } from '../components/Web3Provider';
export const metadata: Metadata = {
manifest: '/manifest.json',
metadataBase: new URL('https://degenfeed.xyz'),
title: {
default: 'DegenFeed — The social feed of web3',
template: '%s — DegenFeed',
},
description:
'See every network. Zero sign-ups. DegenFeed aggregates Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads, Reddit, and RSS into one window.',
openGraph: {
title: 'DegenFeed — The social feed of web3',
description:
'See every network. Zero sign-ups. Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads, Reddit, RSS in one window.',
url: 'https://degenfeed.xyz',
siteName: 'DegenFeed',
type: 'website',
images: [
{
url: '/og-image.png',
width: 1200,
height: 630,
alt: 'DegenFeed — The social feed of web3',
},
],
},
robots: {
index: true,
follow: true,
'max-image-preview': 'large',
'max-snippet': -1,
'max-video-preview': -1,
},
alternates: {
canonical: 'https://degenfeed.xyz',
},
};
export const viewport: Viewport = {
themeColor: '#000000',
colorScheme: 'dark',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<body>
<ErrorTracker />
<Script
defer
data-domain="degenfeed.xyz"
src="https://plausible.io/js/script.js"
strategy="afterInteractive"
/>
<Script
id="sw-register"
strategy="afterInteractive"
>{`if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{})}`}</Script>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: JSON-LD structured data requires innerHTML */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'DegenFeed',
url: 'https://degenfeed.xyz',
description:
'Open-source multi-protocol social aggregator for web3. Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads, Reddit, RSS in one feed.',
applicationCategory: 'SocialNetworkingApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'USD',
},
author: {
'@type': 'Organization',
name: 'Rug Munch Media LLC',
url: 'https://degenfeed.xyz',
},
}),
}}
/>
<SolanaProvider>
<Web3Provider>
<ToastProvider>
<AuthProvider>
<Shell>{children}</Shell>
</AuthProvider>
</ToastProvider>
</Web3Provider>
</SolanaProvider>
</body>
</html>
);
}

View file

@ -0,0 +1,69 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Legal Notice — DegenFeed',
description:
'Legal notice and company information for DegenFeed, operated by Rug Munch Media LLC.',
};
export default function LegalPage() {
return (
<div className="mx-auto max-w-2xl px-4 py-8 text-sm leading-relaxed text-gray-300">
<h1 className="mb-2 text-2xl font-bold text-white">Legal Notice</h1>
<p className="mb-8 text-lg text-degen-400">Rug Munch Media LLC</p>
<h2 className="mb-2 text-lg font-semibold text-white">Company</h2>
<p className="mb-4">
DegenFeed is a product of Rug Munch Media LLC, a Wyoming limited liability company.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">Registered Office</h2>
<p className="mb-4">Registered in the State of Wyoming, United States of America.</p>
<h2 className="mb-2 text-lg font-semibold text-white">Contact</h2>
<ul className="mb-4 space-y-1">
<li>
General inquiries:{' '}
<a href="mailto:admin@rugmunch.io" className="text-degen-400 underline">
admin@rugmunch.io
</a>
</li>
<li>
Legal / law enforcement:{' '}
<a href="mailto:legal@rugmunch.io" className="text-degen-400 underline">
legal@rugmunch.io
</a>
</li>
</ul>
<h2 className="mb-2 text-lg font-semibold text-white">DMCA Agent</h2>
<p className="mb-4">
For copyright-related takedown requests:{' '}
<a href="mailto:dmca@degenfeed.xyz" className="text-degen-400 underline">
dmca@degenfeed.xyz
</a>
</p>
<h2 className="mb-2 text-lg font-semibold text-white">Data Protection Officer</h2>
<p className="mb-4">
For GDPR and data privacy inquiries:{' '}
<a href="mailto:legal@rugmunch.io" className="text-degen-400 underline">
legal@rugmunch.io
</a>
</p>
<h2 className="mb-2 text-lg font-semibold text-white">Disclaimer</h2>
<p className="mb-4">
DegenFeed is provided &ldquo;as is&rdquo; without warranty of any kind. DegenFeed is not a
financial services platform. Nothing on DegenFeed constitutes financial advice. All
trademarks and registered trademarks are the property of their respective owners.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">Governing Law</h2>
<p className="mb-4">
These terms and your use of DegenFeed are governed by the laws of the State of Wyoming,
United States, without regard to conflict of law principles.
</p>
</div>
);
}

View file

@ -0,0 +1,536 @@
'use client';
import type { UnifiedPost } from '@degenfeed/types';
import Image from 'next/image';
import { useCallback, useEffect, useRef, useState } from 'react';
interface NewsMeta {
total: number;
limit: number;
category: string;
query?: string;
mustRead: boolean;
trending: Array<{ topic: string; count: number; score: number }>;
sourceCounts: Record<string, number>;
fetchedAt: number;
sources: number;
sentiment?: Record<string, number>;
sourceCount?: number;
tiers?: string[];
}
interface NewsRaw {
mustRead?: boolean;
tier?: number;
sourceName?: string;
sentiment?: string;
sentimentScore?: number;
kind?: string;
chain?: string;
tokenAddress?: string;
riskScore?: number;
}
const CATEGORIES = [
{ id: '', label: 'All', emoji: '🌐' },
{ id: 'bitcoin', label: 'Bitcoin', emoji: '₿' },
{ id: 'ethereum', label: 'Ethereum', emoji: '⟠' },
{ id: 'solana', label: 'Solana', emoji: '◎' },
{ id: 'defi', label: 'DeFi', emoji: '🏦' },
{ id: 'ai', label: 'AI', emoji: '🤖' },
{ id: 'security', label: 'Security', emoji: '🛡️' },
{ id: 'regulation', label: 'Regulation', emoji: '⚖️' },
{ id: 'macro', label: 'Macro', emoji: '📊' },
{ id: 'nft', label: 'NFTs', emoji: '🖼️' },
{ id: 'layer2', label: 'Layer 2', emoji: '⚡' },
{ id: 'memecoin', label: 'Memes', emoji: '🐸' },
{ id: 'stablecoin', label: 'Stablecoins', emoji: '💲' },
{ id: 'etf', label: 'ETFs', emoji: '📈' },
];
const SOURCE_TABS = [
{ id: 'all', label: 'All Sources' },
{ id: 'rss', label: 'RSS' },
{ id: 'reddit', label: 'Reddit' },
{ id: 'hackernews', label: 'HN' },
];
function formatTime(ts: number): string {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
function stripTags(html: string): string {
return html
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function getRaw(post: UnifiedPost): NewsRaw {
const raw = post.raw as NewsRaw | undefined;
return raw ?? {};
}
function sentimentColor(s: string | null | undefined): string {
const t = (s ?? '').toLowerCase();
if (t.includes('bullish')) return 'text-green-400 bg-green-500/10 border-green-500/20';
if (t.includes('bearish')) return 'text-red-400 bg-red-500/10 border-red-500/20';
return 'text-gray-400 bg-gray-500/10 border-gray-500/20';
}
function sentimentEmoji(s: string | null | undefined): string {
const t = (s ?? '').toLowerCase();
if (t.includes('bullish')) return '🟢';
if (t.includes('bearish')) return '🔴';
return '⚪';
}
function sourceIcon(kind: string | null | undefined): string {
const k = (kind ?? '').toLowerCase();
if (k === 'reddit') return '🔴';
if (k === 'hackernews') return '🟠';
if (k === 'rss') return '📡';
return '📰';
}
function NewsCard({ post }: { post: UnifiedPost }) {
const raw = getRaw(post);
const mustRead = raw.mustRead;
const tier = raw.tier;
const sentiment = raw.sentiment;
const kind = raw.kind;
const imageEmbed = post.embeds.find((e) => e.kind === 'image');
const linkEmbed = post.embeds.find((e) => e.kind === 'link');
const url = linkEmbed?.url || '#';
const title =
stripTags(post.text).slice(0, 120).split(' ').slice(0, -1).join(' ') || post.text.slice(0, 100);
const desc = stripTags(post.html || post.text).slice(0, 200);
return (
<article className="card-hover group relative flex flex-col overflow-hidden border border-white/5 bg-gradient-to-b from-gray-900/80 to-gray-950/80 transition-all duration-300 hover:border-degen-500/20 hover:shadow-[0_0_24px_rgba(34,197,94,0.08)]">
{imageEmbed && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="block aspect-video w-full overflow-hidden"
>
<Image
src={imageEmbed.url}
alt=""
width={800}
height={450}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
unoptimized
/>
</a>
)}
<div className="flex flex-1 flex-col p-4">
<div className="mb-2 flex flex-wrap items-center gap-1.5">
<span className="rounded-md bg-gray-800/80 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-gray-400">
{sourceIcon(kind)} {post.author.displayName}
</span>
{mustRead && (
<span className="rounded-md bg-degen-500/15 px-1.5 py-0.5 text-[10px] font-medium text-degen-400 ring-1 ring-degen-500/20">
Must Read
</span>
)}
{tier === 1 && !mustRead && (
<span className="rounded-md bg-yellow-500/10 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400 ring-1 ring-yellow-500/20">
Tier 1
</span>
)}
{sentiment && (
<span
className={`rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${sentimentColor(sentiment)}`}
>
{sentimentEmoji(sentiment)} {sentiment}
</span>
)}
<span className="ml-auto text-[10px] text-gray-600">{formatTime(post.createdAt)}</span>
</div>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="mb-2 block text-sm font-semibold leading-snug text-white transition-colors group-hover:text-degen-400 line-clamp-2"
>
{title}
</a>
<p className="mb-3 text-xs leading-relaxed text-gray-500 line-clamp-2">{desc}</p>
<div className="mt-auto flex flex-wrap gap-1">
{post.niches.slice(0, 4).map((niche) => (
<span
key={niche}
className="rounded-full bg-gray-800/60 px-2 py-0.5 text-[10px] text-gray-500"
>
#{niche}
</span>
))}
</div>
</div>
</article>
);
}
function SentimentBar({ sentiment }: { sentiment: Record<string, number> | undefined }) {
if (!sentiment || Object.keys(sentiment).length === 0) return null;
const total = Object.values(sentiment).reduce((a, b) => a + b, 0);
if (total === 0) return null;
const bullish = ((sentiment.bullish ?? 0) / total) * 100;
const bearish = ((sentiment.bearish ?? 0) / total) * 100;
const neutral = ((sentiment.neutral ?? 0) / total) * 100;
return (
<div className="mb-4">
<div className="mb-1 flex items-center justify-between text-[11px] text-gray-500">
<span>Market Sentiment</span>
<span className="text-green-400">🟢 {Math.round(bullish)}%</span>
<span className="text-gray-400"> {Math.round(neutral)}%</span>
<span className="text-red-400">🔴 {Math.round(bearish)}%</span>
</div>
<div className="flex h-1.5 overflow-hidden rounded-full bg-gray-800">
<div className="bg-green-500 transition-all" style={{ width: `${bullish}%` }} />
<div className="bg-gray-600 transition-all" style={{ width: `${neutral}%` }} />
<div className="bg-red-500 transition-all" style={{ width: `${bearish}%` }} />
</div>
</div>
);
}
interface NewsClientProps {
initialPosts: UnifiedPost[];
initialMeta: NewsMeta | null;
}
export default function NewsClient({ initialPosts, initialMeta }: NewsClientProps) {
const [posts, setPosts] = useState<UnifiedPost[]>(initialPosts);
const [meta, setMeta] = useState<NewsMeta | null>(initialMeta);
const [state, setState] = useState<'loading' | 'loaded' | 'error'>('loaded');
const [error, setError] = useState<string>();
const [category, setCategory] = useState('');
const [query, setQuery] = useState('');
const [mustRead, setMustRead] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [sourceTab, setSourceTab] = useState('all');
const [newAvailable, setNewAvailable] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchNews = useCallback(
async (showIndicator = false) => {
if (!showIndicator) setState('loading');
try {
const params = new URLSearchParams();
params.set('limit', '50');
if (category) params.set('category', category);
if (query) params.set('q', query);
if (mustRead) params.set('mustRead', 'true');
const res = await fetch(`/api/feed/news?${params.toString()}`, {
credentials: 'include',
cache: 'no-store',
headers: { 'cache-control': 'no-store' },
});
const data = (await res.json()) as {
data?: UnifiedPost[];
meta?: NewsMeta;
error?: string;
};
if (!res.ok) throw new Error(data.error || 'Failed to load news');
if (showIndicator && data.data && data.data.length > 0) {
setNewAvailable(true);
return;
}
setPosts(data.data || []);
setMeta(data.meta || null);
setState('loaded');
} catch (err) {
if (!showIndicator) {
setError(err instanceof Error ? err.message : 'Failed to load news');
setState('error');
}
}
},
[category, query, mustRead],
);
const refreshNow = useCallback(() => {
setNewAvailable(false);
fetchNews(false);
}, [fetchNews]);
useEffect(() => {
if (category === '' && query === '' && !mustRead) return;
fetchNews(false);
}, [category, fetchNews, mustRead, query]);
useEffect(() => {
intervalRef.current = setInterval(() => fetchNews(true), 60000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchNews]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setQuery(searchInput);
};
const filteredPosts =
sourceTab === 'all'
? posts
: posts.filter((p) => {
const raw = getRaw(p);
const kind = (raw.kind ?? '').toLowerCase();
if (sourceTab === 'rss')
return kind === 'rss' && !raw.sourceName?.toLowerCase().includes('reddit');
if (sourceTab === 'reddit')
return kind === 'reddit' || raw.sourceName?.toLowerCase().includes('reddit');
if (sourceTab === 'hackernews') return kind === 'hackernews';
return true;
});
const sentiment = meta?.sentiment ?? undefined;
const sourceCount = meta?.sourceCount ?? meta?.sources ?? 0;
return (
<div className="mx-auto max-w-7xl px-4 py-6">
{/* AI Summary Header */}
<div className="mb-8">
<div className="mb-4 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex-1">
<div className="mb-1 flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight text-white">DegenFeed News</h1>
<span className="rounded-full bg-degen-500/10 px-2.5 py-0.5 text-[11px] font-medium text-degen-400 ring-1 ring-degen-500/20">
LIVE
</span>
</div>
<p className="mt-1 text-sm text-gray-500">
Curated intelligence from {sourceCount || '200+'} sources across RSS, Reddit, and
Hacker News ranked by quality, recency, and sentiment.
</p>
</div>
<div className="flex shrink-0 items-center gap-3 text-right">
{meta && (
<p className="text-xs text-gray-600">
<span className="text-degen-400">{filteredPosts.length}</span> stories · updated{' '}
{formatTime(meta.fetchedAt)}
</p>
)}
<a
href="/api/feed/rss.xml?category=news"
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-gray-800 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:border-degen-500/30 hover:text-degen-400"
>
RSS
</a>
</div>
</div>
<SentimentBar sentiment={sentiment} />
{/* New stories banner */}
{newAvailable && (
<button
type="button"
onClick={refreshNow}
className="mb-4 w-full animate-pulse rounded-xl border border-degen-500/20 bg-degen-500/10 py-2.5 text-center text-sm font-medium text-degen-400 transition-colors hover:bg-degen-500/20"
>
New stories available click to refresh
</button>
)}
{/* Search + Must Read */}
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center">
<form onSubmit={handleSearch} className="flex-1">
<div className="relative">
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search news, tokens, topics..."
className="input pr-20"
/>
<button
type="submit"
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-lg bg-degen-500 px-3 py-1 text-xs font-medium text-black hover:bg-degen-400"
>
Search
</button>
</div>
</form>
<button
type="button"
onClick={() => setMustRead((v) => !v)}
className={`rounded-xl border px-4 py-2.5 text-xs font-medium transition-colors ${
mustRead
? 'border-degen-500 bg-degen-500/20 text-degen-400'
: 'card-hover text-gray-400'
}`}
>
Must Read
</button>
</div>
{/* Category pills */}
<div className="mb-3 flex flex-wrap gap-1.5">
{CATEGORIES.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setCategory(c.id)}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-all ${
category === c.id
? 'border-degen-500 bg-degen-500/20 text-degen-400 shadow-[0_0_12px_rgba(34,197,94,0.2)]'
: 'card-hover text-gray-400'
}`}
>
{c.emoji} {c.label}
</button>
))}
</div>
{/* Source tabs */}
<div className="flex gap-1 border-b border-white/5 pb-2">
{SOURCE_TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setSourceTab(tab.id)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
sourceTab === tab.id
? 'bg-gray-800 text-white'
: 'text-gray-500 hover:text-gray-300'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Main content */}
<div className="grid gap-6 lg:grid-cols-4">
<div className="lg:col-span-3">
{state === 'error' && (
<div className="card border-red-900/50 bg-red-900/10 py-12 text-center">
<div className="mb-2 text-4xl text-red-400">!</div>
<p className="text-red-300">{error}</p>
<button type="button" onClick={refreshNow} className="btn-primary mt-4 text-sm">
Retry
</button>
</div>
)}
{state === 'loaded' && filteredPosts.length === 0 && (
<div className="card py-12 text-center">
<p className="text-gray-500">No news found for these filters.</p>
</div>
)}
{state === 'loaded' && filteredPosts.length > 0 && (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{filteredPosts.map((post) => (
<NewsCard key={post.id} post={post} />
))}
</div>
)}
</div>
<aside className="space-y-4">
{/* Trending */}
{meta?.trending && meta.trending.length > 0 && (
<div className="card">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-widest text-gray-500">
Trending Topics
</h3>
<div className="flex flex-wrap gap-2">
{meta.trending.map((t) => (
<button
key={t.topic}
type="button"
onClick={() => {
setQuery(t.topic);
setSearchInput(t.topic);
}}
className="rounded-full bg-gray-800/80 px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-gray-700 hover:text-white"
>
#{t.topic}
<span className="ml-1.5 text-[10px] text-gray-500">{t.count}</span>
</button>
))}
</div>
</div>
)}
{/* Sources */}
{meta?.sourceCounts && (
<div className="card">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-widest text-gray-500">
Sources
</h3>
<div className="space-y-1.5">
{Object.entries(meta.sourceCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 12)
.map(([name, count]) => (
<div key={name} className="flex items-center justify-between text-xs">
<span className="text-gray-400">{name}</span>
<span className="rounded-full bg-gray-800/80 px-2 py-0.5 text-gray-500">
{count}
</span>
</div>
))}
</div>
</div>
)}
{/* Newsletter CTA */}
<div className="card border-degen-500/10 bg-gradient-to-br from-degen-500/5 to-gray-900/80">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-widest text-degen-400">
Daily Digest
</h3>
<p className="mb-3 text-[11px] text-gray-500">
Get the top stories delivered to your inbox every morning.
</p>
<a href="/newsletter" className="btn-primary block text-center text-xs">
Subscribe
</a>
</div>
{/* How it works */}
<div className="card">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-widest text-gray-500">
How it works
</h3>
<ul className="space-y-1.5 text-[11px] text-gray-500">
<li> Stories ranked by source quality + recency</li>
<li> Deduplicated by canonical URL</li>
<li> Categorized by AI keyword extraction</li>
<li> Updated every 15 minutes</li>
<li> Aggregated from RSS, Reddit, and Hacker News</li>
</ul>
</div>
</aside>
</div>
</div>
);
}

View file

@ -0,0 +1,36 @@
'use client';
import { useEffect } from 'react';
interface NewsErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function NewsError({ error, reset }: NewsErrorProps) {
useEffect(() => {
console.error('[DegenFeed /news error]', error);
}, [error]);
return (
<div className="mx-auto max-w-2xl px-4 py-20">
<div className="card border-red-900/30 bg-red-950/20 py-12 text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-red-500/10 text-2xl text-red-400">
!
</div>
<h2 className="mb-2 text-xl font-bold text-white">News feed failed to load</h2>
<p className="mb-4 text-sm text-gray-400">
Something went wrong while fetching the latest stories.
</p>
{error.message && (
<code className="mb-6 block rounded-lg bg-black/40 px-4 py-3 text-left text-xs font-mono text-red-300">
{error.message}
</code>
)}
<button type="button" onClick={reset} className="btn-primary text-sm">
Try again
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,47 @@
export default function NewsLoading() {
return (
<div className="mx-auto max-w-7xl px-4 py-6">
<div className="mb-8">
<div className="mb-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="mb-2 h-8 w-56 animate-pulse rounded-xl bg-gray-800" />
<div className="h-4 w-72 animate-pulse rounded-lg bg-gray-800" />
</div>
<div className="h-4 w-32 animate-pulse rounded-lg bg-gray-800" />
</div>
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center">
<div className="h-10 flex-1 animate-pulse rounded-xl bg-gray-800" />
<div className="h-10 w-28 animate-pulse rounded-xl bg-gray-800" />
</div>
<div className="flex flex-wrap gap-2">
{['all', 'bitcoin', 'ethereum', 'solana', 'defi', 'ai', 'security', 'nft'].map((c) => (
<div key={c} className="h-7 w-16 animate-pulse rounded-full bg-gray-800" />
))}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-4">
<div className="lg:col-span-3">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta'].map((k) => (
<div key={k} className="card animate-pulse p-4">
<div className="mb-3 aspect-video w-full rounded-xl bg-gray-800" />
<div className="mb-2 h-3 w-20 rounded-lg bg-gray-800" />
<div className="mb-2 h-5 w-full rounded-lg bg-gray-800" />
<div className="h-4 w-3/4 rounded-lg bg-gray-800" />
</div>
))}
</div>
</div>
<aside className="space-y-4">
<div className="card h-40 animate-pulse" />
<div className="card h-56 animate-pulse" />
<div className="card h-40 animate-pulse" />
</aside>
</div>
</div>
);
}

View file

@ -0,0 +1,61 @@
export const runtime = 'edge';
import type { UnifiedPost } from '@degenfeed/types';
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import { Suspense } from 'react';
import NewsLoading from './loading';
import NewsClient from './NewsClient';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
title: 'News',
description:
'Curated crypto, web3, and AI news from RSS feeds, Mastodon, Bluesky, and Reddit. No paywalls.',
};
interface NewsApiResponse {
data?: unknown[];
meta?: {
total: number;
limit: number;
category: string;
query?: string;
mustRead: boolean;
trending: Array<{ topic: string; count: number; score: number }>;
sourceCounts: Record<string, number>;
fetchedAt: number;
sources: number;
};
error?: string;
}
async function getBaseUrl(): Promise<string> {
const h = await headers();
const host = h.get('host') || 'app.degenfeed.xyz';
const protocol = h.get('x-forwarded-proto') || 'https';
return `${protocol}://${host}`;
}
async function NewsData() {
const url = `${await getBaseUrl()}/api/feed/news?limit=50`;
const res = await fetch(url, {
credentials: 'include',
next: { revalidate: 0 },
cache: 'no-store',
headers: { 'cache-control': 'no-store' },
});
const json = (await res.json()) as NewsApiResponse;
const initialPosts = (json.data || []) as UnifiedPost[];
return <NewsClient initialPosts={initialPosts} initialMeta={json.meta || null} />;
}
export default function NewsPage() {
return (
<Suspense fallback={<NewsLoading />}>
<NewsData />
</Suspense>
);
}

View file

@ -0,0 +1,11 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Newsletter',
description:
'Subscribe to the DegenFeed daily digest. Curated web3, crypto, and AI news from 8 protocols delivered to your inbox.',
};
export default function NewsletterLayout({ children }: { children: React.ReactNode }) {
return children;
}

View file

@ -0,0 +1,471 @@
'use client';
import { useCallback, useState } from 'react';
const ALL_NICHES = [
{ id: 'bitcoin', label: 'Bitcoin' },
{ id: 'ethereum', label: 'Ethereum' },
{ id: 'solana', label: 'Solana' },
{ id: 'defi', label: 'DeFi' },
{ id: 'ai', label: 'AI' },
{ id: 'security', label: 'Security' },
{ id: 'regulation', label: 'Regulation' },
{ id: 'macro', label: 'Macro' },
{ id: 'nft', label: 'NFTs' },
{ id: 'layer2', label: 'Layer 2' },
{ id: 'memecoin', label: 'Memes' },
{ id: 'stablecoin', label: 'Stablecoins' },
{ id: 'etf', label: 'ETFs' },
{ id: 'onchain', label: 'On-Chain' },
];
const ALL_PROTOCOLS = [
{ id: 'nostr', label: 'Nostr', color: '#8e30eb' },
{ id: 'farcaster', label: 'Farcaster', color: '#8a63d2' },
{ id: 'lens', label: 'Lens', color: '#abfe2c' },
{ id: 'bluesky', label: 'Bluesky', color: '#1185fe' },
{ id: 'mastodon', label: 'Mastodon', color: '#6364ff' },
{ id: 'rss', label: 'RSS', color: '#ffa500' },
];
interface DigestItem {
id: string;
protocol: string;
author: string;
text: string;
url: string | null;
image: string | null;
niches: string[];
createdAt: number;
metrics: { likes: number; reposts: number; replies: number; quotes: number };
}
interface DigestMeta {
total: number;
fetchedAt: number;
niches: string[];
protocols: string[];
trending: Array<{ topic: string; count: number; score: number }>;
}
function formatTime(ts: number): string {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
function protocolColor(p: string): string {
const map: Record<string, string> = {
nostr: '#8e30eb',
farcaster: '#8a63d2',
lens: '#abfe2c',
bluesky: '#1185fe',
mastodon: '#6364ff',
threads: '#888',
rss: '#ffa500',
};
return map[p] ?? '#666';
}
function protocolEmoji(p: string): string {
const map: Record<string, string> = {
nostr: '⚡',
farcaster: '🟣',
lens: '🌿',
bluesky: '🦋',
mastodon: '🐘',
threads: '🧵',
rss: '📡',
};
return map[p] ?? '●';
}
export default function NewsletterPage() {
const [email, setEmail] = useState('');
const [selectedNiches, setSelectedNiches] = useState<string[]>([]);
const [selectedProtocols, setSelectedProtocols] = useState<string[]>([]);
const [status, setStatus] = useState<'idle' | 'subscribing' | 'subscribed' | 'error'>('idle');
const [statusMsg, setStatusMsg] = useState('');
const [digest, setDigest] = useState<DigestItem[]>([]);
const [digestMeta, setDigestMeta] = useState<DigestMeta | null>(null);
const [digestLoading, setDigestLoading] = useState(false);
const toggleNiche = (id: string) => {
setSelectedNiches((prev) => (prev.includes(id) ? prev.filter((n) => n !== id) : [...prev, id]));
};
const toggleProtocol = (id: string) => {
setSelectedProtocols((prev) =>
prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id],
);
};
const handleSubscribe = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) {
setStatus('error');
setStatusMsg('Please enter a valid email address.');
return;
}
setStatus('subscribing');
try {
const res = await fetch('/api/newsletter/subscribe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
email,
niches: selectedNiches,
protocols: selectedProtocols.length > 0 ? selectedProtocols : undefined,
}),
});
const data = (await res.json()) as { ok?: boolean; error?: string };
if (!res.ok || !data.ok) throw new Error(data.error || 'Subscription failed');
setStatus('subscribed');
setStatusMsg('You are subscribed! Check your inbox for the daily digest.');
} catch (err) {
setStatus('error');
setStatusMsg(err instanceof Error ? err.message : 'Subscription failed');
}
},
[email, selectedNiches, selectedProtocols],
);
const loadDigest = useCallback(async () => {
setDigestLoading(true);
try {
const params = new URLSearchParams();
if (selectedNiches.length > 0) params.set('niches', selectedNiches.join(','));
if (selectedProtocols.length > 0) params.set('protocols', selectedProtocols.join(','));
const res = await fetch(`/api/newsletter/digest?${params.toString()}`, {
credentials: 'include',
cache: 'no-store',
headers: { 'cache-control': 'no-store' },
});
const data = (await res.json()) as { data?: DigestItem[]; meta?: DigestMeta };
setDigest(data.data ?? []);
setDigestMeta(data.meta ?? null);
} catch {
setDigest([]);
} finally {
setDigestLoading(false);
}
}, [selectedNiches, selectedProtocols]);
return (
<div className="mx-auto max-w-4xl px-4 py-8">
{/* Hero */}
<div className="mb-10 text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-degen-500 to-emerald-600 text-2xl shadow-[0_0_24px_rgba(34,197,94,0.3)]">
</div>
<h1 className="mb-2 text-4xl font-bold tracking-tight text-white">DegenFeed Daily</h1>
<p className="mx-auto max-w-xl text-sm leading-relaxed text-gray-500">
The best of web3, AI, and tech aggregated from Nostr, Farcaster, Lens, Bluesky,
Mastodon, and 200+ RSS sources. Ranked by quality, recency, and diversity. Delivered to
your inbox every morning.
</p>
</div>
<div className="grid gap-8 lg:grid-cols-5">
{/* Subscribe form */}
<div className="lg:col-span-3">
<div className="card border-degen-500/10 bg-gradient-to-br from-gray-900/90 to-gray-950/90 p-6">
<h2 className="mb-1 text-lg font-bold text-white">Subscribe</h2>
<p className="mb-5 text-sm text-gray-500">
Pick your topics and protocols, then enter your email.
</p>
<form onSubmit={handleSubscribe}>
{/* Niche selection */}
<div className="mb-5">
<label
htmlFor="newsletter-topics"
className="mb-2 block text-xs font-semibold uppercase tracking-widest text-gray-500"
>
Topics
</label>
<div id="newsletter-topics" className="flex flex-wrap gap-1.5">
{ALL_NICHES.map((n) => (
<button
key={n.id}
type="button"
onClick={() => toggleNiche(n.id)}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-all ${
selectedNiches.includes(n.id)
? 'border-degen-500 bg-degen-500/20 text-degen-400'
: 'card-hover text-gray-400'
}`}
>
{n.label}
</button>
))}
</div>
</div>
{/* Protocol selection */}
<div className="mb-5">
<label
htmlFor="newsletter-sources"
className="mb-2 block text-xs font-semibold uppercase tracking-widest text-gray-500"
>
Sources
</label>
<div id="newsletter-sources" className="flex flex-wrap gap-1.5">
{ALL_PROTOCOLS.map((p) => (
<button
key={p.id}
type="button"
onClick={() => toggleProtocol(p.id)}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-all ${
selectedProtocols.includes(p.id)
? 'border-degen-500 bg-degen-500/20 text-degen-400'
: 'card-hover text-gray-400'
}`}
>
{p.label}
</button>
))}
</div>
</div>
{/* Email input */}
<div className="mb-4">
<label
htmlFor="newsletter-email"
className="mb-1.5 block text-xs font-medium text-gray-400"
>
Email address
</label>
<input
id="newsletter-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="input"
disabled={status === 'subscribed'}
/>
</div>
<button
type="submit"
disabled={status === 'subscribing' || status === 'subscribed'}
className="btn-primary w-full"
>
{status === 'subscribing'
? 'Subscribing...'
: status === 'subscribed'
? 'Subscribed ✓'
: 'Subscribe'}
</button>
{status === 'error' && <p className="mt-2 text-xs text-red-400">{statusMsg}</p>}
{status === 'subscribed' && (
<p className="mt-2 text-xs text-degen-400">{statusMsg}</p>
)}
</form>
</div>
{/* Digest preview */}
<div className="mt-6">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Preview Digest</h2>
<button
type="button"
onClick={loadDigest}
disabled={digestLoading}
className="rounded-lg border border-gray-800 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:border-degen-500/30 hover:text-degen-400"
>
{digestLoading ? 'Loading...' : 'Load Preview'}
</button>
</div>
{digest.length === 0 && !digestLoading && (
<div className="card py-8 text-center">
<p className="text-xs text-gray-500">
Select topics/sources above and click "Load Preview" to see what your daily digest
will look like.
</p>
</div>
)}
{digestLoading && (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="card animate-pulse p-4">
<div className="mb-2 h-3 w-32 rounded bg-gray-800" />
<div className="mb-2 h-4 w-full rounded bg-gray-800" />
<div className="h-3 w-3/4 rounded bg-gray-800" />
</div>
))}
</div>
)}
{digest.length > 0 && !digestLoading && (
<div className="space-y-3">
{digestMeta?.trending && digestMeta.trending.length > 0 && (
<div className="mb-3 flex flex-wrap gap-1.5">
{digestMeta.trending.slice(0, 6).map((t) => (
<span
key={t.topic}
className="rounded-full bg-gray-800/80 px-2 py-0.5 text-[10px] text-gray-400"
>
#{t.topic} <span className="text-gray-600">{t.count}</span>
</span>
))}
</div>
)}
{digest.slice(0, 5).map((item) => (
<div key={item.id} className="card p-4">
<div className="mb-1 flex items-center gap-2">
<span
className="text-[10px] font-medium"
style={{ color: protocolColor(item.protocol) }}
>
{protocolEmoji(item.protocol)} {item.protocol}
</span>
<span className="text-[10px] text-gray-600">{item.author}</span>
<span className="ml-auto text-[10px] text-gray-600">
{formatTime(item.createdAt)}
</span>
</div>
<p className="text-sm leading-snug text-gray-300 line-clamp-2">{item.text}</p>
{item.url && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-block text-[11px] text-degen-500 hover:underline"
>
Read more
</a>
)}
</div>
))}
{digestMeta && (
<p className="text-center text-[10px] text-gray-600">
{digestMeta.total} stories · {new Date(digestMeta.fetchedAt).toUTCString()}
</p>
)}
</div>
)}
</div>
</div>
{/* Sidebar */}
<div className="lg:col-span-2">
<div className="space-y-4">
<div className="card">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-widest text-gray-500">
What you get
</h3>
<ul className="space-y-2 text-xs text-gray-400">
<li className="flex items-start gap-2">
<span className="mt-0.5 text-degen-400"></span>
<span>Top 15 stories from all protocols, ranked by quality</span>
</li>
<li className="flex items-start gap-2">
<span className="mt-0.5 text-degen-400"></span>
<span>Sentiment analysis and trending topics</span>
</li>
<li className="flex items-start gap-2">
<span className="mt-0.5 text-degen-400"></span>
<span>
Multi-protocol aggregation (Nostr, Farcaster, Lens, Bluesky, Mastodon, RSS)
</span>
</li>
<li className="flex items-start gap-2">
<span className="mt-0.5 text-degen-400"></span>
<span>Curated RSS and protocol feeds (50+ sources)</span>
</li>
<li className="flex items-start gap-2">
<span className="mt-0.5 text-degen-400"></span>
<span>HTML and plaintext versions</span>
</li>
<li className="flex items-start gap-2">
<span className="mt-0.5 text-degen-400"></span>
<span>One-click unsubscribe</span>
</li>
</ul>
</div>
<div className="card">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-widest text-gray-500">
RSS Feeds
</h3>
<ul className="space-y-2 text-xs text-gray-400">
<li>
<a
href="/api/feed/rss.xml"
target="_blank"
rel="noopener noreferrer"
className="text-degen-500 hover:underline"
>
Full Feed (RSS 2.0)
</a>
</li>
<li>
<a
href="/api/feed/rss.xml?format=atom"
target="_blank"
rel="noopener noreferrer"
className="text-degen-500 hover:underline"
>
Full Feed (Atom 1.0)
</a>
</li>
<li>
<a
href="/api/feed/rss.xml?category=bitcoin"
target="_blank"
rel="noopener noreferrer"
className="text-degen-500 hover:underline"
>
Bitcoin Feed
</a>
</li>
<li>
<a
href="/api/feed/rss.xml?category=defi"
target="_blank"
rel="noopener noreferrer"
className="text-degen-500 hover:underline"
>
DeFi Feed
</a>
</li>
</ul>
</div>
<div className="card">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-widest text-gray-500">
API
</h3>
<ul className="space-y-1.5 text-xs text-gray-400">
<li>
<code className="rounded bg-gray-800 px-1.5 py-0.5 text-[10px] text-gray-500">
GET /api/newsletter/digest
</code>
</li>
<li>
<code className="rounded bg-gray-800 px-1.5 py-0.5 text-[10px] text-gray-500">
GET /api/newsletter/digest?format=html
</code>
</li>
<li>
<code className="rounded bg-gray-800 px-1.5 py-0.5 text-[10px] text-gray-500">
GET /api/newsletter/digest?format=text
</code>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,79 @@
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export const alt = 'DegenFeed — The social feed of web3';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default function Image() {
return new ImageResponse(
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, #000000 0%, #0a0f0a 50%, #000000 100%)',
fontFamily: 'Inter',
}}
>
<div
style={{
display: 'flex',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
opacity: 0.03,
backgroundImage:
'radial-gradient(circle at 30% 50%, #00ff41 0%, transparent 60%), radial-gradient(circle at 70% 50%, #00ff41 0%, transparent 60%)',
}}
/>
<div
style={{
display: 'flex',
fontSize: 96,
fontWeight: 900,
color: '#00ff41',
letterSpacing: '-0.03em',
textShadow: '0 0 80px rgba(0, 255, 65, 0.3), 0 0 160px rgba(0, 255, 65, 0.15)',
marginBottom: 24,
}}
>
DegenFeed
</div>
<div
style={{
display: 'flex',
fontSize: 32,
fontWeight: 500,
color: '#9ca3af',
marginBottom: 80,
}}
>
The social feed of web3
</div>
<div
style={{
display: 'flex',
position: 'absolute',
bottom: 60,
left: 0,
right: 0,
justifyContent: 'center',
fontSize: 24,
fontWeight: 400,
color: '#4b5563',
}}
>
degenfeed.xyz &middot; Open source, zero sign-ups
</div>
</div>,
{ ...size },
);
}

16
apps/web/src/app/page.tsx Normal file
View file

@ -0,0 +1,16 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function RootPage() {
const router = useRouter();
useEffect(() => {
router.replace('/feed');
}, [router]);
return (
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-sm text-gray-500">Loading</p>
</div>
);
}

View file

@ -0,0 +1,11 @@
export const runtime = "edge";
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Post',
description: 'View post details on DegenFeed.',
};
export default function PostLayout({ children }: { children: React.ReactNode }) {
return children;
}

View file

@ -0,0 +1,372 @@
'use client';
import { addBookmark, removeBookmark } from '@degenfeed/storage';
import type { EngagementAction, UnifiedPost } from '@degenfeed/types';
import Image from 'next/image';
import { useCallback, useEffect, useState } from 'react';
import { useAuth } from '../../../components/AuthProvider';
import { TipModal } from '../../../components/TipModal';
import { sanitizeHtml } from '../../../lib/sanitize';
function formatTime(ts: number): string {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
function protocolColor(p: string): string {
const c: Record<string, string> = {
nostr: '#8e30eb',
farcaster: '#8a63d2',
lens: '#abfe2c',
bluesky: '#1185fe',
threads: '#888',
mastodon: '#6364ff',
rss: '#ffa500',
};
return c[p] ?? '#666';
}
function protocolEmoji(p: string): string {
const m: Record<string, string> = {
nostr: '⚡',
farcaster: '🟣',
lens: '🌿',
bluesky: '🦋',
mastodon: '🐘',
threads: '🧵',
rss: '📡',
};
return m[p] ?? '●';
}
function PostCard({
post,
onEngage,
onTip,
}: {
post: UnifiedPost;
onEngage: (action: EngagementAction, id: string) => Promise<{ ok: boolean; error?: string }>;
onTip: (post: UnifiedPost) => void;
}) {
const [liked, setLiked] = useState(post.engagement.viewerLiked);
const [reposted, setReposted] = useState(post.engagement.viewerReposted);
const [bookmarked, setBookmarked] = useState(post.engagement.viewerBookmarked);
const [metrics, setMetrics] = useState(post.metrics);
const [engaging, setEngaging] = useState<string | null>(null);
const handleAction = useCallback(
async (action: EngagementAction) => {
setEngaging(action);
const result = await onEngage(action, post.id);
if (result.ok) {
if (action === 'like') {
setLiked((v) => !v);
setMetrics((m) => ({ ...m, likes: m.likes + (liked ? -1 : 1) }));
}
if (action === 'repost') {
setReposted((v) => !v);
setMetrics((m) => ({ ...m, reposts: m.reposts + (reposted ? -1 : 1) }));
}
if (action === 'bookmark') {
setBookmarked(true);
}
if (action === 'unbookmark') {
setBookmarked(false);
}
}
setEngaging(null);
},
[post.id, onEngage, liked, reposted],
);
const imageEmbed = post.embeds.find((e) => e.kind === 'image');
const linkEmbed = post.embeds.find((e) => e.kind === 'link');
return (
<article className="card border border-white/5">
{/* Author */}
<div className="mb-4 flex items-center gap-3">
<div
className="flex h-10 w-10 items-center justify-center rounded-full text-sm font-bold"
style={{
backgroundColor: `${protocolColor(post.protocol)}22`,
color: protocolColor(post.protocol),
}}
>
{post.author.displayName[0]?.toUpperCase() || '?'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-white truncate">
{post.author.displayName}
</span>
<span
className="text-[10px] text-gray-500"
style={{ color: protocolColor(post.protocol) }}
>
{protocolEmoji(post.protocol)} {post.protocol}
</span>
</div>
<div className="flex items-center gap-2 text-[11px] text-gray-600">
<span>{formatTime(post.createdAt)} ago</span>
{post.parentId && <span>· reply</span>}
</div>
</div>
</div>
{/* Content */}
<div className="mb-4">
<p className="text-sm leading-relaxed text-gray-200 whitespace-pre-wrap break-words">
{post.text}
</p>
{post.html && (
<div
// biome-ignore lint/security/noDangerouslySetInnerHtml: content is sanitized via DOMPurify before rendering
dangerouslySetInnerHTML={{ __html: sanitizeHtml(post.html) }}
className="mt-2 text-sm leading-relaxed text-gray-400"
/>
)}
</div>
{/* Embeds */}
{imageEmbed && (
<div className="mb-4 overflow-hidden rounded-xl">
<Image
src={imageEmbed.url}
alt=""
width={800}
height={400}
className="w-full object-cover"
loading="lazy"
unoptimized
/>
</div>
)}
{linkEmbed && (
<a
href={linkEmbed.url}
target="_blank"
rel="noopener noreferrer"
className="mb-4 block rounded-xl border border-white/5 bg-gray-900/50 p-3 text-xs text-gray-400 hover:border-degen-500/20 hover:text-degen-400 transition-colors"
>
{linkEmbed.url}
</a>
)}
{/* Niches */}
{post.niches.length > 0 && (
<div className="mb-4 flex flex-wrap gap-1">
{post.niches.map((n) => (
<span
key={n}
className="rounded-full bg-gray-800/60 px-2 py-0.5 text-[10px] text-gray-500"
>
#{n}
</span>
))}
</div>
)}
{/* Metrics */}
<div className="mb-4 flex items-center gap-4 text-xs text-gray-500">
<span>{metrics.likes} likes</span>
<span>{metrics.reposts} reposts</span>
<span>{metrics.replies} replies</span>
<span>{metrics.quotes} quotes</span>
</div>
{/* Actions */}
<div className="flex items-center gap-2 border-t border-white/5 pt-3">
<button
type="button"
onClick={() => handleAction('like')}
disabled={engaging === 'like'}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
liked
? 'bg-degen-500/20 text-degen-400'
: 'text-gray-500 hover:bg-gray-800 hover:text-white'
}`}
>
{liked ? '♥' : '♡'} {metrics.likes}
</button>
<button
type="button"
onClick={() => handleAction('repost')}
disabled={engaging === 'repost'}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
reposted
? 'bg-green-500/20 text-green-400'
: 'text-gray-500 hover:bg-gray-800 hover:text-white'
}`}
>
{metrics.reposts}
</button>
<button
type="button"
onClick={() => handleAction(bookmarked ? 'unbookmark' : 'bookmark')}
disabled={engaging === 'bookmark' || engaging === 'unbookmark'}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
bookmarked
? 'bg-yellow-500/20 text-yellow-400'
: 'text-gray-500 hover:bg-gray-800 hover:text-white'
}`}
>
{bookmarked ? '★' : '☆'}
</button>
<button
type="button"
onClick={() => onTip(post)}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-gray-500 hover:bg-gray-800 hover:text-white transition-colors"
>
Tip
</button>
</div>
</article>
);
}
interface Props {
params: Promise<{ id: string }>;
}
export default function PostPage({ params }: Props) {
const [id, setId] = useState<string | null>(null);
const [post, setPost] = useState<UnifiedPost | null>(null);
const [state, setState] = useState<'loading' | 'loaded' | 'error'>('loading');
const [error, setError] = useState<string>();
const [tipPost, setTipPost] = useState<UnifiedPost | null>(null);
const { engage } = useAuth();
useEffect(() => {
params.then((p) => setId(decodeURIComponent(p.id)));
}, [params]);
useEffect(() => {
if (!id) return;
const postId = id;
let cancelled = false;
async function load() {
setState('loading');
try {
// Use dedicated post endpoint — doesn't hammer the rate limit
// or fetch the entire home feed for one post.
const res = await fetch(`/api/feed/post/${encodeURIComponent(postId)}`, {
credentials: 'include',
});
const data = (await res.json()) as { data?: UnifiedPost; error?: string };
if (!res.ok || !data.data) throw new Error(data.error || 'Post not found');
if (cancelled) return;
setPost(data.data);
setState('loaded');
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Post not found');
setState('error');
}
}
}
load();
return () => {
cancelled = true;
};
}, [id]);
const handleEngage = useCallback(
async (action: EngagementAction, postId: string) => {
if (action === 'bookmark' || action === 'unbookmark') {
try {
if (action === 'bookmark') await addBookmark(postId);
else await removeBookmark(postId);
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Bookmark failed' };
}
}
return engage(action, postId, post?.protocol ?? 'rss');
},
[engage, post],
);
return (
<div className="mx-auto max-w-2xl px-4 py-6">
<a
href="/home"
className="mb-6 inline-flex items-center gap-1 text-sm text-gray-500 transition-colors hover:text-white"
>
&larr; Back to feed
</a>
{state === 'loading' && (
<div className="card animate-pulse p-6">
<div className="mb-4 flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-gray-800" />
<div className="flex-1">
<div className="mb-1 h-4 w-32 rounded bg-gray-800" />
<div className="h-3 w-20 rounded bg-gray-800" />
</div>
</div>
<div className="mb-3 h-4 w-full rounded bg-gray-800" />
<div className="mb-3 h-4 w-3/4 rounded bg-gray-800" />
<div className="h-4 w-1/2 rounded bg-gray-800" />
</div>
)}
{state === 'error' && (
<div className="card border-red-900/30 bg-red-950/20 py-12 text-center">
<p className="text-red-300">{error || 'Post not found'}</p>
<a href="/home" className="btn-primary mt-4 inline-block text-sm">
Back to feed
</a>
</div>
)}
{state === 'loaded' && post && (
<>
<PostCard post={post} onEngage={handleEngage} onTip={setTipPost} />
{/* Thread context */}
{post.parentId && (
<div className="mt-4 rounded-xl border border-white/5 bg-gray-900/30 p-3">
<p className="text-[11px] text-gray-500">
This is a reply.{' '}
<a
href={`/post/${encodeURIComponent(post.parentId)}`}
className="text-degen-500 hover:underline"
>
View parent post
</a>
</p>
</div>
)}
{/* Raw data for power users */}
<details className="mt-6">
<summary className="cursor-pointer text-xs text-gray-600 hover:text-gray-400">
Raw data
</summary>
<pre className="mt-2 overflow-x-auto rounded-xl bg-gray-950 p-4 text-[10px] text-gray-500">
{JSON.stringify(post.raw, null, 2)}
</pre>
</details>
</>
)}
{tipPost && (
<TipModal
open={!!tipPost}
onClose={() => setTipPost(null)}
recipientProtocol={tipPost.protocol}
recipientAuthorId={tipPost.author.id}
recipientDisplayName={tipPost.author.displayName}
publicationId={
tipPost.protocol === 'lens' ? (tipPost.raw as { id?: string })?.id : undefined
}
/>
)}
</div>
);
}

View file

@ -0,0 +1,11 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Premium',
description:
'Unlock advanced filtering, custom feeds, noise reduction, analytics, and cross-posting. $5/mo Pro, $15/mo Creator.',
};
export default function PremiumLayout({ children }: { children: React.ReactNode }) {
return children;
}

View file

@ -0,0 +1,188 @@
'use client';
const TIERS = [
{
id: 'free',
name: 'Free',
price: '$0',
description: 'Read all feeds and engage publicly.',
features: [
'Read all ranked feeds',
'Public likes/replies where supported',
'Basic tipping',
'Wallet sign-in (ETH + SOL)',
],
cta: 'Get started',
popular: false,
},
{
id: 'pro',
name: 'Pro',
price: '$5/mo',
description: 'Support the network and unlock power-user tools.',
features: [
'Cross-post to all connected platforms',
'Advanced algorithm controls (recency/engagement/diversity)',
'Follow authors and mute across the open social graph',
'Bookmark sync across devices',
'Priority support',
],
cta: 'Upgrade to Pro',
popular: true,
},
{
id: 'creator',
name: 'Creator',
price: '$15/mo',
description: 'Monetize with paywalls and premium content.',
features: [
'Everything in Pro',
'Paywalled premium posts (x402 protocol)',
'Tip jar + monetization settings',
'Verified creator badge',
],
cta: 'Become a Creator',
popular: false,
},
{
id: 'enterprise',
name: 'Enterprise',
price: 'Custom',
description: 'White-label, API access, and custom integrations.',
features: [
'Everything in Creator',
'White-label embeddable feed',
'API access with SLA',
'Custom domains and branding',
'Dedicated support channel',
],
cta: 'Contact us',
popular: false,
},
];
export const dynamic = 'force-dynamic';
function handleSubscribe(tier: string) {
// Open the billing checkout in a new tab. The endpoint returns JSON
// with a payment URL; for now we open a direct link to the endpoint
// and the user can follow the payment flow from there.
window.open(`/api/billing/checkout?tier=${tier}`, '_blank');
}
export default function PremiumPage() {
return (
<div className="mx-auto max-w-5xl px-4 py-10">
<div className="mb-10 text-center">
<h1 className="text-3xl font-bold text-white">Support DegenFeed</h1>
<p className="mx-auto mt-2 max-w-xl text-sm text-gray-500">
Upgrade with crypto to support the network and unlock pro tools for power users and
creators.
</p>
</div>
<div className="grid gap-6 md:grid-cols-4">
{TIERS.map((tier) => (
<div
key={tier.id}
className={`card relative flex flex-col ${
tier.popular ? 'border-degen-500/50 bg-degen-500/10' : 'border-gray-800'
}`}
>
{tier.popular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-degen-500 px-3 py-0.5 text-xs font-semibold text-black">
Most popular
</div>
)}
<h2 className="text-xl font-bold text-white">{tier.name}</h2>
<div className="my-2 text-2xl font-bold text-degen-400">{tier.price}</div>
<p className="mb-4 text-sm text-gray-500">{tier.description}</p>
<ul className="mb-6 flex-1 space-y-2 text-sm text-gray-400">
{tier.features.map((f) => (
<li key={f} className="flex items-start gap-2">
<span className="text-degen-500"></span>
<span>{f}</span>
</li>
))}
</ul>
<button
type="button"
onClick={() => tier.id !== 'free' && handleSubscribe(tier.id)}
className={`w-full rounded-lg py-2 text-sm font-semibold ${
tier.popular
? 'bg-degen-500 text-black hover:bg-degen-400'
: 'bg-gray-800 text-white hover:bg-gray-700'
}`}
>
{tier.cta}
</button>
</div>
))}
</div>
<div className="card mt-8 border-degen-500/20 bg-degen-500/5 py-6 text-center">
<p className="text-sm text-gray-400">
Payments are processed on Solana via Blinks. No fiat. No KYC. Cancel anytime.
</p>
<p className="mt-2 text-xs text-gray-600">
All paid features are open source and available in our self-hosted build.
</p>
</div>
<div className="mt-12 grid gap-8 md:grid-cols-3">
<div className="card">
<h3 className="text-lg font-semibold text-white">Open Source</h3>
<p className="mt-2 text-sm text-gray-400">
All code is AGPL-licensed on Forgejo, GitLab, and Codeberg. Self-host if you prefer.
</p>
</div>
<div className="card">
<h3 className="text-lg font-semibold text-white">No Lock-In</h3>
<p className="mt-2 text-sm text-gray-400">
Cancel anytime. Your data is always yours export it with one click.
</p>
</div>
<div className="card">
<h3 className="text-lg font-semibold text-white">Privacy First</h3>
<p className="mt-2 text-sm text-gray-400">
No tracking, no ads, no data sales. Premium funds development directly.
</p>
</div>
</div>
<div className="mt-12">
<h2 className="mb-6 text-xl font-bold text-white">FAQ</h2>
<div className="space-y-4">
<div className="card">
<h3 className="font-semibold text-white">How do payments work?</h3>
<p className="mt-1 text-sm text-gray-400">
Payments are processed on-chain via Solana Blinks. Connect your wallet, sign a
transaction, and you are upgraded instantly. No credit cards, no KYC.
</p>
</div>
<div className="card">
<h3 className="font-semibold text-white">Can I cancel anytime?</h3>
<p className="mt-1 text-sm text-gray-400">
Yes. Cancel from your profile settings. Your subscription ends at the current billing
period. No questions asked.
</p>
</div>
<div className="card">
<h3 className="font-semibold text-white">What is x402 paywall?</h3>
<p className="mt-1 text-sm text-gray-400">
x402 is an open protocol for HTTP 402 Payment Required. Creator-tier users can paywall
individual posts readers pay per-view in USDC. Learn more at x402.org.
</p>
</div>
<div className="card">
<h3 className="font-semibold text-white">Do I need premium to browse?</h3>
<p className="mt-1 text-sm text-gray-400">
No. Browsing DegenFeed is free forever. Premium unlocks power-user and creator tools
while directly funding open-source development.
</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,113 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Privacy Policy',
description:
'Privacy policy for DegenFeed, operated by Rug Munch Media LLC. No tracking, no ads, no data sales.',
};
export default function PrivacyPage() {
return (
<div className="mx-auto max-w-2xl px-4 py-8 text-sm leading-relaxed text-gray-300">
<h1 className="mb-6 text-2xl font-bold text-white">Privacy Policy</h1>
<p className="mb-4 text-gray-500">Last updated: July 2026</p>
<h2 className="mb-2 text-lg font-semibold text-white">1. What We Collect</h2>
<p className="mb-4">DegenFeed is an open-source social aggregator. We store:</p>
<ul className="mb-4 list-inside list-disc space-y-1">
<li>Wallet addresses (Ethereum/Solana) you choose to sign in with.</li>
<li>
Social protocol credentials (Nostr nsec, Bluesky app passwords, etc.) encrypted in your
browser <strong>never sent to our servers</strong>.
</li>
<li>Email addresses if you subscribe to the newsletter.</li>
<li>
Preferences (hide low-quality, non-crypto toggle, favorite niches) stored in Cloudflare
KV, keyed to your wallet address.
</li>
</ul>
<h2 className="mb-2 text-lg font-semibold text-white">2. What We Do NOT Collect</h2>
<ul className="mb-4 list-inside list-disc space-y-1">
<li>Your name, phone number, or physical address.</li>
<li>IP addresses (Cloudflare may log them for DDoS protection; we do not store them).</li>
<li>Browsing history outside DegenFeed.</li>
<li>
Cookies beyond the session cookie (
<code className="rounded bg-gray-800 px-1 text-xs">__Host-dfid</code>) used for sign-in.
</li>
</ul>
<h2 className="mb-2 text-lg font-semibold text-white">3. How We Use Data</h2>
<p className="mb-4">
Your wallet address is used to personalize your feed (show posts from followed accounts,
apply your quality filter). Newsletter emails are used only to send the daily digest.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">4. Your Rights</h2>
<p className="mb-4">You can export or delete all your data at any time:</p>
<ul className="mb-4 list-inside list-disc space-y-1">
<li>
Export: sign in and visit{' '}
<code className="rounded bg-gray-800 px-1 text-xs">/api/account/export</code>
</li>
<li>
Delete: sign in and POST to{' '}
<code className="rounded bg-gray-800 px-1 text-xs">/api/account/delete</code>
</li>
<li>All data is stored in Cloudflare KV. Deletion is immediate and permanent.</li>
</ul>
<h2 className="mb-2 text-lg font-semibold text-white">5. GDPR Rights (EU/EEA Users)</h2>
<p className="mb-4">
If you are in the EU or EEA, you have the right to access, rectify, delete, and port your
data. To exercise these rights, contact{' '}
<a href="mailto:admin@rugmunch.io" className="text-degen-400 underline">
admin@rugmunch.io
</a>
. Our Data Protection Officer can be reached at{' '}
<a href="mailto:legal@rugmunch.io" className="text-degen-400 underline">
legal@rugmunch.io
</a>
.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">6. CCPA Rights (California Users)</h2>
<p className="mb-4">
California residents have the right to know what personal data we collect, request deletion,
and opt out of data sales. DegenFeed does not sell personal data. To exercise CCPA rights,
email{' '}
<a href="mailto:admin@rugmunch.io" className="text-degen-400 underline">
admin@rugmunch.io
</a>
.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">7. COPPA Compliance</h2>
<p className="mb-4">
DegenFeed does not knowingly collect personal information from children under 13. If you
believe a child has provided us with personal data, contact{' '}
<a href="mailto:admin@rugmunch.io" className="text-degen-400 underline">
admin@rugmunch.io
</a>{' '}
and we will delete it promptly.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">8. Third Parties</h2>
<p className="mb-4">
We use Cloudflare (infrastructure) and Plausible (anonymous analytics). No ad networks, no
trackers, no data brokers.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">9. Contact</h2>
<p>
DegenFeed is operated by Rug Munch Media LLC, a Wyoming limited liability company. For
privacy questions, contact{' '}
<a href="mailto:admin@rugmunch.io" className="text-degen-400 underline">
admin@rugmunch.io
</a>
.
</p>
</div>
);
}

View file

@ -0,0 +1,10 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Profile',
description: 'Manage your DegenFeed profile, connected wallets, and social accounts.',
};
export default function ProfileLayout({ children }: { children: React.ReactNode }) {
return children;
}

View file

@ -0,0 +1,288 @@
'use client';
import type { MonetizationPreferences, StoredIdentity } from '@degenfeed/storage';
import {
getDefaultMonetizationPreferences,
getMonetizationPreferences,
getPrimaryIdentity,
saveMonetizationPreferences,
} from '@degenfeed/storage';
import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
interface FormState {
evmAddress: string;
solanaAddress: string;
lightningAddress: string;
farcasterCustodyAddress: string;
lensProfile: string;
nostrPubkey: string;
preferredMethod: string;
}
const METHOD_OPTIONS = [
{ value: '', label: 'Auto (recommended)' },
{ value: 'ethereum', label: 'Ethereum / EVM' },
{ value: 'solana', label: 'Solana' },
{ value: 'lightning_zap', label: 'Lightning Zap' },
{ value: 'farcaster_frame', label: 'Farcaster Frame' },
{ value: 'lens_collect', label: 'Lens Collect' },
];
const EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
const SOLANA_ADDRESS_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
function isValidEvmAddress(value: string): boolean {
return value === '' || EVM_ADDRESS_RE.test(value);
}
function isValidSolanaAddress(value: string): boolean {
return value === '' || SOLANA_ADDRESS_RE.test(value);
}
function isValidLightningAddress(value: string): boolean {
if (value === '') return true;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
function toFormState(prefs: MonetizationPreferences): FormState {
return {
evmAddress: prefs.evmAddress || '',
solanaAddress: prefs.solanaAddress || '',
lightningAddress: prefs.lightningAddress || '',
farcasterCustodyAddress: prefs.farcasterCustodyAddress || '',
lensProfile: prefs.lensProfile || '',
nostrPubkey: prefs.nostrPubkey || '',
preferredMethod: prefs.preferredMethod || '',
};
}
export default function MonetizationPage() {
const [identity, setIdentity] = useState<StoredIdentity | undefined>(undefined);
const [form, setForm] = useState<FormState>(toFormState(getDefaultMonetizationPreferences('')));
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
useEffect(() => {
const load = async () => {
const primary = await getPrimaryIdentity();
setIdentity(primary);
if (primary) {
const prefs =
(await getMonetizationPreferences(primary.id)) ||
getDefaultMonetizationPreferences(primary.id);
setForm(toFormState(prefs));
}
};
load().catch(() => {});
}, []);
const validate = useCallback((next: FormState): boolean => {
const nextErrors: Partial<Record<keyof FormState, string>> = {};
if (!isValidEvmAddress(next.evmAddress)) {
nextErrors.evmAddress = 'Invalid EVM address';
}
if (!isValidSolanaAddress(next.solanaAddress)) {
nextErrors.solanaAddress = 'Invalid Solana address';
}
if (!isValidLightningAddress(next.lightningAddress)) {
nextErrors.lightningAddress = 'Invalid Lightning address';
}
if (!isValidEvmAddress(next.farcasterCustodyAddress)) {
nextErrors.farcasterCustodyAddress = 'Invalid Farcaster custody address';
}
setErrors(nextErrors);
return Object.keys(nextErrors).length === 0;
}, []);
const handleChange = useCallback(
(field: keyof FormState, value: string) => {
const next = { ...form, [field]: value };
setForm(next);
validate(next);
setStatus('idle');
},
[form, validate],
);
const handleSubmit = useCallback(
async (event: React.FormEvent) => {
event.preventDefault();
if (!identity || !validate(form)) return;
setStatus('saving');
try {
const payload: MonetizationPreferences = {
identityId: identity.id,
updatedAt: Date.now(),
...(form.evmAddress ? { evmAddress: form.evmAddress } : {}),
...(form.solanaAddress ? { solanaAddress: form.solanaAddress } : {}),
...(form.lightningAddress ? { lightningAddress: form.lightningAddress } : {}),
...(form.farcasterCustodyAddress
? { farcasterCustodyAddress: form.farcasterCustodyAddress }
: {}),
...(form.lensProfile ? { lensProfile: form.lensProfile } : {}),
...(form.nostrPubkey ? { nostrPubkey: form.nostrPubkey } : {}),
...(form.preferredMethod ? { preferredMethod: form.preferredMethod } : {}),
};
const res = await fetch('/api/tip/settings', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Server rejected settings');
await saveMonetizationPreferences(payload);
setStatus('saved');
} catch {
setStatus('error');
}
},
[form, identity, validate],
);
return (
<div className="mx-auto max-w-2xl px-4 py-6">
<div className="mb-6 flex items-center gap-3">
<Link
href="/profile"
className="rounded-lg bg-gray-800 px-3 py-1.5 text-xs text-gray-400 hover:bg-gray-700 hover:text-white transition-colors"
>
Back
</Link>
<h1 className="text-xl font-bold text-white">Creator Monetization</h1>
</div>
<div className="card mb-6 border-degen-500/20 bg-degen-500/5 p-4">
<p className="text-sm text-degen-400">
Configure how people can tip you across Nostr, Lens, Farcaster, and direct crypto.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="card space-y-4">
<h2 className="text-sm font-medium uppercase tracking-widest text-gray-500">
Direct Crypto
</h2>
<label className="block">
<span className="mb-1 block text-xs text-gray-400">
EVM Address (Ethereum, Base, Optimism, Arbitrum, Polygon)
</span>
<input
type="text"
value={form.evmAddress}
onChange={(e) => handleChange('evmAddress', e.target.value)}
placeholder="0x..."
className="w-full rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-2.5 text-sm text-white outline-none focus:border-degen-500 transition-colors"
/>
{errors.evmAddress && <p className="mt-1 text-xs text-red-400">{errors.evmAddress}</p>}
</label>
<label className="block">
<span className="mb-1 block text-xs text-gray-400">Solana Address</span>
<input
type="text"
value={form.solanaAddress}
onChange={(e) => handleChange('solanaAddress', e.target.value)}
placeholder="7x..."
className="w-full rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-2.5 text-sm text-white outline-none focus:border-degen-500 transition-colors"
/>
{errors.solanaAddress && (
<p className="mt-1 text-xs text-red-400">{errors.solanaAddress}</p>
)}
</label>
</div>
<div className="card space-y-4">
<h2 className="text-sm font-medium uppercase tracking-widest text-gray-500">
Platform Tipping
</h2>
<label className="block">
<span className="mb-1 block text-xs text-gray-400">Lightning Address / LNURL</span>
<input
type="text"
value={form.lightningAddress}
onChange={(e) => handleChange('lightningAddress', e.target.value)}
placeholder="you@walletofsatoshi.com"
className="w-full rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-2.5 text-sm text-white outline-none focus:border-degen-500 transition-colors"
/>
{errors.lightningAddress && (
<p className="mt-1 text-xs text-red-400">{errors.lightningAddress}</p>
)}
</label>
<label className="block">
<span className="mb-1 block text-xs text-gray-400">Farcaster Custody Address</span>
<input
type="text"
value={form.farcasterCustodyAddress}
onChange={(e) => handleChange('farcasterCustodyAddress', e.target.value)}
placeholder="0x..."
className="w-full rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-2.5 text-sm text-white outline-none focus:border-degen-500 transition-colors"
/>
{errors.farcasterCustodyAddress && (
<p className="mt-1 text-xs text-red-400">{errors.farcasterCustodyAddress}</p>
)}
</label>
<label className="block">
<span className="mb-1 block text-xs text-gray-400">Lens Profile (id or handle)</span>
<input
type="text"
value={form.lensProfile}
onChange={(e) => handleChange('lensProfile', e.target.value)}
placeholder="0x01 or @handle"
className="w-full rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-2.5 text-sm text-white outline-none focus:border-degen-500 transition-colors"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-gray-400">Nostr Pubkey (for zaps)</span>
<input
type="text"
value={form.nostrPubkey}
onChange={(e) => handleChange('nostrPubkey', e.target.value)}
placeholder="npub1... or hex"
className="w-full rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-2.5 text-sm text-white outline-none focus:border-degen-500 transition-colors"
/>
</label>
</div>
<div className="card space-y-4">
<label className="block">
<span className="mb-1 block text-xs text-gray-400">Preferred Default Method</span>
<select
value={form.preferredMethod}
onChange={(e) => handleChange('preferredMethod', e.target.value)}
className="w-full rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-2.5 text-sm text-white outline-none focus:border-degen-500 transition-colors"
>
{METHOD_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={status === 'saving' || Object.keys(errors).length > 0}
className="btn-primary text-sm disabled:opacity-50"
>
{status === 'saving' ? 'Saving...' : 'Save Settings'}
</button>
{status === 'saved' && <span className="text-xs text-green-400">Saved locally </span>}
{status === 'error' && (
<span className="text-xs text-red-400">Could not save. Try again.</span>
)}
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,566 @@
'use client';
import type { StoredIdentity } from '@degenfeed/storage';
import Image from 'next/image';
import { QRCodeSVG } from 'qrcode.react';
import { useMemo } from 'react';
import { useAuth } from '../../components/AuthProvider';
import { IdentityLinker } from '../../components/IdentityLinker';
import { useWeb3 } from '../../components/Web3Provider';
export const dynamic = 'force-dynamic';
interface ResolvedIdentity {
id: string;
protocol: string;
displayName?: string;
handle?: string;
avatarUrl?: string;
address?: string;
color: string;
isPrimary: boolean;
}
const PROTOCOL_COLORS: Record<string, string> = {
nostr: '#8e30eb',
farcaster: '#8a63d2',
lens: '#abfe2c',
bluesky: '#1185fe',
threads: '#888888',
mastodon: '#6364ff',
ethereum: '#627eea',
solana: '#9945ff',
};
const PROTOCOL_ICONS: Record<string, string> = {
nostr: '☰',
farcaster: '◈',
lens: '◎',
bluesky: '☁',
threads: '⊞',
mastodon: '🐘',
ethereum: '◆',
solana: '◎',
};
const ALL_NETWORKS = [
{ id: 'mastodon', label: 'Mastodon / GotoSocial', desc: 'your @degenfeed.xyz handle' },
{ id: 'nostr', label: 'Nostr', desc: 'decentralized notes' },
{ id: 'bluesky', label: 'Bluesky', desc: 'AT Protocol' },
{ id: 'lens', label: 'Lens', desc: 'Lens Protocol' },
{ id: 'farcaster', label: 'Farcaster', desc: 'Farcaster Hub' },
{ id: 'threads', label: 'Threads', desc: 'Meta Threads' },
];
function shortenAddress(addr: string): string {
if (addr.length < 10) return addr;
return `${addr.slice(0, 6)}...${addr.slice(-4)}`;
}
function toResolved(identity: StoredIdentity): ResolvedIdentity {
const isWallet = identity.protocol === 'ethereum' || identity.protocol === 'solana';
return {
id: identity.id,
protocol: identity.protocol,
displayName: identity.displayName,
handle: identity.handle,
avatarUrl: identity.avatarUrl,
address: isWallet ? identity.publicId : undefined,
color: PROTOCOL_COLORS[identity.protocol] ?? '#666',
isPrimary: identity.isPrimary,
};
}
export default function ProfilePage() {
const {
primaryIdentity,
linkedIdentities,
ready,
openSignIn,
setPrimaryIdentity: setPrimary,
unlinkIdentity: unlink,
linkGtsUrl,
isWalletOnly,
} = useAuth();
const { isConnected } = useWeb3();
const identities = useMemo(() => {
const list: ResolvedIdentity[] = [];
if (primaryIdentity) list.push(toResolved(primaryIdentity));
for (const identity of linkedIdentities) list.push(toResolved(identity));
return list;
}, [primaryIdentity, linkedIdentities]);
const hasSignedIn = identities.length > 0 || isConnected;
const primaryIsWallet =
primaryIdentity?.protocol === 'ethereum' || primaryIdentity?.protocol === 'solana';
if (!ready) {
return (
<div className="mx-auto max-w-2xl px-4 py-20 text-center text-gray-500">
<div className="mx-auto mb-4 h-2 w-32 animate-pulse rounded-full bg-gray-800" />
<p>Loading...</p>
</div>
);
}
return (
<div className="mx-auto max-w-2xl px-4 py-6">
<div className="card mb-8 py-8 text-center">
<div className="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-degen-500/30 via-purple-500/30 to-fuchsia-500/30 text-3xl font-bold text-white shadow-[0_0_30px_rgba(34,197,94,0.2)]">
{primaryIdentity?.displayName ? primaryIdentity.displayName.charAt(0).toUpperCase() : '?'}
</div>
<h1 className="text-2xl font-bold text-white">
{primaryIdentity?.displayName ?? 'DegenFeed User'}
</h1>
<p className="mt-1 text-sm text-gray-500">
{hasSignedIn
? `${identities.length} connection${identities.length !== 1 ? 's' : ''}`
: 'No accounts connected'}
</p>
</div>
{!hasSignedIn ? (
<div className="card py-12 text-center">
<div className="mb-4 text-4xl text-gray-600">?</div>
<p className="mb-4 text-gray-500">Connect accounts to build your unified identity</p>
<button onClick={openSignIn} className="btn-primary text-sm" type="button">
Connect an account
</button>
</div>
) : (
<>
<div className="mb-6">
<h2 className="mb-3 text-sm font-medium uppercase tracking-widest text-gray-500">
Connected Identities
</h2>
<div className="space-y-2">
{identities.map((id) => (
<div key={id.id} className="card-hover flex items-center gap-4">
<div
className="flex h-10 w-10 items-center justify-center rounded-full text-lg"
style={{ backgroundColor: `${id.color}22`, color: id.color }}
>
{id.avatarUrl ? (
<Image
src={id.avatarUrl}
alt=""
width={40}
height={40}
className="h-10 w-10 rounded-full object-cover"
unoptimized
/>
) : (
PROTOCOL_ICONS[id.protocol] || '?'
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-white truncate">
{id.displayName || id.protocol}
</span>
<span
className="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: `${id.color}22`, color: id.color }}
>
{id.protocol}
</span>
{id.isPrimary && (
<span className="shrink-0 rounded-full bg-degen-500/20 px-2 py-0.5 text-xs font-medium text-degen-400">
Primary
</span>
)}
</div>
{id.address ? (
<div className="flex items-center gap-2">
<code className="text-xs text-gray-400 font-mono">
{shortenAddress(id.address)}
</code>
<button
onClick={() => navigator.clipboard.writeText(id.address ?? '')}
className="text-[10px] text-gray-600 hover:text-gray-400 transition-colors"
type="button"
>
copy
</button>
</div>
) : (
<div className="text-xs text-gray-500 truncate">{id.handle || ''}</div>
)}
</div>
<div className="flex shrink-0 gap-2">
{!id.isPrimary &&
(!primaryIsWallet ||
id.protocol === 'ethereum' ||
id.protocol === 'solana') && (
<>
<button
onClick={() => setPrimary(id.id)}
className="rounded-lg bg-gray-800 px-2 py-1 text-xs text-gray-400 hover:bg-gray-700 hover:text-white transition-colors"
type="button"
>
Set primary
</button>
<button
onClick={() => unlink(id.id)}
className="rounded-lg bg-red-500/10 px-2 py-1 text-xs text-red-400 hover:bg-red-500/20 transition-colors"
type="button"
>
Unlink
</button>
</>
)}
{!id.isPrimary &&
primaryIsWallet &&
id.protocol !== 'ethereum' &&
id.protocol !== 'solana' && (
<button
onClick={() => unlink(id.id)}
className="rounded-lg bg-red-500/10 px-2 py-1 text-xs text-red-400 hover:bg-red-500/20 transition-colors"
type="button"
>
Unlink
</button>
)}
</div>
</div>
))}
</div>
{linkGtsUrl && !identities.some((i) => i.protocol === 'mastodon') && (
<a
href={linkGtsUrl}
className="card-hover mt-3 flex w-full items-center justify-center gap-2 border-degen-500/30 bg-degen-500/10 px-4 py-3 text-sm font-semibold text-degen-400 hover:bg-degen-500/20"
>
<span></span>
Link @degenfeed.xyz account
</a>
)}
{isWalletOnly && (
<p className="mt-3 text-center text-xs text-gray-500">
Your wallet is your primary identity. Link a social account to cross-post.
</p>
)}
<div className="mt-4">
<IdentityLinker onSignIn={openSignIn} />
</div>
{/* Cross-network posting status */}
<div className="mt-6">
<h2 className="mb-3 text-sm font-medium uppercase tracking-widest text-gray-500">
Cross-Network Posting
</h2>
<div className="card space-y-2 p-4">
{ALL_NETWORKS.map((n) => {
const linked = !!identities.find((i) => i.protocol === n.id);
return (
<div key={n.id} className="flex items-center gap-3">
<span
className={`h-2 w-2 shrink-0 rounded-full ${linked ? 'bg-degen-400 shadow-[0_0_4px_rgba(34,197,94,0.5)]' : 'bg-gray-700'}`}
/>
<div className="flex-1">
<span
className={`text-sm ${linked ? 'font-medium text-white' : 'text-gray-500'}`}
>
{n.label}
</span>
<span className="ml-2 text-[10px] text-gray-600">{n.desc}</span>
</div>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${linked ? 'bg-degen-500/15 text-degen-400' : 'bg-gray-800 text-gray-600'}`}
>
{linked ? 'Active' : 'Not linked'}
</span>
</div>
);
})}
{!identities.length && (
<p className="text-xs text-gray-500">
Link accounts to cross-post across all networks.
</p>
)}
</div>
</div>
{/* Mastodon profile card */}
{(() => {
const mastoId = identities.find((i) => i.protocol === 'mastodon');
if (!mastoId) return null;
return (
<div className="mt-6">
<h2 className="mb-3 text-sm font-medium uppercase tracking-widest text-gray-500">
Mastodon Profile
</h2>
<div className="card border-degen-500/20 bg-gradient-to-br from-degen-500/5 to-transparent p-4">
<div className="flex items-center gap-4">
<Image
src={mastoId.avatarUrl || '/icon.svg'}
alt=""
width={56}
height={56}
className="h-14 w-14 rounded-xl object-cover"
unoptimized
/>
<div className="min-w-0 flex-1">
<div className="text-base font-bold text-white">
{mastoId.displayName || mastoId.handle}
</div>
{mastoId.handle && (
<div className="mt-0.5 text-xs font-mono text-degen-400">
@{mastoId.handle}
</div>
)}
</div>
</div>
<div className="mt-3 flex items-center gap-3 text-xs text-gray-400">
<a
href={`https://social.degenfeed.xyz/@${mastoId.handle}`}
target="_blank"
rel="noopener"
className="rounded-lg border border-white/10 px-3 py-1.5 transition-colors hover:border-degen-500/30 hover:text-white"
>
View Profile
</a>
<button
onClick={() => navigator.clipboard.writeText(mastoId.handle || '')}
className="rounded-lg border border-white/10 px-3 py-1.5 transition-colors hover:border-degen-500/30 hover:text-white"
>
Copy Handle
</button>
{mastoId.handle && (
<button
onClick={() => unlink(mastoId.id)}
className="ml-auto rounded-lg border border-red-500/20 bg-red-500/10 px-3 py-1.5 text-red-400 transition-colors hover:bg-red-500/20"
>
Unlink
</button>
)}
</div>
</div>
</div>
);
})()}
</div>
<div className="mb-6">
<h2 className="mb-3 text-sm font-medium uppercase tracking-widest text-gray-500">
Creator Tools
</h2>
<div className="card border-degen-500/20 bg-degen-500/5 p-4">
<div className="mb-3 flex items-center justify-between">
<div>
<div className="text-xs font-semibold text-degen-400">Tips Dashboard</div>
<div className="text-2xl font-bold text-white">$0.00</div>
<div className="text-[10px] text-gray-500">Estimated total tips received</div>
</div>
{(() => {
const tipAddr = identities.find((i) => i.address)?.address;
if (!tipAddr || typeof window === 'undefined') return null;
const tipUrl = tipAddr.startsWith('0x')
? `ethereum:${tipAddr}`
: `solana:${tipAddr}`;
return (
<div className="rounded-lg bg-white p-1.5 shadow-sm">
<QRCodeSVG value={tipUrl} size={64} />
</div>
);
})()}
</div>
<div className="flex flex-wrap gap-2">
<a
href="/profile/monetization"
className="rounded-lg bg-degen-500/20 px-3 py-1.5 text-xs font-medium text-degen-400 hover:bg-degen-500/30 transition-colors"
>
Monetization settings
</a>
<a
href="/profile/monetization"
className="rounded-lg bg-gray-800 px-3 py-1.5 text-xs font-medium text-gray-400 hover:bg-gray-700 hover:text-white transition-colors"
>
Set up tipping
</a>
</div>
</div>
</div>
<div className="mb-6">
<h2 className="mb-3 text-sm font-medium uppercase tracking-widest text-gray-500">
Receive Tips
</h2>
<div className="space-y-3">
{(() => {
const tipAddr = identities.find((i) => i.address)?.address;
if (!tipAddr || typeof window === 'undefined') return null;
const tipUrl = tipAddr.startsWith('0x')
? `ethereum:${tipAddr}`
: `solana:${tipAddr}`;
return (
<div className="card flex flex-col items-center border-degen-500/20 bg-degen-500/5 p-4">
<h3 className="mb-2 text-xs font-semibold text-degen-400">Scan to Tip</h3>
<div className="mb-2 rounded-xl bg-white p-3 shadow-lg">
<QRCodeSVG value={tipUrl} size={140} />
</div>
<p className="text-[10px] text-gray-500 text-center">
Scan with any wallet app to send crypto
</p>
</div>
);
})()}
{identities.filter((i) => i.address).length > 0 && (
<div className="card border-degen-500/20 bg-degen-500/5 p-4">
<h3 className="mb-2 text-xs font-semibold text-degen-400">Direct Crypto</h3>
<p className="mb-3 text-xs text-gray-400">
Anyone can send you tips on these chains no DegenFeed account needed.
</p>
{identities
.filter((i) => i.address)
.map((id) => (
<div
key={`${id.protocol}-${id.address}`}
className="card-hover mb-2 flex items-center justify-between !bg-gray-900/40 px-3 py-2"
>
<div className="flex items-center gap-3 min-w-0">
<div className="hidden sm:block rounded-lg bg-white p-1 shadow-sm">
{typeof window !== 'undefined' && (
<QRCodeSVG
value={
(id.address || '').startsWith('0x')
? `ethereum:${id.address}`
: `solana:${id.address}`
}
size={40}
/>
)}
</div>
<span style={{ color: id.color }}>{PROTOCOL_ICONS[id.protocol]}</span>
<div className="min-w-0">
<div className="text-xs font-medium text-white">
{id.protocol === 'ethereum' ? 'EVM Chain' : 'Solana'}
</div>
<code className="text-xs font-mono text-gray-400 truncate block">
{shortenAddress(id.address || '')}
</code>
</div>
</div>
<button
onClick={() => navigator.clipboard.writeText(id.address ?? '')}
className="shrink-0 rounded-lg bg-gray-800 px-3 py-1.5 text-xs font-medium text-gray-400 hover:bg-gray-700 hover:text-white transition-colors"
type="button"
>
Copy
</button>
</div>
))}
<div className="mt-2 flex flex-wrap gap-1">
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">
Ethereum
</span>
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">
Base
</span>
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">
Optimism
</span>
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">
Arbitrum
</span>
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">
Polygon
</span>
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">
Solana
</span>
<button
onClick={() => {
const addr = identities.find((i) => i.address)?.address;
if (addr) navigator.clipboard.writeText(addr);
}}
className="rounded-full bg-degen-500/20 px-2 py-0.5 text-[10px] text-degen-400 hover:bg-degen-500/30 transition-colors"
type="button"
>
Copy All
</button>
</div>
</div>
)}
<div className="card border-yellow-500/20 bg-yellow-500/5 p-4">
<div className="mb-2 flex items-center gap-2">
<span className="text-lg"></span>
<h3 className="text-xs font-semibold text-yellow-400">Platform Tipping</h3>
</div>
<div className="space-y-2">
{identities.map((id) => (
<div key={`tip-${id.id}`} className="card !bg-gray-900/40 px-3 py-2">
<div className="flex items-center gap-2">
<span style={{ color: id.color }}>{PROTOCOL_ICONS[id.protocol]}</span>
<div>
<div className="text-xs text-white">
{id.protocol === 'ethereum'
? 'Ethereum Wallet'
: id.protocol.charAt(0).toUpperCase() + id.protocol.slice(1)}
</div>
<div className="text-[10px] text-gray-600">
{id.protocol === 'nostr'
? 'Lightning Zaps available'
: id.protocol === 'lens'
? 'Lens Collect available'
: id.protocol === 'farcaster'
? 'Farcaster Frames available'
: id.protocol === 'ethereum' || id.protocol === 'solana'
? 'Direct crypto transfer'
: 'Coming soon'}
</div>
</div>
</div>
{id.protocol === 'nostr' && (
<span className="rounded-full bg-yellow-500/20 px-2 py-0.5 text-[10px] text-yellow-400">
Zap
</span>
)}
{(id.protocol === 'ethereum' || id.protocol === 'solana') && id.address && (
<button
onClick={() => navigator.clipboard.writeText(id.address ?? '')}
className="rounded-lg bg-gray-800 px-2 py-1 text-[10px] text-gray-500 hover:text-white transition-colors"
type="button"
>
Copy
</button>
)}
</div>
))}
</div>
</div>
<div className="card !bg-gray-900/30 p-3">
<h4 className="mb-1 text-[10px] font-semibold uppercase tracking-widest text-gray-600">
How it works
</h4>
<ul className="space-y-1 text-[10px] text-gray-500">
<li> Tips are sent directly DegenFeed never holds funds</li>
<li> Each connected protocol enables its own tipping method</li>
<li> Link more accounts to unlock more ways to receive tips</li>
<li> Tippers use their own wallets no platform account needed</li>
</ul>
</div>
</div>
</div>
<div className="card border-degen-500/20 bg-degen-500/5 py-6 text-center">
<p className="mb-2 text-sm text-degen-400">
Your profile is automatically assembled from all connected accounts.
</p>
<p className="text-xs text-gray-500">
The primary identity is used for your display name and avatar. Use the buttons above
to manage your identities.
</p>
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,190 @@
'use client';
import type { UserPreferences } from '@degenfeed/storage';
import {
getDefaultPreferences,
getPreferences,
getPrimaryIdentity,
savePreferences,
} from '@degenfeed/storage';
import type { Protocol } from '@degenfeed/types';
import { useEffect, useState } from 'react';
const PROTOCOLS: Protocol[] = [
'nostr',
'farcaster',
'lens',
'bluesky',
'mastodon',
'threads',
'rss',
];
const NICHES = [
'crypto',
'bitcoin',
'ethereum',
'solana',
'defi',
'nft',
'ai',
'regulation',
'security',
'macro',
'stablecoin',
'etf',
'layer2',
'memecoin',
'onchain',
];
export const dynamic = 'force-dynamic';
export default function PreferencesPage() {
const [prefs, setPrefs] = useState<UserPreferences | null>(null);
const [saved, setSaved] = useState(false);
const [identityId, setIdentityId] = useState<string>('');
useEffect(() => {
let cancelled = false;
async function load() {
const primary = await getPrimaryIdentity();
const id = primary?.id ?? 'anon';
setIdentityId(id);
const existing = await getPreferences(id);
const defaults = await getDefaultPreferences(id);
if (!cancelled) setPrefs({ ...defaults, ...(existing ?? {}) });
}
load();
return () => {
cancelled = true;
};
}, []);
if (!prefs) {
return <div className="mx-auto max-w-2xl px-4 py-20 text-center text-gray-500">Loading...</div>;
}
const update = (patch: Partial<UserPreferences>) => {
setPrefs((p) => (p ? { ...p, ...patch } : p));
setSaved(false);
};
const togglePlatform = (protocol: Protocol) => {
const set = new Set(prefs.preferredPlatforms);
if (set.has(protocol)) set.delete(protocol);
else set.add(protocol);
update({ preferredPlatforms: Array.from(set) });
};
const toggleNiche = (niche: string) => {
const set = new Set(prefs.preferredNiches);
if (set.has(niche)) set.delete(niche);
else set.add(niche);
update({ preferredNiches: Array.from(set) });
};
const handleSave = async () => {
await savePreferences({ ...prefs, identityId });
setSaved(true);
};
return (
<div className="mx-auto max-w-2xl px-4 py-6">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Algorithm Preferences</h1>
<a href="/profile" className="text-sm text-degen-500 hover:underline">
Back to profile
</a>
</div>
<div className="card mb-4">
<h2 className="mb-3 text-sm font-semibold text-degen-400">Preferred platforms</h2>
<div className="flex flex-wrap gap-2">
{PROTOCOLS.map((p) => (
<button
key={p}
type="button"
onClick={() => togglePlatform(p)}
className={`rounded-full px-3 py-1 text-xs font-medium ${
prefs.preferredPlatforms.includes(p)
? 'bg-degen-500 text-black'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
{p}
</button>
))}
</div>
</div>
<div className="card mb-4">
<h2 className="mb-3 text-sm font-semibold text-degen-400">Preferred niches</h2>
<div className="flex flex-wrap gap-2">
{NICHES.map((n) => (
<button
key={n}
type="button"
onClick={() => toggleNiche(n)}
className={`rounded-full px-3 py-1 text-xs font-medium capitalize ${
prefs.preferredNiches.includes(n)
? 'bg-degen-500 text-black'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
{n}
</button>
))}
</div>
</div>
<div className="card mb-4">
<h2 className="mb-3 text-sm font-semibold text-degen-400">Ranking weights</h2>
<div className="mb-4">
<label className="mb-1 block text-xs text-gray-400">
Recency weight: {prefs.recencyWeight.toFixed(1)}
<input
type="range"
min="0"
max="2"
step="0.1"
value={prefs.recencyWeight}
onChange={(e) => update({ recencyWeight: Number(e.target.value) })}
className="mt-1 w-full accent-degen-500"
/>
</label>
</div>
<div className="mb-4">
<label className="mb-1 block text-xs text-gray-400">
Engagement weight: {prefs.engagementWeight.toFixed(1)}
<input
type="range"
min="0"
max="2"
step="0.1"
value={prefs.engagementWeight}
onChange={(e) => update({ engagementWeight: Number(e.target.value) })}
className="mt-1 w-full accent-degen-500"
/>
</label>
</div>
<label className="flex items-center gap-2 text-sm text-gray-400">
<input
type="checkbox"
checked={prefs.diversityBoost}
onChange={(e) => update({ diversityBoost: e.target.checked })}
className="accent-degen-500"
/>
Boost author diversity
</label>
</div>
<div className="flex items-center gap-4">
<button type="button" className="btn-primary" onClick={handleSave}>
Save preferences
</button>
{saved && <span className="text-sm text-degen-400">Saved.</span>}
</div>
</div>
);
}

View file

@ -0,0 +1,14 @@
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/auth/', '/profile/', '/health'],
},
],
sitemap: 'https://degenfeed.xyz/sitemap.xml',
};
}

View file

@ -0,0 +1,89 @@
import type { MetadataRoute } from 'next';
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = 'https://degenfeed.xyz';
const staticRoutes = [
{ url: baseUrl, lastModified: new Date(), changeFrequency: 'hourly' as const, priority: 1 },
{
url: `${baseUrl}/feed`,
lastModified: new Date(),
changeFrequency: 'hourly' as const,
priority: 0.9,
},
{
url: `${baseUrl}/news`,
lastModified: new Date(),
changeFrequency: 'hourly' as const,
priority: 0.9,
},
{
url: `${baseUrl}/home`,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 0.8,
},
{
url: `${baseUrl}/discover`,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 0.7,
},
{
url: `${baseUrl}/newsletter`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.7,
},
{
url: `${baseUrl}/premium`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.6,
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.5,
},
{
url: `${baseUrl}/faq`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.5,
},
{
url: `${baseUrl}/privacy`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.3,
},
{
url: `${baseUrl}/terms`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.3,
},
{
url: `${baseUrl}/community-guidelines`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.3,
},
{
url: `${baseUrl}/acceptable-use`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.3,
},
{
url: `${baseUrl}/status`,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 0.4,
},
];
return staticRoutes;
}

View file

@ -0,0 +1,184 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
interface ProtocolHealth {
healthy: boolean;
latencyMs: number;
error?: string;
}
interface FeedHealthResponse {
health: Record<string, ProtocolHealth>;
checkedAt: number;
}
const PROTOCOL_LABELS: Record<string, { label: string; color: string; description: string }> = {
nostr: {
label: 'Nostr',
color: '#8e30eb',
description: 'Decentralized notes via WebSocket relays',
},
farcaster: {
label: 'Farcaster',
color: '#8a63d2',
description: 'Casts via Farcaster Hub',
},
lens: {
label: 'Lens',
color: '#abfe2c',
description: 'Lens Protocol GraphQL API',
},
bluesky: {
label: 'Bluesky',
color: '#1185fe',
description: 'AT Protocol public API',
},
mastodon: {
label: 'Mastodon / GotoSocial',
color: '#6364ff',
description: 'Fediverse public timeline',
},
threads: {
label: 'Threads',
color: '#888',
description: 'Meta Threads web scraper',
},
reddit: {
label: 'Reddit',
color: '#ff4500',
description: 'Subreddit RSS hot feeds',
},
rss: {
label: 'RSS / News',
color: '#ffa500',
description: '30+ RSS sources + Hacker News',
},
};
function statusLabel(healthy: boolean): { text: string; className: string } {
if (healthy) return { text: 'Operational', className: 'text-degen-400' };
return { text: 'Degraded', className: 'text-yellow-400' };
}
function timeAgo(ts: number): string {
const diff = Date.now() - ts;
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
return `${h}h ago`;
}
export function StatusClient({ initialData }: { initialData: FeedHealthResponse | null }) {
const [data, setData] = useState<FeedHealthResponse | null>(initialData);
const [loading, setLoading] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/feed/health', {
cache: 'no-store',
headers: { 'cache-control': 'no-store' },
});
if (res.ok) setData((await res.json()) as FeedHealthResponse);
} catch {
/* ignore */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!autoRefresh) return;
const id = setInterval(refresh, 30000);
return () => clearInterval(id);
}, [autoRefresh, refresh]);
const protocols = Object.entries(PROTOCOL_LABELS);
const health = data?.health ?? {};
const checkedAt = data?.checkedAt ?? 0;
const healthy = protocols.filter(([id]) => health[id]?.healthy).length;
const degraded = protocols.length - healthy;
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/10 bg-gray-950/50 p-4">
<div>
<div className="text-2xl font-bold text-white">
{healthy}/{protocols.length} operational
</div>
<div className="text-sm text-gray-500">
{degraded === 0
? 'All protocols responding normally'
: `${degraded} protocol${degraded === 1 ? '' : 's'} degraded`}
{checkedAt > 0 && <span className="ml-2 text-gray-600">· {timeAgo(checkedAt)}</span>}
</div>
</div>
<div className="flex items-center gap-3">
<label className="flex cursor-pointer items-center gap-2 text-xs text-gray-400">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="h-3 w-3 rounded border-white/20 bg-gray-900 text-degen-500 focus:ring-degen-500"
/>
Auto-refresh
</label>
<button
type="button"
onClick={refresh}
disabled={loading}
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
>
{loading ? 'Checking…' : 'Refresh now'}
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{protocols.map(([id, meta]) => {
const h = health[id];
const status = h
? statusLabel(h.healthy)
: { text: 'Unknown', className: 'text-gray-500' };
const latency = h?.latencyMs ?? null;
return (
<div
key={id}
className="rounded-xl border border-white/10 bg-gray-950/50 p-4"
style={{ borderLeftColor: meta.color, borderLeftWidth: 3 }}
>
<div className="mb-1 flex items-center justify-between">
<span className="text-sm font-semibold text-white">{meta.label}</span>
<span className={`text-xs font-medium ${status.className}`}>
{h ? '●' : '○'} {status.text}
</span>
</div>
<p className="mb-2 text-xs text-gray-500">{meta.description}</p>
{latency !== null && (
<div className="text-[10px] text-gray-600">
Latency: <span className="font-mono text-gray-400">{latency}ms</span>
</div>
)}
{h?.error && (
<div className="mt-1 truncate text-[10px] text-red-400" title={h.error}>
{h.error}
</div>
)}
</div>
);
})}
</div>
{data === null && (
<div className="mt-6 rounded-xl border border-yellow-500/20 bg-yellow-500/5 p-4 text-sm text-yellow-400">
Could not reach the health endpoint. Try refreshing once the worker warms up.
</div>
)}
</div>
);
}

View file

@ -0,0 +1,67 @@
export const runtime = 'edge';
/**
* /status Protocol health dashboard
*
* SSR-fetches /api/feed/health from the worker so the page works without JS
* and isn't blocked by CORS. Shows last-checked timestamp, per-protocol
* status (healthy/degraded/down), latency, and a manual refresh button.
*/
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import { StatusClient } from './StatusClient';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
title: 'Status — DegenFeed',
description:
'Live protocol health for Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads, Reddit, and RSS.',
};
interface ProtocolHealth {
healthy: boolean;
latencyMs: number;
error?: string;
}
interface FeedHealthResponse {
health: Record<string, ProtocolHealth>;
checkedAt: number;
}
async function getBaseUrl(): Promise<string> {
const h = await headers();
const host = h.get('host') || 'app.degenfeed.xyz';
const protocol = h.get('x-forwarded-proto') || 'https';
return `${protocol}://${host}`;
}
async function loadHealth(): Promise<FeedHealthResponse | null> {
try {
const res = await fetch(`${await getBaseUrl()}/api/feed/health`, {
cache: 'no-store',
headers: { 'cache-control': 'no-store' },
});
if (!res.ok) return null;
return (await res.json()) as FeedHealthResponse;
} catch {
return null;
}
}
export default async function StatusPage() {
const data = await loadHealth();
return (
<div className="mx-auto max-w-4xl px-4 py-8">
<header className="mb-6">
<h1 className="text-2xl font-bold text-white">Protocol Status</h1>
<p className="mt-1 text-sm text-gray-500">
Live health checks for every protocol DegenFeed aggregates from.
</p>
</header>
<StatusClient initialData={data} />
</div>
);
}

View file

@ -0,0 +1,80 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Terms of Service',
description:
'Terms of Service for DegenFeed, operated by Rug Munch Media LLC, a Wyoming limited liability company.',
};
export default function TermsPage() {
return (
<div className="mx-auto max-w-2xl px-4 py-8 text-sm leading-relaxed text-gray-300">
<h1 className="mb-6 text-2xl font-bold text-white">Terms of Service</h1>
<p className="mb-4 text-gray-500">Last updated: July 2026</p>
<h2 className="mb-2 text-lg font-semibold text-white">1. Acceptance</h2>
<p className="mb-4">
By using DegenFeed, you agree to these terms. If you do not agree, do not use the service.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">2. Eligibility</h2>
<p className="mb-4">
You must be at least 16 years old. If you are under 18, you need parental consent.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">3. Your Content</h2>
<p className="mb-4">
You own the content you post. By posting, you grant DegenFeed a limited license to display
it in feeds and search results. DegenFeed does not claim ownership of your posts. DegenFeed
is an aggregator your posts live on the underlying protocols (Nostr, Farcaster, Lens,
Bluesky, Mastodon). DegenFeed does not store or archive your content beyond what is
necessary to display feeds.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">4. Acceptable Use</h2>
<p className="mb-4">
See our{' '}
<a href="/acceptable-use" className="text-degen-400 underline">
Acceptable Use Policy
</a>
.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">5. DMCA Notice</h2>
<p className="mb-4">
If you believe content on DegenFeed infringes your copyright, send a DMCA notice to{' '}
<a href="mailto:dmca@degenfeed.xyz" className="text-degen-400 underline">
dmca@degenfeed.xyz
</a>{' '}
with: identification of the copyrighted work, the infringing URL, a statement of good faith
belief, your contact information, and a physical or electronic signature. We respond to
valid DMCA notices within 48 hours.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">6. Disclaimer</h2>
<p className="mb-4">
DegenFeed is provided &ldquo;as is&rdquo; without warranty. We are not responsible for
content posted by third parties on connected networks. Content may include financial
opinions nothing on DegenFeed is financial advice.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">6. Limitation of Liability</h2>
<p className="mb-4">
To the fullest extent permitted by law, DegenFeed and its operators shall not be liable for
any indirect, incidental, or consequential damages arising from your use of the service.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">7. Governing Law</h2>
<p className="mb-4">
These terms are governed by the laws of the State of Wyoming, United States, without regard
to conflict of law principles.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">8. Changes</h2>
<p>
We may update these terms. Continued use after changes constitutes acceptance. DegenFeed is
operated by Rug Munch Media LLC, a Wyoming limited liability company.
</p>
</div>
);
}

View file

@ -0,0 +1,578 @@
'use client';
import { buildIdentityRecord } from '@degenfeed/auth';
import {
clearAllSessions,
deleteIdentity,
getAllIdentities,
getPrimaryIdentity,
type StoredIdentity,
saveIdentity,
setPrimaryIdentity as setPrimaryIdentityInDb,
} from '@degenfeed/storage';
import type { EngagementAction } from '@degenfeed/types';
import { ComposeModal, SignInModal } from '@degenfeed/ui';
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { useWeb3 } from '../components/Web3Provider';
import { useSolanaSignIn } from '../hooks/useSolanaSignIn';
import { useWalletSignIn } from '../hooks/useWalletSignIn';
import { engageNostr, publishNoteToNostr } from '../lib/nostr';
import { useToast } from './Toast';
export interface AuthContextValue {
signedInProtocols: Record<string, boolean>;
tokens: Record<string, string>;
walletAddress?: string;
primaryIdentity?: StoredIdentity;
linkedIdentities: StoredIdentity[];
isWalletOnly: boolean;
linkGtsUrl?: string | null;
nostrPublicId?: string;
openSignIn: () => void;
openCompose: () => void;
signOut: () => Promise<void>;
publish: (
text: string,
targets: string[],
) => Promise<Record<string, { ok: boolean; error?: string }>>;
engage: (
action: EngagementAction,
postId: string,
protocol: string,
opts?: {
content?: string;
reaction?: string;
target?: { authorId?: string; publicationId?: string };
},
) => Promise<{ ok: boolean; error?: string }>;
ready: boolean;
setPrimaryIdentity: (id: string) => Promise<void>;
unlinkIdentity: (id: string) => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within an AuthProvider');
return ctx;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [identities, setIdentities] = useState<StoredIdentity[]>([]);
const [primaryIdentity, setPrimaryIdentity] = useState<StoredIdentity | undefined>(undefined);
const [signedInProtocols, setSignedInProtocols] = useState<Record<string, boolean>>({});
const [tokens, setTokens] = useState<Record<string, string>>({});
const [walletAddress, setWalletAddress] = useState<string | undefined>(undefined);
const [linkGtsUrl, setLinkGtsUrl] = useState<string | null>(null);
const [nostrPublicId, setNostrPublicId] = useState<string | undefined>(undefined);
const [ready, setReady] = useState(false);
const [showSignIn, setShowSignIn] = useState(false);
const [showCompose, setShowCompose] = useState(false);
const { account: wagmiAddress, isConnected } = useWeb3();
const { signInWithEthereum, signingIn: walletSigningIn } = useWalletSignIn();
const { signInWithSolana, signingIn: solanaSigningIn } = useSolanaSignIn();
const { showToast } = useToast();
const refresh = useCallback(async () => {
const all = await getAllIdentities();
const primary = await getPrimaryIdentity();
setIdentities(all);
setPrimaryIdentity(primary);
const protocols: Record<string, boolean> = {};
const toks: Record<string, string> = {};
for (const identity of all) {
protocols[identity.protocol] = true;
if (identity.keys?.accessToken) {
toks[identity.protocol] = identity.keys.accessToken;
}
}
setSignedInProtocols(protocols);
setTokens(toks);
const wallet = all.find((i) => i.protocol === 'ethereum' || i.protocol === 'solana');
setWalletAddress(wallet?.publicId ?? (isConnected ? (wagmiAddress ?? undefined) : undefined));
const nostrId = all.find((i) => i.protocol === 'nostr');
setNostrPublicId(nostrId?.publicId);
}, [isConnected, wagmiAddress]);
useEffect(() => {
const init = async () => {
let serverAddress: string | undefined;
try {
const res = await fetch('/api/auth/session', { credentials: 'include' });
if (res.ok) {
const data = (await res.json()) as { address?: string };
serverAddress = data.address;
}
} catch {
/* session endpoint may be unavailable — continue without server session */
}
await refresh().catch(() => {});
if (serverAddress) {
setWalletAddress((prev) => prev ?? serverAddress?.toLowerCase());
}
setReady(true);
};
init();
}, [refresh]);
useEffect(() => {
if (!ready) return;
const processPending = async () => {
const raw = localStorage.getItem('degenfeed_pending_identity');
if (!raw) return;
localStorage.removeItem('degenfeed_pending_identity');
try {
const pending = JSON.parse(raw) as {
protocol: string;
accessToken: string;
profile?: { id?: string; acct?: string; displayName?: string; avatar?: string };
};
if (pending.protocol !== 'mastodon' || !pending.accessToken || !pending.profile?.id) return;
const profile = pending.profile;
const handle = profile.acct?.includes('@')
? profile.acct
: `${profile.acct}@social.degenfeed.xyz`;
const record = await buildIdentityRecord({
protocol: 'mastodon',
publicId: String(profile.id),
handle,
displayName: profile.displayName,
avatarUrl: profile.avatar,
secret: pending.accessToken,
passphrase: 'auto-unlock',
keys: { accessToken: pending.accessToken },
isPrimary: false,
});
await saveIdentity(record);
await refresh();
} catch {
/* ignore */
}
};
processPending();
}, [ready, refresh]);
const handleSignIn = useCallback(
async (protocol: string, credentials?: string | Record<string, string>) => {
try {
if (protocol === 'ethereum') {
const session = await signInWithEthereum();
if (session) {
setLinkGtsUrl(session.gtsAuthorizeUrl ?? null);
await refresh();
setShowSignIn(false);
}
return;
}
// Backwards compat: if credentials is a string, treat as token
const creds =
typeof credentials === 'string' ? { token: credentials } : (credentials ?? {});
const token = creds.token as string | undefined;
switch (protocol) {
case 'solana': {
const session = await signInWithSolana();
if (session) {
setLinkGtsUrl(session.gtsAuthorizeUrl ?? null);
await refresh();
setShowSignIn(false);
}
return;
}
case 'nostr': {
const { signInWithNostrNsec } = await import('@degenfeed/auth');
if (creds.method === 'extension') {
const win = window as unknown as {
nostr?: {
getPublicKey: () => Promise<string>;
signEvent: (e: {
kind: number;
content: string;
tags: string[][];
created_at: number;
}) => Promise<{ sig: string }>;
};
};
if (!win.nostr) throw new Error('NIP-07 extension not detected');
const pubkey = await win.nostr.getPublicKey();
const record = await buildIdentityRecord({
protocol: 'nostr',
publicId: pubkey,
handle: pubkey.slice(0, 12),
displayName: pubkey.slice(0, 12),
secret: null,
passphrase: 'auto-unlock',
isPrimary: false,
});
await saveIdentity(record);
await refresh();
setShowSignIn(false);
return;
}
const nsec = (creds.nsec as string) || token;
if (!nsec) throw new Error('Nostr nsec required');
await signInWithNostrNsec(nsec, 'auto-unlock-passphrase');
break;
}
case 'farcaster': {
const { signInWithFarcasterSigner } = await import('@degenfeed/auth');
if (creds.method === 'warpcast') {
window.location.href = '/api/auth/warpcast/authorize';
return;
}
const fid = creds.fid as string;
const username = creds.username as string;
const signerKey = creds.signerKey as string;
if (!fid || !username || !signerKey) throw new Error('Farcaster credentials required');
await signInWithFarcasterSigner(signerKey, Number(fid), username, 'auto');
break;
}
case 'lens': {
const { signInWithLens } = await import('@degenfeed/auth');
if (!window.ethereum) throw new Error('No Ethereum wallet detected');
await signInWithLens({ wallet: window.ethereum });
break;
}
case 'bluesky': {
const { signInWithBluesky } = await import('@degenfeed/auth');
const handle = creds.handle as string;
const appPass = creds.appPassword as string;
if (!handle || !appPass) throw new Error('Bluesky credentials required');
await signInWithBluesky(handle, appPass, 'auto-unlock');
break;
}
case 'threads': {
const { signInWithThreads } = await import('@degenfeed/auth');
const identifier = creds.handle as string;
const apPass = creds.appPassword as string;
if (!identifier || !apPass) throw new Error('Threads credentials required');
await signInWithThreads(identifier, apPass, 'auto-unlock');
break;
}
case 'mastodon': {
const { signInWithGtS } = await import('@degenfeed/auth');
const instance = (creds.instance as string) || 'https://social.degenfeed.xyz';
const accessToken = creds.accessToken as string;
if (!accessToken) throw new Error('Access token required');
await signInWithGtS(instance, accessToken, 'auto-unlock');
break;
}
default:
throw new Error(`Unknown protocol: ${protocol}`);
}
await refresh();
setShowSignIn(false);
} catch (err) {
const message = err instanceof Error ? err.message : 'Sign-in failed';
showToast('error', `Sign-in failed: ${message}`);
}
},
[signInWithEthereum, refresh, signInWithSolana, showToast],
);
const handlePublish = useCallback(
async (
text: string,
targets: string[],
): Promise<Record<string, { ok: boolean; error?: string }>> => {
try {
const res = await fetch('/api/publish', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text, targets, auth: tokens }),
});
const data = await res.json();
return data.results ?? {};
} catch (_err) {
const results: Record<string, { ok: boolean; error?: string }> = {};
for (const t of targets) results[t] = { ok: false, error: 'Network error' };
return results;
}
},
[tokens],
);
const handleEngage = useCallback(
async (
action: EngagementAction,
postId: string,
protocol: string,
opts: {
content?: string;
reaction?: string;
target?: { authorId?: string; publicationId?: string };
} = {},
): Promise<{ ok: boolean; error?: string }> => {
// Nostr — sign and publish client-side (never send nsec to the server).
if (protocol === 'nostr') {
const nostrIdent = identities.find((i) => i.protocol === 'nostr');
if (!nostrIdent) return { ok: false, error: 'No Nostr identity linked' };
const nativeId = postId.startsWith('nostr:') ? postId.slice('nostr:'.length) : postId;
const targetPubkey = opts.target?.authorId || '';
if (
action === 'like' ||
action === 'unlike' ||
action === 'repost' ||
action === 'unrepost' ||
action === 'reply'
) {
return engageNostr({
action: action === 'unlike' ? 'like' : action === 'unrepost' ? 'repost' : action,
eventId: nativeId,
pubkey: targetPubkey,
content: opts.content,
publicId: nostrIdent.publicId,
});
}
return { ok: false, error: `${action} not supported on Nostr` };
}
// Build auth payload for server-side engagement.
const identity = identities.find((i) => i.protocol === protocol);
const auth: Record<string, string> = {};
// Mastodon — access token + instance URL.
if (protocol === 'mastodon') {
const token = identity?.keys?.accessToken ?? identity?.keys?.mastodon_token ?? '';
if (!token) return { ok: false, error: 'Not signed into Mastodon' };
auth.mastodon = token;
auth.mastodon_instance = identity?.keys?.instanceUrl || 'https://social.degenfeed.xyz';
}
// Bluesky / Threads — access JWT + DID.
else if (protocol === 'bluesky' || protocol === 'threads') {
const jwt = identity?.keys?.accessJwt ?? '';
const did = identity?.publicId ?? '';
if (!jwt || !did) return { ok: false, error: `Not signed into ${protocol}` };
auth.accessJwt = jwt;
auth[`${protocol}_did`] = did;
auth[`${protocol}_jwt`] = jwt;
// Also set them as the bare keys the backend reads.
if (protocol === 'bluesky') auth.bluesky = jwt;
if (protocol === 'threads') auth.threads = jwt;
}
// Lens — access token.
else if (protocol === 'lens') {
const token = identity?.keys?.accessToken ?? '';
if (!token) return { ok: false, error: 'Not signed into Lens' };
auth.lens = token;
auth.accessToken = token;
auth.lens_profileId = identity?.publicId ?? '';
}
// Farcaster — sign and publish client-side (never send signer key to the server).
else if (protocol === 'farcaster') {
const farcasterIdent = identities.find((i) => i.protocol === "farcaster");
if (!farcasterIdent) return { ok: false, error: "No Farcaster identity linked" };
let signerKeyHex = null;
if (farcasterIdent.encryptedSecret) {
try {
const { decryptSecret } = await import("@degenfeed/auth");
signerKeyHex = await decryptSecret(
{
ciphertext: farcasterIdent.encryptedSecret,
salt: farcasterIdent.salt,
iv: farcasterIdent.iv,
},
"auto-unlock-passphrase",
);
} catch {
return { ok: false, error: "Could not decrypt Farcaster signer key. Re-link your account." };
}
}
if (!signerKeyHex) {
return { ok: false, error: "Farcaster signer key not found. Sign in with a self-custody signer key." };
}
const fid = parseInt(farcasterIdent.publicId, 10);
if (isNaN(fid)) return { ok: false, error: "Invalid Farcaster FID" };
const nativeId = postId.startsWith("farcaster:")
? postId.slice("farcaster:".length)
: postId.startsWith("fc:")
? postId.slice("fc:".length)
: postId;
const targetFid = opts.target?.authorId
? parseInt(opts.target.authorId, 10)
: 0;
const castHashHex = nativeId;
if (castHashHex.length < 40) {
return { ok: false, error: "Invalid Farcaster cast hash" };
}
if (action !== "like" && action !== "unlike" && action !== "repost" && action !== "reply") {
return { ok: false, error: `${action} not supported on Farcaster` };
}
const { engageFarcaster } = await import("@degenfeed/farcaster-sdk");
const result = await engageFarcaster({
action: action,
signerKeyHex,
fid,
targetFid,
castHashHex,
content: opts.content,
});
return result;
}
// Generic fallback — send accessToken as protocol key.
else {
if (identity?.keys?.accessToken) auth[protocol] = identity.keys.accessToken;
}
try {
const res = await fetch('/api/engage', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
action,
postId,
protocol,
content: opts.content,
reaction: opts.reaction,
target: opts.target,
auth,
}),
});
const data = (await res.json()) as { ok?: boolean; error?: string };
return { ok: data.ok ?? false, error: data.error };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Network error' };
}
},
[identities],
);
const handleSignOut = useCallback(async () => {
try {
await fetch('/api/auth/signout', { method: 'POST', credentials: 'include' });
} catch {
/* ignore network errors — still clear local state */
}
try {
await clearAllSessions();
for (const identity of identities) {
await deleteIdentity(identity.id);
}
} catch {
/* ignore */
}
setIdentities([]);
setPrimaryIdentity(undefined);
setSignedInProtocols({});
setTokens({});
setWalletAddress(undefined);
setLinkGtsUrl(null);
showToast('success', 'Signed out');
}, [identities, showToast]);
const handleSetPrimary = useCallback(
async (id: string) => {
await setPrimaryIdentityInDb(id);
await refresh();
},
[refresh],
);
const handleUnlink = useCallback(
async (id: string) => {
await deleteIdentity(id);
await refresh();
},
[refresh],
);
const linkedIdentities = useMemo(
() => identities.filter((i) => i.id !== primaryIdentity?.id),
[identities, primaryIdentity],
);
const isWalletOnly = useMemo(() => {
if (!primaryIdentity) return false;
return (
(primaryIdentity.protocol === 'ethereum' || primaryIdentity.protocol === 'solana') &&
linkedIdentities.length === 0
);
}, [primaryIdentity, linkedIdentities]);
return (
<AuthContext.Provider
value={{
signedInProtocols,
tokens,
walletAddress,
primaryIdentity,
linkedIdentities,
isWalletOnly,
linkGtsUrl,
openSignIn: () => setShowSignIn(true),
openCompose: () => setShowCompose(true),
signOut: handleSignOut,
nostrPublicId,
publish: handlePublish,
engage: handleEngage,
ready,
setPrimaryIdentity: handleSetPrimary,
unlinkIdentity: handleUnlink,
}}
>
{children}
<SignInModal
open={showSignIn}
onClose={() => setShowSignIn(false)}
onSignIn={handleSignIn}
walletAddress={walletAddress}
walletSigningIn={walletSigningIn || solanaSigningIn}
/>
<ComposeModal
open={showCompose}
onClose={() => setShowCompose(false)}
signedInProtocols={signedInProtocols}
onPublish={handlePublish}
onRequestSignIn={() => {
setShowCompose(false);
setShowSignIn(true);
}}
primaryIdentity={
primaryIdentity
? {
displayName: primaryIdentity.displayName,
handle: primaryIdentity.handle,
avatarUrl: primaryIdentity.avatarUrl,
}
: undefined
}
nostrBridgePublicId={nostrPublicId}
onNostrBridge={async (text) => {
if (!nostrPublicId) return;
await publishNoteToNostr({ text, publicId: nostrPublicId });
}}
/>
</AuthContext.Provider>
);
}

View file

@ -0,0 +1,59 @@
'use client';
import { usePathname } from 'next/navigation';
import type { ReactNode } from 'react';
interface NavItem {
href: string;
label: string;
icon: ReactNode;
}
function Icon({ children }: { children: React.ReactNode }) {
return <span className="text-lg leading-none">{children}</span>;
}
const NAV: NavItem[] = [
{ href: '/feed', label: 'Feed', icon: <Icon></Icon> },
{ href: '/news', label: 'News', icon: <Icon></Icon> },
{ href: '/discover', label: 'Explore', icon: <Icon></Icon> },
{ href: '/docs', label: 'Docs', icon: <Icon></Icon> },
{ href: '/premium', label: 'Pro', icon: <Icon></Icon> },
{ href: '/profile', label: 'Profile', icon: <Icon></Icon> },
];
export function BottomNav() {
const pathname = usePathname() ?? '/';
return (
<nav
aria-label="Primary mobile"
className="fixed inset-x-0 bottom-0 z-40 border-t border-white/10 bg-gray-950/95 backdrop-blur-xl pb-[env(safe-area-inset-bottom)] lg:hidden"
>
<div className="grid grid-cols-6">
{NAV.map((item) => {
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<a
key={item.href}
href={item.href}
className={`flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-medium transition-colors ${
active ? 'text-degen-400' : 'text-gray-500 hover:text-white'
}`}
aria-current={active ? 'page' : undefined}
>
<span
className={`flex h-7 w-12 items-center justify-center rounded-full ${
active ? 'bg-degen-500/15' : ''
}`}
>
{item.icon}
</span>
<span>{item.label}</span>
</a>
);
})}
</div>
</nav>
);
}

View file

@ -0,0 +1,23 @@
'use client';
import { reportError } from '@degenfeed/utils';
import { useEffect } from 'react';
export function ErrorTracker() {
useEffect(() => {
const onError = (event: ErrorEvent) => {
reportError(event.error ?? new Error(event.message), { tags: { source: 'browser' } });
};
const onRejection = (event: PromiseRejectionEvent) => {
const reason = event.reason instanceof Error ? event.reason : new Error(String(event.reason));
reportError(reason, { tags: { source: 'browser-unhandled' } });
};
window.addEventListener('error', onError);
window.addEventListener('unhandledrejection', onRejection);
return () => {
window.removeEventListener('error', onError);
window.removeEventListener('unhandledrejection', onRejection);
};
}, []);
return null;
}

View file

@ -0,0 +1,254 @@
'use client';
import { getPrimaryIdentity } from '@degenfeed/storage';
import { ComposePreview, getProtocolColor, getProtocolIcon } from '@degenfeed/ui';
import Image from 'next/image';
import { useCallback, useEffect, useRef, useState } from 'react';
import { publishNoteToNostr } from '../lib/nostr';
const TARGETS = [
{ id: 'nostr', label: 'Nostr', charLimit: 50000 },
{ id: 'farcaster', label: 'Farcaster', charLimit: 320 },
{ id: 'lens', label: 'Lens', charLimit: 5000 },
{ id: 'bluesky', label: 'Bluesky', charLimit: 300 },
{ id: 'threads', label: 'Threads', charLimit: 500 },
{ id: 'mastodon', label: 'Mastodon', charLimit: 5000 },
];
interface FeedComposerProps {
signedInProtocols: Record<string, boolean>;
nostrPublicId?: string;
onPost: (
text: string,
targets: string[],
) => Promise<Record<string, { ok: boolean; error?: string }>>;
onRequestSignIn?: (protocol: string) => void;
}
export function FeedComposer({
signedInProtocols,
nostrPublicId,
onPost,
onRequestSignIn,
}: FeedComposerProps) {
const [text, setText] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bridgeToNostr, setBridgeToNostr] = useState(false);
const [posting, setPosting] = useState(false);
const [avatar, setAvatar] = useState<string | null>(null);
const [displayName, setDisplayName] = useState<string>();
const [handle, setHandle] = useState<string>();
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setSelected(new Set(TARGETS.filter((t) => signedInProtocols[t.id]).map((t) => t.id)));
}, [signedInProtocols]);
useEffect(() => {
getPrimaryIdentity()
.then((id) => {
setAvatar(id?.avatarUrl ?? null);
setDisplayName(id?.displayName ?? undefined);
setHandle(id?.handle ?? id?.publicId ?? undefined);
})
.catch(() => {});
}, []);
const handleTextChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const el = e.target;
setText(el.value);
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 240)}px`;
}, []);
const handlePost = useCallback(async () => {
if (!text.trim() || posting || selected.size === 0) return;
setPosting(true);
try {
const targets = Array.from(selected);
const results = await onPost(text.trim(), targets);
// Mastodon → Nostr bridge: fire locally-signed Nostr note in parallel
if (
bridgeToNostr &&
nostrPublicId &&
targets.includes('mastodon') &&
results.mastodon?.ok !== false
) {
try {
await publishNoteToNostr({ text: text.trim(), publicId: nostrPublicId });
} catch {
/* bridge failure is non-fatal — main post already went out */
}
}
setText('');
const el = textareaRef.current;
if (el) {
el.style.height = 'auto';
}
} finally {
setPosting(false);
}
}, [text, posting, selected, onPost, bridgeToNostr, nostrPublicId]);
const toggleTarget = useCallback(
(id: string) => {
if (!signedInProtocols[id]) {
onRequestSignIn?.(id);
return;
}
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
},
[signedInProtocols, onRequestSignIn],
);
const charCount = text.length;
const selectedTargets = TARGETS.filter((t) => selected.has(t.id));
const minCharLimit =
selectedTargets.length > 0 ? Math.min(...selectedTargets.map((t) => t.charLimit)) : 0;
const overLimit = minCharLimit > 0 && charCount > minCharLimit;
const nearLimit = minCharLimit > 0 && charCount > minCharLimit * 0.85 && !overLimit;
const previewTargets = TARGETS.map((t) => ({
id: t.id,
label: t.label,
charLimit: t.charLimit,
enabled: selected.has(t.id),
}));
return (
<div className="border-b border-white/10 px-4 py-3">
<div className="flex gap-3">
<div className="h-10 w-10 flex-shrink-0 overflow-hidden rounded-full bg-gray-800">
{avatar ? (
<Image
src={avatar}
alt=""
width={40}
height={40}
className="h-full w-full object-cover"
unoptimized
/>
) : (
<span className="flex h-full w-full items-center justify-center text-sm text-gray-500">
?
</span>
)}
</div>
<div className="min-w-0 flex-1">
<textarea
ref={textareaRef}
value={text}
onChange={handleTextChange}
placeholder="What is happening?!"
rows={1}
className="w-full resize-none bg-transparent py-2 text-lg text-white placeholder-gray-600 outline-none"
/>
<div className="mt-2 flex flex-wrap items-center gap-2">
{TARGETS.map((t) => {
const connected = !!signedInProtocols[t.id];
const active = selected.has(t.id);
return (
<button
key={t.id}
type="button"
onClick={() => toggleTarget(t.id)}
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium transition-all border ${
active ? 'border-current' : 'border-gray-800 hover:border-gray-600'
} ${connected ? '' : 'opacity-40 hover:opacity-70'}`}
style={{
color: active ? getProtocolColor(t.id) : connected ? '#9ca3af' : '#666',
backgroundColor: active ? `${getProtocolColor(t.id)}18` : 'transparent',
}}
title={
connected
? `${t.label} (${t.charLimit} chars)`
: `Sign in to ${t.label} to publish`
}
>
{!connected ? (
<svg
width="10"
height="10"
viewBox="0 0 16 16"
fill="none"
className="text-gray-500"
aria-hidden="true"
>
<path
d="M8 1C5.5 1 3.5 3 3.5 5.5v2C3 8 2 8.5 2 10v3h12v-3c0-1.5-1-2-1.5-2.5v-2C12.5 3 10.5 1 8 1zM8 13.5c-1 0-1.8-.8-1.8-1.8h3.6c0 1-.8 1.8-1.8 1.8z"
fill="currentColor"
/>
</svg>
) : (
<span className="text-[0.7em]">{getProtocolIcon(t.id)}</span>
)}
{t.label}
</button>
);
})}
</div>
{selected.has('mastodon') && nostrPublicId && (
<label className="mt-2 flex items-center gap-2 text-xs text-gray-400">
<input
type="checkbox"
checked={bridgeToNostr}
onChange={(e) => setBridgeToNostr(e.target.checked)}
className="h-3 w-3 rounded border-white/20 bg-gray-900 text-degen-500 focus:ring-degen-500"
/>
<span>
Also cross-post to <span className="font-medium text-white">Nostr</span>
</span>
<span className="text-[10px] text-gray-600">
(signed locally with your Nostr key)
</span>
</label>
)}
<ComposePreview
text={text}
targets={previewTargets}
author={
text.trim() ? { displayName, handle, avatarUrl: avatar ?? undefined } : undefined
}
/>
<div className="mt-3 flex items-center justify-between border-t border-white/5 pt-3">
<div className="flex items-center gap-3 text-xs">
<span
className={
overLimit ? 'text-red-400' : nearLimit ? 'text-yellow-400' : 'text-gray-500'
}
>
{charCount}
{selected.size > 0 && ` / ${minCharLimit}`}
</span>
{selected.size === 0 && (
<span className="text-yellow-500">Select a network to post</span>
)}
{overLimit && (
<span className="text-red-400">
Over {selectedTargets.find((t) => t.charLimit === minCharLimit)?.label} limit
</span>
)}
</div>
<button
type="button"
onClick={handlePost}
disabled={!text.trim() || posting || selected.size === 0 || overLimit}
className="btn-primary px-4 disabled:opacity-50"
>
{posting ? 'Posting…' : 'Post'}
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,179 @@
'use client';
import { useAuth } from './AuthProvider';
interface ProtocolSummary {
id: string;
label: string;
color: string;
count: number;
}
interface FeedSidebarProps {
trending?: { topic: string; count: number; score: number }[];
protocolSummaries?: ProtocolSummary[];
activeProtocolFilter?: string | null;
onProtocolClick?: (id: string) => void;
}
const NETWORK_HINTS: Record<string, string> = {
nostr: 'Decentralized notes',
farcaster: 'Sufficiently decentralized',
lens: 'User-owned graph',
bluesky: 'AT Protocol',
mastodon: 'Fediverse (GotoSocial)',
threads: 'Meta AT Protocol',
reddit: 'Crypto subreddits',
rss: '30+ news sources',
};
export function FeedSidebar({
trending,
protocolSummaries,
activeProtocolFilter,
onProtocolClick,
}: FeedSidebarProps) {
const { walletAddress, signOut, openSignIn } = useAuth();
const items = trending ?? [];
const protocols = protocolSummaries ?? [];
const totalPosts = protocols.reduce((sum, p) => sum + p.count, 0);
return (
<div className="space-y-4">
{/* Search */}
<a
href="/home/all"
className="flex items-center gap-2 rounded-full border border-white/10 bg-gray-950/70 px-4 py-2.5 text-sm text-gray-400 backdrop-blur-xl transition-colors hover:border-degen-500/30 hover:text-white"
>
<span className="text-lg text-degen-400"></span>
<span>Search posts, topics, tokens</span>
</a>
{/* Wallet card */}
{walletAddress ? (
<div className="rounded-2xl border border-white/10 bg-gray-950/70 p-4 backdrop-blur-xl">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-degen-500/20 font-mono text-sm text-degen-400">
{walletAddress.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">
{walletAddress.slice(0, 6)}{walletAddress.slice(-4)}
</p>
<p className="text-xs text-gray-500">Wallet connected</p>
</div>
</div>
<button
type="button"
onClick={signOut}
className="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
>
Sign out
</button>
</div>
) : (
<div className="rounded-2xl border border-degen-500/30 bg-gradient-to-br from-degen-500/15 to-degen-500/5 p-4 backdrop-blur-xl">
<div className="mb-2 flex items-center gap-2">
<span className="text-degen-400"></span>
<p className="text-sm font-semibold text-white">Read free, post with one click</p>
</div>
<p className="mb-3 text-xs text-gray-400">
No account needed to browse. Sign in with a wallet to post across all networks.
</p>
<button type="button" onClick={openSignIn} className="btn-primary w-full text-sm">
Sign in
</button>
</div>
)}
{/* Live protocol status */}
<div className="rounded-2xl border border-white/10 bg-gray-950/70 p-4 backdrop-blur-xl">
<div className="mb-3 flex items-baseline justify-between">
<h2 className="text-sm font-bold text-white">Live networks</h2>
<span className="text-[10px] text-gray-500">{totalPosts} posts</span>
</div>
{protocols.length === 0 ? (
<p className="text-xs text-gray-500">Warming up</p>
) : (
<ul className="space-y-1">
{protocols.slice(0, 8).map((p) => {
const active = activeProtocolFilter === p.id;
return (
<li key={p.id}>
<button
type="button"
onClick={() => onProtocolClick?.(p.id)}
className={`flex w-full items-center gap-3 rounded-lg px-2 py-1.5 text-left transition-colors ${
active ? 'bg-white/5' : 'hover:bg-white/5'
}`}
title={NETWORK_HINTS[p.id] ?? p.label}
>
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: p.color }}
aria-hidden="true"
/>
<span
className={`flex-1 text-sm ${
active ? 'font-semibold text-white' : 'text-gray-300'
}`}
>
{p.label}
</span>
<span className="text-xs tabular-nums text-gray-500">{p.count}</span>
</button>
</li>
);
})}
</ul>
)}
{activeProtocolFilter && (
<button
type="button"
onClick={() => onProtocolClick?.(activeProtocolFilter)}
className="mt-2 text-[11px] text-degen-400 hover:text-degen-300"
>
Clear filter
</button>
)}
</div>
{/* Trending */}
<div className="rounded-2xl border border-white/10 bg-gray-950/70 p-4 backdrop-blur-xl">
<h2 className="mb-3 text-sm font-bold text-white">Trending now</h2>
{items.length === 0 ? (
<p className="text-xs text-gray-500">Trending topics appear as you browse.</p>
) : (
<div className="space-y-2">
{items.slice(0, 8).map((t) => (
<a
key={t.topic}
href={`/home/${encodeURIComponent(t.topic.toLowerCase().replace(/\s+/g, '-'))}`}
className="block rounded-lg px-2 py-1 transition-colors hover:bg-white/5"
>
<div className="text-[10px] uppercase tracking-wide text-gray-500">Trending</div>
<div className="text-sm font-medium text-white">{t.topic}</div>
<div className="text-xs text-gray-500">{t.count} posts</div>
</a>
))}
</div>
)}
</div>
{/* Premium CTA */}
<div className="rounded-2xl border border-degen-500/20 bg-degen-500/5 p-4 backdrop-blur-xl">
<h2 className="mb-1 text-sm font-bold text-white">DegenFeed Premium</h2>
<p className="mb-3 text-xs text-gray-400">
Cross-posting, algorithm controls, analytics, and creator tools.
</p>
<a href="/premium" className="btn-primary block text-center text-sm">
See plans
</a>
</div>
<p className="px-2 text-[10px] text-gray-700">
© {new Date().getFullYear()} DegenFeed · No account required to browse
</p>
</div>
);
}

View file

@ -0,0 +1,144 @@
'use client';
import { useCallback, useRef, useState } from 'react';
interface IdentityResult {
id: string;
protocol: string;
displayName?: string;
handle?: string;
avatarUrl?: string;
}
function protocolColor(p: string): string {
const c: Record<string, string> = {
nostr: '#8e30eb',
farcaster: '#8a63d2',
lens: '#abfe2c',
bluesky: '#1185fe',
threads: '#888',
mastodon: '#6364ff',
rss: '#ffa500',
};
return c[p] ?? '#666';
}
export function IdentityLinker({ onSignIn }: { onSignIn?: () => void }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<IdentityResult[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const doSearch = useCallback(async (q: string) => {
if (q.length < 2) {
setResults([]);
return;
}
setLoading(true);
try {
const res = await fetch(`/api/search/identity?q=${encodeURIComponent(q)}&limit=5`, {
credentials: 'include',
});
const data = (await res.json()) as { data?: IdentityResult[] };
setResults(data.data ?? []);
} catch {
setResults([]);
}
setLoading(false);
}, []);
const handleChange = useCallback(
(val: string) => {
setQuery(val);
setOpen(true);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(val), 300);
},
[doSearch],
);
const handleSelect = useCallback(
(_result: IdentityResult) => {
setQuery('');
setResults([]);
setOpen(false);
onSignIn?.();
},
[onSignIn],
);
return (
<div className="relative">
<label
htmlFor="identity-link-input"
className="mb-2 block text-xs font-semibold uppercase tracking-widest text-gray-500"
>
Link Another Identity
</label>
<div className="relative">
<input
id="identity-link-input"
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleChange(e.target.value)}
onFocus={() => {
if (results.length > 0) setOpen(true);
}}
onBlur={() => setTimeout(() => setOpen(false), 200)}
placeholder="Search by handle, address, or name..."
className="input pr-10"
/>
{loading && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-500"></span>
)}
</div>
{open && results.length > 0 && (
<div className="absolute left-0 right-0 top-full z-10 mt-1 rounded-xl border border-gray-800 bg-gray-950 shadow-2xl">
{results.map((r) => (
<button
key={r.id}
type="button"
onMouseDown={() => handleSelect(r)}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-gray-800/50 transition-colors first:rounded-t-xl last:rounded-b-xl"
>
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold"
style={{
backgroundColor: `${protocolColor(r.protocol)}22`,
color: protocolColor(r.protocol),
}}
>
{r.displayName?.[0]?.toUpperCase() || '?'}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-white truncate">
{r.displayName || r.handle || r.id}
</div>
<div className="text-xs text-gray-500">{r.handle || r.protocol}</div>
</div>
<span
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: `${protocolColor(r.protocol)}22`,
color: protocolColor(r.protocol),
}}
>
{r.protocol}
</span>
</button>
))}
</div>
)}
{open && query.length >= 2 && results.length === 0 && !loading && (
<div className="absolute left-0 right-0 top-full z-10 mt-1 rounded-xl border border-gray-800 bg-gray-950 p-4 text-center text-xs text-gray-500">
No identities found for &ldquo;{query}&rdquo;
</div>
)}
</div>
);
}

View file

@ -0,0 +1,115 @@
'use client';
import Image from 'next/image';
import { useEffect, useState } from 'react';
export function OnboardingModal() {
const [step, setStep] = useState(0);
const [open, setOpen] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') return;
if (localStorage.getItem('degenfeed_onboarded')) return;
setOpen(true);
}, []);
const dismiss = () => {
localStorage.setItem('degenfeed_onboarded', '1');
setOpen(false);
};
if (!open) return null;
const steps = [
{
icon: '◆',
title: '8 networks, one window',
body: 'Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads, Reddit, RSS — all in one unified feed. No account needed to browse.',
},
{
icon: '🔑',
title: 'Post across all networks',
body: 'Sign in with your wallet once. Post to Nostr and Bluesky from one composer. Like, reply, and repost everywhere.',
},
{
icon: '🐘',
title: 'Get @you@degenfeed.xyz',
body: 'Create your own Mastodon handle on our instance. Email required, admin-approved. Then cross-post to every network.',
cta: { label: 'Create free account →', href: '/api/auth/gotosocial/register' },
},
];
const s = steps[step];
if (!s) return null;
return (
<div
className="fixed inset-0 z-[60] flex items-end justify-center bg-black/80 backdrop-blur-sm sm:items-center"
onClick={dismiss}
>
<div
className="mx-4 w-full max-w-md rounded-2xl border border-degen-500/20 bg-gray-950 p-6 shadow-2xl shadow-degen-500/10"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Image
src="/icon.svg"
alt="DegenFeed"
width={28}
height={28}
className="h-7 w-7 rounded-lg shadow-[0_0_12px_rgba(0,255,65,0.3)]"
/>
<span className="text-sm font-semibold text-white">DegenFeed</span>
</div>
<button
onClick={dismiss}
className="rounded-lg p-1 text-gray-500 hover:text-white transition-colors text-sm"
>
Skip
</button>
</div>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-degen-500/10 text-3xl">
{s.icon}
</div>
<h2 className="mb-2 text-lg font-bold text-white">{s.title}</h2>
<p className="mb-5 text-sm leading-relaxed text-gray-400">{s.body}</p>
<div className="flex items-center justify-between">
<div className="flex gap-1.5">
{steps.map((_, i) => (
<span
key={i}
className={`h-1.5 w-1.5 rounded-full transition-colors ${i === step ? 'bg-degen-400' : 'bg-gray-700'}`}
/>
))}
</div>
<div className="flex gap-2">
{step > 0 && (
<button
onClick={() => setStep(step - 1)}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-gray-400 hover:text-white transition-colors"
>
Back
</button>
)}
{s.cta ? (
<a href={s.cta.href} className="btn-primary text-sm" onClick={dismiss}>
{s.cta.label}
</a>
) : step < steps.length - 1 ? (
<button onClick={() => setStep(step + 1)} className="btn-primary text-sm">
Next
</button>
) : (
<button onClick={dismiss} className="btn-primary text-sm">
Got it
</button>
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,206 @@
'use client';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
interface SearchResult {
id: string;
protocol: string;
author: { displayName: string; avatarUrl?: string };
text: string;
url: string | null;
image: string | null;
niches: string[];
createdAt: number;
metrics: { likes: number; reposts: number; replies: number; quotes: number };
}
function protocolColor(p: string): string {
const c: Record<string, string> = {
nostr: '#8e30eb',
farcaster: '#8a63d2',
lens: '#abfe2c',
bluesky: '#1185fe',
threads: '#888',
mastodon: '#6364ff',
rss: '#ffa500',
};
return c[p] ?? '#666';
}
function formatTime(ts: number): string {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
export function SearchOverlay({ open, onClose }: { open: boolean; onClose: () => void }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
useEffect(() => {
if (open) {
setQuery('');
setResults([]);
setSearched(false);
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [open]);
const doSearch = useCallback(async (q: string) => {
if (q.length < 2) {
setResults([]);
setSearched(false);
return;
}
setLoading(true);
setSearched(true);
setError(null);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&limit=20`, {
credentials: 'include',
});
if (!res.ok) throw new Error(`Search returned ${res.status}`);
const data = (await res.json()) as { data?: SearchResult[]; meta?: { total: number } };
setResults(data.data ?? []);
} catch (err) {
setResults([]);
setError(err instanceof Error ? err.message : 'Search failed');
}
setLoading(false);
}, []);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleChange = useCallback(
(val: string) => {
setQuery(val);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(val), 300);
},
[doSearch],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') onClose();
},
[onClose],
);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/80 pt-16 backdrop-blur-sm">
<div className="w-full max-w-2xl px-4">
<div className="relative mb-4">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search posts, topics, tokens..."
className="input h-12 pl-12 pr-12 text-base"
/>
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">
{loading ? '⟳' : '⌕'}
</span>
<button
type="button"
onClick={onClose}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-lg px-2 py-1 text-xs text-gray-500 hover:text-white transition-colors"
>
ESC
</button>
</div>
{loading && (
<div className="mb-2 space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="card animate-pulse flex items-center gap-3 p-3">
<div className="h-8 w-8 rounded-full bg-gray-800" />
<div className="flex-1 space-y-1.5">
<div className="h-3 w-24 rounded bg-gray-800" />
<div className="h-3 w-full rounded bg-gray-800" />
</div>
</div>
))}
</div>
)}
{error && (
<div className="card border-red-900/50 bg-red-900/10 py-4 text-center">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
{searched && !loading && !error && results.length === 0 && (
<div className="card py-8 text-center">
<p className="text-sm text-gray-500">No results for &ldquo;{query}&rdquo;</p>
</div>
)}
{results.length > 0 && (
<div className="max-h-[60vh] space-y-2 overflow-y-auto">
{results.map((r) => (
<button
key={r.id}
type="button"
onClick={() => {
router.push(`/post/${encodeURIComponent(r.id)}`);
onClose();
}}
className="card-hover flex w-full items-start gap-3 p-3 text-left"
>
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold"
style={{
backgroundColor: `${protocolColor(r.protocol)}22`,
color: protocolColor(r.protocol),
}}
>
{r.author.displayName[0]?.toUpperCase() || '?'}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs">
<span className="font-medium text-white truncate">{r.author.displayName}</span>
<span className="text-gray-500" style={{ color: protocolColor(r.protocol) }}>
{r.protocol}
</span>
<span className="ml-auto text-gray-600">{formatTime(r.createdAt)}</span>
</div>
<p className="mt-0.5 text-sm text-gray-400 line-clamp-2">{r.text}</p>
<div className="mt-1 flex gap-3 text-[10px] text-gray-600">
<span> {r.metrics.likes}</span>
<span> {r.metrics.reposts}</span>
<span> {r.metrics.replies}</span>
</div>
</div>
{r.image && (
<Image
src={r.image}
alt=""
width={48}
height={48}
className="h-12 w-12 shrink-0 rounded-lg object-cover"
loading="lazy"
unoptimized
/>
)}
</button>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,274 @@
'use client';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { useState } from 'react';
import { useAuth } from './AuthProvider';
import { BottomNav } from './BottomNav';
import { OnboardingModal } from './OnboardingModal';
import { SearchOverlay } from './SearchOverlay';
interface NavItem {
href: string;
label: string;
}
const NAV: NavItem[] = [
{ href: '/feed', label: 'Feed' },
{ href: '/news', label: 'News' },
{ href: '/discover', label: 'Discover' },
{ href: '/docs', label: 'Docs' },
{ href: '/premium', label: 'Premium' },
{ href: '/profile', label: 'Profile' },
];
function Logo() {
return (
<a href="/" className="flex items-center gap-2 group">
<Image
src="/icon.svg"
alt="DegenFeed"
width={32}
height={32}
className="h-8 w-8 rounded-lg logo-glow transition-shadow group-hover:shadow-[0_0_20px_rgba(34,197,94,0.6)]"
/>
<span className="text-lg font-bold tracking-tight text-white">DegenFeed</span>
</a>
);
}
function NavLink({ item, onClick }: { item: NavItem; onClick?: () => void }) {
const pathname = usePathname();
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<a
href={item.href}
onClick={onClick}
className={`relative rounded-lg px-3 py-2 text-sm font-medium transition-all ${
active ? 'text-white' : 'text-gray-400 hover:bg-white/5 hover:text-white'
}`}
>
{item.label}
{active && (
<span className="absolute bottom-0 left-1/2 h-0.5 w-4 -translate-x-1/2 rounded-full bg-degen-500 shadow-[0_0_8px_rgba(34,197,94,0.8)]" />
)}
</a>
);
}
export function Shell({ children }: { children: React.ReactNode }) {
const { ready, signedInProtocols, openSignIn, openCompose } = useAuth();
const [mobileOpen, setMobileOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const hasSignedIn = Object.values(signedInProtocols).some(Boolean);
const closeMobile = () => setMobileOpen(false);
return (
<div className="relative flex min-h-screen flex-col bg-black text-gray-200">
<div className="aurora-bg" />
<div className="noise-overlay" />
<a className="skip-link" href="#main">
Skip to content
</a>
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/10 glass">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
<Logo />
<nav className="hidden items-center gap-1 md:flex" aria-label="Primary">
{NAV.map((item) => (
<NavLink key={item.href} item={item} />
))}
</nav>
<div className="hidden items-center gap-3 md:flex">
<button
type="button"
onClick={() => setSearchOpen(true)}
className="rounded-lg px-3 py-2 text-sm text-gray-400 hover:bg-white/5 hover:text-white transition-colors"
aria-label="Search"
>
</button>
{!ready ? (
<div className="h-9 w-24 animate-pulse rounded-lg bg-gray-800" />
) : hasSignedIn ? (
<button type="button" onClick={openCompose} className="btn-primary">
Compose
</button>
) : (
<div className="flex items-center gap-2">
<a
href="/api/auth/gotosocial/register"
className="rounded-lg border border-degen-500/30 bg-degen-500/10 px-3 py-1.5 text-xs font-semibold text-degen-400 transition-all hover:bg-degen-500/20 hover:shadow-[0_0_12px_rgba(34,197,94,0.3)]"
>
Get @degenfeed.xyz
</a>
<button type="button" onClick={openSignIn} className="btn-primary text-sm">
Connect Wallet
</button>
</div>
)}
</div>
<button
type="button"
onClick={() => setMobileOpen((v) => !v)}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-white/5 hover:text-white md:hidden"
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
aria-expanded={mobileOpen}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
{mobileOpen ? (
<path
d="M15 5L5 15M5 5l10 10"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
) : (
<>
<path d="M3 6h14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path
d="M3 10h14"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<path
d="M3 14h14"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</>
)}
</svg>
</button>
</div>
{mobileOpen && (
<div className="border-t border-white/10 bg-gray-950/95 backdrop-blur-xl md:hidden">
<nav className="flex flex-col gap-1 p-4" aria-label="Mobile primary">
{NAV.map((item) => (
<NavLink key={item.href} item={item} onClick={closeMobile} />
))}
</nav>
<div className="border-t border-white/10 p-4">
{!ready ? (
<div className="h-10 w-full animate-pulse rounded-lg bg-gray-800" />
) : hasSignedIn ? (
<button
type="button"
onClick={() => {
closeMobile();
openCompose();
}}
className="btn-primary w-full"
>
Compose
</button>
) : (
<button
type="button"
onClick={() => {
closeMobile();
openSignIn();
}}
className="btn-primary w-full"
>
Connect Wallet
</button>
)}
</div>
</div>
)}
</header>
<main id="main" className="relative z-10 flex-1 pt-16 pb-20 lg:pb-0">
{children}
</main>
<BottomNav />
<footer className="relative z-10 hidden border-t border-white/10 bg-gray-950/50 py-8 lg:block">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 px-4 sm:flex-row">
<div className="flex items-center gap-2">
<span className="text-degen-500"></span>
<span className="text-sm font-semibold text-white">DegenFeed</span>
</div>
<div className="flex items-center gap-6 text-sm text-gray-500">
<a href="/feed" className="transition-colors hover:text-white">
Feed
</a>
<a href="/news" className="transition-colors hover:text-white">
News
</a>
<a href="/docs" className="transition-colors hover:text-white">
Docs
</a>
<a href="/premium" className="transition-colors hover:text-white">
Premium
</a>
<a href="/profile" className="transition-colors hover:text-white">
Profile
</a>
</div>
<div className="flex items-center gap-4 text-xs text-gray-600">
<a href="/privacy" className="transition-colors hover:text-gray-400">
Privacy
</a>
<a href="/terms" className="transition-colors hover:text-gray-400">
Terms
</a>
<a
href="/status"
className="inline-flex items-center gap-1.5 transition-colors hover:text-gray-400"
title="Protocol health"
>
<span className="h-1.5 w-1.5 rounded-full bg-degen-500 shadow-[0_0_4px_rgba(34,197,94,0.5)]" />
<span>Status</span>
</a>
</div>
<p className="text-xs text-gray-600">
© {new Date().getFullYear()} DegenFeed a Rug Munch Media LLC product. No account
required. Open source on{' '}
<a
href="https://git.rugmunch.io/RugMunchMedia/degenfeed-web"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 underline transition-colors hover:text-gray-400"
>
Forgejo
</a>
,{' '}
<a
href="https://gitlab.com/cryptorugmuncher/degenfeed-web"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 underline transition-colors hover:text-gray-400"
>
GitLab
</a>
, and{' '}
<a
href="https://codeberg.org/cryptorugmunch/degenfeed-web"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 underline transition-colors hover:text-gray-400"
>
Codeberg
</a>
.
</p>
</div>
</footer>
<SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} />
<OnboardingModal />
</div>
);
}

View file

@ -0,0 +1,155 @@
'use client';
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react';
interface SolanaWalletProvider {
isPhantom?: boolean;
isSolflare?: boolean;
isBackpack?: boolean;
connect: () => Promise<{ publicKey?: { toString(): string } }>;
disconnect: () => void;
publicKey?: { toString(): string };
signTransaction: (tx: unknown) => Promise<{ serialize(): Uint8Array }>;
}
declare global {
interface Window {
phantom?: { solana?: SolanaWalletProvider };
solflare?: SolanaWalletProvider;
backpack?: { solana?: SolanaWalletProvider };
solana?: SolanaWalletProvider;
}
}
interface SolanaContextValue {
account: string | null;
isConnected: boolean;
connect: () => Promise<string>;
disconnect: () => void;
signAndSendTransaction: (to: string, amount: number) => Promise<{ signature: string }>;
providerName: string;
}
const DEFAULT_SOLANA: SolanaContextValue = {
account: null,
isConnected: false,
connect: async () => {
throw new Error('No Solana wallet');
},
disconnect: () => {},
signAndSendTransaction: async () => {
throw new Error('No Solana wallet');
},
providerName: '',
};
const SolanaContext = createContext<SolanaContextValue>(DEFAULT_SOLANA);
export function useSolana(): SolanaContextValue {
return useContext(SolanaContext);
}
function getSolanaProvider(): { provider: SolanaWalletProvider; name: string } | null {
if (typeof window === 'undefined') return null;
// Phantom
const phantom = window.phantom?.solana;
if (phantom?.isPhantom) return { provider: phantom as SolanaWalletProvider, name: 'Phantom' };
// Solflare
const solflare = window.solflare;
if (solflare?.isSolflare) return { provider: solflare as SolanaWalletProvider, name: 'Solflare' };
// Backpack
const backpack = window.backpack?.solana;
if (backpack?.isBackpack) return { provider: backpack as SolanaWalletProvider, name: 'Backpack' };
// Fallback: window.solana
const fallback = window.solana;
if (fallback)
return {
provider: fallback as SolanaWalletProvider,
name: fallback.isPhantom ? 'Phantom' : 'Solana Wallet',
};
return null;
}
export function SolanaProvider({ children }: { children: ReactNode }) {
const [account, setAccount] = useState<string | null>(null);
const [providerName, setProviderName] = useState('');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const getProvider = useCallback(() => {
const p = getSolanaProvider();
if (p) setProviderName(p.name);
return p?.provider || null;
}, []);
const connect = useCallback(async () => {
const provider = getProvider();
if (!provider)
throw new Error('No Solana wallet detected. Install Phantom, Solflare, or Backpack.');
try {
const resp = await provider.connect();
const pubKey = resp.publicKey?.toString() || provider.publicKey?.toString() || '';
if (pubKey) setAccount(pubKey);
return pubKey;
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'User rejected Solana connection');
}
}, [getProvider]);
const disconnect = useCallback(() => {
const provider = getProvider();
provider?.disconnect();
setAccount(null);
}, [getProvider]);
const signAndSendTransaction = useCallback(
async (to: string, amount: number): Promise<{ signature: string }> => {
const provider = getProvider();
if (!provider || !account) throw new Error('No Solana wallet connected');
const { LAMPORTS_PER_SOL, PublicKey, SystemProgram, Transaction, Connection } = await import(
'@solana/web3.js'
);
const connection = new Connection('https://api.mainnet-beta.solana.com');
const fromPubkey = new PublicKey(account);
const toPubkey = new PublicKey(to);
const tx = new Transaction().add(
SystemProgram.transfer({
fromPubkey,
toPubkey,
lamports: amount * LAMPORTS_PER_SOL,
}),
);
const { blockhash } = await connection.getLatestBlockhash();
tx.recentBlockhash = blockhash;
tx.feePayer = fromPubkey;
const signed = await provider.signTransaction(tx);
const signature = await connection.sendRawTransaction(signed.serialize());
return { signature };
},
[account, getProvider],
);
const value: SolanaContextValue = {
account,
isConnected: !!account,
connect,
disconnect,
signAndSendTransaction,
providerName,
};
if (!mounted) {
return <>{children}</>;
}
return <SolanaContext.Provider value={value}>{children}</SolanaContext.Provider>;
}

View file

@ -0,0 +1,478 @@
'use client';
import { QRCodeSVG } from 'qrcode.react';
import { useCallback, useEffect, useState } from 'react';
import type { TipPostContext } from '../hooks/useTip';
import { useTip } from '../hooks/useTip';
import { useSolana } from './SolanaProvider';
import { useWeb3 } from './Web3Provider';
interface TipModalProps {
open: boolean;
onClose: () => void;
recipientProtocol: string;
recipientAuthorId: string;
recipientDisplayName: string;
publicationId?: string;
postContext?: TipPostContext;
}
export function TipModal({
open,
onClose,
recipientProtocol,
recipientAuthorId,
recipientDisplayName,
publicationId,
postContext,
}: TipModalProps) {
const { state, resolveMethods, createTip, fetchLightningInvoice, setTipStatus } = useTip();
const { account, connect: connectWallet } = useWeb3();
const { account: solanaAccount, connect: connectSolana, signAndSendTransaction } = useSolana();
const [amount, setAmount] = useState('0.001');
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [_invoice, setInvoice] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
// Resolve tipping methods on open
useEffect(() => {
if (open) {
setInvoice(null);
resolveMethods(recipientProtocol, recipientAuthorId, postContext).then((methods) => {
const recommended = methods.find((m) => m.available && m.recommended);
const firstAvailable = methods.find((m) => m.available);
setSelectedMethod(recommended?.method ?? firstAvailable?.method ?? null);
});
}
}, [open, recipientProtocol, recipientAuthorId, postContext, resolveMethods]);
const handleClose = useCallback(() => {
setInvoice(null);
onClose();
}, [onClose]);
const handleSend = useCallback(async () => {
if (!selectedMethod || !amount) return;
try {
if (selectedMethod === 'ethereum') {
let ethAccount = account;
if (!ethAccount) ethAccount = await connectWallet();
if (!ethAccount) throw new Error('No Ethereum wallet connected');
const intent = await createTip(
recipientAuthorId,
recipientProtocol,
selectedMethod,
amount,
publicationId,
);
if (!intent) return;
setTipStatus('signing');
const valueHex = `0x${BigInt(Math.floor(Number.parseFloat(amount) * 1e18)).toString(16)}`;
if (!window.ethereum) throw new Error('No Ethereum wallet detected');
const txHash = await window.ethereum.request({
method: 'eth_sendTransaction',
params: [{ to: intent.intent.to, value: valueHex }],
});
setTipStatus('sent', txHash as string);
return;
}
if (selectedMethod === 'solana') {
let solAccount = solanaAccount;
if (!solAccount) solAccount = await connectSolana();
if (!solAccount) throw new Error('No Solana wallet connected');
const intent = await createTip(
recipientAuthorId,
recipientProtocol,
selectedMethod,
amount,
publicationId,
);
if (!intent) return;
setTipStatus('signing');
const { signature } = await signAndSendTransaction(
intent.intent.to,
Number.parseFloat(amount),
);
setTipStatus('sent', signature);
return;
}
if (selectedMethod === 'lightning_zap') {
setTipStatus('creating');
const result = await fetchLightningInvoice(recipientAuthorId, amount, 'Tip from DegenFeed');
if (!result) return;
setInvoice(result.invoice);
setTipStatus('sent', result.invoice);
return;
}
// Lens Collect
if (selectedMethod === 'lens_collect') {
const intent = await createTip(
recipientAuthorId,
recipientProtocol,
selectedMethod,
amount,
publicationId,
);
if (!intent) return;
setTipStatus('signing');
const provider = window.ethereum;
if (!provider) throw new Error('No Ethereum wallet detected');
// Ensure the wallet is on the correct chain for this tip
const requiredChainId = intent.intent.chainId ?? 137;
const requiredChainHex = `0x${requiredChainId.toString(16)}`;
try {
const currentChain = await provider.request({ method: 'eth_chainId' });
if (currentChain !== requiredChainHex) {
await provider.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: requiredChainHex }],
});
}
} catch {
// User rejected chain switch or chain not added — proceed anyway
// and let eth_sendTransaction surface the real error.
}
const txHash = await provider.request({
method: 'eth_sendTransaction',
params: [
{
from: account,
to: intent.intent.to,
data: intent.intent.data,
value: intent.intent.value,
chainId: `0x${(intent.intent.chainId ?? 137).toString(16)}`,
},
],
});
setTipStatus('sent', txHash as string);
return;
}
// Fallback: Frame / other methods
const intent = await createTip(
recipientAuthorId,
recipientProtocol,
selectedMethod,
amount,
publicationId,
);
if (!intent) return;
setTipStatus('sent', intent.actionUrl || 'ok');
} catch (err) {
setTipStatus('failed', undefined, err instanceof Error ? err.message : String(err));
}
}, [
selectedMethod,
amount,
account,
solanaAccount,
connectWallet,
connectSolana,
signAndSendTransaction,
createTip,
fetchLightningInvoice,
setTipStatus,
recipientAuthorId,
recipientProtocol,
publicationId,
]);
const quickAmounts =
selectedMethod === 'lightning_zap'
? ['1000', '5000', '10000', '50000', '100000']
: ['0.001', '0.005', '0.01', '0.05', '0.1'];
if (!open) return null;
const availableMethods = state.methods.filter((m) => m.available);
const selected = state.methods.find((m) => m.method === selectedMethod);
return (
<dialog
open
className="fixed inset-0 z-50 m-0 flex items-center justify-center bg-black/70 p-0 backdrop-blur-sm open:m-0 open:p-0"
onClick={handleClose}
onKeyDown={(e) => {
if (e.key === 'Escape') handleClose();
}}
aria-modal="true"
aria-label={`Tip ${recipientDisplayName}`}
>
<section
className="w-full max-w-sm rounded-2xl border border-gray-800 bg-gray-950 shadow-2xl"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="document"
tabIndex={-1}
>
<div className="flex items-center justify-between border-b border-gray-800 px-4 py-3">
<h2 className="text-sm font-semibold text-white">Tip {recipientDisplayName}</h2>
<button
onClick={handleClose}
className="rounded-lg p-1.5 text-gray-500 hover:bg-gray-800 hover:text-white transition-colors"
type="button"
aria-label="Close"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
aria-labelledby="closeTitle"
>
<title id="closeTitle">Close</title>
<path
d="M12 4L4 12M4 4l8 8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div>
<div className="px-4 py-4">
{state.status === 'resolving' ? (
<div className="py-8 text-center">
<div className="mb-3 inline-block animate-spin text-2xl"></div>
<p className="text-sm text-gray-500">Loading tipping options...</p>
</div>
) : state.methods.length === 0 || availableMethods.length === 0 ? (
<div className="py-8 text-center">
<p className="mb-2 text-gray-500">No tipping methods available for this creator</p>
<p className="mb-4 text-xs text-gray-600">
They may not have linked any wallets or payment accounts yet.
</p>
<a
href="/profile/monetization"
className="inline-block rounded-lg bg-degen-500/20 px-4 py-2 text-xs font-medium text-degen-400 hover:bg-degen-500/30 transition-colors"
>
Set up tipping
</a>
</div>
) : (
<>
<div className="mb-4">
<label
htmlFor="tip-method"
className="mb-2 block text-xs font-medium uppercase tracking-widest text-gray-500"
>
Method
</label>
<div className="space-y-1.5">
{availableMethods.map((m) => (
<button
key={m.method}
type="button"
onClick={() => setSelectedMethod(m.method)}
className={`w-full flex items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition-all ${
selectedMethod === m.method
? 'border-degen-500 bg-degen-500/10'
: 'border-gray-800 bg-gray-900/50 hover:border-gray-700'
}`}
>
<span className="text-lg">{m.icon}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-white">{m.label}</div>
{m.recommended && (
<span className="shrink-0 rounded-full bg-degen-500/20 px-2 py-0.5 text-[10px] font-medium text-degen-400">
Recommended
</span>
)}
</div>
<div className="text-xs text-gray-500 truncate">{m.description}</div>
</div>
{m.fee && (
<span className="shrink-0 rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-400">
Fee: {m.fee}
</span>
)}
</button>
))}
</div>
</div>
<div className="mb-4">
<label
htmlFor="tip-amount"
className="mb-2 block text-xs font-medium uppercase tracking-widest text-gray-500"
>
Amount
</label>
<div className="relative mb-2">
<input
id="tip-amount"
type="text"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-3 text-lg font-mono text-white outline-none focus:border-degen-500 transition-colors"
placeholder={selectedMethod === 'lightning_zap' ? '1000' : '0.001'}
aria-label="Tip amount"
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-sm text-gray-500">
{selected?.method === 'lightning_zap'
? 'sats'
: selected?.method === 'ethereum'
? 'ETH'
: selected?.method === 'solana'
? 'SOL'
: ''}
</span>
</div>
<div className="flex gap-2">
{quickAmounts.map((a) => (
<button
key={a}
type="button"
onClick={() => setAmount(a)}
className={`rounded-lg px-3 py-1 text-xs font-medium transition-colors border ${
amount === a
? 'border-degen-500 bg-degen-500/20 text-degen-400'
: 'border-gray-800 text-gray-500 hover:border-gray-700'
}`}
>
{a}
</button>
))}
</div>
</div>
{selectedMethod === 'ethereum' && !account && (
<div className="mb-4 rounded-lg border border-yellow-900/50 bg-yellow-900/10 p-3">
<p className="text-xs text-yellow-400">
Connect your Ethereum wallet (MetaMask, Rabby, Rainbow) to send this tip.
</p>
</div>
)}
{selectedMethod === 'solana' && !solanaAccount && (
<div className="mb-4 rounded-lg border border-purple-900/50 bg-purple-900/10 p-3">
<p className="text-xs text-purple-400">
Connect your Solana wallet (Phantom, Solflare, Backpack) to send this tip.
</p>
</div>
)}
{selectedMethod === 'solana' && (
<button
type="button"
onClick={() => {
const recipient =
selected?.address ?? postContext?.author?.solanaAddress ?? recipientAuthorId;
const url = `https://app.degenfeed.xyz/api/blink/tip?recipient=${encodeURIComponent(recipient)}&amount=${encodeURIComponent(amount)}`;
void navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
className="mb-4 w-full rounded-lg border border-purple-900/50 bg-purple-900/10 px-3 py-2 text-xs font-medium text-purple-400 hover:bg-purple-900/20 transition-colors"
>
{copied ? 'Blink URL copied!' : 'Share as Blink'}
</button>
)}
<button
type="button"
disabled={
!selectedMethod || ['creating', 'signing', 'sending'].includes(state.status)
}
onClick={handleSend}
className="btn-primary w-full text-sm disabled:opacity-50"
>
{state.status === 'signing'
? 'Sign in wallet...'
: state.status === 'creating'
? 'Preparing...'
: state.status === 'sending'
? 'Sending...'
: state.status === 'sent'
? '✓ Sent!'
: state.status === 'failed'
? 'Failed - Try Again'
: 'Send Tip'}
</button>
{state.status === 'failed' && state.error && (
<p className="mt-2 text-xs text-red-400 text-center">{state.error}</p>
)}
{state.status === 'sent' && state.txHash && (
<div className="mt-3 rounded-lg bg-green-900/20 border border-green-900/50 p-3">
{selectedMethod === 'lightning_zap' ? (
<>
<p className="mb-2 text-xs text-green-400">Lightning Invoice Ready</p>
<div className="mb-2 flex justify-center">
{typeof window !== 'undefined' && (
<div className="rounded-xl bg-white p-2">
<QRCodeSVG value={`lightning:${state.txHash}`} size={160} />
</div>
)}
</div>
<p className="mb-2 text-center text-[10px] text-gray-500">
Scan with any Lightning wallet
</p>
<div className="flex gap-2">
<code className="flex-1 rounded bg-gray-900 px-2 py-1 text-[10px] text-gray-400 font-mono truncate">
{state.txHash.slice(0, 40)}...
</code>
<button
onClick={() => navigator.clipboard.writeText(state.txHash || '')}
className="shrink-0 rounded-lg bg-gray-800 px-2 py-1 text-[10px] text-gray-400 hover:text-white transition-colors"
type="button"
>
Copy Invoice
</button>
</div>
<a
href={`lightning:${state.txHash}`}
className="mt-2 block text-center text-xs text-degen-500 hover:text-degen-400"
>
Open in wallet
</a>
</>
) : (
<>
<p className="mb-1 text-xs text-green-400">Tip sent successfully!</p>
<a
href={
selectedMethod === 'ethereum'
? `https://etherscan.io/tx/${state.txHash}`
: selectedMethod === 'solana'
? `https://solscan.io/tx/${state.txHash}`
: selectedMethod === 'lens_collect'
? `https://polygonscan.com/tx/${state.txHash}`
: '#'
}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-degen-500 hover:text-degen-400 break-all"
>
View transaction
</a>
</>
)}
</div>
)}
</>
)}
</div>
<div className="border-t border-gray-800 px-4 py-3">
<p className="text-center text-[10px] text-gray-600">
Powered by DegenFeed Cross-platform tipping No platform fees
</p>
</div>
</section>
</dialog>
);
}

View file

@ -0,0 +1,115 @@
'use client';
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react';
export type ToastKind = 'info' | 'success' | 'error' | 'warning';
export interface Toast {
id: string;
kind: ToastKind;
message: string;
/** ms to live; default 4000 */
duration?: number;
}
interface ToastContextValue {
toasts: Toast[];
showToast: (kind: ToastKind, message: string, duration?: number) => void;
dismiss: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within a ToastProvider');
return ctx;
}
let nextId = 0;
function makeId() {
nextId += 1;
return `${Date.now().toString(36)}-${nextId}`;
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const showToast = useCallback((kind: ToastKind, message: string, duration = 4000) => {
const id = makeId();
setToasts((prev) => [...prev, { id, kind, message, duration }]);
}, []);
return (
<ToastContext.Provider value={{ toasts, showToast, dismiss }}>
{children}
<ToastViewport />
</ToastContext.Provider>
);
}
function kindClasses(kind: ToastKind): string {
switch (kind) {
case 'success':
return 'border-degen-500/40 bg-degen-500/10 text-degen-300';
case 'error':
return 'border-red-500/40 bg-red-500/10 text-red-300';
case 'warning':
return 'border-yellow-500/40 bg-yellow-500/10 text-yellow-300';
default:
return 'border-white/10 bg-gray-900/90 text-gray-200';
}
}
function ToastViewport() {
const { toasts, dismiss } = useToast();
if (toasts.length === 0) return null;
return (
<section
className="pointer-events-none fixed inset-x-0 bottom-4 z-[100] flex flex-col items-center gap-2 px-4 sm:bottom-6 sm:right-6 sm:left-auto sm:items-end"
aria-label="Notifications"
>
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
))}
</section>
);
}
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
useEffect(() => {
const ms = toast.duration ?? 4000;
const timer = setTimeout(() => onDismiss(toast.id), ms);
return () => clearTimeout(timer);
}, [toast.id, toast.duration, onDismiss]);
return (
<div
role={toast.kind === 'error' ? 'alert' : 'status'}
className={`pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded-xl border px-4 py-3 shadow-2xl backdrop-blur-xl transition-all animate-in fade-in slide-in-from-bottom-2 ${kindClasses(
toast.kind,
)}`}
>
<span className="text-sm font-medium leading-snug break-words flex-1">{toast.message}</span>
<button
type="button"
onClick={() => onDismiss(toast.id)}
className="shrink-0 rounded-md p-1 text-current/60 hover:bg-white/5 hover:text-current"
aria-label="Dismiss"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M12 4L4 12M4 4l8 8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div>
);
}

View file

@ -0,0 +1,140 @@
'use client';
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react';
interface EIP1193Provider {
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>;
on: (event: string, handler: (...args: unknown[]) => void) => void;
removeListener: (event: string, handler: (...args: unknown[]) => void) => void;
selectedAddress?: string;
isMetaMask?: boolean;
}
declare global {
interface Window {
ethereum?: EIP1193Provider;
}
}
interface Web3ContextValue {
account: string | null;
chainId: number | null;
isConnected: boolean;
connect: () => Promise<string>;
disconnect: () => void;
signMessage: (message: string) => Promise<string>;
isMetaMask: boolean;
}
const DEFAULT_CONTEXT: Web3ContextValue = {
account: null,
chainId: null,
isConnected: false,
connect: async () => {
throw new Error('Web3 not available');
},
disconnect: () => {},
signMessage: async () => {
throw new Error('Web3 not available');
},
isMetaMask: false,
};
const Web3Context = createContext<Web3ContextValue>(DEFAULT_CONTEXT);
export function useWeb3(): Web3ContextValue {
return useContext(Web3Context);
}
function getProvider(): EIP1193Provider | null {
if (typeof window === 'undefined' || !window.ethereum) return null;
return window.ethereum;
}
export function Web3Provider({ children }: { children: ReactNode }) {
const [account, setAccount] = useState<string | null>(null);
const [chainId, setChainId] = useState<number | null>(null);
const isConnected = !!account;
useEffect(() => {
const check = async () => {
const provider = getProvider();
if (!provider) return;
try {
const accounts = (await provider.request({ method: 'eth_accounts' })) as string[];
if (accounts?.[0]) {
setAccount(accounts[0]);
const id = await provider.request({ method: 'eth_chainId' });
setChainId(Number.parseInt(id as string, 16));
}
} catch {
/* ignore */
}
};
check();
const handleAccountsChanged = (accounts: unknown) => {
const addrs = Array.isArray(accounts) ? (accounts as string[]) : [];
setAccount(addrs[0] || null);
};
const handleChainChanged = (chainIdHex: unknown) => {
setChainId(Number.parseInt(String(chainIdHex), 16));
};
const provider = getProvider();
if (provider) {
provider.on('accountsChanged', handleAccountsChanged);
provider.on('chainChanged', handleChainChanged);
return () => {
provider.removeListener('accountsChanged', handleAccountsChanged);
provider.removeListener('chainChanged', handleChainChanged);
};
}
}, []);
const connect = useCallback(async () => {
const provider = getProvider();
if (!provider) throw new Error('No Ethereum wallet detected');
try {
const accounts = (await provider.request({ method: 'eth_requestAccounts' })) as string[];
const selected = accounts?.[0] || provider.selectedAddress;
if (selected) setAccount(selected);
const id = await provider.request({ method: 'eth_chainId' });
setChainId(Number.parseInt(id as string, 16));
return selected || '';
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'User rejected connection');
}
}, []);
const disconnect = useCallback(() => {
setAccount(null);
setChainId(null);
}, []);
const signMessage = useCallback(
async (message: string): Promise<string> => {
const provider = getProvider();
if (!provider || !account) throw new Error('No wallet connected');
const signature = (await provider.request({
method: 'personal_sign',
params: [message, account],
})) as string;
return signature;
},
[account],
);
const value: Web3ContextValue = {
account,
chainId,
isConnected,
connect,
disconnect,
signMessage,
isMetaMask: typeof window !== 'undefined' && !!window.ethereum?.isMetaMask,
};
return <Web3Context.Provider value={value}>{children}</Web3Context.Provider>;
}

View file

@ -0,0 +1,155 @@
'use client';
import type { StoredIdentity } from '@degenfeed/storage';
import { getAllIdentities } from '@degenfeed/storage';
import type { Notification } from '@degenfeed/ui';
import { useCallback, useEffect, useRef, useState } from 'react';
const READ_KEY = 'degenfeed_notifications_read';
const POLL_MS = 60000;
interface NotificationAuth {
nostr_pubkey?: string;
mastodon?: string;
mastodon_instance?: string;
bluesky?: {
handle?: string;
did?: string;
accessJwt?: string;
};
farcaster?: {
fid?: string;
signerKey?: string;
};
}
function buildAuth(identities: StoredIdentity[]): NotificationAuth {
const auth: NotificationAuth = {};
for (const identity of identities) {
if (identity.protocol === 'nostr') {
auth.nostr_pubkey = identity.publicId;
} else if (identity.protocol === 'mastodon') {
auth.mastodon = identity.keys?.accessToken ?? undefined;
auth.mastodon_instance = identity.keys?.instanceUrl ?? 'https://social.degenfeed.xyz';
} else if (identity.protocol === 'bluesky') {
auth.bluesky = {
handle: identity.handle,
did: identity.publicId,
accessJwt: identity.keys?.accessJwt ?? undefined,
};
} else if (identity.protocol === 'farcaster') {
auth.farcaster = {
fid: identity.publicId,
signerKey: identity.keys?.signerKey ?? undefined,
};
}
}
return auth;
}
function loadReadIds(): Set<string> {
try {
const raw = localStorage.getItem(READ_KEY);
if (!raw) return new Set();
return new Set(JSON.parse(raw) as string[]);
} catch {
return new Set();
}
}
function saveReadIds(ids: Set<string>) {
try {
localStorage.setItem(READ_KEY, JSON.stringify(Array.from(ids)));
} catch {
/* ignore */
}
}
export function useNotifications(enabled: boolean) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [readIds, setReadIds] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
setReadIds(loadReadIds());
}, []);
const fetchNotifications = useCallback(async () => {
if (!enabled) return;
setLoading(true);
try {
const identities = await getAllIdentities();
const auth = buildAuth(identities);
const hasAny =
auth.nostr_pubkey || auth.mastodon || auth.bluesky?.accessJwt || auth.farcaster?.fid;
if (!hasAny) {
setNotifications([]);
return;
}
const res = await fetch('/api/notifications', {
method: 'POST',
credentials: 'include',
cache: 'no-store',
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
body: JSON.stringify({ auth }),
});
const data = (await res.json()) as { notifications?: Notification[] };
const fetched = data.notifications || [];
const currentRead = loadReadIds();
setNotifications(
fetched.map((n) => ({
...n,
read: n.read || currentRead.has(n.id),
})),
);
} catch {
/* ignore */
} finally {
setLoading(false);
}
}, [enabled]);
const markRead = useCallback((id: string) => {
setReadIds((prev) => {
const next = new Set(prev);
next.add(id);
saveReadIds(next);
return next;
});
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)));
}, []);
const markAllRead = useCallback(() => {
setReadIds((prev) => {
const next = new Set(prev);
for (const n of notifications) next.add(n.id);
saveReadIds(next);
return next;
});
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
}, [notifications]);
useEffect(() => {
fetchNotifications();
intervalRef.current = setInterval(fetchNotifications, POLL_MS);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchNotifications]);
const decorated = notifications.map((n) => ({
...n,
read: n.read || readIds.has(n.id),
}));
const unreadCount = decorated.filter((n) => !n.read).length;
return {
notifications: decorated,
unreadCount,
loading,
fetchNotifications,
markRead,
markAllRead,
};
}

Some files were not shown because too many files have changed in this diff Show more