diff --git a/.env.sample b/.env.sample index bc9711c..24e1e91 100644 --- a/.env.sample +++ b/.env.sample @@ -2,5 +2,5 @@ MODE=local MONGO_URI=mongodb://:@127.0.0.1:27017/?authSource=admin&retryWrites=true&w=majority DOMAIN=https://localhost:8001 PORT=8001 -API_VERSION="" -APP_NAMe="LOCAL" \ No newline at end of file +API_VERSION="/api/v1" +APP_NAME="LOCAL" \ No newline at end of file diff --git a/README.md b/README.md index ff01fba..e480122 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,62 @@ pip install dist/*.whl pip install --upgrade dist/*.whl ``` +# πŸ“‘ Endpoints + +# πŸ” Cache Admin Endpoints (Authentication) + +To use the cache admin endpoints (`/cache/purge`, `/cache/remove`), you must configure a secret token in your environment and send it in the request header. +Setup + +Add a token in your .env file: + +``` +CACHE_PURGE_TOKEN=your-secret-token +``` + +πŸ§ͺ How to test + +PowerShell + +``` +Invoke-RestMethod ` + -Method DELETE ` + -Uri "http://127.0.0.1:8000/cache/purge" ` + -Headers @{ "X-Cache-Token" = "your-secret-token" } +``` + +🧹 Remove a single cache entry + +``` +Invoke-RestMethod ` + -Method PATCH ` + -Uri "http://127.0.0.1:8000/cache/remove?key=abc123" ` + -Headers @{ "X-Cache-Token" = "your-secret-token" } +``` + +πŸ–₯️ UI Endpoints + +| Method | Path | Description | +| ------ | --------------- | ------------------------------------ | +| GET | `/` | Home page (URL shortener UI) | +| GET | `/recent` | Shows recently shortened URLs | +| GET | `/{short_code}` | Redirects to the original URL | +| GET | `/cache/list` | πŸ”§ Debug cache view (local/dev only) | +| DELETE | `/cache/purge` | 🧹 Remove all entries from cache | +| PATCH | `/cache/remove` | 🧹 Remove a single cache entry | + +πŸ”Œ API Endpoints (v1) + +| Method | Path | Description | +| ------ | ------------------- | ------------------------------------ | +| POST | `/api/v1/shorten` | Create a short URL | +| GET | `/api/v1/version` | Get API version | +| GET | `/api/v1/health` | Health check (DB + cache status) | +| GET | `/api/{short_code}` | Redirect to original URL | +| GET | `/cache/list` | πŸ”§ Debug cache view (local/dev only) | +| DELETE | `/cache/purge` | 🧹 Remove all entries from cache | +| PATCH | `/cache/remove` | 🧹 Remove a single cache entry | + ## License πŸ“œDocs diff --git a/app/api/fast_api.py b/app/api/fast_api.py index d16c37a..d8d5a99 100644 --- a/app/api/fast_api.py +++ b/app/api/fast_api.py @@ -1,40 +1,19 @@ -import os -import re import traceback -from datetime import datetime, timezone -from typing import TYPE_CHECKING - -from fastapi import APIRouter, FastAPI, Request -from fastapi.responses import HTMLResponse, JSONResponse -from pydantic import BaseModel, Field - -if TYPE_CHECKING: - from pymongo.errors import PyMongoError -else: - try: - from pymongo.errors import PyMongoError - except ImportError: - - class PyMongoError(Exception): - pass - +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse from app import __version__ -from app.utils import db -from app.utils.cache import get_short_from_cache, set_cache_pair -from app.utils.helper import generate_code, is_valid_url, sanitize_url - -SHORT_CODE_PATTERN = re.compile(r"^[A-Za-z0-9]{6}$") -MAX_URL_LENGTH = 2048 +from app.routes import api_router, ui_router app = FastAPI( title="Tiny API", version=__version__, description="Tiny URL Shortener API built with FastAPI", + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", ) -api_v1 = APIRouter(prefix=os.getenv("API_VERSION", "/api/v1"), tags=["v1"]) - @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): @@ -45,167 +24,5 @@ async def global_exception_handler(request: Request, exc: Exception): ) -class ShortenRequest(BaseModel): - url: str = Field(..., examples=["https://abcdkbd.com"]) - - -class ShortenResponse(BaseModel): - success: bool = True - input_url: str - short_code: str - created_on: datetime - - -class ErrorResponse(BaseModel): - success: bool = False - error: str - input_url: str - message: str - - -class VersionResponse(BaseModel): - version: str - - -# ------------------------------------------------- -# Home -# ------------------------------------------------- -@app.get("/", response_class=HTMLResponse, tags=["Home"]) -async def read_root(_: Request): - return """ - - - πŸŒ™ tiny API πŸŒ™ - - - -
-

πŸš€ tiny API

-

FastAPI backend for the Tiny URL shortener

- View API Documentation -
- - - """ - - -@api_v1.post("/shorten", response_model=ShortenResponse, status_code=201) -def shorten_url(payload: ShortenRequest): - print(" SHORTEN ENDPOINT HIT ", payload.url) - raw_url = payload.url.strip() - - if len(raw_url) > MAX_URL_LENGTH: - return JSONResponse( - status_code=413, content={"success": False, "input_url": payload.url} - ) - - original_url = sanitize_url(raw_url) - - if not is_valid_url(original_url): - return JSONResponse( - status_code=400, - content={ - "success": False, - "error": "INVALID_URL", - "input_url": payload.url, - "message": "Invalid URL", - }, - ) - - if db.collection is None: - cached_short = get_short_from_cache(original_url) - short_code = cached_short or generate_code() - set_cache_pair(short_code, original_url) - return { - "success": True, - "input_url": original_url, - "short_code": short_code, - "created_on": datetime.now(timezone.utc), - } - - try: - existing = db.collection.find_one({"original_url": original_url}) - except PyMongoError: - existing = None - - if existing: - return { - "success": True, - "input_url": original_url, - "short_code": existing["short_code"], - "created_on": existing["created_at"], - } - - short_code = generate_code() - try: - db.collection.insert_one( - { - "short_code": short_code, - "original_url": original_url, - "created_at": datetime.now(timezone.utc), - } - ) - except PyMongoError: - pass - - return { - "success": True, - "input_url": original_url, - "short_code": short_code, - "created_on": datetime.now(timezone.utc), - } - - -@app.get("/version") -def api_version(): - return {"version": __version__} - - -@api_v1.get("/help") -def get_help(): - return {"message": "Welcome to Tiny API. Visit /docs for API documentation."} - - -app.include_router(api_v1) +app.include_router(api_router) +app.include_router(ui_router) diff --git a/app/main.py b/app/main.py index 8f393ba..c960eeb 100644 --- a/app/main.py +++ b/app/main.py @@ -1,58 +1,90 @@ +# app/main.py from contextlib import asynccontextmanager from pathlib import Path -from typing import Optional import logging +import traceback +import asyncio -from fastapi import FastAPI, Form, Request, status -from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, JSONResponse +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware -from app.api.fast_api import app as api_app +from app.routes import ui_router from app.utils import db -from app.utils.cache import ( - get_from_cache, - get_recent_from_cache, - get_short_from_cache, - rev_cache, - set_cache_pair, - url_cache, -) -from app.utils.config import DOMAIN, MAX_RECENT_URLS, SESSION_SECRET -from app.utils.helper import ( - format_date, - generate_code, - is_valid_url, - sanitize_url, +from app.utils.cache import cleanup_expired + +# ----------------------------- +# Background cache cleanup task +# ----------------------------- +from app.utils.config import ( + CACHE_TTL, + SESSION_SECRET, ) -from app.utils.qr import generate_qr_with_logo + + +async def cache_health_check(): + logger = logging.getLogger(__name__) + logger.info("🧹 Cache cleanup task started") + + interval = max(1, CACHE_TTL // 3) # pure TTL-based + + logger.info(f"πŸ•’ Cache cleanup interval set to {interval}s") + + while True: + try: + cleanup_expired() + except Exception as e: + logger.error(f"Cache cleanup error: {e}") + await asyncio.sleep(interval) # ----------------------------- -# Lifespan: env + DB connect ONCE +# Lifespan: env + DB connect ONCE (DB-optional) # ----------------------------- @asynccontextmanager async def lifespan(app: FastAPI): logger = logging.getLogger(__name__) - logger.info("Application startup: Connecting to database...") - db.connect_db() - db.start_health_check() + logger.info("Application startup: Initializing services...") + + # DB init (optional) + db_ok = db.connect_db() + if db_ok: + db.start_health_check() + logger.info("🟒 MongoDB enabled") + else: + logger.warning("🟑 MongoDB disabled (cache-only mode)") + + # Cache TTL cleanup + cache_task = asyncio.create_task(cache_health_check()) + logger.info("🧹 Cache TTL cleanup enabled") + logger.info("Application startup complete") - yield - + logger.info("Application shutdown: Cleaning up...") - await db.stop_health_check() - - # Close MongoDB client gracefully + + # Stop cache task + cache_task.cancel() + try: + await cache_task + except asyncio.CancelledError: + logger.info("🧹 Cache cleanup task stopped") + + # Stop DB health check + try: + await db.stop_health_check() + except Exception as e: + logger.error(f"Error stopping health check: {str(e)}") + + # Close Mongo client if exists try: if db.client is not None: db.client.close() logger.info("MongoDB client closed") except Exception as e: logger.error(f"Error closing MongoDB client: {str(e)}") - + logger.info("Application shutdown complete") @@ -63,218 +95,21 @@ async def lifespan(app: FastAPI): STATIC_DIR = BASE_DIR / "static" app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") -templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) - - -def build_short_url(short_code: str, request_host_url: str) -> str: - base_url = DOMAIN.rstrip("/") - return f"{base_url}/{short_code}" - - -@app.get("/", response_class=HTMLResponse) -async def index(request: Request): - session = request.session - - new_short_url = session.pop("new_short_url", None) - qr_enabled = session.pop("qr_enabled", False) - qr_type = session.pop("qr_type", "short") - original_url = session.pop("original_url", None) - short_code = session.pop("short_code", None) - info_message = session.pop("info_message", None) - error = session.pop("error", None) - - qr_image = None - qr_data = None - - if qr_enabled and new_short_url and short_code: - qr_data = new_short_url if qr_type == "short" else original_url - qr_filename = f"{short_code}.png" - qr_dir = STATIC_DIR / "qr" - qr_dir.mkdir(parents=True, exist_ok=True) - generate_qr_with_logo(qr_data, str(qr_dir / qr_filename)) - qr_image = f"/static/qr/{qr_filename}" - - all_urls = db.get_recent_urls(MAX_RECENT_URLS) or get_recent_from_cache( - MAX_RECENT_URLS - ) - - return templates.TemplateResponse( - "index.html", - { - "request": request, - "urls": all_urls, - "new_short_url": new_short_url, - "qr_image": qr_image, - "qr_data": qr_data, - "qr_enabled": qr_enabled, - "original_url": original_url, - "error": error, - "info_message": info_message, - "db_available": db.get_collection() is not None, - }, - ) -@app.post("/shorten", response_class=RedirectResponse) -async def create_short_url( - request: Request, - original_url: str = Form(""), - generate_qr: Optional[str] = Form(None), - qr_type: str = Form("short"), -) -> RedirectResponse: - logger = logging.getLogger(__name__) - - session = request.session - qr_enabled = bool(generate_qr) - original_url = sanitize_url(original_url) - - # Basic validation (FastAPI can also handle this via Pydantic) - if not original_url or not is_valid_url(original_url): - session["error"] = "Please enter a valid URL." - return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) - - # 1. Try Cache First - short_code: Optional[str] = get_short_from_cache(original_url) - - if not short_code: - # 2. Try Database if connected - if db.is_connected(): - existing = db.find_by_original_url(original_url) - db_code = existing.get("short_code") if existing else None - if isinstance(db_code, str): - short_code = db_code - set_cache_pair(short_code, original_url) - - # 3. Generate New if still None - if not short_code: - short_code = generate_code() - set_cache_pair(short_code, original_url) - - # Only write to database if connected - if db.is_connected(): - db.insert_url(short_code, original_url) - else: - logger.warning(f"Database not connected, URL {short_code} created in cache only") - session["info_message"] = "URL created (database temporarily unavailable)" - - # --- TYPE GUARD FOR MYPY --- - if not isinstance(short_code, str): - session["error"] = "Internal server error: Code generation failed." - return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) - - # Mypy now knows short_code is strictly 'str' - new_short_url = build_short_url(short_code, DOMAIN) - - session.update( - { - "new_short_url": new_short_url, - "qr_enabled": qr_enabled, - "qr_type": qr_type, - "original_url": original_url, - "short_code": short_code, - } - ) - - return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) - - -@app.get("/recent", response_class=HTMLResponse) -async def recent_urls(request: Request): - recent_urls_list = db.get_recent_urls( - MAX_RECENT_URLS - ) or get_recent_from_cache(MAX_RECENT_URLS) - - normalized = [] - for item in recent_urls_list: - normalized.append( - { - "short_code": item.get("short_code"), - "original_url": item.get("original_url"), - "created_at": item.get("created_at"), - "visit_count": item.get("visit_count", 0), - } - ) - - return templates.TemplateResponse( - "recent.html", - { - "request": request, - "urls": normalized, - "format_date": format_date, - }, +# ----------------------------- +# Global error handler +# ----------------------------- +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + traceback.print_exc() + return JSONResponse( + status_code=500, + content={"success": False, "error": "INTERNAL_SERVER_ERROR"}, ) -@app.post("/delete/{short_code}") -async def delete_url(request: Request, short_code: str): - db.delete_by_short_code(short_code) - - cached = url_cache.pop(short_code, None) - if cached: - rev_cache.pop(cached.get("url"), None) - - return PlainTextResponse("", status_code=204) - - -@app.get("/{short_code}") -async def redirect_short(request: Request, short_code: str): - logger = logging.getLogger(__name__) - # Try cache first - cached_url = get_from_cache(short_code) - if cached_url: - return RedirectResponse(cached_url) - - # Check if database is connected - if not db.is_connected(): - logger.warning(f"Database not connected, cannot redirect {short_code}") - return PlainTextResponse( - "Service temporarily unavailable. Please try again later.", - status_code=503, - headers={"Retry-After": "30"} - ) - - # Try database - doc = db.increment_visit(short_code) - if doc: - set_cache_pair(short_code, doc["original_url"]) - return RedirectResponse(doc["original_url"]) - - return PlainTextResponse("Invalid or expired short URL", status_code=404) - - -@app.get("/coming-soon", response_class=HTMLResponse) -async def coming_soon(request: Request): - return templates.TemplateResponse("coming-soon.html", {"request": request}) - - -@app.get("/health") -async def health_check(): - """Health check endpoint showing database and cache status.""" - state = db.get_connection_state() - - response_data = { - "database": state, - "cache": { - "enabled": True, - "size": len(url_cache), - } - } - - status_code = 200 if state["connected"] else 503 - return JSONResponse(content=response_data, status_code=status_code) - - -app.mount("/api", api_app) - - -@app.get("/_debug/cache") -async def debug_cache(): - return { - "url_cache": url_cache, - "rev_cache": rev_cache, - "recent_from_cache": get_recent_from_cache(MAX_RECENT_URLS), - "size": { - "url_cache": len(url_cache), - "rev_cache": len(rev_cache), - }, - } +# ----------------------------- +# Routers (UI + API) +# ----------------------------- +app.include_router(ui_router) # UI routes at "/" diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..88493cc --- /dev/null +++ b/app/routes.py @@ -0,0 +1,377 @@ +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional +from app.utils.cache import list_cache_clean, clear_cache +from fastapi import ( + APIRouter, + Form, + Request, + status, + HTTPException, + BackgroundTasks, + Header, + Query, +) +from fastapi.responses import ( + HTMLResponse, + PlainTextResponse, + RedirectResponse, + JSONResponse, +) +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel, Field + +from app import __version__ +from app.utils import db +from app.utils.cache import ( + get_from_cache, + get_recent_from_cache, + get_short_from_cache, + set_cache_pair, + increment_visit_cache, + url_cache, + remove_cache_key, + rev_cache, +) +from app.utils.config import DOMAIN, MAX_RECENT_URLS, CACHE_PURGE_TOKEN +from app.utils.helper import generate_code, is_valid_url, sanitize_url, format_date +from app.utils.qr import generate_qr_with_logo + +BASE_DIR = Path(__file__).resolve().parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + +# Routers +ui_router = APIRouter() +api_router = APIRouter() +api_v1 = APIRouter(prefix=os.getenv("API_VERSION", "/api/v1"), tags=["v1"]) + + +# ---------------- UI ROUTES ---------------- + + +@ui_router.get("/", response_class=HTMLResponse) +async def index(request: Request): + session = request.session + + new_short_url = session.pop("new_short_url", None) + qr_enabled = session.pop("qr_enabled", False) + qr_type = session.pop("qr_type", "short") + original_url = session.pop("original_url", None) + short_code = session.pop("short_code", None) + info_message = session.pop("info_message", None) + error = session.pop("error", None) + + qr_image = None + qr_data = None + + if qr_enabled and new_short_url and short_code: + qr_data = new_short_url if qr_type == "short" else original_url + qr_filename = f"{short_code}.png" + qr_dir = BASE_DIR / "static" / "qr" + qr_dir.mkdir(parents=True, exist_ok=True) + generate_qr_with_logo(qr_data, str(qr_dir / qr_filename)) + qr_image = f"/static/qr/{qr_filename}" + + recent_urls = db.get_recent_urls(MAX_RECENT_URLS) or get_recent_from_cache( + MAX_RECENT_URLS + ) + + return templates.TemplateResponse( + "index.html", + { + "request": request, + "urls": recent_urls, + "new_short_url": new_short_url, + "qr_image": qr_image, + "qr_data": qr_data, + "qr_enabled": qr_enabled, + "original_url": original_url, + "error": error, + "info_message": info_message, + "db_available": db.get_collection() is not None, + }, + ) + + +@ui_router.post("/shorten", response_class=RedirectResponse) +async def create_short_url( + request: Request, + original_url: str = Form(""), + generate_qr: Optional[str] = Form(None), + qr_type: str = Form("short"), +): + session = request.session + original_url = sanitize_url(original_url) + + if not original_url or not is_valid_url(original_url): + session["error"] = "Please enter a valid URL." + return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) + + short_code: Optional[str] = get_short_from_cache(original_url) + + if not short_code and db.is_connected(): + existing = db.find_by_original_url(original_url) + db_code = (existing.get("short_code") if existing else None) or ( + existing.get("code") if existing else None + ) + if isinstance(db_code, str): + short_code = db_code + set_cache_pair(short_code, original_url) + + if not short_code: + short_code = generate_code() + set_cache_pair(short_code, original_url) + if db.is_connected(): + db.insert_url(short_code, original_url) + + session.update( + { + "new_short_url": f"{DOMAIN.rstrip('/')}/{short_code}", + "short_code": short_code, + "qr_enabled": bool(generate_qr), + "qr_type": qr_type, + "original_url": original_url, + } + ) + + return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) + + +@ui_router.get("/recent", response_class=HTMLResponse) +async def recent_urls(request: Request): + recent_urls_list = db.get_recent_urls(MAX_RECENT_URLS) or get_recent_from_cache( + MAX_RECENT_URLS + ) + + return templates.TemplateResponse( + "recent.html", + { + "request": request, + "urls": recent_urls_list, + "format_date": format_date, + "db_available": db.get_collection() is not None, + "get_visit_count_from_cache": increment_visit_cache, + }, + ) + + +@ui_router.get("/cache/list") +def cache_list_ui(): + return list_cache_clean() + + +@ui_router.delete("/cache/purge", response_class=PlainTextResponse) +def cache_purge_ui(x_cache_token: str = Header(..., alias="X-Cache-Token")): + """ + Force delete everything from cache (secured by header) + """ + if x_cache_token != CACHE_PURGE_TOKEN: + raise HTTPException(status_code=401, detail="Unauthorized") + + if not url_cache and not rev_cache: + return "No URLs in cache" + + clear_cache() + return "cleared ALL" + + +@ui_router.patch("/cache/remove") +def cache_remove_one_ui( + key: str = Query(..., description="short_code OR original_url"), + x_cache_token: str = Header(..., alias="X-Cache-Token"), +): + # πŸ” Header security + if x_cache_token != CACHE_PURGE_TOKEN: + raise HTTPException(status_code=401, detail="Unauthorized") + + removed = remove_cache_key(key) + + if not removed: + raise HTTPException( + status_code=404, + detail="Key not found in cache.", + ) + + return { + "status": "deleted", + } + + +@ui_router.get("/{short_code}") +def redirect_short_ui(short_code: str, background_tasks: BackgroundTasks): + cached_url = get_from_cache(short_code) + if cached_url: + if db.is_connected(): + background_tasks.add_task(db.increment_visit, short_code) + else: + increment_visit_cache(short_code) + return RedirectResponse(cached_url) + + if db.is_connected(): + doc = db.increment_visit(short_code) + if doc and doc.get("original_url"): + set_cache_pair(short_code, doc["original_url"]) + return RedirectResponse(doc["original_url"]) + + recent_cache = get_recent_from_cache(MAX_RECENT_URLS) + for item in recent_cache or []: + code = item.get("short_code") or item.get("code") + if code == short_code: + original_url = item.get("original_url") + if original_url: + set_cache_pair(short_code, original_url) + return RedirectResponse(original_url) + + return PlainTextResponse("Invalid short URL", status_code=404) + + +@ui_router.delete("/recent/{short_code}") +def delete_recent_api(short_code: str): + recent = get_recent_from_cache(MAX_RECENT_URLS) or [] + removed_from_cache = False + + # Try removing from cache (memory only) + for i, item in enumerate(recent): + code = item.get("short_code") or item.get("code") + if code == short_code: + recent.pop(i) + removed_from_cache = True + break + + db_available = db.is_connected() + db_deleted = False + + # If DB available β†’ rely ONLY on DB + if db_available: + db_deleted = db.delete_by_short_code(short_code) + + if not db_deleted: + raise HTTPException( + status_code=404, detail=f"short_code '{short_code}' not found" + ) + + return { + "status": "deleted", + "short_code": short_code, + "db_deleted": True, + "db_available": True, + } + + # If DB NOT available β†’ rely on cache only + if not removed_from_cache: + raise HTTPException( + status_code=404, detail=f"short_code '{short_code}' not found" + ) + + return { + "status": "deleted", + "short_code": short_code, + "db_deleted": False, + "db_available": False, + } + + +# ---------------- API ROUTES ---------------- + + +@api_router.get("/", response_class=HTMLResponse, tags=["Home"]) +async def read_root(_: Request): + return """ + + + πŸŒ™ tiny API πŸŒ™ + + + +
+

πŸš€ tiny API

+

FastAPI backend for the Tiny URL shortener

+ View API Documentation +
+ + + """ + + +@api_router.get("/version") +def api_version(): + return {"version": __version__} + + +class ShortenRequest(BaseModel): + url: str = Field(..., examples=["https://abcdkbd.com"]) + + +@api_v1.post("/shorten") +def shorten_api(payload: ShortenRequest): + original_url = sanitize_url(payload.url) + if not is_valid_url(original_url): + return JSONResponse(status_code=400, content={"error": "INVALID_URL"}) + + short_code = get_short_from_cache(original_url) + if not short_code: + short_code = generate_code() + set_cache_pair(short_code, original_url) + if db.is_connected(): + db.insert_url(short_code, original_url) + + return { + "success": True, + "input_url": original_url, + "short_code": short_code, + "created_on": datetime.now(timezone.utc), + } + + +@api_router.get("/health") +def health(): + return { + "db": db.get_connection_state(), + "cache_size": len(url_cache), + } + + +api_router.include_router(api_v1) diff --git a/app/static/css/tiny.css b/app/static/css/tiny.css index 873f075..aa18fe2 100644 --- a/app/static/css/tiny.css +++ b/app/static/css/tiny.css @@ -16,80 +16,135 @@ body { background-image: radial-gradient(circle at 50% -20%, #1e1e2e 0%, transparent 50%); } -body { - background: var(--bg); - color: var(--text-primary); - font-family: "Inter", system-ui, sans-serif; - margin: 0; - overflow-x: hidden; - background-image: radial-gradient(circle at 50% -20%, #1e1e2e 0%, transparent 50%); -} - /* Light theme overrides */ body.light-theme { + /* background + glass */ --bg: #f9fafb; --glass: rgba(0, 0, 0, 0.03); --glass-border: rgba(0, 0, 0, 0.07); - --accent: #2563eb; + + /* main card + text */ + --card: #ffffff; --text-primary: #111827; --text-secondary: #4b5563; + --text-color: #111827; - /* Remove or soften the dark gradient */ + /* accent */ + --accent: #2563eb; + + /* Remove the dark radial gradient */ background-image: none; - /* clean white background */ - /* Or use a subtle light gradient if you prefer */ - /* background-image: radial-gradient(circle at 50% -20%, #e5e7eb 0%, transparent 50%); */ } /* Layout */ .main-layout { max-width: 900px; margin: 0 auto; - padding: 4rem 1rem; + padding: 6rem 1rem 4rem; display: flex; flex-direction: column; gap: 2rem; } -.site-header { +.page { + padding-top: 6rem; +} + +.app-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 55px; display: flex; - justify-content: space-between; align-items: center; - padding: 1rem 1.5rem; + justify-content: space-between; + padding: 0 10px; + box-sizing: border-box; + background: var(--glass); border-bottom: 1px solid var(--glass-border); - backdrop-filter: blur(10px); + z-index: 1000; } .header-left, .header-right { display: flex; - gap: 1rem; align-items: center; + gap: 12px; } -.header-center { - flex: 1; - text-align: center; +.app-logo { + width: 36px; + height: 36px; + background: linear-gradient(135deg, #2563eb, #5ab9ff); + color: #ffffff; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; } -.logo { - margin: 0; +.app-name { font-size: 1.5rem; font-weight: 700; color: var(--text-primary); } -.icon-btn { - background: none; - border: none; +.header-nav { + display: flex; + gap: 26px; + margin: 0 auto; +} + +.nav-link, +.nav-link:link, +.nav-link:visited { + text-decoration: none; color: var(--text-primary); - font-size: 1.2rem; + font-weight: 500; + position: relative; +} + +body.dark-theme .app-header { + background: linear-gradient(180deg, #0b1220, #050b14); +} + +.dark-theme .nav-link { + color: #e5e7eb; +} + +.nav-link:hover { + color: #2563eb; +} + +.nav-link.active::after { + content: ""; + position: absolute; + bottom: -6px; + left: 0; + width: 100%; + height: 2px; + background: #111827; +} + +.dark-theme .nav-link.active::after { + background: #f8fafc; +} + +.theme-toggle { + background: transparent; + border: none; cursor: pointer; - transition: color 0.3s; + padding: 8px; + border-radius: 8px; + font-weight: 700; + background: var(--glass); + color: var(--text-primary); } -.icon-btn:hover { +.theme-toggle:hover { color: var(--accent); } @@ -289,9 +344,167 @@ body.light-theme { text-overflow: ellipsis; } + +.hero { + text-align: center; + margin: 40px 0; +} + +.hero h1 { + font-size: 42px; + font-weight: 700; +} + + +.recent-page-container { + max-width: 1100px; + margin: 30px auto; + padding: 28px; + background: var(--card); + backdrop-filter: blur(20px); + border: 1px solid var(--glass-border); + border-radius: 20px; + box-shadow: var(--card-shadow); + color: var(--text-color); + transition: background 0.3s ease, border 0.3s ease; +} + +.recent-table-wrapper { + margin-top: 20px; + width: 100%; + overflow-x: auto; +} + +.recent-table { + width: 100%; + border-collapse: collapse; + border-radius: 12px; + overflow: hidden; + table-layout: fixed; +} + +/* Header */ +.recent-table thead { + background: var(--glass); +} + +.recent-table th { + padding: 8px 14px; + text-align: left; + font-size: 13px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 700; + color: var(--muted); + border-bottom: 1px solid var(--glass-border); +} + +/* Body cells */ +.recent-table td { + vertical-align: middle; + padding: 14px; + font-size: 14px; + color: var(--text-primary); + border-bottom: 1px solid var(--glass-border); + transition: 0.25s ease; +} + +/* Row hover */ +.recent-table tbody tr:hover { + background: rgba(255, 255, 255, 0.05); +} + +/* Short link */ +.short-code a { + color: var(--accent); + font-weight: 700; + text-decoration: none; +} + +.short-code a:hover { + color: var(--accent-2); + text-decoration: underline; +} + +/*.original-url, +.original-url a { + white-space: normal; + word-break: break-word; + overflow-wrap: break-word; +}*/ + +.original-url { + max-width: 100%; +} + +.original-url a { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.original-url a:hover { + color: var(--accent); +} + +/* Created time */ +.created-time { + font-size: 13px; + color: var(--muted); + white-space: nowrap; +} + +/* Visit count highlight */ +.recent-table td:nth-child(5) { + font-weight: 700; + color: var(--accent-2); +} + +/* Dark mode adjustments */ +.dark-theme .recent-table th, +.dark-theme .recent-table td { + color: #e5e7eb; + border-bottom: 1px solid var(--glass-border); +} + +/* Action buttons */ +.action-col { + display: flex; + gap: 10px; +} + +.action-btn { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; + font-size: 16px; + transition: 0.2s ease; +} + +.open-btn { + background: #3b82f6; + color: #fff; +} + +.delete-btn { + background: #ef4444; + color: #fff; +} + +.recent-table-wrapper { + margin-bottom: 20px; +} + + + /* Footer */ -.big-footer { - background: rgba(255, 255, 255, 0.01); +footer.big-footer { + background: var(--bg); border-top: 1px solid var(--glass-border); padding: 4rem 1rem 2rem; margin-top: 4rem; @@ -372,6 +585,27 @@ body.light-theme { color: var(--accent); } +/* Dark mode footer adjustments */ +body.dark-theme footer.big-footer { + background: #020617 !important; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +body.dark-theme .footer-col h4 { + color: #f3f4f6; +} + +body.dark-theme .footer-col p, +body.dark-theme .footer-col ul li a, +body.dark-theme .footer-bottom { + color: #cbd5e1; +} + +body.dark-theme .footer-col ul li a:hover, +body.dark-theme .footer-bottom a { + color: #a5f3fc; +} + /* Responsive adjustments */ @media (max-width: 900px) { .footer-grid { @@ -412,4 +646,4 @@ body.light-theme { gap: 1rem; text-align: center; } -} +} \ No newline at end of file diff --git a/app/static/style.css b/app/static/style.css index b754687..8dd7671 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1,815 +1,1063 @@ -html, -body { - height: 100%; - margin: 0; - font-family: Arial; - padding: 0; - font-family: "Poppins", system-ui, Arial, sans-serif; - background: var(--bg); - background-size: cover; - background-position: center; - background-size: cover; - background-position: center; -} -input { - width: 70%; - margin-top: 2px; - margin-bottom: 2px; - font-size: 16px; -} -.admin-box { - margin: 120px auto 60px; - /* space from header + footer */ -} -.app-layout { - min-height: 100vh; - display: flex; - flex-direction: column; - margin-top: var(--header-height); -} -button { - padding: 8px; - margin: 5px; -} -.error-box { - margin-bottom: 15px; - padding: 10px; - color: #ff4d4d; - border-radius: 8px; - font-weight: 600; -} - -.dark-theme h1 { - background: linear-gradient(90deg, #ffffff, #dddddd, #ffffff); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - text-shadow: 0px 0px 10px rgba(255, 255, 255, 0.4); -} - -.dark-theme p { - background: linear-gradient(90deg, #ffffff, #dddddd, #ffffff); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - text-shadow: 0px 0px 10px rgba(255, 255, 255, 0.4); -} -.dark-theme { - --bg-overlay: rgba(0, 0, 0, 0.75); - --glass-bg: rgba(0, 0, 0, 0.4); - --text-color: #fff; - --input-bg: rgba(50, 50, 50, 0.8); - --input-text-color: #fff; -} - -@keyframes pop { - 0% { - transform: scale(0.7); - opacity: 0; - } - 100% { - transform: scale(1); - opacity: 1; - } -} -/* INPUT CONTAINER */ -.input-field { - flex: 1 1 700px; - display: flex; - align-items: center; - gap: 12px; - border-radius: 12px; - border: 2px solid rgb(6, 0, 0); - background: transparent; /* IMPORTANT */ - padding: 12px 12px; -} -.dark-theme .input-field { - border-color: #ffffff; -} -/* INPUT ITSELF */ -.input-field input[type="text"] { - width: 100%; - border: none; - outline: none; - background-color: transparent !important; - background-image: none !important; - box-shadow: none !important; - font-size: 23px; -} - -.input-field input { - color: #000 !important; -} - -.dark-theme .input-field input { - color: #fff !important; -} - -.input-field input:-webkit-autofill, -.input-field input:-webkit-autofill:hover, -.input-field input:-webkit-autofill:focus, -.input-field input:-webkit-autofill:active { - -webkit-box-shadow: 0 0 0 1000px transparent inset !important; - box-shadow: 0 0 0 1000px transparent inset !important; - background-color: transparent !important; - background-image: none !important; - transition: background-color 9999s ease-in-out 0s; -} - -.input-field input:-webkit-autofill { - -webkit-text-fill-color: #000 !important; -} - -.dark-theme .input-field input:-webkit-autofill { - -webkit-text-fill-color: #fff !important; -} -.input-field input::selection, -.input-field input::-moz-selection { - background: transparent; - color: inherit; -} -.short-code { - color: #0a0000; /* blue like links */ - font-weight: 700; -} - -.app-header { - position: fixed; - top: 0; - left: 0; - width: 97%; - height: 55px; - background: white; - display: flex; - align-items: center; - padding: 0 28px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); - z-index: 1000; -} - -/* Dark mode */ -.dark-theme .app-header { - background: linear-gradient(180deg, #0b1220, #050b14); -} - -footer { - margin-top: 0; -} - -body.dark-theme, -body.dark-theme .page, -body.dark-theme main, -body.dark-theme section { - background: #0f1720 !important; -} - -.header-left { - display: flex; - align-items: center; - gap: 12px; -} - -.app-logo { - width: 36px; - height: 36px; - background: linear-gradient(135deg, #2563eb, #5ab9ff); - color: white; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; -} - -.app-name { - font-size: 20px; - font-weight: 700; - color: #111827; -} - -.dark-theme .app-name { - color: #f8fafc; -} - -.header-nav { - position: absolute; - left: 50%; - transform: translateX(-50%); - display: flex; - gap: 26px; -} - -.nav-link { - text-decoration: none; - color: #111827; - font-weight: 500; - position: relative; -} - -.dark-theme .nav-link { - color: #e5e7eb; -} - -.nav-link:hover { - color: #2563eb; -} - -.nav-link.active::after { - content: ""; - position: absolute; - bottom: -6px; - left: 0; - width: 100%; - height: 2px; - background: #111827; -} - -.dark-theme .nav-link.active::after { - background: #f8fafc; -} - -.header-right { - margin-left: auto; - display: flex; - align-items: center; -} - -:root { - --header-height: 55px; - --bg: #eefaf8; - --card: rgba(255, 255, 255, 0.95); - --muted: #7b8b8a; - --accent-1: #5ab9ff; - --accent-2: #4cb39f; - --accent-grad: linear-gradient(90deg, #4cb39f, #5ab9ff); - --success: #2fb06e; - --glass: rgba(255, 255, 255, 0.85); -} - -.dark-theme { - --bg-overlay: rgba(0, 0, 0, 0.75); - --glass-bg: rgba(0, 0, 0, 0.4); - --text-color: #f3f3f3; - --input-bg: rgba(11, 10, 10, 0.8); - --button-bg: linear-gradient(90deg, #4444ff, #2266ff); - --recent-bg: rgba(255, 255, 255, 0.1); -} - -/* Preserve your dark theme variables too */ -body.dark-theme { - --bg: #0f1720; - --card: rgba(10, 14, 18, 0.92); - --muted: #9aa7a6; -} - -.page { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; - padding: 2rem; - min-height: 80vh; -} - -.theme-toggle { - background: transparent; - border: none; - cursor: pointer; - padding: 8px; - border-radius: 8px; - font-weight: 700; - background: var(--card); -} - -/* Hero */ -.hero { - width: 100%; - max-width: 1100px; - background: transparent; - text-align: center; - padding: 10px; -} - -.hero h1 { - margin: 10px 0 14px; - font-size: 36px; - line-height: 1.05; - color: #000606; -} - -.hero p { - margin: var(--bg-overlay); - color: var(--muted); - max-width: 820px; - margin-left: auto; - margin-right: auto; - color: #000606; -} - -/* Main card & input */ -.card { - width: 100%; - max-width: 1100px; - background: var(--card); - border-radius: 14px; - padding: 15px; - box-shadow: 0 18px 50px rgba(8, 24, 24, 0.06); -} - -.cta { - min-width: 220px; - padding: 14px 22px; - border-radius: 12px; - border: none; - color: rgb(12, 1, 1); - font-weight: 700; - cursor: pointer; - background: var(--accent-grad); - box-shadow: 0 12px 28px rgba(77, 163, 185, 0.12); -} - -.small-action { - display: flex; - align-items: center; - gap: 8px; - color: var(--muted); - margin-top: 10px; -} - -.result { - margin-top: 26px; - background: white; - border-radius: 12px; - padding: 20px; - border: 1px solid rgba(22, 60, 55, 0.03); - box-shadow: 0 8px 28px rgba(7, 20, 20, 0.03); -} - -.result-header { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 12px; -} - -.result-header .dot { - width: 30px; - height: 30px; - background: var(--success); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - color: white; - font-weight: 700; -} - -.short-actions { - display: flex; - justify-content: center; - align-items: center; - gap: 8px; - padding: 10px 14px; - border-radius: 12px; - background: linear-gradient(180deg, rgba(75, 194, 176, 0.06), rgba(94, 207, 255, 0.04)); -} - -.short-box input { - align-items: center; - padding: 10px; - font-size: 15px; -} - -.btn-copy { - border: none; - padding: 10px 14px; - border-radius: 8px; - color: white; - font-weight: 700; - cursor: pointer; -} - -.btn-share { - background: #f2f5f5; - border: none; - padding: 10px 14px; - border-radius: 8px; - color: #0b2b2a; - font-weight: 700; - cursor: pointer; - margin-left: 6px; -} - -.meta-row { - align-items: center; - justify-content: center; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 2px; - padding: 16px; - margin-top: 1px; - align-items: top; - color: black; -} -.result-body { - margin-top: 30px; - - display: flex; - flex-direction: column; - align-items: center; - text-align: center; -} - -.qr-block { - text-align: center; - padding-top: 8px; -} - -.qr-block img { - height: 15rem; - align-items: center; - aspect-ratio: 1; - box-shadow: 0 10px 20px rgba(10, 20, 30, 0.06); - outline: 2px solid green; - outline-offset: 4px; -} - -.download-qr { - display: inline-block; - margin-top: 12px; - text-decoration: none; - color: var(--accent-1); - font-weight: 700; -} - -.action-row { - display: flex; - justify-content: right; - align-items: right; -} - -.action-secondary { - background: #f6fbfb; - border: 1px solid rgba(0, 0, 0, 0.03); - border-radius: 10px; - cursor: pointer; - font-weight: 700; -} - -/* Force Generate QR to stay on one line */ -.qr-inline { - display: inline-flex; - align-items: center; - gap: 8px; - white-space: nowrap; -} - -.qr-inline input { - margin: 0; -} - -/* Responsive */ -@media (max-width: 880px) { - .input-row { - flex-direction: column; - } - - .cta { - width: 100%; - } - - .meta-row { - grid-template-columns: 1fr; - } -} -.result-title { - font-weight: 700; - color: #0e34f6; -} - -.dark-theme .result-title { - color: #150cff; -} - -footer { - min-height: auto; -} - -.app-footer { - background: white; - color: #e5e7eb; - padding: 8px 10px; - margin-top: auto; - position: relative; -} -.dark-theme .app-footer { - background: linear-gradient(180deg, #0b1220, #050b14); -} - -.footer-container { - margin: auto; - display: flex; - gap: 60px; - justify-content: space-between; - flex-wrap: wrap; -} - -.footer-brand { - max-width: 420px; -} - -.footer-logo { - width: 42px; - height: 42px; - background: linear-gradient(135deg, #2563eb, #5ab9ff); - border-radius: 14px; - display: flex; - align-items: center; - justify-content: center; - font-size: 22px; -} -.dark-theme .footer-brand h3, -.dark-theme .footer-brand p, -.dark-theme .footer-col h4, -.dark-theme .app-footer a, -.dark-theme .footer-bottom { - color: #f8fafc; -} - -.footer-brand h3 { - margin: 0; - color: #000000; - font-size: 22px; - font-weight: 700; -} - -.footer-brand p { - margin-top: 8px; - color: #000000; - line-height: 1.6; - font-size: 14px; -} - -/* MAIN CONTENT */ - -/* FOOTER */ -.app-footer { - margin-top: auto; -} - -/* GitHub button */ -.github-btn { - display: inline-flex; - align-items: center; - gap: 2px; - margin-top: 1px; - padding: 10px 16px; - border-radius: 8px; - background: rgba(255, 255, 255, 0.06); - color: #000000; - text-decoration: none; - font-weight: 600; - transition: all 0.25s ease; -} - -.github-btn:hover { - background: black(11, 1, 1); - transform: translateY(-2px); -} - -.footer-links { - display: flex; - gap: 80px; - flex-wrap: wrap; -} - -.footer-col h4 { - margin-bottom: 14px; - font-size: 16px; - color: #000000; - font-weight: 700; -} - -.footer-col a { - display: block; - text-decoration: none; - color: #000000; - margin-bottom: 10px; - font-size: 14px; - transition: color 0.2s ease; -} - -.footer-col a:hover { - text-decoration: underline; -} - -/* Bottom */ -.footer-bottom { - margin-top: 10px; - border-top: 1px solid rgba(255, 255, 255, 0.153); - padding-top: 8px; - padding-bottom: 1px; - text-align: center; - font-size: 14px; - color: #080808; -} -.footer-bottom a { - color: #030000; - font-weight: 600; - text-decoration: none; -} - -.footer-bottom a:hover { - text-decoration: underline; -} - -/* Responsive */ -@media (max-width: 768px) { - .footer-container { - flex-direction: column; - gap: 40px; - } - - .footer-links { - gap: 40px; - } -} -/* REMOVE white line above footer in dark mode */ -footer { - margin-top: 0 !important; -} -.recent-table-wrapper { - margin-top: 20px; - width: 100%; - overflow-x: auto; -} - -.recent-table { - width: 100%; - border-collapse: collapse; - border-radius: 12px; - overflow: hidden; -} - -.recent-table thead { - background: rgb(0, 0, 0); -} -.recent-table th { - color: rgb(0, 0, 0); - padding: 8px 14px; - text-align: left; - font-size: 16px; -} -.short-code a { - color: #2563eb; - font-weight: 600; - text-decoration: none; -} - -.short-code a:hover { - color: #1d4ed8; - text-decoration: underline; -} -.recent-table td { - color: rgb(34, 48, 77); - padding: 10px 14px; - text-align: left; - font-size: 14px; -} - -.created-time { - font-size: 14px; - color: #374151; - white-space: nowrap; -} - -.time-ago { - color: #374151; - font-size: 13px; - margin-left: 2px; -} -.recent-table th { - font-weight: 700; -} - -.recent-table tbody tr, -th { - background: rgb(255, 255, 255); - border-bottom: 1px solid rgb(0, 0, 0); -} -.dark-theme.recent-table tbody tr, -td { - background: rgba(255, 255, 255, 0.04); - border-bottom: 1px solid rgb(0, 0, 0); -} -.recent-table tbody tr:hover { - background: rgb(196, 196, 196); -} - -/* Short code */ -.short-code { - font-weight: 700; -} - -.original-url { - color: #22c55e; - word-break: break-all; -} - -/* Action buttons */ -.action-col { - display: flex; - gap: 10px; -} - -.action-btn { - width: 36px; - height: 36px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - text-decoration: none; - font-size: 16px; - transition: 0.2s ease; -} - -.open-btn { - background: #3b82f6; - color: #fff; -} - -.delete-btn { - background: #ef4444; - color: #fff; -} - -.recent-table-wrapper { - margin-bottom: 20px; -} -/* ========================= - Coming Soon Page -========================= */ - -.coming-soon-page { - display: flex; - align-items: center; - justify-content: center; - padding: 120px 20px 60px; -} - -.coming-soon-card { - max-width: 520px; - width: 100%; - background: var(--card); - border-radius: 16px; - padding: 50px 40px; - text-align: center; - box-shadow: 0 20px 50px rgba(0, 0, 0, 0.08); -} - -.coming-icon { - font-size: 48px; - margin-bottom: 18px; -} - -.coming-soon-card h1 { - font-size: 34px; - margin-bottom: 14px; - color: #000; -} - -.dark-theme .coming-soon-card h1 { - color: #fff; -} - -.coming-soon-card p { - font-size: 15px; - color: var(--muted); - line-height: 1.6; - margin-bottom: 28px; -} - -.coming-btn { - display: inline-block; - padding: 12px 22px; - border-radius: 10px; - background: var(--accent-grad); - color: #fff; - font-weight: 700; - text-decoration: none; - transition: 0.25s ease; -} - -.coming-btn:hover { - transform: scale(1.05); - box-shadow: 0 12px 28px rgba(77, 163, 185, 0.25); -} -.info-box { - margin-bottom: 15px; - padding: 10px; - color: #0e34f6; - border-radius: 8px; - font-weight: 700; -} +html, +body { + height: 100%; + margin: 0; + font-family: Arial; + padding: 0; + font-family: "Poppins", system-ui, Arial, sans-serif; + background: var(--bg); + background-size: cover; + background-position: center; + background-size: cover; + background-position: center; +} + +input { + width: 70%; + margin-top: 2px; + margin-bottom: 2px; + font-size: 16px; +} + +.admin-box { + margin: 120px auto 60px; + /* space from header + footer */ +} + +.app-layout { + min-height: 100vh; + display: flex; + flex-direction: column; + margin-top: var(--header-height); +} + +button { + padding: 8px; + margin: 5px; +} + +.error-box { + margin-bottom: 15px; + padding: 10px; + color: #ff4d4d; + border-radius: 8px; + font-weight: 600; +} + +.dark-theme h1 { + background: linear-gradient(90deg, #ffffff, #dddddd, #ffffff); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + text-shadow: 0px 0px 10px rgba(255, 255, 255, 0.4); +} + +.dark-theme p { + background: linear-gradient(90deg, #ffffff, #dddddd, #ffffff); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + text-shadow: 0px 0px 10px rgba(255, 255, 255, 0.4); +} + +.dark-theme { + --bg-overlay: rgba(0, 0, 0, 0.75); + --glass-bg: rgba(0, 0, 0, 0.4); + --text-color: #fff; + --input-bg: rgba(50, 50, 50, 0.8); + --input-text-color: #fff; +} + +@keyframes pop { + 0% { + transform: scale(0.7); + opacity: 0; + } + + 100% { + transform: scale(1); + opacity: 1; + } +} + +/* INPUT CONTAINER */ +.input-field { + flex: 1 1 700px; + display: flex; + align-items: center; + gap: 12px; + border-radius: 12px; + border: 2px solid rgb(6, 0, 0); + background: transparent; + /* IMPORTANT */ + padding: 12px 12px; +} + +.dark-theme .input-field { + border-color: #ffffff; +} + +/* INPUT ITSELF */ +.input-field input[type="text"] { + width: 100%; + border: none; + outline: none; + background-color: transparent !important; + background-image: none !important; + box-shadow: none !important; + font-size: 23px; +} + +.input-field input { + color: #000 !important; +} + +.dark-theme .input-field input { + color: #fff !important; +} + +.input-field input:-webkit-autofill, +.input-field input:-webkit-autofill:hover, +.input-field input:-webkit-autofill:focus, +.input-field input:-webkit-autofill:active { + -webkit-box-shadow: 0 0 0 1000px transparent inset !important; + box-shadow: 0 0 0 1000px transparent inset !important; + background-color: transparent !important; + background-image: none !important; + transition: background-color 9999s ease-in-out 0s; +} + +.input-field input:-webkit-autofill { + -webkit-text-fill-color: #000 !important; +} + +.dark-theme .input-field input:-webkit-autofill { + -webkit-text-fill-color: #fff !important; +} + +.input-field input::selection, +.input-field input::-moz-selection { + background: transparent; + color: inherit; +} + +.short-code { + color: #0a0000; + /* blue like links */ + font-weight: 700; +} + +footer { + margin-top: 0; +} + +body.dark-theme, +body.dark-theme .page, +body.dark-theme main, +body.dark-theme section { + background: #0f1720 !important; +} + +/*:root { + --header-height: 55px; + --bg: #eefaf8; + --card: rgba(255, 255, 255, 0.95); + --muted: #7b8b8a; + --accent-1: #5ab9ff; + --accent-2: #4cb39f; + --accent-grad: linear-gradient(90deg, #4cb39f, #5ab9ff); + --success: #2fb06e; + --glass: rgba(255, 255, 255, 0.85); +}*/ + +:root { + /* Background */ + --bg: #eefaf8; + --card: rgba(255, 255, 255, 0.85); + + /* Text */ + --text-color: #1f2937; + --text-muted: #6b7280; + + /* Borders */ + --glass-border: rgba(0, 0, 0, 0.08); + + /* Accent */ + --accent-1: #5ab9ff; + --accent-2: #4cb39f; + + /* Shadow */ + --card-shadow: 0 20px 60px rgba(0, 0, 0, 0.12); + --text-primary: var(--text-color); + --text-secondary: var(--text-muted); + --accent: var(--accent-1); +} + +.dark-theme { + --bg-overlay: rgba(0, 0, 0, 0.75); + --glass-bg: rgba(0, 0, 0, 0.4); + --text-color: #f3f3f3; + --input-bg: rgba(11, 10, 10, 0.8); + --button-bg: linear-gradient(90deg, #4444ff, #2266ff); + --recent-bg: rgba(255, 255, 255, 0.1); +} + +/* Preserve your dark theme variables too */ +body.dark-theme { + --bg: #0f1720; + --card: rgba(20, 25, 30, 0.75); + + --text-color: #e5e7eb; + --text-muted: #9aa7a6; + + --glass-border: rgba(255, 255, 255, 0.08); + + --card-shadow: 0 20px 60px rgba(0, 0, 0, 0.6); +} + +.page { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 2rem; + min-height: 80vh; +} + +.theme-toggle { + background: transparent; + border: none; + cursor: pointer; + padding: 8px; + border-radius: 8px; + font-weight: 700; + background: var(--card); +} + +/* Hero */ +.hero { + width: 100%; + max-width: 1100px; + background: transparent; + text-align: center; + padding: 10px; +} + +.hero h1 { + margin: 10px 0 14px; + font-size: 36px; + line-height: 1.05; + color: #000606; +} + +.hero p { + margin: var(--bg-overlay); + color: var(--muted); + max-width: 820px; + margin-left: auto; + margin-right: auto; + color: #000606; +} + +/* Main card & input */ +.card { + width: 100%; + max-width: 1100px; + background: var(--card); + border-radius: 14px; + padding: 15px; + box-shadow: 0 18px 50px rgba(8, 24, 24, 0.06); +} + +.cta { + min-width: 220px; + padding: 14px 22px; + border-radius: 12px; + border: none; + color: rgb(12, 1, 1); + font-weight: 700; + cursor: pointer; + background: var(--accent-grad); + box-shadow: 0 12px 28px rgba(77, 163, 185, 0.12); +} + +.small-action { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + margin-top: 10px; +} + +.result { + margin-top: 26px; + background: white; + border-radius: 12px; + padding: 20px; + border: 1px solid rgba(22, 60, 55, 0.03); + box-shadow: 0 8px 28px rgba(7, 20, 20, 0.03); +} + +.result-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.result-header .dot { + width: 30px; + height: 30px; + background: var(--success); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 700; +} + +.short-actions { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-radius: 12px; + background: linear-gradient(180deg, rgba(75, 194, 176, 0.06), rgba(94, 207, 255, 0.04)); +} + +.short-box input { + align-items: center; + padding: 10px; + font-size: 15px; +} + +.btn-copy { + border: none; + padding: 10px 14px; + border-radius: 8px; + color: white; + font-weight: 700; + cursor: pointer; +} + +.btn-share { + background: #f2f5f5; + border: none; + padding: 10px 14px; + border-radius: 8px; + color: #0b2b2a; + font-weight: 700; + cursor: pointer; + margin-left: 6px; +} + +.meta-row { + align-items: center; + justify-content: center; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2px; + padding: 16px; + margin-top: 1px; + align-items: top; + color: black; +} + +.result-body { + margin-top: 30px; + + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.qr-block { + text-align: center; + padding-top: 8px; +} + +.qr-block img { + height: 15rem; + align-items: center; + aspect-ratio: 1; + box-shadow: 0 10px 20px rgba(10, 20, 30, 0.06); + outline: 2px solid green; + outline-offset: 4px; +} + +.download-qr { + display: inline-block; + margin-top: 12px; + text-decoration: none; + color: var(--accent-1); + font-weight: 700; +} + +.action-row { + display: flex; + justify-content: right; + align-items: right; +} + +.action-secondary { + background: #f6fbfb; + border: 1px solid rgba(0, 0, 0, 0.03); + border-radius: 10px; + cursor: pointer; + font-weight: 700; +} + +/* Force Generate QR to stay on one line */ +.qr-inline { + display: inline-flex; + align-items: center; + gap: 8px; + white-space: nowrap; +} + +.qr-inline input { + margin: 0; +} + +/* Responsive */ +@media (max-width: 880px) { + .input-row { + flex-direction: column; + } + + .cta { + width: 100%; + } + + .meta-row { + grid-template-columns: 1fr; + } +} + +.result-title { + font-weight: 700; + color: #0e34f6; +} + +.dark-theme .result-title { + color: #150cff; +} + +footer { + min-height: auto; +} + +/* /* =============================== + MODERN UI STYLE FOOTER (VISIT PAGE) +================================= */ + +.app-footer { + background: rgba(255, 255, 255, 0.02); + backdrop-filter: blur(16px); + border-top: 1px solid var(--glass-border); + padding: 2.5rem 1rem 1.2rem; + /* reduced space */ + margin-top: 40px; +} + +/* Container */ +.footer-container { + max-width: 1100px; + margin: 0 auto; + + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 1.8rem; +} + +/* Footer columns */ +.footer-col h4 { + font-size: 14px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 16px; + color: var(--text-primary); +} + +.footer-col p { + color: var(--text-secondary); + font-size: 14px; + line-height: 1.6; +} + +.footer-col ul { + list-style: none; + padding: 0; + margin: 0; +} + +.footer-col ul li { + margin-bottom: 10px; +} + +.footer-col ul li a { + color: var(--text-secondary); + text-decoration: none; + font-size: 14px; + transition: 0.2s ease; +} + +.footer-col ul li a:hover { + color: var(--accent); +} + +/* Footer bottom */ +.footer-bottom { + /* margin: 3rem auto 0; + padding-top: 20px; + + display: flex; + justify-content: space-between; + align-items: center; + + border-top: 1px solid var(--glass-border); + font-size: 14px; + color: var(--text-secondary); */ + + max-width: 1200px; + margin: 2rem auto 0; + padding-top: 1rem; + border-top: 1px solid var(--glass-border); + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + color: var(--text-secondary); + font-size: 0.8rem; + +} + +/* Version + GitHub area */ +.footer-bottom a { + color: var(--accent); + text-decoration: none; + transition: 0.2s ease; +} + +.footer-bottom a:hover { + opacity: 0.8; +} + +/* =============================== + DARK MODE SUPPORT +================================= */ + +.dark-theme .app-footer { + background: #0a0a0c; + backdrop-filter: blur(16px); + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.dark-theme .footer-col h4 { + color: #f3f4f6; +} + +.dark-theme .footer-col p, +.dark-theme .footer-col ul li a, +.dark-theme .footer-bottom { + color: #cbd5e1; +} + +.dark-theme .footer-col ul li a:hover, +.dark-theme .footer-bottom a { + color: var(--accent-2); +} + +/* =============================== + MOBILE RESPONSIVE +================================= */ + +@media (max-width: 900px) { + .footer-container { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 600px) { + .footer-container { + grid-template-columns: 1fr; + } + + .footer-bottom { + flex-direction: column; + gap: 10px; + text-align: center; + } +} + +/* REMOVE white line above footer in dark mode */ +footer { + margin-top: 0 !important; +} + +*/ +/* ===================================== + BIG FOOTER STYLE (FOR FIRST PAGE) + Using existing class names +===================================== */ + +/*Footer wrapper +.app-footer { + background: rgba(255, 255, 255, 0.01); + border-top: 1px solid var(--glass-border); + padding: 4rem 1rem 2rem; + margin-top: 4rem; +} + +/* Grid container */ +.footer-container { + max-width: 1200px; + margin: 0 auto; + + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 2rem; +} + +/* Brand column (first column) */ +.footer-container>div:first-child h4 { + font-size: 1.5rem; + margin-bottom: 1rem; +} + +.footer-container>div:first-child p { + color: var(--text-secondary); + line-height: 1.6; + max-width: 320px; +} + +.footer-brand h3 { + font-size: 1.5rem; + margin-bottom: 1rem; +} + +.footer-brand p { + color: var(--text-secondary); + line-height: 1.6; + max-width: 320px; +} + +/* Other footer columns */ +.footer-col h4 { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.1em; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.footer-col ul { + list-style: none; + padding: 0; + margin: 0; +} + +.footer-col ul li { + margin-bottom: 0.8rem; +} + +.footer-col ul li a { + color: var(--text-secondary); + text-decoration: none; + font-size: 0.9rem; + transition: color 0.2s ease; +} + +.footer-col ul li a:hover { + color: var(--accent); +} + +/* Bottom row */ +.footer-bottom { + max-width: 1200px; + margin: 2rem auto 0; + padding-top: 1rem; + border-top: 1px solid var(--glass-border); + + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + + color: var(--text-secondary); + font-size: 0.8rem; +} + +/* Footer links inside bottom */ +.footer-bottom a { + color: inherit; + text-decoration: none; + transition: color 0.2s ease; +} + +.footer-bottom a:hover { + color: var(--accent); +} + +/* Responsive */ +@media (max-width: 900px) { + .footer-container { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 600px) { + .footer-container { + grid-template-columns: 1fr; + } + + .footer-bottom { + flex-direction: column; + gap: 1rem; + text-align: center; + } +} + +*/ + +/* Footer */ +.big-footer { + background: rgba(255, 255, 255, 0.01); + border-top: 1px solid var(--glass-border); + padding: 4rem 1rem 2rem; + margin-top: 4rem; +} + +.footer-grid { + max-width: 1200px; + margin: 0 auto; + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 2rem; +} + +.footer-brand h3 { + font-size: 1.5rem; + margin-bottom: 1rem; +} + +.footer-brand p { + color: var(--text-secondary); + line-height: 1.6; + max-width: 320px; +} + +.footer-col h4 { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.1em; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.footer-col ul { + list-style: none; + padding: 0; + margin: 0; +} + +.footer-col ul li { + margin-bottom: 0.8rem; +} + +.footer-col ul li a { + color: var(--text-secondary); + text-decoration: none; + font-size: 0.9rem; + transition: color 0.2s; +} + +.footer-col ul li a:hover { + color: var(--accent); +} + +.footer-bottom { + max-width: 1200px; + margin: 2rem auto 0; + padding-top: 1rem; + border-top: 1px solid var(--glass-border); + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + color: var(--text-secondary); + font-size: 0.8rem; +} + +.footer-meta { + display: flex; + gap: 1rem; +} + +.footer-meta a { + color: inherit; + text-decoration: none; +} + +.footer-meta a:hover { + color: var(--accent); +} + +/* Responsive adjustments */ +@media (max-width: 900px) { + .footer-grid { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 700px) { + .result-card { + flex-direction: column; + align-items: flex-start; + } + + .result-actions { + align-items: flex-start; + } + + .recent-item { + min-width: 180px; + } +} + +@media (max-width: 600px) { + .hero-input-card h1 { + font-size: 2rem; + } + + .short-url a { + font-size: 1.2rem; + } + + .footer-grid { + grid-template-columns: 1fr; + } + + .footer-bottom { + flex-direction: column; + gap: 1rem; + text-align: center; + } +} + +/*=============================== + MODERN GLASS RECENT TABLE +================================ */ + +.recent-page-container { + width: 100%; + max-width: 1100px; + margin: 30px auto; + padding: 28px; + + background: var(--card); + backdrop-filter: blur(20px); + + border: 1px solid var(--glass-border); + border-radius: 20px; + + box-shadow: var(--card-shadow); + color: var(--text-color); + + transition: background 0.3s ease, border 0.3s ease; +} + +.recent-table-wrapper { + margin-top: 20px; + width: 100%; + overflow-x: auto; +} + +/* Table */ +.recent-table { + width: 100%; + border-collapse: collapse; + border-radius: 12px; + overflow: hidden; +} + +/* Header */ +.recent-table thead { + background: var(--glass); +} + +.recent-table th { + padding: 8px 14px; + text-align: left; + font-size: 13px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 700; + color: var(--muted); + border-bottom: 1px solid var(--glass-border); +} + +/* Body cells */ +.recent-table td { + padding: 14px; + font-size: 14px; + color: var(--text-primary); + border-bottom: 1px solid var(--glass-border); + transition: 0.25s ease; +} + +/* Row hover */ +.recent-table tbody tr:hover { + background: rgba(255, 255, 255, 0.05); +} + +/* Short link */ +.short-code a { + color: var(--accent); + font-weight: 700; + text-decoration: none; +} + +.short-code a:hover { + color: var(--accent-2); + text-decoration: underline; +} + +/* Original URL */ +.original-url { + word-break: break-all; +} + +.original-url a { + color: var(--text-secondary); + text-decoration: none; +} + +.original-url a:hover { + color: var(--accent); +} + +/* Created time */ +.created-time { + font-size: 13px; + color: var(--muted); + white-space: nowrap; +} + +/* Visit count highlight */ +.recent-table td:nth-child(5) { + font-weight: 700; + color: var(--accent-2); +} + +/* Dark mode adjustments */ +.dark-theme .recent-table th, +.dark-theme .recent-table td { + color: #e5e7eb; + border-bottom: 1px solid var(--glass-border); +} + +/* Action buttons */ +.action-col { + display: flex; + gap: 10px; +} + +.action-btn { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; + font-size: 16px; + transition: 0.2s ease; +} + +.open-btn { + background: #3b82f6; + color: #fff; +} + +.delete-btn { + background: #ef4444; + color: #fff; +} + +.recent-table-wrapper { + margin-bottom: 20px; +} + + +/* ========================= + Coming Soon Page +========================= */ + +.coming-soon-page { + display: flex; + align-items: center; + justify-content: center; + padding: 120px 20px 60px; +} + +.coming-soon-card { + max-width: 520px; + width: 100%; + background: var(--card); + border-radius: 16px; + padding: 50px 40px; + text-align: center; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.08); +} + +.coming-icon { + font-size: 48px; + margin-bottom: 18px; +} + +.coming-soon-card h1 { + font-size: 34px; + margin-bottom: 14px; + color: #000; +} + +.dark-theme .coming-soon-card h1 { + color: #fff; +} + +.coming-soon-card p { + font-size: 15px; + color: var(--muted); + line-height: 1.6; + margin-bottom: 28px; +} + +.coming-btn { + display: inline-block; + padding: 12px 22px; + border-radius: 10px; + background: var(--accent-grad); + color: #fff; + font-weight: 700; + text-decoration: none; + transition: 0.25s ease; +} + +.coming-btn:hover { + transform: scale(1.05); + box-shadow: 0 12px 28px rgba(77, 163, 185, 0.25); +} + +.info-box { + margin-bottom: 15px; + padding: 10px; + color: #0e34f6; + border-radius: 8px; + font-weight: 700; +} \ No newline at end of file diff --git a/app/templates/footer.html b/app/templates/footer.html new file mode 100644 index 0000000..84e04aa --- /dev/null +++ b/app/templates/footer.html @@ -0,0 +1,41 @@ + + diff --git a/app/templates/header.html b/app/templates/header.html index c7b19b1..c5f3b30 100644 --- a/app/templates/header.html +++ b/app/templates/header.html @@ -1,11 +1,16 @@ -