First code commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
Cargo.lock
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "time-converter"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8.9"
|
||||
chrono = "0.4.45"
|
||||
chrono-tz = "0.10.4"
|
||||
serde_json = "1.0.150"
|
||||
tokio = { version = "1.52.3", features = ["full"] }
|
||||
@@ -0,0 +1 @@
|
||||
pub mod v1;
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
use axum::extract::Path;
|
||||
use serde_json::json;
|
||||
use chrono::TimeZone;
|
||||
use chrono::Offset;
|
||||
|
||||
pub async fn root() -> String {
|
||||
|
||||
let endpoints = vec![
|
||||
"/timeconvert/with-time/{from_timezone}/{to_timezone}/{time}",
|
||||
"/timeconvert/with-date-time/{from_timezone}/{to_timezone}/{datetime}",
|
||||
"/time/live/{timezone}",
|
||||
"/time/live/{timezone}/{offset_seconds}"
|
||||
];
|
||||
|
||||
let data = json!({
|
||||
"available_endpoints": endpoints
|
||||
});
|
||||
|
||||
build_response("OK".to_string(), data, "".to_string())
|
||||
}
|
||||
|
||||
pub async fn timeconvert_with_time(Path((from_timezone, to_timezone, time)): Path<(String, String, String)>) -> String {
|
||||
let time_diff_result = calculate_time_difference_in_minutes(from_timezone, to_timezone);
|
||||
if let Err(err) = time_diff_result {
|
||||
return build_response("ERROR".to_string(), json!({}), err);
|
||||
}
|
||||
|
||||
let parsed_time_result = parse_time_to_three_part(time);
|
||||
if let Err(err) = parsed_time_result {
|
||||
return build_response("ERROR".to_string(), json!({}), err);
|
||||
}
|
||||
|
||||
let time_diff_in_minutes = time_diff_result.unwrap();
|
||||
let (h, m, s) = parsed_time_result.unwrap();
|
||||
|
||||
let (converted_hours, converted_minutes, converted_seconds, day_diff) = apply_time_difference_to_three_part_time(h, m, s, time_diff_in_minutes);
|
||||
let final_time_string = build_time_string_from_three_part(converted_hours, converted_minutes, converted_seconds);
|
||||
|
||||
let data = json!({
|
||||
"converted_time": final_time_string,
|
||||
"day_diff": day_diff
|
||||
});
|
||||
|
||||
build_response("OK".to_string(), data, "".to_string())
|
||||
}
|
||||
|
||||
pub async fn timeconvert_with_date_time(Path((from_timezone, to_timezone, datetime)): Path<(String, String, String)>) -> String {
|
||||
let time_diff_result = calculate_time_difference_in_minutes(from_timezone, to_timezone);
|
||||
if let Err(err) = time_diff_result {
|
||||
return build_response("ERROR".to_string(), json!({}), err);
|
||||
}
|
||||
|
||||
let parsed_datetime_result = parse_datetime_string(datetime);
|
||||
if let Err(err) = parsed_datetime_result {
|
||||
return build_response("ERROR".to_string(), json!({}), err);
|
||||
}
|
||||
|
||||
let dt = parsed_datetime_result.unwrap();
|
||||
let time_diff_in_minutes = time_diff_result.unwrap();
|
||||
|
||||
let converted_dt = apply_minute_difference_to_datetime(dt, time_diff_in_minutes);
|
||||
|
||||
let data = json!({
|
||||
"converted_datetime": format_datetime_to_string(converted_dt)
|
||||
});
|
||||
|
||||
build_response("OK".to_string(), data, "".to_string())
|
||||
}
|
||||
|
||||
pub async fn time_live(Path(timezone): Path<String>) -> String {
|
||||
let tz_result = parse_timezone(timezone);
|
||||
if let Err(err) = tz_result {
|
||||
return build_response("ERROR".to_string(), json!({}), err);
|
||||
}
|
||||
|
||||
let tz = tz_result.unwrap();
|
||||
let now = get_current_time_in_timezone(tz);
|
||||
|
||||
let data = build_time_and_datetime_json_data(now);
|
||||
|
||||
build_response("OK".to_string(), data, "".to_string())
|
||||
}
|
||||
|
||||
pub async fn time_live_with_offset(Path((timezone, offset_seconds)): Path<(String, String)>) -> String {
|
||||
let tz_result = parse_timezone(timezone);
|
||||
if let Err(err) = tz_result {
|
||||
return build_response("ERROR".to_string(), json!({}), err);
|
||||
}
|
||||
|
||||
let offset_result = parse_offset_seconds(offset_seconds);
|
||||
if let Err(err) = offset_result {
|
||||
return build_response("ERROR".to_string(), json!({}), err);
|
||||
}
|
||||
|
||||
let tz = tz_result.unwrap();
|
||||
let offset_secs = offset_result.unwrap();
|
||||
|
||||
let now = get_current_time_in_timezone(tz);
|
||||
let now_with_offset = apply_seconds_difference_to_datetime(now, offset_secs);
|
||||
|
||||
let data = build_time_and_datetime_json_data(now_with_offset);
|
||||
|
||||
build_response("OK".to_string(), data, "".to_string())
|
||||
}
|
||||
|
||||
fn build_response(status: String, data: serde_json::Value, error_msg: String) -> String {
|
||||
let json: serde_json::Value;
|
||||
|
||||
if error_msg.is_empty() {
|
||||
json = json!({
|
||||
"status": status,
|
||||
"data": data
|
||||
})
|
||||
} else {
|
||||
json = json!({
|
||||
"status": status,
|
||||
"error_msg": error_msg
|
||||
})
|
||||
}
|
||||
|
||||
serde_json::to_string(&json).unwrap()
|
||||
}
|
||||
|
||||
fn calculate_time_difference_in_minutes(from_timezone: String, to_timezone: String) -> Result<i16, String> {
|
||||
let from_offset = convert_timezone_to_utc_offset(from_timezone);
|
||||
let to_offset = convert_timezone_to_utc_offset(to_timezone);
|
||||
|
||||
if from_offset.is_err() || to_offset.is_err() {
|
||||
return Err(format!("{}", from_offset.err().unwrap_or_else(|| to_offset.unwrap_err())));
|
||||
}
|
||||
|
||||
Ok(to_offset.unwrap() - from_offset.unwrap())
|
||||
}
|
||||
|
||||
fn convert_timezone_to_utc_offset(timezone: String) -> Result<i16, String> {
|
||||
let tz = parse_timezone(timezone)?;
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
let offset = tz.offset_from_utc_datetime(&now);
|
||||
let offset_in_minutes = (offset.fix().local_minus_utc() / 60) as i16;
|
||||
|
||||
Ok(offset_in_minutes)
|
||||
}
|
||||
|
||||
fn parse_timezone(timezone: String) -> Result<chrono_tz::Tz, String> {
|
||||
let mut clean_tz = timezone.trim_matches(|c| c == '"' || c == '\'').to_string();
|
||||
|
||||
if clean_tz.starts_with("UTC+") {
|
||||
let hours = &clean_tz[4..];
|
||||
clean_tz = format!("Etc/GMT-{}", hours);
|
||||
} else if clean_tz.starts_with("UTC-") {
|
||||
let hours = &clean_tz[4..];
|
||||
clean_tz = format!("Etc/GMT+{}", hours);
|
||||
}
|
||||
|
||||
clean_tz.parse().map_err(|_| String::from("Could not parse timezone"))
|
||||
}
|
||||
|
||||
fn parse_time_to_three_part(time: String) -> Result<(i16, i16, i16), String> {
|
||||
let clean_time = time.trim_matches(|c| c == '"' || c == '\'');
|
||||
let parts: Vec<&str> = clean_time.split(':').collect();
|
||||
|
||||
if parts.len() != 3 {
|
||||
return Err(String::from("Invalid time format. Expected HH:MM:SS format"));
|
||||
}
|
||||
|
||||
let hours = parts[0].parse::<i16>().map_err(|_| String::from("Invalid hours"))?;
|
||||
let minutes = parts[1].parse::<i16>().map_err(|_| String::from("Invalid minutes"))?;
|
||||
let seconds = parts[2].parse::<i16>().map_err(|_| String::from("Invalid seconds"))?;
|
||||
|
||||
if hours > 23 || minutes > 59 || seconds > 59 {
|
||||
return Err(String::from("Invalid time value. Ensure hours are less than 24, minutes less than 60, and seconds less than 60"));
|
||||
}
|
||||
|
||||
Ok((hours, minutes, seconds))
|
||||
}
|
||||
|
||||
fn apply_time_difference_to_three_part_time(h: i16, m: i16, s: i16, time_diff_in_minutes: i16) -> (i16, i16, i16, i16) {
|
||||
let mut converted_hours = h + time_diff_in_minutes / 60;
|
||||
let mut converted_minutes = m + time_diff_in_minutes % 60;
|
||||
let converted_seconds = s;
|
||||
|
||||
if converted_minutes >= 60 {
|
||||
converted_hours += 1;
|
||||
converted_minutes -= 60;
|
||||
} else if converted_minutes < 0 {
|
||||
converted_hours -= 1;
|
||||
converted_minutes += 60;
|
||||
}
|
||||
|
||||
let mut day_diff = 0;
|
||||
while converted_hours >= 24 {
|
||||
day_diff += 1;
|
||||
converted_hours -= 24;
|
||||
}
|
||||
while converted_hours < 0 {
|
||||
day_diff -= 1;
|
||||
converted_hours += 24;
|
||||
}
|
||||
|
||||
(converted_hours, converted_minutes, converted_seconds, day_diff)
|
||||
}
|
||||
|
||||
fn build_time_string_from_three_part(h: i16, m: i16, s: i16) -> String {
|
||||
format!("{:02}:{:02}:{:02}", h, m, s)
|
||||
}
|
||||
|
||||
fn parse_datetime_string(datetime: String) -> Result<chrono::NaiveDateTime, String> {
|
||||
let clean_datetime = datetime.trim_matches(|c| c == '"' || c == '\'');
|
||||
chrono::NaiveDateTime::parse_from_str(clean_datetime, "%Y-%m-%dT%H:%M:%S")
|
||||
.map_err(|_| String::from("Invalid datetime format. Expected YYYY-MM-DDTHH:MM:SS"))
|
||||
}
|
||||
|
||||
fn apply_minute_difference_to_datetime(mut dt: chrono::NaiveDateTime, time_diff_in_minutes: i16) -> chrono::NaiveDateTime {
|
||||
dt += chrono::TimeDelta::try_minutes(time_diff_in_minutes as i64).unwrap();
|
||||
dt
|
||||
}
|
||||
|
||||
fn format_datetime_to_string(dt: chrono::NaiveDateTime) -> String {
|
||||
dt.format("%Y-%m-%dT%H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
fn get_current_time_in_timezone(tz: chrono_tz::Tz) -> chrono::DateTime<chrono_tz::Tz> {
|
||||
chrono::Utc::now().with_timezone(&tz)
|
||||
}
|
||||
|
||||
fn parse_offset_seconds(offset_seconds: String) -> Result<i64, String> {
|
||||
let clean_offset = offset_seconds.trim_matches(|c| c == '"' || c == '\'');
|
||||
clean_offset.parse::<i64>().map_err(|_| String::from("Invalid offset_seconds format"))
|
||||
}
|
||||
|
||||
fn apply_seconds_difference_to_datetime(mut dt: chrono::DateTime<chrono_tz::Tz>, offset_secs: i64) -> chrono::DateTime<chrono_tz::Tz> {
|
||||
dt += chrono::TimeDelta::try_seconds(offset_secs).unwrap();
|
||||
dt
|
||||
}
|
||||
|
||||
fn build_time_and_datetime_json_data(dt: chrono::DateTime<chrono_tz::Tz>) -> serde_json::Value {
|
||||
json!({
|
||||
"time": dt.format("%H:%M:%S").to_string(),
|
||||
"datetime": dt.format("%Y-%m-%dT%H:%M:%S").to_string()
|
||||
})
|
||||
}
|
||||
+482
@@ -0,0 +1,482 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Time</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #fff;
|
||||
color: #111;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Clock view ── */
|
||||
|
||||
#clock-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
padding: 3vw 4vw;
|
||||
}
|
||||
|
||||
.clock-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: clamp(4rem, 22vw, 28rem);
|
||||
font-weight: normal;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 0.9;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: clamp(1rem, 2.2vw, 2rem);
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
margin-top: 1.5vw;
|
||||
}
|
||||
|
||||
.clock-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ── Converter view ── */
|
||||
|
||||
#converter-view {
|
||||
display: none;
|
||||
height: 100vh;
|
||||
padding: 3vw 4vw;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
border-bottom: 1px solid #111;
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.section-head h1 {
|
||||
font-size: clamp(1.25rem, 3vw, 2rem);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.form-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: #888;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="time"] {
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.4rem 0;
|
||||
border: none;
|
||||
border-bottom: 1px solid #ccc;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-bottom-color: #111;
|
||||
}
|
||||
|
||||
input::placeholder { color: #bbb; }
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-family: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.6rem 1.4rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid #111;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.btn:hover { background: #333; border-color: #333; }
|
||||
|
||||
.btn-ghost {
|
||||
background: #fff;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.btn-ghost:hover { background: #f0f0f0; }
|
||||
|
||||
/* ── Result ── */
|
||||
|
||||
.result {
|
||||
display: none;
|
||||
margin-top: 2.5rem;
|
||||
padding-top: 1.75rem;
|
||||
border-top: 1px solid #111;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: #888;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.result-time {
|
||||
font-size: clamp(3rem, 8vw, 6rem);
|
||||
font-weight: normal;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.result-sub {
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: none;
|
||||
margin-top: 1.5rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #111;
|
||||
font-size: 0.875rem;
|
||||
background: #f5f5f5;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
/* ── Share view ── */
|
||||
|
||||
#share-view {
|
||||
display: none;
|
||||
height: 100vh;
|
||||
padding: 3vw 4vw;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.share-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.share-from {
|
||||
font-size: clamp(1rem, 2vw, 1.5rem);
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.share-time-in {
|
||||
font-size: clamp(3rem, 12vw, 10rem);
|
||||
font-weight: normal;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 0.9;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.share-arrow {
|
||||
font-size: clamp(1rem, 2vw, 1.5rem);
|
||||
color: #888;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.share-to-label {
|
||||
font-size: clamp(1rem, 2vw, 1.5rem);
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.share-time-out {
|
||||
font-size: clamp(3rem, 12vw, 10rem);
|
||||
font-weight: normal;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 0.9;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.share-day {
|
||||
font-size: clamp(0.9rem, 1.5vw, 1.25rem);
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.share-loading {
|
||||
font-size: clamp(1.5rem, 5vw, 3rem);
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Clock -->
|
||||
<div id="clock-view">
|
||||
<div class="clock-main">
|
||||
<div class="time" id="live-time">00:00:00</div>
|
||||
<div class="date" id="live-date"></div>
|
||||
</div>
|
||||
<div class="clock-footer">
|
||||
<button class="btn" onclick="showView('converter-view')">Convert →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Converter -->
|
||||
<div id="converter-view">
|
||||
<div class="section-head">
|
||||
<h1>Time Converter</h1>
|
||||
</div>
|
||||
|
||||
<form id="conv-form" onsubmit="doConvert(event)" class="form-body">
|
||||
<div class="field">
|
||||
<label>From timezone</label>
|
||||
<input type="text" id="from-tz" placeholder="UTC, America/New_York, PST…" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>To timezone</label>
|
||||
<input type="text" id="to-tz" placeholder="Europe/Paris, Asia/Tokyo…" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Time</label>
|
||||
<input type="time" id="time-in" step="1" required>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="submit" class="btn">Convert</button>
|
||||
<button type="button" class="btn btn-ghost" onclick="goHome()">Back</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="error" id="conv-error"></div>
|
||||
|
||||
<div class="result" id="conv-result">
|
||||
<div class="result-label">Result</div>
|
||||
<div class="result-time" id="result-time"></div>
|
||||
<div class="result-sub" id="result-day"></div>
|
||||
<div class="row">
|
||||
<button class="btn btn-ghost" onclick="copyShare()" id="share-btn" style="margin-top:0">Copy share link</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share / Deeplink view -->
|
||||
<div id="share-view">
|
||||
<div class="section-head">
|
||||
<h1>Time Converter</h1>
|
||||
<button class="btn btn-ghost" onclick="goHome()">Home</button>
|
||||
</div>
|
||||
<div class="share-main">
|
||||
<div id="share-loading" class="share-loading">Converting…</div>
|
||||
<div id="share-content" style="display:none">
|
||||
<div class="share-from" id="share-from-label"></div>
|
||||
<div class="share-time-in" id="share-time-in"></div>
|
||||
<div class="share-arrow">↓ in your timezone</div>
|
||||
<div class="share-to-label" id="share-to-label"></div>
|
||||
<div class="share-time-out" id="share-time-out"></div>
|
||||
<div class="share-day" id="share-day-diff"></div>
|
||||
</div>
|
||||
<div id="share-error" class="error" style="margin-top:2rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Clock ──
|
||||
|
||||
function pad(n) { return n < 10 ? '0' + n : '' + n; }
|
||||
|
||||
function tick() {
|
||||
var d = new Date();
|
||||
document.getElementById('live-time').textContent =
|
||||
pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
||||
document.getElementById('live-date').textContent =
|
||||
d.toLocaleDateString('en-US', { weekday:'long', year:'numeric', month:'long', day:'numeric' });
|
||||
}
|
||||
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
|
||||
// ── View routing ──
|
||||
|
||||
function showView(id) {
|
||||
var ids = ['clock-view', 'converter-view', 'share-view'];
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
document.getElementById(ids[i]).style.display = ids[i] === id ? 'flex' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
document.getElementById('conv-error').style.display = 'none';
|
||||
document.getElementById('conv-result').style.display = 'none';
|
||||
document.getElementById('conv-form').reset();
|
||||
history.pushState(null, '', '/');
|
||||
showView('clock-view');
|
||||
}
|
||||
|
||||
// ── Converter ──
|
||||
|
||||
var _lastFrom = '', _lastTime = '';
|
||||
|
||||
function doConvert(e) {
|
||||
e.preventDefault();
|
||||
var fromTz = document.getElementById('from-tz').value.trim();
|
||||
var toTz = document.getElementById('to-tz').value.trim();
|
||||
var t = document.getElementById('time-in').value.trim();
|
||||
if (t.split(':').length === 2) t += ':00';
|
||||
|
||||
_lastFrom = fromTz;
|
||||
_lastTime = t.substring(0, 5); // HH:MM for share link
|
||||
|
||||
callConvert(fromTz, toTz, t, function(converted, dayDiff, errMsg) {
|
||||
var errEl = document.getElementById('conv-error');
|
||||
var resEl = document.getElementById('conv-result');
|
||||
if (errMsg) {
|
||||
resEl.style.display = 'none';
|
||||
errEl.textContent = errMsg;
|
||||
errEl.style.display = 'block';
|
||||
} else {
|
||||
errEl.style.display = 'none';
|
||||
document.getElementById('result-time').textContent = converted;
|
||||
var d = dayDiff;
|
||||
document.getElementById('result-day').textContent =
|
||||
d === 0 ? 'Same day' : (d > 0 ? '+' + d : d) + ' day(s)';
|
||||
resEl.style.display = 'block';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function callConvert(fromTz, toTz, t, cb) {
|
||||
var url = '/api/v1/timeconvert/with-time/' +
|
||||
encodeURIComponent(fromTz) + '/' +
|
||||
encodeURIComponent(toTz) + '/' +
|
||||
encodeURIComponent(t);
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url);
|
||||
xhr.onload = function() {
|
||||
try {
|
||||
var j = JSON.parse(xhr.responseText);
|
||||
if (j.status === 'OK') {
|
||||
cb(j.data.converted_time, j.data.day_diff, null);
|
||||
} else {
|
||||
cb(null, 0, j.error_msg || 'Conversion failed.');
|
||||
}
|
||||
} catch(_) { cb(null, 0, 'Invalid server response.'); }
|
||||
};
|
||||
xhr.onerror = function() { cb(null, 0, 'Network error.'); };
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
// ── Share link ──
|
||||
|
||||
function copyShare() {
|
||||
var t = _lastTime.replace(':', '%3A');
|
||||
var url = window.location.origin + '/share?tz=' +
|
||||
encodeURIComponent(_lastFrom) + '&t=' + t;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(url);
|
||||
} else {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = url;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
var btn = document.getElementById('share-btn');
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(function() { btn.textContent = 'Copy share link'; }, 2000);
|
||||
}
|
||||
|
||||
// ── Share / deeplink landing ──
|
||||
|
||||
function loadShareView() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var fromTz = params.get('tz') || '';
|
||||
var time = params.get('t') || '';
|
||||
|
||||
if (!fromTz || !time) {
|
||||
document.getElementById('share-loading').style.display = 'none';
|
||||
var errEl = document.getElementById('share-error');
|
||||
errEl.textContent = 'Invalid share link.';
|
||||
errEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (time.split(':').length === 2) time += ':00';
|
||||
|
||||
var localTz = (Intl && Intl.DateTimeFormat
|
||||
? Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
: 'UTC');
|
||||
|
||||
callConvert(fromTz, localTz, time, function(converted, dayDiff, errMsg) {
|
||||
document.getElementById('share-loading').style.display = 'none';
|
||||
if (errMsg) {
|
||||
var errEl = document.getElementById('share-error');
|
||||
errEl.textContent = errMsg;
|
||||
errEl.style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('share-from-label').textContent = fromTz;
|
||||
document.getElementById('share-time-in').textContent = time.substring(0, 5);
|
||||
document.getElementById('share-to-label').textContent = localTz;
|
||||
document.getElementById('share-time-out').textContent = converted.substring(0, 5);
|
||||
var d = dayDiff;
|
||||
document.getElementById('share-day-diff').textContent =
|
||||
d === 0 ? '' : (d > 0 ? '+' + d : d) + ' day(s)';
|
||||
document.getElementById('share-content').style.display = 'block';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init: decide which view to show ──
|
||||
|
||||
if (window.location.pathname === '/share') {
|
||||
showView('share-view');
|
||||
loadShareView();
|
||||
} else {
|
||||
showView('clock-view');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
use axum::{
|
||||
routing::get,
|
||||
response::Html,
|
||||
Router,
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
mod api;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Build our application with a router
|
||||
let app = Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/share", get(root))
|
||||
.route("/api", get(get_api_root))
|
||||
.route("/api/v1", get(api::v1::root))
|
||||
.route("/api/v1/timeconvert/with-time/{from_timezone}/{to_timezone}/{time}", get(api::v1::timeconvert_with_time))
|
||||
.route("/api/v1/timeconvert/with-date-time/{from_timezone}/{to_timezone}/{datetime}", get(api::v1::timeconvert_with_date_time))
|
||||
.route("/api/v1/time/live/{timezone}", get(api::v1::time_live))
|
||||
.route("/api/v1/time/live/{timezone}/{offset_seconds}", get(api::v1::time_live_with_offset));
|
||||
|
||||
// Run our app, listening on port 8080
|
||||
let listener = TcpListener::bind("127.0.0.1:8080").await?;
|
||||
println!("Server running on http://127.0.0.1:8080");
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn root() -> Html<&'static str> {
|
||||
Html(include_str!("index.html"))
|
||||
}
|
||||
|
||||
async fn get_api_root() -> String {
|
||||
format!("Available API versions: v1")
|
||||
}
|
||||
Reference in New Issue
Block a user