forked from retoor/devplacepy
Compare commits
10
Commits
682be0861f
...
076f55f380
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
076f55f380 | ||
|
|
e0672f896d | ||
|
|
c9802440d7 | ||
|
|
15f04e0d13 | ||
|
|
551d540bc7 | ||
|
|
fd409ceea7 | ||
|
|
d7d489681a | ||
|
|
62910b0726 | ||
|
|
8db0efff29 | ||
|
|
fa8751a4ca |
@@ -202,6 +202,7 @@ The farm refreshes live over the pub/sub bus (a watered build appears on the own
|
||||
- **Emoji reactions** - react with **any** emoji on posts, comments, gists, and projects, separate from voting and carrying no ranking weight. A short quick-pick palette covers the common reactions, and a `+` button next to it opens the full searchable emoji picker (every standard emoji, including skin tones), so a reaction is never limited to a preset list. Emoji already used on an item are shown as counted chips beside the palette.
|
||||
- **Emoji shortcodes** - typing a `:name:` shortcode in any content (posts, comments, titles, project and gist descriptions, news, and direct messages) renders the matching emoji, using the full GitHub/Discord standard set (for example `:rocket:` becomes a rocket). Server-rendered and live content share one shortcode list; unknown names and shortcodes inside code are left untouched. Documented at `/docs/emoji-shortcodes`. This is distinct from the visual emoji-picker button in the composer, which inserts the literal emoji character.
|
||||
- **Polls** - a post can carry a poll (question plus up to six options); results appear as live bars once the viewer votes, one vote per member. A poll can be attached when the post is created or added later by editing a post that has none.
|
||||
- **Paste an image to attach it** - pressing Ctrl+V (Cmd+V) with a screenshot or copied image on the clipboard while writing a post, a comment, a direct message, an issue, a gist, or a project attaches it immediately, with no trip through the file picker. The upload, its limits, and the resulting attachment are identical to picking the file by hand.
|
||||
- **Bookmarks** - save posts, gists, projects, and news to a personal list at `/bookmarks/saved`.
|
||||
- **Private projects** - an owner can mark a project private so it is visible only to them (and administrators) and excluded from listings, profiles, search, the sitemap, and zip access. Set at creation or toggled later from the project page.
|
||||
- **Read-only projects** - an owner can mark a project read-only, making its entire virtual filesystem immutable: every write, edit, line-edit, move, delete, and upload is refused from all paths (the web UI, the HTTP API, the Devii agent, and container workspace sync) until read-only is turned off. Devii may toggle read-only only after the user explicitly confirms.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -1743,6 +1743,7 @@ def init_db():
|
||||
"statistics_tracking_enabled": "1",
|
||||
"docs_search_mode": "agent",
|
||||
"outbound_proxy_url": "",
|
||||
"ios_app_url": "https://apps.apple.com/app/devplace/id6797215143",
|
||||
"devii_lessons_max_per_owner": "500",
|
||||
"devii_lessons_max_age_days": "90",
|
||||
"moderation_sla_hours": "24",
|
||||
@@ -2142,3 +2143,4 @@ def _backfill_gamification():
|
||||
f"{len(checked)} with milestone-eligible activity"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -657,6 +657,7 @@ class AdminSettingsForm(BaseModel):
|
||||
site_description: str = Field(default="", max_length=500)
|
||||
site_tagline: str = Field(default="", max_length=500)
|
||||
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)
|
||||
allowed_file_types: str = Field(default="", max_length=1000)
|
||||
max_attachments_per_resource: str = Field(default="", max_length=10)
|
||||
@@ -1119,3 +1120,4 @@ class MaturePreferenceForm(BaseModel):
|
||||
class AccountDeleteForm(BaseModel):
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
confirm_text: str = Field(default="", max_length=40)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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="<Group>">
|
||||
<g id="_Group_2" data-name="<Group>">
|
||||
<g id="_Group_3" data-name="<Group>">
|
||||
<path id="_Path_" data-name="<Path>" 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="<Path>" 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="<Group>">
|
||||
<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 |
@@ -57,8 +57,10 @@
|
||||
}
|
||||
|
||||
.auth-field-check input {
|
||||
width: auto;
|
||||
margin-top: 0.2rem;
|
||||
flex: none;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.auth-field-check a {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,8 @@ Every file-upload UI is the one custom element `dp-upload` (`static/js/component
|
||||
- `direct` (the project file browser, `project_files.html`): uploads to a custom `endpoint` with `field-name` plus a settable `extraFields` (e.g. `{path}`) and emits `dp-upload:uploaded` / `dp-upload:done` / `dp-upload:error`. `ProjectFiles.uploadTo(dir)` sets `extraFields` then calls `widget.open()`, and refreshes the tree on `done`.
|
||||
- `field` (create-post image, `feed.html`): wraps a real `<input type="file" name="image">` that submits with the form - no AJAX, inline-image flow unchanged.
|
||||
|
||||
**Paste-to-attach is the opt-in `paste` boolean attribute**, not a per-form handler. With it set, `dp-upload` binds ONE `paste` listener on its `closest("form")` and routes the clipboard's image files through the same `handleFiles` path as the picker and the drop target, so validation, limits, the terms gate and the hidden `attachment_uids` field are shared - never re-implement a clipboard reader in a page controller. It is opt-in because a form may hold several upload buttons (`projects.html` has cover + logo beside the attachment one) and a form-wide default would attach one pasted image to all of them; mark exactly the button that owns the form's content attachments. Set on `_attachment_form.html` (so every form including it - post composer, post edit, gists, projects, issues, screenshots - inherits it), `_comment_form.html`, `messages.html`, and the `mode="embed"` skeleton `AppChat` builds. Non-image clipboard payloads fall through untouched, so pasting text still types.
|
||||
|
||||
The component validates size/type/count and reports errors via `app.toast`. CSS is `.dp-upload-*` in `components.css`; the old `.attachment-upload-*` upload-widget styles were removed, but the `.attachment-gallery`/`.attachment-lightbox` display styles (for already-saved attachments) remain.
|
||||
|
||||
## ReportDialog (`ReportDialog.js`, `app.reportDialog`)
|
||||
|
||||
@@ -10,6 +10,7 @@ export class DeviiTerminal {
|
||||
this.avatar = null;
|
||||
this.bindEscapeTrigger();
|
||||
this.bindDoubleClickTrigger();
|
||||
this.bindOpenTrigger();
|
||||
this.ready = this.init();
|
||||
}
|
||||
|
||||
@@ -29,16 +30,19 @@ export class DeviiTerminal {
|
||||
this.element.setAttribute("avatar", "devii");
|
||||
document.body.appendChild(this.element);
|
||||
|
||||
this.bindTriggers();
|
||||
this.maybeAutoOpen();
|
||||
}
|
||||
|
||||
bindTriggers() {
|
||||
document.querySelectorAll("[data-devii-open]").forEach((trigger) => {
|
||||
trigger.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
this.open(trigger.dataset.deviiPrompt || "");
|
||||
});
|
||||
bindOpenTrigger() {
|
||||
document.addEventListener("click", (event) => {
|
||||
const trigger = event.target.closest("[data-devii-open]");
|
||||
if (!trigger) return;
|
||||
event.preventDefault();
|
||||
this.open(trigger.dataset.deviiPrompt || "");
|
||||
});
|
||||
}
|
||||
|
||||
maybeAutoOpen() {
|
||||
const stored = this.element && this.element.hasStoredState && this.element.hasStoredState();
|
||||
if (document.querySelector("[data-devii-autoopen]") && !stored) {
|
||||
this.open();
|
||||
|
||||
@@ -27,6 +27,7 @@ export class GameFarm {
|
||||
async load() {
|
||||
try {
|
||||
const data = await Http.getJson(this.stateUrl);
|
||||
if (window.app && window.app.dialog && window.app.dialog.isOpen) return;
|
||||
this.render(data.farm);
|
||||
} catch (error) {
|
||||
return;
|
||||
|
||||
@@ -119,6 +119,7 @@ export class ModalManager {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
const proceed = () => {
|
||||
if (!el.isConnected) return;
|
||||
el.dataset.confirmed = "1";
|
||||
el.click();
|
||||
};
|
||||
|
||||
@@ -199,6 +199,7 @@ export class AppChat extends Component {
|
||||
|
||||
this.upload = document.createElement("dp-upload");
|
||||
this.upload.setAttribute("multiple", "");
|
||||
this.upload.setAttribute("paste", "");
|
||||
this.upload.setAttribute("max-files", String(this.maxAttachments));
|
||||
|
||||
this.sendBtn = document.createElement("button");
|
||||
|
||||
@@ -86,6 +86,10 @@ export class AppDialog extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
return this.overlay.classList.contains("visible");
|
||||
}
|
||||
|
||||
open(mode, options) {
|
||||
const opts = options || {};
|
||||
this.mode = mode;
|
||||
|
||||
@@ -91,6 +91,9 @@ export class AppUpload extends Component {
|
||||
this.pendingSubmitForm = null;
|
||||
const form = this.closest("form");
|
||||
if (form) {
|
||||
if (this.boolAttr("paste")) {
|
||||
form.addEventListener("paste", (event) => this.handlePaste(event));
|
||||
}
|
||||
form.addEventListener("submit", (event) => {
|
||||
if (this.busyCount > 0) {
|
||||
event.preventDefault();
|
||||
@@ -108,6 +111,19 @@ export class AppUpload extends Component {
|
||||
this.input.click();
|
||||
}
|
||||
|
||||
handlePaste(event) {
|
||||
const data = event.clipboardData;
|
||||
const files = Array.from(data ? data.files : [])
|
||||
.filter((file) => file.type.startsWith("image/"));
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
if (!data.getData("text/plain")) {
|
||||
event.preventDefault();
|
||||
}
|
||||
this.handleFiles(files);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.items = [];
|
||||
if (this.mode === "field") {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<dp-upload multiple
|
||||
<dp-upload multiple paste
|
||||
max-size="{{ max_upload_size_mb() }}"
|
||||
max-files="{{ max_attachments_per_resource() }}"
|
||||
allowed-types="{{ allowed_file_types() }}"></dp-upload>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% set _user = user %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
<textarea name="content" placeholder="Your opinion goes here..." required aria-required="true" aria-label="Comment" maxlength="125000" class="emoji-picker-target" data-mention></textarea>
|
||||
<div class="comment-form-actions">
|
||||
<dp-upload multiple
|
||||
<dp-upload multiple paste
|
||||
max-size="{{ max_upload_size_mb() }}"
|
||||
max-files="{{ max_attachments_per_resource() }}"
|
||||
allowed-types="{{ allowed_file_types() }}"></dp-upload>
|
||||
|
||||
@@ -8,4 +8,10 @@
|
||||
<a href="/docs/privacy.html"><span class="icon">🔒</span> Privacy</a>
|
||||
<a href="/docs/community-guidelines.html"><span class="icon">🤝</span> Guidelines</a>
|
||||
<a href="/docs/contact.html"><span class="icon">✉️</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>
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
<small>Public origin used for absolute links (SEO, container ingress /p/<slug>). 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -25,6 +25,7 @@ Set with the `mode` attribute:
|
||||
| `label` | (none) | Optional button text shown beside the icon. |
|
||||
| `multiple` | off | Allow selecting more than one file. |
|
||||
| `directory` | off | Allow selecting a whole directory (`webkitdirectory`). |
|
||||
| `paste` | off | Attach images pasted anywhere in the surrounding `<form>` (clipboard screenshots). |
|
||||
| `accept` | (none) | Native accept filter, e.g. `image/*`. |
|
||||
| `allowed-types` | (none) | Comma list of permitted extensions, e.g. `.jpg,.png`. |
|
||||
| `max-size` | `10` | Maximum size per file, in MB. |
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<form class="messages-input-area" method="POST" action="/messages/send" data-live-form>
|
||||
<input type="hidden" name="receiver_uid" value="{{ other_user['uid'] }}">
|
||||
<textarea name="content" placeholder="Type a message..." maxlength="2000" autocomplete="off" data-mention aria-label="Type a message" rows="1"></textarea>
|
||||
<dp-upload multiple
|
||||
<dp-upload multiple paste
|
||||
max-size="{{ max_upload_size_mb() }}"
|
||||
max-files="5"
|
||||
allowed-types="{{ allowed_file_types() }}"></dp-upload>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<div class="auth-field auth-field-check">
|
||||
<label for="accept_terms">
|
||||
<input type="checkbox" id="accept_terms" name="accept_terms" value="1" required aria-required="true"{% if accept_terms %} checked{% endif %}>
|
||||
I accept the <a href="/docs/terms.html" target="_blank" rel="noopener">Terms of Service</a> and the <a href="/docs/community-guidelines.html" target="_blank" rel="noopener">Community Guidelines</a>, and I have read the <a href="/docs/privacy.html" target="_blank" rel="noopener">Privacy Policy</a>.
|
||||
<span>I accept the <a href="/docs/terms.html" target="_blank" rel="noopener">Terms of Service</a> and the <a href="/docs/community-guidelines.html" target="_blank" rel="noopener">Community Guidelines</a>, and I have read the <a href="/docs/privacy.html" target="_blank" rel="noopener">Privacy Policy</a>.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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:
|
||||
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["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
|
||||
|
||||
+62
-19
@@ -41,32 +41,50 @@ os.environ["DEVPLACE_RANKING_TTL"] = "0"
|
||||
os.environ["DEVPLACE_MARKET_SATURATION_TTL"] = "0"
|
||||
|
||||
|
||||
_ASYNC_LOOP = None
|
||||
_ASYNC_LOOP_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _async_loop():
|
||||
# One shared background loop thread for the whole session, not one thread
|
||||
# per run_async() call: dataset's NullPool caches one SQLite connection per
|
||||
# OS thread ID forever (see the root CLAUDE.md NullPool note), so a fresh
|
||||
# throwaway thread per call - this helper is invoked 270+ times across the
|
||||
# suite - permanently claimed one file descriptor apiece for the rest of
|
||||
# the session and could exhaust the process's open-file limit on a long run.
|
||||
global _ASYNC_LOOP
|
||||
with _ASYNC_LOOP_LOCK:
|
||||
if _ASYNC_LOOP is None:
|
||||
loop = asyncio.new_event_loop()
|
||||
threading.Thread(
|
||||
target=loop.run_forever, name="run-async-loop", daemon=True
|
||||
).start()
|
||||
_ASYNC_LOOP = loop
|
||||
return _ASYNC_LOOP
|
||||
|
||||
|
||||
def run_async(coro):
|
||||
"""Run a coroutine from sync test code on a dedicated thread and fresh loop."""
|
||||
result_box = []
|
||||
exc_box = []
|
||||
"""Run a coroutine from sync test code on the shared background loop."""
|
||||
|
||||
async def _wrapped():
|
||||
from devplacepy.database import refresh_snapshot
|
||||
|
||||
def _run():
|
||||
try:
|
||||
result_box.append(asyncio.run(coro))
|
||||
except BaseException as e:
|
||||
exc_box.append(e)
|
||||
return await coro
|
||||
finally:
|
||||
from devplacepy.database import refresh_snapshot
|
||||
|
||||
refresh_snapshot()
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
t.join(timeout=30)
|
||||
from devplacepy.database import refresh_snapshot
|
||||
future = asyncio.run_coroutine_threadsafe(_wrapped(), _async_loop())
|
||||
try:
|
||||
return future.result(timeout=30)
|
||||
except TimeoutError:
|
||||
raise TimeoutError(
|
||||
"run_async timed out waiting for coroutine to complete"
|
||||
) from None
|
||||
finally:
|
||||
from devplacepy.database import refresh_snapshot
|
||||
|
||||
refresh_snapshot()
|
||||
if exc_box:
|
||||
raise exc_box[0]
|
||||
if result_box:
|
||||
return result_box[0]
|
||||
raise TimeoutError("run_async timed out waiting for coroutine to complete")
|
||||
refresh_snapshot()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -301,6 +319,31 @@ def login_user(page, user):
|
||||
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
|
||||
|
||||
|
||||
def paste_image(page, selector, name="pasted.png"):
|
||||
import base64
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (4, 4), (0, 128, 255)).save(buf, "PNG")
|
||||
page.eval_on_selector(
|
||||
selector,
|
||||
"""(target, [data, filename]) => {
|
||||
const bytes = Uint8Array.from(atob(data), (ch) => ch.charCodeAt(0));
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([bytes], filename, { type: "image/png" }));
|
||||
target.dispatchEvent(
|
||||
new ClipboardEvent("paste", {
|
||||
clipboardData: transfer,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
}""",
|
||||
[base64.b64encode(buf.getvalue()).decode(), name],
|
||||
)
|
||||
|
||||
|
||||
def assert_share_copies(page, expected_fragment):
|
||||
from playwright.sync_api import expect
|
||||
|
||||
|
||||
+19
-1
@@ -1,6 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from tests.conftest import BASE_URL
|
||||
from tests.conftest import BASE_URL, paste_image
|
||||
import time
|
||||
import requests
|
||||
from playwright.sync_api import expect
|
||||
@@ -702,6 +702,24 @@ def test_feed_politics_topic(alice):
|
||||
assert politics_link.is_visible()
|
||||
|
||||
|
||||
def test_paste_image_attaches_in_post_composer(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
|
||||
page.locator(".feed-fab").first.click()
|
||||
page.locator("#post-content").wait_for(state="visible", timeout=10000)
|
||||
page.locator("#create-post-modal dp-upload .dp-upload-btn").first.wait_for(
|
||||
state="visible", timeout=10000
|
||||
)
|
||||
paste_image(page, "#post-content")
|
||||
page.locator("#create-post-modal dp-upload .dp-upload-count").first.wait_for(
|
||||
state="visible", timeout=15000
|
||||
)
|
||||
expect(
|
||||
page.locator("#create-post-modal dp-upload input[name='attachment_uids']")
|
||||
).to_have_value(re.compile(r".+"))
|
||||
|
||||
|
||||
def test_create_post_cancel_modal(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
|
||||
@@ -128,7 +128,8 @@ def test_steal_takes_build_and_pays_thief(bob):
|
||||
steal.click()
|
||||
confirm_btn = page.locator(".dialog-confirm")
|
||||
confirm_btn.wait_for(state="visible")
|
||||
confirm_btn.click()
|
||||
with page.expect_response(lambda r: "/steal" in r.url and r.request.method == "POST"):
|
||||
confirm_btn.click()
|
||||
expect(page.locator("form[data-game-action='steal']")).to_have_count(0)
|
||||
page.wait_for_timeout(400)
|
||||
assert _farm_coins("bob_test") == 18
|
||||
@@ -165,7 +166,8 @@ def test_victim_notification_names_the_raider_and_the_amount(bob):
|
||||
steal.click()
|
||||
confirm_btn = page.locator(".dialog-confirm")
|
||||
confirm_btn.wait_for(state="visible")
|
||||
confirm_btn.click()
|
||||
with page.expect_response(lambda r: "/steal" in r.url and r.request.method == "POST"):
|
||||
confirm_btn.click()
|
||||
expect(page.locator("form[data-game-action='steal']")).to_have_count(0)
|
||||
page.wait_for_timeout(400)
|
||||
note = get_table("notifications").find_one(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+19
-1
@@ -3,7 +3,7 @@
|
||||
import re
|
||||
import time
|
||||
from playwright.sync_api import expect
|
||||
from tests.conftest import BASE_URL
|
||||
from tests.conftest import BASE_URL, paste_image
|
||||
from devplacepy.database import get_table
|
||||
import requests
|
||||
def _seed_news_seo():
|
||||
@@ -183,6 +183,24 @@ def test_send_message_appears_in_thread(alice):
|
||||
page.locator(f".message-bubble:has-text('{msg}')").first.wait_for(state="visible")
|
||||
|
||||
|
||||
def test_paste_image_attaches_in_chat(alice):
|
||||
page, _ = alice
|
||||
bob = get_table("users").find_one(username="bob_test")
|
||||
page.goto(
|
||||
f"{BASE_URL}/messages?with_uid={bob['uid']}", wait_until="domcontentloaded"
|
||||
)
|
||||
page.locator(".messages-input-area dp-upload .dp-upload-btn").first.wait_for(
|
||||
state="visible", timeout=10000
|
||||
)
|
||||
paste_image(page, ".messages-input-area textarea[name='content']")
|
||||
page.locator(".messages-input-area dp-upload .dp-upload-count").first.wait_for(
|
||||
state="visible", timeout=15000
|
||||
)
|
||||
expect(
|
||||
page.locator(".messages-input-area dp-upload input[name='attachment_uids']")
|
||||
).to_have_value(re.compile(r".+"))
|
||||
|
||||
|
||||
def test_messages_page_loads(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
|
||||
|
||||
+20
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
import re
|
||||
from playwright.sync_api import expect
|
||||
from tests.conftest import BASE_URL, assert_share_copies
|
||||
from tests.conftest import BASE_URL, assert_share_copies, paste_image
|
||||
def create_post(page, topic="random", content="Test post content", title=None):
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
|
||||
@@ -114,7 +114,10 @@ def test_comment_voted_state_persists(alice):
|
||||
expect(
|
||||
page.locator(".comment-text:has-text('Comment whose vote should persist')")
|
||||
).to_be_visible()
|
||||
page.locator(".comment-vote-btn").first.click()
|
||||
with page.expect_response(
|
||||
lambda r: "/votes/" in r.url and r.request.method == "POST"
|
||||
):
|
||||
page.locator(".comment-vote-btn").first.click()
|
||||
expect(page.locator(".comment-vote-btn").first).to_have_class(
|
||||
re.compile(r"\bvoted\b")
|
||||
)
|
||||
@@ -372,6 +375,21 @@ def test_emoji_picker_opens(alice):
|
||||
assert page.locator("emoji-picker").first.is_visible()
|
||||
|
||||
|
||||
def test_paste_image_attaches_in_comment_form(alice):
|
||||
page, _ = alice
|
||||
create_post(page, "random", "Post for pasted comment attachment")
|
||||
page.locator(".comment-form dp-upload .dp-upload-btn").first.wait_for(
|
||||
state="visible", timeout=10000
|
||||
)
|
||||
paste_image(page, ".comment-form textarea[name='content']")
|
||||
page.locator(".comment-form dp-upload .dp-upload-count").first.wait_for(
|
||||
state="visible", timeout=15000
|
||||
)
|
||||
expect(
|
||||
page.locator(".comment-form dp-upload input[name='attachment_uids']")
|
||||
).to_have_value(re.compile(r".+"))
|
||||
|
||||
|
||||
def test_attachment_upload_ui(alice):
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
Reference in New Issue
Block a user