From 704a9194ed9df11ae773b6cb263437fc8edbf082 Mon Sep 17 00:00:00 2001 From: Johannes Date: Fri, 13 Mar 2026 21:20:29 +0100 Subject: [PATCH] initial diashow webapp --- Dockerfile | 12 ++ app.py | 91 ++++++++++++++ docker-compose.yml | 10 ++ requirements.txt | 2 + static/app.js | 272 +++++++++++++++++++++++++++++++++++++++++ static/index.html | 56 +++++++++ static/style.css | 297 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 740 insertions(+) create mode 100644 Dockerfile create mode 100644 app.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 static/app.js create mode 100644 static/index.html create mode 100644 static/style.css diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..830093f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +CMD ["python", "-u", "app.py"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..ba14d0e --- /dev/null +++ b/app.py @@ -0,0 +1,91 @@ +import re +import random +import requests +from flask import Flask, request, jsonify, send_from_directory + +app = Flask(__name__, static_folder="static") + +USER_AGENTS = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", +] + +def get_headers(): + return { + "User-Agent": random.choice(USER_AGENTS), + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate, br", + "DNT": "1", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + } + +def scrape_images(query, page=0): + url = "https://www.google.com/search" + params = { + "q": query, + "tbm": "isch", + "ijn": str(page), + "start": str(page * 20), + "asearch": "ichunk", + "async": "_id:rg_s,_pms:s,_fmt:pc", + } + try: + resp = requests.get(url, params=params, headers=get_headers(), timeout=10) + resp.raise_for_status() + except requests.RequestException as e: + return [], str(e) + + # Extract original image URLs: Google embeds them as "ou":"https://..." + ou_matches = re.findall(r'"ou"\s*:\s*"(https?://[^"]+)"', resp.text) + + # Fallback: look for image URLs in JSON arrays ["https://...", width, height] + if not ou_matches: + ou_matches = re.findall( + r'(?:^|[^"])(https?://(?!encrypted-tbn)[^"\'\\]+\.(?:jpg|jpeg|png|gif|webp)(?:\?[^"\'\\]*)?)', + resp.text, + re.IGNORECASE, + ) + + seen = set() + images = [] + for img_url in ou_matches: + if img_url in seen: + continue + seen.add(img_url) + # skip google's own thumbnails / tracking pixels + if "google.com" in img_url or "gstatic.com" in img_url: + continue + images.append({"url": img_url}) + if len(images) >= 20: + break + + return images, None + + +@app.route("/api/search") +def search(): + q = request.args.get("q", "").strip() + page = max(0, int(request.args.get("page", 0))) + if not q: + return jsonify({"error": "missing query"}), 400 + images, err = scrape_images(q, page) + if err: + return jsonify({"error": err}), 502 + return jsonify({"images": images, "page": page, "query": q}) + + +@app.route("/") +def index(): + return send_from_directory("static", "index.html") + + +@app.route("/") +def static_files(path): + return send_from_directory("static", path) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0820873 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + diashow: + build: . + restart: unless-stopped + networks: + - caddy + +networks: + caddy: + external: true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c2bfc36 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask==3.0.3 +requests==2.32.3 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..7577818 --- /dev/null +++ b/static/app.js @@ -0,0 +1,272 @@ +"use strict"; + +// ── state ────────────────────────────────────────────────────────────────── +let images = []; +let current_idx = 0; +let current_query = ""; +let current_page = 0; +let is_loading = false; +let is_fetching_more = false; + +// ── elements ─────────────────────────────────────────────────────────────── +const search_screen = document.getElementById("search_screen"); +const gallery_screen = document.getElementById("gallery_screen"); +const search_input = document.getElementById("search_input"); +const search_btn = document.getElementById("search_btn"); +const recents_section= document.getElementById("recents_section"); +const recents_list = document.getElementById("recents_list"); +const img_track = document.getElementById("img_track"); +const img_counter = document.getElementById("img_counter"); +const back_btn = document.getElementById("back_btn"); +const arrow_left = document.getElementById("arrow_left"); +const arrow_right = document.getElementById("arrow_right"); +const loading_bar = document.getElementById("loading_bar"); +const toast_el = document.getElementById("toast"); + +// ── recents ──────────────────────────────────────────────────────────────── +function load_recents() { + return JSON.parse(localStorage.getItem("diashow_recents") || "[]"); +} + +function save_recent(q) { + let recents = load_recents().filter(r => r !== q); + recents.unshift(q); + recents = recents.slice(0, 8); + localStorage.setItem("diashow_recents", JSON.stringify(recents)); +} + +function render_recents() { + const recents = load_recents(); + if (!recents.length) { recents_section.style.display = "none"; return; } + recents_section.style.display = "block"; + recents_list.innerHTML = recents + .map(r => ``) + .join(""); +} + +function escape_html(s) { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +// ── loading bar ──────────────────────────────────────────────────────────── +let bar_timer = null; + +function show_loading() { + loading_bar.style.width = "0%"; + loading_bar.classList.add("active"); + loading_bar.style.transition = "none"; + requestAnimationFrame(() => { + loading_bar.style.transition = "width 1.5s ease-out"; + loading_bar.style.width = "75%"; + }); +} + +function hide_loading() { + loading_bar.style.transition = "width 0.2s ease"; + loading_bar.style.width = "100%"; + clearTimeout(bar_timer); + bar_timer = setTimeout(() => { + loading_bar.classList.remove("active"); + loading_bar.style.width = "0%"; + }, 250); +} + +// ── toast ────────────────────────────────────────────────────────────────── +let toast_timer = null; + +function show_toast(msg) { + toast_el.textContent = msg; + toast_el.classList.add("show"); + clearTimeout(toast_timer); + toast_timer = setTimeout(() => toast_el.classList.remove("show"), 3000); +} + +// ── API ──────────────────────────────────────────────────────────────────── +async function fetch_images(query, page = 0) { + const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&page=${page}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + if (data.error) throw new Error(data.error); + return data.images || []; +} + +// ── search ───────────────────────────────────────────────────────────────── +async function do_search(q) { + if (!q.trim() || is_loading) return; + q = q.trim(); + is_loading = true; + show_loading(); + search_btn.disabled = true; + + try { + const results = await fetch_images(q); + if (!results.length) { + show_toast("no images found :/"); + return; + } + save_recent(q); + current_query = q; + current_page = 0; + images = results; + current_idx = 0; + open_gallery(); + } catch (e) { + show_toast("fetch failed — " + e.message); + } finally { + is_loading = false; + hide_loading(); + search_btn.disabled = false; + } +} + +// ── gallery ──────────────────────────────────────────────────────────────── +function make_slide(img_data, idx) { + const slide = document.createElement("div"); + slide.className = "img_slide"; + slide.dataset.idx = idx; + + const el = document.createElement("img"); + el.alt = ""; + el.loading = "lazy"; + el.src = img_data.url; + el.onerror = () => { + slide.innerHTML = `
image failed to load
`; + }; + slide.appendChild(el); + return slide; +} + +function open_gallery() { + img_track.innerHTML = ""; + images.forEach((img, i) => img_track.appendChild(make_slide(img, i))); + + search_screen.style.display = "none"; + gallery_screen.classList.add("active"); + + go_to(0, false); + document.body.style.overflow = "hidden"; +} + +function close_gallery() { + gallery_screen.classList.remove("active"); + search_screen.style.display = ""; + document.body.style.overflow = ""; + render_recents(); +} + +function update_counter() { + img_counter.textContent = `${current_idx + 1} / ${images.length}`; +} + +function go_to(idx, animate = true) { + if (!animate) img_track.style.transition = "none"; + else img_track.style.transition = ""; + + current_idx = Math.max(0, Math.min(idx, images.length - 1)); + img_track.style.transform = `translateX(-${current_idx * 100}%)`; + update_counter(); + + // reset transition after instant move + if (!animate) requestAnimationFrame(() => img_track.style.transition = ""); + + // load more when 3 images from the end + if (current_idx >= images.length - 3) load_more(); +} + +async function load_more() { + if (is_fetching_more) return; + is_fetching_more = true; + try { + const next_page = current_page + 1; + const more = await fetch_images(current_query, next_page); + if (more.length) { + current_page = next_page; + more.forEach(img => { + const slide = make_slide(img, images.length); + img_track.appendChild(slide); + images.push(img); + }); + update_counter(); + } + } catch (_) { + // silently ignore load-more failures + } finally { + is_fetching_more = false; + } +} + +function next_image() { if (current_idx < images.length - 1) go_to(current_idx + 1); } +function prev_image() { if (current_idx > 0) go_to(current_idx - 1); } + +// ── touch / swipe ────────────────────────────────────────────────────────── +let touch_start_x = 0; +let touch_start_y = 0; +let touch_dx = 0; +let is_swiping = false; + +gallery_screen.addEventListener("touchstart", e => { + touch_start_x = e.touches[0].clientX; + touch_start_y = e.touches[0].clientY; + touch_dx = 0; + is_swiping = false; +}, { passive: true }); + +gallery_screen.addEventListener("touchmove", e => { + const dx = e.touches[0].clientX - touch_start_x; + const dy = e.touches[0].clientY - touch_start_y; + + if (!is_swiping) { + // lock to horizontal if angle is mostly horizontal + if (Math.abs(dx) < Math.abs(dy)) return; + is_swiping = true; + } + + e.preventDefault(); + touch_dx = dx; + // live drag feel + const base = -current_idx * 100; + const drag = (dx / window.innerWidth) * 100; + img_track.style.transition = "none"; + img_track.style.transform = `translateX(calc(${base}% + ${dx}px))`; +}, { passive: false }); + +gallery_screen.addEventListener("touchend", () => { + if (!is_swiping) return; + const threshold = window.innerWidth * 0.2; + if (touch_dx < -threshold) next_image(); + else if (touch_dx > threshold) prev_image(); + else go_to(current_idx); // snap back + is_swiping = false; + touch_dx = 0; +}, { passive: true }); + +// ── keyboard ─────────────────────────────────────────────────────────────── +document.addEventListener("keydown", e => { + if (!gallery_screen.classList.contains("active")) return; + if (e.key === "ArrowRight") next_image(); + else if (e.key === "ArrowLeft") prev_image(); + else if (e.key === "Escape") close_gallery(); +}); + +// ── event listeners ──────────────────────────────────────────────────────── +search_btn.addEventListener("click", () => do_search(search_input.value)); + +search_input.addEventListener("keydown", e => { + if (e.key === "Enter") do_search(search_input.value); +}); + +back_btn.addEventListener("click", close_gallery); +arrow_left.addEventListener("click", prev_image); +arrow_right.addEventListener("click", next_image); + +recents_list.addEventListener("click", e => { + const chip = e.target.closest(".recent_chip"); + if (!chip) return; + const q = chip.dataset.q; + search_input.value = q; + do_search(q); +}); + +// ── init ─────────────────────────────────────────────────────────────────── +render_recents(); +search_input.focus(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..db919a5 --- /dev/null +++ b/static/index.html @@ -0,0 +1,56 @@ + + + + + + + + + diashow + + + + +
+
+ + +
+ + +
+ + +
+ + +
+ + + + + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..74129ee --- /dev/null +++ b/static/style.css @@ -0,0 +1,297 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #0a0a0a; + --surface: #161616; + --surface2: #222; + --accent: #e8e8e8; + --muted: #666; + --radius: 12px; + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +html, body { + height: 100%; + background: var(--bg); + color: var(--accent); + font-family: var(--font); + overflow: hidden; + touch-action: pan-y; + -webkit-tap-highlight-color: transparent; + user-select: none; +} + +/* ── SEARCH SCREEN ── */ +#search_screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 24px; + gap: 32px; +} + +.logo { + font-size: 2rem; + font-weight: 700; + letter-spacing: -0.5px; + color: #fff; +} + +.logo span { + color: var(--muted); + font-weight: 300; +} + +.search_row { + display: flex; + width: 100%; + max-width: 480px; + gap: 8px; +} + +#search_input { + flex: 1; + background: var(--surface); + border: 1px solid #333; + border-radius: var(--radius); + color: var(--accent); + font-size: 1rem; + padding: 14px 16px; + outline: none; + transition: border-color 0.15s; +} + +#search_input:focus { + border-color: #555; +} + +#search_input::placeholder { + color: var(--muted); +} + +#search_btn { + background: var(--accent); + color: var(--bg); + border: none; + border-radius: var(--radius); + padding: 14px 20px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} + +#search_btn:active { + opacity: 0.7; +} + +.recents { + width: 100%; + max-width: 480px; +} + +.recents_label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin-bottom: 8px; +} + +.recents_list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.recent_chip { + background: var(--surface2); + border: 1px solid #333; + border-radius: 999px; + color: var(--accent); + font-size: 0.85rem; + padding: 6px 14px; + cursor: pointer; + transition: background 0.1s; +} + +.recent_chip:active { + background: #333; +} + +/* ── GALLERY SCREEN ── */ +#gallery_screen { + display: none; + position: fixed; + inset: 0; + background: #000; + overflow: hidden; + touch-action: none; +} + +#gallery_screen.active { + display: flex; + align-items: center; + justify-content: center; +} + +/* image container with slide transition */ +#img_track { + display: flex; + width: 100%; + height: 100%; + transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1); + will-change: transform; +} + +.img_slide { + flex: 0 0 100%; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.img_slide img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + display: block; +} + +.img_slide .img_err { + color: var(--muted); + font-size: 0.9rem; + text-align: center; + padding: 24px; +} + +/* overlay UI */ +#gallery_overlay { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 10; +} + +#gallery_top { + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent); + pointer-events: all; +} + +#back_btn { + background: rgba(0,0,0,0.5); + border: none; + border-radius: 999px; + color: #fff; + font-size: 1.2rem; + width: 40px; + height: 40px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; +} + +#back_btn:active { + background: rgba(0,0,0,0.8); +} + +#img_counter { + font-size: 0.85rem; + color: rgba(255,255,255,0.8); + background: rgba(0,0,0,0.4); + padding: 4px 10px; + border-radius: 999px; +} + +/* desktop nav arrows */ +#arrow_left, #arrow_right { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: rgba(0,0,0,0.45); + border: none; + border-radius: 999px; + color: #fff; + font-size: 1.4rem; + width: 48px; + height: 48px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + pointer-events: all; + transition: background 0.15s, opacity 0.15s; + opacity: 0; +} + +#gallery_screen:hover #arrow_left, +#gallery_screen:hover #arrow_right { + opacity: 1; +} + +#arrow_left { left: 16px; } +#arrow_right { right: 16px; } + +#arrow_left:active, #arrow_right:active { + background: rgba(0,0,0,0.75); +} + +/* loading indicator */ +#loading_bar { + position: fixed; + top: 0; + left: 0; + height: 2px; + background: #fff; + width: 0%; + transition: width 0.3s; + z-index: 100; + display: none; +} + +#loading_bar.active { + display: block; +} + +/* error toast */ +#toast { + position: fixed; + bottom: 32px; + left: 50%; + transform: translateX(-50%) translateY(60px); + background: #c0392b; + color: #fff; + padding: 10px 20px; + border-radius: 999px; + font-size: 0.85rem; + z-index: 200; + transition: transform 0.25s; + white-space: nowrap; +} + +#toast.show { + transform: translateX(-50%) translateY(0); +} + +/* mobile: hide arrows */ +@media (hover: none) { + #arrow_left, #arrow_right { display: none; } +}