optimization
This commit is contained in:
1 parent
9dd5b25f10
commit
f4457c5c17
10 files changed
+232
-46
No files matched your search
@@ -4,7 +4,7 @@
|
||||
|
||||
```
|
||||
website/
|
||||
├── index.html # Single-page entry point — all 5 tabs live here
|
||||
├── index.html # Single-page entry point — all 6 tabs live here
|
||||
├── _redirects # Netlify SPA rewrite rule (/* → /index.html)
|
||||
├── js/
|
||||
│ ├── tabs.js # Client-side routing, tab switching, background video swap
|
||||
@@ -19,9 +19,10 @@ website/
|
||||
│ ├── yuukabot.css # Dashboard glassmorphism cards + grid
|
||||
│ ├── calendar.css # Calendar grid, popups, sidebar
|
||||
│ ├── bucketlist.css # Lock screen + list panel
|
||||
│ ├── services.css # Services tab — one .svc-banner per service (Bot Status look)
|
||||
│ └── gallery.css # Gallery grid + lightbox
|
||||
├── gallery/ # Full-quality images, thumbnails/, and manifest.json
|
||||
├── textures/ # Background MP4 videos + clickable character PNG
|
||||
├── textures/ # Background MP4s, clickable character PNG, service logos
|
||||
├── sounds/ # Click SFX (yuuka_click.mp3)
|
||||
├── generate_thumbnails.py # Pillow script: builds thumbnails/ + manifest.json
|
||||
└── firebase.txt # Firestore security rules (reference only, gitignored)
|
||||
@@ -44,7 +45,8 @@ npx serve .
|
||||
```
|
||||
|
||||
- `_redirects` handles SPA routing on Netlify; for local dev, navigate to `/index.html` directly.
|
||||
- After adding images to `gallery/`, run `python generate_thumbnails.py` (requires `Pillow`). It creates missing thumbnails and rewrites `gallery/manifest.json`, which is the list the gallery renders — an image not in the manifest won't show up.
|
||||
- After adding images to `gallery/`, run `python generate_thumbnails.py` (requires `Pillow`, and `ffmpeg` for GIFs). It converts animated GIFs to much smaller MP4s, creates missing thumbnails, and rewrites `gallery/manifest.json`, which is the list the gallery renders — an image not in the manifest won't show up.
|
||||
- **Keep data usage low.** Background videos are 1080p H.264, no audio, `+faststart`, `preload="none"` (tabs.js starts the one the current tab needs). Recent ones use `ffmpeg -i in.mp4 -r 30 -c:v libx264 -preset slow -crf 28 -pix_fmt yuv420p -an -movflags +faststart out.mp4`. Keep untouched originals outside the site folder (`../media_originals/`). Size images to about 2× their displayed size.
|
||||
|
||||
There are **no automated tests** in this project.
|
||||
|
||||
@@ -83,7 +85,7 @@ Examples from the repo: `calendar added`, `fix upcoming filter`, `css polish bs`
|
||||
|
||||
1. Add the new `<section class="tab-content" id="…">` in `index.html`.
|
||||
2. Add a `<button id="…-btn">` to the nav bar (`#top-line`).
|
||||
3. Add the tab name to the `valid` array in `js/tabs.js`.
|
||||
3. Add the tab name to the `valid` array in `js/tabs.js`. For a custom background video, map the tab to a video element in `tabVideos` (tabs can share one) and optionally add a nav theme class.
|
||||
4. Create a dedicated stylesheet in `style/` and JS module in `js/` if needed.
|
||||
5. Link both in `index.html` `<head>` (CSS) and before `</body>` (JS).
|
||||
6. If the feature reads/writes Firestore, add the matching security rules to `firebase.txt`.
|
||||
+10
-10
@@ -26,15 +26,15 @@
|
||||
"25.jpg",
|
||||
"26.jpg",
|
||||
"27.gif",
|
||||
"28.gif",
|
||||
"29.gif",
|
||||
"30.gif",
|
||||
"31.gif",
|
||||
"32.gif",
|
||||
"33.gif",
|
||||
"34.gif",
|
||||
"35.gif",
|
||||
"36.gif",
|
||||
"37.gif",
|
||||
"28.mp4",
|
||||
"29.mp4",
|
||||
"30.mp4",
|
||||
"31.mp4",
|
||||
"32.mp4",
|
||||
"33.mp4",
|
||||
"34.mp4",
|
||||
"35.mp4",
|
||||
"36.mp4",
|
||||
"37.mp4",
|
||||
"38.png"
|
||||
]
|
||||
+71
-21
@@ -3,17 +3,23 @@
|
||||
Run after adding images to gallery/:
|
||||
python generate_thumbnails.py
|
||||
|
||||
- Creates gallery/thumbnails/<name>.jpg (max 400x400) for any image missing one.
|
||||
- Converts animated GIFs to MP4 (needs ffmpeg on PATH). Same animation at a
|
||||
few % of the size; the GIF stays as the source but the site uses the MP4.
|
||||
- Creates gallery/thumbnails/<name>.jpg (max 400x400) for any item missing one.
|
||||
- Rewrites gallery/manifest.json: the ordered list of full-size filenames
|
||||
that js/gallery.js renders.
|
||||
that js/gallery.js renders (the MP4 wins when a GIF has one).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from PIL import Image
|
||||
|
||||
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif"}
|
||||
VIDEO_EXTS = {".mp4"}
|
||||
THUMB_SIZE = (400, 400)
|
||||
FFMPEG = shutil.which("ffmpeg")
|
||||
|
||||
|
||||
def sort_key(filename):
|
||||
@@ -22,48 +28,91 @@ def sort_key(filename):
|
||||
return (0, int(stem), "") if stem.isdigit() else (1, 0, stem.lower())
|
||||
|
||||
|
||||
def list_images(base_folder):
|
||||
def list_files(base_folder, exts):
|
||||
return sorted(
|
||||
(f for f in os.listdir(base_folder)
|
||||
if os.path.isfile(os.path.join(base_folder, f))
|
||||
and os.path.splitext(f)[1].lower() in IMAGE_EXTS),
|
||||
and os.path.splitext(f)[1].lower() in exts),
|
||||
key=sort_key,
|
||||
)
|
||||
|
||||
|
||||
def create_thumbnails(base_folder, images):
|
||||
def convert_gifs(base_folder):
|
||||
if not FFMPEG:
|
||||
print("ffmpeg not found — skipping GIF -> MP4 conversion")
|
||||
return
|
||||
for filename in list_files(base_folder, {".gif"}):
|
||||
src = os.path.join(base_folder, filename)
|
||||
dst = os.path.join(base_folder, os.path.splitext(filename)[0] + ".mp4")
|
||||
if os.path.exists(dst):
|
||||
continue
|
||||
with Image.open(src) as img:
|
||||
if getattr(img, "n_frames", 1) < 2:
|
||||
continue # still image, keep as GIF
|
||||
subprocess.run([
|
||||
FFMPEG, "-hide_banner", "-loglevel", "error", "-y", "-i", src,
|
||||
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", # h264 needs even sizes
|
||||
"-c:v", "libx264", "-crf", "23", "-pix_fmt", "yuv420p",
|
||||
"-an", "-movflags", "+faststart", dst,
|
||||
], check=True)
|
||||
print(f"Converted {filename} -> {os.path.basename(dst)}")
|
||||
|
||||
|
||||
def make_thumbnail(src, thumb_path):
|
||||
if os.path.splitext(src)[1].lower() in VIDEO_EXTS:
|
||||
if not FFMPEG:
|
||||
raise RuntimeError("ffmpeg needed for video thumbnails")
|
||||
subprocess.run([
|
||||
FFMPEG, "-hide_banner", "-loglevel", "error", "-y", "-i", src,
|
||||
"-frames:v", "1", "-vf", "scale='min(400,iw)':'min(400,ih)':force_original_aspect_ratio=decrease",
|
||||
"-q:v", "4", thumb_path,
|
||||
], check=True)
|
||||
return
|
||||
with Image.open(src) as img:
|
||||
# JPEG needs RGB (e.g. for RGBA PNGs or palette GIFs)
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
img.thumbnail(THUMB_SIZE) # keeps aspect ratio
|
||||
img.save(thumb_path, "JPEG", quality=85)
|
||||
|
||||
|
||||
def create_thumbnails(base_folder, files):
|
||||
thumb_dir = os.path.join(base_folder, "thumbnails")
|
||||
os.makedirs(thumb_dir, exist_ok=True)
|
||||
|
||||
count = 0
|
||||
for filename in images:
|
||||
for filename in files:
|
||||
# Always save thumbnail as jpg
|
||||
stem = os.path.splitext(filename)[0]
|
||||
thumb_path = os.path.join(thumb_dir, f"{stem}.jpg")
|
||||
if os.path.exists(thumb_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
with Image.open(os.path.join(base_folder, filename)) as img:
|
||||
# JPEG needs RGB (e.g. for RGBA PNGs or palette GIFs)
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
img.thumbnail(THUMB_SIZE) # keeps aspect ratio
|
||||
img.save(thumb_path, "JPEG", quality=85)
|
||||
count += 1
|
||||
print(f"Created thumbnail for {filename}")
|
||||
make_thumbnail(os.path.join(base_folder, filename), thumb_path)
|
||||
count += 1
|
||||
print(f"Created thumbnail for {filename}")
|
||||
except Exception as e:
|
||||
print(f"Failed to process {filename}: {e}")
|
||||
|
||||
print(f"Created {count} thumbnails in {base_folder}")
|
||||
|
||||
|
||||
def write_manifest(base_folder, images):
|
||||
def pick_for_site(files):
|
||||
# One entry per name; prefer the MP4 over its source GIF
|
||||
chosen = {}
|
||||
for f in files:
|
||||
stem, ext = os.path.splitext(f)
|
||||
if stem not in chosen or ext.lower() in VIDEO_EXTS:
|
||||
chosen[stem] = f
|
||||
return sorted(chosen.values(), key=sort_key)
|
||||
|
||||
|
||||
def write_manifest(base_folder, files):
|
||||
path = os.path.join(base_folder, "manifest.json")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(images, f, indent=2)
|
||||
json.dump(files, f, indent=2)
|
||||
f.write("\n")
|
||||
print(f"Wrote {path} ({len(images)} images)")
|
||||
print(f"Wrote {path} ({len(files)} items)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -71,6 +120,7 @@ if __name__ == "__main__":
|
||||
if not os.path.isdir(folder):
|
||||
print(f"Folder {folder} does not exist.")
|
||||
else:
|
||||
imgs = list_images(folder)
|
||||
create_thumbnails(folder, imgs)
|
||||
write_manifest(folder, imgs)
|
||||
convert_gifs(folder)
|
||||
site_files = pick_for_site(list_files(folder, IMAGE_EXTS | VIDEO_EXTS))
|
||||
create_thumbnails(folder, site_files)
|
||||
write_manifest(folder, site_files)
|
||||
+23
-2
@@ -9,12 +9,13 @@
|
||||
<link rel="stylesheet" href="style/yuukabot.css">
|
||||
<link rel="stylesheet" href="style/calendar.css">
|
||||
<link rel="stylesheet" href="style/bucketlist.css">
|
||||
<link rel="stylesheet" href="style/services.css">
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||||
<title>Esef</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<video autoplay muted loop id="background-video">
|
||||
<video muted loop playsinline preload="none" id="background-video">
|
||||
<source src="textures/Yuuka_body.mp4" type="video/mp4">
|
||||
</video>
|
||||
<video muted loop playsinline preload="none" id="calendar-background-video" style="display:none">
|
||||
@@ -32,6 +33,7 @@
|
||||
<button id="bucketlist-btn">Bucketlist</button>
|
||||
<button id="yuukabot-btn">Bot Status</button>
|
||||
<button id="calendar-btn">Calendar</button>
|
||||
<button id="services-btn">Services</button>
|
||||
</div>
|
||||
|
||||
<section id="about" class="tab-content">
|
||||
@@ -39,7 +41,7 @@
|
||||
<!-- Top half: welcome -->
|
||||
<div id="about-hero">
|
||||
<div id="click-counter-container">
|
||||
<img src="textures/yuuka_click.png" id="yuuka-click-btn" alt="Click Me!">
|
||||
<img src="textures/yuuka_click.webp" id="yuuka-click-btn" alt="Click Me!">
|
||||
<div id="click-counter-display">...</div>
|
||||
<div id="click-counter-label">click me</div>
|
||||
</div>
|
||||
@@ -205,10 +207,29 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="services" class="tab-content">
|
||||
<header class="svc-header">
|
||||
<h1>Services</h1>
|
||||
</header>
|
||||
|
||||
<div class="svc-list">
|
||||
<!-- One banner per service — copy this block to add another -->
|
||||
<article class="svc-banner" style="--brand: #FFD600">
|
||||
<img class="svc-logo" src="textures/revolt_logo.png" alt="">
|
||||
<div class="svc-info">
|
||||
<h2 class="svc-name">ReVoltVPN</h2>
|
||||
<p class="svc-desc">Free, open-source VPN for Android. No sign-up, no logs.</p>
|
||||
</div>
|
||||
<a class="svc-btn" href="https://userevolt.app" target="_blank" rel="noopener">Visit →</a>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Lightbox (gallery) -->
|
||||
<div id="lightbox" class="lightbox">
|
||||
<span class="lightbox-close">×</span>
|
||||
<img class="lightbox-content" id="lightbox-img" alt="">
|
||||
<video class="lightbox-content" id="lightbox-video" muted loop playsinline></video>
|
||||
</div>
|
||||
|
||||
<script src="js/gallery.js" defer></script>
|
||||
|
||||
+19
-4
@@ -4,22 +4,37 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const container = document.getElementById('gallery-container');
|
||||
const lightbox = document.getElementById('lightbox');
|
||||
const lightboxImg = document.getElementById('lightbox-img');
|
||||
const lightboxVideo = document.getElementById('lightbox-video');
|
||||
|
||||
if (!container || !lightbox) return;
|
||||
|
||||
// Animated GIFs are served as MP4 (a few % of the size), so those
|
||||
// open in the <video>; everything else in the <img>.
|
||||
const openLightbox = (src) => {
|
||||
lightboxImg.src = src;
|
||||
const isVideo = src.endsWith('.mp4');
|
||||
lightboxImg.style.display = isVideo ? 'none' : 'block';
|
||||
lightboxVideo.style.display = isVideo ? 'block' : 'none';
|
||||
if (isVideo) {
|
||||
lightboxVideo.src = src;
|
||||
lightboxVideo.play().catch(() => {});
|
||||
} else {
|
||||
lightboxImg.src = src;
|
||||
}
|
||||
lightbox.style.display = 'block';
|
||||
};
|
||||
|
||||
const closeLightbox = () => {
|
||||
lightbox.style.display = 'none';
|
||||
lightboxImg.src = ''; // Clear source to stop GIFs
|
||||
lightboxImg.removeAttribute('src');
|
||||
// Stop playback and any download still in progress
|
||||
lightboxVideo.pause();
|
||||
lightboxVideo.removeAttribute('src');
|
||||
lightboxVideo.load();
|
||||
};
|
||||
|
||||
// Close on the X, on a click outside the image, or with Escape
|
||||
// Close on the X, on a click outside the media, or with Escape
|
||||
lightbox.addEventListener('click', (e) => {
|
||||
if (e.target !== lightboxImg) closeLightbox();
|
||||
if (e.target !== lightboxImg && e.target !== lightboxVideo) closeLightbox();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && lightbox.style.display === 'block') closeLightbox();
|
||||
|
||||
+11
-5
@@ -9,7 +9,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// ── Map URL path → tab id ──────────────────────────────
|
||||
function pathToTab(path) {
|
||||
const slug = path.replace(/^\/+|\/+$/g, '') || 'about';
|
||||
const valid = ['about', 'gallery', 'bucketlist', 'yuukabot', 'calendar'];
|
||||
const valid = ['about', 'gallery', 'bucketlist', 'yuukabot', 'calendar', 'services'];
|
||||
// backward compat: old /ahmet links
|
||||
if (slug === 'ahmet') return 'bucketlist';
|
||||
return valid.includes(slug) ? slug : 'about';
|
||||
@@ -22,11 +22,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// ── Swap background videos + top-bar theme ────────────
|
||||
const topLine = document.getElementById('top-line');
|
||||
|
||||
// map tab id → its dedicated video element (null = use main)
|
||||
// map tab id → its dedicated video element (unlisted = use main)
|
||||
const tabVideos = {
|
||||
calendar: calVideo,
|
||||
bucketlist: bucketlistVideo,
|
||||
yuukabot: botVideo
|
||||
yuukabot: botVideo,
|
||||
services: botVideo
|
||||
};
|
||||
|
||||
function setBgForTab(tabId) {
|
||||
@@ -60,18 +61,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
setBgForTab(tabId);
|
||||
}
|
||||
|
||||
function setUrl(method, tabId) {
|
||||
if (window.location.protocol === 'file:') return;
|
||||
history[method]({ tab: tabId }, '', tabToPath(tabId) + window.location.search);
|
||||
}
|
||||
|
||||
// ── Navigate to a tab (updates URL + shows it) ────────
|
||||
function navigateTo(tabId) {
|
||||
showTab(tabId);
|
||||
window.scrollTo(0, 0);
|
||||
history.pushState({ tab: tabId }, '', tabToPath(tabId));
|
||||
setUrl('pushState', tabId);
|
||||
}
|
||||
|
||||
// ── Initial load: show tab from URL ────────────────────
|
||||
// (also normalises unknown / legacy paths like /ahmet in the address bar)
|
||||
const initialTab = pathToTab(window.location.pathname);
|
||||
showTab(initialTab);
|
||||
history.replaceState({ tab: initialTab }, '', tabToPath(initialTab) + window.location.search);
|
||||
setUrl('replaceState', initialTab);
|
||||
|
||||
// ── Tab button clicks ──────────────────────────────────
|
||||
const buttons = document.querySelectorAll('#top-line button');
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
SERVICES TAB — one banner per service, Bot Status look
|
||||
Add a service by copying a .svc-banner block in index.html and
|
||||
setting its --brand color (used for the left stripe only).
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Tab header (matches .dash-header) ────────────────────── */
|
||||
.svc-header {
|
||||
padding: 120px 20px 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.svc-header h1 {
|
||||
font-size: 2.8rem;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
text-shadow: 0 0 30px rgba(91, 111, 222, 0.5);
|
||||
}
|
||||
|
||||
/* ── Banner list ──────────────────────────────────────────── */
|
||||
.svc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 0 48px 60px;
|
||||
}
|
||||
|
||||
/* ── Banner (same glass as .dash-card) ────────────────────── */
|
||||
.svc-banner {
|
||||
--brand: #5b6fde;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 28px 32px;
|
||||
background: rgba(15, 15, 35, 0.55);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(91, 111, 222, 0.2);
|
||||
border-left: 4px solid var(--brand);
|
||||
border-radius: 16px;
|
||||
text-align: left;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
.svc-banner:hover {
|
||||
border-color: rgba(91, 111, 222, 0.5);
|
||||
border-left-color: var(--brand);
|
||||
box-shadow: 0 6px 28px rgba(91, 111, 222, 0.12);
|
||||
}
|
||||
|
||||
.svc-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.svc-info { flex: 1; min-width: 0; }
|
||||
|
||||
.svc-name {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.6rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* overrides the global p rule in styles.css */
|
||||
.svc-banner .svc-desc {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
.svc-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 24px;
|
||||
border-radius: 10px;
|
||||
background: #5b6fde;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.svc-btn:hover {
|
||||
background: #6d80ee;
|
||||
box-shadow: 0 0 18px rgba(91, 111, 222, 0.5);
|
||||
}
|
||||
.svc-btn:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Reference in new issue
Block a user