Add colorless filters, and make set listings filterable
Colorless There was no way to ask for colorless cards — the colour toggles are a 5-bit WUBRG mask where 0 already means "don't filter by colour", leaving nothing to express "no colours at all". That's exactly what you need to find a colorless commander's card pool. Adds a "C" pip to both colour rows, matching how ColorPips already renders colorless cards, backed by two explicit booleans rather than a sixth bit — a filter mask and a card's real colour mask staying the same shape is worth keeping. The two rows mean different things and both are useful: on the commander row C filters colour *identity*, which is the Commander rule; on the includes row it filters the card's own colours. A card printed colorless can still carry a coloured identity through mana symbols in its text, and it's the identity that bars it from a colorless commander's deck. Set listings News > Releases had its own card path and so couldn't be filtered. A new set_code filter (matched against printings.set_code, already ingested) lets the set browser run through search_reference instead, so every criterion works there identically rather than being reimplemented over a live Scryfall response. That alone would have cost the ability to browse an upcoming set, since search_reference only knows downloaded card data — and upcoming sets are half the point of the tab. So the browser picks a source per set: downloaded data where it exists, with the full filter panel; otherwise Scryfall directly, with the panel hidden and a line explaining why. A panel that silently does nothing would be worse than no panel. The source is decided by an *unfiltered* probe. Keying off whether the current criteria match anything would flip a properly indexed set to "live" the moment a filter excluded everything, dropping the panel exactly when it was in use.
This commit is contained in:
@@ -84,6 +84,19 @@ fn push_card_filters(
|
||||
sql.push_str(" AND oc.name_lower LIKE ?");
|
||||
vals.push(Value::Text(format!("%{}%", text.to_lowercase())));
|
||||
}
|
||||
if let Some(code) = filter.set_code.as_ref().filter(|c| !c.is_empty()) {
|
||||
sql.push_str(&format!(
|
||||
" AND EXISTS (SELECT 1 FROM {REF_DB}.printings p
|
||||
WHERE p.oracle_id = oc.oracle_id AND p.set_code = ? COLLATE NOCASE)"
|
||||
));
|
||||
vals.push(Value::Text(code.clone()));
|
||||
}
|
||||
if filter.colorless_identity {
|
||||
sql.push_str(" AND oc.color_identity = 0");
|
||||
}
|
||||
if filter.colorless_colors {
|
||||
sql.push_str(" AND oc.colors = 0");
|
||||
}
|
||||
if let Some(c) = filter.colors_exact {
|
||||
sql.push_str(" AND oc.colors = ?");
|
||||
vals.push(Value::Integer(c.0 as i64));
|
||||
@@ -303,6 +316,111 @@ mod tests {
|
||||
assert!(all.contains(&"Serra Angel".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_set_filter_narrows_to_that_set_and_composes_with_the_rest() {
|
||||
let fx = owned_fixture();
|
||||
let conn = &fx.catalog.conn;
|
||||
// Fixture cards have no printings; give two of them one.
|
||||
for (oid, set) in [("serra-angel", "m19"), ("lightning-bolt", "lea")] {
|
||||
conn.execute(
|
||||
"INSERT INTO ref.printings(scryfall_id, oracle_id, set_code) VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![format!("p-{oid}"), oid, set],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let in_m19 = names(
|
||||
&fx.catalog
|
||||
.search_library(&LibraryFilter {
|
||||
set_code: Some("m19".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(in_m19, vec!["Serra Angel".to_string()]);
|
||||
|
||||
// Set codes are case-insensitive: the UI shows them uppercased.
|
||||
assert_eq!(
|
||||
fx.catalog
|
||||
.search_library(&LibraryFilter { set_code: Some("M19".into()), ..Default::default() })
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
// And it composes with other criteria rather than overriding them.
|
||||
let none = fx
|
||||
.catalog
|
||||
.search_library(&LibraryFilter {
|
||||
set_code: Some("m19".into()),
|
||||
colorless_identity: true,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(none.is_empty(), "Serra Angel is white, so the two together match nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorless_filters_are_distinct_from_an_empty_colour_selection() {
|
||||
let fx = owned_fixture();
|
||||
// Sol Ring is the only colourless fixture card.
|
||||
let identity = names(
|
||||
&fx.catalog
|
||||
.search_library(&LibraryFilter {
|
||||
colorless_identity: true,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(identity, vec!["Sol Ring".to_string()]);
|
||||
|
||||
let colors = names(
|
||||
&fx.catalog
|
||||
.search_library(&LibraryFilter {
|
||||
colorless_colors: true,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(colors, vec!["Sol Ring".to_string()]);
|
||||
|
||||
// Selecting no colours at all still means "don't filter by colour" —
|
||||
// that's the ambiguity these fields exist to resolve.
|
||||
let unfiltered = fx.catalog.search_library(&LibraryFilter::default()).unwrap();
|
||||
assert_eq!(unfiltered.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colorless_identity_still_admits_a_card_whose_own_colors_differ() {
|
||||
let fx = owned_fixture();
|
||||
// Contrived but real: a card printed colourless that carries a coloured
|
||||
// identity (e.g. a hybrid ability). Identity is what Commander polices.
|
||||
fx.catalog
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE ref.oracle_cards SET colors = 0 WHERE oracle_id = 'lightning-bolt'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let by_colors = names(
|
||||
&fx.catalog
|
||||
.search_library(&LibraryFilter { colorless_colors: true, ..Default::default() })
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(by_colors.contains(&"Lightning Bolt".to_string()));
|
||||
|
||||
let by_identity = names(
|
||||
&fx.catalog
|
||||
.search_library(&LibraryFilter { colorless_identity: true, ..Default::default() })
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(
|
||||
!by_identity.contains(&"Lightning Bolt".to_string()),
|
||||
"its red identity still bars it from a colourless commander"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn price_range_treats_an_unknown_price_as_free() {
|
||||
let fx = owned_fixture();
|
||||
|
||||
@@ -314,6 +314,20 @@ pub struct LibraryFilter {
|
||||
/// Only cards that can head a Commander deck.
|
||||
#[serde(default)]
|
||||
pub can_be_commander: bool,
|
||||
/// Only cards with an empty colour identity — what a colourless commander
|
||||
/// can actually play. The colour bitmasks can't express this: 0 there means
|
||||
/// "no filter", not "no colours".
|
||||
#[serde(default)]
|
||||
pub colorless_identity: bool,
|
||||
/// Only cards printed in this set (matched against `printings.set_code`).
|
||||
/// Lets the release browser reuse the whole criteria pipeline instead of
|
||||
/// reimplementing it over a live Scryfall response.
|
||||
pub set_code: Option<String>,
|
||||
/// Only cards that are themselves colourless (`colors` empty). Distinct
|
||||
/// from `colorless_identity`: a card can be colourless yet carry a coloured
|
||||
/// identity through mana symbols in its rules text.
|
||||
#[serde(default)]
|
||||
pub colorless_colors: bool,
|
||||
/// Printed power/toughness range. Cards whose printed value isn't a plain
|
||||
/// number ("*", "1+*") are excluded from these, since they can't be
|
||||
/// compared — see `oracle_cards.power`.
|
||||
|
||||
@@ -110,6 +110,14 @@ export interface LibraryFilter {
|
||||
legal_in?: string | null;
|
||||
/** Only cards that can head a Commander deck. */
|
||||
can_be_commander?: boolean;
|
||||
/** Only cards printed in this set (matched against printings.set_code).
|
||||
* Lets the release browser reuse the whole criteria pipeline. */
|
||||
set_code?: string | null;
|
||||
/** Only cards with an empty color identity — a colorless commander's pool.
|
||||
* Separate from the color bitmasks, where 0 means "no filter". */
|
||||
colorless_identity?: boolean;
|
||||
/** Only cards that are themselves colorless. */
|
||||
colorless_colors?: boolean;
|
||||
/** Printed power/toughness. Cards printed with "*" or "1+*" are excluded,
|
||||
* since those can't be compared numerically. */
|
||||
power_min?: number | null;
|
||||
|
||||
@@ -1376,9 +1376,20 @@ export function CardTile({
|
||||
export function ColorToggles({
|
||||
value,
|
||||
onChange,
|
||||
colorless,
|
||||
onColorlessChange,
|
||||
colorlessTitle,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (mask: number) => void;
|
||||
/** Renders a "C" pip alongside the colours when provided.
|
||||
*
|
||||
* Colourlessness can't live in the bitmask: 0 there already means "don't
|
||||
* filter by colour", so there'd be no way to say "no colours at all" — which
|
||||
* is exactly what you need to find a colourless commander's card pool. */
|
||||
colorless?: boolean;
|
||||
onColorlessChange?: (v: boolean) => void;
|
||||
colorlessTitle?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className="color-toggles">
|
||||
@@ -1392,6 +1403,15 @@ export function ColorToggles({
|
||||
{c.code}
|
||||
</span>
|
||||
))}
|
||||
{onColorlessChange && (
|
||||
<span
|
||||
title={colorlessTitle ?? "Colorless"}
|
||||
className={`pip C ${colorless ? "on" : ""}`}
|
||||
onClick={() => onColorlessChange(!colorless)}
|
||||
>
|
||||
C
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ export function CardSearch({
|
||||
const [text, setText] = useState("");
|
||||
const [commander, setCommander] = useState(0);
|
||||
const [colorsInclude, setColorsInclude] = useState(0);
|
||||
// Colourlessness can't ride in the bitmask — 0 there means "no filter".
|
||||
const [colorlessIdentity, setColorlessIdentity] = useState(false);
|
||||
const [colorlessColors, setColorlessColors] = useState(false);
|
||||
const [cmcMin, setCmcMin] = useState("");
|
||||
const [cmcMax, setCmcMax] = useState("");
|
||||
const [types, setTypes] = useState<string[]>([]);
|
||||
@@ -89,6 +92,8 @@ export function CardSearch({
|
||||
text.trim() !== "" ||
|
||||
commander !== 0 ||
|
||||
colorsInclude !== 0 ||
|
||||
colorlessIdentity ||
|
||||
colorlessColors ||
|
||||
cmcMin !== "" ||
|
||||
cmcMax !== "" ||
|
||||
types.length > 0 ||
|
||||
@@ -107,6 +112,8 @@ export function CardSearch({
|
||||
text: text.trim() || null,
|
||||
commander_identity: commander || null,
|
||||
colors_include: colorsInclude || null,
|
||||
colorless_identity: colorlessIdentity,
|
||||
colorless_colors: colorlessColors,
|
||||
cmc_min: cmcMin === "" ? null : Number(cmcMin),
|
||||
cmc_max: cmcMax === "" ? null : Number(cmcMax),
|
||||
types,
|
||||
@@ -133,6 +140,8 @@ export function CardSearch({
|
||||
text,
|
||||
commander,
|
||||
colorsInclude,
|
||||
colorlessIdentity,
|
||||
colorlessColors,
|
||||
cmcMin,
|
||||
cmcMax,
|
||||
types,
|
||||
@@ -454,11 +463,23 @@ export function CardSearch({
|
||||
<div className={`filters-more ${filtersExpanded ? "expanded" : ""}`}>
|
||||
<div className="field">
|
||||
<label>Playable in commander (color identity ⊆)</label>
|
||||
<ColorToggles value={commander} onChange={setCommander} />
|
||||
<ColorToggles
|
||||
value={commander}
|
||||
onChange={setCommander}
|
||||
colorless={colorlessIdentity}
|
||||
onColorlessChange={setColorlessIdentity}
|
||||
colorlessTitle="Colorless commander — only cards with no color identity"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Includes colors</label>
|
||||
<ColorToggles value={colorsInclude} onChange={setColorsInclude} />
|
||||
<ColorToggles
|
||||
value={colorsInclude}
|
||||
onChange={setColorsInclude}
|
||||
colorless={colorlessColors}
|
||||
onColorlessChange={setColorlessColors}
|
||||
colorlessTitle="Colorless cards only"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Mana value</label>
|
||||
|
||||
@@ -49,6 +49,9 @@ export function LibraryBrowser({
|
||||
const [text, setText] = useState("");
|
||||
const [commander, setCommander] = useState(0);
|
||||
const [colorsInclude, setColorsInclude] = useState(0);
|
||||
// Colourlessness can't ride in the bitmask — 0 there means "no filter".
|
||||
const [colorlessIdentity, setColorlessIdentity] = useState(false);
|
||||
const [colorlessColors, setColorlessColors] = useState(false);
|
||||
const [cmcMin, setCmcMin] = useState("");
|
||||
const [cmcMax, setCmcMax] = useState("");
|
||||
const [types, setTypes] = useState<string[]>([]);
|
||||
@@ -119,6 +122,8 @@ export function LibraryBrowser({
|
||||
text: text.trim() || null,
|
||||
commander_identity: commander || null,
|
||||
colors_include: colorsInclude || null,
|
||||
colorless_identity: colorlessIdentity,
|
||||
colorless_colors: colorlessColors,
|
||||
cmc_min: cmcMin === "" ? null : Number(cmcMin),
|
||||
cmc_max: cmcMax === "" ? null : Number(cmcMax),
|
||||
types,
|
||||
@@ -137,6 +142,8 @@ export function LibraryBrowser({
|
||||
text,
|
||||
commander,
|
||||
colorsInclude,
|
||||
colorlessIdentity,
|
||||
colorlessColors,
|
||||
cmcMin,
|
||||
cmcMax,
|
||||
types,
|
||||
@@ -538,11 +545,23 @@ export function LibraryBrowser({
|
||||
<div className={`filters-more ${filtersExpanded ? "expanded" : ""}`}>
|
||||
<div className="field">
|
||||
<label>Playable in commander (color identity ⊆)</label>
|
||||
<ColorToggles value={commander} onChange={setCommander} />
|
||||
<ColorToggles
|
||||
value={commander}
|
||||
onChange={setCommander}
|
||||
colorless={colorlessIdentity}
|
||||
onColorlessChange={setColorlessIdentity}
|
||||
colorlessTitle="Colorless commander — only cards with no color identity"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Includes colors</label>
|
||||
<ColorToggles value={colorsInclude} onChange={setColorsInclude} />
|
||||
<ColorToggles
|
||||
value={colorsInclude}
|
||||
onChange={setColorsInclude}
|
||||
colorless={colorlessColors}
|
||||
onColorlessChange={setColorlessColors}
|
||||
colorlessTitle="Colorless cards only"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Mana value</label>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { api, News, SetCard, SetInfo } from "../api";
|
||||
import { CardTile, QtyStepper } from "../components";
|
||||
import { api, LibraryCard, LibraryFilter, News, SetCard, SetInfo } from "../api";
|
||||
import {
|
||||
CardCriteriaFields,
|
||||
CardTile,
|
||||
ColorToggles,
|
||||
QtyStepper,
|
||||
applyCardCriteria,
|
||||
useCardCriteria,
|
||||
} from "../components";
|
||||
|
||||
/** Sets whose release date is still ahead of us, newest-announced first. */
|
||||
function splitByRelease(sets: SetInfo[]): { upcoming: SetInfo[]; released: SetInfo[] } {
|
||||
@@ -59,8 +66,21 @@ export function NewsView() {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const [openSet, setOpenSet] = useState<SetInfo | null>(null);
|
||||
const [cards, setCards] = useState<SetCard[] | null>(null);
|
||||
const [cards, setCards] = useState<(LibraryCard | SetCard)[] | null>(null);
|
||||
// Where a set's cards come from. "local" reads downloaded card data, which is
|
||||
// what makes the criteria filters possible; "live" queries Scryfall directly
|
||||
// for a set that isn't in reference.db yet — an upcoming or newly-released
|
||||
// one — which can't be filtered, so the panel is hidden rather than shown
|
||||
// doing nothing. Null while we work out which.
|
||||
const [source, setSource] = useState<"local" | "live" | null>(null);
|
||||
const [cardsError, setCardsError] = useState<string | null>(null);
|
||||
// The same criteria the Library and Card Search panels use — the set browser
|
||||
// runs through search_reference rather than its own path, so every filter
|
||||
// works here identically instead of being reimplemented.
|
||||
const [setText, setSetText] = useState("");
|
||||
const [setColors, setSetColors] = useState(0);
|
||||
const [setColorless, setSetColorless] = useState(false);
|
||||
const [setCriteria, patchSetCriteria] = useCardCriteria("news-set-criteria");
|
||||
|
||||
const load = useCallback(async (refresh: boolean) => {
|
||||
setBusy(true);
|
||||
@@ -78,17 +98,83 @@ export function NewsView() {
|
||||
load(false);
|
||||
}, [load]);
|
||||
|
||||
async function chooseSet(set: SetInfo) {
|
||||
function openSetBrowser(set: SetInfo) {
|
||||
setOpenSet(set);
|
||||
setCards(null);
|
||||
setCardsError(null);
|
||||
try {
|
||||
setCards(await api.getSetCards(set.code));
|
||||
} catch (err) {
|
||||
setCardsError(String(err));
|
||||
}
|
||||
setSource(null);
|
||||
}
|
||||
|
||||
// Decide the source once per set, with an *unfiltered* probe: asking whether
|
||||
// the current criteria match anything would flip a perfectly indexed set to
|
||||
// "live" the moment a filter excluded everything.
|
||||
useEffect(() => {
|
||||
if (!openSet) return;
|
||||
let cancelled = false;
|
||||
setSource(null);
|
||||
api
|
||||
.searchReference({ set_code: openSet.code }, 1)
|
||||
.then((r) => !cancelled && setSource(r.length > 0 ? "local" : "live"))
|
||||
.catch(() => !cancelled && setSource("live"));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [openSet]);
|
||||
|
||||
// Not in the downloaded card data: fetch the set straight from Scryfall so an
|
||||
// upcoming set is still browsable, just without filters.
|
||||
useEffect(() => {
|
||||
if (!openSet || source !== "live") return;
|
||||
let cancelled = false;
|
||||
api
|
||||
.getSetCards(openSet.code)
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
setCards(r);
|
||||
setCardsError(null);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancelled) return;
|
||||
setCards([]);
|
||||
setCardsError(String(e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [openSet, source]);
|
||||
|
||||
// Refetch whenever the set or any criterion changes, debounced like the other
|
||||
// filter panels so typing doesn't fire a query per keystroke.
|
||||
useEffect(() => {
|
||||
if (!openSet || source !== "local") return;
|
||||
const filter: LibraryFilter = {
|
||||
set_code: openSet.code,
|
||||
text: setText.trim() || null,
|
||||
colors_include: setColors || null,
|
||||
colorless_colors: setColorless,
|
||||
...applyCardCriteria(setCriteria),
|
||||
};
|
||||
let cancelled = false;
|
||||
const t = setTimeout(() => {
|
||||
api
|
||||
.searchReference(filter, 500)
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
setCards(r);
|
||||
setCardsError(null);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancelled) return;
|
||||
setCards([]);
|
||||
setCardsError(String(e));
|
||||
});
|
||||
}, 120);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [openSet, source, setText, setColors, setColorless, setCriteria]);
|
||||
|
||||
async function adjust(oracleId: string, delta: number) {
|
||||
const qty = await api.addOwned(oracleId, delta);
|
||||
setCards((cs) =>
|
||||
@@ -113,17 +199,59 @@ export function NewsView() {
|
||||
{formatDate(openSet.released_at)} · {openSet.set_type.replace(/_/g, " ")} · set code{" "}
|
||||
{openSet.code.toUpperCase()}
|
||||
</div>
|
||||
|
||||
{/* Filters run against downloaded card data, so they only exist in
|
||||
"local" mode. Showing a panel that silently does nothing would be
|
||||
worse than not showing one. */}
|
||||
{source === "local" && (
|
||||
<div className="panel workspace">
|
||||
<div className="filters">
|
||||
<div className="field">
|
||||
<label>Name contains</label>
|
||||
<input
|
||||
value={setText}
|
||||
onChange={(e) => setSetText(e.target.value)}
|
||||
placeholder="e.g. bolt"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Includes colors</label>
|
||||
<ColorToggles
|
||||
value={setColors}
|
||||
onChange={setSetColors}
|
||||
colorless={setColorless}
|
||||
onColorlessChange={setSetColorless}
|
||||
colorlessTitle="Colorless cards only"
|
||||
/>
|
||||
</div>
|
||||
<CardCriteriaFields value={setCriteria} onChange={patchSetCriteria} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{source === "live" && (
|
||||
<p className="hint">
|
||||
This set isn't in your downloaded card data yet, so it's being read
|
||||
straight from Scryfall — filters aren't available here. Run{" "}
|
||||
<strong>Update card data</strong> once it's out to filter it like the
|
||||
rest.
|
||||
</p>
|
||||
)}
|
||||
{cardsError ? (
|
||||
<p className="hint">{cardsError}</p>
|
||||
) : !cards ? (
|
||||
) : !cards || source === null ? (
|
||||
<p className="hint">Loading cards…</p>
|
||||
) : cards.length === 0 ? (
|
||||
<p className="hint">No cards listed for this set yet.</p>
|
||||
<p className="hint">
|
||||
{source === "local"
|
||||
? "No cards match these filters."
|
||||
: "No cards listed for this set yet."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="tile-grid" style={{ marginTop: "var(--space-4)" }}>
|
||||
{cards.map((c) => (
|
||||
<CardTile
|
||||
key={c.oracle_id + c.collector_number}
|
||||
key={c.oracle_id}
|
||||
card={c}
|
||||
corner={
|
||||
<QtyStepper
|
||||
@@ -151,7 +279,7 @@ export function NewsView() {
|
||||
<li
|
||||
key={s.code}
|
||||
className={`set-row ${upcoming ? "upcoming" : ""}`}
|
||||
onClick={() => chooseSet(s)}
|
||||
onClick={() => openSetBrowser(s)}
|
||||
>
|
||||
{/* Fixed-width date gutter: every set name then starts at the same x,
|
||||
which is what turns this from a list into a timeline. */}
|
||||
|
||||
Reference in New Issue
Block a user