Compare commits

..

No commits in common. "bf44806119095e155a48740bbfa38786b496bebf" and "f134a45d33be196d852cf85be77497e8db62bf62" have entirely different histories.

44 changed files with 502 additions and 3025 deletions

9
.gitignore vendored
View File

@ -1,9 +0,0 @@
node_modules/
dist/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.DS_Store
*.local

View File

@ -1,162 +0,0 @@
import puppeteer from "puppeteer";
import { createServer } from "http";
import { readFileSync, existsSync } from "fs";
import { join, dirname, extname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, "..");
const distDir = join(root, "dist");
const PORT = 5174;
async function startServer() {
const httpServer = createServer((req, res) => {
const decodedUrl = decodeURIComponent(req.url || "/");
let filePath = join(distDir, decodedUrl === "/" ? "index.html" : decodedUrl);
if (!existsSync(filePath) || extname(filePath) === "") {
filePath = join(distDir, "index.html");
}
if (existsSync(filePath)) {
const content = readFileSync(filePath);
const ext = extname(filePath);
const contentType =
ext === ".html" ? "text/html" :
ext === ".js" ? "application/javascript" :
ext === ".css" ? "text/css" :
ext === ".json" ? "application/json" :
ext === ".png" ? "image/png" :
ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" :
"application/octet-stream";
res.writeHead(200, { "Content-Type": contentType });
res.end(content);
} else {
res.writeHead(404);
res.end("Not found");
}
});
await new Promise((resolve, reject) => {
httpServer.listen(PORT, () => {
console.log(`Preview server running on http://localhost:${PORT}`);
resolve();
});
httpServer.on("error", reject);
});
return httpServer;
}
async function generatePreview(outputPath, uri, selector) {
const browser = await puppeteer.launch({
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox"]
});
try {
const page = await browser.newPage();
await page.setViewport({
width: 1400,
height: 2000,
deviceScaleFactor: 2
});
await page.goto(`http://localhost:${PORT}${uri}`, {
waitUntil: "networkidle0"
});
await new Promise((resolve) => setTimeout(resolve, 1000));
await page.waitForSelector(selector, { timeout: 10000 });
// Wait for images to load
await page.evaluate(() => {
return Promise.all(
Array.from(document.images).map((img) => {
if (img.complete) return Promise.resolve();
return new Promise((resolve) => {
img.onload = resolve;
img.onerror = resolve;
setTimeout(resolve, 3000);
});
})
);
});
// For tank page, ensure stage renders at full size and wait for layers
if (uri === "/") {
await page.evaluate(() => {
const layout = document.querySelector(".layout");
if (layout) {
layout.style.width = "1400px";
layout.style.minWidth = "1200px";
}
const stage = document.querySelector(".stage");
if (stage) {
stage.style.flex = "1 1 1200px";
stage.style.width = "1200px";
stage.style.maxWidth = "1200px";
}
});
await new Promise((resolve) => setTimeout(resolve, 500));
// Wait for liquid layers to appear and have height
await page.waitForFunction(
() => {
const layers = document.querySelectorAll(".liquid-layer");
return layers.length > 0 && Array.from(layers).some(l => l.offsetHeight > 0);
},
{ timeout: 10000 }
);
}
await new Promise((resolve) => setTimeout(resolve, 500));
const element = await page.$(selector);
if (element) {
await element.screenshot({
path: outputPath,
type: "png"
});
console.log(`✓ Generated preview: ${outputPath}`);
}
} finally {
await browser.close();
}
}
export async function generateClonesPreview() {
if (!existsSync(distDir) || !existsSync(join(distDir, "index.html"))) {
return;
}
let httpServer;
try {
httpServer = await startServer();
await generatePreview(
join(distDir, "vixiclones-preview.png"),
"/vixiclones",
".clones-rows"
);
await generatePreview(
join(distDir, "main-preview.png"),
"/",
".stage"
);
} catch (error) {
console.error("Failed to generate previews:", error);
throw error;
} finally {
if (httpServer) {
await new Promise((resolve) => {
httpServer.close(() => resolve());
});
}
}
}

View File

@ -1,22 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>cumtanks.snowsune.net</title>
<meta property="og:type" content="website" />
<meta property="og:title" content="cumtanks.snowsune.net" />
<meta property="og:image" content="https://cumtanks.snowsune.net/main-preview.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="cumtanks.snowsune.net" />
<meta name="twitter:image" content="https://cumtanks.snowsune.net/main-preview.png" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@ -22,13 +22,6 @@ http {
try_files $uri =404;
}
# Serve vixiclones.html for /vixiclones route
location = /vixiclones {
expires 1h;
add_header Cache-Control "public, no-transform";
try_files /vixiclones.html =404;
}
# Normal caching for other static files
location / {
expires 1h;

2135
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +0,0 @@
{
"name": "cumtanks",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^5.4.0",
"puppeteer": "^24.15.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

View File

@ -1,38 +0,0 @@
: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;
}
}

View File

@ -1,12 +0,0 @@
import "./base.css";
import { renderTank } from "./tank";
import { renderClones } from "./vixiclones";
const app = document.querySelector("#app");
const path = window.location.pathname.replace(/\/$/, "");
if (path === "/vixiclones") {
renderClones(app);
} else {
renderTank(app);
}

View File

@ -1,161 +0,0 @@
.layout {
display: flex;
gap: 24px;
width: 100%;
align-items: flex-start;
}
.logbook {
width: 240px;
max-width: 30vw;
padding: 16px 12px;
background: rgba(30, 30, 40, 0.95);
border: 3px solid #000;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 850px;
overflow-y: auto;
}
.logbook ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.stage {
position: relative;
flex: 1;
max-width: 1200px;
margin: 0 auto;
aspect-ratio: 1200 / 850;
display: flex;
justify-content: center;
align-items: stretch;
overflow: hidden;
}
.stage img {
position: absolute;
inset: 0;
margin: auto;
height: 100%;
pointer-events: none;
}
.stage img.background-back {
z-index: 1;
}
.stage img.background-fore {
z-index: 3;
}
.liquid-layer {
position: absolute;
left: 50%;
width: 80%;
transform: translateX(-50%);
background-repeat: no-repeat;
background-size: contain;
background-position: top center;
border-radius: 4px;
overflow: hidden;
z-index: 2;
}
.liquid-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 4px 8px;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-weight: 600;
border-radius: 4px;
white-space: nowrap;
text-align: center;
text-decoration: none;
}
.liquid-label:hover {
text-decoration: underline;
}
.artist-tag {
position: absolute;
top: 80px;
right: 100px;
width: 150px;
height: 100px;
transform: rotate(51deg);
z-index: 4;
}
.artist-tag a {
position: absolute;
inset: 0;
opacity: 0;
}
.home-link {
width: 220px;
display: flex;
justify-content: flex-start;
}
.home-link a {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.75);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
text-decoration: none;
font-weight: 600;
transition: all 0.2s ease;
}
.home-link a:hover {
background: rgba(255, 255, 255, 0.9);
color: #1f1f29;
}
.log-new {
font-weight: 700;
background: linear-gradient(90deg, #fff, #f6f6ff, #ececff, #fff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 3px #fff6);
}
@media (max-width: 900px) {
.layout {
flex-direction: column;
}
.logbook,
.home-link {
width: 100%;
max-width: none;
}
.home-link {
justify-content: center;
}
.stage {
width: 100%;
}
.artist-tag {
display: none;
}
}

View File

@ -1,178 +0,0 @@
import "./tank.css";
export function renderTank(app) {
document.title = "cumtanks.snowsune.net";
updateMetaTags();
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>
&bull; 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})`;
}
function updateMetaTags() {
const ogImage = document.querySelector('meta[property="og:image"]');
const twitterImage = document.querySelector('meta[name="twitter:image"]');
const ogTitle = document.querySelector('meta[property="og:title"]');
const twitterTitle = document.querySelector('meta[name="twitter:title"]');
const previewUrl = "/main-preview.png";
if (ogImage) ogImage.setAttribute("content", previewUrl);
if (twitterImage) twitterImage.setAttribute("content", previewUrl);
if (ogTitle) ogTitle.setAttribute("content", "cumtanks.snowsune.net");
if (twitterTitle) twitterTitle.setAttribute("content", "cumtanks.snowsune.net");
}

View File

@ -1,97 +0,0 @@
.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%;
}
}

View File

@ -1,128 +0,0 @@
import "./vixiclones.css";
const DEFAULT_CLONES = [
{ slug: "azure" },
{ slug: "cyan" },
{ slug: "indigo" },
{ slug: "lapis" },
{ slug: "midnight" },
{ slug: "navy" },
{ slug: "royal" },
{ slug: "sapphire" },
{ slug: "sky" }
];
export async function renderClones(app) {
document.title = "Vixi Clones";
updateMetaTags();
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 updateMetaTags() {
const ogImage = document.querySelector('meta[property="og:image"]');
const twitterImage = document.querySelector('meta[name="twitter:image"]');
const ogTitle = document.querySelector('meta[property="og:title"]');
const twitterTitle = document.querySelector('meta[name="twitter:title"]');
const previewUrl = "/vixiclones-preview.png";
if (ogImage) ogImage.setAttribute("content", previewUrl);
if (twitterImage) twitterImage.setAttribute("content", previewUrl);
if (ogTitle) ogTitle.setAttribute("content", "Vixi Clones");
if (twitterTitle) twitterTitle.setAttribute("content", "Vixi Clones");
}
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";
}

View File

@ -1,8 +1,4 @@
#!/bin/bash
set -euo pipefail
chmod 644 www/data.json
npm run build
chmod 644 dist/data.json
rsync -avz --delete dist/ joe@snowsunehost:/opt/cumtanks.snowsune.net/www --progress
rsync -avz --delete www/* joe@snowsunehost:/opt/cumtanks.snowsune.net/www --progress

View File

@ -1,28 +0,0 @@
import { defineConfig } from "vite";
import { generateClonesPreview } from "./build/generate-preview.js";
export default defineConfig(({ command }) => {
return {
server: {
host: true
},
build: {
rollupOptions: {
input: {
main: "./index.html",
vixiclones: "./vixiclones.html"
}
}
},
plugins: [
{
name: "generate-clones-preview",
async closeBundle() {
if (command === "build") {
await generateClonesPreview();
}
}
}
]
};
});

View File

@ -1,19 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vixi Clones</title>
<meta property="og:type" content="website" />
<meta property="og:title" content="Vixi Clones" />
<meta property="og:image" content="https://cumtanks.snowsune.net/vixiclones-preview.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Vixi Clones" />
<meta name="twitter:image" content="https://cumtanks.snowsune.net/vixiclones-preview.png" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

Before

Width:  |  Height:  |  Size: 430 KiB

After

Width:  |  Height:  |  Size: 430 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 256 KiB

View File

Before

Width:  |  Height:  |  Size: 928 KiB

After

Width:  |  Height:  |  Size: 928 KiB

View File

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

View File

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 114 KiB

View File

Before

Width:  |  Height:  |  Size: 704 KiB

After

Width:  |  Height:  |  Size: 704 KiB

View File

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 187 KiB

View File

Before

Width:  |  Height:  |  Size: 817 KiB

After

Width:  |  Height:  |  Size: 817 KiB

View File

Before

Width:  |  Height:  |  Size: 10 MiB

After

Width:  |  Height:  |  Size: 10 MiB

View File

@ -1,12 +1,4 @@
{
"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",

View File

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

225
www/index.html Normal file
View File

@ -0,0 +1,225 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cumtanks.snowsune.net</title>
<!-- Open Graph / Discord -->
<meta property="og:type" content="website" />
<meta property="og:title" content="cumtanks.snowsune.net" />
<meta property="og:image" content="/preview.png" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="cumtanks.snowsune.net" />
<meta name="twitter:image" content="/preview.png" />
<link rel="stylesheet" href="styles.css" />
<script src="https://cdn.jsdelivr.net/npm/timesago@1"></script>
</head>
<body>
<img src="preview.png" alt="Preview" style="display: none" aria-hidden="true" />
<div class="homepage-link">
<a href="https://snowsune.net" title="Back to snowsune.net">
<img src="https://snowsune.net/static/stickers/bep_bounce.gif" alt="Home"
style="width: 20px; height: 20px; vertical-align: middle; margin-right: 5px;">
Back to snowsune.net!
</a>
</div>
<div class="container">
<div class="tank-container">
<img src="Alice_close_up_sheath_background.png" alt="Tank Background Back"
class="background-image background-image-back" />
<img src="Alice_close_up_sheath_shot.png" alt="Tank Background Foreground"
class="background-image background-image-fore" />
<!-- Liquid layers will be inserted here by JavaScript! -->
<div class="artist-link">
<a href="https://x.com/Raven4Seth" target="_blank" rel="noopener noreferrer">Art by SethRaven4</a>
</div>
</div>
<div class="settings">
<h2>Settings</h2>
<label>
Tank Top Offset (px):
<input type="number" id="tankTopOffset" value="22" min="0" max="800" />
</label>
<label>
Tank Bottom Offset (px):
<input type="number" id="tankBottomOffset" value="40" min="0" max="800" />
</label>
</div>
<div class="logbook">
<ul id="logList"></ul>
</div>
</div>
<script>
let tankData = null;
const tankContainer = document.querySelector(".tank-container");
const tankTopInput = document.getElementById("tankTop");
const tankBottomInput = document.getElementById("tankBottom");
const tankTopOffsetInput = document.getElementById("tankTopOffset");
const tankBottomOffsetInput = document.getElementById("tankBottomOffset");
async function loadData() {
try {
const response = await fetch("data.json");
tankData = await response.json();
updateTank();
updateLogbook();
} catch (error) {
console.error("Error loading tank data:", error);
}
}
function updateTank() {
if (!tankData) return;
// Clear existing liquid layers
const existingLayers = document.querySelectorAll(".liquid-layer");
existingLayers.forEach((layer) => layer.remove());
const tankContainerHeight = tankContainer.offsetHeight;
const tankTopOffset = tankData.settings.tankTopOffset;
const tankBottomOffset = tankData.settings.tankBottomOffset;
const tankHeight =
tankContainerHeight - tankTopOffset - tankBottomOffset;
let currentHeight = 0;
let cumulativeVolume = 0;
tankData.liquids.forEach((liquid, i) => {
const layer = document.createElement("div");
layer.className = "liquid-layer";
// Set background: image if present, else color
if (liquid.image) {
layer.style.backgroundImage = `url(${liquid.image})`;
layer.style.backgroundSize = "800px";
layer.style.backgroundRepeat = "no-repeat";
layer.style.backgroundPosition = "right 50px top 0px";
layer.style.backgroundColor = liquid.color;
} else {
layer.style.backgroundImage = "";
layer.style.backgroundColor = liquid.color;
}
// Create the label
const label = document.createElement("div");
label.className = "liquid-label";
// If there's a URL, make it a clickable link
if (liquid.url) {
const link = document.createElement("a");
link.href = liquid.url;
link.textContent = `${liquid.name} (${liquid.volume}%)`;
link.className = "liquid-link";
label.appendChild(link);
} else {
label.textContent = `${liquid.name} (${liquid.volume}%)`;
}
cumulativeVolume += liquid.volume;
// Shift label left if cumulative volume is under 20%
if (cumulativeVolume < 20) {
label.style.left = "18%";
label.style.transform = "translate(-30%, -50%)";
} else {
label.style.left = "50%";
label.style.transform = "translate(-50%, -50%)";
}
// Calculate height based on volume percentage
const height = (liquid.volume / 100) * tankHeight;
layer.style.height = `${height}px`;
// Position from bottom, stacking up, starting at tankBottomOffset
layer.style.bottom = `${currentHeight + tankBottomOffset}px`;
currentHeight += height;
// Add the label to the layer
layer.appendChild(label);
tankContainer.appendChild(layer);
});
}
function updateLogbook() {
const logList = document.getElementById("logList");
logList.innerHTML = "";
if (tankData.logs) {
const now = Date.now() / 1000;
tankData.logs.forEach((log) => {
const li = document.createElement("li");
let epoch =
typeof log.date === "number"
? log.date
: Date.parse(log.date) / 1000;
const ageHours = (now - epoch) / 3600;
// Color interpolation: 0h = #fff, 8h = #888
let color;
if (ageHours <= 0) {
color = "#fff";
} else if (ageHours >= 8) {
color = "#888";
} else {
// Linear interpolation between #fff and #888
const t = ageHours / 8;
const r = Math.round(255 + (136 - 255) * t);
const g = Math.round(255 + (136 - 255) * t);
const b = Math.round(255 + (136 - 255) * t);
color = `rgb(${r},${g},${b})`;
}
li.style.color = color;
// Add shimmer class if less than 8 hours old
if (ageHours < 8) {
li.classList.add("log-shimmer");
}
li.textContent = `${window.timesago ? timesago(epoch * 1000) : log.date
} — ${log.text}`;
logList.appendChild(li);
});
// Auto-scroll to bottom
const logbookDiv = document.querySelector(".logbook");
if (logbookDiv) logbookDiv.scrollTop = logbookDiv.scrollHeight;
}
}
// Update settings
function updateSettings() {
if (!tankData) return;
tankData.settings.tankTopOffset = parseInt(tankTopOffsetInput.value);
tankData.settings.tankBottomOffset = parseInt(
tankBottomOffsetInput.value
);
updateTank();
updateLogbook();
}
// Event listeners
tankTopOffsetInput.addEventListener("change", updateSettings);
tankBottomOffsetInput.addEventListener("change", updateSettings);
// Initial load
loadData();
</script>
<!-- Footer -->
<footer class="tiny-footer">
<span>
Art by
<a href="https://x.com/Raven4Seth" target="_blank" rel="noopener noreferrer">SethRave4</a>
&bull; Message
<a href="mailto:vixi@snowsune.net">vixi@snowsune.net</a> for
questions/comments/anything!
</span>
</footer>
</body>
</html>

View File

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 185 KiB

BIN
www/preview.png~ Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

275
www/styles.css Normal file
View File

@ -0,0 +1,275 @@
body {
margin: 0;
padding: 0;
background: #23232b;
font-family: Arial, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
.container {
width: 100%;
max-width: 1200px;
padding: 20px;
}
h1 {
color: white;
text-align: center;
margin: 20px 0;
}
.tank-container {
position: relative;
width: 100%;
height: 850px;
background: #23232b;
overflow: hidden;
border-radius: 0;
margin: 0 auto;
width: 1200px;
left: 0;
top: 0;
padding: 0;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
}
.background-image {
position: absolute;
width: auto;
height: 100%;
max-width: 110%;
max-height: 110%;
object-fit: contain;
border: 4px solid #000;
box-sizing: border-box;
pointer-events: none;
left: 50%;
top: 0;
transform: translateX(-50%);
margin: 0;
padding: 0;
}
.background-image-back {
z-index: 1;
}
.background-image-fore {
z-index: 3;
}
.liquid-layer {
position: absolute;
width: 80%;
left: 50%;
transform: translateX(-50%);
transition: all 0.3s ease;
z-index: 2;
overflow: hidden;
background-position: top center;
background-repeat: no-repeat;
background-size: contain;
}
.liquid-layer > div {
width: 100%;
height: 100%;
position: relative;
}
.liquid-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
font-weight: bold;
z-index: 2;
padding: 5px 10px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
text-align: center;
white-space: nowrap;
font-size: 1.2em;
}
.liquid-link {
color: white;
text-decoration: none;
cursor: pointer;
transition: opacity 0.2s ease;
}
.liquid-link:hover {
opacity: 0.8;
text-decoration: underline;
}
/* Settings are hidden but still in the DOM for development */
.settings {
display: none;
}
.artist-credit {
text-align: center;
padding: 1rem;
color: #666;
font-size: 0.9rem;
font-style: italic;
margin-top: 2rem;
}
.artist-link {
position: absolute;
top: 80px;
right: 100px;
transform: rotate(51deg);
z-index: 100;
width: 150px;
height: 100px;
pointer-events: auto;
/* DEBUG: Temporary visible area for clickable link */
/* outline: 2px dashed #ff0;
background: rgba(255, 255, 0, 0.2); */
}
.artist-link a {
display: block;
width: 100%;
height: 100%;
text-indent: -9999px;
background: transparent;
border: none;
outline: none;
cursor: pointer;
position: absolute;
top: 0;
left: 0;
opacity: 0;
}
.artist-link a:hover {
background: #fff;
color: #23232b;
}
/* Footer */
.tiny-footer {
font-size: 0.75rem;
color: #aaa;
text-align: center;
margin-top: -10px; /* shifts footer upward */
padding: 5px 10px;
}
.tiny-footer a {
color: #aaa;
text-decoration: underline;
}
.tiny-footer a:hover {
color: #fff;
}
.logbook {
position: absolute;
left: 24px;
top: 20px;
width: 260px;
height: 850px;
background: rgba(30, 30, 40, 0.95);
border: 4px solid #000;
box-sizing: border-box;
font-size: 0.85em;
color: #eee;
overflow-y: auto;
z-index: 10;
padding: 16px 10px 16px 16px;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.logbook h2 {
font-size: 1em;
margin: 0 0 8px 0;
color: #fff;
text-align: left;
}
#logList {
list-style: none;
padding: 0;
margin: 0;
font-size: 0.85em;
display: flex;
flex-direction: column;
gap: 4px;
}
#logList li {
color: #ccc;
font-size: 0.95em;
word-break: break-word;
}
.log-shimmer {
background: linear-gradient(
90deg,
#fff 0%,
#f8f8ff 20%,
#ffe 40%,
#fff 60%,
#e0e0e0 80%,
#fff 100%
);
background-size: 400% 100%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 6s linear infinite;
font-weight: bold;
filter: drop-shadow(0 0 2px #fff8);
}
@keyframes shimmer {
0% {
background-position: 400% 0;
}
100% {
background-position: 0 0;
}
}
.homepage-link {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
}
.homepage-link a {
display: inline-block;
padding: 8px 16px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
text-decoration: none;
border-radius: 6px;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
border: 2px solid rgba(255, 255, 255, 0.2);
}
.homepage-link a:hover {
background: rgba(255, 255, 255, 0.9);
color: #23232b;
border-color: rgba(255, 255, 255, 0.8);
transform: translateY(-1px);
}