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/
|
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)
|
├── _redirects # Netlify SPA rewrite rule (/* → /index.html)
|
||||||
├── js/
|
├── js/
|
||||||
│ ├── tabs.js # Client-side routing, tab switching, background video swap
|
│ ├── tabs.js # Client-side routing, tab switching, background video swap
|
||||||
@@ -19,9 +19,10 @@ website/
|
|||||||
│ ├── yuukabot.css # Dashboard glassmorphism cards + grid
|
│ ├── yuukabot.css # Dashboard glassmorphism cards + grid
|
||||||
│ ├── calendar.css # Calendar grid, popups, sidebar
|
│ ├── calendar.css # Calendar grid, popups, sidebar
|
||||||
│ ├── bucketlist.css # Lock screen + list panel
|
│ ├── bucketlist.css # Lock screen + list panel
|
||||||
|
│ ├── services.css # Services tab — one .svc-banner per service (Bot Status look)
|
||||||
│ └── gallery.css # Gallery grid + lightbox
|
│ └── gallery.css # Gallery grid + lightbox
|
||||||
├── gallery/ # Full-quality images, thumbnails/, and manifest.json
|
├── 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)
|
├── sounds/ # Click SFX (yuuka_click.mp3)
|
||||||
├── generate_thumbnails.py # Pillow script: builds thumbnails/ + manifest.json
|
├── generate_thumbnails.py # Pillow script: builds thumbnails/ + manifest.json
|
||||||
└── firebase.txt # Firestore security rules (reference only, gitignored)
|
└── 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.
|
- `_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.
|
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`.
|
1. Add the new `<section class="tab-content" id="…">` in `index.html`.
|
||||||
2. Add a `<button id="…-btn">` to the nav bar (`#top-line`).
|
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.
|
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).
|
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`.
|
6. If the feature reads/writes Firestore, add the matching security rules to `firebase.txt`.
|
||||||
+10
-10
@@ -26,15 +26,15 @@
|
|||||||
"25.jpg",
|
"25.jpg",
|
||||||
"26.jpg",
|
"26.jpg",
|
||||||
"27.gif",
|
"27.gif",
|
||||||
"28.gif",
|
"28.mp4",
|
||||||
"29.gif",
|
"29.mp4",
|
||||||
"30.gif",
|
"30.mp4",
|
||||||
"31.gif",
|
"31.mp4",
|
||||||
"32.gif",
|
"32.mp4",
|
||||||
"33.gif",
|
"33.mp4",
|
||||||
"34.gif",
|
"34.mp4",
|
||||||
"35.gif",
|
"35.mp4",
|
||||||
"36.gif",
|
"36.mp4",
|
||||||
"37.gif",
|
"37.mp4",
|
||||||
"38.png"
|
"38.png"
|
||||||
]
|
]
|
||||||
+71
-21
@@ -3,17 +3,23 @@
|
|||||||
Run after adding images to gallery/:
|
Run after adding images to gallery/:
|
||||||
python generate_thumbnails.py
|
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
|
- 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 json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif"}
|
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif"}
|
||||||
|
VIDEO_EXTS = {".mp4"}
|
||||||
THUMB_SIZE = (400, 400)
|
THUMB_SIZE = (400, 400)
|
||||||
|
FFMPEG = shutil.which("ffmpeg")
|
||||||
|
|
||||||
|
|
||||||
def sort_key(filename):
|
def sort_key(filename):
|
||||||
@@ -22,48 +28,91 @@ def sort_key(filename):
|
|||||||
return (0, int(stem), "") if stem.isdigit() else (1, 0, stem.lower())
|
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(
|
return sorted(
|
||||||
(f for f in os.listdir(base_folder)
|
(f for f in os.listdir(base_folder)
|
||||||
if os.path.isfile(os.path.join(base_folder, f))
|
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,
|
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")
|
thumb_dir = os.path.join(base_folder, "thumbnails")
|
||||||
os.makedirs(thumb_dir, exist_ok=True)
|
os.makedirs(thumb_dir, exist_ok=True)
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
for filename in images:
|
for filename in files:
|
||||||
# Always save thumbnail as jpg
|
# Always save thumbnail as jpg
|
||||||
stem = os.path.splitext(filename)[0]
|
stem = os.path.splitext(filename)[0]
|
||||||
thumb_path = os.path.join(thumb_dir, f"{stem}.jpg")
|
thumb_path = os.path.join(thumb_dir, f"{stem}.jpg")
|
||||||
if os.path.exists(thumb_path):
|
if os.path.exists(thumb_path):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with Image.open(os.path.join(base_folder, filename)) as img:
|
make_thumbnail(os.path.join(base_folder, filename), thumb_path)
|
||||||
# JPEG needs RGB (e.g. for RGBA PNGs or palette GIFs)
|
count += 1
|
||||||
if img.mode != "RGB":
|
print(f"Created thumbnail for {filename}")
|
||||||
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}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to process {filename}: {e}")
|
print(f"Failed to process {filename}: {e}")
|
||||||
|
|
||||||
print(f"Created {count} thumbnails in {base_folder}")
|
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")
|
path = os.path.join(base_folder, "manifest.json")
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
json.dump(images, f, indent=2)
|
json.dump(files, f, indent=2)
|
||||||
f.write("\n")
|
f.write("\n")
|
||||||
print(f"Wrote {path} ({len(images)} images)")
|
print(f"Wrote {path} ({len(files)} items)")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -71,6 +120,7 @@ if __name__ == "__main__":
|
|||||||
if not os.path.isdir(folder):
|
if not os.path.isdir(folder):
|
||||||
print(f"Folder {folder} does not exist.")
|
print(f"Folder {folder} does not exist.")
|
||||||
else:
|
else:
|
||||||
imgs = list_images(folder)
|
convert_gifs(folder)
|
||||||
create_thumbnails(folder, imgs)
|
site_files = pick_for_site(list_files(folder, IMAGE_EXTS | VIDEO_EXTS))
|
||||||
write_manifest(folder, imgs)
|
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/yuukabot.css">
|
||||||
<link rel="stylesheet" href="style/calendar.css">
|
<link rel="stylesheet" href="style/calendar.css">
|
||||||
<link rel="stylesheet" href="style/bucketlist.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">
|
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||||||
<title>Esef</title>
|
<title>Esef</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<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">
|
<source src="textures/Yuuka_body.mp4" type="video/mp4">
|
||||||
</video>
|
</video>
|
||||||
<video muted loop playsinline preload="none" id="calendar-background-video" style="display:none">
|
<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="bucketlist-btn">Bucketlist</button>
|
||||||
<button id="yuukabot-btn">Bot Status</button>
|
<button id="yuukabot-btn">Bot Status</button>
|
||||||
<button id="calendar-btn">Calendar</button>
|
<button id="calendar-btn">Calendar</button>
|
||||||
|
<button id="services-btn">Services</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section id="about" class="tab-content">
|
<section id="about" class="tab-content">
|
||||||
@@ -39,7 +41,7 @@
|
|||||||
<!-- Top half: welcome -->
|
<!-- Top half: welcome -->
|
||||||
<div id="about-hero">
|
<div id="about-hero">
|
||||||
<div id="click-counter-container">
|
<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-display">...</div>
|
||||||
<div id="click-counter-label">click me</div>
|
<div id="click-counter-label">click me</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,10 +207,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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) -->
|
<!-- Lightbox (gallery) -->
|
||||||
<div id="lightbox" class="lightbox">
|
<div id="lightbox" class="lightbox">
|
||||||
<span class="lightbox-close">×</span>
|
<span class="lightbox-close">×</span>
|
||||||
<img class="lightbox-content" id="lightbox-img" alt="">
|
<img class="lightbox-content" id="lightbox-img" alt="">
|
||||||
|
<video class="lightbox-content" id="lightbox-video" muted loop playsinline></video>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="js/gallery.js" defer></script>
|
<script src="js/gallery.js" defer></script>
|
||||||
|
|||||||
+19
-4
@@ -4,22 +4,37 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const container = document.getElementById('gallery-container');
|
const container = document.getElementById('gallery-container');
|
||||||
const lightbox = document.getElementById('lightbox');
|
const lightbox = document.getElementById('lightbox');
|
||||||
const lightboxImg = document.getElementById('lightbox-img');
|
const lightboxImg = document.getElementById('lightbox-img');
|
||||||
|
const lightboxVideo = document.getElementById('lightbox-video');
|
||||||
|
|
||||||
if (!container || !lightbox) return;
|
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) => {
|
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';
|
lightbox.style.display = 'block';
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeLightbox = () => {
|
const closeLightbox = () => {
|
||||||
lightbox.style.display = 'none';
|
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) => {
|
lightbox.addEventListener('click', (e) => {
|
||||||
if (e.target !== lightboxImg) closeLightbox();
|
if (e.target !== lightboxImg && e.target !== lightboxVideo) closeLightbox();
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape' && lightbox.style.display === 'block') closeLightbox();
|
if (e.key === 'Escape' && lightbox.style.display === 'block') closeLightbox();
|
||||||
|
|||||||
+11
-5
@@ -9,7 +9,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// ── Map URL path → tab id ──────────────────────────────
|
// ── Map URL path → tab id ──────────────────────────────
|
||||||
function pathToTab(path) {
|
function pathToTab(path) {
|
||||||
const slug = path.replace(/^\/+|\/+$/g, '') || 'about';
|
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
|
// backward compat: old /ahmet links
|
||||||
if (slug === 'ahmet') return 'bucketlist';
|
if (slug === 'ahmet') return 'bucketlist';
|
||||||
return valid.includes(slug) ? slug : 'about';
|
return valid.includes(slug) ? slug : 'about';
|
||||||
@@ -22,11 +22,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// ── Swap background videos + top-bar theme ────────────
|
// ── Swap background videos + top-bar theme ────────────
|
||||||
const topLine = document.getElementById('top-line');
|
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 = {
|
const tabVideos = {
|
||||||
calendar: calVideo,
|
calendar: calVideo,
|
||||||
bucketlist: bucketlistVideo,
|
bucketlist: bucketlistVideo,
|
||||||
yuukabot: botVideo
|
yuukabot: botVideo,
|
||||||
|
services: botVideo
|
||||||
};
|
};
|
||||||
|
|
||||||
function setBgForTab(tabId) {
|
function setBgForTab(tabId) {
|
||||||
@@ -60,18 +61,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
setBgForTab(tabId);
|
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) ────────
|
// ── Navigate to a tab (updates URL + shows it) ────────
|
||||||
function navigateTo(tabId) {
|
function navigateTo(tabId) {
|
||||||
showTab(tabId);
|
showTab(tabId);
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
history.pushState({ tab: tabId }, '', tabToPath(tabId));
|
setUrl('pushState', tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Initial load: show tab from URL ────────────────────
|
// ── Initial load: show tab from URL ────────────────────
|
||||||
// (also normalises unknown / legacy paths like /ahmet in the address bar)
|
// (also normalises unknown / legacy paths like /ahmet in the address bar)
|
||||||
const initialTab = pathToTab(window.location.pathname);
|
const initialTab = pathToTab(window.location.pathname);
|
||||||
showTab(initialTab);
|
showTab(initialTab);
|
||||||
history.replaceState({ tab: initialTab }, '', tabToPath(initialTab) + window.location.search);
|
setUrl('replaceState', initialTab);
|
||||||
|
|
||||||
// ── Tab button clicks ──────────────────────────────────
|
// ── Tab button clicks ──────────────────────────────────
|
||||||
const buttons = document.querySelectorAll('#top-line button');
|
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