Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24b4f5582 | ||
|
|
74bba2e9b1 | ||
|
|
bd12dd4e99 | ||
|
|
b044780e61 |
Generated
+1
-1
@@ -2948,7 +2948,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.3.31"
|
version = "0.3.34"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "portal"
|
name = "portal"
|
||||||
version = "0.3.32"
|
version = "0.3.34"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ stream.
|
|||||||
- **Answer chains** (`src/chain.rs`): each submission hashes its
|
- **Answer chains** (`src/chain.rs`): each submission hashes its
|
||||||
parent(s), so a visitor's path through the questions is a verifiable
|
parent(s), so a visitor's path through the questions is a verifiable
|
||||||
lineage; `?chain=` links carry it, and self-service transitions
|
lineage; `?chain=` links carry it, and self-service transitions
|
||||||
(unsubscribe) authorize by holding one.
|
(unsubscribe) authorize by holding one. The question nav reads it
|
||||||
|
too: a `followup` page is offered only when it is on that path (the
|
||||||
|
page the chain verifiably answered) or further down from the current
|
||||||
|
page over `action` edges - one followup path never unlocks another.
|
||||||
- **Live resources** (`src/resource.rs`): content can pull a KV
|
- **Live resources** (`src/resource.rs`): content can pull a KV
|
||||||
bucket, Gitea starred/org repos/releases, or any public JSON URL
|
bucket, Gitea starred/org repos/releases, or any public JSON URL
|
||||||
(SSRF fail-closed), reshaped by a content-declared `jq` filter.
|
(SSRF fail-closed), reshaped by a content-declared `jq` filter.
|
||||||
|
|||||||
+67
-19
@@ -40,6 +40,20 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
|
|||||||
// once loaded via <link>/<img> on Safari/iOS.
|
// once loaded via <link>/<img> on Safari/iOS.
|
||||||
<link rel="icon" media="(prefers-color-scheme: light)" href="/favicon-light.svg" type="image/svg+xml"/>
|
<link rel="icon" media="(prefers-color-scheme: light)" href="/favicon-light.svg" type="image/svg+xml"/>
|
||||||
<link rel="icon" media="(prefers-color-scheme: dark)" href="/favicon-dark.svg" type="image/svg+xml"/>
|
<link rel="icon" media="(prefers-color-scheme: dark)" href="/favicon-dark.svg" type="image/svg+xml"/>
|
||||||
|
// Fonts hide behind the stylesheet: the browser must
|
||||||
|
// fetch and parse CSS before it discovers them. The
|
||||||
|
// latin subset is on every page's critical path, so
|
||||||
|
// announce it up front; latin-ext and vietnamese stay
|
||||||
|
// unicode-range-gated and load only when script needs
|
||||||
|
// them - preloading those would tax everyone for a few.
|
||||||
|
// (Font preloads require crossorigin even same-origin.)
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/fonts/quicksand-semibold-latin.woff2"
|
||||||
|
r#as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
// Server-side, so it can read hash.txt and link the
|
// Server-side, so it can read hash.txt and link the
|
||||||
// content-hashed stylesheet (hash-files = true).
|
// content-hashed stylesheet (hash-files = true).
|
||||||
<HashedStylesheet id="leptos" options=options.clone()/>
|
<HashedStylesheet id="leptos" options=options.clone()/>
|
||||||
@@ -89,6 +103,19 @@ pub fn App() -> impl IntoView {
|
|||||||
.and_then(|s| s.stylesheet)
|
.and_then(|s| s.stylesheet)
|
||||||
.map(|p| format!("/site/{p}"))
|
.map(|p| format!("/site/{p}"))
|
||||||
});
|
});
|
||||||
|
// The wordmark paints in the hero on first view; announcing it in
|
||||||
|
// the head starts the fetch during hydration instead of after. On
|
||||||
|
// redoal.com it lives on the content host, so a preconnect opens
|
||||||
|
// that TLS session while the preload is still queued.
|
||||||
|
let wordmark_href = Memo::new(move |_| {
|
||||||
|
site.get().and_then(|r| r.ok()).and_then(|s| s.wordmark)
|
||||||
|
});
|
||||||
|
let wordmark_origin = Memo::new(move |_| {
|
||||||
|
wordmark_href.get().and_then(|w| {
|
||||||
|
let rest = w.strip_prefix("https://")?;
|
||||||
|
Some(format!("https://{}", rest.split('/').next()?))
|
||||||
|
})
|
||||||
|
});
|
||||||
// A content-shipped favicon overrides the built-in one; injected
|
// A content-shipped favicon overrides the built-in one; injected
|
||||||
// into the head after the static defaults, so it wins.
|
// into the head after the static defaults, so it wins.
|
||||||
let favicon = Memo::new(move |_| {
|
let favicon = Memo::new(move |_| {
|
||||||
@@ -100,6 +127,8 @@ pub fn App() -> impl IntoView {
|
|||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Html attr:lang=move || lang.get()/>
|
<Html attr:lang=move || lang.get()/>
|
||||||
|
{move || wordmark_origin.get().map(|href| view! { <Link rel="preconnect" href=href crossorigin="anonymous"/> })}
|
||||||
|
{move || wordmark_href.get().map(|href| view! { <Link rel="preload" as_="image" href=href/> })}
|
||||||
{move || custom_css.get().map(|href| view! { <Stylesheet id="site-custom" href=href/> })}
|
{move || custom_css.get().map(|href| view! { <Stylesheet id="site-custom" href=href/> })}
|
||||||
{move || favicon.get().map(|href| view! { <Link rel="icon" href=href/> })}
|
{move || favicon.get().map(|href| view! { <Link rel="icon" href=href/> })}
|
||||||
<Suspense fallback=|| ()>
|
<Suspense fallback=|| ()>
|
||||||
@@ -184,7 +213,10 @@ fn PortalShell() -> impl IntoView {
|
|||||||
|(path, chain)| get_question(path, chain),
|
|(path, chain)| get_question(path, chain),
|
||||||
);
|
);
|
||||||
let user = Resource::new(|| (), |_| current_user());
|
let user = Resource::new(|| (), |_| current_user());
|
||||||
let nav = Resource::new(move || chain.get().is_some(), list_qualifying_questions);
|
let nav = Resource::new(
|
||||||
|
move || (path.get(), chain.get()),
|
||||||
|
|(path, chain)| list_qualifying_questions(path, chain),
|
||||||
|
);
|
||||||
let announcements = Resource::new(|| (), |_| list_announcements());
|
let announcements = Resource::new(|| (), |_| list_announcements());
|
||||||
provide_context(QuestionRes(question));
|
provide_context(QuestionRes(question));
|
||||||
provide_context(UserRes(user));
|
provide_context(UserRes(user));
|
||||||
@@ -260,7 +292,6 @@ fn QuestionView(
|
|||||||
site: SiteConfig,
|
site: SiteConfig,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let question_id = question.id.clone();
|
let question_id = question.id.clone();
|
||||||
let has_chain = parent_hash.is_some();
|
|
||||||
|
|
||||||
// The provenance counterpart to the qualifies gate below: a
|
// The provenance counterpart to the qualifies gate below: a
|
||||||
// requires_chain page whose visitor holds no verifiable lineage to
|
// requires_chain page whose visitor holds no verifiable lineage to
|
||||||
@@ -289,7 +320,7 @@ fn QuestionView(
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
// A gate is never a dead end: the same nav as every page.
|
// A gate is never a dead end: the same nav as every page.
|
||||||
<QuestionNav current_id=question_id has_chain=has_chain/>
|
<QuestionNav current_id=question_id/>
|
||||||
}
|
}
|
||||||
.into_any();
|
.into_any();
|
||||||
}
|
}
|
||||||
@@ -326,7 +357,7 @@ fn QuestionView(
|
|||||||
}}
|
}}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<QuestionNav current_id=question_id has_chain=has_chain/>
|
<QuestionNav current_id=question_id/>
|
||||||
}
|
}
|
||||||
.into_any();
|
.into_any();
|
||||||
}
|
}
|
||||||
@@ -389,7 +420,7 @@ fn QuestionView(
|
|||||||
.map(|r| {
|
.map(|r| {
|
||||||
view! { <ResponsibleNote responsible=r/> }
|
view! { <ResponsibleNote responsible=r/> }
|
||||||
})}
|
})}
|
||||||
<QuestionNav current_id=question_id has_chain=has_chain/>
|
<QuestionNav current_id=question_id/>
|
||||||
}
|
}
|
||||||
.into_any()
|
.into_any()
|
||||||
}
|
}
|
||||||
@@ -398,8 +429,7 @@ fn QuestionView(
|
|||||||
/// small nav - how a concern reaches the people it speaks to without
|
/// small nav - how a concern reaches the people it speaks to without
|
||||||
/// claiming front-page space (an owner also sees the gated desks here).
|
/// claiming front-page space (an owner also sees the gated desks here).
|
||||||
#[component]
|
#[component]
|
||||||
fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView {
|
fn QuestionNav(current_id: String) -> impl IntoView {
|
||||||
let _ = has_chain; // keyed into the shared resource by PortalShell
|
|
||||||
let NavRes(nav) = expect_context();
|
let NavRes(nav) = expect_context();
|
||||||
view! {
|
view! {
|
||||||
<Suspense fallback=|| ()>
|
<Suspense fallback=|| ()>
|
||||||
@@ -2000,16 +2030,21 @@ pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
|
|||||||
Ok(state.site.load().as_ref().clone())
|
Ok(state.site.load().as_ref().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every question the current visitor qualifies for, as (id, name) -
|
/// Every question the visitor may be offered from `current`, as (id,
|
||||||
/// the site nav's data. Context-dependent: an owner session sees the
|
/// name) - the question nav's data. Front-door pages the visitor
|
||||||
/// gated pages too, and `followup` pages only appear once the visitor
|
/// qualifies for are always in (an owner session sees the gated desks
|
||||||
/// carries an answer chain.
|
/// too); `followup` pages and ended events only when they are on the
|
||||||
|
/// visitor's path - the page their verified `?chain=` answered - or
|
||||||
|
/// further down from `current` (see `content::outcomes_reachable_from`).
|
||||||
|
/// A chain unlocks the followup it came from, never every followup on
|
||||||
|
/// the site; an unverifiable hash unlocks nothing.
|
||||||
#[server(endpoint = "list_qualifying_questions")]
|
#[server(endpoint = "list_qualifying_questions")]
|
||||||
pub async fn list_qualifying_questions(
|
pub async fn list_qualifying_questions(
|
||||||
has_chain: bool,
|
current: String,
|
||||||
|
chain: Option<String>,
|
||||||
) -> Result<Vec<(String, String)>, ServerFnError> {
|
) -> Result<Vec<(String, String)>, ServerFnError> {
|
||||||
use crate::auth::{User, SESSION_USER_KEY};
|
use crate::auth::{User, SESSION_USER_KEY};
|
||||||
use crate::content::is_qualified;
|
use crate::content::{is_qualified, outcomes_reachable_from, Question};
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
|
|
||||||
let state = expect_context::<AppState>();
|
let state = expect_context::<AppState>();
|
||||||
@@ -2020,14 +2055,27 @@ pub async fn list_qualifying_questions(
|
|||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
let mut out: Vec<(String, String)> = state
|
let questions = state.questions.load();
|
||||||
.questions
|
// The one page the chain verifiably answered - the same lookup a
|
||||||
.load()
|
// `requires_chain` gate does, so a forged or foreign hash resolves
|
||||||
|
// to nothing relevant.
|
||||||
|
let answered: Option<String> = match &chain {
|
||||||
|
Some(hash) => crate::chain::lookup_node(&state.jetstream, hash)
|
||||||
|
.await
|
||||||
|
.map(|node| node.question_id),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
// An ended event page is a followup from then on.
|
||||||
|
let is_outcome = |q: &Question| q.is_followup() || q.event_ended_at(now_ms);
|
||||||
|
let reach = outcomes_reachable_from(&questions, ¤t, &is_outcome);
|
||||||
|
let mut out: Vec<(String, String)> = questions
|
||||||
.values()
|
.values()
|
||||||
.filter(|q| is_qualified(user.as_ref(), q))
|
.filter(|q| is_qualified(user.as_ref(), q))
|
||||||
// An ended event page is a followup from then on - only a
|
.filter(|q| {
|
||||||
// visitor carrying a chain (they answered it) still sees it.
|
!is_outcome(q)
|
||||||
.filter(|q| !(q.is_followup() || q.event_ended_at(now_ms)) || has_chain)
|
|| answered.as_deref() == Some(q.id.as_str())
|
||||||
|
|| reach.contains(&q.id)
|
||||||
|
})
|
||||||
// A live event is announced in the header instead.
|
// A live event is announced in the header instead.
|
||||||
.filter(|q| !q.event_live_at(now_ms))
|
.filter(|q| !q.event_live_at(now_ms))
|
||||||
// A dynamic page has no URL of its own to link to.
|
// A dynamic page has no URL of its own to link to.
|
||||||
|
|||||||
+115
-2
@@ -42,8 +42,11 @@ pub struct Question {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub responsible: Option<Responsible>,
|
pub responsible: Option<Responsible>,
|
||||||
/// A page that only makes sense after answering something (the
|
/// A page that only makes sense after answering something (the
|
||||||
/// post-submission pages) - kept out of the question nav unless the
|
/// post-submission pages) - kept out of the question nav unless it is
|
||||||
/// visitor's context carries an answer chain. Unset means inferred
|
/// on the visitor's path (the page their verified `?chain=` answered)
|
||||||
|
/// or further down from the page they are on, over `action` edges
|
||||||
|
/// (see `outcomes_reachable_from`). One followup path never unlocks
|
||||||
|
/// another. Unset means inferred
|
||||||
/// from the file's place in the tree: files nested in a
|
/// from the file's place in the tree: files nested in a
|
||||||
/// subdirectory are followups unless they're the directory's
|
/// subdirectory are followups unless they're the directory's
|
||||||
/// `index.yaml`; top-level files keep the historical default
|
/// `index.yaml`; top-level files keep the historical default
|
||||||
@@ -922,6 +925,74 @@ pub fn path_matches(pattern: &str, path: &str) -> Option<Vec<(String, String)>>
|
|||||||
Some(captures)
|
Some(captures)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The question an alternative's `action` lands on, as a question id:
|
||||||
|
/// a plain path resolves directly, a templated one
|
||||||
|
/// (`/shape/{curve.key}`) to the dynamic page whose pattern has the
|
||||||
|
/// same shape (`/shape/[key]`). `None` when no page declares it.
|
||||||
|
pub fn action_target_id(
|
||||||
|
questions: &std::collections::HashMap<String, Question>,
|
||||||
|
action: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
if questions.contains_key(action) {
|
||||||
|
return Some(action.to_string());
|
||||||
|
}
|
||||||
|
let want: Vec<&str> = action.split('/').filter(|s| !s.is_empty()).collect();
|
||||||
|
questions
|
||||||
|
.values()
|
||||||
|
.filter(|q| q.is_dynamic())
|
||||||
|
.map(|q| &q.id)
|
||||||
|
.find(|id| {
|
||||||
|
let pat: Vec<&str> = id.split('/').filter(|s| !s.is_empty()).collect();
|
||||||
|
pat.len() == want.len()
|
||||||
|
&& pat
|
||||||
|
.iter()
|
||||||
|
.zip(&want)
|
||||||
|
.all(|(p, w)| p == w || (p.starts_with('[') && w.starts_with('{')))
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome pages "further down" from `from`: every `is_outcome`
|
||||||
|
/// question reachable from it over `alternatives[].action` edges,
|
||||||
|
/// walking only *through* outcome pages. A front-door page (not an
|
||||||
|
/// outcome) is where the walk ends, so a "back to the start" action
|
||||||
|
/// never fans out into the whole site. `from` itself is never in the
|
||||||
|
/// result. This, plus the one page a visitor's verified chain answered,
|
||||||
|
/// decides which followups the question nav offers - one followup path
|
||||||
|
/// never unlocks another (an email-link page like redoal's `/watching`,
|
||||||
|
/// which nothing links to, stays out of every nav).
|
||||||
|
pub fn outcomes_reachable_from(
|
||||||
|
questions: &std::collections::HashMap<String, Question>,
|
||||||
|
from: &str,
|
||||||
|
is_outcome: &dyn Fn(&Question) -> bool,
|
||||||
|
) -> std::collections::HashSet<String> {
|
||||||
|
use std::collections::{HashSet, VecDeque};
|
||||||
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
|
let mut queue: VecDeque<Question> = VecDeque::new();
|
||||||
|
if let Some(q) = resolve_question(questions, from) {
|
||||||
|
queue.push_back(q);
|
||||||
|
}
|
||||||
|
while let Some(q) = queue.pop_front() {
|
||||||
|
for action in q.alternatives.iter().filter_map(|a| a.action.as_deref()) {
|
||||||
|
let Some(id) = action_target_id(questions, action) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// A page's own dynamic pattern (`/shape/abc` -> `/shape/[key]`)
|
||||||
|
// is itself, not somewhere further down.
|
||||||
|
if id == from || path_matches(&id, from).is_some() || seen.contains(&id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let target = &questions[&id];
|
||||||
|
if !is_outcome(target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.insert(id.clone());
|
||||||
|
queue.push_back(target.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolves a URL path to its question: an exact id first, else the
|
/// Resolves a URL path to its question: an exact id first, else the
|
||||||
/// one dynamic page whose pattern matches, with each captured segment
|
/// one dynamic page whose pattern matches, with each captured segment
|
||||||
/// substituted into `{name}` placeholders in the clone's resource keys
|
/// substituted into `{name}` placeholders in the clone's resource keys
|
||||||
@@ -1961,6 +2032,48 @@ alternatives:
|
|||||||
assert!(!questions.contains_key("/review/_draft"));
|
assert!(!questions.contains_key("/review/_draft"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn followups_surface_by_reach_not_by_carrying_any_chain() {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
let files = vec![
|
||||||
|
// Front door with three outcomes, one of them templated onto
|
||||||
|
// a dynamic page.
|
||||||
|
("index.yaml".to_string(), "name: Home\nalternatives:\n - name: apply\n action: applied\n - name: subscribe\n action: subscribed\n - name: draw\n action: /shape/{curve.key}\n".to_string()),
|
||||||
|
("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()),
|
||||||
|
("subscribed.yaml".to_string(), "name: Subscribed\nfollowup: true\nalternatives:\n - name: back\n action: /\n".to_string()),
|
||||||
|
// Nested -> followup by inference; leads on to /subscribed.
|
||||||
|
("shape/[key].yaml".to_string(), "name: Shape\nalternatives:\n - name: watch\n action: /subscribed\n - name: back\n action: /\n".to_string()),
|
||||||
|
// Reached only by an out-of-band link: nothing points here.
|
||||||
|
("watching.yaml".to_string(), "name: Watching\nfollowup: true\n".to_string()),
|
||||||
|
// A second, unrelated path with its own outcome.
|
||||||
|
("kontakt.yaml".to_string(), "name: Kontakt\nalternatives:\n - name: send\n action: takk\n".to_string()),
|
||||||
|
("takk.yaml".to_string(), "name: Takk\nfollowup: true\nalternatives:\n - name: back\n action: /\n".to_string()),
|
||||||
|
];
|
||||||
|
let questions = build_questions(&files).unwrap();
|
||||||
|
let is_outcome = |q: &Question| q.is_followup();
|
||||||
|
let reach = |from: &str| outcomes_reachable_from(&questions, from, &is_outcome);
|
||||||
|
let set = |ids: &[&str]| ids.iter().map(|s| s.to_string()).collect::<HashSet<_>>();
|
||||||
|
|
||||||
|
// Templated actions resolve to the dynamic page's pattern.
|
||||||
|
assert_eq!(action_target_id(&questions, "/shape/{curve.key}").as_deref(), Some("/shape/[key]"));
|
||||||
|
assert_eq!(action_target_id(&questions, "/nowhere"), None);
|
||||||
|
|
||||||
|
// From the front door: its own outcomes, and what they lead on
|
||||||
|
// to - but not the other path's outcome, nor the orphan.
|
||||||
|
assert_eq!(reach("/"), set(&["/applied", "/subscribed", "/shape/[key]"]));
|
||||||
|
// The other path only sees its own outcome. Its "back to /"
|
||||||
|
// edge ends the walk at the front door instead of fanning out.
|
||||||
|
assert_eq!(reach("/kontakt"), set(&["/takk"]));
|
||||||
|
// Standing on an outcome: nothing further down through outcomes.
|
||||||
|
assert_eq!(reach("/subscribed"), set(&[]));
|
||||||
|
assert_eq!(reach("/takk"), set(&[]));
|
||||||
|
// The email-link page reaches nothing and nothing reaches it.
|
||||||
|
assert_eq!(reach("/watching"), set(&[]));
|
||||||
|
// A concrete dynamic path resolves to its pattern and walks on;
|
||||||
|
// the pattern itself is not "further down" from its own page.
|
||||||
|
assert_eq!(reach("/shape/abc"), set(&["/subscribed"]));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn declared_id_wins_over_path() {
|
fn declared_id_wins_over_path() {
|
||||||
let files = vec![(
|
let files = vec![(
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ a {
|
|||||||
main.loading,
|
main.loading,
|
||||||
main.not-found {
|
main.not-found {
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
|
/* svh so wrapper browsers that resize the webview as their toolbar
|
||||||
|
animates (Firefox/Chrome iOS) don't reflow this placeholder - the
|
||||||
|
small viewport is a fixed value. Same ladder as .hero-piece. */
|
||||||
|
min-height: 60svh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user