is this too much?~
This commit is contained in:
parent
575e111b20
commit
3d51fc5a7f
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"clones": [
|
||||
{
|
||||
"name": "Cyan",
|
||||
"banner": "Saberchub~",
|
||||
"image": "vixi/overlays/Tirga Belly (Colored).png",
|
||||
"url": "https://booru.snowsune.net/posts/17840?q=tirga"
|
||||
}
|
||||
],
|
||||
"liquids": [
|
||||
{
|
||||
"name": "Coyote Cum",
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 599 KiB |
|
|
@ -0,0 +1,38 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #23232b;
|
||||
font-family: "Inter", Arial, sans-serif;
|
||||
min-height: 100vh;
|
||||
color: #e6e6ff;
|
||||
}
|
||||
|
||||
#app {
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
footer {
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
color: #b3b3c3;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#app {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
}
|
||||
170
src/main.js
170
src/main.js
|
|
@ -1,166 +1,12 @@
|
|||
import "./style.css";
|
||||
import "./base.css";
|
||||
import { renderTank } from "./tank";
|
||||
import { renderClones } from "./vixiclones";
|
||||
|
||||
const app = document.querySelector("#app");
|
||||
const path = window.location.pathname.replace(/\/$/, "");
|
||||
|
||||
app.innerHTML = `
|
||||
<section class="layout">
|
||||
<aside class="logbook">
|
||||
<ul data-role="log"></ul>
|
||||
</aside>
|
||||
<div class="stage">
|
||||
<img class="background-back" src="/Alice_close_up_sheath_background.png" alt="Tank background" />
|
||||
<img class="background-fore" src="/Alice_close_up_sheath_shot.png" alt="Tank overlay" />
|
||||
<div class="artist-tag">
|
||||
<a href="https://x.com/Raven4Seth" target="_blank" rel="noreferrer noopener" aria-label="Art by SethRaven4"></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-link">
|
||||
<a href="https://snowsune.net" title="Back to snowsune.net">
|
||||
<img src="https://snowsune.net/static/stickers/bep_bounce.gif" alt="Home" width="20" height="20" />
|
||||
Back to snowsune.net!
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<footer>
|
||||
Art by <a href="https://x.com/Raven4Seth" target="_blank" rel="noreferrer noopener">SethRaven4</a>
|
||||
• Contact <a href="mailto:vixi@snowsune.net">vixi@snowsune.net</a>
|
||||
</footer>
|
||||
`;
|
||||
|
||||
const stage = app.querySelector(".stage");
|
||||
const logList = app.querySelector("[data-role='log']");
|
||||
let tankData;
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const response = await fetch("/data.json");
|
||||
tankData = await response.json();
|
||||
renderTank();
|
||||
} catch (error) {
|
||||
console.error("Failed to load tank data", error);
|
||||
}
|
||||
if (path === "/vixiclones") {
|
||||
renderClones(app);
|
||||
} else {
|
||||
renderTank(app);
|
||||
}
|
||||
|
||||
function renderTank() {
|
||||
if (!tankData) return;
|
||||
|
||||
clearLayers();
|
||||
drawLayers();
|
||||
drawLogbook();
|
||||
}
|
||||
|
||||
function clearLayers() {
|
||||
stage.querySelectorAll(".liquid-layer").forEach((node) => node.remove());
|
||||
}
|
||||
|
||||
function drawLayers() {
|
||||
const { settings, liquids } = tankData;
|
||||
const height = stage.offsetHeight;
|
||||
const tankHeight = Math.max(
|
||||
0,
|
||||
height - settings.tankTopOffset - settings.tankBottomOffset
|
||||
);
|
||||
|
||||
let offset = 0;
|
||||
let cumulative = 0;
|
||||
|
||||
liquids.forEach((liquid) => {
|
||||
const layer = document.createElement("div");
|
||||
layer.className = "liquid-layer";
|
||||
layer.style.bottom = `${settings.tankBottomOffset + offset}px`;
|
||||
|
||||
const layerHeight = (liquid.volume / 100) * tankHeight;
|
||||
layer.style.height = `${layerHeight}px`;
|
||||
offset += layerHeight;
|
||||
|
||||
if (liquid.image) {
|
||||
layer.style.backgroundImage = `url(${liquid.image})`;
|
||||
layer.style.backgroundSize = "800px";
|
||||
layer.style.backgroundRepeat = "no-repeat";
|
||||
layer.style.backgroundPosition = "right 50px top 0";
|
||||
layer.style.backgroundColor = liquid.color ?? "transparent";
|
||||
} else {
|
||||
layer.style.backgroundColor = liquid.color ?? "#fff";
|
||||
}
|
||||
|
||||
const label = liquid.url
|
||||
? document.createElement("a")
|
||||
: document.createElement("span");
|
||||
label.className = "liquid-label";
|
||||
label.textContent = `${liquid.name} (${liquid.volume}%)`;
|
||||
if (liquid.url) {
|
||||
label.href = liquid.url;
|
||||
label.target = "_blank";
|
||||
label.rel = "noreferrer noopener";
|
||||
}
|
||||
|
||||
cumulative += liquid.volume;
|
||||
if (cumulative < 20) {
|
||||
label.style.left = "18%";
|
||||
label.style.transform = "translate(60%, -50%)";
|
||||
}
|
||||
|
||||
layer.appendChild(label);
|
||||
stage.appendChild(layer);
|
||||
});
|
||||
}
|
||||
|
||||
function drawLogbook() {
|
||||
const now = Date.now();
|
||||
logList.innerHTML = "";
|
||||
|
||||
tankData.logs?.forEach((log) => {
|
||||
const li = document.createElement("li");
|
||||
const epochMs =
|
||||
typeof log.date === "number" ? log.date * 1000 : Date.parse(log.date);
|
||||
const ageHours = (now - epochMs) / (1000 * 60 * 60);
|
||||
|
||||
li.textContent = `${formatRelative(epochMs)} — ${log.text}`;
|
||||
li.style.color = mixColor(ageHours);
|
||||
if (ageHours < 8) li.classList.add("log-new");
|
||||
|
||||
logList.appendChild(li);
|
||||
});
|
||||
|
||||
const container = logList.parentElement;
|
||||
container.scrollTo({ top: container.scrollHeight });
|
||||
}
|
||||
|
||||
function formatRelative(epochMs) {
|
||||
const diffSeconds = Math.round((epochMs - Date.now()) / 1000);
|
||||
const divisions = [
|
||||
{ amount: 60, unit: "second" },
|
||||
{ amount: 60, unit: "minute" },
|
||||
{ amount: 24, unit: "hour" },
|
||||
{ amount: 7, unit: "day" },
|
||||
{ amount: 4.34524, unit: "week" },
|
||||
{ amount: 12, unit: "month" },
|
||||
{ amount: Number.POSITIVE_INFINITY, unit: "year" }
|
||||
];
|
||||
|
||||
let duration = diffSeconds;
|
||||
for (const division of divisions) {
|
||||
if (Math.abs(duration) < division.amount) {
|
||||
return new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(
|
||||
Math.round(duration),
|
||||
division.unit
|
||||
);
|
||||
}
|
||||
duration /= division.amount;
|
||||
}
|
||||
}
|
||||
|
||||
function mixColor(hours) {
|
||||
if (hours <= 0) return "#ffffff";
|
||||
if (hours >= 8) return "#888888";
|
||||
const t = hours / 8;
|
||||
const channel = Math.round(255 + (136 - 255) * t);
|
||||
return `rgb(${channel},${channel},${channel})`;
|
||||
}
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (!tankData) return;
|
||||
requestAnimationFrame(renderTank);
|
||||
});
|
||||
|
||||
loadData();
|
||||
|
|
|
|||
|
|
@ -1,26 +1,3 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #23232b;
|
||||
font-family: "Inter", Arial, sans-serif;
|
||||
min-height: 100vh;
|
||||
color: #e6e6ff;
|
||||
}
|
||||
|
||||
#app {
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
|
|
@ -159,16 +136,6 @@ body {
|
|||
filter: drop-shadow(0 0 3px #fff6);
|
||||
}
|
||||
|
||||
footer {
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
color: #b3b3c3;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import "./tank.css";
|
||||
|
||||
export function renderTank(app) {
|
||||
document.title = "cumtanks.snowsune.net";
|
||||
app.classList.remove("page-clones");
|
||||
app.innerHTML = `
|
||||
<section class="layout">
|
||||
<aside class="logbook">
|
||||
<ul data-role="log"></ul>
|
||||
</aside>
|
||||
<div class="stage">
|
||||
<img class="background-back" src="/Alice_close_up_sheath_background.png" alt="Tank background" />
|
||||
<img class="background-fore" src="/Alice_close_up_sheath_shot.png" alt="Tank overlay" />
|
||||
<div class="artist-tag">
|
||||
<a href="https://x.com/Raven4Seth" target="_blank" rel="noreferrer noopener" aria-label="Art by SethRaven4"></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-link">
|
||||
<a href="https://snowsune.net" title="Back to snowsune.net">
|
||||
<img src="https://snowsune.net/static/stickers/bep_bounce.gif" alt="Home" width="20" height="20" />
|
||||
Back to snowsune.net!
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<footer>
|
||||
Art by <a href="https://x.com/Raven4Seth" target="_blank" rel="noreferrer noopener">SethRaven4</a>
|
||||
• Contact <a href="mailto:vixi@snowsune.net">vixi@snowsune.net</a>
|
||||
</footer>
|
||||
`;
|
||||
|
||||
const stage = app.querySelector(".stage");
|
||||
const logList = app.querySelector("[data-role='log']");
|
||||
let tankData;
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const response = await fetch("/data.json");
|
||||
tankData = await response.json();
|
||||
update();
|
||||
} catch (error) {
|
||||
console.error("Failed to load tank data", error);
|
||||
}
|
||||
}
|
||||
|
||||
function update() {
|
||||
if (!tankData) return;
|
||||
drawLayers();
|
||||
drawLogbook();
|
||||
}
|
||||
|
||||
function drawLayers() {
|
||||
stage.querySelectorAll(".liquid-layer").forEach((node) => node.remove());
|
||||
|
||||
const { settings, liquids } = tankData;
|
||||
const height = stage.offsetHeight;
|
||||
const tankHeight = Math.max(
|
||||
0,
|
||||
height - settings.tankTopOffset - settings.tankBottomOffset
|
||||
);
|
||||
|
||||
let offset = 0;
|
||||
let cumulative = 0;
|
||||
|
||||
liquids.forEach((liquid) => {
|
||||
const layer = document.createElement("div");
|
||||
layer.className = "liquid-layer";
|
||||
layer.style.bottom = `${settings.tankBottomOffset + offset}px`;
|
||||
|
||||
const layerHeight = (liquid.volume / 100) * tankHeight;
|
||||
layer.style.height = `${layerHeight}px`;
|
||||
offset += layerHeight;
|
||||
|
||||
if (liquid.image) {
|
||||
layer.style.backgroundImage = `url(${liquid.image})`;
|
||||
layer.style.backgroundSize = "800px";
|
||||
layer.style.backgroundRepeat = "no-repeat";
|
||||
layer.style.backgroundPosition = "right 50px top 0";
|
||||
layer.style.backgroundColor = liquid.color ?? "transparent";
|
||||
} else {
|
||||
layer.style.backgroundColor = liquid.color ?? "#fff";
|
||||
}
|
||||
|
||||
const label = liquid.url
|
||||
? document.createElement("a")
|
||||
: document.createElement("span");
|
||||
label.className = "liquid-label";
|
||||
label.textContent = `${liquid.name} (${liquid.volume}%)`;
|
||||
if (liquid.url) {
|
||||
label.href = liquid.url;
|
||||
label.target = "_blank";
|
||||
label.rel = "noreferrer noopener";
|
||||
}
|
||||
|
||||
cumulative += liquid.volume;
|
||||
if (cumulative < 20) {
|
||||
label.style.left = "18%";
|
||||
label.style.transform = "translate(60%, -50%)";
|
||||
}
|
||||
|
||||
layer.appendChild(label);
|
||||
stage.appendChild(layer);
|
||||
});
|
||||
}
|
||||
|
||||
function drawLogbook() {
|
||||
const now = Date.now();
|
||||
logList.innerHTML = "";
|
||||
|
||||
tankData.logs?.forEach((log) => {
|
||||
const li = document.createElement("li");
|
||||
const epochMs =
|
||||
typeof log.date === "number" ? log.date * 1000 : Date.parse(log.date);
|
||||
const ageHours = (now - epochMs) / (1000 * 60 * 60);
|
||||
|
||||
li.textContent = `${formatRelative(epochMs)} — ${log.text}`;
|
||||
li.style.color = mixColor(ageHours);
|
||||
if (ageHours < 8) li.classList.add("log-new");
|
||||
|
||||
logList.appendChild(li);
|
||||
});
|
||||
|
||||
logList.parentElement.scrollTo({ top: logList.parentElement.scrollHeight });
|
||||
}
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (!tankData) return;
|
||||
requestAnimationFrame(drawLayers);
|
||||
});
|
||||
|
||||
loadData();
|
||||
}
|
||||
|
||||
function formatRelative(epochMs) {
|
||||
const diffSeconds = Math.round((epochMs - Date.now()) / 1000);
|
||||
const divisions = [
|
||||
{ amount: 60, unit: "second" },
|
||||
{ amount: 60, unit: "minute" },
|
||||
{ amount: 24, unit: "hour" },
|
||||
{ amount: 7, unit: "day" },
|
||||
{ amount: 4.34524, unit: "week" },
|
||||
{ amount: 12, unit: "month" },
|
||||
{ amount: Number.POSITIVE_INFINITY, unit: "year" }
|
||||
];
|
||||
|
||||
let duration = diffSeconds;
|
||||
for (const division of divisions) {
|
||||
if (Math.abs(duration) < division.amount) {
|
||||
return new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(
|
||||
Math.round(duration),
|
||||
division.unit
|
||||
);
|
||||
}
|
||||
duration /= division.amount;
|
||||
}
|
||||
}
|
||||
|
||||
function mixColor(hours) {
|
||||
if (hours <= 0) return "#ffffff";
|
||||
if (hours >= 8) return "#888888";
|
||||
const t = hours / 8;
|
||||
const channel = Math.round(255 + (136 - 255) * t);
|
||||
return `rgb(${channel},${channel},${channel})`;
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
.page-clones {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.clones-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.clones-header h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: clamp(2rem, 3vw, 3rem);
|
||||
}
|
||||
|
||||
.clones-header p {
|
||||
margin: 0;
|
||||
color: #b8b8cc;
|
||||
}
|
||||
|
||||
.clones-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.clones-row {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: repeat(var(--row-count), minmax(160px, 1fr));
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clones-row figure {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
background: rgba(12, 12, 18, 0.75);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.portrait-link {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.portrait {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: 8px;
|
||||
background: #101018;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.portrait img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.clone-banner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(-45deg);
|
||||
background: rgba(20, 20, 24, 0.9);
|
||||
color: #4df9ff;
|
||||
padding: 6px 32px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
width: 300%;
|
||||
}
|
||||
|
||||
.clones-row figcaption {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.clones-row {
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.portrait {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import "./vixiclones.css";
|
||||
|
||||
const DEFAULT_CLONES = [
|
||||
{ slug: "azure" },
|
||||
{ slug: "cyan" },
|
||||
{ slug: "indigo" },
|
||||
{ slug: "lapis" },
|
||||
{ slug: "midnight" },
|
||||
{ slug: "navy2" },
|
||||
{ slug: "royal" },
|
||||
{ slug: "sapphire" },
|
||||
{ slug: "sky" }
|
||||
];
|
||||
|
||||
export async function renderClones(app) {
|
||||
document.title = "Vixi Clones";
|
||||
app.classList.add("page-clones");
|
||||
app.innerHTML = `
|
||||
<header class="clones-header">
|
||||
<h1>Vixi Tracker</h1>
|
||||
<p>Is this too much?~</p>
|
||||
</header>
|
||||
<section class="clones-rows" data-role="rows"></section>
|
||||
`;
|
||||
|
||||
const rowsRoot = app.querySelector("[data-role='rows']");
|
||||
const clones = await loadClones();
|
||||
const firstRow = clones.slice(0, 4);
|
||||
const secondRow = clones.slice(4);
|
||||
|
||||
rowsRoot.innerHTML = [firstRow, secondRow]
|
||||
.filter((row) => row.length)
|
||||
.map(renderRow)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function loadClones() {
|
||||
let overrides = [];
|
||||
try {
|
||||
const response = await fetch("/data.json");
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data?.clones)) overrides = data.clones;
|
||||
} catch (error) {
|
||||
console.error("Failed to load clone data", error);
|
||||
}
|
||||
|
||||
return DEFAULT_CLONES.map((base) => {
|
||||
const override = overrides.find((item) => matchClone(item, base.slug));
|
||||
return normalizeClone({ ...base, ...override });
|
||||
});
|
||||
}
|
||||
|
||||
function matchClone(item, slug) {
|
||||
if (!item) return false;
|
||||
if (item.slug) return item.slug === slug;
|
||||
if (item.name) return toSlug(item.name) === slug;
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeClone(clone) {
|
||||
const slug = clone.slug ?? toSlug(clone.name ?? "");
|
||||
const name = clone.name ?? formatName(slug);
|
||||
return {
|
||||
slug,
|
||||
name,
|
||||
banner: clone.banner ?? null,
|
||||
url: clone.url ?? null,
|
||||
image: clone.image ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function renderRow(row) {
|
||||
return `
|
||||
<div class="clones-row" style="--row-count:${row.length}">
|
||||
${row.map(renderClone).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderClone(clone) {
|
||||
const portraitContent = `
|
||||
<div class="portrait">
|
||||
<img src="/${clone.image ?? `vixi/portraits/${clone.slug}.png`}" alt="${clone.name}" loading="lazy" />
|
||||
${clone.banner ? `<span class="clone-banner">${clone.banner}</span>` : ""}
|
||||
</div>`;
|
||||
|
||||
const clickable = clone.url
|
||||
? `<a class="portrait-link" href="${clone.url}" target="_blank" rel="noreferrer noopener">${portraitContent}</a>`
|
||||
: portraitContent;
|
||||
|
||||
return `
|
||||
<figure>
|
||||
${clickable}
|
||||
<figcaption>${clone.name}</figcaption>
|
||||
</figure>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatName(slug) {
|
||||
if (!slug) return "";
|
||||
return slug
|
||||
.replace(/[-_]/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function toSlug(value) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.replace(/-{2,}/g, "-")
|
||||
|| "clone";
|
||||
}
|
||||
Loading…
Reference in New Issue