diff --git a/public/data.json b/public/data.json index d10aa79..9ac9f0f 100644 --- a/public/data.json +++ b/public/data.json @@ -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", diff --git a/public/vixi/overlays/Tirga Belly (Colored).png b/public/vixi/overlays/Tirga Belly (Colored).png new file mode 100644 index 0000000..d0ac7c1 Binary files /dev/null and b/public/vixi/overlays/Tirga Belly (Colored).png differ diff --git a/src/base.css b/src/base.css new file mode 100644 index 0000000..2a4a6de --- /dev/null +++ b/src/base.css @@ -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; + } +} diff --git a/src/main.js b/src/main.js index 2ed196b..5a2c666 100644 --- a/src/main.js +++ b/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 = ` -
- -
- Tank background - Tank overlay -
- -
-
- -
- -`; - -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(); diff --git a/src/style.css b/src/tank.css similarity index 86% rename from src/style.css rename to src/tank.css index b724e64..9fed74a 100644 --- a/src/style.css +++ b/src/tank.css @@ -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; diff --git a/src/tank.js b/src/tank.js new file mode 100644 index 0000000..b7b1eec --- /dev/null +++ b/src/tank.js @@ -0,0 +1,163 @@ +import "./tank.css"; + +export function renderTank(app) { + document.title = "cumtanks.snowsune.net"; + app.classList.remove("page-clones"); + app.innerHTML = ` +
+ +
+ Tank background + Tank overlay +
+ +
+
+ +
+ + `; + + 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})`; +} diff --git a/src/vixiclones.css b/src/vixiclones.css new file mode 100644 index 0000000..6676960 --- /dev/null +++ b/src/vixiclones.css @@ -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%; + } +} diff --git a/src/vixiclones.js b/src/vixiclones.js new file mode 100644 index 0000000..68be1ec --- /dev/null +++ b/src/vixiclones.js @@ -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 = ` +
+

Vixi Tracker

+

Is this too much?~

+
+
+ `; + + 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 ` +
+ ${row.map(renderClone).join("")} +
+ `; +} + +function renderClone(clone) { + const portraitContent = ` +
+ ${clone.name} + ${clone.banner ? `${clone.banner}` : ""} +
`; + + const clickable = clone.url + ? `${portraitContent}` + : portraitContent; + + return ` +
+ ${clickable} +
${clone.name}
+
+ `; +} + +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"; +}