diff --git a/.gitignore b/.gitignore index 6ec51ca70..a4128414c 100644 --- a/.gitignore +++ b/.gitignore @@ -222,3 +222,11 @@ pnpm-debug.log* # These are backup files generated by rustfmt **/*.rs.bk +.monkeycode +AGENTS.md +.codex +.claude +.aider* +.cursor/ +.env +.env.* diff --git a/extensions/chrome/popup.css b/extensions/chrome/popup.css index c3b8d3a4c..eafe0d83b 100644 --- a/extensions/chrome/popup.css +++ b/extensions/chrome/popup.css @@ -139,6 +139,64 @@ button { margin-top: 8px; } +.search-input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.search-icon { + position: absolute; + left: 10px; + width: 16px; + height: 16px; + color: var(--muted); + pointer-events: none; +} + +.search-input-wrapper input { + width: 100%; + padding-left: 34px; + padding-right: 34px; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.search-input-wrapper input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); +} + +.search-clear { + position: absolute; + right: 8px; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + background: var(--muted); + border: none; + border-radius: 50%; + cursor: pointer; + transition: background-color 0.2s; +} + +.search-clear svg { + width: 12px; + height: 12px; + color: #fff; +} + +.search-clear:hover { + background: var(--text); +} + +.search-clear[hidden] { + display: none; +} + .import-hint { margin-top: 6px; font-size: 12px; diff --git a/extensions/chrome/popup.html b/extensions/chrome/popup.html index a10cb0bca..01d49d47e 100644 --- a/extensions/chrome/popup.html +++ b/extensions/chrome/popup.html @@ -62,7 +62,23 @@

从 CCFDDL 导入

diff --git a/extensions/chrome/popup.js b/extensions/chrome/popup.js index f91b99e4a..b1706b0dd 100644 --- a/extensions/chrome/popup.js +++ b/extensions/chrome/popup.js @@ -11,6 +11,7 @@ const emptyEl = document.getElementById("empty-state"); const countEl = document.getElementById("count"); const loadCcfddlBtn = document.getElementById("load-ccfddl"); const ccfddlSearchInput = document.getElementById("ccfddl-search"); +const ccfddlClearBtn = document.getElementById("ccfddl-clear"); const ccfddlList = document.getElementById("ccfddl-list"); const ccfddlEmpty = document.getElementById("ccfddl-empty"); const langToggle = document.getElementById("lang-toggle"); @@ -554,5 +555,14 @@ chrome.storage.local.get({ [LANG_STORAGE_KEY]: "zh" }, (result) => { }); loadCcfddlBtn.addEventListener("click", loadCcfddlData); -ccfddlSearchInput.addEventListener("input", filterCcfddlList); +ccfddlSearchInput.addEventListener("input", () => { + filterCcfddlList(); + ccfddlClearBtn.hidden = !ccfddlSearchInput.value.trim(); +}); +ccfddlClearBtn.addEventListener("click", () => { + ccfddlSearchInput.value = ""; + ccfddlClearBtn.hidden = true; + filterCcfddlList(); + ccfddlSearchInput.focus(); +}); refreshDeadlinesBtn.addEventListener("click", loadDeadlines); diff --git a/public/styles.css b/public/styles.css index f35e5a06f..421d8d7b5 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1,12 +1,64 @@ -/* --------------------- Open Props --------------------------- */ +/* ==================== CSS 变量系统 ==================== */ +:root { + /* 主色 */ + --color-primary: #409eff; + --color-primary-hover: #66b1ff; + --color-primary-active: #3a8ee6; + + /* 背景色 */ + --color-bg: #ffffff; + --color-bg-secondary: #f5f7fa; + --color-bg-tertiary: #ebeef5; + + /* 文字色 */ + --color-text-primary: #2c3e50; + --color-text-secondary: #666666; + --color-text-muted: #909399; + --color-text-placeholder: #c0c4cc; + + /* 边框色 */ + --color-border: #dcdfe6; + --color-border-light: #ebeef5; + + /* 状态色 */ + --color-success: #67c23a; + --color-warning: #e6a23c; + --color-danger: #f56c6c; + --color-info: #909399; + + /* 阴影 */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.15); + + /* 圆角 */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + + /* 过渡 */ + --transition-fast: 150ms ease; + --transition-normal: 250ms ease; + --transition-slow: 400ms ease; + + /* 字体大小 */ + --font-size-xs: 12px; + --font-size-sm: 14px; + --font-size-base: 16px; + --font-size-lg: 18px; + --font-size-xl: 20px; + --font-size-2xl: 24px; + --font-size-3xl: 29px; +} -/* the props */ +/* --------------------- Open Props --------------------------- */ @import "https://unpkg.com/open-props"; /* ------------------------------------------------------------ */ body { - background-color: white; + background-color: var(--color-bg); + font-family: "PingFang SC", "Microsoft YaHei", "Roboto", "Helvetica Neue", Helvetica, Arial, sans-serif; } a { @@ -18,7 +70,7 @@ a { font-family: "Roboto","Helvetica Neue",Helvetica,Arial,sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - max-width: 980px; + max-width: 1200px; margin-left: auto; margin-right: auto; } @@ -32,30 +84,23 @@ a { .title { - font-size: 29px; - color: #2c3e50; + font-size: var(--font-size-3xl); + color: var(--color-text-primary); text-decoration: underline; text-decoration-color: currentColor; } .subtitle { - color: #666666; + color: var(--color-text-secondary); display: inline-block; } /* --------------------- Footer --------------------------- */ -/* .footer { - color: #666666; - display: block; - padding-top: 8px; - height: 20px; -} */ - @media (min-width: 769px) { .footer { height: 20px; padding-top: 8px; - color: #666666; + color: var(--color-text-secondary); display: flex; justify-content: space-between; align-items: center; @@ -75,7 +120,7 @@ a { .footer { height: 20px; padding-top: 8px; - color: #666666; + color: var(--color-text-secondary); display: flex; flex-direction: column; } @@ -90,63 +135,61 @@ a { .footer-text { order: 2; text-align: center; - font-size: 14px; + font-size: var(--font-size-sm); } } .el-row { - /* display: flex; */ align-items: center; padding-top: 15px; - font-size: 16px; + font-size: var(--font-size-base); } .el-switch { display: flex; align-items: center; - /* margin-bottom: 10px; */ margin-left: 10px; - font-size: 14px; + font-size: var(--font-size-sm); font-weight: 500; } .el-switch .is_active { - color: #409eff; + color: var(--color-primary); } .thaw-switch__input:enabled:not(:checked) ~ .thaw-switch__indicator { color: white; - background-color: #dcdfe6; + background-color: var(--color-border); border-color: white; } .thaw-switch__input:enabled:checked ~ .thaw-switch__indicator { - background-color: #409eff; + background-color: var(--color-primary); } .thaw-switch__input:enabled:checked:hover ~ .thaw-switch__indicator { - background-color: #409eff; + background-color: var(--color-primary-hover); } .thaw-switch__input:enabled:checked ~ .thaw-switch__indicator { - background-color: #409eff; + background-color: var(--color-primary); } .checkbox-item { flex-basis: 33%; - font-size: 14px; + font-size: var(--font-size-sm); font-weight: 500; } .custom-search-input { - border: 1px solid lightgray !important; + border: 1px solid var(--color-text-placeholder) !important; } * input::placeholder { - color: lightgray !important; + color: var(--color-text-placeholder) !important; } .thaw-checkbox--checked { - --thaw-checkbox__indicator--background-color: #409eff; + --thaw-checkbox__indicator--background-color: var(--color-primary); } .thaw-pagination-item, .thaw-button.thaw-pagination-item { @@ -155,21 +198,22 @@ a { } .thaw-button--primary { - background-color: #409eff; + background-color: var(--color-primary); } .thaw-button--primary:hover { - background-color: #409eff; + background-color: var(--color-primary-hover); } .zonedivider{ margin-top: 8px; - border-bottom: 1px solid #ebeef5; + border-bottom: 1px solid var(--color-border-light); } .thaw-table-cell-layout { display: block; padding: 12px 0px; + transition: background-color var(--transition-fast); } .thaw-table-cell-layout .conf-fin { @@ -177,20 +221,22 @@ a { } .conf-title { - font-size: 20px; + font-size: var(--font-size-xl); font-weight: 400; - color: black; + color: var(--color-text-primary); } .countdown-display { - font-size: 20px; + font-size: var(--font-size-xl); font-weight: 400; - color: black; + color: var(--color-text-primary); } .countdown-value { display: inline-flex; align-items: center; + font-family: 'Roboto Mono', 'SF Mono', Monaco, monospace; + letter-spacing: 0.5px; } .tag-container { @@ -198,23 +244,306 @@ a { } .tag-container .plain-tag.thaw-tag { - background-color: #fff; + background-color: var(--color-bg); border-color: #b3d8ff; - border-radius: 4px; + border-radius: var(--radius-sm); border-width: 1px; border-style: solid; height: 20px; line-height: 18px; padding: 0 5px; - font-size: 12px; + font-size: var(--font-size-xs); } .thaw-tag__primary-text { - font-size: 12px; - color: #409eff; + font-size: var(--font-size-xs); + color: var(--color-primary); padding: 0; } .thaw-input__input { width: 125px; } + +.thaw-table-cell-layout:hover { + background-color: var(--color-bg-secondary); +} + +@media (min-width: 768px) { + .thaw-input__input { + width: 200px; + } +} + +/* ==================== 倒计时紧迫感样式 ==================== */ +.countdown-normal { + color: var(--color-text-secondary); +} + +.countdown-attention { + color: var(--color-success); +} + +.countdown-warning { + color: var(--color-warning); +} + +.countdown-urgent { + color: var(--color-danger); +} + +.countdown-container { + display: flex; + flex-direction: column; + gap: 4px; + align-items: flex-start; +} + +/* ==================== 移动端筛选器 ==================== */ +.mobile-filter-bar { + display: none; + align-items: center; + gap: 8px; + padding: 12px 0; +} + +.active-filter-count { + font-size: var(--font-size-sm); + color: var(--color-primary); + font-weight: 500; +} + +.filter-drawer-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 999; + animation: fadeIn var(--transition-fast); +} + +.filter-drawer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + max-height: 80vh; + background: var(--color-bg); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + z-index: 1000; + display: flex; + flex-direction: column; + animation: slideUp var(--transition-normal); + box-shadow: var(--shadow-lg); +} + +.filter-drawer-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--color-border-light); +} + +.filter-drawer-title { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--color-text-primary); +} + +.filter-drawer-content { + flex: 1; + overflow-y: auto; + padding: 16px 20px; +} + +.filter-drawer-footer { + padding: 16px 20px; + border-top: 1px solid var(--color-border-light); +} + +.filter-section { + margin-bottom: 24px; +} + +.filter-section:last-child { + margin-bottom: 0; +} + +.filter-section-title { + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--color-text-secondary); + margin-bottom: 12px; +} + +.filter-checkbox-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +@media (min-width: 769px) { + .filter-trigger-btn { + display: none !important; + } + .filter-drawer-overlay, + .filter-drawer { + display: none !important; + } +} + +@media (max-width: 768px) { + .mobile-filter-bar { + display: flex; + } +} + +/* ==================== 骨架屏样式 ==================== */ +.skeleton-container { + animation: skeletonPulse 1.5s ease-in-out infinite; +} + +.skeleton-header { + margin-bottom: 24px; +} + +.skeleton-title { + width: 280px; + height: 32px; + background: var(--color-bg-secondary); + border-radius: var(--radius-sm); + margin-bottom: 12px; +} + +.skeleton-subtitle { + width: 420px; + height: 20px; + background: var(--color-bg-secondary); + border-radius: var(--radius-sm); +} + +.skeleton-filters { + margin-bottom: 16px; +} + +.skeleton-checkbox-group { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.skeleton-checkbox { + width: 80px; + height: 32px; + background: var(--color-bg-secondary); + border-radius: var(--radius-sm); +} + +.skeleton-search { + margin-bottom: 20px; +} + +.skeleton-search-input { + width: 200px; + height: 32px; + background: var(--color-bg-secondary); + border-radius: var(--radius-sm); +} + +.skeleton-table { + display: flex; + flex-direction: column; + gap: 16px; +} + +.skeleton-row { + display: flex; + justify-content: space-between; + padding: 16px; + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); +} + +.skeleton-cell { + display: flex; + flex-direction: column; + gap: 8px; +} + +.skeleton-cell.main { + flex: 1; +} + +.skeleton-cell.right { + width: 180px; + align-items: flex-end; +} + +.skeleton-line { + height: 14px; + background: var(--color-bg-secondary); + border-radius: var(--radius-sm); +} + +.skeleton-line.title { + width: 60%; + height: 20px; +} + +.skeleton-line.subtitle { + width: 80%; +} + +.skeleton-line.small { + width: 120px; +} + +.skeleton-tags { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.skeleton-tag { + width: 60px; + height: 20px; + background: var(--color-bg-secondary); + border-radius: var(--radius-sm); +} + +.skeleton-countdown { + width: 140px; + height: 20px; + background: var(--color-bg-secondary); + border-radius: var(--radius-sm); +} + +@keyframes skeletonPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +@media (max-width: 768px) { + .skeleton-subtitle { + width: 100%; + } + .skeleton-cell.right { + display: none; + } + .skeleton-search-input { + width: 100%; + } +} diff --git a/src/components/countdown.rs b/src/components/countdown.rs index 2c5a90031..07298df9b 100644 --- a/src/components/countdown.rs +++ b/src/components/countdown.rs @@ -1,13 +1,47 @@ use leptos::prelude::*; use std::time::Duration; -/// Countdown timer component. +#[derive(Clone, Copy, PartialEq)] +pub enum UrgencyLevel { + Normal, + Attention, + Warning, + Urgent, +} + +fn get_urgency(remaining_secs: u64) -> UrgencyLevel { + if remaining_secs < 3 * 86400 { + UrgencyLevel::Urgent + } else if remaining_secs < 7 * 86400 { + UrgencyLevel::Warning + } else if remaining_secs < 30 * 86400 { + UrgencyLevel::Attention + } else { + UrgencyLevel::Normal + } +} + +pub fn use_interval(interval_millis: T, f: F) +where + F: Fn() + Clone + 'static, + T: Into> + 'static, +{ + let interval_millis = interval_millis.into(); + Effect::new(move |prev_handle: Option| { + if let Some(prev_handle) = prev_handle { + prev_handle.clear(); + } + set_interval_with_handle(f.clone(), Duration::from_millis(interval_millis.get())) + .expect("could not create interval") + }); +} + #[component] pub fn CountDown( - /// The remaining time in seconds. remain: u64, ) -> impl IntoView { let remaining_time = RwSignal::new(remain / 1000); + let urgency = Memo::new(move |_| get_urgency(remaining_time.get())); use_interval(1000, move || { remaining_time.update(|r| { @@ -26,30 +60,32 @@ pub fn CountDown( let minutes = secs / 60; let seconds = secs % 60; - let day_string = if days > 1 { "days" } else { "day" }; - - format!( - "{:02} {} {:02} h {:02} m {:02} s", - days, day_string, hours, minutes, seconds - ) + (days, hours, minutes, seconds) }; - view! { {display_time} } -} - -/// A hook to create a reactive interval. -pub fn use_interval(interval_millis: T, f: F) -where - F: Fn() + Clone + 'static, - T: Into> + 'static, -{ - let interval_millis = interval_millis.into(); - Effect::new(move |prev_handle: Option| { - if let Some(prev_handle) = prev_handle { - prev_handle.clear(); + let urgency_class = move || { + match urgency.get() { + UrgencyLevel::Normal => "countdown-normal", + UrgencyLevel::Attention => "countdown-attention", + UrgencyLevel::Warning => "countdown-warning", + UrgencyLevel::Urgent => "countdown-urgent", } + }; - set_interval_with_handle(f.clone(), Duration::from_millis(interval_millis.get())) - .expect("could not create interval") - }); + view! { + + + {move || { + let (days, hours, minutes, seconds) = display_time(); + if days > 0 { + format!("{:02}d {:02}h {:02}m {:02}s", days, hours, minutes, seconds) + } else if hours > 0 { + format!("{:02}h {:02}m {:02}s", hours, minutes, seconds) + } else { + format!("{:02}m {:02}s", minutes, seconds) + } + }} + + + } } diff --git a/src/components/loading.rs b/src/components/loading.rs new file mode 100644 index 000000000..27fbdf924 --- /dev/null +++ b/src/components/loading.rs @@ -0,0 +1,53 @@ +use leptos::prelude::*; + +#[component] +pub fn LoadingSkeleton() -> impl IntoView { + view! { +

+
+
+
+
+ +
+
+
+
+
+
+
+
+ + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } + } + /> +
+ + } +} diff --git a/src/components/showtable.rs b/src/components/showtable.rs index 5d5c3deed..3207227e7 100644 --- a/src/components/showtable.rs +++ b/src/components/showtable.rs @@ -11,6 +11,7 @@ use leptos::prelude::*; use serde_json; use std::collections::HashMap; use std::collections::HashSet; +use std::sync::OnceLock; use thaw::*; use urlencoding::encode; use wasm_bindgen::prelude::*; @@ -21,6 +22,7 @@ use web_sys::{console, window}; pub fn ShowTable() -> impl IntoView { // mobile let is_mobile = RwSignal::new(false); + let show_filters = RwSignal::new(false); // switch let cached_use_english = get_from_local_storage("use_english"); @@ -149,24 +151,10 @@ pub fn ShowTable() -> impl IntoView { all_conf_list.update(|conferences| { for item in conferences.iter_mut() { if item.deadline != "TBD" { - let mut tz_str = item.timezone.clone(); - if tz_str == "AoE" { - tz_str = "UTC-12".to_string(); - } else if tz_str == "UTC" { - tz_str = "UTC+0".to_string(); - } + let tz_str = normalize_timezone(&item.timezone); if let Some(tz_offset) = utc_map.get(&tz_str) { - let ddl_str = if item.deadline.contains(' ') { - format!( - "{}T{}{}", - item.deadline.split(' ').nth(0).unwrap_or(""), - item.deadline.split(' ').nth(1).unwrap_or("00:00:00"), - tz_offset - ) - } else { - format!("{}T23:59:59{}", item.deadline, tz_offset) - }; + let ddl_str = parse_deadline_to_rfc3339(&item.deadline, tz_offset); if let Ok(ddl_datetime) = DateTime::parse_from_rfc3339(&ddl_str) { let diff = ddl_datetime.signed_duration_since(current_time); @@ -193,15 +181,7 @@ pub fn ShowTable() -> impl IntoView { spawn_local(async move { let utc_map = load_utc_map(); - let rank_options: HashMap<&str, &str> = [ - ("A", "CCF A"), - ("B", "CCF B"), - ("C", "CCF C"), - ("N", "Non-CCF"), - ] - .iter() - .cloned() - .collect(); + let rank_options: HashMap<&str, &str> = RANK_OPTIONS.iter().cloned().collect(); let (current_time, current_timezone) = get_browser_time_and_timezone(); @@ -227,29 +207,11 @@ pub fn ShowTable() -> impl IntoView { for timeline_item in year_conf.timeline.iter() { let tz_offset = utc_map.get(&year_conf.timezone).unwrap(); - let ddl_str = if timeline_item.deadline.contains(' ') { - format!( - "{}T{}{}", - timeline_item.deadline.split(' ').nth(0).unwrap(), - timeline_item.deadline.split(' ').nth(1).unwrap(), - tz_offset - ) - } else { - format!("{}T23:59:59{}", timeline_item.deadline, tz_offset) - }; + let ddl_str = parse_deadline_to_rfc3339(&timeline_item.deadline, tz_offset); // abstract type:0 submission type:1 if let Some(abs_ddl) = timeline_item.abstract_deadline.clone() { - let abs_ddl_str = if abs_ddl.contains(' ') { - format!( - "{}T{}{}", - abs_ddl.split(' ').nth(0).unwrap(), - abs_ddl.split(' ').nth(1).unwrap(), - tz_offset - ) - } else { - format!("{}T23:59:59{}", abs_ddl, tz_offset) - }; + let abs_ddl_str = parse_deadline_to_rfc3339(&abs_ddl, tz_offset); if let Ok(abs_ddl_datetime) = DateTime::parse_from_rfc3339(&abs_ddl_str) @@ -336,25 +298,11 @@ pub fn ShowTable() -> impl IntoView { continue; } - let mut tz_str = item.timezone.clone(); - if tz_str == "AoE" { - tz_str = "UTC-12".to_string(); - } else if tz_str == "UTC" { - tz_str = "UTC+0".to_string(); - } + let tz_str = normalize_timezone(&item.timezone); // 4. Calculate deadlines and remaining time if let Some(tz_offset) = utc_map.get(&tz_str) { - let ddl_str = if item.deadline.contains(' ') { - format!( - "{}T{}{}", - item.deadline.split(' ').nth(0).unwrap_or(""), - item.deadline.split(' ').nth(1).unwrap_or("00:00:00"), - tz_offset - ) - } else { - format!("{}T23:59:59{}", item.deadline, tz_offset) - }; + let ddl_str = parse_deadline_to_rfc3339(&item.deadline, tz_offset); if let Ok(ddl_datetime) = DateTime::parse_from_rfc3339(&ddl_str) { // Convert to browser local time and format @@ -373,16 +321,7 @@ pub fn ShowTable() -> impl IntoView { // Handle abstract deadline if let Some(abs_ddl) = &item.abstract_deadline { - let abs_ddl_str = if abs_ddl.contains(' ') { - format!( - "{}T{}{}", - abs_ddl.split(' ').nth(0).unwrap_or(""), - abs_ddl.split(' ').nth(1).unwrap_or("00:00:00"), - tz_offset - ) - } else { - format!("{}T23:59:59{}", abs_ddl, tz_offset) - }; + let abs_ddl_str = parse_deadline_to_rfc3339(abs_ddl, tz_offset); if let Ok(abs_datetime) = DateTime::parse_from_rfc3339(&abs_ddl_str) { @@ -664,37 +603,97 @@ pub fn ShowTable() -> impl IntoView { {move || if use_english.get() { "Subscribe" } else { "订阅" }} -
- - - -
+ {move || { + if is_mobile.get() { + view! { + + {move || { + if show_filters.get() { + view! { +
+ + + +
+ } + .into_any() + } else { + view! {}.into_any() + } + }} + } + .into_any() + } else { + view! { +
+ + + +
+ } + .into_any() + } + }} @@ -786,53 +785,53 @@ pub fn ShowTable() -> impl IntoView { let current_like = conf.is_like; if !current_like { view! { -
- -
+
+ +
} .into_any() } else { view! { -
- -
+
+ +
} .into_any() } @@ -1042,24 +1041,62 @@ pub fn ShowTable() -> impl IntoView { } } -fn load_utc_map() -> HashMap { - let mut utc_map: HashMap = HashMap::new(); +static UTC_MAP: OnceLock> = OnceLock::new(); - for i in -12..=12 { - if i >= 0 { - let offset_str = format!("+{:02}:00", i); - let key = format!("UTC+{}", i); - utc_map.insert(key, offset_str); - } else { - let offset_str = format!("-{:02}:00", -i); - let key = format!("UTC{}", i); +fn normalize_timezone(tz: &str) -> String { + match tz { + "AoE" => "UTC-12".to_string(), + "UTC" => "UTC+0".to_string(), + _ => tz.to_string(), + } +} + +fn parse_deadline_to_rfc3339(deadline: &str, tz_offset: &str) -> String { + if deadline.contains(' ') { + format!( + "{}T{}{}", + deadline.split(' ').nth(0).unwrap_or(""), + deadline.split(' ').nth(1).unwrap_or("00:00:00"), + tz_offset + ) + } else { + format!("{}T23:59:59{}", deadline, tz_offset) + } +} + +const RANK_OPTIONS: &[(&str, &str)] = &[("A", "CCF A"), ("B", "CCF B"), ("C", "CCF C"), ("N", "Non-CCF")]; + +const MOBILE_KEYWORDS: &[&str] = &[ + "phone", "pad", "pod", "iphone", "ipod", "ios", "ipad", "android", "mobile", + "blackberry", "iemobile", "mqqbrowser", "juc", "fennec", "wosbrowser", + "browserng", "webos", "symbian", "windows phone", +]; + +fn get_utc_map() -> &'static HashMap { + UTC_MAP.get_or_init(|| { + let mut utc_map = HashMap::new(); + for i in -12..=12 { + let offset_str = if i >= 0 { + format!("+{:02}:00", i) + } else { + format!("-{:02}:00", -i) + }; + let key = if i >= 0 { + format!("UTC+{}", i) + } else { + format!("UTC{}", i) + }; utc_map.insert(key, offset_str); } - } - utc_map.insert("AoE".to_string(), "-12:00".to_string()); - utc_map.insert("UTC".to_string(), "+00:00".to_string()); + utc_map.insert("AoE".to_string(), "-12:00".to_string()); + utc_map.insert("UTC".to_string(), "+00:00".to_string()); + utc_map + }) +} - utc_map +#[allow(dead_code)] +fn load_utc_map() -> HashMap { + get_utc_map().clone() } #[cfg(target_arch = "wasm32")] @@ -1106,29 +1143,7 @@ fn is_mobile_device() -> bool { .expect("user agent not available") .to_lowercase(); - let mobile_keywords = [ - "phone", - "pad", - "pod", - "iphone", - "ipod", - "ios", - "ipad", - "android", - "mobile", - "blackberry", - "iemobile", - "mqqbrowser", - "juc", - "fennec", - "wosbrowser", - "browserng", - "webos", - "symbian", - "windows phone", - ]; - - mobile_keywords + MOBILE_KEYWORDS .iter() .any(|&keyword| user_agent.contains(keyword)) }