Replace Egui with Web UI

This commit is contained in:
Umut Çamliyurt 2025-02-08 22:29:25 +03:00 committed by Ares
parent 64ac6eb981
commit 6cc3eb6a8b
14 changed files with 491 additions and 207 deletions

View File

@ -70,6 +70,8 @@ Amnezichat offers a highly secure and privacy-focused messaging experience by en
## Client usage:
**For Web UI connect to http://localhost:8000**
sudo apt update
sudo apt install curl build-essential git tor
sudo systemctl enable --now tor.service

View File

@ -70,6 +70,8 @@ Amnezichat, hiçbir kayıt tutulmamasını ve tüm mesaj verilerinin yalnızca s
## İstemci kullanımı:
**Web UI için http://localhost:8000 adresine bağlanın**
sudo apt update
sudo apt install curl build-essential git tor
sudo systemctl enable --now tor.service

View File

@ -21,7 +21,4 @@ rpassword = "7.3.1"
sha3 = "0.10.8"
x25519-dalek = "2.0.1"
ed25519-dalek = "2.1.1"
eframe = "0.24.1"
image = "0.24"
rfd = "0.12"
winapi = { version = "0.3", features = ["winuser", "windef"] }
rocket = { version = "0.5", features = ["json"] }

8
client/Rocket.toml Normal file
View File

@ -0,0 +1,8 @@
[default]
address = "127.0.0.1"
port = 8000
[default.limits]
forms = "2 MiB"
json = "2 MiB"
file = "2 MiB"

View File

@ -2,32 +2,35 @@ use crate::encrypt_data;
use crate::receive_and_fetch_messages;
use crate::send_encrypted_message;
use crate::pad_message;
use eframe::egui;
use image::GenericImageView;
use rfd::FileDialog;
use std::fs;
use rocket::{get, post, routes, serde::json::Json};
use rocket::fs::NamedFile;
use rocket::tokio;
use rocket::fs::FileServer;
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use regex::Regex;
use base64;
use std::path::PathBuf;
#[derive(Clone)]
pub struct MessagingApp {
username: String,
message_input: String,
messages: Arc<Mutex<Vec<String>>>,
shared_hybrid_secret: Arc<std::string::String>,
shared_hybrid_secret: Arc<String>,
shared_room_id: Arc<String>,
shared_url: Arc<String>,
image_data: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct MessageInput {
message: String,
}
impl MessagingApp {
pub fn new(
username: String,
shared_hybrid_secret: Arc<std::string::String>,
shared_room_id: Arc<String>,
shared_url: Arc<String>,
shared_hybrid_secret: Arc<String>,
shared_room_id: Arc<Mutex<String>>,
shared_url: Arc<Mutex<String>>,
) -> Self {
let messages = Arc::new(Mutex::new(vec![]));
let messages_clone = Arc::clone(&messages);
@ -35,160 +38,125 @@ impl MessagingApp {
let shared_room_id_clone = Arc::clone(&shared_room_id);
let shared_url_clone = Arc::clone(&shared_url);
thread::spawn(move || loop {
match receive_and_fetch_messages(
&shared_room_id_clone,
&shared_hybrid_secret_clone,
&shared_url_clone,
true,
) {
Ok(new_messages) => {
let mut msgs = messages_clone.lock().unwrap();
msgs.clear();
msgs.extend(new_messages);
}
Err(e) => {
eprintln!("Error fetching messages: {}", e);
let room_id = Arc::new(shared_room_id_clone.lock().unwrap_or_else(|_| panic!("Failed to lock room_id")).clone());
let url = Arc::new(shared_url_clone.lock().unwrap_or_else(|_| panic!("Failed to lock url")).clone());
tokio::spawn(async move {
loop {
let room_id_str = shared_room_id_clone.lock().unwrap_or_else(|_| panic!("Failed to lock room_id")).clone();
let url_str = shared_url_clone.lock().unwrap_or_else(|_| panic!("Failed to lock url")).clone();
match receive_and_fetch_messages(
&room_id_str,
&shared_hybrid_secret_clone,
&url_str,
true,
) {
Ok(new_messages) => {
let mut msgs = messages_clone.lock().unwrap_or_else(|_| panic!("Failed to lock messages"));
msgs.clear();
msgs.extend(new_messages);
}
Err(e) => {
eprintln!("Error fetching messages: {}", e);
}
}
tokio::time::sleep(Duration::from_secs(10)).await;
}
thread::sleep(Duration::from_secs(10));
});
MessagingApp {
username,
message_input: String::new(),
messages,
shared_hybrid_secret,
shared_room_id,
shared_url,
image_data: None,
shared_room_id: room_id,
shared_url: url,
}
}
}
fn handle_image_upload(&mut self) {
if let Some(file_path) = FileDialog::new().add_filter("Image files", &["png", "jpg", "jpeg", "bmp", "gif"]).pick_file() {
match fs::read(&file_path) {
Ok(data) => {
let encoded = base64::encode(data);
self.image_data = Some(format!("[IMAGE_DATA]:{}[END DATA]", encoded));
}
Err(e) => eprintln!("Error reading image file: {}", e),
#[get("/messages")]
async fn get_messages(app: &rocket::State<MessagingApp>) -> Json<Vec<String>> {
let result = fetch_and_update_messages(&app).await;
match result {
Ok(msgs) => Json(msgs),
Err(e) => {
eprintln!("Error fetching messages: {}", e);
// Return current messages if fetching fails
let msgs = app.messages.lock().unwrap_or_else(|_| panic!("Failed to lock messages"));
Json(msgs.clone())
}
}
}
async fn fetch_and_update_messages(app: &rocket::State<MessagingApp>) -> Result<Vec<String>, String> {
let room_id_str = app.shared_room_id.clone();
let url_str = app.shared_url.clone();
let new_messages = tokio::task::block_in_place(move || {
receive_and_fetch_messages(
&room_id_str,
&app.shared_hybrid_secret,
&url_str,
true,
)
}).map_err(|e| format!("Error fetching messages: {}", e))?;
// Update the in-memory message storage
let mut msgs = app.messages.lock().unwrap_or_else(|_| panic!("Failed to lock messages"));
msgs.clear();
msgs.extend(new_messages.clone());
Ok(new_messages)
}
#[post("/send", data = "<input>")]
async fn post_message(
input: Json<MessageInput>,
app: &rocket::State<MessagingApp>
) -> Result<&'static str, rocket::http::Status> {
// Create the formatted message once
let formatted_message = format!("<strong>{}</strong>: {}", app.username, input.message);
let padded_message = pad_message(&formatted_message, 2048);
let result = tokio::task::block_in_place(|| {
// Encrypt the message
let encrypted = encrypt_data(&padded_message, &app.shared_hybrid_secret)
.map_err(|e| {
eprintln!("Encryption error: {}", e);
rocket::http::Status::InternalServerError
})?;
// Send the encrypted message
send_encrypted_message(&encrypted, &app.shared_room_id, &app.shared_url)
.map_err(|e| {
eprintln!("Error sending message: {}", e);
rocket::http::Status::InternalServerError
})
});
match result {
Ok(_) => {
{
let mut msgs = app.messages.lock().unwrap_or_else(|_| panic!("Failed to lock messages"));
msgs.push(formatted_message);
}
Ok("Message sent")
}
Err(e) => Err(e),
}
}
impl eframe::App for MessagingApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.vertical(|ui| {
let chat_area_height = ui.available_height() - 80.0;
egui::Frame::none()
.fill(egui::Color32::from_black_alpha(50))
.rounding(10.0)
.inner_margin(egui::style::Margin::same(10.0))
.show(ui, |ui| {
ui.set_height(chat_area_height);
egui::ScrollArea::vertical()
.auto_shrink([false, true])
.show(ui, |ui| {
let messages = self.messages.lock().unwrap();
let re = Regex::new(r"</?strong>").unwrap();
for message in messages.iter() {
if message.contains("[IMAGE_DATA]:") {
if let Some(encoded) = message.split("[IMAGE_DATA]:").nth(1) {
if let Some(end_idx) = encoded.find("[END DATA]") {
let image_data = &encoded[..end_idx];
match base64::decode(image_data) {
Ok(decoded) => {
if let Ok(image) = image::load_from_memory(&decoded) {
let size = image.dimensions();
let color_image = egui::ColorImage::from_rgba_unmultiplied(
[size.0 as usize, size.1 as usize],
&image.to_rgba8(),
);
let texture = ctx.load_texture(
"received_image",
color_image,
egui::TextureOptions::LINEAR,
);
ui.image(&texture);
} else {
eprintln!("Failed to decode image format");
}
}
Err(e) => eprintln!("Error decoding base64 image: {}", e),
}
}
}
} else {
let cleaned_message = re.replace_all(message, "");
ui.label(
egui::RichText::new(cleaned_message.as_ref())
.size(16.0)
.color(egui::Color32::WHITE),
);
}
}
});
});
ui.horizontal(|ui| {
let input_box_width = ui.available_width() * 0.65;
let button_width = ui.available_width() * 0.15;
let text_edit = egui::TextEdit::singleline(&mut self.message_input)
.hint_text("Type a message...")
.text_color(egui::Color32::WHITE)
.frame(true);
ui.add_sized([input_box_width, 40.0], text_edit);
if ui.add_sized([button_width, 40.0], egui::Button::new("Send")).clicked() {
let mut message = format!("<strong>{}</strong>: {}", self.username, self.message_input);
if let Some(image) = &self.image_data {
message.push_str(image);
self.image_data = None;
}
// Pad the message to a fixed length (e.g., 2048 bytes)
let padded_message = pad_message(&message, 2048);
if let Err(e) = send_encrypted_message(
&encrypt_data(&padded_message, &self.shared_hybrid_secret).unwrap(),
&self.shared_room_id,
&self.shared_url,
) {
eprintln!("Error sending message: {}", e);
} else {
self.message_input.clear();
}
}
if ui.add_sized([button_width, 40.0], egui::Button::new("Upload Image")).clicked() {
self.handle_image_upload();
}
});
});
});
}
#[get("/")]
async fn serve_webpage() -> Option<NamedFile> {
NamedFile::open(PathBuf::from("static/index.html")).await.ok()
}
pub fn run_gui(
username: String,
shared_hybrid_secret: Arc<std::string::String>,
shared_room_id: Arc<String>,
shared_url: Arc<String>,
) -> Result<(), eframe::Error> {
let app = MessagingApp::new(
username,
shared_hybrid_secret,
shared_room_id,
shared_url,
);
let native_options = eframe::NativeOptions {
..Default::default()
};
eframe::run_native("Amnezichat", native_options, Box::new(|_| Box::new(app)))
}
pub fn create_rocket(app: MessagingApp) -> rocket::Rocket<rocket::Build> {
rocket::build()
.manage(app)
.mount("/", routes![get_messages, post_message, serve_webpage])
.mount("/static", FileServer::from("static"))
}

View File

@ -4,7 +4,8 @@ mod network_operations;
mod key_exchange;
mod authentication;
mod encryption;
use gui::run_gui;
use gui::create_rocket;
use gui::MessagingApp;
use key_operations::key_operations_dilithium;
use key_operations::key_operations_eddsa;
use network_operations::create_client_with_proxy;
@ -469,21 +470,29 @@ fn main() -> Result<(), Box<dyn Error>> {
// Handle GUI or CLI messaging
if interface_choice.to_lowercase() == "gui" {
let shared_hybrid_secret_for_gui = shared_hybrid_secret;
let shared_room_id_for_gui: Arc<String> = {
let locked = shared_room_id.lock().unwrap();
Arc::new(locked.clone())
};
let shared_url_for_gui: Arc<String> = {
let locked = shared_url.lock().unwrap();
Arc::new(locked.clone())
};
let _ = run_gui(
username.clone(),
shared_hybrid_secret_for_gui,
shared_room_id_for_gui,
shared_url_for_gui,
);
let rt = rocket::tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
// Wrap only for passing to run_gui
let shared_hybrid_secret_for_gui = shared_hybrid_secret;
// Correctly clone Arc<Mutex<String>> instead of Arc<String>
let shared_room_id_for_gui: Arc<Mutex<String>> = Arc::clone(&shared_room_id);
let shared_url_for_gui: Arc<Mutex<String>> = Arc::clone(&shared_url);
// Pass the arguments
let app = MessagingApp::new(
username,
shared_hybrid_secret_for_gui,
shared_room_id_for_gui,
shared_url_for_gui,
);
// Await the async launch function
if let Err(e) = create_rocket(app).launch().await {
eprintln!("Rocket server failed: {}", e);
}
});
} else {
loop {
let mut message = String::new();
@ -741,26 +750,28 @@ fn main() -> Result<(), Box<dyn Error>> {
if interface_choice.to_lowercase() == "gui" {
// Wrap only for passing to run_gui
let shared_hybrid_secret_for_gui = shared_hybrid_secret;
let rt = rocket::tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
// Wrap only for passing to run_gui
let shared_hybrid_secret_for_gui = shared_hybrid_secret;
let shared_room_id_for_gui: Arc<String> = {
let locked = shared_room_id.lock().unwrap();
Arc::new(locked.clone())
};
// Correctly clone Arc<Mutex<String>> instead of Arc<String>
let shared_room_id_for_gui: Arc<Mutex<String>> = Arc::clone(&shared_room_id);
let shared_url_for_gui: Arc<Mutex<String>> = Arc::clone(&shared_url);
let shared_url_for_gui: Arc<String> = {
let locked = shared_url.lock().unwrap();
Arc::new(locked.clone())
};
// Pass the arguments
let app = MessagingApp::new(
username,
shared_hybrid_secret_for_gui,
shared_room_id_for_gui,
shared_url_for_gui,
);
// Pass the arguments
let _ = run_gui(
username.clone(),
shared_hybrid_secret_for_gui,
shared_room_id_for_gui,
shared_url_for_gui,
);
// Await the async launch function
if let Err(e) = create_rocket(app).launch().await {
eprintln!("Rocket server failed: {}", e);
}
});
} else {
loop {

View File

@ -487,8 +487,13 @@ pub fn receive_and_fetch_messages(
continue;
}
// If gui is false, skip messages containing `[IMAGE_DATA]:`
if !gui && unpadded_message.contains("[IMAGE_DATA]:") {
// If gui is false, skip messages containing `<media>`
if !gui && unpadded_message.contains("<media>") {
continue;
}
// If gui is false, skip messages containing `<pfp>`
if !gui && unpadded_message.contains("<pfp>") {
continue;
}

147
client/static/index.html Normal file
View File

@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Amnezichat</title>
<link rel="stylesheet" href="/static/styles.css">
<script src="/static/purify.min.js"></script>
<script>
let profilePicBase64 = "";
// Handle profile picture upload
function handleProfilePicChange(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onloadend = function () {
profilePicBase64 = reader.result;
};
reader.readAsDataURL(file);
}
}
// Handle media upload (image/video)
function handleMediaChange(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onloadend = async function () {
const mediaBase64 = reader.result;
let mediaMessage = { message: `<media>${mediaBase64}</media>` };
// Include profile picture in media message if available
if (profilePicBase64) {
mediaMessage.message = `<pfp>${profilePicBase64}</pfp>` + mediaMessage.message;
}
// Send media as a separate message
await fetch('/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mediaMessage)
});
fetchMessages();
};
reader.readAsDataURL(file);
}
}
// Fetch messages from the server
async function fetchMessages() {
const response = await fetch('/messages');
const messages = await response.json();
const messageHTML = messages.map(msg => {
const base64ImagePattern = /^data:image\/(png|jpeg|jpg|gif);base64,/;
const base64VideoPattern = /^data:video\/(mp4|webm|ogg);base64,/;
const pfpMatch = msg.match(/<pfp>(.*?)<\/pfp>/);
const mediaMatch = msg.match(/<media>(.*?)<\/media>/);
let messageText = msg.replace(/<pfp>.*?<\/pfp>/, '').replace(/<media>.*?<\/media>/, '');
let profilePic = "";
let mediaContent = "";
// Display profile picture if available
if (pfpMatch && base64ImagePattern.test(pfpMatch[1])) {
profilePic = `<img src="${pfpMatch[1]}" alt="Profile Picture" style="width: 50px; height: 50px; border-radius: 50%; margin-right: 10px;">`;
}
// Display media as a separate full-size element
if (mediaMatch && (base64ImagePattern.test(mediaMatch[1]) || base64VideoPattern.test(mediaMatch[1]))) {
if (base64ImagePattern.test(mediaMatch[1])) {
mediaContent = `<div>${profilePic}<img src="${mediaMatch[1]}" alt="Media" style="width: 100%; max-width: 600px; margin-top: 10px;"></div>`;
} else if (base64VideoPattern.test(mediaMatch[1])) {
mediaContent = `<div>${profilePic}<video controls style="width: 100%; max-width: 600px; margin-top: 10px;"><source src="${mediaMatch[1]}" type="video/mp4">Your browser does not support the video tag.</video></div>`;
}
return mediaContent; // Media is a separate message
}
return `<div style="display: flex; align-items: center; margin-bottom: 10px;">${profilePic}<p>${DOMPurify.sanitize(messageText)}</p></div>`;
}).join('');
document.getElementById('messages').innerHTML = messageHTML;
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight;
}
// Send text message to the server
async function sendMessage() {
const message = document.getElementById('messageInput').value;
if (message.trim() !== "") {
let messageData = { message: DOMPurify.sanitize(message) };
// Include profile picture in text message if available
if (profilePicBase64) {
messageData.message = `<pfp>${profilePicBase64}</pfp>` + messageData.message;
}
await fetch('/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(messageData)
});
document.getElementById('messageInput').value = '';
fetchMessages();
}
}
function toggleSettings() {
const modal = document.getElementById('settings');
modal.style.display = modal.style.display === 'flex' ? 'none' : 'flex';
}
function closeSettings() {
document.getElementById('settings').style.display = 'none';
}
</script>
</head>
<body onload="fetchMessages()">
<div class="container">
<div id="messages"></div>
<div class="input-container">
<input type="text" id="messageInput" placeholder="Type a message..." autocomplete="off">
<button id="settingsButton" onclick="toggleSettings()">Settings</button>
<button onclick="sendMessage()">Send</button>
</div>
</div>
<div id="settings">
<div class="settings-content">
<h2>Settings</h2>
<!-- Hidden file input for selecting profile picture -->
<input type="file" accept="image/*" id="profilePicInput" style="display:none;" onchange="handleProfilePicChange(event)">
<!-- Button that opens the file input for profile picture -->
<button onclick="document.getElementById('profilePicInput').click()">Choose Profile Picture</button>
<!-- Hidden file input for selecting media (image or video) -->
<input type="file" accept="image/*,video/*" id="mediaInput" style="display:none;" onchange="handleMediaChange(event)">
<!-- Button that opens the file input for media -->
<button onclick="document.getElementById('mediaInput').click()">Send Media</button>
<button onclick="closeSettings()">Close</button>
</div>
</div>
</body>
</html>

3
client/static/purify.min.js vendored Normal file

File diff suppressed because one or more lines are too long

140
client/static/styles.css Normal file
View File

@ -0,0 +1,140 @@
/* Reset the body to cover the full page */
html, body {
height: 100%;
margin: 0;
font-family: 'Helvetica Neue', Arial, sans-serif; /* Clean sans-serif font */
background-color: #1e1f22; /* Dark background for the whole page */
color: #e1e1e1; /* Light text for contrast */
}
/* Center the content and give it full height */
.container {
display: flex;
flex-direction: column;
height: 100%;
padding: 20px;
box-sizing: border-box; /* Ensure padding is included in height calculations */
}
/* Styling for the messages area */
#messages {
flex-grow: 1;
padding: 15px;
overflow-y: auto;
border: 1px solid #333b41; /* Darker border */
background-color: #2c2f36; /* Dark background for message container */
border-radius: 10px;
display: flex;
flex-direction: column;
gap: 10px; /* Space between messages */
margin-bottom: 20px; /* Space between messages and input */
}
/* Styling each message */
#messages p {
padding: 10px;
background-color: #444c56; /* Slightly lighter gray message bubbles */
border-radius: 20px;
max-width: 75%; /* Keep messages from stretching too wide */
}
/* Styling input and button area */
.input-container {
display: flex;
gap: 10px;
align-items: center;
justify-content: flex-end; /* Align input and buttons to the bottom */
width: 100%; /* Ensure it takes full width */
}
input[type="text"] {
flex-grow: 1; /* Make the input take up remaining space */
padding: 10px;
border-radius: 20px;
border: 1px solid #444c56; /* Dark border */
background-color: #2c2f36; /* Dark background */
color: #e1e1e1; /* Light text for input */
}
input[type="text"]:focus {
outline: none;
border-color: #4c8bf5; /* Light blue when focused */
}
button {
padding: 10px 20px;
border: none;
border-radius: 20px; /* Rounded buttons */
background-color: #4c8bf5; /* Signal's blue color */
color: white;
cursor: pointer;
font-size: 16px;
}
button:hover {
background-color: #3978d1; /* Slightly darker blue on hover */
}
button:active {
background-color: #2962a1; /* Even darker blue when pressed */
}
/* Styling for the settings button inside the input container */
#settingsButton {
padding: 10px;
border: none;
border-radius: 20px; /* Rounded button */
background-color: #4c8bf5; /* Signal's blue */
color: white;
cursor: pointer;
font-size: 16px;
}
#settingsButton:hover {
background-color: #3978d1;
}
#settingsButton:active {
background-color: #2962a1;
}
/* Modal for settings (hidden by default) */
#settings {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7); /* Dark background for modal */
justify-content: center;
align-items: center;
}
.settings-content {
background-color: #2c2f36; /* Dark background for modal */
padding: 20px;
border-radius: 10px;
width: 300px;
}
.settings-content h2 {
margin-top: 0;
color: #e1e1e1; /* Light text for headings */
}
.settings-content button {
width: 100%;
margin-top: 10px;
background-color: #4c8bf5; /* Blue background for buttons */
}
/* Styling for profile picture preview */
#profilePicPreview {
width: 100px;
height: 100px;
border-radius: 50%; /* Circular image */
margin-top: 10px;
object-fit: cover;
border: 2px solid #444c56; /* Dark border around the profile picture */
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 308 KiB

View File

@ -1,3 +1,8 @@
[default]
address = "0.0.0.0"
port = 8080
[default.limits]
forms = "2 MiB"
json = "2 MiB"
file = "2 MiB"

View File

@ -51,8 +51,8 @@
<a href="#" class="close-button">&times;</a>
<h2>Installation Instructions</h2>
<!-- Option 1: Build it yourself -->
<h3>Option 1: Build it Yourself</h3>
<!-- Build it yourself -->
<h3>Build it Yourself</h3>
<pre>
sudo apt update
sudo apt install curl build-essential git tor
@ -63,10 +63,6 @@ cd Amnezichat/client/
cargo build --release
cargo run --release
</pre>
<!-- Option 2: Get it from GitHub Releases -->
<h3>Option 2: Get it from GitHub Releases</h3>
<p>If you prefer not to build it yourself, you can download precompiled binaries directly from the
<a href="https://github.com/umutcamliyurt/Amnezichat/releases/" target="_blank" style="color: white;">GitHub Releases page</a></p>
</div>
</div>
</body>

View File

@ -5,15 +5,15 @@ body {
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #2c3e50, #4ca1af);
background: linear-gradient(135deg, #1e3c72, #2a5298);
color: #ecf0f1;
text-align: center;
}
.container {
padding: 30px;
background: rgba(44, 62, 80, 0.95);
border: 1px solid #34495e;
background: rgba(15, 30, 60, 0.95);
border: 1px solid #0f3b5f;
border-radius: 12px;
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.6);
max-width: 600px;
@ -37,7 +37,7 @@ a.download-button {
display: inline-block;
text-decoration: none;
color: #ffffff;
background: linear-gradient(45deg, #1abc9c, #16a085);
background: linear-gradient(45deg, #2980b9, #1e90ff);
padding: 12px 25px;
border-radius: 6px;
font-size: 1rem;
@ -46,7 +46,7 @@ a.download-button {
}
a.download-button:hover {
background: linear-gradient(45deg, #16a085, #1abc9c);
background: linear-gradient(45deg, #1e90ff, #2980b9);
transform: scale(1.05);
}
@ -56,14 +56,14 @@ a.download-button:hover {
.main-links a {
text-decoration: none;
color: #1abc9c;
color: #2980b9;
margin: 0 8px;
font-size: 1rem;
transition: color 0.3s ease;
}
.main-links a:hover {
color: #16a085;
color: #1e90ff;
}
details {
@ -102,9 +102,9 @@ details p, details ul {
}
.modal-content {
background: #2c3e50;
background: #0f1e3c;
padding: 20px;
border: 1px solid #34495e;
border: 1px solid #0a2b4f;
border-radius: 12px;
width: 90%;
max-width: 600px;
@ -112,7 +112,7 @@ details p, details ul {
}
.modal-content pre {
background: #34495e;
background: #0a2b4f;
padding: 15px;
border-radius: 8px;
color: #ecf0f1;