feat(nadia): Implement iOS app badges in footer, topnav, and mobile menu

Outcome: done
Changed: devplacepy/templates/base.html:126-132,189-195; devplacepy/templates/_footer_links.html:12-19; devplacepy/templates/admin_settings.html:33-38; devplacepy/routers/admin/settings.py:19; devplacepy/templating.py:197-199,218; devplacepy/static/css/base.css:720-731,785-793,1151-1161,1210-1212; devplacepy/database/CLAUDE.md:224,228
Verified by: import gate clean; py_compile clean; ruff/pyflakes no new findings; TestClient smoke (badges present with href=ios_app_url, target=_blank rel=noopener noreferrer, static_url-versioned src on /, /feed, /leaderboard; absent when setting empty; admin clear via /admin/settings POST verified); make test-fast: 2659 passed, 1 skipped, 10 failed - all environmental (9 missing docker binary, 1 missing libcairo.so.2), none caused by this change; Playwright e2e inapplicable (chromium cannot launch: libglib-2.0.so.0 missing)
Findings: Badges render only when ios_app_url site setting is non-empty and are editable/clearable via AdminSettingsForm (CLEARABLE_SETTINGS). Researched URL https://apps.apple.com/app/devplace/id6797215143 (trackId 6797215143, bundleId wok.DevPlaceIOS) confirmed via iTunes Lookup API and seeded in schema.py operational_defaults by sibling commit. Topnav badge hides at <=1024px where the mobile panel badge takes over, keeping the 360px layout overflow-free. Badge SVG is Apple's official white variant vendored at devplacepy/static/app-store-badge.svg with retoor header. Apple badge marketing guidance (40px min

Typosaurus-Run: 2910ed1ba2604be09c649141af1baf44
Typosaurus-Node: 2817fd803023406699936e88b76ee632
Typosaurus-Agent: @nadia
Refs: #169
This commit is contained in:
typosaurus 2026-08-16 02:38:28 +00:00
parent 551d540bc7
commit 15f04e0d13
7 changed files with 64 additions and 5 deletions

View File

@ -218,11 +218,12 @@ Site settings are seeded on startup (`site_settings` table):
| `moderation_mature_default_hidden` | `"1"` | Hide mature-labelled content behind an interstitial by default |
| `account_deletion_grace_hours` | `"24"` | Reversible window before a deleted account is purged |
| `contact_email` / `contact_phone` / `contact_address` | `""` | Published contact details, rendered on `/docs/contact.html` |
| `ios_app_url` | `"https://apps.apple.com/app/devplace/id6797215143"` | Official iOS app listing; the App Store badges in the footer, topnav and mobile menu link here and hide when the value is empty |
| `terms_version` / `privacy_version` / `guidelines_version` | `"1"` | Bumping `terms_version` forces re-acceptance before the next write. **Every reader uses `get_setting(key, "1") or "1"`** - an empty stored value must read as the default or the gate 403s every write |
| `ai_third_party_provider` | `""` | Named in the consent copy and the privacy policy |
| `extra_head` | `""` | Raw HTML emitted verbatim into every page `<head>` by `templating.extra_head_tag()`; site-wide trusted-admin input, not sanitized |
Besides the site/news/upload keys above, the **Operational** group is admin-editable at `/admin/settings`: `site_url` (public origin for absolute links incl. container ingress; resolved by `seo.public_base_url()` = setting -> `DEVPLACE_SITE_URL` env -> request origin), `rate_limit_per_minute`, `rate_limit_window_seconds`, `news_service_interval`, `session_max_age_days`, `session_remember_days`, `registration_open`, `maintenance_mode`, `maintenance_message`, `docs_search_mode` (`agent`|`bm25`, default `agent` - picks the `/docs/search.html` surface: the in-page Devii chat or the classic BM25 list; a viewer over their daily AI limit, or a guest when Devii is disabled, auto-falls-back to BM25 via `routers/docs/views.py` `_agent_search_state`), `outbound_proxy_url` (empty by default - when set, every `stealth.stealth_async_client`/`stealth_sync_client` call across the whole app routes through it via `stealth.configured_proxy_url()`; validated as `http(s)://`/`socks5(h)://` with a host in `AdminSettingsForm`; falls back to `DEVPLACE_OUTBOUND_PROXY_URL` when unset - see the "Outbound HTTP" note in the root `CLAUDE.md`). The **Custom Code** key `extra_head` is the sole key in the settings handler's `CLEARABLE_SETTINGS` set, so saving an empty textarea removes it (the default loop skips empty values).
Besides the site/news/upload keys above, the **Operational** group is admin-editable at `/admin/settings`: `site_url` (public origin for absolute links incl. container ingress; resolved by `seo.public_base_url()` = setting -> `DEVPLACE_SITE_URL` env -> request origin), `rate_limit_per_minute`, `rate_limit_window_seconds`, `news_service_interval`, `session_max_age_days`, `session_remember_days`, `registration_open`, `maintenance_mode`, `maintenance_message`, `docs_search_mode` (`agent`|`bm25`, default `agent` - picks the `/docs/search.html` surface: the in-page Devii chat or the classic BM25 list; a viewer over their daily AI limit, or a guest when Devii is disabled, auto-falls-back to BM25 via `routers/docs/views.py` `_agent_search_state`), `outbound_proxy_url` (empty by default - when set, every `stealth.stealth_async_client`/`stealth_sync_client` call across the whole app routes through it via `stealth.configured_proxy_url()`; validated as `http(s)://`/`socks5(h)://` with a host in `AdminSettingsForm`; falls back to `DEVPLACE_OUTBOUND_PROXY_URL` when unset - see the "Outbound HTTP" note in the root `CLAUDE.md`). The **Custom Code** key `extra_head` and the `ios_app_url` badge link are the keys in the settings handler's `CLEARABLE_SETTINGS` set, so saving an empty textarea/field removes them (the default loop skips empty values).
The seed block in `database.py` is guarded by `if "site_settings" in tables:` - on a brand-new DB the table does not exist yet (dataset creates tables lazily on first insert), so none of these rows are written until the table exists. Correct runtime behavior therefore relies on every consumer passing the production default to `get_setting`/`get_int_setting`, not on the seed.
@ -240,3 +241,5 @@ Operational settings - read sites and rules:
| `registration_open` | `auth.py` `signup_page` (GET) and `signup` (POST) | POST returns before any DB write when closed |
**Booleans are `<select>`, never checkboxes.** The settings save handler (`admin.py`) skips empty form values so empty fields don't clobber existing rows. An unchecked checkbox submits nothing, so it could never be turned off - `registration_open` and `maintenance_mode` use `<option value="1">`/`<option value="0">` so a value is always submitted.

View File

@ -16,7 +16,7 @@ from devplacepy.dependencies import json_or_form
logger = logging.getLogger(__name__)
router = APIRouter()
CLEARABLE_SETTINGS = {"extra_head"}
CLEARABLE_SETTINGS = {"extra_head", "ios_app_url"}
@router.get("/settings", response_class=HTMLResponse)
async def admin_settings(request: Request):
@ -85,3 +85,4 @@ async def admin_settings_save(
links=[audit.setting(key)],
)
return action_result(request, "/admin/settings")

View File

@ -717,6 +717,17 @@ img {
line-height: 1;
}
.app-store-nav-link {
display: inline-flex;
align-items: center;
line-height: 0;
}
.app-store-nav-badge {
width: 90px;
height: auto;
}
.topnav-mobile-overlay {
display: none;
position: fixed;
@ -771,6 +782,15 @@ img {
background: var(--accent-light);
}
.app-store-mobile-link {
justify-content: flex-start;
}
.app-store-mobile-badge {
width: 120px;
height: auto;
}
.topnav-mobile-divider {
height: 1px;
background: var(--border);
@ -1128,6 +1148,17 @@ body:has(.page-messages) {
color: var(--text-primary);
}
.app-store-link {
display: inline-flex;
align-items: center;
line-height: 0;
}
.app-store-badge {
width: 120px;
height: auto;
}
.sr-only {
position: absolute;
width: 1px;
@ -1176,7 +1207,8 @@ body:has(.page-messages) {
}
.topnav-tools-dropdown,
.topnav-icon[href="/leaderboard"] {
.topnav-icon[href="/leaderboard"],
.app-store-nav-link {
display: none;
}
@ -1392,4 +1424,3 @@ body:has(.page-messages) {
color: var(--text-secondary);
margin: 0 0 var(--space-lg);
}

View File

@ -8,4 +8,10 @@
<a href="/docs/privacy.html"><span class="icon">&#x1F512;</span> Privacy</a>
<a href="/docs/community-guidelines.html"><span class="icon">&#x1F91D;</span> Guidelines</a>
<a href="/docs/contact.html"><span class="icon">&#x2709;&#xFE0F;</span> Contact</a>
{% set _ios_url = ios_app_url() %}
{% if _ios_url %}
<a href="{{ _ios_url }}" class="app-store-link" target="_blank" rel="noopener noreferrer" aria-label="Download the DevPlace app on the App Store">
<img src="{{ static_url('/static/app-store-badge.svg') }}" alt="Download on the App Store" class="app-store-badge" width="120" height="40" loading="lazy">
</a>
{% endif %}
</nav>

View File

@ -30,6 +30,12 @@
<small>Public origin used for absolute links (SEO, container ingress /p/&lt;slug&gt;). Empty = derive from the request or the DEVPLACE_SITE_URL env var.</small>
</div>
<div class="admin-field">
<label for="ios_app_url">iOS App Store URL</label>
<input type="text" id="ios_app_url" name="ios_app_url" value="{{ settings.get('ios_app_url', '') }}" maxlength="300" placeholder="https://apps.apple.com/app/devplace/id0000000000">
<small>Official iOS app listing. The App Store badge links here in the footer and the top navigation. Empty hides the badges.</small>
</div>
<hr class="admin-settings-divider">
<h3 class="admin-settings-group-title">Uploads & Attachments</h3>

View File

@ -123,6 +123,12 @@
{% else %}
<a href="/auth/login" class="topnav-link"><span class="icon">🔑</span> Login</a>
<a href="/auth/signup" class="btn btn-primary btn-sm"><span class="icon"></span>Sign Up</a>
{% set _ios_url = ios_app_url() %}
{% if _ios_url %}
<a href="{{ _ios_url }}" class="app-store-nav-link" target="_blank" rel="noopener noreferrer" aria-label="Download the DevPlace app on the App Store">
<img src="{{ static_url('/static/app-store-badge.svg') }}" alt="App Store" class="app-store-nav-badge" width="90" height="30" loading="lazy">
</a>
{% endif %}
{% endif %}
<button type="button" class="topnav-hamburger" id="hamburger-btn" aria-label="Toggle menu" aria-controls="mobile-panel" aria-expanded="false"></button>
</div>
@ -180,6 +186,12 @@
<div class="topnav-mobile-divider"></div>
<a href="/auth/login" class="topnav-mobile-link"><span class="icon">🔑</span> Login</a>
<a href="/auth/signup" class="topnav-mobile-link"><span class="icon"></span> Sign Up</a>
{% set _ios_url = ios_app_url() %}
{% if _ios_url %}
<a href="{{ _ios_url }}" class="topnav-mobile-link app-store-mobile-link" target="_blank" rel="noopener noreferrer" aria-label="Download the DevPlace app on the App Store">
<img src="{{ static_url('/static/app-store-badge.svg') }}" alt="Download on the App Store" class="app-store-mobile-badge" width="120" height="40" loading="lazy">
</a>
{% endif %}
{% endif %}
</nav>
</div>

View File

@ -215,6 +215,7 @@ def ai_provider_name() -> str:
templates.env.globals["contact_details"] = contact_details
templates.env.globals["ios_app_url"] = ios_app_url
templates.env.globals["policy_version"] = policy_version
templates.env.globals["moderation_sla_hours"] = moderation_sla_hours
templates.env.globals["moderation_minimum_age"] = moderation_minimum_age
@ -384,4 +385,3 @@ def response_time_ms(request=None) -> str:
templates.env.globals["response_time_ms"] = response_time_ms