Compare commits
18 Commits
663edf97cd
...
feat/vite
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a227a53ea | ||
|
|
bf44806119 | ||
|
|
432bf550ff | ||
|
|
a08692bef0 | ||
|
|
3d51fc5a7f | ||
|
|
575e111b20 | ||
|
|
524cba86e1 | ||
|
|
f134a45d33 | ||
| f495711b01 | |||
| 99e7ddc991 | |||
| 87f4640400 | |||
| 77c189e874 | |||
| d89a686789 | |||
| 7658e811ea | |||
| 69943d15ec | |||
| 2b9ca9ad31 | |||
| 46d0008b72 | |||
| 5370e12afb |
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.*~
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
162
build/generate-preview.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
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());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
index.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!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>
|
||||||
@@ -22,6 +22,13 @@ http {
|
|||||||
try_files $uri =404;
|
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
|
# Normal caching for other static files
|
||||||
location / {
|
location / {
|
||||||
expires 1h;
|
expires 1h;
|
||||||
|
|||||||
2135
package-lock.json
generated
Normal file
15
package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 430 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
BIN
public/Composite.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 256 KiB After Width: | Height: | Size: 256 KiB |
BIN
public/characters/Makanix_Internal.png
Normal file
|
After Width: | Height: | Size: 928 KiB |
BIN
public/characters/ceirus.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
public/characters/orchid.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
public/characters/orchid.png~
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
public/characters/rhettan.png
Normal file
|
After Width: | Height: | Size: 704 KiB |
BIN
public/characters/rich.png
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
public/characters/trip.png
Normal file
|
After Width: | Height: | Size: 505 KiB |
BIN
public/characters/zae.png
Normal file
|
After Width: | Height: | Size: 817 KiB |
BIN
public/characters/zae.png~
Normal file
|
After Width: | Height: | Size: 10 MiB |
111
public/data.json
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
{
|
||||||
|
"clones": [
|
||||||
|
{
|
||||||
|
"name": "Cyan",
|
||||||
|
"banner": "Saberchub~",
|
||||||
|
"image": "vixi/overlays/Tirga Belly (Colored).png",
|
||||||
|
"url": "https://booru.snowsune.net/posts/17840"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Royal",
|
||||||
|
"banner": "Gryf-Belly",
|
||||||
|
"image": "vixi/overlays/TyroGryfBelly.png",
|
||||||
|
"url": "https://booru.snowsune.net/posts/15147"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"liquids": [
|
||||||
|
{
|
||||||
|
"name": "Coyote Cum",
|
||||||
|
"volume": 5,
|
||||||
|
"color": "#f1f2f2",
|
||||||
|
"url": "https://www.f-list.net/c/alice%20prairie"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sergal Cum",
|
||||||
|
"volume": 31,
|
||||||
|
"color": "#6e166b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Trip",
|
||||||
|
"volume": 70,
|
||||||
|
"color": "#f1f2f2",
|
||||||
|
"image": "characters/trip.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"tankTopOffset": 360,
|
||||||
|
"tankBottomOffset": 101
|
||||||
|
},
|
||||||
|
"logs": [
|
||||||
|
{ "date": 1746126240, "text": "Added 1% Coyote Cum." },
|
||||||
|
{ "date": 1751396678, "text": "CV'd Zae~" },
|
||||||
|
{ "date": 1751407200, "text": "Zae added 41% Kan'vi Cum" },
|
||||||
|
{ "date": 1751547600, "text": "Added 1% Coyote Cum." },
|
||||||
|
{ "date": 1751569534, "text": "Dumped 39% into Ky-Li" },
|
||||||
|
{ "date": 1751574910, "text": "Luka pours in a little kittycum~" },
|
||||||
|
{ "date": 1751940097, "text": "Devoured Rich in a locker room~" },
|
||||||
|
{
|
||||||
|
"date": 1751942400,
|
||||||
|
"text": "Rich didn't hold up in the heat."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": 1751942580,
|
||||||
|
"text": "Impossible to keep this fox down! leaking him all over my toes <3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": 1751943180,
|
||||||
|
"text": "Giving up! Theres more fox between my toes and on my roommate's bed than in my balls~"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": 1752109080,
|
||||||
|
"text": "Creamed, gushed poor Zae oops~"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": 1752109080,
|
||||||
|
"text": "\"leaeked\" the last of that kittycum out~"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": 1752119470,
|
||||||
|
"text": "All it took was a little pawjob to empty out~"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": 1752559908,
|
||||||
|
"text": "Won a bet with a beast of a anjanath, turns out he fit after all~"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": 1752977551,
|
||||||
|
"text": "Finally time to churn a bit of this beast down"
|
||||||
|
},
|
||||||
|
{ "date": 1753675451, "text": "GLORP! Nothing left~" },
|
||||||
|
{
|
||||||
|
"date": 1754448094,
|
||||||
|
"text": "Kai didn't really have a good sense of what was going on~"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": 1754626707,
|
||||||
|
"text": "Happy vore day everyone! And in the spirit of the holiday.. im keeping these two Kan'Vi~"
|
||||||
|
},
|
||||||
|
{ "date": 1758328457, "text": "New day~ empty yote >:3" },
|
||||||
|
{
|
||||||
|
"date": 1758585605,
|
||||||
|
"text": "Glorp~ Couldn't wait for fresh filler, Rhettan will have to do~"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": 1760766880,
|
||||||
|
"text": "GLORP! Rich was right... its been too long, I deserve a foxy refill~"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": 1761178488,
|
||||||
|
"text": "Churned a good bit of Rich into equally rich yotecum~"
|
||||||
|
},
|
||||||
|
{ "date": 1762828311, "text": "Geez, guess rich is all but gone~" },
|
||||||
|
{
|
||||||
|
"date": 1763790729,
|
||||||
|
"text": "Glouurrp! Trip Sergal makes better coyote cum anyway~ I'll sleep so good tonight!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": 1764563640,
|
||||||
|
"text": "Trip! Did you have to make such a mess in there?!"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 185 KiB |
BIN
public/vixi/overlays/Tirga Belly (Colored).png
Normal file
|
After Width: | Height: | Size: 599 KiB |
BIN
public/vixi/overlays/TyroGryf.png
Normal file
|
After Width: | Height: | Size: 836 KiB |
BIN
public/vixi/overlays/TyroGryfBelly.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
public/vixi/portraits/azure.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
public/vixi/portraits/cyan.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
public/vixi/portraits/indigo.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
public/vixi/portraits/lapis.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
public/vixi/portraits/midnight.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
public/vixi/portraits/navy.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
public/vixi/portraits/royal.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
public/vixi/portraits/sapphire.png
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
public/vixi/portraits/sky.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
38
src/base.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/main.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
161
src/tank.css
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
178
src/tank.js
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
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>
|
||||||
|
• 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");
|
||||||
|
}
|
||||||
97
src/vixiclones.css
Normal file
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/vixiclones.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
@@ -1,2 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
rsync -avz --delete www/* joe@snowsunehost:/opt/cumtanks.snowsune.net/www --progress
|
set -euo pipefail
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
chmod 644 dist/data.json
|
||||||
|
|
||||||
|
rsync -avz --delete dist/ joe@snowsunehost:/opt/cumtanks.snowsune.net/www --progress
|
||||||
|
|||||||
28
vite.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
19
vixiclones.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!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>
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"liquids": [
|
|
||||||
{
|
|
||||||
"name": "Coyote Cum",
|
|
||||||
"volume": 7,
|
|
||||||
"color": "#f1f2f2",
|
|
||||||
"url": "https://www.f-list.net/c/alice%20prairie"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Bear Cum",
|
|
||||||
"volume": 9,
|
|
||||||
"color": "#523a33",
|
|
||||||
"url": "https://www.f-list.net/c/Glyren"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Kan'vi Cum (Glowing)",
|
|
||||||
"volume": 18,
|
|
||||||
"color": "#FF56BD",
|
|
||||||
"url": "https://www.f-list.net/c/Zae"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"settings": {
|
|
||||||
"tankTopOffset": 265,
|
|
||||||
"tankBottomOffset": 101
|
|
||||||
}
|
|
||||||
}
|
|
||||||
152
www/index.html
@@ -1,152 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Cumtanks.snowusne.net</title>
|
|
||||||
|
|
||||||
<!-- Open Graph / Discord -->
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
<meta property="og:title" content="cumtanks.snowusne.net" />
|
|
||||||
<meta property="og:image" content="/preview.png" />
|
|
||||||
|
|
||||||
<!-- Twitter -->
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta name="twitter:title" content="cumtanks.snowusne.net" />
|
|
||||||
<meta name="twitter:image" content="/preview.png" />
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="styles.css" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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();
|
|
||||||
} 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";
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update settings
|
|
||||||
function updateSettings() {
|
|
||||||
if (!tankData) return;
|
|
||||||
|
|
||||||
tankData.settings.tankTopOffset = parseInt(tankTopOffsetInput.value);
|
|
||||||
tankData.settings.tankBottomOffset = parseInt(tankBottomOffsetInput.value);
|
|
||||||
updateTank();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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>
|
|
||||||
•
|
|
||||||
Message <a href="mailto:vixi@snowsune.net">vixi@snowsune.net</a> for questions/comments/anything!
|
|
||||||
</span>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
BIN
www/preview.png~
|
Before Width: | Height: | Size: 137 KiB |
176
www/styles.css
@@ -1,176 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||