Merge pull request 'feat: Implement links to the official iOS app in the web app' (#170) from typosaurus/169-implement-links-to-the-official-ios-app-in-the-web-app into master

Reviewed-on: retoor/devplacepy#170
This commit is contained in:
retoor 2026-08-16 06:37:41 +02:00
commit e0672f896d
11 changed files with 212 additions and 4 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 | | `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 | | `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` | | `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 | | `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 | | `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 | | `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. 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 | | `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. **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

@ -1743,6 +1743,7 @@ def init_db():
"statistics_tracking_enabled": "1", "statistics_tracking_enabled": "1",
"docs_search_mode": "agent", "docs_search_mode": "agent",
"outbound_proxy_url": "", "outbound_proxy_url": "",
"ios_app_url": "https://apps.apple.com/app/devplace/id6797215143",
"devii_lessons_max_per_owner": "500", "devii_lessons_max_per_owner": "500",
"devii_lessons_max_age_days": "90", "devii_lessons_max_age_days": "90",
"moderation_sla_hours": "24", "moderation_sla_hours": "24",
@ -2142,3 +2143,4 @@ def _backfill_gamification():
f"{len(checked)} with milestone-eligible activity" f"{len(checked)} with milestone-eligible activity"
) )

View File

@ -657,6 +657,7 @@ class AdminSettingsForm(BaseModel):
site_description: str = Field(default="", max_length=500) site_description: str = Field(default="", max_length=500)
site_tagline: str = Field(default="", max_length=500) site_tagline: str = Field(default="", max_length=500)
site_url: str = Field(default="", max_length=300) site_url: str = Field(default="", max_length=300)
ios_app_url: str = Field(default="", max_length=300)
max_upload_size_mb: str = Field(default="", max_length=10) max_upload_size_mb: str = Field(default="", max_length=10)
allowed_file_types: str = Field(default="", max_length=1000) allowed_file_types: str = Field(default="", max_length=1000)
max_attachments_per_resource: str = Field(default="", max_length=10) max_attachments_per_resource: str = Field(default="", max_length=10)
@ -1119,3 +1120,4 @@ class MaturePreferenceForm(BaseModel):
class AccountDeleteForm(BaseModel): class AccountDeleteForm(BaseModel):
password: str = Field(min_length=1, max_length=128) password: str = Field(min_length=1, max_length=128)
confirm_text: str = Field(default="", max_length=40) confirm_text: str = Field(default="", max_length=40)

View File

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

View File

@ -0,0 +1,47 @@
<!-- retoor <retoor@molodetz.nl> -->
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
<title>Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917</title>
<g>
<g>
<g>
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
</g>
<g id="_Group_" data-name="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
<path id="_Path_2" data-name="&lt;Path&gt;" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
</g>
</g>
<g>
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" style="fill: #fff"/>
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
</g>
</g>
</g>
<g id="_Group_4" data-name="&lt;Group&gt;">
<g>
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" style="fill: #fff"/>
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57522,13.99463,45.01369,13.42432,45.01369,12.44434Z" style="fill: #fff"/>
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" style="fill: #fff"/>
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" style="fill: #fff"/>
<path d="M61.21779,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" style="fill: #fff"/>
<path d="M66.4009,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.4009,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29543,13.03955Z" style="fill: #fff"/>
<path d="M71.34816,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.0718,14.772,71.34816,13.87061,71.34816,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72121,10.91846,72.26613,11.49707,72.26613,12.44434Z" style="fill: #fff"/>
<path d="M79.23,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.12453,13.99463,82.563,13.42432,82.563,12.44434Z" style="fill: #fff"/>
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" style="fill: #fff"/>
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" style="fill: #fff"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -717,6 +717,17 @@ img {
line-height: 1; 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 { .topnav-mobile-overlay {
display: none; display: none;
position: fixed; position: fixed;
@ -771,6 +782,15 @@ img {
background: var(--accent-light); background: var(--accent-light);
} }
.app-store-mobile-link {
justify-content: flex-start;
}
.app-store-mobile-badge {
width: 120px;
height: auto;
}
.topnav-mobile-divider { .topnav-mobile-divider {
height: 1px; height: 1px;
background: var(--border); background: var(--border);
@ -1128,6 +1148,17 @@ body:has(.page-messages) {
color: var(--text-primary); 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 { .sr-only {
position: absolute; position: absolute;
width: 1px; width: 1px;
@ -1176,7 +1207,8 @@ body:has(.page-messages) {
} }
.topnav-tools-dropdown, .topnav-tools-dropdown,
.topnav-icon[href="/leaderboard"] { .topnav-icon[href="/leaderboard"],
.app-store-nav-link {
display: none; display: none;
} }
@ -1392,4 +1424,3 @@ body:has(.page-messages) {
color: var(--text-secondary); color: var(--text-secondary);
margin: 0 0 var(--space-lg); 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/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/community-guidelines.html"><span class="icon">&#x1F91D;</span> Guidelines</a>
<a href="/docs/contact.html"><span class="icon">&#x2709;&#xFE0F;</span> Contact</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> </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> <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>
<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"> <hr class="admin-settings-divider">
<h3 class="admin-settings-group-title">Uploads & Attachments</h3> <h3 class="admin-settings-group-title">Uploads & Attachments</h3>

View File

@ -123,6 +123,12 @@
{% else %} {% else %}
<a href="/auth/login" class="topnav-link"><span class="icon">🔑</span> Login</a> <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> <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 %} {% endif %}
<button type="button" class="topnav-hamburger" id="hamburger-btn" aria-label="Toggle menu" aria-controls="mobile-panel" aria-expanded="false"></button> <button type="button" class="topnav-hamburger" id="hamburger-btn" aria-label="Toggle menu" aria-controls="mobile-panel" aria-expanded="false"></button>
</div> </div>
@ -180,6 +186,12 @@
<div class="topnav-mobile-divider"></div> <div class="topnav-mobile-divider"></div>
<a href="/auth/login" class="topnav-mobile-link"><span class="icon">🔑</span> Login</a> <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> <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 %} {% endif %}
</nav> </nav>
</div> </div>

View File

@ -194,6 +194,10 @@ def contact_details() -> dict[str, str]:
} }
def ios_app_url() -> str:
return get_setting("ios_app_url", "").strip()
def policy_version(kind: str) -> str: def policy_version(kind: str) -> str:
return get_setting(f"{kind}_version", "1") or "1" return get_setting(f"{kind}_version", "1") or "1"
@ -211,6 +215,7 @@ def ai_provider_name() -> str:
templates.env.globals["contact_details"] = contact_details 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["policy_version"] = policy_version
templates.env.globals["moderation_sla_hours"] = moderation_sla_hours templates.env.globals["moderation_sla_hours"] = moderation_sla_hours
templates.env.globals["moderation_minimum_age"] = moderation_minimum_age templates.env.globals["moderation_minimum_age"] = moderation_minimum_age

93
tests/e2e/iosapp.py Normal file
View File

@ -0,0 +1,93 @@
# retoor <retoor@molodetz.nl>
import time
from devplacepy.database import get_setting, set_setting
from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS
IOS_SETTING = "ios_app_url"
IOS_APP_URL = "https://apps.apple.com/app/devplace/id6797215143"
LANDING_FOOTER_BADGE = ".landing-footer .app-store-link"
SITE_FOOTER_BADGE = ".site-footer .app-store-link"
NAV_BADGE = ".topnav-right .app-store-nav-link"
MOBILE_BADGE = ".topnav-mobile-panel .app-store-mobile-link"
def _configure():
original = get_setting(IOS_SETTING, "")
set_setting(IOS_SETTING, IOS_APP_URL)
time.sleep(CACHE_VERSION_PROPAGATION_SECONDS)
return original
def _assert_badge(badge):
badge.wait_for(state="visible")
assert badge.get_attribute("href") == IOS_APP_URL
assert badge.get_attribute("target") == "_blank"
assert badge.get_attribute("rel") == "noopener noreferrer"
def test_footer_app_store_badge_on_landing_page(page, app_server):
original = _configure()
try:
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
_assert_badge(page.locator(LANDING_FOOTER_BADGE))
finally:
set_setting(IOS_SETTING, original)
def test_footer_app_store_badge_on_regular_page(page, app_server):
original = _configure()
try:
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
_assert_badge(page.locator(SITE_FOOTER_BADGE))
finally:
set_setting(IOS_SETTING, original)
def test_topnav_app_store_badge_next_to_login_signup(page, app_server):
original = _configure()
try:
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
_assert_badge(page.locator(NAV_BADGE))
assert page.locator(".topnav-right a[href='/auth/login']").is_visible()
assert page.locator(".topnav-right a[href='/auth/signup']").is_visible()
finally:
set_setting(IOS_SETTING, original)
def test_mobile_menu_app_store_badge(page, app_server):
original = _configure()
try:
page.set_viewport_size({"width": 390, "height": 844})
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
hamburger = page.locator("#hamburger-btn")
hamburger.wait_for(state="visible")
hamburger.click()
page.locator("#mobile-panel.open").wait_for(state="visible")
_assert_badge(page.locator(MOBILE_BADGE))
finally:
set_setting(IOS_SETTING, original)
def test_badges_hide_when_ios_app_url_is_cleared(page, app_server):
original = _configure()
try:
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
assert page.locator(LANDING_FOOTER_BADGE).count() == 1
set_setting(IOS_SETTING, "")
time.sleep(CACHE_VERSION_PROPAGATION_SECONDS)
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
assert page.locator(LANDING_FOOTER_BADGE).count() == 0
assert page.locator(NAV_BADGE).count() == 0
assert page.locator(MOBILE_BADGE).count() == 0
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.locator(SITE_FOOTER_BADGE).count() == 0
finally:
set_setting(IOS_SETTING, original)