feat: dynamic png-based clock favicon

This commit is contained in:
2025-07-10 18:36:00 -05:00
parent 1a7e9e3414
commit 696f18af3f
4 changed files with 119 additions and 12 deletions

View File

@@ -1,7 +1,8 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use crate::routes::{ use crate::routes::{
absolute_handler, fallback_handler, implicit_handler, index_handler, relative_handler, absolute_handler, fallback_handler, favicon_handler, implicit_handler, index_handler,
relative_handler,
}; };
use axum::{routing::get, Router}; use axum::{routing::get, Router};
use config::Configuration; use config::Configuration;
@@ -34,6 +35,7 @@ async fn main() {
let app = Router::new() let app = Router::new()
.route("/", get(index_handler)) .route("/", get(index_handler))
.route("/favicon.ico", get(favicon_handler))
.route("/{path}", get(implicit_handler)) .route("/{path}", get(implicit_handler))
.route("/rel/{path}", get(relative_handler)) .route("/rel/{path}", get(relative_handler))
.route("/relative/{path}", get(relative_handler)) .route("/relative/{path}", get(relative_handler))

View File

@@ -3,8 +3,10 @@ use crate::error::{get_error_response, TimeBannerError};
use crate::render::render_time_response; use crate::render::render_time_response;
use crate::template::OutputForm; use crate::template::OutputForm;
use crate::utils::parse_path; use crate::utils::parse_path;
use axum::extract::ConnectInfo;
use axum::extract::Path; use axum::extract::Path;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use std::net::SocketAddr;
/// Root handler - redirects to current time in relative format. /// Root handler - redirects to current time in relative format.
pub async fn index_handler() -> impl IntoResponse { pub async fn index_handler() -> impl IntoResponse {
@@ -49,6 +51,19 @@ pub async fn implicit_handler(Path(path): Path<String>) -> impl IntoResponse {
render_time_response(time, OutputForm::Absolute, extension).into_response() render_time_response(time, OutputForm::Absolute, extension).into_response()
} }
/// Handles `/favicon.ico` - generates a dynamic clock favicon showing the current time.
///
/// Logs the client IP address and returns a PNG image of an analog clock.
pub async fn favicon_handler(ConnectInfo(addr): ConnectInfo<SocketAddr>) -> impl IntoResponse {
let now = chrono::Utc::now();
// Log the IP address for the favicon request
tracing::info!("Favicon request from IP: {}", addr.ip());
// Generate clock favicon showing current time
render_time_response(now, OutputForm::Clock, "png").into_response()
}
/// Fallback handler for unmatched routes. /// Fallback handler for unmatched routes.
pub async fn fallback_handler() -> impl IntoResponse { pub async fn fallback_handler() -> impl IntoResponse {
get_error_response(TimeBannerError::NotFound).into_response() get_error_response(TimeBannerError::NotFound).into_response()

View File

@@ -1,4 +1,4 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Timelike, Utc};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use tera::{Context, Tera}; use tera::{Context, Tera};
use timeago::Formatter; use timeago::Formatter;
@@ -36,9 +36,11 @@ pub enum OutputForm {
Relative, Relative,
/// Absolute display: "2025-01-17 14:30:00 UTC" /// Absolute display: "2025-01-17 14:30:00 UTC"
Absolute, Absolute,
/// Clock display: analog clock with hands showing the time
Clock,
} }
/// Timezone specification formats. /// Timezone specification formats (currently unused but reserved for future features).
pub enum TzForm { pub enum TzForm {
Abbreviation(String), // e.g. "CST" Abbreviation(String), // e.g. "CST"
Iso(String), // e.g. "America/Chicago" Iso(String), // e.g. "America/Chicago"
@@ -58,19 +60,70 @@ pub struct RenderContext {
pub now: Option<i64>, pub now: Option<i64>,
} }
/// Calculates clock hand positions for a given time.
///
/// Returns (hour_x, hour_y, minute_x, minute_y) coordinates for SVG rendering.
/// Clock center is at (16, 16) with appropriate hand lengths for a 32x32 favicon.
fn calculate_clock_hands(time: DateTime<Utc>) -> (f64, f64, f64, f64) {
let hour = time.hour() as f64;
let minute = time.minute() as f64;
// Calculate angles (12 o'clock = 0°, clockwise)
let hour_angle = ((hour % 12.0) + minute / 60.0) * 30.0; // 30° per hour
let minute_angle = minute * 6.0; // 6° per minute
// Convert to radians and adjust for SVG coordinate system (0° at top)
let hour_rad = (hour_angle - 90.0).to_radians();
let minute_rad = (minute_angle - 90.0).to_radians();
// Clock center and hand lengths
let center_x = 16.0;
let center_y = 16.0;
let hour_length = 7.0; // Shorter hour hand
let minute_length = 11.0; // Longer minute hand
// Calculate end positions
let hour_x = center_x + hour_length * hour_rad.cos();
let hour_y = center_y + hour_length * hour_rad.sin();
let minute_x = center_x + minute_length * minute_rad.cos();
let minute_y = center_y + minute_length * minute_rad.sin();
(hour_x, hour_y, minute_x, minute_y)
}
/// Renders a time value using the appropriate template. /// Renders a time value using the appropriate template.
///
/// Uses different templates based on output form:
/// - Relative/Absolute: "basic.svg" with text content
/// - Clock: "clock.svg" with calculated hand positions
pub fn render_template(context: RenderContext) -> Result<String, tera::Error> { pub fn render_template(context: RenderContext) -> Result<String, tera::Error> {
let mut template_context = Context::new(); let mut template_context = Context::new();
let formatter = Formatter::new();
template_context.insert( match context.output_form {
"text", OutputForm::Relative => {
match context.output_form { let formatter = Formatter::new();
OutputForm::Relative => formatter.convert_chrono(context.value, Utc::now()), template_context.insert("text", &formatter.convert_chrono(context.value, Utc::now()));
OutputForm::Absolute => context.value.to_rfc3339(), TEMPLATES.render("basic.svg", &template_context)
} }
.as_str(), OutputForm::Absolute => {
); template_context.insert("text", &context.value.to_rfc3339());
TEMPLATES.render("basic.svg", &template_context)
}
OutputForm::Clock => {
let (hour_x, hour_y, minute_x, minute_y) = calculate_clock_hands(context.value);
TEMPLATES.render("basic.svg", &template_context) // Format to 2 decimal places to avoid precision issues
let hour_x_str = format!("{:.2}", hour_x);
let hour_y_str = format!("{:.2}", hour_y);
let minute_x_str = format!("{:.2}", minute_x);
let minute_y_str = format!("{:.2}", minute_y);
template_context.insert("hour_x", &hour_x_str);
template_context.insert("hour_y", &hour_y_str);
template_context.insert("minute_x", &minute_x_str);
template_context.insert("minute_y", &minute_y_str);
TEMPLATES.render("clock.svg", &template_context)
}
}
} }

37
src/templates/clock.svg Normal file
View File

@@ -0,0 +1,37 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<!-- Clock face -->
<circle cx="16" cy="16" r="15" fill="#ffffff" stroke="#000000" stroke-width="1" />
<!-- Hour markers -->
<g stroke="#000000" stroke-width="1">
<!-- 12 o'clock -->
<line x1="16" y1="2" x2="16" y2="5" />
<!-- 3 o'clock -->
<line x1="30" y1="16" x2="27" y2="16" />
<!-- 6 o'clock -->
<line x1="16" y1="30" x2="16" y2="27" />
<!-- 9 o'clock -->
<line x1="2" y1="16" x2="5" y2="16" />
<!-- Minor markers -->
<line x1="24.5" y1="4.5" x2="23.5" y2="5.5" />
<line x1="27.5" y1="7.5" x2="26.5" y2="8.5" />
<line x1="27.5" y1="24.5" x2="26.5" y2="23.5" />
<line x1="24.5" y1="27.5" x2="23.5" y2="26.5" />
<line x1="7.5" y1="27.5" x2="8.5" y2="26.5" />
<line x1="4.5" y1="24.5" x2="5.5" y2="23.5" />
<line x1="4.5" y1="7.5" x2="5.5" y2="8.5" />
<line x1="7.5" y1="4.5" x2="8.5" y2="5.5" />
</g>
<!-- Hour hand -->
<line x1="16" y1="16" x2="{{ hour_x }}" y2="{{ hour_y }}" stroke="#000000" stroke-width="2"
stroke-linecap="round" />
<!-- Minute hand -->
<line x1="16" y1="16" x2="{{ minute_x }}" y2="{{ minute_y }}" stroke="#000000"
stroke-width="1.5" stroke-linecap="round" />
<!-- Center dot -->
<circle cx="16" cy="16" r="1.5" fill="#000000" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB