feat(web): show Patreon supporters on the web build too
The browser can't call the Patreon API directly (CORS, and the token would be exposed in the bundle), so the web build fetches a pre-generated supporters.json served on the same origin. The Pages workflow generates it server-side with the token (CI secret) after the trunk build. Native keeps its live API fetch. serde_json moves to the shared dependencies so the wasm build can parse the list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
572e9aca01
commit
0e74a11724
4 changed files with 72 additions and 3 deletions
27
.github/workflows/pages.yml
vendored
27
.github/workflows/pages.yml
vendored
|
|
@ -47,6 +47,33 @@ jobs:
|
||||||
# wasm-bindgen output is served verbatim.
|
# wasm-bindgen output is served verbatim.
|
||||||
- run: touch dist/.nojekyll
|
- run: touch dist/.nojekyll
|
||||||
|
|
||||||
|
# The web app can't call the Patreon API directly (CORS + the token would
|
||||||
|
# be exposed in the bundle), so generate the supporters list server-side
|
||||||
|
# here — token stays in the CI secret — and serve it next to the app.
|
||||||
|
# The web build fetches `supporters.json` on the same origin.
|
||||||
|
- name: Generate supporters.json
|
||||||
|
env:
|
||||||
|
OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$OCS_PATREON_TOKEN" ]; then
|
||||||
|
echo '[]' > dist/supporters.json; exit 0
|
||||||
|
fi
|
||||||
|
CID=$(curl -sf -H "Authorization: Bearer $OCS_PATREON_TOKEN" \
|
||||||
|
"https://www.patreon.com/api/oauth2/v2/campaigns" \
|
||||||
|
| jq -r '.data[0].id // empty' || true)
|
||||||
|
if [ -z "$CID" ]; then
|
||||||
|
echo '[]' > dist/supporters.json; exit 0
|
||||||
|
fi
|
||||||
|
curl -sf -H "Authorization: Bearer $OCS_PATREON_TOKEN" \
|
||||||
|
"https://www.patreon.com/api/oauth2/v2/campaigns/$CID/members?fields%5Bmember%5D=full_name,patron_status,currently_entitled_amount_cents&page%5Bcount%5D=200" \
|
||||||
|
| jq -c '[.data[]
|
||||||
|
| select(.attributes.patron_status=="active_patron")
|
||||||
|
| select(.attributes.currently_entitled_amount_cents>0)
|
||||||
|
| {name: .attributes.full_name, cents: .attributes.currently_entitled_amount_cents}]
|
||||||
|
| sort_by(-.cents)' \
|
||||||
|
> dist/supporters.json || echo '[]' > dist/supporters.json
|
||||||
|
echo "supporters: $(jq 'length' dist/supporters.json)"
|
||||||
|
|
||||||
- uses: actions/upload-pages-artifact@v3
|
- uses: actions/upload-pages-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: dist
|
path: dist
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ default = ["solid3d"]
|
||||||
solid3d = ["dep:truck-meshalgo", "dep:truck-shapeops", "dep:lzma-sys"]
|
solid3d = ["dep:truck-meshalgo", "dep:truck-shapeops", "dep:lzma-sys"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
# JSON: plugin marketplace / Patreon fetch (native) + web supporters.json parse.
|
||||||
|
serde_json = "1"
|
||||||
# Stable, dependency-free add-on contract (manifest + ribbon/CadModule types).
|
# Stable, dependency-free add-on contract (manifest + ribbon/CadModule types).
|
||||||
# Plugin authors target this crate's semver, not OpenCADStudio internals.
|
# Plugin authors target this crate's semver, not OpenCADStudio internals.
|
||||||
# The `host` feature (out-of-process plugin runtime: interprocess, libloading,
|
# The `host` feature (out-of-process plugin runtime: interprocess, libloading,
|
||||||
|
|
@ -89,8 +91,6 @@ open = "5"
|
||||||
# `memchr` (1.0.2) via lopdf → nom_locate, and the web build has no filesystem.
|
# `memchr` (1.0.2) via lopdf → nom_locate, and the web build has no filesystem.
|
||||||
printpdf = { version = "0.9.1", default-features = false }
|
printpdf = { version = "0.9.1", default-features = false }
|
||||||
ureq = { version = "3", default-features = false, features = ["rustls"] }
|
ureq = { version = "3", default-features = false, features = ["rustls"] }
|
||||||
# Parse the GitHub Releases API response for the plugin marketplace.
|
|
||||||
serde_json = "1"
|
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
# Web gets the dependency-free manifest/ribbon contract only (no plugin host).
|
# Web gets the dependency-free manifest/ribbon contract only (no plugin host).
|
||||||
|
|
|
||||||
|
|
@ -2329,7 +2329,13 @@ impl OpenCADStudio {
|
||||||
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
|
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
|
||||||
let mut s = Self::new();
|
let mut s = Self::new();
|
||||||
let focus = s.focus_cmd_input();
|
let focus = s.focus_cmd_input();
|
||||||
(s, focus)
|
// Web can't reach the Patreon API directly (CORS); fetch the CI-built
|
||||||
|
// supporters.json served on the same origin instead.
|
||||||
|
let patrons = Task::perform(
|
||||||
|
crate::patreon::fetch_patrons_web(),
|
||||||
|
Message::PatronsFetched,
|
||||||
|
);
|
||||||
|
(s, Task::batch([focus, patrons]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,42 @@ pub fn fetch_patrons() -> Result<Vec<(String, i64)>, String> {
|
||||||
Ok(patrons)
|
Ok(patrons)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Web build: the browser can't call the Patreon API directly (CORS + the
|
||||||
|
/// token would be exposed in the bundle), so it fetches a pre-generated
|
||||||
|
/// `supporters.json` published next to the app on the same origin (produced by
|
||||||
|
/// CI with the token held server-side). Shape: `[{ "name": .., "cents": .. }]`.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub async fn fetch_patrons_web() -> Result<Vec<(String, i64)>, String> {
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
|
||||||
|
let window = web_sys::window().ok_or("no window")?;
|
||||||
|
let resp_val = JsFuture::from(window.fetch_with_str("supporters.json"))
|
||||||
|
.await
|
||||||
|
.map_err(|_| "fetch failed")?;
|
||||||
|
let resp: web_sys::Response = resp_val.dyn_into().map_err(|_| "not a Response")?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(format!("HTTP {}", resp.status()));
|
||||||
|
}
|
||||||
|
let text = JsFuture::from(resp.text().map_err(|_| "text() unavailable")?)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "body read failed")?;
|
||||||
|
let body = text.as_string().ok_or("body is not a string")?;
|
||||||
|
|
||||||
|
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||||
|
let arr = json.as_array().ok_or("supporters.json is not an array")?;
|
||||||
|
Ok(arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| {
|
||||||
|
let name = e["name"].as_str()?.trim().to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((name, e["cents"].as_i64().unwrap_or(0)))
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn get_json(
|
fn get_json(
|
||||||
agent: &ureq::Agent,
|
agent: &ureq::Agent,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue