Auto-update: Wed 29 Jan 2025 04:53:27 AM UTC

This commit is contained in:
Sangye Ince-Johannsen 2025-01-29 04:53:27 +00:00
parent 3af341cd0d
commit d8195753ba
3 changed files with 102 additions and 427 deletions
smurl.py
static/css
templates

146
smurl.py
View file

@ -1,18 +1,12 @@
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse
from fastapi import FastAPI, HTTPException, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, HttpUrl
import sqlite3
import string
import random
from datetime import datetime, timedelta
import urllib.parse
from typing import List
app = FastAPI(title="SMURL")
host = "0.0.0.0"
port = 7997
templates = Jinja2Templates(directory="templates")
app.mount("/static", StaticFiles(directory="static"), name="static")
@ -26,7 +20,7 @@ def init_db():
original_url TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)''')
conn.execute('''
CREATE TABLE IF NOT EXISTS clicks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -39,88 +33,54 @@ def generate_short_code(length=6):
chars = string.ascii_letters + string.digits
return ''.join(random.choice(chars) for _ in range(length))
def get_click_stats(url_id: int, timeframe: str) -> List[dict]:
with sqlite3.connect('urls.db') as conn:
now = datetime.now()
if timeframe == 'day':
interval = 'strftime("%Y-%m-%d %H:00:00", clicked_at)'
start_time = now - timedelta(days=1)
elif timeframe == 'week':
interval = 'strftime("%Y-%m-%d", clicked_at)'
start_time = now - timedelta(weeks=1)
elif timeframe == 'year':
interval = 'strftime("%Y-%m", clicked_at)'
start_time = now - timedelta(days=365)
else: # all time
interval = 'strftime("%Y-%m", clicked_at)'
start_time = datetime.min
cursor = conn.cursor()
cursor.execute(f'''
SELECT {interval} as period, COUNT(*) as clicks
FROM clicks
WHERE url_id = ? AND clicked_at > ?
GROUP BY period
ORDER BY period
''', (url_id, start_time))
return [{"period": row[0], "clicks": row[1]} for row in cursor.fetchall()]
class URLRequest(BaseModel):
url: HttpUrl
@app.get("/")
async def read_root(request: Request):
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.post("/create")
async def create_short_url(url_request: URLRequest, request: Request):
# Parse the input URL
parsed_url = urllib.parse.urlparse(str(url_request.url))
# Check if this is one of our own shortened URLs
if parsed_url.netloc == request.base_url.netloc:
# Extract the short code from the path
short_code = parsed_url.path.strip('/')
if short_code:
with sqlite3.connect('urls.db') as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('''
SELECT id, original_url, created_at
FROM urls WHERE short_code = ?
''', (short_code,))
url_data = cursor.fetchone()
if url_data:
# Get click stats
stats = {
'day': get_click_stats(url_data['id'], 'day'),
'week': get_click_stats(url_data['id'], 'week'),
'year': get_click_stats(url_data['id'], 'year'),
'all': get_click_stats(url_data['id'], 'all')
}
return {
"is_analytics": True,
"original_url": url_data['original_url'],
"created_at": url_data['created_at'],
"stats": stats
}
# If not our URL or no match found, create new short URL
@app.post("/process")
async def process_input(request: Request, user_input: str = Form(...)):
user_input = user_input.strip()
# If it contains a dot, assume it's a URL
if "." in user_input:
if not user_input.startswith(("http://", "https://")):
user_input = "https://" + user_input
with sqlite3.connect('urls.db') as conn:
while True:
short_code = generate_short_code()
try:
conn.execute('INSERT INTO urls (short_code, original_url) VALUES (?, ?)',
(short_code, user_input))
conn.commit()
return templates.TemplateResponse("index.html", {
"request": request,
"short_url": f"{request.base_url}{short_code}"
})
except sqlite3.IntegrityError:
continue # Retry if collision
# Otherwise, treat it as a shortcode and fetch analytics
with sqlite3.connect('urls.db') as conn:
while True:
short_code = generate_short_code()
try:
cursor = conn.cursor()
cursor.execute('INSERT INTO urls (short_code, original_url) VALUES (?, ?)',
(short_code, str(url_request.url)))
return {
"is_analytics": False,
"short_code": short_code
}
except sqlite3.IntegrityError:
continue
cursor = conn.cursor()
cursor.execute('SELECT id, original_url FROM urls WHERE short_code = ?', (user_input,))
result = cursor.fetchone()
if result:
cursor.execute('SELECT COUNT(*) FROM clicks WHERE url_id = ?', (result[0],))
click_count = cursor.fetchone()[0]
return templates.TemplateResponse("index.html", {
"request": request,
"analytics_mode": True,
"original_url": result[1],
"click_count": click_count
})
return templates.TemplateResponse("index.html", {
"request": request,
"error": "Invalid input: Not a recognized URL or shortcode."
})
@app.get("/{short_code}")
async def redirect_to_url(short_code: str):
@ -128,15 +88,15 @@ async def redirect_to_url(short_code: str):
cursor = conn.cursor()
cursor.execute('SELECT id, original_url FROM urls WHERE short_code = ?', (short_code,))
result = cursor.fetchone()
if result:
# Record the click
cursor.execute('INSERT INTO clicks (url_id) VALUES (?)', (result[0],))
conn.commit()
return RedirectResponse(result[1])
raise HTTPException(status_code=404, detail="URL not found")
return RedirectResponse(url=result[1])
raise HTTPException(status_code=404, detail="URL not found")
if __name__ == "__main__":
init_db()
import uvicorn
uvicorn.run(app, host=host, port=port)
uvicorn.run(app, host="0.0.0.0", port=7997)

View file

@ -1,79 +1,24 @@
/* Gruvbox Dark theme colors */
:root {
--bg: #282828;
--bg1: #3c3836;
--bg2: #504945;
--fg: #ebdbb2;
--fg1: #fbf1c7;
--blue: #458588;
--blue-bright: #83a598;
--green: #98971a;
--aqua: #689d6a;
}
body {
background-color: var(--bg);
color: var(--fg);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
}
.container {
width: 90%;
max-width: 700px;
padding: 2rem;
}
/* Big SMURL heading with gradient text */
.logo {
font-size: 4rem;
font-weight: 900;
text-align: center;
margin-bottom: 2rem;
background: linear-gradient(135deg, var(--blue-bright), var(--aqua));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.05em;
}
.url-form {
.url-box {
background: var(--bg1);
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
transition: transform 0.2s ease;
max-width: 600px;
margin: auto;
}
/* Input group and button row */
.input-group {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
.short-url a {
font-size: 4rem;
font-weight: 900;
text-decoration: none;
text-align: center;
display: block;
color: var(--blue-bright);
}
/* The text input box: dark background, no ugly white fill */
input[type="text"] {
flex: 1;
background: var(--bg2);
border: 2px solid transparent;
color: var(--fg);
padding: 1rem;
border-radius: 0.5rem;
font-size: 1.1rem;
transition: all 0.2s ease;
}
input[type="text"]:focus {
outline: none;
border-color: var(--blue);
}
/* The SMURL button */
button {
background: var(--blue);
.copy-btn {
background: var(--blue-bright);
color: var(--fg1);
border: none;
padding: 1rem 2rem;
@ -82,115 +27,19 @@ button {
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
button:hover {
background: var(--blue-bright);
transform: translateY(-1px);
}
/* Hide results by default */
.result {
display: none;
margin-top: 1.5rem;
opacity: 0;
transform: translateY(10px);
transition: all 0.3s ease;
margin-bottom: 0; /* minimize bottom space */
}
/* Show the results container when .visible is added */
.result.visible {
display: block;
opacity: 1;
transform: translateY(0);
margin: 0.5rem auto;
}
/* The big, bold shortened URL link */
.short-url {
text-align: center;
margin: 1rem 0;
}
/* Make the link bigger and bolder */
.short-url a {
color: var(--blue-bright);
font-size: 2rem; /* bigger text */
font-weight: 800; /* bold */
text-decoration: none;
display: inline-block; /* keep it on its own line, centered */
}
/* The COPY button below the link */
.copy-btn {
display: block;
margin: 0.5rem auto 0; /* some spacing above, minimal below */
background: var(--blue);
color: var(--fg1);
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
/* Hover for COPY button */
.copy-btn:hover {
background: var(--blue-bright);
transform: translateY(-1px);
}
/* Analytics section */
.analytics-title {
text-align: center;
font-size: 1.5rem;
font-size: 2rem;
margin-bottom: 1rem;
}
.original-url {
display: block;
margin: 1rem auto;
padding: 1rem;
background: var(--bg2);
border-radius: 0.5rem;
width: fit-content;
.analytics-count {
text-align: center;
color: var(--blue-bright);
text-decoration: none;
font-weight: 700;
}
/* Time-range buttons (24h, Week, etc.) */
.time-range {
display: flex;
justify-content: center;
gap: 1rem;
margin: 1.5rem 0;
}
.time-range button {
background: var(--bg2);
padding: 0.5rem 1rem;
font-size: 0.9rem;
color: var(--fg);
border: 1px solid var(--blue);
border-radius: 0.3rem;
transition: background 0.2s;
cursor: pointer;
}
.time-range button.active {
background: var(--blue);
color: var(--fg1);
}
/* The chart container: keep it dark-themed. */
.chart {
background: var(--bg2);
padding: 1rem;
border-radius: 0.5rem;
height: 300px; /* Recharts uses a 300px container (from the JS) */
font-size: 1.5rem;
font-weight: bold;
margin-top: 0.5rem;
}

View file

@ -1,194 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SMURL - Shorten My URL</title>
<!-- Dark-themed CSS -->
<link rel="stylesheet" href="{{ url_for('static', path='/css/styles.css') }}">
<!-- React & ReactDOM (UMD) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js"></script>
<!-- Recharts (depends on React) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/recharts/2.12.2/Recharts.min.js"></script>
</head>
<body>
<div class="container">
<!-- Big SMURL heading -->
<h1 class="logo">SMURL</h1>
<div class="url-form">
<form id="shortenForm" onsubmit="return handleSubmit(event)">
<div class="url-box">
<form method="post" action="/process">
<div class="input-group">
<!-- type="text" so that we don't get browser's built-in "invalid URL" block -->
<input
type="text"
id="urlInput"
placeholder="Paste your URL here..."
required
>
<!-- The button now says SMURL -->
<button type="submit">SMURL</button>
<input type="text" name="user_input" placeholder="Enter a URL or shortcode..." required>
<button type="submit" class="copy-btn">SMURL</button>
</div>
</form>
<!-- Shortened URL display -->
<div id="shortResult" class="result">
<!-- No "Your shortened URL:" text. Just a big bold link. -->
{% if short_url %}
<div class="result visible">
<div class="short-url">
<a id="shortUrl" href="#" target="_blank"></a>
<a href="{{ short_url }}" target="_blank">{{ short_url }}</a>
</div>
<!-- COPY button on its own line, minimal spacing below -->
<button class="copy-btn" onclick="copyShortUrl()">COPY</button>
<button class="copy-btn" onclick="copyShortUrl(this)">COPY</button>
</div>
{% endif %}
<!-- Analytics view -->
<div id="analyticsResult" class="result">
{% if analytics_mode %}
<div class="result visible">
<h2 class="analytics-title">Analytics for Original URL</h2>
<a id="originalUrl" href="#" target="_blank" class="original-url"></a>
<div class="time-range">
<button data-range="day" class="active">24h</button>
<button data-range="week">Week</button>
<button data-range="year">Year</button>
<button data-range="all">All</button>
</div>
<div id="chart" class="chart"></div>
<a href="{{ original_url }}" target="_blank" class="original-url">{{ original_url }}</a>
<p class="analytics-count">Total Clicks: {{ click_count }}</p>
</div>
{% endif %}
{% if error %}
<div class="result visible error">
<p>{{ error }}</p>
</div>
{% endif %}
</div>
</div>
<script>
// Recharts objects
const {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer
} = Recharts;
function copyShortUrl(button) {
const shortUrlElement = document.querySelector('.short-url a');
if (!shortUrlElement) return;
let currentStats = null;
async function handleSubmit(event) {
event.preventDefault();
let url = document.getElementById('urlInput').value.trim();
// Auto-prepend https:// if user didn't type http:// or https://
if (!/^https?:\/\//i.test(url)) {
url = 'https://' + url;
}
console.log('Submitting to /create:', url);
try {
const response = await fetch('/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const data = await response.json();
console.log('Response from /create:', data);
if (response.ok) {
if (data.is_analytics) {
// We have an analytics response
showAnalytics(data);
} else {
// We have a new short code
showShortUrl(data.short_code);
}
} else {
alert(data.error || 'An error occurred');
navigator.clipboard.writeText(shortUrlElement.textContent)
.then(() => {
button.style.background = '#689d6a';
setTimeout(() => button.style.background = '', 2000);
})
.catch(() => alert('Failed to copy.'));
}
} catch (err) {
console.error('Error in handleSubmit:', err);
alert('An error occurred');
}
}
function showShortUrl(shortCode) {
const shortUrlElement = document.getElementById('shortUrl');
const shortUrl = `${window.location.origin}/${shortCode}`;
shortUrlElement.href = shortUrl;
shortUrlElement.textContent = shortUrl;
// Show shortResult
document.getElementById('shortResult').classList.add('visible');
// Hide analyticsResult
document.getElementById('analyticsResult').classList.remove('visible');
}
function showAnalytics(data) {
currentStats = data.stats;
document.getElementById('originalUrl').href = data.original_url;
document.getElementById('originalUrl').textContent = data.original_url;
// Hide shortResult
document.getElementById('shortResult').classList.remove('visible');
// Show analyticsResult
document.getElementById('analyticsResult').classList.add('visible');
// Show initial chart with 24h data
updateChart('day');
// Set up time range buttons if not already set up
document.querySelectorAll('.time-range button').forEach((button) => {
button.addEventListener('click', (e) => {
document.querySelectorAll('.time-range button').forEach((b) =>
b.classList.remove('active')
);
e.target.classList.add('active');
updateChart(e.target.dataset.range);
});
});
}
function updateChart(timeRange) {
// Safeguard if we have no stats
const statsData = currentStats?.[timeRange] || [];
console.log('updateChart:', timeRange, statsData);
const chartContainer = document.getElementById('chart');
// If there's no data or empty array, you can skip rendering or show a small message
// But let's attempt to render an empty chart anyway
ReactDOM.render(
React.createElement(
ResponsiveContainer,
{ width: '100%', height: 300 },
React.createElement(
LineChart,
{ data: statsData },
React.createElement(XAxis, { dataKey: 'period' }),
React.createElement(YAxis),
React.createElement(Tooltip),
React.createElement(Line, {
type: 'monotone',
dataKey: 'clicks',
stroke: '#83a598',
strokeWidth: 2,
dot: false
})
)
),
chartContainer
);
}
function copyShortUrl() {
const shortUrlElement = document.getElementById('shortUrl');
if (!shortUrlElement || !shortUrlElement.textContent) return;
navigator.clipboard.writeText(shortUrlElement.textContent)
.then(() => alert('Shortened URL copied!'))
.catch(() => alert('Failed to copy.'));
}
</script>
</body>
</html>