Auto-update: Wed 29 Jan 2025 04:53:27 AM UTC
This commit is contained in:
parent
3af341cd0d
commit
d8195753ba
3 changed files with 102 additions and 427 deletions
146
smurl.py
146
smurl.py
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Reference in a new issue