Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1afe8601ed | ||
|
|
989419665e | ||
|
|
7ff75f9a00 | ||
|
|
761ffa85d9 | ||
|
|
21dd784fbb | ||
|
|
e12567ea3c | ||
|
|
7844367bd2 | ||
|
|
80343d3782 | ||
|
|
a2146a4f0b |
15
DEPLOYING.md
15
DEPLOYING.md
@@ -55,7 +55,6 @@ ENCRYPTION_SECRET_KEY= # generate: python -c "from cryptography.fernet import F
|
||||
|
||||
# PostgreSQL
|
||||
POSTGRES_USER=congress
|
||||
POSTGRES_PASSWORD=your-strong-password
|
||||
POSTGRES_DB=pocketveto
|
||||
|
||||
# Redis
|
||||
@@ -70,6 +69,18 @@ OPENAI_API_KEY=sk-...
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
```
|
||||
|
||||
### Database password (secrets file)
|
||||
|
||||
The database password lives in a plain file rather than `.env` so that any characters work with no escaping needed.
|
||||
|
||||
```bash
|
||||
mkdir -p secrets
|
||||
openssl rand -base64 32 | tr -d '+/=' | cut -c1-32 > secrets/db_password
|
||||
chmod 600 secrets/db_password
|
||||
```
|
||||
|
||||
The `secrets/` directory is gitignored. Docker mounts `secrets/db_password` read-only into each container; the backend reads it automatically to build the database URL. Never commit this file.
|
||||
|
||||
Other providers (swap in place of the OpenAI block):
|
||||
```env
|
||||
# Anthropic
|
||||
@@ -112,6 +123,8 @@ SMTP_FROM=alerts@yourdomain.com
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
> **Note:** Make sure `secrets/db_password` exists before running this command (see step 2). Docker Compose will fail to start if the secrets file is missing.
|
||||
|
||||
This will:
|
||||
1. Pull base images (postgres, redis, nginx, node)
|
||||
2. Build the API, worker, beat, and frontend images
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
- [x] Collapsible sidebar — icon-only mode, `localStorage` persistence
|
||||
- [x] Favicon — Landmark icon
|
||||
- [x] LLM Batch API — OpenAI + Anthropic async batch endpoints; 50% cost reduction; state tracked in `AppSetting("llm_active_batch")`
|
||||
- [x] Mobile layout fix — `min-w-0` on flex content column in `AuthGuard` prevents long URLs from forcing viewport overflow on Android Chrome; app shell switched to `fixed inset-0` for stable visual-viewport anchoring
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -132,6 +132,44 @@ docker compose logs -f api worker
|
||||
|
||||
---
|
||||
|
||||
## Mobile page wider than viewport (Android Chrome zoom / dead space)
|
||||
|
||||
**Symptom**
|
||||
|
||||
On Android Chrome, a page zooms out to fit an oversized layout. The content is readable but the bottom third of the screen is dead space (background color only). The same page looks fine on all other devices, and other pages on the same device are fine.
|
||||
|
||||
**Root cause**
|
||||
|
||||
Flex items default to `min-width: auto`, meaning they cannot shrink below their content's natural width. If any descendant (e.g. a long URL in an input field) is wider than the viewport, the containing flex column expands to match, making the entire page layout wider than the viewport. Android Chrome then zooms the viewport out to fit the full layout width — which makes the layout taller than the visual viewport, leaving dead space at the bottom.
|
||||
|
||||
**Fix**
|
||||
|
||||
Add `min-w-0` (Tailwind) / `min-width: 0` (CSS) to the flex item that forms the main content column. This allows the column to shrink to zero, enabling children to wrap and truncate normally.
|
||||
|
||||
In `AuthGuard.tsx` the content column needs both `flex-1` and `min-w-0`:
|
||||
|
||||
```tsx
|
||||
// Before — missing min-w-0
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
|
||||
// After
|
||||
<div className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
```
|
||||
|
||||
Also switch the outer shell from `h-dvh` to `fixed inset-0 flex overflow-hidden` so the shell is anchored to the visual viewport directly (avoids `dvh` staleness issues on Android):
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
<div className="flex h-dvh bg-background">
|
||||
|
||||
// After
|
||||
<div className="fixed inset-0 flex overflow-hidden bg-background">
|
||||
```
|
||||
|
||||
**General rule**: any `flex-1` column that can contain user-supplied text or long URLs should also carry `min-w-0`.
|
||||
|
||||
---
|
||||
|
||||
## Inspecting the database
|
||||
|
||||
```bash
|
||||
|
||||
@@ -10,8 +10,14 @@ import app.models # noqa: F401 — registers all models with Base.metadata
|
||||
|
||||
config = context.config
|
||||
|
||||
# Override sqlalchemy.url from environment if set
|
||||
# Override sqlalchemy.url — prefer env var, then config.py (which reads secrets file)
|
||||
sync_url = os.environ.get("SYNC_DATABASE_URL")
|
||||
if not sync_url:
|
||||
try:
|
||||
from app.config import settings as app_settings
|
||||
sync_url = app_settings.SYNC_DATABASE_URL
|
||||
except Exception:
|
||||
pass
|
||||
if sync_url:
|
||||
config.set_main_option("sqlalchemy.url", sync_url)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ services:
|
||||
- db_password
|
||||
volumes:
|
||||
- ./postgres/data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-congress} -d ${POSTGRES_DB:-pocketveto}"]
|
||||
interval: 5s
|
||||
@@ -19,6 +20,7 @@ services:
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./redis/data:/data
|
||||
healthcheck:
|
||||
@@ -41,6 +43,7 @@ services:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
restart: unless-stopped
|
||||
secrets:
|
||||
- db_password
|
||||
depends_on:
|
||||
@@ -61,6 +64,7 @@ services:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
restart: unless-stopped
|
||||
secrets:
|
||||
- db_password
|
||||
depends_on:
|
||||
@@ -81,6 +85,7 @@ services:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
restart: unless-stopped
|
||||
secrets:
|
||||
- db_password
|
||||
depends_on:
|
||||
@@ -93,6 +98,7 @@ services:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
|
||||
@@ -126,21 +126,21 @@ function ModeFilterSection({
|
||||
<input type="checkbox" checked={!!filters["new_document"]}
|
||||
onChange={(e) => onChange({ ...filters, new_document: e.target.checked })}
|
||||
className="mt-0.5 rounded" />
|
||||
<div><span className="text-sm">New bill text</span><span className="text-xs text-muted-foreground ml-2">The full text of the bill is published</span></div>
|
||||
<div className="min-w-0"><span className="text-sm">New bill text</span><span className="text-xs text-muted-foreground ml-2">The full text of the bill is published</span></div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3 py-1.5 cursor-pointer">
|
||||
<input type="checkbox" checked={!!filters["new_amendment"]}
|
||||
onChange={(e) => onChange({ ...filters, new_amendment: e.target.checked })}
|
||||
className="mt-0.5 rounded" />
|
||||
<div><span className="text-sm">Amendment filed</span><span className="text-xs text-muted-foreground ml-2">An amendment is filed against the bill</span></div>
|
||||
<div className="min-w-0"><span className="text-sm">Amendment filed</span><span className="text-xs text-muted-foreground ml-2">An amendment is filed against the bill</span></div>
|
||||
</label>
|
||||
|
||||
<div className="py-1.5">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input ref={milestoneCheckRef} type="checkbox" checked={milestoneState === "on"}
|
||||
onChange={toggleMilestones} className="mt-0.5 rounded" />
|
||||
<div><span className="text-sm font-medium">Milestones</span><span className="text-xs text-muted-foreground ml-2">Select all milestone types</span></div>
|
||||
<div className="min-w-0"><span className="text-sm font-medium">Milestones</span><span className="text-xs text-muted-foreground ml-2">Select all milestone types</span></div>
|
||||
</label>
|
||||
<div className="ml-6 mt-1 space-y-0.5">
|
||||
{(["vote", "presidential", "committee_report", "calendar", "procedural"] as const).map((k) => {
|
||||
@@ -150,7 +150,7 @@ function ModeFilterSection({
|
||||
<input type="checkbox" checked={!!filters[k]}
|
||||
onChange={(e) => onChange({ ...filters, [k]: e.target.checked })}
|
||||
className="mt-0.5 rounded" />
|
||||
<div><span className="text-sm">{row.label}</span><span className="text-xs text-muted-foreground ml-2">{row.hint}</span></div>
|
||||
<div className="min-w-0"><span className="text-sm">{row.label}</span><span className="text-xs text-muted-foreground ml-2">{row.hint}</span></div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
@@ -484,7 +484,7 @@ export default function NotificationsPage() {
|
||||
</div>
|
||||
|
||||
{/* Channel tab bar */}
|
||||
<div className="flex gap-0 border-b border-border -mx-6 px-6">
|
||||
<div className="flex gap-0 border-b border-border -mx-6 px-6 overflow-x-auto">
|
||||
{([
|
||||
{ key: "ntfy", label: "ntfy", icon: Bell, active: settings?.ntfy_enabled },
|
||||
{ key: "email", label: "Email", icon: Mail, active: settings?.email_enabled },
|
||||
@@ -494,7 +494,7 @@ export default function NotificationsPage() {
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveChannelTab(key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
className={`shrink-0 flex items-center gap-1.5 px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeChannelTab === key
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
@@ -688,12 +688,12 @@ export default function NotificationsPage() {
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-0 border-b border-border">
|
||||
<div className="flex gap-0 border-b border-border overflow-x-auto">
|
||||
{MODES.map((mode) => (
|
||||
<button
|
||||
key={mode.key}
|
||||
onClick={() => setActiveFilterTab(mode.key as ModeKey)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
className={`shrink-0 px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeFilterTab === mode.key
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
@@ -704,7 +704,7 @@ export default function NotificationsPage() {
|
||||
))}
|
||||
<button
|
||||
onClick={() => setActiveFilterTab("discovery")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
className={`shrink-0 px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeFilterTab === "discovery"
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
@@ -749,7 +749,7 @@ export default function NotificationsPage() {
|
||||
<input type="checkbox" checked={!!enabled}
|
||||
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
|
||||
className="rounded" />
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium">Notify me about bills from member follows</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">{src.description}</span>
|
||||
</div>
|
||||
@@ -777,7 +777,7 @@ export default function NotificationsPage() {
|
||||
{unmutedMembers.length > 0 && (
|
||||
<select value=""
|
||||
onChange={(e) => { const id = e.target.value; if (id) setMutedMemberIds((prev) => [...prev, id]); }}
|
||||
className="text-xs px-2 py-1.5 bg-background border border-border rounded-md">
|
||||
className="text-base px-2 py-1.5 bg-background border border-border rounded-md">
|
||||
<option value="" disabled>Mute a member…</option>
|
||||
{unmutedMembers.map((f) => (
|
||||
<option key={f.follow_value} value={f.follow_value}>
|
||||
@@ -807,7 +807,7 @@ export default function NotificationsPage() {
|
||||
<input type="checkbox" checked={!!enabled}
|
||||
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
|
||||
className="rounded" />
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium">Notify me about bills from topic follows</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">{src.description}</span>
|
||||
</div>
|
||||
@@ -835,7 +835,7 @@ export default function NotificationsPage() {
|
||||
{unmutedTopics.length > 0 && (
|
||||
<select value=""
|
||||
onChange={(e) => { const tag = e.target.value; if (tag) setMutedTopicTags((prev) => [...prev, tag]); }}
|
||||
className="text-xs px-2 py-1.5 bg-background border border-border rounded-md">
|
||||
className="text-base px-2 py-1.5 bg-background border border-border rounded-md">
|
||||
<option value="" disabled>Mute a topic…</option>
|
||||
{unmutedTopics.map((f) => (
|
||||
<option key={f.follow_value} value={f.follow_value}>{f.follow_value}</option>
|
||||
@@ -884,14 +884,14 @@ export default function NotificationsPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-muted-foreground">From</label>
|
||||
<select value={quietStart} onChange={(e) => setQuietStart(Number(e.target.value))}
|
||||
className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
|
||||
className="px-2 py-1.5 text-base bg-background border border-border rounded-md">
|
||||
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-muted-foreground">To</label>
|
||||
<select value={quietEnd} onChange={(e) => setQuietEnd(Number(e.target.value))}
|
||||
className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
|
||||
className="px-2 py-1.5 text-base bg-background border border-border rounded-md">
|
||||
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -1048,9 +1048,9 @@ export default function NotificationsPage() {
|
||||
const billTitle = p.bill_title as string | undefined;
|
||||
const briefSummary = p.brief_summary as string | undefined;
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-3">
|
||||
<div className="flex items-start gap-3 py-3 overflow-hidden">
|
||||
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${meta.color}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium">{meta.label}</span>
|
||||
{billLabel && (
|
||||
@@ -1062,7 +1062,7 @@ export default function NotificationsPage() {
|
||||
<span className="text-xs text-muted-foreground ml-auto">{timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
{billTitle && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{billTitle}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1 break-all">{billTitle}</p>
|
||||
)}
|
||||
{(() => {
|
||||
const src = p.source as string | undefined;
|
||||
@@ -1085,7 +1085,7 @@ export default function NotificationsPage() {
|
||||
) : null;
|
||||
})()}
|
||||
{briefSummary && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{briefSummary}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2 break-words">{briefSummary}</p>
|
||||
)}
|
||||
</div>
|
||||
{showDispatch && (
|
||||
|
||||
@@ -42,7 +42,7 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Authenticated or guest browsing: render the full app shell
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<div className="fixed inset-0 flex overflow-hidden bg-background">
|
||||
{/* Desktop sidebar — hidden on mobile */}
|
||||
<div className="hidden md:flex">
|
||||
<Sidebar />
|
||||
@@ -59,7 +59,7 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
|
||||
{/* Content column */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<MobileHeader onMenuClick={() => setDrawerOpen(true)} />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="container mx-auto px-4 md:px-6 py-6 max-w-7xl">
|
||||
|
||||
62
frontend/public/robots.txt
Normal file
62
frontend/public/robots.txt
Normal file
@@ -0,0 +1,62 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Allow: /bills
|
||||
Allow: /bills/
|
||||
Allow: /members
|
||||
Allow: /members/
|
||||
Allow: /topics
|
||||
Allow: /how-it-works
|
||||
Allow: /share/
|
||||
|
||||
Disallow: /login
|
||||
Disallow: /register
|
||||
Disallow: /settings
|
||||
Disallow: /notifications
|
||||
Disallow: /following
|
||||
Disallow: /collections
|
||||
Disallow: /alignment
|
||||
Disallow: /api/
|
||||
Disallow: /_next/
|
||||
|
||||
Crawl-delay: 10
|
||||
|
||||
# AI training crawlers
|
||||
User-agent: GPTBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: CCBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Bytespider
|
||||
Disallow: /
|
||||
|
||||
User-agent: anthropic-ai
|
||||
Disallow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Disallow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: YouBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: cohere-ai
|
||||
Disallow: /
|
||||
|
||||
# SEO tool crawlers
|
||||
User-agent: AhrefsBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: SemrushBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: DotBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: MJ12bot
|
||||
Disallow: /
|
||||
|
||||
User-agent: BLEXBot
|
||||
Disallow: /
|
||||
Reference in New Issue
Block a user