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
|
# PostgreSQL
|
||||||
POSTGRES_USER=congress
|
POSTGRES_USER=congress
|
||||||
POSTGRES_PASSWORD=your-strong-password
|
|
||||||
POSTGRES_DB=pocketveto
|
POSTGRES_DB=pocketveto
|
||||||
|
|
||||||
# Redis
|
# Redis
|
||||||
@@ -70,6 +69,18 @@ OPENAI_API_KEY=sk-...
|
|||||||
OPENAI_MODEL=gpt-4o-mini
|
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):
|
Other providers (swap in place of the OpenAI block):
|
||||||
```env
|
```env
|
||||||
# Anthropic
|
# Anthropic
|
||||||
@@ -112,6 +123,8 @@ SMTP_FROM=alerts@yourdomain.com
|
|||||||
docker compose up --build -d
|
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:
|
This will:
|
||||||
1. Pull base images (postgres, redis, nginx, node)
|
1. Pull base images (postgres, redis, nginx, node)
|
||||||
2. Build the API, worker, beat, and frontend images
|
2. Build the API, worker, beat, and frontend images
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
- [x] Collapsible sidebar — icon-only mode, `localStorage` persistence
|
- [x] Collapsible sidebar — icon-only mode, `localStorage` persistence
|
||||||
- [x] Favicon — Landmark icon
|
- [x] Favicon — Landmark icon
|
||||||
- [x] LLM Batch API — OpenAI + Anthropic async batch endpoints; 50% cost reduction; state tracked in `AppSetting("llm_active_batch")`
|
- [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
|
## Inspecting the database
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -10,8 +10,14 @@ import app.models # noqa: F401 — registers all models with Base.metadata
|
|||||||
|
|
||||||
config = context.config
|
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")
|
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:
|
if sync_url:
|
||||||
config.set_main_option("sqlalchemy.url", sync_url)
|
config.set_main_option("sqlalchemy.url", sync_url)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ services:
|
|||||||
- db_password
|
- db_password
|
||||||
volumes:
|
volumes:
|
||||||
- ./postgres/data:/var/lib/postgresql/data
|
- ./postgres/data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-congress} -d ${POSTGRES_DB:-pocketveto}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-congress} -d ${POSTGRES_DB:-pocketveto}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -19,6 +20,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./redis/data:/data
|
- ./redis/data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -41,6 +43,7 @@ services:
|
|||||||
- POSTGRES_HOST=postgres
|
- POSTGRES_HOST=postgres
|
||||||
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
restart: unless-stopped
|
||||||
secrets:
|
secrets:
|
||||||
- db_password
|
- db_password
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -61,6 +64,7 @@ services:
|
|||||||
- POSTGRES_HOST=postgres
|
- POSTGRES_HOST=postgres
|
||||||
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
restart: unless-stopped
|
||||||
secrets:
|
secrets:
|
||||||
- db_password
|
- db_password
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -81,6 +85,7 @@ services:
|
|||||||
- POSTGRES_HOST=postgres
|
- POSTGRES_HOST=postgres
|
||||||
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
restart: unless-stopped
|
||||||
secrets:
|
secrets:
|
||||||
- db_password
|
- db_password
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -93,6 +98,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -126,21 +126,21 @@ function ModeFilterSection({
|
|||||||
<input type="checkbox" checked={!!filters["new_document"]}
|
<input type="checkbox" checked={!!filters["new_document"]}
|
||||||
onChange={(e) => onChange({ ...filters, new_document: e.target.checked })}
|
onChange={(e) => onChange({ ...filters, new_document: e.target.checked })}
|
||||||
className="mt-0.5 rounded" />
|
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>
|
||||||
|
|
||||||
<label className="flex items-start gap-3 py-1.5 cursor-pointer">
|
<label className="flex items-start gap-3 py-1.5 cursor-pointer">
|
||||||
<input type="checkbox" checked={!!filters["new_amendment"]}
|
<input type="checkbox" checked={!!filters["new_amendment"]}
|
||||||
onChange={(e) => onChange({ ...filters, new_amendment: e.target.checked })}
|
onChange={(e) => onChange({ ...filters, new_amendment: e.target.checked })}
|
||||||
className="mt-0.5 rounded" />
|
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>
|
</label>
|
||||||
|
|
||||||
<div className="py-1.5">
|
<div className="py-1.5">
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
<input ref={milestoneCheckRef} type="checkbox" checked={milestoneState === "on"}
|
<input ref={milestoneCheckRef} type="checkbox" checked={milestoneState === "on"}
|
||||||
onChange={toggleMilestones} className="mt-0.5 rounded" />
|
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>
|
</label>
|
||||||
<div className="ml-6 mt-1 space-y-0.5">
|
<div className="ml-6 mt-1 space-y-0.5">
|
||||||
{(["vote", "presidential", "committee_report", "calendar", "procedural"] as const).map((k) => {
|
{(["vote", "presidential", "committee_report", "calendar", "procedural"] as const).map((k) => {
|
||||||
@@ -150,7 +150,7 @@ function ModeFilterSection({
|
|||||||
<input type="checkbox" checked={!!filters[k]}
|
<input type="checkbox" checked={!!filters[k]}
|
||||||
onChange={(e) => onChange({ ...filters, [k]: e.target.checked })}
|
onChange={(e) => onChange({ ...filters, [k]: e.target.checked })}
|
||||||
className="mt-0.5 rounded" />
|
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>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -484,7 +484,7 @@ export default function NotificationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Channel tab bar */}
|
{/* 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: "ntfy", label: "ntfy", icon: Bell, active: settings?.ntfy_enabled },
|
||||||
{ key: "email", label: "Email", icon: Mail, active: settings?.email_enabled },
|
{ key: "email", label: "Email", icon: Mail, active: settings?.email_enabled },
|
||||||
@@ -494,7 +494,7 @@ export default function NotificationsPage() {
|
|||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => setActiveChannelTab(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
|
activeChannelTab === key
|
||||||
? "border-primary text-foreground"
|
? "border-primary text-foreground"
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
@@ -688,12 +688,12 @@ export default function NotificationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab bar */}
|
{/* 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) => (
|
{MODES.map((mode) => (
|
||||||
<button
|
<button
|
||||||
key={mode.key}
|
key={mode.key}
|
||||||
onClick={() => setActiveFilterTab(mode.key as ModeKey)}
|
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
|
activeFilterTab === mode.key
|
||||||
? "border-primary text-foreground"
|
? "border-primary text-foreground"
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
@@ -704,7 +704,7 @@ export default function NotificationsPage() {
|
|||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveFilterTab("discovery")}
|
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"
|
activeFilterTab === "discovery"
|
||||||
? "border-primary text-foreground"
|
? "border-primary text-foreground"
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
@@ -749,7 +749,7 @@ export default function NotificationsPage() {
|
|||||||
<input type="checkbox" checked={!!enabled}
|
<input type="checkbox" checked={!!enabled}
|
||||||
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
|
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
|
||||||
className="rounded" />
|
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-sm font-medium">Notify me about bills from member follows</span>
|
||||||
<span className="text-xs text-muted-foreground ml-2">{src.description}</span>
|
<span className="text-xs text-muted-foreground ml-2">{src.description}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -777,7 +777,7 @@ export default function NotificationsPage() {
|
|||||||
{unmutedMembers.length > 0 && (
|
{unmutedMembers.length > 0 && (
|
||||||
<select value=""
|
<select value=""
|
||||||
onChange={(e) => { const id = e.target.value; if (id) setMutedMemberIds((prev) => [...prev, id]); }}
|
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>
|
<option value="" disabled>Mute a member…</option>
|
||||||
{unmutedMembers.map((f) => (
|
{unmutedMembers.map((f) => (
|
||||||
<option key={f.follow_value} value={f.follow_value}>
|
<option key={f.follow_value} value={f.follow_value}>
|
||||||
@@ -807,7 +807,7 @@ export default function NotificationsPage() {
|
|||||||
<input type="checkbox" checked={!!enabled}
|
<input type="checkbox" checked={!!enabled}
|
||||||
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
|
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
|
||||||
className="rounded" />
|
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-sm font-medium">Notify me about bills from topic follows</span>
|
||||||
<span className="text-xs text-muted-foreground ml-2">{src.description}</span>
|
<span className="text-xs text-muted-foreground ml-2">{src.description}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -835,7 +835,7 @@ export default function NotificationsPage() {
|
|||||||
{unmutedTopics.length > 0 && (
|
{unmutedTopics.length > 0 && (
|
||||||
<select value=""
|
<select value=""
|
||||||
onChange={(e) => { const tag = e.target.value; if (tag) setMutedTopicTags((prev) => [...prev, tag]); }}
|
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>
|
<option value="" disabled>Mute a topic…</option>
|
||||||
{unmutedTopics.map((f) => (
|
{unmutedTopics.map((f) => (
|
||||||
<option key={f.follow_value} value={f.follow_value}>{f.follow_value}</option>
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-sm text-muted-foreground">From</label>
|
<label className="text-sm text-muted-foreground">From</label>
|
||||||
<select value={quietStart} onChange={(e) => setQuietStart(Number(e.target.value))}
|
<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>)}
|
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-sm text-muted-foreground">To</label>
|
<label className="text-sm text-muted-foreground">To</label>
|
||||||
<select value={quietEnd} onChange={(e) => setQuietEnd(Number(e.target.value))}
|
<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>)}
|
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1048,9 +1048,9 @@ export default function NotificationsPage() {
|
|||||||
const billTitle = p.bill_title as string | undefined;
|
const billTitle = p.bill_title as string | undefined;
|
||||||
const briefSummary = p.brief_summary as string | undefined;
|
const briefSummary = p.brief_summary as string | undefined;
|
||||||
return (
|
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}`} />
|
<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">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xs font-medium">{meta.label}</span>
|
<span className="text-xs font-medium">{meta.label}</span>
|
||||||
{billLabel && (
|
{billLabel && (
|
||||||
@@ -1062,7 +1062,7 @@ export default function NotificationsPage() {
|
|||||||
<span className="text-xs text-muted-foreground ml-auto">{timeAgo(event.created_at)}</span>
|
<span className="text-xs text-muted-foreground ml-auto">{timeAgo(event.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
{billTitle && (
|
{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;
|
const src = p.source as string | undefined;
|
||||||
@@ -1085,7 +1085,7 @@ export default function NotificationsPage() {
|
|||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
{briefSummary && (
|
{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>
|
</div>
|
||||||
{showDispatch && (
|
{showDispatch && (
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// Authenticated or guest browsing: render the full app shell
|
// Authenticated or guest browsing: render the full app shell
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<div className="fixed inset-0 flex overflow-hidden bg-background">
|
||||||
{/* Desktop sidebar — hidden on mobile */}
|
{/* Desktop sidebar — hidden on mobile */}
|
||||||
<div className="hidden md:flex">
|
<div className="hidden md:flex">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@@ -59,7 +59,7 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content column */}
|
{/* 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)} />
|
<MobileHeader onMenuClick={() => setDrawerOpen(true)} />
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 overflow-auto">
|
||||||
<div className="container mx-auto px-4 md:px-6 py-6 max-w-7xl">
|
<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