Compare commits

..

10 commits

6 changed files with 418 additions and 18 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules/

View file

@ -73,6 +73,7 @@
max-width: 1280px;
margin: 1em auto;
padding: 0 1em;
padding-bottom: 100px;
}
section {
@ -116,7 +117,7 @@
display: grid;
/* Responsive grid: min 300px on mobile, larger on desktop */
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1px; /* Small gap for aesthetic */
gap: 0px; /* Small gap for aesthetic */
justify-content: center;
background: #000;
}
@ -203,7 +204,7 @@
@media (hover: none), (max-width: 768px) {
.caption {
opacity: 1;
background: linear-gradient(to top, rgba(0,0,0,0.95) 10%, rgba(0,0,0,0.4) 60%, transparent 100%);
background: linear-gradient(to top, rgba(0,0,0,0.95) 10%, rgba(0,0,0,0.75) 75%, transparent 100%);
padding-bottom: 25px;
}
@ -215,28 +216,54 @@
/* --- FOOTER & SOCIALS --- */
footer {
text-align: center;
padding: 1em 1em 2em; /* Чуть больше отступа снизу */
background: #111;
padding: 0.5em 1em;
background-color: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
color: #555;
font-size: 0.9em;
margin-top: 2em;
position: fixed;
bottom: 0;
left: 0;
width: 100%;
z-index: 1000;
display: flex;
flex-direction: column;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 1.5em; /* Расстояние между копирайтом и иконками */
gap: 40px;
border-top: 0px solid rgba(0,0,0,0);
}
@media (max-width: 768px) {
footer {
flex-direction: column;
gap: 0.5em;
padding: 0.8em;
}
}
.social-links {
display: flex;
align-items: center;
justify-content: center;
gap: 20px; /* Расстояние между иконками */
gap: 15px; /* Расстояние между иконками */
margin: 0;
margin-top: 0.5em;
}
footer p {
margin: 0;
line-height: 1;
}
.social-icon {
display: block;
width: 24px;
height: 24px;
width: 22px;
height: 22px;
fill: #555; /* Цвет иконок в покое (как текст) */
transition: fill 0.3s ease, transform 0.3s ease;
}
@ -246,18 +273,32 @@
transform: translateY(-3px); /* Легкое всплытие */
}
.footer-copyright {
text-align: center;
color: #555;
font-size: 0.8rem;
margin-top: 50px; /* Минимальный отступ от плитки/индикатора */
margin-bottom: 0;
padding-bottom: 10px; /* Зазор до края страницы, чтобы не прилипало к футеру */
}
main {
/* Уменьшаем этот отступ, так как копирайт теперь "съедает" часть места */
padding-bottom: 30px;
}
#scrollToTopBtn {
position: fixed;
bottom: 20px;
right: 20px;
bottom: 0px;
right: 40px;
z-index: 9999;
background-color: rgba(255,255,255,0.1);
background-color: rgba(55,55,55,0.5);
color: #fff;
font-size: 1.2rem;
width: 50px;
height: 50px;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
border-radius: 0%;
cursor: pointer;
opacity: 0;
transform: translateY(20px);
@ -284,6 +325,7 @@
font-size: 0.8rem;
opacity: 0;
transition: opacity 0.3s;
margin-bottom: 20px;
}
#loading-indicator.active {
opacity: 1;
@ -320,8 +362,13 @@
<section>
<div class="video-grid" id="videoGrid"></div>
<div id="loading-indicator">loading works...</div>
<p class="footer-copyright">
&copy; <span id="year"></span> Andrey Flat
</p>
</section>
</main>
<footer>
<div class="social-links">
@ -351,7 +398,6 @@
</div>
<p>&copy; <span id="year"></span> Andrey Flat</p>
</footer>
<button id="scrollToTopBtn" title="Up">&#8679;</button>
@ -676,7 +722,7 @@ window.addEventListener("load", () => {
if (sessionStorage.getItem("af_logged")) return;
sessionStorage.setItem("af_logged", "1");
// WARNING: Exposing secret keys in frontend code is risky.
// WARNING: Exposing secret keys in frontend code is risky. commet
// Anyone can see this key. Use environment variables in Netlify if possible.
const secret = "flat_secret_123";

BIN
models/gs/nebula_rnd1.ply Normal file

Binary file not shown.

BIN
models/gs/nebula_rnd1.splat Normal file

Binary file not shown.

View file

@ -0,0 +1,172 @@
// netlify/functions/privateViewLogger.js
// Требуется package.json с {"type":"module"}
import https from "https";
import { createClient } from "@supabase/supabase-js";
// node-fetch (ленивая подгрузка)
const fetch = (...args) => import("node-fetch").then(({ default: f }) => f(...args));
// --- ENV ---
const BOT_TOKEN = process.env.BOT_TOKEN; // опционально
const CHAT_ID = process.env.CHAT_ID; // опционально
const SECRET_KEY = process.env.SECRET_KEY; // обязателен
const SUPABASE_URL = process.env.SUPABASE_URL; // обязателен
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_KEY; // обязателен
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, { auth: { persistSession: false } });
// ---------- helpers ----------
function extractIp(headers = {}) {
return (
headers["x-nf-client-connection-ip"] ||
(headers["x-forwarded-for"] || "").split(",")[0]?.trim() ||
headers["client-ip"] ||
headers["x-real-ip"] ||
""
);
}
function isPrivateIp(ip) {
if (!ip) return true;
const cleaned = ip.replace(/^\[?([^\]]+)\]?(:\d+)?$/, "$1");
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(cleaned)) {
const [a,b] = cleaned.split(".").map(Number);
if (a === 10 || a === 127) return true;
if (a === 100 && b >= 64 && b <= 127) return true;
if (a === 192 && b === 168) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
return false;
}
const lc = cleaned.toLowerCase();
if (lc === "::1" || lc.startsWith("fc") || lc.startsWith("fd")) return true;
return false;
}
function parseUA(ua = "") {
let os = "Unknown OS", browser = "Unknown Browser";
if (/\bWindows\b/i.test(ua)) os = "Windows";
else if (/\bMac OS X|Macintosh\b/i.test(ua)) os = "macOS";
else if (/\bAndroid\b/i.test(ua)) os = "Android";
else if (/\biPhone|iPad\b/i.test(ua)) os = "iOS";
else if (/\bX11|Linux\b/i.test(ua)) os = "Linux";
if (/Firefox\/\d+/i.test(ua)) browser = "Firefox";
else if (/\bEdg\/\d+/i.test(ua)) browser = "Edge";
else if (/Chrome\/\d+/i.test(ua) && !/Chromium/i.test(ua)) browser = "Chrome";
else if (/Safari\/\d+/i.test(ua) && !/Chrome/i.test(ua)) browser = "Safari";
else if (/Chromium\/\d+/i.test(ua)) browser = "Chromium";
return { os, browser };
}
async function sendTelegram(text) {
if (!BOT_TOKEN || !CHAT_ID) return { ok: true };
const data = JSON.stringify({ chat_id: CHAT_ID, text, parse_mode: "Markdown" });
return new Promise((resolve) => {
const req = https.request(
{
hostname: "api.telegram.org",
path: `/bot${BOT_TOKEN}/sendMessage`,
method: "POST",
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) },
},
(res) => {
res.on("data", () => {});
res.on("end", () => resolve({ ok: res.statusCode === 200, status: res.statusCode }));
}
);
req.on("error", () => resolve({ ok: false }));
req.write(data);
req.end();
});
}
// ---------- handler ----------
export async function handler(event) {
// доступ: ключ ИЛИ тот же домен; блочим TelegramBot UA
const qs = event.queryStringParameters || {};
const { key } = qs;
const headers = event.headers || {};
const ua = headers["user-agent"] || "";
const referer = (headers["referer"] || "").toLowerCase();
const sameOrigin = referer.includes("andreyflat.space");
const keyOk = key && SECRET_KEY && key === SECRET_KEY;
if (ua.includes("TelegramBot") || (!keyOk && !sameOrigin)) {
return {
statusCode: 403,
headers: { "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0" },
body: JSON.stringify({ error: "Forbidden" }),
};
}
// --- извлекаем данные для статистики ---
const ipRaw = extractIp(headers) || "unknown"; // используем ТОЛЬКО для гео, НЕ пишем в БД
const { os, browser } = parseUA(ua);
// 1) сначала пробуем заголовки Netlify
let country = headers["x-country"] || null; // ISO-2, например "ID"
let city = null;
try {
if (headers["x-nf-geo"]) {
const g = JSON.parse(headers["x-nf-geo"]);
if (g?.country?.name || g?.country?.code) country = g.country.name || g.country.code || country;
if (g?.city) city = g.city;
}
} catch {}
// 2) если города нет и IP публичный — мягко добираем через ipapi.co (таймаут 3с)
if (!city && ipRaw !== "unknown" && !isPrivateIp(ipRaw)) {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 3000);
const r = await fetch(`https://ipapi.co/${ipRaw}/json/`, { signal: ctrl.signal });
clearTimeout(t);
if (r.ok) {
const j = await r.json();
city = j.city || city;
country = j.country_name || j.country || country;
}
} catch {
// игнор
}
}
// --- запись в БД (без IP и без path) ---
const insertPayload = {
referrer: headers["referer"] || null,
country: country || null,
city: city || null,
ua_os: os,
ua_browser: browser,
};
const { error } = await supabase.from("pageviews").insert([insertPayload]);
// --- уведомление в Телеграм (опционально) ---
const loc = country ? (city ? `${country}, ${city}` : country) : "Unknown";
const text =
`🔔 *New View*
📍 ${loc}
📱 ${os} / ${browser}`;
if (!error) await sendTelegram(text);
if (error) {
return {
statusCode: 500,
headers: { "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0" },
body: JSON.stringify({ error: error.message }),
};
}
return {
statusCode: 200,
headers: { "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0" },
body: JSON.stringify({ ok: true }),
};
}

181
splat_viewer.html Normal file
View file

@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Splat Viewer — Nebula</title>
<link rel="icon" href="favicon.ico" type="image/x-icon" />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600&display=swap" rel="stylesheet">
<style>
body {
margin: 0;
padding: 0;
background-color: #000;
color: #eee;
font-family: 'Space Grotesk', sans-serif;
overflow: hidden;
display: flex;
flex-direction: column;
height: 100vh;
}
header {
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
background-color: rgba(10, 10, 10, 0.8);
padding: 10px 20px;
text-align: center;
position: absolute;
top: 0;
width: 100%;
z-index: 10;
box-sizing: border-box;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
header h1 {
margin: 0;
font-size: 1.2rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
#container {
flex-grow: 1;
width: 100%;
height: 100%;
}
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
z-index: 5;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top: 3px solid #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.controls-hint {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.6);
padding: 8px 15px;
border-radius: 20px;
font-size: 0.8rem;
color: #888;
pointer-events: none;
z-index: 10;
}
.back-link {
position: absolute;
top: 15px;
left: 20px;
z-index: 20;
color: #fff;
text-decoration: none;
font-size: 0.9rem;
opacity: 0.7;
transition: opacity 0.3s;
}
.back-link:hover {
opacity: 1;
}
</style>
</head>
<body>
<header>
<a href="index.html" class="back-link">&larr; Back</a>
<h1>Nebula Splat Viewer</h1>
</header>
<div id="loading">
<div class="spinner"></div>
<div id="status">Loading Gaussian Splat...</div>
</div>
<div id="container"></div>
<div class="controls-hint">
Left Click: Rotate | Right Click: Pan | Scroll: Zoom
</div>
<script type="module">
// Using a pinned version for stability
import * as SPLAT from "https://cdn.jsdelivr.net/npm/gsplat@1.2.9/dist/index.es.min.js";
async function init() {
const container = document.getElementById('container');
const status = document.getElementById('status');
const loading = document.getElementById('loading');
try {
const renderer = new SPLAT.WebGLRenderer();
container.appendChild(renderer.canvas);
const scene = new SPLAT.Scene();
const camera = new SPLAT.Camera();
const controls = new SPLAT.OrbitControls(camera, renderer.canvas);
// Resize handler
const resize = () => {
renderer.setSize(window.innerWidth, window.innerHeight);
};
window.addEventListener('resize', resize);
resize();
// Load the Splat file
status.innerText = "Downloading Splat...";
await SPLAT.Loader.LoadAsync("/models/gs/nebula_rnd1.splat", scene, (progress) => {
status.innerText = `Loading: ${Math.round(progress * 100)}%`;
});
loading.style.display = 'none';
// Set initial camera position AFTER loading,
// because some models might be very far from origin
camera.position.z = 5;
camera.position.y = 1;
controls.update();
const frame = () => {
controls.update();
renderer.render(scene, camera);
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
console.log("Splat loaded successfully into scene", scene);
} catch (error) {
console.error("Error in GSPLAT init:", error);
status.innerText = "Error: " + error.message;
status.style.color = "#ff4444";
document.querySelector('.spinner').style.display = 'none';
}
}
init();
</script>
</body>
</html>