Files
devplacepy/devplacepy/templates/admin_settings.html
T
typosaurus 15f04e0d13 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
2026-08-16 03:59:11 +00:00

222 lines
15 KiB
HTML

{% extends "admin_base.html" %}
{% block admin_content %}
<div class="admin-toolbar">
<h2>Settings</h2>
</div>
<p class="hint-text admin-settings-intro">Background service parameters (news fetching, intervals, AI grading) are configured per service on the <a href="/admin/services">Services</a> page.</p>
<form method="POST" action="/admin/settings" class="admin-settings-form">
<h3 class="admin-settings-group-title">General</h3>
<div class="admin-field">
<label for="site_name">Site Name</label>
<input type="text" id="site_name" name="site_name" value="{{ settings.get('site_name', 'DevPlace') }}" maxlength="200">
</div>
<div class="admin-field">
<label for="site_description">Site Description</label>
<textarea id="site_description" name="site_description" rows="3" maxlength="500">{{ settings.get('site_description', 'The Developer Social Network') }}</textarea>
</div>
<div class="admin-field">
<label for="site_tagline">Tagline</label>
<input type="text" id="site_tagline" name="site_tagline" value="{{ settings.get('site_tagline', 'Track industry shifts. Discover bold releases. Share what you are building in an open environment built by developers, for developers.') }}" maxlength="500">
</div>
<div class="admin-field">
<label for="site_url">Public URL</label>
<input type="text" id="site_url" name="site_url" value="{{ settings.get('site_url', '') }}" maxlength="300" placeholder="https://devplace.example.com">
<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>
<div class="admin-field">
<label for="max_upload_size_mb">Max Upload Size (MB)</label>
<input type="number" id="max_upload_size_mb" name="max_upload_size_mb" value="{{ settings.get('max_upload_size_mb', '10') }}" min="1" max="500" class="admin-settings-num">
<small class="hint-text">Maximum file size per upload in megabytes.</small>
</div>
<div class="admin-field">
<label for="allowed_file_types">Allowed File Types</label>
<input type="text" id="allowed_file_types" name="allowed_file_types" value="{{ settings.get('allowed_file_types', '') }}" maxlength="1000" placeholder="e.g. .png,.jpg,.mp4,.webm (leave empty for default)">
<small class="hint-text">Comma-separated list of allowed extensions. When set, this is the authoritative allowlist; any extension listed here is enabled and its type auto-detected. Leave empty to use the built-in defaults, which include common image and video formats (mp4, webm, ogv, mov, m4v).</small>
</div>
<div class="admin-field">
<label for="max_attachments_per_resource">Max Attachments Per Resource</label>
<input type="number" id="max_attachments_per_resource" name="max_attachments_per_resource" value="{{ settings.get('max_attachments_per_resource', '10') }}" min="1" max="50" class="admin-settings-num">
<small class="hint-text">Maximum number of files that can be attached to a single post, comment, etc.</small>
</div>
<hr class="admin-settings-divider">
<h3 class="admin-settings-group-title">Operational</h3>
<div class="admin-field">
<label for="docs_search_mode">Docs Search</label>
<select id="docs_search_mode" name="docs_search_mode" class="admin-settings-select-wide">
<option value="agent" {% if settings.get('docs_search_mode', 'agent') == 'agent' %}selected{% endif %}>AI agent (conversational)</option>
<option value="bm25" {% if settings.get('docs_search_mode', 'agent') == 'bm25' %}selected{% endif %}>Keyword (BM25)</option>
</select>
<small class="hint-text">AI agent renders the in-page Devii chat on the docs search page. Keyword uses the classic BM25 results list. Users who exceed their daily AI limit (or guests when Devii is disabled) fall back to keyword search automatically.</small>
</div>
<div class="admin-field">
<label for="registration_open">Registration</label>
<select id="registration_open" name="registration_open" class="admin-settings-select">
<option value="1" {% if settings.get('registration_open', '1') == '1' %}selected{% endif %}>Open</option>
<option value="0" {% if settings.get('registration_open', '1') != '1' %}selected{% endif %}>Closed</option>
</select>
<small class="hint-text">When closed, new account sign-ups are rejected.</small>
</div>
<div class="admin-field">
<label for="maintenance_mode">Maintenance Mode</label>
<select id="maintenance_mode" name="maintenance_mode" class="admin-settings-select">
<option value="0" {% if settings.get('maintenance_mode', '0') != '1' %}selected{% endif %}>Disabled</option>
<option value="1" {% if settings.get('maintenance_mode', '0') == '1' %}selected{% endif %}>Enabled</option>
</select>
<small class="hint-text">When enabled, non-admin visitors see the maintenance page. Admins retain full access.</small>
</div>
<div class="admin-field">
<label for="maintenance_message">Maintenance Message</label>
<input type="text" id="maintenance_message" name="maintenance_message" value="{{ settings.get('maintenance_message', 'DevPlace is undergoing scheduled maintenance. Please check back shortly.') }}" maxlength="300">
<small class="hint-text">Shown to visitors while maintenance mode is enabled.</small>
</div>
<div class="admin-field">
<label for="rate_limit_per_minute">Rate Limit (requests)</label>
<input type="number" id="rate_limit_per_minute" name="rate_limit_per_minute" value="{{ settings.get('rate_limit_per_minute', '60') }}" min="1" class="admin-settings-num">
<small class="hint-text">Maximum mutating requests allowed per IP within the window below.</small>
</div>
<div class="admin-field">
<label for="rate_limit_window_seconds">Rate Limit Window (seconds)</label>
<input type="number" id="rate_limit_window_seconds" name="rate_limit_window_seconds" value="{{ settings.get('rate_limit_window_seconds', '60') }}" min="1" class="admin-settings-num">
<small class="hint-text">Rolling window over which the request limit is counted.</small>
</div>
<div class="admin-field">
<label for="session_max_age_days">Session Length (days)</label>
<input type="number" id="session_max_age_days" name="session_max_age_days" value="{{ settings.get('session_max_age_days', '7') }}" min="1" class="admin-settings-num">
<small class="hint-text">Cookie lifetime for a standard sign-in.</small>
</div>
<div class="admin-field">
<label for="session_remember_days">Remember-Me Length (days)</label>
<input type="number" id="session_remember_days" name="session_remember_days" value="{{ settings.get('session_remember_days', '30') }}" min="1" class="admin-settings-num">
<small class="hint-text">Cookie lifetime when "remember me" is checked at login.</small>
</div>
<div class="admin-field">
<label for="outbound_proxy_url">Outbound Proxy URL</label>
<input type="text" id="outbound_proxy_url" name="outbound_proxy_url" value="{{ settings.get('outbound_proxy_url', '') }}" maxlength="500" placeholder="http://user:pass@host:port" class="admin-settings-select-wide">
<small class="hint-text">Routes every outbound stealth-client request (Devii fetch, DeepSearch, news, attachments, and more) through this proxy. Leave empty to connect directly. Needed only when a destination blocks this server's own IP/network (not its browser fingerprint) - most sites never need this.</small>
</div>
<h3 class="admin-settings-group-title admin-settings-group-title-spaced">Moderation &amp; Safety</h3>
<div class="admin-field">
<label for="moderation_filter_mode">Content Filter Mode</label>
<select id="moderation_filter_mode" name="moderation_filter_mode" class="admin-settings-select">
{% set _mode = settings.get('moderation_filter_mode', 'review') %}
<option value="off"{% if _mode == 'off' %} selected{% endif %}>off - no classification</option>
<option value="label"{% if _mode == 'label' %} selected{% endif %}>label - maturity labels only</option>
<option value="review"{% if _mode == 'review' %} selected{% endif %}>review - publish and raise a report</option>
<option value="block"{% if _mode == 'block' %} selected{% endif %}>block - refuse at creation</option>
</select>
<small class="hint-text">How the classifier acts on a match. <strong>review</strong> is the default: nothing legitimate is ever suppressed by a machine and a human sees every flag. Move to <strong>block</strong> only with evidence from the queue - on a developer platform a blanket block produces false positives on security research and error messages. The sexual and exploitative categories always block, whatever this setting says.</small>
</div>
<div class="admin-field">
<label for="moderation_sla_hours">Moderation Response Window (hours)</label>
<input type="number" id="moderation_sla_hours" name="moderation_sla_hours" value="{{ settings.get('moderation_sla_hours', '24') }}" min="1" max="720" class="admin-settings-num">
<small class="hint-text">The commitment published in the terms and shown on every report acknowledgement. The moderation queue badge turns red once the oldest open report is older than this.</small>
</div>
<div class="admin-field">
<label for="moderation_minimum_age">Minimum Age</label>
<input type="number" id="moderation_minimum_age" name="moderation_minimum_age" value="{{ settings.get('moderation_minimum_age', '16') }}" min="13" max="21" class="admin-settings-num">
<small class="hint-text">Signup is refused below this age. Only the derived age band is stored, never the date of birth.</small>
</div>
<div class="admin-field">
<label for="moderation_mature_default_hidden">Hide Mature Content By Default</label>
<select id="moderation_mature_default_hidden" name="moderation_mature_default_hidden" class="admin-settings-select">
<option value="1"{% if settings.get('moderation_mature_default_hidden', '1') == '1' %} selected{% endif %}>Yes</option>
<option value="0"{% if settings.get('moderation_mature_default_hidden', '1') == '0' %} selected{% endif %}>No</option>
</select>
<small class="hint-text">When on, mature-labelled content renders behind an interstitial until the viewer explicitly opts in. Minor age bands are never offered the reveal for restricted content.</small>
</div>
<div class="admin-field">
<label for="account_deletion_grace_hours">Account Deletion Grace (hours)</label>
<input type="number" id="account_deletion_grace_hours" name="account_deletion_grace_hours" value="{{ settings.get('account_deletion_grace_hours', '24') }}" min="0" max="720" class="admin-settings-num">
<small class="hint-text">How long a deleted account stays restorable from the trash before it is permanently purged. The account is anonymised immediately either way; this only sets when the purge runs.</small>
</div>
<div class="admin-field">
<label for="ai_third_party_provider">Third-party AI Provider Name</label>
<input type="text" id="ai_third_party_provider" name="ai_third_party_provider" value="{{ settings.get('ai_third_party_provider', '') }}" maxlength="120" placeholder="e.g. Acme Model Co.">
<small class="hint-text">Named in the consent copy and the privacy policy, so the user knows who receives their content before they grant consent.</small>
</div>
<h3 class="admin-settings-group-title admin-settings-group-title-spaced">Policy Versions</h3>
<div class="admin-field">
<label for="terms_version">Terms Version</label>
<input type="text" id="terms_version" name="terms_version" value="{{ settings.get('terms_version', '1') }}" maxlength="20" class="admin-settings-num">
<small class="hint-text">Bumping this forces every member to accept the terms again before their next write. Reading and account deletion are never blocked.</small>
</div>
<div class="admin-field">
<label for="privacy_version">Privacy Policy Version</label>
<input type="text" id="privacy_version" name="privacy_version" value="{{ settings.get('privacy_version', '1') }}" maxlength="20" class="admin-settings-num">
</div>
<div class="admin-field">
<label for="guidelines_version">Community Guidelines Version</label>
<input type="text" id="guidelines_version" name="guidelines_version" value="{{ settings.get('guidelines_version', '1') }}" maxlength="20" class="admin-settings-num">
</div>
<h3 class="admin-settings-group-title admin-settings-group-title-spaced">Published Contact Details</h3>
<div class="admin-field">
<label for="contact_email">Contact Email</label>
<input type="text" id="contact_email" name="contact_email" value="{{ settings.get('contact_email', '') }}" maxlength="200" placeholder="support@example.com">
<small class="hint-text">Rendered on /docs/contact.html. Keep it identical to the trader email declared in every app store that carries a DevPlace client.</small>
</div>
<div class="admin-field">
<label for="contact_phone">Contact Phone</label>
<input type="text" id="contact_phone" name="contact_phone" value="{{ settings.get('contact_phone', '') }}" maxlength="60" placeholder="+31 00 000 0000">
</div>
<div class="admin-field">
<label for="contact_address">Contact Address</label>
<textarea id="contact_address" name="contact_address" rows="3" maxlength="500" placeholder="Street, number, postcode, city, country">{{ settings.get('contact_address', '') }}</textarea>
</div>
<h3 class="admin-settings-group-title admin-settings-group-title-spaced">Custom Code</h3>
<div class="admin-field">
<label for="extra_head">Extra Head</label>
<textarea id="extra_head" name="extra_head" rows="10" spellcheck="false" class="admin-settings-code">{{ settings.get('extra_head', '') }}</textarea>
<small class="hint-text">Raw HTML injected into the page &lt;head&gt; on every page. Put anything here: a &lt;style&gt; block, &lt;script&gt;, &lt;link&gt;, meta tags, or an analytics snippet. Applies site-wide. Leave empty to remove.</small>
</div>
<button type="submit" class="btn btn-primary"><span class="icon">&#x1F4BE;</span>Save Settings</button>
</form>
{% endblock %}