add history page and submission tracking functionality
This commit is contained in:
+2
-1
@@ -5,7 +5,7 @@ use leptos_router::{
|
|||||||
ParamSegment, StaticSegment,
|
ParamSegment, StaticSegment,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::codes::{HomePage, SubmitPage};
|
use crate::codes::{history::HistoryPage, HomePage, SubmitPage};
|
||||||
use crate::manage::ManagePage;
|
use crate::manage::ManagePage;
|
||||||
|
|
||||||
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||||
@@ -45,6 +45,7 @@ pub fn App() -> impl IntoView {
|
|||||||
<Routes fallback=|| "Page not found.".into_view()>
|
<Routes fallback=|| "Page not found.".into_view()>
|
||||||
<Route path=StaticSegment("") view=HomePage/>
|
<Route path=StaticSegment("") view=HomePage/>
|
||||||
<Route path=StaticSegment("manage") view=ManagePage/>
|
<Route path=StaticSegment("manage") view=ManagePage/>
|
||||||
|
<Route path=StaticSegment("history") view=HistoryPage/>
|
||||||
<Route
|
<Route
|
||||||
path=(StaticSegment("submit"), ParamSegment("code"))
|
path=(StaticSegment("submit"), ParamSegment("code"))
|
||||||
view=SubmitPage
|
view=SubmitPage
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub const ID: &str = "code1";
|
||||||
pub const CODE: &str = "Qj4eAn";
|
pub const CODE: &str = "Qj4eAn";
|
||||||
pub const LIVE_MESSAGE: &str = "Welcome! Your code has been accepted.";
|
pub const LIVE_MESSAGE: &str = "Welcome! Your code has been accepted.";
|
||||||
pub const NOT_LIVE_MESSAGE: &str = "Thanks for submitting your code. Please try again later.";
|
pub const NOT_LIVE_MESSAGE: &str = "Thanks for submitting your code. Please try again later.";
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::components::A;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
use std::{
|
||||||
|
fs::{self, OpenOptions},
|
||||||
|
io::{BufRead, BufReader, Write},
|
||||||
|
net::IpAddr,
|
||||||
|
sync::Mutex,
|
||||||
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub const SUBMISSION_HISTORY_FILE: &str = "successful_submissions.tsv";
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SuccessfulSubmission {
|
||||||
|
pub submitted_at: SystemTime,
|
||||||
|
pub ip_address: IpAddr,
|
||||||
|
pub server_was_live: bool,
|
||||||
|
pub code_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
static SUBMISSION_HISTORY_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
/// Records a successful code submission in the persistent history file.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub(crate) fn record_successful_submission(
|
||||||
|
ip_address: IpAddr,
|
||||||
|
server_was_live: bool,
|
||||||
|
code_id: &str,
|
||||||
|
) -> Result<(), ServerFnError> {
|
||||||
|
let _guard = SUBMISSION_HISTORY_LOCK
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| ServerFnError::new("Submission history is unavailable"))?;
|
||||||
|
|
||||||
|
persist_submission(&SuccessfulSubmission {
|
||||||
|
submitted_at: SystemTime::now(),
|
||||||
|
ip_address,
|
||||||
|
server_was_live,
|
||||||
|
code_id: code_id.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a snapshot of successful submissions persisted in the history file.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub fn successful_submission_history() -> Result<Vec<SuccessfulSubmission>, ServerFnError> {
|
||||||
|
let _guard = SUBMISSION_HISTORY_LOCK
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| ServerFnError::new("Submission history is unavailable"))?;
|
||||||
|
|
||||||
|
let file = match fs::File::open(SUBMISSION_HISTORY_FILE) {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||||
|
Err(error) => {
|
||||||
|
return Err(ServerFnError::new(format!(
|
||||||
|
"Unable to open submission history: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
BufReader::new(file)
|
||||||
|
.lines()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, line)| {
|
||||||
|
let line = line.map_err(|error| {
|
||||||
|
ServerFnError::new(format!(
|
||||||
|
"Unable to read submission history line {}: {error}",
|
||||||
|
index + 1
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
parse_submission_record(&line).map_err(|error| {
|
||||||
|
ServerFnError::new(format!(
|
||||||
|
"Invalid submission history line {}: {error}",
|
||||||
|
index + 1
|
||||||
|
))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
type HistoryViewEntry = (String, String, bool, String, String);
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
async fn get_submission_history(
|
||||||
|
secret_key: String,
|
||||||
|
) -> Result<Vec<HistoryViewEntry>, ServerFnError> {
|
||||||
|
if !crate::manage::valid_status_secret_key(&secret_key) {
|
||||||
|
return Err(ServerFnError::new("Invalid secret key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut submissions = successful_submission_history()?;
|
||||||
|
submissions.sort_by(|left, right| right.submitted_at.cmp(&left.submitted_at));
|
||||||
|
|
||||||
|
submissions
|
||||||
|
.into_iter()
|
||||||
|
.map(|submission| {
|
||||||
|
let timestamp =
|
||||||
|
submission
|
||||||
|
.submitted_at
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|error| {
|
||||||
|
ServerFnError::new(format!("Invalid submission timestamp: {error}"))
|
||||||
|
})?;
|
||||||
|
let age = SystemTime::now()
|
||||||
|
.duration_since(submission.submitted_at)
|
||||||
|
.map_err(|error| {
|
||||||
|
ServerFnError::new(format!("Submission timestamp is in the future: {error}"))
|
||||||
|
})?;
|
||||||
|
Ok((
|
||||||
|
format_timestamp(timestamp.as_secs(), timestamp.subsec_nanos()),
|
||||||
|
submission.ip_address.to_string(),
|
||||||
|
submission.server_was_live,
|
||||||
|
submission.code_id,
|
||||||
|
format_elapsed(age.as_secs()),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn format_elapsed(seconds: u64) -> String {
|
||||||
|
let units = [
|
||||||
|
(365 * 24 * 60 * 60, "year"),
|
||||||
|
(30 * 24 * 60 * 60, "month"),
|
||||||
|
(7 * 24 * 60 * 60, "week"),
|
||||||
|
(24 * 60 * 60, "day"),
|
||||||
|
(60 * 60, "hour"),
|
||||||
|
(60, "minute"),
|
||||||
|
(1, "second"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (unit_seconds, unit_name) in units {
|
||||||
|
let amount = seconds / unit_seconds;
|
||||||
|
if amount > 0 {
|
||||||
|
let suffix = if amount == 1 { "" } else { "s" };
|
||||||
|
return format!("{amount} {unit_name}{suffix} ago");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"Just now".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn format_timestamp(seconds: u64, nanos: u32) -> String {
|
||||||
|
let days = (seconds / 86_400) as i64;
|
||||||
|
let seconds_in_day = seconds % 86_400;
|
||||||
|
let z = days + 719_468;
|
||||||
|
let era = z / 146_097;
|
||||||
|
let day_of_era = z - era * 146_097;
|
||||||
|
let year_of_era =
|
||||||
|
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
|
||||||
|
let mut year = year_of_era + era * 400;
|
||||||
|
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
|
||||||
|
let month_part = (5 * day_of_year + 2) / 153;
|
||||||
|
let day = day_of_year - (153 * month_part + 2) / 5 + 1;
|
||||||
|
let month = month_part + if month_part < 10 { 3 } else { -9 };
|
||||||
|
year += i64::from(month <= 2);
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"{year:04}-{month:02}-{day:02} {:02}:{:02}:{:02}.{nanos:09} UTC",
|
||||||
|
seconds_in_day / 3_600,
|
||||||
|
(seconds_in_day % 3_600) / 60,
|
||||||
|
seconds_in_day % 60
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn parse_submission_record(line: &str) -> Result<SuccessfulSubmission, String> {
|
||||||
|
let mut fields = line.split('\t');
|
||||||
|
let seconds = fields
|
||||||
|
.next()
|
||||||
|
.ok_or("missing timestamp seconds")?
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|error| format!("invalid timestamp seconds: {error}"))?;
|
||||||
|
let nanos = fields
|
||||||
|
.next()
|
||||||
|
.ok_or("missing timestamp nanoseconds")?
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|error| format!("invalid timestamp nanoseconds: {error}"))?;
|
||||||
|
if nanos >= 1_000_000_000 {
|
||||||
|
return Err("timestamp nanoseconds are out of range".to_string());
|
||||||
|
}
|
||||||
|
let ip_address = fields
|
||||||
|
.next()
|
||||||
|
.ok_or("missing IP address")?
|
||||||
|
.parse::<IpAddr>()
|
||||||
|
.map_err(|error| format!("invalid IP address: {error}"))?;
|
||||||
|
let server_was_live = match fields.next().ok_or("missing live state")? {
|
||||||
|
"true" => true,
|
||||||
|
"false" => false,
|
||||||
|
_ => return Err("invalid live state".to_string()),
|
||||||
|
};
|
||||||
|
// Older history entries predate the code ID column and could only be code1.
|
||||||
|
let code_id = fields.next().unwrap_or("code1").to_string();
|
||||||
|
if fields.next().is_some()
|
||||||
|
|| code_id.is_empty()
|
||||||
|
|| !code_id
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
|
||||||
|
{
|
||||||
|
return Err("invalid code ID or unexpected extra fields".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SuccessfulSubmission {
|
||||||
|
submitted_at: UNIX_EPOCH + Duration::new(seconds, nanos),
|
||||||
|
ip_address,
|
||||||
|
server_was_live,
|
||||||
|
code_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn persist_submission(submission: &SuccessfulSubmission) -> Result<(), ServerFnError> {
|
||||||
|
let timestamp = submission
|
||||||
|
.submitted_at
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|error| ServerFnError::new(format!("Invalid submission timestamp: {error}")))?;
|
||||||
|
let record = format!(
|
||||||
|
"{}\t{}\t{}\t{}\t{}\n",
|
||||||
|
timestamp.as_secs(),
|
||||||
|
timestamp.subsec_nanos(),
|
||||||
|
submission.ip_address,
|
||||||
|
submission.server_was_live,
|
||||||
|
submission.code_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(SUBMISSION_HISTORY_FILE)
|
||||||
|
.map_err(|error| {
|
||||||
|
ServerFnError::new(format!("Unable to open submission history: {error}"))
|
||||||
|
})?;
|
||||||
|
file.write_all(record.as_bytes())
|
||||||
|
.and_then(|()| file.sync_data())
|
||||||
|
.map_err(|error| {
|
||||||
|
ServerFnError::new(format!("Unable to persist submission history: {error}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
async fn clear_submission_history(secret_key: String) -> Result<(), ServerFnError> {
|
||||||
|
if !crate::manage::valid_status_secret_key(&secret_key) {
|
||||||
|
return Err(ServerFnError::new("Invalid secret key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _guard = SUBMISSION_HISTORY_LOCK
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| ServerFnError::new("Submission history is unavailable"))?;
|
||||||
|
OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(SUBMISSION_HISTORY_FILE)
|
||||||
|
.and_then(|file| file.sync_all())
|
||||||
|
.map_err(|error| ServerFnError::new(format!("Unable to clear submission history: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn HistoryPage() -> impl IntoView {
|
||||||
|
let (secret_key, set_secret_key) = signal(String::new());
|
||||||
|
let (entries, set_entries) = signal(Vec::<HistoryViewEntry>::new());
|
||||||
|
let (loaded, set_loaded) = signal(false);
|
||||||
|
let (loading, set_loading) = signal(false);
|
||||||
|
let (error, set_error) = signal(None::<String>);
|
||||||
|
|
||||||
|
let on_load = move |event: leptos::ev::SubmitEvent| {
|
||||||
|
event.prevent_default();
|
||||||
|
let key = secret_key.get_untracked();
|
||||||
|
set_loading.set(true);
|
||||||
|
set_error.set(None);
|
||||||
|
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
match get_submission_history(key).await {
|
||||||
|
Ok(history) => {
|
||||||
|
set_entries.set(history);
|
||||||
|
set_loaded.set(true);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
set_loaded.set(false);
|
||||||
|
set_error.set(Some(error.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set_loading.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_clear = move |_| {
|
||||||
|
let key = secret_key.get_untracked();
|
||||||
|
set_loading.set(true);
|
||||||
|
set_error.set(None);
|
||||||
|
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
match clear_submission_history(key).await {
|
||||||
|
Ok(()) => {
|
||||||
|
set_entries.set(Vec::new());
|
||||||
|
set_loaded.set(true);
|
||||||
|
}
|
||||||
|
Err(error) => set_error.set(Some(error.to_string())),
|
||||||
|
}
|
||||||
|
set_loading.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<section class="status-card history-card">
|
||||||
|
<p class="eyebrow">"CODE SUBMISSION"</p>
|
||||||
|
<h1>"Submission history"</h1>
|
||||||
|
<p class="intro">"Enter the management key to view successful submissions."</p>
|
||||||
|
<form class="submit-form" on:submit=on_load>
|
||||||
|
<label class="key-label" for="history-secret-key">"Management key"</label>
|
||||||
|
<input
|
||||||
|
id="history-secret-key"
|
||||||
|
class="key-input"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
prop:value=secret_key
|
||||||
|
on:input=move |event| set_secret_key.set(event_target_value(&event))
|
||||||
|
/>
|
||||||
|
<div class="history-actions">
|
||||||
|
<button
|
||||||
|
class="status-button clear-history-button"
|
||||||
|
type="button"
|
||||||
|
prop:disabled=loading
|
||||||
|
on:click=on_clear
|
||||||
|
>
|
||||||
|
{move || if loading.get() { "Please wait..." } else { "Clear history" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="status-button go-live"
|
||||||
|
type="submit"
|
||||||
|
prop:disabled=loading
|
||||||
|
>
|
||||||
|
{move || if loading.get() { "Loading..." } else { "View history" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{move || error.get().map(|message| view! {
|
||||||
|
<p class="error-message" role="alert">{message}</p>
|
||||||
|
})}
|
||||||
|
{move || loaded.get().then(|| view! {
|
||||||
|
<div class="history-table-wrap">
|
||||||
|
<table class="history-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>"Submitted (UTC)"</th>
|
||||||
|
<th>"Time since submission"</th>
|
||||||
|
<th>"IP address"</th>
|
||||||
|
<th>"Code"</th>
|
||||||
|
<th>"Live"</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{move || entries.get().into_iter().map(|entry| view! {
|
||||||
|
<tr>
|
||||||
|
<td>{entry.0}</td>
|
||||||
|
<td>{entry.4}</td>
|
||||||
|
<td>{entry.1}</td>
|
||||||
|
<td>{entry.3}</td>
|
||||||
|
<td>{if entry.2 { "Yes" } else { "No" }}</td>
|
||||||
|
</tr>
|
||||||
|
}).collect_view()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{move || (entries.with(Vec::is_empty)).then(|| view! {
|
||||||
|
<p class="loading">"No successful submissions recorded."</p>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
<nav class="page-nav">
|
||||||
|
<A href="/" attr:class="home-link">"Back to home"</A>
|
||||||
|
<A href="/manage" attr:class="home-link">"Manage server status"</A>
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-1
@@ -8,10 +8,17 @@ use leptos_router::{
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
mod code1;
|
mod code1;
|
||||||
|
|
||||||
|
pub mod history;
|
||||||
|
|
||||||
#[server]
|
#[server]
|
||||||
async fn submit_code(code: String) -> Result<String, ServerFnError> {
|
async fn submit_code(code: String) -> Result<String, ServerFnError> {
|
||||||
let live = crate::manage::server_live_state();
|
|
||||||
let message = if code == crate::codes::code1::CODE {
|
let message = if code == crate::codes::code1::CODE {
|
||||||
|
let live = crate::manage::server_live_state();
|
||||||
|
let axum::extract::ConnectInfo(address): axum::extract::ConnectInfo<std::net::SocketAddr> =
|
||||||
|
leptos_axum::extract().await?;
|
||||||
|
|
||||||
|
history::record_successful_submission(address.ip(), live, crate::codes::code1::ID)?;
|
||||||
|
|
||||||
crate::codes::code1::on_submit(live);
|
crate::codes::code1::on_submit(live);
|
||||||
|
|
||||||
if live {
|
if live {
|
||||||
|
|||||||
+4
-1
@@ -26,7 +26,10 @@ async fn main() {
|
|||||||
// `axum::Server` is a re-export of `hyper::Server`
|
// `axum::Server` is a re-export of `hyper::Server`
|
||||||
log!("listening on http://{}", &addr);
|
log!("listening on http://{}", &addr);
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||||
axum::serve(listener, app.into_make_service())
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -15,6 +15,11 @@ pub(crate) fn server_live_state() -> bool {
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
const STATUS_SECRET_KEY: &str = "password";
|
const STATUS_SECRET_KEY: &str = "password";
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub(crate) fn valid_status_secret_key(secret_key: &str) -> bool {
|
||||||
|
secret_key == STATUS_SECRET_KEY
|
||||||
|
}
|
||||||
|
|
||||||
#[server]
|
#[server]
|
||||||
async fn get_live_state() -> Result<bool, ServerFnError> {
|
async fn get_live_state() -> Result<bool, ServerFnError> {
|
||||||
Ok(server_live_state())
|
Ok(server_live_state())
|
||||||
@@ -22,7 +27,7 @@ async fn get_live_state() -> Result<bool, ServerFnError> {
|
|||||||
|
|
||||||
#[server]
|
#[server]
|
||||||
async fn set_live_state(live: bool, secret_key: String) -> Result<(), ServerFnError> {
|
async fn set_live_state(live: bool, secret_key: String) -> Result<(), ServerFnError> {
|
||||||
if secret_key != STATUS_SECRET_KEY {
|
if !valid_status_secret_key(&secret_key) {
|
||||||
return Err(ServerFnError::ServerError("Invalid secret key".to_string()));
|
return Err(ServerFnError::ServerError("Invalid secret key".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +57,10 @@ pub fn ManagePage() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<nav class="page-nav">
|
||||||
<A href="/" attr:class="home-link">"Back to home"</A>
|
<A href="/" attr:class="home-link">"Back to home"</A>
|
||||||
|
<A href="/history" attr:class="home-link">"View submission history"</A>
|
||||||
|
</nav>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-2
@@ -2,6 +2,7 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -13,10 +14,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
width: min(100%, 480px);
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card {
|
.status-card {
|
||||||
|
width: min(100%, 480px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-inline: auto;
|
||||||
padding: clamp(28px, 7vw, 48px);
|
padding: clamp(28px, 7vw, 48px);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
@@ -55,6 +61,17 @@ h1 {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-actions .status-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.key-label {
|
.key-label {
|
||||||
color: #d3dceb;
|
color: #d3dceb;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -111,6 +128,14 @@ h1 {
|
|||||||
background: #d05242;
|
background: #d05242;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clear-history-button {
|
||||||
|
background: #8f3030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-history-button:hover:not(:disabled) {
|
||||||
|
background: #ad3939;
|
||||||
|
}
|
||||||
|
|
||||||
.status-button:hover:not(:disabled) {
|
.status-button:hover:not(:disabled) {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
@@ -162,18 +187,65 @@ h1 {
|
|||||||
|
|
||||||
.home-link {
|
.home-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 24px;
|
|
||||||
color: #8bbaff;
|
color: #8bbaff;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.home-link:hover {
|
.home-link:hover {
|
||||||
color: #b3d2ff;
|
color: #b3d2ff;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-card {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-table-wrap {
|
||||||
|
margin-top: 28px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-table th,
|
||||||
|
.history-table td {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-table th {
|
||||||
|
color: #a9b6c9;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-table td {
|
||||||
|
color: #e6edf7;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-table tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* {
|
* {
|
||||||
transition-duration: 0.01ms !important;
|
transition-duration: 0.01ms !important;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
1791318883 152713800 127.0.0.1 false code1
|
||||||
|
1791318908 161513000 127.0.0.1 true code1
|
||||||
|
Reference in New Issue
Block a user