wip: planning-slot test
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,168 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Games Planning Preview</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #1d232a; --surface: #2a323c; --text: #a6adbb; --muted: #6b7280; --line: #ffffff10;
|
||||
--hour: 104px; --col: 150px; --gap: 10px; --break: 34px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }
|
||||
.page { max-width: 1280px; margin: 0 auto; padding: 48px 16px; }
|
||||
h1 { text-align: center; font-size: 3rem; margin: 0 0 16px; color: #e5e7eb; }
|
||||
.sub { text-align: center; font-size: 1.25rem; margin: 0 0 32px; }
|
||||
.toolbar { display: flex; justify-content: flex-end; gap: 8px; align-items: center; margin-bottom: 24px; }
|
||||
.toggle { width: 44px; height: 24px; border-radius: 999px; border: 2px solid var(--muted); position: relative; }
|
||||
.toggle::after { content: ""; position: absolute; left: 2px; top: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--muted); }
|
||||
.scroller { overflow-x: auto; }
|
||||
.planning { display: grid; gap: var(--gap); width: max-content; margin: 0 auto; }
|
||||
.axis, .column { position: relative; }
|
||||
.axis { width: 56px; }
|
||||
.tick { position: absolute; right: 8px; font-size: 12px; color: var(--muted); transform: translateY(-50%); }
|
||||
.day { position: absolute; left: 0; right: calc(-1 * (var(--col) + var(--gap)) * 6); border-top: 1px solid #ffffff30;
|
||||
font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: #e5e7eb; padding-top: 2px; }
|
||||
.hourline { position: absolute; left: 56px; right: 0; border-top: 1px dashed var(--line); }
|
||||
.break { position: absolute; left: 0; right: 0; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; color: var(--muted); background: repeating-linear-gradient(45deg, #ffffff06 0 6px, transparent 6px 12px); border-radius: 4px; }
|
||||
.column { width: var(--col); }
|
||||
.card { position: absolute; left: 0; right: 0; border-radius: 6px; overflow: hidden; background: var(--surface);
|
||||
box-shadow: 0 20px 25px -5px #0006; transition: all .15s; cursor: pointer; }
|
||||
.card:hover { transform: translateY(-8px) scale(1.05); box-shadow: 0 25px 50px -12px #000a; z-index: 5; }
|
||||
.card img.cover { width: 100%; height: 100%; object-fit: cover; object-position: top; display: block; }
|
||||
.card .time { position: absolute; top: 6px; left: 6px; font-size: 11px; padding: 1px 6px; border-radius: 4px;
|
||||
background: #000a; color: #fff; opacity: 0; transition: opacity .15s; }
|
||||
.card:hover .time { opacity: 1; }
|
||||
.fallback { height: 100%; display: flex; flex-direction: column; justify-content: center; gap: 6px; padding: 10px;
|
||||
border-left: 4px solid var(--c); background: linear-gradient(160deg, color-mix(in srgb, var(--c) 35%, var(--surface)), var(--surface)); }
|
||||
.fallback .title { color: #f3f4f6; font-weight: 600; line-height: 1.2; }
|
||||
.fallback .when { font-size: 12px; color: #d1d5db; }
|
||||
/* multi-anims: one long card, its games stacked & sticky while you scroll */
|
||||
.multi { background: linear-gradient(180deg, color-mix(in srgb, var(--c) 30%, var(--surface)), var(--surface)); padding: 8px; overflow: visible; }
|
||||
.multi:hover { transform: none; }
|
||||
.multi .head { color: #f3f4f6; font-weight: 700; text-transform: uppercase; font-size: 13px; margin-bottom: 8px; letter-spacing: .05em; }
|
||||
.multi .sub-card { position: sticky; top: 8px; aspect-ratio: 2/3; border-radius: 4px; overflow: hidden; margin-bottom: 12px;
|
||||
box-shadow: 0 10px 15px -3px #0008; background: var(--surface); }
|
||||
.multi .sub-card .fallback { border-left: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Get the games</h1>
|
||||
<p class="sub">Don't own them yet? Grab them on Steam</p>
|
||||
<div class="toolbar"><span>Show prices</span><span class="toggle"></span></div>
|
||||
<div class="scroller"><div class="planning" id="planning"></div></div>
|
||||
</div>
|
||||
|
||||
<script src="data.js"></script>
|
||||
<script>
|
||||
// Preview only: in the app this comes from Game.name matching + library_image
|
||||
const STEAM = {
|
||||
"factorio": 427520, "fall guys": 1097150, "lethal company": 1966720, "rocket league": 252950,
|
||||
"r.e.p.o.": 3241660, "tricky towers": 437920, "rounds": 1557740, "cosmoteer": 799600,
|
||||
"golf it!": 571740, "towerfall: ascension": 251470, "puyo puyo — tetris": 546050,
|
||||
"peak": 3527290, "straftat": 2386720, "trackmania": 2225070, "slay the spire 2": 2868840,
|
||||
};
|
||||
const coverOf = (name) => {
|
||||
const id = STEAM[name.toLowerCase()];
|
||||
return id && `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${id}/library_600x900.jpg`;
|
||||
};
|
||||
|
||||
const HOUR = 3600e3;
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
const HOUR_PX = parseFloat(css.getPropertyValue("--hour"));
|
||||
const BREAK_PX = parseFloat(css.getPropertyValue("--break"));
|
||||
const TZ = "Europe/Zurich";
|
||||
const fmtTime = new Intl.DateTimeFormat("en-GB", { timeZone: TZ, hour: "2-digit", minute: "2-digit" });
|
||||
const fmtDay = new Intl.DateTimeFormat("en-GB", { timeZone: TZ, weekday: "long", day: "numeric", month: "short" });
|
||||
|
||||
const slots = PLANNING.list.map((s) => ({ ...s, b: Date.parse(s.begin), e: Date.parse(s.end) }));
|
||||
const singles = slots.filter((s) => !s.is_multi_anims);
|
||||
|
||||
// Hours with something going on; long empty runs (nights) get squashed into a thin break
|
||||
const start = Math.floor(Math.min(...slots.map((s) => s.b)) / HOUR) * HOUR;
|
||||
const end = Math.ceil(Math.max(...slots.map((s) => s.e)) / HOUR) * HOUR;
|
||||
const busy = (t) => singles.some((s) => s.b < t + HOUR && s.e > t);
|
||||
const segments = []; // {from, to, y, h, kind}
|
||||
let y = 0;
|
||||
for (let t = start; t < end;) {
|
||||
if (busy(t)) { segments.push({ from: t, to: t + HOUR, y, h: HOUR_PX, kind: "hour" }); y += HOUR_PX; t += HOUR; continue; }
|
||||
let u = t; while (u < end && !busy(u)) u += HOUR;
|
||||
const short = (u - t) / HOUR < 3;
|
||||
const h = short ? HOUR_PX * (u - t) / HOUR : BREAK_PX;
|
||||
segments.push({ from: t, to: u, y, h, kind: short ? "idle" : "break" }); y += h; t = u;
|
||||
}
|
||||
const total = y;
|
||||
const yOf = (t) => {
|
||||
const s = segments.find((s) => t >= s.from && t <= s.to) || segments.at(-1);
|
||||
return s.y + s.h * Math.min(1, (t - s.from) / (s.to - s.from));
|
||||
};
|
||||
|
||||
const cols = [...new Set(slots.map((s) => s.colonneS))].sort((a, b) => a - b); // drop unused columns
|
||||
const root = document.getElementById("planning");
|
||||
root.style.gridTemplateColumns = `56px repeat(${cols.length}, var(--col))`;
|
||||
|
||||
// Time axis + day headers + hour lines
|
||||
const axis = document.createElement("div");
|
||||
axis.className = "axis"; axis.style.height = total + "px";
|
||||
root.appendChild(axis);
|
||||
let lastDay = null;
|
||||
for (const s of segments) {
|
||||
if (s.kind === "break") {
|
||||
const el = document.createElement("div");
|
||||
el.className = "break"; el.style.top = s.y + "px"; el.style.height = s.h + "px";
|
||||
el.style.right = `calc(-1 * (var(--col) + var(--gap)) * ${cols.length})`;
|
||||
el.textContent = `${fmtTime.format(s.from)} – ${fmtTime.format(s.to)}`;
|
||||
axis.appendChild(el);
|
||||
continue;
|
||||
}
|
||||
for (let t = s.from; t < s.to; t += HOUR) {
|
||||
const ty = yOf(t), day = fmtDay.format(t);
|
||||
if (day !== lastDay) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "day"; d.style.top = ty + "px"; d.textContent = day;
|
||||
d.style.right = `calc(-1 * (var(--col) + var(--gap)) * ${cols.length})`;
|
||||
axis.appendChild(d); lastDay = day;
|
||||
} else {
|
||||
const tick = document.createElement("div");
|
||||
tick.className = "tick"; tick.style.top = ty + "px"; tick.textContent = fmtTime.format(t);
|
||||
axis.appendChild(tick);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = (title, when, color) =>
|
||||
`<div class="fallback" style="--c:${color}"><div class="title">${title}</div>${when ? `<div class="when">${when}</div>` : ""}</div>`;
|
||||
const coverOrFallback = (name, when, color) => {
|
||||
const src = coverOf(name);
|
||||
if (!src) return fallback(name, when, color);
|
||||
// Not every game has a library capsule, swap to the colored card if it 404s
|
||||
return `<img class="cover" src="${src}" alt="${name}" title="${name}"
|
||||
onerror="this.outerHTML = decodeURIComponent('${encodeURIComponent(fallback(name, when, color))}')">`;
|
||||
};
|
||||
|
||||
for (const c of cols) {
|
||||
const col = document.createElement("div");
|
||||
col.className = "column"; col.style.height = total + "px";
|
||||
for (const s of slots.filter((s) => s.colonneS === c)) {
|
||||
const top = yOf(s.b) + 3, height = yOf(s.e) - yOf(s.b) - 6;
|
||||
const when = `${fmtTime.format(s.b)} – ${fmtTime.format(s.e)}`;
|
||||
const card = document.createElement("div");
|
||||
card.style.top = top + "px"; card.style.height = height + "px";
|
||||
if (s.is_multi_anims) {
|
||||
card.className = "card multi"; card.style.setProperty("--c", s.color_code);
|
||||
card.innerHTML = `<div class="head">${s.title}</div>` +
|
||||
s.aandts.map((a) => `<div class="sub-card">${coverOrFallback(a.name, "", s.color_code)}</div>`).join("");
|
||||
} else {
|
||||
card.className = "card";
|
||||
card.innerHTML = coverOrFallback(s.title, when, s.color_code) + `<span class="time">${when}</span>`;
|
||||
}
|
||||
col.appendChild(card);
|
||||
}
|
||||
root.appendChild(col);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user