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 π
-
-
-
-
-
-
- """
-
-
-@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 π
+
+
+
+
+
+
+ """
+
+
+@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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Recent Shortened URLs
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/templates/recent.html b/app/templates/recent.html
index 26c5d69..594db31 100644
--- a/app/templates/recent.html
+++ b/app/templates/recent.html
@@ -1,210 +1,82 @@
-
-
-
-